왜 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가 존재한다면 그 빌더를 이용해 매핑을 시도합니다. 빌더에 대한 기본적인 실행은 아래와 같습니다.
- 기본적으로 타입은 매개변수가 없는 public static 빌더 생성 메서드를 갖는다고 가정합니다. 예를 들어, Person객체는 PersonBuilder를 반환하는 public static 메서드를 갖습니다.
- 빌더 타입은 매개변수가 없는 public 빌드 메서드를 갖습니다. 예를 들어 앞서 언급한 PersonBuilder 객체는 Person을 반환하는 빌드 메서드를 갖습니다.
- 빌드 메서드가 여럿 존재할 경우에는 MapStruct에서 'build'라는 이름의 메서드를 찾습니다. 만약 존재하지 않는다면, 모호성 오류를 발생시킵니다.
- @BeanMapping, @Mapper or @MapperConfig 내의 @Builder를 이용해 구체적인 빌드 메서드를 정의할 수 있습니다.
컴파일러에 Akey value를 전달해 빌더 사용을 비활성화할 수 있습니다.
-Amapstruct.disableBuilders=true
생성자에 관해서
MapStruct는 매핑시에 적합한 빌더가 존재하는지 확인합니다. 만약 빌더가 존재하지 않는다면, 아래의 4가지 과정을 통해 적합한 생성자를 찾습니다.
- @Default 어노테이션이 붙은 생성자를 찾아 사용합니다.
- 단일 public 생성자를 찾아 사용합니다.
- 매개변수가 존재하지 않는 생성자를 찾아 사용합니다.
- 만약 적합한 생성자가 여럿 존재할 경우에는 에러가 발생합니다. -> 이를 해결하기 위해서는 @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;
'웹개발' 카테고리의 다른 글
AWS ParameterStore을 이용해 스프링부트에서 API Key 관리하기 (0) | 2023.12.09 |
---|---|
스프링부트 프로젝트에 Spotless 적용하기 (0) | 2023.10.16 |
TDD (0) | 2023.09.26 |
사용자 인증 (0) | 2023.09.22 |
@ResponseStatus를 이용하여 커스텀 예외를 만드는 방법 (0) | 2023.09.02 |