[#1 - Parallel Route]

Parallel Route(병렬 라우트)

- Parallel Route란 병렬 라우트라는 의미로 한 개의 페이지 내에서 여러 페이지 컴포넌트들을 병렬로 구조화 할 수 있게 해주는 NextJS의 고급 라우팅 패턴 중 하나이다. NextJS는 라우팅 구조를 폴더 구조를 통해서 자동적으로 지원해주기 때문에 서로 다른 페이지 컴포넌트가 하나의 페이지에 공존할 수 없다는 특징을 갖고 있다. 따라서 그 한계점을 해결해주기 위한 라우팅 패턴이라고 볼 수 있다.

 

Slot(슬롯)

- 병렬로 렌더링 될 페이지 컴포넌트를 보관하는 폴더

- @{folderName} 형태로 지정한다.

- Route Group과 동일하게 URL 경로에는 아무런 영향을 미치지 않는 폴더이다.

 

Parallel Route 폴더 구조

- Slot 폴더로 구성된 Parallel Route의 기본 폴더 구조는 다음과 같이 구성된다.

parallel route

예시를 위해 최상위 경로를 임의로 parallel로 설정했다. 하위 폴더에 Slot 폴더 형태로 sidebar 폴더를 생성해주고 각 폴더에는 각 페이지 UI에 해당하는 page 파일이 존재하고 최상위 폴더인 parallel 폴더에는 하위 페이지들을 props로 받아 렌더링을 해주기 위해 layout 파일까지 함께 존재하는 것을 볼 수 있다.

각 페이지 파일들은 <div>Page</div> 태그만 렌더링 해주도록 기본으로 설정을 해두었기에 생략하고 layout 파일에서 Slot 폴더 하위의 페이지를 어떻게 props로 받아와 렌더링하는지 알아보기 위해 layout 파일의 로직만 살펴보도록 하자.

// parallel/layout.tsx
import { ReactNode } from "react";

export default function Layout({children,sidebar}:{children:ReactNode,sidebar:ReactNode}){
  return (
    <div>
      {sidebar}
      {children}
    </div>
  )
}

생각보다 굉장히 간단한 형태로 구성된다. 위 코드 내에서 children props는 최상위 경로에 해당하는 page 파일을 렌더링 해주고 Parallel Route가 적용되어 병렬로 페이지를 구조화하기 위해 Slot 폴더명을 그대로 가져와 props로 전달해주었다. 위와 같이 작성해주면 자동으로 병렬 라우트가 적용되어 최상위 페이지인 parallel 페이지에 parallel page의 내용과 sidebar page의 내용이 함께 들어있는 것을 확인할 수 있다.

 

페이지 간 이동과 Default Page

위의 기본적인 Parallel Route 구조에서 하나의 페이지를 더 추가한 후 추가한 페이지의 하위 경로가 존재한다는 설정을 하여 라우팅 구조를 재구성 해보았다. 폴더 구조는 다음과 같이 변하였다.

간단히 설명을 하자면 @feed라는 Slot 폴더가 추가되며 최상위 페이지에 병렬로 렌더링 되는 페이지가 하나 추가 되었으며 @feed 폴더 하위에는 URL 경로로 인식이되는 settings 폴더가 추가되어 새로운 라우팅 경로가 추가되었다. 이 때 layout 파일에는 Link 태그를 통해 parallel/settings 경로로 이동할 수 있도록 코드를 추가해주었다. 코드는 다음과 같다.

// parallel/layout.tsx
import Link from "next/link";
import { ReactNode } from "react";

export default function Layout({
  children,
  sidebar,
  feed
}: {
  children: ReactNode;
  sidebar: ReactNode;
  feed: ReactNode;
}) {
  return (
    <div>
      <div>
        <Link href={"/parallel"}>Parallel Page</Link>
        &nbsp;
        <Link href={"/parallel/settings"}>Setting Page</Link>
      </div>
      <br />
      {sidebar}
      {feed}
      {children}
    </div>
  );
}

위의 코드에 추가된 Link 태그를 통해 각 경로로 이동하면 렌더링되는 하위 페이지들의 모습은 다음과 같다.

/parallel
/parallel/settings

최상위 파일인 parallel page와 Slot 폴더로 추가된 페이지인 sidebar page는 하위에 settings 라는 경로가 존재하지도 않는데 /parallel/settings 경로로 이동을 했음에도 불구하고 기존의 페이지가 그대로 렌더링이 되는 것을 볼 수 있다.

여기서 알 수 있는 점은 Parallel Route가 적용된 페이지 내에서 특정 페이지가 자기 자신만 갖고 있는 하위 경로의 페이지로 이동을 하였을 때 다른 페이지들은 하위 페이지를 갖고 있지 않다면 하위 경로로 이동이 가능한 페이지들은 이동을 하도록 동작하고 이동이 불가능한 페이지들은 오류를 발생시키는 것이 아닌 이전의 페이지를 유지하도록 동작하게 된다. 따라서 sidebar page와 parallel page는 하위 경로로 이동할 곳이 없기 때문에 기존 페이지를 그대로 렌더링 해주는 것이다.

 

여기서 주의해야할 점이 존재한다. 초기에 /parallel 경로로 접속을 한 후 하위 경로인 /parallel/settings 경로로 이동을 하는 것은 SPA 형태로 인해 없는 페이지에 접속이 들어가도 기존의 페이지를 렌더링 해주는 것이 가능했던 것이다. 하지만 만약 초기 접속부터 하위 경로인 /parallel/settings로 바로 들어가게 된다면 어떨까? 이 경우에는 존재하지 않는 페이지로 다이렉트로 접속 요청을 보냈기 때문에 404 에러를 발생시키게 된다. 그렇기에 다이렉트로 하위 경로로 접속 요청이 발생하는 경우를 대비하여 별도의 처리를 해주어야 한다. 이럴 경우에는 각 페이지에 없는 페이지에 접속 요청이 발생했을 때 렌더링 해 줄 페이지인 default 페이지를 생성해주면 된다. 폴더와 파일 구조는 다음과 같이 구성된다.

폴더와 파일 구조를 위와 같이 설정해두면 초기 접속 요청이 바로 하위 페이지 경로로 들어가더라도 default 페이지를 렌더링 해주어 존재하지 않는 페이지를 대체해줄 수 있게 된다.

 

[#2 - Intercepting Route]

Intercepting Route

인터셉팅 라우트란 단어 뜻 그대로 가로채다라는 의미를 가지는 라우팅 방식을 의미한다. NextJS가 설정해둔 임의의 조건에 충족하는 경우 특정 페이지에 접속하는 요청을 가로채서 렌더링 해주는 방식이다. 이 때 임의의 조건은 "해당 페이지로의 초기 접속 요청이 발생하였는가" 이다. 정리하자면 특정 페이지에 접속을 할 때 초기 접속을 통해 들어왔는지 아니면 다른 페이지로부터 CSR 방식을 통해 Link 컴포넌트나 Router 객체의 push 메서드 등을 통해서 들어왔는지 여부를 확인하여 다른 경로로부터 들어오게 되었다면 인터셉팅 라우트가 동작하게 되는 것이다. 정의만 따졌을 때는 이해가 잘 되지 않을 것이다.

대표적인 예시로는 인스타그램이 있다. 인스타그램은 유저의 프로필 창에서 특정 게시글을 클릭했을 때 그 게시글 페이지로 이동하지 않고 모달 형태로 띄워서 보여준다. 그에 더해 뒤로 가기 버튼을 누르거나 창을 닫았을 때 기존의 프로필 창으로 되돌아오게 된다. 이렇게 동작하는 것이 특정 게시글 페이지로 이동하지 않고 중간에 라우팅을 가로채어 중간에 보여주는 인터셉팅 라우트 방식이다.

 

인터셉트 라우팅을 동작하게 하기 위해서는 특정 폴더를 생성해주어야 한다. 폴더 명은 가로채고자 하는 page의 경로에 해당하는 라우팅 폴더명의 맨 앞에 (.)을 추가해서 생성해주면 된다. 폴더를 생성하면 구조가 다음과 같아진다.

intercepting route

 여기서 폴더명에 추가 된 (.)은 상대 경로를 의미한다. 온점(.)이 한 개면 같은 경로에 위치한 파일, 두 개면 상위 경로에 위치한 파일로 인식이 된다. 추가적으로 최상위 폴더인 app 폴더를 가리키고 싶다면 (...)으로 온점을 세 개 찍어주면 된다.

 

위와 같이 구성한 뒤 인터셉팅 라우트 폴더 내부에도 동일한 UI를 가진 page 파일을 생성해두면 인터셉팅 라우트가 동작하게 된다. 메인 페이지에서 "/book/1" 페이지로 접속 요청했을 때 "/book/1" 페이지로의 최초 접속이 아닌 다른 페이지로부터 CSR 방식을 통해서 들어온 것이기 때문에 인터셉팅 라우트가 동작하여 (.)book/[id] 폴더에 있는 페이지가 렌더링 되게 된다. 간단한 코드 예시를 통해 인터셉팅 라우트의 동작 방식을 확인해보자.

// main.tsx
import Link from 'next/link';

export defult function Home(){
  return (
    <Link href={"/A"}>Page A</Link>
  )
}

// A.tsx
export default function Page(){
  return (
    <div>Detail Page</div>
  )
}

//(.)A.tsx
export default function Page(){
  return (
    <div>Detail Page Intercepting</div>
  )
}

위와 같이 페이지가 구성되어 있다고 가정할 때 메인 페이지에서 A 페이지로 접속을 하기 위해서는 Link 컴포넌트를 통해서 접속되기 때문에 CSR 방식으로 동작한다. 따라서 이 때 인터셉팅 라우트가 동작하여 A 페이지로 접속하였을 때 페이지 내부에는 "Detail Page Intercepting"이라는 문구가 보이게 될 것이다. 이게 인터셉팅 라우트의 동작 방식이다.

하지만 만약 이 사이트에 최초 접속을 하게 된 경로가 메인 페이지가 아닌 A 페이지라면 인터셉팅 라우트가 발동되지 않아서 "Detail Page"라는 문구가 보이는 A 페이지가 렌더링 되게 된다.

 

인터셉팅 라우트를 가장 쉽게 확인 가능한 실제 모델이 인스타그램이라고 앞서 말했다. 우리도 우리의 프로젝트 내부에서 인스타그램처럼 인터셉팅 라우트가 동작하며 해당 페이지가 모달 형태로 띄워지도록 만들어 보자.

 

먼저 모달로 띄워줄 새로운 컴포넌트를 하나 생성해준다. 이 때 모달 컴포넌트는 클라이언트 측에서 동작하는 클라이언트 컴포넌트이므로 "use client" 지시자를 붙여주어야 한다.

 

이어서 컴포넌트 내에서 react-dom이 제공하는 createPortal이라는 메서드를 사용할 것이다. 이 메서드는 인터셉팅 라우트가 동작하여 가로챈 페이지를 모달로 띄워주는 것을 도와주는 메서드이다.

이 메서드는 2-3개의 인수를 받는다. 첫번째 인수으로는 children을 props로 받아 렌더링 해주는 <dialog> 태그를 받는다. 두번째 인수로는 이 모달이 렌더링 될 위치인 DOM 요소를 넣어주어야 한다.

그러면 createPortal 메서드를 통해서 브라우저에 존재하는 특정 DOM 요소 아래에 dialog 태그가 렌더링 되게 되는 것이다. createPortal 메서드를 사용한 모달창 컴포넌트의 코드 구조는 다음과 같다.

//Modal.tsx
"use client";

import { ReactNode } from "react";
import style from "./modal.module.css";
import { createPortal } from "react-dom";

export default function Modal({ children }: { children: ReactNode }) {
  return createPortal(
    <dialog>{children}</dialog>,
    document.getElementById("modal-root") as HTMLElement
  );
}

이 모달 컴포넌트는 props로 children을 받아서 단순히 모달 형식으로 렌더링을 해주는 역할을 하게 되는 것이다. 그렇기 때문에 최상위 layout 파일 내부의 최하단에 modal-root라는 아이디를 가진 div 태그를 하나 생성해주고 인터셉팅 라우트 폴더 하위의 page 파일에 렌더링할 컴포넌트를 Modal 컴포넌트로 감싸주면 인터셉팅 라우트가 동작할 때 해당 페이지가 상위 페이지의 자식 컴포넌트로 렌더링 되는 것이 아닌 별도의 모달로 띄워지게 된다.

 

이 때 해주어야 하는 중요한 설정이 있다. 우리가 현재 모달을 띄워주기 위해 사용중인 dialog 태그는 모달만을 위해서 존재하는 태그이기 때문에 기본값으로는 화면에 보이지 않도록 설정이 되어 있다. 하지만 우리는 인터셉팅 라우트가 동작하자마자 즉, 모달 페이지가 마운트 되자마자 화면에 보여져야 하기 때문에 설정을 해주어야 한다. 위의 코드에 설정값을 넣어서 코드를 확장해주도록 하겠다. 코드는 다음과 같다.

//Modal.tsx
"use client";

import { ReactNode, useRef, useEffect } from "react";
import style from "./modal.module.css";
import { createPortal } from "react-dom";

export default function Modal({ children }: { children: ReactNode }) {
  const dialogRef = useRef<HTMLDialogElement>(null);
  useEffect(() => {
    if(!dialogRef.current?.open){
      dialogRef.current?.showModal();
      dialogRef.current?.scrollTo({
        top:0
      })
    }
  },[]);
  return createPortal(
    <dialog ref={dialogRef}>{children}</dialog>,
    document.getElementById("modal-root") as HTMLElement
  );
}

위와 같이 설정해주면 모달 컴포넌트가 마운트 되었을 때 바로 화면에 띄워지게 되고 스크롤이 최상단으로 이동하게 된다.

 

마지막 작업으로는 우리가 일반적으로 모달을 다룰 때 Esc 키를 누르거나 모달 창 외부를 클릭하여 기존의 창으로 되돌아가곤 하는데 그 동작을 위한 로직을 만들어주면 완성이다. 이 때 사용하는 객체는 "next/navigation"이 제공하는 useRouter 객체이다. 주의해야할 점이 "next/navigation"이 아닌 "next/router"으로부터 import 해오면 오류가 발생하니 주의해야 한다.

이어서 dialog 태그 안에 onClick과 onClose 메서드를 추가해주어서 각각 모달 외부 클릭과 Esc 키를 눌렀을 경우 뒤로가기 동작을 구현해주면 되겠다. 완성된 코드 로직은 다음과 같다.

"use client";

import { ReactNode, useEffect, useRef } from "react";
import style from "./modal.module.css";
import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";

export default function Modal({ children }: { children: ReactNode }) {
  const router = useRouter();
  const dialogRef = useRef<HTMLDialogElement>(null);
  useEffect(() => {
    if (!dialogRef.current?.open) {
      dialogRef.current?.showModal();
      dialogRef.current?.scrollTo({
        top: 0,
      });
    }
  }, []);
  return createPortal(
    <dialog
      ref={dialogRef}
      onClose={() => router.back()}
      onClick={(e) => {
        if ((e.target as any).nodeName === "DIALOG") {
          router.back();
        }
      }}
      className={style.modal}
    >
      {children}
    </dialog>,
    document.getElementById("modal-root") as HTMLElement
  );
}

onClose 메서드는 Esc 키가 클릭되었을 때를 감지하여 뒤로 가기 동작을 실행해주고 onClick 메서드는 클릭 이벤트가 발생한 요소의 Node 이름이 dialog일 경우에는 모달 외부를 클릭한 것이 되기 때문에 뒤로 가기 동작을 실행해주도록 하면된다. 이 때 e.target의 타입을 any 타입으로 단언을 해주었는데 이것은 아직 NextJS가 nodeName이라는 속성을 받아들이기에 온전하지 않은 상태기 때문이라고 한다.

 

이렇게 인스타그램에서 많이 볼 수 있는 인터셉팅 라우트를 모달 화면을 통해 구현하는 법을 알아보았다.

 

[#3 - Parallel Route + Intercepting Route]

앞서 우리는 병렬 라우트와 인터셉팅 라우트의 개념에 대해서 알아보고 어떤 파일 구조와 어떤 메서드를 활용해서 구현하는지까지 간단하게 알아보았다. 앞의 두 가지 고급 라우트 방식을 사용하는 대표적인 예시가 인스타그램이라고 했는데 인스타그램을 자세히 살펴보면 우리가 특정 게시글을 인터셉팅 라우트를 통해 모달 형태로 보고 있는중에는 뒤의 배경화면은 기존의 게시글 목록 화면이 띄워져 있는 것을 볼 수 있다. 잘 생각해보면 우리가 앞서 구현한 인터셉팅 라우트 하나만으로는 인스타그램처럼 구현이 되지 않는 것을 알 수 있다. 인터셉팅 라우트만 사용하여 모달로 창을 띄워주게 되면 특정 게시글 자체는 인터셉팅 되어 모달 형태로 올바르게 띄워지겠지만 뒷 배경은 인터셉팅 라우트를 위해 생성한 폴더의 page 컴포넌트가 렌더링 되기 때문이다. 아래 예시를 보자.

main page

우리가 기대하는 동작 방식은 위의 메인 화면에서 특정 게시글을 클릭했을 때 인터셉팅 라우트가 동작하면서 뒷 배경으로는 메인 화면이 유지되면서 게시글이 모달 형태로 띄워지는 모습이다. 하지만 실제로 동작하는 모습은 다음과 같다.

Intercepting Route

뒷 배경이 메인 화면이 유지되는 것이 아닌 인터셉팅 라우트를 위해 생성해놓은 컴포넌트가 보여진다. 이것을 방지하고 우리가 원하는 방식으로 동작하도록 만들기 위해서는 앞서 보았던 고급 라우트 방식 두 가지를 합쳐서 구현해야 한다.

모달 형태로 특정 게시글이 띄워지도록 구현하는 인터셉팅 라우트를 유지하되 서로 다른 페이지인 메인 페이지와 특정 게시글 페이지 두 개를 한 화면에 병렬로 띄워주어야 하기 때문에 병렬 라우트까지 동시에 구현이 되어야 한다.

 

그러기 위해서는 첫번째로 인터셉팅 라우트 폴더를 병렬 라우트로 처리해주기 위해 상위 폴더에 @modal 이라는 Slot 폴더를 생성해주어야 한다. 이어서 Slot 폴더 내부로 인터셉팅 라우트 폴더인 (.)book/[id]/page.tsx 파일을 옮겨준다.

이후 초기 접속 요청이 다이렉트로 특정 게시글 페이지로 접속하는 경우 404 페이지로 이동하는 것을 방지하기 위해서 Slot 폴더 바로 하위에 default.tsx 파일도 생성을 해준다. 마지막으로 Slot 폴더가 가리키고 있는 상위 레이아웃 파일인 최상위 레이아웃 파일의 최하단에 children props를 전달해준 것 같이 {modal}이라는 Slot 폴더명을 가져와 넣어주면 정상적으로 작동하게 된다. 폴더 구조와 최상위 레이아웃 파일의 형태를 살펴보자.

폴더 구조는 위와 같이 Slot 폴더를 생성해주어 병렬 라우트를 적용할 수 있도록 해주고 하위에는 인터셉팅 라우트가 적용될 수 있도록 (.)로 파일명을 생성해주었다.

루트 레이아웃 파일에 인터셉팅 라우트가 적용된 모달 페이지가 띄워질 수 있도록 div 태그를 추가해주고 별도로 Slot 폴더가 병렬 구조로 라우팅 될 수 있도록 Slot 폴더명으로 삽입해준 것을 볼 수 있다. 위와 같이 적용해준 결과 기대하던 방식으로 올바르게 동작하는 것을 확인할 수 있다. 정상 동작하는 화면은 다음과 같다.

인터셉팅 라우트를 통한 모달 화면도 정상적으로 동작하고 뒷 배경으로 메인 화면도 올바르게 병렬 구조를 통해 렌더링 되는 것을 확인할 수 있다.

개인 프로젝트에서 Parallel RouteIntercepting Route를 통해 페이지들을 병렬 구조화를 한다던지 모달 형태로 인터셉팅 하여 렌더링 해준다던지의 동작을 처리하면 사용자 경험 개선에 좋은 영향을 줄 수 있다.

 

[#1 - Server Action]

Server Action(서버 액션)

서버 액션이란 브라우저 측에서 호출할 수 있는 서버에서 실행되는 비동기 함수를 의미한다. 서버 액션을 활용하면 별도의 API 호출 없이도 간단한 함수 하나만으로 NextJS 서버측에서 실행되는 함수를 직접 호출이 가능해지는 것이다.

서버 액션은 우리가 form 양식을 사용할 때 유용하게 사용할 수 있다. 우리가 간단한 form 양식을 통해 서버측에 데이터를 전달하거나 전달한 데이터를 받아서 사용하기에 굉장히 편리한 방법이다. 세부적으로 살펴보자.

 

form action

우리가 사용하는 form 태그에는 기본적으로 action이라는 속성이 존재한다. 이 속성은 form 태그 내부의 요소들에 입력된 값들을 처리하는 방식을 의미한다. 따라서 데이터를 처리하는 로직, 즉 서버측에 데이터를 전송하는 로직을 만든 후 form 태그의 action 속성에 부여해주면 form 태그 내부의 데이터들은 formData 형식으로 서버측으로 전달된다.

 

"use server"

우리는 이전에 Client Component로 사용하고자 하는 컴포넌트 파일의 최상단에 "use client"라는 지시자를 적어주어야 한다는 점을 공부했다. 마찬가지로 Server Action을 적용하고자 하는 함수 로직에는 "use server"라는 지시자를 붙여주어야 NextJS가 Server Action으로 인식하고 그에 맞게 동작할 수 있게 된다.

 

formData: FormData

우리가 form 양식을 통해 전달한 데이터들은 formData 형식으로 담겨져 서버측에 전달된다고 앞서 살펴보았다. formData 내부에 어떤 형식으로 데이터가 포함되고 해당 데이터들을 어떻게 가져와서 사용하는지에 대해서는 뒤에 살펴볼 예정이다. 참고해야할 점은 TSX를 사용하는 경우 formData의 타입을 지정해주어야 하는데 타입은 간단하게 JS에서 제공해주는 FormData라는 타입을 지정해주면 된다.

 

아래 코드 예시를 살펴보면서 Server Action은 어떻게 구성되어 있으며 사용 방법과 데이터 전달 및 호출 구조를 살펴보도록 하자.

export default function Page(){
  const getData = async (formData: FormData) => {
    "use server"
    const name = formData.get("name");
    const content = formData.get("content");
  }
  return (
    <form action={getData}>
      <input name="name" placeholder="Your Name" />
      <input name="content" placeholder="Content" />
      <button type="submit">Submit</button>
    </form>
  )
}

코드를 살펴보면 Page 컴포넌트 내부에 getData라는 서버 액션을 취하는 함수가 존재한다. 이어서 Page 컴포넌트가 반환하는 UI는 input 태그와 button 태그로 구성되어 있는 form 양식이다. 따라서 form 양식의 input 태그에 값을 입력하고 submit 타입의 버튼을 눌러 양식을 제출하면 getData가 Server Action으로서 실행되며 formData에는 사용자가 input 태그에 입력한 값들이 담겨서 전송되게 된다.

또한 formData.get() 메서드를 통해 formData 내부의 원하는 키워드를 입력하여 데이터 값을 받아오는 동작도 가능하다. 주의해야할 부분은 Server Action은 이름에도 나와있듯이 서버측에서 실행되는 함수이기 때문에 콘솔창에 데이터를 입력해보아도 브라우저에는 출력되지 않는다는 점을 참고해야 한다. 실제로 데이터를 입력해보고 받아오는 과정을 살펴보자.

server action logic
server action formdata

각각의 사진은 Server Action 구현 로직과 Server Action을 통해 받아온 formData의 모습이다. 이와 같이 Server Action을 사용하면 별도의 API를 생성할 필요없이 NextJS 서버측과 간단한 함수 하나만으로 쉽게 데이터 통신이 가능하다는 장점이 있다.

 

추가적으로 타입 관련해서 참고할 점이 있다. formData.get 메서드를 통해 받아온 데이터를 변수에 담아서 타입 추론 결과를 살펴보면 const name: FormDataEntryValue | null 과 같이 FormDataEntryValuenull의 Union Type으로 추론이 되는 것을 볼 수 있다. 이 때 FormDataEntryValue는 string이나 file 타입을 의미한다. 따라서 우리가 받아오는 데이터들의 타입은 일반적으로 string 타입일 것이기 때문에 데이터를 담아놓은 변수에 문자형태로 담아주기 위해 toString 메서드를 사용하거나 String()을 활용하여 문자열로 바꾸어주는 것이 필요하다.

 

정리하자면 Server Action은 보다 간결한 코드로 구현이 가능하다는 장점과 Client Component로 컴포넌트를 생성하여 별도의 API 호출을 할 필요없이 서버측에서만 실행을 하여 보안상으로도 안전하고 편리하게 사용할 수 있다는 장점이 있기에 사용한다.

 

revalidatePath

앞서 살펴 본 Server Action으로 동작하는 로직 내에는 revalidatePath라는 속성을 추가할 수 있다. 이 속성은 서버 액션을 통해 업데이트 된 요소들을 즉각적으로 최신화하여 브라우저를 통해 출력해주는 역할을 한다.

간단한 상황을 예시로 들어보자.

게시글에 댓글을 달 수 있는 기능을 서버 액션을 통해 구현해 두었다고 가정하자. 폼 양식을 통해 댓글을 달고 서버 액션을 통해 formData를 NextJS 서버 측에 전송하였고 그 값을 받아와서 게시글의 하단에 출력되도록 동작해야 한다. 하지만 데이터 베이스에는 올바르게 등록되었지만 전송한 댓글이 실시간으로 화면에 업데이트 되지 않는 상황이 발생하였다. 이 때 사용하는 속성이 revalidatePath 속성이다.

revalidatePath 속성의 정확한 역할은 인자로 주어진 경로의 페이지를 재검증하는 것이다. 이 속성은 인자를 받는데 인자값은 재검증하고자 하는 즉, 데이터 업데이트를 위해 다시 렌더링 해야하는 페이지의 경로이다. 조금 자세하게 설명을 보태자면 서버 액션에서 최초로 데이터 전송을 위해 호출한 API 주소에 인자값으로 주어진 parameter 값을 추가하여 해당 페이지를 재검증하기 위해 재렌더링을 실행하는 것이다.

 

속성에 대해서는 알아보았으니 주의해야 할 점에 대해서 알아보자.

가장 주의해야 할 부분은 바로 캐싱 무효화이다. 우리는 이전에 Data Caching, Request Memoization, Full Route Cache 등 프로젝트의 빌드 과정에서 호출한 데이터 값이나 캐싱 되어있던 데이터들로 완성된 페이지 자체를 캐싱해두는 등 다양한 캐싱 방법에 대해서 공부한 바 있다. 하지만 서버 액션의 revalidatePath 속성을 통해서 페이지 재검증을 하게 되면 기존에 저장되어 있던 모든 캐시 값들은 무효화 처리되어 사라지게 된다. 주의해야 할 점인 부분치고는 어떻게 보면 당연한 이야기이기도 하다. 캐싱 되어있는 데이터를 사용하면 페이지의 렌더링이 빠르다는 장점이 있지만 데이터가 업데이트 되었는데 캐싱된 데이터를 사용한다는 것은 아무 의미가 없다. 따라서 캐싱 데이터를 최신화 해주어야 하기 때문에 기존의 캐싱 데이터들을 삭제하고 새롭게 캐싱하게 된다는 것은 당연하다고 볼 수 있다.

 

그에 더해 추가적으로 주의해야 할 점이 있다. 중간 정리를 한 번 하자면 초기 프로젝트 빌드 시에 저장되어있던 Full Route Cache, Data Cache 등 해당 페이지의 캐시 값들은 서버 액션의 revalidatePath를 통해 페이지 재검증이 실행되면 모두 초기화 된다. 이 때 재검증을 위해 서버 측에 새로 데이터를 요청하여 받아오는데 그 데이터를 Data Cache에 새롭게 저장하게 된다. 여기서 주의해야 할 점이 나오는데 Data Cache 값은 초기 재검증 시에 새롭게 캐싱되지만 Full Route Cache 즉, 페이지 전체는 캐싱되지 않는다는 점을 주의해야 한다. 초기 재검증이 실행되고 난 직후에는 Data Cache만 실행되고 Full Route Cache는 재검증이 일어나고 이후에 다시 해당 페이지에 접속 요청이 들어왔을 경우 Full Route Cache가 실행되어 페이지 전체를 캐싱할 수 있게 된다. revalidatePath 속성에 대한 설명을 들었으니 코드 구성이 어떻게 되어있는지 살펴보도록 하자. 서버 액션이 취해지는 로직 내에 속성만 넣어주면 된다.

revalidatePath

위의 코드를 버면 try-catch 문 내부의 마지막에 revalidatePath 속성이 추가된 것을 볼 수 있다.

 

이 때 나는 재검증하는 방식에 대해 의문점을 가졌다. 초기에 서버 액션을 통해 데이터 요청을 보낸 API 주소 값에 revalidatePath 속성에 인자로 주어진 경로를 추가한 페이지를 외부 API 요청을 하여 재검증하는 방식인걸까? 잘못 생각하던 내용을 정리하자면 다음과 같다.

서버 액션을 통한 최초 요청을 보낸 API : "URL/review"
revalidatePath를 통해 재검증을 하고자 하는 페이지의 API : "URL/review/book/${bookId}"

 

하지만 틀렸다. revalidatePath의 인자로 주어진 값을 더하여 외부 API로 요청을 보내는 것이 아닌 revalidatePath는 주어진 경로에 포함된 캐시 값을 초기화하는 캐시 무효화 기능만 갖고 있는 것이었다. 따라서 revalidatePath는 캐시만 지워주는 역할을하고 해당 경로에 캐싱 된 데이터 값이 없다는 것은 NextJS가 자체적으로 인식하여 캐시 무효화가 진행된 후 다음 요청이 발생했을 때 기존에 요청 보냈던 경로로 다시 fetch 요청을 보내는 것이다.

 

다양한 재검증 방식

앞 단락에서 revalidatePath를 통해 특정 주소에 해당하는 페이지 재검증에 대해서 알아보았다. 하지만 공식 문서에도 나와있듯이 revalidatePath는 1-2개의 인자를 받는다고 나와있다. 첫번째 인자는 재검증을 위한 페이지의 경로를 넣고 선택 사항인 두번째 인자에는 선택적인 속성값을 넣어주면 되는데, 재검증의 방식에는 크게 5가지로 나뉘어져있다. 5가지를 차례로 살펴보자.

 

1. 특정 주소에 해당하는 페이지만 재검증

- 우리가 앞 단락에서 살펴본 방식이 바로 특정 주소 페이지 재검증 방식이다. 이 방식은 하나의 인자를 받으며 인자값은 재검증을 하고자 하는 페이지의 경로를 넣어주면 된다. 사용 방법은 다음과 같다.

revalidatePath(`/book/${bookId}`);

 

2. 특정 경로의 모든 동적 페이지를 재검증

- 이 방식은 경로를 입력한다기보다 프로젝트 내에서 동적 경로를 갖는 모든 페이지 즉, 동적 경로에 해당하는 폴더를 넣어준다고 이해하는 것이 좋다. 첫번째 인자로 재검증을 하고자 하는 동적 경로에 대한 폴더를 넣어주고 두번째 인자로 "page"라는 값을 넣어주면 첫번째 인자로 구성된 동적 경로로 접근 가능한 모든 동적 페이지들이 모두 재검증이 된다. 사용 방법은 다음과 같다.

revalidatePath("/book/[id]","page");

 

3. 특정 레이아웃을 갖는 모든 페이지를 재검증

- 말 그대로 특정 레이아웃을 갖고 있는 모든 하위 페이지들을 모두 재검증하는 방식이다. App Router 버전에서 layout 파일을 page 파일과 동일 경로에 위치시키면 해당 페이지와 하위 경로의 페이지들은 모두 같은 레이아웃 파일이 적용된다는 점을 우리는 알고 있다. 따라서 특정 레이아웃을 갖고 있는 모든 페이지들을 재검증하기 위해 사용하는 방식이며 첫번째 인자로는 위의 동적 페이지 재검증 방식과 비슷하게 특정 레이아웃을 갖고 있는 폴더명을 넣어주고 두번째 인자로는 "layout"이라는 값을 넣어주면 된다. 사용 방법은 다음과 같다.

revalidatePath("/(Home)","layout");

 

4. 모든 데이터 재검증

- 위의 특정 레이아웃 재검증 방식의 확장판 느낌으로 프로젝트 내의 모든 데이터를 재검증하고자 하는 경우에는 첫번째 인자로 전체 레이아웃에 해당하는 index 경로를 넣어주고 두번째 인자로 "layout" 값을 넣어주면 된다. 사용 방법은 다음과 같다.

revalidatePath("/","layout");

 

5. 태그 기준, 데이터 캐시 재검증

- 이 방식은 앞서 Data Cache에서 다루었던 fetch method의 캐싱 설정 값 중에 하나인 {next : {tag : ['a']}} 설정값에 해당하는 재검증 방식이다. Data Cache를 설정하는 과정에서 태그 값을 설정하여 특정 데이터 캐시값을 명시해주는 방법이 있었는데 그 태그 값을 그대로 가져와서 첫번째 인자값으로 넣어주는 것이다. 여기서 주의해야할 점은 태그 기준으로 재검증을 요청할 때에는 revalidatePath가 아닌 revalidateTag로 변경하여 사용하여야 한다. 사용 방법을 Data Cache 로직과 함께 살펴보자.

// fetch Logic
async function getData({params}:{params:string}) {
  const response = await fetch(`URL/${id}`,{next: {tags: [`review-${id}`]}});
  if(!response.ok) return;
  const data: Data = await response.json();
}

// revalidateTag
"use server"

import { revalidatePath } from "next/cache";

export default async function createReviewAction(formData:FormData){
  try {
    const response  = await fetch("URL/review",{
      method: "POST",
      body: JSON.stringify({id})
    })
    revalidateTag(`review-${id}`);
  } catch (error) {
    console.error(error);
    return;
  }
}

위와 같이 Data Cache에 설정해두었던 tag 값을 그대로 가져와서 revalidateTag의 인자로 넣어주면 해당 데이터만 재검증을 요청할 수가 있게 된다. 이 방식을 사용하면 1번 방식의 전체 페이지의 캐싱값을 모두 무효화 시키는 것이 아닌 특정 데이터의 캐시만 무효화를 시킬 수 있기에 더 효율적이고 경제적으로 재검증을 할 수 있다.

 

Server Action in Client Component

서버 액션이 동작하는 컴포넌트에서 UX를 개선하기 위해서 해당 컴포넌트를 Client Component로 전환하여 사용자의 동작에 맞게 예외 처리나 피드백을 해주는 작업을 처리할 수 있다. 그러기 위해서는 서버 액션이 동작하며 Server Component로 동작하던 컴포넌트르 Client Component로 전환한 뒤 뒤에 나오는 훅을 사용해서 개선해보자.

 

useActionState

React 19부터 추가된 Hook으로 form 양식의 상태값을 다양한 방식으로 다룰 수 있도록 도와주는 훅이다. 이 훅은 2-3개의 인자를 받고 3개의 값을 배열 형태로 반환해준다. 첫번째 인자 값은 Server Action 함수를 받고 두번째 인자 값으로는 상태의 초기값을 받는다. 이어서 반환값은 각각 상태값 state, formAction 함수, 로딩 상태를 의미하는 isPending으로 구성된다. 사용하는 방식을 간단하게 코드로 살펴보면 다음과 같다.

const [state, formAction, isPending] = useActionState(ServerAction, null);

현재 Server Action 함수를 사용하고 있는 form 양식의 form 태그의 action 속성값을 기존의 Server Action 함수가 아닌 useActionState로부터 받은 formAction 값을 넣어주어야 한다. 그렇게 되면 form 양식이 제출되었을 때 useActionState 훅이 자동적으로 Server Action을 동작하게 하여 자동으로 상태값 state와 현재 상태인 isPending 값까지 관리를 해주게 된다.

 

state

useActionState로 인해 Server Action이 실행되고 난 후 반환값을 state 값으로 저장하게 되는 것이므로 Server Action 함수에서 반환값을 어떻게 구성하느냐에 따라서 로직이 달라지게 된다. 여기서는 간단하게 반환받은 state 값을 데이터 호출의 성공 여부인 boolean의 status 값과 에러 문구가 문자열 형태로 포함된 error 값을 객체 형태로 설정해두겠다.

따라서 서버 액션 함수 내에서 데이터 호출의 성공 여부에 따라서 state의 값을 {status: true, error: ""}이거나 {status: false, error: "데이터 호출에 실패했습니다"} 등으로 반환받게 하여 state의 값을 이용하여 서버 액션 함수가 아닌 useActionState 훅을 사용하고 있는 컴포넌트 내에서 예를 들어 useEffect를 사용하여 state의 error 문구를 alert으로 출력해준다던지 별도의 에러 문구를 발생시켜주는 방식으로 사용이 가능하다.

 

isPending

isPending은 현재 서버 액션의 동작 상태에 대한 값이 들어간다. 현재 서버 액션을 통해 form 양식이 제출중이라면 false 값을 반환하고, 동작이 완료되었다면 성공의 유뮤와는 상관없이 true 값이 반환된다. 따라서 isPending 값을 통해서 사용자의 버튼 중복 클릭, 양식 제출 중 중복 전송 방지 등 예외 처리를 보다 쉽게 해 줄 수 있다는 장점이 있다.

대표적인 방법으로는 HTML 태그 내에 disabled 속성의 값에 isPending 값을 넣어 양식이 제출중일 경우에는 요소들의 모든 동작을 막아주는 방법이 있다. 또한 isPending 값이 true인 경우에 로딩 화면이나 아이콘 같은 것을 출력해주는 것도 UX적인 측면으로 많은 개선이 된다.

 

결론적으로 사용자가 입력하는 값에 따라서 다양한 상황이 발생하는 Form 양식을 사용하는 경우에는 중복 제출 방지, 에러 핸들링 등 다양한 요구 사항을 충족하기 위해 useActionState 훅을 적극적으로 사용하는 것을 권장한다. 하지만 현재 우리가 사용하고 있는 NextJS 15 버전과 React 19 버전은 둘 다 매우 최신 버전이기 때문에 약간의 오류가 조금씩 발생한다고 한다. 따라서 주의하여 사용하면 될 듯하다.

 

[#1 - Streaming]

Streaming

Streaming이란 개천,하천이라는 뜻을 가진 Stream이라는 단어에서 유래된 용어로, 데이터를 개천이 흐르듯이 순차적으로 보내준다는 의미를 갖는다. Streaming은 NextJS에서 오래 걸리는 컴포넌트의 렌더링을 사용자가 좀 더 좋은 환경에서 기다릴 수 있도록 렌더링이 빠르게 가능한 요소들부터 순차적으로 출력해주는 방식으로 동작한다. 예를 들어 데이터 호출을 통해 받아오는 데이터의 양이 방대한 경우 페이지 전체의 로딩이 길어지는데 그에 따라 사용자가 접속 요청을 보냈음에도 불구하고 빈 회색 화면을 보고만 있는 것 보다는 기다릴 필요 없는 요소들을 먼저 보여주는 느낌이다.

특히 Streaming은 렌더링 된 페이지를 캐싱해놓는 기능인 Full Route Cache가 동작하지 않는 Dynamic Page에서 사용된다. Dynamic Page 내에서 Header, SideBar와 같이 데이터 호출이 필요없는 요소들은 방대한 양의 데이터 호출이 완료될 때까지 기다렸다가 한꺼번에 렌더링 될 필요가 없다. 또한 UX를 개선하기 위해서 Streaming 기능을 사용한다.

 

loading.tsx

Streaming을 적용하는 방법은 매우 간단하다. 우리는 앞서 404 Page를 커스터마이징하는 방법을 간단하게 알아보았었다. NextJS가 프레임워크라는 점을 이용해 단순히 not-found라는 이름으로 파일을 생성해주면 Data Fetching이 실패했을 때 NextJS 자체적으로 not-found로 구현해놓은 페이지를 보여주는 방식으로 동작한다. Streaming 또한 같은 방식으로 동작한다. 바로 Data Fetching이 진행되는 동안 보여줄 로딩 화면을 구성하는 loading 파일을 생성하면 된다. loading.tsx와 같이 파일을 생성한 후 로딩 화면에 맞게 UI를 생성해놓으면 NextJS가 Streaming이 적용된 Dynamic Page에서 로딩 시간이 긴 컴포넌트에 대해서는 loading 페이지를 보여주게 된다. 하지만 loading 페이지를 사용하는데에 주의해야 할 점들이 몇가지 있다.

1. loading 파일은 일반 Component 파일에는 Streaming이 적용되지 않고 page 파일에만 Streaming을 적용해준다.
- NextJS 내에 layout이나 일반 요소들을 구성하는 다양한 컴포넌트들이 존재하는데, 특정 경로의 메인 UI를 담당하는 page 파일에 대해서만 Streaming이 적용된다. loading 파일과 같은 경로에 존재한다고해서 무조건적으로 Streaming이 적용되는 것은 아니다.
2. loading 파일은 async 키워드가 붙은 page 컴포넌트만 Streaming을 적용해준다.
- 1번 내용의 연장선으로 Streaming이 적용되는 파일은 오직 메인 UI를 구성하는 page 파일뿐인데 그중에서도 async 키워드가 붙어 Data Fetching 기능이 포함되어 있는 Dynamic Page에 대해서만 Streaming이 적용된다. async 키워드가 붙지 않은 컴포넌트는 Data Fetching이 필요 없는 컴포넌트이며 그에 따라 Static Page로 생성이 가능하기 때문에 Streaming이 필요 없이 우선적으로 렌더링되는 요소들이기 때문이다. 만약 page 파일이 아닌 다른 컴포넌트들에게도 Streaming을 적용하기 위해서는 React에서 사용했던 Suspense Component를 사용해야 한다. Suspense에 대해서는 다음 단락에서 다루어볼 예정이다.
3. 현재 Streaming이 적용된 page 파일의 하위 경로의 모든 page 파일들에는 최상위 경로의 page에 적용된 loading 파일이 동일하게 적용된다.
- layout 파일과 동일한 개념으로 상위 경로의 layout이 하위 경로에도 동일하게 적용되는 것처럼 loading 파일의 UI 또한 하위 경로의 모든 page 파일에게 동일한 UI가 적용된다.
4. loading 파일로 적용된 Streaming은 브라우저에서 Query String이 변경될 경우에는 Triggering 되지 않는다.
- 예를 들어 검색창을 통해 검색한 결과를 통해 Data Fetching을 하는 page인 경우 page 파일에서 최초로 Data Fetching을 하는 경우 Streaming이 정상적으로 작동하는 것을 볼 수 있지만, 그 이후 추가적인 검색으로 인해 Query String이 변경되는 경우에는 Streaming이 적용되지 않고 단순 페이지 대기 화면이 지속되는 현상이 발생한다. Query String이 변경되는 page에 Streaming을 적용하기 위해서는 앞서 말했던 Suspense Component를 사용해주어야 한다.

 

Suspense Component

Suspense Component를 사용하는 방식은 앞서서 학습한 경험이 있고 React 프로젝트를 진행할 때에도 사용해 본 경험이 있기 때문에 어렵지 않게 사용할 수 있을 것이다. Streaming 적용 관점에서 사용법을 되짚어보자면 Streaming을 적용하고자 하는 Component의 외부에 react에서 불러온 Suspense Component를 씌워주고 대체할 요소를 fallback 옵션을 통해서 넣어주면 된다. 사용한 예시를 간단하게 코드로 확인하면 다음과 같다.

import {Suspense} from 'react';

export default async function Page(){
  const response = await fetch(API);
  if(!response.ok) return <div>Error</div>;
  const data = await response.json();
  return (
    <Suspense fallback={<div>Loading....</div>}>
      <div>{data}</div>
    </Suspense>
  )
}

위의 코드와 같이 사용하였다면 page 파일이 아닌 다른 일반적인 Component들에도 Streaming을 적용할 수 있게 된다. 하지만 우리가 loading 파일이 아닌 Suspense를 사용하고자 하는 가장 큰 이유는 page 파일이 아닌 다른 Component에도 Streaming을 적용하고자하는 것과 Query String이 변경되는 경우에도 적용하고자 하는 것이다. 따라서 첫번째 이유는 Suspense Component를 사용함으로써 해결하였지만 두번째 이유에 대해서는 아직 해결하지 못했다.

Query String이 변경되는 경우에도 적용을 하기 위해서는 아주 간단하게 Suspense에 옵션 하나를 추가해주면 된다. 바로 key 옵션을 넣어주는 것이다. fallback 옵션처럼 key 옵션을 넣어주고 옵션의 값에 searchParams로부터 받아온 Query String 값을 넣어주면 된다. 이와 같이 동작하는 이유는 Suspense의 특성에 대해 알아야한다.

Suspense Component는 최초에 로딩 후 내부 컴포넌트가 렌더링이 완료되고 나서는 다시는 로딩 상태를 표시해주지 않는다. 그 이후에 어떤 동작이 발생하여도 최초에 동작한 이후에는 로딩 상태를 보여주지 않는 것이다. 하지만 React에서 컴포넌트는 key 값이 변경되면 컴포넌트가 아예 변경되었다고 인식하기 때문에 key 값을 옵션으로 부여해주고 옵션 값으로 Query String을 넣어주면 Query String이 변경될 때마다 Suspense Component가 동작하게 되고 그에 따라 로딩 상태가 매번 표시되는 것이다. 사용 방법은 아래와 같다.

import {Suspense} from 'react';

export default async function Page({searchParams}:{searchParams: {q?: string}){
  const response = await fetch(API);
  if(!response.ok) return <div>Error</div>;
  const data = await response.json();
  return (
    <Suspense key={searchParams.q || ""} fallback={<div>Loading....</div>}>
      <div>{data}</div>
    </Suspense>
  )
}

 

[#2 - Skeleton UI]

Skeleton UI

Skeleton UI란 Skeleton이 뼈대라는 의미를 가진 단어에서 온 용어로 뼈대 역할을 하는 UI를 의미한다. 우리는 앞서 Suspense를 통해 화면이 렌더링되는 동안 Data Fetching이 모두 완료되지 않았을 때 로딩 화면을 보여줌으로써 사용자 경험에 더 좋은 방향으로 설계를 하는 방법을 학습했다. Suspense를 통해 로딩 화면을 출력해줌으로써 Streaming을 구현하여 사용자에게 막연한 기다림과 좋지 못한 UX를 제공하지 않도록 하는 좋은 기능을 학습하였지만 로딩 시간동안 보다 더 구체적인 UI를 보여주면서 UX를 조금 더 개선시킬 수 있도록 해주는 것이 바로 Skeleton UI이다. 특별한 기능을 사용하거나 Hook이나 라이브러리를 사용하는 것은 아니고 단순히 렌더링 되는 요소들을 동일하게 생성한 후 뼈대와 같이 회색으로 요소들을 미리보기처럼 보여주며 동작한다. Skeleton UI를 구현한 후 Suspense의 fallback 옵션으로 Skeleton UI를 넣어주면 막연한 로딩 화면보다 조금 더 가시적인 대기 화면을 사용자에게 제공할 수 있게 된다.

우리가 실제로 자주 사용하는 Youtube에 초기 접속을 하면 화면의 UI에 맞게 회색 요소들로 가득 찬 로딩 화면을 경험해 본 적이 있을 것이다. 그것이 바로 Skeleton UI이다. 아래 사진을 참고해보자.

Skeleton UI

 

React Loading Skeleton

Skeleton UI를 퍼블리싱을 통해 직접 제작하는 방법으로 진행하였지만 React 라이브러리 중에 Animation 기능까지 포함된 Skeleton UI를 제공해주는 라이브러리도 존재한다. 추후에 사용해보면 좋을 것 같다.

 

[#3 - Error Handling]

error.tsx

우리는 여태 데이터 호출과 관련하여 오류나 에러 처리를 하기 위해서는 try-catch 문을 주로 사용하여 에러를 잡아내고 사용자에게 메세지 형태로 출력을 해주는 방식을 사용했었다. NextJS에서는 보다 편하게 에러 UI를 생성하는 방법이 존재한다. 현재 위치해 있는 페이지에 해당하는 page.tsx 파일과 동일한 경로에 error.tsx라는 이름으로 파일을 생성해주면 page.tsx 파일에서 데이터 호출을 포함한 다양한 에러가 발생했을 경우 error.tsx 파일에 생성해둔 에러 UI를 자동으로 출력해준다. 또한 Router 경로 설정을 위해 하위 폴더가 존재하는 경우 하위에 있는 모든 페이지들에도 동일한 에러 UI가 적용이 된다. 이것은 데이터 호출을 기다리는 동안 Suspense의 fallback 속성처럼 대체 UI를 생성해두는 것과 같은 맥락이다.

여기서 주의해야 할 점이 있다.

에러라는 것은 Client Component, Server Component 여부를 불문하고 어디서든지 발생이 가능한 요소이다. 따라서 error.tsx의 최상단에는 반드시 Client Component라는 것을 지칭하는 "use client" 지시자를 입력해주어야 한다. 에러 자체는 클라이언트, 서버 측 불문하고 발생하지만 Server Component는 클라이언트 측에서는 렌더링이 되지 않기 때문에  error.tsx 파일을 Client Component로 설정해두지 않으면 클라이언트 측에서는 해당 에러 UI로 대체를 할 수 없기 때문이다. 따라서 Server 측에서 초기 렌더링 시 한 번, Client 측에서 Hydration 과정으로 인해 또 한 번, 양측 모두에서 렌더링이 발생하는 Client Component로 설정을 해주어야 한다.

 

error Props

우리가 에러 UI를 생성하였지만 발생한 에러에 대한 정보, 에러 메세지 등을 확인하고 그 부분을 출력을 해주고 싶은 경우도 발생한다. 이런 경우 error.tsx 파일의 Error Component에 error Props를 전달해주면 확인이 가능하다. NextJS에서는 error라는 이름의 Props로 현재 발생한 에러의 정보를 Error Component에게 전달해준다. 이 때 error Props의 타입은 NextJS에서 제공해주는 Error로 지정을 해주면 된다. 지금까지의 내용을 바탕으로 error.tsx 파일의 예시를 간단하게 코드로 작성해보았다.

"use client"

import {useEffect} from 'react';

export default function Error({error} : {error: Error}){
  //error가 발생할 때마다 error 객체의 에러 메세지 출력
  useEffect(() => {
    console.log(error.message);
  },[error])
  return (
    <div>
      <h1>에러가 발생했습니다.</h1>
      <p>{error.message}</p>
    </div>
  )
}

 

reset(Error) & refresh(useRouter)

NextJS가 Error Component한테 제공해주는 Props는 error 객체말고도 reset이라는 함수도 존재한다. reset 함수는 말그대로 에러 상태를 리셋하고 페이지 렌더링을 다시 시도하는 동작을 제공한다. 현재 간단하게 제작된 서버를 종료해두고 클라이언트 측에서 데이터 호출을 시도해 보았는데 당연히 에러가 발생했고 그에 따라 Error Component UI가 렌더링 되었다. 이후에 reset 함수를 적용하기 위해서 코드를 추가해보았다.

"use client"

import {useEffect} from 'react';

export default function Error({error,reset} : {error: Error; reset: () => void}){
  //error가 발생할 때마다 error 객체의 에러 메세지 출력
  useEffect(() => {
    console.log(error.message);
  },[error])
  return (
    <div>
      <h1>에러가 발생했습니다.</h1>
      <p>{error.message}</p>
      <button onClick={() => reset()}>다시 시도</button>
    </div>
  )
}

위와 같이 에러 상태를 리셋하고 재호출을 위한 버튼을 추가하고 버튼의 onClick 이벤트에 reset 콜백 함수를 추가해주었다. 하지만 reset 함수를 넣어주었음에도 불구하고 서버를 재시작했음에도 에러가 사라지지 않았다. 그 이유는 무엇일까.

reset 함수는 클라이언트 측에서 현재 서버로부터 받은 데이터를 다시 한 번 렌더링을 시도해보는 역할을 할 뿐 서버측에서 데이터를 다시 가져올 수 있도록 API 호출을 시도한다거나 하는 작업은 하지 못하기 때문이다. 따라서 이전에 오류가 발생했던 이유가 서버 측에서 받아온 데이터가 없기 때문이었는데 reset을 통해 다시 렌더링 해봤자 변함이 없는 것이다.

이 문제를 해결하기 위한 대안이 2가지가 있다. 하나씩 살펴보자.

 

1. button 태그의 onClick 이벤트 핸들러의 콜백 함수로 reset이 아닌 window.location.reload()를 주어서 페이지 새로고침을 통해 데이터를 불러오도록 하기.
"use client"

import {useEffect} from 'react';

export default function Error({error,reset}: {error: Error; reset: () => void){
  useEffect(() => {
    console.log(error.message)
  },[error])
  return (
    <div>
      <h1>오류가 발생했습니다.</h1>
      <p>{error.message}</p>
      <button onClick={() => window.location.reload()}>다시 시도</button>
    </div>
  )
}

- 가장 심플하게 처리할 수 있는 방법이다. 하지만 단점이 조금 존재한다.

a.브라우저 측에 저장된 상태 값이나 데이터들이 페이지 새로 고침으로 인해 휘발되거나 초기화 될 수 있다.
b.동일하거나 하위 경로에 존재하는 컴포넌트들 중 에러가 발생하지 않는 컴포넌트들도 리렌더링이 발생하여 불필요한 렌더링이 발생한다.

=> 오류가 발생한 부분만 깔끔하게 리렌더링 시키기 위해 2번 방법으로 대체

 

2. useRouter Hook의 refresh method와 reset 함수 사용하기.

- refresh method현재 페이지에 필요한 Server Component들을 Next Server 측에 다시 한 번 실행하도록 요청을 보내는 method이다. 그렇게 받아온 Server Component의 결과값을 화면에 업데이트 시켜준다.

- reset는 에러 상태를 초기화하고 컴포넌트들을 다시 렌더링하는 기능을 한다. 따라서 reset을 실행하면 에러 상태를 초기화하고 refresh method를 통해 받아온 데이터를 갖고 컴포넌트를 리렌더링하여 화면을 업데이트 시킬 수 있다.

위의 두 과정을 합해서 onClick의 콜백 함수로 주면 어떨까. 아래 코드와 같이 작성해보았다.

"use client"

import {useEffect} from 'react';
import {useRouter} from 'next/navigation';

export default function Error({error,reset}: {error: Error; reset: () => void){
  const router = useRouter();
  useEffect(() => {
    console.log(error.message)
  },[error])
  return (
    <div>
      <h1>오류가 발생했습니다.</h1>
      <p>{error.message}</p>
      <button onClick={() => {
        router.refresh();
        reset();
      }}>다시 시도</button>
    </div>
  )
}

여전히 오류가 발생한다. 그 이유는 refresh method는 비동기적으로 동작하는 method이기 때문이다. refresh method를 통해 Server Component로부터 아직 데이터를 받아오기 전에 reset Function이 먼저 실행되게 되어 reset만 단독으로 실행했을 때와 다를 바가 없게 되었다.

=> 두 함수를 동시에 실행해주는 방법을 사용해야 한다.

 

startTransition(React 18)

React 18버전부터 추가된 method인 startTransition은 하나의 콜백 함수를 인자로 받아서 콜백 함수 내부에 있는 UI를 변경하도록 동작하는 로직을 모두 일괄적으로 실행해주는 기능을 한다. 따라서 refresh method와 reset function을 내부에 넣어주게 되면 두 method가 하나 같이 동작함에 따라 오류 상태가 초기화되고 새로 받아온 데이터 값을 정상적으로 업데이트 시켜줄 수 있게 되었다. 코드는 다음과 같다.

"use client"

import {useEffect, startTransition} from 'react';
import {useRouter} from 'next/navigation';

export default function Error({error,reset}: {error: Error; reset: () => void){
  const router = useRouter();
  useEffect(() => {
    console.log(error.message)
  },[error])
  return (
    <div>
      <h1>오류가 발생했습니다.</h1>
      <p>{error.message}</p>
      <button onClick={() => {
        startTransition(() => {
          router.refresh();
          reset();
        })
      }}>다시 시도</button>
    </div>
  )
}

 

마지막으로 error.tsx 파일을 생성함에 있어서 주의해야 할 점은 파일 내부에서의 위치이다. error.tsx 파일을 동일한 경로와 그 하위 경로에 있는 파일들에 모두 적용이 된다. 따라서 특정 페이지에서 상위 폴더의 에러 UI와 다른 UI를 렌더링 해주고 싶은 경우에는 해당 경로의 page.tsx 파일과 같은 위치에 error.tsx 파일을 생성해주면 된다.

또한 error.tsx 파일을 자신과 동일한 위치에 있는 layout.tsx까지만 렌더링을 해주기 때문에 하위 경로에 있는 layout.tsx 파일은 에러가 발생에 따라 영향을 받지 않는 요소라고 하더라도 무시되어 렌더링 되지 않을 수도 있다. 따라서 App Router 버전에서 에러 UI를 페이지 별로 구분을 해야한다면 번거롭더라도 각 page 파일에 맞게 error.tsx 파일을 생성해주는 것이 더 좋을 수 있다.

[#1 - Full Route Cache]

Full Route Cache

Full Route Cache란 Next 서버 측에서 빌드 타임에 특정 페이지의 렌더링 결과를 캐싱하는 기능을 말한다. Page Router 버전의 SSG 방식과 유사한 느낌이다. 프로젝트 빌드 타임에 Pre-Rendering을 진행하며 백엔드 서버로부터 요청했던 데이터들을 모두 받아와 데이터 캐싱도 Set하여 완료해두고 페이지 렌더링을 모두 완료한 뒤 Full Route Cache로 렌더링 완료된 페이지 자체를 캐싱해두는 것이다. 그에 따라 사용자의 페이지 초기 접속 요청이 발생하였을 때 백엔드 서버에게로 데이터 요청과 응답받은 데이터를 캐싱해두고 페이지를 렌더링 해두는 과정 필요없이 바로 캐싱되어있던 완성된 페이지를 사용자에게 매우 빠른 속도로 보여줄 수 있는 것이다.

하지만 Next에 만들어둔 모든 페이지가 Full Route Cache가 진행되는 것은 아니다. 그 이유는 Next에서는 페이지가 어떤 기능을 사용하는지에 따라 Static Page(정적 페이지)Dynamic Page(동적 페이지)로 구분이 되는데 Full Route Cache는 Static Page에게만 적용이 되기 때문이다. 뒤에 이어서 Static Page와 Dynamic Page에 대해 자세히 알아보도록 하자.

 

Dynamic Page(동적 페이지)

- 특정 페이지가 사용자로부터 접속 요청을 받을 때 마다 매번 새로운 변화가 생기거나, 데이터가 달라지는 경우 Dynamic Page로 분류가 된다. Dynamic Page로 설정되는 기준은 다음과 같다.

1. 캐싱되지 않는 Data Fetching을 사용하는 경우(Fetch Method의 Cache 옵션이 없거나 "no-store"인 경우
2. 동적 함수(쿠키, 헤더, 쿼리스트링)를 사용하는 Component가 있는 경우
- 동적 함수 : 요청에 따라서 가변적인 형태를 갖는 값들을 꺼내오는 기능(Cookie, Header, Query String etc.)

 

따라서 사용자의 요청에 따라서 가변적으로 변하는 값이 포함되어 있기 때문에 페이지를 정적으로 생성하게 되면 데이터의 최신화가 이루어지지 않기 때문에 Full Route Cache 기능이 적용되지 않는다. 그에 따라 사용자의 접속 요청이 발생함과 동시에 페이지가 렌더링 되기 시작하며 상대적으로 Full Route Cache가 되었던 Static Page에 비해 느리게 렌더링이 완료된다.(실제로 매우 방대한 양의 데이터 호출이 있지 않는 한 사용자가 불편함을 느끼거나 눈에 띄게 느린 렌더링이 발생하지는 않는다.)

 

Static Page(정적 페이지)

- Dynamic Page로 구분이 되지 않는 모든 페이지는 Static Page로 구분이 된다. Next에서 Page의 Default 값이다. 페이지 내에 사용자의 요청이 발생할 때 마다 가변적으로 변하는 값이 없기 때문에 페이지를 정적으로 생성해도 되기 때문에 Full Route Cache 기능이 적용이 된다. 따라서 빌드 타임에 페이지가 정적으로 생성되어 캐싱된다.

 

아래 표를 통해서 Dynamic Page와 Static Page를 쉽게 구분할 수 있다.

페이지 분류 동적 함수(쿠키,헤더,쿼리스트링) 데이터 캐시
Dynamic Page Yes No
Dynamic Page Yes Yes
Dynamic Page No No
Static Page No Yes

 

일반적으로는 가능만하다면 Dynamic Page보다는 Static Page로 생성하는 것이 권장되지만 각 페이지 별로 갖고 있는 기능과 성격이 다르기 때문에 페이지의 기능에 맞게 페이지를 생성하는 것이 올바르다. 또한 Dynamic Page는 Full Route Cache만 적용이 불가능하기에 페이지 전체의 캐싱만 불가능한 것이지 Data Caching과 그에 따른 Request Memoization은 모두 가능하기 때문에 필요에 맞게 페이지를 생성하고 캐싱 기능을 사용하면 좋다.

 

무엇보다 가장 중요한 점은 Next에서 페이지의 구분이 생기는 것은 Server Component에만 해당이 되는 점이다. Client Component는 브라우저에서 렌더링이 되기 때문에 페이지의 유형에 영향을 미치지 않는다.

 

Revalidate

Full Route Cache 또한 Revalidate 설정이 가능하다. Full Route Cache는 빌드 타임에 진행된다고 앞서 살펴보았다. 그 시점에 Fetch 요청을 보내면서 Revalidate 시간을 옵션으로 설정하여 요청을 보낼 수 있는데 옵션을 설정하여 보내어도 Data Caching과 Request Memoization, Full Route Cache는 모두 동일하게 진행된다. 하지만 Fetch 요청 때 함께 보낸 Revalidate 시간이 경과하고 나서 페이지 접속 요청이 발생하면 우선적으로 Stale(상한) 데이터가 포함되어 있고 Full Route Cache 되어있던 페이지를 렌더링 해줌과 동시에 즉시 서버측으로부터 캐싱되어 있던 데이터를 업데이트하여 새롭게 Data Caching을 완료한 후 바로 Full Route Cache까지 업데이트를 해둔다. 그 이후로부터 들어오는 페이지 접속 요청에 대해서는 업데이트 된 데이터와 그 데이터가 담긴 페이지를 렌더링 해줄 수 있게 되는 것이다. 이 동작은 Page Router의 ISR(증분 정적 재생성)과 유사한 느낌이라고 보면 이해가 쉬울 것이다.

 

적용하기

진행중인 프로젝트에서 Full Route Cache를 적용하기 위해서는 모든 페이지들을 살펴보면서 각 페이지가 Dynamic Page인지 Static Page인지 확인을 해보아야 한다. 이것을 코드로 확인을 일일이 하기엔 번거로움이 있기 때문에 현재 진행중인 프로젝트를 npm run build를 통해서 빌드를 해보면 빌드된 페이지들의 종류가 표시가 된다. 이 때 주의해야 할 점이 있다. 만약 상위 컴포넌트는 Static Page로 구성이 되어있지만 하위 컴포넌트에 Dynamic한 요소가 존재하여 Dynamic Page로 존재한다면 빌드 시에 오류가 발생한다. 에러 문구는 다음과 같은 형태로 출력된다.

"Generating static pages (3/n) [=  ] x useSearchParams() should be wrapped in a suspense boundary at page "/" "

이 오류 문구가 의미하는 것이 바로 Static Page 하위에 Dynamic Page가 존재한다는 점이다. 이런 경우에는 하위 컴포넌트 내부에 동적으로 동작하는 컴포넌트에 Suspense 컴포넌트를 씌워주면 된다. Suspense 컴포넌트는 React에서 제공해주는 컴포넌트로 비동기적으로 처리되는 요소에 대해서 진행이 완료되지 않았을 경우 fallback 옵션을 통해서 대체를 해두고 처리가 완료되면 화면에 출력해주는 기능을 한다. 일반적으로 비동기적인 기능을 처리하는 컴포넌트에 Suspense 컴포넌트를 씌워주어 UX 측면을 개선시키는데에 주로 사용한다. 따라서 동적인 요소에 Suspense 컴포넌트를 씌워주면 정상적으로 빌드가 진행된다.

빌드가 진행되면 다음과 같은 결과가 터미널에 출력된다.

빌드가 완료되면 각 페이지 별로 f라는 단어가 붙은 페이지는 Dynamic Page, 기호가 붙은 페이지는 Static Page로 구분이 되는 것을 확인할 수 있다. 앞서 말했듯이 페이지는 가능하다면 Static Page로 생성되는 것이 좋다고 했기 때문에 Dynamic Page의 기능들을 확인하면서 Static Page로 전환이 가능한 부분이 있다면 수정을 해주면 된다.

수정하는 대표적인 방법은 Fetch Method를 사용하는 로직에 옵션을 설정하여 페이지 기능에 영향이 없는 선에서 Data Caching을 하도록 설정을 바꾸어주는 것이다. {cache : "no-store"}로 설정되어있던 옵션을 {cache : "force-cache"}로 변경을 해주거나 Revalidate 설정을 위해서 {next : {revalidate: 3}}과 같이 설정을 해주어도 된다. 이와 같이 Static Page로 전환 후 빌드를 해주면 결과가 다음과 같이 바뀌는 것을 볼 수 있다.

Dynamic Page로 동작하던 최상위 페이지가 Static Page로 바뀐 것을 확인할 수 있다.

이에 더해 위와 같이 빌드만 완료했는데도 불구하고 next/server/app/index.html 과 같은 경로를 통해 들어가서 파일을 확인해보면 Next 서버를 실행하지도 않았는데 Full Route Cache를 통해 정적인 페이지가 생성이 되어 있는 것을 확인할 수도 있다. 그리고 서버를 실행시킨 뒤 페이지 내에서 다양한 동작이 실행되면 index.html 파일이 실시간으로 변경되면서 렌더링되는 것까지 확인할 수 있다.

 

generateStaticParams

앞서 우리는 다양한 페이지들을 Static Page로 생성하기 위해 Fetch Method의 Cache 옵션을 설정해주곤 했다. 하지만 아직 Dynamic Routing이 적용되는 페이지들에 대해서는 설정을 해주지 않았다. Dynamic Routing이 적용된 페이지들에게는 어떻게 해야 Static Page로서 생성이 되도록 해줄 수 있을까. 바로 generateStaticParams 함수를 사용하면 된다. Dynamic Routing이 적용된 컴포넌트의 상단에 본 함수를 작성한 뒤 URL Parameter 값이 담긴 객체들이 담긴 배열을 반환해주고 export 처리해주면 Next가 자동으로 인식하여 반환 받은 배열에 속한 URL Parameter 값이 포함된 경로로 생성된 페이지들은 Static Page로 생성하여 Full Route Cache가 적용된다. 사용 코드는 아래 예시와 같다.

export function generateStaticParams(){
  return [{id: "1"},{id: "2"},{id: "3"}];
}

export default async function Page({params}: {params: {id: string|string[]}){
  const response = await fetch(API);
  if(!response.ok){ return <div>Error</div> }
  const data = await response.json();
  return (
  	<div>
      Page {params.id}
      <div>{data}</div>
    </div>
  )
}

위의 예시 코드는 Dynamic Routing이 적용된 페이지 로직이다. 컴포넌트 내부를 살펴보면 Fetch Method 내부에 Cache 옵션이 별도로 설정되어있지 않았다. 또한 컴포넌트 외부에 generateStaticParams 함수를 사용하고 반환값으로 URL Parameter 값이 담긴 객체들이 담긴 배열이 포함되어 있다. generateStaticParams 함수를 통해 Dynamic Routing으로 생성된 페이지들에 대한 Static Page 설정을 해주면 Fetch Method의 Cache 옵션이 설정되어 있지 않아도 자동으로 Static Page로 설정이 되어 Full Route Cache가 적용된다. 위와 같은 로직으로 작성하고 npm run build를 통해 프로젝트를 빌드한 후 출력된 빌드 메시지를 확인해보면 다음과 같은 내용을 확인할 수 있다.

위의 사진을 보면 /book 경로로 Dynamic Routing이 설정된 페이지들이 SSG, 즉 빌드 시점에 Static Page로서 생성이 된 것을 확인할 수 있다.

Static Page로 생성된 페이지 외에 URL Parameter을 통해 다른 Dynamic Routing 페이지에 접속하면 접속 즉시 페이지가 Dynamic Page로 생성되는 것도 확인이 가능하다.

 

dynamicParams

추가적으로 generateStaticParams에 반환값으로 설정해둔 URL Parameter 값들을 제외하고 다른 Dynamic Routing 경로를 통해 페이지 접속을 막고자 하는 경우에는 dynamicParams 변수 값을 false로 설정해주면 된다.

export const dynamicParams = false;
export function generateStaticParams(){
  return [{id: "1"},{id: "2"},{id: "3"}];
}

export default async function Page({params}: {params: {id: string|string[]}){
  const response = await fetch(API);
  if(!response.ok){ return <div>Error</div> }
  const data = await response.json();
  return (
  	<div>
      Page {params.id}
      <div>{data}</div>
    </div>
  )
}

위 코드의 가장 최상단에 dynamicParams를 false로 설정을 해두면 generateStaticParams로 설정해둔 경로 이외의 동적 경로로 접속을 하였을 경우에는 DB에 데이터가 존재함에도 불구하고 404 Page를 반환해준다. 기본값은 true이기 때문에 이외의 경로로 접속하였을 때 즉시 Dynamic Page를 생성해주는 것이다.

 

위에서 살펴본 generateStaticParams 함수는 이전에 Page Router을 학습할 때 보았던 함수와 비슷한 기능을 한다. 바로 Pre-Rendering의 방식중에 SSG 방식에 사용되었던 함수인 getStaticPaths 함수와 동일한 기능을 한다. getStaticPaths 함수에서의 반환값 중 paths 옵션을 generateStaticParams 함수가 반환값으로 갖는다고 생각하면 된다. 또한 위의 dynamicParams의 값을 false로 설정을 해두는 것 역시 getStaticPaths 함수의 반환값 중 fallback 값의 옵션을 false로 설정한 것과 같은 맥락이다.

 

not-found.tsx(jsx)

App Router에서는 404 Page를 커스터마이징하거나 생성하는 방법이 매우 쉽다. App 폴더 바로 하위 경로에 not-found라는 이름으로 파일을 생성해주고 해당 컴포넌트의 UI를 생성하여 다른 페이지에서 서버 오류가 발생하였을 경우 본 컴포넌트를 넣어 사용하면 자동으로 not-found 파일이 페이지에 렌더링된다. Page Router 버전에서 404라는 파일명이 App Router에서 not-found라는 이름으로 바뀐 것 뿐이다.

 

[#2 - Route Segment]

Route Segment

앞서 우리는 빌드 타임에 페이지들이 Dynamic Page나 Static Page로 생성이 되도록 페이지 별로 설정을 해주는 방법을 학습했다. 하지만 별도의 설정 없이 앞서 살펴본 dynamicParams 같이 변수를 하나 선언하고 그 옵션 값을 설정해줌에 따라 강제적으로 Dynamic Page나 Static Page로 생성하는 방법이 존재하는데 그것이 바로 Route Segment이다. 정리하자면 페이지 내부에 Data Cache, dynamicParams, Revalidate,Server Regional 같이 별도의 설정을 모두 무시하고 페이지의 유형을 강제로 Dynamic Page나 Static Page로 설정하는 옵션이다.

Route Segment 사용법은 dynamic이라는 변수를 사용하는 것이다.

 

dynamic

dynamic 변수에 할당할 수 있는 옵션값은 4가지가 있다. 4가지를 차례차례 살펴보자.

1. auto

- auto 옵션은 dynamic 변수의 기본값이다. 강제적인 제어 없이 페이지에 설정되어있는 그대로 페이지를 생성하는 옵션이다.

export const dynamic = "auto";

2. force-dynamic

- force-dynamic 옵션은 해당 페이지를 강제로 Dynamic Page로 생성하는 옵션이다. 페이지 내부에 Static Page로 생성하도록 설정되어있는 옵션이 존재하더라도 본 옵션이 설정되면 강제적으로 Dynamic Page로 생성을 하게 된다.

export const dynamic = "force-dynamic";

3. force-static

- force-dynamic 옵션과 반대로 해당 페이지를 강제로 Static Page로 생성하는 옵션이다. 페이지 내부에 Dynamic Page로 생성하도록 설정되어있는 옵션이 존재하거나 useSearchParams, useParams 등 사용자의 동작에 따라 Dynamic하게 동작하는 요소들이 존재하더라도 해당 요소들을 undefined로 변환시켜버린 후 강제로 Static Page로 생성하는 옵션이다.

export const dynamic = "force-static";

4. error

- force-static 옵션과 동일하게 강제로 페이지를 Static Page로 생성하는 옵션이다. 하지만 force-static과의 차이점은 만약 본 옵션이 설정된 페이지에 Dynamic하게 동작하는 요소가 존재한다면 프로젝트를 빌드할 때 에러를 발생시키는 점이다. force-static 옵션은 Dynamic한 요소가 존재하더라도 무시하고 강제로 Static Page를 생성하는 반면 error 옵션은 페이지 빌드시 에러를 발생시켜 Static Page로서의 생성을 막아준다.

 

위와 같은 페이지 유형 강제 설정 방법을 알아보았지만 위의 방법은 그닥 권장하는 방법이 아니다. App Router 버전의 NextJS는 각 페이지, 각 컴포넌트 별로 동작하는 방식을 분석하여 자동적으로 Static Page, Dynamic Page로 구분을 지어주는 좋은 기능을 가지고 있기 때문에 강제로 NextJS의 동작을 제어하는 것은 그닥 좋은 방법이 아니기 때문이다. 다만 본 방법을 학습함으로써 프로젝트의 빠른 설정이나 테스트 시 유용하게 사용할 수 있기 때문에 알아두면 좋은 기능이다.

 

[#3 - Client Router Cache]

Client Router Cache

Client Router Cache란 브라우저에 저장되는 캐시로서 페이지 이동을 효율적으로 진행하기 위해 페이지의 일부 데이터를 브라우저 측에 보관하는 기능을 말한다. 가장 많이 사용되는 예시로 서로 다른 두개의 페이지가 갖고 있는 공통적인 레이아웃 파일이 존재하는데 레이아웃 같은 경우에는 데이터 호출이 존재하거나 변경되는 사항이 적은 요소이다. 따라서 변동되는 부분이 없는 요소임에도 불구하고 페이지의 이동이 발생하였을 때 해당 페이지 요소들과 같이 재호출이 발생하면서 불필요하게 잦은 호출이 발생하곤 한다. NextJS의 장점 중에 하나인 매우 빠른 페이지 이동에 맞게 최적화 되지 못한 부분이 발생하는 것이다. 그래서 NextJS는 자동으로 레이아웃 파일 같은 페이지 이동에 따라 변동 사항은 없지만 불필요하게 계속 호출이 되는 요소들은 최초 접속을 할 때 렌더링을 하고 Client Router Cache 기능을 통해서 브라우저 측에 보관을 해두고 페이지 이동이 발생하면 브라우저에서 캐싱되어있던 레이아웃 파일을 가져와 최적화 된 페이지 이동을 제공해주는 것이다. 별도의 설정할 필요 없이 자동적으로 제공이 되는 기능이다.

하지만 브라우저에 캐싱해두는 기능이기 때문에 페이지가 새로 고침되거나 브라우저 탭이 닫혔다가 다시 접속을 하는 등 브라우저가 초기화 되는 동작이 발생하면 캐싱 데이터는 사라지기 때문에 그 부분은 유의해야 한다.

[#1 - App Router Data Fetching]

Page Router에서 Data Fetching을 할 때에는 Pre-Rendering을 통해서 데이터를 받아왔었다. 그 중에서도 SSG,SSR,ISR 등 필요한 방식에 따라 getServerSideProps, getStaticProps, getStaticPath 등 사용 방식에 맞는 함수를 사용하여 데이터를 호출한 뒤 필요한 하위 컴포넌트에 Props 형태로 데이터를 전달해주는 로직을 구성했었다. 하지만 App Router에서는 Server Component가 생겨남과 동시에 새로운 Data Fetching 방식이 생겨났다.

함수형 컴포넌트를 사용하고 있음에 따라 컴포넌트에 async 키워드를 붙여줌으로써 해당 컴포넌트 내에서 Data Fetching을 진행하여 await 키워드로 응답 받아 필요한 데이터를 사용하면 되는 방식으로 굉장히 편리하게 Data Fetching이 가능해졌다. Async/await에 대한 사용법과 내용에 대해서는 알고 있으니 코드로 사용 방식을 살펴보자.

export default async function Home(){
  const response = await fetch(URL);
  //Error Handling
  if(!response.ok){
    return <div>Error!</div>
  }
  const data = await response.json();
  return (
    <div>
      <p>{data.id}</p>
    </div>
  )
}

위의 코드를 보면 컴포넌트 자체에 async 키워드를 붙여주고 컴포넌트 내부에서 await 키워드와 함께 fetch 함수를 사용하여 데이터를 호출하여 응답 받은 데이터를 바로 JSX에 사용하였다. 추가적으로 예외 처리도 쉽게 할 수 있다.

 

.env(환경 변수: environment value)

환경 변수란 프로젝트 내에 .env 파일을 생성하여 해당 파일 내부에 입력해둔 변수로 해당 프로젝트 내에서만 접근이 가능하여 프로젝트 전체적으로 사용하는 변수를 저장해두거나 보안이 필요한 경우에 사용하기도 하며 .env 파일은 프로젝트 전체적으로 사용되는 변수들을 모아두어 쉽게 사용하도록 만들어놓은 파일이다.

.env 파일 내부에서 환경 변수는 NEXT_PUBLIC_API_SERVER_URL = http://localhost:12345와 같이 저장하여 사용한다. 이 때 변수명 앞의 NEXT_PUBLIC과 같은 형태로 접두사를 함께 입력해주기도 한다. 그 이유는 만약 앞의 접두사를 작성해놓지 않는다면 Next는 자동으로 해당 변수를 서버 측에서만 활용할 수 있도록 private로 설정을 해버리기 때문에 Client Component에서 접근이 불가능해지게 되기 때문이다.

추가적인 정보로 NextJS에서는 환경 변수를 사용할 때 process.env.NEXT_PUBLIC_API_SERVER_URL과 같은 형태로 호출하여 사용하게 되는데 만약 Next가 아닌 Vite 환경에서 React로 프로젝트를 진행 시 환경 변수를 사용할 때에는 process가 아닌 import.meta.env 형식으로 환경 변수를 호출하여 사용해주어야 한다. Vite 환경에서 프로젝트를 하다가 Next로 넘어와서 환경 변수를 사용할 때 import.meta.env 형식으로 사용하는 것이 익숙할텐데 Next는 Webpack이나 Babel을 사용하기 때문에 Vite와 환경 변수를 다루는 방식이 다르기 때문에 process.env 형식으로 접근해야 한다.

 

import.meta.env vs process.env

import.meta.env.API_KEYprocess.env.API_KEY환경과 플랫폼에 따라 다르다. 이 두 접근 방식은 모두 환경 변수를 사용하는 방법이지만, 그 동작 방식과 목적이 다르다.

 

import.meta.env.API_KEY

Vite와 같은 빌드 도구에서 주로 사용되는 방식이다. Vite는 Next.js와는 다른 빠른 빌드 도구로, 환경 변수를 관리하는 방법도 다르게 처리한다.

  • 빌드 타임 변수: import.meta.env는 빌드 시점에 환경 변수를 처리한다. 즉, Vite는 빌드 중에 해당 변수를 읽고 정적으로 코드에 주입합니다.
  • 클라이언트 사이드에서도 사용 가능: import.meta.env는 클라이언트 사이드에서도 안전하게 사용할 수 있도록 설계되었다. 하지만 노출을 원하는 변수만 주입됩니다.
  • 보안 통제 가능: 클라이언트에 노출되지 않도록 하기 위해, VITE_ 접두사가 붙은 환경 변수만 클라이언트 사이드에서 사용 가능하다. 예를 들어, VITE_API_KEY처럼 명시적으로 작성해야 클라이언트에 노출된다.
  • Next.js에서 사용 불가: Next.js는 Webpack이나 Babel을 주로 사용하기 때문에, Next.js 프로젝트에서는 기본적으로 지원되지 않는다.

process.env.API_KEY

Node.js의 전역 객체인 process에서 환경 변수를 읽는 방식이며, Next.js와 같은 Node 기반의 런타임에서 널리 사용된다.

  • 서버 사이드 환경: process.env는 주로 SSR에서 환경 변수를 읽기 위해 사용된다. CSR에서도 사용할 수 있지만, 클라이언트에 노출하려면 변수를 NEXT_PUBLIC_ 접두사로 정의해야 한다.
  • 런타임 변수: process.env는 런타임에 변수를 읽기 때문에 서버가 시작될 때 설정된 환경 변수를 그대로 반영한다. 따라서 클라이언트에서 환경 변수를 사용하려면 빌드 시점에 미리 결정된 값을 클라이언트 코드로 전달해야 한다.
  • 보안 및 유연성: 클라이언트 사이드에 노출되지 말아야 할 중요한 변수들은 서버에서만 접근 가능하도록 NEXT_PUBLIC_ 접두사를 붙이지 않으면 된다.

Next.js에서는 process.env를 사용하는 것이 표준적이며, Vite에서 사용하는 import.meta.env는 Next.js에서 기본적으로 지원되지 않는다.

[#2 - Data Cache]

Data Cache

데이터 캐시란 Fetch Method를 통해 불러온 데이터를 Next 서버에 보관하는 기능이다. 영구적으로 데이터를 보관하거나 특정 주기로 갱신 시키는 것도 가능하다. 따라서 불필요한 데이터 요청 수를 줄여서 웹 서비스의 성능을 크게 개선할 수 있는 기능이다. 데이터 캐시를 사용하기 위해서는 반드시 Fetch Method를 사용해서 데이터 호출을 해야한다. React의 Axios 같이 별도의 데이터 호출 라이브러리를 사용하면 사용이 불가능하다. 그 이유는 Next에서 제공하는 Fetch Method는 우리가 JS에서 일반적으로 사용하던 Fetch Method와 달리 cache와 같이 일반적인 기능들을 추가해놓은 확장판 Method이기 때문이다. 사용 방법은 Fetch Method의 첫번째 인자로 데이터 호출을 요청할 API를 넣고 두번째 인자로 객체를 부여하는 것인데 객체의 종류에는 4가지가 있다. 모두 알아보자.

 

{ cache: "no-store" }

const response = await fetch(API, {cache: "no-store"};

본 속성은 Data Fetching의 결과를 저장하지 않는 옵션이다. 데이터 캐싱을 아예 하지 않도록 설정하는 옵션이다. Fetch Method를 사용하는 로직에 본 속성을 부여해서 Data Fetching을 진행하면 console 창에 데이터의 캐싱 여부를 확인할 수 있다. 하지만 확인 전에 Next App의 설정 파일인 next.config.mjs 파일에서 설정해주어야 하는 속성이 있다.

//next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  logging: {
    fetches: {
      fullUrl: true
  	}
  }
};
 export default nextConfig;

위와 같이 Next 설정 파일의 속성을 변경해주고 본 속성을 Fetch Method에 넣어서 호출하면 콘솔창에 다음과 같은 결과가 출력된다.

"cache skip"이라는 문구가 함께 출력되며 서버로부터 데이터를 호출 받은 후 캐싱을 스킵했다는 정보를 알려준다.

 

{ cache: "force-cache" }

const response = await fetch(API, {cache: "force-cache"};

본 속성은 데이터 요청의 결과를 무조건 캐싱하도록 하는 속성이다. 한 번 호출 된 이후에는 다시 호출되지 않는 특징을 갖고 있다. 마찬가지로 위의 코드 형태로 Data Fetching을 하면 console 창에 다음과 같은 결과가 출력된다.

"cache hit"이라는 문구가 함께 출력되며 서버로부터 데이터를 다시 호출하지 않고 캐싱 되어있던 데이터를 반환해준다. 앞서 살펴본 "no-store" 속성을 적용하였을 때에는 "cache skip"이라는 문구가 출력되면서 캐싱을 스킵하고 넘어간 반면에 이번에는 "cache hit"이라는 문구로 알 수 있듯이 캐싱 된 데이터를 Hit 하여 찾아낼 수 있게 되는 것이다.

이 때 캐싱된 데이터는 JSON 형태로 Next 서버 내부에 저장이 되는데 로컬에서 찾아보기 위해서는 프로젝트 내의 next > cache > fetch-cache 폴더 경로를 타고 들어가면 JSON 형태로 작성된 파일에서 확인이 가능하다.

 

{ next: { revalidate: 3 } }

본 속성은 Page Router의 ISR 방식과 유사한 형태로 특정 시간을 주기로 캐시를 업데이트 하도록 설정하는 옵션이다. "revalidate: 3"이라는 속성을 부여하면 3초 주기로 데이터를 재호출하여 캐시를 업데이트하는 방식이라고 보면 된다.

 

위에서 살펴본 { cache:  "force-cache" } 속성은 한 번 호출한 데이터를 캐싱한 후 영구적으로 저장하는 방식이지만 본 방식은 데이터를 일정 주기로 교체만 해준다는 점만 다르다.

 

{ next: { tags: ['a'] } }

본 속성은 Page Router의 On-Demand Revalidate와 유사한 형태로 요청이 들어왔을 때 데이터를 최신화하는 옵션이다. 이 속성에 대해서는 추후에 더 자세히 다루어보도록 하자.

 

[#3 - Request Memoization]

Request Memoization

동일한 페이지에서 중복된 API 요청이 발생하게 되는 경우 중복된 API 요청을 Pre-Rendering 시에 캐싱을 통해 Memoization 처리를 해두어 하나의 API 요청만으로도 모든 컴포넌트에서 응답 받은 데이터를 활용할 수 있도록 하는 기능이다. Request Memoization은 NextJS가 자동으로 실행해주는 기능이다. 코드에서의 예시를 살펴보자.

//App.tsx

async function AllData(){
  const response = await fetch(API);
  if(!response.ok){return <div>Error!</div>}
  const data = await response.json();
  return(
    <div>
      {data}
    </div>
  )
}

export default function Home(){
  return (
    <div>
      <AllData />
    </div>
  )
};

//Page.tsx

async function Footer(){
  const response = await fetch(API);
  if(!response.ok){return <div>This is Footer</div>}
  const data = await response.json();
  const dataCount = data.length;
  return (
    <footer>
      <div>This is Footer</div>
      <div>There are {dataCount} data on this page</div>
    </footer>
  )
}

export default function Page(){
  return (
    <div>
      <Footer />
    </div>
  )
}

위의 코드는 동일한 페이지에 있는 Home 컴포넌트와 Page 컴포넌트에서 동일한 API로 요청을 보내고 동일한 데이터를 사용하는 간단한 예시 코드이다. 위와 같은 코드가 실행되면 NextJS에서는 자체적으로 동일한 API 요청이 발생한 것을 보고 Request Memoization을 실행하여 페이지 Pre-Rendering 과정에서 API 요청에 대한 응답 데이터를 캐싱해두어 해당 API 요청을 한 컴포넌트들에게 서버에게로 Data Fetching 없이도 Memoization 해 둔 데이터를 사용할 수 있도록 해준다. 위의 코드와 같은 형태의 로직을 렌더링 해보면 콘솔창에 호출한 API와 그 횟수가 출력되는데 다음과 같이 출력된다.

두 개의 API 요청 중에 아래에 있는 "http://localhost:12345/book" API에게 두 컴포넌트에서 요청을 보냈음에도 불구하고 API 요청 자체는 한 번만 일어난 것을 볼 수 있다. 따라서 별도의 설정 없이도 NextJS 자체적으로 Request Memoization을 실행하여 불필요한 API 요청의 중복을 막아주었다는 것을 확인할 수 있다.

 

여기서 추가적으로 뒤에 노란 문구를 보면 "cache skip"이라는 문구를 확인할 수 있다. Request Memoization 역시 데이터 캐싱 과정을 통해서 불필요한 API 요청의 중복을 방지한 것인데 캐싱 되지 않았다는 문구가 출력되는 이유는 무엇일까. 결론부터 말하자면 Request Memoization은 데이터의 캐싱 과정보다 전 단계인 Pre-Rendering 과정에 실행되기 때문이다. 우리가 앞서 살펴 본 Data Cache는 페이지가 Pre-Rendering 되면서 서버로부터 최초로 받아온 데이터를 Fetch Method의 두번째 인자로 설정해준 객체 값에 따라서 캐싱해두는 방법이 달랐었다. 하지만 Request Memoization은 페이지의 Pre-Rendering 과정, 데이터의 캐싱 과정(캐싱 데이터 확인 하는 영역), 서버로부터 데이터를 받아오는 과정 중에서 Pre-Rendering 과정에서 실행되기 때문에 페이지가 렌더링이 되고 나면 캐싱 되었던 데이터는 모두 소멸된다. 따라서 데이터 캐싱이 페이지 렌더링이 완료되면 모두 소멸되기 때문에 콘솔 창에 "cache skip"이라는 문구가 출력되는 것이다. 그에 따라 웹 페이지를 새로 고침한 경우 Request Memoization 되었던 API 요청이 실행되는 것을 보면 Pre-Rendering이 완료되면 Request Memoization은 종료되고 캐싱된 데이터는 소멸되는 것을 확인해 볼 수 있다.

 

정리하자면, Request MemoizationData Cache는 모두 백엔드 서버에게 데이터 호출을 통해 받아온 데이터를 저장, 캐싱 해놓을 수 있도록하는 기능이다.

Request Memoization은 하나의 페이지를 렌더링하는 동안 동일한 페이지 내에서 중복된 API 요청을 캐싱하여 불필요하게 API 호출을 방지하고 캐싱해둔 데이터를 사용할 수 있도록 해주는 방식이다. 추가적으로 Request Memoization은 NextJS의 Pre-Rendering 과정에서 실행되기 때문에 초기 렌더링이 종료되면 모든 캐시가 소멸되므로 페이지가 종료되었다가 다시 접속 요청이 발생하면 다시 서버 측에 데이터를 요청하고 새로운 Request Memoization이 진행된다

Data Cahce는 백엔드 서버로부터 불러온 데이터를 거의 영구적으로 보관하기 위해 사용되는 기능이다. Request Memoization과 백엔드 서버로부터 불러온 데이터를 캐싱해놓고 캐싱된 데이터를 사용한다는 부분은 동일하지만, Data Cache는 서버가 가동중에는 영구적으로 데이터가 보관된다는 차이점이 존재한다. 이 때 말하는 서버는 백엔드 서버를 의미한다.

위의 차이점이 있는 이유를 잘 생각해보면 Data Cache는 개발자가 fetch method를 사용할 때 캐싱 옵션을 설정해줌에 따라 캐싱 여부와 데이터 호출에 대한 부분을 설정할 수 있지만 Request Memoization은 NextJS가 기본적으로 제공해주고 동작하는 기능이기 때문에 차이점이 존재할 수 밖에 없다는 점이 이해가 된다.

 

여기서 추가적인 궁금증이 발생했다. 어차피 두 기능 모두 Data Fetching을 최적화하는 기능이며 Data Cache 기능을 사용하면 Request Memoization이 필요없지 않을까라는 궁금증을 가졌다. 이 부분에 대해서 찾아본 결과 다음과 같은 이유가 있다.

- Request Memoization은 하나의 컴포넌트 내에서 같은 요청이 여러 번 발생하는 상황을 최적화한다. 따라서 중복된 네트워크 요청을 줄이고 불필요한 데이터 로드를 방지하는 효과가 있다. 결과적으로 일회성 요청 최적화에 집중이 되어 있다.

- Data Cache는 응답 데이터를 캐시에 저장해 반복적으로 사용할 수 있도록 한다. 따라서 네트워크 트래픽을 줄이고 로딩 속도를 향상시킨다. 결과적으로 보다 지속적인 데이터 재사용에 중점을 두고 있다.

위와 같은 구체적인 기능과 목적의 차이점이 있기에 두 기능이 공존하고 있으며 두 기능을 상호 보완적으로 사용하여 성능 최적화를 극대화 할 수 있다.

'NextJS' 카테고리의 다른 글

NextJS[NextJS v.13~] - (4) Streaming  (4) 2024.11.01
NextJS[NextJS v.13~] - (3) Page Caching  (3) 2024.10.11
[NextJS v.13~] - (1) App Router  (1) 2024.09.26
[NextJS] - (4) SEO, Deploy  (6) 2024.09.19
[NextJS] - (3) SSR,SSG,ISR  (1) 2024.09.19

앞서 살펴본 NextJS의 Page Router는 NextJs의 12버전까지 사용하던 라우팅 방식이다. 13버전부터는 Page 폴더를 기반으로 하는 라우팅 구조가 아닌 App 폴더를 기준으로 라우팅 구조가 생성되는 App Rouer를 사용할 수 있게 되었으며 Page Router와 달라진 부분이나 새로 추가된 부분이 있기에 새로 공부해보고자 한다.

[#1 - App Router]

npx create-next-app@rc {Folder Name}
위의 커맨드 명령어의 @rc라는 부분은 현재 NextJs가 15버전이 출시되지 않았지만 추후에 출시가 될 것을 고려하여 미리 가장 비슷한 버전의 프레임워크를 설치하고자 입력해주는 명령어이다. 만약 가장 최신 버전을 설치하고 싶다면 @latest 명령어를 입력해주면 된다.
Use TypeScript
Use ESLint
Use TailwindCSS
Use 'src/ ' Dir
Use App Router
Use Turbopack
Customize Import Alias

 

[#2 - Routing]

App Router는 Page Router 구조와 다르게 라우팅 설정을 하기 위해서는 우리가 입력하고자하는 URL 주소에 맞게 폴더를 생성해준 뒤 해당 폴더 내부에 UI를 그려주는 역할을 하는 page 파일을 생성해 주어야만 한다. 폴더 구조를 살펴보자.

app router 폴더 구조

각 경로의 UI를 그려주는 page 파일을 코드로 살펴보면 아래와 같다.

// src/app/page.tsx
export default function Home(){
  return (
    <div>
      <h1>Home Page</h1>
    </div>
  )
}

// src/app/search/page.tsx
export default function Search({searchParams} : {searchParams: {q?: string}}) {
  return (
    <div>
      <h1>Search {searchParams.q}</h1>
    </div>
  )
}

// src/app/book/[id]/page.tsx
export default function Book({params}:{params: {id: string | string[]}}) {
  return (
    <div>
      <h1>Book {params.id}</h1>
    </div>
  )
}

 

 

위의 사진과 코드를 보면 app 폴더 하위에 book,search 폴더가 있다( (Home) 폴더는 뒤에서 다룰 내용). 저렇게 단순하게 폴더를 생성해주고 각 폴더 내부에 UI를 그려주는 page 파일을 생성해주면 자동으로 라우팅 경로 생성이 완료되는 것이다. book 페이지는 localhost:3000/book, search 페이지는 localhost:3000/search 경로로 접근을 하면 된다.

 

이 때 book 폴더의 하위에 있는 [id] 폴더는 앞서 말했듯이 Dynamic Routing(동적 라우팅) 생성 경로이다. Dynamic Routing은 Page Router 구조와 동일하게 적용되며 동작한다. Catch All SegmentOptional Catch All Segment 폴더로 모두 생성이 가능하다. 나온 김에 다시 한 번 살펴보자면 아래와 같이 정리할 수 있다.

src/app/book/[id]/page.tsx -> Dynamic Routing
=> URL 경로 : localhost:3000/book/1(가능) / localhost:3000/book/1/2/3(불가능) / localhost:3000/book(불가능)

src/app/book/[...id]/page.tsx -> Dynamic Routing + Catch All Segment
=> URL 경로 : localhost:3000/book/1(가능) / localhost:3000/book/1/2/3(가능) / localhost:3000/book(불가능)

src/app/book/[[...id]]/page.tsx -> Dynamic Routing + Optional Catch All Segment
=> URL 경로 : localhost:3000/book/1(가능) / localhost:3000/book/1/2/3(가능) / localhost:3000/book(가능)

 

다만 Page Router 구조와 다른 부분은 URL ParameterQuery String에 접근하는 방식이다. Page Router 구조에서는 useRouter 훅을 사용하여 훅 내부의 객체 형태로 존재하는 query 속성 내부에 query로 전달한 값이나 URL Parameter 값으로 전달한 값이 객체 형태로 존재하는 값들을 불러서 사용했었다. 하지만 App Router 구조에서는 폴더 구조를 따라서 Routing이 되기 때문에 Query 값들이 모두 하위 페이지의 Props로 전달이 되기 때문에 Props를 구조 분해 할당을 통해서 객체 형태로 뽑아 사용하면 된다. 주의해야할 점은 우리는 TS를 사용하고 있기 때문에 구조 분해 할당을 한 객체의 타입을 지정해 주어야 한다는 점이다. 코드 예시를 통해서 Query StringURL Parameter 값을 사용하는 방법을 살펴보자.

query string
url parameter

위의 코드 예시와 같이 Query String과 URL Parameter을 받아볼 수 있다.

 

여기서 추가적으로 알아두면 좋은 것이 있다. App Router 버전에서는 useRouter 훅을 통해서 Query String을 가져올 수 없게 되었다. 하지만 useSearchParams 라는 훅을 통해 검색어, 즉 Query String을 가져올 수 있는데 이전에 useRouter 훅의 query 속성의 속성값인 객체 값을 불러와 사용하던 것과 동일하게 useSearchParams 훅으로부터 get method를 통해 Query String 값을 문자열 형태로 받아오면 된다. 하지만 주의해야 할 점이 있는데 뒤에 나올 이야기지만 App Router 버전부터는 컴포넌트가 Client Component와 Server Component로 나뉘게 된다. React Hook들은 모두 Client Component에서만 사용이 가능하다. 그 이유에 대해서는 뒤에서 다룰 예정이다. 따라서 Client Component 내에서는 useSearchParams 훅을 이용해서 Query String 값을 가져올 수 있다.

 

[#3 - Layout]

Layout

Page Router 구조에서 다소 어렵게 구현했던 Layout을 App Router에서는 쉽게 구현할 수 있다. 폴더 구조 라우팅에 따라 특정 경로로 시작하는 페이지의 레이아웃을 생성하고자 하면 해당 폴더 하위에 page 파일과 같은 경로에 layout 파일을 생성해주기만 하면 된다. 예를 들어 app 폴더 하위에 search라는 경로를 생성하기 위해 폴더를 생성하였고 그 내부에 page 파일이 존재하는 경우, page 파일과 같은 위치에 layout 파일을 생성한 후 로직을 생성하면 /search로 시작하는 모든 페이지의 레이아웃으로 적용이 되게 되는 것이다. 따라서 search 폴더 하위에 추가 경로를 위해 생성한 폴더가 존재한다면 그 페이지 역시 search 페이지와 동일한 layout을 갖게 되는 것이다. 하지만 그 하위 폴더에 또 layout 파일이 존재한다면 레이아웃이 중첩으로 발생되게 되는 것이다.

정리하자면 특정 경로로 시작하는 페이지 하위에 적용하고 싶은 레이아웃이 있다면 해당 폴더의 UI를 그려주는 page 파일과 같은 위치에 layout 파일을 생성해주면 레이아웃 적용이 아주 간단하게 완료되는 것이다.

 

Route Group

Route Group이란 App Router 구조에서 폴더가 생성되면 라우팅이 구현되어 경로 상에 영향을 주는 것과 달리 폴더를 생성하여도 라우팅 경로 상에 아무런 영향을 주지 않는 폴더를 의미한다. 일반적으로 user와 같은 이름으로 폴더를 생성하면 /user 경로로 라우팅이 설정되어 해당 페이지로 접근이 가능하게 되는데, 만약 프로젝트 내에서 폴더명에 소괄호를 씌워주게 된다면 Route Group이 생성되어 경로에 영향을 주지 않는 일반적인 폴더를 생성할 수 있게 되는 것이다.

Route Group이 가장 기본적으로 사용되는 경우는 메인 페이지와 특정 페이지 하나만의 레이아웃을 갖고 싶은 경우에 사용한다. 메인 페이지의 경우에는 별도의 폴더에 속해있지 않고 app 폴더 하위에 page,layout 파일로 생성이 되어있기에 메인 페이지에 특정 레이아웃을 적용시키게 되면 하위 모든 페이지들에 공통적으로 적용이 된다. 하지만 예를 들어 메인 페이지와 검색 페이지에만 적용하고자 하는 검색 창 같은 공통 레이아웃이 존재할 때 app 폴더의 하위 폴더로 (Home) 폴더를 만들고 내부에 search 폴더와 메인 파일인 page, layout 파일을 넣어준다면 두 경로에만 적용되는 레이아웃을 생성할 수 있게 되는 것이다. 좀 더 쉬운 이해를 위해 폴더 구조를 사진으로 확인해보자.

route group

위의 폴더 구조처럼 소괄호로 감싼 폴더를 생성한 후 내부에 Root Page와 그룹으로 묶어서 레이아웃 설정을 하고자 하는 경로의 폴더를 넣어주고 Route Group 내에 별도의 layout 파일을 생성하면 레이아웃을 그룹화 할 수 있게 된다. 정리하자면 Route Group의 장점은 경로 상에 영향을 미치지 않으면서 레이아웃만 동일하게 설정이 가능하다는 점이다.

 

[#4 - React Server Component]

React Server Component

React Server Component란 React 18부터 새롭게 추가된 새로운 유형의 컴포넌트로 기존에 우리가 알고있던 React Component들은 모두 브라우저에서 실행되었었는데 브라우저가 아닌 서버측에서만 실행되는 Component를 React Server Component라고 한다. Server Component가 App Router에서 어떻게 동작하고 왜 등장했는지에 대해서 알아볼 필요가 있다.

우리가 기존에 사용하던 Page Router 구조에서는 Pre-Rendering(사전 렌더링)이라는 과정이 있었는데 사전 렌더링에서는  서버가 사용자의 접속 요청을 받고 JS 파일을 실행하여 HTML 파일로 렌더링을 한 후, 브라우저가 렌더링 된 HTML 파일을 받아 사용자에게 출력해주는 과정을 거쳤다. 그 이후 렌더링 된 HTML 파일에 브라우저 측에서 상호 작용을 위한 기능을 추가해주기 위해, 즉 Hydration(수화) 과정을 위해 JS Bundle 파일을 한 번 더 렌더링 해주면 수화 과정을 거치고 상호 작용이 가능한 완성된 페이지를 사용자에게 보여줄 수 있었다. 추가적으로 이 과정에서 처음으로 사용자에게 화면이 렌더링 되는데까지 걸리는 시간을 FCP(First Contentful Paint), 상호 작용이 가능하도록 수화 과정을 거친 후 완성된 페이지가 사용자에게 전달되는데가지의 시간을 TTI(Time To Interactive)라고 부르기도 했다.

여기서 Page Router의 단점이 드러나는데, Page Router 구조는 JS Bundle 파일을 사용자에게 재렌더링 하는 과정에서 상호 작용이 없는 페이지까지 모두 포함해서 JS Bundle 파일로 생성하여 수화 과정을 거치도록 하는 단점이 존재하였다. 어차피 상호 작용이 없는 페이지라면 처음 렌더링 된 화면 자체를 유지해도 될 뿐더러 수화 과정을 거칠 필요가 없는 것이다. 이 단점을 보완하기 위해 상호 작용이 필요한 페이지들만 따로 모아서 JS Bundle 형태로 재렌더링하는 과정을 통해 번들 파일의 용량을 줄이고 화면 렌더링에 대해 최적화를 하기 위해 Server Component가 등장하게 된 것이다. 특정 지시어를 통해서 Server Component와 Client Component를 구별할 수 있는데 그 때 사용하는 지시자가 다음에 나올 "use client" 지시자이다.

 

"use client" Directive

앞 단락에서 설명했듯이 상호 작용이 필요없는 Component들에 대해서는 Server Component로 유지하고 사용자와의 상호 작용이 필요한 Component들에 대해서는 Client 측에서 렌더링이 되도록 Client Component로 생성해주어야 한다. App Router 자체는 Component들을 기본적으로 Server 측에서 렌더링 하도록 동작하기 때문에 Client Component로 생성하기 위해서는 "use client" 지시자를 해당 컴포넌트 최상단에 입력해줌으로써 Server Component로 동작하던 Component를 Client Component로 동작하도록 변경해 줄 수 있는 것이다.

 

이렇게 Client Component와 Serve Component를 구분하게 되면 각 컴포넌트들은 실행되는 횟수가 달라진다. Server Component들은 서버측에서 Pre-Rendering을 진행할 때 딱 한 번만 실행이 되며, Client Component들은 Pre-Rendering 진행 시 한 번, Hydration 과정을 진행할 때 한 번 해서 총 2번이 실행된다. 각 컴포넌트에 console.log를 통해서 실행되는 횟수를 살펴보자.

Server Component

위의 사진은 Server Component의 렌더링 횟수를 확인해본 결과이다. 서버측에서만 한 번 렌더링이 되기에 브라우저의 콘솔 창에는 아무것도 출력되지 않고 서버의 콘솔 창에만 출력하고자 하는 문구가 출력된 것을 볼 수 있다.

Client Component

반면 위 사진은 Client Component의 렌더링 횟수를 확인해 본 결과로, "use client" 지시자가 입력된 것을 확인할 수 있고 서버측에서 한 번 렌더링 될 때 콘솔 창에 출력 된 것과 브라우저의 콘솔 창에 두 번 렌더링되면서 문구가 출력된 것을 볼 수 있다.

 

또한 추가적으로 확인해보자면 useEffect와 같은 React 훅은 브라우저에서 동작되는 훅이기 때문에 Server Component에서 상호작용이 필요한 React Hook들을 사용하면 오류가 발생한다. 아래 사진을 참고해보자.

Server Component useEffect

Server Component에서 useEffect 훅을 사용하려고 했더니 "This React hook only works in a client component"라는 에러 문구가 뜨면서 사용 불가하다고 알려준다.

 

NextJS 공식 문서에서는 정말 필요한 경우가 아니라면 가능한 Server Component로 구성할 것을 권장한다. 그 이유는 앞서 살펴봤듯이 Client 측에서 렌더링 할 컴포넌트가 적을수록 NextJS 서버가 브라우저에게 전달해줄 JS Bundle 파일의 용량도 줄어들 것이기 때문이다.

 

Co-Location

우리는 현재 프로젝트 내에서 라우팅 경로에 따른 폴더와 파일들을 생성하였지만 라우팅에 아무 영향을 끼치지 않는 별도의 컴포넌트 자체의 독립적인 파일은 app 폴더 하위에 단순하게 파일을 생성하면 된다. NextJS Framework가 인식하고 동작하는 page,layout,not-found 지정된 파일명이 아니라면 일반적인 JS,TS 파일로 인식하기 때문에 특정 page 파일에 필요한 컴포넌트는 같은 경로에 생성하여 사용해주면 된다. 이 방식을 Co-Location이라고 한다.

Co-Location

위의 폴더 구조에서 볼 수 있듯이 검색 바에 해당하는 컴포넌트 파일인 searchbar.tsx는 레이아웃이나 UI를 위한 파일이 아니라고 NextJs가 인식하기 때문에 일반적인 컴포넌트처럼 사용이 가능하다.

 

주의사항

1. Server Component에는 브라우저에서 실행될 코드가 포함되면 안된다.

- React Hooks나 Event Handler 등이나 브라우저에서 실행되는 기능을 담고 있는 라이브러리는 Server Component에서 실행되지도 않을뿐더러 해당 로직이 포함된 경우 Server Component 자체적으로 오류가 발생되기 때문에 사용해서는 안된다.

2. Client Component는 클라이언트에서만 실행되지 않는다.

- Client Component는 이름 때문에 헷갈릴 수 있지만 클라이언트 측에서'도' 실행이 되는 컴포넌트이지 서버에서 실행되지 않는 컴포넌트가 아니다. 모든 컴포넌트 들은 최초 접속 요청을 받고 난 후 JS에서 HTML 파일로 렌더링이 되는 과정을 거친 후 "use client" 지시자가 붙은 Client Component들만 따로 Hydration 과정을 위해 다시 한 번 렌더링이 되면서 실행이 되는 것이기 때문에 Client Component는 서버 측에서 Pre-Rendering 시 한 번, Hydration을 위해 한 번 해서 총 2번이 실행되기 되는 것이다.

3. Client Component에서 Server Component를 import 할 수 없다.

- 어떻게 보면 당연한 이유이기도 하다. Client Component는 Hydration 과정을 위해 한 번 더 실행이 되는데 Server Component는 Pre-Rendering 과정에서만 실행이 되기 때문이다. 조금 자세히 생각해보면 App Router 구조에서는 서버 측에서 Hydration을 위해 받아오는 JS Bundle 파일의 용량을 줄이기 위해 Server Component를 Hydration 과정을 위한 렌더링에서는 제외시키기 때문에 존재하지 않는 파일이 되기 때문이다.

하지만 NextJS에서는 이것 조차 커버를 해준다. 보통 프로젝트를 진행하다보면 수많은 파일과 수많은 Component가 존재하기 때문에 Client Component인지 Server Component인지 헷갈린 상태로 import 하는 경우가 발생할 확률이 높다. 이런 경우 NextJs는 오류를 발생시키지 않고 Client Component에 import 되어있는 Server Component를 Client Component로 전환시켜 동작하도록 해준다.

앞서 살펴보았듯이 Server Component들이 Client Component로 전환이 되면 Hydration 하는 JS Bundle 파일의 용량이 커지게 되면 TTI가 길어지게 되므로 비효율적인 상황이 발생할 수 있게 된다. 이런 경우 Client Component에게 Props 형태로 Server Component를 전달해주게 되면 NextJS에서는 Server Component의 결과만 Props로 전달해주기 때문에 클라이언트 측에서 불필요한 렌더링을 발생시키지 않게 된다. 본 내용은 쉬운 이해를 위해 코드로 확인을 해보도록 하자.

위의 코드를 보면 serverComponent 파일의 컴포넌트는 Server Component인데도 불구하고 Client Component에 import 되어 Client Component로 실행이 되어 브라우저의 콘솔 창에도 문구가 출력되는 것을 볼 수 있다. 이런 상황을 방지하기 위해 다음 예시로는 Client Component에 Server Component를 Props로 전달해보도록 하자.

위의 코드를 보면 Home 컴포넌트에서 Client Component의 Props(children)로 Server Component를 전달해주었고, 그 결과 Server Component의 불필요한 렌더링이 사라진 것을 확인할 수 있다.

 

4. Server Component에서 Client Component에게 직렬화 되지 않는 Props는 전달이 불가하다.

- 우선 직렬화(Serialization)란 객체, 배열, 클래스 등의 복잡한 구조의 데이터를 네트워크 상으로 전송하기 위해 아주 단순한 형태(문자열, Byte)로 변환하는 과정을 말한다. 우리가 JS를 사용하면서 알고 있는 대부분의 데이터는 직렬화가 가능하지만 JS에서 함수는 직렬화가 불가능한 데이터에 속한다. 결론적으로 함수는 직렬화가 불가능하기에 Server Component에서 Client Component로 함수를 Props로 전달하게 되면 오류가 발생하는 것이다.

이 이유에 대해서는 앞서 살펴본 Pre-Rendering(사전 렌더링)의 과정에 대해 한 단계 깊이 살펴보아야 한다. 우리는 사전 렌더링 과정에서 모든 컴포넌트들이 HTML 파일로 렌더링이 된다고 알고 있었지만 엄밀히 말하면 그 사이 과정에서 RSC Payload라는 결과물이 생성되는 과정이 먼저 진행된다. RSC PayloadReact Server Component Payload의 약자로 Server Component들을 직렬화한 결과물, 순수 데이터를 의미한다. RSC Payload에는 Server Component의 렌더링 결과, 연결된 Client Component의 위치, Client Component에게 전달하는 Props의 값 등 모든 데이터가 포함된다. 정리하자면 모든 컴포넌트 들이 사전 렌더링이 진행되어 HTML로 렌더링 되는 과정에서 Client Component보다 Server Component가 먼저 렌더링이 된다는 것이다. 따라서 사전 렌더링 과정에서 직렬화가 되는 것인데 직렬화 되지 않는 데이터인 함수가 Server Component에서 Client Component로 전달이 되면 RSC Payload에 포함되는 데이터의 오류가 발생할 수 밖에 없게 되는 것이다.

 

[#5 - Navigation]

App Router 버전에서 페이지 이동은 Page Router 버전과 동일하게 CSR 방식으로 동작한다. 복습 차원에서 Page Router 버전에서 Navigate 동작 방식을 간단하게 되짚어 보자.

1. 초기 접속 요청이 발생한 뒤, 페이지 이동에 대한 요청이 발생.
2. 이동하고자하는 페이지에 대한 컴포넌트들이 담긴 JS Bundle 파일을 요청과 동시에 서버로부터 전달받음.
3. 이동하고자 하는 페이지의 JS 파일을 실행하여 페이지를 교체.

이에 더해 JS Bundle 파일을 받아올 때 페이지의 용량이 매우 커서 렌더링에 많은 시간이 걸리는 것을 방지하기 위해 사용자가 초기에 접속 요청을 하였을 때 해당 페이지에서 이동 요청이 가능한 요소들에 대해 Pre-Fetching이 적용되어 해당 페이지에서 이동이 가능한 페이지들에 대한 데이터까지 미리 받아오는 동작도 존재했었다.

App Router도 앞서 말한 방식과 동일하게 동작하는데 App Router 버전에서 Server Component가 추가되면서 추가로 동작하는 과정이 하나 생겼다. 바로 RSC Payload의 호출이다. 그 이유는 App Router 버전에서는 Client Component와 Server Component의 구분이 생기면서 페이지 이동 요청이 발생했을 때 반환되는 JS Bundle 파일에는 Client Component들만 존재하기 때문에 렌더링하고자 하는 페이지의 Server Component 요소들이 같이 호출되지 않게 되는 것이다. 따라서 페이지를 구성하는 모든 컴포넌트들을 다 호출해야 하기 때문에 Client Component가 들어있는 JS Bundle과 Server Component의 렌더링 결과물인 RSC Payload까지 함께 호출을 하게 된다. 그 결과 JS Bundle을 실행하며 RSC Payload와 합쳐 페이지를 교체해주게 되는 것이다.

 

Server Component로 구성되어 있는 페이지로 이동을 하였을 때 브라우저의 network 탭을 통해서 호출 된 데이터를 확인해보면 다음과 같이 구성되어 있다.

Server Component로만 구성되어 있기 때문에 Home 페이지에서 Search 페이지로 이동을 했을 때 Payload만 호출 받아오기 때문에 Payload 데이터를 브라우저에서 위와 같이 확인 가능하다. 만약 Search 페이지에 Client Component가 포함되어 있다면 어떨까

위와 같이 Client Component도 함께 호출 된 것을 확인할 수 있다.

 

Programmatic Navigate

Programmatic Navigate란 사용자의 동작에 의해 이벤트 핸들러가 동작하여 페이지 이동하는 것을 의미한다. 사용자의 검색 동작이나 버튼 클릭과 같은 특정 이벤트가 발생했을 경우 페이지를 이동하도록 설정해놓은 로직에 의해 페이지가 이동하는 것이라고 보면 된다. 이 때 사용하는 Hook은 Page Router에서도 사용해 본 useRouter Hook이다. 헷갈리지 말아야 할 부분이 Link 태그는 HTML의 anchor 태그를 React에서 사용하기 위해 컴포넌트화 된 요소이기 때문에 특정 이벤트가 발생하는 요소가 아니다. 이벤트 발생에 따른 프로그래매틱한 페이지 이동을 위해 검색창이나 버튼에 onChange, onClick 함수를 넣어주고 이벤트가 발생하였을 때 useRouter 훅의 push method를 이용해서 페이지 이동을 하도록 코드를 작성해보자. 여기서 주의해야할 점이 있는데 앞서 Page Router에서 useRouter 훅을 사용할 때에는 "next/router"가 제공해주는 훅을 사용했지만 App Router 버전에서는 "next/navigation"이 제공해주는 훅을 사용해야 한다. 그렇지 않으면 오류가 발생한다. 아래 코드 예시로 useRouter 훅을 사용하여 Programmatic Navigate를 구현해보자.

// SearchBar.tsx
"use client"
import {useState} from 'react';
import {useRouter} from 'next/navigation';

export default function SearchBar(){
  const router = useRouter();
  const [search, setSearch] = useState("");
  const onChangeSearch = (e:React.ChangeEvent<HTMLInputElement>) => {
    setSearch(e.target.value)
  }
  const onSubmit = () => {
    router.push(`/search?q=${search}`)
  }
  
  return (
    <div>
      <input type="text" value={search} onChange={onChangeSearch} />
      <button onClick={onSubmit}>Search</button>
    </div>
  )
}

위와 같이 코드를 작성하면 input 창에 사용자가 입력한 값에 따라 Search 페이지에 Query String과 함께 이동을 하게 된다.

 

Pre-Fetching

Pre-Fetching이란 Page Router 버전에서도 존재했던 방식으로 사용자가 현재 위치해 있는 페이지로부터 이동이 가능한 페이지에 대한 정보를 미리 호출을 해놓아서 페이지 이동 요청이 발생했을 때 이동하고자 하는 페이지의 용량이 큰 경우를 대비하여 서버 측에서 JS Bundle 파일을 생성하여 페이지 렌더링까지 걸리는 시간을 최소화 하기 위해서 실행되는 동작이다. Pre-Fetching은 우리가 제일 많이 사용하는 npm run dev를 통한 개발 환경에서는 확인할 수 없고 npm run build를 통해 프로젝트를 빌드하고 Production 환경에서 확인이 가능하다.

위의 사진은 보면 프로젝트를 빌드하고 Production 환경을 실행한 결과이다. 현재 위치하고 있는 페이지는 Home 페이지인데 Navbar에 보면 Search 페이지와 Book1 페이지로 이동이 가능한 Link 태그가 존재한다. 두 요소는 페이지 이동 요청을 보낼 수 있는 요소이기 때문에 NextJS에서 프로젝트 빌드 시점에 미리 두 페이지에 대한 JS Bundle 파일을 호출을 해오는 Pre-Fetching을 하였다. 브라우저의 network 창에 목록들중 선택 되어있는 항목과 그 아래 항목은 각각 Search, Book 페이지에 대한 호출 결과이다.

추가적인 정보를 간단하게 말하자면 App Router에 존재하는 모든 페이지는 기본적으로 Static이거나 Dynamic한 페이지로 나뉜다. Static한 페이지는 Page Router 버전에서 살펴보았던 SSG 방식처럼 Pre-Rendering 되는 빌드 타임에 미리 생성되는 정적인 페이지를 의미하고 Dynamic한 페이지는 SSR 방식처럼 Pre-Rendering 되는 브라우저의 요청을 받을 때 마다 생성되는 동적인 페이지를 의미한다. Page Router에서 모든 페이지는 기본적으로 SSG 방식으로 렌더링 되었던 것처럼 App Router에서도 모든 페이지는 기본적으로 Static한 페이지로 렌더링이 되지만 컴포넌트 내부에 Query String, URL Parameter와 같이 동적으로 동작하는 요소들이 존재한다면 자동으로 Dynamic한 페이지로 설정이 된다. 이에 따라 Static 페이지는 빌드 시점에 JS Bundle과 RSC Payload를 모두 Pre-Fetching하지만 Dynamic 페이지는 RSC Payload만 Pre-Fetching한다는 특이점이 존재한다.

가장 중요한 점을 정리해보자.

App Router 버전에서 Client Component는 JS Bundle로, Server Component는 RSC Payload로 렌더링이 진행된다.

 

'NextJS' 카테고리의 다른 글

NextJS[NextJS v.13~] - (3) Page Caching  (3) 2024.10.11
NextJS[NextJS v.13~] - (2) Data Fetching on App Router  (1) 2024.10.05
[NextJS] - (4) SEO, Deploy  (6) 2024.09.19
[NextJS] - (3) SSR,SSG,ISR  (1) 2024.09.19
[NextJS] - (2) Page Router  (2) 2024.09.10

[#1 - SEO(Search Engine Optimization)]

SEO

SEO란 Search Engine Optimization의 약자로 검색 엔진 최적화라는 뜻을 가진다. 검색 엔진 최적화는 우리가 배포하고 운용 중인 웹 페이지가 구글이나 네이버 같이 검색 엔진에게 잘 노출이 되도록 웹 페이지의 정보들을 코드에 입력해 줌으로써 사용자들의 검색 결과에 더 빈번하고 쉽게 노출되도록 설정해주는 작업을 의미한다.

웹 페이지의 정보를 담고 있는 태그는 HTML에서 다루어 보았던 meta 태그이다. NextJS에서도 마찬가지로 meta 태그를 활용하여 페이지의 UI를 담고 있는 index 파일들에 SEO를 적용해 볼 예정인데 그 전에 한가지 개념이 필요하다.

 

Head

HTML을 공부했던 때로 되돌아 가보자. 기본 파일 구조를 떠올려보면 header, body 태그가 기본적으로 존재했던 기억이 있을 것이다. body 태그에는 웹 페이지의 내용이 들어있고, header 태그에는 웹 페이지의 정보가 담겨 있었다. meta 태그도 당연히 header 태그 내에 들어있었다. NextJS에서 meta 태그를 사용하기 전에 next/head가 제공해주는 Head 태그를 import 해서 사용해야 한다. 코드는 다음과 같다.

import Head from 'next/head';

NextJS에서 자체적으로 제공해주는 Head 태그이다. 이 때 주의해야 할 점은 Next에서 제공해주는 Head 태그는 두 가지가 있는데 하나는 next/head, 다른 하나는 next/document에서 제공해준다. 대부분의 페이지에서 사용할 Head 태그는 head에서 제공해주는 태그를 사용해야하고 document에서 제공해주는 Head는 프로젝트 최상단의 document.tsx 파일의 Head 태그에 사용해야 한다.

이어서 Head 태그를 import 해 온 뒤 내부에는 HTML에서와 동일하게 title 태그로 웹 페이지의 제목을 입력해주고, meta 태그를 통해서 SEO를 설정하면 된다. 본 프로젝트에서는 다음과 같이 설정하였다.

import Head from 'next/head';

export default function Home(){
  return (
    <>
      <Head>
        <title>Books</title>
        <meta property="og:image" content="/thumbnail.png" />		//대표 이미지
        <meta property="og:title" content="Books" />			//제목
        <meta property="og:description" content="Hello Books" />	//페이지 요약
      </Head>
    </>
  )
}

여기서 meta 태그의 속성들에 대해 살펴보면 HTML에서의 meta 태그 속성 중 name이라는 속성이 있었는데 name과 같은 역할을 하는 속성이 property 속성이다. ogOpen Graph의 약자로 Open Graph는 페이스북에서 만든 프로토콜이다. 이 프로토콜은 공유하고자 하는 웹 페이지를 미리보기로 구성하여 볼 수 있도록 해주는 프로토콜이다. 우리가 해당 페이지를 외부로 공유할 때 미리보기로 보여줄 정보들과 포함할 정보들을 입력할 때 쓰는 속성이라고 이해해두면 쉽다.

따라서 OG의 기본 속성인 og:image, og:title과 옵션 속성인 og:description을 넣어주었다. 각각 페이지의 대표 이미지, 페이지의 제목, 페이지의 내용 요약이다. 위 코드와 같이 Head 태그를 구성하여 페이지 UI를 담당하는 파일들의 JSX 상단에 넣어주면 SEO 설정이 완료가 된다. 또한 동적 라우팅으로 생성되는 페이지는 서버로부터 받아온 데이터를 meta 태그의 content 속성값으로 넣어주어도 된다. 하지만 여기서 한가지 중요하게 짚고 넘어가야할 점이 있다.

 

우리는 대부분의 페이지를 SSG, ISR로 Pre-Rendering을 해오고 있다. 그래서 동적으로 생성되는 페이지들은 우리가 getStaticPaths 함수 내에서 paths로 미리 Rendering 해오지 않은 페이지들에 대해서는 { fallback: true } 옵션에 의해 요청이 들어온 순간 SSR 방식으로 동작해 요청에 맞게 페이지를 서버로부터 요청하여 렌더링해주는 방식으로 생성된다. 따라서 paths로 미리 불러놓지 않은 페이지들은 우리가 해당 페이지로 접속 요청을 보냈을 때 Head 태그를 포함해 우리가 작성해둔 JSX 코드들은 빌드 시점에는 불러온 데이터가 없기 때문에 동적으로 meta 태그를 생성하지 못하게 되는 것이다. 간단하게 아래 코드를 통해 무슨 말인지 이해해보자.

export const getStaticPaths = () => {
  return {
    paths: [
      { params: { id: "1" } },
      { params: { id: "2" } },
      { params: { id: "3" } },
    ],
    fallback: true,
  };
};
 
 // 글 맥락상 getStaticProps 함수는 생략
 
export default function Book({
  book,
}: InferGetStaticPropsType<typeof getStaticProps>) {
  const router = useRouter();
  if (router.isFallback) {
    return (
      <>
        <div>Loading...</div>
      </>
    );
  }
  if (!book) return "Error";
  const { id, title, subTitle, description, author, publisher, coverImgUrl } =
    book;
  return (
    <>
      <Head>
        <title>{title}</title>
        <meta property="og:image" content={coverImgUrl} />
        <meta property="og:title" content={subTitle} />
        <meta property="og:description" content={description} />
      </Head>
      <div>
        <div style={{ backgroundImage: `url('${coverImgUrl}')` }}>
          <img src={coverImgUrl} alt="CoverImg" />
        </div>
        <div>{title}</div>
        <div>{subTitle}</div>
        <div>
          {author} | {publisher}
        </div>
        <div>{description}</div>
      </div>
    </>
  );
}

위의 코드를 간단하게 분석해보면 Book Component는 Dynamic Routing을 통해 생성되는 동적 페이지이다. 따라서 Head 태그를 보면 Content와 Title의 값이 book 데이터로부터 받아온 값들로 할당이 되어있다. 하지만 getStaticPaths 함수에 paths는 id가 3인 페이지까지만 설정이 되어있기에 그 이외의 숫자 값이 포함된 동적 경로로 페이지를 호출하면 빌드 타임에 호출되지 않는 페이지들은 fallback 옵션에 의해 SSR 방식으로 그 즉시 서버로부터 해당 페이지를 호출하여 렌더링 해준다. 그 과정 속에서 props가 없는 fallback 페이지를 렌더링 해준 후 데이터 호출이 완료되면 Component에 props를 전달하여 데이터가 모두 포함된 완성된 페이지를 렌더링 해주기 때문에 처음 페이지를 호출하였을 때에는 Head 태그가 존재하지 않는 것이다.

따라서 이런 경우를 방지해주기 위해서는 본 Component 상단에 useRouter 훅의 isFallback 속성이 true인 경우 로딩문구를 출력해주도록 한 로직을 활용해야 한다. 해당 로직은 페이지가 fallback 상태의 페이지일 때 보여주는 요소들을 선언해주는 로직으로 해당 로직에 Head 태그의 defalut 값을 입력해놓으면 fallback 상태의 페이지일 때에도 우리가 원하는 SEO 설정을 구현할 수 있게 된다. 적용하면 아래와 같은 형태가 된다.

export const getStaticPaths = () => {
  return {
    paths: [
      { params: { id: "1" } },
      { params: { id: "2" } },
      { params: { id: "3" } },
    ],
    fallback: true,
  };
};

 // 글 맥락상 getStaticProps 함수는 생략�

export default function Book({
  book,
}: InferGetStaticPropsType<typeof getStaticProps>) {
  const router = useRouter();
  if (router.isFallback) {
    return (
      <>
        <Head>
          <title>Books</title>
          <meta property="og:image" content="/thumbnail.png" />
          <meta property="og:title" content="Books" />
          <meta property="og:description" content="Hello Books" />
        </Head>
        <div>Loading...</div>
      </>
    );
  }
  if (!book) return "Error";
  const { id, title, subTitle, description, author, publisher, coverImgUrl } =
    book;
  return (
    <>
      <Head>
        <title>{title}</title>
        <meta property="og:image" content={coverImgUrl} />
        <meta property="og:title" content={subTitle} />
        <meta property="og:description" content={description} />
      </Head>
      <div>
        <div style={{ backgroundImage: `url('${coverImgUrl}')` }}>
          <img src={coverImgUrl} alt="CoverImg" />
        </div>
        <div>{title}</div>
        <div>{subTitle}</div>
        <div>
          {author} | {publisher}
        </div>
        <div>{description}</div>
      </div>
    </>
  );
}

 

[#2 - Deploy(배포)]

Vercel

프론트엔드에서 많이 사용하는 프로젝트 배포 방법에는 Netlify, Vercel, Github Pages 등이 있다. NextJS는 Vercel 회사에서 만든 React 기반 FrameWork이기 때문에 Vercel로 배포를 해보도록 하자.

Vercel 홈페이지에 가입이 완료되었다면 진행중인 프로젝트로 와서 Vercel을 설치해 주어야 한다.

1. npm install -g vercel(macOS인 경우 sudo를 맨 앞에 붙여서 실행) 명령어를 통해 설치해준다. 전역적으로 설치를 하기 위해 global을 붙여서 설치해준다.

2. 설치가 완료되었다면 프로젝트를 진행하던 IDE에서 Vercel로 로그인을 해준다. vercel login 명령어를 통해서 로그인을 진행해준다. 내가 사용하는 이메일을 통해서 로그인을 해주면 다음과 같이 완료 문구가 뜨게 된다.

vercel login

3. 로그인이 완료되면 vercel 명령어를 통해 프로젝트 배포를 위한 설정을 시작한다. 명령어를 입력하면 여러가지 질문이 나오는데 본인이 진행중인 프로젝트와 Vercel 계정에 따라서 설정해주면 된다. 완료되면 Production 환경으로 배포된 링크가 나오면서 문구가 뜬다. 다음과 같은 방식으로 진행된다. 이 때 주의해야할 점은 프로젝트 이름에 대문자를 포함시키면 에러가 발생하기 때문에 소문자로 만들어주어야 한다.

vercel client

4. 이후 개인 프로젝트 진행 시 개인이 만든 서버가 존재한다면 서버 역시 배포를 같이 해주어야 한다. 배포해주었다면 다음과 같이 안내 문구가 뜬다.

vercel server

5. 개인이 만든 서버라면 프로젝트 내부에서 개발용 백엔드 서버로 데이터 요청을 보내던 링크를 배포가 완료된 서버 주소로 변경해주어야 한다. 그렇지 않으면 로컬에서 백엔드 서버를 실행시켜주지 않았을 경우 아무 데이터를 받아볼 수 없기 때문이다. 모두 배포가 완료되었다면 Vercel 홈페이지에서 개인 대시보드를 보면 다음과 같이 보이게 된다.

vercel dashboard

6. 만약 5번 과정을 진행했다면 다시 한 번 vercel --prod 명령어를 통해서 바로 Production 환경으로 배포를 하도록 실행해준다. 성공한다면 다음과 같이 안내문구가 뜨면서 배포가 완료된다.

vercel --prod

추가로 배포가 완료되면 대시보드에서 해당 프로젝트로 들어가면 다음과 같이 배포된 페이지의 정보를 볼 수 있게 된다.

vercel deploy

정보 창에서 Domains 주소가 배포 완료된 주소이다.

위와 같은 과정을 통해서 배포를 완료했으며 배포 완료된 링크를 SNS를 통해서 공유해보면 앞서 SEO 설정 시에 썸네일로 설정해두었던 사진이 썸네일로 뜨면서 전송이 되는 것까지 확인할 수 있다.

 

이렇게해서 NextJS의 Page Router 방식을 이용해서 Pre-Fetching, CSS Modules, Layout, Pre-Rendering(SSR,SSG,ISR,OnDemandISR), SEO, Deploy 과정을 학습하며 Vercel로 배포까지 완료해보았다. 추후에는 NextJS v13 이후부터 사용되는 App Router 방식에 대해서 학습해보도록 하겠다.

'NextJS' 카테고리의 다른 글

NextJS[NextJS v.13~] - (2) Data Fetching on App Router  (1) 2024.10.05
[NextJS v.13~] - (1) App Router  (1) 2024.09.26
[NextJS] - (3) SSR,SSG,ISR  (1) 2024.09.19
[NextJS] - (2) Page Router  (2) 2024.09.10
[NextJS] - (1) NextJS  (1) 2024.09.10

[#1 - Pre-Rendering & Data Fetching]

우리가 이전에 React를 사용해서 Data Fetching을 하는 과정을 되짚어보면 다음과 같이 정리할 수 있다.

1. 호출한 데이터를 보관할 State 생성
2. Data Fetching을 위한 함수 생성
3. Component의 Mount 시점에 Data Fetching 함수 호출
4. Data Loading 과정의 예외 처리

 

React는 CSR 방식으로 동작하고 위의 과정은 Component가 Mount 되는 시점에 Data Fetching을 실행하기 때문에 사용자가 데이터를 받기까지 시간이 오래 걸린다는 단점이 있었다. 첨언하자면 CSR로 인해 느린 FCP에 더해 그 이후 Data Fetching을 실행하기 때문에 데이터를 받기까지 시간이 오래 걸리게 되는 것이다.

하지만 NextJS에서는 SSR 방식과 더불어 Pre-Rendering 과정에서 Data Fetching을 통해서 React의 느린 FCP를 보완하고 Data Fetching을 미리 실행하기 때문에 더 빠르게 화면을 보고 데이터를 더 빠르게 받아볼 수 있게 된다.

간단히 정리해보자면 다음과 같다.

React App의 Data Fetching Next App의 Data Fetching
Component Mount 이후에 발생 Pre-Rendering 중에 발생
Data Fetching 시점이 늦어짐 Data Fetching 시점이 매우 빨라짐

 

하지만 여기서 의문을 가질 수 있는 부분이 존재한다. NextJS가 아무리 빠른 시점에 데이터를 가져온다고 하여도 프로젝트 내에서 받아와야 하는 데이터의 양이 정말 방대하다면 Pre-Rendering 과정에서 많은 시간을 소요하여 React 보다 더 비효율적인 상황이 발생할 수도 있지 않을까라는 의문을 가질 수 있는 것이다.

NextJS는 이런 상황도 방지할 수 있도록 프로젝트를 빌드하는 시점에 미리 Pre-Rendering을 통해 Data Fetching을 실행하여 두는 방식이 존재한다. 사용자로부터 웹 페이지 접속 요청이 들어왔을 때 빠르게 Pre-Rendering을 통해서 데이터를 가져오는 방법도 있는 반면 데이터의 양이 방대한 경우 사용자의 요청이 있기 전에 프로젝트 자체를 빌드하는 시점에 Pre-Rendering을 통해 데이터를 가져오는 방법 또한 존재한다. 이 방법을 SSG라고 하는데 이 부분은 추후에 다루어 보도록 하자.

정리하자면 NextJS는 다음과 같이 3가지의 Pre-Rendering 방식이 존재한다.

SSR(Server Side Rendering) SSG(Static Site Generation) ISR(Incremental Static Regeneration)
서버 사이드 렌더링 정적 사이트 생성 증분 정적 재생성
요청이 있을 때 마다 사전 렌더링 방식 빌드 타임에 페이지 사전 렌더링 방식 -

 

[#2 - SSR(Server Side Rendering]

SSR(Server Side Rendering)

- SSR이란 CSR 방식의 단점을 보완한 Pre-Rendering 방식으로 사용자가 페이지 접속 요청을 보낼 때마다 서버에서 JS파일을 실행하여 렌더링을 해주는 방식이다. React의 동작 방식인 CSR은 빈 HTML 파일에 JS 파일을 다운 받아 브라우저에서 렌더링을 해주는 과정을 거치기 때문에 동작 시간이 오래걸리는 반면, SSR은 접속하고자 하는 페이지의 JS 파일을 미리 다운 받아 HTML 파일을 생성하여 브라우저에 보내주기 때문에 사용자가 더 빠르게 완성된 웹 페이지를 받아볼 수 있는 장점이 있다. 또한 페이지 요청이 올 때마다 서버측에서 페이지를 제작하여 보내주기 때문에 페이지 내부의 데이터를 항상 최신으로 유지할 수 있다는 장점도 존재한다. 하지만 백엔드 서버로부터 데이터를 가져오는 과정이 길어지는 경우 페이지 전체의 동작이 지연된다는 단점이 있다.

 

getServerSideProps

getServerSideProps란 NextJS 내의 특정 컴포넌트를 SSR 방식으로 Pre-Rendering 하고자 할 때 사용하는 함수이다. 특정 컴포넌트의 위에  getServerSideProps를 선언해주며 export 처리해주면 NextJS Framework 자체적으로 인식하여 해당 함수를 본 컴포넌트보다 먼저 실행되면서 컴포넌트에 필요한 데이터를 미리 받아올 수 있도록 해준다. getServerSideProps는 Pre-Rendering 과정에서 단 한 번만 실행이 되고 서버측에서만 실행이 되는 함수이다. 따라서 window 객체 같이 브라우저에서 사용 가능한 객체는 사용이 불가능하다. 또한 getServerSideProps 함수를 통해 필요한 데이터를 미리 받아오는 Pre-Rendering이기 때문에 본 함수는 props 객체를 반환해야한다. 코드 사용 방식은 다음과 같다.

import { InferGetServerSidePropsType } from "next";

export const getServerSideProps = () => {
  const data = "Hello";
  return {props: {data}};
};
export default function Home({data}: InferGetServerSidePropsType<typeof getServerSideProps>){
  return (
    <div>{data}</div>
  )
}

위의 코드 예시에서는 임의로 data 변수를 생성하였지만 실제로는 getServerSideProps를 통해서 data를 미리 호출 받아 props 객체로 반환해준다. 이후 props 객체를 Component에서 props로 받아(위의 예시에서는 구조분해할당을 사용하였다) 사용할 수 있는 것이다.

추가적으로 위의 예시에서 볼 수 있듯이 props로 받는 객체에 대한 Type도 지정을 해주어야 한다. Type은 일반적으로 NextJS에서 기본적으로 제공해주는 Type을 사용하면 되는데 SSR을 통해 받은 데이터는 위와 같이 InferGetServerSidePropsType의 Generic 문법을 통해 InferGetServerSidePropsType<typeof getServerSideProps>와 같이 정의해줄 수 있다.

 

Server(Client Server) & Browser

NextJS에서 Page 역할을 하는 Component는 Server에서 한 번 사용자의 요청이 발생했을 때 Pre-Rendering이 되고 Browser에서 또 한 번 JS Bundle 형태로 전달이 될 때(Hydration) Rendering이 되어 총 2번 렌더링이 진행된다. 그래서 Component 내에서 console.log를 통해 데이터를 출력하면 터미널에 한 번 브라우저에 한 번 총 2번 출력이 되는 것을 알 수 있다. 따라서 앞서 언급하였듯이 Component에서도 마찬가지로 Server에서 렌더링이 되는 경우가 있기 때문에 window 객체와 같이 브라우저에서 사용이 가능한 객체들을 사용하면 오류가 발생한다.

하지만 사용이 불가능한 것은 아니다. 브라우저 측에서 사용할 수 있는 객체들을 사용하기 위해서는 Component가 Mount 되고 나서 사용하면 Server 측에 렌더링이 되었을 때는 사용하지 않을 수 있기 때문에 오류가 발생하지 않을 것이다. 따라서 브라우저에서만 사용이 가능한 객체를 사용하기 위해서는 Component 내에 useEffect 훅을 사용해서 Component가 Mount 되는 시점에 동작하도록 의존성 배열을 빈 배열로 부여하면서 window 객체 같이 브라우저용 객체를 사용하면 오류가 발생하지 않고 정상적으로 사용이 가능하게 된다.

 

getServerSideProps 활용하기

특정 Component를 SSR 방식으로 Pre-Rendering하기 위해서 getServerSideProps 함수를 사용한다고 앞에서 살펴보았다. 본 함수를 활용하는 방식의 예시와 Type 정의 등 몇가지를 알아보도록 하자.

 

1. context: GetServerSidePropsContext

getServerSideProps 함수를 사용할 때 NextJS에서 기본 제공되는 context 객체를 사용할 수 있다. context 객체 안에는 정말 많은 속성이 존재하지만 우리가 지금 단계에서 알아야 할 속성은 query와 params이다. 검색 기능을 구현하였을 때 Query String을 사용하게 되는데 사용자가 검색한 단어를 context.query.q를 통해 접근하여 url 요청 같은 기능에서 사용이 가능하다.

또한 Dynamic Routing을 구현하였을 때 URL Parameter을 볼 수 있는데 이것 또한 context.params.id를 통해 URL Parameter에 접근하여 해당 페이지에 대한 url 요청을 보내거나 고유 id 값으로 사용을 하는 것도 가능하다.

이 때 우리는 현재 TS를 사용중이기 때문에 context의 Type을 정의해주어야 하는데, context의 Type은 NextJS에서 제공해주는 GetServerSidePropsContext로 정의해주면 된다.

 

2. InferGetServerSidePropsType<typeof getServerSideProps>

앞서 살펴본 정보이기도 하지만 다시 한 번 학습하자면 우리는 getServerSideProps 함수를 통해서 Pre-Rendering을 통해 데이터를 받은 후 Component에 props로 전달해주어서 데이터를 활용하게 된다. 이 때 props의 타입을 정의해주게 되는데 NextJS에서 기본적으로 props에 대한 Type을 제공해주는데 InferGetServerSidePropsType와 Generic 문법을 합쳐서 전달받은 데이터인 props 객체의 타입을 정의해주어 사용하게 된다.

 

3. Promise<T[ ]>

이 Type 자체는 getServerSideProps 함수와는 직접적으로 연결되어 있지는 않지만 별도로 우리가 Data Fetching을 위한 TS 파일을 생성하여 데이터 호출을 위한 함수를 사용할 때 반환값에 대한 Type으로 정의되는 Type이다. async/await을 통해 Data Fetching을 하는 함수가 있을 때 해당 함수의 반환값은 Promise가 되므로 NextJS에서 제공해주는 Promise Type을 정의해주면 되지만 데이터를 올바르게 받아온 경우에러가 발생한 경우 다른 값을 반환하게 되기 때문에 Promise와 Generic 문법을 사용하여 Promise<T>와 같은 형태로 사용해주면 된다.

 

[#3 - SSG(Static Site Generation)]

SSG(Static Site Generation)

- SSG정적 사이트 생성이라는 뜻으로 SSR의 단점을 해결하는 Pre-Rendering 방식 중에 하나이다. 여기서 다시 SSR의 장점과 단점을 간단하게 살펴보면 SSR은 빠른 페이지 이동과 데이터의 빠른 최신화가 가능하다는 장점이 있지만 백엔드 서버로부터 데이터 요청이 지연되는 경우 웹 페이지 전체의 동작이 지연되고 멈추게 되는 단점이 있다. 이런 SSR의 단점을 보완하기 위해 SSG 방식을 사용하게 된다.

SSG는 프로젝트의 빌드 타임에 페이지를 미리 Pre-Rendering 해주는 방식을 말한다. 사용자가 웹 페이지에 접속을 하고 특정 페이지로의 이동 요청을 포함한 모든 요청이 발생하기 전, 프로젝트 자체가 빌드하는 시점에 백엔드 서버로부터 필요한 데이터를 모두 받아오고 JS Bundle 파일을 모두 Pre-Rendering 해두기 때문에 사용자의 요청이 발생하고 나서 추가적으로 서버에 요청을 보낼 필요가 없어지기에 굉장히 빠른 속도로 응답을 해줄 수 있게 된다. 따라서 FCP는 물론 TTI까지 굉장히 빠른 속도로 처리가 가능하게 되어 Pre-Rendering에 많은 시간이 소요되더라도 사용자의 요청에는 매우 빠른 속도로 응답이 가능하다는 엄청난 장점을 보유하고 있는 방식이다.

하지만 정적 사이트 생성이라는 이름에서 알 수 있듯이 한 번 렌더링 된 페이지에 대한 요청은 다시 보내지 않기 때문에 새로운 데이터의 반영에는 어려움이 있다는 단점이 존재한다. 빌드 시점에 페이지를 모두 정적인 상태로 렌더링 해두었기 때문에 빠르게 응답이 가능한 반면 새로운 응답에는 어려움이 있기 마련인 것이다.

따라서 SSG 방식은 페이지 내에 데이터의 변화가 거의 없는, 데이터의 요청이 필요하지 않은 페이지에서 사용하기에 용이하다고 할 수 있다.

 

getStaticProps

특정 컴포넌트가 SSR 방식으로 동작하게 해주기 위해서 우리는 해당 컴포넌트가 들어있는 파일의 상단에 getServerSideProps라는 함수를 사용해서 Data-Fetching과 같은 동작을 구현했다. SSG 방식으로 동작을 하기 위해서 굉장히 비슷한 방식으로 적용하면 된다. getStaticProps라는 함수를 SSR과 같은 방식으로 사용해주면 된다. Data-Fetching과 같은 로직은 그대로 유지하되 SSR로 동작을 하고 싶으면 getServerSideProps, SSG로 동작을 하고 싶으면 getStaticProps를 사용하면 되는 것이다. SSR을 적용해 보았기에 쉽게 이해가 가능하다.

하지만 우리는 TSX를 사용하고 있기 때문에 getStaticProps 함수가 반환하여 컴포넌트로 전달해주는 props의 타입을 정의해주어야 하는데 앞서 SSR에서의 props의 타입은 InferGetServerSidePropsType<typeof getServerSideProps>로 정의해주었다면 SSG의 props 타입은 InferGetStaticPropsType<typeof getStaticProps>으로 정의하여 NextJS와 TS가 자체적으로 Props의 타입을 추론하도록 Generic 문법을 사용해주면 된다.

 

이 때 올바르게 SSG 방식으로 동작하는지 확인하기 위해 getStaticProps 함수 내에 console.log로 임시 문구를 출력해보면 기존 SSR 방식과 동일하게 터미널에 문구가 출력되는 것을 볼 수 있다. 이러한 이유는 SSG는 프로젝트가 빌드되는 시점에 적용이 되기 때문에 올바른 동작을 확인하기 위해서는 npm run build로 프로젝트를 빌드하고 npm run start를 통해 제공 환경인 Production 환경에서 실행을 시켜보아야 확인이 가능하다. 프로젝트를 빌드시키면 다음과 같은 기록들이 터미널에 뜨게 되는데 사진을 보고 하나씩 살펴보자.

SSG

console.log로 getStaticProps 함수 내에서 확인차 출력해둔 문구가 빌드 시점에 출력이 된 것을 확인할 수 있다.

 

SSG

프로젝트가 빌드되며 각 파일, 페이지들에 대한 정보를 터미널에 출력 시켜주었는데 각 페이지 별로 앞에 기호가 붙은 것을 볼 수 있다. F, ○(빈 원), ●(채워진 원)이 있는데 각각이 의미한 요소는 하단에 보면 알 수 있다.

 

●(채워진 원)

- SSG로 동작하는 파일을 의미한다. 터미널에 prerendered as static(uses getStaticProps)로 의미를 표시해주고 있으며 getStaticProps 함수를 사용하여 SSG 방식으로 동작하는 파일을 의미한다.

F(Function)

- SSR로 동작하는 파일을 의미한다. 터미널에 정보에 따르면 server-rendered on demand라는 의미를 가진 기호로 요청에 따라 서버가 렌더링 해주는 파일이라는 의미이다.

○(빈 원)

- 아무것도 설정해두지 않은 파일을 의미한다. NextJS의 Default 설정 방식이 페이지에 적용된 것인데 여기서 prerendered as static content라는 문구를 보면 알 수 있듯이 NextJS는 기본적으로 페이지 Rendering을 SSG 방식으로 하는 것을 알 수 있다.

 

NextJS Default SSG

우리가 Query String을 사용하는 검색 기능이 있는 페이지의 경우 getStaticProps 함수를 사용할 수 없다. 그 이유는 Query String에 접근하기 위해서는 함수의 매개변수로 context 객체를 사용하는데 getStaticprops 함수의 context 객체에는 query 속성이 존재하지 않기 때문이다. 그 이유는 사용자가 요청하기 전에 페이지를 생성하기 때문에 사용자의 요청에 포함되는 검색 기능 자체가 SSG 방식에서는 적용이 불가능하기 때문에 애초에 SSG방식의 context 객체의 query 속성은 존재하지 않는 것이다.

따라서 NextJS는 페이지를 기본값으로 SSG 방식으로 렌더링을 해주기 때문에 이런 경우는 아무 렌더링 방식 설정 없이 컴포넌트만 생성을하고 컴포넌트 내에 로직을 생성하여 Client 측에서 Data-Fetching을 진행하면 결론적으로는 SSG 방식으로 렌더링이 가능하게 된다. 다만 별도의 로직은 useEffect 훅을 사용하기 때문에 Component가 Mount 되는 시점에 데이터를 불러오게 된다는 점은 참고해야 한다.

정리하자면 빌드 시점에 Data-Fetching이 불가한 페이지 같은 경우에는 Data-Fetching을 Client 측에서 진행해주면 된다. 또한 NextJS는 기본적으로 모든 페이지를 SSG 방식으로 렌더링을 한다.

 

SSG on Dynamic Route Pages

앞서 우리가 살펴 본 SSG 방식의 적용 유형은 Next의 Router에 따라 구현한 정적 경로 페이지들에 대해 적용한 방식이였다. getStaticProps 함수를 사용하여 NextJS에게 해당 파일과 컴포넌트는 SSG 방식으로 동작할 것이라고 알려주는 방식으로 처리하였는데 [id].tsx와 같이 동적 경로로 생성된 페이지들에게 동일하게 getStaticProps를 사용하였더니 페이지에서 오류가 발생하였다. Component의 props Type도 올바르게 지정해주었고 getStaticProps 함수 내에서 사용하는 URL Parameter가 담긴 context 객체의 타입 또한 GetStaticPropsContext로 올바르게 지정해주었음에도 오류가 발생하였다. 그 이유는 무엇일까?

동적 경로를 통해 이동한 페이지에서 발생한 오류를 살펴보면 "getStaticPaths is required for dynamic SSG pages~..."와 같이 명시되어 있다. 동적 경로 페이지에서 SSG 방식을 사용하기 위해서는getStaticPaths라는 함수가 필수라고 알려주고 있다. 여기서 알 수 있듯이 동적 경로 라우팅을 설정해둔 페이지에서 SSG 방식을 적용하기 위해서는 getStaticProps 함수뿐만 아니라 getStaticPaths를 통해서 이동할 경로를 빌드 시점에 미리 설정을 해주고 Pre-Rendering을 진행한다. 따라서 프로젝트의 동적 경로로 생성된 페이지의 getStaticProps 상단에 getStaticPaths 함수를 선언 후 사용해주도록 하자.

 

getStaticPaths

getStaticPaths는 객체를 반환한다. 반환되는 객체에는 두가지 속성이 존재한다.

 

paths

paths 속성은 배열을 속성값을 갖고 있다. 속성값인 배열 내에는 다시 객체들이 들어있는데 들어있는 객체는 params 속성을 갖고 {id: "1"}과 같이 객체 형태의 URL Parameter 값을 속성값으로 갖는다. 이 때 id 값이 동적 경로를 통해 접근 가능한 URL Parameter 값을 의미하고 그 값은 반드시 문자열 형태로 입력이 되어야 한다. 그래야만 NextJS Framework 자체가 인식이 가능하기 때문이다. paths 속성으로 반환해준 값에 대해서는 npm run build를 통해 프로젝트를 빌드한 후 빌드 메세지를 살펴보면 paths 속성으로 반환한 값은 SSG 방식으로 미리 Pre-Rendering 된 것을 확인할 수 있다. 아래 사진을 참고해보자.

 

fallback

fallback 속성은 특정 옵션 값을 갖고 있는 속성으로 앞서 정의해 준 paths 속성에 포함되어 있지 않은 경로 값에 대해서 페이지 이동이 발생하면 대체재로 사용되는 속성이다. false로 선언해주면 paths 속성에 없는 경로로 접근하였을 때 바로 에러 페이지(404.tsx)를 보여주게 된다. false 외에도 true, blocking Option도 존재하는데 fallback Option에 대해서는 다음에 다루어 보도록 하자.

 

Fallback Option

False

앞 단락에서 살펴본 옵션으로 paths 속성에 없는 경로를 요청하게 되는 경우 에러 페이지를 반환해주는 옵션이다.

 

Blocking

True,False 속성과 다르게 문자열 형태로 "blocking" 형태로 부여해주어야 하는 옵션으로 본 옵션이 설정된 경우 paths에 없는 페이지를 요청하였을 때 그 즉시 서버 측에 페이지 요청을 보내서 SSR 방식으로 처리를 해주는 방식이다. False 옵션은 존재하지 않는 요청이 들어왔을 때 NotFound 페이지를 반환하는 반면 Blocking 옵션은 요청이 들어온 경우 즉시 서버에 요청을 보내서 데이터를 받아오는 옵션인 것이다. 하지만 본 옵션을 사용할 때 주의해야 할 점이 있다. 만약 서버에 요청한 데이터가 응답을 주는데까지 오랜 시간이 걸리는 경우 사용자는 아무 동작도 할 수 없이 빈 화면을 보고 있어야 한다. 이런 경우 대체할 수 있는 옵션이 바로 다음에 나올 True 옵션이다.

 

True

False 옵션과 같이 Boolean 형태로 입력하는 속성이며 경로에 없는 페이지를 요청하였는데 요청한 페이지의 데이털르 받아오는데 오랜 시간이 소요되는 경우 사용하는 옵션이다. 본 옵션이 설정되었을 때에는 getStaticProps 함수의 반환값을 받지 않고 생략한 뒤 먼저 Component를 호출하여 렌더링 시켜준 후 Data-Fetching이 완료된 후 Component에 Props를 전달해주어 해당 페이지에 대한 정보를 렌더링해주는 방식으로 동작하게 된다. 정리하자면 Blocking 옵션과 동일하게 SSR 방식으로 동작하지만 props가 없는 Fallback 상태의 페이지를 먼저 반환하고 이후에 Data-Fetching이 완료된 후 Props를 후속으로 보내주어 페이지를 완성시키는 과정으로 진행이 된다고 보면 될 것 같다.

fallback 상태 : Component가 서버로부터 데이터를 전달받지 못한 상태

fallback 상태의 페이지 : getStaticProps로부터 받은 Props 즉, 데이터가 없는 페이지

 

isFallback(useRouter)

웹 페이지를 그려주는 Component에서 useRouter 훅을 사용할 때 useRouter의 isFallback 속성을 사용하면 해당 Component가 Fallback 상태일 때 반환해 줄 요소를 부여할 수 있다. 간단한 코드 예시로 살펴보자.

import {useRouter} from 'next/router';

export default function Home(){
  const router = useRouter();
  if(router.isFallback) return "로딩중입니다.";
  return (
    <div>Home</div>
  )
}

위의 코드 예시와 같이 useRouter의 isFallback 속성에 반환값을 설정해주면 해당 웹 페이지가 데이터 로딩중과 같이 응답을 기다리고 있는 상태인 경우 설정한 값을 사용자에게 보여줄 수 있게 된다. React의 Suspense와 동일한 기능을 한다고 생각하면 이해가 쉬울 것 같다.

따라서 isFallback 속성은 getStaticPaths 함수에서의 fallback 옵션이 True인 경우 데이터의 요청이 완료되지 않은 페이지를 사용자가 받은 시점에 오류 상태가 아닌 로딩 상태라는 것을 알려주기 위해 사용하기에 용이한 속성이다.

 

export default function Book({book}: InferGetStaticPropsType<typeof getStaticProps>) {
  if (!book) return "존재하지 않는 페이지입니다.";
  return (
    <div>{book.title}</div>
  );
}

추가적으로 하나 더 살펴보자면 만약 데이터가 반환되지 않아서 위 코드와 같이 에러 문구를 반환하도록 구현해둔 Component에서 위와 같이 별도의 문구를 출력하지 않고 바로 프로젝트 내에 생성해둔 에러 페이지로 이동시켜주고 싶은 경우에 getStaticProps 함수 내에서 구현하는 방법이 존재한다. 함수 내에서 반환된 데이터가 없는 경우 {notFound: true}라는 객체를 반환해주면 되는데 코드로 확인해보도록 하자.

위의 코드와 같이 notFound 객체를 true 속성값과 함께 반환해주면 getStaticPaths의 fallback 옵션이 true인 경우에 데이터를 반환받지 못했을 경우 에러 페이지를 쉽게 보여줄 수 있게 되는 것이다.

[#4 - ISR(Incremental Static Regeneration)]

ISR(Incremental Static Regeneration)

- ISR이란 증분 정적 재생성이라는 뜻으로 웹 페이지의 렌더링 방식중에 하나이다. CSR, SSR, SSG에 비해 생소한 개념이기에 어렵게 느껴질 수 있지만 개념 자체는 어렵지 않은 방식이다. 결론부터 말하자면 ISR 방식은 SSR과 SSG의 장점들을 합쳐서 페이지의 렌더링을 더 최적화하고 효율적으로 하는 방식이다. 과정을 간단하게 설명하자면 처음 페이지의 렌더링 과정은 SSG와 동일하다. 하지만 SSG로 생성된 페이지는 사용자가 아무리 재요청을 보내도 서버 측에 새로운 페이지 요청을 보내지 않고 빌드 시점에 미리 생성해둔 페이지를 계속 반환해주는 방식이기 때문에 굉장히 빠른 속도로 페이지 렌더링이 가능하지만 새로운 데이터를 업데이트 하는데에 어려움이 있다는 단점이 있었다. 이러한 SSG의 단점을 보완하고자 사용하는 방식이 ISR이라고 이해해도 좋다.

ISR의 동작 원리에 대해 살펴보면 기본 동작은 SSG와 동일하다. 하지만 시간의 흐름에 따라 일정 시간이 지난 뒤에 사용자가 웹 페이지에 대한 요청을 보내면 SSR 방식으로 동작하여 새로운 데이터를 반영하는 것이 가능하도록 동작한다. 정리하자면 SSG 방식으로 빌드 시점에 생성된 웹 페이지를 사용하는 사용자가 새로운 데이터를 요청하였을 때 일정 시간까지는 정적인 페이지를 반환해주다가 특정 시점이 지난 후에 들어오는 요청부터는 SSR 방식으로 서버에서 데이터를 받아 SSR 방식으로 웹 페이지를 렌더링 해주는 것이다.

따라서 SSG 방식의 장점인 데이터의 양이 많아도 초기 렌더링이 매우 빠르다는 점과 SSR 방식의 장점인 최신 데이터 반영에 더해 빠른 페이지 이동을 유지하면서 장점들을 모아놓은 렌더링 방식이다.

 

ISR을 페이지에 적용하는 것은 매우 간단하다. SSG로 생성할 Component의 getStaticProps 함수의 반환값 객체에 한가지 속성을 추가해서 반환해주면 된다. 바로 revalidate라는 속성이다. revalidate는 재생성하다,재검증하다라는 뜻을 가진 영단어로 ISR 방식을 위해 직관적으로 설정이 가능한 속성이라고도 볼 수 있다. revalidate 속성의 속성값으로는 주기를 입력해주면 되는데 숫자형태의 값을 넣어주면 입력한 초 주기로 웹 페이지가 새로운 데이터를 받아와 브라우저에 렌더링 해주게 되는 것이다. 코드 예시를 살펴보면 다음과 같이 작성할 수 있다.

export const getStaticProps = async () => {
  const response = await fetch(URL);
  const data = await response.json();
  return {
    props: {data},
    revalidate: 3
  }
}

편의상 getStaticProps 함수만 작성하였는데 위 코드와 같이 반환하는 객체에 Component에 전달해줄 props 데이터와 더불어 revalidate 속성까지 추가해주면 해당 페이지는 3초 주기로 데이터를 렌더링 하게 된다.

 

ISR로 빌드되어 생성된 페이지의 빌드 메세지를 살펴보면 다음과 같이 ISR이 어느 주기로 적용되는지 알 수 있다.

ISR

페이지를 정적인 상태로 브라우저에게 제공함으로써 굉장히 빠른 속도로 렌더링이 가능하다는 장점과 더불어 일정 시간 주기로 최신 데이터를 갱신할 수 있다는 장점이 있기에 가장 강력하고 추천하는 Pre-Rendering 방식이라고 할 수 있겠다.

 

주문형 재검증(On-Demand-ISR)

앞서 ISR 방식의 장점과 더불어 사용 방식에 대해 알아보았다. 하지만 시간 기반의 ISR을 적용하기 어려운 페이지들이 존재한다. 예를 들어 커뮤니티의 게시글과 같이 시간의 흐름과 관계 없이 사용자의 행동에 따라 데이터가 업데이트 되는 페이지는 ISR을 적용하기 어려운 부분이 존재한다. 우리가 revalidate로 설정해둔 시간 내에 사용자의 동작이 발생하지 않을 수도 있고 시간 내에 새로운 데이터를 필요로하는 사용자의 요청이 발생할 수 있기 때문에 ISR을 적용하기 어려운 것이다. 이런 경우 사용할 수 있는 렌더링 방식이 주문형 재검증 방식이다.

주문형 재검증 방식이란 요청을 받을 때마다 정적 페이지를 재생성하는 방식을 의미한다. 동작 원리를 간단히 살펴보면 사용자가 데이터 업데이트에 관한 행동을 했을 때 revalidate 요청을 보내서 그 순간 페이지 재생성하게 된다. 결론적으로 주문형 재검증 방식을 사용하면 대부분의 페이지를 최신 데이터 상태를 유지하면서 정적인 페이지로 생성이 가능하게 되어 빠른 렌더링과 빠른 페이지 이동까지 모두 겸비한 최적화 된 페이지를 생성할 수 있게 되는 것이다.

적용 방식은 새로운 api 파일을 생성하면서 적용하게 된다. 기존에 적어두었던 revalidate 속성을 지운 뒤 api 폴더 내에 새로운 TS 파일을 생성하여 다음과 같은 코드를 작성해준다.

//revalidate.ts
import { NextApiRequest, NextApiResponse } from "next";

export default async function handler(
  req: NextApiRequest,
  res:NextApiResponse
){
  try {
    await res.revalidate('/')	//요청을 받았을 때 재생성할 페이지 경로
    return res.json({revalidate: true})	//요청 성공 시 재성성 완료를 의미하는 객체 반환
  } catch (error) {
    res.status(500).send("revalidatioin Failed");	//요청 실패 시 서버 에러 500 반환과 문구 반환
  }
}

올바르게 적용되었는지를 확인하려면 마찬가지로 npm run build를 통해 프로젝트를 빌드한 후 npm run startProduction 환경을 열어준 뒤 확인해야 한다. 만약 프로젝트 내에 커뮤니티 같이 주문형 재검증 방식을 사용하고자 하는 페이지가 있다면 사용자가 데이터 요청을 하는 로직에 다음 API를 localhost:3000/api/revalidate로 설정하여 요청을 보낸다면 요청의 성공과 실패에 따라 특정 동작을 실행해 줄 것이다. 만약 요청이 성공하였다면 위 코드의 await res.revalidate('/') 코드가 동작하여 지정해둔 페이지를 재생성하여 재렌더링 해주는 방식으로 동작하게 된다.

임의로 위의 코드를 테스트하고자 하면 브라우저에 위의 API 주소를 입력하여 요청 성공 시 응답 객체인 { revalidate: true }를 받고 나서 적용할 페이지를 새로고침 해보면 페이지가 재생성되는 것을 알 수 있다. 주의해야할 점은 앞서 말했듯이 주문형 재검증 방식 역시 ISR이기 때문에 기본적으로는 SSG 방식으로 동작하기 때문에 반드시 프로젝트를 빌드를 해야 모든 과정이 확인이 가능하고 적용이 가능하다는 점이다.

'NextJS' 카테고리의 다른 글

[NextJS v.13~] - (1) App Router  (1) 2024.09.26
[NextJS] - (4) SEO, Deploy  (6) 2024.09.19
[NextJS] - (2) Page Router  (2) 2024.09.10
[NextJS] - (1) NextJS  (1) 2024.09.10
[NextJS(v.14)] - (3) Data Fetching  (0) 2024.08.20
본 포스팅은 Inflearn(인프런) 이정환님의 한 입 크기로 잘라먹는 NextJS(15+) 강의를 참고하여 작성되었습니다.

[#1 - Page Router]

Page Router

Page Router는 말그대로 pages 폴더의 구조를 기반으로 Page Routing을 제공하는 구조를 말한다. UI를 그려주는 메인 파일명을 index라는 파일명으로 설정을 해주어야 한다. 아래 표는 NextJS 14버전부터 사용 가능한 App Router와 이전 버전의 Page Router을 간단하게 비교해둔 표이다.

App Router Page Router
app 폴더(최상위 폴더) page 폴더(최상위 폴더)
page 파일(UI) index 파일(UI)
- _app 파일(React의 App.jsx 파일 역할)
- _document 파일(React의 index.html 파일 역할)
layout 파일(레이아웃) _app 파일에 작성
not-found 파일(에러) 404 파일(에러)

 

아래의 npx 명령어를 통해서 NextJS 프로젝트를 시작할 수 있는데, 별도의 추가 기능에 대한 질문이 나온다. 본인이 사용하고자하는 기능이 있으면 추가하여 NextJS를 설치 후 프로젝트를 진행하면 된다.

npx(node package executor) create-next-app@14 {FileName}
Use TypeScript
Use ESLint
Use TailwindCSS
Use 'src/' directory
Use import alias customizing

 

_app.tsx

_app 파일은 React에서 모든 컴포넌트들의 상위 컴포넌트 역할을 했던 App 파일과 같이 모든 Page Component들의 부모 역할을하는 Next App의 Root Component로 내부에 공통 컴포넌트나, Layout을 작성하거나 로직을 구현하는 것이 가능한 파일이다.

 

_document.tsx

_document 파일은 모든 페이지에 공통적으로 적용이 되어야하는 Next App의 HTML 코드를 설정하는 Component로 React에서 index 파일과 동일한 역할을 한다. _app 파일과 동일하게 모든 Component에 적용이되므로 meta Tag, Font, Charset, Google Analytics 같은 서드 파티 스크립트 등 페이지 전체에 적용되는 HTML 파일을 관리하는 파일이다.

 

next.config.mjs

Next App 전체의 설정 파일이다.

[#2 - Settings Page Router]

Page Router Setting

pages 폴더 하위에 특정 파일을 생성하면 해당 파일 이름으로 Routing이 자동적으로 동작하게 된다. 예를 들어 Search.tsx라는 파일을 생성하였다면 메인 url에 /search만 붙여준다면 Search.tsx로 생성한 페이지로 이동이 가능하게 된다.

파일이 아닌 폴더를 생성하고 내부에 index 파일만 존재한다면 해당 폴더명으로 url 주소 접근이 가능하며 마찬가지로 Routing 구현이 가능하게 된다.

 

Query String

이 때 특정 폴더명을 통해서 해당 페이지로 접근을 하는 동시에 Query String이 포함된 url 주소로 접근을 하는 경우도 발생한다. Query String이란 사용자가 검색한 정보에 따라 해당 정보를 url 주소에 포함시켜 해당 페이지로 전환을 시켜줄 수 있는 정보를 의미한다. 대부분 url 주소 마지막에 "?"가 붙고 그 이후에 나오는 정보들이 Query String이다.

Query Stringnext/router에서 제공하는 useRouter 훅을 통해서 객체에 접근이 가능하다. 객체 내부에 query 속성을 통해서 사용자가 url 주소에 함께 입력한 Query String 정보에 대해서 가져올 수 있다.

예를들어 "localhost:3000/user?page=12" 와 같은 형태로 사용자가 입력하였을 경우 "?"를 기준으로 뒤에 작성된 부분은 Query String이라고하며 해당 내용은 useRouter로 생성된 객체에 query 속성에 page를 key 값으로, 12를 value 값으로 저장이 되는 것이다.

여기서 참고로 NextJS는 url 주소를 읽는 과정에서 query String이 존재하면 컴포넌트를 불러오는 과정에서 한 번 더 컴포넌트를 호출하게 된다.

 

Dynamic Routing(동적 라우팅) / useRouter

url 주소 뒤에 동적으로 변하는 주소값을 url parameter라고 한다. url parameter가 붙으면 같은 주소 내에서 동적인 라우팅이 설정이 가능하다. 예를 들어 블로그의 게시글이나 쇼핑몰의 상품 같은 경우 각 페이지 별로 상위 주소는 동일하지만 서로 다른 parameter 값을 갖고 있는 것을 볼 수 있다. "/item/12", "/page/24"와 같은 형태이다. 이런 페이지들은 해당 parameter 값에 따라 일일히 페이지를 생성해주는 것이 아닌 동적으로 Routing을 구현하는 것인데 구현 방식은 15버전과 동일하다. parameter에 해당하는 값의 이름을 대괄호를 씌워서 파일명으로 설정해주는 것이다. [id].tsx와 같은 형태이다. 이 때 useRouter을 통해서 query String과 동일하게 객체의 query 속성을 보게 되면 url parameter 값이 존재하는 것을 볼 수 있게 된다.

여기서 주의해야 할 점은 Query String과 URL Parameter 값이 모두 존재할 때 useRouter 객체의 query 속성에는 어떻게 부여되는지이다. Query String은 key와 value 형태로 저장이 되며 url Parameter는 id를 key로 갖고 parameter 값은 "/" 기준으로 나뉘어 배열 형태의 value로 저장되게 된다.

만약 url Parameter 값이 굉장히 많아지게 되면 parameter 값은 "/" 기준으로 나뉘어 배열 형태로 value로 저장되게 된다. 이 때는 파일명을 변경해주어야 하는데 Catch All Segment라고 해서 모든 구간을 잡아낸다라는 의미로 [...id].tsx와 같이 변경해주어야 한다.

마지막으로 동적 라우팅을 구현한 페이지에는 query String이나 url Parameter가 없는 경우의 페이지는 구현되어있지 않은데 아무 요소도 없는 페이지를 구현하기 위해서는 Optional Catch All Segment라고 해서 파일명을 대괄호로 한 번 더 감싸서 [[...id]].tsx로 변경해준다면 아무 요소가 없는 url 주소로도 페이지가 생성되게 된다.

오류(에러) 페이지 또한 자체적으로 Customizing이 가능하다. 에러 페이지는 404 Page로 알려져있기 때문에 파일명을 404.tsx로 설정한 후 내부 컴포넌트를 작성하면 Next App 내에서의 오류나 에러가 발생하였을 때 자동적으로 404 Page가 뜨게 된다.

 

[#3 - Navigating]

Navigate

우리가 흔히 알고 있는 페이지 이동 방식을 살펴보면 anchor 태그를 이용해서 이동하고자 하는 페이지를 새로이 요청하면서 이동하는 방식이 존재하고 React-Router-Dom에서 사용했던 Link 태그와 to 속성을 이용해서 이동하는 방식이 있는 것을 알 수 있다. anchor 태그는 페이지 이동 간에 매번 새로운 요청을 보내기 때문에 속도 저하와 불필요한 요청으로 인한 비효율적인 방식이다. CSR을 통해서 페이지 이동을 하기 위해서 Next App에서도 React와 마찬가지로 Link 태그를 이용해서 Navigate를 구현한다. Next14 이전 버전에서는 next/navigation이 아닌 next/link가 제공해주는 Link Component를 통해서 구현하는 것이 좋다.

Link Component는 React에서의 Link 태그와 사용 방법이 동일하지만 React에서는 to 속성을 사용하여 이동 경로를 지정해주었다면 Next App에서는 a 태그와 동일하게 href 속성을 이용해서 경로를 지정해준다.

또한 Link Component를 통해서 Navigator Bar를 구현하는 것이므로 _app 파일의 컴포넌트 내부에 작성해주는 것이 바람직하다.

 

useRouter / push,replace,back

위와 같이 Navigator Bar을 생성하여 페이지 이동을 편리하게 할 수 있는 UI를 구현하는 방법도 있지만 특정 이벤트나 특정 조건이 만족함에 따라 페이지를 이동시켜주는 Programmatic Navigation도 구현이 가능하다. 대표적인 예로는 Link Component가 아닌 특정 버튼을 눌렀을 때 이벤트를 감지하고 페이지를 이동시켜주는 방식이라고 생각하면 쉬울 것 같다.

이 때 사용하는 Hook이 존재하는데 앞서 살펴보았던 next/router의 useRouter이다. useRouter의 push method를 통해 링크를 전달해주면 페이지 이동이 가능하게 된다. 간단하게 코드 형식을 살펴보면 다음과 같다.

import {useRouter} from 'next/router';

export default function App({Component, pageProps}: AppProps) {
  const router = useRouter();
  const onClick = () => {
    router.push('/detail');
  }
  return (
    <div>
      <button onClick={onClick}>Move to Detail Page</button>
      <Component {...pageProps} />
    </div>
  )
}

button 태그에 onClick 키워드를 통해 클릭 이벤트가 발생하면 useRouter의 push method에 '/detail' 경로를 전달해주어 detail 페이지로 이동할 수 있도록 구현한 로직이다.

push method 외에도 replace와 back method도 존재한다. replace뒤로가기를 방지하며 페이지를 이동하는 method이고 back은 페이지 뒤로가기 method이다. 사용 방법은 동일하다.

 

[#4 - Pre-Fetching]

Pre-Fetching

Pre-Fetching이란 사용자의 보다 빠른 페이지 이동을 위해 제공되는 기능으로 사용자가 현재 위치해 있는 페이지에서 이동할 가능성이 있는 페이지에 대한 정보들을 미리 호출을 해놓아 실제 사용자의 이동 요청이 발생하였을 때 더 빠른 속도로 이동을 시켜주는 기능을 의미한다.

 

NextJS는 Next App에 작성해두었던 React Component들을 자동으로 페이지 별로 분리하여 저장을 해둔다. 따라서 Pre-Rendering 과정에서 현재 사용자가 위치해 있는 페이지에 대한 JS파일만을 Bundle로 생성하여 사용자에게 보내주는 것이다. 그러므로 사용자가 다른 페이지로 이동을 하였을 경우에는 이동한 페이지의 React Component들이 담긴 JS파일 번들러를 다시 다운로드 받아서 보내주어야 한다는 것이다. 예를 들어 /search 페이지로 접속 요청을 보냈을 때는 Search JS Bundle 파일만 다운을 받아서 렌더링을 해주는 것이고 /user 페이지로 이동 요청을 보낸 경우에는 그 때 User JS Bundle 파일을 다운받아서 이동 요청에 맞게 페이지를 렌더링 해주는 것이다.

 

NextJS가 이렇게 동작하는 이유는 만약 사용자의 첫 요청에 모든 페이지의 JS Bundle 파일을 전달해주는 경우 파일 용량이 매우 커지게 되면서 Hydration 과정이 오래걸리기 때문에 TTI(Time To Interface)의 시간이 늘어나기 때문이다.

 

Pre-Fetching의 동작 원리를 간단하게 살펴보면 사용자가 초기 접속 요청을 보낸 페이지를 렌더링 해주는 과정에서 초기 페이지에 포함되어있는 링크나 버튼 태그들을 살펴보고 해당 요소들로 인해서 이동이 가능한 페이지가 존재한다면 이동할 가능성이 있는 페이지는 모두 미리 불러오는 것이다. 쉽게 표현하자면 이동 가능한 범위 내의 페이지를 모두 다운 받아놓았기 때문에 페이지 이동 요청이 발생하더라도 빠르게 페이지 이동 처리를 해줄 수 있게 되는 것이다.

 

Pre-Fetching은 개발 모드가 아닌 프로젝트가 빌드 된 Production 환경에서 확인이 가능하다. 따라서 진행중인 프로젝트의 터미널에서 npm run build를 통해 프로젝트를 build한 후 npm run start를 통해 운영 환경에서 서버를 동작시킨 후 개발자 도구의 network 탭을 통해 Pre-Fetching을 확인 가능하다. network 탭을 살펴보면 현재 존재하는 페이지 이외에도 Link Component로 연결되어있는 페이지들에 대한 정보까지 다운로드가 되어있는 것을 확인할 수 있을 것이다.

 

여기서 참고해야할 점이 있는데 일반적으로 Link Component를 통한 페이지 이동이 아니라 버튼의 이벤트 발생에 따른 페이지 이동 같은 경우는 Pre-Fetching이 적용되지 않는다. 이런 경우는 페이지가 Mount 되는 시점에 맞게 useRouter 훅을 통해 Pre-Fetching을 수동으로 시켜주는 방법을 사용해야 한다. 페이지의 Mount 되는 시점은 useEffect 훅에 빈 의존성 배열을 전달해줌으로써 맞출 수 있고 useEffect 훅 내부에 useRouter의 prefetch method를 통해 Pre-Fetching하고자하는 url parameter을 전달해주면 Link Component가 아닌 페이지 이동 요소에도 Pre-Fetching을 적용시켜줄 수 있다.

 

그렇다면 반대로 Link Component로 작성된 요소의 Pre-Fetching을 방지하는 방법은 무엇일까. Link Component에 prefetch={false} 속성을 넣어주면 해당 Link Component는 Pre-Fetching이 동작하지 않게 된다. 코드 형태는 아래와 같은 형태로 작성되게 된다.

import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect } from "react";

export default function App({ Component, pageProps }: AppProps) {
  const router = useRouter();
  const handleClick = () => {
    router.push('/test'); //클릭 이벤트에 의한 페이지 이동
  }
  useEffect(() => {
    router.prefetch('/test'); 
  },[])
//페이지가 mount 되는 시점에 클릭 이벤트에 의해 페이지 전환이 이루어지는 요소에 대한 prefetching 기능 부여
  return (
    <>
      <header>
        <Link href={"/"}>Home</Link>
        <Link href={"/search"} prefetch={false}>Search</Link> {/*Prefetching 방지 속성 적용*/}
        <Link href={"/book"}>Book</Link>
        <div>
          <button onClick={handleClick}>Move to Test Page</button>
        </div>
      </header>
      <Component {...pageProps} />
    </>
  );
}

 

[#5 - API Routes]

API Routes

API Routes란 NextJS에서 API를 구축할 수 있게 해주는 기능이다. Pages 폴더 하위에 api 폴더 내에 생성된 파일들은 웹페이지가 아닌 API Routes로서 API 응답을 정의하는 파일로 자동 설정이 된다. 또한 폴더와 파일의 경로와 동일한 경로를 갖게 된다. 예를 들어 api 폴더 내에 time.ts라는 파일을 생성한 후 특정 로직을 구현하면 NextJS가 자동으로 API로 생성을 해주며 localhost:3000/api/time이라는 경로로 API 요청을 보내게되면 구현한 로직에 따라 함수가 실행되면서 데이터를 반환받을 수 있게 되는 것이다.

 

간단하게 현재 시간을 반환하는 API를 구현하는 예시를 보자. 아래와 같이 간단하게 구현이 가능하다.

import type {NextApiRequest, NextApiResponse} from 'next';

export default function Time(
  req: NextApiRequest,
  res: NextApiResponse
){
  const date = new Date();
  res.json({time: date.toLocalString()})
}

위와 같이 생성된 time.ts 파일을 통해 서버를 구동하여 localhost:3000/api/time에 접속하게 되면 다음과 같이 반환한 데이터를 확인할 수 있다.

 

이 때 req,res의 타입인 NextApiRequest와 NextApiResponse는 NextJS에서 제공해주는 API 요청과 응답의 타입으로 import type을 통해 사용하면 된다.

 

[#6 - Styling in NextJS]

React에서는 특정 파일의 CSS 파일을 동일한 파일명으로 생성한 후 import 해서 사용하곤 했었다. 하지만 NextJS에서는 _app 파일의 App 컴포넌트를 제외한 다른 컴포넌트들은 별도로 생성된 CSS 파일을 그대로 import하는 것은 불가능하다. 그 이유는 서로 다른 두개의 CSS 파일에 공통된 클래스 명이 존재하게 되는 경우 두 파일이 동일한 폴더에 속해 있는 경우 클래스명 충돌이 발생하여 스타일 오류가 발생할 수 있기 때문이다. 따라서 CSS 파일을 그대로 불러와 사용하는 것은 불가하고 이런 경우 NextJS에서 기본적으로 제공하는 CSS Module 방식을 사용하면 된다. CSS Module을 사용하면 NextJS 자체적으로 클래스명을 다른 파일의 클래스명과 중복되지 않도록 무작위로 설정해주는 방식을 통해 클래스 작명에 난관을 겪을 필요가 없어진다.

 

정리하자면 NextJS에서는 CSS의 클래스명 중복의 우려를 원천차단하기 위해서 일반 CSS 파일은 _app 파일 외에는 적용이 불가능하도록 동작하기 때문에 별도의 Component들에는 CSS Module 파일을 생성하여 해당 모듈 파일을 import 해서 사용해야 한다. 폴더 구조와 파일명의 예시를 사진과 함께 살펴보자.

Style Module

위의 사진을 보면 알 수 있듯이 tsx 파일이 존재하고 해당 파일에 대한 스타일을 적용할 수 있는 CSS 파일을 CSS Module 형식으로 저장하여 사용하는 것을 볼 수 있다. CSS Module 파일들은 별도의 styles 폴더에 _app 파일에 적용되는 global.css 파일과 함께 CSS 파일끼리 모아서 관리하는 방법도 있고 해당 CSS Module 파일이 적용되는 Component 파일과 파일명이 비슷하기 때문에 위의 사진처럼 같은 경로에 저장하여 관리하는 방법도 존재한다. 이 부분은 선택적이며 회사나 팀에 따라 설정하면 될 것 같다.

 

[#7,8 Global Layout & Page Layout]

Global Layout

프로젝트를 진행할 때 Naviagte Bar나 웹페이지의 Footer 같은 요소들은 일부 페이지가 아닌 전체 페이지에 적용을 시켜주어야 한다. React로 프로젝트를 진행할 때에는 각각에 해당하는 Component들을 생성한 후 별도의 layout 파일을 생성한 후 Outlet Component와 함께 Layout을 적용해주었었다. NextJS에서는 최상위 컴포넌트인 _app 파일의 App 컴포넌트에 Global Layout을 적용해주면 된다.

 

방법은 간단하다. Global Layout에 해당하는 파일과 컴포넌트를 별도로 생성해준다. 이후 전역적으로 적용이 될 요소들을 컴포넌트 내에 구현을 해주는 것이다. Header, NavBar, Footer 등의 요소들을 생성해 준 후 Global Layout 컴포넌트를 _app 파일에 import 하여 App 컴포넌트가 받는 Component들의 외부에 Layout을 적용시켜주는 것이다. 말로 설명하면 복잡하고 어려우니 간단하게 코드 예시로 확인해보자.

Global Layout
_app

위의 사진을 보면 알 수 있듯이 GlobalLayout이라는 컴포넌트를 별도로 생성한 후에 App 컴포넌트에 import 해주면서 App 컴포넌트가 받는 많은 하위 컴포넌트들을 GlobalLayout이 함께 하위 컴포넌트로 받아 모든 페이지에 Layout이 적용이 되는 것이다. 이러한 과정에서 React 요소들은 ReactNode라는 타입을 정의 받는다.

 

Page Layout

위의 Global Layout 적용에 이어서 전역적인 Layout 적용이 아닌 특정 페이지에만 적용하고 싶은 Layout도 존재할 것이다. Page Layout은 좀 생소한 방식으로 적용이 된다. 사실 NextJS 14버전 이후부터는 폴더 구조를 통해 Router을 생성해 둔 후 각 페이지에 적용하고자 하는 Layout은 layout.tsx라는 파일을 생성하여 적용하면 Page Layout이 쉽게 구현이 되지만 현재 학습중인 NextJS는 13이전 버전이기 때문에 생소한 방식으로 적용을 해야 한다.

 

앞서 첨부된 사진을 보면 App 컴포넌트 내에 getLayout이라는 함수가 사용되고 있는 것을 볼 수 있다. getLayout이란 NextJS에서 일부 페이지에만 적용하고자 하는 Layout이 있을 때 Layout Component를 생성한 후 적용하고자 하는 컴포넌트에 getLayout method를 사용하여 컴포넌트를 반환해주는 형식이다. 예시 코드를 살펴보자.

Page Layout

위의 사진은 일부 페이지에만 사용될 Page Layout을 생성해둔 Component이다. 예를 들어 본 Component가 Home Component에만 Layout이 적용이 되야하는 경우 Home Component에는 getLayout을 사용하여 다음과 같은 로직이 작성된다.

Home.tsx

Component 외부에 별도의 Method를 사용하는 문법 자체가 굉장히 생소하지만 JS 문법에 따르면 함수 또한 객체이기 때문에 함수에 method를 적용하는 것이 이상하지만은 않다. 앞서 살펴본 App 컴포넌트에 Global Layout을 씌워준 형태처럼 Page Layout을 적용해주고자 하는 컴포넌트에 method를 사용하여 Page Layout을 컴포넌트 겉에 씌워준 후 반환해주면 최상위 컴포넌트인 App 컴포넌트에서 getLayout method의 매개변수로 하위 컴포넌트들을 받아서 처리해주면 Page Layout이 적용이 되는 것이다. 앞의 _app 파일 코드 사진을 보면 Page Layout이 어떤 과정을 통해서 적용이 되었는지 확인 할 수 있을 것이다.

'NextJS' 카테고리의 다른 글

[NextJS] - (4) SEO, Deploy  (6) 2024.09.19
[NextJS] - (3) SSR,SSG,ISR  (1) 2024.09.19
[NextJS] - (1) NextJS  (1) 2024.09.10
[NextJS(v.14)] - (3) Data Fetching  (0) 2024.08.20
[NextJS(v.14)] - (2) Routing  (2) 2024.08.16
본 포스팅은 Inflearn(인프런) 이정환님의 한 입 크기로 잘라먹는 NextJS(15+) 강의를 참고하여 작성되었습니다.

[NextJS의 Pre-Rendering]

Pre-Rendering(사전 렌더링)

- 브라우저의 요청에 사전에 렌더링이 완료된 HTML을 응답하는 렌더링 방식으로 CSR의 단점을 효율적으로 해결하는 기술이다. NextJS의 대표적인 기능 중 하나이다.

유저의 접속 요청 -> 프론트 서버 자체적으로 JS 파일들을 실행하여 렌더링(JS를 실행하여 HTML로 변환하는 과정)이 완료된 HTML을 브라우저에 전송 -> 브라우저는 바로 화면에 렌더링(HTML 코드를 브라우저가 화면에 그려내는 과정)하여 유저의 접속 완료 -> 유저는 인터렉션 불가한 화면을 받음 -> 프론트 서버의 JS Bundle 파일을 후속으로 전달 -> 브라우저가 JS Bundle 파일을 받아서 HTML과 연결하여 JS를 실행하는 Hydration 과정을 실행-> Interactive한 화면 유저에게 제공

 

TTI(Time To Interactive)

- 유저가 페이지 요청을 한 시점부터 Hydration이 완료된 Interactive한 웹 페이지를 제공받는데까지 걸린 시간

이후 페이지 이동에 대해서는 CSR과 동일한 방식으로 처리된다.

초기 접속 속도가 빠르며 페이지 이동이 빠른 CSR의 장점을 계승하고 단점을 보완한 방식이다.

 

CSR(Client Side Rendering)

- ReactJS 앱의 기본적인 렌더링 방식으로 클라이언트(브라우저)에서 직접 화면을 렌더링하는 방식이다.

유저의 접속 요청 -> 리액트 프론트 서버의 index.html 응답 -> 브라우저의 빈 화면 렌더링 -> 프론트 서버의 Bundling된 JS 파일을 브라우저에 후속으로 전송 -> 브라우저의 JS Bundle 파일은 React 실행 -> 브라우저의 컨텐츠 렌더링 -> 유저의 화면 접속 가능

 

JS Bundle 파일에는 해당 서비스에서 접근 가능한 모든 컴포넌트에 대한 코드가 포함되어 있다. 따라서 유저가 페이지 이동을 위해 서버에 별도로 요청을 보낼 필요 없이 브라우저에서 자체적으로 이동을 시켜주기 때문에 빠른 페이지 이동이 가능하다.

장점 : 초기 접속 이후 페이지 이동이 매우 빠르고 쾌적한 환경을 제공할 수 있다.
단점 : 초기 접속 속도가 느리다.

 

FCP(First Contentful Paint)

- 유저의 "요청 시작" 시점으로부터 컨텐츠가 화면에 렌더링되는데 까지 걸리는 시간. 웹 페이지의 성능을 대표할 정도로 중요한 지표이다.

 

FCP에 따른 사용자의 이탈률을 살펴보면 다음과 같다.

3초 이상인 경우 이탈률 32% 증가
5초 이상인 경우 이탈률 90% 증가
6초 이상인 경우 이탈률 106% 증가
10초 이상인 경우 이탈률 123% 증가

 

NextJS는 Pre-Rendering 과정을 통해 CSR의 단점을 보완하며 SSR의 장점을 계승하여 사용자에게 있어서 TTI와 FCP를 최적화하는데에 특화되어 있는 React의 단점을 보완한 Framework이다.

'NextJS' 카테고리의 다른 글

[NextJS] - (3) SSR,SSG,ISR  (1) 2024.09.19
[NextJS] - (2) Page Router  (2) 2024.09.10
[NextJS(v.14)] - (3) Data Fetching  (0) 2024.08.20
[NextJS(v.14)] - (2) Routing  (2) 2024.08.16
NextJS(v.14) - Routing(1)  (4) 2024.07.06

+ Recent posts