Next.js의 라우팅은 어떻게 구현되어 있을까?

JunHo Yoo·2026년 5월 25일

최근 Next.js의 공식 문서와 인프런 강의를 보면서 공부하던 중 라우터가 어떻게 구현되어 있는지에 대한 내용을 학습했습니다. 사용할때는 단순하게 구현이 되어있겠지라고 생각하며 사용했지만, 내부 로직에 대해 이해를 하고 사용하니 SSR이 어떻게 Code Spliting을 하는지, URL과 파일 디렉토리를 어떻게 연결지어 페이지를 불러오는지에 대해 알 수 있었습니다. 오늘은 그 중에서도 Next.js가 빌드할 때, 라우트를 어떻게 정의하는지, 서버에 요청이 들어왔을 때, 어떻게 매칭하여 페이지를 전송하는지에 대해 정리하겠습니다.

Server 구축

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);
});

서버 생성

  1. http.createServer를 사용해 서버를 생성한다.
    1-1: req의 쿼리스트링을 분리하여 URL Segment를 생성한다.
    1-2: 리턴 값 정의, GET 방식이 아닌 다른 방식으로 올때 에러 핸들링 등 서버 핸들링을 정의한다.
    1-3: matchRoute 메서드를 통해 해당 URL에 매핑되는 페이지가 있는지 검사한다. (없다면 404 리턴)
    1-4: matchRoute 메서드에서 Map을 순회하며 해당 세그먼트에 해당하는 경로가 있는지 확인한다.
    1-5:이때, 동적 세그먼트를 분리하여 params로 변환하고, buildRoutes에서 정의했던 page를 require하는 핸들러를 리턴한다.

라우트 구축

  1. buildRoutes 메서드를 통해 map 구조에 디렉토리를 순회하며 검사한다.
    2-1: 만약 현재 entry가 디렉터리라면 buildRoutes 메서드를 재귀적으로 탐색한다.
    2-2: 만약 현재 파일이 page.js라면 route에 page를 제외한 경로를 key로 map에 등록한다.
    2-3: 만약 현재 파일이 route.js라면 route를 제외한 경로를 key로 Map에 등록한다.

참고

Next.js 까보기

profile
매일 발전하는 프론트엔드 개발자

0개의 댓글