블로그 개요
2주간 DDD와 MSA 구조를 적요한 물류 서비스 개발 프로젝트에 참여하게 되었다.
사전에 DDD 개념을 학습했음에도 불구하고, 실제 도메인에 이를 적용하는 과정은 생각보다 훨씬 어려웠다.
특히 나는 대부분의 프로젝트를 모놀리식 기반의 계층형 구조(Spring MVC)로 개발해 왔기 때문에,
DDD에서 요구하는 도메인 중심 설계 방식과 패키지 구조를 실제 코드에 녹여내는 과정에서 많은 혼란을 겪었다.
따라서 이번 글에서는 기존 계층형 구조의 한계점을 되짚어 보고,
DDD 4계층 구조를 선택하게 된 이유와 설계 과정에서 고민했던 내용을 정리하고자 한다.
또한 이 과정에서 DIP를 통해 의존성을 제어한 방식과,
나아가 헥사고날 아키텍처로 확장하기 위해 어떤 고민을 했는지를 함께 정리함으로써,
다음 프로젝트에서 DDD 기반 구조를 보다 명확하게 적용할 수 있는 기준을 마련하고자 한다.
계층형 구조 (Spring MVC)
- Controller, Service, Domain/Repository 로 이루어진 3-Tier 아키텍처
- Controller : 사용자의 요청 & 응답을 처리
- Service : 비즈니스 로직을 수행
- Domain/Repository : DB와 상호작용
- 직관적인 계층 간의 흐름과 DDD와 헥사고날 아키텍처에 비해 학습에 학습 난이도가 낮음
- 빠른 기능개발이 이루어져야 하는 MVP 프로젝트나 작은 규모의 프로젝트에서 채택

만약 사용자의 증가로 인해 서비스 요청량이 기하급수적으로 많아지고
새로운 기능들이 끊임없이 도입된다면 어떤 문제가 발생할 수 있을까? 🤔
문제점 ① : 만능 서비스
현재 구조에서 가장 큰 문제점은 Service 계층만이 비즈니스 로직을 처리할 수 있다는 것이다.
프로젝트 규모가 커지고 다양한 기술을 도입함에 따라
서비스 로직에 외부 API 호출과 각종 라이브러리 사용이 추가된다면
Service의 역할이 폭발적으로 증가한다.
이는 단일 책임 원칙 위배와 가독성 저하의 원인이 되며 결과적으로 유지보수의 복잡성을 유발한다.
문제점 ② : 트랜잭션 지연과 장애 전파
외부 서비스 및 API 연동이 많아질 수록 시스템 안정성과 실패 복구의 중요성이 높아진다.
하지만 현재 구조에서는 다음과 같은 문제가 발생한다.
[ 가정 ] @Transactional 로 외부 API가 묶인 서비스가 있다면?
- 외부 API 응답 지연이 발생하면 DB 커넥션을 물고 있는 시간 또한 증가하여
전체 시스템 장애로 이어질 수 있다. - DB에 주요 데이터가 잘 저장되었는데, 외부 시스템 에러로 인해 데이터 전체 롤백이 이루어지는 등
외부 시스템의 에러가 우리 시스템 롤백으로 직결된다.
문제점 ③ : 눈물나는 단위 테스트
계층형 아키텍처에서는 DIP가 적용되지 않아 서비스가 외부 API와 특정 라이브러리 구현체에 강하게 결합되어 있다.
따라서 핵심 비즈니스 로직 하나를 테스트 하기위해
DB, 외부 API 연동 객체, 알림 라이브러리 등을 전부 설정해야 하며,
이는 테스트 코드 작성이 비즈니스 로직 작성보다 더 오래 걸리고 복잡해진다는 문제가 발생한다.
결국 개발자는 테스트를 포기하게 될 수 있으며 결과적으로 시스템 안정성이 급격하게 떨어지는 원인이 된다.
문제점 ④ : DB에 끌려다니는 계층형 구조
계층형 아키텍처에서는 도메인 엔티티가 JPA와 같은 영속성 기술에 직접 의존하는 경우가 많다.
이로 인해 DB 구조나 영속성 전략(fetch 전략, 트랜잭션 등) 의 변경이 발생하면,
비즈니스 규칙이 변경되지 않았음에도 도메인 코드가 함께 수정되는 상황이 발생할 수 있다.
또한 배송과 배송 경로가 연관 관계를 맺고 있고, 배송 경로의 쿼리를 줄이기 위해 벌크 저장을 구현한다고 가정해 보자.
이때 실행 순서상 delivery 먼저 save 되지 않으면, deliveryRoute에서 FK 참조가 안되거나 영속성 꼬이는 문제가 발생한다.
Delivery delivery = new Delivery(...);
deliveryRepository.save(delivery); // 먼저 저장
List<DeliveryRoute> routes = createRoutes(delivery);
deliveryRouteRepository.saveAll(routes); // 벌크 저장
이 과정에서 서비스 계층은 비즈니스 로직과 직접적인 관련이 없는 save 순서나 영속성 상태를 고려해야 하며,
이는 영속성 내부 동작을 서비스 계층이 이해해야 하는 상태가 되었음을 의미한다.
결과적으로 서비스 계층에서도 Lazy Loading, 트랜잭션 범위, Flush 타이밍과 같은 영속성 동작을 고려해야 하므로
영속성에 대한 의존성이 프로젝트 전반으로 확산되고 변경에 취약한 구조가 될 수 있다.
나아가 특정 기능에서만 DB 스펙이 변경되는 경우,
그 영향을 받는 서비스 로직이나 도메인 코드 일부를 함께 수정해야 하는 상황이 발생할 수 있다.
DDD 계층 구조 (4계층)
- 도메인 모델 중심의 설계 기반
- 모든 소스코드 의존성은 반드시 외부에서 내부로, 고수준 정책(도메인)을 향해야 한다.
- 즉, 비즈니스 로직이 DB 또는 Web 같이 세부 기술의 변경에 영향을 받지 않도록 설계해야 한다.
1. 고수준 : 무엇을 해야 하는지에 대한 정책이나 비즈니스 규칙을 의미
- 예시: 배송 경로를 생성한다, 배달 기사를 배정한다, 허브 간의 최적 경로를 계산한다.
2. 저수준 : 추상화된 개념을 실제 어떻게 구현할지에 대한 세부적인 개념
- 예시: RDB(postgres)에 데이터를 저장한다, JPA를 이용해 Delivery Entity를 저장한다,
기사 배정 알림을 Slack에 보낸다.
계층형 아키텍처의 문제점은 알았어.
그럼 DDD에서 어떻게 의존성 방향(외부 → 내부) 을 제어할 수 있지?
DIP(Dependency Inversion Principle) : 의존성 역전의 법칙
- SOLID 객체 지향 설계 원칙 중 하나
- 상위 모듈이 하위 모듈에 의존하지 않고 둘 다 추상화(인터페이스) 에 의존하게 하여 결합도를 낮추는 원칙
계층형 아키텍처를 살펴보면
Repository는 JPA를 직접 의존하고 서비스는 이를 다시 의존하는 관계를 가지고 있다.
Controller → Service → JpaRepository → DB
이는 위에서 언급했다시피, DB(JPA)의 변경이 서비스 계층까지 영향을 받을 수 있다는 문제점이 생긴다.
또한 Querydsl, MyBatis와 같은 여러 영속성 기술을 함께 사용하는 경우, 서비스 계층이 이러한 특정 구현 기술에 더욱 강하게 의존될 수 있다.
이를 해결하기 위해 Repository 인터페이스는 Domain 계층에 정의하고, 그 구현체는 Infrastructure 계층에 위치하게 한다.
결과적으로 Infrastructure → Domain 방향으로 의존하게 되면서
DB와 같은 저수준 기술이 도메인을 향해 의존하게 되는 구조가 만들어진다.
Controller → Service → Repository (Interface) ← Infrastructure (JPA 구현) → DB
↑
Domain
따라서 DB 기술이 변경되더라도 대부분 Infrastructure 계층 내부에서 처리할 수 있으며,
Service나 Domain 계층에 미치는 영향을 최소화할 수 있다.

1️⃣ DDD 아키텍처 패키지 구조( + 헥사고날 아키텍처)
src/main/java/com/example/project
│
├── ❶ presentation (또는 interface/controller)
│ ├── controller
│ │ ├── ExternalApiController.java
│ │ └── InternalServiceController.java
│ └── dto
│ ├── request
│ └── response
│
├── ❷ application (Service & Port)
│ ├── service
│ │ └── DomainService.java (UseCase 구현체)
│ ├── port
│ │ ├── in (입력 포트: 외부 -> 내 의존성)
│ │ │ └── DomainUseCase.java (interface)
│ │ └── out (출력 포트: 내 -> 외부 의존성)
│ │ └── ExternalSystemPort.java (interface)
│ └── dto
│ ├── query
│ ├── command
│ └── result
│
├── ❸ domain (핵심 비즈니스)
│ ├── entity
│ │ └── DomainEntity.java
│ └── repository
│ └── DomainRepository.java (interface)
│
└── ❹ infrastructure (구현체)
├── persistence
│ ├── DomainRepositoryImpl.java (DomainRepository 구현)
│ ├── DomainJpaRepository.java (Spring Data JPA)
│ └── DomainQuerydslRepository.java (Querydsl)
├── external
│ ├── FeignClientImpl.java (ExternalSystemPort 구현)
│ └── ExternalApiAdapter.java
└── event
└── SpringEventPublisher.java
1. Presentation Layer
- Controller(external/internal), DTO(request/response)가 위치한다.
- 사용자의 요청을 가장 먼저 받는 곳이며, Application Layer의 인터페이스(Port In)를 호출하는 역할을 수행한다.
2. Application Layer
- Use Case 흐름 제어를 담당하는 계층으로 service, port, DTO(query, command)가 위치한다.
- PORT (interface)
- in: 컨트롤러가 서비스를 호출할 때 사용하는 인터페이스 (DIP 적용).
- out: 서비스가 DB나 외부 API를 호출할 때 사용하는 인터페이스.
- query, command DTO를 사용하여 비즈니스 로직에 필요한 데이터를 구분한다.
3. Domain Layer
- 가장 고수준의 영역으로 Entity와 Domain Repository(interface)가 위치한다.
- Repository가 인터페이스로만 존재하며 실제 DB 기술(JPA, QueryDSL)은 전혀 모르는 상태를 유지한다.
- VO 를 도입하여 도메인의 개념과 책임을 명확하게 표현하고,
비즈니스 규칙을 도메인 내부에 위치시키도록 한다.
4. Infrastructure Layer
- 가장 저수준의 영역으로 구현체들이 모이는곳이다.
- Domain Repository의 실제 구현체(Impl)가 여기서 JPA나 QueryDSL을 사용해 데이터를 처리한다.
- Feign Client나 Spring Event 같은 기술적인 세부 사항도 이 계층에서 처리하여, 로직과 기술을 완벽히 분리한다.
🤔 DDD 구조를 적용해보면서 가진 의문점
왜 Application 계층에 별도의 DTO 패키지를 두어 사용하는가?
[ 고민 배경 ]
이미 Presentation과 Infrastrucutre 계층에는 외부와 요청/응답을 주고받기 위한 DTO가 존재하며,
필요한 정보만 조합하여 전달함으로써 엔티티가 외부에 직접 노출되지 않도록 캡슐화하고 있다.
또한 Application 계층에서만 DTO를 별도로 두게 된다면,
항상 외부 계층의 DTO를 Application DTO로 변환하는 과정이 필요하게 된다.
이러한 구조는 초기 개발 단계에서 의미없는 변환 코드가 증가하고 구현 복잡도가 매우 높아지는 것처럼 느꼈다.
하지만 DDD 구조는 각 계층을 서로의 세부 구현에 의존되지 않도록 분리하는 것을 지향한다.
특히 비즈니스 로직이 DB 또는 Web 같이 세부 기술의 변경에 영향을 받지 않도록 설계해야 한다는
DDD의 기본 원칙을 고려한다면, Application 계층에 별도의 DTO를 두는 것은 단순한 구조적 선택이 아니라
계층 간 의존성을 제어하기 위한 중요한 설계 방식이라고 볼 수 있다.
그 이유는 다음과 같다.
① 계층별 DTO의 관심사의 차이
각 계층의 DTO는 서로 다른 이유로 변경되는 객체이기 때문에 분리되어야 한다.
- Presentation DTO (Request/Response)
- 클라이언트(프론트엔드)와의 약속
- UI 레이아웃, API 스펙, 노출하고 싶은 필드 위주로 구성 (예: 비밀번호 제외, 날짜 포맷 변경 등)
- UI 요구사항에 따라 자주 변경될 가능성이 높은 DTO
- Application DTO (Command/Query/Result)
- 서비스 로직을 수행하기 위해 정말 필요한 데이터만 포함하는 객체
- 외부에서 어떤 API 형식을 쓰든 상관없이, 우리 비즈니스를 수행하기 위한 입력과 출력 데이터 구조를 정의
- UI 나 DB 구조와 무관하게 비즈니스 흐름을 유지하기 위한 DTO
- Infrastrucutre DTO (Request/Response)
- 외부 API 또는 외부 시스템과 통신하기 위한 데이터 객체
- Feign Client 요청/응답 객체, 카카오 로그인 API 요청/응답 객체 등
- 외부 시스템 스펙에 따라 변경되는 DTO
② 서비스 로직의 "UI 종속성" 발생
프론트엔드에서 화면 디자인이 바뀌어 API 필드명을 userName에서 userId로 변경해야 한다고 가정해보자.
만약 Presentation 계층에서 사용하는 DTO를 서비스 내부에서도 사용하고 있었다면,
DTO 필드명을 바꾸는 순간,
이 DTO를 파라미터로 받는 서비스 메서드 내부 로직과 테스트 코드까지 함께 수정해야 할 수 있다.
이는 결국 UI 요청 사항에 따라 비지니스 코드가 영향을 받는 구조가 되며,
DDD에서 지향하는 계층 간 독립성 원칙에 어긋나는 상황이 발생할 수 있다.
[ 결론 ]
결과적으로 각 계층별 DTO는 서로 다른 이유로 변경되고 있기 때문에 분리되어야 한다.
물론 초기에는 파일 수와 변환 코드가 2배로 늘어나서 오히려 생산성이 떨어지는 것처럼 느껴질 수 있다.
하지만 프로젝트가 큐모가 커지면
"API 스펙은 그대로인데 내부 로직만 바꿔야 하는 경우"
혹은
"내부 로직은 그대로인데 API 필드명만 바꿔야 하는 경우" 가 발생할 수 있다.
이때 각 계층의 DTO 가 분리되어 있다면,
영향을 최소하하면서 변경을 처리할 수 있으며, DTO를 계층별로 분리해서 관리하는 진정한 의미를 체감할 수 있다.
참고자료