https://www.youtube.com/watch?v=ED2H_y6dmC8
06:45:20
백엔드의 데이터 조회 함수를 개선하고, URL 기반 상태 관리를 통해 페이지네이션과 검색 기능을 구현하는 과정
Update "getMany" procedure → 여러 데이터를 가져오는 기존 백엔드 함수(getMany)를 수정 또는 개선하기.
Add NUQS for param handling → URL/쿼리 파라미터 관리를 위해 Nuqs 라이브러리를 도입하기.
- client side → 프론트엔드에 파라미터 처리 로직 적용.
- server side → 백엔드에서도 동일한 파라미터 처리 방식 구현.
Add “Entity” components → 엔티티(데이터 항목)를 보여주는 재사용 가능한 UI 컴포넌트를 만들기.
Add UI for search → 검색 기능 UI 추가로 사용자 데이터 검색 가능하게 만들기.
Add UI for pagination → 페이지 이동(이전/다음 등) UI 추가로 데이터 페이지네이션 구현.
1. Update "getMany" procedure - 백엔드 함수 개선
- 예전 방식:
// ID만으로 검색
findUnique({ where: { id: 5 } })
- 현재 방식
// ID + 추가 조건으로 검색
findUnique({
where: {
id: 5,
deleted: false
}
})
데이터 하나를 찾을 때, 단순히 ID뿐만 아니라 다른 조건도 섞어서 검사할 수 있다.
이렇게 하면 권한 체크, 삭제 여부 등을 한번에 필터링할 수 있어 보안과 로직 처리에 유리하다.
페이지네이션 상수 정의
- 전체 데이터를 한 번에 가져오는 대신, 페이지별로 나눠서 가져오도록 개선
src/config/constants.ts
export const PAGINATION ={
DEFAULT_PAGE: 1,
DEFAULT_PAGE_SIZE: 10,
MAX_PAGE_SIZE: 100,
MIN_PAGE_SIZE: 1,
}
getMany 프로시저 구현
- skip: 건너뛸 데이터 개수 = (현재 페이지 - 1) × 페이지 크기
- take: 가져올 데이터 개수
- 2페이지를 보고 싶다면, 1페이지 분량(pageSize)만큼 건너뛰고(skip) 다음 데이터를 가져온다(take).
src/app/features/workflows/server/routers.ts
getMany: protectedProcedure
// 입력값 검증
.input(
z.object({
page: z.number().default(PAGINATION.DEFAULT_PAGE),
pageSize: z
.number()
.min(PAGINATION.MIN_PAGE_SIZE)
.max(PAGINATION.MAX_PAGE_SIZE)
.default(PAGINATION.DEFAULT_PAGE_SIZE),
search: z.string().default(""),
})
)
.query(async ({ ctx, input }) => {
const { page, pageSize, search } = input;
// Promise.all을 사용해 데이터 조회와 개수 파악을 병렬로 처리
const [items, totalCount] = await Promise.all([
// 실제 데이터 조회
prisma.workflow.findMany({
skip: (page - 1) * pageSize, // 페이지 시작 위치 계산
take: pageSize, // 가져올 개수
where: {
userId: ctx.auth.user.id,
name: {
contains: search, // 부분 일치 검색
mode: "insensitive" // 대소문자 구분 없이
},
},
orderBy: {
updatedAt: "desc" // 최신순 정렬
},
}),
// 전체 개수 파악 (페이지네이션 UI에 필요)
prisma.workflow.count({
where: {
userId: ctx.auth.user.id,
name: {
contains: search,
mode: "insensitive"
},
},
}),
]);
// 페이지네이션 메타데이터 계산
const totalPages = Math.ceil(totalCount / pageSize);
const hasNextPage = page < totalPages;
const hasPreviousPage = page > 1;
// 결과 반환
return {
items,
page,
pageSize,
totalCount,
totalPages,
hasNextPage,
hasPreviousPage,
};
})
초기 에러 수정
- 기존 코드는 파라미터를 전달하지 않아 에러 발생.
- 다음 단계에서 nuqs를 도입하면서 해결
queryOptions
// ❌ 파라미터 없이 호출
return useSuspenseQuery(trpc.workflows.getMany.queryOptions());
2. Add NUQS for param handling - URL 파라미터 관리
일반적인 useState는 새로고침하면 값이 날아가지만,
nuqs를 사용해 URL에 상태를 저장하면 다음과 같은 장점이 있다.
- 새로고침해도 유지: 2페이지를 보다가 새로고침해도 그대로 2페이지
- 링크 공유 가능: URL을 복사해서 공유하면 동일한 검색 결과를 볼 수 있음
- 뒤로 가기 지원: 브라우저 뒤로 가기 버튼이 의도대로 동작
설치 및 기본 설정
npm install nuqs
src/app/layout.tsx
- 어댑터 추가
import { NuqsAdapter } from 'nuqs/adapters/next/app'
<TRPCReactProvider>
<NuqsAdapter>
{children}
</NuqsAdapter>
</TRPCReactProvider>
https://nuqs.dev/docs/server-side
Server-Side usage | nuqs
Type-safe search params on the server
nuqs.dev
① 설정 (params.ts)
src/app/features/workflows/params.ts
- parseAsInteger: URL은 문자열이지만 자동으로 숫자로 변환
- withDefault: 기본값 설정
- clearOnDefault: 기본값일 때 URL을 깔끔하게 유지 (예: ?page=1 대신 /workflows)
import { parseAsInteger, parseAsString } from "nuqs/server";
import { PAGINATION } from '@/config/constants'
export const workflowsParams = {
page: parseAsInteger
.withDefault(PAGINATION.DEFAULT_PAGE)
.withOptions({ clearOnDefault: true }), // 기본값일 때 URL에서 제거
pageSize: parseAsInteger
.withDefault(PAGINATION.DEFAULT_PAGE_SIZE)
.withOptions({ clearOnDefault: true }),
search: parseAsString
.withDefault('')
.withOptions({ clearOnDefault: true })
}
// 결과: http://localhost:3000/workflows?page=2&search=hello
② 클라이언트에서 사용 (use-workflows-params.ts)
src/features/workflows/hooks/use-workflows-params.ts
import {useQueryStates} from "nuqs"
import { workflowsParams } from "../params"
export const useWorkflowsParams = () => {
return useQueryStates(workflowsParams)
}
사용 예시
const [params, setParams] = useWorkflowsParams()
// 읽기: params.page, params.search
// 쓰기: setParams({ page: 3 })
③ 서버 파라미터 로더 생성
src/features/workflows/server/params-loader.ts
import { createLoader } from "nuqs/server"
import { workflowsParams } from "../params"
export const workflowsParamsLoader = createLoader(workflowsParams)
④ 서버와 클라이언트의 연결 (page.tsx & use-workflows.ts)
- 사용자가 페이지 번호를 클릭하거나 검색어를 입력
- nuqs가 브라우저 주소창(URL)을 업데이트 (예: ?search=hello&page=2)
- useWorkflowsParams 훅이 바뀐 URL 값을 감지하고 새로운 params를 반환
- **tRPC(useSuspenseQuery)**가 바뀐 params를 가지고 백엔드에 요청
- 백엔드의 getMany 함수가 실행되어 결과를 반환
- React Query가 캐싱하고 화면을 자동으로 업데이트
서버 컴포넌트: src/app/(dashboard)/(rest)/workflows/page.tsx
import { WorkflowsContainer, WorkflowsList } from "@/features/workflows/components/workflows";
import { workflowsParamsLoader } from "@/features/workflows/server/params-loader"
import { prefetchWorkflows } from "@/features/workflows/server/prefetch";
import { requireAuth } from "@/lib/auth-utils";
import { HydrateClient } from "@/trpc/server";
import type { SearchParams } from "nuqs/server";
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
type Props = {
searchParams: Promise<SearchParams>
}
const Page = async ({ searchParams }: Props) => {
await requireAuth()
// URL 파라미터를 서버에서 파싱
const params = await workflowsParamsLoader(searchParams)
// 파싱된 파라미터로 데이터 프리패치
prefetchWorkflows(params)
return (
<WorkflowsContainer>
<HydrateClient>
<ErrorBoundary fallback={<div>Error!</div>}>
<Suspense fallback={<p>Loading...</p>}>
<WorkflowsList/>
</Suspense>
</ErrorBoundary>
</HydrateClient>
</WorkflowsContainer>
)
}
export default Page;
클라이언트 훅: src/features/workflows/hooks/use-workflows.ts (수정 후)
import { useTRPC } from "@/trpc/client"
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { useWorkflowsParams } from "./use-workflows-params";
/**
* Hook to fetch all workflows using suspense
*/
export const useSuspenseWorkflows = () => {
const trpc = useTRPC();
const [params] = useWorkflowsParams() // URL에서 파라미터 읽기
return useSuspenseQuery(trpc.workflows.getMany.queryOptions(params));
}
export const useCreateWorkflow = () => {
const queryClient = useQueryClient();
const trpc = useTRPC();
return useMutation(trpc.workflows.create.mutationOptions({
onSuccess: (data) => {
toast.success(`Workflow "${data.name}" created`)
// 워크플로우 목록 새로고침
queryClient.invalidateQueries(
trpc.workflows.getMany.queryOptions({})
)
},
onError: (error) => {
toast.error(`Failed to create workflow: ${error.message}`)
}
}))
}

http://localhost:3000/workflows?page=2

3. Add "Entity" components - 재사용 가능한 UI 컴포넌트
Entity 패턴의 의도
"어떤 데이터(엔티티)든 상관없이 UI 형태는 똑같이 쓰겠다"는 의도로, 재사용 가능한 컴포넌트를 만든다.
나중에 Users, Projects 등 다른 리스트를 만들 때도 같은 UI 패턴을 재사용할 수 있다.
src/components/entity-components.tsx
EntitySearch 컴포넌트
import { Input } from "@/components/ui/input"
import { SearchIcon } from "lucide-react"
interface EntitySearchProps {
value: string;
onChange: (value: string) => void;
placeholder?: string
}
export const EntitySearch = ({
value,
onChange,
placeholder = "Search"
}: EntitySearchProps) => {
return (
<div className="relative ml-auto">
<SearchIcon className="size-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"/>
<Input
className="max-w-[200px] bg-background shadow-none border-border pl-8"
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</div>
)
}
EntityContainer 컴포넌트
interface EntityContainerProps {
header: React.ReactNode;
search: React.ReactNode;
pagination: React.ReactNode;
children: React.ReactNode;
}
export const EntityContainer = ({
header,
search,
pagination,
children
}: EntityContainerProps) => {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
{header}
{search}
</div>
{children}
{pagination}
</div>
)
}
Workflows 전용 컴포넌트
src/features/workflows/components/workflows.tsx
export const WorkflowsSearch = () => {
const [params, setParams] = useWorkflowsParams();
return (
<EntitySearch
value={params.search}
onChange={(value) => setParams({ ...params, search: value })}
placeholder="Search workflows"
/>
)
}
export const WorkflowsContainer = ({
children
}: {
children: React.ReactNode
}) => {
return (
<EntityContainer
header={<WorkflowsHeader/>}
search={<WorkflowsSearch/>}
pagination={<WorkflowsPagination/>} // 5번에서 구현
>
{children}
</EntityContainer>
)
}
4. Add UI for search - 검색 기능 최적화
- 디바운스(Debounce)라는 개념 등장
- 문제점: 사용자가 "Hello"를 치면 H, He, Hel, Hell, Hello 매 타이핑마다 서버에 요청을 보냄. 서버터짐
- 해결책 (Debounce): 사용자가 타이핑을 멈추고 0.5초(500ms)가 지날 때까지 기다렸다가, 마지막에만 요청 보냄.
- 코드의 흐름:
- localSearch 상태를 따로 관리 → 화면에는 즉시 반영 (부드러운 UX)
- useEffect 내부의 setTimeout이 타이핑 중에는 계속 초기화(clearTimeout)
- 타이핑이 멈추면 500ms 후 setParams 실행 → URL 변경 → 서버 요청
- 검색어가 바뀌면 page: 1로 초기화 (새로운 검색 결과의 첫 페이지를 보여줌)
useEntitySearch 훅 구현
src/features/workflows/hooks/use-entity-search.tsx
import { useEffect, useState } from "react";
import { PAGINATION } from "@/config/constants"
interface UseEntitySearchProps<T extends {
search: string;
page: number
}> {
params: T;
setParams: (params: T) => void;
debounceMs?: number;
}
export function useEntitySearch<T extends {
search: string;
page: number;
}>({
params,
setParams,
debounceMs = 500
}: UseEntitySearchProps<T>) {
// 로컬 상태: 즉시 UI에 반영 (부드러운 UX)
const [localSearch, setLocalSearch] = useState(params.search);
// 디바운스 로직
useEffect(() => {
// 검색어를 지운 경우 즉시 반영
if (localSearch === "" && params.search !== "") {
setParams({
...params,
search: "",
page: PAGINATION.DEFAULT_PAGE // 1페이지로 리셋
});
return;
}
// 타이핑이 멈추면 500ms 후에 URL 업데이트
const timer = setTimeout(() => {
if (localSearch !== params.search) {
setParams({
...params,
search: localSearch,
page: PAGINATION.DEFAULT_PAGE // 검색 시 1페이지로
})
}
}, debounceMs)
// 타이핑 중에는 타이머 취소 (cleanup)
return () => clearTimeout(timer);
}, [localSearch, params, setParams, debounceMs]);
// URL이 외부에서 변경되면 로컬 상태도 동기화
useEffect(() => {
setLocalSearch(params.search)
}, [params.search])
return {
searchValue: localSearch,
onSearchChange: setLocalSearch,
}
}
WorkflowsSearch 컴포넌트 개선
src/features/workflows/components/workflows.tsx
export const WorkflowsSearch = () => {
const [params, setParams] = useWorkflowsParams();
const { searchValue, onSearchChange} = useEntitySearch({
params,
setParams,
})
return (
<EntitySearch
value={searchValue}
onChage={onSearchChange}
placeholder="Search workflows"
/>
)
}
5. Add UI for pagination - 페이지네이션 UI
EntityPagination 컴포넌트
- 현재 페이지와 전체 페이지 수 표시
- disabled prop으로 데이터 로딩 중 중복 클릭 방지
- Math.max, Math.min으로 범위 벗어나는 것 방지
src/components/entity-components.tsx
interface EntityPaginationProps{
page: number;
totalPages: number;
onPageChange: (page:number) => void;
disabled?:boolean
}
export const EntityPagination = ({
page,
totalPages,
onPageChange,
disabled,
}:EntityPaginationProps) => {
return(
<div className="flex items-center justify-between gap-x-2 w-full">
<div className="flex-1 text-sm text-muted-foreground">
Page {page} of {totalPages || 1}
</div>
<div className="flex items-center justify-end space-x-2 py-4">
<Button
disabled={page ===1 || disabled}
variant="outline"
size="sm"
onClick={()=>onPageChange(Math.max(1, page - 1))}
>
Previous
</Button>
<Button
disabled={page === totalPages || totalPages === 0 ||disabled}
variant="outline"
size="sm"
onClick={()=>onPageChange(Math.min(totalPages, page + 1))}
>
Next
</Button>
</div>
</div>
)
}
WorkflowsPagination 컴포넌트
src/features/workflows/components/workflows.tsx
export const WorkflowsPagination = () => {
const workflows = useSuspenseWorkflows();
const [params, setParams] = useWorkflowsParams();
return (
<EntityPagination
disabled={workflows.isFetching} // 로딩 중에는 버튼 비활성화
totalPages={workflows.data.totalPages}
page={workflows.data.page}
onPageChange={(page) => setParams({ ...params, page })}
/>
)
}
최종 WorkflowsContainer
export const WorkflowsContainer = ({
children
}: {
children: React.ReactNode
}) => {
return (
<EntityContainer
header={<WorkflowsHeader/>}
search={<WorkflowsSearch/>}
pagination={<WorkflowsPagination/>}
>
{children}
</EntityContainer>
)
}

역할 정리:
- Header: 제목이나 "추가하기" 버튼 표시
- Search: useEntitySearch를 통한 최적화된 검색창
- Children: 실제 워크플로우 리스트 내용
- Pagination: 페이지 이동 컨트롤러
'Clone Coding' 카테고리의 다른 글
| N8N & Zapier - Workflow Page (0) | 2026.01.17 |
|---|---|
| N8N & Zapier - Workflows UI (1) | 2026.01.16 |
| N8N & Zapier - Workflows Crud / 2 (0) | 2026.01.13 |
| N8N & Zapier - Workflows Crud / 1 (0) | 2026.01.12 |
| N8N & Zapier - Payments Setup (1) | 2026.01.09 |