컴포넌트·데이터

서버에서 읽고, 브라우저에서는 눌러야 하는 부분만 분리합니다.

어디에 둘지

필요 두는 곳
DB·API 조회, 비밀 키, HTML만으로 충분한 목록 서버 컴포넌트. 지시어 없음
onClick, useState, useEffect, window 클라이언트 컴포넌트. 파일 첫 줄 'use client'

'use client'는 “이 컴포넌트는 브라우저에서만 실행된다”가 아닙니다. 서버도 첫 HTML을 만들 때 그 컴포넌트를 그립니다. 지시어는 서버 모듈 그래프와 클라이언트 모듈 그래프의 경계입니다. 그 파일이 가져온 모듈까지 브라우저 묶음에 들어갑니다. 그래서 버튼 파일에만 붙이고, 레이아웃 전체에 붙이지 않습니다.

클라이언트 컴포넌트

'use client'

import { useState } from 'react'

export default function Counter() {
  const [count, setCount] = useState(0)

  return (
    <p>
      {count}
      <button type="button" onClick={() => setCount(count + 1)}>
        증가
      </button>
    </p>
  )
}

서버 컴포넌트인 페이지에서 이 파일을 가져오면 됩니다. 페이지 자체에는 'use client'를 적지 않습니다.

서버에서 읽은 값을 넘기기

서버 컴포넌트는 async일 수 있습니다. 조회 결과를 props로 넘깁니다. props는 React가 직렬화할 수 있는 값이어야 합니다. 함수, 클래스 인스턴스, Date처럼 그대로 못 넘는 값은 넘기지 않습니다.

import LikeButton from './like-button'

export default async function Page({ params }) {
  const { id } = await params
  const post = await getPost(id)

  return (
    <>
      <h1>{post.title}</h1>
      <LikeButton likes={post.likes} />
    </>
  )
}
'use client'

export default function LikeButton({ likes }) {
  return <p>좋아요 {likes}</p>
}

서버 화면을 클라이언트 안에 끼우기

클라이언트 파일이 서버 컴포넌트를 import하면 그 서버 컴포넌트도 브라우저 묶음으로 끌려갑니다. 서버에서 그린 결과를 넣고 싶으면 children으로 받습니다. children은 부모(서버)가 미리 그린 결과입니다.

'use client'

export default function Modal({ children }) {
  return <div role="dialog">{children}</div>
}
import Modal from './modal'
import Cart from './cart'

export default function Page() {
  return (
    <Modal>
      <Cart />
    </Modal>
  )
}

Cart가 서버 컴포넌트여도, Page가 서버에서 Modal의 자식으로 넘기면 Cart는 서버에서 실행됩니다.

서버에서 목록 가져오기

같은 트리 안의 같은 fetch는 React가 한 번으로 줄입니다. 컴포넌트마다 필요한 곳에서 호출해도 됩니다. 응답은 기본으로 캐시되지 않습니다.

export default async function Page() {
  const data = await fetch('https://api.vercel.app/blog')
  const posts = await data.json()

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

ORM이나 DB 클라이언트도 서버 컴포넌트에서 바로 부릅니다. 쿼리 문자열은 브라우저 묶음에 들어가지 않습니다. 그래도 “이 사용자가 이 글을 볼 수 있는가”는 따로 검사합니다. 서버에서 돈다는 사실만으로 권한 검사가 끝나지 않습니다.

느린 구간만 나중에

헤더를 먼저 보여주고 목록만 기다리려면 Suspense로 목록을 감쌉니다.

import { Suspense } from 'react'
import BlogList from '@/components/BlogList'

export default function Page() {
  return (
    <>
      <h1>블로그</h1>
      <Suspense fallback={<p>목록을 불러오는 중</p>}>
        <BlogList />
      </Suspense>
    </>
  )
}

BlogList가 async 서버 컴포넌트이면, 헤더는 먼저 가고 목록은 준비되는 대로 이어 붙습니다. @/는 create-next-app이 기본으로 넣는 경로 별칭입니다. 프로젝트 루트를 가리킵니다.

폼으로 저장하기

서버에서 실행되는 함수에 'use server'를 붙입니다. 파일 맨 위에 두면 그 파일의 export가 모두 서버 함수입니다. 폼의 action에 넘기면 제출 시 서버에서 실행됩니다.

'use server'

export async function createPost(formData) {
  const title = formData.get('title')
  // 여기서 DB에 저장한다. 비밀 키도 서버에만 있다.
}
import { createPost } from './actions'

export default function Page() {
  return (
    <form action={createPost}>
      <label htmlFor="title">제목</label>
      <input id="title" name="title" />
      <button type="submit">저장</button>
    </form>
  )
}

입력과 라벨은 id와 htmlFor로 연결합니다. placeholder만으로 이름표를 대신하지 않습니다.

환경 변수

  • .env.local에 두고 Git에는 올리지 않습니다.
  • process.env.API_KEY는 서버에서만 값이 있습니다. 클라이언트 묶음에서는 빈 문자열로 바뀝니다.
  • 브라우저에 줘야 하는 값만 NEXT_PUBLIC_을 붙입니다. 예: NEXT_PUBLIC_SITE_URL. 이 접두어가 붙은 값은 소스에 남으므로 비밀이 아닙니다.

서버 전용 파일을 클라이언트가 가져가면 빌드가 실패하게 만들 수 있습니다. 파일 첫 줄에 import 'server-only'를 둡니다. 패키지는 npm install server-only로 추가합니다.