글 작성자: 자바니또

개요

JPA라는 ORM을 사용하면 흔히 DTO와 Entity 를 분리하라는 말을 들어봤을 것이다. 왜 Entity만으로 해결하면 안될까? DTO는 무엇일까? 이번 포스팅에서는 DTO와 Entity의 개념을 정리하고 DTO는 왜사용해야 하는지, 또 어떤식으로 사용해야 하는지 알아보자. 

목차

  • DTO vs Entity vs VO
  • API에서 Entity 직접 사용의 문제점
  • Entity 대신 DTO 사용
  • DTO와 Entity 사이의 변환
  • DTO의 구조적 효과

Entity vs DTO vs VO

Entity, DTO, VO는 생김새는 비슷해 보이지만 쓰임새와 목적이 분명하게 다르기에 정확하게 구분할 줄 알아야 한다. 

Entity

  • 테이블 데이터를 OOP세계에서 다루기 위해 나온 테이블 데이터 모델링 클래스이자, 비즈니스 객체이다.
  • 테이블 데이터의 PK와 매핑되는 Entity의 필드가 식별자로 사용된다.
  • 비즈니스 로직을 갖고 있어서 OOP 스럽게 코딩을 가능하게 해준다.

DTO

  • Data Transfer Object의 약자로, 계층간의 데이터를 전달하는 목적으로 사용되는 객체이다. 
  • 데이터 전달이 목적이기 때문에 식별자와 비즈니스 로직은 존재하지 않는다.

VO

  • Value Object의 약자로, 흔히 '값 객체'라 부른다. 연관된 데이터를 한곳에 모아 그 자체를 값으로하여 사용하기 위해 만든다. 흔히 사용되는 String 과 Wrapper 클래스(Integer, Long..)이 값 객체이다.
  • 그 자체가 값을 의미하는 객체이기 때문에 보통 모든 필드가 식별자이다. 
  • 값으로 사용되는 객체이기 떄문에 불변객체(Immutable Object) 여야 한다. 

Entity DTO VO
Has Identity Key Field X All Field
Has Business O X O
Has Value O O O

API 에서 Entity 직접 사용의 문제점

 

API의 목적은 무엇인지 생각해보자. API는 클라이언트로부터 정해진 약속대로 구조화된 데이터를 받으면, 정해진 약속대로 구조화된 데이터를 전달해준다. API의 목적은 사용자와 약속을 하여 약속만 지키면 원하는 데이터를 주는 것이 목적이다. 

Entity는 테이블의 데이터를 모델링한 비즈니스 객체이다. 테이블 데이터를 담아서 OOP 스럽게 사용하는 것이 목적이다. 클라이언트는 API 서버 내부에서 어떤 객체가 사용되는지 몰라도 API를 사용할 수 있어야 한다. 

	@GetMapping("/api/v1/orders")
    public List<Order> ordersV1() {
        List<Order> all = orderRepository.findAll();

        // ... logic
        
        return all;
    }

API를 통해 반환된 데이터 예시

주문 정보를 모두 반환해주는 API에서 주문 엔티티(Order)를 직접 사용하고 있다. 클라이언트가 받는 데이터는 Order가 가지고 있는 데이터의 구조대로 받게 된다. 즉, Entity의 모양이 API의 명세가 되어 버린다.

API에서 가장 중요한 것은 명세되어있는 약속이며 자주 바뀌는 것은 사용자 모두에게 영향을 미치기에 좋지 않다. 만약 테이블이 변경되어 Order에 필드가 추가된다면 API의 명세가 변경되어 버리고 이것을 사용하는 클라이언트들에게 영향을 줄 것이다.

Entity 대신 DTO 사용

Entity 를 직접사용할 때 문제가 발생하는 원인은 목적의 차이에 있다. 따라서 API의 명세에 목적을 둔 DTO를 만들고, 클라이언트와 통신할 때는 DTO를 사용함으로써 문제를 해결할 수 있다. 

	@GetMapping("/api/v2/orders")
    public List<OrderResponseDto> ordersV2() {
        List<Order> orders = orderRepository.findAll();

        return orders.stream().map(OrderResponseDto::new).collect(toList());
    }

반환타입이 Entity 리스트에서 DTO 리스트로 변경되었다. 하지만 한가지 아쉬운게 있다. 바로 확장성이다. 만약 리스트안에 있는 내용물의 개수도 같이 클라이언트에게 전송하고 싶다면 List 타입으로는 확장이 불가능하다. 다음과 같이 바꿔서 해결할 수 있다. 

	@GetMapping("/api/v2/orders")
    public Result<List<OrderResponseDto>> ordersV2() {
        List<OrderResponseDto> orders = orderService.findAll()
                .stream()
                .map(OrderResponseDto::new))
                .collect(Collectors.toList());

        return new Result<List<OrderResponseDto>>(orders, orders.size());
    }
    
    @Getter
    @Setter
    private static class Result<T> {
    	private T data;
        private int count;
        
        public Result(T data, int count) {
        	this.data = data;
            this.count = count;
        }
    }

DTO와 Entity 사이의 변환

List<OrderResponseDto> orders = orderService.findAll()
                .stream()
                .map(OrderResponseDto::new))
                .collect(Collectors.toList());

예시에서 사용된 코드이다. java 8부터 도입된 Stream 을 사용하여 직관적이고 빠르게 Order를 OrderResponseDto로 변경하고 있다. OrderResponseDto는 생성자로 Entity를 주입받도록 설계되어 있기에 가능하다. 그렇다면 반대로 클라이언트로부터 받은 DTO를 Entity로 변경할 때는 어떻게 해야할까?

객체지향 설계방법인 SOLID 원칙중 DIP는 잘 변하지 않는 것에 의존하라는 원칙이다. DTO와 Entity중 어느것이 더 자주변하냐 하면 PresentationLayer에서도 사용되는 DTO가 물론 더 자주 변한다. Entity는 DTO보다 row level에서 사용되기 때문에 자주 변하면 안된다. 그렇다면 의존의 방향은 DTO -> Entity 가 맞다. 그래서 DTO는 Entity를 생성자로 그대로 받아도 되는 것이다. 

반대로 DTO에서 Entity로 변환할 때는 Entity 클래스 코드에 DTO가 명시되어 있으면 안된다. 따라서 다음과 같이 코드를 짤수 있다. 

new Order(orderRequestDto.getItemId(), int orderCount);

예를 들기위해 간단하게 만들었다. 중요한 것은 DTO를 주입하는 것이 아니라 Entity타입보다 더 row-level인 원시타입(premitive type)으로 분해하여 전달했다는 것이다.