다음과 같은 내용들에 대해 정리하였다.
PathVariable
로 들어오는 여러 도메인(shops
, owners
, bus
)을 enum
을 이용해 예외처리를 깔끔하게 할 수 있다고 하여 조사하고 있었다. enum
에 대한 DTO-엔티티 매핑이 어떻게 이루어져야 할 지 고민이 되었다.
DTO-엔티티 변환 과정을 필수적으로 생각하고 있던 내 생각과 Controller에서 엔티티를 인자로 받는 방식이 상이하다고 느껴져 위 주제들을 조사하게 되었다.
(조사하다가 김영한 강사님의 인프런 질의응답이 많이 있었고, 꽤나 높은 퀄리티의 답변을 얻을 수 있었다. 이를 앞으로도 자주 사용해보아야겠다.)
현재 컨트롤러와 서비스 사이에 객체를 변환할 때 DTO를 사용한다.
여러가지 고민들을 살펴보자.
컨트롤러에서 DTO를 변환한다고 하면, 엔티티가 서비스 계층을 벗어나는데 이래도 괜찮을까? 타 계층에서 엔티티를 의존해도 괜찮을까?
이론적인 관점에서는 서비스에서만 엔티티를 의존하는 것이 좋다. 반대로 실용적인 관점에서는 여러 계층(Controller, Service, Repository) 모두 엔티티를 의존하는 것이 좋다. 따라서, 실용성을 중요시 여긴다면 컨트롤러에서 변환도 가능할 것이다. 추가로, JPA-지연로딩을 활용하기 위해 서비스에서만 엔티티를 사용할 수도 있을 것이다.
public ResponseEntity<Void> createQuest(@RequestBody QuestDTO questDTO) {
Quest quest = questDTO.toEntity();
questService.createQuest(quest); // quest의 userId가 업데이트된다.
notiService.notification(quest);
}
public ResponseEntity<Void> createQuestOnly(@RequestBody QuestDTO questDTO) {
questService.createQuest(questDTO.toEntity()); // domain.Quest 패키지에 대한 import가 생기지 않는다!
}
항상 그렇듯, 정답은 없다. 하지만 진행하는 프로젝트가 (1) JPA-지연로딩이 일어나지 않는다는 점 (2) 복잡한 수준의 매핑은 존재하지 않는다는 점 (3) 강건성이 크게 중요하지 않다는 점을 고려하였을 때, 컨트롤러에서의 변환을 선택할 것이다.
mapstruct
modelmapper
mapstruct
, modelmapper
등 라이브러리의 단점코드를 통한 객체 변환을 진행하자. 컴파일 시점에 오류를 확인할 수 있다는 강점이 있고, AI 도구의 도움을 받을 수 있어 효과가 클 것이다. 하지만 mapstruct 등을 완전히 금지하진 않을 것이다.
기존에는 DTO는 무조건 별도 패키지에 보관하였다. 하지만, 하나의 클래스에서만 사용한다면, 생성된 클래스와 같은 패키지 경로에 위치시킬 수도 있을 것이다. 여러 패키지에서 사용하는 경우 별도 패키지를 만들어 이용할 수 있다.
주의할 것은 양방향 패키지 의존관계이다. 컨트롤러에서 만든 DTO를 서비스에서 참조하게 된다면, 양방향 의존관계가 일어난다.
이너클래스를 활용하여 클래스 파일의 생성을 줄일 수 있다.
public class QuestDTO {
public static class Create {
private String title;
private String description;
private Long categoryId;
private Long userId;
}
public static class Update {
private String title;
private String description;
private Long categoryId;
}
public static class Read { ... }
}
@RestController
@RequestMapping("/quests")
public class QuestController {
@GetMapping("/{id}")
public ResponseEntity<QuestDTO.Read> getQuest(@PathVariable Long id) {
Quest quest = questService.getQuestById(id);
QuestDTO.Read questDTO = mapQuestToReadDTO(quest);
return ResponseEntity.ok(questDTO);
}
}
(Request, Response도 이너 클래스로 나눠볼 수 있을 것 같다.)
Q. 서로 다른 위치에 있는 DTO가 비슷한 필드들을 가진다면 어떻게 해야할까? 어드민에서 사용하는 DTO와 일반 권한이 사용하는 DTO와 같은 경우 말이다. 이러한 경우 DTO를 통일하여 하나만 사용해도 될까?
A. DTO의 중복은 코드의 유사성을 기준으로 사용하는 것이 아니라 라이프사이클의 유사성을 기준으로 한다. 예를 들어, 어드민 서비스와 일반 서비스의 DTO가 동일한 필드를 갖더라도 라이프 사이클이 다르기에 중복하여 사용하지 않는다. (이러한 관점은 DTO 뿐만이 아니라 비지니스 로직에서도 동일하게 적용될 것이다.)
결론적으로 DTO를 꼭 사용해야 하는가에 대한 의문을 갖게 되었다. DTO를 사용하였을 때 감소시킬 수 있는 의존성과, 이로 인해 발생하는 불편함을 고려하여 상황에 맞게 적절히 사용해야 하는 게 정답인 것 같다.
단, 엔티티 자체를 응답하는 경우는 없어야 한다. 엔티티 변경시 API 스펙까지 변경이 될 수도 있기 때문이다.
mapstruct
대신 직접 코드로 변환할 것.+) 비슷한 맥락으로, ResponseEntity 없는 응답, Controller-Repository 의존도 상황에 따라 허용할 것.