summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-12-03 08:30:24 +0900
committerjoonhoekim <26rote@gmail.com>2025-12-03 08:30:24 +0900
commit5fea18182821dfcc3203c5ea4bb0548ec995718a (patch)
tree7faf241404f34dbd8cb4052b4be56137607eed41
parent7bdddbacf8610140c0c9db7ccb09d546203ce380 (diff)
(김준회) 서버사이드 페칭 작업을 위한 어댑터 초안
-rw-r--r--components/client-table-v2/GUIDE.md178
-rw-r--r--components/client-table-v2/adapter/create-table-service.ts101
-rw-r--r--components/client-table-v2/adapter/drizzle-table-adapter.ts173
-rw-r--r--lib/table/server-query-builder.ts129
4 files changed, 452 insertions, 129 deletions
diff --git a/components/client-table-v2/GUIDE.md b/components/client-table-v2/GUIDE.md
new file mode 100644
index 00000000..4ccadfc7
--- /dev/null
+++ b/components/client-table-v2/GUIDE.md
@@ -0,0 +1,178 @@
+# Table Component & Data Fetching Guide
+
+이 가이드는 `ClientVirtualTable`을 사용하여 테이블을 구현하고, 데이터를 페칭하는 3가지 주요 패턴을 설명합니다.
+
+## 개요
+
+프로젝트의 복잡도와 요구사항에 따라 아래 3가지 패턴 중 하나를 선택하여 사용할 수 있습니다.
+
+| 모드 | 패턴 | 적합한 상황 | 특징 |
+|---|---|---|---|
+| **Client** | 1. Client-Side | 데이터가 적을 때 (< 1000건), 빠른 인터랙션 필요 | 전체 데이터 로드 후 브라우저에서 처리 |
+| **Server** | 2. Factory Service | 단순 CRUD, 마스터 테이블 | 코드 1줄로 서버 액션 생성, 빠른 개발 |
+| **Server** | 3. Custom Service | 복잡한 조인, 비즈니스 로직 | 완전한 쿼리 제어, Adapter를 도구로 사용 |
+
+---
+
+## 1. Client-Side (기본 모드)
+
+데이터가 많지 않을 때 가장 간단한 방법입니다. 모든 데이터를 한 번에 받아와 `data` prop으로 넘깁니다.
+
+### 사용법
+
+```tsx
+// page.tsx
+import { getAllUsers } from "@/lib/api/users";
+import { ClientVirtualTable } from "@/components/client-table-v2/client-virtual-table";
+import { columns } from "./columns";
+
+export default async function UsersPage() {
+ const users = await getAllUsers(); // 전체 목록 조회
+
+ return (
+ <ClientVirtualTable
+ fetchMode="client"
+ data={users}
+ columns={columns}
+ enablePagination
+ enableSorting
+ enableFiltering
+ />
+ );
+}
+```
+
+---
+
+## 2. Factory Service (추천 - 단순 조회용)
+
+`createTableService`를 사용하여 서버 사이드 페칭을 위한 액션을 자동으로 생성합니다.
+
+### 1) Server Action 생성
+
+```typescript
+// app/actions/user-table.ts
+"use server"
+
+import { db } from "@/lib/db";
+import { users } from "@/lib/db/schema";
+import { columns } from "@/components/users/columns";
+import { createTableService } from "@/components/client-table-v2/adapter/create-table-service";
+
+// 팩토리 함수로 액션 생성 (한 줄로 끝!)
+export const getUserTableData = createTableService({
+ db,
+ schema: users,
+ columns: columns
+});
+```
+
+### 2) 클라이언트 컴포넌트 연결
+
+```tsx
+// components/users/user-table.tsx
+"use client"
+
+import { getUserTableData } from "@/app/actions/user-table";
+import { ClientVirtualTable } from "@/components/client-table-v2/client-virtual-table";
+import { columns } from "./columns";
+import { useState, useEffect } from "react";
+
+export function UserTable() {
+ const [data, setData] = useState([]);
+ const [totalRows, setTotalRows] = useState(0);
+ const [loading, setLoading] = useState(false);
+
+ // 테이블 상태 관리
+ const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
+ const [sorting, setSorting] = useState([]);
+ const [columnFilters, setColumnFilters] = useState([]);
+ const [globalFilter, setGlobalFilter] = useState("");
+
+ // 데이터 페칭
+ useEffect(() => {
+ const fetchData = async () => {
+ setLoading(true);
+ const result = await getUserTableData({
+ pagination,
+ sorting,
+ columnFilters,
+ globalFilter
+ });
+ setData(result.data);
+ setTotalRows(result.totalRows);
+ setLoading(false);
+ };
+
+ fetchData();
+ }, [pagination, sorting, columnFilters, globalFilter]);
+
+ return (
+ <ClientVirtualTable
+ fetchMode="server"
+ data={data}
+ rowCount={totalRows}
+ columns={columns}
+ isLoading={loading}
+ // 상태 연결
+ pagination={pagination} onPaginationChange={setPagination}
+ sorting={sorting} onSortingChange={setSorting}
+ columnFilters={columnFilters} onColumnFiltersChange={setColumnFilters}
+ globalFilter={globalFilter} onGlobalFilterChange={setGlobalFilter}
+ />
+ );
+}
+```
+
+---
+
+## 3. Custom Service (복잡한 로직용)
+
+여러 테이블을 조인하거나, 특정 권한 체크 등 복잡한 로직이 필요할 때는 `DrizzleTableAdapter`를 직접 사용합니다.
+
+### 1) Custom Server Action 작성
+
+```typescript
+// app/actions/order-table.ts
+"use server"
+
+import { db } from "@/lib/db";
+import { orders, users } from "@/lib/db/schema";
+import { DrizzleTableAdapter } from "@/components/client-table-v2/adapter/drizzle-table-adapter";
+import { count, eq } from "drizzle-orm";
+
+export async function getOrderTableData(tableState) {
+ // 1. 어댑터로 조건절 생성
+ const adapter = new DrizzleTableAdapter(orders, columns);
+ const { where, orderBy, limit, offset } = adapter.getQueryParts(tableState);
+
+ // 2. 커스텀 쿼리 작성 (예: 유저 조인)
+ const data = await db
+ .select({
+ orderId: orders.id,
+ amount: orders.amount,
+ userName: users.name // 조인된 컬럼
+ })
+ .from(orders)
+ .leftJoin(users, eq(orders.userId, users.id))
+ .where(where)
+ .orderBy(...orderBy)
+ .limit(limit)
+ .offset(offset);
+
+ // 3. 카운트 쿼리
+ const total = await db
+ .select({ count: count() })
+ .from(orders)
+ .where(where);
+
+ return {
+ data,
+ totalRows: total[0]?.count ?? 0
+ };
+}
+```
+
+### 2) 클라이언트 연결
+
+Factory Service 방식과 동일하게 `useEffect`에서 `getOrderTableData`를 호출하면 됩니다.
diff --git a/components/client-table-v2/adapter/create-table-service.ts b/components/client-table-v2/adapter/create-table-service.ts
new file mode 100644
index 00000000..41c38906
--- /dev/null
+++ b/components/client-table-v2/adapter/create-table-service.ts
@@ -0,0 +1,101 @@
+import { PgTable } from "drizzle-orm/pg-core";
+import { DrizzleTableAdapter, DrizzleTableState } from "./drizzle-table-adapter";
+import { ColumnDef } from "@tanstack/react-table";
+import { SQL, and, count } from "drizzle-orm";
+
+// Define a minimal DB interface that we need
+// Adjust this to match your actual db instance type
+interface DbInstance {
+ select: (args?: any) => any;
+}
+
+export interface CreateTableServiceConfig<TData> {
+ /**
+ * Drizzle Database Instance
+ */
+ db: DbInstance;
+
+ /**
+ * Drizzle Table Schema (e.g. users, orders)
+ */
+ schema: PgTable; // Using PgTable as base, works for most Drizzle tables
+
+ /**
+ * React Table Columns Definition
+ * Used to map accessorKeys to DB columns
+ */
+ columns: ColumnDef<TData, any>[];
+
+ /**
+ * Optional: Custom WHERE clause to always apply (e.g. deleted_at IS NULL)
+ */
+ defaultWhere?: SQL;
+
+ /**
+ * Optional: Custom query modifier
+ * Allows joining other tables or selecting specific fields
+ */
+ customQuery?: (queryBuilder: any) => any;
+}
+
+/**
+ * Factory function to create a standardized server action for a table.
+ *
+ * @example
+ * export const getUsers = createTableService({
+ * db,
+ * schema: users,
+ * columns: userColumns
+ * });
+ */
+export function createTableService<TData>(config: CreateTableServiceConfig<TData>) {
+ const { db, schema, columns, defaultWhere, customQuery } = config;
+
+ // Return the actual Server Action function
+ return async function getTableData(tableState: DrizzleTableState) {
+ const adapter = new DrizzleTableAdapter(schema, columns);
+ const { where, orderBy, limit, offset, groupBy } = adapter.getQueryParts(tableState);
+
+ // Merge defaultWhere with dynamic where
+ const finalWhere = defaultWhere
+ ? (where ? and(defaultWhere, where) : defaultWhere)
+ : where;
+
+ // 1. Build Data Query
+ let dataQuery = db.select()
+ .from(schema)
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .limit(limit)
+ .offset(offset);
+
+ if (groupBy && groupBy.length > 0) {
+ dataQuery = dataQuery.groupBy(...groupBy);
+ }
+
+ // Apply custom query modifications (joins, etc)
+ if (customQuery) {
+ dataQuery = customQuery(dataQuery);
+ }
+
+ // 2. Build Count Query
+ const countQuery = db.select({ count: count() })
+ .from(schema)
+ .where(finalWhere);
+
+ // Execute queries
+ // We use Promise.all to run them in parallel
+ const [data, countResult] = await Promise.all([
+ dataQuery,
+ countQuery
+ ]);
+
+ const totalRows = Number(countResult[0]?.count ?? 0);
+
+ return {
+ data: data as TData[],
+ totalRows,
+ pageCount: Math.ceil(totalRows / (tableState.pagination?.pageSize ?? 10))
+ };
+ };
+}
diff --git a/components/client-table-v2/adapter/drizzle-table-adapter.ts b/components/client-table-v2/adapter/drizzle-table-adapter.ts
new file mode 100644
index 00000000..05bf4c5f
--- /dev/null
+++ b/components/client-table-v2/adapter/drizzle-table-adapter.ts
@@ -0,0 +1,173 @@
+import {
+ ColumnFiltersState,
+ SortingState,
+ PaginationState,
+ GroupingState,
+ ColumnDef
+} from "@tanstack/react-table";
+import {
+ SQL,
+ and,
+ or,
+ eq,
+ ilike,
+ gt,
+ lt,
+ gte,
+ lte,
+ inArray,
+ asc,
+ desc,
+ getTableColumns,
+} from "drizzle-orm";
+import { PgTable, PgView, PgColumn } from "drizzle-orm/pg-core";
+
+// Helper to detect if value is empty or undefined
+const isEmpty = (value: any) => value === undefined || value === null || value === "";
+
+export interface DrizzleTableState {
+ sorting?: SortingState;
+ columnFilters?: ColumnFiltersState;
+ globalFilter?: string;
+ pagination?: PaginationState;
+ grouping?: GroupingState;
+}
+
+export class DrizzleTableAdapter<TData> {
+ private columnMap: Map<string, PgColumn>;
+
+ constructor(
+ private table: PgTable | PgView,
+ private columns: ColumnDef<TData, any>[]
+ ) {
+ // Create a map of accessorKey -> Drizzle Column for fast lookup
+ this.columnMap = new Map();
+ // @ts-ignore - getTableColumns works on views in newer drizzle versions or we can cast
+ const drizzleColumns = getTableColumns(table as any);
+
+ columns.forEach(col => {
+ // We currently only support accessorKey which maps directly to a DB column
+ if ('accessorKey' in col && typeof col.accessorKey === 'string') {
+ const dbCol = drizzleColumns[col.accessorKey];
+ if (dbCol) {
+ this.columnMap.set(col.accessorKey, dbCol);
+ }
+ }
+ });
+ }
+
+ private getColumn(columnId: string): PgColumn | undefined {
+ return this.columnMap.get(columnId);
+ }
+
+ /**
+ * Build the WHERE clause based on column filters and global filter
+ */
+ getWhere(columnFilters?: ColumnFiltersState, globalFilter?: string): SQL | undefined {
+ const conditions: SQL[] = [];
+
+ // 1. Column Filters
+ if (columnFilters) {
+ for (const filter of columnFilters) {
+ const column = this.getColumn(filter.id);
+ if (!column) continue;
+
+ const value = filter.value;
+ if (isEmpty(value)) continue;
+
+ // Handle Array (range or multiple select)
+ if (Array.isArray(value)) {
+ // Range filter (e.g. [min, max])
+ if (value.length === 2 && (typeof value[0] === 'number' || typeof value[0] === 'string')) {
+ const [min, max] = value;
+ if (!isEmpty(min) && !isEmpty(max)) {
+ conditions.push(and(gte(column, min), lte(column, max))!);
+ } else if (!isEmpty(min)) {
+ conditions.push(gte(column, min)!);
+ } else if (!isEmpty(max)) {
+ conditions.push(lte(column, max)!);
+ }
+ }
+ // Multi-select (IN)
+ else if (value.length > 0) {
+ conditions.push(inArray(column, value)!);
+ }
+ }
+ // Boolean
+ else if (typeof value === 'boolean') {
+ conditions.push(eq(column, value)!);
+ }
+ // Number
+ else if (typeof value === 'number') {
+ conditions.push(eq(column, value)!);
+ }
+ // String (Search)
+ else if (typeof value === 'string') {
+ conditions.push(ilike(column, `%${value}%`)!);
+ }
+ }
+ }
+
+ // 2. Global Filter
+ if (globalFilter) {
+ const searchConditions: SQL[] = [];
+ this.columnMap.forEach((column) => {
+ // Implicitly supports only text-compatible columns for ilike
+ // Drizzle might throw if type mismatch, so user should be aware
+ searchConditions.push(ilike(column, `%${globalFilter}%`));
+ });
+
+ if (searchConditions.length > 0) {
+ conditions.push(or(...searchConditions)!);
+ }
+ }
+
+ return conditions.length > 0 ? and(...conditions) : undefined;
+ }
+
+ /**
+ * Build the ORDER BY clause
+ */
+ getOrderBy(sorting?: SortingState): SQL[] {
+ if (!sorting || !sorting.length) return [];
+
+ return sorting.map((sort) => {
+ const column = this.getColumn(sort.id);
+ if (!column) return null;
+ return sort.desc ? desc(column) : asc(column);
+ }).filter(Boolean) as SQL[];
+ }
+
+ /**
+ * Build the GROUP BY clause
+ */
+ getGroupBy(grouping?: GroupingState): SQL[] {
+ if (!grouping || !grouping.length) return [];
+
+ return grouping.map(g => this.getColumn(g)).filter(Boolean) as SQL[];
+ }
+
+ /**
+ * Get Limit and Offset
+ */
+ getPagination(pagination?: PaginationState) {
+ if (!pagination) return { limit: 10, offset: 0 };
+ return {
+ limit: pagination.pageSize,
+ offset: pagination.pageIndex * pagination.pageSize,
+ };
+ }
+
+ /**
+ * Helper to apply all state to a query builder.
+ * Returns the modifier objects that can be passed to drizzle query builder.
+ */
+ getQueryParts(state: DrizzleTableState) {
+ return {
+ where: this.getWhere(state.columnFilters, state.globalFilter),
+ orderBy: this.getOrderBy(state.sorting),
+ groupBy: this.getGroupBy(state.grouping),
+ ...this.getPagination(state.pagination)
+ };
+ }
+}
diff --git a/lib/table/server-query-builder.ts b/lib/table/server-query-builder.ts
deleted file mode 100644
index 7ea25313..00000000
--- a/lib/table/server-query-builder.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import {
- ColumnFiltersState,
- SortingState,
- PaginationState,
- GroupingState
-} from "@tanstack/react-table";
-import {
- SQL,
- and,
- or,
- eq,
- ilike,
- like,
- gt,
- lt,
- gte,
- lte,
- inArray,
- asc,
- desc,
- not,
- sql
-} from "drizzle-orm";
-import { PgTable } from "drizzle-orm/pg-core";
-
-/**
- * Table State를 Drizzle Query 조건으로 변환하는 유틸리티
- */
-export class TableQueryBuilder {
- private table: PgTable;
- private searchableColumns: string[];
-
- constructor(table: PgTable, searchableColumns: string[] = []) {
- this.table = table;
- this.searchableColumns = searchableColumns;
- }
-
- /**
- * Pagination State -> Limit/Offset
- */
- getPagination(pagination: PaginationState) {
- return {
- limit: pagination.pageSize,
- offset: pagination.pageIndex * pagination.pageSize,
- };
- }
-
- /**
- * Sorting State -> Order By
- */
- getOrderBy(sorting: SortingState) {
- if (!sorting.length) return [];
-
- return sorting.map((sort) => {
- // 컬럼 이름이 테이블에 존재하는지 확인
- const column = this.table[sort.id as keyof typeof this.table];
- if (!column) return null;
-
- return sort.desc ? desc(column) : asc(column);
- }).filter(Boolean) as SQL[];
- }
-
- /**
- * Column Filters -> Where Clause
- */
- getWhere(columnFilters: ColumnFiltersState, globalFilter?: string) {
- const conditions: SQL[] = [];
-
- // 1. Column Filters
- for (const filter of columnFilters) {
- const column = this.table[filter.id as keyof typeof this.table];
- if (!column) continue;
-
- const value = filter.value;
-
- // 값의 타입에 따라 적절한 연산자 선택 (기본적인 예시)
- if (Array.isArray(value)) {
- // 범위 필터 (예: 날짜, 숫자 범위)
- if (value.length === 2) {
- const [min, max] = value;
- if (min !== null && max !== null) {
- conditions.push(and(gte(column, min), lte(column, max))!);
- } else if (min !== null) {
- conditions.push(gte(column, min)!);
- } else if (max !== null) {
- conditions.push(lte(column, max)!);
- }
- }
- // 다중 선택 (Select)
- else {
- conditions.push(inArray(column, value)!);
- }
- } else if (typeof value === 'string') {
- // 텍스트 검색 (Partial Match)
- conditions.push(ilike(column, `%${value}%`)!);
- } else if (typeof value === 'boolean') {
- conditions.push(eq(column, value)!);
- } else if (typeof value === 'number') {
- conditions.push(eq(column, value)!);
- }
- }
-
- // 2. Global Filter (검색창)
- if (globalFilter && this.searchableColumns.length > 0) {
- const searchConditions = this.searchableColumns.map(colName => {
- const column = this.table[colName as keyof typeof this.table];
- if (!column) return null;
- return ilike(column, `%${globalFilter}%`);
- }).filter(Boolean) as SQL[];
-
- if (searchConditions.length > 0) {
- conditions.push(or(...searchConditions)!);
- }
- }
-
- return conditions.length > 0 ? and(...conditions) : undefined;
- }
-
- /**
- * Grouping State -> Group By & Select
- * 주의: Group By 사용 시 집계 함수가 필요할 수 있음
- */
- getGroupBy(grouping: GroupingState) {
- return grouping.map(g => {
- const column = this.table[g as keyof typeof this.table];
- return column;
- }).filter(Boolean);
- }
-}