서로 다른 레이어에서 변수들을 전달할 때 서로 다른 dto를 사용하는데, 얼핏 비슷하면서도 한두 개 다른 변수들을 하나씩 매핑해 주는 게 매우 귀찮고 번거로웠다.
그래서 이를 자동(?)으로 해주는 라이브러리 같은 게 있을까 싶어서 몇 개 찾아보았다.
구글링 해보면 여러 개가 나오는데 아래 3개를 먼저 확인해 보았다.
1. 스프링에 기본 내장되어 있는 BeanUtils.copyProperties
2. 사용하기 편해 보이는 ModelMapper
3. 요즘 제일 인기 있는 Mapstruct
실제로 사용하려는 목적이기에 현실적인 사용성을 중심으로 살펴보았다.
dependency | di주입 | 원리(setter 선언 필요?) | 필드 커스텀 가능 여부 | |
Mapstruct | 추가 필요(4개) | 빈 주입 필요 interface 작성 |
리플랙션X - 컴파일 시 작성된 getter/setter or 빌더 or 생성자 등 관련 코드를 이용하여 변환 class 만듦 - setter 없어도 됨 |
매핑으로 커스텀 설정 가능 |
ModelMapper | 추가 필요(1개) | 주입 필요 없음 | 리플렉션O getter/setter 기반 - 필드명/타입이 동일할 경우 필드 엑서스 레벨을 private으로 설정하면 setter가 필요없지만 - 커스텀 시 setter 선언 필요 |
커스텀을 위해 typeMap과 addMapping 을 이용하여 손수 매핑해줘야 함 |
BeanUtils.copyProperties | 추가 필요 없음 spring 내장 |
주입 필요 없음 static method void type |
리플렉션O getter/setter 기반 - setter 선언 필요 - 커스텀 시 setter 수정 필요 |
더 복잡한 변환이 필요할 경우 사용 불가하며 spring BeanWrapper를 직접 구현해야 함 |
Mapstruct
lombok과 함께 사용가능(lombok compile 이후에 작동)
implementation 'org.mapstruct:mapstruct:1.5.3.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.3.Final'
testAnnotationProcessor 'org.mapstruct:mapstruct-processor:1.5.3.Final'
//If you are using Lombok 1.18.16 or newer you also need to add lombok-mapstruct-binding in order to make Lombok and MapStruct work together.
implementation 'org.projectlombok:lombok-mapstruct-binding:0.2.0'
@Mapper(config = CommonMapper.class,
nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE,
nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS)
public interface CharacterUpdateRequestMapper {
@Mapping(target = "searchType", source = "findType")
@Mapping(target = "searchValue", source = "value")
@Mapping(target = "characterDetail", source = "detailCharacterData")
CharacterUpdateRequest toRequest(ExternalCharacterUpdateRequest external);
@Mapping(target = "position", source = "lastSavePosition")
@Mapping(target = "openedDivisions", ignore = true)
@Mapping(target = "divisionFlattened", ignore = true)
CharacterDetail toDetail(ExternalCharacterDetail external);
}
@RequiredArgsConstructor
...
private final CharacterUpdateRequestMapper characterUpdateRequestMapper;
public CharacterUpdateRequest usingMapStruct() {
return characterUpdateRequestMapper.toRequest(externalCharacterUpdateRequest);
}
필드명이 다를 경우만 mapping을 적어주면 된다(nested object도. 을 사용하여 매핑 가능).
class type이 다르면 converter를 별도로 구현해야 한다.
컴파일 시 구현체를 자동으로 생성해 주며 잘못된 매핑을 적을 경우 컴파일이 되지 않기 때문에 런타임 에러를 방지할 수 있다.
참고자료
https://mapstruct.org/documentation/stable/reference/html/#defining-mapper
https://madplay.github.io/post/mapstruct-in-springboot
ModelMapper
implementation 'org.modelmapper:modelmapper:3.1.1'
public class ModelMapperUtils {
private static final ModelMapper MODEL_MAPPER;
static {
MODEL_MAPPER = new ModelMapper();
MODEL_MAPPER.getConfiguration()
.setMatchingStrategy(MatchingStrategies.STRICT)
.setFieldAccessLevel(AccessLevel.PRIVATE)// 필드 전체 이름이 같아서 setter가 필요없다면
.setFieldMatchingEnabled(true);
}
public static CharacterUpdateRequest fromExternalCharacterUpdateRequest(ExternalCharacterUpdateRequest externalCharacterUpdateRequest) {
//클래스 매핑하고 필드 매핑해야함
MODEL_MAPPER.createTypeMap(ExternalCharacterUpdateRequest.class, CharacterUpdateRequest.class)
.addMapping(ExternalCharacterUpdateRequest::getDetailCharacterData, CharacterUpdateRequest::setCharacterDetail)
.addMapping(ExternalCharacterUpdateRequest::getFindType, CharacterUpdateRequest::setSearchType);
MODEL_MAPPER.createTypeMap(ExternalCharacterDetail.class, CharacterDetail.class)
.addMapping(ExternalCharacterDetail::getLastSavePosition, CharacterDetail::setPosition);
return MODEL_MAPPER.map(externalCharacterUpdateRequest, CharacterUpdateRequest.class);
}
}
같은 형/타입일 경우 자동으로 매핑해 주고, 변형된 필드일 경우 손수 getter/setter를 사용하여 매핑해줘야 한다.
런타임 시점에 Reflection API를 사용하여 객체를 매핑하기 때문에 컴파일 시점에 성능 최적화를 하지 못하고 다른 방식보다 오버헤드가 많다는 특징이 있다.
참고자료
https://stackoverflow.com/questions/70274381/how-to-map-a-nested-list-using-modelmapper
https://www.baeldung.com/java-modelmapper
BeanUtils.copyProperties
예전에 다른 분께 memory leak 이슈가 있으니 사용에 조심해야 한다고 피드백받았던 적이 있어서 잠시 사용을 안 했었는데, 다시 찾아보니 관련 reference가 잘 나오지 않는다..
1. Circular References (순환 참조)
- 객체 간에 순환 참조가 있는 경우, 객체가 서로를 참조하게 되어 가비지 컬렉션이 되지 않을 수 있습니다. 이로 인해 메모리 누수가 발생할 수 있습니다.
2. Large Object Graphs
- 복사하는 객체가 큰 그래프를 가지거나 깊게 중첩된 경우, 복사 과정에서 많은 메모리를 소모할 수 있습니다. 특히, 여러 번 복사할 경우, 사용하지 않는 객체가 메모리에 남아 있을 수 있습니다.
3. Static Fields
- 복사하려는 객체가 정적 필드를 포함하고 있다면, 이 필드는 여전히 메모리에 남아 있어 메모리 누수가 발생할 수 있습니다. 정적 필드는 클래스 로딩 시 한 번만 메모리에 할당됩니다.
dto 내부에서 생성자 및 세팅 함수 작성 시 유리하다.
public CharacterUpdateRequest(ExternalCharacterUpdateRequest externalCharacterUpdateRequest) {
BeanUtils.copyProperties(externalCharacterUpdateRequest, this);
super.setSearchValue("custom setting");
}
같은 형/타입일 경우 자동으로 매핑해 주고, 그 외에는 setter 이용하는 등 개발해줘야 한다.
변형폼:
//일부만 변환할 때
public static void copyProperties(Object source, Object target, Class<?> editable)
//일부만 제외할 때
public static void copyProperties(Object source, Object target, String... ignoreProperties)
mapStruct 사용후기
before: 아래와 같은 함수를 2쌍 만들어 주었어야 함
public static OasisBannerResponse from(StaticOasisBanner entity) {
return OasisBannerResponse.builder()
.seq(entity.getSeq())
.exposureType(entity.getExpsType())
.regDate(entity.getRegDate())
.startDate(entity.getStartDate())
.endDate(entity.getEndDate())
.linkType(CommonEnumUtil.fromCode(entity.getLinkType().toString(), LinkType.values()).getCode())
.linkUrl(entity.getLinkUrl())
.iconImageUrl(entity.getIconImgUrl())
.backgroundColor(entity.getBackgroundColor())
.koreanText(entity.getKoText())
.englishText(entity.getEnText())
.registerer(entity.getRegisterer())
.updater(entity.getUpdater())
.build();
}
after: 인터페이스에 변수 매핑하면 끝..
object -> entity 한방향 매핑만 잘 연결해 놓으면 역방향 매핑(entity -> object)은 @InheritInverseConfiguration만 달아주면 알아서 매핑해준다는게 너무 편리하였다.
@Mapper(config = CommonMapper.class,
nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE,
nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS)
public interface OasisBannerMapper {
@Mapping(target = "exposureType", source = "expsType")
@Mapping(target = "iconImageUrl", source = "iconImgUrl")
@Mapping(target = "koreanText", source = "koText")
@Mapping(target = "englishText", source = "enText")
@Mapping(target = "linkType", expression = "java(convertLinkType(entity.getLinkType()))")
OasisBannerResponse toResponse(StaticOasisBanner entity);
@Mapping(target = "extra", ignore = true)
@Mapping(target = "lastDate", ignore = true)
@InheritInverseConfiguration
StaticOasisBanner toEntity(OasisBannerResponse request);
그러나...
단점 1. 커스텀이 (생각보다) 쉽지 않다(러닝 커브가 있음)
적절한 설정값을 확인해야 하고 다양한 매핑을 지원해야 하는지 확인해야 하는 등, 특이점이 많은 매핑에는 아직 시간이 걸린다(가끔은 그냥 빌더가 더 편하다).
예시로..
@Mapping(target = "expsType", source = "request.exposureType")
@Mapping(target = "iconImgUrl", source = "request.iconImageUrl")
@Mapping(target = "koText", source = "request.koreanText")
@Mapping(target = "enText", source = "request.englishText")
@Mapping(target = "extra", ignore = true)
@Mapping(target = "lastDate", ignore = true)
@Mapping(target = "updater", source = "updater")
// @InheritInverseConfiguration
StaticOasisBanner toEntity(OasisBannerResponse request, String updater);
위처럼 argument가 여러 개일 경우 @InheritInverseConfiguration를 사용하지 못하고 하나씩 다 매핑해줘야 한다.(이걸로 못 찾고 시간을 허비함..)
@InheritInverseConfiguration를 사용하여 반대 매핑도 자동으로 되는 게 매력적이었는데, 다시 수동으로 하려니 메리트가 절감된 느낌
단점 2. 에러가 친절하지 못함
단점 1에 기술한대로 추가 arguments가 있을 때는 모두 매핑해야 한다는 사실을 모르고 처음에는 생각대로 해봤는데, 빌드조차 되지 않았다. 그런데 에러 내용이 갑자기 querydsl 관련 에러였다?
처음에는 빌드창을 조금만 띄워놓았어서 에러내용을 끝까지 못 보고 querydsl 설정이 꼬였나 싶어서 거기만 팠는데,, 에러창을 더 열어보니 결국은 mapstruct 빌드로 부터 시작한 것이었다.
파악해 보니, 빌드 순서가 lombok -> mapstruct -> querydsl 순이었고
mapstruct 빌드가 실패하면 querydsl도 빌드되지 않기 때문에 에러메시지는 querydsl이 실패한 것처럼 보이나,
실제로 에러의 시작점은 mapstruct이었던 것이다(처음에 이것을 모르고 querydsl 설정이 문제라고 생각하여 시간을 허비함).
단점 3. @MappingTarget사용 시 setter 사용
@InheritConfiguration
void setEntity(OasisBannerResponse request, @MappingTarget StaticOasisBanner entity);
위처럼 @MappingTarget을 이용하면 setter 기반으로 매핑을 만들어주기 때문에 target 객체에 setter를 선언해야 한다..
setter를 최소화하고 싶었는데.... 고민이 더 필요할 것 같다.
기존 <interface>
@Mapping(target = "exposureType", source = "expsType")
@Mapping(target = "iconImageUrl", source = "iconImgUrl")
@Mapping(target = "koreanText", source = "koText")
@Mapping(target = "englishText", source = "enText")
@Mapping(target = "linkType", source = "linkType", qualifiedByName = "convertToLinkTypeFrom")
OasisBannerResponse toResponse(StaticOasisBanner entity);
-> 구현체: Builder를 이용한 객체 생성(builder -> constructor -> getter/setter 순으로 찾아서 구현체 생성)
@Override
public OasisBannerResponse toResponse(StaticOasisBanner entity) {
if ( entity == null ) {
return null;
}
OasisBannerResponse.OasisBannerResponseBuilder oasisBannerResponse = OasisBannerResponse.builder();
if ( entity.getExpsType() != null ) {
oasisBannerResponse.exposureType( entity.getExpsType() );
}
... 생략
<interface> MappingTarget 사용 시
@InheritConfiguration
void setEntity(OasisBannerRequest request, @MappingTarget StaticOasisBanner entity);
-> 구현체: setter를 이용한 매핑
@Override
public void setEntity(OasisBannerRequest request, StaticOasisBanner entity) {
if ( request == null ) {
return;
}
entity.setExpsType( request.getExposureType() );
if ( request.getIconImageUrl() != null ) {
entity.setIconImgUrl( request.getIconImageUrl() );
}
... 생략
단점 4. lombok의 @Singular와 함께 사용 불가한 듯
<before> 기존에는 아래처럼 구현했었음
public class OasisBannerResponse {
@JsonInclude(value = JsonInclude.Include.NON_DEFAULT)
@JsonProperty("freeWayName")
@Singular
private List<CodeValueModel> freeWayNames;
... 변수 생략
public static OasisBannerResponse from(StaticOasisBanner entity) {
return OasisBannerResponse.builder()
.startDate(entity.getStartDate())
.endDate(entity.getEndDate())
.linkType(entity.getLinkType())
.linkUrl(entity.getLinkUrl())
.iconImageUrl(entity.getIconImgUrl())
.backgroundColor(entity.getBackgroundColor())
.freeWayName(new CodeValueModel(LanguageType.KO.getCode(), entity.getKoText()))
.freeWayName(new CodeValueModel(LanguageType.EN.getCode(), entity.getEnText()))
.build();
}
}
<after> mapStruct 사용
기댓값: singular처럼 매핑 -> but 컴파일 에러 남
@Mapping(target = "iconImageUrl", source = "iconImgUrl")
@Mapping(target = "freeWayName", source = "enText", qualifiedByName = "addEnglish")
@Mapping(target = "freeWayName", source = "koText", qualifiedByName = "addKorean")
OasisBannerResponse toResponse(StaticOasisBanner entity);
@Named("addKorean")
default CodeValueModel addKorean(String koText) {
return toCodeValueModel(LanguageType.KO, koText);
}
@Named("addEnglish")
default CodeValueModel addEnglish(String enText) {
return toCodeValueModel(LanguageType.EN, enText);
}
error: Target property "freeWayName" must not be mapped more than once.
error: Unmapped target property: "freeWayNames".
여러 개 있어서 싫고,,매핑을 안 해서 싫더냐?! 거참 까다로운 자식..
해결: 결국.. entity를 다 받아서 했다..ㅠ
@Mapping(target = "iconImageUrl", source = "iconImgUrl")
@Mapping(target = "freeWayNames", source = ".", qualifiedByName = "setFreeWayNames")
OasisBannerResponse toResponse(StaticOasisBanner entity);
@Named("setFreeWayNames")
default List<CodeValueModel> setFreeWayNames(StaticOasisBanner entity) {
List<CodeValueModel> codes = new ArrayList<>();
setLanguageText(LanguageType.KO, entity.getKoText(), codes);
setLanguageText(LanguageType.EN, entity.getEnText(), codes);
return codes;
}
그리고
@Singular
private List<CodeValueModel> freeWayNames;
@Singular 선언이 있으면 위처럼 freeWayNames에 매핑을 했음에도 freeWayName도 매핑하라는 에러가 나고, 결국 @Singular를 지워야만 한다.
error: Unmapped target property: "freeWayName".
즉, 두개를 동시에 못쓴다는 이야기..
참고
1. 함수를 통해 변수를 변환하고 매핑해야 하는 경우
여러 가지 방법이 있겠지만 expression = "java()" 이 방법은 코드를 하드코딩하는 방법이라 잘못된 점을 빌드할 때까지 알 수 없음..
qualifiedByName = "" 방법을 사용하는 게 더 나은 것 같다.
enum을 더 효율적으로 매핑할 수 있는지 확인 필요
2. 설정을 함수별로 세팅할 수 있음
@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE,
nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS)
void update(AmcPackageRequest amcPackageRequest, @MappingTarget AmcPackage amcPackage);
3. MappingConfig 안에서 세부 설정을 할 수 있음
특히 collection mapping관련해서 설정값을 확인할 필요 있음
참고
성능: https://www.baeldung.com/java-performance-mapping-frameworks
사용방법: