MapStruct란?
MapStruct는 Java Bean 간 매핑 코드를 컴파일 타임에 자동 생성하는 코드 제너레이터입니다. Entity → DTO, DTO → Entity 변환을 수동으로 작성하면 반복적이고 실수하기 쉬운데, MapStruct는 인터페이스 선언만으로 타입 안전한 매핑 코드를 생성합니다.
이 글에서는 기본 매핑부터 커스텀 변환, 중첩 객체 매핑, 컬렉션 매핑, Spring DI 통합, 양방향 매핑, 그리고 실전 아키텍처 패턴을 다룹니다.
의존성 설정
<!-- Maven -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.6.3</version>
</dependency>
<!-- Annotation Processor -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>1.6.3</version>
</path>
<!-- Lombok과 함께 사용 시 순서 중요! -->
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.34</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>0.2.0</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
기본 매핑
// Entity
@Entity
@Getter @Setter
public class User {
@Id @GeneratedValue
private Long id;
private String firstName;
private String lastName;
private String email;
private LocalDateTime createdAt;
@ManyToOne(fetch = FetchType.LAZY)
private Department department;
@OneToMany(mappedBy = "user")
private List<Order> orders;
}
// Response DTO
public record UserResponse(
Long id,
String firstName,
String lastName,
String email,
String fullName,
String departmentName,
int orderCount,
LocalDateTime createdAt
) {}
// Create DTO
public record CreateUserRequest(
String firstName,
String lastName,
String email
) {}
@Mapper(componentModel = "spring") // Spring Bean으로 등록
public interface UserMapper {
// 같은 이름 필드는 자동 매핑
// 다른 이름은 @Mapping으로 지정
@Mapping(target = "fullName",
expression = "java(user.getFirstName() + " " + user.getLastName())")
@Mapping(target = "departmentName", source = "department.name")
@Mapping(target = "orderCount", expression = "java(user.getOrders().size())")
UserResponse toResponse(User user);
// DTO → Entity (id, createdAt 등은 무시)
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "department", ignore = true)
@Mapping(target = "orders", ignore = true)
User toEntity(CreateUserRequest request);
// 컬렉션 매핑 (자동으로 반복 처리)
List<UserResponse> toResponseList(List<User> users);
}
컴파일 시 MapStruct가 UserMapperImpl 클래스를 자동 생성합니다. 리플렉션 없이 순수 getter/setter 호출이므로 성능이 뛰어납니다.
커스텀 변환 메서드
@Mapper(componentModel = "spring")
public interface OrderMapper {
@Mapping(target = "statusLabel", source = "status", qualifiedByName = "statusToLabel")
@Mapping(target = "totalFormatted",
expression = "java(formatCurrency(order.getTotalAmount()))")
@Mapping(target = "userName", source = "user.firstName")
OrderResponse toResponse(Order order);
// @Named로 커스텀 변환 메서드 정의
@Named("statusToLabel")
default String statusToLabel(OrderStatus status) {
return switch (status) {
case PENDING -> "주문 접수";
case CONFIRMED -> "주문 확인";
case SHIPPED -> "배송 중";
case DELIVERED -> "배송 완료";
case CANCELLED -> "취소됨";
};
}
// expression에서 호출하는 헬퍼 메서드
default String formatCurrency(BigDecimal amount) {
if (amount == null) return "₩0";
return "₩" + NumberFormat.getNumberInstance(Locale.KOREA).format(amount);
}
}
업데이트 매핑: 기존 엔티티에 DTO 값 적용
@Mapper(componentModel = "spring")
public interface UserMapper {
// 기존 엔티티를 업데이트 (null 필드는 무시)
@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
void updateEntity(UpdateUserRequest dto, @MappingTarget User entity);
}
// 서비스에서 사용
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepo;
private final UserMapper userMapper;
@Transactional
public UserResponse update(Long id, UpdateUserRequest request) {
User user = userRepo.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
userMapper.updateEntity(request, user); // 변경된 필드만 업데이트
// JPA 더티체킹으로 자동 저장
return userMapper.toResponse(user);
}
}
중첩 객체 매핑
// 중첩 DTO
public record OrderDetailResponse(
Long id,
String status,
UserSummary user, // 중첩 객체
List<OrderItemDto> items // 중첩 컬렉션
) {}
public record UserSummary(Long id, String name, String email) {}
public record OrderItemDto(Long productId, String productName, int quantity, BigDecimal price) {}
@Mapper(componentModel = "spring", uses = {UserMapper.class})
public interface OrderMapper {
@Mapping(target = "user", source = "user") // UserMapper.toSummary() 자동 사용
@Mapping(target = "items", source = "orderItems")
OrderDetailResponse toDetailResponse(Order order);
// OrderItem → OrderItemDto
@Mapping(target = "productName", source = "product.name")
OrderItemDto toItemDto(OrderItem item);
}
// UserMapper에 UserSummary 매핑 추가
@Mapper(componentModel = "spring")
public interface UserMapper {
@Mapping(target = "name",
expression = "java(user.getFirstName() + " " + user.getLastName())")
UserSummary toSummary(User user);
}
uses 속성으로 다른 Mapper를 주입하면, MapStruct가 자동으로 적절한 매핑 메서드를 선택합니다.
Enum 매핑
@Mapper(componentModel = "spring")
public interface StatusMapper {
// Enum 간 매핑
@ValueMapping(source = "PENDING", target = "IN_PROGRESS")
@ValueMapping(source = "CONFIRMED", target = "IN_PROGRESS")
@ValueMapping(source = "SHIPPED", target = "IN_TRANSIT")
@ValueMapping(source = MappingConstants.ANY_REMAINING, target = "UNKNOWN")
ExternalStatus toExternalStatus(OrderStatus internal);
}
데코레이터 패턴: 복잡한 로직 추가
@Mapper(componentModel = "spring")
@DecoratedWith(UserMapperDecorator.class)
public interface UserMapper {
UserResponse toResponse(User user);
}
// 데코레이터 — 추가 로직 삽입
public abstract class UserMapperDecorator implements UserMapper {
@Autowired
@Qualifier("delegate")
private UserMapper delegate;
@Autowired
private StorageService storageService;
@Override
public UserResponse toResponse(User user) {
UserResponse response = delegate.toResponse(user);
// 프로필 이미지 URL을 signed URL로 변환
if (user.getProfileImageKey() != null) {
String signedUrl = storageService.getSignedUrl(user.getProfileImageKey());
return response.withProfileImageUrl(signedUrl);
}
return response;
}
}
컴파일 타임 안전성
@Mapper(
componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.ERROR, // 매핑 안 된 타겟 필드 → 컴파일 에러
unmappedSourcePolicy = ReportingPolicy.WARN // 매핑 안 된 소스 필드 → 경고
)
public interface UserMapper {
// id 필드를 매핑하지 않으면 컴파일 에러 발생
// → @Mapping(target = "id", ignore = true) 명시 필요
User toEntity(CreateUserRequest request);
}
이것이 MapStruct의 최대 장점입니다. 엔티티에 필드가 추가되면 컴파일 시점에 매핑 누락을 잡아줍니다. AOP나 리플렉션 기반 매퍼(ModelMapper)에서는 런타임까지 발견할 수 없는 버그입니다.
성능 비교
- MapStruct: 컴파일 타임 코드 생성 → 순수 setter 호출 → 가장 빠름
- ModelMapper: 리플렉션 기반 → 10~100배 느림, 런타임 에러 위험
- 수동 매핑: MapStruct와 동일 성능, 유지보수 비용 높음
실전 아키텍처 패턴
// 레이어별 매핑 책임
Controller ←→ [Mapper] ←→ Service ←→ Repository
Request DTO → Entity (toEntity)
Response DTO ← Entity (toResponse)
// 패키지 구조
com.example.user/
├── UserController.java
├── UserService.java
├── UserRepository.java
├── User.java // Entity
├── dto/
│ ├── CreateUserRequest.java
│ ├── UpdateUserRequest.java
│ └── UserResponse.java
└── mapper/
└── UserMapper.java // MapStruct 인터페이스
마무리
MapStruct는 Spring 프로젝트에서 DTO 매핑의 사실상 표준입니다. 컴파일 타임 코드 생성으로 성능과 타입 안전성을 동시에 확보하며, unmappedTargetPolicy = ERROR로 필드 누락을 컴파일 시점에 차단합니다. Method Security와 결합하여 권한별로 다른 DTO를 반환하는 패턴, 전역 예외 처리에서 에러 DTO 매핑까지 — MapStruct는 Spring 애플리케이션의 모든 계층에서 활용됩니다.