Ian's Archive 🏃🏻

thumbnail
한입 크기로 잘라먹는 Next.js
React
2025.04.17.

회사 프로젝트에 필요해져서 강의듣고 빠르게 정리!

정리해보자


1. Next.js 소개

Next.js는 React.js보다 더 강력하고 편하게 사용할 수 있는 기능들을 제공

  • 페이지 라우팅
  • 빌트인 최적화 기능
  • 다이나믹 HTML 스트리밍
비교 기준 프레임워크 라이브러리
기능 구현의 주도권 개발자에게 없다 개발자에게 있다
기능 제공 거의 모든 기능을 제공 기본기능 외 제공 X

사전 렌더링 이해

  • 브라우저의 요청에 사전에 렌더링이 완료된 HTML을 응답하는 렌더링 방식
  • Client Side Rendering의 단점을 효율적으로 해결하는 기술
    • 클라이언트(브라우저)에서 직접 화면을 렌더링 하는 방식 - 1. 접속 요청 - 2. index.html(빈 껍데기) - 3. 빈화면 렌더링 - 4. JS Bundle 리턴 - 5. JS실행 - 6. 컨텐츠 렌더링 1
    • 장점 : 페이지 이동이 매우 빠르고 쾌적
    • 단점 : 초기 접속 속도(FCP)가 느리다 - FCP(First Contentful Paint) : 요청 시작 시점으로부터 컨텐츠가 화면에 처음 나타나는데 걸리는 시간 - index.html, JS Bundle을 받아와야 하고, 실행까지 해줘야 함 2
    • JS 실행 (렌더링) : 자바스크립트 코드(React 컴포넌트)를 HTML로 변환하는 과정
    • 화면에 렌더링 : HTML코드를 브라우저가 화면에 그려내는 작업
    • 수화(Hydration) : HTML과 Javascript를 연결하는 과정
      • TTI (Time To Interactive)
  • 페이지 이동의 경우엔 클라이언트 렌더링 방식으로 처리 3

Next.js가 사전 렌더링으로 인해 얻는 장점

  • React App의 단점 해소
    • 빠른 FCP 달성
  • React App의 장점 승계
    • 빠른 페이지 이동

수강을 위한 백엔드 서버 설정

  1. github에서 소스 다운로드
  2. supabase 로그인
  3. .env 생성 후 설정
  4. npm install
  5. npx prisma db push
  6. npm run seed
  7. npm run build
  8. npm run start

npx prisma studio
-> 편하게 볼 수 있다.

2. Page Router 핵심 정리

2.1) Page Router를 소개합니다.

  • 현재 많은 기업에서 사용되고 있는 안정적인 라우터
  • React Router처럼 페이지 라우팅 기능을 제공

특징

  • Pages의 폴더 경로와 파일 이름에 따라 라우팅
  • 동적 경로는 []안에 넣어준다.

프로젝트 생성

copyButtonText
npx create-next-app@14 section02

npm run dev

폴더 구조

  • node_mudule : 의존성을 보관
  • package.json : 패키지 내용 저장
    • pages
      • _app.tsx
      • _document.tsx:
        • 위 두개의 파일은 next앱의 모든 페이지에 공통적으로 적용 될 로직이나 레이아웃, 데이터를 다루기 위해 필요한 파일
        • 메타 태그를 설정하거나, 서드파티 스크립트를 넣다던가 등등 페이지 전체에 적용
  • next.config.mjs
    • next app의 설정을 관리하는 파일

_app.tsx

  • 모든 Page 컴포넌트의 부모 컴포넌트 역할 (root 컴포넌트)
  • 인자값
    • Component : 현재 페이지 역할을 할 컴포넌트를 받는다.
    • pageProps : 페이지 역할을 할 컴포넌트에 전달될 props

결론적으로 next에서는 어떤 페이지를 렌더링 하던간에 App Component아래 Page역할을 하는 컴포넌트가 렌더링 된다.

_document.tsx

  • 모든 페이지에 공통적으로 적용이 되야 하는 next app의 html코드를 설정하는 컴포넌트

2.2) 페이지 라우팅 설정하기

  • QueryString
    • 사용하기 위해선 useRouter라는 훅을 next에서 가져와서 사용하면 된다.
      • 이름 그대로 Router라는 객체를 컴포넌트에서 사용할 수 있도록 반환하는 함수
      • back() 이나 push()처럼 페이지 이동 함수도 내장되어 있다.
      • query 프로퍼티 안에 객체 형태로 값이 저장되어 있다.
copyButtonText
import { useRouter } from 'next/router'

export default function Page() {
  const router = useRouter()

  // const q = router.query.q;
  const { q } = router.query

  return <h1>Search {q}</h1>
}
  • url parameter
    • queryString과 동일하게 useRouter()을 사용하고 router객체에 id라는 프로퍼티로 저장된다.
    • 여러개의 url parameter를 주고 싶을 경우 ex - book/1234/234/243/234
      • [...id].tsx로 만들어준다. (Catch All Segment라 부른다.)
      • 여러개의 id는 배열 형태로 저장된다.
      • 파라미터가 없는 경우는 대응이 안되는데 해결책은 2가지
        • index.tsx생성
        • [[...id]].tsx 대괄호를 2번 사용한다. (Optional Catch All Segment라 부른다.)
copyButtonText
import { useRouter } from 'next/router'

export default function Page() {
  const router = useRouter()

  const { id } = router.query

  return <h1>Book</h1>
}
  • 404Page
    • page아래 만들면 된다.

2.3) 네비 게이팅

copyButtonText
import { useRouter } from "next/router";

export default function App({ Component, pageProps }: AppProps) {
  const router = useRouter();

  const onClickButton = () => {
    router.push('/test');
  };
  ...
}

2.4) 프리페칭

페이지를 사전에 미리 불러온다.

  • 현재 사용자가 보고있는 페이지에서 이동 가능성이 있는 페이지를 모두 미리 불러온다.
  • 컴포넌트로 명시된 경로가 아니라면 프리페칭이 이루어지지 않는다. - 사용하면 자동으로 프리페칭 이루어진다.
  • useRouterperfetch()명시적으로 작성해주면 prefetch가 이루어 진다.
  • 컴포넌트를 prefetch하기 싫으면 인자값으로 `prefetch={false}`를 넘겨주면 된다.

4

copyButtonText
export default function App({ Component, pageProps }: AppProps) {
  const router = useRouter();

  const onClickButton = () => {
    router.push('/test');
  };

  useEffect(() => {
    router.prefetch('/test');
  }, [])
  ...
}

2.5) API Routes

Next.js에서 API를 구축할 수 있게 해주는 기능

  • /pages/api에 있는 경우 api응답을 정의하는 파일로서 자동으로 설정
copyButtonText
import type { NextApiRequest, NextApiResponse } from 'next'

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  const date = new Date()
  res.json({ time: date.toLocaleString() })
}

2.6) 스타일링

  • React Component Styling하는 과정과 동일
    • 인라인 방법
    • css파일을 만들어 분리해주는 방법
      • 글로벌 css는 App Component외에서는 css파일을 불러오는 방식을 지원하지 않는다.
    • CSS Moudule 활용
      • 활용하는 이유
        • Next.js에서는 page별로 css가 겹치는 문제를 원천차단하기 위해서
copyButtonText
import style from './index.module.css'

export default function Home() {
  return (
    <>
      <h1 className={style.h1}>인덱스</h1>
      <h2 className={style.h2}>H2</h2>
    </>
  )
}

import GlobalLayout from '@/components/global-layout'
import '@/styles/globals.css'
import type { AppProps } from 'next/app'

export default function App({ Component, pageProps }: AppProps) {
  return (
    <GlobalLayout>
      <Component {...pageProps} />
    </GlobalLayout>
  )
}

여기서 @는 Next.js + TypeScript 프로젝트에서 자주 쓰는 **커스텀 경로 별칭(alias)**이다.

tsconfig.json이나 jsconfig.json을 보면

copyButtonText
    "paths": {
      "@/*": ["./src/*"]
    }

그래서 @/styles/globals.css = src/styles/globals.css

2.7) 글로벌 레이아웃 설정

  • 글로벌 레이아웃은 루트 컴포넌트인 App컴포넌트에 적용시켜주면 된다.
    • 페이지 컴포넌트를 감싸는 구조

특정 경우에만 컴포넌트를 감싸주고 싶을 때

copyButtonText
import SearchableLayout from '@/components/searchable-layout'
import style from './index.module.css'
import { ReactNode } from 'react'

export default function Home() {
  return (
    <>
      <h1 className={style.h1}>인덱스</h1>
      <h2 className={style.h2}>H2</h2>
    </>
  )
}

Home.getLayout = (page: ReactNode) => {
  return <SearchableLayout>{page}</SearchableLayout>
}
  • 메서드를 추가해 컴포넌트를 감싸주고 특정 경우에만 불러온다.
  • ReactNode는 전달받는 컴포넌트의 타입
  • 페이지 역할
    • _app.tsx : 앱 전체에 공통으로 적용되는 컴포넌트 (전역 설정)
      • app Component로 어떤 요청이 오던 간에 루트 컴포넌트로써 렌더링 된다.
        • 현재 접속요청이 온 페이지 컴포넌트들을 Component라는 props로 전달을 받게 된다.
    • index.tsx : ”/“경로로 접근 시 렌더링 되는 페이지
    • 페이지 연결은 Next.js내부 라우팅 시스템이 한다.
copyButtonText
import GlobalLayout from '@/components/global-layout'
import '@/styles/globals.css'
import { NextPage } from 'next'
import type { AppProps } from 'next/app'
import { ReactNode } from 'react'

type NextPageWithLayout = NextPage & {
  getLayout?: (page: ReactNode) => ReactNode
}

export default function App({
  Component,
  pageProps,
}: AppProps & {
  Component: NextPageWithLayout
}) {
  const getLayout = Component.getLayout ?? ((page: ReactNode) => page)

  return <GlobalLayout>{getLayout(<Component {...pageProps} />)}</GlobalLayout>
}
  • 위와같이 전달받는데 null병합 연산자를 사용해 메서드가 정의되어 있지 않아도 사용 가능하게 한다.
  • ((page: ReactNode) => page); 부분은 페이지를 받은 그대로 반환한다는 뜻
  • AppProps는 Next.js의 _app.tsx 전용 타입 정의 app Component는 기본적으로 아래와 같은 구조이다.
copyButtonText
type AppProps = {
  Component: NextPage
  pageProps: any
}
  • compoent: NextPageWithLayout은 App Component가 받는 타입을 확장한 것
copyButtonText
export default function SearchableLayout({
  children,
}: {
  children: ReactNode
}) {
  const router = useRouter()
  const [search, setSearch] = useState('')

  // 기본적으로 query string이 string or string[]이다.
  // 타입 단언으로 string으로 지정한다.
  const q = router.query.q as string

  useEffect(() => {
    setSearch(q || '')
  }, [q])

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

  const onSubmit = () => {
    if (!search || q === search) return
    router.push(`/search?q=${search}`)
  }

  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === 'Enter') {
      onSubmit()
    }
  }

  return (
    <div>
      <div>
        <input
          value={search}
          onKeyDown={onKeyDown}
          onChange={onChangeSearch}
          placeholder="검색어를 입력하세요..."
        />
        <button onClick={onSubmit}>검색</button>
      </div>
      {children}
    </div>
  )
}
  • React.ChangeEvent는 TypeScript에서 DOM 이벤트에 타입을 명확하게 지정하는 방식

대표적인 이벤트 타입

요소 이벤트타입
<input /> React.ChangeEvent
<textarea /> React.ChangeEvent
<select /> React.ChangeEvent
<form onSubmit={…}> React.FormEvent
<button onClick={…}> React.MouseEvent
<div onClick={…}> React.MouseEvent

2.8) 사전 렌더링과 데이터 페칭

  • 기존 리액트 App에서의 패칭
      1. 불러온 데이터를 보관할 state생성
      1. 데이터 페칭 함수 생성
      1. 컴포넌트 마운트 시점에 fetchData호출
      1. 데이터 로딩중일때 예외처리
    • => 이 방식의 단점 - 초기 접속 요청부터 데이터 로딩까지 오랜시간이 걸림
      • 이유 : CSR방식의 FCP가 느리고, 게다가 마운트가 완료된 다음 호출하기 때문. (FCP + 데이터 로딩시간동안 기다려야 함)
  • Next App의 데이터 페칭
    • 사전 렌더링 중 발생함 (당연히 컴포넌트 마운트 이후에도 발생 가능)
    • 데이터 요청 시점이 매우 빨라지는 장점 있음
    • 추가적인 로딩없이 한방에 보여준다.

5

  • 서버의 응답 시간이 너무 길 경우 빌드 타임에 데이터를 가져오는 설정도 가능

6

Next.js에서 제공하는 사전 렌더링 방식

    1. 서버사이드 렌더링(SSR)
    • 가장 기본적인 사전 렌더링 방식
    • 요청이 들어올 때 마다 사전 렌더링을 진행 함
    1. 정적 사이트 생성 (SSG)
    • 빌드 타임에 미리 페이지를 사전 렌더링 해둠
    1. 증분 정적 재생성(ISR)
    • 향후에 다룰 사전 렌더링 방식

2.9) 서버사이드 렌더링 (SSR)

  • export const getServerSideProps = () => {}; 이 있으면 서버사이드 렌더링 방식으로 호출한다. (약속된 이름)
    • 페이지 컴포넌트보다 먼저 실행이 되어 필요한 데이터 불러오는 함수
    • data를 props에 담아 리턴이 가능
    • 리턴값은 props라는 단 하나의 객체여야만 한다.
    • getServerSizeProps는 서버측에서만 실행하는 코드이다. (console 사용 불가)
      • window객체에도 접근 못함 -> 브라우저로 접근을 못한다.
  • 컴포넌트는 서버에서 2번 실행된다.
      1. 서버측 - 사전 렌더링 할 때
      1. 브라우저측 - 브라우저에서 자바스크립트 번들 형태로 전달되어서 브라우저에서 실행될 때 (하이드레이션)
      • 이러한 이유 때문에 window객체를 사용하지 못한다. ex - window.location
        • window.location같은 기능을 사용하고 싶을 땐 useRouter()를 사용한다
        • useEffect() 를 사용해 마운트 이후에 사용도 가능하다.
  • Component에서 props의 type을 지정할 땐 아래와 같이 InferGetServerSidePropsType을 사용
  • Promise.all()을 사용할 경우 여러 프로미스를 병렬로 실행하므로 여러 개의 비동기 작업을 동시에 처리할 수 있다.
copyButtonText
export const getServerSideProps = async () => {
  // 컴포넌트보다 먼저 실행되어서, 컴포넌트에 필요한 데이터를 불러오는 함수
  // const allBooks = await fetchBooks();
  // const recoBooks = await fetchRandomBooks();

  const [allBooks, recoBooks] = await Promise.all([
    fetchBooks(),
    fetchRandomBooks(),
  ])

  return {
    props: {
      allBooks,
      recoBooks,
    },
  }
}

export default function Home({
  allBooks,
  recoBooks,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
  return (
    <div className={style.container}>
      <section>
        <h3>지금 추천하는 도서</h3>
        {recoBooks.map(book => (
          <BookItem key={book.id} {...book} />
        ))}
      </section>
      <section>
        <h3>등록된 모든 도서</h3>
        {allBooks.map(book => (
          <BookItem key={book.id} {...book} />
        ))}
      </section>
    </div>
  )
}

Home.getLayout = (page: ReactNode) => {
  return <SearchableLayout>{page}</SearchableLayout>
}
  • query string 읽어오는 방법
    • context : GetServerSideProps를 매개변수로 받는다
      • GetServerSideProps에는 브라우저에서 받은 요청에 대한 모든 정보가 포함되어 있다.

2.10) SSG

시작하기 전에

  • SSR
    • 장점 : 페이지 내부의 데이터를 항상 최신으로 유지할 수 있다.
    • 단점 : 데이터 요청이 늦어질 경우 모든게 늦어진다.
  • SSG
    • SSR의 단점을 해결하는 사전 렌더링 방식
    • 빌드 타임에 페이지를 미리 사전 렌더링 해 둠
    • 단점 : 매번 똑같은 페이지만 응답, 최신 데이터 반영은 어렵다.

7

  • SSG
    • export const getStaticProps = async () => {}함수가 있으면 SSG방식으로 호출한다.
    • 받는 컴포넌트에서는 export default function Page({book,}: InferGetStaticPropsType<typeof getStaticProps>) {}로 받아 사용한다
      • InferGetStaticPropsType와 똑같이 getStaticProps로 생성한 함수의 반환값 타입을 추론에서 PropsType으로 설정해주는 역할
    • 수정했는데도 화면에 바로 반영되는 것을 볼 수 있는데 dev모드로 실행했기 때문
      • npm run build
      • static, SSG, Dynamic(SSR)을 볼 수 있는데 아무런 설정 안하고 route하면 SSR이 기본값이다.
    • SSG방식은 SSR방식처럼 Context에서 query string을 가져올 수 없다.
      • 제약사항이 있음에도 불구하고 SSG로 동작시키길 원한다면 사전 렌더링 과정 이후에 Client에서 직접 실행해줘야함
        • 이전에 React의 방식으로
  • [id].tsx SSG
    • 동적인 경로를 가진 파일을 SSG하기 위해선 선수 과정으로 경로를 설정하는 것이 필요하다.
      • 해당 역할을 하는 것은 getStaticPaths()
      • path라는 값으로 현재 어떤 경로들이 존재할 수 있는지 배열로 리턴해야 한다.
      • url parameter값은 반드시 문자열로 명시해야 한다.
      • fallback 옵션을 추가해야 한다 -> path값에 존재하지 않는 url로 접속 요청을 보내게 되면 설정할 대비책
        • false : 404 Not Found반환
        • blocking : 즉시 생성 (Like SSR)
        • true : 즉시 생성 + 페이지만 미리 반환
          • true로 해줬을 때 loading중이면 useRouter()isFallback을 이용해 분기처리가 가능
      • data가 없을 경우 return {notFound: true}를 통해 에러처리가 가능하다
copyButtonText
export const getStaticPaths = () => {
  return {
    paths: [
      { params: { id: '1' } },
      { params: { id: '2' } },
      { params: { id: '3' } },
    ],
    fallback: true,
    // fallback : "blocking"
    // fallback : false
  }
}

export const getStaticProps = async (context: GetStaticPropsContext) => {
  // 해당 페이지는 무조건 param이 있는 페이지
  // 그로므로 !로 단언해줘도 상관 없다.
  const id = context.params!.id
  const book = await fetchOneBook(Number(id))

  if (!book) {
    return {
      notFound: true,
    }
  }

  return {
    props: {
      book,
    },
  }
}

export default function Page({
  book,
}: InferGetStaticPropsType<typeof getStaticProps>) {
  const router = useRouter()

  if (router.isFallback) return '로딩 중입니다.'

  if (!book) return '문제가 발생 하였습니다.'

  const { id, title, subTitle, description, author, publisher, coverImgUrl } =
    book
  return (
    <div className={style.container}>
      <div
        className={style.cover_img_container}
        style={{ backgroundImage: `url('${coverImgUrl}')` }}
      >
        <img src={coverImgUrl} />
      </div>
      <div className={style.title}>{title}</div>
      <div className={style.subTitle}>{subTitle}</div>
      <div className={style.author}>
        {author} | {publisher}
      </div>

      <div className={style.description}>{description}</div>
    </div>
  )
}

2.11) ISR(Incremental Static Regeneration) 증분 정적 재생성

  • SSG방식으로 생성된 정적 페이지를 일정 시간을 주기로 다시 생성하는 기술
    • 매우 빠른 속도 응답 가능 (SSG 장점)
    • 최신 데이터 반영 가능 (기존 SSR방식의 장점)
  • revalidate 옵션을 준다. (아래 코드는 3초마다 재생성한다.)
copyButtonText
export const getStaticProps = async () => {
  // 컴포넌트보다 먼저 실행되어서, 컴포넌트에 필요한 데이터를 불러오는 함수
  // const allBooks = await fetchBooks();
  // const recoBooks = await fetchRandomBooks();
  console.log('인덱스 페이지')

  const [allBooks, recoBooks] = await Promise.all([
    fetchBooks(),
    fetchRandomBooks(),
  ])

  return {
    props: {
      allBooks,
      recoBooks,
    },
    revalidate: 3,
  }
}

주문형 재검증

  • 요청받을 때마다 페이지를 다시 생성
    • 시간으로 생성하는 것 보다 자원 낭비가 적다.
    • api를 생성하여 revalidate할 경로를 작성한다.
copyButtonText
import { NextApiRequest, NextApiResponse } from 'next'

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse,
) {
  try {
    await res.revalidate('/')
    return res.json({ revalidate: true })
  } catch (err) {
    res.status(500).send('Revalidation Failed')
  }
  await res.revalidate('/')
}

2.12) SEO 설정

  • 메타 태그 설정을 위해 next/head의 import
copyButtonText
export default function Home({
  allBooks,
  recoBooks,
}: InferGetStaticPropsType<typeof getStaticProps>) {
  return (
    <>
      <Head>
        <title>한입 북스</title>
        <meta property="og:image" content="/thumbnail.png" />
        <meta property="og:title" content="한입 북스" />
        <meta property="og:description" content="한입 북스에 등록된 도서들을 만나보세요" />
      </Head>
        <title>한입 북스</title>
      </Head>
      <div className={style.container}>
        <section>
          <h3>지금 추천하는 도서</h3>
          {recoBooks.map((book) => (
            <BookItem key={book.id} {...book} />
          ))}
        </section>
        <section>
          <h3>등록된 모든 도서</h3>
          {allBooks.map((book) => (
            <BookItem key={book.id} {...book} />
          ))}
        </section>
      </div>
    </>
  );
}

2.13) 배포하기

vercel에 배포

바로 배포하는 명령어

  • vercel –prod

2.14) 페이지 라우터 정리

  • Page Router의 장점
      1. 파일 시스템 기반의 간편한 페이지 라우팅 제공
      • page폴더의 구조로 설정
      • 동적 경로의 경우 []로 url파라미터에 대응
        • book/100/2345/12233같은 경우 […id].tsx로 대응
          • index page까지 대응하려면 [[…id]].tsx
      1. 다양한 방식의 사전 렌더링 제공
      • 느린 fcp에 대응하기 위해 서버에서 js를 실행해 fcp를 단축 (사전 렌더링)
      • SSR, SSG, ISR로 나뉜다.
  • Page Router의 단점
      1. 페이지별 레이아웃 설정이 번거롭다.
      1. 데이터 페칭이 페이지 컴포넌트에 집중된다.
      1. 불 필요한 컴포넌트들도 JS Bundle에 포함된다.
      • 상호 작용이 없는 컴포넌트들도 js bundle에 포함되어 2번씩 실행된다.

3. App Router 시작하기

3.1) App Router 시작하기

  • 변경되거나 추가되는 사항
    • 페이지 라우팅 설정 방식 변경
    • 레이아웃 설정 방식 변경
    • 데이터 페칭 방식 변경
    • React 18 신규 기능 추가
      • ex - Streaming, React Server Component
  • 크게 변경되지 않은 사항
    • 네비게이팅 (Navigating)
    • 프리페칭 (Pre-fetching)
    • 사전 렌더링 (Pre-Rendering)

npx create-next-app@latest section03으로 프로젝트 생성

추가로 eslint.config.mjs 설정 변경

copyButtonText
import { dirname } from 'path'
import { fileURLToPath } from 'url'
import { FlatCompat } from '@eslint/eslintrc'

const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

const compat = new FlatCompat({
  baseDirectory: __dirname,
})

const eslintConfig = [
  ...compat.extends('next/core-web-vitals', 'next/typescript'),
  {
    // 이 부분이 추가
    rules: {
      '@typescript-eslint/no-unused-vars': 'off',
      '@typescript-eslint/no-explicit-any': 'warn',
    },
  },
]

export default eslintConfig

3.2) 페이지 라우팅 설정하기

  • App이라는 폴더를 기반으로 라우팅 설정
    • page.tsx 페이지 이름을 갖는 파일이 페이지 역할을 하는 파일로 자동 설정된다.
    • ~/search경로 만들고 싶으면 search폴더 아래에 page.tsx파일을 생성해야 한다.
    • 동적 경로를 만들고 싶을 때는 [id] 같은 대괄호로 감싸진 폴더를 만든 다음에 page.tsx파일을 생성한다
  • App Router의 Next버전에서는 query string이나 url파라미터와 같은 경로상의 포함되는 값들은 Page컴포넌트에 props로 전달이 된다.
    • 함수형 컴포넌트에 async를 붙이는데 리액트의 서버 컴포넌트이기 때문에 async를 붙일 수 있다.
    • 아래는 query string과 param을 가져오는 코드이다.
    • url파라미터가 중첩으로 여러개 전달되는 경우에도 대응하게 만들고 싶으면 […id]라고 폴더명을 변경한다.
      • url파라미터가 없는 경우에도 대응하고 싶으면 [[…id]]로 만들어 준다.
copyButtonText
export default function Page(props) {
  console.log(props)
  return <div>Search 페이지</div>
}

export default async function Page({
  searchParams,
}: {
  searchParams: Promise<{ q: string }>
}) {
  const { q } = await searchParams

  return <div>Search 페이지 : {q}</div>
}

export default async function Page({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params

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

3.3) 레이아웃 설정하기

  • 폴더 아래에 layout.tsx가 있으면 search페이지의 레이아웃으로 자동 적용
  • 폴더의 모든 경로로 시작하는 페이지의 레이아웃으로 적용된다.
    • ex - /search/layout.tsx -> /search 모든 경로에 적용
  • root layout이 의 로 나타나고 있다.
    • html의 기초 틀을 유지할 수 있게 한다.
    • children이라는 props로 자동으로 페이지 컴포넌트가 전달된다.
      • layout component를 직접 구성할 때는 리턴문 안에 어디에 렌더링 할 것인지 지정해줘야 한다.
  • 공통된 경로로 시작하지 않은 페이지에도 동일한 레이아웃을 설정하는 방법
    • 라우트 그룹 활용 -> 폴더명을 소괄호로 감싸 만든다.
      • ex - (with-searchbar)
      • 더이상 경로에 어떠한 영향도 주지 않으면서, 각기 다른 경로를 가진 페이지 파일을 하나의 파일에 묶을 수 있는 기능
        • 경로상에 영향을 미치지 않으면서 layout만 동일하게 적용이 가능하다.

8

3.4) 리액트 서버 컴포넌트 이해하기

  • React18v부터 추가된, 새로운 유형의 컴포넌트
  • 서버측에서만 실행되는 컴포넌트 (브라우저에서 실행X)
  • Page Router버전에서의 Next.js에는 어떤 문제가 있었나
    • Hydration을 위해 모든 컴포넌트들을 JS Bundle로 묶어 보내준다.
      • 리액트 훅이나 이벤트 핸들러가 붙어있는건 ok / 상호작용이 없는 정적인 컴포넌트도 포함하는건 불필요
    • 문제점을 해결하기 위해선 사전 렌더링 과정중에 JS Bundle을 구성할 때 상호작용이 없는 정적인 컴포넌트를 제외한다
    • 그렇게 되면 클라이언트 JS Bundle에 컴포넌트들만 포함된다.
  • 페이지의 대부분을 서버컴포넌트로 구성할 것은 권장, 클라이언트 컴포넌트는 꼭 필요한 경우에만 사용
  • 서버 컴포넌트는 서버측에서만 할 수 있었던 보안에 민감한 작업이나 데이터를 패칭해오는 다양한 작업을 진행할 수 있다.
  • 기본적으로 server component, client component로 바꾸고 싶으면 페이지 최 상단에 “use client”를 기입한다.
copyButtonText
'use client'

import Image from 'next/image'
import styles from './page.module.css'
import { useEffect } from 'react'

export default function Home() {
  console.log('Home 컴포넌트 실행')

  useEffect(() => {})

  return <div className={styles.page}>인덱스 페이지</div>
}
  • 상호작용이 있는 컴포넌트만 클라이언트 컴포넌트로 만들어준다. (Link는 HTML고유 기능)
  • 페이지나 레이아웃이 아닌 컴포넌트를 위한 파일도 만들어 줬는데 파일 이름이 Page나 Layout이 아니면 일반적인 javascript or typescript파일로 간주
    • 컴포넌트를 위한 파일도 app하위에 위치시켜도 된다. -> Co-Location이라 부른다.

3.5) 리액트 서버 컴포넌트 주의사항

    1. 서버 컴포넌트에는 브라우저에서 실행될 코드가 포함되면 안된다.
    • React Hooks나 이벤트 핸들러는 사용 불가
    • 브라우저에서 실행되는 기능을 담고있는 라이브러리도 사용 불가
    1. 클라이언트 컴포넌트는 클라이언트에서만 실행되지 않는다.
    • 서버와 클라이언트 모두에서 실행된다.
    1. 클라이언트 컴포넌트에서 서버 컴포넌트를 import할 수 없다
    • 클라이언트 컴포넌트는 서버와 브라우저 모두에서 실행된다.
    • 만약 import해서 서버 컴포넌트를 사용한다면 해당 내용은 오직 서버에서만 실행된다. -> 코드가 없다
      • 이런 경우 Next.js는 자동으로 서버 컴포넌트를 클라이언트 컴포넌트로 변경한다.
      • 부득이하게 Client Component에서 Server 컴포넌트로 사용해야 하는 경우
        • 바로 import해서 사용하는 것이 아니라 children props로 받아 렌더링을 시킨다.
        • children으로 받은 컴포넌트는 클라이언트 코드로 변경하지 않는다.
          • 결과물만 받게 구조가 변경되었기 때문
    1. 서버 컴포넌트에서 클라이언트 컴포넌트에게 직렬화 되지 않는 Props는 전달 불가하다.
    • 직렬화 되지 않은 Props?
      • 직렬화 : 객체, 배열, 클래스 등의 복잡한 구조의 데이터를 네트워크 상으로 전송하기 위해 아주 단순한 형태(문자열, Byte)로 변환하는 것을 의미한다.
    • 자바스크립트의 함수는 직렬화가 불가능 -> 함수 전달 불가능
    • 서버 컴포넌트가 Next.js에서 어떻게 실행되는지 살펴보면 이해가 쉽다
      • 서버 컴포넌트는 사전 렌더링 때 실행된다.
      • 페이지를 구성하는 모든 컴포넌트들 -> 서버 컴포넌트들만 따로 실행 -> Client Component실행-> 완성된 HTML페이지
      • 서버 컴포넌트들만 실행이 될 때 RSC Payload(JSON과 유사한 형태)라는 문자열이 생성된다.
        • RSC Payload
          • React Server Component의 순수한 데이터 (결과물)
          • React Server Component를 직렬화한 결과
          • RSC Payload에는 서버 컴포넌트의 모든 데이터 포함
            • 서버 컴포넌트의 렌더링 결과
            • 연결된 클라이언트 컴포넌트 위치
            • 클라이언트 컴포넌트에게 전달하는 Props값
      • 함수는 직렬화가 안되기 때문에 RSC Payload에 포함되지 않는다. -> 함수는 props로 전달 불가능

3.6) 네비게이팅

  • 페이지 이동은 Page Router와 동일한데 Client Side Rendering 방식으로 처리된다.
  • pre-fetching에서 동작이 조금 달라진다.
    • JS Bundle은 브라우저에서 실행할 자바스크립트 코드 (Client Components만 포함됨)

9

  • 프로그래미틱한 페이지 이동 구현
    • useRouter()를 사용한다
      • Page Router와 달리 useRouter를 App Router 버전인 next/navigation에서 가져온다.
  • App Router의 페이지는 static과 Dynamic페이지로 나뉜다.
    • 기본적으로 static, query string, url parameter들을 꺼내던가 하는 작업이 있으면 Dynamic페이지로 설정된다.
    • static은 RSC payload와 js bundle을 불러오고, dynamic 페이지는 data upload가 필요할 수도 있으니 RSC payload만 불러온다.
      • dynamic 페이지는 JS bundle은 향후에 실제 페이지 이동이 발생했을 때만 불러온다

=> App Router버전에서는 페이지의 데이터가 Server Component는 RSC Payload로 Client Component는 JS Bundle로 나뉘어서 동시에 전달된다.

3.7) 한입북스 UI 구현하기

  • useSearchParams() 라는 훅
    • 현재 페이지에 전달된 query string값을 꺼내올 수 있다.
      • PageRouter에서는 useRouter()를 사용해 router.query.q로 불러왔다.
      • AppRouter는 불가능
      • **useSearchParams()**를 사용해야 한다.

4. 데이터 페칭

4.1) 앱 라우터의 데이터 페칭

  • Page Router
      1. SSR (서버사이드 렌더링)
      • export async function getServerSideProps() {}
      1. SSG (정적 사이트 생성)
      • export async function getStaticProps() {}
      1. Dynamic SSG(동적 경로에 대한 정적 사이트 생성)
      • export async function getStaticPaths() {}
    • Page Router에서는 클라이언트 컴포넌트만 있기 때문에 서버와 클라이언트 모두 실행이 된다
      • 위와같은 특수한 함수를 사용해 서버에서만 사용하게 코드를 작성했어야 했다.
      • 클라이언트 컴포넌트에는 Async키워드를 사용할 수 없었다.
        • 브라우저에서 동작 시 문제를 일으킬 수 있기 때문이다.
  • App Router
    • 컴포넌트 내부에서 직접 필요로 하는 데이터를 불러와서 바로 렌더링해서 사용할 수 있는 방식으로 변경
    • 필요로 하는 곳에서 불러오니 props를 전달해서 사용한다는 Page Router의 단점이 아래와 같이 사라진다.

10

copyButtonText
async function AllBooks() {
  const response = await fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/book`);
  const allBooks: BookData[] = await response.json();

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

  return (
    <div>
      {allBooks.map((book) => (
        <BookItem key={book.id} {...book} />
      ))}
    </div>
  );
}

// query string 사용
import BookItem from "@/components/book-item";
import { BookData } from "@/types";

export default async function Page({
  searchParams,
}: {
  searchParams: Promise<{
    q?: string;
  }>;
}) {
  const response = await fetch(
    `${process.env.NEXT_PUBLIC_API_SERVER_URL}/book/search?q=${searchParams.q}`
  );

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

  const books: BookData[] = await response.json();

  return (
    <div>
      {books.map((book) => (
        <BookItem key={book.id} {...book} />
      ))}
    </div>
  );
}

// params
export default async function Page({
  params,
}: {
  params: Promise<{ id: string | string[] }>;
}) {
  const response = await fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/book/${params.id}`);

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

  const book = await response.json();

  const { id, title, subTitle, description, author, publisher, coverImgUrl } =
    book;
  ...
}
  • url 환경변수로 등록
    • NEXT_PUBLIC_API_SERVER_URL=http://localhost:12345
      • NEXT_PUBLIC이 없으면 서버측 컴포넌트에서만 사용이 가능하다
      • 클라이언트 컴포넌트에서도 사용이 가능하려면 NEXT_PUBLIC을 접두사로 붙여야 한다.

4.2) 데이터 캐시

  • 데이터 캐시 : fetch메서드를 활용해 불러온 데이터를 Next 서버에서 보관하는 기능
    • 영구적으로 데이터를 보관하거나, 특정 시간을 주기로 갱신시키는 것도 가능
    • 불필요한 데이터 요청 수를 줄여서 웹 서비스의 성능을 크게 개선할 수 있다.
    • ex - const response = await fetch(~/apu, { cache: “force-cache})
      • 요청 결과를 무조건 캐싱함
      • 한번 호출 된 이후에는 다시는 호출되지 않음
    • axios같은 http request라이브러리 에서는 사용 불가능하고 오직 fetch메서드에서만 활용 가능
      • Next.js에서 제공하는 fetch는 일반적인 fetch메서드가 아닌 데이터 캐시와 관련된 여러 기능을 추가한 확장판개념의 메서드이기 때문
  • 캐시 옵션
      1. {cache: “no-store”}
      • 데이터 페칭의 결과를 저장하지 않는 옵션
      • 캐싱을 아예 하지 않도록 설정하는 옵션
      • 데이터 캐싱의 기본값은 캐싱을 하지 않는 것 (15버전 이하는 기본값이 캐싱하는 것)
      1. {cache: “force-cache”}
      • 요청의 결과를 무조건 캐싱함
      • 한번 호출 된 이후에는 다시는 호출되지 않음
      1. {next: {revalidate:3}};
      • 특정 시간을 주기로 캐시를 업데이트
      • 마치 Page Router의 ISR방식과 유사
      1. {next: {tags: [‘a’]}}
      • On-Demand Revalidate
      • 요청이 들어왔을 때 데이터를 최신화 함

fetch데이터 확인을 위한 config옵션

copyButtonText
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  /* config options here */
  logging: {
    fetches: {
      fullUrl: true,
    },
  },
}

export default nextConfig

4.3) 리퀘스트 메모이제이션

  • 다양한 API요청 중에 중복된 요청을 제거시켜 캐싱시켜 한번만 요청할 수 있도록 자동으로 데이터 페칭을 최적화 해주는 기능
  • 하나의 페이지를 렌더링 하는 동안에 중복된 API요청을 캐싱하기 위해 존재함
    • 렌더링이 종료되면 모든 캐시가 소멸된다.

5. 페이지 캐싱

5.1) 풀 라우트 캐시1

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

SSG와 비슷하게 빌드타임에 페이지를 만들어 줘 바로 페이지 반환

11

  • Dynamic Page로 설정되는 기준 (서버 컴포넌트만 해당, 클라이언트 컴포넌트는 페이지 유형에 영향을 미치지 않음)
    • 특정 페이지가 접속 요청을 받을 때 마다 매번 변화가 생기거나, 데이터가 달라질 경우
      1. 캐시되지 않는 Data Fetching을 사용하는 경우
      1. 동적 함수(쿠키, 헤더, 쿼리스트링)을 사용하는 컴포넌트가 있을 때
  • static page로 설정되는 기준
    • Dynamic페이지가 아니면 모두 static page가 된다.
동적함수 사용 유무 데이터캐시 페이지 분류
YES NO Dynamic Page
YES YES Dynamic Page
NO NO Dynamic Page
NO YES Static Page
  • 맨 마지막의 동적사용 x, 데이터 캐시o -> 풀 라우트 캐시가 가능하다
  • 풀 라우트 캐시도 revalidate가 가능하다.

5.2) 풀 라우트 캐시2

  • useSearchParams() -> Suspense로 감싸줘야 한다.
    • Suspense태그로 감싸주게 된다면 사전 렌더링 과정에선 배제되고, 오직 클라이언트 측에서만 렌더링 된다.

5.3) 풀 라우트 캐시 - 동적 경로에 적용하기

  • 실시간으로 데이터를 백엔드 서버로 부터 불러와서 렌더링하는 페이지는 static으로 설정 불가능
    • 풀 라우트 캐시를 포기하지만, 조금이라도 생성 속도를 최적화를 하자면 데이터 캐시만 따로 적용하는 방법이 있다.
  • parameter로 받는 경우, 어떤 경로가 올 수 있는지 알려줘야 한다. -> 빌드타임에 만들어 질 수 있도록
    • generateStaticParams()를 활용한다
    • 정적인 url파라미터를 담은 배열을 반환하면 빌드타임에 넥스트 서버가 자동으로 정적 파라미터들을 읽어서 파라미터에 해당하는 페이지를 정적으로 만들게 된다.
    • 명시할 때는 문자열로만 명시해야 한다.
    • 데이터 캐싱이 설정되지 않은 데이터 패칭이 존재하더라도 무조건 해당하는 페이지가 static페이지로 강제로 설정된다.
  • 404오류일 경우엔 nextjs의 notFound() 를 활용해 404페이지로 리다이렉트 해준다.
    • app폴더 아래에 not-found.tsx를 만들어준다.
  • 설정하지 않은 페이지들도 접속하면 static한 page를 만드는데 그렇지 않고 정적으로 설정해둔 url파라미터 이외에 전부 에러처리를 하려면
    • export const dynamicParams = false; 를 줘서 내보내주면 된다. (기본값이 true)
    • dynamicParams이 true일 경우 요청 오면 새로운 페이지를 실시간으로 생성해서 응답
copyButtonText
export const dynamicParams = false;

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

export default async function Page(props: {
  params: Promise<{ id: string | string[] }>;
}) {
  const { id } = await props.params;
  ...
}

5.4) 라우트 세그먼트 옵션

  • 풀라우트 캐시를 위해서 모든 페이지의 컴포넌트들이 동적인 함수를 사용하진 않는지, 또는 캐싱되지 않는 데이터 페칭을 하고있지 않는지 확인해야 한다.
  • 라우트 세그먼트 옵션 : 강제로 특정 페이지를 스태틱, 다이나믹 페이지로 설정하던지, Page Revalidate를 하던지 페이지를 동작을 강제로 설정할 수 있다.
    • dynamicParams처럼 약속된 이름의 변수를 선언하고, 값을 설정함으로써 페이지의 설정을 강제로 조절할 수 있는 기능 -> 라우트 세그먼트 옵션
    • 많이 사용되는 옵션은 Dynamic옵션
      • export const dynamic = ”;
      • dynamic옵션을 사용하면 페이지 내부의 동적함수나 데이터 캐시의 유무를 떠나서 강제로 static or dynamic으로 설정 가능
          1. auto : 기본값으로 아무것도 강제하지 않음 (기본값이니 생략 가능)
          1. force-dynamic : 페이지를 강제로 Dynamic 페이지로 설정
          1. force-static : 페이지를 강제로 static 페이지로 설정
          • 페이지 내부의 동적함수는 빈값을 리턴하게 자동으로 설정함
          1. error : 페이지를 강제로 Static 페이지로 설정 (설정하면 안되는 이유가 있으면 -> 빌드 에러)
    • 라우트 세그먼트 옵션을 지정해주는건 권장사항이 아니다.
      • App Router기반의 NextJS은 페이지를 구성하고 있는 각각의 컴포넌트들이 어떻게 동작하는지에 따라서 컴포넌트 단위로 페이지를 스태틱, 다이나믹으로 설정해주는 아주 좋은 기능이 있는데 이러한 매커니즘을 무시하기 때문

5.5) 클라이언트 라우터 캐시

  • 브라우저에 저장되는 캐시
  • 페이지 이동을 효율적으로 진행하기 위해 페이지의 일부 데이터를 보관함
    • 레이아웃에 대한 데이터를 따로 추출을 해서 클라이언트 라우터 캐시라는 이름으로 보관하도록 자동으로 설정한다.
    • 한번 접속한 페이지의 레이아웃만 따로 보관해 페이지 이동이 추가로 발생하였을 때 공통된 레이아웃을 중복해 불러오지 않도록 페이지의 이동을 최적화

13

6. 스트리밍과 에러 핸들링

6.1) 스트리밍이란?

  • 잘게 쪼개진 데이터를 연속적으로 보내주는 기술
    • 사용자에게 긴로딩 없이 좋은 경험을 제공
  • 일반적인 웹서비스에서도 HTML페이지를 스트리밍 하는 기능 제공
    • 단순한 컴포넌트부터 화면에 보여주고 데이터 패칭등으로 렌더링이 오래걸릴 것 같은 컴포넌트를 대체 UI(로딩바)화면에 보여주고 있다가 서버측에서 컴포넌트 렌더링이 완료되면 컨텐츠를 보여준다.
  • Static Page보단 Dynamic Page에 자주 사용된다.
    • loading.tsx를 생성하면 동일한 경로에 있는 페이지 컴포넌트를 스트리밍 되도록 설정해주면 렌더링하는데 시간이 소요하더라도 전체 페이지의 렌더링을 지연시키지 않는다.
    • 주의사항
        1. 상위 경로에 있더라도 loading.tsx로 동일하게 설정된다.
        1. loading.tsx파일이 스트리밍 하도록 스트리밍하는 페이지 컴포넌트는 async가 붙어서 비동기로 작동하도록 설정된 페이지 컴포넌트에만 스트리밍을 제공한다.
        1. loading.tsx파일은 Page Component에만 스트리밍을 적용할 수 있다.
        • 페이지 컴포넌트(Page.tsx) 외에 별도의 컴포넌트에도 적용하고 싶다면 loading.tsx가 아니라 react에서 제공하는 를 별도로 활용해줘야 한다.
        1. loading.tsx로 설정된 스트리밍은 브라우저에서 query string이 변경될 때는 트리거 되지 않는다.
        • 이런 경우에도 스트리밍하고 싶은 경우에는 react에서 제공하는 를 별도로 활용해줘야 한다.

6.2) 스트리밍 1. 페이지 스트리밍 적용하기

  • 특정 페이지 컴포넌트를 스트리밍 하게 설정하려면 해당 컴포넌트와 동일한 위치에 loading.tsx파일 생성
    • loading.tsx에 대체 UI역할을 할 컴포넌트 작성
    • loading.tsx는 동일한 경로에 페이지만 스트리밍하는게 아니라 layout파일처럼 해당하는 경로 아래 모든 페이지가 스트리밍 되도록 설정된다.

6.3) 스트리밍 2. 컴포넌트 스트리밍 적용하기

  • 태그로 스트리밍하길 원하는 컴포넌트를 감싸준다.
    • loading상태를 대체하길 원하는 UI는 Suspense의 fallback이라는 props로 컴포넌트를 전달해준다.(UI자체를 넘겨줘도 ok)
    • Suspense를 이용하는 방식 또한 loading.tsx을 이용했던 방법과 동일하게 브라우저에서 쿼리 스트링만 변경되었을 경우에 loading상태가 표시되지 않는다.
    • key라는 props를 전달해서 key값이 변화할 때마다 다시 loading상태로 돌아가도록 설정한다.
      • Suspense는 최초로 한번 내부 컴포넌트 로딩이 완료된 이후에는 어떠한 컨텐츠가 변경된다 하더라도 기본적으로는 새롭게 로딩상태로 돌아가지 않는다.
      • 이럴땐 key값을 줘서 쿼리스트링이 바뀔 때 마다 리액트에게 새로운 컴포넌트로 인식하도록 화면에 새롭게 그리게 설정해준거다.
        • 리액트에서는 키값이 바뀌면 컴포넌트가 달라졌다, 다른 컴포넌트가 생겼다 라고 인식한다.
  • 태그를 사용하면 병렬로 하나의 페이지에 여러개의 컴포넌트들을 완료되는 순서대로 각각 렌더링 시킬 수 있다.
copyButtonText
export default async function Page({
  searchParams,
}: {
  searchParams: Promise<{
    q?: string
  }>
}) {
  const { q } = await searchParams

  return (
    <Suspense key={q || ''} fallback={<div>Loading...</div>}>
      <SearchResult q={q || ''} />
    </Suspense>
  )
}
export default async function Home() {
  return (
    <div className={style.container}>
      <section>
        <h3>지금 추천하는 도서</h3>
        <Suspense fallback={<BookListSkeleton count={3} />}>
          <RecoBooks />
        </Suspense>
      </section>
      <section>
        <h3>등록된 모든 도서</h3>
        <Suspense
          fallback={
            <>
              <BookListSkeleton count={3} />
            </>
          }
        >
          <AllBooks />
        </Suspense>
      </section>
    </div>
  )
}

6.4) 스켈레톤 UI적용하기

  • 스켈레톤 UI : 컨텐츠가 로드되는 중에 실제 컨텐츠 대신에 보여지게 된다.
    • 뼈대역할을 하는 UI
    • 실제 컨텐츠 대신에 실루엣만 보여주는 UI
  • React Loading Skeleton이라는 라이브러리도 존재 (퍼블리싱이 귀찮으면 해당 라이브러리 사용하자)

6.5) 에러 핸들링

  • loading.tsx처럼 error핸들링 할 error.tsx를 해당하는 경로에 생성한다.
    • 에러 파일과 같은 경로에 있거나 하위 경로에 있는 페이지에서 오류 발생 시 page.tsx대신 화면에 출력된다.
    • error.tsx는 클라이언트 컴포넌트로 생성해야 하는데 그 이유는 오류는 서버, 클라이언트 모두 발생할 수 있기 때문에 함께 대응할 수 있어야 하기 때문이다.
    • 현재 발생하는 에러나 메시지를 핸들링 하고 싶은 경우에는 props로 전달되는 error를 이용만 하면 된다.
      • Next는 에러 발생 시 에러 컴포넌트에게 자바스크립트 error타입의 error객체를 props로 component에 전달해준다.
  • props로 reset이라는 props도 전달된다.
    • 에러가 발생한 페이지를 복구하기 위해서 다시한번 컴포넌트들을 렌더링 시켜보는 기능을 가진 함수
    • 기능을 만들어주더라도 클라이언트 측에서만 다시 렌더링 하기 때문에 서버측에서는 다시실행하지 않아 같은 에러 페이지 노출
      • 즉, 클라이언트 내부에서 발생한 오류만 복구
      • 서버측에서 실행이 필요한 경우엔 window.location.reload()거나 router.refresh()
        • router.refresh()는 현재 페이지에 필요한 서버컴포넌트만 다시 불러옴
          • 해당 함수는 비동기적으로 실행되기 때문에 startTransition()을 사용 (async - await는 사용불가 refresh함수는 반환값이 프로미스가 아닌 보이드이기 때문)
            • 하나의 콜백함수를 인수로 받아 콜백함수 안에있는 UI변경 작업을 모두 일괄적으로 처리한다.
        • reset()함수도 같이 작성하는데 그 이유는 reset()함수가 에러상태를 초기화하고, 컴포넌트를 다시 렌더링하기 때문
copyButtonText
'use client'

import { useRouter } from 'next/navigation'
import { startTransition, useEffect } from 'react'

export default function Error({
  error,
  reset,
}: {
  error: Error
  reset: () => void
}) {
  const router = useRouter()

  useEffect(() => {
    console.error(error.message)
  }, [error])

  return (
    <div>
      <h2>오류가 발생했습니다.</h2>
      {/* <button onClick={() => reset()}>다시 시도 </button> */}
      {/* <button onClick={() => window.location.reload()}>다시 시도 </button> */}
      <button
        onClick={() => {
          startTransition(() => {
            router.refresh()
            reset()
          })
        }}
      >
        다시 시도{' '}
      </button>
    </div>
  )
}

7. 서버 액션

7.1) 서버 액션이란

  • 브라우저에서 호출할 수 있는 서버에서 실행되는 비동기 함수
    • 함수 내부에 “use server”를 붙여서 서버측에서만 실행되게 한다.
  • 브라우저에서 form에 제출 이벤트가 발생했을 때 서버에서만 실행하는 함수를 브라우저가 직접 호출하면서 데이터까지 form데이터 형식으로 전달할 수 있게 해주는 기능
  • 서버액션에서는 매개변수로 formData를 인수로 받는다.
  • FormData는 자바스크립트 내장 타입
  • 코드상에 서버 액션을 만들게 되면 자동으로 이러한 코드를 실행하는 API가 자동으로 생성된다.
    • 이런 API는 브라우저에서 이러한 폼 태그를 제출했을 때 자동으로 호출된다.
  • 서버 액션을 사용하는 이유
    • client component로 만들던가 별도의 API를 만들어서 호출할 수도 있는데 굳이 사용하는 이유
      • 코드가 매우 간결하다
      • 이 서버 액션은 오직 서버측에서만 실행되는 코드
copyButtonText
function ReviewEditor() {
  async function createReviewAction(formData: FormData) {
    'use server'

    const content = formData.get('content')?.toString()
    const author = formData.get('author')?.toString()
  }

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

7.2) 리뷰 추가 기능 구현하기

  • action구현 후 분리
  • <input name="bookId" value={bookId} hidden readOnly/> readOnly가 없으면 문제 hidden값에는 붙여주는게 좋다.
copyButtonText
'use server'

async function createReviewAction(formData: FormData) {
  const content = formData.get('content')?.toString()
  const author = formData.get('author')?.toString()

  if (!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)
  } catch (err) {
    console.error(err)
    return
  }
}

7.3) 리뷰 조회 기능 구현하기

  • 페이지를 리렌더링 하고싶을 때 revalidatePath() 사용
    • next서버가 자동으로 인수로 전달한 경로의 페이지를 재생성
      • server action의 결과를 화면에 바로 나타낼 수 있다.
  • revalidatePath 주의점
      1. revalidatePath는 오직 서버측에서만 호출할 수 있는 메서드이다.
      • action내부에서 호출하거나 서버컴포넌트 내부에서만 호출 가능
      • client Component에서는 호출 불가능
      1. 경로에 해당하는 페이지를 전부 재검증, 해당 페이지의 모든 캐시를 무효화 시킨다.
      • 데이터 캐시는 force-cache로 하더라도 전부 재검증하기 때문에 무효화, 삭제
      1. 풀라우트 캐시까지 함께 삭제(무효화)된다.
      • 새롭게 생성된 페이지를 캐시에 저장해주지 않음 -> 새로 접속해야 다이나믹 페이지처럼 생성이 된다.
copyButtonText
revalidatePath(`/book/${bookId}`)

14 15

7.4, 7.5) 리뷰 재검증 구현하기, 다양한 재검증

copyButtonText
// 1. 특정 주소의 해당하는 페이지만 검증
revalidatePath(`/book/${bookId}`)

// 2. 특정 경로의 모든 동적 페이지를 재검증
revalidatePath('/book/[id]', 'page')

// 3. 특정 레이아웃을 갖는 모든 페이지 재검증
revalidatePath('/(with-searchbbar)', 'layout')

// 4. 모든 데이터 재검증
revalidatePath('/', 'layout')

// 5. 태그를 기준, 데이터 캐시 재검증
// 태그값을 가지고있는 특정 패치들만 재검증된다.
revalidateTag(`review-${bookId}`)

const response = await fetch(
  `${process.env.NEXT_PUBLIC_API_SERVER_URL}/review/book/${bookId}`,
  { next: { tags: [`review-${bookId}`] } },
)

7.6) 클라이언트 컴포넌트에서의 서버 액션

  • useActionState() : react19에서 추가된 react hooks
    • form 태그의 상태를 쉽게 핸들링할 수 있도록 도와주는 여러가지 기능이 있다.
    • 호출하면서 2개의 인수 전달, 첫번째는 핸들링하려는 폼의 액션함수, 두번쨰는 form상태 초기값
      • 3개의 값을 반환한다.
      • state, action, isPending (현재 실행중인지 아닌지)
  • input태그에 hidden으로 사용하고 onChange하지 않을 경우에는 readOnly로 작성해서 수정하지 않는다 명시해야한다
    • 보통 input태그에는 onChange가 없으면, 사용자가 입력 필드를 편집할 수 없음
copyButtonText
'use server'

import { revalidatePath, revalidateTag } from 'next/cache'
import { delay } from '@/util/delay'

export async function createReviewAction(_: any, formData: FormData) {
  const bookId = formData.get('bookId')?.toString()
  const content = formData.get('content')?.toString()
  const author = formData.get('author')?.toString()

  if (!bookId || !content || !author) {
    return {
      status: false,
      error: '리뷰 내용과 작성자를 입력해주세요.',
    }
  }

  try {
    await delay(2000)
    const response = await fetch(
      `${process.env.NEXT_PUBLIC_API_SERVER_URL}/review`,
      {
        method: 'POST',
        body: JSON.stringify({ bookId, content, author }),
      },
    )
    if (!response.ok) {
      throw new Error(response.statusText)
    }
    console.log(response.status)

    // // 1. 특정 주소의 해당하는 페이지만 검증
    // revalidatePath(`/book/${bookId}`);

    // // 2. 특정 경로의 모든 동적 페이지를 재검증
    // revalidatePath("/book/[id]", "page");

    // // 3. 특정 레이아웃을 갖는 모든 페이지 재검증
    // revalidatePath("/(with-searchbbar)", "layout");

    // // 4. 모든 데이터 재검증
    // revalidatePath("/", "layout");

    // 5. 태그를 기준, 데이터 캐시 재검증
    revalidateTag(`review-${bookId}`)
    return {
      status: true,
      error: '',
    }
  } catch (err) {
    console.error(err)
    return {
      status: false,
      error: '리뷰 작성을 실패했습니다.',
    }
  }
}

;('use client')

import { useActionState, useEffect } from 'react'
import style from './review-editor.module.css'
import { createReviewAction } from '@/actions/create-review.action'

export default function ReviewEditor({ bookId }: { bookId: string }) {
  const [state, formAction, isPending] = useActionState(
    createReviewAction,
    null,
  )

  useEffect(() => {
    if (state && !state.status) {
      alert(state.error)
    }
  }, [state])

  return (
    <div>
      <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>
    </div>
  )
}

7.7) 리뷰 삭제 기능 구현하기

  • submit()메서드 보단 requestSubmit()을 추천
    • submit()메서드는 유효성 검사나 이벤트 핸들러들을 전부 다 무시하고 강제로 form제출을 발생시킨다. (주의해서 사용)
    • requestSubmit()이라는 메서드는 실제로 사용자가 submit 버튼을 클릭한거와 동일하게 비교적 의도한대로 안전하게 동작

8. 고급 라우팅 패턴

8.1) 페러렐 라우트

  • Parallel Route : 병렬 라우트
    • 하나의 화면안에 여러개의 페이지(Page.tsx)를 병렬로 함께 렌더링
      • ex) sns, 관리자 페이지
  • 이름앞에 골뱅이 표시가 붙은 폴더를 슬롯이라 한다.
    • 슬롯이 하는 역할은 병렬로 렌더링 될 하나의 페이지 컴포넌트를 보관하는 폴더
    • 슬롯안에 보관된 페이지 컴포넌트는 부모 레이아웃 컴포넌트의 props로 자동으로 전달된다.
    • Slot(슬롯)은 URL 경로에는 아무런 영향을 미치지 않는다.
    • Slot은 몇개가 되도 상관이 없다.
    • 하위 폴더에 폴더를 만들고 페이지를 만들면 layout에 속해 하위 페이지로 생성된다.
      • 해당 컴포넌트만 교체되는 형식
      • 이렇게 이전의 페이지가 유지가 되는 건 Link컴포넌트를 이용해서 브라우저측에서 클라이언트 렌더링 방식으로 이동할 때만 한정(새로고침하면 404에러)
        • 방지하기 위해선 슬롯별로 현재 렌더링 하는 페이지가 없을 때 대신 렌더링 할 디폴트 페이지를 만들면 된다.
  • Layout에 제공되는 Children페이지 또한 @Children안에 있는 페이지 컴포넌트라 봐도 똑같다.
    • 매번 만들면 귀찮을 수도 있기 때문에 슬롯을 만들지 않고 일반적인 페이지 봐도 무방

8.2) 인터셉팅 라우트

  • 사용자 요청을 가로채서 원래 렌더링 되야하는 페이지가 아닌 원하는 어떤 페이지를 대신 렌더링하도록 설정하는 라우팅 패턴
  • 초기설정이 아닐 경우에만 인터셉팅 라우트가 동작
    • Link컴포넌트나 Router에서 제공하는 push같은 클라이언트 사이드 방식으로 페이지를 이동할 때만 인터셉팅이 일어난다.
  • ex) 인스타그램 - 상세 페이지 누르면 모달형태(client rendering / 인터셉팅 라우트), 새로고침 시 해당 페이지로 이동
  • (.)book[id] 같은 형태로 작성
    • (.)동일한 레벨 의 세그먼트를 일치시키려면
    • (..)한 단계 위의 세그먼트와 일치시키려면
    • (..)(..)두 레벨 위의 세그먼트와 일치시키려면
    • (…)루트 app 디렉토리 의 세그먼트와 일치시키려면
  • modal구현
    • createPortal() 사용
      • 브라우저에 존재하는 DOM요소 아래에 고정적으로 Modal요소를 렌더링 하도록 설정

8.3) 패럴랠 & 인터셉팅 라우트

  • 모달 뒷부분에 index페이지를 노출하고 싶은 경우
    • slot활용

9. 최적화와 배포

9.1) 이미지 최적화

  • Next에서는 자체적으로 이미지 최적화가 적용되어 있다.
    • webp, AVIF등의 차세대 형식으로 변환하기 (jpeg같은 포맷 대신)
    • 디바이스 사이즈에 맞는 이미지 불러오기
      • image의 width와 height를 명시해줘야 하는데 이유는 필요이상으로 큰 이미지를 불러오지 않을 수 있도록 설정
    • 레이지 로딩 적용
    • 블러 이미지 활용
    • 기타 등등
  • 설정을 해주면 에러가 나는데 next.config.js에 특정 경로들은 안전하다고 선언해줘야 한다.
copyButtonText
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  /* config options here */
  experimental: {
    typedRoutes: false,  // ← 이걸 통해 타입 충돌 완화 가능
    // fetch instrumentation은 아직 공식 도큐먼트엔 없음
  },
  logging: {
    fetches: {
      fullUrl: true,
    }
  },
  images: {
    domains: ["shopping-phinf.pstatic.net"]
  }
};

export default nextConfig;

9.2) 검색 엔진 최적화(SEO)

  • 검색엔진 최적화(SEO)
    • sitemap설정하기
    • rss발행하기
    • 시멘틱 태그 설정하기
    • 메타 데이터 설정하기
  • index페이지에서 metadata라는 변수를 선언해서 export로 내보내주면 metadata로 설정된 값이 자동으로 index페이지의 metadata로 설정된다.
    • slash뒤에 정적인 image file을 적어주면 public directory아래를 가르키게 된다.
  • 동적인 값을 활용해서 메타데이터를 설정하는 경우엔 generateMetadata()로 메타데이터를 내보낸다.
    • 매개변수를 전달받을 수 있어서 페이지 컴포넌트와 동일하게 값을 받아온다.
copyButtonText
export const metadata: Metadata = {}

export async function generateMetadata({
  searchParams,
}: {
  searchParams: Promise<{ q?: string }>
}): Promise<Metadata> {
  const { q } = await searchParams

  return {
    title: `${q} : 한입북스 검색`,
  }
}

Tip!!

  • react에서 일반 변수와 state 사용 차이점
    • 일반 변수는 값만 저장됨
    • useState로 만든 state는 값 + 자동 리렌더링이 같이 작동

Reference

한 입 크기로 잘라먹는 Next.js(v15)

Thank You for Visiting My Blog, I hope you have an amazing day 😆
© 2023 Ian, Powered By Gatsby.