본문 바로가기
Clone Coding

N8N & Zapier - Workflows Pagination

by zzuny-code 2026. 1. 15.
반응형

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 - 백엔드 함수 개선

 

Prisma 문서 참고

 

  • 예전 방식:
// 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