Skip to content

Commit 6a6ff51

Browse files
authored
[REFACTOR] DataTable 가상화 스크롤로 적용 (#476)
1 parent 9a6b0a0 commit 6a6ff51

File tree

9 files changed

+13341
-9152
lines changed

9 files changed

+13341
-9152
lines changed

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"@commitlint/cli": "^20.3.1",
2525
"@commitlint/config-conventional": "^20.3.1",
2626
"@datadog/browser-rum": "^6.26.0",
27+
"@tanstack/react-virtual": "^3.13.0",
2728
"@tosspayments/tosspayments-sdk": "^2.5.0",
2829
"@vercel/blob": "^2.2.0",
2930
"agentation": "^1.3.2",

frontend/src/app/(sidebar)/dashboard/_components/dashboard-table/ExpenseTableToolbar.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use client";
22

3+
import { memo } from "react";
34
import type { ExpenseTableToolbarProps } from "@/app/(sidebar)/dashboard/_types";
45
import { formatAmount } from "@/utils/amount";
56
import Button from "@/components/common/Button/Button";
@@ -10,7 +11,7 @@ import { cn } from "@/utils/style";
1011
* - 좌측: 선택 삭제, 선택 합치기
1112
* - 우측: 지출 합계, 저장하기
1213
*/
13-
export default function ExpenseTableToolbar({
14+
const ExpenseTableToolbar = memo(function ExpenseTableToolbar({
1415
totalExpense = 0,
1516
selectedCount = 0,
1617
hasUnsavedChanges = false,
@@ -73,4 +74,6 @@ export default function ExpenseTableToolbar({
7374
</div>
7475
</div>
7576
);
76-
}
77+
});
78+
79+
export default ExpenseTableToolbar;

frontend/src/components/ui/DataTable/ClientDataTable.tsx

Lines changed: 71 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
"use client";
22

3-
import type { ReactNode } from "react";
3+
import { memo, useMemo, useRef } from "react";
44
import type { DataTableProps, DataTableColumn, DataTableShellCol } from "./dataTable.type";
5-
import { TH_BASE_CLASS, TD_CELL_WRAPPER_BASE } from "./dataTableConstants";
5+
import { TH_BASE_CLASS, VIRTUAL_ROW_HEIGHT } from "./dataTableConstants";
66
import DataTableShell from "./DataTableShell";
7+
import VirtualRow from "./VirtualRow";
78
import { cn } from "@/utils/style";
89
import ArrowUpIcon from "@/assets/icons/components/arrow-up.svg";
910
import ArrowDownIcon from "@/assets/icons/components/arrow-down.svg";
11+
import { useVirtualizer } from "@tanstack/react-virtual";
1012

1113
// --- 정렬 아이콘 ---
12-
const SortIcon = ({ order }: { order: "asc" | "desc" }) => {
14+
const SortIcon = memo(function SortIcon({ order }: { order: "asc" | "desc" }) {
1315
const Icon = order === "asc" ? ArrowUpIcon : ArrowDownIcon;
1416

1517
return (
@@ -20,7 +22,7 @@ const SortIcon = ({ order }: { order: "asc" | "desc" }) => {
2022
<Icon aria-hidden="true" />
2123
</span>
2224
);
23-
};
25+
});
2426

2527
// --- 헤더 셀 렌더 ---
2628
const renderHeaderCell = <T,>(
@@ -68,45 +70,6 @@ const renderHeaderCell = <T,>(
6870
);
6971
};
7072

71-
// --- 바디 셀 렌더 ---
72-
const renderBodyCell = <T,>(
73-
col: DataTableColumn<T>,
74-
row: T,
75-
rowIndex: number,
76-
selectedCell: DataTableProps<T>["selectedCell"],
77-
mode: "read" | "edit",
78-
onCellClick: DataTableProps<T>["onCellClick"],
79-
renderCell: (col: DataTableColumn<T>, row: T, rowIndex: number) => ReactNode,
80-
) => {
81-
const isSelected = selectedCell?.rowIndex === rowIndex && selectedCell?.accessor === col.accessor;
82-
const isLifted = mode === "edit" && isSelected;
83-
84-
return (
85-
<td
86-
key={`${rowIndex}-${String(col.accessor)}`}
87-
className={cn(
88-
"h-[48px] align-top overflow-visible bg-transparent p-0 border-b border-gray-50",
89-
isLifted ? "relative z-10" : "",
90-
)}
91-
style={{ width: col.width }}
92-
data-row-index={rowIndex}
93-
data-accessor={String(col.accessor)}
94-
data-cell-id={`${rowIndex}-${String(col.accessor)}`}
95-
onClick={onCellClick ? () => onCellClick(rowIndex, col.accessor) : undefined}
96-
>
97-
<div
98-
className={cn(
99-
TD_CELL_WRAPPER_BASE,
100-
isSelected ? "ring-2 ring-yellow-300 ring-inset" : "",
101-
isLifted ? "-translate-y-0.5 shadow-sm ring-2 ring-yellow-300 ring-inset" : "",
102-
)}
103-
>
104-
{renderCell(col, row, rowIndex)}
105-
</div>
106-
</td>
107-
);
108-
};
109-
11073
/**
11174
* 정렬·셀 클릭·키보드 네비게이션을 지원하는 Client 전용 DataTable
11275
*/
@@ -122,52 +85,89 @@ const ClientDataTable = <T,>({
12285
onCellClick,
12386
onKeyDown,
12487
bottomSlot,
125-
scrollContainerRef,
88+
scrollContainerRef: externalRef,
12689
...rest
12790
}: DataTableProps<T>) => {
91+
"use no memo";
92+
12893
const hasCellInteraction = mode === "edit" && (onCellClick ?? onKeyDown);
12994

130-
const renderCell = (col: DataTableColumn<T>, row: T, rowIndex: number) => {
131-
return col.render ? (
132-
col.render(row[col.accessor], row, rowIndex)
133-
) : mode === "edit" && col.editor ? (
134-
col.editor(row[col.accessor], row, rowIndex)
135-
) : (
136-
<span>
137-
{row[col.accessor] == null || row[col.accessor] === "" ? "-" : String(row[col.accessor])}
138-
</span>
139-
);
140-
};
141-
142-
const colgroupColumns: DataTableShellCol[] = columns.map((c) => ({
143-
accessor: String(c.accessor),
144-
width: c.width,
145-
}));
95+
// 외부 ref가 없을 경우 내부 fallback ref 사용 (읽기 전용 테이블에서 사용)
96+
const internalRef = useRef<HTMLDivElement>(null);
97+
const scrollRef = externalRef ?? internalRef;
98+
99+
const virtualizer = useVirtualizer({
100+
count: data.length,
101+
getScrollElement: () => scrollRef.current,
102+
estimateSize: () => VIRTUAL_ROW_HEIGHT,
103+
overscan: 5,
104+
});
105+
106+
const virtualItems = virtualizer.getVirtualItems();
107+
108+
const colgroupColumns = useMemo<DataTableShellCol[]>(
109+
() => columns.map((c) => ({ accessor: String(c.accessor), width: c.width })),
110+
[columns],
111+
);
112+
113+
const headerSlot = useMemo(
114+
() => (
115+
<thead className="bg-gray-50">
116+
<tr>{columns.map((col) => renderHeaderCell(col, sortConfig, onSort))}</tr>
117+
</thead>
118+
),
119+
[columns, sortConfig, onSort],
120+
);
146121

147122
return (
148123
<DataTableShell
149124
className={className}
150125
columns={colgroupColumns}
151126
tabIndex={hasCellInteraction ? 0 : undefined}
152127
onKeyDown={hasCellInteraction ? onKeyDown : undefined}
128+
headerSlot={headerSlot}
153129
bottomSlot={bottomSlot}
154-
scrollContainerRef={scrollContainerRef}
130+
scrollContainerRef={scrollRef}
155131
{...rest}
156132
>
157-
<thead className="bg-gray-50 sticky top-0 z-10">
158-
<tr>{columns.map((col) => renderHeaderCell(col, sortConfig, onSort))}</tr>
159-
</thead>
160133
<tbody>
161-
{data.map((row, rowIndex) => (
134+
{/* 위쪽 spacer */}
135+
{virtualItems.length > 0 && virtualItems[0] && (
136+
<tr style={{ height: `${virtualItems[0].start}px` }} aria-hidden="true">
137+
<td colSpan={columns.length} />
138+
</tr>
139+
)}
140+
141+
{virtualItems.map((virtualRow) => {
142+
const row = data[virtualRow.index];
143+
if (!row) return null;
144+
145+
return (
146+
<VirtualRow
147+
key={rowKey ? rowKey(row, virtualRow.index) : virtualRow.index}
148+
virtualIndex={virtualRow.index}
149+
row={row}
150+
columns={columns}
151+
selectedAccessor={
152+
selectedCell?.rowIndex === virtualRow.index ? selectedCell.accessor : null
153+
}
154+
mode={mode}
155+
onCellClick={onCellClick}
156+
/>
157+
);
158+
})}
159+
160+
{/* 아래쪽 spacer */}
161+
{virtualItems.length > 0 && virtualItems[virtualItems.length - 1] && (
162162
<tr
163-
key={rowKey ? rowKey(row, rowIndex) : rowIndex}
164-
className="border-b border-gray-50 last:border-b-0"
163+
style={{
164+
height: `${virtualizer.getTotalSize() - (virtualItems[virtualItems.length - 1]?.end ?? 0)}px`,
165+
}}
166+
aria-hidden="true"
165167
>
166-
{columns.map((col) =>
167-
renderBodyCell(col, row, rowIndex, selectedCell, mode, onCellClick, renderCell),
168-
)}
168+
<td colSpan={columns.length} />
169169
</tr>
170-
))}
170+
)}
171171
</tbody>
172172
</DataTableShell>
173173
);

frontend/src/components/ui/DataTable/DataTable.tsx

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,22 @@ const DataTable = <T,>({
3232
}));
3333

3434
return (
35-
<DataTableShell className={className} columns={colgroupColumns} {...rest}>
36-
<thead className="bg-gray-50 sticky top-0 z-10">
37-
<tr>
38-
{columns.map((col) => (
39-
<th key={String(col.accessor)} className={TH_BASE_CLASS} style={{ width: col.width }}>
40-
{col.label}
41-
</th>
42-
))}
43-
</tr>
44-
</thead>
35+
<DataTableShell
36+
className={className}
37+
columns={colgroupColumns}
38+
headerSlot={
39+
<thead className="bg-gray-50">
40+
<tr>
41+
{columns.map((col) => (
42+
<th key={String(col.accessor)} className={TH_BASE_CLASS} style={{ width: col.width }}>
43+
{col.label}
44+
</th>
45+
))}
46+
</tr>
47+
</thead>
48+
}
49+
{...rest}
50+
>
4551
<tbody>
4652
{data.map((row, rowIndex) => (
4753
<tr
Lines changed: 34 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,52 @@
1+
"use client";
2+
3+
import { memo } from "react";
14
import { cn } from "@/utils/style";
25
import type { DataTableShellProps } from "./dataTable.type";
36
/**
47
* 테이블 공통 레이아웃 (wrapper, colgroup, table)
58
* DataTable / DataTableInteractive에서 공유
69
*/
7-
const DataTableShell = ({
10+
const DataTableShell = memo(function DataTableShell({
811
className,
912
columns,
1013
children,
14+
headerSlot,
1115
bottomSlot,
1216
scrollContainerRef,
1317
tabIndex,
1418
onKeyDown,
1519
...tableRest
16-
}: DataTableShellProps) => (
17-
<div
18-
className={cn(
19-
"flex flex-col w-full border border-gray-50 rounded-600 overflow-hidden min-h-0",
20-
className ?? "",
21-
)}
22-
tabIndex={tabIndex}
23-
onKeyDown={onKeyDown}
24-
>
25-
<div ref={scrollContainerRef} className="data-table-scroll flex-1 min-h-0 overflow-auto">
26-
<table
27-
{...tableRest}
28-
className="w-full border-separate border-spacing-0 text-left table-fixed"
29-
>
30-
<colgroup>
31-
{columns.map((col) => (
32-
<col key={String(col.accessor)} style={{ width: col.width }} />
33-
))}
34-
</colgroup>
35-
{children}
20+
}: DataTableShellProps) {
21+
const colDefs = columns.map((col) => (
22+
<col key={String(col.accessor)} style={{ width: col.width }} />
23+
));
24+
25+
return (
26+
<div
27+
className={cn(
28+
"flex flex-col w-full border border-gray-50 rounded-600 overflow-hidden min-h-0",
29+
className ?? "",
30+
)}
31+
tabIndex={tabIndex}
32+
onKeyDown={onKeyDown}
33+
>
34+
<table className="w-full border-separate border-spacing-0 text-left table-fixed shrink-0">
35+
<colgroup>{colDefs}</colgroup>
36+
{headerSlot}
3637
</table>
37-
{bottomSlot}
38+
<div ref={scrollContainerRef} className="data-table-scroll flex-1 min-h-0 overflow-auto">
39+
<table
40+
{...tableRest}
41+
className="w-full border-separate border-spacing-0 text-left table-fixed"
42+
>
43+
<colgroup>{colDefs}</colgroup>
44+
{children}
45+
</table>
46+
{bottomSlot}
47+
</div>
3848
</div>
39-
</div>
40-
);
49+
);
50+
});
4151

4252
export default DataTableShell;

0 commit comments

Comments
 (0)