Skip to main content

Command Palette

Search for a command to run...

mapstruct 로 보일러플레이트 코드 줄이기

Updated
mapstruct 로 보일러플레이트 코드 줄이기
K

backend developer interested in technical problem

배경

사내에서 교통 모니터링용 레이더 디바이스를 관리하는 API를 개발했을때 이야기입니다. 반복되는 코드에서 느꼈던 피로를 개선하고자 mapstruct를 적용해봤습니다.

실황

우선 일부 필드만 추출한 Device에 대해 간단히 이야기해야할 것 같습니다. 교통 모니터링용 레이더 디바이스로 신호등이나 가로등에 설치하며 설치된 좌표를 기록하는 위도, 경도와 레이더가 바라보는 방향을 의미하는 heading_angle필드 등이 있습니다.

public class Device extends BaseEntity {

    @Id
    @Column(name = "id", columnDefinition = "uuid", nullable = false)
    private UUID id;

    @Column(name = "serial_number", nullable = false, length = 100)
    private String serialNumber;

    @Column(name = "latitude", nullable = false)
    private Double latitude;

    @Column(name = "longitude", nullable = false)
    private Double longitude;

    @Column(name = "heading")
    private Double heading;

    ...

    }

소규모 프로젝트이고 기능이 단순CRUD기능만 있기 때문에 JPA엔티티를 그대로 도메인 엔티티 처럼 사용했습니다. 때문에 JPA엔티티-도메인엔티티 사이의 변환은 불필요하지만, 그럼에도 여전히 많은 매핑을 해야했습니다. 응답dto로, 생성/수정요청dto로 변환해야하기 때문입니다.

휴먼에러로 인한 필드 누락

필드가 많아질수록 누락되는 필드가 생길 확률이 높아집니다. 해당 프로젝트는 아니지만 필드가 많은 dto를 매핑할 때 몇번이고 매핑이 잘 되었는지 확인한 기억이 있습니다. (ㄱ-)

  // DeviceMapper.java - createdAt, updatedAt 누락
  // 필드가 많아질 수록 발생할 가능성이 높아진다.
  public DeviceResponse toResponse(Device entity) {
      return DeviceResponse.builder()
          .id(entity.getId())
          .serialNumber(entity.getSerialNumber())
          // createdAt, updatedAt을 넣어줘야함
          .build();
  }

반복되는 작업

예를들어 Device Entity에 status필드 추가 시에 수정해야하는 코드들은 다음과 같습니다.

  1. Device.java (필드 추가)

  2. DeviceResponse.java (필드 추가)

  3. DeviceResponse.of() (매핑 추가)

  4. DeviceUpdateRequest.java (필드추가)

  5. DeviceMapper.toResponse() (매핑 추가)

  6. DeviceMapperTest.java (테스트 수정)

어떻게 개선할 수 있을까

해결책 선택지

Java reflection기반 ModelMapper

런타임에 Reflection을 사용해 자동으로 필드를 매핑하는 라이브러리입니다. 별도 설정 없이 modelMapper.map(entity, DeviceResponse.class) 한 줄이면 끝나는 간편함이 매력적이었습니다. 하지만 Reflection 특성상 필드명 오타나 타입 불일치를 컴파일 타임에 잡아낼 수 없고, 런타임 에러가 발생했을 때 디버깅이 어렵다는 단점 때문에 프로덕션 환경에는 적합하지 않다고판단했습니다. 추가로 대량 데이터 처리 시 Reflection 오버헤드로 인한 성능 이슈도 고려 대상이었습니다.

(채택)Mapstruct

컴파일 시점에 매핑 코드를 자동 생성하는 Annotation Processor 기반 라이브러리입니다. 초기 학습 곡선과 Annotation Processor 설정이 필요하다는 진입 장벽이 있었지만, 컴파일 타임에 타입 검증을 수행해 필드 누락이나 타입 불일치를 배포 전에 발견할 수 있다는 점이 결정적이었습니다. 생성된 코드를 직접 확인할 수 있고, Reflection을 사용하지 않아 수동 매핑과 동일한성능을 유지하면서도 보일러플레이트 코드를 줄일 수 있었습니다.

Mapstruct 를 써보자

mapstruct

MapStruct란 Entity를 DTO로 변환하거나 DTO를 Entity로 변환하려고 할 때 사용하는 객체 매핑 라이브러리입니다.

사용법

사용법을 다루기엔 글이 다른곳으로 샐 것 같아서, 숙지시에 도움이 되었던 글을 소개하고자합니다.

네이버클라우드 기술블로그에서 간단한 사용법을 볼 수있습니다.

MapStruct 1.6.3 Reference Guide

위의 글과 아래의 Quick Guide to Mapstruct만 봐도 기본적인 사용법은 숙지가 되었습니다.

A quick and practical guide to using MapStruct

보다 자세한 내용은 mapstruct 공식문서를 참고하시면 될 것 같습니다.

mapstruct 공식문서

Mapstruct 실전 적용시 알아둘 것

사용법은 생략했지만 몇가지 유의할 점은 짚고 넘어가야 할 것 같습니다.

unmappedTargetPolicy: 매핑되지 않은 필드 처리

MapStruct는 기본적으로 sourcetarget의 모든 필드를 매핑하려고 시도합니다. 하지만 실무에서는 target에만 있는 필드가 종종 있습니다.

아래는 그 예시 입니다. Device를 생성하는 요청dto를 Device엔티티로 매핑할 때 id필드는 DB혹은 애플리케이션 내부에서 생성하기 때문에 요청dto에는 포함되어있지 않습니다.

// DeviceCreateRequest (source)
public class DeviceCreateRequest {
    private String serialNumber;
    private Double latitude;
    private Double longitude;
    private Double heading;
    private String model;
    // id 필드없음. id필드는 애플리케이션내부에서 UUID를 생성하기 떄문에 생성요청dto에는 없다.
}

// Device Entity (target)
public class Device {
    private UUID id;  // Request에 없음
    private String serialNumber;
    private Double latitude;
    private Double longitude;
    private Double heading;
    private String model;

   // Request에 없음
    @OneToMany(mappedBy = "device")\
    private List<DeviceIntersectionGroup> deviceIntersectionGroups; 
}

//unmappedTargetPolicy를 WARN으로 설정한 매퍼.
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.WARN,
  nullValueMappingStrategy = NullValueMappingStrategy.RETURN_DEFAULT)
public interface DeviceMapperMapstruct {


Device toEntity(DeviceCreateRequest request);

이상태에서 매퍼를 통해 매핑을 한다면 source에는 없지만 target에는 존재하는 unmapped필드가 존재하게됩니다. 이러한 필드에 대한 처리 정책을 정하는게 unmappedTargetPolicy입니다. IGNORE,WARN, ERROR 3가지 정책이 있습니다. IGNORE를 제외하고 WARN과 ERROR로 설정할 때 무슨결과가 나오는지 알아보겠습니다.

  • IGNORE: 매핑되지 않은 필드가 있더라도 무시

  • WARN: 컴파일시 경고 메시지 출력

  • ERROR: 컴파일시 에러로 인해 컴파일 실패 처리

unmappedTargetPolicy = ReportingPolicy.WARN으로 설정했을때

매핑되지 않은 필드에 대한 어노테이션을 주석처리했을때, 컴파일을 해보면 아래와같은 경고메시지를 볼 수있습니다. ReportingPolicy.WARN은 별도로 unmappedTargetPolicy를 지정하지 않을때 기본값이기도합니다.

unmappedTargetPolicy = ReportingPolicy.ERROR로 설정했을 때

다음은 unmappedTargetPolicy = ReportingPolicy.ERROR로 설정한뒤 컴파일 했을 경우에 컴파일 결과입니다. 에러와 함께 컴파일이 실패했기 때문에 누락된 필드에 대해 인지할 수 있습니다.

누락이아닌 명시적으로 제외해야하는 필드가 있다면 어노테이션 속성값에 ignore=true를 추가해주면 됩니다.

예시{@Mapping(target = "id", ignore = true)}

어떻게 설정하는게 좋을까

저는 매핑에 실수가 없도록 하고자 unmappedTargetPolicy = ReportingPolicy.ERROR로 설정했습니다. 이유는 다음과 같습니다.

  1. WARN이었다면 경고만 보고 넘어갈 수 있지만, ERROR는 무조건 처리해야 합니다.

  2. 의도가 명확합니다. @Mapping(target = "id", ignore = true) -> 코드만 봐도 이 필드는 의도적으로 제외한 거구나 알 수 있습니다.

  3. 팀 협업 시 용이

    • 새로운 팀원이 필드를 추가해도 컴파일 에러로 확실히 알릴수있고, 코드 리뷰 없이도 자동 검증이 가능합니다.

nullValueStrategy: null처리 전략

MapStruct에서 필드 수준의 null 처리 전략은 nullValuePropertyMappingStrategynullValueMappingStrategy 두 가지로 제어합니다.

  • nullValueMappingStrategy엔티티 전체 매핑 시 source가 null일 때의 동작을 정의합니다.

  • nullValuePropertyMappingStrategy매핑 대상 필드가 null일 때 각각의 필드에 대한 동작을 정의합니다.

nullValueMappingStrategyRETURN_NULLRETURN_DEFAULT 두 가지 전략이 있습니다.

  • RETURN_NULL: source가 null이면 null 반환 (기본값)

  • RETURN_DEFAULT: source가 null이면 디폴트값 반환 (NPE 방지)

nullValuePropertyMappingStrategy

  • SET_TO_NULL: null인 필드 값을 대상에 null로 설정

  • SET_TO_DEFAULT: primitive 타입의 기본값으로 설정(예: int 0, boolean false)

  • IGNORE: null인 경우 대상의 기존 값을 유지

실제로 mapstruct가 생성한 코드를 비교해보겠습니다. 우선 클래스자체가 null인 경우필드중 한가지 값이 null인 경우 두 가지 케이스를 나눠서 보겠습니다.

nullValueMappingStrategy: 클래스 레벨 null

nullValueMappingStrategy = RETURN_DEFAULT의 경우 mapstruct라이브러리가 아래와같이 우선 빈객체라도 만들어서 리턴하는 코드를 생성하는것을 볼 수 있습니다.

nullValueMappingStrategy = RETURN_NULL 의 경우 아래처럼 객체가 null일경우 그대로 null을 반환하는 코드를 자동으로 생성한것을 볼 수 있습니다.

nullValuePropertyMappingStrategy: 필드 레벨 null처리

위에서 보여준 예시는 객체자체가 null이아닐뿐, 필드에 대해서는 null에 대한 처리가 되어있지 않습니다. 이경우 여전히 NPE의 위험이 존재하기 때문에 필드별로 null에 대한 처리를 해줘야하죠.

때문에 아래처럼 메서드 레벨에서 필드별로 기본값을 지정할 수 있습니다. source가 null일 경우 targetdefaultValue에 해당하는 값을 넣어줍니다.

위과 같이 필드에 대한 어노테이션을 추가해주면 mapstruct가 생성한 코드는 아래와같이 필드가 null일 경우에 어노테이션에서 설정한 기본값으로 설정해줍니다.

부분 update로직에서는?

REST API에서 PATCH 요청을 처리할 때, null이 아닌 필드만 업데이트하고 싶은 경우가 있습니다. 혹은 클라이언트가 어떤 요청을 보낼 지 모르니 null인경우에 대한 처리를 해줘야 데이터 유실을 방지할 수 있습니다.

예를 들어, Device의 위도(latitude)필드만 수정하고 싶을 때 다음과 같은 요청을 보냅니다. (팀의 정책에 따라 기존에 존재하는 값 기반으로 수정할 필드와 기존 필드값도 포함해 모든 필드값을 보내게 할 수도 있습니다. 수정할 필드만 보내는 API가 있다는 경우에 대해 이야기하겠습니다!)

{"latitude": 37.5665}

이 경우 latitude만 업데이트하고, 나머지 필드(longitude, heading, model)는 기존 값을 유지해야 합니다.

MapStruct의 부분 업데이트 지원

MapStruct는 @MappingTargetnullValuePropertyMappingStrategy.IGNORE를 조합하면 부분 업데이트를 지원합니다. 아래처럼 구현하면 request의 필드가 null이면 device의 기존 값을 유지하고, null이 아닌 필드만 업데이트합니다.

@Mapper(componentModel = "spring",
      nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
public interface DeviceMapper {

  @Mapping(target = "id", ignore = true)
  void updateDevice(@MappingTarget Device device, DeviceUpdateRequest request);
  }

하지만 저는 Setter사용을 지양하기 때문에 Setter가 없습니다. Setter없는 클래스에서는 다음과 같이 아무런 매핑을 수행하지 않는 코드가 생성됩니다.

MapStruct는 내부적으로 다음과 같은 코드를 생성하려고 시도합니다 device.setLatitude(request.getLatitude()); ,device.setLongitude(request.getLongitude()); 하지만 Setter가 존재하지 않기 때문에 아무런 코드가 생성되지 않은채 함수가 리턴되는 것 입니다.

하지만 저는 Device를 불변 객체(Immutable)로 만들었고 때문에 setter가 없어서 MapStruct는 빈 메서드를 생성합니다.

Setter를 사용하지 않거나 불변패턴을 사용하는 객체는 Builder를 메서드 인자로 넘겨주면됩니다.

다음와 같은 코드가 생성됩니다

코드 비교

응답 DTO를 생성하기 위한 매핑 메서드를 생성할 경우 mapstruct는 메서드 선언만 하면 완료되는것에 비해 사용하지 않으면 일일이 매핑로직을 작성해 줘야합니다. 보일러플레이트 코드를 구현하는 노고를 줄일 수 있습니다. 사용법을 숙지하면 코드를 상당량 줄일 수 있고, 반복되는 코드에서 오는 피로감을 줄일 수 있습니다. 몇줄 차이가 나지 않아보이지만 매핑 메서드 여러개가 만들어지면 많은 양이 차이가 나게됩니다.

[mapstruct사용 전 매퍼 구현]

[mapstruct를 사용 시 매퍼]

익숙해지면 생산성에 좋은 영향을 줄 것 같은 mapstruct 라이브러리에 대해서 알아봤습니다. 글 읽어주셔서 감사합니다.

참고