MapStruct란?
- 어노테이션 기반으로 작성되며 Bean으로 등록할 수 있어 여러 프레임워크의 DI를 활용하여 사용할 수도 있다.
- 타입 세이프하게 객체의 타입 변환 시에 데이터 매핑을 도와주는 어노테이션 프로세서
- 서버 어플리케이션을 개발할 때 작업하는 DTO 변환 작업은 대부분이 반복적인 작업이 대부분
- 도메인 객체를 풍부하게 사용하면서, 반환 데이터가 달라지게 될 경우 이를 적절하고 큰 힘을 들이지 않고 매핑할 수 있도록 도와주는 것이 MapStruct
- 리플렉션이 아닌 직접 메소드를 호출하는 방식으로 동작하여 속도가 빠름
- 컴파일 시점에 매핑 정보가 타입 세이프한지를 검증함.
- 빌드 타임에 매핑이 올바르지 않다면 에러 정보를 log에 띄워줌
SetUp
dependencies{
implementation 'org.mapstruct:mapstruct:1.4.2.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.4.2.Final'
}
Mapper 정의하기
1. 기본적인 매핑 방법
- Mapper를 정의하는 가장 단순한 방법으로는 자바 인터페이스에 매핑 메소드를 정의하고 org.mapstruct.Mapper 어노테이션을 붙이면 된다.
@Mapper
public interface CarMapper{
@Mapping(source = "make", target = "manufacturer")
@Mapping(source = "numberOfSeats", target = "seatCount")
CarDto carToCarDto(Car car);
@Mapping(source = "name", target = "fullName")
PersonDto personToPersonDto(Person person);
}
- @Mapper가 붙은 인터페이스는 MapStruct Code Generator가 해당 인터페이스의 구현체를 생성해준다.
- 구현체 생성 시 source가 되는 클래스와 target이 되는 클래스의 속성명을 비교하고 자동으로 매핑 코드를 작성한다.
- 매핑될 속성명이 다를 경우 @Mapping 어노테이션을 통해 매핑정보를 맞춰준다.
위와 같은 Mapper를 생성했다면 MapStruct는 아래와 같은 구현체를 자동으로 생성해준다.
// GENERATED CODE
public class CarMapper implements CarMapper{
@Override
public CarDto carToCarDto(Car car){
if( car == null ) {
return null;
}
CarDto carDto = new CarDto();
if ( car.getFeatures() != null ){
carDto.setFeatures( new ArrayList<String> car.getFeatures() );
}
carDto.setManufacturer(car.getMake());
carDto.setSeatCount(car.getNumberOfSeats());
carDto.setDriver(personToPersonDto(car.getDriver()));
carDto.setPrice(String.valueOf(car.getPrice()));
if ( car.getCategory() != null ){
carDto.setCategory( car.getCategory().toString() );
}
carDto.setEngine( engineToEngineDto( car.getEngine() ) );
return carDto;
}
@Override
public PersonDto personDto(Person person){
//...
}
private EngineDto engineToEngineDto(Engine engine){
if ( engine == null ){
return null;
}
EngineDto engineDto = new EngineDto();
engineDto.setHorsePower(engine.getHorsePower());
engineDto.setFuel(engine.getFuel());
return engineDto;
}
}
2. 여러 개의 soruce 파라미터로 매핑 메소드 작성
- MapStruct는 파라미터가 여러 개인 경우에도 기능을 지원한다. 여러 엔티티들을 합칠 때 용이하다.
@Mapper
public interface AddressMapper {
@Mapping(source = "person.description", target = "description")
@Mapping(source = "address.houseNo", target = "houserNumber")
DeliveryAddressDto personAndAddressToDeliveryAddressDto(Person person, Address address);
}
- 각 송성들은 이름을 비교하여 자동으로 매핑된다.
- source들의 속성명이 겹치는 경우 @Mapping 어노테이션을 통해 어느 source의 속성을 매핑할 것인지 명시해줘야 한다.
아래와 같이 source로 주어진 파라미터를 직접적으로 target의 속성에 매핑할 수도 있다.
@Mapper
public interface AddressMapper {
@Mapping(source = "person.description", target = "description")
@Mapping(source = "hn", target = "houseNumber")
DeliveryAddressDto personAndAddressToDeliveryAddressDto(Person person, Integer hn);
}
source에 포함된 bean 속성을 target에 매핑하는법
@Mapper
public interface CustomerMapper {
@Mapping(target = "name", source = "recored.name")
@Mapping(target = ".", source = "record")
@Mapping(target = ".", source = "account")
Customer customerDtoToCustomer(CustomerDto customerDto);
}
- "."는 target의 this를 의미한다.
- CustomerDto.record와 CustomerDto.account를 매핑할 source로 지정한다.
- record와 account에 매핑할 속성명이 겹치는 경우에는 @Mapping 어노테이션으로 명시하여 지정해준다. 만약 record와 account가 name이라는 이름의 속성을 둘 다 갖고 있다면 첫번째 @Mapping 어노테이션처럼 어느 source의 name을 사용할 건지 명시해준다.
3. Mapper에 커스텀 메소드 작성
- MapStruct가 자동으로 생성하는 매핑 코드를 불가피하게 쓰지 못하는 경우가 있다. 이런 경우에 아래 두 가지 방법으로 Mapper에 커스텀 메소드를 작성하여 사용할 수 있게 해준다.
1) 인터페이스에 default 메소드로 커스텀 매핑을 추가하는 방법
@Mapper
public interface CarMapper {
@Mapping(...)
CarDto carToCarDto(Car car);
default PersonDto personToPersonDto(Person person){
//hand-written mapping logic
}
}
2) Mapper를 추상 클래스로 정의하는 방법
@Mapper
public abstract class CarMapper{
@Mapping(...)
public abstract CarDto carToCarDto(Car car);
public PersonDto personToPersonDto(Person person){
//hand-written mapping logic
}
}
4. MapStruct로 target을 인스턴스로 받아 업데이트
- MapStruct는 source를 인자로 받아 target으로 변환하여 반환하는 기능이 주 기능이지만 객체를 target으로 하여 업데이트하는 기능도 지원한다.
@Mapper
public interface CarMapper{
void updateCarFromDto(CarDto carDto, @MappingTarget Car car);
}
- carDto와 car를 매핑하여 car의 속성을 carDto의 속성으로 업데이트해준다.
- 기본적인 매핑 방법은 MapStruct가 제공하는 기존 매핑 방법들과 동일하다.
- 리턴 타입을 void 대신에 target 파라미터로 변경하는 것도 가능, 이 경우엔 업데이트된 속성들을 가지고 새로 생성된 target 객체가 반환된다.
- 업데이트 시 Collection 타입의 속성에 대해서는 아래의 같은 Strategy들을 제공한다.
- CollectionMappingStrategy.ACCESSOR_ONLY: target 객체의 컬렉션 객체가 clear되고 source의 컬렉션으로 업데이트 한다.
- CollectionMappingStrategy.ADDER_PREFERRED or CollectionMappingStrategy.TARGET_IMMUTABLE: 기존의 target 객체의 컬렉션 객체를 유지한채로 새로운 데이터를 추가하여 update한다.
5. Mapper의 Builder 사용
- MapStruct 사용시 target이 immutable한 클래스라면 Builder를 사용하여 매퍼가 구현된다.
! immutable한 클래스 -> 필드에 직접 접근할 수도 없고 Setter도 정의하지 않아 임의로 필드를 변경할 수 없게 설계된 클래스
target이 되는 Immutable한 Person 클래스
public class Person{
private final String name;
protected Person(Person.Builder builder){
this.name = builder.name;
}
public static Person.Builder builder(){
return new Person.Builder();
}
public static class Builder {
private String name;
public Builder name(String name){
this.name = name;
return this;
}
public Person create(){
return new Person(this);
}
}
}
Builder를 사용한 Mapper 구현체 예시
@Mapper
public interface PersonMapper{
Person map(PersonDto dto);
}
// GENERATED CODE
public class PersonMapperImpl implements PersonMapper{
public Person map(PersonDto dto){
if(dto == null){
return null;
}
Person.Builder builder = Person.builder();
builder.name(dto.getName());
return builder.create();
}
}
Mapper의 생성자 사용
- MapStruct가 Mapper를 구현할 때 Builder가 있는 지부터 체크한다. 만약 Builder가 정의되어 있지 않다면 생성자를 사용하도록 기본적으로 설계되어 있다. 생성자가 여러개 있는 경우에 MapStruct는 아래와 같은 우선순위로 사용할 생성자를 찾는다.
- @Default이 붙어있는 생성자를 사용
- public 레벨의 생성자가 하나만 존재하는 경우 해당 생성자를 사용
- 파라미터가 없는 기본 생성자를 사용
만약 파라미터가 없는 기본 생성자 없이 여러 생성자가 존재할 경우 MapStruct는 어느 생성자를 사용할 지 판단할 수 없게된다. 이 경우에 컴파일 에러를 발생시킴
public class Vehicle {
protected Vehicle() { }
// MapStruct will use this constructor, because it is a single public constructor
public Vehicle(String color) { }
}
public class Car {
// MapStruct will use this constructor, because it is a parameterless empty constructor
public Car() { }
public Car(String make, String color) { }
}
public class Truck {
public Truck() { }
// MapStruct will use this constructor, because it is annotated with @Default
@Default
public Truck(String make, String color) { }
}
public class Van {
// There will be a compilation error when using this class because MapStruct cannot pick a constructor
public Van(String make) { }
public Van(String make, String color) { }
}
Mapper 사용하기
MapStruct가 생성한 구현체를 사용하기 위해선 두 가지 방식이 존재한다.
- Mapper Factory 사용
- Dependency Injection 사용
Mapper Factory 사용
CarMapper mapper = Mappers.getMapper(CarMapper.class);
위와 같이 org.mapstruct.factory.Mappers 클래스를 사용하면 생성된 Mapper 인스턴스를 받아와 사용할 수 있다. MapStruct는 이 경우에 아래와 같은 관례로 작성하여 사용하길 권장
- 인터페이스 매퍼에 인스턴스를 선언
@Mapper
public interface CarMapper{
CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);
CarDto carToCarDto(Car car);
}
Car car = ...;
CarDto dto = CarMapper.INSTANCE.carToCarDto(car);
Dependency Injection 사용
DI를 지원하는 프레임워크를 사용한다면 MapStruct로 구현된 매퍼들도 DI를 통해 사용할 수 있다.
- cdi: 매퍼를 application-scoped CDI bean으로 생성하며 @Inject를 통해 사용할 수 있다
- spring: 매퍼를 스프링 빈으로 생성하며 @Autowired를 통해 사용할 수 있다
- jsr330: @javax.inject.Named, @Singleton 어노테이션을 매퍼에 붙여 빈을 생성하며 @Inject를 통해 사용할 수 있다
Spring 예제
@Mapper(componentModel = "spring")
public interface CarMapper{
CarDto carToCarDto(Car car);
}
- componentModel 속성에 String으로 위의 내용들(cdi, spring, jsr330) 중 하나를 주입하여 각 프레임워크에 맞는 빈을 생성해줌.
위와 같이 매퍼를 구성하면 Spring Context에 빈이 등록되기 때문에 아래와 같이 사용할 수 있다.
@Autowired
private CarMapper mapper;
@RequiredArgsConstructor
public class CarService{
private final CarMapper carMapper;
}