편리한 객체 간 매핑을 위한 MapStruct 적용기 (feat. SENS)

Ncloud 문자/알림 발송 서비스 SENS 개발 과정에서 MapStruct를 활용해 보았습니다.

NAVER Cloud
NAVER Cloud
25 min readDec 15, 2022

--

안녕하세요, 네이버 클라우드 플랫폼 (Ncloud) 입니다.

Ncloud의 메시지 알림 서비스 Simple & Easy Notification Service(이하 SENS)를 개발하며 경험한 내용을 공유드립니다.

개발하다 보면 객체 간의 매핑은 거의 필수라고 할 수 있는데요, SENS를 진행하면서도 객체 간 매핑은 피할 수 없었습니다.

SENS 프로젝트는 멀티 모듈로 구성했고, 객체 간 인터페이스를 DTO로 두었습니다. 그러다 보니 각 모듈에서 필요한 객체를 DTO를 이용해 처리하는 경우가 많아졌고, 코드 복잡성도 높아지고 매핑 관리도 어려웠습니다.

이런 상황에서 ModelMapper와 같은 일반적인 매퍼를 사용하면, 매핑에 필요한 로직이 모두 분산되어 매핑이 많아질수록 코드를 관리하기가 힘들었습니다.

코드 복잡성과 관리의 어려움에서 벗어나기 위해 SENS에서는 MapStruct 를 적용해보았는데요. MapStruct를 적용하고 사용한 경험을 공유드립니다.

MapStruct 란?

MapStructJava bean 유형 간의 매핑 구현을 단순화하는 코드 생성기입니다.

MapStruct의 특징은 아래와 같습니다.

  • 컴파일 시점에 코드를 생성하여 런타임에서 안정성을 보장합니다.
  • 다른 매핑 라이브러리보다 속도가 빠릅니다.
  • 반복되는 객체 매핑에서 발생할 수 있는 오류를 줄일 수 있으며, 구현 코드를 자동으로 만들어주기 때문에 사용이 쉽습니다.
  • Annotation processor를 이용하여 객체 간 매핑을 자동으로 제공합니다.
  • 다만, Lombok 라이브러리에 먼저 dependency (의존성) 추가가 되어있어야 합니다. MapStruct는 Lombok의 getter, setter, builder를 이용하여 생성되므로 Lombok 보다 먼저 의존성이 선언된 경우 실행할 수 없습니다.

MapStruct 사용 방법

SENS에서 MapStruct를 사용한 방법입니다. 아래 사용된 코드는 샘플 코드임을 참고 부탁드립니다.

1. 기본 사용방법

MapStruct를 사용하기 위해서는 먼저 dependency (의존성) 추가가 필요합니다.

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

다만 주의할 점은 MapStruct가 Lombok보다 뒤에 dependency 선언이 되어야 합니다.

MapStruct는 Lombok의 getter, setter, builder를 이용하여 생성되므로 Lombok보다 먼저 dependency가 선언 되는 경우 정상적으로 실행할 수 없습니다.

먼저 API를 통해 메세지를 보낼 body를 받았다고 가정해보겠습니다. 해당 메세지는 아래 RequestDto 에 담겨져 있습니다.

public class RequestDto {
private String title;
private String content;
private String sender;
private List<String> receiver;
private LocalDateTime requestTime;
private String type;
}

그리고 RequestDto에 담긴 내용을 MessageBodyDto에 매핑하려고 합니다.

public class MessageBodyDto {
private String title;
private String content;
private String sender;
private List<String> receiver;
private LocalDateTime requestTime;
private String type;
}

이제 매핑을 위한 Interface를 만들어줍니다.

@Mapper
public interface MessageMapper {
MessageMapper INSTANCE = Mappers.getMapper(MessageMapper.class);

// RequestDto -> MessageBodyDto 매핑
MessageBodyDto toMessageBodyDto(RequestDto requestDto);
}

Mapper 인터페이스에 @Mapper 어노테이션을 붙이면 MapStruct가 자동으로 MessageMapper의 구현체를 생성해줍니다.

위에서 사용된 MessageMapper INSTANCE = Mappers.getMapper(MessageMapper.class)는 매퍼 클래스에서 MessageMapper 를 찾을 수 있도록 하는 방법입니다. 매퍼 interface에서 위와 같이 Instance를 선언해주면 매퍼에 대한 접근이 가능합니다.

매핑하려는 객체는 필드값이 동일하기 때문에, 구현 코드를 작성 또는 수정하지 않고 쉽게 매핑할 수 있습니다.

자동으로 생성되는 구현체는 아래와 같습니다.

public class MessageMapperImpl implements MessageMapper {

@Override
public MessageBodyDto toMessageBodyDto(RequestDto requestDto) {
if ( requestDto == null ) {
return null;
}

MessageBodyDto.MessageBodyDtoBuilder messageBodyDto = MessageBodyDto.builder();

messageBodyDto.title( requestDto.getTitle() );
messageBodyDto.content( requestDto.getContent() );
messageBodyDto.sender( requestDto.getSender() );
List<String> list = requestDto.getReceiver();
if ( list != null ) {
messageBodyDto.receiver( new ArrayList<String>( list ) );
}
messageBodyDto.requestTime( requestDto.getRequestTime() );
messageBodyDto.requestType( requestDto.getRequestType() );

return messageBodyDto.build();
}
}

매퍼를 위한 interface만 만들면, 매핑이 필요한 객체에 대해 자동으로 구현체를 만들어줍니다. 위 구현체는 빌드 시 build/classes/java/main/에 매핑 인터페이스가 위치한 곳에 만들어지게 됩니다.

지금까지 매핑하고자 하는 컬럼이 동일할 때 매핑하는 방법에 대해 알아보았습니다. 실전에서는 매핑하려는 객체가 단순하지 않은 경우가 있었는데요. 아래에서는 SENS에서 사용했던 다양한 매핑 방법을 설명 드리겠습니다.

2. 매핑에 여러 객체가 필요한 경우

여러 객체를 하나의 객체에 매핑하는 경우입니다.

PageDto와 위에 사용된 RequestDtoMessageServiceDto에 매핑해보려고 합니다.

public class PageDto {
private Integer pageIndex;
private Integer pageCount
}
public class MessageServiceDto {
private String title;
private String content;
private String sender;
private List<String> receiver;
private String type;
private Integer pageIdx;
private Integer pageCnt;
}
public interface MessageMapper {
MessageMapper INSTANCE = Mappers.getMapper(MessageMapper.class);

//PageDto, RequestDto -> MessageServiceDto 매핑
@Mapping(source="pageDto.pageIndex", target="pageIdx")
@Mapping(source="pageDto.pageCount", target="pageCnt")
MessageServiceDto toMessageServiceDto(PageDto pageDto, RequestDto requestDto);
}

매핑하려는 모든 컬럼들이 같다면 별도의 어노테이션으로 표시할 필요가 없지만, 만약 지정해야 하는 경우가 있다면 예시처럼 @Mapping을 이용하여 source에는 매핑값을 가지고 올 대상, target에는 매핑할 대상을 각각 작성해줍니다.

이렇게 코드를 작성하면 매핑할 필드명이 다르거나, 두 객체 간 같은 필드가 있어도 특정 필드를 지정하여 매핑할 수 있습니다.

구현체는 아래와 같이 자동으로 만들어집니다.

    @Override
public MessageServiceDto toMessageServiceDto(PageDto pageDto, RequestDto requestDto) {
if ( pageDto == null && requestDto == null ) {
return null;
}

MessageServiceDto.MessageServiceDtoBuilder messageServiceDto = MessageServiceDto.builder();

if ( pageDto != null ) {
messageServiceDto.pageIdx( pageDto.getPageIndex() );
messageServiceDto.pageCnt( pageDto.getPageCount() );
}
if ( requestDto != null ) {
messageServiceDto.title( requestDto.getTitle() );
messageServiceDto.content( requestDto.getContent() );
messageServiceDto.sender( requestDto.getSender() );
List<String> list = requestDto.getReceiver();
if ( list != null ) {
messageServiceDto.receiver( new ArrayList<String>( list ) );
}
messageServiceDto.type( requestDto.getType() );
}

return messageServiceDto.build();
}

3. 매핑에 여러 파라미터가 필요한 경우

RequestDto와 여러 가지 다른 인자값이 MessageListServiceDto에 매핑되어야 하는 경우입니다.

public class MessageListServiceDto {
private String messageId;
private Integer count;
private String title;
private String content;
private String sender;
private List<String> receiver;
private LocalDateTime requestTime;
}

Mapper Interface를 작성할 때 위에서 작성한 것처럼 작성하되, 필요한 파라미터를 추가로 작성해줍니다.

public interface MessageMapper {
MessageMapper INSTANCE = Mappers.getMapper(MessageMapper.class);

//messageId, count, requestDto -> MessageServiceDto 매핑
MessageListServiceDto toMessageListServiceDto(String messageId, Integer count, RequestDto requestDto);
}

그러면 MapStruct에서 파라미터를 포함하여 매핑하는 구현체를 자동으로 만들어줍니다.

    @Override
public MessageListServiceDto toMessageListServiceDto(String messageId, Integer count, RequestDto requestDto) {
if ( messageId == null && count == null && requestDto == null ) {
return null;
}

MessageListServiceDto.MessageListServiceDtoBuilder messageListServiceDto = MessageListServiceDto.builder();

if ( requestDto != null ) {
messageListServiceDto.title( requestDto.getTitle() );
messageListServiceDto.content( requestDto.getContent() );
messageListServiceDto.sender( requestDto.getSender() );
List<String> list = requestDto.getReceiver();
if ( list != null ) {
messageListServiceDto.receiver( new ArrayList<String>( list ) );
}
messageListServiceDto.requestTime( requestDto.getRequestTime() );
}
messageListServiceDto.messageId( messageId );
messageListServiceDto.count( count );

return messageListServiceDto.build();
}

4. 추가 매핑 방법 with Custom

앞서 설명한 매핑 방법으로는 원하는 만큼 유연하게 매핑할 수가 없는데요. SENS에서는 매핑하는 필드의 타입이 다르거나, default 값을 채워야 한다거나, 매핑되는 구현체를 직접 작성해야 하는 경우가 있었습니다.

이런 상황에서 SENS에서는 어떻게 유연하게 매핑 했는지, 방법을 공유해 드립니다.

4-1 매핑 시 default 값 지정

source 객체에 빈 값이 들어오는 경우, NPE 를 피하기 위해, 특정 default 값이 지정되어야 하는 경우 등의 상황에서 defaultValuedefaultExpression을 이용해서 default 값을 지정할 수 있습니다.

아래 예시에서는 RequestDtoMessageListServiceDto에 매핑할 때, messageId가 null 인 경우 UUID 값을 default 값으로 채워주는 예시입니다.

@Mapper(imports = UUID.class)
public interface MessageMapper {
MessageMapper INSTANCE = Mappers.getMapper(MessageMapper.class);

@Mapping(source = "messageId", target = "messageId", defaultExpression = "java(UUID.randomUUID().toString())")
@Mapping(source = "requestDto.type", target = "type", defaultValue = "SMS")
@Mapping(source = "requestDto.sender", target="sender", ignore=true)
MessageListServiceDto toMessageListServiceDto(String messageId, Integer count, RequestDto requestDto);
}

4-2 매핑 시 특정 필드 매핑 무시

특정 필드를 빼고 매핑해야 하는 경우 ignore 를 사용해 제외할 수 있습니다.

@Mapper(imports = UUID.class)
public interface MessageMapper {
MessageMapper INSTANCE = Mappers.getMapper(MessageMapper.class);

@Mapping(source = "requestDto.sender", target="sender", ignore=true)
MessageListServiceDto toMessageListServiceDto(String messageId, Integer count, RequestDto requestDto);
}

4-3 특정 필드 매핑 시 지정 메소드 이용

이외에 별도 메소드를 매핑에 이용한 방법입니다.

MessageListServiceDtotype이 아래와 같이 enum으로 변경되고, RequestDtoMessageListServiceDto에 매핑될 때 type의 데이터 타입이 enum으로 변경되어야 한다고 가정해봅시다.

public class MessageListServiceDto {
private String messageId;
private Integer count;
private String title;
private String content;
private String sender;
private List<String> receiver;
private LocalDateTime requestTime;
private Type type;
}
public enum Type {
SMS("SMS"),
LMS("LMS"),
MMS("MMS");

private String code;

@Override
public String toString(){
return this.code;
}
}

enum class가 위와 같고, mapper에 enum으로 매핑하기 위한 메소드를 작성해줍니다.

    @Mapping(source = "requestDto.type", target = "type", qualifiedByName = "typeToEnum")
MessageListServiceDto toMessageListServiceDto(String messageId, Integer count, RequestDto requestDto);

@Named("typeToEnum")
static Type typeToEnum(String type) {
switch (type.toUpperCase()) {
case "LMS":
return Type.LMS;
case "MMS":
return Type.MMS;
default:
return Type.SMS;
}
}

qualifiedByName에 매핑할때 이용할 메소드를 지정해주고, 커스텀 메소드에는 @Named()를 이용해 매핑에 이용될 메소드라는 것을 명시해줍니다.

지금까지 qualifiedByName을 사용하여 특정 필드 매핑 시 이용할 메소드를 지정하는 방법을 공유드렸습니다. 참고로 아래와 같이 작성해도 동일하게 동작합니다.

default {TargetFieldDataType} To{TargetFieldName} ({SourceFieldDataType} SourceFieldName) {
...
}

enum 매핑 코드를 qualifiedByName 없이 작성하면 아래와 같이 수정할 수 있습니다

MessageListServiceDto toMessageListServiceDto(String messageId, Integer count, RequestDto requestDto);

default Type toType(String type) {
switch (type.toUpperCase()) {
case "LMS":
return Type.LMS;
case "MMS":
return Type.MMS;
default:
return Type.SMS;
}
}

기본적으로 간단한 enum 매핑을 MapStruct 에서 지원하기도 하며, enum → enum 매핑은 @EnumMapping, @ValueMapping을 통해 매핑할 수 있습니다.

4-4 사용자 정의 매퍼 메소드

매핑이 까다로운 경우, MapStruct에서 자동으로 구현되는 매핑 메소드 외에 직접 매핑 메소드를 구현해야 하는 경우가 있습니다.

이런 경우 default를 붙여 메소드를 만들어주면, 구현 메소드 대신 default 로 정의한 메소드를 사용할 수 있습니다.

위에서 사용한 toMessageListServiceDtodefault메소드로 만들어보겠습니다.

default MessageListServiceDto toMessageListServiceDto(String messageId, Integer count, RequestDto requestDto) {
String messageType = Optional.ofNullable(requestDto.getType()).orElse("sms").toUpperCase();
Type msgType = Type.SMS;

if (messageType.equals("LMS")) {
msgType = Type.LMS;
} else if (messageType.equals("MMS")){
msgType = Type.MMS;
}

return MessageListServiceDto.builder()
.messageId(Optional.ofNullable(messageId).orElse(UUID.randomUUID().toString()))
.count(Optional.ofNullable(count).orElse(0))
.title(requestDto.getTitle())
.content(requestDto.getContent())
.sender(requestDto.getSender())
.receiver(Optional.ofNullable(requestDto.getReceiver()).orElse(Collections.EMPTY_LIST))
.requestTime(LocalDateTime.now())
.type(msgType)
.build();
}

아래와 같이 default를 붙여서 매퍼를 사용하면, 위에서 직접 작성한 default 메소드를 매핑 시 사용하게 됩니다.

MessageListServiceDto messageListServiceDto = MessageMapper.INSTANCE.toMessageListServiceDto(messageId, count, resDto);

MapStruct Processor 옵션 및 매핑 정책

MapStruct 는 Annotation Processor 를 이용한 매핑인 만큼, Annotation 을 통한 옵션이나, 매핑에 대한 정책을 @Mapper 에 설정할 수 있습니다. 간략하게 MapStruct에서 제공하는 옵션과 정책을 소개하도록 하겠습니다.

ComponentModel

매퍼를 빈으로 만들어야 하는 경우, 아래와 같이 설정하면 빈으로 등록할 수 있습니다.

@Mapper(componentModel = "spring")
public interface MessageMapper {
...
}

생성된 매퍼는 싱글톤 범위의 빈이며, @Autowired를 통해 빈을 조회할 수 있습니다.

매퍼를 빈으로 등록해서 사용하는 경우나 매퍼 내부에서 다른 빈을 주입받아 사용이 필요한 경우, 위와 같이 빈을 등록하여 사용할 수 있습니다.

unmmappedTargetPolicy

해당 정책은 타겟이 되는 필드에 대한 정책입니다. Target 필드는 존재하는데 source의 필드가 없는 경우에 대한 정책입니다.

정책 옵션

  • ERROR : 매핑 대상이 없는 경우, 매핑 코드 생성 시 error 가 발생합니다.
  • WARN : 매핑 대상이 없는 경우, 빌드 시 warn 이 발생합니다.
  • IGNORE : 매핑 대상이 없는 경우 무시하고 매핑됩니다.

정책 설정

@Mapper(unmmapedTargetPolicy = ReportingPolicy.{ERROR,WARN,IGNORE})
public interface MessageMapper {
MessageMapper INSTANCE = Mappers.getMapper(MessageMapper.class);

MessageBodyDto toMessageBodyDto(RequestDto requestDto);
}

nullValueMappingStrategy / nullValueIterableMappingStrategy

source가 null 인 경우에 제어할 수 있는 null 정책입니다.

nullValueIterableMappingStrategy는 iterables나 map에 해당되는 정책입니다.

정책 옵션

  • RETURN_NULL : source가 null 일 경우, target을 null 로 설정합니다.
  • RETURN_DEFAULT : source가 null 일 경우, default 값으로 설정됩니다. iterable에는 collection이 매핑 되며, map은 빈 map 으로 매핑이 됩니다.

정책 설정

@Mapper(
nullValueMapMappingStrategy = NullValueMappingStrategy.{RETURN_NULL,RETURN_DEFAULT},
nullValueIterableMappingStrategy = NullValueMappingStrategy.{RETURN_NULL,RETURN_DEFAULT}
)
public interface MessageMapper {
MessageMapper INSTANCE = Mappers.getMapper(MessageMapper.class);

MessageBodyDto toMessageBodyDto(RequestDto requestDto);
}

이외 상세한 정책은 MapStruct 공식 가이드에서 확인하실 수 있습니다.

마무리

지금까지 SENS 개발 과정에서 경험한 MapStruct 사용기를 공유했습니다.

MapStruct는 구현 메소드를 자동으로 만들어주고, 필요할 경우 직접 메소드를 만들어 유연하게 사용할 수 있어 편리한 라이브러리였습니다.

다만 MapStruct는 코드를 자동으로 생성해 주기에오류가 있는지, 매핑이 원하는대로 동작하는지, 실제로 실행하기 전까지 확인이 어려웠습니다. 따라서 매핑을 위한 테스트 코드를 작성하여 놓치는 부분이 없는지 꼼꼼히 확인했습니다.

그래도 반복되는 매핑 코드 작성이나 휴먼 에러와 같은 잠재적인 에러 발생 확률을 줄일 수 있다는 점이 좋았는데요. 앞으로 매핑에 어려움이 있으실 때 가볍게 참고하실만한 자료가 되길 바라는 마음으로 열심히 작성해 보았습니다.

MapStruct 공식 reference 에는 앞서 설명드린 내용 외 고급 사용 방법이나 매핑 전략들이 더욱 자세히 소개되어 있습니다. 참고 되시길 바랍니다.

긴 글 읽어주셔서 감사합니다.

✅본 기술 포스팅은 NAVER Cloud의 Cloud Native Platform Dev 한민지 님의 기여로 작성되었습니다.

✅관련하여 궁금한 점은 댓글로 남겨주시기 바라며, Ncloud 유저그룹 (페이스북, 오픈카톡) 을 활용하시면 유저 분들과 함께 의견 나누실 수 있습니다.

--

--

NAVER Cloud
NAVER Cloud

We provide cloud-based information technology services for industry leaders from startups to enterprises.