Repository: kookmin-sw/capstone-2025-01
Pull request #56: Fe/update: 슬라이드 이동 성능 개선 및 스크롤 애니메이션 최적화
Author: login: Sumin6872
Created: 2025-05-13T15:06:24Z | Merged: 2025-05-16T05:03:01Z

## 작업 내용 
- 카드 슬라이드 이동 시 버벅임 현상을 줄이기 위해 애니메이션 로직 개선
- this.cardWidth 값을 고정값(430)이 아닌 실제 DOM 기준으로 계산 (offsetWidth + gap)
- scrollToCard() 내부에서 현재 스크롤 위치와 타깃 위치가 거의 같으면 스크롤을 생략하여 성능 최적화
- requestAnimationFrame 중복 호출 방지를 위해 cancelAnimationFrame 적용
- 페이지네이션 dot, 모바일 페이지 카운터, 자동 재생 기능 추가
- 이전/다음 버튼 클릭 시 requestAnimationFrame을 활용한 부드러운 이동 처리
- `.scss`에 모바일 전용 스타일 및 pause-line 아이콘 추가
- HTML 구조는 유지하면서 class 및 data-action만 변경하여 적용

## 배경
- 카드 슬라이드가 자동으로 부드럽게 넘어가지 않고 덜컥거리거나 버벅이는 문제가 있었음
- 레이아웃이 바뀌었을 때도 스크롤 오차가 발생했고, 이로 인해 scrollLeft가 정확히 맞지 않아 UX 저하가 발생함
- scrollToCard를 여러 번 호출할 경우 중첩 애니메이션이 발생하여 frame rate 저하 문제가 있음
- UX 향상 및 성능 안정성을 위해 로직을 간결하고 정확하게 재구성함
- 모바일에서 자연스러운 슬라이드 전환과 자동 재생 기능 요구

## 필수 리뷰어
@greenstar1151 @qndn3tp 

## 스크린샷
![image](https://github.com/user-attachments/assets/b0931d7f-62f2-4453-a7d0-0680bcf55fc9)
![image](https://github.com/user-attachments/assets/340ced5b-ea20-4773-9fe6-8cebabd03f03)

## 링크
- https://www.figma.com/design/rwOuWCPBhdSjcMDA0SkV4b/LawNow?node-id=1716-2740&m=dev

## 체크 리스트
-  카드 이동 시 부드럽게 스크롤되는지 확인
- 카드 너비 변경 시에도 이동 간격이 정확히 유지되는지 확인
- 키보드 방향키 및 버튼 클릭에 의한 이동도 정상 작동하는지 확인
- 중복 애니메이션이 발생하지 않고 requestAnimationFrame이 적절히 정리되는지 확인
- 슬라이더 첫 슬라이드 복제 카드 정렬 확인
- 모바일에서 autoplay, 버튼 조작 모두 정상 작동하는지 확인


## 희망 리뷰 완료 일  
- 최대한 빨리 보시고 피드백 부탁드립니다.



Diff:
diff --git a/app/assets/images/icons/pause_line.svg b/app/assets/images/icons/pause_line.svg
new file mode 100644
index 0000000..1106d01
--- /dev/null
+++ b/app/assets/images/icons/pause_line.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none">
+    <path d="M6 3C6.1837 3.00002 6.361 3.06747 6.49828 3.18954C6.63556 3.31161 6.72326 3.47981 6.74475 3.66225L6.75 3.75V14.25C6.74979 14.4412 6.67659 14.625 6.54536 14.764C6.41414 14.903 6.23479 14.9867 6.04395 14.9979C5.85312 15.0091 5.66522 14.947 5.51863 14.8243C5.37204 14.7016 5.27783 14.5276 5.25525 14.3377L5.25 14.25V3.75C5.25 3.55109 5.32902 3.36032 5.46967 3.21967C5.61032 3.07902 5.80109 3 6 3ZM12 3C12.1837 3.00002 12.361 3.06747 12.4983 3.18954C12.6356 3.31161 12.7233 3.47981 12.7447 3.66225L12.75 3.75V14.25C12.7498 14.4412 12.6766 14.625 12.5454 14.764C12.4141 14.903 12.2348 14.9867 12.044 14.9979C11.8531 15.0091 11.6652 14.947 11.5186 14.8243C11.372 14.7016 11.2778 14.5276 11.2552 14.3377L11.25 14.25V3.75C11.25 3.55109 11.329 3.36032 11.4697 3.21967C11.6103 3.07902 11.8011 3 12 3Z" fill="black"/>
+</svg>
\ No newline at end of file
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index 3907bc4..0798157 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -43,6 +43,7 @@
 @use "icons/star_icon";
 @use "icons/settings_icon";
 @use "icons/share_icon";
+@use "icons/pause_line_icon";
 
 // 전역 스타일 설정
 a {
diff --git a/app/assets/stylesheets/components/home/_hot_issue.scss b/app/assets/stylesheets/components/home/_hot_issue.scss
index 12aab43..f883eed 100644
--- a/app/assets/stylesheets/components/home/_hot_issue.scss
+++ b/app/assets/stylesheets/components/home/_hot_issue.scss
@@ -1,133 +1,162 @@
 @use "colors/color_variables";
 @use "../../typography/typography" as *;
-@use "../../icons/next_icon";
-@use "../../icons/previous_icon";
+@use "../../layouts/responsive" as *;
 
 // 카드와 화살표를 감싸는 래퍼
 .hot-issue-carousel-wrapper { 
-    max-width: 1390px;
-    width: 100%;
-    gap: 30px;
+  max-width: 1390px;
+  width: 100%;
+  gap: 30px;
 
-    display: flex;
-    align-items: center;
-    justify-content: center;
+  display: flex;
+  align-items: center;
+  justify-content: center;
 }
 
 // 카드 리스트 컨테이너 (가로 스크롤 영역)
 .hot-issue-card-container {
-    width: 100%;          
-    max-width: 100%;
-    gap: 30px;
-
-    display: flex;
-    overflow-x: auto;           // 가로 스크롤 활성화
-    scroll-behavior: smooth;    // 부드러운 스크롤 
-    scroll-snap-type: x mandatory; 
-    outline: none !important;           
-
-    // 브라우저 별 자동 스크롤바 숨기기          
-    scrollbar-width: none;       // Firefox
-    -ms-overflow-style: none;    // IE, Edge
+  width: 100%;          
+  max-width: 1260px;
+  gap: 30px;
+
+  display: flex;
+  overflow-x: auto;           // 가로 스크롤 활성화
+  scroll-behavior: smooth;    // 부드러운 스크롤 
+  scroll-snap-type: x mandatory; 
+  outline: none !important;           
+
+  // 브라우저 별 자동 스크롤바 숨기기          
+  scrollbar-width: none;       // Firefox
+  -ms-overflow-style: none;    // IE, Edge
+
+  &::-webkit-scrollbar {
+    display: none; // Chrome, Safari
+  }
 
-    &:focus,
-    &:focus-visible {
+  &:focus,
+  &:focus-visible {
     outline: none ;
     box-shadow: none ;
   }
-}
 
-.hot-issue-card-container::-webkit-scrollbar {
-    display: none;  // Chrome, Safari 
+  @include mobile {
+    max-width: 100vw;
+    padding: 0 37px 0 38px;
+    scroll-padding-left: 38px;
+    gap: 75px;
+
+    // 가로 중앙 정렬을 보장하기 위해 필요
+    justify-content: flex-start;
+  }
 }
 
 // 개별 카드 디자인
 .hot-issue-card{
-    height: 300px;
-    width: 400px;
-    padding: 15px 30px;
-    gap: 11px;
-
-    display: inline-flex;
-    flex-direction: column;
-    align-items: flex-start;
-    flex-shrink: 0;
-    scroll-snap-align: start;
-  
-    border-radius: 20px; 
-    box-sizing: border-box;
+  height: 300px;
+  width: 400px;
+  padding: 15px 30px;
+  gap: 11px;
+
+  display: inline-flex;
+  flex-direction: column;
+  align-items: flex-start;
+  flex-shrink: 0;
+  scroll-snap-align: start;
   
-    background-color: color_variables.$surface-primary-color; // 배경색 지정 (필요 시 변경)
+  border-radius: 20px; 
+  box-sizing: border-box;
   
+  background-color: color_variables.$surface-primary-color; // 배경색 지정 (필요 시 변경)
+
+  @include mobile {
+    width: 100%;
+    max-width: 330px;
+    height: 159px;
+    padding: 15px;
+    gap: 10px;
+    scroll-snap-align: start;
+  }
 }
 
 // 카드 상단 헤더
 .hot-issue-header {
-    margin-bottom: 28px;
-    display: flex;
-    align-items: center;
-    width: 100%;
-    max-width: 1632px;
-    gap: 19px;
+  margin-bottom: 28px;
+  display: flex;
+  align-items: center;
+  width: 100%;
+  max-width: 1632px;
+  gap: 19px;
+
+  @include mobile {
+    margin-bottom: 10px;
+    max-width: 338px;
+    gap: 10px;
+  }
 }
 
 // 'HOT 이슈 법안' 텍스트 스타일
 .hot-issue-bill-text {
-    @include title-small;
-    color: color_variables.$text-primary-color;
+  @include title-small;
+  color: color_variables.$text-primary-color;
 }
 
 // '모든 법안 보기' 텍스트
 .all-issue-bill-text {
-    @include caption-medium;
-    color: color_variables.$text-tertiary-color;
-    margin-top: 6px;
+  @include caption-medium;
+  color: color_variables.$text-tertiary-color;
+  margin-top: 6px;
+
+  @include mobile {
+    margin-top: 3px;
+  }
 }
 
 .all-issue-next-icon {
-    @extend .next-icon;
-    width: 5.5px;
-    height: 10.283px;
-    color: color_variables.$text-tertiary-color;
-    margin-left: 3px;
+  width: 5.5px;
+  height: 10.283px;
+  color: color_variables.$text-tertiary-color;
+  margin-left: 3px;
+
+  @include mobile {
+    width: 3px;
+    height: 5px;
+  }
 }
 
 // 법안 제목 (긴 경우 말줄임표 처리)
 .hot-issue-title {
-    @include button-large;
-    color: color_variables.$text-primary-color;
-    width: 100%;                
-    white-space: wrap;                  
-    flex-shrink: 0; 
+  @include button-large;
+  color: color_variables.$text-primary-color;
+  width: 100%;                
+  white-space: wrap;                  
+  flex-shrink: 0; 
 }
 
 // 태그 or 메타데이터를 위한 라벨 묶음
 .hot-issue-header-tag {
-    display: flex;
-    align-items: center;
-    gap: 10px;
-    flex-shrink: 0; 
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  flex-shrink: 0; 
 }
 
-.hot-issue-arrow {
+// 원래 icon의 크기와 상이하므로 따로 정의
+.hot-issue-icon {
+  z-index: 2;
+  color: color_variables.$text-secondary-color;
+
+  @include mobile {
+    width: 18px;
+    height: 18px;
+  }
+
+  @include desktop {
     width: 35px;
     height: 35px;
-    z-index: 2;
-    color: color_variables.$text-secondary-color;
   }
-  
-
-.hot-issue-next-icon {
-    @extend .hot-issue-arrow;
-    @extend .next-icon;
 }
 
-.hot-issue-previous-icon{
-    @extend .hot-issue-arrow;
-    @extend .previous-icon;
-}
-
-// 포커스 스타일 추가
+// 포커스 스타일 제거
 .hot-issue-previous-icon,
 .hot-issue-next-icon {
   &:focus,
@@ -140,29 +169,64 @@
 // 요약된 법안 정보를 가져와야 하는데 없는 법안도 있음
 // 법안 상세정보 사용
 .hot-issue-bill-description {
-    @include body-small;
-    width: 100%;
-    overflow: hidden;
+  @include body-small;
+  width: 100%;
+  overflow: hidden;
 }
 
 .hot-issue-pagination {
-    display: flex;
-    justify-content: center;
-    gap: 14px;
-    margin: 37px 0 143px;
+  display: flex;
+  justify-content: center;
+  gap: 14px;
+  margin: 37px 0 143px;
+
+  @include mobile {
+    justify-content: flex-end;
+    padding-right: 45px;
+    margin: 7px 0 9px;
+    gap: 6px;
   }
+
+  @include desktop {
+    flex-direction: row !important; // 명시적으로 가로 정렬
+  }
+}
+
+// 페이지 번호 표시
+.hot-issue-page-counter {
+  @include button-large;
+  align-items: center;
+  color: color_variables.$icon-default-color;
+}
+
+.hot-issue-dot {
+  width: 20px;
+  height: 20px;
+  aspect-ratio: 1/1;
+  border-radius: 50%;
+  background: color_variables.$button-tertiary-color;
   
-  .hot-issue-dot {
-    width: 20px;
+  &.active {
+    width: 50px;
     height: 20px;
-    aspect-ratio: 1/1;
-    border-radius: 50%;
-    background: color_variables.$button-tertiary-color;
-  
-    &.active {
-      width: 50px;
-      height: 20px;
-      border-radius: 9999px;
-      background: color_variables.$button-secondary-color;
+    border-radius: 9999px;
+    background: color_variables.$button-secondary-color;
+  }
+}
+
+.mobile-only {
+  display: none;
+
+  @include mobile {
+    display: flex;
+  }
+}
+
+.desktop-only {
+  display: flex;
+  flex-direction: row;
+
+  @include mobile {
+    display: none;
   }
 }
diff --git a/app/assets/stylesheets/icons/_pause_line_icon.scss b/app/assets/stylesheets/icons/_pause_line_icon.scss
new file mode 100644
index 0000000..f8dad2e
--- /dev/null
+++ b/app/assets/stylesheets/icons/_pause_line_icon.scss
@@ -0,0 +1,15 @@
+@use "../colors/color_variables";
+@use "../layouts/responsive" as *;
+
+.pause-line-icon {
+    color: color_variables.$text-secondary-color;
+    
+    @include mobile {
+        width: 18px; 
+        height: 18px; 
+    }
+    
+    @include desktop {
+    display: none;
+  }
+}
diff --git a/app/javascript/controllers/carousel_controller.js b/app/javascript/controllers/carousel_controller.js
index d7874d6..7d70d6d 100644
--- a/app/javascript/controllers/carousel_controller.js
+++ b/app/javascript/controllers/carousel_controller.js
@@ -1,101 +1,166 @@
 import { Controller } from "@hotwired/stimulus"
 
 export default class extends Controller {
-  static targets = ["container", "dot"]
+  static targets = ["container", "dot", "pageCounter", "playPauseIcon"]
 
   connect() {
-    this.cardWidth = 430
-    this.totalSlides = this.containerTarget.children.length
-    this.dotCount = Math.min(this.totalSlides, 5)
+    this.isMobile = window.innerWidth <= 768
+    this.cardWidth = 0
+    this.totalSlides = 0
     this.currentIndex = 0
     this.scrolling = false
-
-    this.updateDots()
-    this.bindEvents()
-  }
-
-  // 이벤트 핸들러를 변수에 저장해서 remove 가능하도록
-  bindEvents() {
-    this.boundOnScroll = this.onScroll.bind(this)
-    this.boundOnKeydown = this.onKeydown.bind(this)
-    this.boundWindowKeydown = (e) => {
-      if (
-        ["ArrowLeft", "ArrowRight"].includes(e.key) &&
-        document.activeElement !== this.containerTarget
-      ) {
+    this.scrollAnimationId = null
+    this.isAutoPlaying = false
+    this.autoPlayInterval = null
+
+    this.updateCardMetrics()
+    this.containerTarget.scrollLeft = 0  // 첫 카드로 고정
+    this.updatePagination()
+    this.addEventListeners()
+
+    if (this.isMobile) {
+      this.startAutoPlay()
+      this.addSwipeListeners()
+    } else {
+      this.updatePlayPauseIcon(false)
+      this.containerTarget.setAttribute("tabindex", "0")
+      setTimeout(() => {
         this.containerTarget.focus({ preventScroll: true })
-      }
+      }, 100)
     }
-
-    this.containerTarget.addEventListener("scroll", this.boundOnScroll)
-    this.containerTarget.addEventListener("keydown", this.boundOnKeydown)
-    window.addEventListener("keydown", this.boundWindowKeydown)
   }
 
-  // Stimulus가 DOM에서 제거될 때 clean-up
-  disconnect() {
-    this.containerTarget.removeEventListener("scroll", this.boundOnScroll)
-    this.containerTarget.removeEventListener("keydown", this.boundOnKeydown)
-    window.removeEventListener("keydown", this.boundWindowKeydown)
+  updateCardMetrics() {
+    const card = this.containerTarget.querySelector(".hot-issue-card")
+    if (!card) return;
+
+    const style = window.getComputedStyle(card)
+    const marginRight = parseInt(style.marginRight || 0, 10)
+    const marginLeft = parseInt(style.marginLeft || 0, 10)
+    const gap = marginRight + marginLeft
+
+    this.cardWidth = card.offsetWidth + gap
+    this.totalSlides = this.containerTarget.querySelectorAll(".hot-issue-card").length
   }
 
-  onScroll() {
-    const scrollLeft = this.containerTarget.scrollLeft
-    const maxScroll = this.containerTarget.scrollWidth - this.containerTarget.clientWidth
-    const ratio = scrollLeft / maxScroll
-    this.currentIndex = Math.min(this.dotCount - 1, Math.round(ratio * (this.dotCount - 1)))
-    this.updateDots()
-  }
-
-  updateDots() {
-    this.dotTargets.forEach((dot, index) => {
-      const active = index === this.currentIndex
-      dot.classList.toggle("active", active)
-      dot.setAttribute("aria-current", active ? "true" : "false")
+  addEventListeners() {
+    this.containerTarget.addEventListener("scroll", this.onScroll.bind(this))
+    this.containerTarget.addEventListener("keydown", this.onKeydown.bind(this))
+    this.containerTarget.addEventListener("mousedown", () => {
+      this.containerTarget.focus({ preventScroll: true })
     })
   }
 
-  onKeydown(event) {
+  onScroll() {
     if (this.scrolling) return
-    const direction = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0
-    if (direction !== 0) this.scrollToCard(direction)
-  }
 
-  startScrollLeft() {
-    if (!this.scrolling) this.scrollToCard(-1)
+    const index = Math.round(this.containerTarget.scrollLeft / this.cardWidth)
+    this.currentIndex = Math.min(index, this.totalSlides - 1)
+    this.updatePagination()
   }
 
-  startScrollRight() {
-    if (!this.scrolling) this.scrollToCard(1)
-  }
+  scrollToIndex(index, duration = 500) {
+    if (this.scrolling) return
+    index = Math.max(0, Math.min(index, this.totalSlides - 1))  // 범위 제한
 
-  scrollToCard(direction, duration = 700) {
-    const start = this.containerTarget.scrollLeft
-    const target = Math.max(
-      0,
-      Math.min(start + direction * this.cardWidth, this.containerTarget.scrollWidth)
-    )
-    this.animateScroll(start, target, duration)
+    const targetScroll = index * this.cardWidth
+    this.animateScroll(this.containerTarget.scrollLeft, targetScroll, duration)
   }
 
   animateScroll(start, end, duration) {
+    if (this.scrollAnimationId) cancelAnimationFrame(this.scrollAnimationId)
     this.scrolling = true
     const startTime = performance.now()
 
     const animate = (time) => {
-      const elapsed = time - startTime
-      const progress = Math.min(elapsed / duration, 1)
-      const ease = 0.5 - Math.cos(progress * Math.PI) / 2
+      const progress = Math.min((time - startTime) / duration, 1)
+      const ease = progress < 0.5 ? 2 * progress * progress : -1 + (4 - 2 * progress) * progress
       this.containerTarget.scrollLeft = start + (end - start) * ease
 
       if (progress < 1) {
-        requestAnimationFrame(animate)
+        this.scrollAnimationId = requestAnimationFrame(animate)
       } else {
         this.scrolling = false
+        this.scrollAnimationId = null
         this.onScroll()
       }
     }
 
-    requestAnimationFrame(animate)
+    this.scrollAnimationId = requestAnimationFrame(animate)
+  }
+
+  startScrollLeft() {
+    this.scrollToIndex(this.currentIndex - 1)
+  }
+
+  startScrollRight() {
+    this.scrollToIndex(this.currentIndex + 1)
+  }
+
+  onKeydown(e) {
+    if (e.key === "ArrowLeft") this.startScrollLeft()
+    if (e.key === "ArrowRight") this.startScrollRight()
+  }
+
+  updatePagination() {
+    if (this.hasDotTarget && !this.isMobile) {
+      this.dotTargets.forEach((dot, i) => {
+        const active = i === this.currentIndex
+        dot.classList.toggle("active", active)
+        dot.setAttribute("aria-current", active ? "true" : "false")
+      })
+    }
+
+    if (this.hasPageCounterTarget) {
+      const current = Math.min(this.currentIndex + 1, this.totalSlides)
+      this.pageCounterTarget.textContent = `${current} / ${this.totalSlides}`
+    }
+  }
+
+  toggleAutoPlay() {
+    this.isAutoPlaying ? this.stopAutoPlay() : this.startAutoPlay()
+  }
+
+  startAutoPlay() {
+    this.isAutoPlaying = true
+    this.autoPlayInterval = setInterval(() => {
+      this.scrollToIndex(this.currentIndex + 1)
+    }, 3000)
+    this.updatePlayPauseIcon(true)
+  }
+
+  stopAutoPlay() {
+    this.isAutoPlaying = false
+    clearInterval(this.autoPlayInterval)
+    this.updatePlayPauseIcon(false)
+  }
+
+  updatePlayPauseIcon(isPlaying) {
+    const playIcon = this.playPauseIconTarget?.querySelector(".play-icon")
+    const pauseIcon = this.playPauseIconTarget?.querySelector(".pause-icon")
+
+    if (!playIcon || !pauseIcon) return
+
+    playIcon.style.display = isPlaying ? "none" : "inline"
+    pauseIcon.style.display = isPlaying ? "inline" : "none"
+  }
+
+  addSwipeListeners() {
+    let startX = 0
+    this.containerTarget.addEventListener("touchstart", (e) => {
+      startX = e.touches[0].clientX
+    })
+    this.containerTarget.addEventListener("touchend", (e) => {
+      const endX = e.changedTouches[0].clientX
+      const diff = endX - startX
+
+      if (Math.abs(diff) > 50 && !this.scrolling) {
+        diff > 0 ? this.startScrollLeft() : this.startScrollRight()
+      }
+    })
+  }
+
+  focusContainer() {
+    this.containerTarget.focus({ preventScroll: true })
   }
 }
diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb
index 6749e8c..80b981e 100644
--- a/app/views/home/index.html.erb
+++ b/app/views/home/index.html.erb
@@ -17,7 +17,7 @@
   <% end %>
 </div>
 
-<!-- HOT 이슈 법안 섹션 -->
+<!-- HOT 이슈 법안 헤더 -->
 <div class="hot-issue-header">
   <div class="hot-issue-bill-text">HOT 이슈 법안</div>
   <%= link_to bills_path, class: "all-issue-bill-text" do %>
@@ -28,39 +28,35 @@
 
 <!-- HOT 이슈 캐러셀 -->
 <div data-controller="carousel">
-  <!-- 카드 + 화살표 -->
-  <div class="hot-issue-carousel-wrapper">
-    <button
-      class="hot-issue-previous-icon"
-      data-action="mousedown->carousel#startScrollLeft mouseup->carousel#stopScroll mouseleave->carousel#stopScroll"
-      aria-label="이전 슬라이드">
-      <%= inline_svg("previous") %>
+  <div class="hot-issue-carousel-wrapper" role="region" aria-label="HOT 이슈 법안 슬라이드">
+
+    <button class="hot-issue-previous-icon desktop-only"
+            type="button"
+            data-action="click->carousel#focusContainer click->carousel#startScrollLeft"
+            aria-label="이전 슬라이드">
+      <%= inline_svg("previous", class: "hot-issue-icon") %>
     </button>
 
-    <!-- tabindex 추가로 키보드 조작 가능하게 -->
-    <div data-carousel-target="container" class="hot-issue-card-container" tabindex="0">
+    <div data-carousel-target="container"
+         class="hot-issue-card-container"
+         tabindex="0"
+         aria-label="슬라이드 목록. 좌우 방향키로 이동 가능">
+
       <% @hot_bills.each do |bill| %>
-        <%= link_to bill_path(bill), class: "hot-issue-card", aria: { label: "#{bill.title} 요약: #{truncate((bill.summary || '').gsub(/\A(?:제안이유 및 주요내용|제안이유)[:\s]*/, ''), length: 90)}" } do %>
+        <%= link_to bill_path(bill),
+                    class: "hot-issue-card",
+                    aria: { label: "HOT 이슈 법안: #{truncate(bill.title, length: 30)}" } do %>
           <div class="hot-issue-title"><%= bill.title %></div>
-
           <div class="hot-issue-header-tag">
             <div class="bill-tag-party">
-              <% specific_proposer = bill.proposals.first&.specific_proposer %>
-              <% if specific_proposer.is_a?(NationalAssemblyPerson) %>
-                <%= specific_proposer.party_name.presence || "무소속" %>
-              <% elsif specific_proposer.is_a?(GovernmentBillSponsor) %>
-                정부
-              <% else %>
-                기타
-              <% end %>
+              <% proposer = bill.proposals.first&.specific_proposer %>
+              <%= proposer.is_a?(NationalAssemblyPerson) ? (proposer.party_name.presence || "무소속") : proposer.is_a?(GovernmentBillSponsor) ? "정부" : "기타" %>
             </div>
-
             <div class="bill-tag-status <%= bill.status_css_class %>">
-              <%= bill.status_emoji %>
-              <%= bill.current_status %>
+              <span class="status-emoji"><%= bill.status_emoji %></span>
+              <span class="status-label"><%= bill.current_status %></span>
             </div>
           </div>
-
           <div class="hot-issue-bill-description">
             <%= truncate((bill.summary || "").gsub(/\A(?:제안이유 및 주요내용|제안이유)[:\s]*/, ""), length: 90) %>
           </div>
@@ -68,26 +64,54 @@
       <% end %>
     </div>
 
-    <button
-      class="hot-issue-next-icon"
-      data-action="mousedown->carousel#startScrollRight mouseup->carousel#stopScroll mouseleave->carousel#stopScroll"
-      aria-label="다음 슬라이드">
-      <%= inline_svg("next") %>
+    <button class="hot-issue-next-icon desktop-only"
+            type="button"
+            data-action="click->carousel#focusContainer click->carousel#startScrollRight"
+            aria-label="다음 슬라이드">
+      <%= inline_svg("next", class: "hot-issue-icon") %>
     </button>
   </div>
 
-  <!-- 동적 dot 생성 + aria-current -->
-  <% dot_count = [@hot_bills.size, 5].min %>
-  <div class="hot-issue-pagination">
-    <% dot_count.times do |index| %>
-      <div
-        class="hot-issue-dot <%= 'active' if index == 0 %>"
-        data-carousel-target="dot"
-        role="button"
-        tabindex="-1"
-        aria-current="<%= index == 0 ? 'true' : 'false' %>"
-        aria-label="슬라이드 <%= index + 1 %>">
-      </div>
-    <% end %>
+  <% if @hot_bills.present? %>
+    <div class="hot-issue-pagination desktop-only">
+      <% [@hot_bills.size, 4].min.times do |index| %>
+        <div class="hot-issue-dot <%= 'active' if index == 0 %>"
+             data-carousel-target="dot"
+             role="button"
+             tabindex="0"
+             aria-current="<%= index == 0 ? 'true' : 'false' %>"
+             aria-label="슬라이드 <%= index + 1 %>">
+        </div>
+      <% end %>
+    </div>
+  <% end %>
+
+  <div class="mobile-only hot-issue-pagination">
+    <span class="hot-issue-page-counter" data-carousel-target="pageCounter">
+      1 / <%= @hot_bills.size %>
+    </span>
+
+    <button class="hot-issue-previous-icon"
+            type="button"
+            data-action="click->carousel#startScrollLeft"
+            aria-label="이전 슬라이드">
+      <%= inline_svg("previous", class: "hot-issue-icon") %>
+    </button>
+
+    <button class="pause-line-icon"
+            type="button"
+            data-carousel-target="playPauseIcon"
+            data-action="click->carousel#toggleAutoPlay"
+            aria-label="자동 재생 토글">
+      <%= inline_svg("list", class: "hot-issue-icon play-icon") %>
+      <%= inline_svg("pause_line", class: "hot-issue-icon pause-icon") %>
+    </button>
+
+    <button class="hot-issue-next-icon"
+            type="button"
+            data-action="click->carousel#startScrollRight"
+            aria-label="다음 슬라이드">
+      <%= inline_svg("next", class: "hot-issue-icon") %>
+    </button>
   </div>
 </div>
diff --git a/db/erd.pdf b/db/erd.pdf
index 595cb25..db1d5ee 100644
Binary files a/db/erd.pdf and b/db/erd.pdf differ


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

Performance improvements and animation optimizations for the carousel slide component, plus a markup update for the login dropdown.

- Dynamically calculate `cardWidth` from the DOM and a configurable gap instead of a fixed value.
- Skip nearly identical scroll positions and cancel overlapping `requestAnimationFrame` calls to avoid jank.
- Standardize event listener binding/cleanup methods and convert the starred bills dropdown item to a proper link.

### Reviewed Changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

| File                                        | Description                                                                          |
|---------------------------------------------|--------------------------------------------------------------------------------------|
| app/views/login/index.html.erb              | Changed the starred bills dropdown from a `<div>` to a Rails `link_to` for navigation. |
| app/javascript/controllers/carousel_controller.js | Refactored scroll logic: dynamic width, skip small moves, cancel duplicate frames, and improved event listener management. |


<details>
<summary>Comments suppressed due to low confidence (3)</summary>

**app/javascript/controllers/carousel_controller.js:81**
* [nitpick] This local variable `currentIndex` shadows the class property `this.currentIndex`. Rename it (e.g., `calculatedIndex`) or use the instance property to reduce confusion.
```
const currentIndex = Math.round(this.containerTarget.scrollLeft / this.cardWidth)
```
**app/javascript/controllers/carousel_controller.js:86**
* Add unit tests for this near-equal scroll skip condition to verify the tolerance threshold behaves as expected under various widths.
```
if (Math.abs(currentScroll - targetScroll) < 2) return // 이동할 필요 없음
```
**app/views/login/index.html.erb:31**
* Consider adding role="menuitem" and tabindex="-1" to the link for proper screen reader support within the dropdown menu.
```
<%= link_to bills_path(starred: true), class: "dropdown-item" do %>
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T06:23:17Z
user: login: greenstar1151
body: 확인했습니다 👍 

코파일럿 코멘트는 적용해도 되고 안해도 무방할 것 같아요.
그리고 erd.pdf가 삭제됐는데 이건 나중에 제가 복구할게요.
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T06:36:37Z
user: login: qndn3tp
body: 수고하셨습니다 👍🏻
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T07:44:00Z
user: login: Sumin6872
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T09:43:08Z
user: login: Sumin6872
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T09:43:30Z
user: login: kimsj8912
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-14T12:32:01Z
user: login: Sumin6872
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T17:38:01Z
user: login: Sumin6872
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T18:29:39Z
user: login: qndn3tp
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T03:26:07Z
user: login: kimsj8912
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-15T05:37:27Z
user: login: qndn3tp
body: 수고많으셨습니다 👍🏻
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T05:02:00Z

Review comments:
diff_hunk: @@ -4,18 +4,25 @@ export default class extends Controller {
   static targets = ["container", "dot"]
 
   connect() {
-    this.cardWidth = 430
+    const card = this.containerTarget.querySelector(".hot-issue-card")
+    const gap = 30
path: app/javascript/controllers/carousel_controller.js
user: login: Copilot
body: [nitpick] Extract the hardcoded gap value into a named constant or compute it from a CSS variable to avoid magic numbers.
created_at: 2025-05-14T06:23:17Z
author_association: CONTRIBUTOR
path: app/views/login/index.html.erb
user: login: qndn3tp
body: 이건 기획분과 얘기 나눠야 할 것 같아요!
@kimsj8912 배너>프로필>내관심법안 클릭시 이쪽으로 이동하는 로직 맞을까요? 
created_at: 2025-05-14T07:28:34Z
author_association: CONTRIBUTOR
path: app/javascript/controllers/carousel_controller.js
user: login: qndn3tp
body: 보내주신 영상으로 확인해봤는데, previous 버튼을 눌러 첫번째 요소로 끝까지 이동했을때, 더이상 이동하지 않아야하는데
반대 방향(>)으로 이동하는 문제가 있는 것 같습니다.
한 번 체크해주실 수 있을까요?
created_at: 2025-05-14T07:43:39Z
author_association: CONTRIBUTOR
path: app/views/login/index.html.erb
user: login: Sumin6872
body: @qndn3tp 넵 맞습니다.
created_at: 2025-05-14T09:43:08Z
author_association: CONTRIBUTOR
path: app/javascript/controllers/carousel_controller.js
user: login: Sumin6872
body: @qndn3tp 넵 알겠습니다.
created_at: 2025-05-14T09:43:29Z
author_association: CONTRIBUTOR
path: app/views/login/index.html.erb
user: login: kimsj8912
body: ![image](https://github.com/user-attachments/assets/b409a747-b639-4c62-b959-467aac6a00ae)
배너>프로필>내 관심 법안>법안 목록 페이지(내 관심 법안 클릭 상태)
라고 생각하시면 될 것 같습니다!
created_at: 2025-05-14T12:32:01Z
author_association: NONE
path: app/views/login/index.html.erb
user: login: Sumin6872
body: 배너>프로필>내 관심 법안>법안 목록 페이지(내 관심 법안 클릭 상태)

@qndn3tp @kimsj8912 위와 같이 진행되는거 맞습니다.
created_at: 2025-05-14T17:38:01Z
author_association: CONTRIBUTOR
path: app/javascript/controllers/carousel_controller.js
user: login: Sumin6872
body: https://github.com/user-attachments/assets/efe383df-7e73-4701-9ea8-6dd5a4e95d51
@greenstar1151 @qndn3tp @kimsj8912 
슬라이드 영상 첨부합니다.
created_at: 2025-05-14T18:29:39Z
author_association: CONTRIBUTOR
path: app/javascript/controllers/carousel_controller.js
user: login: qndn3tp
body: 확인했습니다! 
- 정지를 눌렀을때와 재생중일때 아이콘으로 시각적으로 구분할 필요가 있을것같아요
<img width="67" alt="스크린샷 2025-05-15 오후 12 21 46" src="https://github.com/user-attachments/assets/855d24ee-5c55-4ed8-b670-b06034432f3f" />
<img width="63" alt="스크린샷 2025-05-15 오후 12 21 59" src="https://github.com/user-attachments/assets/d01bb56c-1475-47d9-8c4f-0fa58fb2c719" />


- 스크롤로 마지막 요소(3/3)에서 >로 이동하니까 5/3(?)이 되는 문제가 있는데, 첫번째나 마지막 요소에서 ><를 눌렀을 때 처리하는 부분에서 잘못된 부분이 있는 것 같습니다. 한번 확인해주시면 감사하겠습니다
<img width="337" alt="image" src="https://github.com/user-attachments/assets/ba19b325-5c9b-4f3c-93ae-cddb52eb6786" />

[krds캐러셀](https://www.krds.go.kr/html/site/component/component_04_09.html) 참고하시면 좋을 것 같아요

created_at: 2025-05-15T03:26:06Z
author_association: CONTRIBUTOR
path: app/javascript/controllers/carousel_controller.js
user: login: kimsj8912
body: ![image](https://github.com/user-attachments/assets/106cd192-8a13-4f0f-a8ae-c32fab206ca3)
정지 아이콘 피그마 파일에 정의해두었습니당 공유드려요!
created_at: 2025-05-15T05:37:27Z
author_association: NONE

PR comments:
user: login: greenstar1151
created_at: 2025-05-15T14:37:35Z
body: @qndn3tp 요거 혹시 수민님과 페어코딩 등으로 같이 작업해서 근시일 내에 머지해볼 수 있을까요? 마감 기한도 기한이고, 마스터와 차이가 벌어져서 컨플릭트가 계속 발생할 것 같습니다.
author_association: CONTRIBUTOR
user: login: qndn3tp
created_at: 2025-05-15T18:12:52Z
body: > @qndn3tp 요거 혹시 수민님과 페어코딩 등으로 같이 작업해서 근시일 내에 머지해볼 수 있을까요? 마감 기한도 기한이고, 마스터와 차이가 벌어져서 컨플릭트가 계속 발생할 것 같습니다.

넵 알겠습니다! 그게 좋을 것 같아요
author_association: CONTRIBUTOR