[Next.js] Next.js에서의 라우팅은 어떻게 다를까?

라우팅이란 무엇인가?

URL 경로와 그에 대응하는 처리 로직을 연결하는 것이다.

다만 경로에 따라 특정 UI나 컴포넌트는 반환해주는 것만이 아니라, 경로에 맞는 적절한 응답이나 뷰를 제공하는 맵핑작업이다.

 

 

대표적인 라우팅 방식

  • 명시적 라우팅
import { BrowserRouter, Routes, Route } from 'react-router-dom';

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/detail" element={<Detail />} />
        <Route path="/detail/:id" element={<DetailPage />} />
      </Routes>
    </BrowserRouter>
  );
}

 

명시적 라우팅은 코드로 직접 라우트를 선언하고 정의하는 방식이다.

대표적으로 react-router-dom 이 있는데, 위처럼 코드로 직접적으로 'path' 에 각각 'component' 를 맵핑해 명시적으로 구현한 형태이다.

 

어떤식으로 라우팅 되는지 이해하기 위해 간단하게 알아보자.

 

1. 라우트 수집 및 평탄화

function createRoutesFromChildren(
	children,      // <Route path="/detail/:id" element={<DetailPage />} />
	parentPath = []
) {
  let routes = [];
  React.Children.forEach(children, (element, index) => {
	  ...  // 검증 부분 생략
    let route = {
      id: element.props.id || treePath.join("-"),  // "0", "1", "1-1", ...
      element: element.props.element,              // <DetailPage />
      path: element.props.path,                    // "detail/:id"
      ...
    };
    ...
    routes.push(route);
  });
  return routes;
}

'<Route>' 컴포넌트가 전달받은 정보들을 토대로 컴포넌트들을 JavaScript 객체로 생성한다.

routes = [
  {
    id: "0",
    path: "/detail",
    element: <Detail />,
  },
  {
    id: "1",
    path: "/detail/:id",
    element: <DetailPage />,
  }
]

이런 식으로 맵핑된 라우트 객체가 생성되게 된다.

 

맵핑된 객체를 가지고 평탄화 작업을 진행하게 되는데, 부모와 자식간의 레벨을 맞추기 위한 작업이다. 점수 기반의 정렬을 하기 위해 필요한 작업인데 여기서는 동등한 레벨이기에 점수 계산만 적용된다.

 

branches = [
  {
    path: "/detail",
    score: 13,
    routesMeta: [{ relativePath: "/detail", childrenIndex: 0, ... }]
  },
  {
    path: "/detail/:id",
    score: 17,
    routesMeta: [{ relativePath: "/detail/:id", childrenIndex: 1, ... }]
  }
]

반환된 결과에서 score의 의미는 해당 경로가 얼마나 구체적이고 좁은 범위를 의미하는가를 수치화한 것이다.

(높을수록 구체적이다.)

 

2. URL 매칭 (with 정규식 생성)

 

이제 평탄화된 라우트를 순회하며 현재 URL과 매칭을 시도한다.

예를 들어 사용자가 '/detail/123' 에 접속을 시도하면,

조금 전 평탄화된 라우트 객체에서 score가 높은 순서대로 정규식을 이용한 매칭을 시도한다.

 

'/detail/123'.match(/^\/detail\/([^\\/]+)$/) // 정규식 매칭

// 결과 반환
return {
  params,                     // { id: "123" }
  pathname: matchedPathname,  // "/detail/123"
  pathnameBase,               // "/detail/123"
  pattern                     // { path: "detail/:id", ... }
};

이런 형태로 진행되고, params는 별도로 추출된다.

 

3. context로 params 전달 및 렌더링

 

마지막 과정으로 params는 context로 전달되고, 매칭된 Element를 조립하는 과정이 진행된다.

우리가 아는 'useParams()' 도 여기서 전달된 params를 가져오는 context 훅이다.

 

결론적으로 react-router는 정규식 패턴 매칭을 이용해 라우팅을 처리한다고 볼 수 있다.

 

 

  • 코드 기반 라우팅
const express = require('express');
const app = express();

app.get('/detail', (req, res) => {
  res.send('<h1>Detail Page</h1>');
});

app.get('/detail/:id', (req, res) => {
  res.send(`<h1>Detail ${req.params.id}</h1>`);
});

app.listen(3000);

코드 기반 라우팅은 경로에 따라 필요한 코드를 전달해주는 방식이다.

 

경로와 코드 및 컴포넌트를 연결하는 점에서 명시적 라우팅과 크게 다를게 무엇인가 싶을텐데, 과거의 웹을 구성하던 방식을 생각하면 이해하기 쉽다.

 

현재의 SPA 방식이 아닌 MPA 방식을 선택했다면 매 경로마다 일치하는 코드(HTML 문서)를 전달했을 것이다. 반면 SPA라면 초기에 하나의 HTML에 JS번들을 전부 다운로드받아 컴포넌트를 교체하는 방식이다.

즉, 명령형(MPA)과 선언형(SPA)의 차이 정도로 이해하면 될 듯하다.

 

app.get('/api/users', (req, res) => {
  res.json([{ id: 1, name: 'John' }])
})

참고로 현재에는 Express(코드 기반 라우팅)은 HTML을 보내지 않고 JSON만을 보내는 역할을 한다.

 

 

  • 데코레이터 기반 라우팅
@Controller('users')
export class UsersController {
  @Get(':id')
  findOne(@Param('id') id: string) {
    return { id, name: 'John' };
  }
}

데코레이터 기반 라우팅은 클래스와 메서드 위에 '@' 기호를 붙여서 라우팅 정보를 선언하는 방식이다.

데코레이터는 어떻게 동작해야하는지를 담고있는 설명서와 같다.

여기서는 데코레이터를 통해 'GET /users/:id' 라는 경로를 생성해두고, 'GET /users/123' 같은 요청이 들어오면 'findOne' 메서드를 실행하도록 하는 기능을 담고 있다.

 

  • 폴더구조 기반 라우팅
// Next.js 12이하
pages/
├── detail.js      -> localhost:3000/detail + html
└── detail/
    └── [id].js 

// Next.js 13이상
app/
└── detail/        -> localhost:3000/detail
    ├── page.js    -> html(detail)
    ├── layout.js
    ├── loading.js
    └── [id]/
        └── page.js

Next.js가 사용하고 있는 파일구조 기반의 라우팅에서는 파일/폴더를 기준으로 라우팅을 한다.

12/13 버전을 기준으로 라우팅 방식에 약간의 변화가 생겼는데, 간단히 설명하자면,

 

12버전 이하

12버전까지는 pages 폴더 하위에 파일명 = URL인 라우팅 방식을 제공하였다. 파일명이 pathname이기 때문에 굉장히 직관적인 구조이며 단순하게 설정이 가능했다.

 

13버전+

 

13버전부터는 app 폴더 하위에 원하는 url path으로 네이밍된 폴더가 위치하게 된다. 라우팅 경로는 생성되었지만, 응답해줄 페이지 컴포넌트가 존재하지 않기에 그 하위에 'page.tsx' 라는 파일을 생성해 제공한다.

 

12버전까지를 Page Router라고 하고 13버전부터를 App Router라고 하는데, Next가 Page Router의 직관적인 구조를 포기하고 폴더명을 기준으로 라우팅을 하게된 이유는 무엇일까?

 

‘detail’이라는(위의 경우) 폴더 아래에 ‘page’파일 외의 다른 역할을 해줄 파일이 필요했기 때문이다. 예를 들어 페이지별로 레이아웃이나 로딩, 에러 페이지 등은 굉장히 자주 쓰이는 컴포넌트들이다. 12버전에서는 각 페이지 컴포넌트 내에서 관리하거나, '_app.js' 에서 각 경로마다 분기처리를 해야했다.

 

function MyApp({ Component, pageProps, router }) {
  let Layout = DefaultLayout;

  if (router.pathname.startsWith('/detail')) {
    Layout = DetailLayout;
  }

  return (
    <Layout> 
      <Component {...pageProps} /> 
    </Layout>
  );
}

페이지가 많아지고 각각 다른 레이아웃을 필요로 한다면 그야말로 분기처리의 지옥이 될 것이다.

 

13버전의 App Router에서는 이를 폴더 구조를 이용해 분기처리를 해결하고, 좀더 손쉽게 사용할 수 있도록

'layout.tsx', 'loading.tsx', 'error.tsx' 등으로 공용화(추상화) 하였다.

 

참고로 layout.tsx, page.tsx 등은 Next.js에서의 파일 컨벤션이다.

 

Next.js 공식문서 중

 

Next.js가 이러한 구조를 채택하는 이유는 개발자 경험(DX)을 핵심 설계 원칙으로 두고 있기 때문이다.

즉, 개발자가 애플리케이션을 손쉽게 구축할 수 있도록 지원하고자 하기 때문에, 복잡한 라우팅 설정 등을 프레임워크 내부로 추상화하여 감추었다.

 

다시 돌아가서, Next.js가 폴더구조 기반의 라우팅, 이제 정확히는 파일 시스템 기반 라우팅을 채택한 이유도 개발자 경험을 개선하고자 하였기 때문이다.

 

Next.js에서는 어떤 식으로 라우팅 되는지 이해하기 위해 간단하게 알아보자.

 

1. 빌드 타임: 파일 스캔 및 라우트 맵핑

Next.js의 라우팅 관련 코드가 상대적으로 매우 복잡한 관계로 그 과정과 개념만 살려 표현하겠다.

라우트로 변환할 대상은 app 하위의 파일들과 같다.

function walk(dir, segments = []) {
	// 라우트 파싱
	...
	const newSegments = [
      ...segments,
      { name: folderName, type: segmentType, paramName }
  ]
        
  walk(path.join(dir, folderName), newSegments);
}

파일 시스템을 이용해 app 폴더 하위의 파일을 읽어와 라우트로 변환한다.

이때 동적/정적 라우트 구분을 하여 type으로 반환하고, (main)과 같은 특수 폴더를 포함하지 않도록 처리한다.

 

[
  {
    segments: [
      { name: 'detail', type: 'static', paramName: 'detail' },
      { name: '[id]', type: 'dynamic', paramName: 'id' }
    ],
    filePath: 'app/detail/[id]/page.tsx'
  }
]

결과값은 앞서 보았던 react-router에서의 라우트 객체와 유사하다.

 

2. 정규식 변환 및 우선순위 정렬

 

Next에서도 react-router와 같이 정규식을 이용해 url과의 비교를 진행한다.

다만, 가장 큰 차이점은 정규식을 빌드 시점에 한 번만 생성한다는 것이다.

또한, 점수 기반과 달리 사전에 구체적인 순서대로 정렬을 해둔다.

 

3. 라우트 매니페스트 생성(routes-manifest.json)

 

빌드 과정에서 생성해낸 모든 라우팅 정보를 JSON파일로 저장한다.

'./next/routes-manifest.json' 해당 경로에

{
  "version": 3,
  "staticRoutes": [
    "/about",
    "/detail/new"
  ],
  "dynamicRoutes": [
    {
      "page": "/detail/[id]",
      "regex": "^\\/detail\\/([^\\/]+?)(?:\\/)?$",
      "routeKeys": ["id"]
    }
  ]
}

이런 형태의 라우팅 정보를 저장해두고 서버가 시작될 때 메모리에 로드되어 런타임 매칭에 사용된다.

이 지점이 React Router와의 가장 큰 차이인데, 런타임 환경에서 JSX 트리를 순회하여 라우트를 수집하는 방식과 달리 Next.js에서는 빌드 타임에 이미 모든 라우트를 계산하고 파일로 저장해둔다.

 

4. 런타임: URL 매칭 및 컴포넌트 조립

 

런타임 환경에서의 URL 매칭은 유사하다.

사용자가 '/detail/123' 에 접속을 시도하면,

  1. staticRoutes 맵에서 검색
  2. dynamicRoutes 을 순회하며 정규식 매칭 → 캡처 그룹에서 params 추출

의 순서로 라우트 정보 매칭을 시도한다.

 

원하는 컴포넌트를 찾았다면, 이제 조립을 하면 된다.

앞서 말했듯이 파일 컨벤션을 통해 모든 라우팅 구성이 이루어져있기 때문에, 컴포넌트 조립시에도 이 파일 컨벤션을 이용한다.

 

async function renderPage(matchedRoute) {
  const { page, params } = matchedRoute
  // page = '/detail/[id]', params = { id: '123' }
  const segments = page.split('/').filter(Boolean)
  // ['detail', '[id]']
  
  const layouts = []
  let currentPath = 'app'
  
  for (const segment of segments) {
    currentPath = path.join(currentPath, segment)
    const layoutPath = path.join(currentPath, 'layout.tsx')
    
    if (fs.existsSync(layoutPath)) {
      layouts.push(await import(layoutPath))
    }
  }
  
  // layouts = [
  //   RootLayout,    // app/layout.tsx
  //   DetailLayout   // app/detail/layout.tsx
  // ]
  
  const PageComponent = await import(path.join(currentPath, 'page.tsx'))
  let tree = <PageComponent params={params} />
  
  for (let i = layouts.length - 1; i >= 0; i--) {
    const Layout = layouts[i]
    tree = <Layout params={params}>{tree}</Layout>
  }
  
  return tree;
}

요청된 path를 이용해 해당 폴더 위치를 찾아내고, 폴더 하위에 있는 'layout.tsx'등의 파일을 찾아 적용한다.

 

<RootLayout>
  <DetailLayout>
    <PageComponent params={{ id: '123' }} />
  </DetailLayout>
</RootLayout>

얻어진 컴포넌트 트리는 이러할 것이다.

 

결론적으로 Next.js는 정규식 패턴 매칭을 사용한다는 점에서 React Router와 유사하지만, 모든 계산을 빌드 타임에 수행하고 그 결과를 매니페스트 파일로 저장한다는 점이 핵심적인 차이다.