DTO(Domain Transfer Object)

김민우·2023년 12월 12일
1

잡동사니

목록 보기
16/22

Spring Boot를 통해 프로젝트를 진행한다면 계층 간 데이터 전달 시 DTO 클래스 사용은 필수다. 그러나 이를 왜 사용하는지, 사용한다면 어떤 장점이 있는지에 대해 놓치고 있는 것 같아 자세히 정리해보려 한다.

추가로, Spring Boot에서 사용하는 5 Layer Architecture의 특징을 통해 DTO를 설계하는 방법도 알아보자.

DTO란?


DTO(Domain Transter Object, 도메인 전송 객체)란 레이어 간 데이터 전달 시 사용되는 객체다. MVC 패턴에서 Controller는 요청 흐름에 따라 Model과 View를 호출한다.

Model(비지니스 처리 로직)과 View(UI 영역)은 서로를 모르는 상태이며 Controller에 의해 서로 상호 작용하며 로직을 수행한다.

Controller는 View로 부터 전달받은 사용자 데이터를 해석하여 Model을 통해 알맞는 비지니스 로직을 수행한 후 결과를 View에 전달한다. 이처럼 Model과 View는 서로를 모르기에 MVC 패턴을 활용한다면 비지니스 로직 영역과 UI 영역을 각각 독립적으로 개발이 가능하다.

Model 로직 수행 결과를 View로 전달하는 과정에서 DTO가 사용된다. Model의 값을 그대로 전달 할 수 있지만 일반적으로 DTO로 객체를 컨버팅하여 View로 전달한다.

DTO를 안쓴다면?

만약, 도메인(Model 로직 수행 결과)을 그대로 View에 전달한다고 생각해보자. 도메인 내의 민감한 비지니스 로직 기능이 외부에 노출되므로 보안 문제를 야기한다. 게다가 UI 입장에서 Model에 대한 필요하지 않은 데이터도 포함될 수 있다.

Model과 View의 결합도가 높아지는 것도 문제가 된다. 각 레이어의 요구 사항이 변동된다면 다른 레이어에도 영향을 준다.

  • Entity 속성 변경 -> UI 계층의 JSON, FE의 js 코드 수정 요구
  • View 요구 사항 변경 -> Model 비지니스 로직 수정

이처럼 상호간 강하게 결합된다면 MVC 패턴의 장점을 살릴 수 없게 된다.

DTO를 사용한다면?

우선적으로 도메인 내 민감한 정보를 모두 감추고 필요한 정보만 제공함으로써 캡슐화가 가능해진다. 추가로, Model과 View의 결합도를 낮춰 유지보수 및 확장에 유리해진다. DTO는 MVC 패턴에서 Model과 View 사이에 의존성을 낮추기 위해 도입된 객체라 봐도 무방하다.

DTO의 궁극적인 역할


Spring Boot를 사용하여 개발하다보면 DTO가 어떤 역할까지 맡아도 되는지 그 기준이 애매해진다. 이를 확실히 하기 위해선 DTO의 역할을 정확히 알아야 한다.

내가 생각하는 DTO의 역할은 아래와 같다.

DTO는 택배 배송과정에서 상자와 같다

이 말을 명심하고 애매한 상황에 접근해보자.

1. DTO에서 검증을 해도 될까?

e.g.) 사용자가 1을 입력하면 재시작, 2를 입력하면 종료하는 프로그램이 있다. 만약, 1, 2외 다른 숫자가 입력된다면 예외가 발생하고 어플리케이션이 종료된다.

입력값을 DTO로 변환하여 전달하는 상황이다. 아래와 같이 설계하면 어떨까?

public class AnswerRequestDto {
    private final int number;

    public AnswerRequestDto(final int number) {
        this.number = number;
    }

    private void validate(final int number) {
        if (number != 1 && number != 2) {
            throw new IllegalArgumentException();
        }
    }
}

DTO에서 입력값을 검증하고 있다. 이 설계가 올바르다 할 수 있을까? 앞서 언급한 DTO는 택배 배송과정에서 상자라는 것을 생각해보자. 상자가 안의 내용물을 검증한다면 어색할 것이다.

나는 View에서 기본적인 검증(e.g. 숫자인지 여부)정도만 검증하고 비지니스 요구사항은 Model에서 검증하는게 좋다고 생각한다. (물론, 이 검증 기준에 대한 정답은 없다. 중요한건 팀원끼리 명확한 검증 기준을 확립하고 이에 맞게 검증을 실행하는 것이다.)

참고
Spring Boot 에서 제공하는 검증 어노테이션(@Size, @Min 등)은 이와 다르다. 어노테이션은 택배 상자에 경고 스티커를 붙이는 정도로 생각할 수 있고 이는 어색하지 않다.

cf) DTO를 enum으로?

처음 이를 설계할 때 1, 2만 입력되므로 DTO를 열거체(enum)으로 설계할까 생각했다. 그러나, 열거체로 특정 값만 입력되도록 설계한다는 것은 DTO가 검증 책임을 가지는 것이라 판단해 클래스로 만들었다.

2. 도메인과 DTO의 의존성

도메인과 DTO의 의존성을 어쩔 수 없이 생긴다. 핵심은 이를 절대로 양방향으로 설계하면 안되며 DTO가 도메인을 의존하는 단방향 관계로 설계하자.

DTO는 변경 가능성이 매우 높다. 이를 변경 가능성이 적은 도메인이 의존한다면 DTO 변경시 도메인도 수정하는 상황이 발생하게 된다.

대표적으로 도메인이 DTO에 의존하는 상황은 Entity -> DTO 변환 메서드를 만들 때 발생한다.

User.java

@Entity
public class User {
    private final Long id;
    private final String name;
    private final String email;
    private final String password;
    private final List<Role> roles;

    public UserResponseDto toDto() {
        return new UserResponseDto(this.name, this.email);
    }
}

UserResponseDto.java

public class UserResponseDto {
    private final String name;
    private final String email;

    public UserResponseDto(final String name, final String email) {
        this.name = name;
        this.email = email;
    }
}

현재 Entity -> DTO 변환 메서드가 Entity에 존재하여 DTO를 의존하고 있따. 만약, DTO 내부 코드가 수정된다면 User 클래스의 수정도 불가피하다.

정적 팩토리 메서드를 활용하여 DTO에서 Entity를 의존하도록 수정하자.

수정된 UserResponseDto.java

public class UserResponseDto {
    private final String name;
    private final String email;

    public UserResponseDto(final String name, final String email) {
        this.name = name;
        this.email = email;
    }
    
    public static UserResponseDto from(final User user) {
        return new UserResponseDto(user.getName(), user.getEmail());
    }
}

참고
아래와 같이 정적 팩토리 메서드를 작성할 수도 있다.

public static UserResponseDto of(final String name, final String email) {
        return new UserResponseDto(name, email);
    }

그러나, 이렇게 되면 외부에서 User 객체의 필드를 노출하여 캡슐화가 이뤄지지 않는다.

public class UserService {
    ...
    public UserResponseDto response(final User user) {
         return UserResponseDto.of(user.getEmail(), user.getName());
    }
}

따라서, 정적 팩토리 메서드 파라미터는 가급적이면 Entity를 노출하자.

DTO를 변환하는 레이어


5 Layer Architecture에서 도메인을 DTO로 변환하는 레이어는 크게 2가지가 있다.

  • Service 레이어에서 변환
  • Controller 레이어에서 변환

응답/요청 DTO에 따라 살펴보자.

1. Service 레이어에서 변환

아래 코드는 Service 레이어에서 DTO를 엔티티로 변환하고 있다.

UserController.java

public class UserController {
    ...
    
    @PostMapping("/signUp")
    public ResponseEntity<SignUpResponseDto> signUp(@RequestBody final SignUpRequestDto signUpRequestDto) {
        final SignUpResponseDto signUpResponseDto = userService.login(signUpRequestDto);
        return ResponseEntity.ok()
                .body(signUpResponseDto);
    }
}

UserService.java

public class UserService {
    ...

    public User login(final SignUpRequestDto signUpRequestDto) {
        final User user = signUpRequestDto.toEntity();
        return userRepository.save(user);
    }
}

장점은 Controller(Presentation 계층) 에서 도메인 노출을 하지 않기 때문에 민감한 비지니스 로직에 대한 안정성이 보장된다.

그러나, DTO는 View와 결합도가 강하여 UI 계층 수정 시 DTO를 수정해야 하는 문제가 발생한다. DTO가 수정된다면 Service 레이어의 수정 또한 필요해진다. 따라서, View의 변경 사항이 Service 레이어까지 영향을 미치게 된다. (즉, Model과 View 사이의 의존성 발생)

2. Controller 레이어에서 변환

아래 코드는 Controller 레이어에서 DTO를 엔티티로 변환하고 있다.

UserController.java

public class UserController {
 	...
    
    @PostMapping("/signUp")
    public ResponseEntity<SignUpResponseDto> signUp(@RequestBody final SignUpRequestDto signUpRequestDto) {
        final User user = userService.login(signUpRequestDto.toEntity());
        return ResponseEntity.ok()
                .body(SignUpResponseDto.from(user));
    }
}

UserService.java

public class UserService {
	...
    
    public User login(final User user) {
        return userRepository.save(user);
    }
}

Controller에서 DTO를 엔티티로 변환한다면 위에서 말한 안정성이 지켜지지 않아 보안 이슈가 발생할 가능성이 있다. 물론 Service 레이어와 View의 의존성이 없어지는 장점이 있다.

이를 요약하면 다음과 같다.

Controller 에서 DTO -> Entity 변환

  • Controller에 도메인이 노출 -> 비지니스 로직을 담당하는건 Service인데?
  • 위험성이 커진다

Service 에서 DTO -> Entity 변환

  • Service 레이어가 View에 의존
  • 유지보수성이 안좋아짐

이 둘 사이의 Trade off 가 존재하는데 팀원끼리 적절히 합의하여 통일성을 유지하는게 좋을 것 같다.

Service와 Controller에서 원하는 포맷은 다르다

참고 : 잊을만 하면 돌아오는 정산 신병들 (우아한 기술 블로그)

위 2가지 방법의 장점을 모두 살릴 수 있는 방법을 고민해보자. 어떻게 하면 Service 레이어와 View의 의존성을 분리하면서 외부로 부터 민감한 비지니스 로직 노출을 피할 수 있을까?

DTO의 계층을 나누고 Controller에서 Service가 원하는 포맷(Service 레이어의 DTO)으로 DTO를 변환하여 전달해보자.

UserController.java

public class UserController {
    ...

    @PostMapping("/signUp")
    public ResponseEntity<SignUpResponseDto> signUp(@RequestBody final SignUpRequestDto signUpRequestDto) {
        final SignUpResponseDto signUpResponseDto = userService.login(signUpRequestDto.toServiceDto());
        return ResponseEntity.ok()
                .body(signUpResponseDto);
    }
}

UserService.java

public class UserService {
	...
	
    public SignUpResponseDto login(final SignUpServiceRequestDto loginServiceRequestDto) {
        return userRepository.save(loginServiceRequestDto.toEntity());
    }
}

이렇게 된다면 Service 레이어와 View의 의존성이 없어지며 도메인이 외부로 노출되지 않게 된다. 다음 프로젝트를 진행한다면 이런 DTO 구조도 고민해보면 좋을 것 같다!

0개의 댓글