안녕하세요, 오늘은 JPA @OneToMany 단방향에 대한 이야기를 해보려 합니다.
JPA에서는 @ManyToOne 또는 @OneToMany 를 사용해 실제 데이터베이스의 FK를 표현할 수 있습니다. 근데, 엔티티 상태 전환이나 dirty checking의 이점을 보고자 많은 사람들이 부모 객체에 Collection으로 자식 객체들을 가지고 있으려고 하는데요, 이를 위해 JPA는 @OneToMany 를 제공합니다. 하지만, @OneToMany 를 사용하면 불필요한 쿼리가 나갈 수 있습니다.
오늘은 그 문제점들과, 동작 원리, 그리고 해결 방법에 대해 알아보도록 하겠습니다.
@OneToMany 단방향
연관관계는 다음과 같습니다.
주문 도메인
@Entity
@Table(name = "orders")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String orderStatus;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> orderItems = new ArrayList<>();
// getters and setters
...
}
주문 항목 도메인
@Entity
@Table(name = "order_item")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Column(name = "order_id")
private Long orderId;
// getters and setters
}
그리고 서비스 클래스의 다음과 같은 코드를 실행시켰습니다.
public Order save() {
final Order order = new Order("COOKING");
order.getOrderItems().add(new OrderItem("치킨"));
order.getOrderItems().add(new OrderItem("피자"));
orderRepository.save(order);
return order;
}
이에 대한 실제 쿼리를 살펴보겠습니다.
insert into orders (id, order_status)
values (default, ?)
insert into order_item (id, name, order_id)
values (default, ?, ?)
insert into order_item (id, name, order_id)
values (default, ?, ?)
#### 문제의 쿼리들 ####
insert into orders_order_items (order_id, order_items_id)
values (?, ?)
insert into orders_order_items (order_id, order_items_id)
values (?, ?)
1 개의 주문을 저장하고, 2 개의 주문 항목들을 저장했습니다. 이때 3개의 쿼리를 예상했지만, 실제로는 5 개의 쿼리가 발생했습니다.
왜 그럴까요?
그 이유는 DB 상에서는 현재의 연관 관계가 다대다 처럼 동작하기 때문입니다. 따라서, 2 개의 테이블로 동작하는 것이 아니라 다대다 매핑을 위해 orders_order_items 라는 테이블을 추가해 3 개의 테이블로 동작하기 때문입니다. 다음과 같은 구조가 된다면 DB의 용량도 잡아먹게 되고 더 많은 동작들이 일어나게 되므로 비효율적일 것입니다. 이를 해결하기 위해서는 @JoinColumn 어노테이션을 사용해야 합니다.
@OneToMany 단방향과 @JoinColumn
앞선 문제를 해결하기 위해선, 주문 도메인의 orderItems 필드를 다음과 같이 수정해야 합니다.
주문 도메인
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "order_id")
private List<OrderItem> orderItems = new ArrayList<>();
@JoinColumn 을 사용하면 Hibernate에게 order_id 라는 FK가 주문 항목 도메인에 존재한다고 알려주는 것입니다. 현재의 구조로 변경하고 다시 서비스 코드를 실행해보겠습니다.
insert into orders (id, order_status)
values (default, ?)
insert into order_item (id, name, order_id)
values (default, ?, ?)
insert into order_item (id, name, order_id)
values (default, ?, ?)
#### 문제의 쿼리들 ####
update order_item set order_id=? where id=?
update order_item set order_id=? where id=?
무슨 일인지 이번에는 update 쿼리가 나가게 됐는데요. 그 이유는 orders라는 엔티티를 저장하고 order_item들을 저장할 때, order_id 를 모르기 때문에, 먼저 저장한 후에 update문을 통해서 세팅해주기 때문입니다. 사실, 이에 대한 이유에는 Hibernate의 Flush 순서를 알아야 하는데, 이 부분은 더 밑에서 얘기하도록 하겠습니다.
흠… 그렇다면 주문을 저장해놓고 주문 항목들을 저장하면 잘되지 않을까? 하고 새롭게 코드를 짜봤습니다. 우선은 이전의 주문 도메인의 cascade 옵션을 제거하고 새롭게 서비스 코드를 짜봤습니다.
주문 도메인
@OneToMany
@JoinColumn(name = "order_id")
private List<OrderItem> orderItems = new ArrayList<>();
서비스 코드
public Order order2() {
final Order order = new Order("COOKING",
List.of(new OrderItem("치킨"), new OrderItem("피자")));
orderRepository.save(order);
for (final OrderItem orderItem : order.getOrderItems()) {
orderItem.setOrderId(order.getId());
orderItemRepository.save(orderItem);
}
return order;
}
우선 현재 코드에는 몇 가지 문제가 존재합니다. 객체지향적인 코드를 위해 @OneToMany 를 사용했지만, 실질적으로 setId() 도 사용하고, 각 orderItems() 를 꺼내와서, 각 엔티티를 직접적으로 저장했습니다. 하지만, 쿼리수를 줄이기 위해 현재와 같이 객체지향적인 코드를 포기했습니다.
이렇게 설정하고 실제 쿼리를 살펴보면 다음과 같습니다.
insert into orders (id, order_status)
values (default, ?)
insert into order_item (id, name, order_id)
values (default, ?, ?)
insert into order_item (id, name, order_id)
values (default, ?, ?)
#### 또 발생하는 문제의 쿼리들 ####
update order_item set order_id=? where id=?
update order_item set order_id=? where id=?
왜 이렇게 될까요? 전 분명 주문만 저장하고 주문 항목을 order_id 와 함께 나중에 저장했는데?!
이유는 사실 쓰기 지연 저장소에 이미 update 쿼리가 저장되었기 때문인데요, 다음의 코드를 실행했을 때 실제 결과를 살펴보도록 하겠습니다.
서비스 코드
public Order order2() {
final Order order = new Order("COOKING");
order.getOrderItems().add(new OrderItem("치킨"));
order.getOrderItems().add(new OrderItem("피자"));
orderRepository.save(order);
return order;
}
실행 결과
insert into orders (id, order_status) values (default, ?)
update order_item set order_id=? where id=?
#### 예외 발생 ####
org.hibernate.TransientObjectException: object references an unsaved transient instance - save the transient instance before flushing: com.example.springjpa.OrderItem; nested exception is java.lang.IllegalStateException: org.hibernate.TransientObjectException: object references an unsaved transient instance - save the transient instance before flushing: com.example.springjpa.OrderItem
org.springframework.dao.InvalidDataAccessApiUsageException: org.hibernate.TransientObjectException: object references an unsaved transient instance - save the transient instance before flushing: com.example.springjpa.OrderItem; nested exception is java.lang.IllegalStateException: org.hibernate.TransientObjectException: object references an unsaved transient instance - save the transient instance before flushing: com.example.springjpa.OrderItem
네, update가 날라가면서 동시에 예외가 발생하게 됩니다. 예외를 살펴보면 존재하지 않는 instance에 대한 update문이 날라갔기 때문인데요.
이전의 서비스 코드에서 update가 나중에 나가게 된 것은 save()를 호출했을 때는, 현재 GeneratedValue 에 대한 전략이 IDENTITY 이기 때문에, 쓰기 지연 저장소에 save()가 저장되지 않고 바로 저장된 후 ID를 반환하게 됩니다. 하여, 모든 save() 메서드는 바로 호출 즉시 쿼리로 나가게 됩니다.
즉, order를 저장할 때 저장 쿼리가 발생했고, order는 orderItems가 이미 추가되어 있어서 이때 update에 대한 쿼리가 쓰기 지연 저장소에 쌓이게 됩니다. 이후 orderItem을 저장할 때도 바로 저장 쿼리가 발생합니다. 마지막으로, 서비스 코드가 종료됐으므로 Transaction 이 끝나기 직전에 flush()가 호출되어 쓰기 지연 저장소가 비워짐으로써 update 쿼리가 발생한 것입니다.
현재의 상황에서는 orderItem에 대한 저장없이 쓰기 지연 저장소를 flush() 했기 때문에 예외가 발생한 것입니다.
그럼 'GeneratedValue 에 대한 전략이 IDENTITY 가 아니면 되는거 아니야?' 라고 하실 수도 있는데, 그래도 똑같은 문제가 발생합니다. 이에 대한 자세한 내용을 살펴보기 위해 Hibernate의 Flush 순서에 대해 잠깐 살펴보도록 하겠습니다.
Hibernate의 Flush 순서
1. OrphanRemovalAction
2. AbstractEntityInsertAction
3. EntityUpdateAction
4. QueuedOperationCollectionAction
5. CollectionRemoveAction
6. CollectionUpdateAction
7. CollectionRecreateAction
8. EntityDeleteAction
Flush에 대한 순서를 살펴보면, insert(Persist) 가 먼저 발생하고, collection에 대한 처리가 이뤄집니다. 즉, Hibernate는 자식 엔티티를 FK없이 먼저 저장하고 collection에 대한 처리가 이뤄질 때, FK에 대한 update 쿼리가 발생하게 되는 것입니다.
이 때문에 delete 도 다음과 같은 문제를 발생시킵니다.
서비스 코드
order.getOrderItems().remove(0);
실행 결과
update order_item set order_id=null where order_id=? and id=?
delete from order_item where id=?
단순히 delete 쿼리를 기대했지만, 실제로는 EntityUpdateAction 이 먼저 발생하고, 컬렉션에 대한 처리가 끝난 후에야 실제 엔티티가 삭제됩니다.
그럼 이런 딜레마를 해결하기 위해선 어떻게 해야할까요?
@OneToMany 양방향 (실질적으로는 @ManyToOne 양방향)
사실 @OneToMany 단방향에 대한 문제는 김영한님의 ‘JPA 프로그래밍’에서 이미 확인할 수 있고, 또 유명한 해결책인 @ManyToOne 양방향이 있습니다.
주문 도메인
@Entity
@Table(name = "orders")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String orderStatus;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> orderItems = new ArrayList<>();
// getters and setters
...
}
주문 항목 도메인
@Entity
@Table(name = "order_item")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
// 연관 관계 편의 메서드
public void mapOrder(final Order order) {
this.order = order;
order.getOrderItems().add(this);
}
// getters and setters
}
서비스 코드
public Order order() {
final Order order = new Order("COOKING");
new OrderItem("치킨").mapOrder(order);
new OrderItem("피자").mapOrder(order);
orderRepository.save(order);
return order;
}
실행 결과
insert into orders (id, order_status) values (default, ?)
insert into order_item (id, name, order_id) values (default, ?, ?)
insert into order_item (id, name, order_id) values (default, ?, ?)
이제야 정상적으로 의도한 실행 결과를 만들 수 있었습니다. 사실상 @OneToMany 양방향이라는 것은 존재하지 않고 (물론 만들 수는 있지만 꼼수를 써야합니다), 똑같은 동작을 하는 @ManyToOne 양방향을 사용하면 됩니다! 연관 관계 편의 메서드는 서로 관계를 맺을 때 양쪽에다 걸어야 하기 때문에 두 곳 모두 추가하도록 현재와 같이 추가됐습니다.
결론
오늘은 @OneToMany 에서 발생하는 문제와 그에 대한 해결책, 그리고 Flush 순서에 대해 알아봤습니다. 사실 다대일 양방향이 가장 쉬운 정답이라는 것은 알고 있었지만, 왜 그런지? 그리고 어떻게 동작하는 지? 를 매번 까먹어서 작성해봤습니다. 그리고 조사 과정에서 flush 순서도 알게되서 새로운 지식을 알게 됐어요.
앞으로 JPA가 의도한 것처럼 동작하지 않는다면 저도 해당 순서를 다시 살펴보러 올 것 같아요. 😊
참고 문헌
김영한. 자바 ORM 표준 JPA 프로그래밍
https://vladmihalcea.com/the-best-way-to-map-a-onetomany-association-with-jpa-and-hibernate/
댓글