Next.js 14 App Router 블로그 제작 후기

Next.js 14 App Router 블로그 제작 후기
OU9999

블로그 재설계 이유와 후기

개요

기존에 운영하던 블로그에서 여러 문제가 눈에 들어와 수정하고 싶었다. 리팩토링과 업데이트를 이어가다 보니 잘못된 설계 위에 계속 덧붙이기보다 처음부터 다시 설계해 만들고 싶어졌다.

이 글에는 내가 겪은 설계 문제와 App Router를 사용하며 얻은 이점을 담았다.

이 글은 2024년 2월, React 18과 Next.js 14로 블로그를 다시 만들며 남긴 회고다. 아래 평가는 Firebase나 App Router의 보편적인 우열보다 당시 내 데이터와 컴포넌트 구조에 대한 판단에 가깝다.

문제점

기존 블로그 메인화면

기존 블로그 메인화면

[ 기존 블로그 깃허브 , 기존 블로그 배포링크 ]

기존 블로그는 프론트엔드를 공부하기 시작한 지 얼마 되지 않았을 때 만들었다. 다양한 애니메이션과 개성 있는 트랜지션으로 화려함을 추구하던 시기였다.

시간이 흘러 시선이 달라진 뒤에는 보이는 모습보다 기술적인 부분에 더 관심을 기울이고, 성능과 최적화 그리고 간편함에서 오는 우아함을 추구하고 싶었다.

다음은 기존 블로그에서 발견한 문제들이다.

Firebase는 어울리지 않는다

기존 블로그의 데이터(Markdown 글, 댓글, 방명록)는 Firebase에서 가져왔다. 당시에는 Firebase를 배운 지 얼마 되지 않아 직접 응용해 보고 싶었다.

운영/배포 환경은 SSG로 만들었지만 next dev에서는 getStaticProps가 요청마다 실행되기 때문에 Firebase 접근도 반복됐다. 이것은 개발 모드의 정상 동작이었고, Firebase 자체의 문제는 아니었다.

돌아보면 댓글과 방명록처럼 갱신되는 데이터는 Realtime Database와 잘 맞을 수 있었다. 다만 거의 변하지 않는 Markdown 원문까지 원격 DB에서 읽게 만든 선택은 내 블로그에 과했다. 글은 로컬 파일과 정적 빌드로 옮기고, 실시간 데이터와 Firebase Authentication이 필요한 기능은 별개의 문제로 나누는 편이 맞았다.

Next.js의 이점을 못 살리는 설계

기존 블로그는 Next.js를 사용해 만들었지만 그 이점을 전혀 살리지 못하고 있었다. 사용하는 라이브러리와 프레임워크가 어떤 방식으로 작동하고 왜 필요한지 이론을 바탕으로 살폈어야 했는데, 코드를 작성하는 실무적인 도구로만 사용했기 때문이다.

아래는 기존 블로그의 pages 구조다.

// 기존 pages 형태 getStaticProps,getStaticPaths는 생략
 
export default function Home({ notesArr }: IHomeProps) {
  const [MainPageMobile, setMainPageMobile] =
    useState<React.ComponentType<IHomeProps> | null>(null);
 
  const { isDesktopView } = useDevicehook();
 
  useEffect(() => {
    import("@/components/Mobile/Home/MainPageMobile").then((module) => {
      setMainPageMobile(() => module.default);
    });
  }, []);
 
  return (
    <>
      {isDesktopView ? (
        <MainPage notesArr={notesArr!} />
      ) : (
        MainPageMobile && <MainPageMobile notesArr={notesArr} />
      )}
    </>
  );
}

당시에는 isDesktopView로 기기 크기를 파악한 뒤 모바일일 때만 컴포넌트를 불러온다고 생각했다. 하지만 import()는 조건문 안이 아니라 useEffect 안에서 무조건 실행되므로 데스크톱에서도 모바일 chunk를 요청한다. MainPage는 정적으로 import했기 때문에 모바일 사용자도 데스크톱 코드가 포함된 초기 bundle을 받는다.

기존 블로그는 SSG 방식으로 설계했다. 서버에서 정적인 HTML을 미리 생성해 사용자에게 전달하는 방식인데, 블로그의 다른 설계와 모순이 생겼다.

기기 판단은 클라이언트가 마운트된 뒤에야 끝나므로 서버가 미리 만든 HTML에는 모바일 UI가 들어가지 않는다. 결국 모든 사용자가 데스크톱 코드를 받고 모바일 chunk까지 추가로 요청하며, 모바일에서는 hydration 뒤에야 최종 UI가 정해진다. 문제의 핵심은 동적 import 자체가 아니라, 양쪽 구현을 싣고 클라이언트에서 뒤늦게 선택한 구조였다.

개선된 블로그 제작 후기

개선된 블로그 제작 과정의 후기를 담고 있지만, 세세한 개발 과정이나 작동 원리는 다루지 않는다.

Dependencies

  • Node.js@20
  • React@18
  • next@14
  • TypeScript@5
  • tailwindcss
  • next-contentlayer
  • vercel

Rendering

서버/클라이언트 컴포넌트

서버/클라이언트 컴포넌트

서버 컴포넌트 코드는 서버 또는 정적 빌드 단계에서 실행되고 클라이언트 JavaScript bundle에는 포함되지 않는다. Next.js는 Server Component의 RSC Payload와 HTML을 만들고, 브라우저는 이를 Client Component의 지시 정보와 함께 사용한다. 단순히 결과 UI만 전달한다고 표현하기보다, 클라이언트에서 실행할 JavaScript의 경계를 줄일 수 있다는 점이 내가 원하던 모습에 가까웠다.

Moving Client Components Down the Tree To reduce the Client JavaScript bundle size, we recommend moving Client Components down your component tree.

Next.js docs

Next.js 공식 문서에서는 클라이언트 최적화를 위해 클라이언트 컴포넌트를 컴포넌트 트리의 아래쪽에 두기를 권장한다. 이런 구조는 App Router가 아니더라도 지향해야 할 설계 방향이라고 생각한다.

import { useState } from "react";
 
const HomePage = () => {
  const [count, setCount] = useState(0);
 
  const handleCountClick = () => {
    setCount((currentCount) => currentCount + 1);
  };
 
  return (
    <>
      <div>
        <p>{count}</p>
        <button onClick={handleCountClick}>count+1</button>
      </div>
      <OtherComponent1 />
      <OtherComponent2 />
      <OtherComponent3 />
    </>
  );
};

위와 같은 코드는 내가 React를 배운 지 얼마 되지 않았을 때 자주 하던 실수다. HomePage 안에 counter와, count 상태를 사용하지 않는 OtherComponent들이 함께 있다.

버튼을 눌러 count 상태가 바뀌면 HomePage와 그 아래 컴포넌트의 render 함수가 다시 호출될 수 있다. 다만 React는 render 결과를 비교한 뒤 실제로 달라진 DOM만 commit하므로, render 호출과 DOM 변경은 같은 뜻이 아니다.

import { useState } from "react";
 
const Counter = () => {
  const [count, setCount] = useState(0);
 
  const handleCountClick = () => {
    setCount((currentCount) => currentCount + 1);
  };
 
  return (
    <div>
      <p>{count}</p>
      <button onClick={handleCountClick}>count+1</button>
    </div>
  );
};
 
const HomePage = () => {
  return (
    <>
      <Counter />
      <OtherComponent1 />
      <OtherComponent2 />
      <OtherComponent3 />
    </>
  );
};

상태를 Counter 안으로 옮기면 count가 바뀔 때 형제 컴포넌트의 render 작업을 피할 수 있다. 이것은 App Router만의 패턴이라기보다 상태를 사용하는 곳 가까이에 두는 React의 일반적인 설계 원칙이고, Client Component의 경계를 필요한 부분까지 내리는 방향과도 잘 맞았다.

Routing

블로그 레이아웃

블로그 레이아웃

새 블로그는 심플함을 추구했다. 페이지가 바뀌어도 기본 레이아웃이 유지되는 것은 공유 layout.tsx와 App Router의 partial rendering 덕분이었다. Parallel Routes@head-section, @right-section처럼 같은 레이아웃 안에 여러 named slot을 동시에 또는 조건에 따라 렌더링할 때 사용했다.

app/
├── (about-layout)/
│   ├── about/
│   └── layout.tsx
├── (blog-layout)/
│   ├── @head-section/
│   ├── @right-section/
│   ├── p/[title]/
│   ├── tags/tag/
│   ├── layout.tsx
│   ├── page.tsx
├── layout.tsx
├── not-found.tsx

(about-layout)(blog-layout) 같은 Route Group은 URL 경로에 폴더 이름을 추가하지 않으면서 경로를 묶어준다. 각 그룹 안에 별도의 layout.tsx를 두면 페이지 묶음마다 다른 레이아웃을 구성할 수 있다.

다만 파일 이름을 page.tsx, layout.tsx처럼 고정해야 해서 코드 에디터에 같은 이름의 창이 여러 개 열려 있는 점은 불편할 수도 있겠다고 느꼈다.

그 외

  • Tailwind CSS의 utility class는 스타일을 컴포넌트 가까이에서 짧게 조합할 수 있었다. 다만 기존 CSS 속성에 익숙한 사람에게는 축약된 클래스 이름을 다시 익혀야 하는 비용이 있었다.
  • Markdown 원문을 Firebase에 저장하는 대신 로컬 파일로 옮겼다. 당시 next-contentlayer를 이용해 frontmatter와 MDX를 빌드 단계에서 타입이 있는 데이터로 바꿔 참조했다.
  • 댓글은 간단한 설정으로 구현할 수 있고 광고 기능이 없는 giscus를 선택했다.

마치며

블로그의 디자인과 설계, 개발도 중요하지만 가장 중요한 본질은 글이다.

예전 블로그에는 널리 알려진 단순한 문제 해결이나 기본 개념을 노트 필기하듯 적었다. 이제는 직접 문제를 탐구하고, 다양한 동작 원리를 쉽게 풀어 설명하는 글을 쓰려고 한다.

블로그 디자인 참조