Node/Express에서 MVC 제대로 적용하기
컨트롤러 비대화 방지, 동적 라우트 설계, 모델/리포지토리 분리를 다루는 MVC 가이드
Posted
신호 1: 컨트롤러에
By okorion
Node/Express에서 MVC 제대로 적용하기
결론 요약
- MVC는 파일 분리 패턴이 아니라 “역할과 의존성 경계”를 강제하는 설계 규칙이다.
- 컨트롤러 비대화는 기능 추가가 아니라 책임 누적에서 발생한다.
- 동적 라우트는 URL 기술이 아니라 리소스 식별 규칙이다.
- 모델은 “DB 접근 코드 묶음”이 아니라 도메인 규칙의 소유자다.
- 파일 스토리지에서 DB로 옮길 때 구조가 유지되면, 확장은 비용이 아니라 선택이 된다.
MVC 패턴을 Node/Express에서 제대로 적용하는 법
컨트롤러 비대화를 구조적으로 차단하기
1️⃣ MVC 정의: 역할과 의존성 관점
결론
MVC는 누가 무엇을 알아도 되는지를 제한하는 규칙이다.
| 계층 | 책임(해야 할 일) | 알면 안 되는 것 |
|---|---|---|
| Controller | 요청 해석, 응답 조립 | DB 구조, 저장 방식 |
| Model | 데이터 접근, 도메인 규칙 | HTTP(req/res), 상태 코드 |
| View | 데이터 표현 | 비즈니스 규칙, DB |
핵심은 의존성 방향이다. Controller → Model은 가능하지만, Model → Controller는 구조 붕괴 신호다.
2️⃣ 왜 필요한가: 컨트롤러가 커지는 순간 구조는 이미 깨졌다
컨트롤러는 본질적으로 입출력 어댑터다. 여기에 규칙·검증·저장 로직이 쌓이면 다음 문제가 동시에 발생한다.
- 테스트 불가능
- 중복 로직 증가
- DB 교체 시 전면 수정
- API/SSR 병행 불가
즉, MVC는 “깔끔함” 문제가 아니라 변경 비용 통제 문제다.
3️⃣ 컨트롤러 비대화의 5가지 신호와 리팩토링 방향
신호 1: 컨트롤러에 if/else가 늘어난다
- 원인: 도메인 규칙이 컨트롤러에 있음
- 해결: 규칙을 모델 메서드로 이동
신호 2: DB 쿼리가 컨트롤러에 직접 등장
- 원인: 데이터 접근과 요청 처리가 결합
- 해결: 모델/리포지토리로 추출
신호 3: 동일한 로직이 여러 컨트롤러에 복제
- 원인: 공통 도메인 규칙 부재
- 해결: 서비스/모델 계층 도입
신호 4: 컨트롤러 테스트가 어렵다
- 원인: 외부 의존성(DB, 파일)이 직접 연결
- 해결: 의존성 주입 + 모델 단위 테스트
신호 5: 저장소 변경 시 컨트롤러 수정
- 원인: 저장 방식이 컨트롤러에 노출
- 해결: 추상화(Repository Interface)
4️⃣ 동적 라우트와 리소스 설계: URL은 식별자다
결론
동적 라우트(:id)는 기능이 아니라 리소스 식별 규칙이다.
예시: 제품 / 장바구니
1
2
/products/:productId
/cart/:userId
설계 기준
- URL은 “행동”이 아니라 대상(리소스) 중심
행동은 HTTP method로 표현
- GET /products/:id
- POST /cart/:userId/items
컨트롤러는 ID를 해석만 하고, 무엇이 가능한지는 모델이 결정한다.
5️⃣ 모델 계층의 책임: 데이터 + 도메인 규칙
❌ 흔한 오해
“Model = DB CRUD 함수 모음”
✅ 실제 역할
- 데이터 접근
- 도메인 규칙(가격 계산, 재고 차감, 상태 전이)
- 일관성 보장
toy 예시
1
2
// 나쁜 예 (컨트롤러에서 규칙 처리)
if (price < 0) return res.status(400).end();
1
2
// 개선 예 (모델 책임)
Product.create({ price }); // 내부에서 검증
컨트롤러는 요청을 조립하고, 모델은 정합성을 보장한다.
6️⃣ 파일 스토리지 → DB 이전 시 구조를 유지하는 방법
문제 상황
- 초반: JSON 파일 저장
- 확장: SQL/Mongo로 이전
실패 패턴
- 컨트롤러에
fs→sequelize코드 직접 교체
해결 패턴: 리포지토리 추상화
1
2
3
4
5
// repository interface
productRepository.save(product);
// 구현체 교체
FileProductRepository → DbProductRepository
컨트롤러/서비스는 저장 방식 변경을 모른다. 이 구조가 있으면 “기술 선택”은 리팩토링이 아니라 설정이 된다.
7️⃣ 나쁜 MVC vs 좋은 MVC
❌ 나쁜 MVC
1
2
3
4
app.post('/product', (req, res) => {
fs.writeFileSync('products.json', JSON.stringify(req.body));
res.json({ ok: true });
});
- 요청/저장/응답 결합
- 테스트 불가
- 확장 불가
✅ 좋은 MVC
1
2
3
router.post('/', productController.create);
controller → service → repository
- 책임 분리
- 교체 가능
- 테스트 가능
권장 폴더 구조 예시
1
2
3
4
5
6
7
src/
├─ routes/
├─ controllers/
├─ services/
├─ models/
├─ repositories/
└─ views/
핵심은 이름이 아니라 의존성 방향이다.
구조 점검 체크리스트 (15개)
- 컨트롤러에 DB 코드가 없다
- 컨트롤러는 req/res만 다룬다
- 모델은 HTTP를 모른다
- 도메인 규칙은 모델/서비스에 있다
- URL은 리소스 중심이다
- HTTP method로 행동을 표현한다
- 동적 라우트는 식별자 역할만 한다
- 중복 로직이 모델로 수렴한다
- 저장 방식 교체가 컨트롤러 수정으로 이어지지 않는다
- 파일 → DB 이전 시 인터페이스가 유지된다
- 테스트가 모델 단위로 가능하다
- 컨트롤러 함수가 20줄을 넘지 않는다
- 에러 처리가 중앙화되어 있다
- 서비스 계층 도입 기준이 명확하다
- MVC를 “파일 구조”가 아니라 “책임 규칙”으로 설명할 수 있다
This post is licensed under CC BY 4.0 by the author.
