Node.js - Express 라우터 분할
Express에서 라우팅 로직을 별도의 컨트롤러 파일로 분리하여 코드의 모듈성과 유지보수성을 높이는 방법을 학습합니다.
Node.js - Express 라우터 분할
애플리케이션의 규모가 커지면서 app.js
파일에 모든 라우팅 로직을 작성하는 것은 코드의 가독성과 유지보수성을 떨어뜨립니다. Express에서는 라우팅 로직을 기능별로 별도의 파일로 분리하여 관리하는 것이 일반적입니다. 이 단원에서는 라우팅 관련 함수들을 controllers
폴더로 분리하고, 이를 동적으로 로드하여 적용하는 방법을 학습합니다.
1. 컨트롤러(Controller)의 개념
컨트롤러는 MVC(Model-View-Controller) 아키텍처 패턴에서 유래한 개념으로, 사용자의 입력을 받아 처리하고, 모델(데이터)과 뷰(화면) 사이의 상호작용을 관리하는 역할을 합니다.
Express 기반의 백엔드 애플리케이션에서는 클라이언트의 요청(Request)을 받아 그에 따른 비즈니스 로직을 수행한 후, 응답(Response)을 보내주는 함수를 의미합니다. 즉, router.get('/path', (req, res) => { ... })
에서 (req, res) => { ... }
에 해당하는 콜백 함수가 컨트롤러의 역할을 수행합니다.
이러한 컨트롤러 함수들을 기능별로 묶어 별도의 파일로 관리하면, app.js
는 서버 설정과 미들웨어 관리라는 핵심적인 역할에만 집중할 수 있게 됩니다.
2. 라우팅 로직 분리하기
프로젝트 구조
라우팅 로직을 분리하기 위해 다음과 같이 controllers
폴더를 생성하고, 기능별로 파일을 구성합니다.
1
2
3
4
5
6
7
8
9
/
|-- controllers/
| |-- HelloController.js
| `-- ...
|-- helpers/
| |-- RouteHelper.js
| `-- ...
|-- app.js <-- 프로젝트 root에 위치해야 합니다.
`-- ...
controllers
: 각 기능(Resource)에 대한 라우팅 핸들러(컨트롤러 함수)들을 모아두는 폴더입니다.helpers/RouteHelper.js
: 컨트롤러 파일에 정의된 함수들을 Express 라우터에 자동으로 등록해주는 헬퍼 모듈입니다.
컨트롤러 작성 (/controllers/*.js
)
각 컨트롤러 파일은 특정 기능과 관련된 라우팅 함수들을 export
합니다. 예를 들어, HelloController.js
는 간단한 인사 메시지를 출력합니다.
이때, 각 함수가 어떤 HTTP 메서드와 경로에 연결될지를 지정하기 위해 RouteHelper.js
에서 만든 데코레이터 함수(GET
, POST
등)를 사용합니다.
실습: /controllers/HelloController.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { GET } from '../helpers/RouteHelper.js';
/**
* Hello 라우팅 핸들러
*/
export const hello = GET("/hello")((req, res, next) => {
res.send("<h1>Hello World</h1>");
});
/**
* World 라우팅 핸들러 - 배열을 사용하여 여러 경로를 동시에 설정
*/
export const world = GET(["/world", "/test"])((req, res, next) => {
const data = {
name: "Express",
type: "Framework",
};
res.json(data); // JSON 형식으로 응답
});
GET("/hello")
는 hello
함수가 /hello
경로의 GET 요청을 처리하도록 지정하는 “꼬리표” 역할을 합니다. 이 꼬리표 정보는 나중에 RouteHelper
가 읽어서 실제 라우팅 규칙으로 변환합니다.
잠깐! 클로저(Closure)란?
RouteHelper.js
의 코드를 이해하기 전에 자바스크립트의 중요한 개념인 클로저(Closure)에 대해 먼저 알아야 합니다. 클로저는 함수와 그 함수가 선언될 당시의 어휘적 환경(Lexical Environment)의 조합입니다. 말이 조금 어렵지만, 간단히 말해 “외부 함수의 변수에 접근할 수 있는 내부 함수”를 의미합니다.
클로저의 기본 형태
1
2
3
4
5
6
7
8
9
10
11
12
13
14
function outerFunction(outerVariable) {
// 외부 함수는 outerVariable 이라는 매개변수를 가짐
// 이 내부 함수가 바로 클로저입니다.
return function innerFunction(innerVariable) {
// 내부 함수는 자신의 변수(innerVariable)뿐만 아니라
// 외부 함수(outerFunction)의 변수(outerVariable)에도 접근할 수 있습니다.
console.log("outerVariable: " + outerVariable);
console.log("innerVariable: " + innerVariable);
}
}
const newFunction = outerFunction("outside");
newFunction("inside");
위 예제에서 outerFunction
은 innerFunction
이라는 또 다른 함수를 반환합니다. outerFunction
의 실행이 끝나도, newFunction
(즉, innerFunction
)은 자신이 생성될 때의 환경( outerVariable
이 “outside” 값을 가졌던 환경)을 여전히 “기억”하고 있습니다. 그래서 newFunction
을 실행하면 outerVariable
의 값에 접근할 수 있는 것입니다.
클로저는 언제 사용할까?
클로저는 주로 다음과 같은 경우에 유용하게 사용됩니다.
- 상태를 기억하고 싶을 때: 함수 호출이 끝나도 특정 값을 계속 기억하고 싶을 때 사용합니다. 예를 들어, 호출 횟수를 세는 카운터 함수를 만들 수 있습니다.
- 정보를 은닉하고 싶을 때: 외부에서 직접 접근해서는 안 되는 변수를 숨기고, 허용된 함수를 통해서만 조작하도록 할 때 사용합니다. (캡슐화)
- 함수 팩토리(Function Factory)를 만들 때: 비슷한 형태의 함수를 동적으로 생성해야 할 때 사용합니다.
우리의 RouteHelper.js
가 바로 3번째 경우에 해당합니다. GET(path)
함수는 path
라는 정보를 “기억”하는 새로운 함수를 생성하여 반환합니다. 이 반환된 함수가 우리가 컨트롤러에서 정의하는 (req, res, next) => { ... }
함수를 인자로 받아, 최종적으로 라우팅 정보를 완성하는 것입니다.
이제 이 클로저 개념을 생각하며 RouteHelper.js
코드를 살펴보겠습니다.
라우팅 헬퍼 (/helpers/RouteHelper.js
)
RouteHelper.js
는 컨트롤러 파일들을 스캔하여, 데코레이터 함수(GET
, POST
등)가 붙은 함수들을 찾아 Express 앱에 라우터로 등록하는 역할을 합니다.
실습: /helpers/RouteHelper.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import logHelper from './LogHelper.js';
/**
* 라우팅 데코레이터 함수 (내부용)
*/
const Route = (method, path) => {
return function(target) {
// 함수에 라우팅 메타데이터(꼬리표) 추가
target._routeMethod = method.toLowerCase();
target._routePath = Array.isArray(path) ? path : [path];
target._isRoute = true;
return target;
};
};
// 각 HTTP 메서드에 대한 데코레이터 함수들
export const GET = (path) => Route('GET', path);
export const POST = (path) => Route('POST', path);
export const PUT = (path) => Route('PUT', path);
export const DELETE = (path) => Route('DELETE', path);
/**
* 컨트롤러 모듈을 받아 라우팅 함수들을 Express 앱에 등록
*/
const routeHelper = (app, module) => {
// 모듈에서 export된 모든 항목을 순회
Object.keys(module).forEach(key => {
const func = module[key];
// 함수이고, 라우팅 꼬리표(_isRoute)가 있는지 확인
if (typeof func === 'function' && func._isRoute) {
const method = func._routeMethod; // 'get', 'post' 등
const paths = func._routePath; // ['/path1', '/path2']
// 각 경로에 대해 라우팅 등록
paths.forEach(path => {
app[method](path, func); // app.get('/path', func) 와 동일
logHelper.debug(`Route registered: ${method.toUpperCase()} ${path}`);
});
}
});
};
export default routeHelper;
이 헬퍼는 app.js
에서 사용되어, controllers
폴더 안의 모든 라우팅 설정을 자동으로 적용해 줍니다.
3. app.js 에서 동적 라우팅 적용
이제 app.js
에서 기존에 직접 작성했던 라우팅 코드들을 제거하고, controllers
폴더의 파일들을 동적으로 읽어와 RouteHelper
를 통해 라우팅을 설정하도록 수정합니다.
필요한 모듈 추가
라우팅 분리를 위해 fs-file-tree
모듈을 사용하여 특정 폴더 내의 파일 목록을 쉽게 가져올 수 있습니다.
1
$ yarn add fs-file-tree
app.js 수정
app.js
의 라우팅 관련 부분을 다음과 같이 수정합니다.
실습: /app.js
app.js
의 12, 21, 72~91 라인을 중심으로 변경 사항을 확인하세요.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/*----------------------------------------------------------
* 1) 환경설정 파일 로드
*----------------------------------------------------------*/
// ... 생략 ...
/*----------------------------------------------------------
* 2) 필요한 모듈 로드
*----------------------------------------------------------*/
import logger from "./helpers/LogHelper.js";
import utilHelper from "./helpers/UtilHelper.js";
// highlight-start
import routeHelper from './helpers/RouteHelper.js';
// highlight-end
import express from "express";
// ... 생략 ...
import expressWinston from "express-winston";
// highlight-start
import fsFileTree from 'fs-file-tree';
// highlight-end
/*-----------------------------------------------------------
* 3) Express 객체 생성 및 설정
*----------------------------------------------------------*/
// ... 생략 ...
/*----------------------------------------------------------
* 4) 라우터 객체를 이용한 URL별 분기 처리
*----------------------------------------------------------*/
// 기존의 router.get(...) 코드들은 모두 삭제합니다.
// highlight-start
(async () => {
// fsFileTree 모듈을 사용하여 controllers 디렉토리 내의 모든 .js 파일을 재귀적으로 탐색
const files = await fsFileTree('./controllers', { ext: '.js', recursive: true });
// 각 파일을 동적으로 import하고, 라우팅 함수들을 등록
for (const key in files) {
const file = files[key];
const module = await import(`./${file.path}`);
// 함수 기반 라우팅 등록
routeHelper(app, module);
}
})();
// highlight-end
/*----------------------------------------------------------
* 5) 설정한 내용을 기반으로 서버 구동 시작
*----------------------------------------------------------*/
// ... 생략 ...
소스코드 설명
import routeHelper from './helpers/RouteHelper.js';
(12라인): 우리가 만든 라우팅 헬퍼 모듈을 가져옵니다.import fsFileTree from 'fs-file-tree';
(21라인): 파일 시스템을 트리 구조로 탐색하는 모듈을 가져옵니다.async
즉시 실행 함수 (72~91라인):fsFileTree
를 사용해controllers
폴더 내의 모든.js
파일 목록을 가져옵니다.for...in
루프를 돌며 각 파일을import()
함수를 사용해 동적으로 로드합니다.import()
는 Promise를 반환하므로await
키워드를 사용합니다.- 로드된 모듈 객체(
module
)와 Express 앱 인스턴스(app
)를routeHelper
함수에 전달합니다. routeHelper
는 모듈 내부를 검사하여@GET
,@POST
등이 붙은 함수를 찾아app.get(...)
,app.post(...)
형태로 Express에 자동으로 등록해 줍니다.
이 구조를 통해, 새로운 API가 필요할 때마다 controllers
폴더에 파일을 추가하거나 기존 파일에 함수를 추가하기만 하면, app.js
를 수정할 필요 없이 자동으로 라우팅이 설정됩니다. 이는 코드의 결합도를 낮추고, 확장성을 높이는 매우 효율적인 방법입니다.