본문 바로가기
📗 JPA

MapStruct! JPA Entity 매핑 간 주의해서 사용하자

by SweeetGogum 2024. 3. 24.

최근 비상 결제 모드를 개발 및 배포하며, 
참으로 어이 없는 작은 코딩 관련 이슈로 인해 데이터에 이상이 있었다.
바로, MapStruct 얘 때문인데..
한번 나열해보겠다.
(항상 생각하는 거지만, 코드 단 한줄의 파급효과가 엄청 나다.)

 

MapStruct 란?

  • Java Bean 유형 간 매핑 구현을 도와주는 코드 생성기
  • 컴파일 타임에 코드 생성 및 런타임에서 안정성 보장
  • 순수 Java code 를 호출하므로 다른 매핑 라이브러리보다 속도가 빠르다. (Reflection 을 사용하지 않기 때문)
  • Annotation Processor 를 이용하여 매핑 방식에 편리함을 제공

 

사용 예시

JAVA

@Mapper
public interface UserMapper {

		UserMapper INSTANCE = Mappers.getMapper(UserMapper.class);
		
		UserResponseDTO convertToUserResponseDTO(User user);
}

 
Kotlin

@Entity
@Table(name = "users")
class User(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,
    val uid: String,
    val name: String,
    val address: String,
    val phoneNumber: String,
    val createdAt: LocalDateTime = LocalDateTime.now(),
    @LastModifiedDate
    val updatedAt: LocalDateTime = LocalDateTime.now(),
)

data class UserResponseDTO(
    val uid: String,
    val name: String,
    val address: String,
)

@Mapper(
    componentModel = ComponentModel.SPRING,
    nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE,
    nullValueMappingStrategy = NullValueMappingStrategy.RETURN_DEFAULT,
    unmappedTargetPolicy = ReportingPolicy.IGNORE,
    unmappedSourcePolicy = ReportingPolicy.IGNORE
)
interface UserMapper {

		companion object {
				val INSTANCE = Mappers.getMapper(UserMapper::class.java)
		}
		
		fun convertToResponseDTO(user: User): UserResponseDTO
}

 
위와 같이 예시 코드가 있다.
gradle 의 classes task 를 실행하여, build 하게 되면 아래와 같이 MapStruct 가 UserMapper 를 구현한, UserMapperImpl 클래스를 생성해주는 것을 확인할 수 있다.
 

MapStruct 사용 중 이슈 발생

최근 MapStruct 사용 중 이슈가 발생했다.
 

이슈 설명

  • Jpa Entity 매핑 중, id (PK) 까지 매핑 되어 버린 이슈
  • Jpa Entity 를 생성하여 Id 필드 값이 0 이거나, null 일 경우, mysql 의 auto_increment 속성에 의해 선형적으로 증가한 데이터로 저장할 수 있다.
    • 하지만, id 필드 값을 할당한 후 save 를 하게 되면, JPA 는 해당 식별 키를 가진 DB Record 가 존재할 경우, Update 쿼리를, 존재하지 않을 경우 Insert 쿼리를 생성하여 Flush 한다.
    • 이 부분에서 개발계, 운영계 DB 데이터가 상이하여 인지 되지 못한 채 문제를 일으켰다.

이슈 트래킹 과정

    @Mapping(
        source = "dto.orderItemStatus",
        target = "status"
    )
    @Mapping(
        source = "order",
        target = "order"
    )
    @Mapping(
        target = "oiid",
        expression = "java(OrderItem.generateOIID(hashedId))"
    )
    fun convertToOrderItem(
        dto: OrderSyncRequestDTO.OrderItem,
        order: Order,
        sellerId: Long,
        hashedId: String,
    ): OrderItem
  • 문제가 되었던 함수
  • Order JPA Entity 를 인자로 넘긴 상황
@Generated(
    value = "org.mapstruct.ap.MappingProcessor",
    date = "2024-03-21T14:37:36+0900",
    comments = "version: 1.5.4.Final, compiler: IncrementalProcessingEnvironment from kotlin-annotation-processing-gradle-1.7.22.jar, environment: Java 17.0.7 (Azul Systems, Inc.)"
)
@Component
public class OrderItemMapperImpl implements OrderItemMapper {

    @Override
    public OrderItem convertToOrderItem(OrderSyncRequestDTO.OrderItem dto, Order order, long sellerId, String hashedId) {

        OrderItemStatus status = null;
        String productName = null;
        BigDecimal productPrice = null;
        int quantity = 0;
        BigDecimal vat = null;
        BigDecimal vos = null;
        BigDecimal discountedPrice = null;
        BigDecimal originalPrice = null;
        String categoryName = null;
        Integer originalQuantity = null;
        if ( dto != null ) {
            isVat = dto.getVatted();
            status = dto.getOrderItemStatus();
            productName = dto.getProductName();
            productPrice = dto.getProductPrice();
            quantity = dto.getQuantity();
            totalPrice = dto.getTotalPrice();
            vat = dto.getVat();
            vos = dto.getVos();
            discountedPrice = dto.getDiscountedPrice();
            originalPrice = dto.getOriginalPrice();
            priceType = dto.getPriceType();
            originalQuantity = dto.getOriginalQuantity();
        }
        Order order1 = null;
        long sellerId1 = 0L;
        if ( order != null ) {
            order1 = order;
            sellerId1 = order.getSellerId();
        }

        String oiid = OrderItem.generateOIID(hashedId);
        LocalDateTime paidAt = null;
        LocalDateTime refundedAt = null;
        Long productId = null;

        OrderItem orderItem = new OrderItem( oiid, productName, productPrice, quantity, options, paidAt, refundedAt, productId, sellerId1, totalPrice, isVat, vat, vos, originalPrice, status, order1 );

				// 이 부분이 문제
        if ( order != null ) {
            orderItem.setId( order.getId() );
            orderItem.setCreatedAt( order.getCreatedAt() );
            orderItem.setUpdatedAt( order.getUpdatedAt() );
        }

        return orderItem;
    }
}

 
OrderItemMapperImpl 클래스에서 해당 추상 메서드를 구현한 코드는 같은 이름의 필드를 매핑하기 위해 order 의 id, createdAt, updatedAt 를 getter 를 통해 호출, 새롭게 생성된 orderItem 객체에 속성해 setter 를 통해 부여하고 있는 부분을 확인할 수 있다.
 

개발계

개발계에서 order_items 테이블 중 중간에 비어 있는 레코드가 다수 있었고, auto_increment 되는 order 의 id 에 해당 되는 레코드가 없었으므로 Insert Query 가 발생했다.

 

개발계 디버그 테스트

요구 사항

  • order 및 order_items 를 생성하고 저장

디버그 과정

  • 새로이, auto_increment 될 order 의 id = 9098875
  • order_items id 가 9098875 인 레코드는 없는 상황
  • order_items jpa entity 는 2개가 생겼지만, id (order entity 의 id 와 동일) 는 동일한 것을 확인할 수 있다..
  • 그 결과 insert 는 되지만, order_item 레코드는 1개만 쌓인다.
  • 만약 있었다면, 기존 데이터는 update 된다.

⇒ 동일한 요청을 통해 update 가 되어버린 order_item 데이터 확인 가능
 

테스트계

  • 운영계에서 order_items 테이블은 비어 있는 데이터가 없었으므로, 새롭게 auto_increment 되는 order id 값과 동일한 order_item 의 id 값은 이미 존재하므로, update Query 가 발생되었다.

⇒ order_items 가 2개 인 상황에서 update 쿼리만 발생 되는 것을 확인할 수 있었다. (rollback 을 통해, DB 반영은 안 시켰다.)
⇒ 실제로 2개 로우가 새롭게 적재되어야 하는 order_item 은 동일한 order_id id 값을 할당 받은 후 기존에 저장 되어 있는 order_item 레코드의 데이터를 update 하는 결과로 이어졌다.
 

해결

  • hotfix 상황이므로, 간략히 MapStruct 애노테이션 Mapping 의 속성 중 ignore 속성을 통해 id, createdAt, updatedAt 필드를 true 로 설정했다.
    @Mapping(
        source = "dto.orderItemStatus",
        target = "status"
    )
    @Mapping(
        source = "order",
        target = "order"
    )
    @Mapping(
        target = "oiid",
        expression = "java(OrderItem.generateOIID(hashedId))"
    )
    @Mapping( // 추가
        target = "id",
        ignore = true
    )
    @Mapping( // 추가
        target = "createdAt",
        ignore = true
    )
    @Mapping( // 추가
        target = "updatedAt",
        ignore = true
    )
    fun convertToOrderItem(
        dto: OrderSyncRequestDTO.OrderItem,
        order: Order,
        sellerId: Long,
        hashedId: String,
    ): OrderItem
  • ignore 속성을 이용한 모습을 확인 가능하다.
  • 이 상태로 다시 빌드를 진행하면, MapStruct 는 Target 의 해당 필드 매핑을 무시한다.

 

결론

  • Object 간 매핑에 편의성을 더하기 위해 사용하는 MapStruct!
  • JPA Entity 간 매핑에 유의하자..
  • JPA Entity 매핑에 필요한 인자로 JPA Entity 를 이용을 지양하자..
반응형