시리즈 안내

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

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

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

Entity를 DTO로 변환하는 코드는 프로젝트 어디에나 있다. 처음엔 간단해 보이지만, 필드가 늘어나고 중첩 객체가 생기면 Mapper 클래스가 금세 복잡해진다. MapStruct는 이 변환 코드를 컴파일 타임에 자동으로 생성해준다. 런타임 리플렉션 없이, 직접 작성한 코드와 동일한 품질로.

직접 만든 Mapper의 모습

discodeit 프로젝트의 BinaryContentMapper를 보자. 가장 단순한 형태다.

@Component
public class BinaryContentMapper {
    public BinaryContentDto toDto(BinaryContent binaryContent) {
        return new BinaryContentDto(
                binaryContent.getId(),
                binaryContent.getFileName(),
                binaryContent.getSize(),
                binaryContent.getContentType()
        );
    }
}

필드 이름이 같고 타입도 같으면 이 코드는 그냥 나열이다. BinaryContent에 필드가 추가될 때마다 여기도 수동으로 추가해야 한다. 빠뜨려도 컴파일러는 아무 말이 없다.

UserMapper는 조금 더 복잡하다. 다른 Mapper를 주입받고, 조건 분기도 들어간다.

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

    public UserDto toDto(User user) {
        return new UserDto(
                user.getId(),
                user.getRole(),
                user.getUsername(),
                user.getEmail(),
                user.getProfile() == null ? null : binaryContentMapper.toDto(user.getProfile()),
                sessionManager.isOnline(user.getUsername())
        );
    }
}

profile이 null일 수 있어서 직접 null 체크를 해야 하고, online 필드는 Entity에 없는 값이라 SessionManager를 통해 직접 계산한다. 이런 케이스가 많아질수록 Mapper 코드는 점점 손이 많이 간다.

MapStruct가 하는 일

MapStruct는 @Mapper 인터페이스를 보고 컴파일 타임에 구현체를 자동으로 생성한다. 애노테이션 프로세서(annotation processor)가 빌드 시점에 실제 Java 코드를 만들어내기 때문에, 런타임에 리플렉션이 전혀 없다.

아래는 MapStruct가 생성하는 구현체의 실제 모습이다. target/generated-sources에서 확인할 수 있다.

// MapStruct가 생성한 코드 (직접 작성하지 않음)
@Component
public class BinaryContentMapperImpl implements BinaryContentMapper {
    @Override
    public BinaryContentDto toDto(BinaryContent binaryContent) {
        if (binaryContent == null) {
            return null;
        }
        UUID id = binaryContent.getId();
        String fileName = binaryContent.getFileName();
        Long size = binaryContent.getSize();
        String contentType = binaryContent.getContentType();
        BinaryContentDto binaryContentDto = new BinaryContentDto(id, fileName, size, contentType);
        return binaryContentDto;
    }
}

직접 작성한 코드와 구조가 동일하다. 차이는 null 체크가 자동으로 들어간다는 것과, 이 코드를 내가 쓰지 않았다는 것이다.

아래 다이어그램은 MapStruct가 빌드 과정에 어떻게 개입하는지를 나타낸다.

sequenceDiagram participant Dev as 개발자 participant Compiler as javac participant AP as 애노테이션 프로세서
(MapStruct) participant Output as 생성된 구현체 Dev->>Compiler: 빌드 실행 Compiler->>AP: @Mapper 인터페이스 감지 Note over AP: 인터페이스 메서드 분석
필드명/타입 매핑 규칙 계산 AP->>Output: XxxMapperImpl.java 생성 Compiler->>Output: 생성된 파일 컴파일 Note over Output: 일반 Java 클래스로 존재
리플렉션 없음

생성된 구현체는 빌드 결과물의 일부로 존재한다. IDE에서도 열어볼 수 있고, 디버깅도 가능하다.

의존성 설정

Gradle 기준으로 두 가지를 추가해야 한다. 의존성 하나, 애노테이션 프로세서 하나.

dependencies {
    implementation 'org.mapstruct:mapstruct:1.5.5.Final'
    annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
}

Lombok도 함께 쓴다면 순서가 중요하다. Lombok의 annotationProcessor가 MapStruct보다 먼저 선언되어야 한다.

annotationProcessor 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
Lombok과의 순서 문제

Lombok이 먼저 실행되어야 @Getter로 생성된 메서드를 MapStruct가 감지할 수 있다. 순서가 바뀌면 MapStruct가 getter를 못 찾아 매핑에 실패한다.

기본 매핑 작성

BinaryContentMapper를 MapStruct로 바꿔보자. 클래스 대신 인터페이스로 선언하고 @Mapper만 붙이면 된다.

@Mapper(componentModel = "spring")
public interface BinaryContentMapper {
    BinaryContentDto toDto(BinaryContent binaryContent);
}
  • componentModel = "spring" : 생성된 구현체를 Spring Bean으로 등록한다. 기존처럼 @Autowired나 생성자 주입으로 쓸 수 있다.
  • 메서드 시그니처만 선언하면 나머지는 MapStruct가 채운다.
  • 필드명이 같으면 자동으로 매핑된다.

필드명이 다를 때는 @Mapping으로 명시한다.

@Mapper(componentModel = "spring")
public interface BinaryContentMapper {

    @Mapping(source = "fileName", target = "name")
    BinaryContentDto toDto(BinaryContent binaryContent);
}

source는 Entity 필드명, target은 DTO 필드명이다.

필드명이 같을 때와 다를 때

MapStruct의 기본 전략은 이름 기반 매핑이다. 소스와 타겟의 필드명이 같으면 자동으로 연결된다. 타입이 달라도 기본 타입 간 변환(intlong, Stringint 등)은 자동으로 처리한다.

이름이 다를 때는 @Mapping이 필요하다. 아예 무시하고 싶은 필드는 ignore = true를 쓴다.

@Mapping(target = "createdAt", ignore = true)
BinaryContentDto toDto(BinaryContent binaryContent);

타겟 필드를 null로 두겠다는 뜻이다. DTO에 Entity에 없는 필드가 있을 때 유용하다.

1편 핵심 정리

- MapStruct는 @Mapper 인터페이스를 보고 컴파일 타임에 구현체를 생성한다

- 런타임 리플렉션 없음 → 성능 부담 없음

- 필드명이 같으면 자동 매핑, 다르면 @Mapping(source, target)으로 지정

- Lombok과 함께 쓸 때 annotationProcessor 순서 주의