
Entity 와 Record 를 분리하는 이유에 대해선 이전에 알아보았습니다. 그럼 매번 이렇게 번거로운 작업을 해야 할까요? Java 에서는 객체간 매핑을 수행하는 라이브러리들이 있습니다. ModelMapper, MapStruct, Jmapper, Orika 등이 있지만 record 를 완벽지원하고, 여러가지 장점을 갖춘 MapStruct 에 대해 알아보겠습니다.
MapStruct 는 컴파일 시에 매핑 코드를 생성하여 타입 안정성을 보장하고 런타임에 매핑을 수행하는 다른 라이브러리들에 비해 성능적으로 우수합니다. 자동 매핑을 지원하면서도 커스텀도 가능합니다. 어노테이션 기반 설정방식으로 코드가 명확하고 유지보수에도 우수합니다.
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 도 추가해 줍니다.
의존성 추가가 완료되면 domain.member package 에 MemberMapper interface 를 생성합니다.
domain.member.MemberMapper
@Mapper
public interface MemberMapper {
MemberSearchResponse toRecord(Member entity);
}
아무런 매핑 설정 없이 toRecord method 를 생성한 후 프로젝트를 실행합니다.

그럼 build.generated.sources.annotationProcessor...domain.member 에 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 을 사용하여 설정 해주면 됩니다.
매핑하고자 하는 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);
}
여러 개의 @Mapping 이 필요한 경우 @Mappings를 사용하여 묶어 줍니다.
@Mapper
public interface MemberMapper {
@Mappings({
@Mapping(target = "cellphone", source = "tel"),
@Mapping(target = "address", source = "addr")
})
MemberSearchResponse toRecord(Member entity);
}
매핑을 무시하고 싶은 경우에는 ignore 속성을 설정합니다.
@Mapping(target = "address", source = "addr", ignore = true)
null 일 때 기본 값을 매핑하고 싶다면 defaultValue 속성을 설정합니다.
@Mapping(target = "address", source = "addr", defaultValue = "주소 없음")
좀 더 복잡한 로직의 매핑이 필요하다면 expression 속성을 설정합니다.
@Mapping(target = "address", expression = "java(entity.getZipCode() + entity.getAddr())")
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);
@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 를 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가 생성한 매퍼의 싱글톤 인스턴스를 반환합니다.
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 내부에서 관리하는 싱글톤 | 스프링 컨텍스트에서 관리되는 스프링 빈 |
| 스프링 빈과의 연동 | 불가능 | 가능 |
| 의존성 주입 지원 | 불가능 | 가능 |
| 주로 사용되는 상황 | 간단한 매핑, 독립적으로 사용 | 스프링 환경에서 다른 빈과의 연동이 필요한 경우 |
다른 클래스의 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);
}
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();
}
}
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은 매핑 메서드가 실행된 후 매핑 결과를 추가로 조작할 때 사용됩니다.
아래 조건을 충족해야만 특정 메서드에 대해 실행되니 주의하세요.
위의 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 는 Java 컴파일러와 함께 동작하며, 앞에서 의존성 주입했던 MapStrcut Processor 가 @Mapper 를 인식하여 매핑 규칙을 분석합니다. 입출력 타입, @Mapper에 작성한 매핑 규칙들을 분석하여 구현 Class 를 생성하고, Java Complier 에 의해 바이트 코드로 변환됩니다.
보통 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();
}
}