[Next.js]App Router

맛없는콩두유·2025년 3월 4일
1

App Router

  • 동적 경로

  • localhost:3000/search?q=1
    [src/app/search/page.tsx]

export default async function Page({
  searchParams,
}: {
  searchParams: Promise<{ q: string }>;
}) {
  const { q } = await searchParams;
  return <div>Search 페이지 : {q}</div>;
}
  • localhost:3000/book/1
    [src/app/book/[id]/page.tsx]
export default async function Page({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;

  return <div>book/ [id] 페이지 : {id}</div>;
}

레이아웃 설정

라우트 그룹(경로에 영향을 미치지 않는 폴더)

[src/app/[with-searchbar]]]
: 하나의 소괄호()로 된 폴더를 만들어 놓고, 해당 폴더에 page.tsx를 넣으면
경로에 영향을 미치지 않지만 해당 폴더 내에 layout.tsx를 만들어 놓으면, 해당 폴더내에 있는 page.tsx에만 layout이 적용이 되어 유용하게 쓰일 수 있다.

book 폴더의 page.tsx에는 layout이 적용이 안된다.

Server Component vs Client Component

  • Server Component: 서버측에서만 실행되는 컴포넌트 (브라우저 실행 X)
    App Router에서는 기본적으로 Server Component를 사용한다. (useEffect, useState 사용 불가능)
    하지만 경우에 따라 상호작용이 있어야 하는 컴포넌트만 Client Component를 사용하면 된다.
    사용 법은 상단에 "use client"를 적어주면 Server Component -> Client Component로 적용된다.
"use client";

import { useState } from "react";

export default function Searchbar() {
  const [search, setSearch] = useState("");

  const onChangeSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
    setSearch(e.target.value);
  };

  return (
    <div>
      <input value={search} onChange={onChangeSearch} />
      <button>검색</button>
    </div>
  );
}

주의사항

네비게이팅

[src/app/layout.tsx]

	return (
    <html lang="en">
      <body className={`${geistSans.variable} ${geistMono.variable}`}>
        <header>
          <Link href={"/"}>index</Link>
          &nbsp;
          <Link href={"/search"}>Search</Link>
          &nbsp;
          <Link href={"/book/1"}>book/1</Link>
        </header>
        {children}
      </body>
    </html>
  );

import Link from "next/link";
를 통해 네비게이팅이 가능하다.

	import { useRouter } from "next/navigation";
	export default function Searchbar() {
    	const router = useRouter();
		
		...

    const onSubmit = () => {
      router.push(`/search?q=${search}`);
    };
    return (
      <div>
        <input value={search} onChange={onChangeSearch} />
        <button onClick={onSubmit}>검색</button>
      </div>
    );

import { useRouter } from "next/navigation";
useRouter를 통해서 프로그래매틱한 페이지 이동도 가능하다.
주의) Page Router 버전에서는 from 'next/router'; 를 사용

Pre-Fetching

: 데이터를 미리 가져와 빠르게 화면을 렌더링
Link 태그 사용 시 자동으로 pre-fetching 지원

데이터 패칭

In Page Router


In App Router


=> 기존의 getServerSideProps, getStaticProps ...를 대체한다!!

데이터 캐시

fetch 메서드를 활용해 불러온 데이터를 Next 서버에서 보관하는 기능
: 영구적으로 데이터를 보관 / 특정 시간을 주기로 갱신
=> 불 필요한 데이터 요청의 수를 줄여 웹 서비스 성능을 크게 개선


=> 오직 fetch 메서드에서만 활용 가능

{ cache: "no-store"}

  • 데이터 패칭의 결과를 저장하지 않는 옵션
  • 캐싱을 아예 하지 않도록 설정하는 옵션

{ cache: "force-cache"}

  • 요청의 결과를 무조건 캐싱
  • 한번 호출 된 이후에는 다시는 호출되지 않음

{ next: {revalidate: 3 }}

  • 특정 시간을 주기로 캐시를 업데이트 함
  • 마치 Page Router의 ISR 방식과 유사 함

{ next: {tags: ['a'] }}

  • On-Demand Revalidate
  • 요청이 들어왔을 떄 데이터를 최신화 함

리퀘스트 메모이제이션

  • 요청을 기억함
  • 같은 요청을 여러개 보낼 떄 중복된 API 요청을 하나의 요청으로 자동으로 합쳐줌.

페이지 캐싱

풀 라우트 캐시

Next 서버측에서 빌드 타임에 특정 페이지의 렌더링 결과를 캐싱하는 기능


정적(Static) 페이지에서만 풀 라우트 캐시를 적용해서 빠른 속도로 처리 가능
=> { cache: "force-cache" }를 fetch함수에서 적용해주면 된다.

동적 경로에 generateStaticParams

정적인 param을 빌드 타임에 만들어내는 기능

export function generateStaticParams() {
  return [{ id: "1" }, { id: "2" }, { id: "3" }];
}

export default async function Page({
  params,
}: {
  params: Promise<{ id: string | string[] }>;
}) {
  const param = await Promise.resolve(params); // `params`를 비동기적으로 해석
  const response = await fetch(
    `${process.env.NEXT_PUBLIC_API_SERVER_URL}/book/${param.id}`
  );

generateStaticParams 안에 배열로 특정 id를 설정해서 빌드 타임에 호출하여
속도가 빠르게 가능하다.
특정 id가 설정된 param 외에 호출이 될 시에 실시간으로 동적 페이지로 만들어진다.

라우트 세그먼트 옵션

특정 페이지에 캐싱이나 Revalidate 동작을 강제로 설정할 수 있게해주는 옵션

export const dynamicParmas = false;
// 정적인 param을 빌드 타임에 만들어내는 기능
export function generateStaticParams() {
  return [{ id: "1" }, { id: "2" }, { id: "3" }];
}

generateStaticParams으로 설정된 param 외에 호출 시
dynamicParmas = false 옵션으로 강제로 404 Page로 이동

export const dynamic = "force-dynamic";
// 특정 페이지의 유형을 강제로 Static, Dynamic 페이지로 설정
// 1. auto : 기본 값, 아무것도 강제하지 않음.
// 2. force-dynamic : 페이지를 강제로 Dynamic 페이지로 설정
// 3. force-static : 페이지를 강제로 Static 페이지로 설정
// 4. error : 페이지를 강제로 Static 페이지로 설정 (설정하면 안되는 이유 => 빌드 오류)

클라이언트 라우터 캐시

  • 브라우저에 저장되는 캐시
  • 페이지 이동을 효율적으로 진행하기 위헤 일부 데이터를 보관

    현재 ~/(index) 와 ~/search를 호출할 때 공통된 레이아웃(루트, 서치바)컴포너트를 호출하게되는데, 이것을 불필요한 동작이기 때문에
    ~/(index)를 호출 할 때 공통된 레이아웃을 캐시하여 ~/search를 호출할 떄 캐시된 공통된 레이아웃은 빠르게 가져오고, 나머지 필요한 페이지 및 기타는 새로 요청해서 불러오게 되는 기능이다.

페이지 스트리밍(loading.tsx)

  • 데이터를 패칭 전 서버에서 HTML을 점진적으로 전송하여 사용자가 더 빠르게 콘텐츠를 볼 수 있도록 하는 기술이다.

    search 폴더에 loading.tsx를 생성해주면 자동으로 페이지 스트리밍이 적용됨.
    단, 비동기 페이지 컴포넌트에서만 사용 가능하다.

컴포넌트 스트리밍(Suspense Component)

... 

async function SearchResult({ q }: { q: string }) {
  await delay(1500);
  const response = await fetch(
    `${process.env.NEXT_PUBLIC_API_SERVER_URL}/book/search?q=${q}`,
    { cache: "force-cache" }
  );

  if (!response.ok) {
    return <div>오류가 발생했습니다..</div>;
  }

 ...

}

export default function Page({
  searchParams,
}: {
  searchParams: {
    q?: string;
  };
}) {
 
  return (
    <Suspense key={searchParams.q || ""} fallback={<div>Loading ...</div>}>
      <SearchResult q={searchParams.q || ""} />;
    </Suspense>
  );
}

페이지 스트리밍 시 loading.tsx를 사용하는 대신에 SusPense를 이용해
SearchResult를 Suspense로 묶어서 fallback 시 대체 UI로서 Loading ...이 표시된다.

Suspense 컴포넌트는 한 페이지 내에 비동기 작업이 여러 개가 있을 떄 진가를 발휘한다.

스켈레톤 UI 적용하기

	        <Suspense
          fallback={
            <>
              <BookItemSkeleton />
            </>
          }
        >

fallback 속성에 대체될 Component를 적어주면 된다.

에러 핸들링

fetch 호출 시 try-catch 구문으로 전부 에러를 핸들링 하기에는 손이 많이 가는 문제점이 발생한다. 그래서 Next.js에서는 페이지 경로에 error.tsx를 만들어 주면 자동으로 에러를 핸들링 한다.

"use client";

import { useRouter } from "next/navigation";
import { useEffect } from "react";

export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  const router = useRouter();
  useEffect(() => {
    console.log(error.message);
  }, [error]);
  return (
    <div>
      <h3>오류가 발생했습니다.( {error.message} )</h3>
      <button
        onClick={() => {
          startTransition(() => {
            router.refresh(); // 현재 페이지에 필요한 서버컴포넌트들을 다시 불러옴
            reset(); // 에러 상태를 초기화, 컴포넌트들을 다시 렌더링
          });
        }}
      >
        다시 시도
      </button>
    </div>
  );
}

props로 error를 받으면 error의 message 출력이 가능하고,
오류 발생시 router의 refresh함수를 통해서 현재 페이지의 서버컴포넌트의 fetch 데이터를 다시 불러와 데이터를 재랜더링하고 오류 페이지를 없앨 수 있다.
이 때, router.refresh()는 비동기함수인데,reset()이 먼저 실행이 되는 문제가 발생해 이럴 떄는 startTransition을 이용해
순차적으로 실행이 되게끔 콜백함수를 적어주면 된다.

서버 액션

  • 브라우저에서 호출할 수 있는 서버에서 실행되는 비동기 함수

    FormData를 브라우저에서 Next 서버로 서버 액션 호출을 하여 기존에 API를 활용해야하던 것에서 자바스크립트 함수만으로 이용이 가능하다.
function ReviewEditor() {
  async function createReviewAction(formData: FormData) {
    // server action
    "use server";
    const content = formData.get("content")?.toString();
    const author = formData.get("author")?.toString();

    console.log(content, author);
  }
  return (
    <section>
      <form action={createReviewAction}>
        <input name="content" placeholder="리뷰 내용" />
        <input name="author" placeholder="작성자" />
        <button type="submit">작성하기</button>
      </form>
    </section>
  );
}

서버 액션을 만들게되면 자동으로 API가 생성이되고, 브라우저에서 form 태그를 제출했을 떄 자동으로 생성이된다.

재검증 구현(revalidatePath)

	"use server";

import { revalidatePath } from "next/cache";

export async function createReviewAction(formData: FormData) {
  // server action
  const bookId = formData.get("bookId")?.toString();
  const content = formData.get("content")?.toString();
  const author = formData.get("author")?.toString();

  if (!bookId || !content || !author) {
    return;
  }

  try {
    const response = await fetch(
      `${process.env.NEXT_PUBLIC_API_SERVER_URL}/review/`,
      {
        method: "POST",
        body: JSON.stringify({ bookId, content, author }),
      }
    );
    console.log(response.status);
    revalidatePath(`/book/${bookId}`); // 재검증
  } catch (err) {
    console.error(err);
    return;
  }
}

현재 서버 액션에서 revalidatePath를 요청하면 Next 서버 측에게
데이터를 다시 요청해서 화면에 바로 나타내게 할 수 있다.

즉, 관련된 페이지가 재생성된다.

Form 태그와 useActionState

useActionState는 폼의 제출 후 상태를 자동으로 관리할 수 있도록 도와줍니다.

Form 태그 제출 시 useActionState를 사용하여 isPending를 통해
요청 중인지 아닌지 상태를 확인할 수 있다. isPending 속성을 이용해
로딩 중인 UI를 표시할 수 있다.

"use client";

import { createReviewAction } from "@/actions/create-review.action";
import style from "./review-editor.module.css";
import { useActionState } from "react";

export default function ReviewEditor({ bookId }: { bookId: string }) {
  const [state, formAction, isPending] = useActionState(
    createReviewAction, // 서버 액션
    null
  );
  return (
    <section>
      <form className={style.form_container} action={formAction}>
        <input name="bookId" value={bookId} hidden readOnly />
        <textarea
          disabled={isPending}
          required
          name="content"
          placeholder="리뷰 내용"
        />
        <div className={style.submit_container}>
          <input
            disabled={isPending}
            required
            name="author"
            placeholder="작성자"
          />
          <button disabled={isPending} type="submit">
            {isPending ? "...." : "작성하기"}
          </button>
        </div>
      </form>
    </section>
  );
}

먼저, 서버 컴포넌트였던 것들 "use client"를 이용해
클라이언트 컴포넌트로 바꾼다.

  const [state, formAction, isPending] = useActionState(
    createReviewAction, // 서버 액션
    null
  );


  export async function createReviewAction(_: any, formData: FormData) {
  • createReviewAction: (필수) 폼 데이터를 받아 처리하는 서버 액션 함수

  • initialState: (선택) 초기 상태 값 (null 또는 기본 객체 등 사용 가능)

  • useActionState는 자동으로 이전 상태 값(state)을 첫 번째 매개변수로 전달하기 때문에, createReviewAction 함수에서도 해당 값을 받을 매개변수를 설정해야 합니다. 하지만 이 값을 사용하지 않을 경우 _와 같이 이름을 지정하여 무시할 수 있습니다.

requestSubmit()

const formRef = useRef<HTMLFormElement>(null);

const [state, formAtion, isPending] = useActionState(
  deleteReviewAction,
  null
);

<form ref={formRef} action={formAtion}>
      <input name="reviewId" value={reviewId} hidden />
      <input name="bookId" value={bookId} hidden />
      {isPending ? (
        <div>...</div>
      ) : (
        <div onClick={() => formRef.current?.requestSubmit()}>삭제하기</div>
      )}
    </form>

form 태그 내부에 <button> 태그가 아닌 일반 <div> 태그를 사용할 경우, 기본적으로 submit 동작을 수행할 수 없습니다. 이를 해결하기 위해 useRef를 사용하여 requestSubmit()을 호출하면, <button type="submit">의 역할을 대신할 수 있습니다.

Parallel Route(병렬 라우트)

  • 여러 개의 페이지(라우트)를 동시에 렌더링할 수 있도록 하는 기능

📌 app 라우트 구조와 병렬 라우트 설명

위 이미지에서 app 디렉터리 내 parallel 폴더를 보면 병렬 라우트(Parallel Routes) 구조가 적용되어 있다.
특히 parallel 폴더 아래에 @feed@sidebar라는 디렉터리가 있는데, 이는 병렬 슬롯(Parallel Slot)으로 사용된다.
이러한 구조는 parallel/layout.tsx 파일에서 각각 feedsidebar로 매핑되어 렌더링된다.


🛠️ 병렬 라우트 적용 방식 (parallel/layout.tsx)

import Link from "next/link";
import { ReactNode } from "react";

export default function Layout({
  children,
  sidebar,
  feed,
}: {
  children: ReactNode;
  feed: ReactNode;
  sidebar: ReactNode;
}) {
  return (
    <div>
      <div>
        <Link href={"/parallel"}>parallel</Link>
        &nbsp;
        <Link href={"/parallel/setting"}>parallel/setting</Link>
      </div>
      {sidebar} {/* 병렬로 렌더링될 sidebar 슬롯 */}
      {feed} {/* 병렬로 렌더링될 feed 슬롯 */}
      {children} {/* 기본적으로 페이지가 렌더링되는 영역 */}
    </div>
  );
}

Intercepting Route(인터셉팅 라우트)

  • 특정 경로를 방문할 때 기존 페이지를 덮어씌우는 방식으로 동작하는 라우팅 기능입니다. 이를 활용하면 모달, 임시 페이지, 또는 특정 UI를 유지하면서 새로운 콘텐츠를 로드하는 효과를 낼 수 있습니다.

    여기서 (.)book/[id]경로를 보면 book/[id]가 같은 경로에 있기 때문에 (.)을 하나만 적어주고, 만약 app 폴더 안의 test 폴더 안에 book/[id] 을 intercepting 하려면, (..)이 되어야한다.

이미지 최적화

	<img src={coverImgUrl}/>
	
      => 
    
    <Image
    src={coverImgUrl}
    width={240}
    height={300}
    alt={`도서 ${title}의 표지 이미지`}
    />

검색 엔진 최적화(SEO)

  • 정적 MetaData
 export const metadata: Metadata = {
  title: "한입 북스",
  description: "한입 북스에 등록된 도서를 만나보세요.",
  openGraph: {
    title: "한입 북스",
    description: "한입 북스에 등록된 도서를 만나보세요.",
    images: ["/thumbnail.png"],
  },
};
  • 동적 MetaData
	//동적으로 meta data를 생성하는 역할
export async function generateMetadata({
  searchParams,
}: {
  searchParams: Promise<{ q?: string }>;
}): Promise<Metadata> {
  const { q } = await Promise.resolve(searchParams); // searchParams를 await 처리
  return {
    title: `${q} : 한입북스 검색`,
    description: `${q}의 검색 결과입니다.`,
    openGraph: {
      title: `${q} : 한입북스 검색`,
      description: `${q}의 검색 결과입니다.`,
      images: ["/thumbnail.png"],
    },
  };
}

배포하기

	npm i -g vercel

	vercel login

	vercel

재배포

	vercel --prod

환경 변수 추가(Vercel)

localhost가 아닌 배포된 서버 주소를 입력해 환경 변수를 등록해야한다.

profile
하루하루 기록하기!

0개의 댓글