11. Next.js 13과 리액트

영근·2024년 4월 15일
0

11.1 app 디렉터리의 등장

  • 기존 Next.js에서는 Layout 구조를 구현하기가 힘들었다.
    • 페이지 공통으로 집어넣을 수 있는 곳은 _app.tsx, _document.tsx인데, 목적이 서로 달랐다.
      • _app.tsx : 페이지를 초기화하는 용도. 페이지 변경 시 유지하고 싶은 레이아웃, 상태 유지, componentDidCatch를 이용한 에러 핸들링, 페이지간 추가적인 데이터 삽입, global CSS 주입의 작업이 가능하다.
      • _document.tsx
        • <html>, <body> 태그 수정, SSR시 일부 CSS-in-JS를 지원하는 코드 삽입
        • 서버에서만 작동 -> onClick등 이벤트 핸들러를 붙이거나 클라이언트 로직을 붙이는 것을 금지하고 있다.
    • 공통 레이아웃 유지는 _app이 유일했으나, 이는 제한적이고 페이지별로 서로 다른 레이아웃을 유지하기 힘들다.

라우팅

라우팅을 정의하는 법

  • 기본적으로 파일 시스템을 기반으로 하지만, 약간의 차이가 있다.
  • Nextjs 12이하 : /pages/a/b.tsx 또는 /pages/a/b/index.tsx는 모두 동일한 주소로 변환된다.(파일명이 index이면 무시)
  • Nextjs 13 : /app/a/b는 /a/b로 변환되며, 파일명은 무시된다. 폴더명까지만 주소로 변환된다.
  • app 내부에서 가질 수 있는 파일명은 예약어로 제한된다.

layout.js

  • 파일명이 될 수 있는 예약어 중 하나
  • 해당 폴더에 layout이 있다면 그 하위 폴더 및 주소에 모두 영향을 미친다.
  • 기존 _document에서 수행했던 CSS-in-JS의 초기화도 루트의 레이아웃에서 적용하는 방식으로 바뀌었다.
  • 주소별 공통 UI를 포함하고, _app, _document를 대신해 웹페이지를 시작하는 데 필요한 공통코드를 삽입할 수 있다.
  • 주의해야 할 점
    • 레이아웃 이외의 다른 목적으로는 사용할 수 없다.
    • children을 props로 받아서 렌더링해야 한다.
    • export default로 내보내는 컴포넌트가 있어야 한다.
    • API 요청과 같은 비동기 작업을 수행할 수 있다.

page.js

  • 파일명이 될 수 있는 예약어 중 하나
  • layout을 기반으로 컴포넌트를 노출한다.
  • props
    • params(optional) : [...id] 같은 동적 라우트 파라미터를 사용할 경우 값이 들어온다.
    • searchParams
      • URLSearchParams를 의미한다.
      • layout에서는 제공되지 않는다. -> layout은 페이지 탐색 중에 리렌더링을 수행하지 않기 때문
  • 규칙
    • 내부에서 export default로 내보내야 한다.

error.js

  • 해당 라우팅 영역에서 사용하는 공통 컴포넌트
  • 특정 라우팅별로 서로 다른 에러 UI를 렌더링할 수 있다.
'use client'

import {useEffect} from 'react'

export default function Error({
    error,
    reset
}: {
    error: Error // 에러 정보를 담고 있는 객체
    reset: () => void // 에러 바운더리 초기화 -> 클라이언트에서만 작동. 따라서 Error 컴포넌트도 클라이언트 컴포넌트여야 한다.
}){
    useEffect(() => {
        console.log('logging error:', error)
    },[error])

    return (
        <>
        <div>
            <strong>Error: </strong> {error?.message}
        </div>
        <div>
            <button onClick={() => reset()}>에러 리셋</button>
        </div>
        </>
    )
}
  • 같은 수준의 layout에서 에러가 발생하면 해당 error 컴포넌트로 이동하지 않는다.

not-found.js

  • 특정 라우팅 하위 주소를 찾을 수 없는 404 페이지를 렌더링할 때 사용된다.
  • 서버 컴포넌트로 구성

loading.js

  • 리액트 Suspense를 기반으로 해당 컴포넌트가 불러오는 중임을 나타낼 때 사용하낟.
  • 'use client'를 사용해 클라이언트에서 렌더링되게 할 수 있다.

route.js

  • REST API의 get, post와 같은 메서드명을 예약어로 선언해두면 HTTP 요청에 맞게 해당 메서드를 호출한다.
  • route가 존재하는 폴더 내부에는 page.tsx가 존재할 수 없다.
import {NextRequest} from 'next/server'

export async function GET(request : NextRequest){}

export async function HEAD(request : NextRequest){}

export async function POST(request : NextRequest){}

...
  • params
    • request : Request를 확장한 Nextjs만의 Request. API 요청 관련 cookie, headers, nextUrl 등 확인 가능
    • context : params만을 가지고 있는 객체. 동적 라우팅 파라미터 객체가 포함되어 있다.

리액트 서버 컴포넌트

기존 리액트 컴포넌트와 서버 사이드 렌더링의 한계

  • 기존의 구조
    • 미리 서버에서 DOM을 만듦 -> 이 DOM을 기준으로 클라이언트에서는 하이드레이션 진행 -> 브라우저에서는 상태 추적, 이벤트 핸들러 추가, 응답에 따라 렌더링 트리 변경
    • 한계점
      • JS 번들 크기가 0인 컴포넌트를 만들 수 없다.
      • 백엔드 리소스에 대한 직접적인 접근이 불가능하다. : REST API 등의 방법을 사용하는 수 밖에 없다.
      • 자동 코드 분할이 불가능하다.
        • 코드 분할 : 하나의 거대한 코드 번들 대신, 코드를 여러 작은 단위로 나누어 필요할 때만 동적으로 지연 로딩해 앱을 초기화하는 속도를 높이는 기법
        • 리액트에서는 lazy를 사용
      • 연쇄적으로 발생하는 클라이언트와 서버의 요청을 대응하기 어렵다. : 최초 컴포넌트의 요청과 렌더링이 끝나기 전까지 하위 컴포넌트의 요청과 렌더링이 끝나지 않는다.
      • 추상화에 드는 비용이 증가한다
    • 즉 서버 사이드 렌더링의 한계점은 리액트가 클라이언트 중심으로 돌아가기 때문이다.
    • SSR : 서버 데이터에 쉽게 접근 + CSR : 다양한 사용자 경험을 제공 -> 두 구조의 장점을 모두 취하고자 하여 서버 컴포넌트가 등장했다.

서버 컴포넌트란?

하나의 언어, 하나의 프레임워크, 그리고 하나의 API와 개념을 사용하면서 서버와 클라이언트 모두에서 컴포넌트를 렌더링할 수 있는 기법

  • 서버 컴포넌트와 클라이언트 컴포넌트가 혼재될 수 있다.
  • 서버 컴포넌트
    • 요청이 오면 서버에서 딱 한 번 실행된다 -> 상태를 가질 수 없다. -> useState, useReducer등의 훅을 사용할 수 없다.
    • 한 번 렌더링되면 끝 -> 렌더링 생명주기를 사용할 수 없다.
    • effect나 state에 의존하는 사용자 정의 훅을 사용할 수 없다.(서버에서 제공할 수 있는 기능만 사용하는 훅이면 가능)
    • DOM API를 쓰거나 window.document 등에 접근할 수 없다.
    • DB, 파일시스템 등 서버에만 있는 데이터를 async/await으로 접근할 수 있다.
    • 다른 서버 컴포넌ㅌ, div등 요소, 클라이언트 컴포넌트 모두 렌더링할 수 있다.
  • 클라이언트 컴포넌트
    • 서버 컴포넌트를 불러오거나, 서버 전용 훅 등을 사용할 수 없다.
    • 서버 컴포넌트 -> 클라이언트 컴포넌트 -> 서버 컴포넌트 구조는 가능하다.(이미 서버에서 만들어진 트리를 삽입해서 보여주기 때문)
    • state, effect 사용 가능, 브라우저 API 사용 가능
  • 공용 컴포넌트
    • 클라이언트, 서버 모두에서 사용할 수 있다.
    • 두 가지의 모든 제약을 받는다.

  • 리액트는 모든 것을 다 공용 컴포넌트로 판단하는데, 클라이언트 컴포넌트라는 것을 명시하기 위해 'use client'를 붙인다.

서버 컴포넌트는 어떻게 작동하는가?

app.get(
  "/",
  handleErrors(async function (_req, res) {
    await waitForWebpack();
    const html = readFileSync(
      path.resolve(__dirname, "../build/index.html"),
      "utf8"
    );
    res.send(html);
  })
);
// 서버 사이드 렌더링이 수행되지 않는 코드
// 사용자가 최초에 들어왔을 때 수행하는 작업은 오로지 index.html을 제공하는 것 뿐이다.
  • 작동 과정

      1. 서버가 렌더링 요청을 받는다. : 서버가 렌더링 과정을 수행해야 하므로 서버 컴포넌트를 사용하는 모든 페이지는 서버에서 시작된다.
      1. 서버는 받은 요청에 따라 컴포넌트를 JSON으로 직렬화(serialize)한다.
      • 서버에서 렌더링 할 수 있는 것은 직렬화, 클라이언트 컴포넌트로 표시된 부분은 해당 공간을 플레이스홀더 형식으로 비워두고 나타낸다.
      • 서버는 와이어 포맷(데이터 형태)을 스트리밍 해 클라이언트에 제공한다.
      • 브라우저는 이 결과물을 받아 역직렬화한다음 렌더링을 수행한다.
      1. 브라우저가 리액트 컴포넌트 트리를 구상한다.
      • 받은 결과물을 다시 파싱한 결과물을 바탕으로 트리를 재구성해 컴포넌트를 만든다.
      • 클라이언트 컴포넌트 -> 클라이언트에서 렌더링 / 서버에서 만들어진 결과물 -> 그대로 리액트 트리를 만든다.
  • 작동방식의 특징

    • 서버에서 클라이언트로 정보를 보낼 때 스트리밍 형태로 보낸다 : 클라이언트가 줄 단위로 JSON을 읽고 컴포넌트를 렌더링해 시간 소요가 적다.
    • 각 컴포넌트 별로 번들링 별개 -> 필요에 따라 컴포넌트를 지연해서 받을 수 있다.
    • 결과물이 HTML이 아닌 JSON이다.
      • 따라서 서버 컴포넌트에서 클라이언트 컴포넌트로 props를 넘길 때 반드시 JSON 직렬화 가능한 데이터로 넘겨야 한다.

Next.js에서의 리액트 서버 컴포넌트

새로운 fetch 도입과 getServerSideProps, getStaticProps, getInitialProps의 삭제

getServerSideProps, getStaticProps, getInitialProps가 /app 디렉터리 내부에서는 삭제되었고, 그 대신 모든 데이터 요청은 fetch를 기반으로 다뤄진다.

async function getData() {
  const result = await fetch("https://api.example.com/");

  if (!result.ok) {
    throw new Error("데이터 불러오기 실패"); // 가까운 에러 바운더리에 전달된다.
  }

  return result.json();
}

export default async function Page() {
  const data = await getData();

  return (
    <main>
      <Children data={data} />
    </main>
  );
}
  • 서버에서 직접 데이터를 불러올 수 있다.
  • 컴포넌트가 비동기적으로 작동할 수 있다.
  • 데이터를 불러올 때까지 기다렸다가, 데이터가 불러와지면 페이지가 렌더링되어 클라이언트로 전달된다.
  • fetch API를 확장 -> 같은 서버 컴포넌트 트리 내에 동일한 요청이 있다면 재요청이 발생하지 않도록 요청 중복을 방지했다.

정적 렌더링과 동적 렌더링

  • 정적 라우팅 : 빌드 타임에 미리 렌더링을 해두고 캐싱해 재사용할 수 있게 했다.
  • 동적 라우팅 : 서버에 매번 요청이 올 때마다 컴포넌트를 렌더링하게 했다.
  • Next.js가 제공하는 next/headers나 next/cookie 같은 헤더 정보, 쿠키 정보를 불러오는 함수를 사용하면 정적 렌더링 대상에서 제외된다.
  • 동적 주소이지만 캐싱하고 싶은 경우 : genereateStaticParams를 사용한다.
export async function generateStaticParams(){
    return [{id: '1'}, {id: '2'}, {id: '3'}, {id: '4'}]
}

async function fetchData(params: {id: string}){
    const res = await fetch(`https://.../${params.id}`)
    const data = await res.json()
    return data
}

export default async function Page({
    params
} : {
    params: {id: string}
    children?: React.ReactNode
    }){
    const data = await fetchData(params)

    return(
        <div>
            <h1>{data.title}</h1>
        </div>
    )
}
  • 미리 HTML 결과물을 만들어둔다.

  • fetch 옵션

    • fetch(URL, {cache : 'force-cache'}) : 불러온 데이터를 캐싱해 해당 데이터로만 관리한다.
    • fetch(URL, {cache : 'force-cache'}), fetch(URL, {next: {revalidate: 0}}) : 캐싱하지 않고 매번 새로운 데이터를 불러온다.
    • fetch(URL, {next: {revalidate: 10}}) : 정해진 유효시간 동안 캐싱하고, 이 유효시간이 지나면 캐시를 파기한다.

캐시와 mutating, 그리고 revalidating

  • 페이지에 revalidate 변수를 선언해서 페이지 레벨로 정의할 수 있다.
export const revalidate = 60;
  • 루트에 선언하면 하위 모든 라우팅에서는 페이지를 60초 간격으로 갱신해 새로 렌더링한다.
  • 캐시와 갱신 과정
      1. 최초로 해당 라우트로 요청이 올 때는 미리 정적으로 캐시해 둔 데이터를 보여준다.
      1. 캐시된 초기 요청은 revalidate에 선언된 값만큼 유지된다.
      1. 만약 해당 시간이 지나도 일단은 캐시된 데이터를 보여준다. + 백그라운드에서 다시 데이터를 불러온다.
      1. 이후 캐시된 데이터를 갱신한다.(실패했다면 과거 데이터를 보여준다.)
  • 전적으로 무효화하고 싶다면 router.refresh()를 사용한다.
    • 서버에서 루트부터 데이터를 전체적으로 가져와 갱신한다.
    • 브라우저나 리액트의 state에는 영향을 미치지 않는다.

스트리밍을 활용한 점진적인 페이지 불러오기

  • 과거의 SSR : 요청받은 페이지를 모두 렌더링해서 내려줄 떄까지 사용자에게 아무것도 보여줄 수 없다.
  • 이를 해결하기 위해 HTML을 작은 단위로 쪼개 완성되는 대로 클라이언트로 점진적으로 보내는 스트리밍이 도입되었다.
  • 스트리밍을 활용할 수 있는 방법
    • 경로에 loading.tsx 배치 : loading 파일을 배치하면 자동으로 Suspense가 배치된다.
    • Suspense 배치 : 직접 리액트의 Suspense를 배치한다.

서버 액션

API를 굳이 생성하지 않더라도 함수 수준에서 서버에 직접 접근해 데이터 요청 등을 수행할 수 있는 기능

  • 특정 함수 실행 그 자체만을 서버에서 수행할 수 있다는 장점이 있다.
  • 사용하기 위해서는 'use server'를 상단에 선언하고, 함수는 반드시 async여야 한다.

form의 action

action props를 추가해서 양식 데이터를 처리할 URI를 넘겨줄 수 있다.

export default function Page() {
  async function handleSubmit() {
    "use server";

    console.log(
      "해당 작업은 서버에서 수행합니다. 따라서 CORS 이슈가 없습니다."
    );

    const response = await fetch("https://...", {
      method: "post",
      body: JSON.stringify({
        title: "foo",
        body: "bar",
        userId: 1,
      }),
      headers: {
        "Content-type": "application/json; charset=UTF-8",
      },
    });

    const result = await response.json();
    console.log(result);
  }

  return (
    <form action={handleSubmit}>
      <button type="submit">요청 보내보기</button>
    </form>
  );
}
  • 서버 액션을 실행하면 클라이언트에서는 현재 라우트 주소와 ACTION_ID만 보내고 아무것도 실행하지 않는다.
  • 서버에서는 요청받은 라우트 주소와 ACTION_ID를 바탕으로, 실행할 내용을 찾고 서버에서 직접 실행하낟.
  • 내용을 빌드 시점에 미리 서버로 옮김 -> 클라이언트 번들링 결과물에 포함되지 않고 서버에서만 실행된다.
  • 이 모든 과정은 새로고침 없이 수행된다.
  • revalidatePath : 인수로 넘겨받은 경로의 캐시를 초기화해서 해당 URL에서 즉시 새로운 데이터를 불러오는 역할을 한다.(server mutation)

사용 시 주의할 점

  • 클라이언트 컴포넌트 내에서 정의될 수 없다. 클라이언트 컴포넌트에서 사용하고자 할 떄는 서버 액션만 모여 있는 파일을 별도로 import 해야 한다.
  • props 형태로 서버 액션을 클라이언트 컴포넌트에 넘기는 것 또한 가능하다.

그 밖의 변화

  • 프로젝트 전체 라우트에서 쓸 수 있는 미들웨어 강화
  • SEO를 쉽게 작성할 수 있는 기능 추가
  • 정적으로 내부 링크를 분석할 수 있는 기능 등 다양한 내용 추가

Next.js 13 코드 맛보기

getServerSideProps

  • 과거에는 getServerSideProps 등을 사용해야만 서버에서 데이터를 불러와서 하이드레이션 할 수 있었다.
  • 13 버전부터는 서버 컴포넌트라면 어디든 서버 관련 코드를 추가할 수 있게 되었다.
  • 기존 getServerSideProps와 마찬가지로 미리 렌더링되어 완성된 HTML이 내려온다.
    • 리액트 18부터는 서버 컴포넌트의 렌더링 결과를 컴포넌트별로 직렬화된 데이터로 받는다.
    • 클라이언트는 이 데이터를 바탕으로 하이드레이션한다.

getStaticProps

  • 과거 : getStaticProps나 getStaticPaths를 이용해 사전에 미리 생성 가능한 경로를 모아둔다음, 이 경로에 내려줄 props를 미리 빌드하는 형식으로 구성
  • 13 : fetch, cache로 유사하게 구현 가능

로딩, 스트리밍, 서스펜스

Next.js 13에서는 스트리밍과 리액트의 서스펜스를 활용해 컴포넌트가 렌더링 중이라는 것을 나타낼 수 있다.

profile
Undefined JS developer

0개의 댓글