우아한테크코스 레벨 2에서는 하나의 방탈출 예약 시스템 프로젝트를 미션 별로 발전시켜나가는 경험을 할 수 있었습니다. 하나의 프로젝트에 기능을 추가해 나가다보니 지금까지 진행했던 미션들과는 다른 부분이 있었습니다. 바로 현재 미션에서 작성한 코드들이 이후 미션의 유지보수성에 직접적인 영향을 미치기에 이를 신경 쓴 것인데요, 이 덕분에 유지보수성과 확장성에 대해 조금 더 깊은 고민을 해볼 수 있었습니다. 이번 포스팅에서는 그 고민들을 풀어내보려고 합니다.
이번 포스팅의 주제는 DTO의 침투 레이어입니다입니다. 레이어의 책임을 어떻게 해석할 것이며 DTO를 다루는 레이어를 어디까지 한정할 지에 대한 의견 사이에서 제 생각을 전달드리려고해요. 개인적으로 관심있던 주제라서 토론 스터디에서 스터디원들과 해당 주제에 대해서 30분 가량의 토론을 진행한 적도 있습니다. 이와 함께 이번 포스팅에서 잘 풀어내보겠습니다.
웹 개발을 진행하다보면 개발자들 사이의 의견이 갈리는 지점이 있습니다. 바로 웹 요청 DTO가 어느 레이어까지 침투하게 설계할 지 입니다. 웹 요청 DTO는 Controller레이어까지만 침투할 수도 있고 Service 레이어까지 침투할 수도 있습니다. (사실 계층간 DTO를 별도로 설정해 엄격한 레이어드 아키텍처를 구현할 수도 있습니다. 해당 방식은 이번 포스팅에서 다루지 않습니다) 이러한 DTO 운용 방식은 프로젝트의 설계 철학과 유지보수 전략에 따라 달라질 수 있죠. 각 방식은 각각 장단점이 존재합니다.
먼저 각 구현 방식의 코드를 살펴보시겠습니다. 해당 코드는 방탈출 미션에서 사용된 예약을 생성하는 Controller의 핸들러 메서드입니다.
@PostMapping()
public ResponseEntity<ReservationResponse> createReservation(@RequestBody ReservationRequest reservationRequest) {
Reservation reservation = reservationRequest.toReservation();
Reservation savedReservation = reservationService.saveReservation(reservation);
ReservationResponse response = ReservationResponse.of(savedReservation);
return ResponseEntity.created(URI.create("/reservations/" + response.id())).build();
}
@PostMapping()
public ResponseEntity<ReservationResponse> createReservation(@RequestBody ReservationRequest reservationRequest) {
ReservationResponse response = reservationService.saveReservation(reservationRequest);
return ResponseEntity.created(URI.create("/reservations/" + response.id())).build();
}
Controller에서만 DTO를 핸들링하는 경우 Controller에서 요청 DTO를 엔티티로 변환하는 작업을 수행하고 있습니다. 반면 서비스까지 DTO가 침투하는 경우는 변환하지 않고 그대로 서비스 클래스에 요청 DTO를 인자로 전달하고 있습니다.
| 방식 | 장점 | 단점 |
|---|---|---|
| Controller만 DTO를 핸들링 | - Service의 순수성 유지: 도메인 로직과 표현 계층 객체의 분리 - 레이어 간 명확한 경계로 가독성 향상 - 외부 요청 형태에 의존하지 않아 독립적 테스트 가능 | - Controller의 복잡성 증가: DTO를 도메인 모델로 변환하거나 응답 객체를 생성하는 작업 추가 - Controller가 많은 책임을 가질 위험 |
| Service까지 DTO를 침투 | - 코드 간결성: Controller에서 데이터를 단순히 전달하며 역할 축소 - 직관적인 데이터 흐름: 요청 데이터와 비즈니스 로직 연결성 강화 | - Service의 의존성 증가: 요청 포맷 변경 시 Service 수정 필요 - 도메인 로직과 표현 계층 간 결합으로 도메인 모델 순수성 저하 |
각 방식의 장단점을 비교해보았습니다. DTO는 자주 변할 수 있는 표현 계층 객체입니다. 자주 변할 수 있는 객체이기에 이러한 객체가 어느 계층과 연결되는지에 따라 프로젝트의 전체적인 특성이 바뀌기도 하죠. 위의 장단점 역시 표현 계층 객체의 특징을 중점으로 해석한 것이라고 볼 수 있겠습니다. 이어지는 글에서는 저의 선택과 함께 장단점들의 심화 탐구를 통한 저만의 근거를 제시해보도록 하겠습니다.
저의 선택은 Service까지 DTO를 침투시키는 것입니다. 물론 도메인 성격이나 프로젝트 성격에 따라 유연하게 변경될 수 있겠지만 저의 기조를 말씀드림을 알려드립니다. 처음부터 저의 선택이 DTO를 Service까지 침투시키는 것은 아니었어요. 원래는 Controller 계층이 뷰관련 객체를 엄격히 담당해야 한다고 생각했습니다. 하지만 팀프로젝트를 진행하고 토론 스터디에서 얘기를 나눠보며 제 생각은 점차 바뀌어나갔는데요 하나씩 소개드려보겠습니다.
사실 DTO를 어디까지 침투시키느냐는 각 레이어의 책임에 대한 해석과 조금 더 연관이 있습니다. 컨트롤러가 웹 요청을 받고 응답을 전송하는데에만 집중하겠다고 하면 DTO는 서비스까지 전달되는 것이 맞겠죠. 다만 이 경우는 서비스가 DTO객체를 도메인으로 변환해야 하기 때문에 서비스 계층의 책임이 넓어지게 됩니다. 반대로 컨트롤러의 책임을 DTO 처리까지 확장할 수도 있습니다. 이 경우는 DTO객체를 다루는 레이어를 컨트롤러로 한정할 수 있겠네요.
저는 컨트롤러의 책임을 다음과 같이 한정했습니다.
제가 한정했다고는 하지만 위의 책임들은 레이어드 아키텍처를 운용할 경우 컨트롤러의 필수적인 개념들입니다. 이것만 봐도 컨트롤러의 책임은 매우 많죠. 책임이 많다는 말은 협업하는 개발자가 컨트롤러 클래스를 통해 파악해야 하는 명세가 많음을 이야기합니다. 만약 변환 로직과 같은 상대적으로 덜 중요한 로직이 컨트롤러 레이어에 위치한다면 이는 강조하고자 하는 명세가 상대적으로 덜 중요한 로직과 함께 위치하는 셈이 됩니다. 중요한 명세와 함께 덜 중요한 명세가 다뤄지게 되면 정말 중요한 정보는 희석될 수 있죠.
클래스는 명세의 집약체입니다. 해당 클래스의 주요 관심사에 대한 정보만이 압축되어야 하죠. 개인적으로 좋은 코드는 클래스의 관심사에 벗어나는 boiler plate 코드가 없는 것이라고 생각합니다. boiler plate 코드는 코드를 읽는 개발자로 하여금 정보 파악의 흐름에서 길을 잃게 만들고 피로하게 합니다. 변환 로직을 서비스로 옮기고 컨트롤러를 얇게 유지한다면 충분히 중요한 정보들만이 강조될 수 있습니다.
그렇다면 서비스는 책임이 별로 없을까요? 저는 "컨트롤러는 이미 하고 있는 일이 많고 서비스 레이어는 그렇지 않기 때문에 변환 로직이 옮겨져야 한다"라고 말씀드리는 것이 아닙니다. 제가 드리고 싶은 말씀은 서비스 레이어는 책임 범위가 넓고 모호하다는 것입니다.
마틴 파울러가 정의한 서비스 레이어에 대해서 살펴보겠습니다.
마틴 파울러의 서비스 레이어 소개
A Service Layer defines an application's boundary and its set of available operations from the perspective of interfacing client layers. It encapsulates the application's business logic, controlling transactions and coor-dinating responses in the implementation of its operations.
서비스 레이어는 클라이언트 레이어 인터페이스의 관점에서 애플리케이션의 경계와 사용 가능한 작업 집합을 정의합니다. 애플리케이션의 비즈니스 로직을 캡슐화하여 트랜잭션을 제어하고 운영 구현에서 응답을 조정합니다.
또 @Service 애너테이션의 javadoc 에서 서비스 클래스가 어떻게 설명되고 있는지 살펴보시죠.
@Service의 javadoc
May also indicate that a class is a "Business Service Facade" (in the Core J2EE patterns sense), or something similar. This annotation is a general-purpose stereotype and individual teams may narrow their semantics and use as appropriate.
또한, 애너테이션이 명시된 클래스가 “비즈니스 서비스 파사드” (Core J2EE 패턴 관점) 또는 유사한 역할을 수행함을 나타낼 수도 있습니다. 이 어노테이션은 범용적인 스테레오타입이며, 각 팀에서 적절히 그 의미와 사용을 좁힐 수 있습니다.
위의 두 참조를 통해 저희는 서비스 레이어가 비즈니스 로직을 처리하고 persistence 레이어와 함께 트랜잭션을 관리함을 알 수 있습니다. 추가적인 역할들도 있는데 요약하면 다음과 같겠습니다.
서비스 레이어는 비즈니스 로직과 인프라 로직의 혼합체입니다. 책임 범위가 넓고 모호하죠. 비즈니스 로직만 해도 충분히 모호한 경계를 가지기에 서비스 레이어는 다양한 일들을 처리합니다. 서비스 레이어에 DTO와 엔티티를 변환하는 코드 호출이 위치하는 것이 더 바람직하다고 생각한 이유입니다.
서비스 레이어는 관심사가 모호한 만큼 책임이 방대함을 말씀드렸습니다. 서비스 레이어를 짬통 정도로 생각한다면 코드가 지나치게 중앙 집중화되어 다른 레이어를 침범할 우려도 있죠. 서비스 레이어에서 DTO와 엔티티를 변환하는 동작을 위치시키는 것을 반대하는 분들은 이러한 점을 꼬집을 수도 있겠습니다. 또 어떤 분들은 뷰 로직과 결합된 서비스 레이어가 특정 API와의 강결합을 유지하기에 재사용성이 떨어진다고 말씀하실 수도 있습니다.
하지만 저는 이러한 의견에 반대합니다. 애초에 서비스 레이어의 위와 같은 문제지점들은 DTO를 Entity로 변환하는 로직때문에 부가적으로 생기는 문제가 아닙니다. 서비스는 원래 관심사가 많습니다. 사실 이러한 문제를 해결하기 위해 서비스 계층을 다시 계층화 하는 구조가 오래전부터 고민되고 있었습니다. 비즈니스 서비스 파사드를 다루는 상위 서비스인 Facade Layer를 도입하거나, 비즈니스 로직을 이루기 위한 도구로서의 의미를 갖는 하위 서비스 계층인 Implement Layer를 도입하는 등 서비스 계층을 다시 다중화 하는 것이죠.
서비스 레이어는 해석이 자유롭고 계층을 다시 세분화 하는 것도 팀마다 구조를 달리합니다. 모호한 만큼 유연한 서비스 계층의 이점을 충분히 살릴 수 있기에 저는 서비스 레이어에 변환 로직을 위치 시키는 단점의 의미가 약하다고 생각합니다.
Facade와 Implement에 대해 조금 더 자세히 알아보고 싶으신 분들은 다음을 참고하셔도 좋을 것 같습니다. 여담으로 Implement는 최근에 알게되었는데 시야가 트이는 기분이더군요. 토스의 제미니님이 소개해주신 개념인데 살펴보시길 강추드립니다.
JPA와 함께 사용되는 경우를 생각해봅시다. 사실 이런 가정을 하고 싶지는 않지만 대부분의 프로젝트에서 JPA를 사용하니 상황을 차용하겠습니다. 컨트롤러에서 프록시 객체를 직접 다루는 것은 현실적으로 제약이 많습니다. JPA는 지연 로딩(Lazy Loading) 전략을 사용해 성능 최적화를 꾀합니다. 이때, 엔티티는 실제 데이터가 로드되지 않은 상태에서 프록시 객체로 반환될 수 있습니다. 프록시 객체는 영속성 컨텍스트 내에서만 초기화되고 영속성 컨텍스트 밖에서 프록시 객체를 참조하면 LazyInitializationException이 발생합니다.
기본적으로 영속성 컨텍스트의 생명주기는 Transaction과 같이 가는데요, 이 말은 Controller에서 도메인 객체를 다루게 되면 LazyInitializationException이 발생할 위험이 있다는 뜻이 됩니다. OSIV 옵션을 true로 두어 뷰까지 세션을 유지할 수도 있지만 이는 성능을 타협해야 하는 민감한 부분입니다. 절대적인 해결책이 될 수 없죠.
사실 해결책이 아주 없는 것은 아닙니다. 현업에서는 의도치 않은 쿼리를 막기 위해 연관 객체를 간접 참조하는 경우가 많기도 하기 때문에 문제 상황이 아닐 수도 있고요. 상황에 따라 다르겠습니다. 하지만 제가 말씀드리고 싶은 것은 기술의 범용성을 따져보았을 때에도 서비스 레이어까지 DTO가 침투하는게 효율적이라는 것입니다.
저는 위의 근거들을 바탕으로 엔티티와 DTO 사이의 변환 로직은 서비스 레이어가 가져가는 것이 좋다고 생각합니다. 결과적으로 DTO는 서비스레이어까지 침투하는 것이죠. 근거들을 설명드리면서 레이어드 아키텍처의 책임에 대한 저의 고민까지 말씀드렸습니다.
저희가 아키텍처라고 하며 절대적인 설계 방법인 것처럼 얘기하지만 사실 레이어드 아키텍처는 모든 프로젝트에서 동일한 형태로 핏하게 적용될 정도로 엄격하지 않습니다. 각 레이어의 책임을 해석하는 방식은 개발자마다 달라질 수 있고, 프로젝트나 도메인 성격에 따라서 달라질 수도 있죠. 저는 Controller와 Service 사이의 강결합을 끊기 위해 해당 레이어들 사이의 중간자를 삽입하는 글도 본적이 있습니다. (우아한 기술블로그
정답은 없지만 선택은 존재합니다. 더 나아가 최선의 선택도 존재한다고 저는 믿습니다. 개념에 대한 깊은 학습과 유연한 자세가 최선의 선택을 도와줌을 믿으며 마무리합니다.