들어가며
현재 콩콩밥밥이라는 스터디를 통해 초록 스터디의 스프링 입문과정에 참여하고 있습니다.
미션으로 방탈출 프로그램의 예약 CRD API를 구현하던 중, 단순히 View에 전달해야 하는 데이터가 Entity의 모든 필드와 동일하다는 이유로 Controller 메서드의 반환값으로 Entity를 그대로 사용했습니다.
@RestController
@RequestMapping("/reservations")
public class ReservationController {
private final ReservationService reservationService;
public ReservationController(final ReservationService reservationService) {
this.reservationService = reservationService;
}
// ‼️문제 지점 - domain과 view 결합도 증가
@GetMapping()
public List<Reservation> getReservations() {
return reservationService.getReservations();
}
}
그러나 이로 인해 도메인과 View 간의 결합도가 증가하게 되었다는 리뷰를 받았습니다.
이를 해결하기 위해 Controller의 응답형은 무엇이 되어야 할까요?
질문에 대한 답변으로 DTO란 무엇이며, 왜 필요한지에 대해 알아 봅시다!
DTO 정의
Data Transfer Object를 줄여 DTO라 칭하며, Controller와 클라이언트 간에 데이터를 주고받기 위해 사용하는 객체입니다.
DTO 특징
1. 오직 getter 메서드 만을 갖는다.
DTO는 오직 데이터를 전송하는 목적만을 가지고 있으므로 불변성을 유지해야 하며, 데이터를 변경하는 로직 및 비지니스 로직을 포함해서는 안 됩니다.
2. 계층 간 의존성이 분리된다.
DTO를 사용함으로써 도메인 엔티티(Entity)가 직접 노출되지 않으며, 이는 도메인 모델과 외부 계층(View, Controller 등)의 결합도를 낮추는 데 기여합니다.
DTO 사용 흐름
Client는 Controller에 데이터를 전달하며,
Controller는 이를 비즈니스 계층인 Service로 넘겨 비즈니스 로직을 처리합니다.
Service는 비즈니스 로직을 처리한 뒤, Client에게 전달할 데이터를 ResponseDto라는 객체에 담아 Controller에 반환합니다.
Controller는 최종적으로 이 ResponseDto를 Client에 전달합니다.
한편, Repository에서는 DTO를 사용하지 않습니다.
Repository에서는 Controller로 전달된 DTO에서 필요한 정보를 추출하여 기본 타입(String, int 등)으로 받아 작업을 수행합니다.
흐름이 이해가 가시나요? 조금 더 이해를 돕기 위해 코드를 준비해봤습니다!
코드를 통한 예시
RequestDto
public record CreateReservationRequest(
String name,
LocalDate date,
@JsonProperty("timeId")
long timeId
) {
public Reservation toReservation(final Time time) {
return new Reservation(
name,
date,
time
);
}
}
코드에서 볼 수 있듯이 DTO에 데이터를 변경하는 로직 및 비지니스 로직이 존재하지 않음을 알 수 있습니다.
Controller
@RestController
@RequestMapping("/reservations")
public class ReservationController {
private final ReservationService reservationService;
public ReservationController(final ReservationService reservationService) {
this.reservationService = reservationService;
}
@PostMapping()
public ResponseEntity<ReservationResponse> createReservation(final @RequestBody CreateReservationRequest request) {
// 클라이언트로 부터 받은 requestDto 서비스에 전달, 서비스로부터 받은 responseDto 클라이언트에 전달
final ReservationResponse response = reservationService.createReservation(request);
return ResponseEntity.created()
.body(response);
}
}
Service
@Service
public class ReservationService {
private final ReservationRepository reservationRepository;
public ReservationService(final ReservationRepository reservationRepository) {
this.reservationRepository = reservationRepository;
}
public ReservationResponse createReservation(final CreateReservationRequest request) {
final Time time = getTime(request.timeId());
final Reservation reservation = request.toReservation(time);
validateAvailability(reservation);
validateExpiredDateTime(reservation);
final Reservation reservationWithId = reservationRepository.save(reservation);
return new ReservationResponse(reservationWithId);
}
}
Service 계층에서는 DTO내 값을 꺼내어 비지니스 로직을 수행하며, 이후 응답 DTO를 생성하여 Controller에 전달합니다.
DTO를 사용해야 하는 이유
앞서 Controller의 응답값으로 Entity를 반환할 경우, 도메인과 View 간의 결합도가 증가한다는 문제가 있다고 언급했습니다.
그리고 DTO의 특징(계층 간 의존성이 분리된다)에 대해 이야기 하며 DTO를 사용함으로써, 도메인과 View의 결합도를 낮출 수 있다고 하였습니다.
• Entity를 요청이나 응답 값을 전달하는 클래스로 사용하면 왜 도메인과 뷰 간의 결합도가 증가할까요?
그 이유는 바로 Entity가 데이터베이스와 매핑되어 있는 중요한 클래스이기 때문입니다.
Entity는 테이블 생성 및 스키마 변경의 기준이 되는 핵심 도메인 클래스이며, 수많은 서비스 클래스와 비즈니스 로직이 이를 중심으로 동작합니다.
반면, View는 비즈니스 요구사항에 따라 빈번하게 변경되는 영역입니다.
따라서 Entity를 요청이나 응답 데이터로 직접 사용하면 View의 변경이 Entity의 수정으로 이어지고, 이는 연쇄적으로 많은 클래스와 로직에 영향을 미칩니다.
또한 응답 값으로 여러 테이블들을 조인한 결과값을 전달해야 할 경우가 빈번하기 때문에 entity만으로는 응답 값을 표현하기에는 한계가 존재합니다.
• 이를 해결하기 위해 DTO를 사용한다면?
DTO는 마치 뼈와 뼈가 맞닿는 연골 같은 역할을 해줍니다.
따라서 View의 변경 요청이 너무 많아도 DTO만 변경하면 Domain 까지 해당 변경의 여파라 흘러들어오는 걸 막아줄 수 있습니다.
두 객체 간의 결합도도 더 느슨하게 유지할 수 있고요!
• 예시 상황
@Getter
public class Reservation {
private final Long id;
private final String name;
private final LocalDate date;
// ‼️뷰의 책임에 해당되는 부분이 도메인에 섞이는 문제점 발생
@JsonFormat(pattern = "HH:mm")
private final LocalTime time;
public Reservation(Long id, String name, LocalDate date, LocalTime time) {
this.id = id;
this.name = name;
this.date = date;
this.time = time;
}
}
@JsonFormat은 클라이언트에게 어떤 형식으로 time을 보여줄 지 설정하는 어노테이션으로, View의 책임이라 볼 수 있습니다.
하지만 현재 Reservation이라는 Domain에 @JsonFormat이 존재하게 되면서 View의 책임이 혼재하고 있습니다.
- 문제 발생: View 요구사항이 변경된 경우
만약 'time의 형식을 24시간제가 아닌 오후/오전 시간 형태로 바꿔주세요!' 와 같은 요청이 들어올 경우, 도메인 클래스까지 수정해야 하는 상황이 발생합니다.
도메인 클래스는 비즈니스 로직과 데이터베이스와의 매핑을 책임져야 하는데, 뷰 관련 책임이 섞이면서 도메인 클래스가 비즈니스 역할에 집중하지 못하게 됩니다.
- DTO 도입
View 관련 변경 요청이 들어왔을 때 DTO만 수정하면 되므로 유지보수가 용이합니다.
@Getter
public class Reservation {
private final Long id;
private final String name;
private final LocalDate date;
private final LocalTime time;
public Reservation(Long id, String name, LocalDate date, LocalTime time) {
this.id = id;
this.name = name;
this.date = date;
this.time = time;
}
}
====
public record ReservationResponse(
long id,
String name,
LocalDate date,
@JsonFormat(pattern = "HH:mm")
LocalTime time
) {
public ReservationResponse(final Reservation reservation) {
this(reservation.getId(),
reservation.getName(),
reservation.getDate(),
reservation.getTime());
}
}
결론
DTO를 활용하면 변경이 잦은 View 계층에서의 요구사항 변화에 유연하게 대처할 수 있으며,
각 계층의 역할과 책임을 명확히 분리하여 안정적이고 확장 가능한 코드를 작성할 수 있습니다.
더 안전하고 유지보수성이 높은 시스템을 위해 DTO를 활용해보는 게 어떨까요?!
참고 자료
https://www.youtube.com/watch?v=z5fUkck_RZM
'Spring' 카테고리의 다른 글
필터(Servlet Filter) VS 인터셉터(Spring Interceptor) 차이 및 활용 예시 (0) | 2025.03.30 |
---|---|
스프링에서 응답을 보낼 때 ResponseEntity를 사용하자! (0) | 2025.03.28 |
@Mock, @MockBean, @Spy, @SpyBean, @InjectMocks 의 차이 (0) | 2025.03.27 |
직렬화(Serialization)와 역직렬화(Deserialization) (0) | 2025.03.27 |
PUT, PATCH의 차이점 (feat. 멱등성) (0) | 2025.02.15 |