Skip to content

Feat: Window 기반 가상 스크롤 컴포넌트 구현#109

Merged
DreamPaste merged 12 commits intodevfrom
feat/SOS-57-window-scroll
Jan 3, 2026
Merged

Feat: Window 기반 가상 스크롤 컴포넌트 구현#109
DreamPaste merged 12 commits intodevfrom
feat/SOS-57-window-scroll

Conversation

@DreamPaste
Copy link
Member

📌 개요

Window 기반 가상 스크롤 컴포넌트 구현 및 스크롤 관련 훅(스크롤 복원, 리사이즈 감지) 통합

  • Window 가상 스크롤 컴포넌트 구현 (Flex 기반 레이아웃)
  • Container/Window 모두 지원하는 스크롤 복원 훅 구현
  • Container/Window 자동 호환되는 리사이즈 감지 훅 구현
  • 무한 스크롤 관련 유틸 훅 추가 (데이터 평탄화, IntersectionObserver 트리거)
  • 예제 컴포넌트 및 사용법 가이드 제작

🗒 상세 설명

1. Window 가상 스크롤 컴포넌트 구현

파일: apps/web/src/components/infiniteScrolls/WindowVirtualScroll.tsx (신규)

Window 전체를 스크롤 컨테이너로 사용하는 가상 스크롤 컴포넌트를 구현했습니다. 기존 VirtualList가 고정 높이 컨테이너 내부에서만 동작하는 한계를 극복하기 위해 제작되었습니다.

핵심 기술 및 구현사항

  • Flex 기반 레이아웃: position: absolute + transform 대신 paddingTop/paddingBottom으로 normal flow 유지

    • 더 자연스러운 페이지 스크롤 경험
    • 다른 요소들과의 레이아웃 충돌 방지
  • TanStack React Virtual 활용: useWindowVirtualizer 사용

    • Window 객체를 스크롤 컨테이너로 직접 사용
    • 동적 높이 측정 지원 (measureElement ref 콜백)
  • 기존 Props 재사용: VirtualListProps 인터페이스 공유로 일관성 유지

    • Container 가상화와 동일한 API
    • 마이그레이션 용이

사용 예시

import { WindowVirtualScroll } from '@/components/infiniteScrolls/WindowVirtualScroll';

function MyListPage() {
  const containerRef = useRef<HTMLDivElement>(null);

  return (
    <WindowVirtualScroll
      items={data}
      renderItem={(item, index) => <Card key={item.id} {...item} />}
      getItemKey={(item) => item.id}
      parentRef={containerRef}
      estimateSize={60}
      overscan={3}
      gap={16}
      storageKey="my-page-scroll"
      resetScroll={false}
    />
  );
}

🖼 적용 이미지 혹은 영상

(추후 스크린샷 추가 예정)


2. 스크롤 복원 훅 구현

파일: apps/web/src/hooks/scroll/useScrollRestoration.ts (신규)

페이지 새로고침 시에도 스크롤 위치를 복원하는 훅입니다. Container/Window 양방향을 모두 지원합니다.

핵심 기술 및 구현사항

  • Union 타입 설계: ContainerScrollOptions | WindowScrollOptions

    • TypeScript discriminated union으로 타입 안전성 보장
    • type: 'container' | 'window' discriminator로 자동 분기
  • 3단계 저장 전략:

    1. 초기화: sessionStorage에서 이전 위치 불러오기 → virtualizer.initialOffset
    2. 실시간 저장: rAF throttle로 프레임당 1회만 저장 (성능 최적화)
    3. 이탈 시 보장: visibilitychange, beforeunload, cleanup에서 flush
  • 성능 최적화:

    const saveScrollPosition = rafThrottle((offset: number) => {
      sessionStorage.setItem(storageKey, String(offset));
    });
    • requestAnimationFrame 기반 throttle
    • 불필요한 sessionStorage 쓰기 방지
  • 별도 헬퍼 함수: useScrollRestorationInitialOffset

    • SSR 안전 (window undefined 체크)
    • 초기 오프셋 값만 반환

사용 예시

// Container 모드
const virtualizer = useVirtualizer({
  getScrollElement: () => parentRef.current,
  // ...
});

useScrollRestoration({
  type: 'container',
  virtualizer,
  parentRef,
  storageKey: 'my-list',
  enabled: true,
});

// Window 모드
const virtualizer = useWindowVirtualizer({ ... });

useScrollRestoration({
  type: 'window',
  virtualizer,
  storageKey: 'my-page',
  enabled: true,
});

3. 리사이즈 감지 훅 구현

파일: apps/web/src/hooks/scroll/useVirtualizerMeasure.ts (신규)

컨테이너 크기 변화를 감지하고 virtualizer를 재측정하는 훅입니다. Container/Window 자동 호환이 핵심입니다.

핵심 기술 및 구현사항

  • 통합 타입 설계 (제네릭 확장 방식):

    export interface VirtualizerMeasureOptions<
      TScrollElement extends Element | Window = Element | Window,
      TItemElement extends Element = Element,
    > {
      virtualizer: Virtualizer<TScrollElement, TItemElement>;
      observeRef: React.RefObject<Element>;
      enabled?: boolean;
    }
    • 타입 추론 자동화: useVirtualizer, useWindowVirtualizer 모두 타입 에러 없이 사용 가능
    • discriminator 불필요: 단일 인터페이스로 API 단순화
  • 이중 감지 시스템:

    1. ResizeObserver: 컨테이너 크기 변화 감지 (우선순위 높음)
    2. Window Resize: 브라우저 창 크기 변화 감지 (보조)
  • 성능 최적화:

    const measure = rafThrottle(() => {
      virtualizer.measure();
    });
    • rAF Throttle로 프레임당 1회 제한
    • 불필요한 리플로우 방지
  • 정리 보장:

    • ResizeObserver disconnect
    • Window resize 리스너 제거
    • 대기 중인 rAF 취소 (measure.cancel())

사용 예시

// Container 가상화
const virtualizer = useVirtualizer({ ... });
useVirtualizerMeasure({
  virtualizer,        // Virtualizer<HTMLDivElement, T> - 타입 자동 추론
  observeRef: parentRef,
});

// Window 가상화
const virtualizer = useWindowVirtualizer({ ... });
useVirtualizerMeasure({
  virtualizer,        // Virtualizer<Window, T> - 타입 자동 추론
  observeRef: containerRef,
});

4. 무한 스크롤 관련 유틸 훅

4-1. useInfiniteData 훅

파일: apps/web/src/hooks/scroll/useInfiniteData.ts (신규)

TanStack Query의 pages 배열을 flat array로 변환하는 훅입니다.

const { items, hasNextPage, fetchNextPage, isFetchingNextPage, isLoading } = useInfiniteData({
  query: useInfiniteQuery({ ... }),
  getItemsFromPage: (page) => page.data.items, // 선택사항
});

4-2. useInfiniteScroll 훅

파일: apps/web/src/hooks/scroll/useInfiniteScroll.ts (신규)

IntersectionObserver 기반 자동 페칭 트리거 훅입니다.

const triggerRef = useInfiniteScroll({
  hasNextPage,
  fetchNextPage,
  isFetchingNextPage,
  enabled: true,
  threshold: 0.5,
});

return <div ref={triggerRef}>Loading...</div>;

5. 예제 컴포넌트 제작

파일: apps/web/src/components/infiniteScrolls/VirtualList.example.tsx (신규)

훅 조합 패턴 시연 및 VirtualList 실전 사용법 가이드를 제공합니다.

핵심 기술 및 구현사항

  • 훅 조합 패턴 (useScrollRestoration + useVirtualizerMeasure)
  • 상세 주석으로 사용법 설명
  • Mock 데이터 기반 실행 가능한 예제

사용 예시

<VirtualListExample
  items={mockData}
  renderItem={(item) => <Card {...item} />}
  getItemKey={(item) => item.id}
  estimateSize={80}
  overscan={5}
  gap={12}
  storageKey="example-list"
  resetScroll={false}
/>

📸 스크린샷

UI 컴포넌트가 아닌 훅 및 유틸 작업이므로 스크린샷 없음.

AS-IS

  • Container 가상화만 지원 (고정 높이 컨테이너 내부 스크롤)
  • 스크롤 복원 로직이 VirtualList 컴포넌트 내부에 응집
  • 리사이즈 감지 로직이 VirtualList 컴포넌트 내부에 응집

TO-BE

  • Window 가상화 추가 지원 (전체 페이지 스크롤)
  • 스크롤 복원 훅 분리 및 Container/Window 통합 지원
  • 리사이즈 감지 훅 분리 및 타입 자동 호환
  • 재사용 가능한 무한 스크롤 훅 제공

🔗 이슈

closes #57


✅ 체크리스트

  • 코드가 스타일 가이드를 따릅니다

  • 자체 코드 리뷰를 완료했습니다

  • 복잡/핵심 로직에 주석을 추가했습니다

  • 관심사 분리를 확인했습니다 (훅 분리)

  • 잠재적 사이드이펙트를 점검했습니다 (cleanup 함수 구현)

  • Vercel Preview로 테스트를 완료했습니다 (진행 예정)


🧪 테스트 방법

다음 방법으로 변경 사항을 검증할 수 있습니다:

1. Window 가상 스크롤 테스트

import { WindowVirtualScroll } from '@/components/infiniteScrolls/WindowVirtualScroll';

const TestPage = () => {
  const parentRef = useRef<HTMLDivElement>(null);

  return (
    <WindowVirtualScroll
      items={Array.from({ length: 1000 }, (_, i) => ({
        id: i,
        text: `Item ${i}`,
      }))}
      renderItem={(item) => (
        <div style={{ padding: 20 }}>Item {item.id}</div>
      )}
      getItemKey={(item) => item.id}
      parentRef={parentRef}
      estimateSize={60}
      gap={8}
    />
  );
};

확인 사항:

  • 부드러운 스크롤 (1000개 아이템)
  • 동적 높이 측정 (각 아이템 높이가 다를 때)
  • 스크롤 위치 저장/복원 (새로고침 후)

2. 타입 호환성 테스트

// Container 가상화
const virtualizer1 = useVirtualizer<HTMLDivElement, HTMLDivElement>({ ... });
useVirtualizerMeasure({ virtualizer: virtualizer1, observeRef: parentRef }); // ✅ 타입 에러 없음

// Window 가상화
const virtualizer2 = useWindowVirtualizer<HTMLDivElement>({ ... });
useVirtualizerMeasure({ virtualizer: virtualizer2, observeRef: containerRef }); // ✅ 타입 에러 없음

확인 사항:

  • TypeScript 타입 에러 없음
  • 브라우저 창 리사이즈 시 재측정
  • sessionStorage에 스크롤 위치 저장 확인

3. 성능 테스트

  • rAF throttle 동작 확인 (프레임당 1회 제한)
  • ResizeObserver cleanup 확인 (메모리 누수 방지)
  • Passive 이벤트 리스너 적용 확인

📝 추가 노트

타입 설계에 대한 논의

두 가지 타입 설계 방식을 사용했습니다:

  1. useScrollRestoration: Union 타입 (type: 'container' | 'window')

    • 명시적 타입 구분 필요
    • 런타임 분기 로직에 적합
  2. useVirtualizerMeasure: 제네릭 확장 (TScrollElement extends Element | Window)

    • 타입 추론 자동화
    • API 단순화 (discriminator 불필요)

useVirtualizerMeasure의 경우 로직이 동일하므로 제네릭 방식이 더 적합했고, useScrollRestoration은 이벤트 리스너 부착 대상이 다르므로 Union 타입이 더 적합했습니다.

파일 구조 개선

기존에는 VirtualList 컴포넌트 내부에 스크롤 복원/리사이즈 감지 로직이 응집되어 있었습니다. 이번 작업으로 다음과 같이 관심사를 분리했습니다:

apps/web/src/
├── components/infiniteScrolls/     # UI 컴포넌트
│   ├── VirtualList.tsx
│   ├── WindowVirtualScroll.tsx
│   └── VirtualList.example.tsx
└── hooks/scroll/                   # 재사용 가능한 로직
    ├── useScrollRestoration.ts
    ├── useVirtualizerMeasure.ts
    ├── useInfiniteData.ts
    └── useInfiniteScroll.ts

@DreamPaste DreamPaste added Feat 💡 새로운 기능을 구현하고 추가합니다! Refactor 🫧 기존 내용을 개선하거나 최적화합니다! 휘건 labels Dec 23, 2025
@linear
Copy link

linear bot commented Dec 23, 2025

@github-actions
Copy link

github-actions bot commented Dec 23, 2025

📦 번들 분석 결과

📊 번들 크기 요약

항목
📦 전체 번들 크기 3.7M
📄 JavaScript 크기 1.6M
🗂️ JavaScript 파일 수 64개

🔍 주요 청크 파일 (크기순)

79e27356-a397cfaca953c890.js - 169K
framework-69e0f7d37422957b.js - 137K
main-a3247b30c12eafe6.js - 130K
2356-dea7484209cf7469.js - 122K
9870-dbaf3a370645f2d3.js - 121K
polyfills-42372ed130431b0a.js - 110K
5867-b97ee42a40c66b62.js - 90K
5251-dc07ab16b26ff9a9.js - 75K
page-a1946fe19c26d153.js - 30K
2845-56a40d00b08c6d2e.js - 28K

🤖 자동 생성된 번들 분석 리포트

@github-actions
Copy link

github-actions bot commented Dec 23, 2025

⚡ Lighthouse 성능 분석 결과

📊 전체 평균 점수

지표 점수
🚀 Performance 75점
♿ Accessibility 87점
✅ Best Practices 100점
🔍 SEO 100점

📈 측정 현황

  • 측정 성공: 15/16 페이지
  • 상태: success

📄 페이지별 상세 분석

🏠 커뮤니티 페이지: /main/community

지표 점수
🚀 Performance 73점
♿ Accessibility 80점
✅ Best Practices 100점
🔍 SEO 100점

📊 상세 분석 보기

👥 창업자 페이지: /main/founder

지표 점수
🚀 Performance 75점
♿ Accessibility 87점
✅ Best Practices 100점
🔍 SEO 100점

📊 상세 분석 보기

🏡 홈 페이지: /main/home

지표 점수
🚀 Performance 75점
♿ Accessibility 91점
✅ Best Practices 100점
🔍 SEO 100점

📊 상세 분석 보기

🗺️ 지도 페이지: /main/maps

지표 점수
🚀 Performance 75점
♿ Accessibility 87점
✅ Best Practices 100점
🔍 SEO 100점

📊 상세 분석 보기

👤 프로필 페이지: /main/profile

지표 점수
🚀 Performance 75점
♿ Accessibility 88점
✅ Best Practices 100점
🔍 SEO 100점

📊 상세 분석 보기

🔗 전체 상세 분석 결과

📊 전체 상세 Lighthouse 분석 결과 보기

📄 측정된 페이지

  • /main/community
  • /main/founder
  • /main/home
  • /main/maps
  • /main/profile

모든 페이지에서 성능 측정이 완료되었습니다.


🤖 자동 생성된 Lighthouse 성능 리포트

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

이 PR은 Window 기반 가상 스크롤 컴포넌트를 구현하고, 스크롤 관련 기능을 재사용 가능한 훅으로 분리하여 관심사를 명확히 했습니다. 또한 투표 게시판 기능을 완전히 구현하여 자유게시판과 동일한 사용자 경험을 제공합니다.

주요 변경사항

  • Window 가상 스크롤 컴포넌트: 전체 페이지 스크롤을 지원하는 WindowVirtualScroll 컴포넌트를 추가하여 Container 기반 가상화의 한계를 극복
  • 스크롤 관련 훅 분리: useScrollRestoration, useVirtualizerMeasure, useInfiniteScroll, useInfiniteData 훅을 분리하여 재사용성과 테스트 가능성 향상
  • 투표 게시판 구현: 투표 게시판 페이지, 투표 카드 컴포넌트, 투표 상태 칩, 마감 시간 포맷팅 유틸리티 등 완전한 투표 게시판 기능 구현

Reviewed changes

Copilot reviewed 21 out of 27 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
apps/web/src/components/infiniteScrolls/WindowVirtualScroll.tsx Window 기반 가상 스크롤 컴포넌트 신규 구현 (Flex 레이아웃 사용)
apps/web/src/components/infiniteScrolls/VirtualList.tsx VirtualListProps 인터페이스를 export하여 재사용 가능하도록 변경
apps/web/src/components/infiniteScrolls/VirtualList.example.tsx 훅 조합 패턴을 시연하는 예제 컴포넌트 추가
apps/web/src/hooks/scroll/useScrollRestoration.ts 스크롤 위치 저장/복원 훅 추가 (Container/Window 통합 지원)
apps/web/src/hooks/scroll/useVirtualizerMeasure.ts 컨테이너 리사이즈 감지 및 재측정 훅 추가
apps/web/src/hooks/scroll/useInfiniteScroll.ts IntersectionObserver 기반 무한 스크롤 훅 추가
apps/web/src/hooks/scroll/useInfiniteData.ts TanStack Query pages 배열을 flat array로 변환하는 훅 추가
apps/web/src/utils/vote-deadline.ts 투표 마감 시간을 사용자 친화적 형식으로 변환하는 유틸리티 추가
apps/web/src/components/chips/VoteStatusChip.tsx 투표 상태(진행중/마감임박/마감/완료)를 표시하는 칩 컴포넌트 추가
apps/web/src/app/main/community/votesboard/components/VoteBoardCard.tsx 투표 게시글 카드 컴포넌트 추가
apps/web/src/app/main/community/votesboard/page.tsx 투표 게시판 서버 컴포넌트로 리팩토링 (프리패칭 지원)
apps/web/src/app/main/community/votesboard/ClientPage.tsx 투표 게시판 클라이언트 로직 구현 (무한 스크롤, 필터링, 정렬)
apps/web/src/app/main/community/freeboard/ClientPage.tsx FloatingButton 리팩토링에 따른 사용법 업데이트
apps/web/src/components/buttons/FloatingCategoryMenu.tsx 카테고리 메뉴를 범용적으로 사용하기 위해 route prop 추가
apps/web/src/components/buttons/FloatingButton.tsx 오버레이 로직을 외부로 분리하여 버튼을 단순화
apps/web/src/app/main/community/constants/votesOptions.tsx 투표 상태 필터 옵션 상수 추가
apps/web/src/app/main/community/constants/sortOptions.ts 조회순 정렬 옵션 추가
apps/web/src/types/options.types.ts SortValue 타입에 'VIEW' 추가
apps/web/next.config.js S3 이미지 경로를 freeboard에서 모든 경로로 확장
apps/web/package.json date-fns 의존성 추가, dev 스크립트를 HTTPS로 변경
package.json 루트 패키지의 dev 스크립트 업데이트
.gitignore monorepo-docs 디렉토리 제외 추가
apps/web/src/generated/api/** 투표 게시판 API 타입 업데이트 (자동 생성)
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

/**
* useScrollRestorationInitialOffset Hook
*
* sessionStorage에서 저장된 스크롤 위치를 불러와 virtualizer의 initialOffset으로 사용합
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

주석이 중간에 잘린 것으로 보입니다. "사용합" 다음에 "니다"가 누락되어 있습니다.

Suggested change
* sessionStorage에서 저장된 스크롤 위치를 불러와 virtualizer의 initialOffset으로 사용합
* sessionStorage에서 저장된 스크롤 위치를 불러와 virtualizer의 initialOffset으로 사용합니다

Copilot uses AI. Check for mistakes.
return `${hoursLeft}시간 후 마감`;
}

// 1시간 이내: 분 단위 표시
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1시간 이내일 때 분 단위를 표시하는데, differenceInMinutes가 0을 반환하는 경우 "0분 후 마감"이 표시될 수 있습니다. 사용자 경험을 위해 최소 1분 또는 "곧 마감"과 같은 대체 메시지를 표시하는 것을 고려해보세요.

Suggested change
// 1시간 이내: 분 단위 표시
// 1시간 이내: 분 단위 표시
if (minutesLeft === 0) {
return '곧 마감';
}

Copilot uses AI. Check for mistakes.
Comment on lines +9 to +10
"dev:web": "pnpm --filter web dev:https",
"dev:docs": "pnpm --filter docs dev",
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

스크립트 이름이 변경되어 기존 dev:storybook 명령어가 제거되었습니다. 이 변경으로 인해 기존에 pnpm dev:storybook을 사용하던 개발자나 CI/CD 파이프라인에 영향을 줄 수 있습니다. 의도적인 변경인지 확인하고, 필요하다면 문서나 README를 업데이트해야 합니다.

Copilot uses AI. Check for mistakes.
Comment on lines +48 to +53
if (observeRef?.current) {
resizeObserver = new ResizeObserver(() => {
measure();
});
resizeObserver.observe(observeRef.current);
}
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

observeRefundefined이거나 .currentnull인 경우 ResizeObserver가 생성되지 않지만, window resize 이벤트 리스너는 여전히 등록됩니다. 이는 의도된 동작일 수 있지만, observeRef가 필수가 아니라면 JSDoc이나 TypeScript 타입에 이를 명시하는 것이 좋습니다. 또는 observeRef가 없을 때 경고를 출력하는 것을 고려해보세요.

Copilot uses AI. Check for mistakes.
renderItem={(post) => <VoteBoardCard post={post} />}
/>

{/* TODO: FloatingButton 추가 */}
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO 주석이 이미 구현된 코드를 가리키고 있습니다. 라인 115에 FloatingButton이 이미 추가되어 있으므로 이 TODO 주석은 제거해야 합니다.

Suggested change
{/* TODO: FloatingButton 추가 */}

Copilot uses AI. Check for mistakes.
Comment on lines +7 to +8
"dev": "node server.mjs",
"dev:http": "next dev",
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dev 스크립트의 동작이 변경되었습니다. 기존에는 next dev로 HTTP 서버를 실행했지만, 이제는 node server.mjs로 HTTPS 서버를 실행합니다. 이는 개발 환경에서 예상치 못한 동작을 일으킬 수 있으므로, HTTPS 인증서 설정이 제대로 되어 있는지, 그리고 모든 개발자가 이 변경사항을 인지하고 있는지 확인해야 합니다.

Suggested change
"dev": "node server.mjs",
"dev:http": "next dev",
"dev": "next dev",
"dev:https": "node server.mjs",

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,104 @@
// src/components/CommunityCard.tsx
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

파일 경로 주석이 실제 파일 위치와 일치하지 않습니다. 주석에는 "src/components/CommunityCard.tsx"라고 되어 있지만, 실제 파일은 "apps/web/src/app/main/community/votesboard/components/VoteBoardCard.tsx"입니다. 주석을 실제 파일 경로에 맞게 수정하거나 제거하는 것을 권장합니다.

Suggested change
// src/components/CommunityCard.tsx

Copilot uses AI. Check for mistakes.
Comment on lines +40 to +45
if (
voteStatus === VotePostSummaryResponseVoteStatus.COMPLETED ||
voteStatus === VotePostSummaryResponseVoteStatus.DELETED
) {
return 'completed';
}
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DELETED 상태를 completed로 표시하는 것은 사용자에게 혼란을 줄 수 있습니다. 삭제된 투표는 완료된 투표와 다른 의미이므로, 별도의 상태 타입(예: 'deleted')을 추가하고 적절한 라벨("삭제됨")과 색상을 사용하는 것을 고려해보세요.

Copilot uses AI. Check for mistakes.
const virtualizer = useWindowVirtualizer({
count: items.length,
estimateSize: () => estimateSize,
overscan,
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

items 배열이 비어있을 때(items.length === 0) getItemKey 함수가 items[index]에 접근하면 undefined를 반환할 수 있습니다. virtualizer는 이를 처리할 수 있지만, getItemKey prop에 대한 JSDoc에 빈 배열 처리에 대한 언급이 있으면 좋겠습니다.

Suggested change
overscan,
overscan,
/**
* getItemKey는 virtualizer가 요청하는 index를 기반으로 key를 생성합니다.
*
* - `count` `items.length` 같기 때문에, `items.length === 0` 경우
* virtualizer는 콜백을 호출하지 않습니다.
* - 따라서 정상적인 흐름에서는 `items[index]` `undefined` 상태로
* `getItemKey` 전달되지 않습니다.
* - 만약 `count` `items.length` 달라지는 커스텀 사용을 한다면
* `items[index]` `undefined` 있으므로, 해당 경우를 처리하도록
* 상위 레벨의 `getItemKey` 구현에서 방어 코드를 있습니다.
*/

Copilot uses AI. Check for mistakes.
Comment on lines +17 to +24
/** 페이지 데이터에서 아이템 배열 추출 함수 (기본값: 페이지 자체를 배열로 간주) */
getItemsFromPage?: (page: TPageData) => TData[];
}) {
// pages 배열을 flat array로 변환
const items = useMemo(() => {
if (!query.data?.pages) return [];
return query.data.pages.flatMap((page) => getItemsFromPage(page));
}, [query.data?.pages, getItemsFromPage]);
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

의존성 배열에 getItemsFromPage 함수를 포함하면, 부모 컴포넌트에서 인라인 함수로 전달할 때마다 불필요한 재계산이 발생할 수 있습니다. useCallback으로 메모이제이션되지 않은 함수가 전달되면 매 렌더링마다 새로운 배열이 생성됩니다. getItemsFromPage를 의존성에서 제거하고 안정적인 참조를 유지하도록 개선하거나, JSDoc에 메모이제이션 필요성을 명시하는 것을 권장합니다.

Suggested change
/** 페이지 데이터에서 아이템 배열 추출 함수 (기본값: 페이지 자체를 배열로 간주) */
getItemsFromPage?: (page: TPageData) => TData[];
}) {
// pages 배열을 flat array로 변환
const items = useMemo(() => {
if (!query.data?.pages) return [];
return query.data.pages.flatMap((page) => getItemsFromPage(page));
}, [query.data?.pages, getItemsFromPage]);
/**
* 페이지 데이터에서 아이템 배열 추출 함수 (기본값: 페이지 자체를 배열로 간주)
*
* 성능을 위해, 인라인 함수로 전달하는 경우 `useCallback` 등으로 메모이제이션된
* 안정적인 함수 참조를 전달하는 것을 권장합니다.
*/
getItemsFromPage?: (page: TPageData) => TData[];
}) {
// pages 배열을 flat array로 변환
const items = useMemo(() => {
if (!query.data?.pages) return [];
return query.data.pages.flatMap((page) => getItemsFromPage(page));
}, [query.data?.pages]);

Copilot uses AI. Check for mistakes.
@github-actions
Copy link

Copy link
Member

@youdaeng2 youdaeng2 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

스크롤 관련 로직이 훅으로 잘 분리되어서 전보다 가독성이나 재사용 측면에서 훨씬 나아진 것 같습니다. 복잡한 내용이 있어서 어려웠을 것 같은데 잘 수정해주셨네요.
예시도 남겨주셔서 마이그레이션에 도움이 될 것 같아요!!! 고생혔습니다~

@DreamPaste DreamPaste merged commit 7d0086c into dev Jan 3, 2026
4 checks passed
@DreamPaste DreamPaste deleted the feat/SOS-57-window-scroll branch January 3, 2026 09:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Feat 💡 새로운 기능을 구현하고 추가합니다! Refactor 🫧 기존 내용을 개선하거나 최적화합니다! 휘건

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants