프로젝트를 구현하던 중 한 서비스가 다른 서비스를 호출할 때 JPA 엔티티를 그대로 넘겨도 되는지, DTO로 한 번 더 감싸야 하는지가 궁금했다. 당시에는 DTO를 Controller ↔ Service 사이에서 쓰는 객체라고 이해하고 있었지만, 그 정의만으로는 서비스와 서비스 사이를 설명하기 어려웠다.
지금은 질문을 조금 다르게 본다. DTO를 어느 계층에 둘지보다, 어떤 경계를 넘어가는 데이터의 계약인지가 먼저다. 서비스끼리 호출한다는 이유만으로 DTO를 만들 필요도 없고, 같은 프로세스 안이라는 이유만으로 엔티티를 어디서나 공유해도 되는 것도 아니다.
DTO는 계층 장식이 아니라 경계의 계약이다
DTO(Data Transfer Object)는 데이터를 옮기기 위한 구조다. Martin Fowler가 정리한 원래 맥락은 원격 호출 수를 줄이기 위해 여러 값을 한 객체에 담는 것이었지만, 오늘날 애플리케이션에서는 외부 입력·응답이나 모듈 사이의 공개 계약을 분리하는 용도로도 널리 쓴다.
판단 기준은 다음과 같다.
| 데이터가 건너는 경계 | 우선 고려할 형태 | 이유 |
|---|---|---|
| HTTP 요청·응답 | Request·Response DTO | 직렬화 형식, 검증, 공개 필드를 도메인과 분리한다. |
| 하나의 유스케이스 내부 | 값 객체, ID, 도메인 객체 | 의미 없는 매핑을 반복하지 않고 도메인 행위를 유지한다. |
| 애플리케이션 모듈 사이 | 공개 Command·Result 또는 모듈 API | 상대 모듈의 내부 엔티티와 저장 구조가 새지 않게 한다. |
| 프로세스·네트워크·메시지 경계 | 버전이 있는 DTO·이벤트 | 배포 시점, 호환성, 재시도와 직렬화를 명시해야 한다. |
| 저장소 구현 내부 | 해당 모듈이 소유한 영속 객체 | 외부 모듈이 JPA 매핑과 생명주기에 의존하지 않게 한다. |
따라서 DTO는 Controller와 Service 사이에서만 쓴다도, 모든 계층 사이에 DTO를 둔다도 보편 규칙은 아니다. DTO가 보호하려는 변경 이유가 있는지부터 확인해야 한다.
서비스 호출보다 유스케이스의 주인을 먼저 정한다
서비스 A가 서비스 B를 호출하는 행위 자체는 문제가 아니다. 문제는 호출 관계가 업무 흐름의 소유권을 흐리거나 순환 의존을 만들 때다.
예를 들어 주문 생성 과정에서 재고 확인과 결제가 함께 필요하다면 OrderService와 InventoryService가 서로를 호출하게 만들기보다, 주문 생성 유스케이스가 흐름을 조정하도록 둘 수 있다.
record PlaceOrderCommand(
String customerId,
String productId,
int quantity
) {}
record Reservation(String reservationId) {}
record OrderResult(String orderId) {}
interface InventoryApi {
Reservation reserve(String productId, int quantity);
}
interface OrderRepository {
OrderResult save(PlaceOrderCommand command, Reservation reservation);
}
final class PlaceOrderUseCase {
private final InventoryApi inventory;
private final OrderRepository orders;
public PlaceOrderUseCase(InventoryApi inventory, OrderRepository orders) {
this.inventory = inventory;
this.orders = orders;
}
public OrderResult place(PlaceOrderCommand command) {
Reservation reservation = inventory.reserve(
command.productId(), command.quantity());
return orders.save(command, reservation);
}
}
여기서 PlaceOrderCommand는 유스케이스의 입력 계약이고, InventoryApi는 재고 모듈이 공개한 기능이다. 주문 모듈이 재고 모듈의 JPA 엔티티를 받지 않으므로 재고 테이블이나 지연 로딩 방식이 바뀌어도 계약의 영향 범위를 줄일 수 있다.
반대로 한 모듈 안에서 하나의 트랜잭션으로 동작하는 작은 로직까지 DTO로 계속 복사하면 클래스와 매핑 코드만 늘 수 있다. 같은 소유권과 생명주기 안에서는 값 객체나 도메인 객체를 직접 사용하는 편이 더 명확할 때도 있다.
JPA 엔티티를 경계 밖으로 내보낼 때 생기는 문제
JPA 엔티티는 단순한 데이터 상자가 아니다. 영속성 컨텍스트에 연결된 동안 변경 감지 대상이 될 수 있고, 연관관계는 아직 초기화되지 않았을 수도 있다. 이를 웹 응답이나 다른 모듈의 공개 API로 넘기면 다음 문제가 따라온다.
- 직렬화 과정에서 예상하지 못한 지연 로딩과 추가 SQL이 발생할 수 있다.
- 호출자가 엔티티를 변경해 의도하지 않은 업데이트를 만들 수 있다.
- API 필드가 테이블·연관관계 구조에 묶인다.
- 트랜잭션이 끝난 뒤 초기화되지 않은 연관관계에 접근하면 실패할 수 있다.
- 양방향 연관관계가 JSON 순환 참조를 만들 수 있다.
이 문제는 엔티티는 Service 밖으로 절대 나가면 안 된다는 문장 하나로 해결되지 않는다. 엔티티를 소유한 모듈과 트랜잭션 안에서 필요한 행위를 끝내고, 경계를 넘을 때 필요한 값만 명시적인 결과 모델로 바꾸는 것이 핵심이다.
직접 호출과 이벤트는 실패 방식이 다르다
모듈 결합을 낮추려고 모든 서비스 호출을 이벤트로 바꾸는 것도 위험하다.
- 즉시 결과가 필요하고 같은 트랜잭션에서 성공·실패해야 한다면 동기 API가 자연스럽다.
- 후속 알림처럼 원래 유스케이스의 완료와 분리할 수 있다면 이벤트가 어울린다.
- 비동기 메시지 이벤트로 분리하면 재시도, 중복 처리, 순서, 최종 일관성과 관측 방법을 새로 설계해야 한다.
같은 process의 동기 application event는 호출자의 transaction과 실패를 그대로 공유할 수 있다. 이벤트라는 이름만으로 시간·배포 경계가 분리되는 것은 아니므로 실제 전달 방식을 계약에 포함해야 한다.
Spring의 @Transactional은 같은 애플리케이션 안의 선언적 트랜잭션을 다루지만, 네트워크 너머의 서비스 호출까지 하나의 로컬 트랜잭션으로 묶어 주지는 않는다. 호출 경계가 프로세스를 넘는 순간 DTO 모양뿐 아니라 실패와 보상 정책도 계약의 일부가 된다.
퍼사드는 언제 도움이 될까
당시에는 서비스 간 호출을 정리할 방법으로 퍼사드 패턴을 더 알아봐야겠다고 적었다. 지금 보니 퍼사드는 여러 기능을 하나의 거친 진입점으로 묶을 때 유용하지만, 책임이 불분명한 서비스를 숨겨 주는 만능 해법은 아니다.
먼저 유스케이스의 주인을 정하고, 그 주인이 필요한 모듈 API를 호출하게 한다. 그 진입점이 여러 내부 기능을 감추는 역할을 한다면 결과적으로 퍼사드와 닮을 수 있다. 패턴 이름보다 호출 방향과 변경 책임이 먼저다. 관련 개념은 퍼사드 패턴 정리, 업무 규칙과 유스케이스의 차이는 클린 아키텍처 업무 규칙에서 이어서 볼 수 있다.
지금의 판단 기준
서비스 간 호출을 볼 때는 다음 순서로 확인한다.
- 이 업무 흐름의 최종 책임을 지는 유스케이스는 무엇인가?
- 호출 대상은 같은 모듈의 내부 구현인가, 다른 모듈의 공개 기능인가?
- JPA 엔티티의 변경 감지와 지연 로딩이 경계 밖으로 새고 있지는 않은가?
- 동기 결과가 필요한가, 실패를 분리할 수 있는 후속 작업인가?
- DTO를 추가했을 때 실제로 독립되는 변경 이유가 있는가?
처음 품었던 질문은 “Service 사이에도 DTO를 써야 하나?”였다. 지금의 답은 “항상”도 “절대”도 아니다. 데이터가 건너는 경계와 그 경계가 보호해야 할 책임을 먼저 정한 뒤, 필요한 곳에만 DTO를 둔다.
참고 자료
'배움과 성장 > 백엔드·데이터' 카테고리의 다른 글
| 우아콘 2022 회원 시스템 이벤트 아키텍처: 3개 계층과 Outbox 학습 노트 (0) | 2022.10.21 |
|---|---|
| Spring 예외 처리 기준: try-catch와 @RestControllerAdvice 역할 나누기 (0) | 2022.10.18 |
| Spring Boot JSON 날짜 형식: @JsonFormat과 전역 설정의 경계 (0) | 2022.10.11 |
| REST API URI 설계: DB 테이블이 아니라 도메인 리소스를 이름 짓는 법 (1) | 2022.10.02 |
| Spring @Valid 입력 검증: null·빈 문자열·날짜·@Validated 구분 (0) | 2022.07.30 |
댓글