안녕하세요 브로콜리입니다.
최근 가까운 사람이 회사 면접을 보면서 Dto, Entity, Domain 간의 객체 매핑 과정에서 MapStruct를 사용해보는 것은 어떻겠냐는 조언을 들었습니다.
여기서 MapStruct라는 키워드를 알게되었고, Domain to Dto 나 Dto to Domain은 개발자에게 참으로 지루한 작업 중 하나이므로 손쉽게 매핑이 가능한 라이브러리가 있다면 한번 알아보고 쉽다는 욕심이 생겼습니다.
이로써 오늘은 MapStruct의 공식문서를 기반으로 실습 코드를 작성하며 학습한 내용을 정리해보려 합니다.
MapStruct란?
- 객체간 매핑을 손쉽게 도와주는 라이브러리
- AnnotationProcessor를 통해 build 타임에 MapperImpl을 getter/setter를 기반으로 생성 alike Lombok
- 신속, 명확, 타입 안전
MapStruct SetUp (Gradle 기준)
MapStruct은 JSR269에 기반을 둔 AnnotationProcessor입니다.
두가지 의존성 추가가 필요한데요
- org.mapstruct:mapstruct: @Mapping 같은 애너테이션을 포함합니다.
- org.mapstruct:mapstruct-processor : mapper를 구현하는데 필요한 annotation processor입니다.
implementation "org.mapstruct:mapstruct:1.5.2.Final"
annotationProcessor "org.mapstruct:mapstruct-processor:1.5.2.Final"
testAnnotationProcessor "org.mapstruct:mapstruct-processor:1.5.2.Final"
implementation "org.projectlombok:lombok-mapstruct-binding:0.2.0"
annotationProcessor "org.projectlombok:lombok-mapstruct-binding:0.2.0"
밑에 추가한 org.projectlombok:lombok-mapstruct-binding:{version}의 경우, Lombok과 MapStruct를 함께 사용하기 위해 필요한 의존성 추가입니다.
예시로 익히기
여러 설명보다 한가지 예시를 보는 것이 이해가 더 빨랐습니다.
Car 객체와 CarDto 객체가 있는 상황을 가정하겠습니다.
@Getter
@Setter
@AllArgsConstructor
public class Car {
private String make;
private int numberOfSeats;
private CarType type;
//constructor, getters, setters etc.
}
public enum CarType {
SEDAN,
SUV,
;
}
@Getter
@Setter
@AllArgsConstructor
public class CarDto {
private String manufacturer;
private int seatCount;
private String type;
//constructor, getters, setters etc.
}
그럼 MapStruct를 활용해 다음과 같은 CarMapper를 선언할 수 있습니다.
@Mapper
public interface CarMapper {
CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);
@Mapping(source = "make", target = "manufacturer")
@Mapping(source = "numberOfSeats", target = "seatCount")
CarDto carToCarDto(Car car);
}
source는 값을 가져오는 객체를 의미하며 파라미터로 넘겨주는 car를 의미합니다.
source객체는 값을 가져오는 만큼 getter가 구현되어 있어야 합니다.
target은 값을 넣어주는 객체로 매핑할 객체를 의미합니다.
builder 혹은 setter가 필요합니다.
이를 기반으로 @Mapping에 대한 설정을 해석해보면 다음과 같습니다.
@Mapping(source = "make", target = "manufacturer")
=> source인 Car의 make 필드를 target인 CarDto의 manufacturer 필드로 매핑해줘!
@Mapping(source = "numberOfSeats", target = "seatCount")
=> source인 Car의 numberOfSeats 필드를 target인 CarDto의 seatCount 필드로 매핑해줘!
이외에 필드명이 동일한 필드는 자동으로 매핑됩니다.
정말 잘 mapping이 될까요? 공식문서에 기재된 테스트를 통해 확인해봅시다
class CarMapperTest {
@Test
void carToCarDto() {
//given
Car car = new Car( "Morris", 5, CarType.SEDAN );
//when
CarDto carDto = CarMapper.INSTANCE.carToCarDto( car );
//then
assertThat( carDto ).isNotNull();
assertThat( carDto.getManufacturer() ).isEqualTo( "Morris" );
assertThat( carDto.getSeatCount() ).isEqualTo( 5 );
assertThat( carDto.getType() ).isEqualTo( "SEDAN" );
}
}
테스트 결과 잘 통과되는 모습을 볼 수 있습니다.
그리고, build/generated 폴더를 따라가면 CarMapperImpl이 생성된 모습을 볼 수 있습니다.
CarMapperImpl을 살펴보면 Java의 기본 method인 getter/setter등을 활용하여 애너테이션에 설정해준 정보를 기반으로 직접 mapping 로직을 구성한 것을 볼 수 있습니다.
public class CarMapperImpl implements CarMapper {
@Override
public CarDto carToCarDto(Car car) {
if ( car == null ) {
return null;
}
String manufacturer = null;
int seatCount = 0;
String type = null;
manufacturer = car.getMake();
seatCount = car.getNumberOfSeats();
if ( car.getType() != null ) {
type = car.getType().name();
}
CarDto carDto = new CarDto( manufacturer, seatCount, type );
return carDto;
}
}
MapStruct Mapping 필드 정의하기
이제 MapStruct의 세부 설정에 대해 알아봅시다.
@Mapping의 source와 target 정하기
- source : 값을 가져오는 객체로 getter가 필요함
- target : 매핑할 객체로 setter | builder 가 필요함
- source -target 필드의 필드명이 같다면 매핑이 진행됨
- source 필드명과 target 필드명이 다른 경우, @Mapping 어노테이션을 통해 직접 지정 가능
@Mapping(source= "소스객체 필드명", target = "타겟객체 필드명")
Target toTarget(Source s);
활용하는 생성자 선정 기준
- public 생성자가 단 한개인 경우 - 유일한 생성자 채택
@Getter
@Setter
public class Vehicle {
private String color;
protected Vehicle() { }
// 유일한 public 생성자 채택
public Vehicle(String color) {
this.color = color;
}
}
- public 생성자가 두 개 이상이고 @Default를 붙인 생성자가 있는 경우 - @Default를 붙인 생성자
public class Truck {
private String make;
private String color;
public Truck() { }
// @Default가 붙어있으므로 채택
@Default
public Truck(String make, String color) {
this.make = make;
this.color = color;
}
}
@Target(value = {ElementType.CONSTRUCTOR})
@Retention(value = RetentionPolicy.CLASS)
public @interface Default {
}
- public 생성자가 두 개 이상이고 그중 기본 생성자가 있는 경우 - 기본 생성자 활용
@Getter
@Setter
public class Car {
private String make;
private String color;
//기본 생성자가 열려 있을 경우 > 기본 생성자 활용
public Car() { }
public Car(String make, String color) {
this.make = make;
this.color = color;
}
}
- public 생성자가 두개 이상이고 @Default를 붙인 생성자가 없고, 기본 생성자도 없는 경우 - 오류발생!
public class Van {
//public 생성자가 2개 이상 > 에러 발생
public Van(String make) { }
public Van(String make, String color) { }
}
여러 필드 활용해서 매핑하기
- 필드객체.필드명을 통해 필드내 객체 필드값에 접근 가능 (Jpql 문법과 비슷)
@Mapper
public interface AddressMapper {
//다양한 파라미터를 받을 경우 명확히 지정 > 모호할 경우 에러 발생 가능
//파라미터가 null일 경우 mapping되는 값도 null이 된다
@Mapping(target = "description", source = "person.description")
@Mapping(target = "houseNumber", source = "address.houseNo")
DeliveryAddressDto personAndAddressToDeliveryAddressDto(Person person, Address address);
//직접 바인딩도 가능
@Mapping(target = "description", source = "person.description")
@Mapping(target = "houseNumber", source = "hn")
DeliveryAddressDto personAndAddressToDeliveryAddressDto_V2(Person person, Integer hn);
}
> 테스트를 통해 검증해보기
@Test
void personAndAddressToDeliveryAddressDto() {
Address address = new Address(3);
Person person = new Person("testPerson");
AddressMapper mapper = Mappers.getMapper(AddressMapper.class);
DeliveryAddressDto deliveryAddressDto = mapper.personAndAddressToDeliveryAddressDto(person, address);
assertThat(deliveryAddressDto.getDescription()).isEqualTo(person.getDescription());
assertThat(deliveryAddressDto.getHouseNumber()).isEqualTo(String.valueOf(address.getHouseNo()));
}

기본값 설정하기 by defaultValue / constant
- defaultValue : target 필드에 매핑된 값이 없을 때(null인 경우) 적용할 값
ex ) `@Mapping(target="longProperty", source = "longProp", defaultValue = "-1) //longProperty가 null일 때 -1로 채움
- constant : 매핑하지 않고 항상 적용할 값
@Mapping(target="longProperty", constant = "-1) //longProperty는 항상 -1
무시하는 매핑 필드 설정하기
- @Mapping(ignore =true) 매핑이 필요하지 않은 타겟 필드 설정 가능
@Mapper
public interface CarMapper {
@Mapping(target = "numberOfSeats", ignore = true)
Car carDtoToCar(CarDto carDto);
}
- Mapper 전체에서 무시하기
- target에서 매핑되지 않은 필드 무시 : unmappedTargetPolicy = ReportingPolicy.IGNORE
- source에서 매핑되지 않은 필드 무시 : unmappedSourcePolicy = ReportingPolicy.IGNORE
@Mapper(
componentModel = "spring", //빈으로 등록
unmappedTargetPolicy = ReportingPolicy.IGNORE, // target에서 매핑되지 않은 필드 무시
unmappedSourcePolicy = ReportingPolicy.IGNORE // source에서 매핑되지 않은 필드 무시
)
public interface Mapper{
//매핑 로직
}
Mapper 불러오기
- Mappers.getMapper(매핑 클래스.class)로 직접 불러오기
Mappers.getMapper(SourceTargetMapper.class);
- Mapper를 빈으로 등록하기
@Mapper(componentModel = "spring")
public interface SourceTargetMapper {
SourceTargetMapper INSTANCE = Mappers.getMapper(SourceTargetMapper.class);
@Mapping(target = "timeAndFormat", expression = "java(new org.sample.TimeAndFormat(s.getTime(), s.getFormat()))")
Target sourceToTarget(Source s);
}
default 메서드를 활용한 커스텀 매핑 로직 만들기
- interface mapper > default 메서드
- abstract class mapper > 구현한 메서드가 커스텀 매핑 로직이
@Mapper
public interface CarMapper {
@Mapping(...)
CarDto cartoCarDto(Car car);
default PersonDto personToPersonDto(Person person){
// 커스터마이즈 매핑 logic
}
}
@MappingTarget으로 업데이트하기
- dto의 필드값을 기준으로 domain에 덮어씌우거나, 반대로 domain의 값을 dto에 전체 업데이트할 때 사용
- @MappingTarget : mapping할 target이 누구인지 지정
- 반환값은 target의 특정 필드값으로 지정 가능
@Mapper
public interface CarMapper {
CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);
//특정 객체를 기준으로 update하는 로직도 가능
@Mapping(target = "make", source = "manufacturer")
@Mapping(target = "numberOfSeats", source = "seatCount")
void updateCarFromDto(CarDto carDto, @MappingTarget Car car);
}
> 테스트로 검증하기
@Test
void updateMethod() {
Car car = new Car( "Morris", 5, CarType.SEDAN );
CarDto carDto = new CarDto("Coli", 3, CarType.SUV.name());
//when carDto > car에 update
CarMapper.INSTANCE.updateCarFromDto(carDto, car);
//then
assertThat( car.getMake()).isEqualTo(carDto.getManufacturer());
assertThat( car.getNumberOfSeats() ).isEqualTo( carDto.getSeatCount() );
assertThat( car.getType().name() ).isEqualTo("SUV");
}

> 생성된 CarImpl의 updateCarFromDto()
@Override
public void updateCarFromDto(CarDto carDto, Car car) {
if ( carDto == null ) {
return;
}
car.setMake( carDto.getManufacturer() );
car.setNumberOfSeats( carDto.getSeatCount() );
if ( carDto.getType() != null ) {
car.setType( Enum.valueOf( CarType.class, carDto.getType() ) );
}
else {
car.setType( null );
}
}
@InheritInverseConfiguration으로 매핑 반대로 적용하기
- Dto to Domain에 대한 매핑로직을 구현한 이후, 정확히 반대로 Domain to Dto가 필요한 상황이 빈번합니다.
- @InheritInverseConfiguration은 거꾸로 Mapping하는 함수 설정을 그대로 물려줄 수 있습니다.
@Mapper
public interface CustomerMapper {
CustomerMapper INSTANCE = Mappers.getMapper(CustomerMapper.class);
@Mapping(source = "name", target ="customerName")
CustomerDto toDto(Customer customer);
//위의 매핑로직을 똑같이 하되 거꾸로 적용
@InheritInverseConfiguration
Customer toCustomer(CustomerDto customerDto);
}
@Getter
@Setter
@AllArgsConstructor
public class Customer {
private Long id;
private String name;
}
@AllArgsConstructor
public class CustomerDto {
public Long id;
public String customerName;
}
> 테스트를 통해 검증하기
class CustomerMapperTest {
@Test
void toDto() {
Customer customer = new Customer(1L, "소비자 이름");
CustomerDto customerDto = CustomerMapper.INSTANCE.toDto(customer);
assertThat(customerDto.id).isEqualTo(customer.getId());
assertThat(customerDto.customerName).isEqualTo(customer.getName());
}
@Test
void toCustomer() {
CustomerDto customerDto = new CustomerDto(1L, "소비자 이름");
Customer customer = CustomerMapper.INSTANCE.toCustomer(customerDto);
assertThat(customerDto.id).isEqualTo(customer.getId());
assertThat(customerDto.customerName).isEqualTo(customer.getName());
}
}

> 구현된 CustomerMapperImpl
public class CustomerMapperImpl implements CustomerMapper {
@Override
public CustomerDto toDto(Customer customer) {
if ( customer == null ) {
return null;
}
String customerName = null;
Long id = null;
customerName = customer.getName();
id = customer.getId();
CustomerDto customerDto = new CustomerDto( id, customerName );
return customerDto;
}
@Override
public Customer toCustomer(CustomerDto customerDto) {
if ( customerDto == null ) {
return null;
}
String name = null;
Long id = null;
name = customerDto.customerName;
id = customerDto.id;
Customer customer = new Customer( id, name );
return customer;
}
}
다른 Mapper를 활용하여 Mapping하기
예를 들어 Date <-> String의 매핑 로직을 작성한 DateMapper가 있다고 가정해봅시다
public class DateMapper {
public String asString(Date date) {
return date != null ? new SimpleDateFormat( "yyyy-MM-dd" )
.format( date ) : null;
}
public Date asDate(String date) {
try {
return date != null ? new SimpleDateFormat( "yyyy-MM-dd" )
.parse( date ) : null;
}
catch ( ParseException e ) {
throw new RuntimeException( e );
}
}
}
그럼 이 로직을 활용하여 Date와 String 간의 변환은 DateMapper를 경유하도록 Mapper를 선언할 수 있습니다. @Mapper(uses = {Mapper1.class, Mapper2.class}) 를 사용하면 됩니다.
@Mapper(uses = {DateMapper.class})
public interface CarMapper {
CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);
CarDto toDto(Car car);
}
> 테스트를 통해 검증하기
class CarMapperTest {
@Test
void toDto() {
Car car = new Car(Date.valueOf(LocalDate.now()));
CarDto carDto = CarMapper.INSTANCE.toDto(car);
String expectedDate = new SimpleDateFormat("yyyy-MM-dd").format(car.getManufacturingDate());
assertThat(carDto.getManufacturingDate()).isEqualTo(expectedDate);
}
}

콜백 메서드 : @BeforeMapping / @AfterMapping
- @BeforeMapping : mapping 전에 할 행동을 지정가능
- @AfterMapping :mapping이후 할 행동을 지정가능
@Mapper
public abstract class VehicleMapper {
@BeforeMapping
protected void flushEntity(AbstractVehicle vehicle) {
// I would call my entity manager's flush() method here to make sure my entity
// is populated with the right @Version before I let it map into the DTO
}
@AfterMapping
protected void fillTank(AbstractVehicle vehicle, @MappingTarget AbstractVehicleDto result) {
result.fuelUp( new Fuel( vehicle.getTankCapacity(), vehicle.getFuelType() ) );
}
public abstract CarDto toCarDto(Car car);
}
// Generates something like this:
public class VehicleMapperImpl extends VehicleMapper {
public CarDto toCarDto(Car car) {
flushEntity( car );
if ( car == null ) {
return null;
}
CarDto carDto = new CarDto();
// attributes mapping ...
fillTank( car, carDto );
return carDto;
}
}
@MapperConfig를 통해 공통 설정 공유하기
- mapper의 옵션을 config interface에 지정
- mapper에서 config 설정을 통해 공유 가능
@MapperConfig(
uses = CustomMapperViaMapperConfig.class,
unmappedTargetPolicy = ReportingPolicy.ERROR
)
public interface CentralConfig {
}
@Mapper(config = CentralConfig.class, uses = { CustomMapperViaMapper.class } )
// Effective configuration:
// @Mapper(
// uses = { CustomMapperViaMapper.class, CustomMapperViaMapperConfig.class },
// unmappedTargetPolicy = ReportingPolicy.ERROR
// )
public interface SourceTargetMapper {
...
}
MapStruct에 대한 개인적인 생각
- 장점 : 손쉬운 매핑
Domain과 Entity를 분리하였거나, 계층간 Dto를 세분화한 프로젝트에서 손쉽게 반복되는 코드 작업을 줄여주므로 유용할 것 같았다. 특히 record에 직접 domain을 매핑하기 위해서는 정적 팩터리 메서드를 활용하는 경우가 많았는데 그러한 코드를 mapper를 통해 걷어낼 수 있다는 점이 매력적이다.
- 장점 : 리플렉션이 아닌 POJO 중심의 라이브러리
getter/setter를 활용해 Impl 객체를 생성하는 점도 좋다. 리플렉션을 사용하지 않고 순수 자바객체의 메서드를 사용하기에 실행 속도 상에서도 큰 저하가 없다
- 장점 : 타입 안전한 매핑 로직
자동으로 생성되는 Impl 로직은 Enum - String 자동 형변환 등 상당히 똑똑하게 구현된 부분이 많았다. 이는 개발자의 휴먼 에러를 줄이고 타입안전한 매핑 로직을 제공함과 동시에 Impl 객체가 getter/setter를 기반으로 생성되므로 매핑 로직에 필요한 getter/setter의 부재를 컴파일 타임에 개발자가 알 수 있다.
- 장점: 중복 로직 크로스 커팅
반복되는 매핑 로직의 경우 Mapper 하나에서 응집성 있게 관리할 수 있게 된다.
- 단점 : 문자열로 다루는 필드명들
Mapping 애너테이션의 옵션을 문자열을 통해 다루게 된다. 만약 오타가 나면? 매핑이 시작되는 런타임에 오류가 난다. 매핑 로직의 검증이 더욱 중요해진다.
- 단점 : 추상화하여 명확한 매핑 과정을 알 수 없음
@Mapping(target = "a", source = "a.b.c") 와 같은 설정이 있다고 가정하자. a.getB().getC()를 활용할텐데 만약 getB나 getC가 구현되어 있지 않으면 어떻게 될까? annotationProcessor에 특성상 결국 한번 빌드를 해서 Impl 객체가 생성되어야 컴파일 타임에 오류가 잡힌다. 완전한 컴파일 타임 안전성을 보장한다고 할 수 없다. 코드상에서 매핑로직을 관리하지 않고 한번 추상화하여 관리하다보니 어떻게 매핑되는지를 알게되기 쉽지 않다.
실습코드 보러가기
실습에 활용한 코드는 다음 레포지토리에서 확인하실 수 있습니다.
https://github.com/coli-geonwoo/blog/tree/feature/mapstruct
GitHub - coli-geonwoo/blog: 블로그 헤이,브로의 실습 코드를 모아놓은 저장소입니다.
블로그 헤이,브로의 실습 코드를 모아놓은 저장소입니다. Contribute to coli-geonwoo/blog development by creating an account on GitHub.
github.com
Reference)
https://mein-figur.tistory.com/entry/mapstruct-1
[Java/Spring] (1) MapStruct를 활용해서 손쉽게 매핑하기
MapStruct를 활용해서 손쉽게 매핑하기 Spring Project에서 레이어 간 이동을 할 때, DTO를 생성해서 데이터를 이동시키곤 합니다. DTO 내부에 필드의 갯수가 정말 많다면, 단지 데이터를 이동시키는 코
mein-figur.tistory.com
https://mapstruct.org/documentation/stable/reference/html/
MapStruct 1.6.3 Reference Guide
If set to true, MapStruct in which MapStruct logs its major decisions. Note, at the moment of writing in Maven, also showWarnings needs to be added due to a problem in the maven-compiler-plugin configuration.
mapstruct.org
https://github.com/mapstruct/mapstruct-examples/tree/main/mapstruct-on-gradle
mapstruct-examples/mapstruct-on-gradle at main · mapstruct/mapstruct-examples
Examples for using MapStruct. Contribute to mapstruct/mapstruct-examples development by creating an account on GitHub.
github.com