Next.js App Router를 사용할때 발생하는 10가지 실수

유환빈·2024년 1월 17일

Front

목록 보기
1/2
post-thumbnail

next.js 14와 app router 가 출시되고 새로운 여러가지 기술들을 사용해보면서 이렇게 쓰는게 맞나 할때가 있었는데 좋은글을 발견하게되서 번역하면서 정리해볼려고한다.

원본글 -> Common mistakes with the Next.js App Router and how to fix them

Next.js App Router의 일반적인 오류 및 해결 방법

수백 명의 개발자들과 이야기하고 수천 개의 Next.js 저장소를 살펴본 후, Next.js App Router로 구축할 때 흔히 발생하는 10가지 오류를 발견했습니다.

이 게시물에서는 이러한 오류가 발생할 수 있는 이유, 문제 해결 방법 및 새로운 App Router 모델에 대한 이해를 돕기 위한 몇 가지 팁을 공유합니다.


Server Components 와 함께 Route Handlers 사용

Server Component 에 대해 다음과 같은 코드를 고려해 보십시오 :

// app/page.tsx

export default async function Page() {
  let res = await fetch('http://localhost:3000/api/data');
  let data = await res.json();
  return <h1>{JSON.stringify(data)}</h1>;
}

async 구성 요소는 일부 JSON 데이터를 검색하기 위해 Route Handler에 요청합니다.

// app/data/router.ts

export async function GET(request: Request) {
  return Response.json({ data: 'Next.js' });
}

이 접근 방식에는 두 가지 주요 문제가 있습니다.

  1. Route HandlersServer Components 는 모두 서버에서 안전하게 실행됩니다. 추가 네트워크 홉이 필요하지 않습니다.대신 경로 처리기 내부에 배치하려던 모든 로직을 서버 구성 요소에 직접 호출할 수 있습니다. 외부 API 또는 Promise 일 수 있습니다.
  1. 이 코드는 Node.js가 있는 서버에서 실행되기 때문에 featch 의 절대 URL과 상대 URL을 비교해야 합니다. 사실 여기서 localhost 를 하드코딩하지 않고, 우리가 처한 환경에 따라 조건부 확인이 필요합니다. 로직을 직접 호출할 수 있기 때문에 이것은 불필요합니다.

대신 다음을 수행하는 것이 좋습니다.

// app/page.tsx

export default async function Page() {
  //  비동기 기능을 직접 호출
  let data = await getData(); // { data: 'Next.js' }
  // 또는 외부 API를 직접 호출
  let data = await fetch('https://api.vercel.app/blog')
}

정적 또는 동적 라우트 핸들러

GET 메서드를 사용할 경우 기본적으로 라우트 핸들러가 캐시됩니다. 이는 Pages Router 및 API Route에서 이동하는 기존 Next.js 개발자들에게 종종 혼란을 줄 수 있습니다.

예를 들어 다음 코드는 next build 도중에 미리 렌더링됩니다 :

// app/api/data/route.ts

export async function GET(request: Request) {
  return Response.json({ data: 'Next.js' });
}

이 JSON 데이터는 다른 빌드가 완료될 때까지 변경되지 않습니다. 왜 그런 겁니까?

경로 처리기를 페이지의 구성 요소로 간주할 수 있습니다. 경로에 대한 특정 요청을 처리하려고 합니다. Next.js에는 페이지 및 레이아웃과 같은 경로 처리기 위에 추가 추상화가 있습니다. 이것이 바로 경로 핸들러가 기본적으로 페이지와 같이 정적이며 동일한 경로 세그먼트 구성 옵션 을 공유하는 이유입니다.

이 기능은 이전에 페이지 라우터의 API Routes에서는 불가능했던 몇 가지 새로운 기능을 해제합니다. 예를 들어 JSON, 또는 txt 파일, 또는 빌드 중에 계산되어 미리 렌더링될 수 있는 모든 파일을 생성하는 Route Handler를 가질 수 있습니다. 그러면 정적으로 생성된 파일이 자동으로 캐시되고 원하는 경우 주기적으로 업데이트됩니다.

// app/api/data/route.ts

export async function GET(request: Request) {
  let res = await fetch('https://api.vercel.app/blog');
  let data = await res.json();
  return Response.json(data);
}

또한 이는 경로 핸들러가 정적 파일 호스팅을 지원하는 어느 곳에나 Next.js 애플리케이션을 배포할 수 있는 정적 내보내기 와 호환된다는 것을 의미합니다.


라우터 핸들러와 Client Components

클라이언트 구성 요소와 함께 경로 처리기를 사용해야 한다고 생각할 수도 있습니다. 경로 처리기는 async 데이터를 표시하거나 가져오거나 변경할 수 없기 때문입니다. 경로 핸들러를 작성하고 생성할 필요 없이 클라이언트 구성 요소에서 직접 서버 작업fetch 할 수 있습니다.

// 이름을 저장하기 위한 양식 및 입력입니다.
// app/user-form.tsx

'use client';

import { save } from './actions';

export function UserForm() {
  return (
    <form action={save}>
      <input type="text" name="username" />
      <button>Save</button>
    </form>
  );
}

이는 양식과 이벤트 핸들러 모두에서 작동합니다.

// 서버 작업은 이벤트 핸들러에서 호출할 수 있습니다.
// app/user-form.tsx

'use client';

import { save } from './actions';

export function UserForm({ username }) {
  async function onSave(event) {
    event.preventDefault();
    await save(username);
  }

  return <button onClick={onSave}>Save</button>;
}

Server Component 와 함께 Suspense 사용

다음 Server Component 를 봐보세요. 데이터를 가져오는 동안 표시될 대체 UI를 정의하려면 Suspense를 어디에 배치해야 합니까?

// app/page.tsx

async function BlogPosts() {
  let data = await fetch('https://api.vercel.app/blog');
  let posts = await data.json();
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

export default function Page() {
  return (
    <section>
      <h1>Blog Posts</h1>
      <BlogPosts />
    </section>
  );	
}

Page 구성 요소의 내부를 추측했다면, 맞습니다. Suspense 는 데이터를 가져오는 비동기 구성 요소보다 더 높게 배치되어야 합니다.
Suspense 는 데이터 가져오기를 수행하는 async 구성 요소보다 더 높게 설정해야 합니다. 그 바운더리가 async 구성 요소 내부에 있으면 작동하지 않습니다.

// React Server 구성요소와 함께 Suspense 사용하기.
// app/page.tsx

import { Suspense } from 'react';

async function BlogPosts() {
  let data = await fetch('https://api.vercel.app/blog');
  let posts = await data.json();
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

export default function Page() {
  return (
    <section>
      <h1>Blog Posts</h1>
      <Suspense fallback={<p>Loading...</p>}>
        <BlogPosts />
      </Suspense>
    </section>
  );
}

앞으로 부분 사전 렌더링을 통해 이 패턴은 사전 렌더링되어야 하는 구성 요소와 요청시 실행되어야 하는 구성 요소를 정의하는 것을 포함하여 더욱 일반화되기 시작할 것입니다.

import { unstable_noStore as noStore } from 'next/cache';

async function BlogPosts() {
  noStore(); // 이 구성 요소는 동적으로 실행됩니다.
  let data = await fetch('https://api.vercel.app/blog');
  let posts = await data.json();
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

들어오는 요청 사용

들어오는 요청 개체는 서버 구성 요소에서 액세스할 수 없으므로 수신 요청의 일부를 읽는 방법이 명확하지 않을 수 있습니다. 이로 인해 불필요하게 SearchParams 사용과 같은 클라이언트 후크가 사용될 수 있습니다.

Server Component 에는 들어오는 요청에 액세스할 수 있는 특정 기능과 소품이 있습니다. 예를 들어 :

  • cookies()
  • headers()
  • params
  • searchParams
// URL 및 검색 매개변수의 일부를 읽습니다.
// app/blog/[slug]/page.tsx

export default function Page({
  params,
  searchParams,
}: {
  params: { slug: string }
  searchParams: { [key: string]: string | string[] | undefined }
}) {
  return <h1>My Page</h1>
}

App router 와 함께 Context 제공자 사용

React Context 를 사용하고 싶거나 컨텍스트에 의존하는 외부 종속성을 사용하고 있을 수 있습니다. 내가 본 두 가지 일반적인 실수는 서버 구성 요소(지원되지 않음)와 함께 컨텍스트를 사용하려고 시도하는 것과 앱 라우터에 공급자를 배치하는 것입니다.

서버 및 클라이언트 구성 요소가 인터리브되도록 하려면 공급자(또는 여러 공급자)를 childrenprop으로 가져와 렌더링하는 별도의 클라이언트 구성 요소로 만드는 것이 중요합니다. 예를 들어:

// React Context를 사용하는 클라이언트 구성 요소입니다.
// app/theme-provider.tsx

'use client';

import { createContext } from 'react';

export const ThemeContext = createContext({});

export default function ThemeProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>;
}

그런 다음 별도의 파일에 있는 공급자를 클라이언트 구성 요소로 사용하면 레이아웃 내부에서 이 구성 요소를 가져와 사용할 수 있습니다.

// 클라이언트 컨텍스트 공급자와 서버 구성 요소 하위 항목을 엮는 루트 레이아웃입니다.
// app/layout.tsx

import ThemeProvider from './theme-provider';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}

공급자가 루트에서 렌더링되면 앱 전체의 다른 모든 클라이언트 구성 요소가 이 컨텍스트를 사용할 수 있습니다. 특히 이 구성에서는 page 트리의 아래쪽에 있는 다른 서버 구성 요소(포함)를 계속 허용합니다.


Server and Client Components 를 함께사용

많은 React 및 Next.js 개발자는 처음으로 서버 및 클라이언트 구성 요소를 사용하는 방법을 배우고 있습니다. 이 새로운 모델을 배울 수 있는 실수와 기회가 있을 것으로 예상됩니다!

예를 들어 다음 페이지를 살펴보세요.

// app/page.tsx

export default function Page() {
  return (
    <section>
      <h1>My Page</h1>
    </section>
  );
}

이것은 서버 구성 요소입니다. 이는 구성 요소에서 직접 데이터를 가져올 수 있는 것과 같은 새로운 기능과 함께 제공되지만 특정 클라이언트 측 React 기능을 사용할 수 없음을 의미하기도 합니다.

예를 들어, 카운터인 버튼을 만드는 것을 고려해 보세요. 이것은 "use client" 명령어가 맨 위에 표시된 새 Client Component 파일이어야 합니다 :

// app/counter.tsx

'use client';

import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

그런 다음 페이지에서 이 구성 요소를 가져와 사용할 수 있습니다.

// app/page.tsx

import { Counter } from './counter';

export default function Page() {
  return (
    <section>
      <h1>My Page</h1>
      <Counter />
    </section>
  );
}

페이지는 서버 구성 요소이고 <Counter> 는 클라이언트 구성 요소입니다. 카운터보다 트리에 있는 구성 요소도 서버 구성 요소가 될 수 있습니다 :

// app/page.tsx

import { Counter } from './counter';

function Message() {
  return <p>This is a Server Component</p>;
}

export default function Page() {
  return (
    <section>
      <h1>My Page</h1>
      <Counter>
        <Message />
      </Counter>
    </section>
  );
}

클라이언트 구성요소의 자식은 서버 구성요소가 될 수 있습니다! 다음은 업데이트된 카운터입니다 :

// app/counter.tsx

'use client';

import { useState } from 'react';

export function Counter({ children }: { children: React.ReactNode }) {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      {children}
    </div>
  );
}

“use client” 불필요하게 추가

이전 예제를 기반으로 하면 "use client" 지침을 모든 곳에 추가해야 한다는 뜻입니까?

"use client" 명령어가 추가되면 클라이언트 측의 자바스크립트를 실행할 수 있는 기능을 제공하는 "클라이언트 경계"로 넘어갑니다. 클라이언트 구성요소는 Next.js Pages Router의 구성요소와 마찬가지로 여전히 서버에서 미리 렌더링됩니다.

이미 클라이언트 경계에 있기 때문에 <Counter>의 형제들은 클라이언트 구성요소가 됩니다. 모든 파일에 "use client"를 추가할 필요는 없습니다. 이것은 트리 위에 있는 구성요소가 클라이언트 구성요소가 되고 하위 서버 구성요소를 더 아래로 엮는 앱 라우터의 점진적인 채택을 위해 채택된 접근 방식일 수 있습니다.


mutations 후 데이터를 재검증하지 않음

Next.js App Router에는 데이터 가져오기, 캐싱 및 재검증 을 위한 전체 모델이 포함되어 있습니다. 개발자들은 여전히 이 새로운 모델을 학습하고 있고, 우리는 그들의 피드백을 기반으로 계속해서 개선하고 있기 때문에, 내가 본 한 가지 흔한 실수는 변형 후에 데이터를 재검증하는 것을 잊어버렸다는 것입니다.

예를 들어, 다음 Server Component 를 생각해 보십시오. 양식이 표시되는데, 양식은 Server Action(서버 액션) 을 사용하여 제출을 처리하고 Postgres 데이터베이스 에 새 항목을 작성합니다.

// app/page.tsx

export default function Page() {
  async function create(formData: FormData) {
    'use server';

    let name = formData.get('name');
    await sql`INSERT INTO users (name) VALUES (${name})`;
  }

  return (
    <form action={create}>
      <input name="name" type="text" />
      <button type="submit">Create</button>
    </form>
  );
}

양식을 제출하고 삽입이 성공적으로 완료되면 이름 목록을 표시하는 데이터가 자동으로 업데이트됩니까? 아니요, Next.js 에 지시하지 않는 한 그렇지 않습니다. 예를 들어 :

// app/page.tsx

import { revalidatePath } from 'next/cache';

export default async function Page() {
  let names = await sql`SELECT * FROM users`;

  async function create(formData: FormData) {
    'use server';

    let name = formData.get('name');
    await sql`INSERT INTO users (name) VALUES (${name})`;

    revalidatePath('/'); //서버 작업 내부의 데이터를 재검증합니다.

  }

  return (
    <section>
      <form action={create}>
        <input name="name" type="text" />
        <button type="submit">Create</button>
      </form>
      <ul>
        {names.map((name) => (
          <li>{name}</li>
        ))}
      </ul>
    </section>
  );
}

try/catch 블록 내부로 리디렉션

서버 구성 요소 또는 서버 작업과 같은 서버 측 코드를 실행할 때 리소스를 사용할 수 없거나 변형이 성공한 경우 리디렉션을 원할 수 있습니다.

redirect() 기능은 TypeScript never 타입을 사용하기 때문에 return redirect() 를 사용할 필요가 없습니다. 또한 내부적으로 이 기능은 Next.js 고유의 오류를 발생시킵니다. 이는 try/catch 블록 외부로의 리디렉션을 처리해야 함을 의미합니다.

예를 들어, 서버 구성요소의 내부를 리디렉션하려는 경우 다음과 같이 표시될 수 있습니다:

import { redirect } from 'next/navigation';

async function fetchTeam(id) {
  const res = await fetch('https://...');
  if (!res.ok) return undefined;
  return res.json();
}

export default async function Profile({ params }) {
  const team = await fetchTeam(params.id);
  if (!team) {
    redirect('/login'); // 서버 구성 요소에서 리디렉션.
  }

  // ...
}

또는 클라이언트 구성 요소에서 리디렉션을 시도하는 경우 이는 이벤트 핸들러가 아닌 서버 작업 내부에서 수행되어야 합니다.

// 서버 작업을 통해 클라이언트 구성 요소에서 리디렉션
// app/client-redirect.tsx

'use client';

import { navigate } from './actions';

export function ClientRedirect() {
  return (
    <form action={navigate}>
      <input type="text" name="id" />
      <button>Submit</button>
    </form>
  );
}
// 새 경로로 리디렉션되는 서버 작업
// app/action.ts

'use server';

import { redirect } from 'next/navigation';

export async function navigate(data: FormData) {
  redirect('/posts');
}

요약

  • Server Components 에서 Route Handlers 사용하는것보단 서버 컴포넌트에서 직접 호출하세요.
  • GET 메서드를 사용할 때 라우트 핸들러는 기본적으로 캐시됩니다.
  • Server Action 으로 Form 라우터 핸들러 대신 사용할 수 있습니다.
  • Server Component 들과 함께 Suspense 사용할려면 Suspense 는 데이터를 가져오는 비동기 구성 요소보다 더 높게 배치되어야 합니다.
  • Server Component 에는 SearchParams 대신에 들어오는 요청에 액세스할 수 있는 특정 기능과 소품이 있습니다.
  • Server Components 와 Client Components 둘다 자식으로 사용가능합니다.
  • 'use client' 을 선언하면 형제들도 클라이언트 구성요소가되서 모든 파일에 추가할 필요없습니다.
  • mutations 후 revalidatePath 같은걸 이용해서 데이터를 재검증해주세요.
  • redirect() 기능은 TypeScript never 타입을 사용하기 때문에 return redirect() 를 사용할 필요가 없습니다.

마무리

이런 글을 읽기만했었는데 써보면서 정리하니까 더 이해가 잘 됬고 revalidatePath 같은것도 평소에 이름만 알았는데 사용법을 알게되어서 좋았다.

profile
프론트엔드 공부하고있는 유환빈입니다

0개의 댓글