Spring MapStruct DTO 매핑

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 애플리케이션의 모든 계층에서 활용됩니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux