java

java mapping library MapStruct

maruoov 2022. 12. 18. 16:37

자바 프로그램 개발을 하면서 가장 많이 하는 일중 하나가 객체 변환인것 같다.
database entity -> dto 변환, 도메인 레이어 별로의 dto 변환등의 작업이 많이 필요하다.
이는 필드 추가/제거시에도 양쪽 dto 변환, 레이어별로 변환 작업이 항상 필요하여 귀찮은 작업이 발생하고 실수가 발생할 여지가 많았다.

지금까지 위의 변환을 해왔던 방법으로는
1.  메소드 안에서 new 객체 생성을 통한 변환
2. 메소드 안에서 빌더패턴을 이용한 변환
3. private 메소드로 분리
4. 별도 converter 생성하고 static method 를 통한 변환
5. class 마다 변환 메소드 생성
등의 방법을 해왔던것 같다.

객체의 수가 적다면 위의 방법으로도 충분하지만, 프로젝트 진행시 도메인 레이어가 여러 레이어로 나눠지고 레이어별 dto 를 따로 가지다 보니 특정 경우에 따른 변환 메소드 생성 등으로 인한 코드의 비대해짐, 필드 추가/제거 시의 누락에 대한 실수와 스트레스가 지속적으로 발생했다.

더 좋은 방법이 있을까 고민하던중 MapStruct 라는 java mapping library 를 찾아서 정리해본다
다음은 홈페이지에 있는 해당 라이브러리를 사용해야 하는 이유인데, 위의 고민을 해결해주고자 한다.

Why?

Multi-layered applications often require to map between different object models (e.g. entities and DTOs). Writing such mapping code is a tedious and error-prone task. MapStruct aims at simplifying this work by automating it as much as possible.

In contrast to other mapping frameworks MapStruct generates bean mappings at compile-time which ensures a high performance, allows for fast developer feedback and thorough error checking.


그럼 사용법을 알아보자
다음과 같은 세 종류의 entity, dto 가 있다고 하자

@Getter
public class CarEntity {

    private long id;
    private String name;
    private String type;
    private LocalDate productionDate;
    private long price;

    @Builder
    public CarEntity(long id, String name, String type, LocalDate productionDate, long price) {
        this.id = id;
        this.name = name;
        this.type = type;
        this.productionDate = productionDate;
        this.price = price;
    }
}

@Getter
public class CarDto {

    private String name;
    private String type;
    private LocalDate productionDate;
    private long price;

    @Builder
    public CarDto(String name, String type, LocalDate productionDate, long price) {
        this.name = name;
        this.type = type;
        this.productionDate = productionDate;
        this.price = price;
    }
}

@Getter
public class CarResponse {

    private String carName;
    private String carType;
    private String price;

    @Builder
    public CarResponse(String carName, String carType, String price) {
        this.carName = carName;
        this.carType = carType;
        this.price = price;
    }
}

이 세 객체간의 변환을 MapStruct  를 이용해서 해보자.
@Mapper 인터페이스를 추가해주고, 변환대상->결과 메소드를 만들어준다

@Mapper
public interface CarMapper {
    CarDto entityToDto(CarEntity carEntity);
}

그리고 다음과 같이 객체 변환 테스트 코드를 작성해보자

public class MapStructTest {
    CarMapper MAPPER = Mappers.getMapper(CarMapper.class);

    @Test
    public void mapstruct() {
        CarEntity carEntity = CarEntity.builder()
                .id(1L)
                .name("그랜져")
                .type("hybrid")
                .productionDate(LocalDate.of(2022, 12, 18))
                .price(3000L)
                .build();

        final CarDto result = MAPPER.entityToDto(carEntity);

        assertThat(result.getName()).isEqualTo(carEntity.getName());
        assertThat(result.getType()).isEqualTo(carEntity.getType());
        assertThat(result.getProductionDate()).isEqualTo(carEntity.getProductionDate());
        assertThat(result.getPrice()).isEqualTo(carEntity.getPrice());
    }
}


Mapper 만 만들어주고 별도로 해준거 없이 성공적으로 테스트 코드가 성공하는걸 알 수 있다.
MapStruct 는 어떻게 변환을 해주는 걸까?

@Mapper 를 생성하고 빌드시에 다음과 같은 클래스를 생성해준다. 기본적으로 같은 필드에 대해서 변환을 수행해준다.

@Generated(
    value = "org.mapstruct.ap.MappingProcessor",
    date = "2022-12-18T16:06:20+0900",
    comments = "version: 1.5.2.Final, compiler: IncrementalProcessingEnvironment from gradle-language-java-7.5.1.jar, environment: Java 17.0.4 (JetBrains s.r.o.)"
)
public class CarMapperImpl implements CarMapper {

    @Override
    public CarDto entityToDto(CarEntity carEntity) {
        if ( carEntity == null ) {
            return null;
        }

        CarDto.CarDtoBuilder carDto = CarDto.builder();

        carDto.name( carEntity.getName() );
        carDto.type( carEntity.getType() );
        carDto.productionDate( carEntity.getProductionDate() );
        carDto.price( carEntity.getPrice() );

        return carDto.build();
    }
}


필드 이름이 다른 경우에는 설정 추가를 통해 변환을 해줄수 있도록 되어있다.
만약 필드 이름을 틀린다거나, 다른 자료형에 대해 변환을 한다거나, 누락해야 하거나, 기본값을 줘야 하는 경우라면 어떻게 해야할까?

위의 필드 이름이 다른 CarDto, CarResponse 로 테스트를 해보자
만약 별도 설정없이 추가를 해준다면, 이름이 같은 필드에 대해서만 변환을 해준다.

@Mapper
public interface CarMapper {
    CarResponse dtoToResponse(CarDto carDto); // 별도 설정 없이 선언만 해줬다.
}

// 빌드시 다음과 같은 코드가 생성된다


@Generated(
    value = "org.mapstruct.ap.MappingProcessor",
    date = "2022-12-18T16:11:33+0900",
    comments = "version: 1.5.2.Final, compiler: IncrementalProcessingEnvironment from gradle-language-java-7.5.1.jar, environment: Java 17.0.4 (JetBrains s.r.o.)"
)
public class CarMapperImpl implements CarMapper {

    @Override
    public CarResponse dtoToResponse(CarDto carDto) {
        if ( carDto == null ) {
            return null;
        }

        CarResponse.CarResponseBuilder carResponse = CarResponse.builder();

        carResponse.price( String.valueOf( carDto.getPrice() ) );
// 이름이 같은 필드만 변환해주고, 형변환을 해주는걸 볼수 있다 
        return carResponse.build();
    }
}


필드 이름이 다른 경우를 설정해주려면, @Mapping Annotation 을 사용해준다

@Mapper
public interface CarMapper {
    @Mapping(source = "name", target = "carName")
    @Mapping(source = "type", target = "carType")
// @Mapping annotation 을 사용해 필드 이름 맵핑
    CarResponse dtoToResponse(CarDto carDto);
}



@Generated(
    value = "org.mapstruct.ap.MappingProcessor",
    date = "2022-12-18T16:14:45+0900",
    comments = "version: 1.5.2.Final, compiler: IncrementalProcessingEnvironment from gradle-language-java-7.5.1.jar, environment: Java 17.0.4 (JetBrains s.r.o.)"
)
public class CarMapperImpl implements CarMapper {


    @Override
    public CarResponse dtoToResponse(CarDto carDto) {
        if ( carDto == null ) {
            return null;
        }

        CarResponse.CarResponseBuilder carResponse = CarResponse.builder();

// 설정한 필드에 대해 맵핑 코드 생성
        carResponse.carName( carDto.getName() );
        carResponse.carType( carDto.getType() );
        carResponse.price( String.valueOf( carDto.getPrice() ) );

        return carResponse.build();
    }
}


만약 동일한 필드에 대해서 맵핑을 설정해주면 어떻게 될까?
위의 맵핑의 target 을 동일한 필드로 바꾸고 빌드를 해보니 다음과 같은 에러가 발생했다.

CarMapper.java:13: error: Target property "carName" must not be mapped more than once.
    CarResponse dtoToResponse(CarDto carDto);

다행히 휴먼 에러에 대해서도 어느정도 방지가 되어 있다.

위의 테스트코드에서는 getMapper 를 통해서 mapper 를 가져왔는데, 스프링의 빈으로 등록하여 주입받을순 없을까?
Mapper 에 간단한 설정만 추가해주면 빈으로 사용할수 있다.

@Mapper(componentModel = "spring")
public interface CarMapper {

    CarDto entityToDto(CarEntity carEntity);

    @Mapping(source = "name", target = "carName")
    @Mapping(source = "type", target = "carType", defaultValue = "type")
    CarResponse dtoToResponse(CarDto carDto);
}



@Generated(
    value = "org.mapstruct.ap.MappingProcessor",
    date = "2022-12-18T16:20:44+0900",
    comments = "version: 1.5.2.Final, compiler: IncrementalProcessingEnvironment from gradle-language-java-7.5.1.jar, environment: Java 17.0.4 (JetBrains s.r.o.)"
)
@Component // @Component 가 붙은걸 볼수 있다.
public class CarMapperImpl implements CarMapper {
}


@SpringBootTest
public class MapStructTest {

    @Autowired
    CarMapper mapper; // 다음과 같이 SpringBootTest , Autowired 를 통해서 주입받을수 있다

    @Test
    public void mapstruct() {
        CarEntity carEntity = CarEntity.builder()
                .id(1L)
                .name("그랜져")
                .type("hybrid")
                .productionDate(LocalDate.of(2022, 12, 18))
                .price(3000L)
                .build();

        final CarDto result = mapper.entityToDto(carEntity);

        assertThat(result.getName()).isEqualTo(carEntity.getName());
        assertThat(result.getType()).isEqualTo(carEntity.getType());
        assertThat(result.getProductionDate()).isEqualTo(carEntity.getProductionDate());
        assertThat(result.getPrice()).isEqualTo(carEntity.getPrice());
    }
}



MapStruct 를 사용한다고 했을때, 객체 변환간의 성능 저하는 없을까?
일반적으로 생각했을때 런타임에 타빕변환을 추론하는게 아닌 빌드타임에 변환 코드를 만들어놓고 실행하는 것이므로 성능 저하가 발생하진 않을거 같은데 테스트 해보자

public class MapStructPerformanceTest {

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

    @Test
    public void performanceTest() {
        CarEntity carEntity = CarEntity.builder()
                .id(1L)
                .name("그랜져")
                .type("hybrid")
                .productionDate(LocalDate.of(2022, 12, 18))
                .price(3000L)
                .build();


        long mapstructTransformStart = System.currentTimeMillis();
        for (int i = 0; i < 500_000; i++) {
            CarDto carDto = MAPPER.entityToDto(carEntity);
        }
        long mapstructTransformEnd = System.currentTimeMillis() - mapstructTransformStart;

        System.out.printf("%.2f\n", (double)mapstructTransformEnd / 1000.0);

        long normalTransformStart = System.currentTimeMillis();
        for (int i = 0; i < 500_000; i++) {
            CarDto carDto = CarDto.builder()
                    .name(carEntity.getName())
                    .type(carEntity.getType())
                    .price(carEntity.getPrice())
                    .productionDate(carEntity.getProductionDate())
                    .build();
        }
        long normalTransformEnd = System.currentTimeMillis() - normalTransformStart;

        System.out.printf("%.2f\n", (double)normalTransformEnd / 1000.0);
    }
}

 

0.01
0.01

 

운영환경에서의 테스트는 아니지만, 성능 저하는 없는 것으로 볼수 있을것 같다.


매우 다양한 옵션들이 있어 전부 다 정리하지는 못했지만, 생각보다 다양한 기능들과 휴먼에러 가능성을 줄여주는걸 알수 있었다.
물론 라이브러리를 사용할 경우에도 휴먼에러 발생 가능성이 있고, java class 혹은 method 로 변환을 관리하는 것보다 annotation 을 통해서 관리하는게 에러가 발생할 가능성이 높다고 할수도 있을거 같다.
하지만 어떤 방식을 사용하든 실수의 가능성은 있으므로 변환 방식이 중요한게 아니라
테스트 코드 작성, CI등을 통해서 검증하는게 결국엔 더 좋지 않을까란 생각이 든다.
물론 이런 프레임워크를 사용한다 하더라도 객체 변환 코드로부터 완전히 자유로울 순 없겠지만 기존 방식의 수많은 맵핑 코드 생성, 레이어간 변환에서 좀더 편의성을 가질수 있지 않을까 생각한다.

현재 특정 프로젝트는 외부인프라스트럭쳐/도메인모델/dto모델/응답모델이 다 나뉘어져 있어 필드 추가 제거등에 대해 스트레스가 있는 편인데 도입해봐도 좋지 않을까 생각한다.

'java' 카테고리의 다른 글

java subList memory leak  (0) 2023.02.20
Spring Webclient queryParam 특수문자 encoding 이슈  (1) 2022.12.20