일기장

왜 MapStruct와 같은 주석 매핑을 사용하는 가?

1. 직접 하나하나 매핑하는 것은 굉장히 귀찮은 일이다.

2. 비즈니스 로직에 매핑 코드가 끼어있으면 코드 가독성이 떨어질 수 있다.

3. 개발자가 직접 매핑을 하다보면 실수가 발생할 수 있다.

4. 변경사항이 발생했을 경우 Source나 Target 코드만 변경하면 된다.

 


사전 설정

이 글은 MapStruct 1.5.5.Final Reference Guide를 기준으로 작성되었습니다.
MapStruct는 Java9 이상의 버전에서만 사용할 수 있습니다. (@Generated의 사용이 필요함)

 

MapStruct 종속성 설정

1. Maven 프로젝트

...
<properties>
    <org.mapstruct.version>1.5.5.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>
...

- Maven 프로젝트의 경우 pom.xml에 위와같이 종속성을 추가해줍니다. 

 

2. Gradle 프로젝트

...
plugins {
    ...
    id "com.diffplug.eclipse.apt" version "3.26.0" // Eclipse IDE의 경우에만 넣어주세요!
}

ext {
    mapstructVersion = "1.6.0.Beta1"
}

dependencies {
    ...
    implementation "org.mapstruct:mapstruct:${mapstructVersion}"
    annotationProcessor "org.mapstruct:mapstruct-processor:${mapstructVersion}"

    // 테스트 코드에서 사용할 경우
    testAnnotationProcessor "org.mapstruct:mapstruct-processor:${mapstructVersion}"
}
...

- Gradle 프로젝트의 경우에도 build.gradle에 위와 같이 종속성을 추가해줍니다. 버전은 상황에 맞게 수정해주세요.

 

구성옵션 수정

MapStruct는 여러 구성옵션들을 제공하고 있습니다. 구성옵션들은 -Akey=value형태로 컴파일러에게 파라미터 값(compilerArgs)으로 전달됩니다.

옵션 용도 기본값
mapstruct. suppressGeneratorTimestamp true로 설정하면 @Generated내에 존재하는 time stamp 생성이 막힌다. false
mapstruct.verbose true로 설정하면 mapstruct가 로그를 남긴다. (maven 컴파일러 구성 문제 이슈가 있어서 Maven 프로젝트에서는 showWarnings 구성을 추가해주어야 합니다. [아래 예시 참조])  false
mapstruct. suppressGeneratorVersionInfoComment true로 설정하면 @Generated내에 존재하는 comment 속성이 막힌다. 그 주석에는 MapStruct 버전에 대한 정보와 어노테이션 처리에 사용되는 컴파일러에 관한 정보들이 담겨져 있다. false
mapstruct.defaultComponentModel 이 옵션은 MapStruct가 어떤 컴포넌트 모델에 기반하여 생성관리를 해야하는지를 정한다. 
default로 둘 경우 인스턴스 생성을 Mappers.getMapper(Class)를 통해 직접해주어야 한다.

1. default
2. cdi
3. spring
4. jsr330
5. jakarta
6. jakarta-cdi

이 옵션은 후에 @Mapper.componentModel() 어노테이션을 통해 설정할 수 있는데, 값이 충돌될 경우 어노테이션의 값을 우선적용한다.
default
mapstruct.defaultInjectionStrategy 이 옵션은 MapStruct가 Mapper클래스에 종속성을 주입하는 방식을 정합니다. CDI, Spring, JSR330와 같이 어노테이션 기반의 컴포넌트 모델에 대해서만 지원하는 옵션입니다.

1. field
- 필드를 통해 의존성을 주입 (코드 간결)

2. construct
- 생성자를 통해 의존성을 주입 (객체 불변성 유지)

defaultComponentModel과 마찬가지로, @Mapper.injectionStrategy() 어노테이션을 통해 설정할 수 있는데, 값이 충돌될 경우 어노테이션 값이 우선적용된다.
field
mapstruct.unmappedTargetPolicy 원본 데이터를 Source, 변환할 데이터를 Target이라고 하는데, 이 옵션은 Source로 Target의 옵션을 완전히 채우지 못했을 때 보고정책을 설정합니다.

1. ERROR
- 매핑 실패하는 속성이 존재할 경우, 에러코드를 발생시킨다.

2. WARN
- 빌드시에 경고문구를 보여준다.

3. IGNORE
- 무시된다.

추후 @Mapper.unmappedTargetPolicy()를 이용해 특정 매퍼 클래스에 대해 설정할 수 있다.
@BeanMapping.unmappedTargetPolicy()를 통해 특정 Bean 매핑에 대해 설정값이 들어오면, 이 설정값이 앞선 두 방법에 비해 우선순위를 갖는다.
warn
mapstruct.unmappedSourcePolicy 유사하게 Source가 Target에 매핑되지 못했을 때 보고정책을 나타내는 옵션이다. 즉, 사용되지 않은 Source 속성값이 존재할 때를 의미한다.

1. ERROR
- 매핑 실패하는 속성이 존재할 경우, 에러코드를 발생시킨다.

2. WARN
- 빌드시에 경고문구를 보여준다.

3. IGNORE
- 무시된다.

@Mapper.unmappedSourcePolicy(), @BeanMapping.ignoreUnmappedSourceProperties()를 통해 따로 설정값을 세팅할 수 있다.
warn
mapstruct. disableBuilders true로 설정하면, 매핑을 진행할 때 빌더 패턴을 사용하지 않는다. 
모든 매퍼 클래스에 대하여 @Mapper( builder = @Builder( disableBuilder = true ) )로 설정한 것과 동일하다.
false

 

<!-- Maven -->

...
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.5.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>
        <!-- 메이븐 컴파일러 문제때문에, verbose mode를 설정하면 showWarnings을 추가해줘야합니다 -->
        <showWarnings>true</showWarnings>
        <compilerArgs>
            <arg>
                -Amapstruct.suppressGeneratorTimestamp=true
            </arg>
            <arg>
                -Amapstruct.suppressGeneratorVersionInfoComment=true
            </arg>
            <arg>
                -Amapstruct.verbose=true
            </arg>
        </compilerArgs>
    </configuration>
</plugin>
...

 

// Gradle

...
compileJava {
    options.compilerArgs += [
        '-Amapstruct.suppressGeneratorTimestamp=true',
        '-Amapstruct.suppressGeneratorVersionInfoComment=true',
        '-Amapstruct.verbose=true'
    ]
}
...

 

Mapper 클래스 정의

기본적인 매핑방법

MapStruct를 사용하여 기본 매핑을 구현하려면 우선 @Mapper 어노테이션을 이용해 자바 인터페이스를 정의하고 필요한 매핑 메서드를 포함시켜야 합니다. 빌드 시간동안 MapStruct가 인터페이스의 구현체를 생성해줍니다.

@Mapper
public interface CarMapper {

    @Mapping(target = "manufacturer", source = "make")
    @Mapping(target = "seatCount", source = "numberOfSeats")
    CarDto carToCarDto(Car car);

    @Mapping(target = "fullName", source = "name")
    PersonDto personToPersonDto(Person person);
}

매개변수는 Source, 타입 부분은 Target이라고 부르는데 말 그대로 Source에서 읽을 수 있는 속성들은 모두 Target으로 매핑시켜줍니다. Target의 속성명과 Source의 속성명이 다른 경우에는 @Mapping 어노테이션을 통해서 직접 매핑시켜줄 수 있습니다.

 

암묵적인 자동 매핑이 싫다면
@BeanMapping(ignoreByDefault = true)를 통해 @Mapping으로 명시적 정의된 필드만 매핑시킬 수 있습니다.

 

// 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;
    }
}

자동으로 생성된 코드들을 보면 MapStruct의 철학을 알 수 있습니다. 직접 수동으로 코드를 작성한 것처럼 getter와 setter를 통해 Source에서 Target으로 매핑하는 것을 지향하고 있습니다.

 

만약 Source와 Target 속성의 Type이 다르다면 어떻게 될까요?

MapStruct에서는 이를 타입 자동변환 (위의 Price의 경우처럼) 과 또 다른 매핑 메서드를 생성및 호출해 매핑하는 방식 (driver과 engine의 경우처럼) 으로 해결했습니다.

 

수동매핑

만약 MapStruct를 이용한 매핑이 의도한대로 작동하지 않을 경우, 유저가 직접 매핑 메서드를 작성할 수 있습니다.

자바8 이상부터는 인터페이스에 직접 default 메서드를 정의할 수 있습니다.

@Mapper
public interface CarMapper {
    @Mapping(...)
    CarDto carToCarDto(Car car);

    default PersonDto personToPersonDto(Person person) {
        // 수동 매핑 로직
    }
}

이 경우, carToCarDto 메서드에서 Person 객체를 PersonDto로 매핑할 때 자동으로 personToPersonDto 메서드가 호출됩니다.

 

- 인터페이스 대신 추상 클래스를 매퍼로 정의하고, 커스텀 메서드를 직접 작성하는 방법도 존재합니다.

@Mapper
public abstract class CarMapper {
    @Mapping(...)
    public abstract CarDto carToCarDto(Car car);

    public PersonDto personToPersonDto(Person person) {
        // 수동 매핑 로직
    }
}

 

여러 개의 Source를 하나의 Target으로 매핑

MapStruct에서는 여러 매개변수를 가진 매핑 메서드를 지원하고 있습니다. 아래는 그 예시입니다.

@Mapper
public interface AddressMapper {

    @Mapping(target = "description", source = "person.description")
    @Mapping(target = "houseNumber", source = "address.houseNo") // Source객체명.속성명
    DeliveryAddressDto personAndAddressToDeliveryAddressDto(Person person, Address address);
}

기본적으로 Source의 속성명은 동일한 Target 속성명을 찾아가 매핑됩니다. 하지만, 여러가지의 Source 객체가 존재하고, 그 중 중복되는 속성명이 있다면 MapStruct는 에러를 발생시킵니다. 이 경우에는 @Mapping 어노테이션을 사용하는 것이 강제됩니다. (직접 속성 지정 필수)

 

@Mapper
public interface AddressMapper {

    @Mapping(target = "description", source = "person.description")
    @Mapping(target = "houseNumber", source = "hn")
    DeliveryAddressDto personAndAddressToDeliveryAddressDto(Person person, Integer hn);
}

위와 같이 Source객체의 속성이 아니라, Source자체를 Target에 매핑시킬 수도 있습니다.

 

빌더에 관해서

MapStruct는 불변객체에 대한 매핑을 Builder를 통해 지원합니다. 매핑을 시도할 때, 해당 타입에 대한 Builder가 존재한다면 그 빌더를 이용해 매핑을 시도합니다. 빌더에 대한 기본적인 실행은 아래와 같습니다.

  1. 기본적으로 타입은 매개변수가 없는 public static 빌더 생성 메서드를 갖는다고 가정합니다. 예를 들어, Person객체는 PersonBuilder를 반환하는 public static 메서드를 갖습니다.
  2. 빌더 타입은 매개변수가 없는 public 빌드 메서드를 갖습니다. 예를 들어 앞서 언급한 PersonBuilder 객체는 Person을 반환하는 빌드 메서드를 갖습니다.
  3. 빌드 메서드가 여럿 존재할 경우에는 MapStruct에서 'build'라는 이름의 메서드를 찾습니다. 만약 존재하지 않는다면, 모호성 오류를 발생시킵니다.
  4.  @BeanMapping, @Mapper or @MapperConfig 내의 @Builder를 이용해 구체적인 빌드 메서드를 정의할 수 있습니다.
컴파일러에 Akey value를 전달해 빌더 사용을 비활성화할 수 있습니다.
-Amapstruct.disableBuilders=true

 

생성자에 관해서

MapStruct는 매핑시에 적합한 빌더가 존재하는지 확인합니다. 만약 빌더가 존재하지 않는다면, 아래의 4가지 과정을 통해 적합한 생성자를 찾습니다.

  1. @Default 어노테이션이 붙은 생성자를 찾아 사용합니다.
  2. 단일 public 생성자를 찾아 사용합니다.
  3. 매개변수가 존재하지 않는 생성자를 찾아 사용합니다.
  4. 만약 적합한 생성자가 여럿 존재할 경우에는 에러가 발생합니다. -> 이를 해결하기 위해서는 @Default 어노테이션을 붙여주세요!
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 인스턴스 생성

의존성 주입 프레임워크를 사용하지 않는 경우

DI 프레임워크를 사용하지 않을 경우에는 org.mapstruct.factory.Mappers 클래스를 통해 매퍼 인스턴스를 얻을 수 있습니다. 매퍼의 인터페이스 타입을 getMapper() 메서드의 매개변수로 전달해 호출할 수 있습니다.

CarMapper mapper = Mappers.getMapper(CarMapper.class);

 

컨벤션상 Mapper 인터페이스는 INSTANCE라는 속성을 정의하고 있어야합니다. INSTANCE는 매퍼 타입의 단일 인스턴스를 보유하고 있습니다.

@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);

    abstract CarDto carToCarDto(Car car);
}

 

이렇게 생성된 인스턴스는 아래와 같이 사용될 수 있습니다.

Car car = ...;
CarDto dto = CarMapper.INSTANCE.carToCarDto(car);

 

프레임워크를 사용하는 경우

만약 CDI나 스프링과 같은 DI 프레임워크를 사용하고 있다면, 앞선 방식이 아닌 의존성 주입을 통해 인스턴스를 얻는 것을 권장합니다.

@Mapper(componentModel = MappingConstants.ComponentModel.SPRING)
public interface CarMapper {

    CarDto carToCarDto(Car car);
}

componentModel에 허용되는 값은 mapstruct.defaultComponentModel 옵션값과 동일합니다.

참고로, 이 옵션값은 MappingConstants.ComponentModel 클래스에서 상수로 정의됩니다.

 

이 경우 아래와 같은 방법으로 매퍼 인스턴스를 얻을 수 있습니다.

@Inject
private CarMapper mapper;

 

 

profile

일기장

@공군급양

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!