next.js의 라우팅과 서버 액션

next.js의 라우팅과 서버 액션

파일 시스템 라우팅

next.js의 라우팅은 파일 시스템 기반입니다. app 라우터 기준으로,

  • /app/page.tsx => https://your.site/

  • /app/example/page.tsx => https://your.site/example

이 되는 식이죠. your.site는 사이트의 도메인을 나타내는 예시 값입니다. 각 page.tsxdefault 내보내기로 JSX 컴포넌트를 내보내야 하고요.

layout.tsx, page.tsx

page.tsx는 한 페이지를 나타냅니다. default 내보내기로 JSX 컴포넌트를 내보내야 합니다.

export default function Page() {
  return <h1>Hello, Home page!</h1>
}

layout.tsx는 특정 폴더 내의 모든 페이지에서 공유할 UI를 넣는 파일입니다. page.tsx와 마찬가지로 default 내보내기로 JSX 컴포넌트를 내보내지만, { children } 를 인자로 받습니다.

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <section>
      {/* 폴더 안의 모든 페이지에서 공통적으로 사용되는 UI */}
      <nav></nav>

      {children}
    </section>
  )
}

참고로 루트 레이아웃은 반드시 필요합니다. 또한, 루트 레이아웃 같은 경우 HTML 계층도의 최상단에 위치하기 때문에, <html></html> 이 필요합니다.

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  )
}

layout.tsx의 중첩

layout.tsx 파일은 렌더링될 page.tsx까지 디렉토리를 타고 내려가면서 순서대로 중첩됩니다. 상위 폴더에 있는 레이아웃 파일이 HTML 계층도에서 더 상위에 있습니다. 예시를 하나 들어보겠습니다.

❯ tree app
app
├── docs
│   ├── (with-search)
│   │   ├── building-your-application
│   │   │   ├── layout.tsx
│   │   │   └── routing
│   │   │       └── page.tsx
│   │   ├── layout.tsx
│   │   ├── nav.tsx
│   │   └── test
│   │       └── page.tsx
│   └── 1
│       └── page.tsx
├── favicon.ico
├── globals.css
├── layout.tsx
├── page.tsx
└── products
    └── [productId]
        └── page.tsx

위는 현재 폴더 구조입니다. (with-search)는 아래에서 설명할 예정인 라우트 그룹인데, 여기서는 그냥 폴더라고 생각하시면 됩니다.

  • /docs/building-your-application/routing :

    • /layout.tsx, /docs/(with-search)/layout.tsx, /docs/(with-search)/building-your-application/layout.tsx 순서대로 중첩
  • /docs/test :

    • /layout.tsx, /docs/(with-search)/layout.tsx 순서대로 중첩
  • /docs/1 :

    • /layout.tsx 만 사용됨

이런 식입니다.

동적 라우팅

그런데 url에 동적인 값이 들어가는 경우도 있죠? 그런 경우엔 파일 이름에 특수문자를 사용합니다.

  • [productId]

  • [...slug]

  • [[...slug]]

단일 인자 라우팅

이런 식으로요. 위에서부터 살펴보자면, [productId]는 URL에서 두 개의 / 사이에 오는 값을 string으로 page.tsx 에 넘겨주라는 의미입니다.

타입스크립트로 표현하면 다음과 같습니다.

// 파일: /app/products/[productId]/page.tsx

export default function ProductDetail({ params }: { params: { productId: string } }) {
  console.log(params.productId)
  console.log(params)

  return (
    <div>
      <h1>Product Detail: {params.productId}</h1>
    </div>
  );
}

위와 같은 파일이 있을 때, /products/foo로 접속하면 params{ productId: "foo" } 가 됩니다. 주의하셔야할 점이 하나 있는데, 그건 바로 /products/123처럼 변수 위치에 숫자만 있는 경우에도 params.productId의 타입은 문자열이라는 점입니다. 참고로 이 문법은 productId/가 들어가는 걸 허용하지 않기 때문에, /products/foo/images 로 접속하면 /app/products/[productId]/images/pages.tsx가 렌더링됩니다.

가변 인자 라우팅 (rest 문법)

[...slug] 같은 경우 /가 몇개가 있는지와 상관 없이 해당 위치에서부터 마지막까지의 pathname을 먹습니다. 위에서 /products/[productId]/image로 접속해도 /app/products/[productId]/page.tsx가 렌더링되지는 않는다는 얘기를 했는데요, /app/products/[...productId]/page.tsx 가 존재하면 이 파일이 렌더링됩니다. 이때의 params.productIdstring[] 타입입니다.

타입스크립트로 표현하면 다음과 같습니다.

// 파일: /app/products/[...productId]/page.tsx

export default function ProductDetail({ params }: { params: { productId: string[] } }) {
  console.log(params)

  return (
    <div>
      <h1>Product Detail</h1>
      <p>{params.productId.join(' !!!!! ')}</p>
    </div>
  );
}

/products/foo/images 로 접속했을 때, params.productId의 값은 ["foo", "images"]입니다. 이런 걸 어디에 쓰나 싶으실 수도 있는데, 웹에서 사용할 수 있는 클라우드 스토리지 서비스처럼 폴더 개념이 있는 경우 매우 유용합니다. 단, 이 문법은 빈 문자열에는 매칭되지 않습니다. 쉽게 말해서, /foo/bar/[...slug]/page.tsx/foo/bar로 접속했을 때 렌더링되지 않습니다.

옵셔널 가변 인자 라우팅

[[...slug]] 같은 경우, 빈 문자열에도 매칭되는 Rest 문법입니다. /foo/bar/[[...slug]]/page.tsx 가 있다면, /foo/bar로 접속했을 때 해당 파일이 렌더링됩니다.

참고로 값을 []로 감싸서 해당 값이 옵셔널임을 나타내는 건 유닉스 계열의 CLI 도구에서 흔히 볼 수 있는 일종의 약속입니다.

라우트 그룹

라우트 그룹은, 말 그대로 일부 라우트를 그룹으로 묶은 겁니다. 근데 라우터가 파일 시스템 기반이니까 특정 폴더에 넣은 꼴이 되는 것이죠. 그런데 폴더 이름이 그대로 URL 경로가 되는 게 next.js의 라우팅 시스템이므로, 특정 폴더는 URL경로에서 제외하기 위한 문법이 필요하고, 그 문법은 (dir-name)입니다.

페이지들을 용도에 따라 분류하기 위해서 사용할 수도 있고, layout.tsx를 특정 페이지들에만 적용시키기 위해서 사용할 수도 있습니다. 저 같은 경우는 /app/(auth)/layout.tsx에 인증이 안 되어있으면 로그인 페이지로 보내는 로직을 넣어놓고, 인증을 해야만 접근할 수 있는 페이지는 /app/(auth)/settings/profile/page.tsx 같은 (auth) 폴더의 하위 경로에 만듭니다. 이렇게 하면 인증 여부 확인하는 로직을 단 한번만 작성한 뒤 인증이 필요한 모든 페이지에서 재사용할 수 있습니다.

Parallel 라우트

next.js는 한 페이지를 여러 파일로 쪼갠 뒤, 각각에 대해 page.tsx, layout.tsx 나, 이 글에서 다루지 않는 loading.tsx, error.tsx등을 사용하는 걸 지원합니다.

foo라는 폴더에 이름이 @left, @right 인 폴더들이 있다면, 해당 폴더들의 page.tsx를 렌더링한 게 foo/layout.tsx 에 인자로 넘어옵니다. 이를 타입스크립트로 표현하면 다음과 같습니다.

export default function Layout(props: {
  children: React.ReactNode
  left: React.ReactNode
  right: React.ReactNode
}) {
  return (
    <div>
      <div>
          {props.left}
      </div>
      <main>
          {props.children}
      </main>
      <div>
          {props.right}
      </div>
    </div>
  )
}

예시 이름이 @left, @right이었기 때문에 각각 left, right가 된 것입니다.

Intercepting 라우트

Intercepting 라우트는 말 그대로 특정 URL로 이동할 때 이를 가로채는 라우팅인데요, 이를 이용하면 URL을 바꾸면서도 기존 페이지의 상태는 유지하는 게 가능합니다. 그런데 URL 매칭 룰이 꽤 복잡합니다.

공식 예시인 nextgram 을 사용해서 설명할 건데, 폴더 구조는 다음과 같습니다.

❯ tree
.
├── @modal
│   ├── (.)photos
│   │   └── [id]
│   │       └── page.tsx
│   └── default.tsx
├── default.tsx
├── global.css
├── layout.tsx
├── opengraph-image.png
├── page.tsx
└── photos
    └── [id]
        └── page.tsx

아래 화면은 nextgram을 실행한 화면입니다.

여기서 이미지 하나를 클릭하면 화면이 아래처럼 되는데요, 주소창을 보시면 URL이 바뀌어있습니다.

URL이 바뀌기 때문에 즐겨찾기나 공유 기능을 자연스럽게 사용할 수 있습니다.

새로고침을 하거나, 즐겨찾기로 들어오거나, 공유된 링크를 이용해서 접속하면 위와 같은 화면이 나옵니다.

컴포넌트 렌더링 (서버/클라이언트 컴포넌트)

RSC: React Server Component

React에는 React Server Component라는 개념이 있습니다. 이름은 서버에서 렌더링하는 컴포넌트라는 뜻입니다. 일반적으로 서버쪽 코드에서 데이터 가져오는 로직은 훨씬 간단하기에 코드가 간단해지고, RSC는 서버에서 렌더링되는만큼 검색 엔진 최적화 (SEO)에도 도움이 됩니다.

export default async function Page() {
  await wait(10000)

  return <div>My doc</div>;
}

async function wait(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

서버 컴포넌트는 async/await을 지원합니다. 위의 코드로 페이지를 만들면, 10초를 기다린 뒤 My Doc이 렌더링되고 페이지가 브라우저에 렌더링됩니다.

하지만 서버 컴포넌트는 클라이언트에게 클로져 같은 걸 넘길 수 없고, useEffect, useState 같은 훅들을 사용할 수도 없습니다. 서버 컴포넌트와 클라이언트 컴포넌트를 적절히 섞어서 활용해야하는 이유죠.

클라이언트 컴포넌트

next.js에서 모든 컴포넌트는 기본값으로 서버 컴포넌트이기 때문에, 클라이언트 컴포넌트는 클라이언트 컴포넌트라고 표시를 해줘야합니다. 이는 파일 첫번째 줄에 'use client'; 를 추가함으로써 표시합니다.

'use client'

export default function ProductDetail({ params }: { params: { productId: string } }) {  

  return (
    <div>
      <h1>Product Detail</h1>
    </div>
  );
}

위의 예시는 페이지 전체를 클라이언트 컴포넌트로 선언하는 예시지만, 페이지의 일부만을 클라이언트 컴포넌트로 만들 수도 있습니다. 단, 서버 컴포넌트와 클라이언트 컴포넌트를 같은 파일에 넣을 수는 없습니다.

클라이언트 컴포넌트에서는 async/await을 사용할 수 없지만 useStateuseEffect 같은 훅을 사용할 수 있습니다.

Hydration

클라이언트에서 자바스크립트로 DOM 요소들에 이벤트 핸들러 함수등을 연결하는 걸 Hydration이라고 합니다. 클라이언트 컴포넌트들에 대해서 일어납니다.

서버 액션

개발을 하다보면 데이터베이스 관련 작업들처럼 서버에서 해야하는 작업들이 있는데요, 원래는 이걸 하려면 form으로 다른 페이지로 이동시키거나, REST 엔드포인트를 만들어서 fetch 등으로 해당 엔드포인트를 호출했어야합니다. 그 엔드포인트에서는 요청 body 비직렬화해서 작업을 수행한 뒤 처리 결과를 직렬화해서 클라이언트한테 반환해줘야했고요. 그런데 서버 액션을 쓰면 이런 작업들을 할 필요가 없어집니다.

export default function Page() {
  async function create(formData: FormData) {
    'use server'

    // DB 쿼리
  }

  return <form action={create}>...</form>
}

사용자가 작성한 create 함수의 코드는 서버에서 실행됩니다. 클라이언트 사이드에도 create함수가 존재하긴 하지만, 하는 일은 전혀 다릅니다. 클라이언트 사이드에 존재하는 create 함수는 서버사이드의 create 함수를 JSON 직렬화/비직렬화를 통해 호출하고 반환 값을 직렬화해서 반환하는 함수입니다.

위 예시처럼 특정 기능에 관련된 로직을 한 파일에 모으기 위해 서버 액션을 사용할 수도 있고, 'use server';을 사용하는 서버 액션들을 한 파일에 모을 수도 있습니다.

타입 추론을 포함한 타입스크립트 타이핑이 완벽하게 지원되기 때문에 잘 사용하면 매우 편리합니다.