바닐라 JavaScript로 Next.js App Router의 핵심 메커니즘 구현
Next.js의 복잡한 최적화 레이어(RSC, Streaming, Turbopack)를 제외하고, App Router의 본질적인 동작 원리인 파일 기반 라우팅, SSR/CSR 전환, Layout 시스템, 데이터 페칭을 직접 구현하여 프레임워크의 설계 철학을 체득한다.
이전 경험: React의 VDOM, Diffing, Fiber 아키텍처를 바닐라 JavaScript로 직접 구현하며, "프레임워크를 재현하는 과정"이 깊은 이해로 이어진다는 것을 확인했다.
Next.js의 위치: 현대 웹 개발에서 Next.js는 단순한 프레임워크를 넘어 "웹 애플리케이션 아키텍처의 표준"이 되었다. 하지만 그 내부 동작은 여러 레이어로 추상화되어 있어 블랙박스처럼 느껴진다.
학습 목표: App Router의 핵심 메커니즘을 직접 구현함으로써:
- 서버/클라이언트 경계를 어떻게 설계하는지
- 파일 시스템이 어떻게 라우터가 되는지
- SSR에서 CSR로 자연스럽게 전환되는 구조
- Layout이 중첩되면서도 효율적으로 렌더링되는 방법
이 모든 것을 코드 레벨에서 이해하고자 한다.
| 도전 요소 | 상세 설명 |
|---|---|
| 서버/클라이언트 경계 설계 | "use client" 지시어를 통해 컴포넌트 렌더링 위치를 결정하고, SSR과 CSR의 책임을 명확히 분리 |
| 파일 기반 라우팅 엔진 | 디렉토리 구조를 스캔하여 라우트 트리를 생성하고, 동적 세그먼트([id])를 파싱하는 매칭 알고리즘 구현 |
| Layout 중첩 시스템 | 상위 layout이 하위 페이지를 감싸는 구조를 DFS로 수집하고, 효율적으로 조립 |
| Hydration 전략 | 서버에서 생성된 HTML에 클라이언트 이벤트를 연결하는 "부분 Hydration" 구현 |
app/디렉토리를 재귀적으로 스캔하여 라우트 트리 생성- 동적 라우트
[id]지원 및 파라미터 추출 - 부모 노드 참조를 통한 Layout 상속 구조
app/blog/[id]/page.jsx→/blog/123URL 매핑- 정적 라우트 우선 매칭, 동적 라우트는 fallback
params객체로 동적 세그먼트 추출 ({ id: "123" })
요청 수신
→ 라우트 매칭
→ getData() 호출 (있는 경우)
→ 컴포넌트 렌더링
→ Layout 중첩 적용
→ HTML 응답
- 부모 노드를 따라가며 모든 상위 layout 수집
- 수집된 layout을 역순으로 적용하여 중첩 구조 생성
- 최종적으로
<RootLayout><PageLayout><Page /></PageLayout></RootLayout>형태
- 파일 첫 줄에
"use client"지시어가 있으면 클라이언트 컴포넌트로 분류 - 클라이언트 컴포넌트는 서버에서
<div data-client="...">placeholder만 렌더링 - 브라우저에서 해당 placeholder를 찾아 실제 컴포넌트로 hydrate
- 서버에서 생성된
data-client속성을 가진 DOM 노드를 탐색 - 해당 경로의 JavaScript 모듈을 동적 import
- 기존 DOM을 유지하면서 이벤트 리스너만 연결 (부분 Hydration)
- 각 클라이언트 컴포넌트는 독립적으로 hydrate
- Transform 캐싱: 파일 mtime + size 기반 해시로 Babel 변환 결과 캐시
- Hot Reload: 5초마다 라우트 트리 재스캔 (개발 모드)
- 부분 Hydration:
data-client속성이 있는 DOM만 활성화
// app/blog/[id]/page.jsx
export async function getData({ params }) {
const post = await fetchPost(params.id);
return { post };
}
export default function Page({ data }) {
return <h1>{data.post.title}</h1>;
}// render.js
const Page = await loadComponent(node.page);
const data = Page.getData
? await Page.getData({ params })
: null;
element = createElement(Page.default, { data, params });┌─────────────────────────────────────┐
│ Application Layer │
│ (app/*/page.jsx, layout.jsx) │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ Router Layer │
│ (scan.js, matcher.js, load.js) │
└─────────────────────────────────────┘
↓
┌──────────────────┬──────────────────┐
│ Server Layer │ Client Layer │
│ (render.js) │ (hydrate.js) │
└──────────────────┴──────────────────┘
↓
┌─────────────────────────────────────┐
│ Runtime Layer │
│ (createElement, VDOM, Diff) │
└─────────────────────────────────────┘
| 영역 | 선택 | 이유 |
|---|---|---|
| 모듈 시스템 | ESM | Node.js에서 최신 표준이며, 동적 import 지원 |
| JSX 변환 | Babel | 서버(CommonJS)와 클라이언트(ESM) 각각 변환 |
| 라우팅 | 파일 시스템 | Next.js App Router 구조를 그대로 재현 |
| 상태 관리 | useState(Fiber 기반) | React Hooks API와 동일한 인터페이스 |
- React Server Components: 복잡한 직렬화 프로토콜 제외
- Streaming SSR: 점진적 렌더링 대신 완전한 HTML 생성
- Code Splitting: 번들링 시스템 없이 동적 import만 사용
- Middleware: 라우팅 레벨에서만 처리
mini-next/
├── app/ # 애플리케이션 코드
│ ├── layout.jsx # 루트 Layout
│ ├── page.jsx # 홈 페이지
│ ├── blog/
│ │ └── [id]/
│ │ └── page.jsx # 동적 라우트
│ └── counter/
│ └── page.jsx # 클라이언트 컴포넌트
│
├── core/ # 프레임워크 코어
│ ├── router/
│ │ ├── scan.js # 파일 시스템 스캔
│ │ ├── matcher.js # URL 매칭 알고리즘
│ │ └── load.js # 컴포넌트 로더
│ ├── ssr/
│ │ ├── render.js # SSR 엔진
│ │ └── renderToString.js
│ ├── jsx/
│ │ └── createElement.js # JSX 런타임
│ └── client-hooks.js # useState, render 등
│
├── client/ # 클라이언트 런타임
│ ├── hydrate.js # Hydration 로직
│ └── navigation.js # SPA 네비게이션 (미구현)
│
└── server/
└── server.js # Express 서버 + 캐싱
- 파일의 수정 시간(mtime)과 크기를 조합한 해시 생성
- Babel transform 결과를 메모리 캐시에 저장
- 파일이 변경되지 않으면 캐시에서 즉시 반환
측정 결과
첫 요청: page.js → 31ms (transform)
두 번째 요청: page.js → 20ms (cache hit) ✅
개선율: 약 35.5% 감소
개발 모드에서 5초마다 app/ 디렉토리를 재스캔하여 새로운 페이지 추가나 삭제를 자동 감지합니다. 서버 재시작 없이 파일 시스템 변경사항이 반영됩니다.
| 컴포넌트 타입 | 초기 HTML | Script 로딩 | 총 시간 |
|---|---|---|---|
| Server Component | 17-42ms | - | 17-42ms |
| Client Component | 5ms | 2-8ms | 7-13ms |
분석:
- Server Component는 SSR 오버헤드가 있지만 즉시 콘텐츠 표시
- Client Component는 초기 응답은 빠르지만 JS 다운로드 + 실행 필요
- 실제 Next.js 프로덕션 빌드와 유사한 패턴
프로덕션 모드를 구현한다면, 빌드 타임에 모든 컴포넌트를 사전 변환하여 .next/ 디렉토리에 저장할 수 있습니다. 런타임에는 이미 변환된 코드를 바로 사용하여 SSR 시간을 17-42ms에서 5-10ms로 단축할 수 있습니다.
"파일 시스템 = 라우터"
- Convention over Configuration의 실제 구현
- 디렉토리 구조가 곧 URL 구조가 되는 것의 장점
- 개발자가 라우팅 로직을 작성하지 않아도 됨
서버/클라이언트 경계
- 단순한 문자열 지시어(
"use client")로 렌더링 위치 결정 - 서버는 HTML을, 클라이언트는 interactivity를 담당
- 명확한 책임 분리가 복잡도를 낮춤
Layout 중첩의 효율성
- 상위 Layout은 변경되지 않고 재사용
- 페이지 전환 시 필요한 부분만 업데이트
- SPA의 장점(빠른 전환) + SSR의 장점(초기 로딩)
Babel Transform의 서버/클라이언트 분기
// 서버: CommonJS (eval로 실행)
plugins: [
['@babel/plugin-transform-modules-commonjs']
]
// 클라이언트: ESM (브라우저 native import)
presets: [[
'@babel/preset-env',
{ modules: false }
]]Layout 상속 구조
- 단순 재귀로는 부모 참조 불가
parent속성을 라우트 트리에 추가하여 해결- DFS로 상위 layout 수집 후 역순으로 감싸기
| 기능 | 현재 상태 | 구현 방법 |
|---|---|---|
| SPA Navigation | 미구현 | navigation.js에서 <a> 클릭 가로채기 + history.pushState |
| Metadata API | 미구현 | generateMetadata() 함수 지원 |
| Error Boundary | 미구현 | error.jsx 파일 기반 에러 처리 |
| Loading UI | 미구현 | loading.jsx + Suspense 구현 |
프로덕션 빌드 시스템
- 모든 페이지를 빌드 타임에 사전 변환
- 변환된 결과를
.next/디렉토리에 저장 - 런타임에는 사전 변환된 코드를 즉시 사용
Code Splitting
- 현재는 모든 페이지가 독립적으로 로드
- 페이지별 번들 생성 및 공통 의존성 분리
- Dynamic import를 통한 lazy loading 최적화
✅ Next.js App Router의 핵심 메커니즘을 바닐라 JavaScript로 재현
- 파일 기반 라우팅
- SSR/CSR 전환
- Layout 중첩
- 데이터 페칭
- 부분 Hydration
✅ 실제 동작하는 MVP 완성
- 동적 라우트 지원
- 서버/클라이언트 컴포넌트 분리
- 개발 모드 최적화 (캐싱, Hot Reload)
✅ 프레임워크 설계 철학 체득
- Convention over Configuration
- 레이어 분리 아키텍처
- 성능과 개발 경험의 트레이드오프
이전 프로젝트(React 구현): "프레임워크가 무엇을 하는가"를 이해 이번 프로젝트(Next.js 구현): "프레임워크가 어떻게 설계되는가"를 이해
단순히 API를 사용하는 것을 넘어, 왜 그런 API가 필요한지, 내부에서 어떤 문제를 해결하고 있는지를 코드 레벨에서 경험했다.
이번 미션을 통해 구축한 기반 위에:
- SPA Navigation을 추가하여 완전한 풀스택 프레임워크로 발전
- 실제 프로젝트에 적용하며 실전 경험 축적
- 오픈소스로 공개하여 피드백 수렴
최종 목표: "프레임워크를 만드는 사람의 시각"으로 기술을 바라보는 개발자