Claude Code로 React 가상 스크롤 구현하기
Claude Code와 TanStack Virtual로 React/TypeScript 가상 스크롤, 가변 높이, 접근성, Playwright 검증을 구현합니다.
가상 스크롤이 필요한 이유
가상 스크롤은 긴 목록 전체를 DOM에 올리지 않고, 화면에 보이는 행과 그 주변 행만 렌더링하는 방식입니다. DOM은 브라우저가 페이지 구조를 표현하기 위해 유지하는 요소 트리라고 보면 됩니다. 1만 개의 로그나 고객 행을 단순히map으로 그리면, 보이지 않는 행까지 레이아웃 계산, 페인트, 이벤트 처리, 접근성 트리에 포함됩니다. 사용자가 보는 것은 스무 줄 정도인데 브라우저는 수천 개의 노드를 관리하게 됩니다.
Claude Code는 React 컴포넌트를 빠르게 만들어 주지만, “virtual scroll 만들어줘”라는 요청만으로는 데모 수준에서 멈추기 쉽습니다. 실제 화면에서는 행 높이가 고정인지, 빠르게 스크롤할 때 빈 영역이 보이는지, 키보드로 이동 가능한지, 스크린 리더에 총 개수와 현재 위치가 전달되는지, 상세 화면에서 돌아왔을 때 위치가 복원되는지, 이미지 로딩 후 높이가 바뀌는지, 모바일 폭에서 가로 스크롤이 생기지 않는지까지 확인해야 합니다.
가상 스크롤이 유용한 대표 사례는 로그 뷰어, 고객 목록, 채팅 기록, 검색 결과, 관리자 테이블입니다. 로그 뷰어는 긴 배포 로그나 에러 로그를 빠르게 탐색해야 합니다. 고객 목록은 CRM, 결제 관리, 팀 관리 화면에서 행 수가 빠르게 늘어납니다. 채팅 기록은 긴 대화와 AI 응답 때문에 DOM이 커지기 쉽습니다. 검색 결과는 필터 변경 후 재렌더링이 잦습니다. 관리자 테이블은 열 너비, 선택 상태, 고정 헤더, 권한 필드를 함께 다뤄야 합니다. 서버에서 데이터를 계속 가져오는 흐름은무한 스크롤 구현과 함께 보고, 전체 렌더링 개선은성능 최적화를 함께 읽는 것이 좋습니다.
Claude Code에 줄 요구사항
가상 스크롤은 겉보기로는 멀쩡해도 사용성 문제가 숨어 있을 수 있습니다. 그래서 구현 전에 라이브러리, 데이터 규모, 접근성, 모바일 폭, 검증 방법을 같이 적어야 합니다.
React 18 + TypeScript로 가상화된 로그 뷰어를 구현해 주세요.
조건:
- @tanstack/react-virtual 사용
- 10000개 이상의 행을 지원하되 모든 행을 DOM에 올리지 않기
- 기본 행 높이는 44px
- role, aria-label, aria-posinset, aria-setsize 추가
- 390px 모바일 폭에서 페이지 가로 스크롤이 생기지 않기
- overscan 값을 선택한 이유 설명
- Playwright로 스크롤 후 행 표시와 가로 overflow 검사
- 마지막에 TanStack Virtual 공식 문서 기준으로 API 사용 검토
이 정도로 적으면 Claude Code가 단순한 목록이 아니라 검증 가능한 기능을 만들게 됩니다. 고객 목록이라면 고객명, 상태, 마지막 활동일을 넣고, 검색 결과라면 제목과 요약, 채팅 기록이라면 작성자와 본문, 첨부 파일을 넣으면 됩니다. 핵심은 “빠르게 보이게 해줘”가 아니라 “어떤 실패를 막아야 하는지”를 함께 전달하는 것입니다.
TanStack Virtual로 고정 높이 목록 만들기
React에서는@tanstack/react-virtual을 우선 검토할 만합니다. 이 라이브러리는 완성된 UI 컴포넌트가 아니라, 보이는 항목과 위치를 계산하는 headless 도구입니다. 마크업, 스타일, 접근성은 우리가 책임지고, 가상화 계산만 맡기는 방식입니다. 공식 문서는TanStack Virtual docs와Virtualizer API를 기준으로 확인하세요.
npm install @tanstack/react-virtual
고정 높이 로그 뷰어는 첫 구현으로 적합합니다. 바깥 요소는 스크롤을 담당하고, 안쪽 요소는 전체 높이를 만들며, 실제 행은translateY로 위치를 맞춥니다.
import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
type LogRow = {
id: string;
level: "info" | "warn" | "error";
message: string;
createdAt: string;
};
export function VirtualLogViewer({ rows }: { rows: LogRow[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 44,
overscan: 12,
getItemKey: (index) => rows[index]?.id ?? index,
});
return (
<section aria-labelledby="log-heading">
<h2 id="log-heading">Application logs</h2>
<div
ref={parentRef}
data-testid="virtual-log-viewport"
role="list"
aria-label={`Application logs, ${rows.length} rows`}
style={{
height: 520,
overflow: "auto",
border: "1px solid #d4d4d8",
borderRadius: 6,
}}
>
<div
style={{
height: rowVirtualizer.getTotalSize(),
position: "relative",
width: "100%",
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index];
if (!row) return null;
return (
<div
key={virtualRow.key}
role="listitem"
aria-posinset={virtualRow.index + 1}
aria-setsize={rows.length}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
display: "grid",
gridTemplateColumns: "92px 72px minmax(0, 1fr)",
gap: 12,
alignItems: "center",
padding: "0 12px",
boxSizing: "border-box",
borderBottom: "1px solid #eee",
}}
>
<time dateTime={row.createdAt}>{row.createdAt}</time>
<strong>{row.level.toUpperCase()}</strong>
<span style={{ overflowWrap: "anywhere" }}>{row.message}</span>
</div>
);
})}
</div>
</div>
</section>
);
}
overscan은 화면 밖에 미리 렌더링할 행 수입니다. 너무 작으면 빠르게 스크롤할 때 빈 영역이 보이고, 너무 크면 DOM이 다시 많아집니다. 단순 텍스트 로그는 8에서 16 사이를 테스트하고, 아바타, 메뉴, 코드 하이라이트, 차트가 들어가는 행은 더 낮게 시작해서 측정하는 편이 안전합니다.
가변 높이 채팅 기록
채팅 기록, 문의 댓글, AI 응답 로그는 행 높이가 자주 달라집니다. 메시지 길이, 이미지, 첨부 파일, 번역 표시, 에러 배너가 높이를 바꿉니다. 이때는estimateSize로 대략적인 높이를 주고, 실제 렌더링된 요소를measureElement로 측정합니다.
import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
type Message = {
id: string;
author: string;
body: string;
avatarUrl?: string;
};
export function VirtualChatHistory({ messages }: { messages: Message[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 96,
overscan: 8,
getItemKey: (index) => messages[index]?.id ?? index,
});
return (
<div
ref={parentRef}
role="log"
aria-label="Chat history"
style={{ height: 520, overflow: "auto" }}
>
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const message = messages[virtualItem.index];
if (!message) return null;
return (
<article
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualItem.start}px)`,
padding: "12px 16px",
boxSizing: "border-box",
}}
>
{message.avatarUrl ? (
<img
src={message.avatarUrl}
alt=""
width={32}
height={32}
loading="lazy"
onLoad={() => virtualizer.measure()}
/>
) : null}
<p style={{ margin: 0, fontWeight: 700 }}>{message.author}</p>
<p style={{ margin: "4px 0 0", overflowWrap: "anywhere" }}>
{message.body}
</p>
</article>
);
})}
</div>
</div>
);
}
이미지는 뒤늦게 로드되므로 높이 흔들림의 원인이 됩니다. 이미지 크기를 미리 예약하고, 로드 후 재측정하세요. 새 메시지가 들어왔을 때 항상 맨 아래로 붙일지, 사용자가 과거 메시지를 읽는 중이면 현재 위치를 유지할지도 제품 요구사항으로 정해야 합니다.
접근성과 키보드 조작
가상 리스트는 일부 행만 DOM에 있으므로 보조 기술에 더 명확한 정보를 줘야 합니다. 전체 개수, 현재 위치, 리스트의 목적, 키보드 이동을 표현합니다. 고객 목록에서는 방향키로 선택 행을 움직이고 Enter로 상세를 열 수 있어야 합니다.
import type { KeyboardEvent } from "react";
type KeyboardParams = {
activeIndex: number;
rowCount: number;
setActiveIndex: (index: number) => void;
scrollToIndex: (index: number) => void;
};
export function handleVirtualListKeyDown(
event: KeyboardEvent,
{ activeIndex, rowCount, setActiveIndex, scrollToIndex }: KeyboardParams,
) {
const lastIndex = Math.max(0, rowCount - 1);
let nextIndex = activeIndex;
if (event.key === "ArrowDown") nextIndex = Math.min(lastIndex, activeIndex + 1);
if (event.key === "ArrowUp") nextIndex = Math.max(0, activeIndex - 1);
if (event.key === "PageDown") nextIndex = Math.min(lastIndex, activeIndex + 10);
if (event.key === "PageUp") nextIndex = Math.max(0, activeIndex - 10);
if (event.key === "Home") nextIndex = 0;
if (event.key === "End") nextIndex = lastIndex;
if (nextIndex !== activeIndex) {
event.preventDefault();
setActiveIndex(nextIndex);
scrollToIndex(nextIndex);
}
}
행 자체에 포커스를 두면 스크롤 중 행이 언마운트되면서 포커스를 잃을 수 있습니다. 안정적인 방식은 스크롤 컨테이너에 포커스를 두고aria-activedescendant로 활성 행을 알리는 것입니다. 스크린 리더와 키보드 검사는 브라우저마다 다르므로접근성 구현 가이드와 함께 확인하세요.
Playwright 검증과 리뷰 프롬프트
가상 스크롤은 눈으로만 판단하기 어렵습니다. 모바일 폭, 스크롤 후 목표 행, 가로 overflow, 콘솔 에러를 자동으로 확인합니다.
import { expect, test } from "@playwright/test";
test("virtual log viewer scrolls without horizontal overflow", async ({ page }) => {
const errors: string[] = [];
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/debug/virtual-log-viewer");
const viewport = page.getByTestId("virtual-log-viewport");
await expect(viewport).toBeVisible();
const before = await viewport.boundingBox();
await viewport.evaluate((node) => {
node.scrollTop = 2400;
});
await expect(page.getByText("Log #250")).toBeVisible();
const after = await viewport.boundingBox();
expect(after?.width).toBe(before?.width);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
await page.evaluate(() => document.documentElement.clientWidth),
);
expect(errors).toEqual([]);
});
마지막으로 Claude Code에 다음처럼 비판적인 리뷰를 요청합니다.
이 React 가상 스크롤 구현을 리뷰해 주세요.
확인할 점:
- TanStack Virtual 공식 API를 따르는가
- 고정 높이와 가변 높이 책임이 섞이지 않았는가
- overscan이 너무 작아 빠른 스크롤에서 빈 영역이 생기지 않는가
- role, aria 속성, 키보드 조작이 일관되는가
- 이미지 로딩 후 높이 재측정이 되는가
- 상세 화면에서 돌아올 때 스크롤 위치가 복원되는가
- SSR 또는 hydration 이후 초기 높이가 바뀌지 않는가
- Playwright가 모바일 폭과 스크롤 후 행을 검증하는가
흔한 함정과 다음 단계
주의할 함정은 명확합니다. 가변 높이를 고정 높이처럼 처리하면 행이 겹칩니다. overscan이 부족하면 흰 공간이 보이고, 과하면 DOM이 늘어납니다. 키보드 대응이 없으면 마우스 없는 사용자가 막힙니다. 스크린 리더에 총수와 위치를 알려주지 않으면 목록 의미가 흐려집니다. 스크롤 복원이 없으면 상세 화면에서 돌아올 때 맥락을 잃습니다. 이미지 로딩 후 높이 변화는 채팅과 상품 카드에서 자주 발생합니다. SSR 차이는 hydration 뒤 레이아웃 점프를 만듭니다. 긴 문자열은minmax(0, 1fr)와overflowWrap없이는 모바일 폭을 깨뜨립니다.
개념은scrollTop -> visible range -> overscan range -> virtual rows -> translateY -> measureElement로 설명하면 됩니다. 이 흐름을 팀 문서나CLAUDE.md에 적어 두면 Claude Code가 만든 구현을 리뷰하기 쉬워집니다.
팀의 로그 뷰어, 고객 테이블, 검색 결과, 채팅 화면에 적용하려면Claude Code training and consultation에서 실제 저장소 기준으로 요구사항, 프롬프트, 접근성, Playwright 증거를 정리할 수 있습니다. 공식 자료는TanStack Virtual docs와Virtualizer API를 기준으로 삼으세요.
실제로 시험해 본 결과
고정 높이 로그 뷰어는 전체rows.map보다 DOM 행 수가 크게 줄어 스크롤 문제를 확인하기 쉬웠습니다. 반면 가변 높이 채팅 기록에서는 이미지 크기를 예약하지 않으면 로드 후 스크롤 위치가 살짝 움직였습니다. 공개 전 체크로는 실제 데이터에 맞춘estimateSize, 390px 폭 Playwright 검사, 중간 행까지 스크롤한 뒤 가로 overflow가 없는지 확인하는 과정이 가장 효과적이었습니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Claude Code 권한 세이프티 래더: 통제력을 잃지 않고 allow 넓히기
read-only에서 제한 편집, 검증 명령, deploy 확인까지 권한을 단계적으로 넓히는 방법.
Claude Code Small PR Proof Pack: 작은 PR을 리뷰 가능한 상태로 만드는 증거 세트
Claude Code의 작은 PR에 diff, 검증, 공개 URL, CTA 경로, rollback을 붙이는 실무 체크리스트.
Claude Code 커밋 전 리뷰 게이트: diff, 테스트, 공개 URL, CTA 확인
Claude Code 작업을 커밋하기 전에 diff 범위, build, 공개 URL, Gumroad 링크, 상담 CTA, 테스트 누락과 무관한 파일을 확인하는 방법입니다.