NestJS에 자연스럽게 녹아드는 모듈러 모놀리식 아키텍처 가이드
시작하며: NestJS와 아키텍처
NestJS는 Node.js 생태계에서 보기 드물게, 강력한 객체지향 기반의 설계 철학을 갖춘 백엔드 프레임워크입니다. Angular에서 영향을 받은 구조와 함께, 실질적으로는 Spring Framework에 가까운 모듈 기반 구조와 DI(Dependency Injection) 컨테이너, 그리고 데코레이터 중심의 선언적 구성 방식을 갖추고 있습니다.
하지만 NestJS는 Spring과는 다릅니다.
Spring이 @Bean, @ComponentScan 등의 클래스 스캐닝 기반 설정과 런타임 리플렉션 기반의 자동 조립을 전제로 한다면, NestJS는 정적이고 명시적인 모듈 시스템을 중심으로 구성됩니다. 클래스를 어떤 모듈에서 등록하고, 어디에 의존하는지 명확히 선언해야만 동작하며, 이는 추상적인 객체 생성보다는 구조적으로 모듈화된 코드 설계를 자연스럽게 유도합니다.
(물론 NestJS도 내부 로직은 런타임 리플렉션 기반이긴 합니다. 다만 스프링이 전역 단일 애플리케이션 컨테이너가 쭉 스캔을 해주고 처리해주는 방식이라면, NestJS는 모듈 단위로 해당 동작이 일어나죠.)
이러한 특성 때문에 NestJS는 어느 정도 이상 규모가 되는 서비스에서 아키텍처적 선택을 고민하게 됩니다.
이 글에서는 제가 3년간 NestJS 관련 다양한 아키텍처를 직접 짜보면서 NestJS의 모듈 시스템 특성과 가장 궁합이 좋았던 계층형 모듈러 모놀리식 아키텍처를 소개합니다.
실제 개발 과정에서 구조적 명확성과 확장성을 어떻게 확보했는지를 중심으로, 실용적인 설계 기준을 함께 정리해보려 합니다.
계층의 구분
계층형 아키텍처는 운영체제부터 대규모 백엔드 시스템에 이르기까지, 복잡도를 제어하고 변경 비용을 낮추는 구조적 해법으로 오랫동안 검증되어 왔습니다. 그러나 NestJS에 이 구조를 그대로 적용하기엔 몇 가지 실질적인 제약이 존재합니다.
Spring Framework처럼 전역 DI 컨테이너를 기반으로 동작하는 프레임워크에서는, 서로 다른 계층 간에도 의존성 역전(DIP: Dependency Inversion Principle)이 자연스럽게 적용됩니다. 상위 계층에서 하위 계층을 주입받는 구조뿐만 아니라, 인터페이스를 통해 하위 계층이 상위 계층에 의존하도록 재구성하는 것 역시 어렵지 않습니다.
반면 NestJS는 명시적으로 모듈 단위로 DI 컨텍스트를 구성하기 때문에, 서로 다른 계층 간 의존성을 구성하려면 해당 provider들을 동일한 모듈 내에 등록해야만 동작합니다. 즉, 의존성 역전을 위해선 오히려 여러 계층이 하나의 모듈에 응집되어야 하는 역설적 구조가 발생할 수 있습니다.
이러한 특성 때문에 NestJS에서는 전통적인 4계층 구조(표현/응용/도메인/인프라)보다는 "계층"과 "모듈"을 함께 고려한 하이브리드 접근법이 더 적합합니다.
제가 제안하는 방식은 모듈을 도메인 단위로 나누고, 각 모듈 내부에 도메인 로직과 인프라스트럭처 계층을 함께 포함하는 구조입니다. 이렇게 하면 단일 모듈 내에서 계층 간 책임을 응집력 있게 다루면서도, NestJS의 DI 제약을 자연스럽게 피할 수 있습니다.
다만 시스템 전체로 보았을 때는 여전히 모듈 간의 수직적인 계층적 참조 관계를 구성합니다.
예를 들어, payment, coupon과 같은 도메인 모듈은 상위의 payment-processing, promotion 같은 응용 모듈에서 참조되고,
이 응용 모듈들은 다시 컨트롤러나 이벤트 컨슈머가 포함된 BC(Bounded Context) 단위의 서비스 모듈에서 불려집니다.
즉, 모듈 간의 계층적 의존 방향은 다음과 같은 상향식 참조 흐름을 가집니다
사진을 보시면 각 모듈간의 참조관계를 확인할 수 있습니다.
레이어는 도메인 주도 설계의 전술적 설계 테크닉에서 파생된 presentation - application -domain -infrastructure 4계층 전략을 사용합니다.
서비스 모듈
결제와 환불, 쿠폰 등 금전적인 요소들을 응집해서 처리하는 최상위 billing 서비스가 존재한다고 했을 때, 이에 대응하는 billing module을 우선 최상위에 모듈로 둡니다. 이 모듈은 응용 계층들의 모듈을 import하고 controller를 등록합니다.
위와 같은 구성이 될 수 있죠. 추가적으로 횡단 관심사 모듈들인 캐싱이나 리더 선출 모듈을 주입받고 있습니다.
이 모듈에서 controller <-> application 레이어를 조합하고 처리합니다.
애플리케이션 모듈
여러 도메인 로직을 조합하고, 외부 채널 등과 소통하며 비즈니스 로직을 직접적으로 처리하는 것을 애플리케이션 모듈에서 맡아서합니다.
아래와 같이 도메인 모듈을 조합해서 사용합니다.
과정에서 특정 서비스 내지 bounded-context 내부에서 공통적으로 사용할만한 응용 로직이 존재하면 애플리케이션의 SupportModule로 등록해서, 타 애플리케이션 모듈들에서 재활용해서 씁니다. 가령 다른 서비스와 직접적으로 소통할 일이 있는 경우 외부 포트 등을 SupportModule에 넣어서 사용하는 식입니다.
내부에서는 여러 클래스들 조합해서 OOP를 하시면 됩니다. 다만 상위 모듈로 넘겨줄 땐, facade class에 응집하여서 내부 상세 로직을 캡슐화합니다.
도메인 모듈
도메인 모듈은 시스템의 비즈니스 개념과 규칙을 가장 순수한 형태로 담고 있는 계층입니다.
이곳에는 핵심 엔터티, 밸류 오브젝트(Value Object), 도메인 서비스, 리포지토리 인터페이스 등 도메인의 불변성과 일관성을 보장하는 요소들만 포함됩니다.
도메인 모듈은 응용 계층이나 인프라 계층과는 기술적·상황적 의존 없이 독립적으로 설계되어야 하며,
외부 시스템이나 채널, 프레임워크와의 연결 지점은 인프라 레이어를 통해 위임합니다.
즉, 외부와의 상호작용(알림, 외부 API 호출 등)은 도메인 내부에 포함되지 않고,
그에 대한 계약(인터페이스)만 정의한 채 구현은 바깥 계층에 맡깁니다.
이러한 설계는 도메인 모듈의 변경 가능성을 최소화하고, 비즈니스 규칙의 순수성과 가독성을 유지하는 데 목적이 있습니다.
모듈의 구성입니다. 응용 계층과 마찬가지로 Service는 도메인 계층의 Facade역할을 합니다. 또한 class를 직접 provide하지 않고 인터페이스 기반 DI가 들어간 요소들이 있는데, 이는 인프라 레이어에 구현체가 등록된 케이스에 해당합니다. 디렉토리를 보겠습니다.
인프라계층에 해당하는 요소를 위와 같이 분리합니다. CouponReader 등은 인터페이스만 정의되어있습니다.
models쪽에는 실제 도메인 메서드를 지니고 있는 AggregateRoot나 DomainEntity, ValueObject 들을 정의해 놓습니다.
이 엔티티는 도메인 레이어 외부에 누설되는 순간 도메인 모듈 내부에 대한 캡슐화가 깨지기 때문에, 서비스 계층에서 return해 상위 모듈로 올릴 때는 순수히 데이터만 지닌 info라는 객체로 매핑해서 올립니다.
앞서 말한 외부 세계와의 의존성, 기술적 상세 구현에 대한 내용은 infrastructures로 몰아 넣어 놓습니다. 여기서 Orm에서 사용하는 영속성 Entity는 은닉합니다. 인자와 리턴 값 모두 도메인레이어의 모델을 사용하고, 내부에서 처리할 때 영속성 Entity로 매핑합니다. https://deviq.com/principles/persistence-ignorance
모듈러 모놀리스
billing과 같은 서브도메인 단위의 서비스 모듈을 Bounded Context 또는 팀 단위로 설계하고,
이러한 모듈들을 응집하여 하나의 통합된 서버에서 구동하는 구조가 바로 모듈러 모놀리식 아키텍처(Modular Monolith Architecture)입니다.
이렇게 구성된 각 모듈은 하나의 명확한 서브 도메인을 책임지며,
전체 시스템은 이들을 조합함으로써 도메인 주도 설계에서 말하는 도메인 전체의 구성을 형성합니다.
https://www.sktenterprise.com/bizInsight/blogDetail/dev/7765
NestJS의 모듈 시스템은 이 아키텍처와 자연스럽게 맞물립니다.
NestJS는 기능 단위가 아닌 도메인 단위의 모듈 구성을 중심으로 하고 있으며, 각 모듈은 독립적인 DI 컨텍스트를 갖고 책임과 경계를 분리할 수 있도록 설계되어 있습니다. 이는 모듈러 모놀리식 아키텍처의 핵심인 명확한 Bounded Context 구분 및 내부 응집도와 정확히 일치합니다.
서비스 모듈 간 통신은 이벤트 기반 또는 명시적인 API 호출을 통해 이루어집니다.
NestJS 환경에서는 이를 별도의 메시지 브로커 없이도 인메모리 이벤트 큐로 구현할 수 있으며,
이 구조를 통해 메시지 큐 기반 아키텍처와 유사한 decoupling 효과를 확보할 수 있습니다.
이벤트 핸들러를 각 모듈에 분산 등록하면, 마치 컨슈머 그룹을 갖춘 메시지 큐처럼 동작할 수 있으며, graceful shutdown과 이벤트 처리 보장만 신중히 설계하면 운영 안정성도 확보할 수 있습니다.
이 방식은 초기에는 인프라 리소스를 최소화한 채 서비스 간 의존도를 느슨하게 유지할 수 있는 장점을 가지며,
추후 Kafka, Redis Streams 등 외부 메시지 브로커로 이관할 수 있는 확장성도 열어둡니다.
번외) 메시지 브로커 추상화에 대한 고려
물론 메시지 큐 인프라를 사용하게 되는 경우, 인터페이스를 추상화해두는 것이 이관에 도움이 될 수 있습니다.
다만 Kafka, Redis 등은 각각의 고유 기능과 최적화 방식이 다르기 때문에, 너무 일반화된 추상 레이어는 오히려 각 인프라의 특화된 기능을 쓰기 어렵게 만드는 비용이 발생할 수 있습니다.
따라서 인메모리 이벤트 기반을 사용할 경우, 초기부터 과도한 추상화보다는, 사용하는 메시징 인프라에 맞춘 구체적 설계를 하는 것이 나을 수 도 있습니다.