Engineering
Object Mapping 어디까지 해봤니?
2020년 1월 7일
원문에서 보기 ↗머리말
TOAST Notification에서는 API 버전 별 객체와, 비즈니스 로직, 레이어 간 객체와 객체 간 매핑(Mapping) 시 MapStruct를 사용하고 있습니다. 이 글에서는 Object Mapping 라이브러리인 MapStruct에 대해 소개합니다. NHN Forward 2019에서 발표한 내용에 대해 조금 더 자세히 설명합니다.
아래 '문제', '해결 방법' 부분은 평어체를 사용합니다.
문제
Spring 프레임워크에서 개발을 예로 들어보자. Controller, Service, Repository 등 레이어 간 데이터를 주고받을 때나 비즈니스 로직에서, 하나의 객체를 타입이 다른 객체로 형(Type) 변환하거나 여러 객체를 다른 객체로 합치는 일은 매우 빈번하다.
이런 작업을 개발자가 모두 직접 할 때 발생하는 문제점을 몇 가지 나열해 보면 다음과 같다.
- 재미가 없고 반복적이고 코드 중복이 발생하기 쉽다.
- 실수하기 쉽다.
- 결국 생산성을 떨어뜨린다.
- 비즈니스 로직에 섞이게 되면 코드가 복잡해진다.
Object Mapping 라이브러리는 이런 문제를 해결해주는 아주 좋은 도구다. 다음과 같이 손으로 작성된 객체 변환 코드가 있다. 여기서는 비교적 단순한 객체라 코드가 짧지만, 필드가 몇 개만 더 늘어나도 가독성이 떨어지고, 개발자에게 피곤한 작업이 된다.
MessageEntity toMessageEntity(Message message) {
return MessageEntity
.builder()
.id(message.getId())
.to(message.getTo())
.body(message.getBody())
.messageType(message.getMessageType())
.status(message.getStatus())
.createdDateTime(message.getCreatedDateTime())
.build();
}
해결 방법
MapStruct?
MapStruct는 자바에서 객체 간 매핑에 대한 코드를 자동으로 생성해주는 매핑 라이브러리다. Annotation Processor를 사용해 컴파일 시 매핑 코드를 생성하는데, 많은 장점이 있다. 몇 가지만 살펴보면 다음과 같다.
- 컴파일 시 오류를 확인할 수 있다.
- 리플렉션(Reflction)을 사용하지 않기 때문에 매핑 속도가 빠르다.
- 디버깅이 쉽다.
- 생성된 매핑 코드를 눈으로 직접 확인할 수 있다.
그리고 구글 트랜드로 봤을 때 MapStruct가 자바 매핑 라이브러리 중 전체적으로 가장 많이 사용되고 있는 것으로 보인다.

시작하기
다음과 같이 간단한 메시지 발송 API가 있다고 가정해보자. API 요청 본문 객체를 DB 엔티티 객체로 매핑하는 예다.
curl -X POST http://127.0.0.1/v1.0/messages -d '{"to":"jinho-shin","title":"제목","body":"내용","messageType":"AD"}'
public class V1_0SendMessage {
private String to;
private String title;
private String body;
private String messageType;
}
public class MessageEntity {
private String id = RandomStringUtils.randomAlphanumeric(16);
private String to;
private String title;
private String body;
private String messageType;
private String status = "READY";
private String statusMessage;
private OffsetDateTime createdDateTime = OffsetDateTime.now();
private OffsetDateTime updatedDateTime = OffsetDateTime.now();
}
API 요청 본문 객체 'V1_0SendMessage'를 DB 엔티티 객체 'MessageEntity'로 매핑하는 코드다. MapStruct의 Mapper 표시를 위한 '@Mapper'와 인터페이스를 정의하고, 메서드 파라미터엔 소스(Source) 객체인 'V1_0SendMessage'을 정의하고, 리턴 타입엔 타깃(Target) 객체인 'MessageEntity'을 정의한다.
@Mapper
public interface MessageMapper {
MessageEntity toMessageEntity(V1_0SendMessage v1_0SendMessage);
}
실제 객체 매핑에 사용되는 코드는 MapStruct가 생성한다. 다음은 MapStruct에서 생성한 매핑 코드다.
@Generated(
value = "org.mapstruct.ap.MappingProcessor", // MapStruct의 Annotation Processor
date = "2019-11-10T14:39:59+0900",
comments = "version: 1.3.0.Final, compiler: javac, environment: Java 1.8.0_202 (AdoptOpenJdk)"
)
@Component
public class MessageMapperImpl implements MessageMapper {
@Override
public MessageEntity toMessageEntity(V1_0SendMessage v1_0SendMessage) {
if ( v1_0SendMessage == null ) {
return null; // 기본적으로 소스가 null이면 타깃도 null이다. 뒤에 설명할 정책(Policy)이나 전략(Strategy)으로 설정할 수 있다.
}
MessageEntity messageEntity = new MessageEntity();
messageEntity.setTo( v1_0SendMessage.getTo() );
messageEntity.setTitle( v1_0SendMessage.getTitle() );
messageEntity.setBody( v1_0SendMessage.getBody() );
messageEntity.setMessageType( v1_0SendMessage.getMessageType() );
return messageEntity;
}
}
기능 소개
여기서부터는 TOAST Notification에서 MapStruct를 사용하면서 유용하다고 생각한 기능이나, 맞닥드린 문제와 해결 방법에 대해 설명한다.
1. 서로 다른 속성 매핑
다음과 같이 'EmailMessageEntity'의 'emailAddress' 속성을 'MessageEntity'의 'to' 속성에 매핑해야 된다고 해보자.
public class EmailMessageEntity {
private String id = RandomStringUtils.randomAlphanumeric(16);
private String emailAddress;
private String title;
private String body;
private String messageType;
private String status = "READY";
private String statusMessage;
private OffsetDateTime createdDateTime = OffsetDateTime.now();
private OffsetDateTime updatedDateTime = OffsetDateTime.now();
}
다음과 같이 매핑 메서드에 '@Mapping'에 소스와 타깃 속성을 설정하면 된다.
@Mapper
public interface MessageMapper {
@Mapping(source = "emailAddress", target = "to")
MessageEntity toMessageEntity(EmailMessageEntity emailMessageEntity);
}
다음은 MapStruct가 구현한 'MessageMapperImpl'의 일부다.
@Override
public MessageEntity toMessageEntity(EmailMessageEntity emailMessageEntity) {
if ( emailMessageEntity == null ) {
return null;
}
MessageEntity messageEntity = new MessageEntity();
messageEntity.setTo( emailMessageEntity.getEmailAddress() ); // emailAddress 속성을 to 속성에 매핑한다.
messageEntity.setId( emailMessageEntity.getId() );
messageEntity.setTitle( emailMessageEntity.getTitle() );
messageEntity.setBody( emailMessageEntity.getBody() );
messageEntity.setMessageType( emailMessageEntity.getMessageType() );
messageEntity.setStatus( emailMessageEntity.getStatus() );
messageEntity.setStatusMessage( emailMessageEntity.getStatusMessage() );
messageEntity.setCreatedDateTime( emailMessageEntity.getCreatedDateTime() );
messageEntity.setUpdatedDateTime( emailMessageEntity.getUpdatedDateTime() );
return messageEntity;
}
2. 객체 합치기 #1
메시지 발송 결과에 대한 객체를 매핑해야 한다고 해보자. 다음은 메시지 결과 'MessageResult' 객체다.
public class MessageResult {
private String id;
private String to;
private String title;
private String body;
private String messageType;
private String status;
private String statusMessage;
private OffsetDateTime createdDateTime;
private OffsetDateTime updatedDateTime;
private String sender;
private int senderReplyCode;
private Collection<Exception> exceptions;
}
다음처럼 'MessageEntity', 'sender'(발송 서버), 'senderReplyCode'(발송 서버의 응답), 'exceptions'(오류)를 합치는 매핑 메서드를 정의한다.
@Mapper
public interface MessageMapper {
MessageResult toMessageResult(MessageEntity messageEntity, String sender, int senderReplyCode, Collection<Exception> exceptions);
}
다음은 MapStruct가 구현한 MessageMapperImpl의 일부다.
@Override
public MessageResult toMessageResult(MessageEntity messageEntity, String sender, int senderReplyCode, Collection<Exception> exceptions) {
if ( messageEntity == null && sender == null && exceptions == null ) {
return null;
}
MessageResult messageResult = new MessageResult();
if ( messageEntity != null ) {
messageResult.setId( messageEntity.getId() );
messageResult.setTo( messageEntity.getTo() );
messageResult.setTitle( messageEntity.getTitle() );
messageResult.setBody( messageEntity.getBody() );
messageResult.setMessageType( messageEntity.getMessageType() );
messageResult.setStatus( messageEntity.getStatus() );
messageResult.setStatusMessage( messageEntity.getStatusMessage() );
messageResult.setCreatedDateTime( messageEntity.getCreatedDateTime() );
messageResult.setUpdatedDateTime( messageEntity.getUpdatedDateTime() );
}
if ( sender != null ) {
messageResult.setSender( sender );
}
if ( exceptions != null ) {
Collection<Exception> collection = exceptions;
if ( collection != null ) {
messageResult.setExceptions( new ArrayList<Exception>( collection ) );
}
}
messageResult.setSenderReplyCode( senderReplyCode );
return messageResult;
}
3. 객체 합치기 #2
다음과 같이 템플릿을 이용한 메시지가 있다고 하자.
public class TemplateMessage {
private String id;
private String to;
private TemplateEntity templateEntity;
private String messageType;
private String status;
private OffsetDateTime createdDateTime;
}
이 'TemplateMessage'는 'MessageEntity'와 'TemplateEntity' 합쳐서 생성해야 한다.
public class TemplateEntity {
private String templateId;
private String template;
private Map<String, String> templateParameters;
}
매핑 메서드는 다음과 같이 정의할 수 있다.
@Mapper
public interface MessageMapper {
@Mapping(source = "templateEntity", target = "template") // templateEntity를 template 속성에
TemplateMessage toTemplateMessage(MessageEntity messageEntity, TemplateEntity templateEntity);
}
다음은 MapStruct가 구현한 'MessageMapperImpl'의 일부다.
@Override
public TemplateMessage toTemplateMessage(MessageEntity messageEntity, TemplateEntity templateEntity) {
if ( messageEntity == null && templateEntity == null ) {
return null;
}
TemplateMessage templateMessage = new TemplateMessage();
if ( messageEntity != null ) {
templateMessage.setId( messageEntity.getId() );
templateMessage.setTo( messageEntity.getTo() );
templateMessage.setMessageType( messageEntity.getMessageType() );
templateMessage.setStatus( messageEntity.getStatus() );
templateMessage.setStatusMessage( messageEntity.getStatusMessage() );
templateMessage.setCreatedDateTime( messageEntity.getCreatedDateTime() );
templateMessage.setUpdatedDateTime( messageEntity.getUpdatedDateTime() );
}
if ( templateEntity != null ) {
templateMessage.setTemplate( templateEntity );
}
return templateMessage;
}
4. 속성 무시하기
다음과 같이 객체가 있다고 하자.
public class TemplateEntity {
private String templateId;
private String template;
private Map<String, String> templateParameters;
private OffsetDateTime createdDateTime;
private OffsetDateTime updatedDateTime;
}
public class V1_0Template {
private String template;
private Map<String, String> templateParameters;
}
다음과 같이 우리가 매핑 시 타깃 객체에 대한 매핑 정책을 엄격하게 가져가기 위해 타깃 객체에 매핑 시 매핑되지 않은 속성이 있다면 컴파일 에러를 발생시키도록 정책을 설정했다고 하자.
@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR) // 정책에 대한 설명은 글 아래 설명합니다.
public interface TemplateMapper {
TemplateEntity toTemplateEntity(V1_0Template template);
}
소스 객체인 'V1_0Template'를 타깃 객체 'TemplateEntity'로 매핑하면 컴파일 시 다음과 같은 컴파일 에러가 발생한다.
Error:(8, 20) java: Unmapped target properties: "templateId, createdDateTime, updatedDateTime".
이 때 다음과 같이 '@Mapping'로 매핑되지 않는 속성 무시를 지정할 수 있다.
@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface TemplateMapper {
@Mapping(target = "templateId", ignore = true)
@Mapping(target = "createdDateTime", ignore = true)
@Mapping(target = "updatedDateTime", ignore = true)
TemplateEntity toTemplateEntity(V1_0Template template);
}
5. 직접 구현하기
가끔 MapStruct에서 매핑 코드를 구현하지 못하거나 직접 구현해야될 때가 있다. MapStruct는 default 메서드를 이용해 매핑 메서드를 직접 구현할 수 있게 해준다.
다음은 같이 객체를 'String'으로 매핑하는 메서드를 직접 구현한 예다.
@Mapper
public interface JsonMapper {
ObjectMapper OBJECT_MAPPER = new ObjectMapper();
default String toString(Object obj) {
try {
return OBJECT_MAPPER.writeValueAsString(obj);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
6. java.lang.StackOverflowError
마지막으로 MapStruct를 사용하다 마주친 오류에 대해 설명하겠다.
'TemplateEntity'의 'templateParameters' 속성을 'Map'이 아닌 'JSON String'으로 저장하려 한다. 소스 객체인 'V1_0Template'의 'templateParameters' 속성은 'Map'인데, 위에서 구현한 'JsonMapper'을 사용하여 'Map'에서 'JSON String'으로 변환할 수 있다.
public class TemplateEntity {
private String templateId;
private String template;
private String templateParameters;
private OffsetDateTime createdDateTime;
private OffsetDateTime updatedDateTime;
}
그리고 다음과 같이 'MapStructMapperConfig'라는 곳에 MapStruct의 'Mapper' 설정을 공통화했다. 'Mapper'가 많아지면 'Mapper'마다 설정해주는 것에서 중복이 크게 발생하므로 설정을 공통화했다고 하자.
/**
* 특정 타입이나 객체간 매핑을 MapStruct 스스로할 수 없거나 다른 Mapper를 이용해야 한다면 'uses'를 사용할 수 있다.
* 'uses = JsonMapper.class'로 지정하면 객체에서 String으로 변환이 필요할 때 JsonMapper를 사용한다.
* /
@MapperConfig(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.ERROR, uses = JsonMapper.class)
public class MapStructMapperConfig {
}
각 Mapper마다 MapStructMapperConfig의 설정을 사용하도록 한다.
@Mapper(config = MapStructMapperConfig.class)
public interface TemplateMapper {
...
@Mapper(config = MapStructMapperConfig.class)
public interface JsonMapper {
...
하지만 컴파일을 진행하면 다음과 같이 'java.lang.StackOverflowError'가 발생한다.
"Error:(12, 8) java: Internal error in the mapping processor: java.lang.StackOverflowError "
Stack Overflow의 원인은 'JsonMapper'에서 'MapStructMapperConfig'의 설정을 사용하면서 'uses = JsonMapper.class 설정이 다시 'JsonMapper'에 적용되면서 순환(Cycle)이 발생하는 것이다. 이를 해결하기 위해서는 다음과 같이 'MapStructMapperConfig'를 사용하지 않도록 해 순환을 끊어야 한다.
@Mapper(componentModel = "spring")
public interface JsonMapper {
...
Policy & Strategy
매핑 정책(Policy)과 전략(Strategy)를 설정할 수 있다. 아래는 몇 가지 유용한 매핑 정책과 전략에 대한 설명이다.
| 정책 | 값 | 설명 | | - | - | - | | unmappedSourcePolicy | IGNORE(default),
WARN,
ERROR
| Source의 필드가 Target에 매핑되지 않을 때 정책이다.
예, ERROR로 설정하면 매핑 시 Source.aField가 사용되지 않는다면 컴파일 오류가 발생시킨다.| | unmappedTargetPolicy | IGNORE,
WARN(default),
ERROR | Target의 필드가 매핑되지 않을 때 정책이다.
예, ERROR로 설정하면 매핑 시 Target.aField에 값이 매핑되지 않는다면 컴파일 오류가 발생시킨다.| | typeConversionPolicy | IGNORE(default),
WARN,
ERROR | 타입 변환 시 유실이 발생할 수 있을 때 정책이다.
예, ERROR로 설정하면 long에서 int로 값을 넘길 때 값에 유실이 발생할 수 있다. 이런 경우에 컴파일 오류를 발생시킨다.|
| 전략 | 값 | 설명 | | - | - | - | | nullValueMappingStrategy | RETURN_NULL(default),
RETURN_DEFAULT | Source가 null일 때 정책이다.| | nullValuePropertyMappingStrategy | SET_TO_NULL(default),
SET_TO_DEFAULT,
IGNORE | Source의 필드가 null일 때 정책이다.|
결론
지금까지 MapStruct의 기능과 여러 가지 사용 방법을 알아봤습니다. MapStruct는 객체 간 매핑 중 많은 부분을 자동화해주는 훌륭한 도구라고 생각합니다. 최근 1년간(2018년 9월 ~ 2019년 9월) MapStruct의 'Maven Central Repository' 다운 수도 꾸준히 증가하고 있습니다. 개인적인 사용 경험을 생각해보면 MapStruct도 Lombok처럼 많이 사용될 것이라고 생각합니다. 
MapStruct에 대해 더 알아보고 싶으신 분은 아래 홈페이지와 가이드 문서 확인 부탁드립니다.
- MapStruct: https://mapstruct.org/
- MapStruct 가이드 문서: https://mapstruct.org/documentation/stable/reference/html/
끝까지 읽어주셔서 감사합니다.