이 문서는 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가 빌드 과정에 어떻게 개입하는지를 나타낸다.
(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이 먼저 실행되어야 @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의 기본 전략은 이름 기반 매핑이다. 소스와 타겟의 필드명이 같으면 자동으로 연결된다. 타입이 달라도 기본 타입 간 변환(int → long, String → int 등)은 자동으로 처리한다.
이름이 다를 때는 @Mapping이 필요하다. 아예 무시하고 싶은 필드는 ignore = true를 쓴다.
@Mapping(target = "createdAt", ignore = true)
BinaryContentDto toDto(BinaryContent binaryContent);
타겟 필드를 null로 두겠다는 뜻이다. DTO에 Entity에 없는 필드가 있을 때 유용하다.
- MapStruct는 @Mapper 인터페이스를 보고 컴파일 타임에 구현체를 생성한다
- 런타임 리플렉션 없음 → 성능 부담 없음
- 필드명이 같으면 자동 매핑, 다르면 @Mapping(source, target)으로 지정
- Lombok과 함께 쓸 때 annotationProcessor 순서 주의