반복적인 DTO 변환 작업을 한 번에 정의! MapStruct 기본 정리
MapStruct 1.4.2.Final Reference Guide를 기준으로 작성되었습니다.
Introduction
MapStruct는 객체의 타입 변환 시에 유용하게 사용할 수 있는 라이브러리이다.
어노테이션 기반으로 작성되며 Bean으로 등록할 수 있어 여러 프레임워크의 DI를 활용하여 사용할 수도 있다.
- 타입 세이프하게 객체의 타입 변환 시에 데이터 매핑을 도와주는 어노테이션 프로세서
- 서버 어플리케이션을 개발할 때 작업하는 DTO 변환 작업은 대부분이 반복적인 작업이 대부분
- 도메인 객체를 풍부하게 사용하면서, 반환 데이터가 달라지게 될 경우 이를 적절하고 큰 힘을 들이지 않고 매핑할 수 있도록 도와주는 것이 바로 MapStruct
- 리플렉션이 아닌 직접 메소드를 호출하는 방식으로 동작하여 속도가 빠름
- 컴파일 시점에 매핑 정보가 타입 세이프한 지를 검증함
- 빌드 타임에 매핑이 올바르지 않다면 에러 정보를 log에 띄워줌
Setup
MapStruct를 사용하기 위해선 아래 두 가지의 artifact를 추가해줘야 한다.
- org.mapstruct:mapstruct: MapStruct가 제공하는 필수적인 어노테이션들을 포함함
- org.mapstruct:mapstruct-processor: MapStruct가 제공하는 어노테이션으로 짜여진 클래스들을 처리하는 프로세서들을 포함한다. MapStruct가 제공하는 어노테이션 기반으로 작성된 클래스들의 구현체를 생성하는 역할을 함
필자는 Maven을 주로 사용한다. 다른 빌드툴들의 세팅방법은 공식문서를 살펴보는 것을 추천함!
pom.xml에 다음과 같이 구성해주면 MapStruct를 사용할 수 있다.
...
<properties>
<org.mapstruct.version>1.4.2.Final</org.mapstruct.version>
</properties>
...
<dependencies>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
</dependencies>
...
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
...
Mapper 정의하기
기본적인 매핑 방법
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가 해당 인터페이스의 구현체를 생성해준다.- 구현체 생성 시 soruce가 되는 클래스와 target이 되는 클래스의 속성명을 비교하고 자동으로 매핑 코드를 작성한다.
- 매핑될 속성명이 다를 경우
@Mapping
어노테이션을 통해 매핑정보를 맞춰준다.
위와 같은 Mapper를 생성했다면 MapStruct는 아래와 같은 구현체를 자동으로 생성해준다.
// GENERATED CODE
public class CarMapperImpl 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 personToPersonDto(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;
}
}
다양한 매핑 메소드 작성 방법
여러 개의 soruce 파라미터로 매핑 메소드 작성
MapStruct는 파라미터가 여러 개인 경우에도 기능을 지원한다. 여러 엔티티들을 합칠 때 용이하다.
@Mapper
public interface AddressMapper {
@Mapping(source = "person.description", target = "description")
@Mapping(source = "address.houseNo", target = "houseNumber")
DeliveryAddressDto personAndAddressToDeliveryAddressDto(Person person, Address address);
}
- 각 속성들은 이름을 비교하여 자동으로 매핑된다.
- source들의 속성명이 겹치는 경우
@Mapping
어노테이션을 통해 어느 source의 속성을 매핑할 것인지 명시해줘야 한다.
아래와 같이 soruce로 주어진 파라미터를 직접적으로 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
publicinterfaceCustomerMapper {
@Mapping( target = "name", source = "record.name" )
@Mapping( target = ".", source = "record" )
@Mapping( target = ".", source = "account" )
Customer customerDtoToCustomer(CustomerDto customerDto);
}
- "."는 target의 this를 의미한다.
CustomerDto.record
와CustomerDto.account
를 매핑할 soruce로 지정한다.- record와 account에 매핑할 속성명이 겹치는 경우에는
@Mapping
어노테이션으로 명시하여 지정해준다.- 만약 record와 account가 name이라는 이름의 속성을 둘 다 갖고 있다면 첫번째
@Mapping
어노테이션처럼 어느 soruce의 name을 사용할 건지 명시해준다.
- 만약 record와 account가 name이라는 이름의 속성을 둘 다 갖고 있다면 첫번째
Mapper에 커스텀 메소드 작성
MapStruct가 자동으로 생성하는 매핑 코드를 불가피하게 쓰지 못하는 경우가 있다. 이런 경우에 애아래 두 가지 방법으로 Mapper에 커스텀 메소드를 작성하여 사용할 수 있게 해준다.
인터페이스에 default 메소드로 커스텀 매핑을 추가하는 방법
@Mapper
public interface CarMapper {
@Mapping(...)
...
CarDto carToCarDto(Car car);
default PersonDto personToPersonDto(Person person) {
//hand-written mapping logic
}
}
Mapper를 추상 클래스로 정의하는 방법
@Mapper
public abstract class CarMapper {
@Mapping(...)
...
public abstract CarDto carToCarDto(Car car);
public PersonDto personToPersonDto(Person person) {
//hand-written mapping logic
}
}
MapStruct로 target을 인스턴스로 받아 업데이트
MapStruct는 soruce를 인자로 받아 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되고 soruce의 컬렉션으로 업데이트한다.CollectionMappingStrategy.ADDER_PREFERRED
orCollectionMappingStrategy.TARGET_IMMUTABLE
: 기존의 target 객체의 컬렉션 객체를 유지한채로 새로운 데이터를 추가하여 update한다- 컬렉션 매핑에 대한 더 자세한 내용은 링크를 참고!
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);
}
- 추상 클래스 매퍼에 인스턴스를 선언
@Mapper
public abstract class CarMapper {
public static final CarMapper INSTANCE = Mappers.getMapper( CarMapper.class );
CarDto carToCarDto(Car car);
}
이 패턴을 적용하면 mapper를 사용하기 위해 인스턴스를 새로 생성할 필요 없이 싱글톤으로 생성된 인스턴스를 사용할 수 있다.
Car car = ...;
CarDto dto = CarMapper.INSTANCE.carToCarDto( car );
Dependency Injection 사용
DI를 지원하는 프레임워크를 사용한다면 MapStruct로 구현된 매퍼들도 DI를 통해 사용할 수 있다! 이를 지원한다니 정말 사랑스럽다..
필자는 Spring을 사용하는데, 너무 고마운 기능이다. Spring을 포함한 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 사용
@Autowired
private CarMapper mapper;
- 생성자 인젝션으로 매퍼 빈 주입
...
@RequiredArgsConstructor
public class CarService {
private final CarMapper carMapper;
...
}
Outro
MapStruct의 기본 개념과 세팅법, 사용하기 위한 매핑 문법과 사용하는 방법에 대해서 정리했다. 여기서 소개되는 내용으로는 복잡한 매핑을 구현하기엔 무리가 있다.
매핑 시 타입 변환도 정의할 수도 있는데(예를 들면 Enum타입의 속성을 String으로 변환하는 작업) 추가적인 내용은 공식문서를 참고하면 자세하게 알아낼 수 있다.
참고
MapStruct 공식 문서 - https://mapstruct.org/documentation/stable/reference/html/#mapping-with-builders