
Next.js App Router에서 SSR과 Client 상태 동기화
React
2025.07.21.
Next.js App Router는 서버 컴포넌트(RSC)를 기반으로 동작하여 서버 사이드 렌더링(SSR)의 이점을 누릴 수 있다.
하지만 검색, 필터링, 페이지네이션처럼 URL의 검색 파라미터(?page=2&keyword=test)와 상호작용해야 하는 기능을 구현할 때 두 가지를 모두 고려해야 해서 까다롭다.
서버 컴포넌트: 페이지에 처음 진입할 때 URL의 searchParams를 읽어서 초기 데이터를 가져와야 한다.
클라이언트 컴포넌트: 사용자의 입력(검색, 페이지 이동)에 따라 URL을 동적으로 바꾸고 UI를 업데이트해야 한다.
서버 컴포넌트에서 초기 데이터를 설정하고, 클라이언트 컴포넌트에서 이 값을 이어받아 동적인 상호작용을 처리하면서도 URL 파라미터를 일관되게 유지하는 방법을 코드를 통해 살펴본다.
(구현하느라 꽤 애먹어서 정리해 둔다.)
1. 페이지 컴포넌트 (서버) : 초기 데이터 준비
먼저 페이지의 진입점 역할을 하는 서버 컴포넌트다. 이 컴포넌트는 URL의 searchParams를 받아 서버에서 초기 데이터를 가져온 뒤, 클라이언트 컴포넌트에 넘겨주는 역할을 한다.
interface PageProps {
searchParams: {
page?: string
size?: string
keyword?: string
searchField?: string
}
}
export default async function JobManagementPage({ searchParams }: PageProps) {
const params = await Promise.resolve(searchParams)
const page = Number(safe(params.page, '1'))
const size = Number(safe(params.size, '10'))
const keyword = safe(params.keyword, '')
const searchField = safe(params.searchField, 'clct_data_nm')
const result = await serverGet<ApiResponse<SchedulListResponse>>(
`${process.env.NEXT_PUBLIC_CLCT_URL}/wrk/api/clct/scheduls`,
{
page,
size,
keyword,
searchField,
},
)
const data = result?.data?.data ?? {
list: [],
meta: {
page,
size,
totalCount: 0,
totalPages: 1,
hasNext: false,
hasPrevious: false,
},
}
return (
<div className={styles.mainContent}>
<div className={styles.breadcrumbWrapper}>
<ul className={styles.breadcrumb}>
<li>홈</li>
<li>연계 작업 관리</li>
<li>연계 작업 관리</li>
</ul>
</div>
<h2 className={styles.pageTitle}>연계작업관리</h2>
<DataTable
initialSearchParams={{ page, size, keyword }}
initialData={data}
/>
</div>
)
}
- Next.js App Router는 페이지 컴포넌트에 URL의 쿼리 스트링을 파싱한 searchParams 객체를 자동으로 props로 전달해 준다.
- 이렇게 받은 파라미터를 사용해 서버 환경에서 직접 API를 호출하여 데이터를 가져온다.
- 마지막으로, 가져온 데이터(initialData)와 이때 사용된 파라미터(initialSearchParams)를 클라이언트 컴포넌트인
에 props로 넘겨준다.
2. UI 컴포넌트 (클라이언트): 상태 동기화 및 상호작용
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
import { useState, useEffect } from 'react'
export default function DataTable({
initialSearchParams,
initialData,
}: DataTableProps) {
const router = useRouter()
const searchParams = useSearchParams() // 클라이언트에서 현재 URL 파라미터 읽기
// 1. 서버에서 받은 초기 파라미터를 fallback으로 사용
const fallbackPage = initialSearchParams.page
const fallbackSize = initialSearchParams.size
const fallbackKeyword = initialSearchParams.keyword ?? ''
// 2. 현재 URL 파라미터를 우선으로 사용, 없으면 fallback 값 사용
const page = Number(searchParams.get('page') || fallbackPage)
const size = Number(searchParams.get('size') || fallbackSize)
const keyword = searchParams.get('keyword') ?? fallbackKeyword
const initialSearchField = initialSearchParams.searchField || 'clct_data_nm'
// 3. 사용자 입력을 위한 React State (UI 상태)
const [searchField, setSearchField] = useState(initialSearchField)
const [searchKeyword, setSearchKeyword] = useState(keyword)
// ... (생략) ...
const handleSearch = () => {
// 4. URL을 업데이트하여 서버에 새로운 데이터 요청
const query = new URLSearchParams({
page: '1', // 검색 시에는 첫 페이지로
size: size.toString(),
keyword: searchKeyword,
searchField: searchField,
}).toString()
router.push(`?${query}`)
}
return (
<>
<div className={styles.searchBox}>
{/* ... */}
<input
type="text"
value={searchKeyword} // React state와 연결
onChange={e => setSearchKeyword(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
/>
<button onClick={handleSearch}>검색</button>
{/* ... */}
</div>
{/* ... 테이블 및 페이지네이션 렌더링 ... */}
<Pagination currentPage={page} totalPages={totalPages} />
</>
)
}
- searchParams.get(‘page’) || fallbackPage 이 부분이 핵심이다. 클라이언트에서 useSearchParams 훅을 통해 현재 URL의 파라미터를 우선적으로 가져온다. 만약 URL에 값이 없다면(예: 최초 페이지 로드), 서버에서 props로 내려준 초기값(fallbackPage)을 사용한다. 덕분에 SSR 결과물과 클라이언트의 첫 렌더링이 일치하여 화면 깜빡임이 없다.
- handleSearch 함수는 router.push()를 호출하여 브라우저의 URL을 변경한다. URL이 바뀌면 Next.js는 이 변경을 감지하고, 새로운 searchParams와 함께 서버 컴포넌트(JobManagementPage)를 다시 실행한다. 이 과정이 반복되면서 데이터가 자연스럽게 갱신된다.