본문 바로가기
React/Next.js

[NextJS] NextJS14 앱 라우터의 일반적인 실수와 해결 방법 정리 (번역)

by 검은냥냥이 2024. 7. 10.

Next.js App Router 사용 시 발생하는 일반적인 실수와 해결 방법을 다음과 같이 요약할 수 있습니다:

 

서버 컴포넌트에서 라우트 핸들러 사용

서버 컴포넌트(서버사이드 상태)에서 데이터를 직접 불러오는 것이 더 효율적입니다. 이는 서버 리소스를 절약하고, 성능을 향상시킵니다. 이제는 서버 컴포넌트에서 직접 데이터를 불러오는 방식이 권장됩니다.

app/page.tsx

export default async function Page() {
  // call your async function directly
  let data = await getData(); // { data: 'Next.js' }
  // or call an external API directly
  let data = await fetch('https://api.vercel.app/blog')
  // ...
}

이 코드는 "use client"가 선언되지 않아 서버 사이드에서만 실행됩니다. 따라서 라우터 API를 사용할 필요 없이 필요한 외부 요청을 직접 처리하는 것이 좋습니다.

 

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

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

정적 라우트 핸들러 예시:

app/api/data/route.ts

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

이 코드는 next build 동안 미리 렌더링되며, JSON 데이터는 다른 빌드가 완료될 때까지 변경되지 않습니다. 빌드 시 미리 렌더링되고, 캐시되며 데이터가 변경되지 않습니다.

동적 라우트 핸들러 예시:

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);
}

이 예시는 블로그 게시물 목록을 JSON 데이터로 반환합니다. 요청 시마다 새로운 데이터를 가져오며, 외부 API와 통신할 수 있습니다.

 

클라이언트 컴포넌트와 라우트 핸들러

클라이언트 컴포넌트는 "async"로 표시할 수 없기 때문에 데이터 페칭이나 변형을 위해 Route Handlers를 사용해야 한다고 생각할 수 있습니다. 하지만, 클라이언트 컴포넌트에서 직접 서버 액션을 호출하는 것이 가능합니다.

폼을 통한 데이터 저장:

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>;
}

이 코드에서는 이벤트 핸들러를 통해 서버 액션을 호출하여 데이터를 저장합니다.

 

서스펜스 사용

서버 컴포넌트에서 데이터를 페칭할 때, 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>
  );
}

Suspense를 사용하여 데이터 페칭 중 로딩 UI를 표시하는 코드:

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>
  );
}

위와 같이 동적인 처리를 하고 있는 "BlogPosts" 부모인 "Page" 내부에 해당 컴포넌트를 감싸면 됩니다.

 

들어오는 요청 사용

서버 컴포넌트에서는 들어오는 요청 객체에 직접 접근할 수 없기 때문에 URL의 일부나 검색 매개변수를 읽기 위해 클라이언트 훅인 useSearchParams를 사용하는 경우가 있습니다. 하지만, 서버 컴포넌트에는 이러한 작업을 수행할 수 있는 특정 함수와 속성이 있습니다. 예를 들어:

  • cookies()
  • headers()
  • params
  • searchParams

다음과 같은 코드 예제에서는 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>
}

 

이 코드는 params와 searchParams를 통해 URL과 검색 매개변수에 접근할 수 있는 방법을 설명합니다.

 

변경 후 데이터 재검증

데이터 변경 후 해당 클라이언트에서 변경된 부분 재렌더링이 필요하다면, "revalidatePath"를 통해서 원하는 경로에 부분 재렌더링을 통해 변경되거나 추가된 데이터를 보여줄 수 있습니다.

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 블록 밖에서 처리해야 합니다.

예를 들어, 서버 컴포넌트에서 리디렉션하려면 다음과 같이 작성할 수 있습니다:

app/page.tsx

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/actions.ts

'use server';

import { redirect } from 'next/navigation';

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

 

서버와 클라이언트 컴포넌트 함께 사용

기본적으로 예전에는 서버와 클라이언트 1번씩 렌더링이 진행됬다면, 현재는 동일하게 서버에서 렌더링이 이루어지고 화면단은 "use client"가 있는 경우에만 렌더링이 추가로 이루어 집니다. 그렇기 때문에 모든 곳에서 "use client"를 남발할 필요가 없습니다.

서버사이드에서 필요한 정보들을 받기 위해서 구조적으로 아래와 같은 형태를 유지할 수도 있습니다.

app/user/page.tsx (서버사이드/상위컴포넌트)

async function fetchData(query: NUser.SearchQuery) {
  return await UserAPI().getUsers(query)
}

export default async function DashboardUserPage({ searchParams }: any) {
  const query: NUser.SearchQuery = {
    search: searchParams["search"] || "",
    page: Number(searchParams["page"]) || 1,
    limit: 8,
    sort: searchParams["sort"] || "desc",
    start_date: searchParams["start_date"] || "",
    end_date: searchParams["end_date"] || "",
    sort_field: searchParams["sort_field"] || "created_at",
  }

  const { data } = await fetchData(query)

  return (
    <DashBoardUserPage query={query} data={data} totalCount={data.length} />
  )
}


app/user/page-client.tsx (클라이언트사이드/하위컴포넌트)

"use client"

interface Props {
  data: NUser.Model[]
  totalCount: number
  query: NUser.SearchQuery
}

export default function DashBoardUserClientPage(props: Props) {
...something
}

위와 같이 부모 서버사이드를 만들어서 처리할 수도 있습니다.

 

참고 링크

 

Common mistakes with the Next.js App Router and how to fix them – Vercel

Learn how to use the Next.js App Router more effectively and understand the new model.

vercel.com

 

728x90
사업자 정보 표시
레플라 | 홍대기 | 경기도 부천시 부일로 519 화신오피스텔 1404호 | 사업자 등록번호 : 726-04-01977 | TEL : 070-8800-6071 | Mail : support@reafla.co.kr | 통신판매신고번호 : 호 | 사이버몰의 이용약관 바로가기