NextJS 13 튜토리얼 1 - React Essentials

Sal Jeong·2023년 6월 15일
0

NextJS를 제대로 공부하기 위해 docs를 다시 처음부터 정리해보는 시간을 가지기로 한다.

https://nextjs.org/docs

의 prerequisites부터 시작하는데,
React 공식 docs는 너무 방대하므로 넘어가고,

React Essentials,
Intstallation,
이후 Main features로 진행하기로 함.

NextJS란 무엇인가?

웹 어플리케이션을 만들기 위한 프레임워크이다.

리액트의 컴포넌트 방식으로 UI를 만들수 있고 이에 더해 추가적인 기능과 구조 최적화 방식을 제공한다.

또한 개발자에게 있어 추가적인 추상화와 자동 설정 방법을 제공한다.
번들링, 컴파일링 등등. 이 점에서 어플리케이션 개발 그 자체에 집중할 수 있게 해줌.

결과적으로 개인이든 팀의 일부이든 빠르고 최적화된 interactive한 웹 페이지를 만뜰게 도와주는 프레임워크이다.

Prerequisites - React Essentials

Server Components

컴포넌트들을 서버와 클라이언트 레이어로 구분함으로써, client 단에서의 반응성과 전통적인 서버 렌더링 방식의 퍼포먼스를 구현할 수 있다.

Thinking in Server Components

리액트 문법과 더불어서 13버전에서 소개된 Server Components는 서버와 클라이언트가 함께 렌더링하는 하이브리드 어플리케이션 개발 모델을 제시하게 되었다.

기존 SPA에서 모든 렌더링을 어플리케이션 레이어에서 하는것과는 다르게 컴포넌트의 목적에 맞게 유동적으로 렌더링 주체를 바꿀 수 있게 되었다.

이런 페이지를 생각해 보면

위 페이지를 컴포넌트 별로 나눠 본다면, 사실 유저의 반응이 필요한 컴포넌트는 4가지인 것을 알 수 있다. 나머지 Static한 부분은 Server에서, 반응성이 필요한 부분은 클라에서 렌더링하는것이 이 개념이고 이것을 NextJS의 server-first approach라고 부르기로 한다.

Why Server Components?

그렇다면, 이런 방식을 사용하는 이유는 무엇인가?

Server Components를 통해 서버 인프라를 쉽게 구현할 수 있다.
기존의 SPA에서 컴포넌트에 필요한 데이터를 페칭해서 가져온다면, 이 방식에서는 데이터를 데이터베이스 -> 서버 -> 컴포넌트화 -> 브라우저로 전달하기 때문에 결과적으로 클라이언트의 코드, 번들 사이즈가 줄며 최적화에 도움이 된다.
이 방식은 기존의 PHP, Ruby on Rails와도 비슷하고 리액트의 자유로운 코딩 방식을 적용할 수 있게 해준다.

Server Components로 가져올 수 있는 이점은
시작 페이지 로딩이 빨라짐
클라이언트 단의 코드 번들 사이즈가 줄어듬
클라이언트 단의 런타임을 캐싱할 수 있고 사이즈를 예측하기 편해지고, 코드가 늘어나도 사이즈가 극적으로 증가하지 않음.
-> 유저의 반응성이 필요한 컴포넌트는 클라 단에서, static한 컴포넌트는 서버에서 처리하기 때문.

NextJS의 route가 로딩되었을 경우, 기존 리액트에서는 빈 html을 서브하고 같이 페칭된 css, js 번들이 실행되면서 페이지를 렌더링하는 방식이었다. 이 부분 역시 NextJS에서는 progressive하게 로딩된다.
먼저 위 서버 컴포넌트들이 만들어진 html을 내려받는다.
이와 동시에 nextJS의 코드가 시작되면서 react의 라이프사이클에 따라 유저의 반응이 필요한 컴포넌트들을 추가해 주게 된다.

이러한 구현을 하기 위해서는 모든 앱 라우터 안에 정의된 컴포넌트는 서버 컴포넌트여야 하며, 반응성이 필요할 경우 이를 'use client' directive를 정의해 줌으로써 클라이언트 컴포넌트로 만들 수 있다.

Client Components

클라이언트 컴포넌트는 위에서 상술했듯 반응성이 필요할 경우 사용한다.
NextJS에서 이 컴포넌트도 서버에서 pre-render 되고, client에서 hydrate 된다.
이것은 기존 Pages Router가 어떻게 작동했는지를 생각해보면 이해가 편할 것이다.

use client directive

컴포넌트를 client 단으로 변경하려면 이 directive를 넣어준다.

'use client'
 
import { useState } from 'react'
 
export default function Counter() {
  const [count, setCount] = useState(0)
 
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  )
}

기존 nextJS와는 다르게, 이렇게 컴포넌트를 엄격하게 구분함으로써, use client 구문이 없다면 에러를 띄우게 된다.

use client구문은 파일의 맨 상위에 작성해야 한다. 이 구문이 존재하게 된다면 해당 파일의 모든 코드와 임포트되는 코드는 클라이언트 번들에 추가되게 된다.

Good to know:

서버 컴포넌트의 모듈 그래프(import statements) 역시 서버에서 렌더되기 때문에, 클라이언트 번들의 사이즈를 증가시키지 않는다.

클라이언트 컴포넌트의 모듈 그래프는 클라이언트에서 렌더된다. 하지만 넥스트 js에서는 이 부분 역시 서버에서 pre-rendered되고, 데이터 패칭 이후 hydrate가 클라이언트단에서 이루어 진다.
use client 구문은 파일의 맨 상단에 작성해야한다.
use client가 없는 모든 파일은 server components이다.
use client가 선언된 파일의 모든 모듈은 클라이언트 컴포넌트에 포함되며 번들에 포함된다.

When to use Server and Client Components?

기본적으로 서버 컴포넌트를 사용하는것을 추천하고
아래 경우에서 클라이언트 컴포넌트를 사용하면 된다.

Patterns

Moving Client Components to the Leaves

위 클라이언트 컴포넌트들은 '트리 쉐이킹' 기법을 통허 더욱 최적화 할 수 있다.
예를 들어서, 서치 바 컴포넌트가 있다고 하면, 이를 로고, 링크, 검색바, 버튼 등으로 더 나눌 수 있을 것이다.
이 레이아웃들을 더 쪼개어 검색바 버튼 부분은 클라이언트 컴포넌트, 나머지는 서버 컴포넌트로 쪼갬으로써 자바스크립트 번들을 더욱 줄일 수 있을 것이다.

// SearchBar is a Client Component
import SearchBar from './searchbar'
// Logo is a Server Component
import Logo from './logo'
 
// Layout is a Server Component by default
export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <nav>
        <Logo />
        <SearchBar />
      </nav>
      <main>{children}</main>
    </>
  )
}

Composing Client and Server Components

서버와 클라이언트 모두 위 도식처럼 같은 컴포넌트 트리에서 합칠 수 있다.

실제로 동작하는 리액트 프로세스는 이렇다:

서버에서는 리액트가 모든 서버 컴포넌트를 먼저 렌더링한다.
클라이언트 컴포넌트에 포함된 서버 컴포넌트역시 렌더링한다.
독립된 클라이언트 컴포넌트는 이 과정에서 제외한다.

이 코드가 클라이언트에 도착하면 리액트가 클라이언트 컴포넌트와, 서버 컴포넌트를 렌더하게 되고 두 가지 렌더를 합치는 과정을 수행한다.
만약 서버 컴포넌트가 클라 컴포넌트에 네스티드 되어 있다면, 이 과정 역시 클라이언트에서 수행한다.
또한, nextJS에서 페이지를 로딩할때에는 더 빠른 로딩시간을 위해서 클라이언트 컴포넌트 역시 서버에서 pre-render 과정을 거치고, 클라이언트에서 로직을 수행하며 hydrate한다.

Nesting Server Components inside Client Components

위의 플로우에 더해서 서버 컴포넌트를 클라이언트 컴포넌트에 추가하는데 있어 한가지 제약이 있다. 이것은 서버에서의 추가적인 연산을 막기 위함이다.

Unsupported Pattern: Importing Server Components into Client Components

서버 컴포넌트를 클라이언트 컴포넌트에서 임포트하는 것이 그것이다.

'use client'
 
// This pattern will **not** work!
// You cannot import a Server Component into a Client Component.
import ExampleServerComponent from './example-server-component'
 
export default function ExampleClientComponent({
  children,
}: {
  children: React.ReactNode
}) {
  const [count, setCount] = useState(0)
 
  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
 
      <ExampleServerComponent />
    </>
  )
}

대신 클라이언트 컴포넌트에서 서버 컴포넌트를 프롭으로 받아올 수 있다.

이유는 서버 컴포넌트는 서버에서 렌더되고 클라이언트 컴포넌트는 클라에서 렌더되기 때문에, 클라에서 이비 렌더된 서버 컴포넌트 자리를 받아와 메꿀 수 있기 때문이다.

아래의 코드처럼 서버 컴포넌트를 칠드런의 일부로 취급하여 불러올 수 있다.

'use client'
 
import { useState } from 'react'
 
export default function ExampleClientComponent({
  children,
}: {
  children: React.ReactNode
}) {
  const [count, setCount] = useState(0)
 
  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
 
      {children}
    </>
  )
}

이 경우 클라이언트컴포넌트는 어떤 칠드런이 렌더될 지 알 수 없다.
또한 서버 컴포넌트 자체가 존재하는지도 알 수 없다.

클라이언트 컴포넌트가 이 경우 알 수 있는 것은 오직 어떠한 칠드런이 들어올 경우, 이것을 렌더하는 연산 하나 뿐이다.

그리고 부모인 서버 컴포넌트의 경우 위의 클라이언트, 서버 컴포넌트 예시를 모두 임포트해서 클라이언트 컴포넌트 예시의 children으로 서버 컴포넌트를 불러올 수 있다.

// This pattern works:
// You can pass a Server Component as a child or prop of a
// Client Component.
import ExampleClientComponent from './example-client-component'
import ExampleServerComponent from './example-server-component'
 
// Pages in Next.js are Server Components by default
export default function Page() {
  return (
    <ExampleClientComponent>
      <ExampleServerComponent />
    </ExampleClientComponent>
  )
}

이 방법을 통해 클라이언트, 서버 컴포넌트는 서로 독립적으로 렌더되어 서버 컴포넌트(위 wrapper 페이지 컴포넌트) -> ExampleClientComponent -> ExampleServerComponent의 순으로 렌더된다.

Good to know

위 패턴은 리액트 개발자에게 있어서 이미 친숙할 것이다.
이 composition 패턴에 있어 중요한것은 props로 전달되는 children이 어떤 것인지 컴포넌트간 알 수 없다는 것으로 렌더링 순서를 분리하는 것이 가능해진다.
또한 이러한 패턴을 통해 부모 컴포넌트(클라이언트 컴포넌트)의 스테이트가 변경되었을 때 네스티드된 차일드가 리렌더 되지 않게 한다.
단순히 children이 아닌 다른 이름으로도 JSX를 넘길 수 있다.

p.s) 예전에 이런 방식으로 다이얼로그를 중앙에서 처리한 적이 있었음. Component는 실제로 해당 다이얼로그 컴포넌트 그 자체였기 때문에.. 위 내용과 맞다.

import { useMemo } from "react";

import { Dialogs } from "@/components/common/contexts/dialogs/useDialogsContextState";
import { motion } from "framer-motion";

import ErrorBoundary from "@/routes/error/ErrorBoundary";

type RenderDialogProps = {
    Component: React.ReactNode; 
    // 여기 타입이 헷갈린다. Component 자체를 넘기는 것이기 때문에... 
    //React.FC가 아닌가? 왜 ReactNode로 되어있는지 나중에 한번 봐야
};

const RenderDialog: React.FunctionComponent<RenderDialogProps> = ({
    Component,
}) => {
    const Dialog = useMemo(
        () => Component,
        [Component]
    );
    const SafeComponent = (
        <ErrorBoundary>
            <Dialog />
        </ErrorBoundary>
    );

    return SafeComponent;
};

export default RenderDialog;

Passing props from Server to Client Components (Serialization)

서버에서 클라이언트로 Props를 넘기기 위해선 먼저 serialized되어야 한다. 펑션, 날짜 등 그대로는 넘어가지 않음.

Where is the Network Boundary?

앱 라우터를 사용한다면 위의 경우 네트워크 바운더리는 서버 컴포넌트와 클라이언트 컴포넌트를 가리킨다. 기존 NextJS에서 getStaticProps/getServerSideProps 과 page Components를 사용했던 것과는 다르게, 서버 컴포넌트 자체에서 넘기는 데이터는 시리얼라이즈 될 필요가 없고, 네트워크 boundary를 넘지 않는다.

Keeping Server-Only Code out of Client Components (Poisoning)

Since JavaScript modules can be shared between both Server and Client Components, it's possible for code that was only ever intended to be run on the server to sneak its way into the client.

자바스크립트 모듈 공유에 있어서 클라이언트에 위치한 코드가 서버에서 실행되게 만들 수 있다.

For example, take the following data-fetching function:

// lib/data.ts


export async function getData() {
  const res = await fetch('https://external-service.com/data', {
    headers: {
      authorization: process.env.API_KEY,
    },
  })
 
  return res.json()
}

얼핏 본다면 이것은 서버와 클라이언트에 모두 사용될 수 있을것 같지만, 환경 변수에 NEXT_PUBLIC이 존재하지 않는다. 이 말은 해당 코드는 서버에서만 사용된다는 것으로, 이를 통해서 Nextjs에서는 중요한 정보를 클라에 노출시키지 않는 방식으로 사용할 수 있다.

결과적으로 위 펑션을 클라에서 임포트하고 실행시켜도 제대로 실행되지 않는다. 물론 위 환경변수를 바꿀 수도 있겠지만, 보안의 목적에서 권장되지 않는다.

The "server only" package

위와 같은 상황을 방지하려면, server-only 패키지를 통해 다른 팀원이 위 코드를 실수로 클라에서 임포트 했을 경우 빌드 에러를 줄 수 있다.

npm install server-only
import 'server-only'
 
export async function getData() {
  const res = await fetch('https://external-service.com/data', {
    headers: {
      authorization: process.env.API_KEY,
    },
  })
 
  return res.json()
}

위 임포트 구문으로 만약 이 모듈이 클라에서 사용되었을 때 빌드 오류를 일으킬 수ㅜ 있다.

또한, client-only 패키지도 있으며, 이것은 말 그대로 클라에서만 사용하는 코드를 구별하기 위해 사용 가능하다 - 예를 들어서 window 오브젝트를 사용하는 경우가 있다.

Data Fetching

NextJS 13에서는 모든 데이터 페칭을 서버 컴포넌트에서 하도록 권장한다.
이전 버전처럼 클라이언트에서도 가능하지만, 더 나은 ux와 퍼포먼스를 내기 위해서이다.

Third-party packages

NextJS 13에서의 서버 컴포넌트가 기존과는 다른 방식이기 때문에, 서드 파티 라이브러리를 사용하려면 해당 라이브러리에서도 useState, useEffect, and createContext 에 use client 구문을 써주어야만 한다.

하지만 그런 라이브러리가 아직 많지 않으므로 이 부분은 에러의 소지가 있다.

예를 들어서 hypothetical acme-carousel 라이브러리를 사용할 때, 이 라이브러리에서 use client 디렉티브가 없는 것을 알 수 있다.

이러한 방식으로 따로 래퍼를 구성하여 클라이언트 컴포넌트로 사용 가능하다.

'use client'
 
import { useState } from 'react'
import { Carousel } from 'acme-carousel'
 
export default function Gallery() {
  let [isOpen, setIsOpen] = useState(false)
 
  return (
    <div>
      <button onClick={() => setIsOpen(true)}>View pictures</button>
 
      {/* Works, since Carousel is used within a Client Component */}
      {isOpen && <Carousel />}
    </div>
  )
}

이것을 서버 컴포넌트에서 사용할 경우, 에러가 발생한다.(아래 코드)

import { Carousel } from 'acme-carousel'
 
export default function Page() {
  return (
    <div>
      <p>View pictures</p>
 
      {/* Error: `useState` can not be used within Server Components */}
      <Carousel />
    </div>
  )
}

여기서도 wrapper를 사용해 라이브러리를 재정의한다.

'use client'
 
import { Carousel } from 'acme-carousel'
 
export default Carousel
import Carousel from './carousel'
 
export default function Page() {
  return (
    <div>
      <p>View pictures</p>
 
      {/*  Works, since Carousel is a Client Component */}
      <Carousel />
    </div>
  )
}

와 같은 방법으로 서버 컴포넌트에서 사용가능하다.

이러한 케이스가 자주 있지는 않을 것이다. 대부분의 서드 파티 라이브러리를 사용하는 컴포넌트는 클라이언트에서 사용될 것이기 때문이다.(useState와 같은 라이프사이클을 사용하므로)
하지만, 프로바이더 컴포넌트는 어플리케이션의 맨 상위에 위치하는데, 이에 대해서는 후술하도록 한다.

Library Authors

라이브러리 저자들은 use client 구문을 사용해주는 것이 좋다.

Contexts

대부분의 리액트 앱에서 컨텍스트는 필수이다. (createContext를 사용하든, 서드파티 패키지를 사용하든)

NextJS에서도 클라이언트 컴포넌트에서는 이것이 잘 동작하지만 서버 컴포넌트에서는 반응성이 없으므로(스테이트가 없으므로) 그대로 사용할 수는 없고 이것은 의도된 사항이다.

여기서는 서버 컴포넌트에서 데이터를 공유하는 얼터너티브한 메소드를 소개하기로 한다. 그 전에, 클라이언트 컴포넌트에서 컨텍스트를 사용하는 방법을 먼저 소개한다.

Using context in Client Components

'use client'
 
import { createContext, useContext, useState } from 'react'
 
const SidebarContext = createContext()
 
export function Sidebar() {
  const [isOpen, setIsOpen] = useState()
 
  return (
    <SidebarContext.Provider value={{ isOpen }}>
      <SidebarNav />
    </SidebarContext.Provider>
  )
}
 
function SidebarNav() {
  let { isOpen } = useContext(SidebarContext)
 
  return (
    <div>
      <p>Home</p>
 
      {isOpen && <Subnav />}
    </div>
  )
}

위 코드에서 생각할 수 있는 문제는, 일반적으로 컨텍스트 프로바이더는 앱의 루트, 맨 상위에 위치한다는 것이다. 하지만 NextJS에서는 서버 컴포넌트가 따로있고 여기서는 컨텍스트가 필요하지 않기 때문에, 이전 버전에서처럼 컨텍스트를 선언하는 것은 에러를 일으킨다.

// app/layout.tsx
// 해당 Root wrapper는 서버 컴포넌트이므로 에러가 난다.
import { createContext } from 'react'
 
//  createContext is not supported in Server Components
export const ThemeContext = createContext({})
 
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
      </body>
    </html>
  )
}

위 코드에서 theme-provider를 클라이언트 컴포넌트로 분리한다.

app/theme-provider.tsx


'use client'
 
import { createContext } from 'react'
 
export const ThemeContext = createContext({})
 
export default function ThemeProvider({ children }) {
  return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
}

상술한 대로 상위 서버 컴포넌트에서 클라이언트 컴포넌트 자체를 children으로 넘길 수 있기 때문에 코드는 아래처럼 된다.

import ThemeProvider from './theme-provider'
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html>
      <body>
        <ThemeProvider>{children}</ThemeProvider> // 프로바이더를 컨슘하는 클라이언트 컴포넌트
      </body>
    </html>
  )
}

이렇게 프로바이더가 루트 레벨에 위치함으로써 다른 모든 클라이언트 컴포넌트가 위 컨텍스트를 바라볼 수 있게 된다.

Good to know:

프로바이더는 JSX 트리의 가장 딥한 부분에 위치하는 것이 좋다.
위 코드에서 ThemeProvider가 html맨 하위의 children만을 래핑하는데, 서버 컴포넌트의 최적화에 도움이 되기 때문이다.

Rendering third-party context providers in Server Components

서드파티 패키지에서 제공하는 컨텍스트 역시 마찬가지로, 루트에 최대한 가까이 프로바이더를 위치시킨다.
만약, 해당 서드 파티가 use client 구문이 존재한다면 서버 컴포넌트에서 사용하는 것이 가능하겠지만, 현재 대부분의 라이브러리에서 그렇지 않기 때문에 다른 방법을 사용해야 한다.

아래의 코드는 사용하는 라이브러리에서 use client가 없으므로 에러가 난다.

import { ThemeProvider } from 'acme-theme'
 
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {/*  Error: `createContext` can't be used in Server Components */}
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  )
}

상술한 것처럼 따로 프로바이더를 클라이언트 컴포넌트로 선언한다.

'use client'
 
import { ThemeProvider } from 'acme-theme'
import { AuthProvider } from 'acme-auth'
 
export function Providers({ children }) {
  return (
    <ThemeProvider>
      <AuthProvider>{children}</AuthProvider>
    </ThemeProvider>
  )
}

이러한 식으로 프로바이더를 루트에서 사용한다.

import { Providers } from './providers'
 
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

위의 코드와 같은 방식대로, 해당 프로바이더 하위의 컴포넌트에서 컨텍스트를 사용할 수 있게 되었다.

만약 서드파티 메인테이너가 use client를 코드에 넣어줄 경우 위 래퍼는 사용하지 않아도 문제가 없을 것이다.

Sharing data between Server Components

서버 컴포넌트는 interactive하지 않다. 그러므로 state의 필요가 없고 context도 필요가 없다. 대신 서버 컴포넌트에서 어떤 데이터를 참조해야 할 경우 자바스크립트 패턴을 사용할 수 있다.
아래에는 여러 서버 컴포넌트에 디비의 데이터를 참조하고 싶을 경우 사용한다.

//utils/database.ts

export const db = new DatabaseConnection()
app/users/layout.tsx
// app/users/layout.tsx

import { db } from '@utils/database'
 
export async function UsersLayout() {
  let users = await db.query()
  // ...
}
// app/users/[id]/page.tsx

import { db } from '@utils/database'
 
export async function DashboardPage() {
  let user = await db.query()
  // ...
}

위 예시처럼 모듈화 된 코드를 서버 컴포넌트에서 사용하여 활용할 수 있다.
이러한 패턴을 글로벌 싱글턴이라고 부른다.

Sharing fetch requests between Server Components

데이터를 페칭할 때 해당 데이터를 다른 페이지나 레이아웃에 공유하고 싶을 때가 있다. 이 경우 프롭으로 해결하려 한다면 번잡함이 생길 수 있다.
p.s.) 간단하게 생각해서, 게시물의 카드 리스트 -> 카드 컴포넌트가 있을 때, 해당 게시물 컴포넌트에서 좋아하기를 누르면 상위 리스트는 리페칭을 통해 업데이트 된 좋아요 정보를 표시해 줘야 한다.

대신에 같은 페치 리퀘스트를 쓰는 것을 추천한다. Nextjs의 서버 컴포넌트에서 해당 리퀘스트에서 중복을 제거하고, 캐싱을 통해 같은 데이터를 바라볼 수 있게 해준다.

profile
Can an old dog learn new tricks?

0개의 댓글