Tech Dec 19, 2025

확장성 있는 테이블 컴포넌트 구축하기

확장성 있는 테이블 컴포넌트 구축하기

디자인 시스템을 구축하면서 마지막까지 테이블 컴포넌트의 작업은 미뤄두고 있었습니다.

그 이유는 새로 구현하기에 기존 테이블 컴포넌트가 과도하게 복잡하고, 다양한 요구사항이 혼합되어 있었기 때문입니다.

기존 테이블 컴포넌트에 적용된 요구사항들이 대표적으로 아래와 같았습니다.

테이블의 컬럼별로 boolean, status, badge 등의 다양한 타입들이 존재하고, 각 타입마다 보여주는 방식이 달라야 합니다. 수정 모드, 무한 스크롤, 페이지네이션 영역, 카드 형태 등 부가적인 다양한 기능들도 포함되어 있어야 합니다.

이런 테이블 컴포넌트를 어떻게 설계했고, 어떤 문제들을 해결해 나갔는지 소개하고자 합니다.

자주 사용하지 않는 기능은 과감히 분리하거나 버리자

기존 테이블 컴포넌트는 모든 기능을 하나의 컴포넌트에 집어넣으려다 보니, 코드가 점점 꼬이고 비대해졌습니다. 이를 해결하기 위해 가장 먼저 한 일은 실제 얼마나 자주 활용되는 기능인지 조사하고, 이를 기반으로 디자이너 분들과 논의하는 것이었습니다.

수정 모드와 카드 형태의 과감한 정리

  • 수정 모드: 테이블 내부에서 요소를 일괄 수정하는 기능은 구현이 복잡할 뿐만 아니라, 렌더링 성능 저하와 사용성 이슈를 자주 야기했습니다.

    테이블 내 직접 수정 대신 체크박스를 통한 일괄 수정으로 UX를 변경하여 테이블 본연의 ‘조회’ 목적에 집중하게 했습니다.

  • 카드 형태 테이블: 데이터 구성과 레이아웃이 일반 테이블과 크게 달랐습니다.

    하나의 컴포넌트에서 분기 처리로 제어하는 대신, 별도의 카드 리스트 컴포넌트로 분리하여 관리 효율을 높였습니다.

이 과정을 통해 테이블의 관심사와 복잡도를 낮추었고, 핵심 기능인 ‘데이터 조회 및 출력’의 성능 최적화에 집중할 수 있는 발판을 마련했습니다.


데이터가 많을 때 발생하는 성능 이슈를 개선하자

테이블이 제공하는 기능이 많아질수록 브라우저가 부담해야 할 계산량은 기하급수적으로 늘어납니다.

가장 큰 문제는 데이터가 1,000개를 넘어갈 때 발생했습니다. 체크박스를 클릭하면 반응이 지연되거나, 상세 정보를 보기 위해 사이드 패널을 열면 화면이 3~5초간 멈추는 현상이 나타났습니다.

1. 문제 분석: 왜 이렇게 느렸을까?

리렌더링과 리플로우

사이드 패널이 열리며 테이블을 옆으로 밀어낼 때, 리플로우가 발생하고 있었습니다.

  • 리플로우: 패널이 공간을 차지하면서 테이블의 너비가 변합니다. 브라우저는 줄어든 너비에 맞춰 수만 개의 셀 위치를 다시 계산해야 하는데, 이를 리플로우라고 합니다.

상태 업데이트

체크박스를 클릭하는 경우 테이블 전체가 업데이트되면서 리렌더링 이슈가 발생하고 있었습니다.

  • DOM 노드의 과부하: 1,000개의 로우가 존재할 때, 각 로우에 포함된 체크박스, 배지, 버튼 등을 합치면 수만 개의 DOM 요소가 생성됩니다. 브라우저는 이 많은 요소를 관리하고 배치하는 데 막대한 자원을 소모합니다.

  • 불필요한 전체 리렌더링: 체크박스 하나를 클릭해 상태가 변할 때마다, 테이블 전체가 다시 그려지면서 모든 로우의 컴포넌트 함수가 재실행되고 있었습니다.

2. 해결책: 가상화(Virtualization) 기술 도입

이 문제를 해결하기 위해 가상화(Virtualization) 기술을 도입했습니다.

가상화란? 수천 개의 데이터가 있더라도 사용자의 화면(Viewport)에 실제로 보이는 수십 개의 로우만 DOM에 렌더링하고, 보이지 않는 나머지 데이터는 메모리상에만 유지하는 기법입니다. 사용자가 스크롤을 할 때마다 필요한 부분의 DOM만 빠르게 교체하여 렌더링 성능을 극대화합니다.

테이블에 가상화를 적용하기 위해 다음과 같은 요소들을 고려했습니다.

  • 전체 높이 계산: 전체 데이터 개수와 각 항목의 높이를 곱해 totalSize를 계산합니다. 가상화 영역을 감싸는 컨테이너의 높이로 totalSize를 설정합니다.

  • 동적 렌더링: 사용자가 스크롤할 때마다 현재 뷰포트(viewport) 내에 위치해야 할 항목의 index를 계산합니다. 뷰포트를 벗어난 항목은 DOM에서 즉시 제거되고, 새로 진입하는 항목만 추가됩니다.

  • 절대 좌표 배치: 렌더링되는 각 행은 컨테이너 내에서 absolute 포지션을 가집니다. 가상화 라이브러리가 계산해준 시작 위치(start) 값을 translateY 속성에 적용하여 각 행을 정확한 스크롤 위치에 배치합니다.

3. 구현: 가상화 적용된 TableBody 컴포넌트

/** 테이블 본문. 가상화된 행 렌더링 */
export function TableBody<T>({
  onRowClick,
  rowHeight = TABLE_ROW_HEIGHT,
  className,
}: TableBodyProps<T>) {
  const { table, bodyRef } = useTableContext<T>();
  const { data, columnEntries } = table;

  // 가상화 계산
  const { virtualItems, totalSize } = useVirtualization({
    items: data,
    parentRef: bodyRef,
    rowHeight,
  });

  if (!data.length) return null;

  return (
    <div ref={bodyRef}>
      <div style={{ height: `${totalSize}px` }}>
        {virtualItems.map((virtualRow) => {
          const { row, index } = data[virtualRow.index];
          return (
            <TableRow<T>
              key={index}
              row={row}
              index={index}
              startY={virtualRow.start}
              rowHeight={rowHeight}
              // columnEntries, onRowClick 등 나머지 props는 생략
            />
          );
        })}
      </div>
    </div>
  );
}

4. 도입 결과: DOM 노드 10,000개 → 약 200개

가상화 도입 후, 데이터 규모와 무관하게 일정한 성능을 유지할 수 있었습니다. 측정 결과는 다음과 같습니다.

측정 항목도입 전 (1,000건 기준)도입 후 (10,000건 이상 가능)
DOM 노드 수10,000개 이상약 200개 내외 유지
체크박스 반응 속도약 2~3초 (지연 발생)즉시 반응 (0.1초 미만)
사이드 패널 오픈3~5초 뒤 열림지연 없이 즉시 오픈

가상화 도입으로 인해, 데이터가 많아져도 일정한 성능을 유지하는 안정적인 테이블 구조를 갖추게 되었습니다.

다양한 요구사항에도 대처 가능한 유연한 구조 설계

기존 테이블 컴포넌트는 모든 구성 데이터를 props 하나에 의존해 전달받는 구조였습니다. 하지만 서비스가 성장하며 요구사항이 복잡해질수록, 이러한 방식은 여러 문제를 야기했습니다.

기존 테이블의 문제점

  1. 고정된 타입 기반 렌더링의 한계 테이블을 구성하는 데이터는 사전에 정의된 타입에 따라 정해진 형태로만 출력되었습니다. 이런 이유로 아주 미세한 커스텀 UI가 필요하더라도 매번 새로운 타입을 추가 정의하거나, 기존 로직을 우회하는 방식으로 대응해야 했습니다. 이는 코드의 복잡도를 높이고 유지보수를 어렵게 만드는 원인이 되었습니다.

  2. 스타일 강제 주입으로 인한 결합도 상승 스타일링 방식 역시 유연하지 못했습니다. 특정 셀이나 로우의 스타일을 수정하려면 부모 테이블의 className을 기점으로 자식 요소를 깊게 탐색하여 강제로 스타일을 오버라이드해야 했습니다.

해결책: 컴파운드 컴포넌트 패턴

컴파운드 컴포넌트 패턴 (Compound Component Pattern): 함께 동작하는 컴포넌트들의 집합 하나의 기능을 수행하기 위해 여러 개의 하위 컴포넌트가 협력하는 설계 방식입니다.

HTML의 <select>와 <option> 태그 관계처럼, 부모 컴포넌트가 전체적인 상태를 관리하고 자식 컴포넌트들이 그 상태를 공유하며 각자의 UI를 담당하는 구조를 말합니다.

컴파운드 컴포넌트 패턴을 도입해 페이지네이션, 무한 스크롤 등의 부가 기능을 테이블 컴포넌트를 직접 수정하지 않고도 추가할 수 있는 구조로 만들었습니다.

추가 고려: 어떤 형식으로 사용되어야 할까?

처음에는 다음과 같은 구조를 생각했습니다.

<Table>
  <Table.Header>
    <Table.HeaderCell width={80}>타이틀1</Table.HeaderCell>
    <Table.HeaderCell width={100}>타이틀2</Table.HeaderCell>
  </Table.Header>
  <Table.Body>
    <Table.BodyCell width={80}>{데이터1}</Table.BodyCell>
    <Table.BodyCell width={100}>{데이터2}</Table.BodyCell>
  </Table.Body>
</Table>

하지만 이 구조는 header와 body의 관계를 파악하려면 셀이 몇 번째 위치에 있는지 일일이 따져야 했습니다. 셀이 많아지게 된다면 분명 header나 body의 셀을 누락하는 휴먼 이슈가 발생할 것이 보였습니다.

이 구조를 개선하기 위해 데이터 관리의 주체를 Table 루트 컴포넌트로 한정하고, 나머지 하위 컴포넌트는 이를 받아 사용하도록 했습니다.

const table = {
    data1: {
        header: '타이틀1',
        width: 80,
        cell: '데이터1'
    },
    data2: {
        header: '타이틀2',
        width: 100,
        cell: '데이터2'
    }
}
<Table table={table}>
  <Table.Header />
  <Table.Body />
</Table>

이 구조 덕분에 header cell과 body cell의 동기화·유지보수 이슈를 해결할 수 있었습니다. 또한 Table 루트 컴포넌트가 주입받은 설정 객체를 Context API에 저장해 두면, HeaderCell이나 BodyCell도 복잡한 Props Drilling 없이 설정 정보에 접근할 수 있습니다.

컴포넌트 구조 및 사용 예시

새로 설계된 테이블 컴포넌트는 다음과 같은 역할별 컴포넌트로 구성됩니다.

컴포넌트역할주요 기능
TableRoot컨테이너Context Provider, 레이아웃 구조 제공
TableToolbar툴바 영역검색, 필터 등 테이블 상단 UI 배치
TableHeader헤더 영역컬럼명 표시, 커스텀 렌더러 지원
TableBody본문 영역가상화된 행 및 그룹 구분선 렌더링
TableObserver무한 스크롤스크롤 끝 감지, 추가 데이터 로드 트리거 (Body 내부에 배치)
TableFooter푸터 영역페이지네이션 등 하단 UI 배치

이 컴포넌트들을 상황에 따라 조합하여 직관적으로 코드를 작성할 수 있게 되었습니다. 또한 반드시 전용 컴포넌트가 아니어도 <Pagination />과 같은 컴포넌트를 원하는 위치에 넣어 동작시킬 수 있어 확장성을 크게 높였습니다.

// 무한 스크롤 테이블 예시
const columns: Columns<SampleData> = useMemo(
  () => ({
    identifier: { header: '식별자', width: 80 },
    name: { header: '상품명', width: 200 },
    status: {
      header: '상태',
      width: 100,
      cell: (value) => <Status value={value} />,
    },
    ...
  }),
  [],
);
const { data, fetchNext, ... } = useGetProductsQuery();

const table = useTable({ data, columns });

return (
  <Table table={table}>
    <Table.Toolbar>{/* 상단 필터 버튼 등 */}</Table.Toolbar>
    <Table.Header />
    <Table.Body onRowClick={handleRowClick} />
    <Table.Observer onIntersect={fetchNext} />
  </Table>
);
// 페이지네이션 테이블 예시
const columns: Columns<SampleData> = useMemo(
  () => ({
    identifier: { header: '식별자', width: 80 },
    name: { header: '상품명', width: 200 },
    status: {
      header: '상태',
      width: 100,
      cell: (value) => <Status value={value} />,
    },
    ...
  }),
  [],
);

const table = useTable({ data: paginatedData, columns });

return (
  <Table table={table}>
    <Table.Header />
    <Table.Body onRowClick={handleRowClick} />
    <Table.Footer>
      <Pagination />
    </Table.Footer>
  </Table>
);

마치며

새로운 테이블 컴포넌트를 설계하면서 가장 중요하게 고려한 사항은 세 가지였습니다.

  1. 성능 최적화: 많은 데이터가 있어도 성능 저하가 발생하지 않아야 합니다.
  2. 높은 확장성: 컴포넌트 자체를 수정하지 않고도 대부분의 요구사항에 대응할 수 있는 구조여야 합니다.
  3. 복잡도 감소: 복잡한 구현 및 분기 처리 로직을 제거하여 개발 및 이해에 드는 공수를 줄여야 합니다.

이번 테이블 컴포넌트 개편을 통해 대량의 데이터를 다루는 웹 애플리케이션에서 성능 문제를 해결하고, 다양한 기획 및 디자인 요구사항에 유연하게 대응할 수 있는 구조를 만들 수 있었습니다.

또한 개발 이후 타 팀에서 데이터가 많아도 더 이상 끊기지 않아 업무 속도가 빨라졌다는 긍정적인 피드백을 받을 수 있어 뿌듯했습니다.