신입때 만든 테이블 컴포넌트를 되살려보자
신입 시절 작성한 엉망진창 테이블 컴포넌트를 리팩토링하며, 재사용성과 테스트 가능성을 확보하고 과거와 현재 나의 성장을 실험한 기록
들어가며
최근 시각화 작업이나 테이블을 다룰 일들이 많아졌는데 예전 프로젝트를 찾아보던 중 완전 신입때 싸질러 놓은 똥 같은 테이블 컴포넌트를 발견했다.
그때는 기능 빠르게 찍어내는 게 실력이라고 착각하던 시절이라 유지보수나 확장성 따위는 고려하지 않았다. 컬럼 하나 바꾸려면 여러 파일을 뒤져야 했고, 새로운 테이블 추가도 매번 처음부터 구현해야 했다. 당연히 테스트는 꿈도 못 꿨다.
이번에는 그때의 나보다 조금이나마 성장한 지금의 내가 그때 쌓였던 기술 부채를 어떻게 고치고 해결할 수 있을지 실험해보고자하는 글이며 + 셀프 반성 글이다.
단순히 코드를 고치는 게 아니라, 재사용성과 확장성을 확보하면서도 테스트 가능한 구조로 리팩토링하는 과정을 기록하려 한다.
내가 만든 테이블의 문제점
export default function UserTableView() {
const { data, isLoading, error } = useQuery(/* ... */);
const [sortConfig, setSortConfig] = useState(/* ... */);
const handleSort = useCallback((field: string) => {
// 복잡한 정렬 로직...
// 각 필드마다 정렬 기준이 다를때는 어떻게 처리할 것인지?
if(sortConfig.order ==='asc'){
}
else if(sortConfig.order==='desc'){
}
else{
}
}, [data, sortConfig]);
return (
<TableContainer>
<TableHeader>
<TableRow>
<TableHeaderCell onClick={() => handleSort("id")}>아이디</TableHeaderCell>
<TableHeaderCell onClick={() => handleSort("name")}>이름</TableHeaderCell>
<TableHeaderCell onClick={() => handleSort("status")}>상태</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{/* 데이터 렌더링 */}
</TableBody>
</TableContainer>
);
}
이전에 내가 작성한 테이블 컴포넌트를 보면 다음과 같은 문제가 있었다.
- 코드 중복
- 확장성 문제
- 테스트 난이도
→ 모든 테이블에서 정렬, 필터링, 페이징 로직을 반복 작성해야 했다.
→ 새로운 테이블을 만들 때마다 거의 동일한 코드를 다시 구현해야 했다.
→ 정렬 기준이 다른 필드들은 분기처리가 필요했다.
→ 한 컴포넌트 안에 데이터, 정렬, 렌더링이 섞여 있어 테스트가 거의 불가능한 수준이다.
이 문제들을 해결하려면 단순히 UI만 바꾸는 수준이 아니라 데이터와 UI를 분리하고 테이블 로직을 재사용 가능하게 만드는 설계가 필요하다고 생각했다.
어떻게 리팩토링을 할것인가?
리팩토링에서 고민한 핵심 질문을 정리해보면 두 가지였다.
- 재사용성을 어떻게 확보할까?
- 데이터와 UI를 어떻게 분리할까?
→ 테이블 UI는 다르지만, 정렬/페이징/필터링 로직은 거의 동일하다. 이 로직을 반복하지 않고, 한 곳에서 관리하면서 모든 테이블에서 재사용하고 싶었다.
→ 기존 구조는 컬럼 정의, 데이터, 렌더링이 한 컴포넌트 안에 몰려 있어, 작은 변경에도 전체를 수정해야 하는 문제점이 있었다.
이를 해결하기 위해 다음과 같은 순서로 리팩토링을 해보기로 했다.
데이터와 컬럼 정의를 격리
- 컬럼 정의를 설정 객체로 만들고, 데이터는 API 훅이나 상위 컴포넌트에서 관리한다
DataTable 내부에서 정렬 상태 관리
- 테이블 헤더를 클릭 시 컬럼에서 미리 정의해서 주입한
cmp함수를 기반으로 오름차순 → 내림차순 → 정렬 해제 순서로 정렬한다
렌더링과 데이터 로직 분리
- DataTable은 컬럼과 데이터를 받아 렌더링만 담당하며, 정렬 상태만 관리한다
이 기준을 가지고 리팩토링을 시작해보자.
데이터와 컬럼을 격리시켜보자
기존 코드에서는 컬럼 정의와 데이터가 혼재되어있었다. 기존 구조에서는 컬럼 하나를 수정할 때마다 데이터를 함께 살펴야 했고, 새로운 테이블을 추가할 때 거의 동일한 코드를 반복해야하는 문제점이 있었다.
나는 컬럼과 데이터를 완전히 격리시켜 props를 관리했고. render props 패턴으로 각 테이블의 데이터를 렌더링할 수 있도록 수정했다.
export interface Column<D extends object> {
title: string;
render: (row: D) => React.ReactNode;
}
export const userColumns: Column<User>[] = [
{ title: '이메일', render: user => user.email},
{ title: '상태', render: user => <StatusBadge status={user.status}>{user.status}</StatusBadge>}
];render props 패턴을 활용하면 스타일에 대한 의존성을 모두 제거할 수 있고 외부에서 원하는 스타일을 주입할 수 있어서 확장성이 뛰어나다.
이렇게 컬럼과 데이터를 분리하면 다음과 같은 장점이 있다.
- 컬럼 정의만 바꾸면 UI를 자유롭게 수정 가능하다
- DataTable은 렌더링만 담당한다. → 어떤 데이터든 재사용 가능
- 새로운 도메인 테이블 추가 시 컬럼와 데이터만 추가하면 바로 재사용이 가능하다.
- render props 패턴을 통해 칼럼 스타일을 외부에서 정의할 수 있어 확장성이 뛰어나다.
즉, 데이터와 컬럼을 격리함으로써 재사용성과 확장성을 동시에 확보했다.
정렬을 외부에서 주입하자
이제 DataTable 컴포넌트는 칼럼과 데이터를 기반으로 렌더링만 담당하게끔 구현했다. 데이터와 컬럼을 분리하면서 구조가 한층 명확해졌지만, 여전히 고민되는 부분이 하나 있었다. 바로 정렬 로직을 어디서 관리할 것인가였다.
처음에는 단순히 DataTable 내부에서 정렬 기준을 관리하고, 클릭 시마다 상태를 바꾸는 형태로 구현했다. 그런데 막상 몇 개의 테이블에 적용해보니 정렬 방식이 도메인마다 달라서 한 가지 정렬 로직으로는 충분하지 않았다.
예를 들어
- 사용자 테이블은 이름 오름차순
- 상품 테이블은 재고량 내림차순
- 주문 테이블은 날짜 기준 최신순
이런 요구사항이 계속 나오다 보니, “정렬 로직을 DataTable 안에 넣는 게 맞을까?”라는 의문이 들었다.
결국 내린 결론은 정렬 자체는 DataTable의 책임이지만, 정렬 기준(cmp)은 외부에서 주입받자였다. DataTable은 그저 "정렬해야 한다"는 사실만 알고, “어떻게 정렬할지”는 컬럼이 스스로 결정하는 구조다.
이렇게 하면 DataTable은 완전히 도메인에 독립적인 컴포넌트가 된다.
그러면 다시 리팩토링을 시작해보자.
Column 타입의 확장
정렬 기준을 컬럼에서 직접 정의할 수 있도록 Column 타입을 확장했다.
sortable 속성으로 정렬 가능 여부를 제어하고, cmp 함수로 실제 비교 로직을 주입한다.
export interface Column<D extends object> {
title: string;
render: (row: D) => React.ReactNode;
sortable?: boolean;
cmp?: (a: D, b: D) => number; // 컬럼별 비교 함수를 외부에서 주입
}
사용 예시는 다음과 같다.
export const userColumns: Column<User>[] = [
{ title: '이메일', render: user => user.email, sortable: true, cmp: (a, b) => a.email.localeCompare(b.email) },
{ title: '이름', render: user => user.name, sortable: true, cmp: (a, b) => a.name.localeCompare(b.name) },
{ title: '상태', render: user => <StatusBadge status={user.status}>{user.status}</StatusBadge>, sortable: true, cmp: (a, b) => a.status.localeCompare(b.status) },
];각 칼럼은 스스로 정렬 기준을 알고 있으므로, DataTable은 단지 “이 컬럼이 정렬 가능하다면 cmp를 호출해서 정렬하자” 정도의 역할만 수행한다.
DataTable 구현
function DataTable<D extends object>({ data, columns }: { data: D[]; columns: Column<D>[] }) {
const [sortConfig, setSortConfig] = useState<{ columnIndex: number | null; direction: 'asc' | 'desc' | null }>({ columnIndex: null, direction: null });
const handleSort = useCallback((columnIndex: number) => {
setSortConfig(prev => {
if (prev.columnIndex === columnIndex) {
if (prev.direction === 'asc') return { columnIndex, direction: 'desc' };
if (prev.direction === 'desc') return { columnIndex, direction: null };
}
return { columnIndex, direction: 'asc' };
});
}, []);
const sortedData = useMemo(() => {
if (sortConfig.columnIndex === null || sortConfig.direction === null) return data;
const column = columns[sortConfig.columnIndex];
if (!column.cmp) return data;
return [...data].sort((a, b) => {
const result = column.cmp!(a, b);
return sortConfig.direction === 'asc' ? result : -result;
});
}, [data, columns, sortConfig]);
return (
<TableContainer>
<TableHeader>
<TableRow>
{columns.map((col, idx) => (
<TableHeaderCell key={idx} onClick={() => col.sortable && handleSort(idx)}>
{col.title}
</TableHeaderCell>
))}
</TableRow>
</TableHeader>
<TableBody>
{sortedData.map((row, idx) => (
<TableRow key={idx}>
{columns.map((col, cIdx) => (
<TableCell key={cIdx}>{col.render(row)}</TableCell>
))}
</TableRow>
))}
</TableBody>
</TableContainer>
);
}
이렇게 설계했을 때 장점을 정리해보자면
- DataTable은 단일 책임만 가진다.
- 도메인별 유연한 정렬 기준 적용
- 테스트와 유지보수 용이
정렬 기준을 모르기 때문에, 어떤 데이터든 정렬 가능하다.
오직 “정렬을 요청받으면 실행하는 것”만 담당한다.
각 컬럼에서 cmp만 바꿔주면 정렬 방식이 완전히 달라질 수 있다.
숫자, 문자열, 날짜 등 어떤 타입이든 대응 가능하다.
정렬 로직이 DataTable 내부에서 캡슐화되어 있으므로 테스트가 단순해진다.
컬럼만 바꿔도 원하는 정렬 동작을 쉽게 검증할 수 있다.
테이블 재사용과 확장성
테이블을 리팩토링하면서 가장 크게 얻은 결과는 “이제 테이블 하나로 대부분의 리스트 페이지를 커버할 수 있다”는 점이었다.
예전에는 사용자 목록, 주문 목록, 상품 목록을 각각 따로 만들었고, 테이블 헤더나 정렬 로직도 거의 비슷한데 컴포넌트가 다 따로였다.
결국 같은 코드를 조금씩 수정해서 세 군데에 복붙한 셈이었고… 유지보수는 점점 지옥이 되어가서 프로젝트를 방치했다.
이번 리팩토링의 핵심은 DataTable을 완전히 도메인 독립적으로 설계한 점이다.
즉, DataTable은 “데이터를 그리는 역할만”, 정렬이나 렌더링 방식은 “컬럼이 결정”하도록 설계했기 때문에, 새로운 테이블이 생겨도 DataTable은 건드릴 일이 없었다.
사용자 테이블은
export const userColumns: Column<User>[] = [
{ title: '이메일', render: user => user.email, sortable: true, cmp: (a, b) => a.email.localeCompare(b.email) },
{ title: '이름', render: user => user.name, sortable: true, cmp: (a, b) => a.name.localeCompare(b.name) },
{ title: '상태', render: user => <StatusBadge status={user.status}>{user.status}</StatusBadge>, sortable: true, cmp: (a, b) => a.status.localeCompare(b.status) },
];
export function UserList({ users }: { users: User[] }) {
return <DataTable data={users} columns={userColumns} />;
}
상품 테이블은
export const productColumns: Column<Product>[] = [
{ title: '상품명', render: p => p.name, sortable: true, cmp: (a, b) => a.name.localeCompare(b.name) },
{ title: '재고', render: p => p.stock.toLocaleString(), sortable: true, cmp: (a, b) => a.stock - b.stock },
{ title: '가격', render: p => `${p.price.toLocaleString()}원`, sortable: true, cmp: (a, b) => a.price - b.price },
];
export function ProductList({ products }: { products: Product[] }) {
return <DataTable data={products} columns={productColumns} />;
}
이렇게 컬럼만 바꾸면 새로운 도메인의 테이블도 손쉽게 추가 가능하며, DataTable의 정렬 로직은 그대로 재사용할 수 있게 성공적으로 리팩토링할 수 있었다.
개별 테이블 테스트를 해보자
테스트도 물론 가능해졌다.
기존에는 비즈니스 로직과 UI 렌더링이 한 컴포넌트에 뒤섞여 있어서
테스트를 작성하기가 사실상 불가능했다. 정렬 하나만 테스트하려고 해도 API 호출 → 상태 관리 → 렌더링까지 전부다 신경을 써야하는 구조였다.
하지만 리팩토링 이후엔 구조적으로 테스트가 쉬워졌다. DataTable은 단순히 렌더링만, 그리고 정렬만 담당하면 되니까 외부에서 테스트하기도 훨씬 간편해졌다. 사실상 실제 DOM 상의 변화만 검증하면 된다.
import { render, screen, fireEvent } from '@testing-library/react';
import DataTable from '../DataTable';
import { userColumns } from '../userColumns';
const mockUsers = [
{ id: '1', name: '홍길동', status: 'ACTIVE' },
{ id: '2', name: '박창준, status: 'FREE' },
];
test('DataTable 정렬 테스트', () => {
render(<DataTable data={mockUsers} columns={userColumns} />);
const nameHeader = screen.getByText('이름');
fireEvent.click(nameHeader);
const rows = screen.getAllByRole('row');
expect(rows[1]).toHaveTextContent('박창준'); // 오름차순 확인
});
마무리
이번 리팩토링은 완전 신입 때 작성한 테이블 컴포넌트를 단순히 고치는것을 넘어 지금의 나와 과거의 내가 얼마나 성장했는지 일종의 셀프 실험이었다.

최근에 강남언니의 기술블로그에서 [SaaS] 왜 우리는 뷰모델링을 중요하게 생각하는가 라는 글을 읽었는데 위와 같은 내용이 나온다.
예전엔 빨리 만드는 것이 잘한다고 착각했고 당장의 기능구현만 완료하면 뿌듯해했다. 하지만 조금만 요구사항이 바뀌어도 전혀 대응하지 못하는 코드를 작성한 지난 과거를 반성하고 앞으로는 개발자로서 나도 이 복잡함을 관리하는 습관을 길러야겠다는 생각이 들었다.
단순히 기능을 빠르게 만드는 것보다 데이터와 UI, 상태, 사용자 경험까지 모두 고려하면서 코드를 구조적으로 설계하는 습관을 기르는 것이 중요해지는 순간이다.