Spring Boot : 3.3.4
MapStruct : 1.5.5 Final
Java : 21
Lombok : 1.8.34
Java 애플리케이션에서 객체 간의 매핑을 쉽게 해주는 매핑 프레임워크이다.
Annotation processor를 이용해 동작한다.
주로 DTO <-> Entity간의 변환작업시 사용되며 컴파일 시점에 매핑 코드를 자동으로 생성해준다.
매핑을 명시해주는 인터페이스를 선언하고 빌드시 인터페이스 기반으로 클래스 파일이 생기며 사용할 곳에서 의존성 주입해 사용하는 방식이다.
컴파일시 변환 클래스가 생성되기 때문에 속도가 빠르다.
예전
주요 기능 및 특징
컴파일 타임에 매핑 코드 생성
MapStruct는 런타임이 아니라 컴파일 시점에 매핑 코드를 생성한다. 이로 인해 성능 오버헤드가 적고, 매핑이 잘못되었을 경우 컴파일 타임에 오류를 발견할 수 있다.
자동 매핑
동일한 이름의 필드는 자동으로 매핑된다. 예를 들어, User 엔티티와 UserDTO 객체의 필드 이름이 같다면 별도의 설정 없이 자동으로 매핑이 가능하다.
커스터마이징 가능
필드 이름이 다르거나 복잡한 매핑 로직이 필요할 때는 수동으로 매핑을 정의할 수 있다. 이를 위해 MapStruct는 다양한 어노테이션을 제공하며 매핑을 유연하게 커스터마이징할 수 있다.
지원하는 매핑 방식:
기본적인 객체 간 필드 복사 외에도 컬렉션(List, Set, Map), 중첩된 객체, enum 타입 간의 매핑을 지원한다.
다양한 변환 기능을 제공하여 타입 변환을 처리할 수 있다.. 예를 들어 String을 Integer로 변환하거나 Date를 String으로 변환할 수 있다.
# CarDTO.class
@Builder
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class CarDTO {
private Long id;
private String name;
private Integer price;
private List<String> options;
private EnumCarColor color;
private LocalDateTime regDttm;
}
# Car.class
@Builder
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class Car {
private Long id;
private String name;
private Integer price;
private List<String> options;
private EnumCarColor color;
private LocalDateTime regDttm;
}
CarDTO carDTO = CarDTO.builder()
.id(id)
.name(name)
.price(price)
.options(options)
.color(color)
.regDttm(regDttm)
.build();
public Car toEntity(){
return Car.builder()
.id(id)
.name(name)
.price(price)
.options(options)
.color(color)
.regDttm(regDttm)
.build();
}
Car car = carDTO.toEntity();
CarDTO carDTO = CarDTO.builder()
.id(id)
.name(name)
.price(price)
.options(options)
.color(color)
.regDttm(regDttm)
.build();
Car car = modelMapper.map(carDTO, Car.class);
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
변환하는 작업이 없는걸 확인할 수 있다.
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
lombok이 먼저 선언되어야함.
lombok-mapstruct-binding 추가
Lombok 1.18.16이상부터
lombok-mapstruct-binding을 추가하면 순서와 상관없이 적용시켜준다고 한다.
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0'
참고 : https://mapstruct.org/faq/
interface를 생성해주고 변환할 클래스와 파라미터로 넘어갈 클래스를 명시해준다.
컴파일시 generated/sources/annotationProcessor/java/main/클래스경로에 {mapper명}Impl로 클래스가 생긴다.
기본적으로 변수명이 똑같다면 자동 맵핑해준다.
public class CarMapperImpl implements CarMapper {
@Override
public Car toEntity(CarDTO carDTO) {
if ( carDTO == null ) {
return null;
}
Car.CarBuilder car = Car.builder();
car.id( carDTO.getId() );
car.name( carDTO.getName() );
car.price( carDTO.getPrice() );
List<String> list = carDTO.getOptions();
if ( list != null ) {
car.options( new ArrayList<String>( list ) );
}
car.color( carDTO.getColor() );
car.regDttm( carDTO.getRegDttm() );
return car.build();
}
@Override
public CarDTO toDTO(Car car) {
if ( car == null ) {
return null;
}
CarDTO.CarDTOBuilder carDTO = CarDTO.builder();
carDTO.id( car.getId() );
carDTO.name( car.getName() );
carDTO.price( car.getPrice() );
List<String> list = car.getOptions();
if ( list != null ) {
carDTO.options( new ArrayList<String>( list ) );
}
carDTO.color( car.getColor() );
carDTO.regDttm( car.getRegDttm() );
return carDTO.build();
}
componentModel
생성된 매퍼 구현체의 빈 관리 전략을 정의
기본 (class)
일반 클래스로 생성되고 Mappers.getMapper로 호출해 사용해야한다.
@Mapper
public interface CarMapper {
Car toEntity(CarDTO carDTO);
CarDTO toDTO(Car car);
}
private CarMapper carMapper = Mappers.getMapper(CarMapper.class);
Car car = carMapper.toEntity(carDTO);
unmappedSourcePolicy
소스 객체의 필드가 타겟 객체로 매핑되지 않았을 때 어떻게 처리할지를 결정(기본값 : ignore)
error로 설정시 소스 필드가 매핑되지 않으면 컴파일 에러를 발생(source에 있는 모든 변수가 target에 매핑되야함)
unmappedTargetPolicy
타겟 객체의 필드에 소스 객체에서 매핑되지 않는 필드가 있을 때 어떻게 처리할지를 결정(기본값 : warning)
error로 설정시 타겟 필드가 소스 객체로부터 매핑되지 않으면 컴파일 에러를 발생(target객체에 모든 필드가 source로 부터 매핑되야함)
nullValueCheckStrategy
소스코드가 null일때 어떻게 매핑할지 전략정의
기본적으로 mapstruct사용시 선언되지 않은 변수는 기본값(0, false)이나 null이 들어간다.
NullValueCheckStrategy.ALWAYS
넘어온 객체가 null일 경우 매핑을 무시한다.
기존 target에 값이 있고 source가 null일때 target값을 유지해야할때 사용.
해당 옵션 선언시 아래처럼 생성된다.
@Mapper(nullValueCheckStrategy= NullValueCheckStrategy.ALWAYS)
public class CarMapperImpl implements CarMapper {
@Override
public Car toEntity(CarDTO carDTO) {
if ( carDTO == null ) {
return null;
}
Car.CarBuilder car = Car.builder();
if ( carDTO.getId() != null ) {
car.id( carDTO.getId() );
}
if ( carDTO.getName() != null ) {
car.name( carDTO.getName() );
}
if ( carDTO.getPrice() != null ) {
car.price( carDTO.getPrice() );
}
car.cost( carDTO.getCost() );
List<String> list = carDTO.getOptions();
if ( list != null ) {
car.options( new ArrayList<String>( list ) );
}
if ( carDTO.getColor() != null ) {
car.color( carDTO.getColor() );
}
if ( carDTO.getRegDttm() != null ) {
car.regDttm( carDTO.getRegDttm() );
}
return car.build();
}
@Override
public CarDTO toDTO(Car car) {
if ( car == null ) {
return null;
}
CarDTO.CarDTOBuilder carDTO = CarDTO.builder();
if ( car.getId() != null ) {
carDTO.id( car.getId() );
}
if ( car.getName() != null ) {
carDTO.name( car.getName() );
}
if ( car.getPrice() != null ) {
carDTO.price( car.getPrice() );
}
carDTO.cost( car.getCost() );
List<String> list = car.getOptions();
if ( list != null ) {
carDTO.options( new ArrayList<String>( list ) );
}
if ( car.getColor() != null ) {
carDTO.color( car.getColor() );
}
if ( car.getRegDttm() != null ) {
carDTO.regDttm( car.getRegDttm() );
}
return carDTO.build();
}
}
기본 매핑
기본적으로 source로 사용할 dto객체와 target으로 사용할 entity 객체의 변수가 동일하면 별도의 작업 없이 메서드 선언만으로 객체 변환을 해준다.
GenericMapper를 하나 만들어두고 변수가 동일한곳에는 mapper에서 인터페이스를 상속받고 사용하면 따로 변환 코드를 작성할 필요가 없어 편리하다.
# GenericMapper.class
public interface GenericMapper<D, E> {
D toDto(E entity);
E toEntity(D dto);
}
# UserMapper.class
@Mapper(componentModel = "spring")
public interface UserMapper extends GenericMapper<UserDTO, User> {
}
# UserDTO.class
@Builder
@Getter
@ToString
public class UserDTO {
private Long userId;
private String userName;
private Integer age;
private EnumUserStatus status;
}
# User.class
@Builder
@Getter
@ToString
public class User {
private Long userId;
private String userName;
private Integer age;
private EnumUserStatus status;
}
# CarDTO.class
@Builder
@Getter
public class CarDTO {
private Long id;
private String name;
private Integer price;
private List<String> options;
private EnumCarColor color;
private LocalDateTime regDttm;
private String status;
private String tempVehicleId;
}
# CompanyDTO.class
@Builder
@Getter
public class CompanyDTO {
private Long companyId;
private String name;
}
# ReservationDTO.class
@Builder
@Getter
public class ReservationDTO {
private String reservationName;
private String reservationMobile;
}
# Car.class
@Builder
@Getter
@ToString
public class Car {
private Long id;
private String name;
private Integer price;
private String description;
private List<String> options;
private EnumCarColor color;
private LocalDateTime regDttm;
private String status;
private String tempVehicleId;
private String stringDate;
private Long companyId;
private String companyName;
private Reservation reservation;
private String notes;
private String testParameter;
}
# Reservation.class
@Builder
@Getter
@ToString
public class Reservation {
private String name;
private String mobile;
}
# CarMapper.class
@Mapper(componentModel = "spring", imports = {UUID.class, EnumCarColor.class})
public interface CarMapper {
@Mapping(target = "tempVehicleId", defaultExpression = "java(UUID.randomUUID().toString())")
@Mapping(target = "description", expression = "java(buildDescription(carDTO))")
@Mapping(target = "status", defaultValue = "NORMAL")
@Mapping(target = "name", source="carDTO.name")
@Mapping(target = "color", defaultExpression = "java(EnumCarColor.BLUE)")
@Mapping(target = "id", ignore = true)
@Mapping(target = "stringDate", source="carDTO.regDttm", qualifiedByName = "localDateTimeToString")
@Mapping(target = "companyName", source="companyDTO.name")
@Mapping(target = "notes", source="note")
@Mapping(target = "reservation.name", source="reservationDTO.reservationName")
@Mapping(target = "reservation.mobile", source="reservationDTO.reservationMobile")
Car toEntity(CarDTO carDTO, CompanyDTO companyDTO, ReservationDTO reservationDTO, String note, String testParameter);
CarDTO toDTO(Car car);
default String buildDescription(CarDTO carDTO) {
return carDTO.getName() + ", "+ carDTO.getPrice() +"원";
}
@Named("localDateTimeToString")
default String localDateTimeToString(LocalDateTime localDateTime) {
return localDateTime.toLocalDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
}
}
# CarMapperImpl.class
@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2024-10-06T21:46:25+0900",
comments = "version: 1.5.5.Final, compiler: IncrementalProcessingEnvironment from gradle-language-java-8.10.2.jar, environment: Java 21.0.4 (Amazon.com Inc.)"
)
@Component
public class CarMapperImpl implements CarMapper {
@Override
public Car toEntity(CarDTO carDTO, CompanyDTO companyDTO, ReservationDTO reservationDTO, String note, String testParameter) {
if ( carDTO == null && companyDTO == null && reservationDTO == null && note == null && testParameter == null ) {
return null;
}
Car.CarBuilder car = Car.builder();
if ( carDTO != null ) {
if ( carDTO.getTempVehicleId() != null ) {
car.tempVehicleId( carDTO.getTempVehicleId() );
}
else {
car.tempVehicleId( UUID.randomUUID().toString() );
}
if ( carDTO.getStatus() != null ) {
car.status( carDTO.getStatus() );
}
else {
car.status( "NORMAL" );
}
car.name( carDTO.getName() );
if ( carDTO.getColor() != null ) {
car.color( carDTO.getColor() );
}
else {
car.color( EnumCarColor.BLUE );
}
car.stringDate( localDateTimeToString( carDTO.getRegDttm() ) );
car.price( carDTO.getPrice() );
List<String> list = carDTO.getOptions();
if ( list != null ) {
car.options( new ArrayList<String>( list ) );
}
car.regDttm( carDTO.getRegDttm() );
}
if ( companyDTO != null ) {
car.companyName( companyDTO.getName() );
car.companyId( companyDTO.getCompanyId() );
}
car.reservation( reservationDTOToReservation( reservationDTO ) );
car.notes( note );
car.testParameter( testParameter );
car.description( buildDescription(carDTO) );
return car.build();
}
@Override
public CarDTO toDTO(Car car) {
if ( car == null ) {
return null;
}
CarDTO.CarDTOBuilder carDTO = CarDTO.builder();
carDTO.id( car.getId() );
carDTO.name( car.getName() );
carDTO.price( car.getPrice() );
List<String> list = car.getOptions();
if ( list != null ) {
carDTO.options( new ArrayList<String>( list ) );
}
carDTO.color( car.getColor() );
carDTO.regDttm( car.getRegDttm() );
carDTO.status( car.getStatus() );
carDTO.tempVehicleId( car.getTempVehicleId() );
return carDTO.build();
}
protected Reservation reservationDTOToReservation(ReservationDTO reservationDTO) {
if ( reservationDTO == null ) {
return null;
}
Reservation.ReservationBuilder reservation = Reservation.builder();
reservation.name( reservationDTO.getReservationName() );
reservation.mobile( reservationDTO.getReservationMobile() );
return reservation.build();
}
}
@Mapper(imports={... class})
mapper에서 expression 등에서 동적메서드를 사용할경우 클래스를 import 하거나 new 연산자로 클래스 전체 패키지와 클래스명을 선언해서 호출할 수 있다.
expression
target에 parameter 클래스의 값을 활용해 커스텀 메서드나 객체를 사용해서 값을 할당할 수 있다.
기본 매핑 자체를 배제하고 expression으로 지정된 메서드의 return 값이 매핑된다.
간단한 변환일경우 인라인으로 작성한다.
여러 파라미터를 넘길 수 있다.
객체가 아니라 get으로 여러개도 가능하다.
@Mapping(target = "description", expression = "java(buildDescription2(carDTO.getName(), carDTO.getPrice()))")
default String buildDescription2(String name, Integer price) {
return name + ", "+ price +"원";
}
@named + qualifiedByName
expression과 비슷하지만 파라미터로 source 하나만 넘길 수 있다. 재사용할 필요가 있을때 정의해놓고 쓰면 좋을것 같다.
defaultExpression
source가 null일 경우 target에 동적 표현식을 사용해 값을 할당. source에 값이 있다면 그 값이 들어간다.
defaultValue
soruce가 null일 경우 문자열 지정. 문자열이 아니면 오류가 난다.
source에 값이 있다면 그 값이 들어간다.
ignore
target에서 맵핑을 무시할지 여부를 정할수 있다
source와 target의 변수가 다를때, 혹은 파라미터가 여러개일때
기본적으로 source와 target의 변수가 같고 파라미터가 하나라면 별도의 선언이 필요없지만 source가 여러개라면 source.변수명
등으로 선언하지 않으면 오류가 발생할 수 있다.
단일 파라미터일땐 이게 오류가 발생하지 않지만
@Mapping(target = "stringDate", source="regDttm", qualifiedByName = "localDateTimeToString")
파라미터가 2개이상이되면 객체를 지정해주지않으면 regDttm이 어떤 파라미터에서 가져와야 할지 몰라서 오류가 난다.
@Mapping(target = "stringDate", source="carDTO.regDttm", qualifiedByName = "localDateTimeToString")
target이 객체일경우에는 객체.변수로 매핑을 해줘야한다.
source의 객체가 여러개고 같은 name이라는 변수를 가지고 있으면
source에 객체.name이라는 변수로 지정해줘야 한다.
@Mapping(target = "notes", source="note")
파라미터명이 다른 파라미터 객체 내에 변수랑 일치해도 오류가 발생하지 않는다. CarDTO 내에 name이 있지만 파라미터 name사용가능@Mapping(target = "notes", source="name")
Car toEntity(CarDTO carDTO, CompanyDTO companyDTO, ReservationDTO reservationDTO, String name, String testParameter);
# Product.class
@Comment("유저아이디")
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
private String description;
# User.class
private String description;
# ProductMapper.class
@Mapping(target="description", source="dto.description")
@Mapping(target="user", source="user")
Product toCreateEntity(ProductCreateDTO dto, User user, String productNameInitials);
겹치는 변수명 명시안할시 오류 메세지
default Car toEntity(CarDTO carDTO, CompanyDTO companyDTO, ReservationDTO reservationDTO, String note, String testParameter){
return Car.builder().build();
}
default 메서드로 만든 후 직접 구현하는 방법도 있다.MapStruct 1.5.5 Final 문서 : https://mapstruct.org/documentation/1.5/reference/html/