DTO 활용에 대한 고민 (반환 위치, 변환 방법, 저장 위치, 중복 사용)

Hansu Park·2023년 10월 30일
0

도입

다음과 같은 내용들에 대해 정리하였다.

  • 컨트롤러와 서비스 중 어디서 DTO 변환을 할 것인지
  • 다양한 변환 방법 중 어떠한 방법을 사용해야 하는지
  • 어느 경로에 DTO 객체를 저장해야 하는지
  • DTO 중복 사용
  • 프로젝트에서 사용할 방법들을 정리

PathVariable로 들어오는 여러 도메인(shops, owners, bus)을 enum을 이용해 예외처리를 깔끔하게 할 수 있다고 하여 조사하고 있었다. enum 에 대한 DTO-엔티티 매핑이 어떻게 이루어져야 할 지 고민이 되었다.
DTO-엔티티 변환 과정을 필수적으로 생각하고 있던 내 생각과 Controller에서 엔티티를 인자로 받는 방식이 상이하다고 느껴져 위 주제들을 조사하게 되었다.

(조사하다가 김영한 강사님의 인프런 질의응답이 많이 있었고, 꽤나 높은 퀄리티의 답변을 얻을 수 있었다. 이를 앞으로도 자주 사용해보아야겠다.)

변환을 수행할 계층에 대한 고민

현재 컨트롤러와 서비스 사이에 객체를 변환할 때 DTO를 사용한다.

여러가지 고민들을 살펴보자.

엔티티의 사용 범위 측면

컨트롤러에서 DTO를 변환한다고 하면, 엔티티가 서비스 계층을 벗어나는데 이래도 괜찮을까? 타 계층에서 엔티티를 의존해도 괜찮을까?

이론적인 관점에서는 서비스에서만 엔티티를 의존하는 것이 좋다. 반대로 실용적인 관점에서는 여러 계층(Controller, Service, Repository) 모두 엔티티를 의존하는 것이 좋다. 따라서, 실용성을 중요시 여긴다면 컨트롤러에서 변환도 가능할 것이다. 추가로, JPA-지연로딩을 활용하기 위해 서비스에서만 엔티티를 사용할 수도 있을 것이다.

실용성 측면

  • 서비스에서 변환: 서비스의 기능과 DTO가 1:1 매핑된다.
  • 컨트롤러에서 변환: 서비스를 호출하여 응답받은 엔티티를 다른 서비스를 호출의 인자로 재사용할 수 있다. (이런 경우가 많진않다.)
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) 강건성이 크게 중요하지 않다는 점을 고려하였을 때, 컨트롤러에서의 변환을 선택할 것이다.

매핑 방법에 대한 고민

방법

  1. mapstruct
  2. modelmapper
  3. 컨트롤러, 서비스에서 코드를 이용하여 변환
  4. JPQL 등을 활용해 DTO를 즉시 생성

mapstruct, modelmapper 등 라이브러리의 단점

  • 복잡한 모델 설계: 코드 작성보다 시간이 오래 걸림
  • 런타임에서야 에러 확인: 불편함
  • 모델 매퍼: 동시성 이슈가 있다.

제시되는 방법

  1. 가장 간단한 방법을 택한다.
  2. 코드를 적어본다.
  3. 의존관계를 생각한 다음 리팩터링한다. (복잡해지면 잘못된 코드)

간단한 방법의 예시

  1. 단순한 생성자
  2. 의미있는 스태틱 팩터리 메서드
  3. (더욱 복잡한 방법들)

결론

코드를 통한 객체 변환을 진행하자. 컴파일 시점에 오류를 확인할 수 있다는 강점이 있고, AI 도구의 도움을 받을 수 있어 효과가 클 것이다. 하지만 mapstruct 등을 완전히 금지하진 않을 것이다.

DTO의 저장 위치에 대한 정보

패키지 위치

기존에는 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를 꼭 사용해야 하는가에 대한 의문을 갖게 되었다. DTO를 사용하였을 때 감소시킬 수 있는 의존성과, 이로 인해 발생하는 불편함을 고려하여 상황에 맞게 적절히 사용해야 하는 게 정답인 것 같다.

단, 엔티티 자체를 응답하는 경우는 없어야 한다. 엔티티 변경시 API 스펙까지 변경이 될 수도 있기 때문이다.

전체 결론

  • 실용성을 위해 Controller에서 변환할 것.
    - 지연로딩, 복잡한 엔티티 매핑은 서비스에서 변환할 것.
  • (개인) 잦은 런타임 에러로 불편함을 겪게하는 mapstruct 대신 직접 코드로 변환할 것.
    - copilot 등의 AI 도구가 활용가능하기에 더욱 효과적일 것.
    - 다른 인원이 다른 방법을 사용하더라도 제한하지 않을 것.
  • 여러 계층에서 사용하는 경우를 제외하고 사용하는 계층과 같은 패키지에 정의할 것.
  • 이너 클래스를 적극 활용할 것.
  • 요청 DTO는 필수가 아니라 선택.

+) 비슷한 맥락으로, ResponseEntity 없는 응답, Controller-Repository 의존도 상황에 따라 허용할 것.

참고

0개의 댓글