낯선 DDD와 조금 더 가까워지기

hyezuu·2025년 3월 17일

계층형 아키텍처에서 DDD를 위한 DTO 활용법

시작하며

구조만 4계층으로 한다고 다 DDD가 아니었다..

의존 방향의 역전, DIP의 부재 등 다양한 문제가 있어 새롭게 구현해야할 상품 모듈은 DDD로 구현하고자 했다. 단순히 계층을 나누는 것만으로는 DDD의 핵심 원칙을 준수할 수 없기 때문에 의존성 방향과 각 계층의 역할을 명확히 하는 작업이 필요하다.

계층마다 DTO 객체 사용하기

계층에서 의존할 수 있는 객체들은 정해져있다. 계층 간 데이터를 주고받을 때 사용할 수 있는 방법은 다음과 같다:

  1. Java의 기본 자료형을 그대로 넘겨주기
  2. 계층마다 새로운 DTO를 활용해 넘겨주기

아직 추가적인 방법들에 대한 공부가 더 필요하지만, 여기서는 두 번째 접근 방식인 계층별 DTO 사용법에 대해 다룬다.

계층별 DTO 사용 예시

Presentation 계층의 DTO

public record PostProductRequest(
	@NotNull String name, @NotNull UUID companyId, @NotNull UUID hubId, @PositiveOrZero Integer quantity) {

	public PostProductRequest {
		quantity = quantity != null ? quantity : 0;
	}

	public PostProductRequestDto toApplicationDto() {
		return PostProductRequestDto.builder()
			.name(name)
			.companyId(companyId)
			.hubId(hubId)
			.quantity(quantity).build();
	}
}

이 DTO의 주요 역할은:

  • 외부 요청 데이터 검증 (@NotNull, @PositiveOrZero 등)
  • 기본값 설정 (quantity가 null이면 0으로 설정)
  • Application 계층 DTO로의 변환

Presentation 계층에서의 사용

@RestController
@RequestMapping("/api/v1/products")
@RequiredArgsConstructor
public class ProductExternalController {

	private final ProductService productService;

	@PostMapping
	public ResponseEntity<PostProductResponse> saveProduct(
		@Valid @RequestBody PostProductRequest requestDto) {

		return ResponseEntity.status(HttpStatus.CREATED)
			.body(PostProductResponse
				.from(productService.saveProduct(requestDto.toApplicationDto())));
	}
}

컨트롤러에서는 @Valid 애노테이션을 통해 요청 데이터의 유효성을 검증하고, toApplicationDto()를 호출하여 Application 계층에서 사용할 수 있는 DTO로 변환한 후 서비스를 호출한다.

위 그림에서 볼 수 있듯이, Presentation 계층은 Application 계층을 알아도 되지만, Application 계층은 Presentation 계층에 의존해선 안 된다. Presentation 계층의 주요 역할은:

  • Application 객체로의 변환
  • 데이터의 전달
  • 유효성 검사

Presentation 계층 DTO의 import 구조

package takeoff.logistics_service.msa.product.product.presentation.dto.request;

import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import java.util.UUID;
import takeoff.logistics_service.msa.product.product.application.dto.request.PostProductRequestDto;

import를 보면 Presentation 계층에서 Application 계층으로의 단방향 의존성이 명확하게 드러난다. 표현계층에서는 응용계층을 알고 있고, 표현계층 자체의 클래스도 import하고 있어 의존 방향이 올바르게 설정되어 있다.

Application 계층의 DTO

package takeoff.logistics_service.msa.product.product.application.dto.request;

import java.util.UUID;
import lombok.Builder;
import takeoff.logistics_service.msa.product.product.model.command.CreateProduct;

@Builder
public record PostProductRequestDto(String name, UUID companyId, UUID hubId, Integer quantity) {

	public CreateProduct toCommand() {
		return new CreateProduct(name, companyId);
	}
}

Application DTO의 주요 역할은:

  • Domain 계층에서 사용할 Command 객체로의 변환
  • 필요한 경우 추가적인 데이터 가공

Application 계층 서비스의 구현

@Service
@RequiredArgsConstructor
public class ProductServiceImpl implements ProductService {

	private final ProductRepository productRepository;
	private final StockClient stockClient;

	@Override
	public PostProductResponseDto saveProduct(PostProductRequestDto requestDto) {

		Product product = productRepository.save(Product.create(requestDto.toCommand()));
		PostStockResponseDto responseDto =
			stockClient.saveStock(PostStockRequestDto.from(product.getId(), requestDto));

		return PostProductResponseDto.from(product, responseDto);
	}
}

서비스 계층에서는:
1. Application DTO를 Domain Command 객체로 변환
2. Domain 모델의 메서드를 호출하여 비즈니스 로직 실행
3. 필요한 경우 외부 서비스(여기서는 StockClient) 호출
4. 결과를 Application Response DTO로 반환

결론

여기서 추가적으로 외부 API 요청을 위한 Application DTO, Infrastructure를 위한 별도의 DTO가 추가되면 코드 구조가 더 복잡해질 수 있다. 하지만 이런 방식은 각 계층의 책임을 명확히 분리하고, 의존성 방향을 올바르게 설정함으로써 DDD의 핵심 원칙을 지키는 데 도움이 된다(고 한다).

어렵다는 것은 익숙하지 않다는 것이다. 최대한 명확한 규칙과 예쁜 레퍼런스를 만들어 두고 이런 패턴에 익숙해지는 것이 중요하다. 계층별 DTO 사용은 처음에는 보일러플레이트 코드가 많아 보일 수 있지만, 장기적으로는 유지보수성과 테스트 용이성을 크게 향상시키겠지!

profile
기록

0개의 댓글