Study/java

반복적인 DTO 변환 작업을 한 번에 정의! MapStruct 기본 정리

유경호 2021. 3. 7. 19:26
반응형

 

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.recordCustomerDto.account 를 매핑할 soruce로 지정한다.
  • record와 account에 매핑할 속성명이 겹치는 경우에는 @Mapping 어노테이션으로 명시하여 지정해준다.
    • 만약 record와 account가 name이라는 이름의 속성을 둘 다 갖고 있다면 첫번째 @Mapping 어노테이션처럼 어느 soruce의 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 or CollectionMappingStrategy.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

반응형