2025.10.27·7

프론트엔드 데이터 패칭 다시 보기 (05) - Next.js App Router에서는 이 패턴을 어떻게 다시 설계해야 하는가

App Router에서 서버 컴포넌트, DAL, 최상단 orchestration, client cache의 역할을 어떻게 나누면 좋은지 정리합니다.

프론트엔드 데이터 패칭 다시 보기 (05) - Next.js App Router에서는 이 패턴을 어떻게 다시 설계해야 하는가 대표 이미지
용어 보기접기/펼치기
Glossary

App Router에서 중요한 것은 React Query를 없애는 일이 아닙니다. 서버가 더 잘할 일과 클라이언트 캐시가 더 잘할 일을 다시 나누는 일입니다.

들어가며

이전 글에서는 prefetch와 hydration이 언제 유용하고, 언제부터 구조를 흐리기 시작하는지 정리했습니다. 작은 예제에서는 예쁘게 보이지만, 화면이 커질수록 중요한 것은 더 많은 prefetch가 아니라 누가 요청의 우선순위와 경계를 조율하느냐였습니다.

이제 마지막 질문은 자연스럽게 Next.js App Router로 이어집니다. App Router는 Server Components를 기본값으로 두고, 클라이언트보다 서버 가까이에서 데이터를 다루게 만듭니다. 이 구조 안에서는 React에서 익숙했던 "클라이언트에서 상태를 들고 필요한 곳마다 쿼리를 붙이는 방식"이 그대로 맞지 않을 때가 많습니다.

그래서 이 글의 목표는 간단합니다. App Router에서 서버 상태 패턴을 어떻게 다시 배치해야 하는지 정리해 보는 것입니다. 무엇을 서버에서 읽고, 무엇을 DAL로 감싸고, 무엇을 페이지 최상단에서 조율하고, 어떤 부분에만 React Query 같은 client cache를 남겨 둘지까지 하나의 흐름으로 묶어 보겠습니다.

이 글에서는 다섯 가지를 중심으로 보겠습니다.

  • App Router가 서버와 클라이언트의 책임을 어떻게 다시 나누는지
  • 왜 새 프로젝트에서는 DAL이 중심축이 되기 쉬운지
  • 왜 페이지 최상단 orchestration이 더 중요해지는지
  • 어떤 데이터는 서버에서 끝내고 어떤 데이터만 client cache에 맡겨야 하는지
  • React Query를 "없애는" 것이 아니라 어디에 남겨야 하는지
Keypoint

App Router에서는 데이터를 어디서 가져올지가 먼저가 아닙니다. 서버가 소유할 경계와 클라이언트가 이어받을 경계를 먼저 나누는 일이 더 중요합니다.

한 줄로 먼저 요약하면 이렇습니다.

  • 서버에서 안전하게 읽을 수 있는 데이터는 가능한 한 서버 가까이에 둡니다.
  • 데이터 접근은 DAL로 모아 권한과 DTO 변환을 한곳에서 처리합니다.
  • 첫 화면에 중요한 요청은 page나 layout 상단에서 orchestration합니다.
  • 반복 상호작용과 재동기화가 중요한 부분에만 client cache를 남깁니다.

App Router는 기본값부터 서버 쪽에 가깝습니다

App Router에서 page와 layout은 기본적으로 Server Component입니다. Next.js 문서도 데이터를 원본 가까이에서 읽고, 비밀값과 데이터베이스 접근을 서버 쪽에 두는 방향을 기본값으로 설명합니다.

이 말은 단순히 "서버에서 fetch도 할 수 있다"는 수준이 아닙니다. 화면 구조 자체가 서버 우선으로 다시 짜여 있다는 뜻에 가깝습니다.

  • 서버 컴포넌트는 데이터베이스와 내부 API 가까이에서 실행될 수 있습니다.
  • 클라이언트 컴포넌트는 상호작용과 브라우저 API를 담당합니다.
  • "use client" 경계 아래는 다시 클라이언트 번들로 묶입니다.

즉 App Router에서는 처음부터 이런 질문을 하게 됩니다.

  • 이 데이터는 정말 브라우저까지 내려와야 하는가
  • 이 상호작용은 서버 데이터 전체를 클라이언트에 넘길 만큼 중요한가
  • 이 화면은 서버에서 이미 준비할 수 있는가

그래서 같은 서버 상태 문제라도, React만 보던 시절과는 구조적 기준점이 달라집니다. 이전에는 "클라이언트에서 이 값을 어떻게 들고 있을까"가 먼저였다면, 여기서는 이 값을 굳이 브라우저까지 내려보내야 하는가가 먼저가 됩니다.

새 프로젝트에서는 DAL이 중심축이 되기 쉽습니다

Next.js의 데이터 보안 가이드는 데이터 접근 방식을 세 가지로 나누어 설명합니다. 기존 대형 시스템에서는 외부 HTTP API를 계속 쓸 수 있고, 프로토타입에서는 컴포넌트 단위 접근도 가능하지만, 새 프로젝트에는 Data Access Layer를 권장합니다. 그리고 가능하면 한 접근 방식을 중심으로 두고 섞지 않는 편이 더 명확하다고 설명합니다.

이 권장이 중요한 이유는 단순히 코드를 예쁘게 모으기 위해서가 아닙니다. App Router에서는 서버 컴포넌트가 데이터를 쉽게 읽을 수 있기 때문에, 반대로 말하면 원본 데이터를 너무 쉽게 렌더 트리 안으로 끌고 들어올 위험도 커집니다.

DAL은 이 문제를 줄이는 가장 현실적인 경계입니다.

  • 데이터 조회 로직을 한곳에 모읍니다.
  • 권한 검사를 같은 자리에서 수행합니다.
  • 클라이언트에 넘겨도 되는 DTO만 반환합니다.
  • server-only 같은 표시로 서버 전용 경계를 분명하게 유지합니다.

예를 들어 사용자 프로필을 읽는 로직은 이런 식으로 정리할 수 있습니다.

// 서버 전용 모듈임을 명시합니다.
import "server-only";
 
import { cache } from "react";
import { cookies } from "next/headers";
import { db } from "@/lib/db";
 
type ProfileDTO = {
  username: string | null;
  phoneNumber: string | null;
};
 
// 요청 단위에서 같은 사용자 정보를 재사용할 수 있게 캐시합니다.
const getCurrentUser = cache(async () => {
  const token = (await cookies()).get("AUTH_TOKEN")?.value;
  return validateSession(token);
});
 
export async function getProfileDTO(slug: string): Promise<ProfileDTO> {
  const viewer = await getCurrentUser();
  const user = await db.user.findUnique({ where: { slug } });
 
  // 화면에 필요한 안전한 필드만 DTO로 좁혀서 반환합니다.
  return {
    username: user?.username ?? null,
    phoneNumber: viewer?.isAdmin ? user?.phoneNumber ?? null : null,
  };
}

이 구조의 핵심은 단순히 재사용이 아닙니다. 무엇을 읽을 수 있고 무엇을 넘길 수 있는지의 기준을 DAL이 먼저 정한다는 데 있습니다. 즉 page나 component는 "어디서 읽을까"를 고민하기보다, 이미 안전하게 정리된 DTO를 받아 렌더링에 집중하게 됩니다.

Keypoint

App Router에서 DAL은 편의용 유틸리티보다, 데이터 접근과 노출 범위를 먼저 제한하는 경계에 가깝습니다.

페이지 최상단 orchestration이 더 중요해집니다

4편에서 이야기했듯, prefetch가 커질수록 문제는 개별 query보다 배치에서 생깁니다. App Router에서는 이 배치 문제가 더 선명해집니다. page와 layout이 서버에서 실행되기 때문에, 오히려 상위에서 요청을 모아 조율할 수 있는 자리가 분명하게 생기기 때문입니다.

이때 page나 layout은 단순히 화면 껍데기가 아니라, 첫 화면의 critical path를 설계하는 오케스트레이션 레이어가 됩니다.

  • 어떤 데이터가 첫 화면에 꼭 필요한지 정합니다.
  • 어떤 요청을 병렬로 시작할지 정합니다.
  • 어떤 값은 서버 렌더에서 바로 끝낼지 정합니다.
  • 어떤 값만 client cache로 이어줄지 정합니다.

예를 들어 글 상세 화면이라면 이런 식으로 페이지 상단에서 먼저 판단할 수 있습니다.

import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getQueryClient } from "@/lib/query-client";
import { getPostDTO, getCommentsSummaryDTO } from "@/data/posts";
import { postQueryOptions } from "@/lib/query-options";
import { PostInteractivePanel } from "./post-interactive-panel";
 
export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
 
  // 첫 화면에 필요한 서버 데이터는 page 상단에서 병렬로 조율합니다.
  const [post, commentsSummary] = await Promise.all([
    getPostDTO(slug),
    getCommentsSummaryDTO(slug),
  ]);
 
  const queryClient = getQueryClient();
 
  // 이후 반복 상호작용이 필요한 데이터만 client cache로 미리 넘깁니다.
  await queryClient.prefetchQuery(postQueryOptions(slug));
 
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <article>
        <h1>{post.title}</h1>
        <p>{commentsSummary.count}개의 댓글</p>
        <PostInteractivePanel slug={slug} />
      </article>
    </HydrationBoundary>
  );
}

여기서 중요한 것은 모든 데이터를 React Query에 태운 것이 아니라는 점입니다.

  • 본문 제목과 요약 정보는 서버 렌더만으로 충분합니다.
  • 반복 상호작용이 생길 패널만 client cache를 이어받습니다.
  • prefetch는 "가능한 것 전부"가 아니라 "이후 상호작용의 기준점이 될 것"에만 씁니다.

즉 App Router에서 orchestration은 더 추상적인 원칙이 아니라, page와 layout이라는 구체적인 배치 자리를 갖게 됩니다. 4편에서 말한 "상위에서 조율하라"는 원칙이 App Router에서는 실제 파일 구조와 렌더 경계로 바로 연결됩니다.

이 구조가 실무적으로 납득되는 이유는, 아래처럼 클라이언트 쪽 책임도 같이 분리되기 때문입니다.

"use client";
 
import { useQuery } from "@tanstack/react-query";
import { postQueryOptions } from "@/lib/query-options";
 
export function PostInteractivePanel({ slug }: { slug: string }) {
  const postQuery = useQuery(postQueryOptions(slug));
 
  // 서버에서 한 번 준비한 값을 첫 기준점으로 삼고,
  // 이후 상호작용 이후의 재동기화만 클라이언트가 맡습니다.
  return (
    <section>
      <button>좋아요</button>
      <p>현재 조회수: {postQuery.data.viewCount}</p>
    </section>
  );
}

이 예시에서 볼 포인트는 단순히 훅을 하나 더 썼다는 점이 아닙니다.

  • 본문 제목과 요약은 서버가 바로 렌더합니다.
  • 인터랙션 패널은 같은 데이터를 client cache에서 이어받습니다.
  • 이후 mutation과 invalidate, background refetch는 이 패널 근처에서 이어집니다.

모든 데이터를 React Query로 끌고 갈 필요는 없습니다

App Router를 쓰기 시작하면 종종 두 극단으로 가기 쉽습니다.

  • 서버 컴포넌트가 있으니 React Query는 필요 없다고 보는 쪽
  • 기존 습관대로 모든 데이터를 다시 React Query에 태우는 쪽

실무에서는 둘 다 과할 때가 많습니다.

서버에서 한 번 읽고 끝나는 데이터라면, 굳이 client cache까지 끌고 갈 이유가 없습니다.

  • 읽기 전용 콘텐츠
  • 첫 화면에만 필요한 요약 데이터
  • 상호작용 없이 서버에서 렌더만 하면 되는 정보

반대로 아래 같은 경우에는 client cache가 여전히 강합니다.

  • 같은 화면 안에서 반복적으로 다시 읽히는 데이터
  • mutation 이후 invalidate와 재동기화가 중요한 영역
  • optimistic update나 background refetch가 필요한 UI
  • 탭, 패널, 필터 조합처럼 클라이언트 상호작용이 많은 영역

즉 App Router 이후의 질문은 "React Query를 쓸까 말까"가 아니라, 어떤 서버 상태를 브라우저 안에서도 계속 살아 있는 상태로 둘 가치가 있는가에 가깝습니다.

한 줄로 줄이면 이렇습니다.

  • 서버에서 끝내도 되는 데이터는 서버에서 끝냅니다.
  • 브라우저 안에서 계속 다시 맞춰야 하는 데이터만 client cache에 남깁니다.

이 기준을 한 번 더 실무적으로 바꾸면 이렇게 말할 수 있습니다.

  • 읽고 바로 렌더하면 끝나는 데이터는 서버 책임입니다.
  • 권한과 노출 범위가 중요한 데이터는 DAL 책임입니다.
  • 첫 화면의 요청 우선순위는 page나 layout 책임입니다.
  • mutation과 반복 상호작용 이후에도 살아 있어야 하는 데이터만 client cache 책임입니다.

그래서 기준은 서버 우선, 클라이언트 선택이 됩니다

지금까지를 하나로 묶으면 App Router에서의 기본 전략은 꽤 분명합니다.

  1. 데이터 접근은 DAL로 모읍니다.
  2. page와 layout에서 첫 화면 요청을 orchestration합니다.
  3. 서버에서 바로 렌더해도 되는 데이터는 그대로 끝냅니다.
  4. 상호작용 이후에도 계속 다시 맞춰야 하는 데이터만 client cache로 넘깁니다.

이렇게 보면 React에서 출발한 서버 상태 패턴이 Next.js에 와서 완전히 뒤집히는 것은 아닙니다. 오히려 같은 문제를 더 분명한 경계 안에서 다시 나누게 되는 것에 가깝습니다.

  • 서버 상태는 여전히 동기화 문제입니다.
  • 다만 App Router는 그 동기화를 서버와 클라이언트 어느 쪽이 맡을지 더 일찍 결정하게 만듭니다.
  • 그 결정의 중심에 DAL과 page-level orchestration이 들어옵니다.
Keypoint

App Router의 핵심은 React Query를 대체하는 데 있지 않습니다. 서버에서 끝낼 일과 브라우저 안에서 계속 살아야 할 일을 더 일찍 나누게 만드는 데 있습니다.

마치며

React에서 서버 상태 문제를 보기 시작하면 처음에는 캐시와 stale data, mutation, prefetch 같은 개념이 중심처럼 보입니다. 하지만 App Router까지 오면 마지막으로 남는 질문은 조금 다릅니다.

이 데이터는 어디에서 소유하고, 어디까지를 클라이언트에 넘길 것인가.

App Router는 이 질문에 더 엄격한 기본값을 줍니다. page와 layout은 서버에서 시작하고, 데이터 접근은 DAL로 모으기 쉬우며, 클라이언트는 상호작용과 지속적인 재동기화가 필요한 부분에 더 집중하게 됩니다.

그래서 이 시리즈를 한 문장으로 정리하면 이렇습니다. 서버 상태를 잘 다룬다는 것은 라이브러리를 고르는 일보다, 데이터의 원본과 경계를 어디에 둘지 일관된 기준을 세우는 일에 더 가깝습니다.