최근 진행하는 프로젝트에서는 MSA 학습과 동시에 DDD(Domain-Driven Design)와 CQRS도 시도해보기로 하였다. 다른 팀원들도 도메인 주도 설계라는 것을 처음 접해보기에 설계 원칙에 대해 이런저런 논의를 나누며 학습을 진행하고 있다.
어떻게 DDD 흉내를 내서 기능을 구현하고 PR을 올렸는데 다음과 같은 리뷰를 받았다.

application 계층인 CreateDeliveryCommand에서 presentation 계층인 DeliveryManagerRequest를 역으로 의존하고 있다는 말이다. (command는 CQRS의 조회/쓰기 모델 분리 개념이다. 해당 프로젝트에서는 application 계층에서 command와 query DTO를 분리하여 CQRS 스타일의 조회 로직을 구성하였다.)
아래에서 DDD의 계층 개념에 대해 알아보자.
1. DDD 계층형 아키텍처 개요
DDD에서 흔히 사용되는 계층형 아키텍처(Layered Architecture)는 소프트웨어 구성 요소를 여러 층으로 나누어 관심사를 분리하는 방식이다.
일반적인 구조는 4계층 정도로 나뉘며 다음과 같다.
- Presentation 계층 : 사용자의 요청(HTTP, 메시지 등)을 받아들이고 응답을 반환한다. (Controller, DTO)
- Application 계층 : 비즈니스 흐름(유스케이스)을 조정한다. 도메인 로직은 직접 포함하지 않고, Domain 계층의 Service를 호출하여 작업을 수행한다. Command, Query, 서비스(Service)가 여기에 위치한다.
- Domain 계층 : 핵심 비즈니스 로직을 수행한다. 엔티티, 도메인 서비스(Domain Service), 어그리게이트(Aggregate), 값 객체(Value Object) 등이 이 계층에 속한다.
- Infrastructure 계층 : 영속성(Persistence, DB), 메시징, 외부 API 호출 등 기술적 세부사항을 다룬다.
그리고 이 계층들은 다음과 같은 방향으로 의존성을 가진다.
여기서 핵심은 바깥쪽 계층은 안쪽 계층을 의존할 수 있지만, 안쪽 계층은 바깥쪽 계층을 의존해서는 안된다
Presentation 계층 -> Application 계층 -> Domain 계층 <- Infra 계층
예를 들어, Presentation에서 Domain을 의존하는 방식은 의존 방향성을 생각해보면 맞는 것 같지만, 엄밀하게 따지면 이러한 것도 DDD를 위반한 사례라고 볼 수 있다.

2. 현재 코드에서의 DDD 위반 사례
문제점: 현재 코드에서는 UpdateManagerCommand가 Application 계층에 위치하며, 다음과 같이 presentation 계층의 클래스를 import하고 있다. 그리고 DeliveryManagerRegisterRequest (Presentation 계층의 DTO)를 사용하여 from 팩토리 메서드를 만들고 있는 형태이다.
// ...
import com.delivery_signal.eureka.client.delivery.presentation.dto.request.DeliveryManagerRegisterRequest;
// ...
@Builder
// Application 계층에 있는 Command
public record UpdateManagerCommand(
Long managerId, // UserService의 (user_id)
String slackId,
DeliveryManagerType type,
// 업체 배송 담당자일 경우 필수. 허브 배송 담당자는 null 허용
UUID hubId
) {
// Presentation 계층의 DTO를 import 하고 사용함
public static UpdateManagerCommand from(DeliveryManagerRegisterRequest request) {
return UpdateManagerCommand.builder()
.managerId(request.managerId())
.slackId(request.slackId())
.type(request.type())
.hubId(request.hubId())
.build();
}
}
이것은 Application 계층이 Presentation 계층에 의존하는 것을 의미하며, DDD 아키텍처 원칙을 위반한다.
그런데 이게 왜 문제일까?
결합도 증가
Application 계층이 Presentation 계층의 변경에 직접적으로 영향을 받게 되어 결합도가 높아진다
책임(관심사) 분리 위반
- presentation 계층의 DTO는 HTTP 요청 형식, 유효성 검사 등 외부 통신에 특화되어 있다.
- application 계층의 Command는 특정 비즈니스 기능 실행에 필요한 데이터를 정의한다.
- application 계층이 presentation DTO에 의존하면, presentation 계층이 변경될 때 application 계층도 불필요하게 변경되어야 한다. (application 계층은 "어떻게(HTTP로, CLI로 등)" 요청이 들어왔는지에 대해 알아서는 안 된다)
3. 해결책 : Presentation과 Application의 역의존 관계 삭제 (책임 분리)
해결책은 의존성의 방향을 올바르게 맞추는 것이었다. 즉, Presentation 계층이 Application 계층의 Command를 생성하도록 해야 한다.
DeliveryManagerRegisterRequest (Presentation)를 처리하는 Controller (Presentation)가 UpdateManagerCommand를 생성하여 Application Service에 전달하도록 하는 것이다.
Application 계층의 Command에서는 Presentation DTO에 대한 의존성을 완전히 제거하고, 오직 Command 필드에만 집중할 수 있게 된다.
// package com.delivery_signal.eureka.client.delivery.application.command;
// **불필요한 import 삭제** // import com.delivery_signal.eureka.client.delivery.presentation.dto.request.DeliveryManagerRegisterRequest; // 👈 이 줄을 제거
import com.delivery_signal.eureka.client.delivery.domain.model.DeliveryManagerType;
import java.util.UUID;
import lombok.Builder;
@Builder
public record UpdateManagerCommand(
Long managerId, // UserService의 (user_id)
String slackId,
DeliveryManagerType type,
// 업체 배송 담당자일 경우 필수. 허브 배송 담당자는 null 허용
UUID hubId
) {
// Presentation DTO를 사용하는 from(..) 메서드 제거!
}
그러면 데이터 변환(매핑)은 이제 Presentation 계층의 책임이 된다. (Controller)
DeliveryManagerController와 같은 Presentation 계층의 코드가 클라이언트로부터 DeliveryManagerRegisterRequest를 받은 후, Application 계층의 Command로 변환하여 사용하도록 수정하였다.
// package com.delivery_signal.eureka.client.delivery.presentation;
@RestController
@RequiredArgsConstructor
public class DeliveryManagerController {
private final DeliveryManagerService deliveryManagerService; // Application Service
@PutMapping("/managers/{managerId}")
public ResponseEntity<Void> updateManager(
@PathVariable Long managerId,
@RequestBody DeliveryManagerRegisterRequest request
) {
// 핵심: Presentation DTO를 Application Command로 변환하는 작업은
// 이 Presentation 계층에서 이루어져야 함
// Application -> Presentation DTO를 의존(역방향 의존)하는 부분 삭제
// UpdateManagerCommand command = UpdateManagerCommand.from(request);
UpdateManagerCommand command = UpdateManagerCommand.builder()
.managerId(managerId)
.slackId(request.getSlackId())
.type(request.getType())
.hubId(request.getHubId())
.build();
// Application 계층 호출
deliveryManagerService.updateManager(command);
return ResponseEntity.ok().build();
}
}
이렇게 하면, Application 게층은 순수하게 UpdateManagerComman만 받아서 역방향으로 의존하지 않고, 나머지 서비스를 문제없이 수행할 수 있게 된다.
그리하여 프로젝트 초기 DDD를 적용한 계층 구조는 다음과 같다.
com.delivery_signal.eureka.client.delivery
├── application
│ ├── command // 쓰기(Command)에 필요한 데이터 객체 (DTO)
│ │ └── CreateDeliveryCommand.java
│ ├── dto // 조회(Query) 결과를 담는 데이터 객체 (DTO)
│ │ └── DeliveryQueryResponse.java
│ └── service // 도메인 객체 조율 및 트랜잭션 경계 설정 (Use Case)
│ └── Deliveryervice.java
│
├── domain
│ ├── model // 핵심 비즈니스 로직 및 데이터 (Entity, Aggregate Root, Value Object)
│ │ └── Delivery.java // Aggregate Root (배송 애그리게잇 루트)
│ └── repository // 인터페이스만 정의, Infrastructure에서 구현
│ └── DeliveryRepository.java
│
├── infrastructure
│ ├── repository
│ │ ├── DeliveryRepositoryImpl.java // Domain Repository 구현체
│ │ └── JpaDeliveryManagerRepository // Spring Data JPA 인터페이스 등
│
└── presentation
├── controller // HTTP 요청 처리 및 응답 반환
│ └── DeliveryController.java
├── dto // 클라이언트와 주고받는 요청/응답 데이터 객체 (Request/Response DTO)
│ ├── request
│ │ └── DeliveryRegisterRequest.java
│ └── response
│ └── DeliveryResponse.java
└── mapper // Application DTO를 Presentation DTO로 변환
└── DeliveryMapper.java
'Project' 카테고리의 다른 글
| [RUSH_CREW] 2. DDD에서의 VO(Value Object) (0) | 2025.12.02 |
|---|---|
| [RUSH_CREW] 1. 타임딜 서비스 개요 (feat. DDD Bounded Context) (0) | 2025.11.26 |
| [DELIVERY_SIGNAL] Circuit Breaker 구현(Resilience4j) (0) | 2025.11.21 |
| [DELIVERY_SIGNAL] 배송 담당자 순번 할당 설계/구현 (0) | 2025.11.07 |
| [SURFING THE GANGWON] 기상청 API 호출 성능 최적화 (0) | 2025.10.01 |