Java object mapping, MapStruct

GEONNY·2024년 8월 8일
0

Building-API

목록 보기
19/28
post-thumbnail

Entity 와 Record 를 분리하는 이유에 대해선 이전에 알아보았습니다. 그럼 매번 이렇게 번거로운 작업을 해야 할까요? Java 에서는 객체간 매핑을 수행하는 라이브러리들이 있습니다. ModelMapper, MapStruct, Jmapper, Orika 등이 있지만 record 를 완벽지원하고, 여러가지 장점을 갖춘 MapStruct 에 대해 알아보겠습니다.

📌MapStruct

MapStruct 는 컴파일 시에 매핑 코드를 생성하여 타입 안정성을 보장하고 런타임에 매핑을 수행하는 다른 라이브러리들에 비해 성능적으로 우수합니다. 자동 매핑을 지원하면서도 커스텀도 가능합니다. 어노테이션 기반 설정방식으로 코드가 명확하고 유지보수에도 우수합니다.

📌설정방법

📍Add dependency

build.gradle

implementation group: 'org.projectlombok', name: 'lombok-mapstruct-binding', version: '0.2.0'
implementation "org.mapstruct:mapstruct:1.5.5.Final"
annotationProcessor "org.mapstruct:mapstruct-processor:1.5.5.Final"

build.gradle 파일을 열어 dependencies 에 추가합니다. lombok 의 @Getter, @Setter, @Builder 를 인식하지 못하는 호환성 문제를 해결하기 위한 lombok-mapstruct-binding 도 추가해 줍니다.

📍@Mapper 생성

의존성 추가가 완료되면 domain.member package 에 MemberMapper interface 를 생성합니다.
domain.member.MemberMapper

@Mapper
public interface MemberMapper {
    MemberSearchResponse toRecord(Member entity);
}

아무런 매핑 설정 없이 toRecord method 를 생성한 후 프로젝트를 실행합니다.

그럼 build.generated.sources.annotationProcessor...domain.memberMemberMapperImpl 파일이 생성되고, 해당 구현 클래스에 의해 매핑작업이 수행되게 됩니다.

📍MemberMapperImpl

public class MemberMapperImpl implements MemberMapper {

    @Override
    public MemberSearchResponse toRecord(Member entity) {
        if ( entity == null ) {
            return null;
        }

        MemberSearchResponse.MemberSearchResponseBuilder memberSearchResponse 
        	= MemberSearchResponse.builder();

        memberSearchResponse.memberId( entity.getMemberId() );
        memberSearchResponse.memberName( entity.getMemberName() );
        memberSearchResponse.useYn( entity.getUseYn() );
        memberSearchResponse.createDate( entity.getCreateDate() );
        memberSearchResponse.updateDate( entity.getUpdateDate() );

        return memberSearchResponse.build();
    }
}

생성된 코드를 보면 Builer Pattern 을 활용하여 entity 의 데이터를 record로 매핑 하는 것을 보실 수 있습니다. (record 에 @Builder 가 있기 때문에)
MapStruct 는 source 와 target, 여기서는 Entity 와 record 내 field 명이 같은 경우 위와 같이 자동으로 매핑 코드가 생성이 됩니다. field 명이 다른 경우에는 @Mapping 을 사용하여 설정 해주면 됩니다.

📍@Mapping

매핑하고자 하는 source 와 target 이 다를 경우 @Mapping 을 사용해서 매핑 설정을 해 줍니다. 예를들어 Entity 에는 tel 이라는 field를 record의 cellphone field 에 매핑하고 싶다면 아래와 같이 설정해줍니다.
domain.member.MemberMapper

@Mapper
public interface MemberMapper {
	@Mapping(target = "cellphone", source = "tel")
    MemberSearchResponse toRecord(Member entity);
}

🎈@Mappings 복수 매핑

여러 개의 @Mapping 이 필요한 경우 @Mappings를 사용하여 묶어 줍니다.

@Mapper
public interface MemberMapper {
    @Mappings({
            @Mapping(target = "cellphone", source = "tel"),
            @Mapping(target = "address", source = "addr")
    })
    MemberSearchResponse toRecord(Member entity);
}

🎈ignore 매핑 무시

매핑을 무시하고 싶은 경우에는 ignore 속성을 설정합니다.

@Mapping(target = "address", source = "addr", ignore = true)

🎈defaultValue 기본값 매핑

null 일 때 기본 값을 매핑하고 싶다면 defaultValue 속성을 설정합니다.

@Mapping(target = "address", source = "addr", defaultValue = "주소 없음")

🎈expression 커스텀 매핑

좀 더 복잡한 로직의 매핑이 필요하다면 expression 속성을 설정합니다.

@Mapping(target = "address", expression = "java(entity.getZipCode() + entity.getAddr())")

🎈Collection 매핑

Collection 매핑의 경우 아래와 같이 method 에 @Mapping 을 추가해도 정상적으로 동작하지 않습니다.

@Mapping(target = "address", expression = "java(entity.getZipCode() + entity.getAddr())")
List<MemberSearchResponse> toRecordList(List<Member> entityList);

아래와 같이 Collection 의 generic type 의 method를 생성해 주고, method 에 @Mapping을 설정해 정상적으로 동작합니다.

@Mapping(target = "address", expression = "java(entity.getZipCode() + entity.getAddr())")
MemberSearchResponse toRecord(Member entity);

List<MemberSearchResponse> toRecordList(List<Member> entityList);

📍Mapper 의 사용

@Override
public MemberSearchResponse getMemberById(String memberId) {
    Member memberEntity = memberRepository.findById(memberId)
            .orElseThrow(() -> new EntityNotFoundException(
            	"회원 ID 가 존재하지 않습니다. -> " + memberId));
                
    MemberMapper memberMapper = Mappers.getMapper(MemberMapper.class);
    return memberMapper.toRecord(memberEntity);
}

Mappers.getMapper method 를 통해 Mapper instance 를 생성 하여 toRecord method를 호출 합니다. 결과는 이전과 같습니다.

🎈instance 등록 방식

instance 를 mapper 에 등록해서 사용할 수도 있습니다.
domain.member.MemberMapper

@Mapper
public interface MemberMapper {
    MemberMapper INSTANCE = Mappers.getMapper(MemberMapper.class);
    
    MemberSearchResponse toRecord(Member entity);
}

domain member.service.MemberServiceImpl

@Override
public MemberSearchResponse getMemberById(String memberId) {
    Member memberEntity = memberRepository.findById(memberId)
            .orElseThrow(() -> new EntityNotFoundException(
            	"회원 ID 가 존재하지 않습니다. -> " + memberId));
        
    return MemberMapper.INSTANCE.toRecord(memberEntity);
}

getMapper method 는 MapStruct가 생성한 매퍼의 싱글톤 인스턴스를 반환합니다.

🎈spring bean 등록 방식

domain.member.MemberMapper

@Mapper(componentModel = "spring")
public interface MemberMapper {
    MemberSearchResponse toRecord(Member entity);
}

domain member.service.MemberServiceImpl

@Service
@RequiredArgsConstructor
public class MemberServiceImpl implements MemberService {

    private final MemberRepository memberRepository;

    private final MemberMapper memberMapper;

    @Override
    public MemberSearchResponse getMemberById(String memberId) {
        Member memberEntity = memberRepository.findById(memberId)
                .orElseThrow(() -> new EntityNotFoundException(
                	"회원 ID 가 존재하지 않습니다. -> " + memberId));

        return memberMapper.toRecord(memberEntity);
    }

@Mapper(componentModel = "spring") 을 설정하여 spring bean 에 등록한 후 DI 하여 사용합니다.

위에서 설명한 getMapper method 호출방식과 componentModel 설정의 차이는 아래와 같습니다.

특징 Mappers.getMapper @Mapper(componentModel = "spring")
매퍼 인스턴스 관리 MapStruct 내부에서 관리하는 싱글톤 스프링 컨텍스트에서 관리되는 스프링 빈
스프링 빈과의 연동 불가능 가능
의존성 주입 지원 불가능 가능
주로 사용되는 상황 간단한 매핑, 독립적으로 사용 스프링 환경에서 다른 빈과의 연동이 필요한 경우

🎈다른 class 의 method 사용

다른 클래스의 method 를 호출하기 위해선 @Mapper 에 import 속성을 설정 합니다. import 에 설정하는 class는 static class 로 생성합니다.
common.converter.Converter

public class Converter {
    public static String toUpperCase(String text) {
        return StringUtils.isEmpty(text) ? null : text.toUpperCase();
    }
}

Converter 의 toUpperCase method 를 호출하여 memberId 를 대문자로 매핑합니다.

@Mapper(componentModel = "spring", imports = Converter.class)
public interface MemberMapper {
    @Mapping(target = "memberId"
    	   , expression = "java(Converter.toUpperCase(entity.getMemberId()))")
    MemberSearchResponse toRecord(Member entity);
}

🎈default method 사용 @Named, qualifiedByName

interface 의 default method 를 활용하여 매핑 합니다.

@Mapper(componentModel = "spring")
public interface MemberMapper {
    @Mapping(target = "memberId"
           , expression = "java(toUpperCase(entity.getMemberId()))")
    MemberSearchResponse toRecord(Member entity);

    default String toUpperCase(String text) {
        return StringUtils.isEmpty(text) ? null : text.toUpperCase();
    }
}

default method 사용 시 주의해야 될 것이 있는데, 별도의 설정이 없으면 MapStruct 자동 매핑 규칙에 의해 type 이 같은 모든 필드에 적용이 됩니다. 위와 같이 설정 시 memberId field 에만 적용 설정을 하였지만, type 이 String 으로 같은 memberName, useYn 에도 적용이 되게 됩니다. 자동 생성된 코드를 보면 toUpperCase 호출 코드가 추가된 것을 확인할 수 있습니다.

    @Override
    public MemberSearchResponse toRecord(Member entity) {
        if ( entity == null ) {
            return null;
        }

        MemberSearchResponse.MemberSearchResponseBuilder memberSearchResponse 
        	= MemberSearchResponse.builder();

        memberSearchResponse.memberName( toUpperCase( entity.getMemberName() ) );
        memberSearchResponse.useYn( toUpperCase( entity.getUseYn() ) );
        memberSearchResponse.createDate( entity.getCreateDate() );
        memberSearchResponse.updateDate( entity.getUpdateDate() );

        memberSearchResponse.memberId( toUpperCase(entity.getMemberId()) );

        return memberSearchResponse.build();
    }

이를 해결 하기 위해선 default method 에 @Named() 를 사용하여 이름을 정해주고, @Mapping 속성에 qualifiedByName 속성을 추가하여 매핑해줘야 합니다.

@Mapper(componentModel = "spring")
public interface MemberMapper {
    @Mapping(target = "memberId", source = "memberId", qualifiedByName = "toUpperCase")
    MemberSearchResponse toRecord(Member entity);

    @Named("toUpperCase")
    default String toUpperCase(String text) {
        return StringUtils.isEmpty(text) ? null : text.toLowerCase();
    }
}

🎈다른 bean 을 mapper에서 사용

uses 속성을 사용하여 다른 bean을 mapper에서 활용할 수 있습니다.
domain.member.MemberMapper

@Mapper(componentModel = "spring", uses = AesCryptoService.class)
public interface MemberMapper {
	@Mapping(target = "memberName", source = "memberName", qualifiedByName = "decrypt"),
    MemberSearchResponse toRecord(Member entity);
}

config.crypto.AesCryptoService

@Service
public class AesCryptoService implements CryptoService {
    @Named("decrypt")
    public String decrypt(String encryptedText) {
    // 생략
    }
}

📍@AfterMapping

@AfterMapping은 매핑 메서드가 실행된 후 매핑 결과를 추가로 조작할 때 사용됩니다.
아래 조건을 충족해야만 특정 메서드에 대해 실행되니 주의하세요.

  1. 타겟 타입 매핑
    @MappingTarget 을 통해 해당 매핑 메서드가 반환하는 특정 타입의 객체를 지정합니다.
  2. 컨텍스트 매핑
    @Context를 사용하면 특정 매핑 메서드와 연결된 컨텍스트 데이터를 기반으로 실행됩니다.
  3. 명시적 호출
    동일한 Mapper Interface 내에서만 실행

위의 Collection 매핑 코드를 보겠습니다.

@Mapping(target = "address", expression = "java(entity.getZipCode() + entity.getAddr())")
MemberSearchResponse toRecord(Member entity);

List<MemberSearchResponse> toRecordList(List<Member> entityList,
										@Context Integer offset);

toRecordList method 실행 후에 실행될 default method를 작성합니다.

@AfterMapping
default List<MemberSearchResponse> addRowNumber(
		@MappingTarget List<MemberSearchResponse> list,
		@Context Integer offset) {
	return IntStream.range(0, list.size())
    	.mapToObj(i -> {
       		MemberSearchResponse original = list.get(i);
            return MemberSearchResponse.builder()
             	.rowNumber(offset + i + 1)
                //생략
                .build();
        }).toList();
}

toRecordList 가 호출 된 후에 addRowNumber 가 호출됩니다.

📚참고

📕MapStruct 동작 원리

MapStruct 는 Java 컴파일러와 함께 동작하며, 앞에서 의존성 주입했던 MapStrcut Processor 가 @Mapper 를 인식하여 매핑 규칙을 분석합니다. 입출력 타입, @Mapper에 작성한 매핑 규칙들을 분석하여 구현 Class 를 생성하고, Java Complier 에 의해 바이트 코드로 변환됩니다.

📙interface 상속으로 기본 메서드 생성

보통 Mapper를 작성할 때 반복하여 작성하는 메서드 들이 있습니다. 위의 toRecord, toRecordList 의 경우 데이터를 조회하는 거의 모든 메서드에 추가해야 하죠. 이렇게 반복되는 메서드를 갖는 interface 를 생성하고 해당 interface 를 상속 받으면 반복 코드를 줄일 수 있습니다. 물론 @Mapping 설정이 필요 하면 override 해야 하지만 설정할 필요가 없는 경우에는 다른 코드 추가 없이 상속만으로 해결할 수 있습니다.
common.mapStruct.GenericMapper

public interface GenericMapper<R, E> {
    R toRecord(E entity);
    List<R> toRecordList(List<E> entityList);
}

domain.member.MemberMapper
@Mapping 이 필요한 toRecord method 는 override 하고 toRecordList method 는 작성을 하지 않아도 사용 가능.

@Mapper(componentModel = "spring")
public interface MemberMapper extends GenericMapper<MemberSearchResponse, Member> {
    @Mapping(target = "memberId", source = "memberId", qualifiedByName = "toUpperCase")
    MemberSearchResponse toRecord(Member entity);

    @Named("toUpperCase")
    default String toUpperCase(String text) {
        return StringUtils.isEmpty(text) ? null : text.toUpperCase();
    }
}
profile
Back-end developer

0개의 댓글