최근 비상 결제 모드를 개발 및 배포하며,
참으로 어이 없는 작은 코딩 관련 이슈로 인해 데이터에 이상이 있었다.
바로, 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 를 이용을 지양하자..
'📗 JPA' 카테고리의 다른 글
JPA 집계함수 sum 은 long 을 반환한다. (0) | 2023.07.26 |
---|---|
[Spring Data JPA] JPA Enum 필드에 관한 문제 (1) | 2023.02.21 |
[Spring Data JPA] 한방 쿼리의 효능 (feat : JPQL) (0) | 2023.02.01 |
[Spring Data JPA] Transaction 없이 읽기 (0) | 2023.01.30 |
JPA 개념 정리 (0) | 2022.07.15 |