KT넥스알 데이터 클라우드 플랫폼(NDC)에 MapStruct 객체 매핑을 적용한다면?

생성일
2023/08/07 04:27
태그
테크리포트
기술블로그
kt NexR
MapStruct
객체매핑
글쓴이
hani.jang
속성
속성 1

서론

NDC에서는 자체 DB 뿐만 아니라 Keycloack Admin API, Kubernetes Client API, 타 어플리케이션 API 호출 등 여러 클라이언트 API를 조회하여 가공 처리하는 작업들이 존재합니다.
많은 DTO를 생성해야 하므로 반복적인 Builder 패턴을 추가하는 작업을 줄이고자 Object Mapping 라이브러리인 MapStruct Mapper를 사용하고 있습니다.
최근에 개발 진행 중 어플리케이션 빌드 시 오류가 발생하면서 Mapper를 다양한 방법으로 사용하고자 리서치를 하게 되었습니다.

관련 기술/연구, 선행작업

DTO 란?

DTO란 Data Transfer Object의 약자로, 계층 간 데이터 전송을 위해 도메인 모델 대신 사용되는 객체입니다.

사용이유

도메인 모델에는 UI에 표시되지 않아도 될 정보가 많이 포함될 수 있고 외부에 노출하면 안 되는 정보일 경우 보안 문제가 발생할 수도 있습니다.
화면 마다 사용하는 도메인 모델이 다르거나 여러 도메인 모델을 조합해서 보여줘야 될 경우도 발생합니다.
도메인 모델을 계층 간 전송에 사용을 하면 뷰의 요구 사항 변화로 인해 도메인 코드를 변경 해야 하는 불편한 일이 발생할 수 있습니다.
DTO를 사용하면 도메인 모델의 변경 없이 DTO 조작만으로 요구 사항에 맞는 데이터 포맷으로 조정할 수 있습니다.

객체 매핑

객체 매핑 시 발생할 수 있는 문제점

재미가 없고 반복적인 코드 중복이 발생하며 생산성이 떨어집니다.
실수하기 쉽습니다.
비즈니스 로직에 섞이면 코드가 복잡해 질 수 있습니다.

매핑 방법별 장단점

Builder 패턴

장점

객체 변환을 위한 별도의 과정을 거치지 않고 구현한 메서드를 호출하기 때문에 성능에 대한 영향이 없다.
이름이 다른 필드 간 매핑도 getter를 사용하게 올바르게 조합하기만 하면 된다.
매핑하는 필드 타입이 다른 경우 컴파일 타임에 식별이 가능하다.

단점

객체의 필드명 변경이나 추가 시 매핑하는 코드를 함께 수정해야 한다.
필드가 많거나 조합 형태의 데이터가 많다면 휴먼 에러가 발생할 수 있다.

ModelMapper

장점

간결한 코드 작성이 가능하다.
일반적으로 필드 변경 사항에 대해서 고려하지 않아도 된다.
Lombok 라이브러리와 충돌 없이 사용할 수 있다.

단점

매핑 시 Reflection API를 사용해 객체 필드 정보를 추출하고 Map을 만든 다음, 들어온 인자와 매칭 시켜주고 Map의 정보를 기준으로 값을 매핑 해주는 방식이기 때문에 다른 방식보다는 오버 헤드가 많아 상대적으로 성능이 좋지 않다.
바이트코드 생성 방식을 이용하므로 이슈 발생 시 디버깅이 어렵다.
일반적으로 setter를 사용한다.

MapStruct

장점

간결한 코드 작성이 가능하다.
객체 필드의 변경 사항이 다른 로직에 영향을 주지 않는다.
Annotation Processor를 사용하여 컴파일 시 매핑 코드를 생성하므로 ModelMapper 보다 매핑 속도가 빠르다.
매핑 불가능한 상태 등의 문제가 발생한 경우, 컴파일 에러를 발생시키므로, 상대적으로 런타임에서 안정성을 보장한다.
컴파일로 생성된 매핑 코드를 확인할 수 있습니다.

단점

전혀 다른 형태의 필드 매핑 시, Mapping 로직이 복잡해 질 수 있다.
final 필드에 대한 매핑을 제공하지 못한다.
Lombok 라이브러리와의 충돌이 발생할 수 있다.

MapStruct 란?

자바에서 객체 간 매핑에 대한 코드를 자동으로 생성해주는 매핑 라이브러리입니다. Annotation 을 사용하여 컴파일 시 매핑 코드를 생성합니다.
Annotation Processor를 사용하여 컴파일 시 매핑코드를 생성하므로 리플렉션을 사용하지 않아 매핑 속도가 빠릅니다.

MapStruct 사용 이유

필드 매핑을 위한 반복적인 코드 작업을 하지 않고 간결하게 매핑을 할 수 있도록 합니다.

가설

MapStruct 를 사용하여 다양한 케이스와 필드 형태를 편하게 매핑할 수 있다.

해결 방법

개발 환경

IntelliJ IDEA 2021.2.3 (Ultimate Edition)
Spring Boot 2.7.6
Java 11
Gradle gradle-7.5.1-bin

의존성 추가

build.gradle
implementation 'org.mapstruct:mapstruct:1.4.2.Final' implementation 'org.projectlombok:lombok-mapstruct-binding:0.2.0' annotationProcessor "org.mapstruct:mapstruct-processor:1.4.2.Final"
Plain Text
복사

주의할 점

Lombok 과 함께 사용할 경우 라이브러리 의존성 설정 순서가 중요하다.
mapstruct annotation processor 보다 lombok annotation processor 가 먼저 동작해야 한다.
lombok에서 getter 나 builder 등이 만들기 전에 매핑을 시도한다면 매핑할 수 있는 방법을 찾지 못해 문제가 발생한다.
compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' implementation 'org.mapstruct:mapstruct:1.4.2.Final' implementation 'org.projectlombok:lombok-mapstruct-binding:0.2.0' annotationProcessor "org.mapstruct:mapstruct-processor:1.4.2.Final"
Plain Text
복사

Mapper 구현

주의할 점

MapStruct는 getter, setter를 기반으로 매핑되기 때문에 getter, setter 생성이 필수이다.

매퍼 테스트

코드

Entity, DTO
@Data public class UserEntity { private String uuid; private String userId; private String userName; private String password; private String mobile; private String email; } @Data @AllArgsConstructor public class UserDto { private String uuid; private String userId; private String userName; private String password; private String mobile; private String email; }
Plain Text
복사
Mapper
@Mapper 어노테이션만 명시하여 간단하게 매핑을 할 수 있습니다.
@Mapper public interface UserMapper { UserMapper INSTANCE = Mappers.getMapper(UserMapper.class); UserEntity toEntity(UserDto userDto); UserDto toDto(UserEntity userEntity); }
Plain Text
복사
테스트 코드
public class MapStructTest { @Test public void test() { UserDto userDto = new UserDto("3782840d-afa2-4acc-be91-879b7361e028", "hani", "사용자", "1234!@#$", "010-1111-2222", "hani@mail.com"); UserEntity userEntity = UserMapper.INSTANCE.toEntity(userDto); assert userDto.getUserId().equals(userEntity.getUserId()); } }
Plain Text
복사
테스트 결과

생성된 매퍼 파일

@Generated( value = "org.mapstruct.ap.MappingProcessor", date = "2022-12-10T18:47:03+0900", comments = "version: 1.4.2.Final, compiler: IncrementalProcessingEnvironment from gradle-language-java-7.5.1.jar, environment: Java 11.0.14 (Oracle Corporation)" ) public class UserMapperImpl implements UserMapper { @Override public UserEntity toEntity(UserDto userDto) { if ( userDto == null ) { return null; } UserEntity userEntity = new UserEntity(); userEntity.setUuid( userDto.getUuid() ); userEntity.setUserId( userDto.getUserId() ); userEntity.setUserName( userDto.getUserName() ); userEntity.setPassword( userDto.getPassword() ); userEntity.setMobile( userDto.getMobile() ); userEntity.setEmail( userDto.getEmail() ); return userEntity; } @Override public UserDto toDto(UserEntity userEntity) { if ( userEntity == null ) { return null; } UserDtoBuilder userDto = UserDto.builder(); userDto.uuid( userEntity.getUuid() ); userDto.userId( userEntity.getUserId() ); userDto.userName( userEntity.getUserName() ); userDto.password( userEntity.getPassword() ); userDto.mobile( userEntity.getMobile() ); userDto.email( userEntity.getEmail() ); return userDto.build(); } }
Plain Text
복사

케이스 별 매퍼 구현

1. Entity, DTO의 필드명이 모두 같을 경우

위의 샘플 파일과 동일하게 @Mapper 어노테이션 명시 만으로 매핑을 할 수 있습니다.

2. Entity, DTO의 필드명이 다를 경우

@Data public class UserEntity { private String uuid; private String userId; private String userName; private String password; private String mobile; private String email; } @Data @AllArgsConstructor public class UserDto { private String id; private String username; private String name; private String password; private String mobile; private String mail; } @Mapper public interface UserMapper { UserMapper INSTANCE = Mappers.getMapper(UserMapper.class); UserEntity toEntity(UserDto userDto); UserDto toDto(UserEntity userEntity); }
Plain Text
복사
테스트 결과
@Mapping 설정을 하지 않을 경우 warning이 발생합니다.

3. Entity, DTO의 필드가 다를 경우

Entity, DTO 필드명이 다를 경우와 동일하게 warning이 발생하지만 테스트 결과 정상 처리 됩니다.
@Data public class UserEntity { private String uuid; private String userId; private String userName; private String password; private String mobile; private String email; } @Data @AllArgsConstructor public class UserDto { private String uuid; private String userId; private String userName; private String password; private String email; private String addr; } @Mapper public interface UserMapper { UserMapper INSTANCE = Mappers.getMapper(UserMapper.class); UserEntity toEntity(UserDto userDto); UserDto toDto(UserEntity userEntity); }
Plain Text
복사
테스트 결과

Warning 해결 방법

Mapper 수정
변환할 Source의 필드명과 Target 의 필드명을 @Mapping 어노테이션을 사용하여 매핑 정보를 설정합니다.
@Mapper public interface UserMapper { UserMapper INSTANCE = Mappers.getMapper(UserMapper.class); @Mapping(source = "id", target = "uuid") @Mapping(source = "username", target = "userId") @Mapping(source = "name", target = "userName") @Mapping(source = "mail", target = "email") UserEntity toEntity(UserDto userDto); }
Plain Text
복사

4. 하나의 객체로 합치기

@Mapper public interface UserMapper { UserMapper INSTANCE = Mappers.getMapper(UserMapper.class); @Mapping(source = "user.id", target = "uuid") @Mapping(source = "user.username", target = "userId") @Mapping(source = "user.name", target = "userName") @Mapping(source = "user.mobile", target = "mobile") @Mapping(source = "addr.si", target = "si") @Mapping(source = "addr.si", target = "dong") UserEntity toEntity(UserDto user, AddrDto addr); }
Plain Text
복사
생성된 매퍼 파일
@Generated( value = "org.mapstruct.ap.MappingProcessor", date = "2022-12-12T18:02:35+0900", comments = "version: 1.4.2.Final, compiler: IncrementalProcessingEnvironment from gradle-language-java-7.5.1.jar, environment: Java 11.0.14 (Oracle Corporation)" ) public class UserMapperImpl implements UserMapper { @Override public UserEntity toEntity(UserDto user, AddrDto addr) { if ( user == null && addr == null ) { return null; } UserEntity userEntity = new UserEntity(); if ( user != null ) { userEntity.setUuid( user.getId() ); userEntity.setUserId( user.getUsername() ); userEntity.setUserName( user.getName() ); userEntity.setMobile( user.getMobile() ); } if ( addr != null ) { userEntity.setSi( addr.getSi() ); userEntity.setDong( addr.getSi() ); } return userEntity; } }
Plain Text
복사

5. 타입 변환

지정을 하지 않아도 대부분 암시적 형 변환이 이루어지지만 형식을 지정하여 변환을 할 수도 있습니다.
@Mapper public interface UserMapper { UserMapper INSTANCE = Mappers.getMapper(UserMapper.class); @Mapping(source = "birthday", target = "birthday", dateFormat = "yyyy-MM-dd") UserEntity toEntity(UserDto userDto); }
Plain Text
복사
@Generated( value = "org.mapstruct.ap.MappingProcessor", date = "2022-12-12T18:39:17+0900", comments = "version: 1.4.2.Final, compiler: IncrementalProcessingEnvironment from gradle-language-java-7.5.1.jar, environment: Java 11.0.14 (Oracle Corporation)" ) public class UserMapperImpl implements UserMapper { @Override public UserEntity toEntity(UserDto userDto) { if ( userDto == null ) { return null; } UserEntity userEntity = new UserEntity(); try { if ( userDto.getBirthday() != null ) { userEntity.setBirthday( new SimpleDateFormat( "yyyy-MM-dd" ).parse( userDto.getBirthday() ) ); } } catch ( ParseException e ) { throw new RuntimeException( e ); } userEntity.setUuid( userDto.getUuid() ); userEntity.setUserId( userDto.getUserId() ); userEntity.setUserName( userDto.getUserName() ); userEntity.setPassword( userDto.getPassword() ); userEntity.setEmail( userDto.getEmail() ); return userEntity; } }
Plain Text
복사

정책

1. Source, Target 매핑 정책

매핑 될 필드가 존재하지 않을 경우에 대한 정책을 지정할 수 있습니다.
매핑 정책
설명
옵션
unmappedSourcePolicy
매핑 시 소스 속성이 설정되지 않은 경우 적용되는 정책입니다.
ReportingPolicy.ERROR : 매핑되지 않은 소스 속성으로 인해 매핑 코드 생성 실패 ReportingPolicy.WARN : 매핑되지 않은 소스 속성은 빌드 시 경고 발생 ReportingPolicy.IGNORE : 매핑되지 않은 소스 속성은 무시
unmappedTargetPolicy
매핑 시 타겟 속성이 설정되지 않은 경우 적용되는 정책입니다.
ReportingPolicy.ERROR : 매핑되지 않은 타겟 속성으로 인해 매핑 코드 생성 실패 ReportingPolicy.WARN : 매핑되지 않은 타겟 속성은 빌드 시 경고 발생 ReportingPolicy.IGNORE : 매핑되지 않은 타겟 속성은 무시
소스 또는 타겟 속성을 매핑하지 않으면 오류 처리
@Mapper(unmappedSourcePolicy = ReportingPolicy.ERROR, unmappedTargetPolicy = ReportingPolicy.ERROR)
소스 또는 타겟 속성을 매핑하지 않아도 정상 처리
@Mapper(unmappedSourcePolicy = ReportingPolicy.IGNORE, unmappedTargetPolicy = ReportingPolicy.IGNORE)
warning도 표시되지 않습니다.

2. null 정책

source 가 null 일 경우 기본 생성자로 필드가 비어있는 target 오브젝트를 반환합니다.
@Mapper(unmappedSourcePolicy = ReportingPolicy.ERROR, unmappedTargetPolicy = ReportingPolicy.ERROR, nullValueMappingStrategy = NullValueMappingStrategy.RETURN_DEFAULT) public interface UserMapper { UserMapper INSTANCE = Mappers.getMapper(UserMapper.class); UserEntity toEntity(UserDto userDto); }
Plain Text
복사
@Generated( value = "org.mapstruct.ap.MappingProcessor", date = "2022-12-12T18:56:55+0900", comments = "version: 1.4.2.Final, compiler: IncrementalProcessingEnvironment from gradle-language-java-7.5.1.jar, environment: Java 11.0.14 (Oracle Corporation)" ) public class UserMapperImpl implements UserMapper { @Override public UserEntity toEntity(UserDto userDto) { UserEntity userEntity = new UserEntity(); if ( userDto != null ) { userEntity.setUuid( userDto.getUuid() ); userEntity.setUserId( userDto.getUserId() ); userEntity.setUserName( userDto.getUserName() ); userEntity.setEmail( userDto.getEmail() ); } return userEntity; } }
Plain Text
복사
각 필드 별 null 정책을 부여할 수 있습니다.
String은 빈 문자열, List는 빈 ArrayList, 특정 오브젝트라면 해당 오브젝트의 기본 생성자 등으로 기본값을 생성합니다.
@Mapper public interface UserMapper { UserMapper INSTANCE = Mappers.getMapper(UserMapper.class); @Mapping(source = "roles", target = "roles", nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.SET_TO_DEFAULT) UserEntity toEntity(UserDto userDto); }
Plain Text
복사

3. 특정 필드 매핑 무시

매핑하지 않을 필드 속성에 ignore=true 를 넣어줍니다.
@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR) public interface UserMapper { UserMapper INSTANCE = Mappers.getMapper(UserMapper.class); @Mapping(target = "addr", ignore = true) @Mapping(target = "phone", ignore = true) UserEntity toEntity(UserDto userDto); }
Plain Text
복사

결론

MapStruct는 객체 간 매핑 시 개발자의 변환 코드 작업을 최소화하고 휴먼 에러를 줄일 수 있고 쉽고 간결하게 사용할 수 있는 장점이 많은 좋은 라이브러리라고 생각합니다.
어노테이션과 속성명 만으로도 별도의 설명 없이 이해할 수 있는 수준의 코드로 작성되기 때문에 개인적으로는 가독성도 훌륭하다고 생각합니다.
다만, 적용하고자 하는 프로젝트에서 전혀 다른 형태의 매핑이 많을 경우에는 오히려 코드가 복잡해 질 수 있으므로 충분히 검토 후 적용할 수 있도록 합니다.
참고자료
본 기술블로그에 게재되는 모든 컨텐츠의 저작권은 케이티넥스알(kt NexR)에서 가지고 있으며, 동의 없는 컨텐츠 수정 및 무단 복제는 금하고 있습니다. 컨텐츠(글/사진/영상 등)를 공유하실 경우 반드시 출처를 밝혀주시기 바랍니다. Copyright(c) kt NexR, Inc. All Rights Reserved.