[Spring] MapStruct 사용법

klmin·2024년 10월 6일
0
post-thumbnail

Spring Boot : 3.3.4
MapStruct : 1.5.5 Final
Java : 21
Lombok : 1.8.34

MapStruct 란?

Java 애플리케이션에서 객체 간의 매핑을 쉽게 해주는 매핑 프레임워크이다.
Annotation processor를 이용해 동작한다.
주로 DTO <-> Entity간의 변환작업시 사용되며 컴파일 시점에 매핑 코드를 자동으로 생성해준다.
매핑을 명시해주는 인터페이스를 선언하고 빌드시 인터페이스 기반으로 클래스 파일이 생기며 사용할 곳에서 의존성 주입해 사용하는 방식이다.
컴파일시 변환 클래스가 생성되기 때문에 속도가 빠르다.
예전

  • 주요 기능 및 특징

    1. 컴파일 타임에 매핑 코드 생성
      MapStruct는 런타임이 아니라 컴파일 시점에 매핑 코드를 생성한다. 이로 인해 성능 오버헤드가 적고, 매핑이 잘못되었을 경우 컴파일 타임에 오류를 발견할 수 있다.

    2. 자동 매핑
      동일한 이름의 필드는 자동으로 매핑된다. 예를 들어, User 엔티티와 UserDTO 객체의 필드 이름이 같다면 별도의 설정 없이 자동으로 매핑이 가능하다.

    3. 커스터마이징 가능
      필드 이름이 다르거나 복잡한 매핑 로직이 필요할 때는 수동으로 매핑을 정의할 수 있다. 이를 위해 MapStruct는 다양한 어노테이션을 제공하며 매핑을 유연하게 커스터마이징할 수 있다.

    4. 지원하는 매핑 방식:
      기본적인 객체 간 필드 복사 외에도 컬렉션(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;
}
  • 주로 사용하는 매핑방식
    1. 객체 수동변환
      메서드를 사용해 객체를 수동으로 변환
      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();
    2. ModelMapper
      런타임시 리플렉션을 활용하기때문에 성능상 좋지 않다고 한다.
      CarDTO carDTO = CarDTO.builder()
                    .id(id)
                    .name(name)
                    .price(price)
                    .options(options)
                    .color(color)
                    .regDttm(regDttm)
                    .build();
                    
      Car car = modelMapper.map(carDTO, Car.class);
    MapStruct는 mapper 클래스를 만들어주고 정의를 해주면 컴파일시에 자동으로 클래스파일이 생성되어 갖다가 쓸 수 있다.
    변환 클래스파일을 만들어줘야 하는 단점이 있다.
    복잡한 경우 명시적으로 매핑을 해줘야할 필요성이 있다.

의존성 설정

  1. lombok이 mapstruct 보다 먼저 선언되야함.
    mapstruct를 lombok보다 먼저 선언하면 객체에 롬복 어노테이션이 있을시 @Getter, @Setter, @Builder등이 작동하지 않아 mapstruct가 변환을 못한다고 한다.
 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이 먼저 선언되어야함.
  1. 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/

mapstruct 사용법

  • 우선순위
    1. 빌더 (@Builder가 있으면 우선적으로 사용)
      부모클래스도 빌더를 사용하려면 superbuilder 선언 해야함.
    2. 생성자 (매개변수가 있는 생성자가 있으면 사용)
    3. setter 메서드 (객체 필드에 값을 설정할 때 사용)
    4. getter 메서드 (소스 객체에서 값을 읽을 때 사용)

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
    생성된 매퍼 구현체의 빈 관리 전략을 정의

    1. 기본 (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);
  1. spring 빈 주입
    componentModel ="Srping" 선언하면 @Component가 붙어 빈으로 사용할 수 있다.



  • 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이라는 변수로 지정해줘야 한다.

  • 매개변수로 객체가 아닌 단일 파라미터
    target 객체와 변수명이 같다면 별도 선언을 안해줘도되지만(String testParameter)
    변수명이 다르다면 이런식으로 선언해줘야한다.(String note)
    @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);

겹치는 변수명 명시안할시 오류 메세지

  • 메서드 사용자정의
    mapstruct로 인해 변환이 복잡한 경우라면 기존 매퍼 메서드를 제거하고
    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/

profile
웹 개발자

0개의 댓글