Spring Boot

[Spring] DTO는 왜 써야 하나요?

수수한개발자 2022. 8. 18.
728x90

1. 글 작성 이유 

DTO(Data Transfer Object)란 계층간 데이터 교환을 위해 사용하는 객체(Java Beans)입니다.

 

어느순간부터 DTO를 사용하게 되면서 이것을 왜? 사용하는가에 대해 갑자기 생각이들었습니다.

아래 코드를 보면서 DTO는 왜 필요한가에 대해 생각해보겠습니다.

 

*  아래 코드들은 글 작성을 위한 실제 설계의 일부입니다.

 

Entity

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class GroupTab {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String groupName;

    private String userId;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "member_id")
    private Member member;
    
    public GroupTab(String groupName, Member member) {
        this.groupName = groupName;
        this.member = member;
        this.userId = member.getUserId();
    }

    public static GroupTab of(String groupName, Member member) {
        return new GroupTab(
            groupName,
            member
        );
    }
}

 

Service

@RequiredArgsConstructor
@Transactional
@Service
public class GroupTabService {

    private final GroupTabRepository groupTabRepository;

    private final MemberRepository memberRepository;

    public void saveGroupTab(GroupTabDto dto) {
        Member member = memberRepository.getReferenceById(dto.getMemberDto().getMemberId());
        groupTabRepository.save(dto.toEntity(member));
    }
}

 

DTO

RequestDto

@Data
public class GroupTabRequestDto {

    @NotBlank
    private final String groupName;

    public GroupTabDto toDto(MemberDto memberDto) {
        return GroupTabDto.of(
                this.groupName,
                memberDto
        );
    }
}

DTO

@Data
public class GroupTabDto {
    private final Long id;

    private final String groupName;

    private final MemberDto memberDto;

    public static GroupTabDto of(String groupName, MemberDto memberDto) {
        return new GroupTabDto(
                null,
                groupName,
                memberDto
        );
    }

    public GroupTab toEntity(Member member) {
        return GroupTab.of(
                groupName,
                member
        );
    }

}

 

Controller

@Slf4j
@RequiredArgsConstructor
@Controller
@RequestMapping("/groupTabs")
public class GroupTabController {

    private final GroupTabService groupTabService;

    @PostMapping("/new")
    public String saveGroupTab(@Valid @ModelAttribute("groupTab") GroupTabRequestDto groupTabRequestDto, 
    				BindingResult bindingResult, 
                                @AuthenticationPrincipal PrincipalDetails principalDetails) {
        if (bindingResult.hasErrors()) {
            log.info("error={}", bindingResult);
            return "groupTabs/createGroupTabForm";
        }

        groupTabService.saveGroupTab(groupTabRequestDto.toDto(principalDetails.toDto()));
        return "redirect:/";
    }

}

 

위의 예제코드들은 GroupTab이라는 엔티티를 생성하는 코드입니다.

GroupTabRequestDto 라는 객체로 뷰 레이어에서 받아온 값으로 GroupTabrequestDto를 컨트롤러에게 던져주고,

컨트롤러는 해당 requestDto를 일반 Dto로 변환시켜 Service에게 넘겨주고,

Service에서는 받은 Dto를 toEntity 메서드로 생성된 groupTab 객체를 JPA로 저장하게 됩니다.

(위의 코드에서 시큐리티가 적용된 부분(@AuthenticationPrincipal)는 그냥 그렇구나 하고 작성하겠습니다.. 물론 이것또한 이번글의 내용 범주에 들어갑니다.)

 

2. 언제, 왜 DTO를 사용하나요?

 

GroupTab 엔티티를 보면 id, groupName, userId 그리고 연관관계 맺어진 멤버뿐입니다. 왜 변환을 사용해야 하며, 그렇다면 언제 DTO가 필요한지에 대해 생각해보겠습니다.

 

1) Entity 클래스와 거의 유사한 형태임에도 DTO 클래스를 추가로 생성하는 이유는 Entity클래스가 데이터베이스와 맞닿은 핵심 클래스이기 때문입니다.

 

Entity 클래스를 기준으로 테이블이 생성되고 스키마가 변경되는데, 화면 변경과 같은 사소한 기능 변경을 위해 테이블과 연결된 Entity 클래스를 변경하는 것이 너무나 큰 변경이 되는 것입니다.

 

2) 다양한 비즈니스 로직과 요구사항에 대해 유연하게 대응할 수 있습니다.

 

파라미터로 엔티티 자체를 받게 되면 엔티티에서 정해진 포맷에 맞춰 개발을 해야 합니다. 하지만 DTO는 각 비즈니스 로직에 맞춘 필드들만 생성함으로써 DTO를 보면 어떤 값들이 매핑되는지 쉽게 파악할 수 있고, 만약 API 설계 상황에서 필드에 다른 이름을 부여하거나 하는 상황에서도 유엲나게 대처할 수 있습니다.

 

3) Controller와 Service 사이에서 강한 의존을 방지하기 위해 DTO를 사용합니다.

 

참고 = > https://techblog.woowahan.com/2711/

 

Service가 받고 싶은 파라미터가 Controller에게 종속적이게 되면 Service가 Controller 패키지에 의존하게 됩니다. 따라서 이를 방지하기 위해서 라도 Service가 원하는 포맷에 맞춰 Controller 딴에서 DTO를 통해 그 포맷을 맞춰주는 것입니다.

 

MVC 구조로 프로젝트를 구성하는 분들은 Controller -> Service -> Repository 구조가 익숙하실텐데요. 이 경우 의존성의 방향이 Controller에서 Repository로 단방향으로 흐르는 것이 일반적입니다. 현재는 toDto라는 메소드가 있지만 없을때의 상황이라고 쳐보겠습니다.

 

위의 컨트롤러 코드는 아마 다음과 같이 될 것 입니다.

 

 @PostMapping("/new")
    public String saveGroupTab(@Valid @ModelAttribute("groupTab") GroupTabRequestDto groupTabRequestDto, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            log.info("error={}", bindingResult);
            return "groupTabs/createGroupTabForm";
        }

        groupTabService.saveGroupTab(groupTabRequestDto);
        return "redirect:/";
    }

 

위의 경우 문제가 되는 것은 다음과 같습니다.

  • Service가 받고 싶은 포맷(Parameter)이 Controller에 종속적이게 된다 : Service가 Controller 패키지에 의존하게 된다.
  • Service레이어가 모듈로 분리되는 경우 해당 Type을 사용할 수 없다.
  • 트랜잭션으로 처리되어야하는 DTO 항목이, 항상 요청으로 들어온 값과 동일하지 않을 수 있습니다. 아래의 그림을 예로 들어보겠습니다.

 

사용자 요청의 파라미터를 통해 외부 API를 여러번 호출한 이후 Service 레이어를 호출하는 경우, Controller가 받은 Web DTO와 Service가 받아야 할 DTO가 달라집니다.

 

참고로 위의 예제에서 Controller에서(Controller-Service사이에 중간 레이어를 두고 하는 경우 포함) 외부 API를 조회하는 이유는 Service에서 해당 작업을 수행하는 경우 트랜잭션과 무관한 작업이 트랜잭션내에 포함되기 때문에 DB 타임아웃과 같은 이슈가 발생할 수 있기에 위와 같이 작성하였습니다.
외부 API 호출뿐만 아니라 Client 요청 이후 Service 레이어를 호출하기 전 다른 작업으로 인해 데이터 포맷이 달라질 수 있습니다.
이런 경우에 Service 레이어가 Controller 레이어 DTO에 의존하고 있다면 문제가 될 수 있습니다. 따라서 Service 레이어는 자신이 원하는 포맷으로 데이터를 받을 수 있어야합니다.

 

해결법 : 서비스의 포맷에 맞게 변환해서 전달

 

이러한 문제를 개선하기 위해 Service는 자신이 원하는 포맷에 맞게 데이터를 받고 Controller에서 그 포맷을 만들어주는 것이 적절합니다.

 

예제 코드를 보면 엔티티에서는 그룹이름과 연관관계를 맺는 Member 엔티티와 userId가 필요합니다.

여기에 맞게 포맷을 만들어 주는것이 toDto 메소드입니다.

 

@Data
public class GroupTabRequestDto {

    @NotBlank
    private final String groupName;

    public GroupTabDto toDto(MemberDto memberDto) {
        return GroupTabDto.of(
                this.groupName,
                memberDto
        );
    }
}

컨트롤러
groupTabService.saveGroupTab(groupTabRequestDto.toDto(principalDetails.toDto()));

보시면 GroupTabRequestDto는 groupName이라는 필드만 있습니다.

여기서 컨트롤러에서  groupTabrequestDto 객체를  toDto 메서드로 MemberDto와 함께 파라미터로 넘겨주고 있습니다.

(principalDetails 클래스 또한 위와같이 toDto 메소드 구현하였다.)

 

그럼 이렇게 해서 GroupDto.of 정적메소드를 통하여 Dto로 변환시켜줍니다.

 

@Data
public class GroupTabDto {
    private final Long id;

    private final String groupName;

    private final MemberDto memberDto;

    public static GroupTabDto of(String groupName, MemberDto memberDto) {
        return new GroupTabDto(
                null,
                groupName,
                memberDto
        );
    }

    public GroupTab toEntity(Member member) {
        return GroupTab.of(
                groupName,
                member
        );
    }
}

public class GroupTabService {
    public void saveGroupTab(GroupTabDto dto) {
        Member member = memberRepository.getReferenceById(dto.getMemberDto().getMemberId());
        groupTabRepository.save(dto.toEntity(member));
    }
}

 

그러면 여기서 GroupTabDto는 Service에서 원하는 값인 groupName과 member객체를 받기위해 toEntity 메서드로 변환 시켜 사용할 수 있습니다. 이렇게 포맷에 맞추는 과정을 보았습니다.

 

여기서 Service에서 엔티티를 받고 엔티티를 반환하는 형태도 좋은 방법이라고 생각합니다. 다만 아래와 같은 이유로 저는 DTO를 받고, DTO를 반환하고 있습니다.

  • 불완전한 엔티티를 Service 파리미터로 받는 부분이 적절하지 않다.
  • Service 메소드별로 원하는 포맷이 달라지는 경우 결국 DTO로 분리될 것이고, 이는 Service 파라미터가 엔티티/DTO로 받게 되어 일관성을 위배할 수 있다.
  • 반환 타입의 경우 DTO를 넘기지 않으면 Response에 포함될 필요가 없는 필드까지 Controller에 다시 ResponseDto로 들어가게 된다는 점에서 문제가 생긴다고 생각합니다.
  • ex) 만약 그룹이름만 뷰에서 보여주면 되는 서비스라면 엔티티를 반환할경우 생성자, 생성일시, 수정자, 수정일시 같은 필요 없는 필드가 포함되게 됩니다.

 

 

3. 정리 

서비스는 자신이 원하는 포맷을 기다리고 있으면 되고 컨트롤러에서 DTO를 생성하여 서비스에게 넘겨주면 된다고 생각합니다 그리고 서비스는 해당 DTO를 비즈니스 로직을 거쳐 ResponseDto를 다시 던져주면 되겠습니다.

 

DTO에 대한 개념이나 사용 방법은 수많은글들이 있고 현재도 쓰여지고 있습니다.

저 또한 다른 수많은분 들의 글들을 보고 예제를 만들어 적는 수준에 그치지만 왜? 라는 것에 초점을 맞추어 예제를 만들며 정리해보고 고민하는것에 조금이라도 의미를 부여하고자 합니다.

 

감사합니다.

728x90

댓글