Repository: StarSync-FE/StarSync
Pull request #186: ✨Feat: 캐러셀 구현
Author: login: Insung-Jo
Labels: name: ✨Feat
description: 기능 개발
name: 💄Style
description: 스타일링
Created: 2025-04-25T05:43:58Z | Merged: 2025-04-25T06:38:30Z

## 🧩 관련 이슈 번호

- #132

## ✨ 요약

<!-- 구현 및 수정한 내용을 간단하게 적어주세요. -->
- 캐러셀 UI 구현
- 캐러셀 반응형 구현
- 캐러셀 기능 구현

## 📌 주요 변경 사항

<!-- 해당 PR의 변경 사항을 자세하게 적어주세요. -->

- 캐러셀의 기초 적인 UI와 반응형을 구현 했습니다.
- 캐러셀 기능 구현을 했습니다 (버튼 클릭 시 이동, 모바일의 경우 스와이프 등)

## ✅ 체크리스트

<!-- 체크리스트 내용을 수정하고 싶으면 회의 때 얘기부탁드려요. -->

- [x] PR 하기 전에 이슈에서 빼먹은건 없는지 확인했습니다.
  - [x] 구현 기간에 맞는 이슈에서 서브이슈로 등록했습니다.
  - [x] 라벨 및 마일스톤을 사이드 탭에서 등록했습니다.
  - [x] PR 사이드 탭에서는 Projects을 등록 하지않았습니다.
- [x] PR을 보내는 브랜치가 올바른지 확인했습니다.
- [x] 팀원들이 리뷰하기 쉽도록 설명을 자세하게 작성했습니다.
- [x] 변경사항을 충분히 테스트 했습니다.
- [x] (함수를 구현 했을 때) JSDoc을 양식에 맞춰서 작성했습니다.
- [x] 컨벤션에 맞게 구현했습니다.

## 📷 UI 변경 사항 (선택)

<!-- UI 관련 구현 및 수정 사항이 있다면 이미지 or 동영상을 첨부해주세요.  -->

https://github.com/user-attachments/assets/3212a00c-93e3-43ad-b4fd-dbc7a76c7576


https://github.com/user-attachments/assets/ba3bc979-5e98-4a61-858a-3ae40388fdf2


## ❓무슨 문제가 발생했나요 ?
- 빠른 작업을 위해 AI를 사용하다 보니 코드가 복잡할 수 있습니다

## 💬 논의 사항

<!-- 논의하고 싶은 사항을 적어 주시고, 토론이 필요하시면 토론 탭에 추가 부탁드립니다. -->
- none

## 💬 기타 참고 사항

<!-- 리뷰어가 확인해주면 좋은 부분이나 기타 등등을 작성해주면 감사합니다. -->
- 코드 부분은 저도 아직 이해하는 단계로 알아가고 있습니다. 그래서 리뷰가 다소 미흡할 수 있다는 점 참고해주시길..
- listPage를 수정했기 때문에 충돌이 발생할 수 있습니다. 이 부분도 확인해주시길 바랍니다.
  - 사유 : 캐러셀 listPage에 반영
- **후원 하기 버튼을 누르면 지금 모달 쪽은 수정을 안해서 500 뜨니 누르면 안됩니당** 

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - 새로운 캐러셀(Carousel) 컴포넌트가 추가되어, 여러 카드를 가로로 스크롤하며 볼 수 있습니다.
  - 캐러셀 내 각 아이템은 동적으로 데이터를 받아 카드 형태로 렌더링됩니다.
  - 캐러셀은 반응형으로 동작하며, 화면 크기에 따라 보여지는 카드 수가 자동 조절됩니다.
  - 좌우 이동 버튼으로 캐러셀을 탐색할 수 있습니다.

- **Refactor**
  - 카드(Card) 컴포넌트가 동적 데이터와 모달 제어를 위한 props를 받아 재사용 가능하도록 개선되었습니다.

- **Style**
  - 캐러셀 및 카드 컴포넌트에 맞춘 새로운 스타일이 적용되었습니다.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


Diff:
diff --git a/src/components/card/Card.jsx b/src/components/card/Card.jsx
index 51b37f8..f3f5b0d 100644
--- a/src/components/card/Card.jsx
+++ b/src/components/card/Card.jsx
@@ -1,45 +1,52 @@
-import MococoImg from '@/assets/icons/mococo-no-background.png';
+import creditImg from '@/assets/images/credit.png';
 import CustomButton from '@/components/customButton';
-import { mockData } from '@/data/mockData';
 import { addCommas, getDaysRemaining, getDonationPercentage } from '@/utils/format';
 import { useEffect, useState } from 'react';
 import * as S from './card.styles';
 
-const Card = () => {
+const Card = ({ data, setModalType }) => {
   const [isActive, setIsActive] = useState(false);
-  const test = mockData.donations[0];
-  const daysLeft = getDaysRemaining(test.deadline);
-  const percent = `${getDonationPercentage(test.targetDonation, test.receivedDonations)}%`;
+  const daysLeft = getDaysRemaining(data.deadline);
+  const percent = `${getDonationPercentage(data.targetDonation, data.receivedDonations)}%`;
   const isCompleted = daysLeft > 0;
 
   useEffect(() => {
     setIsActive(isCompleted);
   }, [isCompleted]);
 
+  const handleClick = () => {
+    setModalType('donation');
+  };
+
   return (
     <article css={S.card}>
       <div css={S.wrapper}>
         <figure>
-          <img src={test.idol.profilePicture} alt="큐티" css={S.image} />
+          <img src={data.idol.profilePicture} alt={data.idol.name} css={S.image} />
         </figure>
 
-        <CustomButton type="button" variant="carousel" style={S.button} disabled={!isActive}>
-          <img src={MococoImg} alt="모코코" css={S.icon} />
+        <CustomButton
+          type="button"
+          variant="carousel"
+          style={S.button}
+          disabled={!isActive}
+          onClick={handleClick}
+        >
           {isActive ? '후원하기' : '후원 마감'}
         </CustomButton>
       </div>
 
       <div css={S.content}>
         <div css={S.header}>
-          <p>{test.subtitle}</p>
-          <h2>{test.title}</h2>
+          <p>{data.subtitle}</p>
+          <h2>{data.title}</h2>
         </div>
 
         <div css={S.info}>
           <div css={S.statusBar}>
             <div css={S.statusLeft}>
-              <img src={MococoImg} alt="프리티" css={S.icon} />
-              <p>{addCommas(test.receivedDonations)}</p>
+              <img src={creditImg} alt="크레딧" css={S.icon} />
+              <p>{addCommas(data.receivedDonations)}</p>
             </div>
             <p>{daysLeft > 1 ? `D-${daysLeft}` : daysLeft === 1 ? '오늘 마감' : '마감 완료'}</p>
           </div>
diff --git a/src/components/carousel/Carousel.jsx b/src/components/carousel/Carousel.jsx
new file mode 100644
index 0000000..579785b
--- /dev/null
+++ b/src/components/carousel/Carousel.jsx
@@ -0,0 +1,101 @@
+import { Card } from '@/components/card';
+import { getItemMetrics } from '@/utils/carousel';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import * as S from './carousel.styles';
+
+const Carousel = ({ data, setModalType }) => {
+  const [currentIndex, setCurrentIndex] = useState(0);
+  const [itemsPerView, setItemsPerView] = useState(4);
+  const containerRef = useRef(null);
+
+  // data.list의 길이를 사용
+  const itemsLength = data?.list?.length || 0;
+
+  const updateItemsPerView = useCallback(() => {
+    if (!containerRef.current) return;
+
+    const viewportWidth = containerRef.current.offsetWidth;
+    const { itemWidth, gap } = getItemMetrics();
+    const totalItemWidth = itemWidth + gap;
+    const calculatedItemsPerView = Math.floor(viewportWidth / totalItemWidth);
+
+    setItemsPerView(calculatedItemsPerView);
+    const newMaxIndex = Math.max(0, itemsLength - calculatedItemsPerView);
+    setCurrentIndex((prev) => Math.min(prev, newMaxIndex));
+  }, [itemsLength]);
+
+  useEffect(() => {
+    updateItemsPerView();
+    window.addEventListener('resize', updateItemsPerView);
+    return () => window.removeEventListener('resize', updateItemsPerView);
+  }, [updateItemsPerView]);
+
+  const maxIndex = Math.max(0, itemsLength - itemsPerView);
+
+  const handleNext = () => {
+    setCurrentIndex((prev) => Math.min(prev + 1, maxIndex));
+  };
+
+  const handlePrev = () => {
+    setCurrentIndex((prev) => Math.max(prev - 1, 0));
+  };
+
+  const getSlideOffset = () => {
+    const { itemWidth, gap } = getItemMetrics();
+    return currentIndex * (itemWidth + gap);
+  };
+
+  return (
+    <div css={S.wrapper}>
+      <h2 css={S.carouselTitle}>후원을 기다리는 조공</h2>
+      <div css={S.viewportArea}>
+        <button
+          type="button"
+          onClick={handlePrev}
+          css={S.navigationButton}
+          disabled={currentIndex === 0}
+          aria-label="이전"
+        >
+          <svg css={S.arrowIcon} viewBox="0 0 8 14" xmlns="http://www.w3.org/2000/svg">
+            <title>이전</title>
+            <path d="M7 1L1 7L7 13" strokeLinecap="round" strokeLinejoin="round" />
+          </svg>
+        </button>
+
+        <div ref={containerRef} css={S.carouselContainer}>
+          <div
+            css={S.carouselTrack}
+            style={{
+              '--slide-offset': `-${getSlideOffset()}px`,
+            }}
+          >
+            {data?.list?.length > 0 ? (
+              data.list.map((item) => (
+                <div key={item.id} css={S.carouselItem}>
+                  <Card data={item} setModalType={setModalType} />
+                </div>
+              ))
+            ) : (
+              <div>표시할 항목이 없습니다</div>
+            )}
+          </div>
+        </div>
+
+        <button
+          type="button"
+          onClick={handleNext}
+          css={S.navigationButton}
+          disabled={currentIndex >= maxIndex}
+          aria-label="다음"
+        >
+          <svg css={S.arrowIcon} viewBox="0 0 8 14" xmlns="http://www.w3.org/2000/svg">
+            <title>다음</title>
+            <path d="M1 1L7 7L1 13" strokeLinecap="round" strokeLinejoin="round" />
+          </svg>
+        </button>
+      </div>
+    </div>
+  );
+};
+
+export default Carousel;
diff --git a/src/components/carousel/carousel.styles.js b/src/components/carousel/carousel.styles.js
new file mode 100644
index 0000000..5cdebf1
--- /dev/null
+++ b/src/components/carousel/carousel.styles.js
@@ -0,0 +1,100 @@
+import media from '@/styles/responsive';
+import { css } from '@emotion/react';
+
+export const arrowIcon = css`
+  height: 1.4rem;
+  fill: none;
+  stroke: var(--white);
+  stroke-width: 0.3rem;
+`;
+
+export const wrapper = css`
+  width: 100%;
+  margin-block: 4rem;
+`;
+
+export const carouselTitle = css`
+  margin-inline: 2rem;
+  margin-bottom: 1.6rem;
+  font-size: 2.4rem;
+  font-weight: 700;
+  color: var(--white);
+
+  ${media({
+    fontSize: ['2rem', '2rem', '2.4rem', '2.4rem'],
+    marginInline: ['2rem', '2rem', '8rem', '8rem'],
+    marginBottom: ['1.6rem', '1.6rem', '2.4rem', '3.2rem'],
+  })}
+`;
+
+export const viewportArea = css`
+  display: flex;
+  align-items: center;
+  gap: 0;
+  padding-inline: 2rem;
+
+  ${media({
+    gap: ['0', '0', '2rem', '2rem'],
+  })}
+`;
+
+export const carouselContainer = css`
+  overflow-x: scroll;
+  width: 100%;
+  scroll-snap-type: x mandatory;
+  scroll-behavior: smooth;
+
+  &::-webkit-scrollbar {
+    display: none;
+  }
+`;
+
+export const carouselTrack = css`
+  display: flex;
+  gap: 1.2rem;
+  transition: transform 0.3s ease-in-out;
+
+  ${media({
+    transform: [
+      'none',
+      'none',
+      'translateX(var(--slide-offset))',
+      'translateX(var(--slide-offset))',
+    ],
+  })}
+`;
+
+export const carouselItem = css`
+  flex-basis: 15.8rem;
+  min-width: 15.8rem;
+
+  ${media({
+    flexBasis: ['15.8rem', '15.8rem', '28.2rem', '28.2rem'],
+    minWidth: ['15.8rem', '15.8rem', '28.2rem', '28.2rem'],
+  })}
+`;
+
+export const navigationButton = css`
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  width: 40px;
+  height: 78px;
+  border-radius: 6px;
+  background-color: var(--black);
+  transition: opacity 0.2s;
+
+  &:disabled {
+    opacity: 0;
+    cursor: default;
+    pointer-events: none;
+  }
+
+  &:hover {
+    opacity: 0.8;
+  }
+
+  ${media({
+    display: ['none', 'none', 'flex', 'flex'],
+  })}
+`;
diff --git a/src/components/carousel/index.js b/src/components/carousel/index.js
new file mode 100644
index 0000000..2c8b160
--- /dev/null
+++ b/src/components/carousel/index.js
@@ -0,0 +1 @@
+export { default as Carousel } from './Carousel';
diff --git a/src/constants/carousel/carouselConstants.js b/src/constants/carousel/carouselConstants.js
new file mode 100644
index 0000000..c7548f5
--- /dev/null
+++ b/src/constants/carousel/carouselConstants.js
@@ -0,0 +1,10 @@
+const CAROUSEL_CONSTANTS = {
+  MOBILE_BREAKPOINT: 768,
+  ITEM_DIMENSIONS: {
+    MOBILE_ITEM_WIDTH: 158,
+    DESKTOP_ITEM_WIDTH: 282,
+    ITEM_GAP: 12,
+  },
+};
+
+export default CAROUSEL_CONSTANTS;
diff --git a/src/constants/carousel/index.js b/src/constants/carousel/index.js
new file mode 100644
index 0000000..458b00f
--- /dev/null
+++ b/src/constants/carousel/index.js
@@ -0,0 +1 @@
+export { default as CAROUSEL_CONSTANTS } from './carouselConstants';
diff --git a/src/pages/list/ListPage.jsx b/src/pages/list/ListPage.jsx
index 9a877ab..021cef6 100644
--- a/src/pages/list/ListPage.jsx
+++ b/src/pages/list/ListPage.jsx
@@ -1,3 +1,4 @@
+import { Carousel } from '@/components/carousel';
 import { Charge } from '@/components/charge';
 import { Chart } from '@/components/chart';
 import { ListModal } from '@/components/list';
@@ -12,10 +13,6 @@ export const wrapper = css`
   font-family: Pretendard, sans-serif;
 `;
 
-export const carousel = css`
-  margin: 10rem 0;
-`;
-
 const ListPage = () => {
   const [modalType, setModalType] = useState(null); // 모달 타입 상태 관리
   const [credit, setCredit] = useState(0); // 크레딧 상태 관리
@@ -40,7 +37,7 @@ const ListPage = () => {
   return (
     <div css={wrapper}>
       <Charge credit={credit} setModalType={setModalType} /> {/* Charge에 credit 전달 */}
-      <div css={carousel}>캐러셀</div>
+      <Carousel data={donations} setModalType={setModalType} />
       <Chart data={chart} />
       <ListModal
         modalType={modalType}
diff --git a/src/utils/carousel/getItemMetrics.js b/src/utils/carousel/getItemMetrics.js
new file mode 100644
index 0000000..4e95146
--- /dev/null
+++ b/src/utils/carousel/getItemMetrics.js
@@ -0,0 +1,13 @@
+import { CAROUSEL_CONSTANTS } from '@/constants/carousel';
+
+const getItemMetrics = () => {
+  const mobileWidth = CAROUSEL_CONSTANTS.ITEM_DIMENSIONS.MOBILE_ITEM_WIDTH;
+  const desktopWdith = CAROUSEL_CONSTANTS.ITEM_DIMENSIONS.DESKTOP_ITEM_WIDTH;
+  const isMobile = window.innerWidth < CAROUSEL_CONSTANTS.MOBILE_BREAKPOINT;
+  const itemWidth = isMobile ? mobileWidth : desktopWdith;
+  const gap = CAROUSEL_CONSTANTS.ITEM_DIMENSIONS.ITEM_GAP;
+
+  return { itemWidth, gap };
+};
+
+export default getItemMetrics;
diff --git a/src/utils/carousel/index.js b/src/utils/carousel/index.js
new file mode 100644
index 0000000..d08c86a
--- /dev/null
+++ b/src/utils/carousel/index.js
@@ -0,0 +1 @@
+export { default as getItemMetrics } from './getItemMetrics';


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 7**

<details>
<summary>🔭 Outside diff range comments (1)</summary><blockquote>

<details>
<summary>src/pages/list/ListPage.jsx (1)</summary><blockquote>

`38-38`: _⚠️ Potential issue_

**로컬 스토리지 키 오타 수정 필요**

`localStorage.setItem('selecteCredit', newCredit);` 부분에서 키 이름에 오타가 있습니다. 26줄에서는 `selectedCredit`으로 올바르게 사용되었으나, 여기서는 `selecteCredit`으로 잘못 입력되었습니다.


다음과 같이 수정해주세요:
```diff
-    localStorage.setItem('selecteCredit', newCredit);
+    localStorage.setItem('selectedCredit', newCredit);
```

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (6)</summary><blockquote>

<details>
<summary>src/components/card/Card.jsx (1)</summary><blockquote>

`25-25`: **이미지 접근성 및 오류 처리 개선이 필요합니다.**

현재 코드는 `data.idol.profilePicture`가 유효한 URL인지 확인하지 않습니다. 또한 이미지 로드 실패 시 대체 처리가 없습니다.



다음과 같이 개선할 수 있습니다:

```diff
-<img src={data.idol.profilePicture} alt={data.idol.name} css={S.image} />
+<img 
+  src={data.idol?.profilePicture || '/path/to/default-profile.png'} 
+  alt={data.idol?.name || '아이돌 프로필'} 
+  css={S.image} 
+  onError={(e) => {
+    e.target.onerror = null;
+    e.target.src = '/path/to/default-profile.png';
+  }}
+/>
```

</blockquote></details>
<details>
<summary>src/components/carousel/carousel.styles.js (2)</summary><blockquote>

`41-50`: **스크롤 동작 및 접근성 개선이 필요합니다.**

`carouselContainer` 스타일은 스크롤 동작을 적용하고 있지만, 일부 접근성 및 크로스 브라우저 이슈가 있을 수 있습니다.



다음과 같이 개선할 수 있습니다:

```diff
export const carouselContainer = css`
  overflow-x: scroll;
  width: 100%;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
+  -webkit-overflow-scrolling: touch; /* iOS에서 부드러운 스크롤링 지원 */
+  scrollbar-width: none; /* Firefox에서 스크롤바 숨기기 */
+  -ms-overflow-style: none; /* IE/Edge에서 스크롤바 숨기기 */

  &::-webkit-scrollbar {
    display: none;
  }
`;
```

---

`77-100`: **접근성 및 상호작용성 개선이 필요합니다.**

네비게이션 버튼에 접근성과 사용자 경험을 향상시킬 수 있는 몇 가지 개선사항이 있습니다.



다음과 같이 개선할 수 있습니다:

```diff
export const navigationButton = css`
  display: flex;
  justify-content: center;
  align-items: center;
  width: 40px;
  height: 78px;
  border-radius: 6px;
  background-color: var(--black);
-  transition: opacity 0.2s;
+  transition: opacity 0.2s, background-color 0.2s;
+  z-index: 1; /* 다른 요소 위에 표시되도록 보장 */

  &:disabled {
    opacity: 0;
    cursor: default;
    pointer-events: none;
  }

  &:hover {
    opacity: 0.8;
+    background-color: var(--gray-800, #333);
  }

+  &:focus {
+    outline: 2px solid var(--primary, #007bff);
+    outline-offset: 2px;
+  }
+
+  &:active {
+    opacity: 0.7;
+    background-color: var(--gray-900, #222);
+  }

  ${media({
    display: ['none', 'none', 'flex', 'flex'],
  })}
`;
```

</blockquote></details>
<details>
<summary>src/components/carousel/Carousel.jsx (3)</summary><blockquote>

`27-31`: **이벤트 리스너 부하 및 디바운싱 개선이 필요합니다.**

현재 리사이즈 이벤트 리스너는 창 크기가 변경될 때마다 실행되어 성능에 영향을 미칠 수 있습니다.



다음과 같이 디바운싱을 적용하여 개선할 수 있습니다:

```diff
+// 디바운스 유틸리티 함수
+function debounce(func, wait) {
+  let timeout;
+  return function executedFunction(...args) {
+    const later = () => {
+      clearTimeout(timeout);
+      func(...args);
+    };
+    clearTimeout(timeout);
+    timeout = setTimeout(later, wait);
+  };
+}

useEffect(() => {
  updateItemsPerView();
-  window.addEventListener('resize', updateItemsPerView);
-  return () => window.removeEventListener('resize', updateItemsPerView);
+  const debouncedUpdateItemsPerView = debounce(updateItemsPerView, 200);
+  window.addEventListener('resize', debouncedUpdateItemsPerView);
+  return () => window.removeEventListener('resize', debouncedUpdateItemsPerView);
}, [updateItemsPerView]);
```

---

`59-63`: **SVG 아이콘을 별도 컴포넌트로 분리하는 것이 좋습니다.**

좌우 화살표 SVG 아이콘 코드가 중복되어 있습니다. 이를 별도의 컴포넌트로 분리하면 유지보수가 쉬워집니다.



새로운 파일 `src/components/carousel/CarouselArrow.jsx`를 만들고 다음과 같이 작성할 수 있습니다:

```jsx
import * as S from './carousel.styles';

const CarouselArrow = ({ direction = 'left' }) => {
  const isLeft = direction === 'left';
  const title = isLeft ? '이전' : '다음';
  const pathD = isLeft ? 'M7 1L1 7L7 13' : 'M1 1L7 7L1 13';

  return (
    <svg css={S.arrowIcon} viewBox="0 0 8 14" xmlns="http://www.w3.org/2000/svg">
      <title>{title}</title>
      <path d={pathD} strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
};

export default CarouselArrow;
```

그리고 Carousel 컴포넌트에서 다음과 같이 사용할 수 있습니다:

```diff
+import CarouselArrow from './CarouselArrow';

// ...

<button
  type="button"
  onClick={handlePrev}
  css={S.navigationButton}
  disabled={currentIndex === 0}
  aria-label="이전"
>
-  <svg css={S.arrowIcon} viewBox="0 0 8 14" xmlns="http://www.w3.org/2000/svg">
-    <title>이전</title>
-    <path d="M7 1L1 7L7 13" strokeLinecap="round" strokeLinejoin="round" />
-  </svg>
+  <CarouselArrow direction="left" />
</button>

// ... 

<button
  type="button"
  onClick={handleNext}
  css={S.navigationButton}
  disabled={currentIndex >= maxIndex}
  aria-label="다음"
>
-  <svg css={S.arrowIcon} viewBox="0 0 8 14" xmlns="http://www.w3.org/2000/svg">
-    <title>다음</title>
-    <path d="M1 1L7 7L1 13" strokeLinecap="round" strokeLinejoin="round" />
-  </svg>
+  <CarouselArrow direction="right" />
</button>
```


Also applies to: 91-95

---

`43-46`: **CSS 변수 활용을 위한 구조 개선이 필요합니다.**

`getSlideOffset` 함수는 CSS 변수 값 계산만을 위해 존재합니다. 이는 React의 스타일 처리 흐름을 복잡하게 만듭니다.



최신 react와 emotion 패턴을 활용하여 다음과 같이 개선할 수 있습니다:

```diff
-const getSlideOffset = () => {
-  const { itemWidth, gap } = getItemMetrics();
-  return currentIndex * (itemWidth + gap);
-};

// ...

<div
  css={S.carouselTrack}
-  style={{
-    '--slide-offset': `-${getSlideOffset()}px`,
-  }}
+  css={css`
+    --slide-offset: ${-currentIndex * (getItemMetrics().itemWidth + getItemMetrics().gap)}px;
+  `}
>
```

또는 성능을 위해 계산된 값을 메모이제이션할 수 있습니다:

```diff
+const { itemWidth, gap } = useMemo(() => getItemMetrics(), []);
+const slideOffset = useMemo(
+  () => -currentIndex * (itemWidth + gap),
+  [currentIndex, itemWidth, gap]
+);

// ...

<div
  css={S.carouselTrack}
  style={{
-    '--slide-offset': `-${getSlideOffset()}px`,
+    '--slide-offset': `${slideOffset}px`,
  }}
>
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: .coderabbit.yaml**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 906c32894cc0ce343ffbcbfd352956d8b7eb0f7d and fc75cb35e41170cee0ab38d76e85491e168ac474.

</details>

<details>
<summary>📒 Files selected for processing (9)</summary>

* `src/components/card/Card.jsx` (1 hunks)
* `src/components/carousel/Carousel.jsx` (1 hunks)
* `src/components/carousel/carousel.styles.js` (1 hunks)
* `src/components/carousel/index.js` (1 hunks)
* `src/constants/carousel/carouselConstants.js` (1 hunks)
* `src/constants/carousel/index.js` (1 hunks)
* `src/pages/list/ListPage.jsx` (2 hunks)
* `src/utils/carousel/getItemMetrics.js` (1 hunks)
* `src/utils/carousel/index.js` (1 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (4)</summary>

<details>
<summary>src/utils/carousel/getItemMetrics.js (1)</summary><blockquote>

<details>
<summary>src/constants/carousel/carouselConstants.js (1)</summary>

* `ITEM_DIMENSIONS` (1-5)

</details>

</blockquote></details>
<details>
<summary>src/pages/list/ListPage.jsx (2)</summary><blockquote>

<details>
<summary>src/components/carousel/Carousel.jsx (1)</summary>

* `Carousel` (6-99)

</details>
<details>
<summary>src/data/mockData.js (2)</summary>

* `donations` (214-405)
* `donations` (214-405)

</details>

</blockquote></details>
<details>
<summary>src/components/carousel/carousel.styles.js (1)</summary><blockquote>

<details>
<summary>src/styles/responsive.js (1)</summary>

* `media` (14-14)

</details>

</blockquote></details>
<details>
<summary>src/components/carousel/Carousel.jsx (2)</summary><blockquote>

<details>
<summary>src/utils/carousel/getItemMetrics.js (3)</summary>

* `getItemMetrics` (3-11)
* `itemWidth` (5-7)
* `gap` (8-8)

</details>
<details>
<summary>src/components/card/Card.jsx (1)</summary>

* `Card` (7-61)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (5)</summary><blockquote>

<details>
<summary>src/utils/carousel/index.js (1)</summary>

`1-1`: **모듈 내보내기가 잘 구성되어 있습니다.**

getItemMetrics 유틸리티를 명확하게 내보내는 구조로 잘 작성되었습니다. 다른 컴포넌트에서 이 유틸리티 함수를 쉽게 임포트하여 사용할 수 있을 것입니다.

</details>
<details>
<summary>src/constants/carousel/carouselConstants.js (1)</summary>

`1-5`: **상수 정의가 명확하게 구성되어 있습니다.**

캐러셀 아이템의 크기와 간격을 명확하게 정의하여 일관된 UI를 유지하는데 도움이 될 것입니다. 모바일과 데스크톱 환경을 구분하여 반응형 디자인을 지원하는 접근 방식이 좋습니다.

</details>
<details>
<summary>src/components/carousel/index.js (1)</summary>

`1-1`: **컴포넌트 내보내기가 잘 구성되어 있습니다.**

Carousel 컴포넌트를 명확하게 내보내는 구조로 잘 작성되었습니다. 이는 다른 파일에서 컴포넌트를 쉽게 임포트할 수 있게 해줍니다.

</details>
<details>
<summary>src/constants/carousel/index.js (1)</summary>

`1-1`: **상수 내보내기가 잘 구성되어 있습니다.**

ITEM_DIMENSIONS 상수를 명확하게 내보내는 구조로 잘 작성되었습니다. 이를 통해 다른 컴포넌트에서 캐러셀 관련 상수를 쉽게 임포트하여 사용할 수 있습니다.

</details>
<details>
<summary>src/pages/list/ListPage.jsx (1)</summary>

`1-1`: **캐러셀 컴포넌트가 성공적으로 통합되었습니다.**

ListPage에 Carousel 컴포넌트를 적절하게 추가하고, 필요한 데이터와 모달 제어 함수를 전달하여 기능이 올바르게 작동할 수 있도록 구현되었습니다.



Also applies to: 44-44

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: CHANGES_REQUESTED
author_association: NONE
submitted_at: 2025-04-25T05:48:57Z
user: login: pappaya109
body: 고생하셨습니다! 남은 작업도 화이팅입니다~
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T06:17:34Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (1)</summary><blockquote>

<details>
<summary>src/constants/carousel/carouselConstants.js (1)</summary><blockquote>

`1-10`: **상수 정의가 깔끔하게 구성되었습니다.**

상수 구조가 논리적으로 잘 정리되어 있습니다. 하지만 몇 가지 개선점이 있습니다:

1. JSDoc 주석을 추가하여 상수의 목적과 단위(픽셀)를 명확히 설명하면 코드 가독성과 유지보수성이 향상될 것입니다.
2. 현재는 모바일과 데스크톱만 구분되어 있는데, 필요에 따라 태블릿 등 중간 크기의 브레이크포인트를 고려해보는 것도 좋을 것 같습니다.


다음과 같이 JSDoc 주석을 추가하여 문서화를 개선할 수 있습니다:

```diff
+/**
+ * 캐러셀 컴포넌트에 사용되는 상수들
+ * @constant
+ */
const CAROUSEL_CONSTANTS = {
+  /** 모바일 뷰포트 기준점 (픽셀) */
  MOBILE_BREAKPOINT: 768,
+  /** 캐러셀 아이템 크기 관련 상수 (픽셀) */
  ITEM_DIMENSIONS: {
+    /** 모바일에서 아이템 너비 */
    MOBILE_ITEM_WIDTH: 158,
+    /** 데스크톱에서 아이템 너비 */
    DESKTOP_ITEM_WIDTH: 282,
+    /** 아이템 간 간격 */
    ITEM_GAP: 12,
  },
};

export default CAROUSEL_CONSTANTS;
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: .coderabbit.yaml**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between fc75cb35e41170cee0ab38d76e85491e168ac474 and e73b5f42e80777d1e6c0de5f7f6b744b22bd9c79.

</details>

<details>
<summary>📒 Files selected for processing (3)</summary>

* `src/constants/carousel/carouselConstants.js` (1 hunks)
* `src/constants/carousel/index.js` (1 hunks)
* `src/utils/carousel/getItemMetrics.js` (1 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (1)</summary>

* src/utils/carousel/getItemMetrics.js

</details>

<details>
<summary>🔇 Additional comments (1)</summary><blockquote>

<details>
<summary>src/constants/carousel/index.js (1)</summary>

`1-1`: **바레엘 관련 상수의 중앙 집중식 내보내기가 잘 구현되었습니다.**

index.js 파일을 통한 재내보내기 패턴은 좋은 관행입니다. 추후 캐러셀 관련 상수가 더 추가되었을 때 가져오기(import)를 단순화할 수 있습니다.

현재는 상수가 하나뿐이지만, 확장성을 고려한 좋은 설계입니다.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-25T06:18:56Z
user: login: coderabbitai[bot]
state: APPROVED
author_association: NONE
submitted_at: 2025-04-25T06:23:39Z
user: login: sgoldenbird
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T06:28:38Z
user: login: yuj2n
body: 인성님 캐러셀 구현 수고 많으셨고 라이브러리 안 쓰시고 직접 구현하려고 노력하셔서 멋집니당~👍👍
나중에 어떻게 구현하셨는 지 자세한 설명을 들으며 캐러셀 공부를 해보고 싶네용 😋
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T06:50:18Z
user: login: Insung-Jo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T21:50:29Z
user: login: Insung-Jo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T21:54:49Z

Review comments:
diff_hunk: @@ -0,0 +1,13 @@
+import { ITEM_DIMENSIONS } from '@/constants/carousel';
+
+const getItemMetrics = () => {
+  const isMobile = window.innerWidth < ITEM_DIMENSIONS.MOBILE_ITEM_WIDTH;
+  const itemWidth = isMobile
+    ? ITEM_DIMENSIONS.MOBILE_ITEM_WIDTH
+    : ITEM_DIMENSIONS.DESKTOP_ITEM_WIDTH;
+  const gap = ITEM_DIMENSIONS.ITEM_GAP;
+
+  return { itemWidth, gap };
+};
path: src/utils/carousel/getItemMetrics.js
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**모바일 뷰 감지 로직이 부정확합니다.**

현재 코드는 `isMobile` 변수를 결정할 때 `window.innerWidth < ITEM_DIMENSIONS.MOBILE_ITEM_WIDTH`를 사용하고 있습니다. 이것은 창 너비가 모바일 아이템 너비보다 작은지 확인하는 것인데, 이는 뷰포트 크기에 따른 디바이스 타입을 결정하는 올바른 방법이 아닙니다.



다음과 같이 수정하는 것이 좋습니다:

```diff
-  const isMobile = window.innerWidth < ITEM_DIMENSIONS.MOBILE_ITEM_WIDTH;
+  const MOBILE_BREAKPOINT = 768; // 모바일 기기 기준점 (픽셀)
+  const isMobile = window.innerWidth < MOBILE_BREAKPOINT;
```

또한 서버 사이드 렌더링 환경에서는 `window` 객체가 없을 수 있으므로 다음과 같은 방어 코드를 추가하는 것이 좋습니다:

```diff
const getItemMetrics = () => {
+  // 서버 사이드 렌더링 환경에서는 기본값 반환
+  if (typeof window === 'undefined') {
+    return {
+      itemWidth: ITEM_DIMENSIONS.DESKTOP_ITEM_WIDTH,
+      gap: ITEM_DIMENSIONS.ITEM_GAP
+    };
+  }
+  
-  const isMobile = window.innerWidth < ITEM_DIMENSIONS.MOBILE_ITEM_WIDTH;
+  const MOBILE_BREAKPOINT = 768; // 모바일 기기 기준점 (픽셀)
+  const isMobile = window.innerWidth < MOBILE_BREAKPOINT;
  const itemWidth = isMobile
    ? ITEM_DIMENSIONS.MOBILE_ITEM_WIDTH
    : ITEM_DIMENSIONS.DESKTOP_ITEM_WIDTH;
  const gap = ITEM_DIMENSIONS.ITEM_GAP;

  return { itemWidth, gap };
};
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
const getItemMetrics = () => {
  // 서버 사이드 렌더링 환경에서는 기본값 반환
  if (typeof window === 'undefined') {
    return {
      itemWidth: ITEM_DIMENSIONS.DESKTOP_ITEM_WIDTH,
      gap: ITEM_DIMENSIONS.ITEM_GAP
    };
  }

  const MOBILE_BREAKPOINT = 768; // 모바일 기기 기준점 (픽셀)
  const isMobile = window.innerWidth < MOBILE_BREAKPOINT;
  const itemWidth = isMobile
    ? ITEM_DIMENSIONS.MOBILE_ITEM_WIDTH
    : ITEM_DIMENSIONS.DESKTOP_ITEM_WIDTH;
  const gap = ITEM_DIMENSIONS.ITEM_GAP;

  return { itemWidth, gap };
};
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commit e73b5f4
created_at: 2025-04-25T05:48:56Z
author_association: NONE
diff_hunk: @@ -1,45 +1,52 @@
-import MococoImg from '@/assets/icons/mococo-no-background.png';
+import creditImg from '@/assets/images/credit.png';
 import CustomButton from '@/components/customButton';
-import { mockData } from '@/data/mockData';
 import { addCommas, getDaysRemaining, getDonationPercentage } from '@/utils/format';
 import { useEffect, useState } from 'react';
 import * as S from './card.styles';
 
-const Card = () => {
+const Card = ({ data, setModalType }) => {
   const [isActive, setIsActive] = useState(false);
-  const test = mockData.donations[0];
-  const daysLeft = getDaysRemaining(test.deadline);
-  const percent = `${getDonationPercentage(test.targetDonation, test.receivedDonations)}%`;
+  const daysLeft = getDaysRemaining(data.deadline);
+  const percent = `${getDonationPercentage(data.targetDonation, data.receivedDonations)}%`;
   const isCompleted = daysLeft > 0;
 
   useEffect(() => {
     setIsActive(isCompleted);
   }, [isCompleted]);
 
+  const handleClick = () => {
+    setModalType('donation');
+  };
path: src/components/card/Card.jsx
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**setModalType 함수의 유효성 검사가 필요합니다.**

현재 `handleClick` 함수는 `setModalType`이 유효한 함수인지 확인하지 않고 호출하고 있습니다.



다음과 같이 개선할 수 있습니다:

```diff
const handleClick = () => {
-  setModalType('donation');
+  if (typeof setModalType === 'function') {
+    setModalType('donation');
+  }
};
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
  const handleClick = () => {
    if (typeof setModalType === 'function') {
      setModalType('donation');
    }
  };
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-25T05:48:56Z
author_association: NONE
diff_hunk: @@ -1,45 +1,52 @@
-import MococoImg from '@/assets/icons/mococo-no-background.png';
+import creditImg from '@/assets/images/credit.png';
 import CustomButton from '@/components/customButton';
-import { mockData } from '@/data/mockData';
 import { addCommas, getDaysRemaining, getDonationPercentage } from '@/utils/format';
 import { useEffect, useState } from 'react';
 import * as S from './card.styles';
 
-const Card = () => {
+const Card = ({ data, setModalType }) => {
   const [isActive, setIsActive] = useState(false);
-  const test = mockData.donations[0];
-  const daysLeft = getDaysRemaining(test.deadline);
-  const percent = `${getDonationPercentage(test.targetDonation, test.receivedDonations)}%`;
+  const daysLeft = getDaysRemaining(data.deadline);
+  const percent = `${getDonationPercentage(data.targetDonation, data.receivedDonations)}%`;
path: src/components/card/Card.jsx
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**props 유효성 검사 및 상태 관리 개선이 필요합니다.**

현재 코드는 `data` 및 `setModalType`의 유효성을 검사하지 않습니다. 또한 `isActive` 상태는 `isCompleted`에서 직접 파생될 수 있으므로 별도의 상태가 필요 없을 수 있습니다.



다음과 같이 개선할 수 있습니다:

```diff
-const Card = ({ data, setModalType }) => {
+const Card = ({ data = {}, setModalType = () => {} }) => {
  const [isActive, setIsActive] = useState(false);
-  const daysLeft = getDaysRemaining(data.deadline);
-  const percent = `${getDonationPercentage(data.targetDonation, data.receivedDonations)}%`;
+  const daysLeft = data.deadline ? getDaysRemaining(data.deadline) : 0;
+  const percent = data.targetDonation && data.receivedDonations 
+    ? `${getDonationPercentage(data.targetDonation, data.receivedDonations)}%` 
+    : '0%';
  const isCompleted = daysLeft > 0;
```

또는 상태 대신 유도된 값을 사용하는 방식으로 변경할 수 있습니다:

```diff
const Card = ({ data, setModalType }) => {
-  const [isActive, setIsActive] = useState(false);
  const daysLeft = getDaysRemaining(data.deadline);
  const percent = `${getDonationPercentage(data.targetDonation, data.receivedDonations)}%`;
  const isCompleted = daysLeft > 0;
+  const isActive = isCompleted;

-  useEffect(() => {
-    setIsActive(isCompleted);
-  }, [isCompleted]);
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
const Card = ({ data = {}, setModalType = () => {} }) => {
  const [isActive, setIsActive] = useState(false);
  const daysLeft = data.deadline ? getDaysRemaining(data.deadline) : 0;
  const percent = data.targetDonation && data.receivedDonations
    ? `${getDonationPercentage(data.targetDonation, data.receivedDonations)}%`
    : '0%';
  const isCompleted = daysLeft > 0;
`````

`````suggestion
const Card = ({ data, setModalType }) => {
  const daysLeft = getDaysRemaining(data.deadline);
  const percent = `${getDonationPercentage(data.targetDonation, data.receivedDonations)}%`;
  const isCompleted = daysLeft > 0;
  const isActive = isCompleted;
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-25T05:48:56Z
author_association: NONE
diff_hunk: @@ -0,0 +1,100 @@
+import media from '@/styles/responsive';
+import { css } from '@emotion/react';
+
+export const arrowIcon = css`
+  height: 1.4rem;
+  fill: none;
+  stroke: var(--white);
+  stroke-width: 0.3rem;
+`;
+
+export const wrapper = css`
+  width: 100%;
+  margin-block: 4rem;
+`;
+
+export const carouselTitle = css`
+  margin-inline: 2rem;
+  margin-bottom: 1.6rem;
+  font-size: 2.4rem;
+  font-weight: 700;
+  color: var(--white);
+
+  ${media({
+    fontSize: ['2rem', '2rem', '2.4rem', '2.4rem'],
+    marginInline: ['2rem', '2rem', '8rem', '8rem'],
+    marginBottom: ['1.6rem', '1.6rem', '2.4rem', '3.2rem'],
+  })}
+`;
+
+export const viewportArea = css`
+  display: flex;
+  align-items: center;
+  gap: 0;
+  padding-inline: 2rem;
+
+  ${media({
+    gap: ['0', '0', '2rem', '2rem'],
+  })}
+`;
+
+export const carouselContainer = css`
+  overflow-x: scroll;
+  width: 100%;
+  scroll-snap-type: x mandatory;
+  scroll-behavior: smooth;
+
+  &::-webkit-scrollbar {
+    display: none;
+  }
+`;
+
+export const carouselTrack = css`
+  display: flex;
+  gap: 1.2rem;
+  transition: transform 0.3s ease-in-out;
+
+  ${media({
+    transform: [
+      'none',
+      'none',
+      'translateX(var(--slide-offset))',
+      'translateX(var(--slide-offset))',
+    ],
+  })}
+`;
+
+export const carouselItem = css`
+  flex-basis: 15.8rem;
+  min-width: 15.8rem;
+
+  ${media({
+    flexBasis: ['15.8rem', '15.8rem', '28.2rem', '28.2rem'],
+    minWidth: ['15.8rem', '15.8rem', '28.2rem', '28.2rem'],
+  })}
path: src/components/carousel/carousel.styles.js
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**하드코딩된 크기 값을 상수로 대체해야 합니다.**

`carouselItem` 스타일에 있는 너비 값들(`15.8rem`, `28.2rem`)이 하드코딩되어 있습니다. 이것은 `ITEM_DIMENSIONS` 상수와 동기화되지 않을 수 있습니다.



상수를 CSS 변수나 JavaScript 상수로 정의하고 재사용하여 일관성을 유지하는 것이 좋습니다:

```diff
+import { ITEM_DIMENSIONS } from '@/constants/carousel';

+// px을 rem으로 변환 (1rem = 10px로 가정)
+const MOBILE_ITEM_WIDTH_REM = ITEM_DIMENSIONS.MOBILE_ITEM_WIDTH / 10 + 'rem';
+const DESKTOP_ITEM_WIDTH_REM = ITEM_DIMENSIONS.DESKTOP_ITEM_WIDTH / 10 + 'rem';

export const carouselItem = css`
-  flex-basis: 15.8rem;
-  min-width: 15.8rem;
+  flex-basis: ${MOBILE_ITEM_WIDTH_REM};
+  min-width: ${MOBILE_ITEM_WIDTH_REM};

  ${media({
-    flexBasis: ['15.8rem', '15.8rem', '28.2rem', '28.2rem'],
-    minWidth: ['15.8rem', '15.8rem', '28.2rem', '28.2rem'],
+    flexBasis: [MOBILE_ITEM_WIDTH_REM, MOBILE_ITEM_WIDTH_REM, DESKTOP_ITEM_WIDTH_REM, DESKTOP_ITEM_WIDTH_REM],
+    minWidth: [MOBILE_ITEM_WIDTH_REM, MOBILE_ITEM_WIDTH_REM, DESKTOP_ITEM_WIDTH_REM, DESKTOP_ITEM_WIDTH_REM],
  })}
`;
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
import { ITEM_DIMENSIONS } from '@/constants/carousel';

// px을 rem으로 변환 (1rem = 10px로 가정)
const MOBILE_ITEM_WIDTH_REM = ITEM_DIMENSIONS.MOBILE_ITEM_WIDTH / 10 + 'rem';
const DESKTOP_ITEM_WIDTH_REM = ITEM_DIMENSIONS.DESKTOP_ITEM_WIDTH / 10 + 'rem';

export const carouselItem = css`
  flex-basis: ${MOBILE_ITEM_WIDTH_REM};
  min-width: ${MOBILE_ITEM_WIDTH_REM};

  ${media({
    flexBasis: [
      MOBILE_ITEM_WIDTH_REM,
      MOBILE_ITEM_WIDTH_REM,
      DESKTOP_ITEM_WIDTH_REM,
      DESKTOP_ITEM_WIDTH_REM,
    ],
    minWidth: [
      MOBILE_ITEM_WIDTH_REM,
      MOBILE_ITEM_WIDTH_REM,
      DESKTOP_ITEM_WIDTH_REM,
      DESKTOP_ITEM_WIDTH_REM,
    ],
  })}
`;
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-25T05:48:56Z
author_association: NONE
diff_hunk: @@ -0,0 +1,101 @@
+import { Card } from '@/components/card';
+import { getItemMetrics } from '@/utils/carousel';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import * as S from './carousel.styles';
+
+const Carousel = ({ data, setModalType }) => {
+  const [currentIndex, setCurrentIndex] = useState(0);
+  const [itemsPerView, setItemsPerView] = useState(4);
+  const containerRef = useRef(null);
+
+  // data.list의 길이를 사용
+  const itemsLength = data?.list?.length || 0;
+
+  const updateItemsPerView = useCallback(() => {
+    if (!containerRef.current) return;
+
+    const viewportWidth = containerRef.current.offsetWidth;
+    const { itemWidth, gap } = getItemMetrics();
+    const totalItemWidth = itemWidth + gap;
+    const calculatedItemsPerView = Math.floor(viewportWidth / totalItemWidth);
+
+    setItemsPerView(calculatedItemsPerView);
+    const newMaxIndex = Math.max(0, itemsLength - calculatedItemsPerView);
+    setCurrentIndex((prev) => Math.min(prev, newMaxIndex));
+  }, [itemsLength]);
+
+  useEffect(() => {
+    updateItemsPerView();
+    window.addEventListener('resize', updateItemsPerView);
+    return () => window.removeEventListener('resize', updateItemsPerView);
+  }, [updateItemsPerView]);
+
+  const maxIndex = Math.max(0, itemsLength - itemsPerView);
+
+  const handleNext = () => {
+    setCurrentIndex((prev) => Math.min(prev + 1, maxIndex));
+  };
+
+  const handlePrev = () => {
+    setCurrentIndex((prev) => Math.max(prev - 1, 0));
+  };
+
+  const getSlideOffset = () => {
+    const { itemWidth, gap } = getItemMetrics();
+    return currentIndex * (itemWidth + gap);
+  };
+
+  return (
+    <div css={S.wrapper}>
+      <h2 css={S.carouselTitle}>후원을 기다리는 조공</h2>
+      <div css={S.viewportArea}>
+        <button
+          type="button"
+          onClick={handlePrev}
+          css={S.navigationButton}
+          disabled={currentIndex === 0}
+          aria-label="이전"
+        >
+          <svg css={S.arrowIcon} viewBox="0 0 8 14" xmlns="http://www.w3.org/2000/svg">
+            <title>이전</title>
+            <path d="M7 1L1 7L7 13" strokeLinecap="round" strokeLinejoin="round" />
+          </svg>
+        </button>
+
+        <div ref={containerRef} css={S.carouselContainer}>
+          <div
+            css={S.carouselTrack}
+            style={{
+              '--slide-offset': `-${getSlideOffset()}px`,
+            }}
+          >
+            {data?.list?.length > 0 ? (
+              data.list.map((item) => (
+                <div key={item.id} css={S.carouselItem}>
+                  <Card data={item} setModalType={setModalType} />
+                </div>
+              ))
+            ) : (
+              <div>표시할 항목이 없습니다</div>
+            )}
path: src/components/carousel/Carousel.jsx
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**데이터 체크 및 오류 처리 개선이 필요합니다.**

현재 코드는 데이터가 없을 때 단순한 메시지만 표시합니다. 데이터 구조 확인과 오류 상태를 더 명확하게 처리할 필요가 있습니다.



다음과 같이 개선할 수 있습니다:

```diff
-{data?.list?.length > 0 ? (
-  data.list.map((item) => (
-    <div key={item.id} css={S.carouselItem}>
-      <Card data={item} setModalType={setModalType} />
-    </div>
-  ))
-) : (
-  <div>표시할 항목이 없습니다</div>
-)}
+{(() => {
+  // 데이터 로딩 또는 오류 상태 처리
+  if (!data) {
+    return <div css={S.emptyState}>데이터를 불러오는 중입니다...</div>;
+  }
+  
+  if (!Array.isArray(data.list)) {
+    return <div css={S.emptyState}>데이터 형식이 올바르지 않습니다</div>;
+  }
+  
+  if (data.list.length === 0) {
+    return <div css={S.emptyState}>표시할 항목이 없습니다</div>;
+  }
+  
+  // 정상 데이터 렌더링
+  return data.list.map((item) => (
+    <div key={item.id || index} css={S.carouselItem}>
+      <Card data={item} setModalType={setModalType} />
+    </div>
+  ));
+})()}
```

추가로 `carousel.styles.js`에 `emptyState` 스타일을 정의해야 합니다:

```jsx
export const emptyState = css`
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  min-height: 200px;
  color: var(--white);
  font-size: 1.6rem;
  background-color: var(--gray-900, rgba(0, 0, 0, 0.1));
  border-radius: 8px;
`;
```

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-25T05:48:56Z
author_association: NONE
diff_hunk: @@ -0,0 +1,101 @@
+import { Card } from '@/components/card';
+import { getItemMetrics } from '@/utils/carousel';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import * as S from './carousel.styles';
+
+const Carousel = ({ data, setModalType }) => {
+  const [currentIndex, setCurrentIndex] = useState(0);
+  const [itemsPerView, setItemsPerView] = useState(4);
+  const containerRef = useRef(null);
+
+  // data.list의 길이를 사용
+  const itemsLength = data?.list?.length || 0;
path: src/components/carousel/Carousel.jsx
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**주석과 데이터 구조 확인 로직 개선이 필요합니다.**

현재 주석은 불필요하며, 데이터 구조를 확인하는 로직이 여러 곳에서 중복되어 있습니다.



다음과 같이 개선할 수 있습니다:

```diff
-// data.list의 길이를 사용
-const itemsLength = data?.list?.length || 0;
+// 데이터가 올바른 형식인지 확인
+const hasValidData = data && Array.isArray(data.list);
+const itemsLength = hasValidData ? data.list.length : 0;
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
  // 데이터가 올바른 형식인지 확인
  const hasValidData = data && Array.isArray(data.list);
  const itemsLength = hasValidData ? data.list.length : 0;
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-25T05:48:56Z
author_association: NONE
diff_hunk: @@ -0,0 +1,101 @@
+import { Card } from '@/components/card';
+import { getItemMetrics } from '@/utils/carousel';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import * as S from './carousel.styles';
+
+const Carousel = ({ data, setModalType }) => {
+  const [currentIndex, setCurrentIndex] = useState(0);
+  const [itemsPerView, setItemsPerView] = useState(4);
+  const containerRef = useRef(null);
+
+  // data.list의 길이를 사용
+  const itemsLength = data?.list?.length || 0;
+
+  const updateItemsPerView = useCallback(() => {
+    if (!containerRef.current) return;
+
+    const viewportWidth = containerRef.current.offsetWidth;
+    const { itemWidth, gap } = getItemMetrics();
+    const totalItemWidth = itemWidth + gap;
+    const calculatedItemsPerView = Math.floor(viewportWidth / totalItemWidth);
+
+    setItemsPerView(calculatedItemsPerView);
+    const newMaxIndex = Math.max(0, itemsLength - calculatedItemsPerView);
+    setCurrentIndex((prev) => Math.min(prev, newMaxIndex));
+  }, [itemsLength]);
path: src/components/carousel/Carousel.jsx
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**성능 및 예외 처리 개선이 필요합니다.**

`updateItemsPerView` 함수는 복잡한 계산을 수행하지만 몇 가지 개선이 필요합니다:
1. `containerRef`가 없을 때 함수가 조기 종료됩니다.
2. `getItemMetrics` 함수 호출 시 예외 처리가 없습니다.



다음과 같이 개선할 수 있습니다:

```diff
const updateItemsPerView = useCallback(() => {
  if (!containerRef.current) return;

  const viewportWidth = containerRef.current.offsetWidth;
-  const { itemWidth, gap } = getItemMetrics();
+  let itemWidth, gap;
+  try {
+    const metrics = getItemMetrics();
+    itemWidth = metrics.itemWidth;
+    gap = metrics.gap;
+  } catch (error) {
+    console.error('캐러셀 측정값 계산 중 오류 발생:', error);
+    // 기본값 설정
+    itemWidth = 282; // DESKTOP_ITEM_WIDTH
+    gap = 12; // ITEM_GAP
+  }
  const totalItemWidth = itemWidth + gap;
-  const calculatedItemsPerView = Math.floor(viewportWidth / totalItemWidth);
+  // 최소 1개 이상의 아이템이 보이도록 보장
+  const calculatedItemsPerView = Math.max(1, Math.floor(viewportWidth / totalItemWidth));

  setItemsPerView(calculatedItemsPerView);
  const newMaxIndex = Math.max(0, itemsLength - calculatedItemsPerView);
  setCurrentIndex((prev) => Math.min(prev, newMaxIndex));
}, [itemsLength]);
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
  const updateItemsPerView = useCallback(() => {
    if (!containerRef.current) return;

    const viewportWidth = containerRef.current.offsetWidth;
    let itemWidth, gap;
    try {
      const metrics = getItemMetrics();
      itemWidth = metrics.itemWidth;
      gap = metrics.gap;
    } catch (error) {
      console.error('캐러셀 측정값 계산 중 오류 발생:', error);
      // 기본값 설정
      itemWidth = 282; // DESKTOP_ITEM_WIDTH
      gap = 12;  // ITEM_GAP
    }
    const totalItemWidth = itemWidth + gap;
    // 최소 1개 이상의 아이템이 보이도록 보장
    const calculatedItemsPerView = Math.max(1, Math.floor(viewportWidth / totalItemWidth));

    setItemsPerView(calculatedItemsPerView);
    const newMaxIndex = Math.max(0, itemsLength - calculatedItemsPerView);
    setCurrentIndex((prev) => Math.min(prev, newMaxIndex));
  }, [itemsLength]);
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-25T05:48:56Z
author_association: NONE
diff_hunk: @@ -1,45 +1,52 @@
-import MococoImg from '@/assets/icons/mococo-no-background.png';
+import creditImg from '@/assets/images/credit.png';
 import CustomButton from '@/components/customButton';
-import { mockData } from '@/data/mockData';
 import { addCommas, getDaysRemaining, getDonationPercentage } from '@/utils/format';
 import { useEffect, useState } from 'react';
 import * as S from './card.styles';
 
-const Card = () => {
+const Card = ({ data, setModalType }) => {
path: src/components/card/Card.jsx
user: login: yuj2n
body: useLoaderData 함수로 라우터에서 불러온 데이터를 data prop으로 넘겨주시고 
setModalType prop을 받아서 모달 타입에 따라서 후원하기 모달창이 보이게 해주셨군요!
created_at: 2025-04-25T06:44:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,100 @@
+import media from '@/styles/responsive';
+import { css } from '@emotion/react';
+
+export const arrowIcon = css`
+  height: 1.4rem;
+  fill: none;
+  stroke: var(--white);
+  stroke-width: 0.3rem;
+`;
+
+export const wrapper = css`
+  width: 100%;
+  margin-block: 4rem;
+`;
+
+export const carouselTitle = css`
+  margin-inline: 2rem;
path: src/components/carousel/carousel.styles.js
user: login: yuj2n
body: `margin-block`, `margin-inline`이라는 속성은 처음보네용!
created_at: 2025-04-25T06:48:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,100 @@
+import media from '@/styles/responsive';
+import { css } from '@emotion/react';
+
+export const arrowIcon = css`
+  height: 1.4rem;
+  fill: none;
+  stroke: var(--white);
+  stroke-width: 0.3rem;
+`;
+
+export const wrapper = css`
+  width: 100%;
+  margin-block: 4rem;
+`;
+
+export const carouselTitle = css`
+  margin-inline: 2rem;
+  margin-bottom: 1.6rem;
+  font-size: 2.4rem;
+  font-weight: 700;
+  color: var(--white);
+
+  ${media({
+    fontSize: ['2rem', '2rem', '2.4rem', '2.4rem'],
+    marginInline: ['2rem', '2rem', '8rem', '8rem'],
+    marginBottom: ['1.6rem', '1.6rem', '2.4rem', '3.2rem'],
+  })}
+`;
+
+export const viewportArea = css`
+  display: flex;
+  align-items: center;
+  gap: 0;
+  padding-inline: 2rem;
+
+  ${media({
+    gap: ['0', '0', '2rem', '2rem'],
+  })}
+`;
+
+export const carouselContainer = css`
+  overflow-x: scroll;
+  width: 100%;
+  scroll-snap-type: x mandatory;
+  scroll-behavior: smooth;
path: src/components/carousel/carousel.styles.js
user: login: yuj2n
body: 처음 보는 속성입니다..😲
created_at: 2025-04-25T06:48:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,100 @@
+import media from '@/styles/responsive';
+import { css } from '@emotion/react';
+
+export const arrowIcon = css`
+  height: 1.4rem;
+  fill: none;
+  stroke: var(--white);
+  stroke-width: 0.3rem;
+`;
+
+export const wrapper = css`
+  width: 100%;
+  margin-block: 4rem;
+`;
+
+export const carouselTitle = css`
+  margin-inline: 2rem;
path: src/components/carousel/carousel.styles.js
user: login: Insung-Jo
body: 상하, 좌우 이렇게 묶여있는 거라 편하게 사용할 수 있습니다!
created_at: 2025-04-25T21:50:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,100 @@
+import media from '@/styles/responsive';
+import { css } from '@emotion/react';
+
+export const arrowIcon = css`
+  height: 1.4rem;
+  fill: none;
+  stroke: var(--white);
+  stroke-width: 0.3rem;
+`;
+
+export const wrapper = css`
+  width: 100%;
+  margin-block: 4rem;
+`;
+
+export const carouselTitle = css`
+  margin-inline: 2rem;
+  margin-bottom: 1.6rem;
+  font-size: 2.4rem;
+  font-weight: 700;
+  color: var(--white);
+
+  ${media({
+    fontSize: ['2rem', '2rem', '2.4rem', '2.4rem'],
+    marginInline: ['2rem', '2rem', '8rem', '8rem'],
+    marginBottom: ['1.6rem', '1.6rem', '2.4rem', '3.2rem'],
+  })}
+`;
+
+export const viewportArea = css`
+  display: flex;
+  align-items: center;
+  gap: 0;
+  padding-inline: 2rem;
+
+  ${media({
+    gap: ['0', '0', '2rem', '2rem'],
+  })}
+`;
+
+export const carouselContainer = css`
+  overflow-x: scroll;
+  width: 100%;
+  scroll-snap-type: x mandatory;
+  scroll-behavior: smooth;
path: src/components/carousel/carousel.styles.js
user: login: Insung-Jo
body: `scroll-snap-type: x mandatory;`은 수평 방향으로 스크롤이 끝날 때 요소에 딱 맞춰지는 속성 입니다!
`scroll-behavior: smooth;`은 스크롤을 부드럽게 이동 시킬 수 있는 속성 입니다.
[MDN - scroll-snap-type ](https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-snap-type)
[MDN - scroll-behavior](https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-behavior)
created_at: 2025-04-25T21:54:49Z
author_association: CONTRIBUTOR

PR comments:
user: login: coderabbitai[bot]
created_at: 2025-04-25T05:44:04Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

<details>
<summary>📝 Walkthrough</summary>

## Walkthrough

이번 변경사항에서는 캐러셀(Carousel) 컴포넌트와 관련된 여러 파일이 새롭게 추가 및 수정되었습니다. Card 컴포넌트는 mock 데이터 대신 외부에서 data와 setModalType을 props로 받아서 동적으로 렌더링하도록 리팩토링되었습니다. 새로운 Carousel 컴포넌트가 도입되어 리스트 페이지에서 데이터를 받아 가로로 스크롤 가능한 카드 목록을 보여줍니다. 캐러셀의 스타일, 아이템 크기 상수, 유틸리티 함수 등이 추가되었고, 관련된 index 파일에서 각각의 모듈을 재수출하도록 설정되었습니다. ListPage에서는 기존의 캐러셀 placeholder가 실제 Carousel 컴포넌트로 대체되었습니다.

## Changes

| 파일/경로                                                         | 변경 요약                                                                                       |
|-------------------------------------------------------------------|-------------------------------------------------------------------------------------------------|
| src/components/card/Card.jsx                                      | Card 컴포넌트가 mock 데이터 대신 props(data, setModalType)로 동작하도록 리팩토링.                |
| src/components/carousel/Carousel.jsx<br>src/components/carousel/index.js | Carousel 컴포넌트 및 해당 index 파일 추가. 데이터 기반 가로 스크롤 카드 목록 구현 및 export.      |
| src/components/carousel/carousel.styles.js                        | 캐러셀 컴포넌트 전용 스타일 정의. 반응형, 네비게이션 버튼, 트랙 등 스타일링 추가.                |
| src/constants/carousel/carouselConstants.js<br>src/constants/carousel/index.js | 캐러셀 아이템 크기 및 간격 상수 정의 및 export.                                                 |
| src/utils/carousel/getItemMetrics.js<br>src/utils/carousel/index.js| 뷰포트 크기에 따라 아이템 크기와 간격을 계산하는 유틸리티 함수 추가 및 export.                  |
| src/pages/list/ListPage.jsx                                       | 캐러셀 placeholder를 Carousel 컴포넌트로 교체, 데이터 및 setModalType props 전달.                |

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    participant ListPage
    participant Carousel
    participant Card

    ListPage->>Carousel: data, setModalType 전달
    Carousel->>Card: 각 item의 data, setModalType 전달
    Card->>Card: 버튼 클릭 시 setModalType('donation') 호출
```

## Poem

> 🐰  
> 캐러셀이 빙글빙글, 카드가 줄지어,  
> 데이터 따라 춤추는 봄바람 속으로.  
> 클릭 한 번에 모달이 뿅!  
> 토끼는 기뻐서 귀를 쫑긋,  
> 코드 속 봄 소풍, 오늘도 출발!  
> 🌸🛤️

</details>

<!-- walkthrough_end -->


---

<details>
<summary>📜 Recent review details</summary>

**Configuration used: .coderabbit.yaml**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between e73b5f42e80777d1e6c0de5f7f6b744b22bd9c79 and fb3ae208c1ed9d3fefbb853f06d092b017fb1c75.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `src/pages/list/ListPage.jsx` (2 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (1)</summary>

* src/pages/list/ListPage.jsx

</details>

</details>
<!-- internal state start -->


<!-- DwQgtGAEAqAWCWBnSTIEMB26CuAXA9mAOYCmGJATmriQCaQDG+Ats2bgFyQAOFk+AIwBWJBrngA3EsgEBPRvlqU0AgfFwA6NPEgQAfACgjoCEYDEZyAAUASpETZWaCrKNxU3bABsvkCiQBHbGlcABpIcVwvOkgAIkAKcgAxEmouQAS5wBv2wAFByEAbWsAQNdj0DHoHAWZ1Gno5SGxESkgASQwHDCIwACl8dGRbSAxHAQaARgAOADZwtFpaf0R65CQHEkgzYYBmACYNGFgV5m0sADN8Bjr+I4i96ztUXGv4Zm5otgxcanh8LHxLtEZnfB1Ei+JhPL7sSAAd3UsD80m4X0QkhWSiRRCwmHo8DeyjEyMgR2wGDxXzQXnUsg0RgA0iR5AxYJhSItiV5sEoOAYoAAREhSLz4bivXAXK4rARoJEMSAAVUaBPwfHuKwYAKBXipUEaTxe7A+X1FcwRLXxQ0ZEk+Su6ZAc/lqiBU8HJuHkaAYFHw80gtHgRyOlAhSgtDBWSIAXtJNZBEkSSRgyQSUrhsHN7NgGT1+mgLUR9VgLX8BHgCFgGOSGABrZCY+zQ7grZnJ1MG5iCJ0ovnwEOIKkGOAqr64CjwIsEPhMDBHeAUNj0e7UMVQxUV9Dk9HIaH3Rf+LzUGJLYLFegnHz4SExZWMPaV8mIEUESA2lMrXiChoHlb+IhIId58K7oZyTacIBCoYlYSYCh/DEch5nCJR3nbegSAAD2eTA82QE4+H8C0SHPChEHCe5FUBIhYRoO9sSIKZaD2fxiRWB8mB9NoFAwKQ3k+FophKHgPXrPhaFORw9XEA1N1hDoAGVuVOKF6JWNBuGeLsVGiXs5SvJlpHQO0HBUxUqkgGpqHeBl9wOZlvRIVsWl/cQ2MvVUPXVAByasVJSMCQyPEySHNS1e37dihxHPBFX6fBjPne9rhY/Z4HIkUhgUHVUO9Q8H0vIFRQAQXlAh8C8LDIqobh4HoINgUFYU/P8IIQhiIklEI94SmouFcwoViiB4aL2HgBNcPgfDIHJCp3nElodjymZ1G4skvFkcJWx9KdVWm5BsuuW9cCsNBSDS40IQI/ZpiY7psRoIgqBoRcXMBeovHCSEEAzA56TQXK2AoI6JyOctcG2xkRRG/DKGQRBYEBLxqiUyFnBWH5ewAOW6RB6wYX0u29JAznmbj+uu5BItfAQXg3AM/G0ep4dkL45z2eooso3sLEgABhFhhShxwDhcNxri+Za0rqg17g8bxfAa4I7xQLCPWYRcgT4WJECIYqlAwNQetCIo3utNC5jp9AVOgobppM+QHVkTrYkADCHAAOawANVcAC0HAFahwAJpsAE6aAD8igACjersKLAxBd0oiJuliABxfBFEgIRBCKSLYgAdWBXxhPIWIAEodn7O0zqigkU2VccefYIjIEaqjEXCSKfUQAmkUROFIKUegJYQXobF7fRjHAKAyG734IuIMhlGM0FhS4Xh+GEURxCkGR6UUZRVHULQdEHkwoHcRZqwxCfSHIO6Yln9guCoSE0ycFxrYUVqVDUTRtF0MBDCH0wDEQCgGAAHpQQnTeIgYBzhaCAM5pAjQQhEAoU5LEZBBgOYFUnufPcpR+bOHkD8bSbRpBCxWDAnqx1wRvChJKOERw3RjgvN0N0IZuAigAAa0GoGgVhflWH1FwAAWUUGSaAsh6zcOoa+bgLI7wpDHvaTqjIeoJXoK2Ss3pOGzR8DQgMjFtrdEvKolcOc8zqPeFCam/h0IhnoJJb0sh4wVGlEw6QejFzcElKbdhnDuGCBEGIcI2Iyzsk6koaYQESDwVJFbNArYiTAwCcJXwr4pzRHCGUSIqS/IZJIIXB4YIKDGUeIdJS8wSAikRsgBkOljxK3QJAVRpwroTmjnU90dB1AoAnK9d6sJUAYDPPafcWBLzGOibEyhzd0KUkgHlfoY0yxdhXIyEo0Q+CsOWbQaInNywVm4RU9AMwGGLlHCWbpod/g+GQLwspgiOFeBEfWQOblRncTcnnPZexhnXBOQaVACzKx0FyeKYsXwPKQFYV8bZizuGSKodWQ59AmGKl6stIFJkQVYAAsCPyzd1IxDvHuOEBxsTsR9NNBMEk9gV0XC8g0glPhYmrHiKQaKyT3lQiKbCi5KrFRQFZFY+zsDcA4TFbouVLw8q8GChxKJ7ExLUj4aZAB5KQVAfDEXiiwUB5TqETlVcZI4tSCXiGlIY0xfw6jFJaX8fwlqKYRPNWAWYyJSxaooeUmExQUA4ioMy4FuASwRGHEQUgw42J/DWgmF09YCGkF7OYSweUvA0DutxVxIzRC7lTe3fBqEEQFJiGTbAFNcaDXEEQrkXM3XkEoWieMTYVhCpFXQLgrCJzy1IfQAAvJAQOedIBdr0NwwASYTgvbSKTtA7e0AG9zVpJuUI+5oiVgAF9+2Du4SS3hADgHVurhAnq0DYHwJQqwgwyDYhGAgGAIw/8gEgPdeAx66oj2uWenAhBSCUFoMaBg6e+KcGP3wVUwhiAjCzPIHfGwKQxBl2JOSkEe7KGsNIU9YEm6WRDkUOmC8oM4QlEhnUmGw4wyDiWjbd0xUALRH+G+7FwHIE9iaCKZxLCrkiq4aFQ4nU/h7VFOoWyx9EXgr4bc4Ry7uGqh8BKNREiBJMZCg+mtIpiVvEOJhyg8ZfDGpWFy5yKYGIigtEie13qlAoV7W2gz7BmjmdYf2msl4BjMCGHwfBAnmDIGMyOGjglIB4TvoHVhHnEBWEoAANVGpCezOxGgiiBNWcF/gjjcIfGwSUz4HpkexA0aEtAtw1loHKxx5GLlnEjrpGGd8DgYHkCFgkHSSWXgC/mlKkpC1YA81CSqBW+K5m4PUspw4GCkwEIhcgNSWB1LwE6CkcG4zgtILgOLtl+FDa7IgVhwVNVgmU/YMpyAhV1LmPACMj5OIij2lPK0HcyTlcJU5wYDR8HedM/Vms0whB1DiiqazlDsTmfQAwTu1FRZvTIIuaEJQBnhhVMs5kOxUbZiSiYn5LRVwCmq/gC0Tl4pqmeuNEgRwRRlSSrAFKeDyAoBoMwV6nrcX2tKFNXS4OMQ/fsO8Ap/A+Cj1FJePaaLn0E9/JWMFxHTtkd8D8I4fCFahQ9D4GIBZvXhI57IaIh2kRsU5tJaSjBvtTckZQct6bw4VcXK9mjIXeyNEuCMzh40fxy9siwladTaHSbdCuNg8wrWoEmbuWQgLIAAFE3S9Jp96xcfHUAMVakMpESg6kocgZJpDYQeAeJCWeL5Kwuscb8pG7TZSU2C/T4MqGLpoj0CU9XPlrWYjUNYdJbhXK7zq86hIZwnxzjAlsnXwJbJepQnKoJECGKa41i7IiXsKrlDqqy7tiEr4LSojhJjREyIpjA5cT5lU+PsXxhzCY2xRWHG4y6+GHjfFECtmirCCOlVOq/hNFteNqDE3JunmmlpGayzOEwlFDzSMkLT4E8BLWlDLVGjA0rVRjGmgzoXIT22AILVoFbVQ3VE3SwG3XvXTyfQPy8FfTQw1BPT2U9UkSuVnQ43nQEUXQeVXS23PRQS5F/jvV3SXzAQPWIK4PVA0Hbw1w/S/UvR/T/QvmwQfjwUuBA2ZHAzmTvn4OkD2DKXqWwxoy5T+CF2xVr3+0ww9FoBw3oDqE6hD1bCth12kjAGxE6GklixFCUCnFgnqW8HEGeFDCrxiHMJMgFErFKj4C72HCelo2IMfF1DAW9SCWH2cA9Dvmnx4hH2Uj8wnHG0oGInUEyRayMj0hSHCE0MQzUxywoGIl9QrACQ6lX2wATBC14noCPxRytjRwU2uAUJZEgnzUJSNE3ykBsjrSuFcnIlaQN2VlYVnCGm4Rm2dDd0+2+06iNwKWgMdwrF00HHsFOwdQFm/DiP6xrkBgyglCREnz4hf0QGwk8yBw9C9HWn9EMxMn8DQArARBJkFwIK4wKMfHjHtWQHF1IzU200ox8E6lsVvwTi3BbgV18DNGR0ihrAQDX0vFBKowlDcywEzgEGpA6VAjPHqEIieLo18BFxXASwJGiAs0D0BA9QKyDUwCRCtglnDhOIVD4EfyT0wCKTf0gCR2P3qInyyLV2r26y3CnBQhiB9FeDbjiJkwrFukBD4iYAFEKL8GlK7gUAoHPgOO7ncWxhdEpNfzTVKIiJCSQDxURT4hhlVQ5z3EaM/HhE6M/G8F0memXmRFFmhgGUihhJWFqNzC5IDXbg+1oC+3llJLwHsA1M6i5UuJ0RFFBJIAh1hx7CMATRmS/2zXRx2g7H/xTNJkuBQOMiLQgLeMiGgKvRmQRUfDQhAPoE8IUPYgJTAVbSiLPEaAnFYXCFYUhFH0oBbLHQIOgDSJIC7NYQyIKTyluIHNyO5nyPPjHJ7OKOnNxJW2YAHM9LzAACEMUsCRMd1tDgYeDnpdzgQ+D3Cex4FGCL0r1WCty8D9zCCAdUJBCmDhDE1f0z5/1xCBZJDY0K0IMxoczzSadTpqFpglSmtrgcD2DtV8DcTAFbyUIP1W92xclUAdM6p/AwAcyQYOxaFnCyzWsCRalLwtsiDMCVD9CaMWoGgnMYkVgU9cStt2Tuh78GgBRvwGBwhKAPQ+ANkgIaJud5deVAYBlpCWdqZro9CDD380FkzAC0ybIMzADc1yzUDeLwDywCyFoK0oAQ9FKWMEVW1fyqCidvpk1MwMCCcV0NywLtzILuCYK4KHzzyb0/5Lz7JMAdzcjryJzazgZ7yzyRCXyxD753zRQhKYDvy75x1XLF4/ERQZV6AUM8obAlUZRpIQ8AAZAAfU5iVVRmkmgDylRmgBbzl1Euw2sSjwIrYIis4PcvHMRHam8pPIaw0l2FQF8WXheNTMhG6FU34jfDmOkFbX4SVRXMaFSpD3SpXJsBDzympCsCVUaAKq7NlwfAAHZxhRgzkMxyRFjRZ7CcsML7A2kIc8stxQIUh7j8BroGSVC1AaMgwuxpBqjwVGhoAQ9+F0ruRGh+EQ8crGhsqW9eIor2rYpwih9dJ7h/APTRJhterBITdBrhrRrxqXq3r0r05GhuRoAAAJbhZa7oYYAAVg2vBW5BD2kmpGgCVSsHSpRvevRsxpxv23vG6E2FGE2CetYVpvStjjyisFxuUIfGGG2F2GkAHBcrCNlOiD9VFhX0ql03gCFKqkeBtCJi7zZF0hrDOruIeP+0nEVAOCti5VyOp1shWLDGv1KFDLYhbDbBo0K2kArAIAG3QlwBOMtPBU5gSqSpSoyqypyryoKqKratg1QBzMbwOvsKMpFF/MNSmwMVUJyUTKTRTWkv0WuCUDkq2iAO0tAJ4GLVUqgI0pLKVMjuwpzNbXHS5i9uSrSsyv+oDsKrIK3FmJN3BSGpGrGomqmpmrmoWugHET4lgmMk5teves+u+t+v+u4UDiSO4zYlYXbqRpptHrRoxuxoHNJvJspupq5vpvXo5q5p5r5v7S3UqrqtcuspfVqvFoas23spYMcrPpvsvr3NsvgSEI/xmWfOuywUCtwWCvhy/LkJwsyOQoAvhWAtz03NwOfuvOgoI1gsapSRyRasrz3BQpIDQu0ojsMrLu0rwrjtAo0A8vPrAW4TWjtNqAIyVGuBlQ9urp9rrv9vysbsRwYupWYtxibkzQAKzpLhKv0LKsijWhxkBSTqkqzpkozqzXkuzJzu7jAPztLS4hN2LK0twqApbXocSprt9vrpYaKtQrDsRWQFLuMtjqGOIevq8s2yj0stIbcoIPgfMzsrPIftvR3XcWZEAT2kAVSp/AOjjRPQ/r8p/uMgcAkIAZ0hgJChouILTw4JFEeFwv2U0cmyGIAAFwLH1ry6KQpeBOwnpdqiccs4rcjuEqz3C/J1BNcrVAKeBdwQwYY4YGhWFgAfQJAh1+SKJrgaAUIRRYhMgsgDYLFbJsdg9mgZFpgNUXxGmAotZctqFLE5nqh5BLw6EKjfA2nTLgQuntzyrrgZIAANTa2EaCEgZEdjKJNNcRRYYGcFDjGFASHhUTOgiTTMP4WFNLDCI6IvP8lB9GfgDh/AFi3inTepH5/vShEK8xO0A4JQdmT/FOyRtO9MmRrOhShvBRvO/MwumAqAQFmFh8Yx5SpRyAlR+QaR3hn/EueFxOtx69DxoBcYl+4EQBJbBctbMKEbHy79J80Q3+iJoK4DQB0K4B8YubQkeDImVhDlmnLl4bWx1AARgwlpBCSgCoKnY2/PZWk0duPYjrB6P7D1aHO+E62AWwq8UQKsFAO3a4Icj1fLXpZADXEGTAOpApvaibG69sG486nW5m2SuWhSMpeiFpH6W25GISB2p2k25Wc1ms+qpjOLOEJsdHd14OkUWe7EZ/PHLwe7YejzdOHrWAAe4TQUmIWV5SbhNWw8CxxNi+xCrCWMQ2t0WbZnZADok0LooMrNu7bwQAo254+rGoWW4fdtH8QMXVsUwbbl0xkgesDqa2qB+63yaNROz+5O7/duKRnhzM7OrF0l3FlRosytGMaVg0NJ1tOV1bdbEbPtCytglluBm95gBVjbVx5gxlpy5l8QEqOBt+xAEJ/l/ywVwDD8kK2QyDEBrnMByhVJ0smSsC599ywD+C5qw+f5jBrB1rHBrC4ymO/Coh9lspTlu92xihsi6hxcOh2V0j+V8juiwFxivgTh6USKBKT8qmO0FV6xCSpF7d1M1F2S9Fn/TFiso9guk9ouuaJU8uxbej29ud6eox+RmyfD3AE+7Ap9v91lm8hBz9y9Fg7+feN4uRb6AgAVmeKuN4G+NAeQ8Dp+BKKgLed+XeL+AwUz2edQdKyqRAdK8Gc8WgdKglLnQeTz4eAkBgFagmhgAQdYAmkgAAFmGGGBWoAAYQwSB0uVB1hRhaA1qSBRgCakuABOYYEgYYdat0JLlapLz+QwUzkgFa9YAQAmo4JLzYIr9Lla3r2gCr8YBgdLpQdrlao4cYAQOrpLgQTYTYAQWgUr6L0rhriLiAAkeLtAEgTYdL0YBgCrhb2gdYf0I4VQYro79L8YWgdL0rub9LtLk74YaLgmlb1bqAbz3AXz2gfzwLugdK3nfQIAA -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details open="true">
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---



<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=StarSync-FE/StarSync&utm_content=186):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai generate sequence diagram` to generate a sequence diagram of the changes in this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` or `@coderabbitai 요약` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: NONE
user: login: sgoldenbird
created_at: 2025-04-25T06:28:55Z
body: 수고하셨습니다~
author_association: CONTRIBUTOR