Express는 구조를 강제하지 않는다. 파일 하나에 모든 로직을 몰아넣어도 동작하지만, 프로젝트가 커질수록 그 자유가 독이 된다. MVC와 Layered Architecture는 이 문제를 해결하는 가장 보편적인 두 가지 접근법이다.
MVC(Model-View-Controller) 는 역할을 세 레이어로 나눈다. Express에서 View는 보통 JSON 응답이 대신하므로, 실질적으로는 Controller와 Model 분리가 핵심이다.
src/
├── controllers/
│ ├── userController.ts
│ └── postController.ts
├── models/
│ ├── user.ts
│ └── post.ts
├── routes/
│ ├── userRoutes.ts
│ └── postRoutes.ts
└── app.ts
Controller: HTTP 요청을 받아 Model을 호출하고 응답을 반환하는 역할 / Model: 데이터 구조와 DB 접근 로직을 담당
// controllers/userController.ts
import { Request, Response } from "express";
import { findUserById } from "../models/user";
export async function getUser(req: Request, res: Response) {
const user = await findUserById(req.params.id);
if (!user) return res.status(404).json({ message: "Not found" });
res.json(user);
}
MVC는 단순하고 직관적이지만, Model 레이어에 비즈니스 로직과 DB 쿼리가 섞이기 쉽다.
Layered Architecture는 MVC를 더 세밀하게 분리한다. 비즈니스 로직을 Service, DB 접근을 Repository로 독립시킨다.
src/
├── controllers/ ← HTTP 요청/응답만 처리
├── services/ ← 비즈니스 로직
├── repositories/ ← DB 쿼리
├── models/ ← 타입/스키마 정의
├── routes/
└── app.ts
Repository: DB 접근을 추상화하는 레이어. ORM 교체나 테스트 목킹이 쉬워진다.
각 레이어는 바로 아래 레이어만 호출한다:
Request → Controller → Service → Repository → DB
// repositories/userRepository.ts
export async function findById(id: string) {
return db.query("SELECT * FROM users WHERE id = $1", [id]);
}
// services/userService.ts
import { findById } from "../repositories/userRepository";
export async function getUser(id: string) {
const user = await findById(id);
if (!user) throw new Error("User not found");
return user;
}
// controllers/userController.ts
import { getUser } from "../services/userService";
export async function getUserHandler(req: Request, res: Response) {
try {
const user = await getUser(req.params.id);
res.json(user);
} catch (e) {
res.status(404).json({ message: (e as Error).message });
}
}
| 항목 | MVC | Layered |
|---|---|---|
| 복잡도 | 낮음 | 중간 |
| 비즈니스 로직 위치 | Model | Service |
| DB 교체 난이도 | 높음 | 낮음 (Repository만 수정) |
| 테스트 용이성 | 보통 | 높음 |
| 소규모 프로젝트 | 적합 | 과할 수 있음 |
규모가 더 커지면 레이어 기준 폴더 대신 도메인(Feature) 기준으로 묶는 방식이 효과적이다.
src/
├── users/
│ ├── userController.ts
│ ├── userService.ts
│ ├── userRepository.ts
│ └── userRoutes.ts
├── posts/
│ ├── postController.ts
│ ├── postService.ts
│ └── postRoutes.ts
└── app.ts
Feature 분리: 관련 파일을 도메인 단위로 모아두어 응집도를 높이는 방식. 팀 단위 병렬 개발에 유리하다.
레이어 기준 구조는 같은 기능의 파일이 여러 폴더에 흩어지지만, Feature 기준은 한 폴더 안에서 완결된다. 프로젝트 초기에는 Layered 구조로 시작하고, 도메인이 명확히 분리될 시점에 Feature 분리로 전환하는 전략이 현실적이다.