[모던 리액트 Deep Dive] 11. Next.js 13과 리액트 18

공효은·2024년 6월 9일
0

react

목록 보기
11/11

Next.js 버전 13은 Next.js의 릴리스 역사를 통틀어 가장 큰 변화가 있는 릴리스라고 해도 과언이 아니다.

  • 서버 사이드 렌더링의 구조에 많은 변화가 있는 리액트 18을 채택했다.
  • 레이아웃 지원을 지원하기 시작했다.
  • 바벨을 대체할 러스트(Rust)기반 SWC를 뒤이어 웹팩을 대체할 Turbopack 출시

11.1 app 디렉터리의 등장

현재까지 Next.js 의 아쉬운 점으로 평가받던 것 중 하나는 바로 레이아웃의 존재다.
공통 헤더와 공통 사이드바가 거의 대부분의 페이지에 필요흔 웹사이트를 개발한다고 가정해보자.
만약 react-router-dom을 사용한다면 다음과 같이 구현할 수 있다.

import {Routes, Route, Outlet, Link} from 'react-router-dom'

export default function App() {
  return (
    <div>
      <div>Routes 외부의 공통 영역</div>
      
      <Routes>
        <Route path="/" element={<Layout/>}>
          <Route index element={<Home/>}/>
          <Route path="/menu1" element={<Menu1/>}/>
          <Route path="/menu2" element={<Menu2/>}/>
          <Route path="*" element={<NoMatch/>}/>
        </Route>
      </Routes>
    </div>
    )
}

function Layout() {
  return (
    <div>
      <nav>
        <ul>
          <li>
            <Link to="/">Home</Link>
          </li>
          <li>
            <Link to="/menu1">menu1</Link>
          </li>
          <li>
            <Link to="/menu2">menu2</Link>
          </li>
        </ul>
      </nav>
      
      <hr />
      <div>/ path 하위의 공통 영역</div>
      
      <Outlet/>
    </div>
    )
}

function Home() {
  return (
    <div>
      <h2>Home</h2>
    </div>
  )
}

function Menu1() {
  return (
    <div>
      <h2>Menu1</h2>
    </div>
  )
}

function Menu2() {
  return (
    <div>
      <h2>Menu2</h2>
    </div>
  )
}

function NoMatch() {
  return (
    <div>
      <h2>Nothing to see here!</h2>
      <p>
        <Link to="/">Go to the home page</Link>
      </p>
    </div>
  )
}

<Routes> 영역은 주소에 따라 바뀌는 영역으로, 각 주소에 맞는 컴포넌트를 선언해서 넣어주면 된다.
<Routes> 의 외부 영역은 주소가 바뀌더라도 공통 영역으로 남는다. <Routes> 내부만 주소에 맞게 변경된다.
또한 <Routes> 내부의 <Outlet/><Routes> 의 주소 체계 내부의 따른 하위 주소를 렌더링하는 공통 영역이다. 사용하기에 따라 <Routes>의 외부 영역 같이 해당 주소의 또 다른 영역을 공통으로 꾸밀 수 있다.

outlet 정의

An <Outlet> should be used in parent route elements to render their child
route elements. This allows nested UI to show up when child routes are
rendered. If the parent route matched exactly, it will render a child index
route or nothing if there is no index route.

이 구조를 Next.js에서 유지하려면 어떻게 해야할까?

13 버전 이전 까지 모든 페이지는 각각의 물리적으로 구별된 파일로 독립돼 있었다.
페이지 공통으로 무언가를 집어 넣을 수 있는 곳은 _document, _app이 유일하다.
하지만... 서로 다른 목적을 지니고 있다.

  • _document: 페이지에서 쓰이는 <html><body> 태그를 수정하거나. 서버사이드 렌더링 시 styled-components 같은 일부 CSS-in-JS를 지원하기 위한 코드를 삽입하는 제한적인 용도로 사용된다. 오직 서버에서만 작동하므로 onClcik 같은 이벤트 핸들러를 붙이거나 클라이언트 로직을 붙이는것이 금지 된다.

  • _app: _app은 페이지를 초기화하기 위한 용도로 사용되며, 다음과 같은 작업이 가능하다.

    • 페이지 변경 시에 유지하고 싶은 레이아웃
    • 페이지 변경 시 상태 유지
    • componentDidCatch를 활용한 에러 핸들링
    • 페이지간 추가적인 데이터 삽입
    • global CSS 주입

즉, 이전의 Next.js 12 버전까지는 페이지 공통 레이아웃을 유지할 수 있는 방법은 _app이 유일했다.
그러나 이 방식은 _app에서밖에 할 수 없어 제한적이고, 각 페이별로 서로 다른 레이아웃을 유지할 수 없다. 이런 레이아웃의 한계를 극복하기 위해 나온 것이 Next.js의 app 레이아웃이다.

11.1.1 라우팅

  • /pages로 정의하던 라우팅 방식 /app 디렉터리로 이동
  • 파일명으로 라우팅하는 것이 불가능해졌다.

라우팅을 정의하는 법

기본적으로 Next.js의 라우팅은 파일 시스템을 기반으로 하고 있다.
app 기반 라우팅 시스템은 기존에 /pages를 사용했던 것과 다음과 같은 차이가 있다.

  • Next.js 12 이하: /pages/a/b.tsx 또는 /pages/a/b/index.tsx 는 모두 동일한 주소로 변환된다. 즉, 파일명이 index라면 이 내용은 무시된다.
  • Next.js 13 app: /app/a/b는 /a/b로 변환되며. 파일명은 무시된다.
    폴더명까지만 주소로 변환된다.

즉, Next.js 13의 app 디렉터리 내부의 파일명은 라우팅 명칭에 아무런 영향을 미치지 못한다.
app 내부에서 가질 수 있는 파일명은 뒤이어 설명할 예약어로 제한된다

layout.js

Next.js 13 부터는 app 디렉터리 내부의 폴더명이 라우팅이 되며,
이 폴더에 포함될 수 있는 파일명은 몇 가지로 제한돼 있다. 그중 하나가 layout.js 이다.

이 파일은 페이지의 기본적인 레이아웃을 구성하는 요소이다. 해당 폴더에 layout이 있다면 그 하위 폴더 및 주소에 모두 영향을 미친다.

// app/layout.tsx

import { ReactNode } from 'react'

export default function AappLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="ko">
      <head>
        <title>안녕하세요!</title>
      </head>
      <body>
        <h1>웹페이지에 오신 것을 환영합니다.</h1>
        <main>{children}</main>
      </body>
    </html>
  )
}


//app/blog/layout.tsx

import { ReactNode } from 'react'

export default function BlogLayout({ children }: { children: ReactNode }){
  return <section>{children}</section>
}
  • app/layout.tsx: 루트에는 단 하나의 layout 을 만들 수 있다. 이 layout 은 모든 페이지에 영향을 미치는 공통 레이아웃이다. 일반적으로 웹 페이지를 만드는 데 필요한 공통적인 내용(html, head등)을 다룬다.
    꼭 공통 레이아웃이 필요하진 않더라도 웹 페이지에 필요한 기본 정보만 담아둬도 충분히 유용하다.
  • app/blog/layout.tsx: 페이지 하위에 추가되는 layout은 해당 주소 하위에만 적용된다. 앞의 레리아웃을 활용하면 다음과 같은 구조가 완성될 것이다.
   <html lang="ko">
      <head>
        <title>안녕하세요!</title>
      </head>
      <body>
        <h1>웹페이지에 오신 것을 환영합니다.</h1>
        <main><section>여기에 블로그 글</section></main>
      </body>
    </html>

layout에서 주의해야할점

  • layout은 app 디렉터리 내부에서는 예약어다. 무조건 layout.{js|jsx|ts|tsx}로 사용해야하며, 레이아웃 이외의 다른 목적으로는 사용할 수 없다.
  • layout은 children을 props로 받아서 렌더링해야 한다. 레이아웃이므로 당연히 그려야 할 컴포넌트를 외부에서 주입받고 그려야한다.
  • layout 내부에는 반드시 export default로 내보내는 컴포넌트가 있어야한다.
  • layout 내부에서도 API 요청과 같은 비동기 작업을 수행할 수 있다.

page.js

page도 에약어 이며, 이전까지 Next.js에서 일반적으로 다뤘던 페이지를 의미한다.

export default function BlogPage() {
  return <>여기에 블로그 글</>
}

이 page는 앞에서 구성했던 layout을 기반으로 위와 같은 리액트 컴포넌트를 노출하게 된다. 이 page가 받는 props는 다음과 같다.

  • params: 옵셔널 값으로, 앞서 설명한 [...id]와 같은 동적 라우트 파라미터를 사용할 경우 해당 파라미터에 값이 들어온다.
  • searchParams: URL에서 ?a=1과 같은 URLSearchParams를 의미한다.
    이 값은 layout에서는 제공되지 않는다. 그 이유는 layout은 페이지 탐색중에는 리렌더링을 수행하지 않기 때문이다.
    즉, 같은 페이지에서 search parameter만 다르게 라우팅을 시도하는 경우 layout을 리렌더링하는 것은 불필요하기 때문이다 만약 search parameter에 의존적인 작업을 해야 한다면 반드시 page 내부에서 수행해야 한다.

page에서 주의해야할 점

  • page도 역시 app 디렉터리 내부의 예약어다. 무조건 page.{js|jsx|ts|tsx}로 사용해야한다.
  • page 내부에는 반드시 export default로 내보내는 컴포넌트가 있어야한다.

error.js

error.js는 해당 라우팅 영역에서 사용되는 공통 에러 컴포넌트다. 이 error.js를 사용하면 특정 라우팅별로 서로 다른 에러 UI를 렌더링하는 것이 가능해진다.

'use client'

import {useEffect} from 'react'

export default funcion Error({error, reset}:{error:Error, reset: () => void})
{
  useEffect(() => {
    console.log('logging error:', error)
  }, [error])
  
  return (
    <>
      <div>
        <string>Error:</string> {error?.message}
      </div>
      <div>
        <button onClick={() => reset()}> 에러 리셋</button>
      </div>
   </>
  )
}

error페이지는 에러 정보를 담고 있는 error: Error 객체와 에러 바운도리를 초기화할 rest: () => void를 props로 받는다.
에러 바운더리는 클라이언트에서만 작동하므로 error 컴포넌트도 클라이언트 컴포넌트여야한다.
이 error 컴포넌트는 같은 수준의 layout에서 에러가 발생할 경우 해당 error 컴포넌트로 이동하지 않는다.

그 이유는 아마도 <Layout><Error>{children}</Error><Layout>과 같은 구조로 페이지가 렌더링되기 때문일 것이다.
만약 Layout 에서 발생하는 에러를 처리하고 싶다면 상위 컴포넌트의 error를 사용하거나, app의 루트 에러처리를 담당하는 app/global-error.js 페이지를 생성하면 된다.

not-found.js

not-fount는 특정 라우팅 하위의 주소를 찾을 수 없는 404 페이지를 렌더링할 떄 사용된다.

loading.js

리액트 Suspense를 기반으로 해당 컴포넌트가 불러오는 중임을 나타낼 때 사용할 수 있다.

export default function Loading() {
  return 'Loading...'
}

route.js

디렉터리가 라우팅 주소를 담당하며 파일명은 route.js로 통일됐다. /app/api/hello/route.ts에 다음 예제와 같은 내용을 추가했다고 가정해보자.

// app/api/hello/route.ts

import { NextRequest } from 'next/server'

export async function GET(request: Request) {}

export async function HEAD(request: Request) {}

export async function POST(request: Request) {}

export async function PUT(request: Request) {}

export async function DELETE(request: Request) {}

export async function PATCH(request: Request) {}

export async function OPTIONS(request: Request) {}
  • route.ts ㅍ일 내부에 REST API의 get, post와 같은 메서드명을 예약어로 선언해 두면 HTTP 요청에 맞게 해당 메서드를 호출하는 방식으로 작동한다.

  • route.ts가 존재하는 폴더 내부에는 page.tsx가 존재할 수 없다.

route의 함수들이 받을 수 있는 파라미터는 다음과 같다.

  • request: NextRequest 객체이며, fetch의 Request를 확장한 Next.js만의 Request 이다. 이 객체에는 API 요청와 관련된 cookie, headers 등 뿐만 아니라 nextUrl 같은 주소 객체도 확인할 수 있다.
  • context: params만을 갖고 있는 객체이며, 이 객체는 파일 기반 라우팅에서 언급한 것과 동일한 동적 라우팅 파라미터 객체가 포함되어있다.
    이 객체는 Next.js에서 별도 인터페이스를 제공하지 않으므로 주소의 필요에 따라 원하는 형식으로 선언하면 된다.
// app/api/users/[id]/routes.ts

export async function GET(
request: NextRequest,
context: { params: { id: string} },
){
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/users/${context.params.id}`,
   )
  
  // ...
  return new Response(JSON.stringify(result), {
    status: 200,
    headers: {
      'content-type': 'application/json',
    },
  })
}

11.2 리액트 서버 컴포넌트

리액트 18에서 새로 도입된 리액트 서버 컴포넌트는 서버 사이드 렌더링과 완전 다른 개념이다.
두 용어 모두 '서버' 라는 단어가 포함돼 있어 혼동의 여지가 있지만 '서버'라는 단어가 있다는 점, 그리고 '서버'에서 무언가 작업을 수행한다는 점을 제외하면 완전히 다른 개념으로 보는 것이 옳다.

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

리액트의 모든 컴포넌트는 클라이언트에서 작동하며, 브라우저에서 자바스크립트 코드 처리가 이뤄진다.
예를 들어, 리액트로 만들어진 페이지를 방문한다고 가정해보자.
웹사이트를 방문하면 리액트 실행에 필요한 코드를 다운로드하고 리액트 컴포넌트 트리를 만든 다음, DOM에 렌더링한다.

서버 사이드 렌더링의 경우는?
미리 서버에서 DOM을 만들어 오고, 클라이언트에서는 이렇게 만들어진 DOM을 기준으로 하이드레이션을 진행한다.
이후 브라우저에서는 상태를 추적하고, 이벤트 핸들러를 DOM에 추가하고, 응답에 따라 렌더링 트리를 변경하기도 한다.

지금까지의 구조의 한계점

  • 자바스크립트 번들 크기가 0인 컴포넌트를 만들 수 없다.
  • 백앤드 리소스에 대한 직접적인 접근이 불가능하다. 리액트를 사용하는 클라이언트에서 백엔드 데이터에 접근하려면 REST API와 같은 방법을 사용하는 것이 일반적이다.
  • 자동 코드 분할이 불가능하다. 코드 분할이란 하나의 거대한 코드 번들 대신, 코드를 여러 작은 단위로 나누어 필요할 때만 동적으로 지연 로딩함으로서 앱을 초기화 하는 속도를 높여주는 기법을 말한다. 일반적으로 리액트에서는 lazy를 사용해 구현해 왔다.
  • 연쇄적으로 발생하는 클라이언트와 서버의 요청을 대응하기 어렵다; 하나의 요청으로 컴포넌트가 렌더링되고, 또 그 컴포넌트의 렌더링 결과로 또 다른 컴포넌트를 렌더링하는 시나리오를 상상해보자.
    이 시나리오에서는 최초 컴포넌트의 요청과 렌더링이 끝나기 전까지는 하위 컴포넌트의 요청과 렌더링이 끝나지 않는다.
  • 추상화에 드는 비용이 증가한다. (코드가 복잡해지고 코드양이 많아진다.)

결국 서버 사이드 렌더링, 클라이언트 사이드 렌더링은 모두 이 문제를 해결하기에는 아쉬움이 있다.
서버 사이드 렌더링은 정적 콘텐츠를 빠르게 제공하고, 서버에 있는 데이터에 손쉽게 제공할 수 있는 반면 사용자의 인터랙션에 따른 다양한 사용자 경험을 제공하긴 어렵다.

클라이언트 사이드 렌더링은 사용자의 인터랙션에 따라 정말 다양한 것들을 제공할 수 있지만 서버에 비해 느리고 데이터를 가져오는 것도 어렵다.

이러한 두 구조의 장점을 모두 취하고자 하는 것이 바로 리액트 서버 컴포넌트다.

11.2.2 서버 컴포넌트란?

서버 컴포넌트(Server Component)란 하나의 언어, 하나의 프레임워크, 그리고 하나의 API와 개념을 사용하면서 서버와 클라이언트 모두에서 컴포넌트를 렌더링할 수 있는 기법을 의미한다.
서버에서 할 수 있는 일은 서버가 처리하게 두고, 서버가 할 수 없는 나머지 작업은 클라이언트인 브라우저에서 수행된다.

서버컴포넌트의 이론에 따르면 모든 컴포넌트는 서버 컴포넌트가 될 수도 있고, 클라이언트 컴포넌트가 될 수도 있다.
따라서 컴포넌트 트리에서 위와 같이 클라이언트 및 서버 컴포넌트가 혼재된 상황은 자연스럽다.
어떻게 이런 구조가 가능할까? 그 비밀은 흔히 children으로 자주 사용되는 ReactNode에 달려 있다.

// ClientComponent.jsx
'use client'
// ❌ 이렇게 클라이언트 컴포넌트에서 서버 컴포넌트를 불러오는 것은 불가능 하다.

import ServerComponent from './ServerComponent.server'
export default function ClientComponent() {
  return (
    <div>
      <ServerComponent />
    </div>
   )
}


'use client'
// ClientComponent.jsx

export default function ClientComponent({children}) {
  return (
    <div>
      <h1>클라이언트 컴포넌트</h1>
      {children}
    </div>
   )
}


// ServerComponent.jsx
export default function ServerComponent({children}) {
  return (
    <div>
      <h1>서버 컴포넌트</h1>
    </div>
   )
}

// ParentServerComponent.jsx
import ClientComponent from './ClientComponent'
import ServerComponent from './ServerComponent'

export default function ParentServerComponent({children}) {
  return (
    <ClientComponent>
      <ServerComponent />
    </ClientComponent>
   )
}
  • 서버 컴포넌트

    • 요청이 오면 그 순간 서버에서 딱 한 번 실행될 뿐이므로 상태를 가질 수 없다. 따라서 리액트에서 상태를 가질 수 있는 useState, useReducer 등의 훅을 사용할 수 없다.
    • 렌더링 생명주기를 사용할 수 없다. 한번 렌더링되면 끝이다. 따라서 useEffect, useLayoutEffect를 사용할 수 없다.
    • 물론 customHook도 사용할 수 없다.
    • 브라우저에서 실행되지 않고, 서버에서만 실행되기 때문에 DOM API를 쓰거나 window, document등에 접근할 수 없다.
    • 데이터베이스, 내부 서비스, 파일 시스템 등 서버에만 있는 데이터를 async/await으로 접근할 수 있다.
    • 다른 서버 컴포넌트를 렌더링하거나 div, span, p 같은 요소를 렌더링하거나, 혹은 클라이언트 컴포넌트를 렌더링할 수 있다.
  • 클라이언트 컴포넌트

    • 브라우저에서만 실행되므로 서버 컴포넌트를 불러오거나, 서버 전용 훅이나 유틸리티를 불러 올 수 없다.
    • 서버 컴포넌트가 클라이언트 컴포넌트를 렌더링하는데, 그 클라이언트 컴포넌트가 자식으로 서버 컴포넌트를 갖는 구조는 가능하다. (클라이언트 입장에서 봤을 때 서버 컴포넌트는 이미 서버에서 만들어진 트리를 가지고 있을 것이고, 클라이언트 컴포넌트는 이미 서버에서 만들어진 그 트리를 삽입해서 보여주기만 한다.)
    • hook을 사용할 수 있고, 브라우저 api 사용 가능하다.(일반적인 리액트 컴포넌트와 같다)
  • 공통 컴포넌트

    • 이 컴포넌트는 서버와 클라이언트 모두에서 사용할 수 있다. 당연히 서버컴포넌트와 클라이언트 컴포넌트의 모든 제약을 받는 컴포넌트가 된다.

    리액트는 모든 것을 다 공용 컴퍼넌트로 판단한다. 즉, 모든 컴포넌트를 다 서버에서 실행 가능한 것으로 분류한다. 대신, 클라이언트 컴포넌트라는 것을 명시적으로 선언하려면 파일의 맨 첫 줄에 "use client" 라고 작성해 두면 된다.

11.2.3 서버 사이드 렌더링과 서버 컴포넌트의 차이

서버사이드 렌더링

  • 응답받은 페이지 전체를 HTML로 렌더링하는 과정을 서버에서 수행한 후 그 결과를 클라이언트에 내려준다. 그리고 이후 클라이언트에서 하이드레이션 과정을 거쳐 서버의 결과물을 확인하고 이벤트를 붙이는 등의 작업을 수행한다.
  • 서버 사이드 렌더링의 목적은 초기에 인터랙션은 불가능 하지만 정적인 HTML을 빠르게 내려주는 데 초점을 두고 있다. 따라서 여전히 초기 HTML이 로딩된 이후에는 클라이언트에서 자바스크립트 코드를 다운로드, 파싱, 실행하는데 비용이 든다.

이후에는 서버 사이드 렌더링과 서버 컴포넌트를 모두 채택하는 것도 가능해질 것이다.
서버 컴포넌트를 활용해 서버에서 렌더링 할 수 있는 컴포넌트는 서버에서 완성해 제공받는 다음, 클라이언트 컴포넌트는 서버 사이드 렌더링으로 초기 HTML으로 빠르게 전달 받을 수가 있다.

이 두가지 방법을 결합하면 클라이언트 및 서버 컴포넌트를 모두 빠르게 보여줄 수 있고, 동시에 클라이언트에서 내려받아야 하는 자바스크립트의 양도 줄어들어 브라우저의 부담을 덜 수 있다.

결론적으로 둘은 대체제가 아닌 상호 보완하는 개념이다.

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

https://yceffort.kr/2022/01/how-react-server-components-work 참고!

profile
잼나게 코딩하면서 살고 싶어요 ^O^/

0개의 댓글