React 및 Next.js 15 App Router 심화 학습 노트

댓글 1
댓글을 작성하려면 로그인이 필요합니다.

좋은 글이에요!

댓글을 작성하려면 로그인이 필요합니다.

좋은 글이에요!
날짜: 2026-03-26
주제: Next.js App Router 패턴, next/navigation, 아키텍처 및 렌더링 최적화, Server Action 설계 요령
최근 프로젝트를 진행하며 Next.js 15 환경의 App Router가 강제하는 새로운 멘탈 모델에 대해 집중적으로 학습했다. 과거 Page Router 시절과 달리, 클라이언트와 서버 사이의 경계(Boundary)를 어떻게 적절하게 나누느냐에 따라 애플리케이션의 성능과 확장성이 크게 좌우된다는 점을 실감했다. 이 문서는 향후 개발 시 반드시 준수해야 할 핵심 컨벤션과 안티 패턴을 정리하는 회고록이자 가이드라인이다.
Next.js App Router의 강력함은 **React Server Component (RSC)**를 기본값으로 사용한다는 점에 있다. 서버 기반 렌더링에 이점을 온전히 얻기 위해서는 뷰의 계층 구조 속에서 'use client' 선언을 최소화하고 최대한 말단으로 밀어내는 것이 중요하다.
page.tsx와 layout.tsx에서 최상단 'use client' 금지페이지의 진입점 역할을 하는 page.tsx와 layout.tsx 파일은 가급적 Server Component로 유지해야 한다. 페이지 전체를 클라이언트에서 렌더링할 경우, SEO 저하 및 번들 사이즈 급증과 같은 치명적인 문제가 발생할 수 있다.
URL 파라미터를 파싱하는 작업은 Server Component에서 담당하고, 클라이언트 상호작용이 필요한 하위 컴포넌트에 한해서 데이터를 Props로 넘겨준다.
// ✅ app/board/[id]/page.tsx (Server Component)
import { DetailViewer } from './components/detail-viewer'
export default async function BoardDetailPage({
params
}: {
params: Promise<{ id: string }>
}) {
// 서버 컴포넌트에서는 params 구조분해 할당 전 반드시 await 처리
const { id } = await params
// Server 측 처리를 끝낸 값을 Client Component에 주입
return (
<main className="container mx-auto p-4">
<DetailViewer id={id} />
</main>
)
}
// ✅ app/board/[id]/components/detail-viewer.tsx (Client Component)
'use client'
import { useQuery } from '@tanstack/react-query'
import { queries } from '@/(api)/board'
export function DetailViewer({ id }: { id: string }) {
// CSR에 필요한 상태 관리 및 훅 사용
const { data, isLoading } = useQuery(queries.detail(id))
if (isLoading) return <LoadingSpinner size="xl" />
return (
<article className="prose">
<h1>{data.title}</h1>
<p>{data.content}</p>
</article>
)
}
next/navigation의 useParams()를 예외적으로 사용하여 해결한다.next/navigation을 활용한 URL 상태 관리React 상태(State) 설계 시, 단순히 useState를 사용하는 대신 URL(Query String)을 단일 진실 공급원(Single Source of Truth)으로 삼는 패턴이 강력하다. 페이지를 새로고침하거나 링크를 공유하더라도 이전 상태가 초기화되지 않기 때문이다.
과거 Page Router에서 사용하던 next/router는 더이상 사용할 수 없으며 절대 혼용해서는 안 된다. 모든 라우팅과 파라미터 제어는 오직 next/navigation 패키지에서 가져와야 한다.
'use client'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useCallback } from 'react'
export function SearchFilter() {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
// Query String 업데이트 로직
const handleFilterChange = useCallback((key: string, value: string) => {
const params = new URLSearchParams(searchParams.toString())
if (value) {
params.set(key, value)
} else {
params.delete(key)
}
// history stack을 쌓지 않으려면 router.replace 활용
router.push(`${pathname}?${params.toString()}`)
}, [pathname, searchParams, router])
return (
<div className="flex gap-4 mb-6">
<select
onChange={(e) => handleFilterChange('category', e.target.value)}
defaultValue={searchParams.get('category') ?? ''}
className="select-box"
>
<option value="">전체 카테고리</option>
<option value="react">React</option>
<option value="nextjs">Next.js</option>
</select>
</div>
)
}
useSearchParams() 대신 Server Component의 searchParams prop을 사용하는 것이 서버 사이드 최적화에 유리하다.코드베이스가 방대해짐에 따라 API, 쿼리, 뮤테이션 등을 체계적으로 관리하는 디렉토리 구조가 필수적이다. 여러 모듈에서 복잡하게 import 구문이 생성되는 것을 방지하기 위해 Barrel Export 패턴을 일관되게 적용하도록 한다.
// ✅ @/(api)/user/index.ts
// 목적: user 모듈 내의 코드를 외부로 간결하게 노출
export * from "./user.service"
export * from "./user.queries"
export * from "./user.mutations"
export * from "./user.types"
// ✅ 외부 모듈에서 호출 시 (정상 구현 코드)
import { queries, UserService, UserType } from "@/(api)/user"
// ❌ 나쁜 안티 패턴 (직접 개별 파일 import 금지)
import { queries } from "@/(api)/user/user.queries"
// ❌ 교차 참고 재노출 금지
// export { adminUtil } from "@/(api)/admin"
이 패턴을 유지하면 결합도는 낮아지고, 리팩토링 시 수정 영역이 디렉토리 내로 안전하게 제한된다.
대부분의 React 개발자들이 흔히 저지르는 실수가 useCallback과 useMemo의 남용이다. React에서는 객체 동등성(Reference equality) 비교 비용이 재렌더링 비용보다 높을 때가 많다.
useMemo/memo를 도입할 것.map()을 돌리는 내부 코드가 비대해지면, 인라인으로 길게 적기보다는 해당 row 자체를 작은 단위 컴포넌트로 분리하는 것이 구조 파악과 버그 추적에 효과적이다. 컴포넌트 길이가 길어지면 분리점을 모색해야 한다.따라서 특별한 성능 병목이 확인되지 않았다면 가독성이 높은 직관적이고 순수한 렌더링 코드를 우선시 해야 한다.
서버와의 통신 로직, 특히 Next.js의 Server Action과 Route Handler를 구성할 때 여러 모범 설계 철학을 준수해야 한다.
데이터에 접근할 때는 항상 검증 계층을 거치게 하여 무단 접근을 방지한다. 세션 검증이 모든 액션에 주입되도록 설계한다.
// actions/post-actions.ts
'use server'
import { verifySession } from '@/lib/auth'
import { db } from '@/lib/db'
import { revalidateTag } from 'next/cache'
export async function createPostAction(formData: FormData) {
try {
// 1. 보안 및 인가 처리 중앙화
const session = await verifySession()
if (!session?.user) {
throw new Error('Unauthorized access')
}
const title = formData.get('title')
// 2. 비즈니스 서비스 호출 (Route Handler 내 비즈니스 로직은 금지)
const newPost = await db.post.create({
data: { title: title as string, authorId: session.user.id }
})
// 3. 서버 응답 이후의 데이터 무효화 (캐시 최신화)
revalidateTag('posts')
// 4. DTO 변환을 통해 클라이언트엔 필요한 응답만 전송
return {
success: true,
data: { id: newPost.id, title: newPost.title }
}
} catch (error) {
// 민감한 에러 스택은 가리고 추상화된 메시지만 클라이언트에 전달
const message = error instanceof Error ? error.message : '예기치 못한 오류가 발생했습니다.'
return { success: false, error: message }
}
}
무관한 독립적인 데이터 페칭 혹은 처리 작업은 Promise.all() 등을 통해 묶음 처리하여, 응답 시간을 유의미하게 감소시켜야 한다.
// ❌ 안티 패턴: Waterfall 발생
const userData = await fetchUser(id)
const preferences = await fetchPreferences(id)
// ✅ 모범 패턴: 병렬 처리 (요청 즉시 실행 후 한 번에 대기)
const [userData, preferences] = await Promise.all([
fetchUser(id),
fetchPreferences(id)
])
클라이언트의 React Query 데이터 무효화를 설계할 때는 특정 QueryKey 문자열을 중복해서 하드코딩하지 않고, 함수를 통해 래핑하는 것이 바람직하다.
// @/(api)/post/post.mutations.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { queries } from './post.queries'
// 무효화 공통 패턴 분리
const invalidatePostQueries = {
all: (queryClient: ReturnType<typeof useQueryClient>) => ({
predicate: (query: { queryKey: readonly unknown[] }) =>
query.queryKey.includes(queries.baseConfig()[0]), // 기본 베이스 키 포함 유무 검사
}),
}
export function useCreatePostMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: PostPayload) => api.post('/posts', data),
onSuccess: () => {
// 깔끔하고 실수 없는 캐시 클리어 방식
queryClient.invalidateQueries(invalidatePostQueries.all(queryClient))
}
})
}
next/navigation만 사용해야 하며 (next/router 혼용 절대 주의), 상태 관리는 전역 라이브러리 추가 도입 이전에 URL Query String 기반 설계를 최우선으로 검토하자.오늘 정리한 원칙들을 향후 프로젝트 기반 뼈대로 삼고 타이트하게 코드 리뷰에 반영할 예정이다. 계속해서 나만의 보일러플레이트와 기술 블로그 템플릿을 고도화해 나가자!