MapStruct (2/2)

이전 편: [스프링] 1. MapStruct — 직접 만든 Mapper와 비교하며 시작하기

이 문서가 시리즈의 마지막 편입니다.

시리즈 안내

이 문서는 MapStruct 시리즈 2편이다.

- 1편 — 직접 만든 Mapper와 비교하며 시작하기

- 2편 — 의존성 주입, 커스텀 변환, 자주 하는 실수 (현재)

1편에서 필드명이 같은 단순 매핑은 끝냈다. 실제 프로젝트의 Mapper는 이보다 복잡하다. 다른 Mapper를 참조하거나, Entity에 없는 값을 외부에서 가져오거나, 컬렉션을 변환해야 하는 경우가 반드시 생긴다.

다른 Mapper 참조하기

discodeit의 UserMapperBinaryContentMapper를 주입받아 썼다.

@Component
@RequiredArgsConstructor
public class UserMapper {
    private final BinaryContentMapper binaryContentMapper;

    public UserDto toDto(User user) {
        return new UserDto(
                ...
                user.getProfile() == null ? null : binaryContentMapper.toDto(user.getProfile()),
                ...
        );
    }
}

MapStruct에서는 uses 속성으로 다른 Mapper를 선언하면 자동으로 위임한다.

@Mapper(componentModel = "spring", uses = BinaryContentMapper.class)
public interface UserMapper {
    UserDto toDto(User user);
}

User.profileBinaryContent 타입이고, UserDto.profileBinaryContentDto 타입이면, MapStruct가 자동으로 BinaryContentMapper.toDto()를 찾아 호출한다. null 체크도 생성된 코드에 포함된다.

ChannelMapper처럼 컬렉션 안의 요소를 변환해야 할 때도 마찬가지다.

// 기존 직접 작성 코드
channel.getParticipants().stream().map(userMapper::toDto).toList()
// MapStruct
@Mapper(componentModel = "spring", uses = UserMapper.class)
public interface ChannelMapper {
    ChannelDto toDto(Channel channel, Instant lastMessageAt);
}

Channel.participantsList<User>이고 ChannelDto.participantsList<UserDto>면, uses에 선언된 UserMapper를 통해 자동으로 각 요소를 변환한다.

Entity에 없는 필드 처리하기

UserDtoonline 필드는 User Entity에 없다. SessionManager를 통해 실시간으로 계산해야 한다. 이런 경우 MapStruct 인터페이스만으로는 처리할 수 없다.

두 가지 방법이 있다.

방법 A — abstract class + @AfterMapping

인터페이스 대신 abstract class로 선언하고, 변환 후 후처리 메서드를 직접 작성한다.

@Mapper(componentModel = "spring")
public abstract class UserMapper {
    @Autowired
    private SessionManager sessionManager;

    public abstract UserDto toDto(User user);

    @AfterMapping
    protected void fillOnlineStatus(User user, @MappingTarget UserDto.UserDtoBuilder dto) {
        dto.online(sessionManager.isOnline(user.getUsername()));
    }
}
  • MapStruct가 toDto() 구현체를 생성하면서, 마지막에 @AfterMapping 메서드를 자동으로 호출한다.
  • @MappingTarget은 현재 조립 중인 타겟 객체를 가리킨다.
  • UserDto가 Builder 패턴을 지원해야 한다.

방법 B — @Mapping(expression)

간단한 표현식은 expression 속성으로 직접 넣을 수 있다.

@Mapper(componentModel = "spring")
public interface UserMapper {
    @Mapping(target = "online", expression = "java(sessionManager.isOnline(user.getUsername()))")
    UserDto toDto(User user);
}

표현식이 복잡해지면 읽기 어려워진다. 단순한 경우에만 쓰는 게 낫다.

어떤 방법을 선택할까

외부 의존성(Service, Repository 등)이 필요하면 abstract class + @AfterMapping.

단순 계산이나 타입 변환은 expression.

복잡도가 높아질수록 abstract class가 유지보수하기 쉽다.

컬렉션 매핑

단일 객체 변환 메서드가 있으면, 리스트 변환 메서드는 선언만 해도 된다.

@Mapper(componentModel = "spring")
public interface BinaryContentMapper {
    BinaryContentDto toDto(BinaryContent binaryContent);

    List<BinaryContentDto> toDtoList(List<BinaryContent> binaryContents);
}

toDtoList()는 내부적으로 toDto()를 각 요소에 적용한다. null 리스트가 들어오면 null을 반환하고, 빈 리스트는 빈 리스트를 반환한다.

역방향 매핑

DTO → Entity 변환도 선언할 수 있다.

@Mapper(componentModel = "spring")
public interface BinaryContentMapper {
    BinaryContentDto toDto(BinaryContent binaryContent);

    @InheritInverseConfiguration
    BinaryContent toEntity(BinaryContentDto dto);
}

@InheritInverseConfigurationtoDto()@Mapping 설정을 source/target을 뒤집어서 재사용한다. 필드명이 달라서 @Mapping을 여러 개 달았다면, 역방향에 똑같이 반복할 필요가 없다.

자주 하는 실수

Lombok getter를 못 찾는 문제

빌드 시 아래 경고가 나오면 Lombok 순서 문제다.

warning: Unmapped target property: "fieldName"

annotationProcessor 순서를 확인한다.

annotationProcessor 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'

필드 누락 경고를 무시하는 문제

MapStruct는 기본적으로 타겟 필드가 매핑되지 않으면 경고(warning)만 낸다. 오류가 아니다. 그래서 빠뜨린 필드가 있어도 빌드는 통과한다. 이를 오류로 바꾸려면 unmappedTargetPolicy를 설정한다.

@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface BinaryContentMapper {
    BinaryContentDto toDto(BinaryContent binaryContent);
}

팀 프로젝트에서 실수를 막으려면 전역 설정으로 두는 게 낫다. MapperConfig로 공통 설정을 만들 수 있다.

@MapperConfig(
    componentModel = "spring",
    unmappedTargetPolicy = ReportingPolicy.ERROR
)
public interface BaseMapperConfig {}
@Mapper(config = BaseMapperConfig.class)
public interface BinaryContentMapper {
    BinaryContentDto toDto(BinaryContent binaryContent);
}

uses에 선언하지 않은 Mapper를 기대하는 문제

uses에 없는 Mapper의 변환을 기대하면 MapStruct가 직접 변환을 시도한다. 타입이 맞지 않으면 컴파일 오류가 나고, 우연히 타입이 맞아 매핑되면 의도치 않은 동작이 생긴다. 중첩 객체가 있으면 항상 uses를 명시하는 습관을 들여야 한다.

순환 참조 주의

A Mapper가 B Mapper를 uses하고, B Mapper가 A Mapper를 uses하면 순환 참조로 컴파일이 실패한다. 중간 Mapper를 분리하거나, 한쪽에서 직접 변환 로직을 가져야 한다.


면접 질문

Q. MapStruct와 ModelMapper의 차이는?

MapStruct는 컴파일 타임 코드 생성, ModelMapper는 런타임 리플렉션이다. MapStruct는 성능이 뛰어나고 타입 안전하지만 빌드 설정이 필요하다. ModelMapper는 설정이 간단하지만 런타임 오버헤드와 타입 불안전 위험이 있다.

Q. MapStruct가 생성한 코드는 어디서 확인하나?

build/generated/sources/annotationProcessor (Gradle) 또는 target/generated-sources/annotations (Maven)에 생성된다. IDE에서도 직접 열어볼 수 있다.

Q. (함정) @Mapper를 인터페이스가 아닌 추상 클래스에 붙여도 되나?

된다. abstract class에 붙이면 직접 구현한 메서드는 그대로 유지되고, abstract 메서드만 MapStruct가 생성한다. 외부 의존성이 필요한 경우 abstract class 방식이 더 유연하다.

Q. (함정) unmappedTargetPolicy를 설정하지 않으면 어떻게 되나?

기본값이 WARN이라 빌드는 성공한다. 매핑되지 않은 필드는 null이나 기본값이 들어간다. 운영 중에 데이터 누락으로 이어질 수 있어, 프로젝트 초기에 ERROR로 설정하는 게 안전하다.