MapStruct를 이용한 JPA Entity 매핑 주의사항
Java 코드 생성기 Mapstruct를 이용하여 Jpa Entity 매핑을 진행할 때 집중해 볼 내용안녕하세요. 페이히어 백엔드 개발을 맡고 있는 김남영입니다.
최근 프로젝트를 진행하며 개발하던 중,
MapStruct
를 사용하며 이슈가 있었습니다. 바로 JPA Entity Id 필드까지 매핑 된 이슈인데요.
그 이슈 트래킹 과정 정리와 MapStruct 동작에 대해서 살펴보았습니다.
MapStruct란?
- Java Bean 유형 간 매핑 구현을 도와주는 코드 생성기
- 컴파일 타임에 코드 생성 및 Runtime에서 안정성 보장
- 순수 Java Code를 호출하므로, 다른 매핑 라이브러리보다 속도가 빠르다
- Reflection을 사용 하지 않기 때문
- Annotation Processor 기반으로 매핑에 편리함을 제공
- 관련 Github Repository : [https://github.com/mapstruct/mapstruct]
사용 예시
- 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
}
위와 같이 Kotlin 코드 기반 MapStruct를 활용한, JPA Entity to DTO 로직을 볼 수 있습니다.
Spring Project의 대표적 Build Tool인 Gradle 을 사용한다면, classes
Task 를 수행할 수 있습니다.
해당 Task 를 수행했다면 MapStruct 가 UserMapper Interface 를 구현한, UserMapperImpl
Java Class 를 생성하는 것을 확인할 수 있습니다.
@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2024-03-24T14:16:40+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.)"
)
public class UserMapperImpl implements UserMapper {
@Override
public UserResponseDTO convertToUserResponseDTO(User user) {
if (user == null) {
return null;
}
String uid = null;
String name = null;
String address = null;
uid = user.getUid();
name = user.getName();
address = user.getAddress();
UserResponseDTO userResponseDTO = new UserResponseDTO(uid, name, address);
return userResponseDTO;
}
}
이렇게 구현 Class를 Spring Project build directory 에서 확인할 수 있습니다.
MapStruct 사용 중 이슈 발생 (JPA Entity 간 매핑)
JPA Entity 간 매핑에 MapStruct 를 사용하며 이슈가 발생 했는데요.
바로 JPA Entity 의 id(PK) 필드까지 Mapping
되어 버린 이슈 입니다.
MySQL 사용 경우, JPA Entity를 생성할 때 Id 필드 값이 0 이거나, null 일 때, auto_increment 속성에 의해 선형적으로 증가한 데이터로 저장할 수 있습니다. 하지만 table 내 기존 id 필드를 가진 record 가 있는 경우, JPA는 Update 쿼리를 생성하여 Flush 하게 됩니다.
이슈 트래킹 과정
요구 사항
- MapStruct를 이용해서 OrderItem Jpa Entity를 생성 후 Save 한다.
문제의 매핑 함수입니다.
@Mapping(
source = "order",
target = "order"
)
@Mapping(
target = "externalKey",
expression = "java(OrderItem.generateExternalKey(hashId))"
)
fun convertToOrderItem(
dto: OrderRequestDTO.OrderItem,
order: Order,
userId: Long,
hashId: String,
): OrderItem
뒤이어, MapStruct가 구현한 코드를 살펴보겠습니다.
@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(OrderRequestDTO.OrderItem dto, Order order, long userId, String hashId) {
OrderItemStatus status = null;
String productName = null;
BigDecimal productPrice = null;
int quantity = 0;
if (dto != null) {
status = dto.getOrderItemStatus();
productName = dto.getProductName();
productPrice = dto.getProductPrice();
quantity = dto.getQuantity();
}
Order order1 = null;
long userId = 0L;
if (order != null) {
order1 = order;
userId = order.getUserId();
}
String externalKey = OrderItem.generateExternalKey(hashId);
OrderItem orderItem = new OrderItem(externalKey, productName, productPrice, quantity, userId, quantity, status, order1);
if (order != null) {
orderItem.setId(order.getId());
orderItem.setCreatedAt(order.getCreatedAt());
orderItem.setUpdatedAt(order.getUpdatedAt());
}
return orderItem;
}
}
문제가 되는 부분을 찾으셨나요?
가장 마지막 구현 부인 order를 이용하여 id, createdAt, updatedAt 필드를 매핑하는 코드입니다.
if (order != null) {
orderItem.setId(order.getId());
orderItem.setCreatedAt(order.getCreatedAt());
orderItem.setUpdatedAt(order.getUpdatedAt());
}
인자로 넘긴 Order JPA Entity의 필드와 동일하므로, 새로 생성된 OrderItem JPA Entity 객체에 매핑하고 있는 모습을 확인할 수 있었습니다. MapStruct는 getter(Order)를 통해 호출, setter(OrderItem)를 통해 매핑하였습니다.
개발 환경
해당 동작에 대해 디버그를 진행했습니다.
"order": {
"oid": null,
"order_name": "Order Mapping 테스트",
"status": "PAID",
"order_items": [
{
"product_external_key": null,
"product_name": "Mapper 1",
"product_price": 4000,
"total_price": 4000,
"quantity": 1
},
{
"product_external_key": null,
"product_name": "Mapper 2",
"product_price": 4000,
"discounted_price": 0,
"total_price": 4000,
"quantity": 1
}
]
}
요청 payload는 위와 같습니다.
먼저 Order JPA Entity를 생성 합니다. auto_increment 되는 Order id 값은 9098875
convertToOrderItem
함수를 통해 생성 된 orderItems의 id 모두 Order의 id 인 9098875 를 할당 받은 모습을 확인할 수 있습니다.
OrderItems JPA Entity 객체는 2개가 생겼습니다. 하지만 id 값이 동일하므로, insert 쿼리는 1회만 발생했습니다.
만약 해당 id 값을 가진 order_item Row가 이미 존재했다면?
해당 Row 데이터는 update 됩니다.
통상적으로 주문 (Order) 보다 주문 상품 (OrderItem) Row가 훨씬 많습니다. Order Entity를 새로 생성하여 할당 받은 id 값은, 이미 OrderItem table에 존재할 것입니다.
동일한 요청을 통해 update 쿼리가 발생하는 것을 확인할 수 있고, 역시 기존 Row 의 데이터는 Update 됩니다.
해결 방법
개발 후 테스트 과정에서 문제를 인지한 후, 문제를 해결하기 위해, Mapping
애노테이션 속성 중, ignore
속성을 이용했습니다.
@Mapping(
source = "order",
target = "order"
)
@Mapping(
target = "externalKey",
expression = "java(OrderItem.generateExternalKey(hashId))"
)
@Mapping( // 추가
target = "id",
ignore = true
)
@Mapping( // 추가
target = "createdAt",
ignore = true
)
@Mapping( // 추가
target = "updatedAt",
ignore = true
)
fun convertToOrderItem(
dto: OrderRequestDTO.OrderItem,
order: Order,
userId: Long,
hashId: String,
): OrderItem
ignore 속성은, Target 필드의 매핑을 무시하는 속성입니다. 다시 build를 진행하였고, MapStruct가 구현한 코드에서 id, createdAt, updatedAt 필드를 매핑하는 코드가 사라진 것을 확인할 수 있었습니다.
글을 마치며
Object간 매핑에 편의를 위해 사용하는 MapStruct!
편히 생성해주는 Impl Code 를 집요하게 살펴 볼 필요가 있어 보입니다.
JPA Entity
간 매핑에는 특히, 유의 해야 할 것 같습니다.
JPA Entity 매핑을 진행하기 위해 JPA Entity를 인자로 넘기는 상황에서, 좀 더 면밀히 생각해보게 되었습니다.
추후 위 코드의 리팩토링을 고려해 본다면, JPA간 1 : N
참조 관계인 Order 와 OrderItem 의 매핑은, Mapper 함수 인자로 Order를 전달하는 것이 아닌, JPA 연관 관계 편의 함수를 통해 따로 진행하는 것이 바람직해 보인다는 생각이 듭니다.
예시로 들게 된다면,
@Entity
@Table(name = "order_items")
class OrderItem(
// 세부 필드는 생략
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
var order: Order, // N:1 단방향 관계
) {
fun updateOrder(newOrder: Order) {
this.order = newOrder // N:1 단방향 관계로 order 만 업데이트
}
}
위 함수를 상위에서 호출하여 order entity를 할당하는 방식으로 진행할 수 있어 보입니다. 🙂
단순 코드 이슈 였지만, 결과로 이어지는 임팩트가 정말 큰 이슈였고, MapStruct를 사용하시는 Java 개발자 분들께 더 잘 알고, 안전하게 사용하시길 바라면서 정리해 보았습니다.
다음 기회에 더 유용하고, 생동감 있는 주제로 찾아 뵙겠습니다. 긴 글 읽어주셔서 감사합니다.