DataTransferObject : 데이터 전송 객체
Entity : 도메인
여러분은 DTO를 어떻게 활용하고 계신가요?
저는 스프링 부트를 사용하여 개발의 생산성을 충분히 느끼면서 개발을 하고 있습니다. 이 중 API 통신에 있어서 가장 편리한 ArgumentResolver가 Jackson 라이브러리로 JSON 데이터를 Object에 매핑해주는 기능을 정말 잘 사용하고 있습니다.
우리가 사용하는 엔티티는 도메인의 한 부분으로 JPA를 사용하신다면 데이터베이스와 직접적으로 연결되어있습니다.
이 말은 데이터베이스가 변경되지 않는 이상 엔티티는 변경에 꽉 닫혀있어야 함을 말하고 있습니다.
그래서 우리는 외부에서 들어오는 여러 데이터를 엔티티로 직접 검증하고 받지 않습니다.
그 이유는 프레젠테이션 계층은 변경에 매우 취약하다는 약점이 있기 때문이죠.
그래서 우리는 DTO로 외부 데이터를 검증하고 검증된 값을 엔티티에 맵핑하는 과정을 진행합니다.
최근 작업을 하다가 갑자기 싸해지는 기분이 들었습니다.
엔티티의 정적 생성 팩토리 메서드를 보았는데요. 다음과 같았습니다.
public static Item create(ItemForm itemForm) {
Item item = new Item();
item.name = itemForm.getName();
return item;
}
데모 코드이지만 실무의 Item은 상당히 많은 필드를 가지고 있죠.
그리고 오늘 ItemForm DTO의 수많은 변경이 이뤄졌습니다.
그리고 오늘 지옥을 맛봤죠.
왜! 내가 DTO를 사용해 엔티티와 프레젠테이션 계층을 분리했는데, DTO를 엔티티에 끌고 와서 이 난리를 쳤을까?
실수를 깨닫고 곰곰이 생각해보았습니다.
매핑 작업은 필요하다. 그럼 매핑하는 주체가 누가 되어야 할까?
사실 이는 클라이언트 코드에서의 읽기 쉬운 코드는 다음과 같습니다.
Item item = Item.create(itemForm);
Item을 생성하는 책임은 Item이 가지고 있습니다. 하지만 곰곰이 생각해보면 정적 팩토리 생성 메서드를 제공한다는 의미가 무엇일까 다시 한번 고민해봤습니다.
싱글톤으로 엔티티를 관리할 것도 아닌데?
정적 팩토리 생성 메서드를 제공하는 것은 생성하는 로직을 한 곳으로 모아 관리하기 위함이며 이를 통해 기본 생성자를 통해 코드 구석구석에서 생성할 수 없이 막는 통일성을 부여할 수 있다.
팩토리 자체가 아이템 엔티티와 일치하는가? 아이템은 누구로 부터 생성되는 것이지?
이 경우에 저는 DTO라고 결론내렸습니다.
따라서 엔티티의 생성을 위한 필드를 가지고 있으며 생성을 책임져야 하는 곳 바로 DTO 였습니다.
여기서 우리는 빌더 패턴을 적용할 수 있습니다.
@Builder
private Item(String name) {
this.name = name;
}
이때 클래스 레벨이 아닌 필요한 매개변수 선언과 private 생성자에 lombok에 어노테이션을 사용하면 필요한 필드만 정적 생성자를 사용한 빌더 패턴으로 기본 생성자도 막고 private 하게 엔티티를 생성할 수 있습니다.
이때 DTO는 대게 컨트롤러에서 DTO의 객체가 생성된 채로 받을 수 있기 때문에 인스턴스의 메서드로 정의하여 엔티티로 변환할 수 있다는 것도 장점이 될 수 있겠네요.
정리하면 다음과 같다.
첫 번째는 서버에서 전달되는 데이터를 받을 때 DTO로 받지 않고 엔티티로 파싱 하여 사용하고 있다면 DTO를 활용하자.
이로 인해 엔티티를 변경에 영향을 최소화할 수 있다.
두 번째는 DTO는 사용하는데 엔티티가 DTO를 알 필요가 없다. 따라서 DTO에게 엔티티 생성 책임을 전달하자.!
세 번째는 DTO가 엔티티를 생성함에 따라 setter와 public한 생성자를 노출해야 하는데, 이를 방지하기 위해 빌더 패턴을 적용하되 필요한 필드 값과 private 생성자를 빌더 패턴으로 구현하자.
예제 코드를 다음과 같이 설정했다.
내 경우는 DTO에서 연관관계를 전부 설정할 수 있어 다음과 같이 진행했지만 만약 별도의 작업 이후 연관관계를 설정한다면 Item을 리턴 받은 이후 설정하도록 코드 작성을 할 것 같다.
실제 lombok을 사용하여 하는 경우 나는 컴파일된 결과를 한 번씩 보는 편이다.
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
package com.login.flutter.domain;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import javax.persistence.OneToMany;
public class Item {
private String name;
private String id;
@OneToMany(
mappedBy = "item"
)
private List<ItemType> itemTypes;
private Item(String name, List<ItemType> itemTypes) {
this.name = name;
itemTypes.forEach((itemType) -> {
itemType.addItem(this);
});
this.itemTypes = itemTypes;
this.id = "IDENTITY 를 사용할 경우 NULL 값이 들어오는데 대신 의미있는 값으로 대체 가능";
}
public static ItemBuilder builder() {
return new ItemBuilder();
}
public String toString() {
String var10000 = this.getName();
return "Item(name=" + var10000 + ", id=" + this.getId() + ", itemTypes=" + this.getItemTypes() + ")";
}
public String getName() {
return this.name;
}
public String getId() {
return this.id;
}
public List<ItemType> getItemTypes() {
return this.itemTypes;
}
public static class ItemBuilder {
private String name;
private ArrayList<ItemType> itemTypes;
ItemBuilder() {
}
public ItemBuilder name(final String name) {
this.name = name;
return this;
}
public ItemBuilder itemType(final ItemType itemType) {
if (this.itemTypes == null) {
this.itemTypes = new ArrayList();
}
this.itemTypes.add(itemType);
return this;
}
public ItemBuilder itemTypes(final Collection<? extends ItemType> itemTypes) {
if (itemTypes == null) {
throw new NullPointerException("itemTypes cannot be null");
} else {
if (this.itemTypes == null) {
this.itemTypes = new ArrayList();
}
this.itemTypes.addAll(itemTypes);
return this;
}
}
public ItemBuilder clearItemTypes() {
if (this.itemTypes != null) {
this.itemTypes.clear();
}
return this;
}
public Item build() {
List itemTypes;
switch (this.itemTypes == null ? 0 : this.itemTypes.size()) {
case 0:
itemTypes = Collections.emptyList();
break;
case 1:
itemTypes = Collections.singletonList((ItemType)this.itemTypes.get(0));
break;
default:
itemTypes = Collections.unmodifiableList(new ArrayList(this.itemTypes));
}
return new Item(this.name, itemTypes);
}
public String toString() {
return "Item.ItemBuilder(name=" + this.name + ", itemTypes=" + this.itemTypes + ")";
}
}
}
이 컴파일된 코드를 보면 lombok이 정말 많은 코드를 줄여주는 것을 알 수 있다.
하지만 주목해야 하는 점이 있다.
실제 build()하는 과정의 리스트를 저장할 때 Collections.unmodifiableList(new ArrayList(this.itemsTypes)); 를 사용하는 것을 볼 수 있다.
이는 확실하게 Immutable 한 리스트를 반환한다.
따라서 빌드된 이후에 Item에 itemType을 추가할 때 java.lang.UnsupportedOperationException이 발생한다.
나의 경우에는 생성 시점에 모든 세팅을 전부 진행하는 것이 생성 코드를 한 곳으로 모아주는 효과가 있기 때문에 다음과 같이 빌더 패턴으로 사용한 것이다.
만약에 생성 이후 추가적으로 컬렉션에 값을 추가해야 한다면 다른 방법을 생각해야 한다.
빌더 패턴을 사용해 해당 컬렉션을 추가하지 말고 별도의 연관관계 메서드를 제공하여 컬렉션에 값을 추가하는 로직을 작성해야 할 것이다.
그리고 해당 필드는 private 생성자에서 제외해야 한다.
컴파일된 코드를 보면 생성자 필드에 컬렉션이 존재하고 해당 컬렉션이 없는 경우 Collections.emptyList()를 반환하는 것을 볼 수 있는데,
이 메서드도 변경 불가능한 컬렉션을 반환하기 때문에 해당 컬렉션 필드는 다음과 같이 필드에서 초기화를 진행하고 별도로 작업해야 한다.!
private List<ItemType> itemTypes = new ArrayList<>();
public void addItemType(ItemType itemType) {
itemType.setItem(this);
itemTypes.add(itemType);
}
'데이터 접근 기술 > JPA' 카테고리의 다른 글
@Converter, Enum (0) | 2023.07.07 |
---|---|
Hibernate 5 Bootstrapping API (0) | 2023.02.01 |
상속관계 매핑 (0) | 2022.12.04 |
값 타입 (0) | 2022.11.20 |
OSIV (0) | 2022.10.31 |
댓글