Spring을 이용한 프로젝트를 진행하던 중에 ModelMapper
를 사용하게 되어 이에 대해 한 번 정리해보려 합니다.
ModelMapper
는
어떠한 Object에 있는 필드 값들을 원하는 Object에 자동으로 mapping 시켜주는 라이브러리이다.
즉, Source Object → Destination Object 를 자동으로 해준다고 생각하면 된다.
그렇다면, 어떤 경우에 ModelMapper
를 사용할까?
보통 DTO를 통해 데이터를 받아 다른 Object(엔티티 등...)에 넣거나 이와 반대로 객체를 DTO로 변환할 때, getter와 setter를 이용해 원하는 필드들을 일일이 넣는다. 아니면, of
와 from
이라는 정적 메서드(static method)를 만들어 사용한다.
이 작업들은 솔직히 귀찮은 일이고, 실수도 발생할 수 있다. 번거로운게 제일 큰 것 같다...
이러한 단점을 해결해줄 수 있는 것이 바로 ModelMapper
라이브러리이다!
우선 ModelMapper
를 사용하기 위해 dependency를 추가해주자!
Maven 프로젝트의 경우, pom.xml에 다음 dependency를 추가하자.
<dependency>
<groupId>org.modelmapper</groupId>
<artifactId>modelmapper</artifactId>
<version>3.0.0</version>
</dependency>
Gradle 프로젝트의 경우, build.gradle에 다음 dependency를 추가하자.
dependencies{
...
implementation 'org.modelmapper:modelmapper:3.0.0'
...
}
매핑 예시를 설명하기 위해 아래와 같은 클래스들을 선언해주자.
Source Model
// 각 클래스에 Getter와 Setter, Constructor가 구현되어 있다고 가정하자.
class Order {
Customer customer;
Address billingAddress;
}
class Customer {
Name name;
}
class Name {
String firstName;
String lastName;
}
class Address {
String street;
String city;
}
Destination Model
// Getter와 Setter, Constructor가 구현되어 있다고 가정하자.
class OrderDTO {
String customerFirstName;
String customerLastName;
String billingStreet;
String billingCity;
}
간단하게 다음과 같이 사용할 수 있다.
ModelMapper.map(Object source, Class<D> destinationType)
이를 이용해 order
인스턴스를 orderDTO
에 mapping 시켜보자.
// Class에 값 넣기
Order order = new Order(
new Customer(new Name("LastName", "FirstName")),
new Address("Street", "City")
);
// ModelMapper 선언
ModelMapper mapper = new ModelMapper();
// mapping 하기
OrderDTO orderDto = mapper.map(order, OrderDTO.class);
실행해보면, 위와 같이 연관관계를 자동으로 판단해 필드값들이 mapping된 결과를 확인할 수 있다.
이처럼, ModelMapper
에서는 map(source, destination)
메서드가 호출되면 source
와 destination
의 타입을 분석해 매칭 전략이나 기타 설정값들에 따라 일치하는 속성을 결정하게 된다.
위의 예시처럼, source
와 destination
의 객체 타입이나 프로퍼티가 다른 경우에도 ModelMapper
는 설정된 매칭 전략에 따라 최선의 mapping 과정을 수행한다.
하지만, ModelMapper
는 source
및 destination
을 암묵적으로 일치시키기 위해 최선을 다함에도 속성 간의 매핑을 명시적으로 정의해야 하는 경우도 있다.
아래에서 이와 관련된 내용을 좀 더 자세하게 예시를 통해 알아보자.
아래와 같은 객체 매핑이 필요한 경우를 예시로 들어보자.
// Getter와 Setter, Constructor가 구현되어 있다고 가정하자.
class Item {
private String name;
private Integer stock;
private Integer price;
private Boolean sale;
}
class Bill {
private String itemName;
private Integer qty;
private Integer singlePrice;
private Double discount;
}
Item item = new Item("item", 10, 1500, true);
Bill bill = mapper.map(item, Bill.class);
처음의 예제보다 간단해 보이지만, mapper.map()
의 결과는 다음과 같다.
ModelMapper의 기본 매칭 전략으로는 모호한 연관 관계들이 매핑되지 않는다.
이를 해결하기 위해, TypeMap
을 제공한다.
TypeMap
인터페이스를 구현해ModelMapper
객체의 매핑 관계를 설정해줄 수 있다.
사용법은 아래와 같다.
TypeMap<BaseScr, BaseDest> typeMap =
modelMapper.createTypeMap(BaseScr.class, BaseDest.class)
.addMapping(BaseScr::getFirstName, BaseDest::setName);
typeMap.include(ScrA.class, DesTA.class);
addMapping()
메서드를 이용하여 source
의 getter
와 destination
의 setter
를 연결시켜주면 된다.
그럼 이를 이용해 위의Item
과 Bill
을 mapping 시켜보자.
우리가 원하는 매핑 전략은 다음과 같다.
Item.stock
→ Bill.qty
Item.price
→ Bill.singlePrice
Item.sale
→ Bill.discount
수량
과 가격
의 경우는 아래와 같이 메서드 레퍼런스를 통해 간단히 설정이 가능하다.
mapper.typeMap(Item.class, Bill.class).addMappings(m -> {
m.map(Item::getStock, Bill::setQty);
m.map(Item::getPrice, Bill::setSinglePrice);
});
Bill bill2 = mapper.map(item, Bill.class);
위의 결과에서 볼 수 있듯 임의로 설정한 수량
과 가격
의 매핑 관계가 정상적으로 적용된 것을 확인할 수 있다.
하지만, Item.sale
과 Bill.discount
와 같이 클래스 타입이 다른 경우에는 추가적인 방법이 필요하다.
매핑하고자 하는 source
와 destination
의 타입이 다른 경우, Converter
인터페이스를 사용해 값을 설정해줄 수 있다.
위의 예제에서 Item.sale == true
일 경우에 Bill.discount
값을 30.0으로 설정한다고 가정하자.
mapper.using(Converter<S, D>)
의 패턴을 이용하면 유연한 타입 변환이 가능하다.
mapper.typeMap(Item.class, Bill.class).addMappings(m -> {
m.map(Item::getStock, Bill::setQty);
m.map(Item::getPrice, Bill::setSinglePrice);
m.using((Converter<Boolean, Double>) ctx -> ctx.getSource() ? 30.0 : 0.0)
.map(Item::isSale, Bill::setDiscount);
});
Bill bill2 = mapper.map(item, Bill.class);
실행 결과는 아래와 같다.
Converter
를 이용해 정상적으로 타입이 변환된 것을 확인할 수 있다.
매핑을 원하지 않을 경우 skip
을 통해 매핑이 이루어지지 않도록 설정할 수도 있다.
아래와 같이 사용할 수 있다.
typeMap.addMapping(mapper -> mapper.skip(Destination::setName));
위의 예시에서는 아래와 같이 사용할 수 있다.
mapper.typeMap(Item.class, Bill.class).addMappings(m -> {
m.map(Item::getStock, Bill::setQty);
m.map(Item::getPrice, Bill::setSinglePrice);
m.using((Converter<Boolean, Double>) ctx -> ctx.getSource() ? 30.0 : 0.0)
.map(Item::isSale, Bill::setDiscount);
m.skip(Bill::setItemName);
});
객체에 새로운 값들을 한꺼번에 업데이트해줄 때, ModelMapper
의 기본 매칭 전략을 사용하면 null 값까지 함께 업데이트되는 문제가 발생하기 때문에 이를 위해 매핑 설정을 할 수 있다.
ModelMapper mapper = new ModelMapper();
mapper.getConfiguration().setSkipNullEnabled(true);
ModelMapper
는 기본적으로 매칭 전략에 맞지 않는 속성들은 null 값으로 초기화한다. 이때 개발하는 입장에선 어떤 객체에 대해 모든 속성이 올바르게 매핑되었는지 검증이 필요할 때가 있다.
이때 ModelMapper().validate()
를 이용해 매핑 검증이 실패하는 경우 예외 처리를 해주기 때문에 추가적인 Exception Handling이 가능하다.
...
try{
modelMapper.validate();
} catch(ValidationException e){
// Exception Handling
}
...
앞서 설정한 여러 가지 조건들에 의해 ModelMapper
는 지능적인 Object Mapping을 수행한다.
아래와 같이 STANDARD
, LOOSE
, STRICT
전략이 있다.
modelMapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STANDARD);
modelMapper.getConfiguration().setMatchingStrategy(MatchingStrategies.LOOSE);
modelMapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STRICT);
가장 기본적인 매칭 전략으로써, STANDARD
전략을 사용하면 source
와 destination
의 속성들을 지능적으로 매칭시킬 수 있다.
destination
속성 이름 토큰이 일치해야 한다.source
속성 이름은 일치하는 토큰이 하나 이상 있어야 한다. 위 조건들을 충족하지 못하는 경우 매칭에 실패하여 null을 반환한다.
느슨한 매칭 전략으로, LOOSE
전략을 사용하면 계층 구조의 마지막 destination
속성만 일치하도록 요구하여 source
와 destination
을 느슨하게 매치시킬 수 있다.
destination
속성 이름에는 모든 토큰이 일치해야 한다.source
속성 이름은 일치하는 토큰이 하나 이상 있어야 한다. LOOSE
전략은 속성 계층 구조가 매우 다른 source
, destination
객체에 사용하는데에 이상적이다.
→ 처음 예시의 Order
, OrderDTO
와 같이 객체의 속성이 계층 구조를 가지는 경우에 효과적이다.
엄격한 매칭 전략으로써, STRICT
전략을 사용하면 source
속성을 destination
속성과 엄격하게 일치시킬 수 있다. 따라서 불일치나 모호성이 발생하지 않도록 완벽한 일치 정확도를 얻을 수 있다. 이때 source
와 destination
의 속성 이름들이 서로 정확하게 일치해야 한다.
destination
속성 이름 토큰이 일치해야 한다.source
속성 이름에는 모든 토큰이 일치해야 한다.STRICT
전략을 통해 앞서 다룬 TypeMap
을 사용하지 않고도 모호함이나 예기치 않은 매핑이 발생하지 않도록 하는 경우에 간편하게 사용이 가능하다. 하지만, 반드시 매칭되어야 하는 속성의 이름들이 서로 정확하게 일치해야 한다!