최근 Next.js의 공식 문서와 인프런 강의를 보면서 공부하던 중 라우터가 어떻게 구현되어 있는지에 대한 내용을 학습했습니다. 사용할때는 단순하게 구현이 되어있겠지라고 생각하며 사용했지만, 내부 로직에 대해 이해를 하고 사용하니 SSR이 어떻게 Code Spliting을 하는지, URL과 파일 디렉토리를 어떻게 연결지어 페이지를 불러오는지에 대해 알 수 있었습니다. 오늘은 그 중에서도 Next.js가 빌드할 때, 라우트를 어떻게 정의하는지, 서버에 요청이 들어왔을 때, 어떻게 매칭하여 페이지를 전송하는지에 대해 정리하겠습니다.
const http = require('http'); // Node.js 내장 HTTP 서버 모듈
const fs = require('fs').promises; // 비동기 파일 시스템 API (fs.readdir 등)
const path = require('path'); // 경로 문자열 처리 유틸리티
const { URL } = require('url'); // URL 파싱 (pathname, searchParams 분리)
const PORT = 3000; // 서버가 수신할 포트 번호
const routes = new Map(); // 라우트 테이블: { "/blog/[slug]" → handler모듈 }
async function buildRoutes(dir, baseRoute = '') {
// withFileTypes: true → Dirent 객체 반환 (isDirectory/isFile 메서드 포함)
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.resolve(dir, entry.name);
// URL용 세그먼트 조합: .js 확장자 제거 후 '/'로 연결
const routePart = [baseRoute, entry.name.replace(/\.js$/, '')]
.filter(Boolean).join('/');
if (entry.isDirectory()) {
// 라우트 그룹 감지: (marketing) 처럼 괄호로 감싼 폴더는 URL에서 제외
const isGroup = /^\(.*\)$/.test(entry.name);
// 그룹이면 baseRoute 유지, 아니면 routePart 추가하며 재귀
await buildRoutes(fullPath, isGroup ? baseRoute : routePart);
} else if (entry.isFile() && entry.name === 'page.js') {
// page.js → 화면 렌더링용 (GET 전용)
// "blog/page" → "/blog" | 루트 "page" → "/"
const routePath = '/' + routePart.replace(/\/?page$/, '');
routes.set(routePath, require(fullPath)); // Map에 등록
} else if (entry.isFile() && entry.name === 'route.js') {
// route.js → API 엔드포인트 (GET/POST/PUT/DELETE 등 메서드명으로 export)
const routePath = '/' + routePart.replace(/\/?route$/, '');
routes.set(routePath, require(fullPath));
}
}
}
function matchRoute(pathname) {
// ① 정확히 일치하는 정적 라우트부터 먼저 확인 (가장 빠른 경로)
if (routes.has(pathname)) {
return { handler: routes.get(pathname), params: {} };
}
// 요청 URL을 세그먼트 배열로 분리: "/blog/hello" → ["blog","hello"]
const pathParts = pathname.split('/').filter(Boolean);
// ② 등록된 모든 패턴을 순서대로 비교 (동적 라우트 탐색)
for (const [route, handler] of routes) {
const routeParts = route.split('/').filter(Boolean);
const params = {}; // 추출된 동적 파라미터 저장소
let matched = true;
// [...name] catch-all 세그먼트 위치 탐색
const catchAllIdx = routeParts.findIndex(p => p.startsWith('[...'));
if (catchAllIdx !== -1) {
// catch-all 이전 정적 세그먼트들이 먼저 일치해야 함
for (let i = 0; i < catchAllIdx; i++) {
if (routeParts[i] !== pathParts[i]) { matched = false; break; }
}
if (matched && pathParts.length >= catchAllIdx) {
// [...path] → paramName = "path", 나머지 세그먼트를 '/'로 다시 합침
const paramName = routeParts[catchAllIdx].replace(/^\[\.\.\.(.+)\]$/, '$1');
params[paramName] = pathParts.slice(catchAllIdx).join('/');
return { handler, params };
}
continue; // catch-all 이전 세그먼트 불일치 → 다음 패턴으로
}
// 세그먼트 수가 다르면 매칭 불가
if (routeParts.length !== pathParts.length) continue;
// 세그먼트 하나씩 비교
for (let i = 0; i < routeParts.length; i++) {
const routeSeg = routeParts[i];
const pathSeg = pathParts[i];
if (routeSeg.startsWith('[') && routeSeg.endsWith(']')) {
// [slug] 동적 세그먼트 → 값 캡처 (% 인코딩도 디코드)
const paramName = routeSeg.slice(1, -1); // "[slug]" → "slug"
params[paramName] = decodeURIComponent(pathSeg);
} else if (routeSeg !== pathSeg) {
matched = false; // 정적 세그먼트 불일치 → 루프 탈출
break;
}
}
if (matched) return { handler, params }; // 매칭 성공
}
return null; // 일치하는 라우트 없음 → 404로 처리
}
const server = http.createServer(async (req, res) => {
// URL 파싱: pathname과 쿼리스트링 분리
// ex) "/blog/hello?page=2" → pathname="/blog/hello", search="?page=2"
const urlObj = new URL(req.url, `http://localhost`);
// 후행 슬래시 제거: "/blog/" → "/blog" | 루트는 "/" 유지
const pathname = urlObj.pathname.replace(/\/$/, '') || '/';
const method = req.method.toUpperCase(); // "get" → "GET" 정규화
// ── 헬퍼 메서드 주입 ─────────────────────────────────────────────────
// req.query: URLSearchParams → 일반 객체로 변환
// ex) "?role=admin&page=2" → { role: "admin", page: "2" }
req.query = Object.fromEntries(urlObj.searchParams);
// req.json(): 요청 바디를 스트림으로 읽어 JSON 파싱 후 반환
req.json = () => new Promise((resolve, reject) => {
let body = '';
req.on('data', chunk => (body += chunk)); // 청크 단위로 수신
req.on('end', () => { // 수신 완료 후 파싱
try { resolve(JSON.parse(body)); }
catch (e) { reject(e); } // JSON 파싱 실패 시 에러 전파
});
});
// res.json(): JSON 응답 전송 (Content-Type 자동 설정)
res.json = (data, status = 200) => {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data, null, 2));
};
// res.send(): HTML 응답 전송
res.send = (html, status = 200) => {
res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);
};
// ── 라우트 매칭 ──────────────────────────────────────────────────────
const matched = matchRoute(pathname);
if (!matched) {
// 매칭된 라우트 없음 → 404 응답
return res.json({ error: 'Not Found', path: pathname }, 404);
}
const { handler, params } = matched;
req.params = params; // { slug: "hello-world" } 형태로 핸들러에서 접근 가능
// ── 디스패치: 올바른 핸들러 함수 선택 후 호출 ────────────────────────
try {
if (typeof handler[method] === 'function') {
// route.js: exports.GET / exports.POST 등 메서드명으로 export된 경우
await handler[method](req, res);
} else if (typeof handler.default === 'function') {
// page.js: exports.default 로 export된 페이지 핸들러
if (method !== 'GET' && method !== 'HEAD') {
// 페이지는 GET/HEAD 외 메서드 허용 안 함
return res.json({ error: 'Method Not Allowed' }, 405);
}
await handler.default(req, res);
} else {
// 해당 HTTP 메서드가 export되지 않은 경우 → 405 + Allow 헤더
const allowed = ['GET','POST','PUT','PATCH','DELETE']
.filter(m => typeof handler[m] === 'function').join(', ');
res.writeHead(405, { 'Allow': allowed });
res.end(`Method ${method} not allowed`);
}
} catch (err) {
// 핸들러 내부에서 throw된 에러 → 500 응답으로 변환
console.error(`[에러] ${method} ${pathname}:`, err);
res.json({ error: 'Internal Server Error', message: err.message }, 500);
}
// 접근 로그 출력 (핸들러 실행 이후)
console.log(`${method} ${pathname} ${res.statusCode ?? 200}`);
});
// pages/ 디렉터리 절대경로 계산 (__dirname = server.js 위치)
const PAGES_DIR = path.join(__dirname, 'pages');
// buildRoutes()는 async → Promise 반환 → .then()으로 완료 시점 처리
buildRoutes(PAGES_DIR).then(() => {
// 라우트 스캔이 끝난 후에만 포트를 열어 요청을 받기 시작
server.listen(PORT, () => {
console.log(`✅ http://localhost:${PORT}\n`);
});
}).catch(err => {
// pages/ 폴더 읽기 실패 등 초기화 에러 → 즉시 종료
console.error('라우트 빌드 실패:', err);
process.exit(1);
});