개발을 하다 보면 API의 요청이나 응답을 처리할 때 또는 다른 계정으로 넘기는 파라미터가 너무 많은 시점에 별도의 DTO를 생성해야 하나 고민을 하는 시점이 생긴다. 또한 간단한 애플리케이션이 아니고서는 엔티티와 DTO를 분리하는 것을 추천하는데, 이번에는 그 이유에 대해 알아보고자 한다.
엔티티(Entity) 또는 도메인 객체(Domain Object)와 DTO를 분리해야 하는 이유
엔티티는 비즈니스 로직을 포함하는 도메인 엔티티와 데이터베이스 관련 처리를 위한 영속성 엔티티로 나누어질 수 있다. 하지만 아래의 내용에서는 설명의 편의를 위해 2개를 합하여 설명하고자 한다.
엔티티(Entity)와 DTO를 분리해야 하는 이유
- 관심사의 분리
- Validation 로직 및 불필요한 코드 등과의 분리
- API 스펙의 유지
- API 스펙의 파악이 용이
1. 관심사의 분리
관심사의 분리(separation of concerns, SoC)는 소프트웨어 분야의 오랜 원칙 중 하나로써, 서로 다른 관심사들을 분리하여 변경 가능성을 최소화하고, 유연하며 확장가능한 시스템을 만드는 것이다. 엔티티와 DTO를 분리해야 하는 근본적인 이유는 관심사가 다르기 때문이다.
DTO(Data Transfer Object)의 핵심 관심사는 이름 그대로 데이터의 전달이다. DTO는 데이터를 담고, 다른 계층 또는 다른 컴포넌트들로 데이터를 넘겨주기 위한 자료구조(Data Structure)이다. 그러므로 어떠한 기능 및 동작도 없어야 한다.
반면에 엔티티는 핵심 비지니스 로직을 담는 비지니스 도메인의 영역의 일부이다. 그러므로 엔티티 또는 도메인 객체에는 비지니스 로직이 추가될 수 있다. 또한 엔티티 or 도메인 객체는 다른 계층이나 컴포넌트들 사이에서 전달을 위해 사용되는 객체가 아니다.
엔티티와 DTO는 엄연히 서로 다른 관심사를 가지고 있고, 그렇기 때문에 분리하는 것이 합리적이다.
2. Validation 로직 및 불필요한 코드 등과의 분리
Spring에서는 요청 데이터 검증을 위한 @Valid 어노테이션을 지원하고 있다. (@Valid에 대해서 모르면 여기를 참고)
@Valid 처리를 위해서는 @NotNull, @NotEmpty, @Size 등과 같은 어노테이션들을 필드에 붙여주어야 한다. 반면에 JPA도 변수에 @Id, @Column 등과 같은 어노테이션들을 활용해 객체와 관계형 데이터베이스를 매핑해주는데, DTO와 엔티티를 분리하지 않는다면 엔티티의 코드가 상당히 복잡해진다.
예를 들어 만약 엔티티와 DTO를 분리하지 않으면 다음과 같은 복잡한 클래스가 탄생하게 된다.
@Entity
@Table
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Membership {
@NotNull
@Size(min = 0)
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(nullable = false)
private Long id;
@NotNull
@Enumerated(EnumType.STRING)
private MembershipType membershipType;
@NotBlank
@Column(nullable = false)
private String userId;
@NotNull
@Size(min = 0)
@Setter
@Column(nullable = false)
@ColumnDefault("0")
private Integer point;
@CreationTimestamp
@Column(nullable = false, length = 20, updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(length = 20)
private LocalDateTime updatedAt;
}
이렇게 어노테이션들이 중구난방으로 작성되어 있는 엔티티는 직접 겪지 않아도 유지보수가 힘들어짐을 느낄 수 있다. 또한 위의 엔티티 클래스의 생성일자(createdAt)나 수정일자(updatedAt)를 나타내는 변수들은 API 요청 및 응답에서 필요 없을 수 있다. 그렇기 때문에 응답에서 해당 변수를 제거하기 위해서는 @JsonIgnore 등과 같은 또 다른 어노테이션들을 붙여주어야 할 것이다.
이렇게 계속 엔티티 클래스를 사용하게 되면 핵심 비지니스 도메인 코드들이 아닌 요청/응답을 위한 값, 유효성 검증을 위한 코드 등이 추가되면서 엔티티 클래스가 지나치게 비대해질 것이고, 확장 및 유지보수 등에서 매우 어려움을 겪게 될 것이다.
그러므로 이러한 경우에는 별도의 DTO를 생성하여 엔티티로부터 분리하는 것이 바람직할 것이다.
3. API 스펙의 유지
예를 들어 멤버십 조회 API를 제공하고 있고, 다음과 같은 엔티티 클래스를 API의 응답으로 활용하고 있다고 하자.
@Entity
@Table
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Membership {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(nullable = false)
private Long id;
@Enumerated(EnumType.STRING)
private MembershipType membershipType;
@Column(nullable = false)
private String userId;
@Setter
@Column(nullable = false)
@ColumnDefault("0")
private Integer point;
}
그러면 우리는 위의 결과로 다음과 같은 Json 메세지를 받게 될 것이다.
{
"id" : "15",
"membershipType" : "NAVER",
"userId" : "NC10523",
"point" : "10000"
}
그런데 내부 정책의 변경으로 userId를 memberId로 변경해야 하는 상황이라고 하자. 만약 우리가 DTO를 사용하지 않았다면 userId가 memberId로 바뀜에 따라 API의 스펙이 변경되고, 이로 인해 API를 사용하던 사용자들은 모두 장애를 겪게 될 것이다.
물론 @JsonProperty를 이용해 반환되는 값의 이름을 변경할 수 있지만, 이는 결국 Entity를 무겁게 만들어 근본적인 해결책이 될 수 없다. 스펙이 변경되어 테이블에 컬럼이 추가되는 경우도 마찬가지이다. 테이블에 새로운 컬럼이 추가되면 엔티티에 새로운 변수가 추가될 것이고, 별도로 처리를 하지 않는 이상 API 응답이 추가되어 스펙이 변경되게 된다.
그러므로 DTO를 이용해 분리하여 독립성을 높이고 변경이 전파되는 것을 방지해야 한다. 만약 우리가 응답을 위한 DTO 클래스를 활용하고 있으면, Entity 클래스의 변수가 변경되어도 API 스펙이 변경되지 않으므로 안정성을 확보할 수 있다.
4. API 스펙의 파악이 용이
DTO를 사용함으로써 얻는 또 다른 장점은 DTO를 통해 API 스펙을 어느정도 파악할 수 있다는 점이다. 예를 들어 다음과 같은 사용자 등록 API에 대한 Request DTO가 있다고 하자.
@Getter
@RequiredArgsConstructor
public class UserRequestDto {
@Email
@Size(max = 100)
private final String email;
@NotBlank
private final String pw;
@NotNull
private final UserRole userRole;
@Min(12)
private final int age;
}
물론 해당 스펙을 완벽히 파악하는 것은 불가능하겠지만, 꽤나 많은 API 스펙들을 얻을 수 있다. email의 값은 반드시 email 포맷이어야 하고, 최대 글자수가 100이며 pw는 비어있어서는 안되는 등을 파악할 수 있다.
DTO를 작성함으로써 어느 정도 API 문서의 요약본을 작성하는 것과 유사한 효과를 얻을 수 있으며, 특히 요즘 같이 MSA 아키텍처로 개발을 많이 하는 상황에서 다른 사람이 작성한 코드를 파악할 때 요청/응답 스펙을 비교적 손쉽게 파악할 수 있어 용이하다.
결론적으로 위와 같은 이유로 엔티티(Entity) 또는 도메인 객체(Domain Object)와 DTO를 분리하는 것이 좋다.
참고