(번역) Next.js의 app 디렉터리 아키텍처 이해하기

한정(Han Jung)
19 min readMay 10, 2023

--

원문: https://www.smashingmagazine.com/2023/02/understanding-app-directory-architecture-next-js/

짧은 요약: 새로운 app 디렉터리 아키텍처는 최근 출시된 Next.js 릴리즈의 주인공으로, 많은 궁금증을 불러일으키고 있습니다. 이 글에서는 Atila Fassina가 새로운 전략의 장점과 함정을 살펴보고, 당장 프로덕션 환경에 적용해야 하는지를 생각해 봅니다.

Next.js 13 출시 이후, 발표에 포함된 새로운 기능들이 얼마나 안정적인지에 대한 논쟁이 있었습니다. “Next.js 13의 새로운 기능은 무엇인가요?”글에서 우리는 릴리즈에 담긴 내용을 다뤘으며, 몇 가지 흥미로운 실험을 수행했지만 Next.js 13은 확실히 안정적이라는 것을 확인했습니다. 그리고 그 이후로 새로운 <Link><Image> 컴포넌트와 (아직 베타 버전인) @next/font를 살펴봤습니다. 이런 기능들은 즉각적으로 장점을 누릴 수 있었습니다. 터보팩은 발표에서 분명히 밝혔듯이 아직 알파 버전으로, 개발 빌드만을 대상으로 하며 아직 한창 개발 중인 프로젝트입니다. 아직 통합 및 최적화가 진행 중이므로 여러분의 프로젝트에서 사용 가능할지는 여러분의 기술 스택에 달려 있습니다. 이 글의 범위는 엄밀히 말해 이번 발표의 주인공인 새로운 App 디렉터리 아키택처(줄여서 AppDir)에 관한 것입니다.

App 디렉터리는 리액트 생태계의 중요한 진화인 리액트 서버 컴포넌트 및 엣지 런타임과 결합하여 있기 때문에 계속해서 궁금증을 불러 일으키는 디렉터리입니다. 이는 분명 Next.js의 미래 모습일 것입니다. 하지만 아직 실험 단계이며, 로드맵이 향후 몇 주 내에 완성될 수 있는 단계로 보이지는 않습니다. 그럼, 지금 바로 프로덕션에 사용해야 할까요? 그렇게 될 경우 어떤 이점을 얻을 수 있으며, 어떤 함정에 빠질 수 있을까요? 언제나 그렇듯이 소프트웨어 개발의 답은 ‘상황에 따라 다르다’입니다.

그래서 App 디렉터리가 뭔가요?

App 디렉터리는 Next.js에서 라우트를 처리하고 뷰를 렌더링하기 위한 새로운 전략입니다. 이 전략은 몇 가지 서로 다른 기능을 하나로 묶어 리액트 Concurrent 기능을 최대한 활용할 수 있도록 고안되었습니다.(네 맞습니다. 리액트 Suspense에 대해 이야기하고 있습니다.) 하지만 이는 Next.js 앱의 컴포넌트와 페이지에 대해 생각하는 방식에 큰 패러다임 변화를 가져왔습니다. 이 새로운 앱 빌드 방식은 아키텍처에 매우 환영할 만한 많은 개선점을 제공합니다. 아래는 간략하게 정리된 내용입니다.

  • 부분(partial) 라우팅
    * 라우트 그룹
    * 병렬 라우트
    * 인터셉팅 라우트
  • 서버 컴포넌트 vs 클라이언트 컴포넌트
  • Suspense 바운더리
  • 그리고 더 많은 기능은 이 문서에서 확인할 수 있습니다.

비교해보기

현재 라우팅 및 렌더링 아키텍처(Page 디렉터리)의 경우 개발자는 라우트별로 데이터를 가져와야 했습니다.

  • getServerSideProps: 서버 사이드 렌더링
  • getStaticProps: 서버 사이드 프리 렌더링 그리고/또는 증분 정적 재생성
  • getStaticPaths + getStaticProps: 서버 사이드 프리 렌더링 또는 정적 사이트 생성

지금까지는 페이지 단위로 렌더링 전략을 선택할 수 없었습니다. 대부분의 앱은 전체적으로 서버 사이드 렌더링을 적용하거나 정적 사이트 생성을 사용했습니다. Next.js는 아키텍처 내에 개별적으로 라우트를 생각하는 것이 표준이 될 만큼 충분한 추상화를 만들었습니다.

앱이 브라우저에 도달하면 하이드레이션이 시작하고, _app 컴포넌트를 리액트 컨텍스트 Provider로 감싸서 데이터를 집합적으로 공유하는 라우트를 가질 수 있게 되었습니다. 이를 통해 데이터를 렌더링 트리 맨 위로 끌어올려 앱의 리프 컴포넌트로 계단식으로 내려갈 수 있게 되었습니다.

import { type AppProps } from "next/app";

export default function MyApp({ Component, pageProps }: AppProps) {
return (
<SomeProvider>
<Component {...pageProps} />
</SomeProvider>
);
}

라우터별로 필요한 데이터를 구성, 렌더링 하는 기능 덕분에 이 접근 방식은 앱에서 데이터를 전역적으로 사용해야 할 때 매우 유용한 도구가 되었습니다. 이 전략을 사용하면 데이터를 앱 전체에 뿌릴 수 있지만, 모든 것을 컨텍스트 Provider로 래핑하면 하이드레이션이 앱 루트에 묶이게 됩니다. 즉, 더 이상 서버에서 해당 트리(해당 컨텍스트의 Provider내에 있는 모든 라우트)의 브랜치를 렌더링 할 수 없습니다.

여기서, 레이아웃 패턴이 도입됩니다. 페이지 주위에 래퍼를 생성하면 앱 전체의 렌더링 전략을 결정하는 대신 라우트별로 렌더링 전략을 다시 선택하거나 해제할 수 있게됩니다. 페이지 디렉터리에서 상태를 관리하는 방법에 대한 자세한 내용은 “Next.js의 상태관리” 문서와 Next.js 공식 문서를 참고하세요.

레이아웃 패턴은 훌륭한 솔루션이었습니다. 렌더링 전략을 세밀하게 정의할 수 있다는 것은 매우 환영할 만한 부분이었습니다. 그래서 App 디렉터리에 레이아웃 패턴을 전면에 배치했습니다. Next.js 아키텍처의 일급 객체(first-class citizen)로서 성능, 보안 및 데이터 처리 측면에서 엄청난 개선을 이룰 수 있었습니다.

리액트 Concurrent 기능을 사용하면 이제 컴포넌트를 브라우저로 스트리밍하고, 각 컴포넌트가 자체 데이터를 처리하도록 할 수 있습니다. 따라서 이제 렌더링 전략이 페이지 전체가 아닌 컴포넌트 기반으로 훨씬 더 세분화됩니다. 레이아웃은 기본적으로 중첩되므로 개발자는 파일 시스템 아키텍처에 따라 각 페이지에 어떤 영향을 미치는지 더 정확하게 파악할 수 있게 됩니다. 무엇보다 컨텍스트를 사용하려면 “use client” 지시문을 통해 컴포넌트를 클라이언트 사이드로 명시적으로 전환해야 합니다.

App 디렉터리의 구성 요소

이 아키텍처는 페이지 별 레이아웃 아키텍처를 기반으로 합니다. 이제 _app 컴포넌트도 없고 _document 컴포넌트도 없습니다. 이 두 컴포넌트는 모두 루트 layout.jsx 컴포넌트로 대체되었습니다. 예상하셨겠지만, 이는 전체 애플리케이션을 래핑하는 특별한 레이아웃입니다.

export function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}

루트 레이아웃은 서버가 전체 앱에 반환하는 HTML을 한 번에 조작하는 방법입니다. 루트 레이아웃은 서버 컴포넌트이며 페이지 이동시 다시 렌더링되지 않습니다. 즉, 레이아웃의 모든 데이터나 상태는 앱의 수명주기 내내 유지됩니다.

루트 레이아웃은 전체 앱을 위한 특별한 컴포넌트지만, 다른 구성요소를 위한 루트 컴포넌트도 가질 수 있습니다.

  • loading.jsx: 전체 라우트의 Suspense 바운더리를 정의할 수 있습니다.
  • error.jsx: 전체 라우트의 에러 바운더리를 정의할 수 있습니다.
  • template.jsx: 레이아웃과 유사하지만, 모든 페이지 이동시 다시 렌더링합니다. 인/아웃 트랜지션과 같은 경로 간 상태를 처리하는데 특히 유용합니다.

이러한 컴포넌트와 규칙은 기본적으로 중첩됩니다. 즉, /about은 자동으로 /의 래퍼 안에 중첩됩니다.

마지막으로, 모든 라우트에 대한 page.jsx가 있어야 하는데, 이는 해당 URL 세그먼트(당신이 컴포넌트를 넣는 위치로 알고 있는)에 대해 렌더링할 주요 컴포넌트를 정의하기 때문입니다. 이 컴포넌트는 기본적으로 중첩되지 않으며, 해당 URL 세그먼트와 정확하게 일치하는 경우에만 DOM에 표시됩니다.

아키텍처에는 훨씬 더 많은 기능이 추가될 예정이지만, 이정도면 프로덕션 환경에서 Page 디렉터리를 App 디렉터리로 마이그레이션을 고려하기 전 멘탈 모델을 세우기에 충분할 것입니다. 꼭 공식 업그레이드 가이드도 확인하세요.

서버 컴포넌트 요약

리액트 서버 컴포넌트를 사용하면 앱은 인프라를 활용해 성능과 전반적인 사용자 경험을 개선할 수 있습니다. 예를 들어 RSC는 최종 번들에 종속되지 않기 때문에 번들 크기가 즉각적으로 개선됩니다. 그리고 서버에 렌더링 되기 때문에 모든 종류의 파싱, 포맷팅 또는 컴포넌트 라이브러리가 서버에 남게 됩니다. 또한, 비동기적인 특성 덕분에 서버 컴포넌트는 클라이언트로 스트리밍됩니다. 이를 통해 렌더링 된 HTML은 브라우저에서 점진적으로 개선될 수 있습니다.

따라서 서버 컴포넌트는 앱 크기와 번들 크기 사이의 선형적 상관관계를 깨고 최종 번들의 크기를 보다 예측할 수 있고 캐싱할 수 있으며 일정하게 유지할 수 있게 합니다. 따라서 RSC는 기존 리액트 컴포넌트(현재는 혼동을 피하기 위해 클라이언트 컴포넌트로 부름)에 비해 모범 사례로 즉시 자리 잡았습니다.

서버 컴포넌트에서 데이터 페칭은 훨씬 유연하며, 제 생각에는 바닐라 자바스크립트에 더 가깝게 느껴져 러닝 커브가 낮다고 느껴집니다. 예를 들어 자바스크립트 런타임을 이해하면 데이터 페칭을 병렬 또는 순차적으로 정의할 수 있으므로 리소스 로딩 워터폴을 더욱 세밀하게 제어할 수 있게 됩니다.

  • 병렬 데이터 페칭, 모든 것을 기다립니다.
import TodoList from "./todo-list";

async function getUser(userId) {
const res = await fetch(`https://<some-api>/user/${userId}`);
return res.json();
}
async function getTodos(userId) {
const res = await fetch(`https://<some-api>/todos/${userId}/list`);
return res.json();
}
export default async function Page({ params: { userId } }) {
// 두 요청을 동시에 시작합니다.
const userResponse = getUser(userId);
const todosResponse = getTodos(username);
// 모든 프라미스가 이행될 때까지 기다립니다.
const [user, todos] = await Promise.all([userResponse, todosResponse]);
return (
<>
<h1>{user.name}</h1>
<TodoList list={todos}></TodoList>
</>
);
}
  • 병렬로 진행. 한 요청을 기다리면서 다른 요청을 스트리밍합니다.
async function getUser(userId) {
const res = await fetch(`https://<some-api>/user/${userId}`);
return res.json();
}

async function getTodos(userId) {
const res = await fetch(`https://<some-api>/todos/${userId}/list`);
return res.json();
}
export default async function Page({ params: { userId } }) {
// 두 요청을 동시에 시작합니다.
const userResponse = getUser(userId);
const todosResponse = getTodos(userId);
// user 정보를 기다립니다.
const user = await userResponse;
return (
<>
<h1>{user.name}</h1>
<Suspense fallback={<div>Fetching todos...</div>}>
<TodoList listPromise={todosResponse}></TodoList>
</Suspense>
</>
);
}
async function TodoList({ listPromise }) {
// 앨범 프라미스가 이행되도록 기다립니다.
const todos = await listPromise;
return (
<ul>
{todos.map(({ id, name }) => (
<li key={id}>{name}</li>
))}
</ul>
);
}

이 경우 <TodoList>는 수행중인 프라미스를 수신하고 렌더링하기 전에 이를 기다려야 합니다. 앱은 모든 작업이 완료될 때까지 suspense fallback 컴포넌트를 렌더링 합니다.

  • 순차적 데이터 페칭은 한 번에 하나의 요청을 실행하고 각 요청을 기다립니다.
async function getUser(username) {
const res = await fetch(`https://<some-api>/user/${userId}`);
return res.json();
}

async function getTodos(username) {
const res = await fetch(`https://<some-api>/todos/${userId}/list`);
return res.json();
}

export default async function Page({ params: { userId } }) {
const user = await getUser(userId);
return (
<>
<h1>{user.name}</h1>
<Suspense fallback={<div>Fetching todos...</div>}>
<TodoList userId={userId} />
</Suspense>
</>
);
}
async function TodoList({ userId }) {
const todos = await getTodos(userId);
return (
<ul>
{todos.map(({ id, name }) => (
<li key={id}>{name}</li>
))}
</ul>
);
}

이제 PagegetUser 페칭을 기다린 다음 렌더링을 시작합니다. <TodoList>에 도달하면, getTodos를 페칭한 뒤 기다립니다. 이것은 페이지 디렉터리에서 사용하던 것보다 더 세분되어 있습니다.

주의해야 할 중요한 사항

  • 동일한 컴포넌트 범위 내에서 실행되는 요청은 병렬로 실행됩니다.(자세한 내용은 아래 확장된 Fetch API 문단에서 확인하세요.)
  • 동일한 서버 런타임내에서 실행되는 동일한 요청은 중복이 제거됩니다.(실제로는 캐시 만료가 가장 짧은 요청 하나만 실행됨)
  • fetch를 사용하지 않는 요청(예: SDK, ORM 또는 데이터베이스 클라이언트와 같은 타사 라이브러리)의 경우 세그먼트 캐시 구성을 통해 수동으로 구성하지 않는 한 라우트 캐싱에 영향을 미치지 않습니다.
export const revalidate = 600; // 매 10분마다 revalidate

export default function Contributors({ params }: { params: { projectId: string } }) {
const { projectId } = params;
const { contributors } = await myORM.db.workspace.project({ id: projectId });
return <ul>{/* ... */}</ul>;
}

개발자에게 얼마나 더 많은 제어권을 주는지 보죠. Page 디렉터리 내에서는 모든 데이터를 사용할 수 있을 때까지 렌더링이 차단됩니다. getServerSideProps를 사용하면 전체 라우트에 대한 데이터를 사용할 수 있을 때까지 사용자에게 로딩 스피너가 계속 표시됩니다. App 디렉터리 내에서 이 동작을 모방하려면 해당 라우트에 대한 layout.tsx에서 페칭 요청이 발생해야 하므로 항상 피해야 합니다. "전부 아니면 전무" 접근 방식은 필요한 경우가 거의 없으며, 세분된 전략과는 반대로 체감 성능을 악화시킵니다.

확장된 Fetch API

구문은 fetch(route, options)로 동일합니다. 그러나 웹 Fetch 사양에 따르면 options.cache는 이 API가 브라우저 캐시와 상호작용하는 방식을 결정합니다. 하지만 Next.js에서는 프레임워크 서버 사이드 HTTP 캐시와 상호작용 합니다.

Next.js의 확장된 Fetch API 및 캐시 정책과 관련해 두 가지 값을 이해하는 것이 중요합니다.

  • force-cache: 기본값. 새로운 match를 찾아 반환합니다.
  • no-store 또는 no-cache: 모든 요청에 대해 원격 서버에서 페칭합니다.
  • next.revalidate: ISR과 동일한 구문이며, 리소스를 새 리소스로 간주하는 엄격한 임계값을 지정합니다.
fetch(`https://route`, { cache: "force-cache", next: { revalidate: 60 } });

캐싱 전략을 통해 요청을 분류할 수 있습니다.

  • 정적 데이터: 더 오래 유지. (예: 블로그 글)
  • 동적 데이터: 자주 변경되거나 사용자 상호 작용의 결과물. (예: 댓글 섹션, 장바구니)

기본적으로 모든 데이터는 정적 데이터로 간주됩니다. 이는 force-cache가 기본 캐싱 전략이기 때문입니다. 완전히 동적인 데이터에 대해 force-cache를 사용하지 않으려면 no-store 또는 no-cache를 정의하면 됩니다.

쿠키 또는 헤더 설정과 같은 동적 기능을 사용하는 경우 기본값이 force-cache에서 no-store로 전환되는 부분을 참고하세요!

마지막으로, 증분 정적 재생성과 더 유사한 기능을 구현하려면 next.revalidate를 사용해야 합니다. 전체 라우트에 대해 정의하는 대신 해당 경로의 일부인 컴포넌트만 정의한다는 이점이 있습니다.

Page 디렉터리에서 App 디렉터리로 이전하기

Page 디렉터리에서 App 디렉터리로 로직을 옮기는 것은 많은 작업처럼 보일 수 있지만, Next.js는 두 아키텍처가 공존할 수 있도록 준비되어 있어 마이그레이션을 점진적으로 수행할 수 있습니다. 또한, 문서에 매우 훌륭한 마이그레이션 가이드가 있으므로 리팩터링에 뛰어들기 전에 충분히 읽어보시기를 권장합니다.

마이그레이션 라우트를 안내하는 것은 이 글의 범위를 벗어나며 위 문서와 중복될 수 있습니다. 대신, 공식 문서에서 제공하는 내용 외에 가치를 더하기 위해, 제 경험에서 예상되는 마찰 지점에 대한 통찰력을 제공하려고 노력할 것입니다.

리액트 컨텍스트를 다룬다면

이 글에서 언급한 모든 이점을 제공하기 위해 RSC는 상호작용할 수 없으며, 이는 훅이 없다는 것을 의미합니다. 따라서 클라이언트 사이드 로직을 렌더링 트리의 하위 컴포넌트로 최대한 늦게 푸시하기로 했으며, 상호작용 기능을 추가하면 해당 컴포넌트의 자식이 클라이언트 사이드가 됩니다.

일부 컴포넌트를 푸시하는 것이 불가능한 경우도 있습니다. 예를 들어 일부 핵심 기능이 리액트 컨텍스트에 의존하는 경우가 있습니다. 대부분의 라이브러리는 프로퍼티 드릴링으로부터 사용자를 방어할 준비가 되어있기 때문에, 많은 라이브러리가 루트에서 먼 자식 컴포넌트로 건너뛰는 컨텍스트 Provider를 생성합니다. 따라서 리액트 컨텍스트를 완전히 버리면 일부 외부 라이브러리가 제대로 작동하지 않을 수 있습니다.

임시 해결책은 존재합니다. Provider를 위한 클라이언트 사이드 래퍼입니다.

// /providers.jsx
‘use client’

import { type ReactNode, createContext } from 'react';
const SomeContext = createContext();
export default function ThemeProvider({ children }: { children: ReactNode }) {
return (
<SomeContext.Provider value="data">
{children}
</SomeContext.Provider>
);
}

따라서 레이아웃 컴포넌트는 클라이언트 컴포넌트가 렌더링 시 건너뛰어도 괜찮습니다.

// app/.../layout.jsx
import { type ReactNode } from 'react';
import Providers from ‘./providers’;

export default function Layout({ children }: { children: ReactNode }) {
return (
<Providers>{children}</Providers>
);
}

이 작업을 수행하면 전체 브랜치가 클라이언트 사이드에서 렌더링 된다는 점을 인식해야 합니다. 이 접근방식은 <Providers> 컴포넌트 내의 모든 것이 서버에서 렌더링 되지 않으므로 최후의 수단으로만 사용하세요.

타입스크립트 및 비동기 리액트 요소

레이아웃 및 페이지 외부에서 async/await을 사용할 때 타입스크립트는 JSX 정의와 일치할 것으로 예상되는 응답 유형에 따라 오류를 생성합니다. 이 기능은 지원되며 런타임에도 계속 작동하지만, Next.js 문서에 따르면 이 문제는 타입스크립트에서 업스트림으로 수정해야 합니다.

현재로써는 {/* @ts-expect-error Server Component */} 주석을 추가하는 것이 해결책입니다.

작업에 대한 클라이언트 사이드 페칭

지금까지 Next.js에는 데이터 변이 시나리오가 내장되어 있지 않았습니다. 클라이언트 사이드에서 실행되는 요청은 개발자의 재량에 따라 알아내야 했습니다. 하지만 리액트 서버 컴포넌트는 다릅니다. 리액트 팀은 프라미스를 수락한 다음 처리하고 값을 직접 반환하는 use훅을 개발중입니다.

앞으로는 use훅이 현업에서 사용되는 useEffect의 나쁜 사례를 대체할 것이며(이에 관한 자세한 내용은 "Goodbye UseEffect"에서 확인할 수 있습니다.), 클라이언트 사이드 리액트에서 (페칭을 포함한)비동기성을 처리하는 표준이 될 수 있습니다.

당분간은 클라이언트 사이드 페칭 요구사항에 대해 React-Query및 SWR과 같은 라이브러리를 사용하는 것이 좋습니다. 하지만, fetch동작에 특히 유의하세요!

그럼 준비 되었나요?

실험은 앞으로 나아가기 위한 핵심이며, 시도하지 않고는 성공할 수 없습니다. 이 글이 여러분의 구체적인 사용사례에 대한 답을 찾는데 도움이 되었길 바랍니다.

새로운 프로젝트의 경우 저는 App 디렉터리를 사용하고 Page 디렉터리는 비즈니스에 중요한 기능이나 예비용으로 남겨둡니다. 리팩터링을 한다면 클라이언트 사이드 페칭의 양에 따라 달라질 수도 있습니다. 소수의 경우는 실행할 것이고 다수의 경우 전체 내용을 기다릴 것입니다.

트위터나 댓글로 여러분의 생각을 알려주세요.

👻 FYI 👻 이 글은 App Router가 아직 실험 기능일 때 작성되었으며 Next.js는 2023년 5월 5일 13.4 버전을 통해 App Router가 Stable단계에 들어섰다고 공식 발표했습니다. 관련 발표는 이 링크에서 확인할 수 있습니다.

--

--