반응형

MapStruct란?

  • 어노테이션 기반으로 작성되며 Bean으로 등록할 수 있어 여러 프레임워크의 DI를 활용하여 사용할 수도 있다.
  • 타입 세이프하게 객체의 타입 변환 시에 데이터 매핑을 도와주는 어노테이션 프로세서
  • 서버 어플리케이션을 개발할 때 작업하는 DTO 변환 작업은 대부분이 반복적인 작업이 대부분
  • 도메인 객체를 풍부하게 사용하면서, 반환 데이터가 달라지게 될 경우 이를 적절하고 큰 힘을 들이지 않고 매핑할 수 있도록 도와주는 것이 MapStruct
  • 리플렉션이 아닌 직접 메소드를 호출하는 방식으로 동작하여 속도가 빠름
  • 컴파일 시점에 매핑 정보가 타입 세이프한지를 검증함.
  • 빌드 타임에 매핑이 올바르지 않다면 에러 정보를 log에 띄워줌

SetUp

dependencies{
    implementation 'org.mapstruct:mapstruct:1.4.2.Final'
    annotationProcessor 'org.mapstruct:mapstruct-processor:1.4.2.Final'
}

Mapper 정의하기

1. 기본적인 매핑 방법

  • 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가 해당 인터페이스의 구현체를 생성해준다.
  • 구현체 생성 시 source가 되는 클래스와 target이 되는 클래스의 속성명을 비교하고 자동으로 매핑 코드를 작성한다.
  • 매핑될 속성명이 다를 경우 @Mapping 어노테이션을 통해 매핑정보를 맞춰준다.

위와 같은 Mapper를 생성했다면 MapStruct는 아래와 같은 구현체를 자동으로 생성해준다.

// GENERATED CODE
public class CarMapper 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 personDto(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;
    }
}

2. 여러 개의 soruce 파라미터로 매핑 메소드 작성

  • MapStruct는 파라미터가 여러 개인 경우에도 기능을 지원한다. 여러 엔티티들을 합칠 때 용이하다.
@Mapper
public interface AddressMapper {
    @Mapping(source = "person.description", target = "description")
    @Mapping(source = "address.houseNo", target = "houserNumber")
    DeliveryAddressDto personAndAddressToDeliveryAddressDto(Person person, Address address);
}
  • 각 송성들은 이름을 비교하여 자동으로 매핑된다.
  • source들의 속성명이 겹치는 경우 @Mapping 어노테이션을 통해 어느 source의 속성을 매핑할 것인지 명시해줘야 한다.

아래와 같이 source로 주어진 파라미터를 직접적으로 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
public interface CustomerMapper {
    @Mapping(target = "name", source = "recored.name")
    @Mapping(target = ".", source = "record")
    @Mapping(target = ".", source = "account")
    Customer customerDtoToCustomer(CustomerDto customerDto);
}
  • "."는 target의 this를 의미한다.
  • CustomerDto.record와 CustomerDto.account를 매핑할 source로 지정한다.
  • record와 account에 매핑할 속성명이 겹치는 경우에는 @Mapping 어노테이션으로 명시하여 지정해준다. 만약 record와 account가 name이라는 이름의 속성을 둘 다 갖고 있다면 첫번째 @Mapping 어노테이션처럼 어느 source의 name을 사용할 건지 명시해준다.

3. Mapper에 커스텀 메소드 작성

  • MapStruct가 자동으로 생성하는 매핑 코드를 불가피하게 쓰지 못하는 경우가 있다. 이런 경우에 아래 두 가지 방법으로 Mapper에 커스텀 메소드를 작성하여 사용할 수 있게 해준다.

1) 인터페이스에 default 메소드로 커스텀 매핑을 추가하는 방법

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

    default PersonDto personToPersonDto(Person person){
        //hand-written mapping logic
    }
}

2) Mapper를 추상 클래스로 정의하는 방법

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

    public PersonDto personToPersonDto(Person person){
        //hand-written mapping logic
    }
}

4. MapStruct로 target을 인스턴스로 받아 업데이트

  • MapStruct는 source를 인자로 받아 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되고 source의 컬렉션으로 업데이트 한다.
    • CollectionMappingStrategy.ADDER_PREFERRED or CollectionMappingStrategy.TARGET_IMMUTABLE: 기존의 target 객체의 컬렉션 객체를 유지한채로 새로운 데이터를 추가하여 update한다.

5. 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를 통해 사용할 수 있다.

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

 

반응형

+ Recent posts