DYO 공부하는 블로그

[Next.js] Next 정리 본문

React/Next.js

[Next.js] Next 정리

DYODa 2025. 9. 14. 22:38

Next란?

React 기반 풀스택 웹 프레임워크이고 SSR(서버 사이드 렌더링)과 SSG(정적 웹 페이지 생성)를 지원한다. Next는 초기 로딩 속도 개선, SPA 작동 방식의 가장 큰 문제인 SEO(검색 엔진 최적화) 문제를 해결하기 위해서 등장했다.

 

Next는 풀스택 프레임워크이므로 프론트, 백 구분 없이 작성 가능하고 번들러 또한 terbopack으로 지원하고 있다. ( Vercel 배포 환경에서 최적화되어 있고, 다른 배포 환경에서는 문제가 있을 수 있으니 Webpack 사용도 고려 )

 

다양한 내장 컴포넌트를 이용한 최적화 기능이 다수 마련되어 있고, JS 파일도 함께 내려줘야 하는 CSR(클라이언트 사이드 렌더링)방식과 달리 정적 HTML을 보내 줄 수 있는 SSR(서버 사이드 렌더링)의 속도 차이도 있기 때문에 Next를 적용했을 때 로드 속도를 크게 개선시킬 수 있다.

 

Vercel의 라우팅 방식

Next는 파일 기반 라우팅을 사용한다. Vercel 규칙에 따라 정해진 디렉터리명, 파일명으로 중첩 라우트, 동적 라우트를 관리 할 수 있다. 라우팅 방식은 크게 나누어 Page Router, App Router 방식이 있다. 둘은 문법 차이도 있고, 동작 철학도 다르다.

  • Page Router : 주로 클라이언트 방식, getStaticProps, getServerSideProps 등으로 SSR/SSG 제어
  • App Router : 주로 서버 방식, 기본적으로 RSC(리액트 서버 컴포넌트)이고, ‘use client’ 명령어로 RCC(리액트 클라이언트 컴포넌트)로 선택한다. Segment + Layout 방식 지원

아직 Page Router를 많이 사용하기도 하지만, 학습자 입장에서는 최신 기술인 App Router 위주로 다뤄보도록 하자.

 

1. Next.js App Router 기본 개념

1.1 메타데이터 관리

// src/app/layout.tsx
// 메타데이터는 Component export function 바깥에서 쓴다.
export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
  openGraph: {
    title: "Triangle",
    description: "다양한 작가 다양한 사진",
    url: "https://triangle.com",
    siteName: "Triangle",
    type: "website",
    images: [
      {
        url: "https://triangle.com/og-image.png",
        width: 1200,
        height: 630,
        alt: "Triangle 사이트 이미지",
      },
    ],
    locale: "ko-KR",
  },
  twitter: {
    title: "Triangle",
    description: "다양한 작가 다양한 사진",
    images: [
      {
        url: "https://triangle.com/og-image.png",
        width: 1200,
        height: 630,
        alt: "Triangle 사이트 이미지",
      },
    ],
  },
};

 

핵심 개념:

  • metadata 객체로 SEO 최적화
  • OpenGraph와 Twitter 카드 설정으로 소셜 미디어 공유 최적화
  • 루트 레이아웃에서 전역 메타데이터 설정

1.2 동적 메타데이터 생성

// src/app/(top-badge-layout)/photos/[id]/page.tsx
// Dynamic 페이지에서 메타데이터를 넣기 위해 생성
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;

  // 필요하다면 이렇게 가져올 수 있음. 실패 확률이 있으니 try/catch문 사용
  // const data = await fetchPhotosByOne(id);

  return {
    title: `Triangle | Photos-${id}`
  }
}

 

핵심 개념:

  • 동적 라우트에서 generateMetadata 함수로 페이지별 메타데이터 생성
  • params는 Promise로 래핑되어 있어 await 필요

2. 서버 컴포넌트와 데이터 페칭

2.1 서버 컴포넌트 기본

// src/app/page.tsx
// 10개의 랜덤 이미지를 가져와 렌더링
export default async function Home() {
  // RSC에서는 Promise all을 사용할 경우 같은 시간에 동시에 보내므로 추가 처리가 필요
  const data = await getRandomPhotos();
  console.log(data);

  return (
    <div>
      <div className="flex flex-col items-center py-10">
        <h1 className="text-2xl font-bold">Triangle에서</h1>
        <p>다양한 작가들의</p>
        <p>사진을 만나보세요</p>
      </div>
      <ul>
        <AnimatedPhotoList data={data} />
      </ul>
    </div>
  );
}

 

핵심 개념:

  • 서버 컴포넌트는 async 함수로 작성 가능
  • 서버에서 데이터를 미리 가져와서 렌더링

2.2 Next.js Data Cache 정책 typescript

// src/utils/fetchPhotos.ts
/*
next.js(APP ROUTER)는 서버에서 fetch로 데이터를 가져올 때 Data Cache라는 저장소에 결과를 보관함.
options.cache 속성을 사용하여 캐시 정책을 설정할 수 있음.

- default : 
(개발 환경) : 항상 새로운 요청
(프로덕션 환경) : '우선' 정적 데이터 사용

- Dynamic API => cookies(), headers(), searchParams() => 동적 라우팅
⭐ Next.js가 자동으로 SSR/SSG를 판단하여 빌드한다.

- force-cache : 무조건 정적 데이터만 관리하도록 함.
= export const dynamic = 'force-cache' : 이 페이지는 무조건 정적 페이지만 가져옴.
같은 URL 요청이 있으면 Data Cache에서 데이터를 가져옴. 데이터가 없으면 새로 패치해서 캐시에 저장, 이후 정적 데이터 사용

- 'no-store' : 무조건 새로운 데이터만 관리하도록 함.
= export const dynamic = 'no-store' : 이 페이지는 무조건 동적 페이지만 가져옴.
새로운 데이터만 관리하도록 함.
*/

export default async function fetchPhotos(
  init: RequestInit = { cache: 'no-store' }
): Promise<Photo[]> {
  const END_POINT = `${process.env.NEXT_PUBLIC_BASE_URL}/v2/list?page=5&limit=10`;

  try {
    const res = await fetch(END_POINT, { ...init});
    if (!res.ok) {
      throw new Error();
    }
    return await res.json();
  } catch {
    console.error("error!");
    return [];
  }
}

 

핵심 개념:

  • cache: 'no-store': 항상 새로운 데이터 요청 (동적 페이지)
  • cache: 'force-cache': 캐시된 데이터 우선 사용 (정적 페이지)
  • Next.js가 자동으로 SSR/SSG 판단

2.3 ISR (Incremental Static Regeneration)

// src/utils/getRandomPhotos.ts
export const getRandomPhotos = async () => {
  const promises = Array.from({ length: 10 }).map((_, i) =>
    fetch(
      `${process.env.NEXT_PUBLIC_BASE_URL}/seed/${Math.random()}/400/300`,
      {next: {revalidate:10} }
      // ISR 설정
    ).then((res) => res.url)
  );

  const urls = await Promise.all(promises);
  return urls;
};

 

핵심 개념:

  • {next: {revalidate:10}}: 10초마다 재검증하여 데이터 갱신
  • ISR로 정적 페이지의 장점과 동적 데이터의 장점을 모두 활용

3. 동적 라우팅과 정적 생성

3.1 정적 파라미터 생성

// src/app/(top-badge-layout)/photos/[id]/page.tsx
// 데이터 몇개 만들어 두도록 함. 이 페이지 링크가 보인다면 다른 근처 페이지도 prefetch함.
export async function generateStaticParams() {
  return [{id: '40'}, {id: '71'}, {id:'72'}]
}

// false : 미리 만들어진 페이지 이외의 다른 페이지는 접근 불가 404
// true(default) : 블로킹 콜백 -> 없으면 만들어줌. 있으면 사용함.
export const dynamicParams = true;

 

핵심 개념:

  • generateStaticParams: 빌드 시 미리 생성할 페이지들 지정
  • dynamicParams: true: 미리 생성되지 않은 페이지도 동적으로 생성 허용

3.2 404 처리

// src/app/(top-badge-layout)/photos/page.tsx
// 낫파운드 페이지로 자동으로 넘어감
if(data.length === 0) notFound();

// src/app/(top-badge-layout)/photos/[id]/page.tsx
// 404 페이지로 리다이렉트, server component에서만 사용 가능
if(!data) notFound();

 

핵심 개념:

  • notFound(): 서버 컴포넌트에서만 사용 가능한 404 리다이렉트
  • not-found.tsx 파일은 루트 폴더에 위치해야 함

4. 클라이언트 컴포넌트와 상태 관리

4.1 React Query 설정

// src/providers/QueryProvider.tsx
function QueryProvider({ children }: { children: React.ReactNode }) {
  // 마운트 될 때 한번만 실행하고 메모이제이션을 사용하여 값을 저장
  const [client] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 1000 * 60, // 1분
            gcTime: 1000 * 60 * 10, // 10분
            refetchOnWindowFocus: false,
            refetchIntervalInBackground: false, // 백그라운드에서 refetch여부
            retry: 1, // 실패시 재시도 횟수
          },
        },
      })
  );

  // 데이터를 클라이언트에 저장하고 사용할 수 있게 해주는 프로바이더.
  return (
    <QueryClientProvider client={client}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

 

핵심 개념:

  • useState로 QueryClient 메모이제이션
  • staleTime: 데이터가 신선한 상태로 유지되는 시간
  • gcTime: 캐시에서 데이터가 제거되는 시간

4.2 Server Actions와 useActionState

// src/actions/createUser.action.ts
"use server";

export async function createUser(_: unknown, formData: FormData) {
  const email = (formData.get("email") ?? "").toString().trim();
  const password = (formData.get("password") ?? "").toString().trim();

  if (!email || !password) throw new Error("이메일과 비밀번호를 입력해주세요.");

  ****const res = await fetch("https://jsonplaceholder.typicode.com/users", {
    method: "POST",
    headers: { "Content-type": "application/json" },
    body: JSON.stringify({ email, password }),
    cache: "no-store",
  });

  if (!res.ok) {
    throw new Error("회원가입 실패");
  }

  // 토큰을 JSON으로 돌려준다고 가정
  const  {accessToken, refreshToken} = await res.json();

  const cookieStore = await cookies(); // server client에서만 가능

  // K, V, OPTIONS
  cookieStore.set('access_token', accessToken, {
    httpOnly: true,
    secure: true,
    sameSite:'lax',
    path:'/',
    maxAge: 60 * 15 // 15분
  })

  cookieStore.set('refresh_token', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite:'lax',
    path:'/',
    maxAge: 60 * 60 * 24 * 30 // 30일
  })

  redirect("/login");
}
// src/components/SubmitForm.tsx
function SubmitForm() {
  const [state, submitAction, isPending] = useActionState(createUser, null)

  return (
    <form action={submitAction}>
      {/* 여기에 액션을 추가 */}
      <div>
        <label htmlFor="">이메일</label>
        <input
          type="email"
          name="email"
          id=""
          className="border-b m-2 focus:outline-none"
        />
      </div>
      <div>
        <label htmlFor="">
          비밀번호
          <input
            type="password"
            name="password"
            className="border-b m-2 focus:outline-none"
          />
        </label>
      </div>
      <div>
        <button
          type="submit"
          className="w-full bg-emerald-400 px-3 py-2 mt-10 font-bold rounded-md"
        >
          {isPending ? 'wait...' : '회원가입'}
        </button>
      </div>
    </form>
  );
}

 

핵심 개념:

  • "use server": 서버에서만 실행되는 함수
  • useActionState: Server Action의 상태를 관리하는 훅
  • cookies(): 서버에서만 사용 가능한 쿠키 관리

4.3 Revalidation 전략

// src/actions/createUser.action.ts
// Actions는 서버 데이터를 추가하지만 트리거를 작동시키지는 않음.
// 이 경우는 루트를 revalidate를 시도함 ( 모든 데이터 초기화 )
// revalidatePath('/')

// 이 경우는 특정 위치를 revalidate를 시도함 ( 타겟 링크 초기화 )
// 이런 방법을 PURGE라고 함.
// revalidatePath('/photos/75')

// 이 경우는 관련 연결된 모든 페이지를 초기화함
// revalidatePath('/photos/[id]', 'page')

// 이 경우는 관련 레이아웃을 공유하는 모든 페이지를 초기화함
// revalidatePath('/(top-badge-layout)', 'layout')

// 이 경우는 특정 fetch만 초기화함
// revalidatePath('tagName')

 

핵심 개념:

  • revalidatePath('/'): 모든 데이터 초기화
  • revalidatePath('/photos/75'): 특정 페이지만 초기화 (PURGE)
  • revalidatePath('/photos/[id]', 'page'): 동적 라우트의 모든 페이지 초기화
  • revalidatePath('/(top-badge-layout)', 'layout'): 레이아웃 공유 페이지들 초기화

5. 라우팅과 네비게이션

5.1 클라이언트 사이드 네비게이션

// src/components/SearchForm.tsx
function SearchForm() {
  // back, forward, push 등으로 CSR로 만들 수 있음.
  // SSR 만들고 싶으면 redirect
  const router = useRouter();
  const pathname = usePathname();

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    const input = e.currentTarget.querySelector("#search")! as HTMLInputElement;
    const keyword = input.value.trim();

    router.push(`${pathname}?q=${keyword}`)
  };

 

핵심 개념:

  • useRouter(): 클라이언트 사이드 라우팅
  • usePathname(): 현재 경로 정보
  • router.push(): 프로그래매틱 네비게이션

5.2 동적 라우트와 searchParams

// src/app/(top-badge-layout)/search/page.tsx
async function Page({
  searchParams,
}: {
  searchParams: Promise<{ q?: string }>;
}) {
  // searchParams를 사용하면 dynamic으로 분류됨 ( no-store 적용 안 됨. )
  const { q } = await searchParams;

  const data = await fetchPhotos({cache:'no-store'});
  const result = q
    ? data.filter((p) => p.author.toLowerCase().includes(q.toLowerCase()))
    : [];

 

핵심 개념:

  • searchParams는 Promise로 래핑
  • searchParams 사용 시 자동으로 dynamic 페이지로 분류

6. 애니메이션과 GSAP

6.1 GSAP ScrollTrigger

// src/components/AnimatedPhotoList.tsx
useEffect(() => {
  let ctx: { revert(): void } | undefined;

  (async () => {
    if (typeof window === "undefined") return;

    const gsap = (await import("gsap")).default;
    const { ScrollTrigger } = await import("gsap/dist/ScrollTrigger");
    gsap.registerPlugin(ScrollTrigger);

    ctx = gsap.context(() => {
      const items = gsap.utils.toArray<HTMLElement>(".photo-item");

      items.forEach((el, i) => {
        gsap.from(el, {
          opacity: 0,
          y: 30,
          duration: 0.6,
          ease: "power2.out",
          scrollTrigger: {
            trigger: el,
            start: "top 65%",
            toggleActions: "play none none reverse",
            // markers:true
          },
        });
      });
    }, listRef);

    return () => ctx?.revert();
  })();
}, []);

 

핵심 개념:

  • 동적 import로 GSAP 로드
  • gsap.context(): 성능 최적화를 위한 컨텍스트 사용
  • ScrollTrigger: 스크롤 기반 애니메이션

6.2 ScrollSmoother 설정

// src/providers/ScrollSmooterProvider.tsx
useEffect(() => {
  if (initialized.current) return;
  let smoother: { kill(): void };

  (async () => {
    if (typeof window === "undefined") return;

    try {
      const gsap = (await import("gsap")).default;
      const { ScrollTrigger } = await import("gsap/dist/ScrollTrigger");
      const { ScrollSmoother } = await import("gsap/dist/ScrollSmoother");

      gsap.registerPlugin(ScrollTrigger, ScrollSmoother);

      // DOM이 완전히 로드된 후 실행
      await new Promise((resolve) => {
        if (document.readyState === "complete") {
          resolve(true);
        } else {
          window.addEventListener("load", () => resolve(true));
        }
      });

      smoother = ScrollSmoother.create({
        wrapper: "#smooth-wrapper",
        content: "#smooth-content",
        smooth: 1.2,
        effects: true,
        normalizeScroll: true, // 모바일 호환성
        ignoreMobileResize: true, // 모바일에서 resize event 무시
      });

      initialized.current = true;
    } catch {
      console.error("ScrollSmoother 초기화 실패");
    }
  })();

 

핵심 개념:

  • ScrollSmoother: 부드러운 스크롤 효과
  • normalizeScroll: true: 모바일 호환성
  • ignoreMobileResize: true: 모바일 리사이즈 이벤트 무시

7. 로딩과 에러 처리

7.1 로딩 UI

// src/app/(top-badge-layout)/photos/loading.tsx
function Loading() {
  return (
    <div className="p-2">
      <ul className="grid grid-cols-2 gap-2 mt-2 animate-pulse">
        {
          Array.from({length:8}).map((_, i) => (
            <li key={i}>
              <div className="w-full h-40 bg-gray-600 rounded mb-2"></div>
              <div className="w-3/4 h-4 bg-gray-600 rounded"></div>
            </li>
          ))
        }
      </ul>
    </div>
  )
}

 

핵심 개념:

  • loading.tsx: 자동으로 로딩 UI 표시
  • 스켈레톤 UI로 사용자 경험 향상

7.2 에러 바운더리

// src/app/(top-badge-layout)/photos/error.tsx
"use client";

function Error() {
  return <div>Error</div>;
}
export default Error;

 

핵심 개념:

  • error.tsx: 에러 발생 시 표시할 UI
  • "use client" 지시어 필요

8. 타입스크립트 활용

8.1 타입 정의

// src/@types/type.d.ts
export interface Photo {
  id:           string;
  author:       string;
  width:        number;
  height:       number;
  url:          string;
  download_url: string;
}

 

8.2 컴포넌트 Props 타입typescript

// src/components/AnimatedPhotoList.tsx
interface Props {
  data: string[];
}

function AnimatedPhotoList({ data }: Props) {
  // ...
}

 

핵심 개념:

  • 인터페이스로 타입 정의
  • 컴포넌트 Props에 타입 적용

'React > Next.js' 카테고리의 다른 글

[Next] Next에서 Tanstack Query 사용하기  (0) 2025.11.20