티스토리 뷰

현재 진행중인 프로젝트에서 spring boot 2.7.18 을 사용하고 있다. 현시점(2024.04.28) 기준 2.x 버전에서 가장 최신이다. 엔티티 로딩과정에서 직관적이지 못한 이슈를 만나서 정리하고, 어떤때 발생하고 어떻게 해결할 수 있는지 정리해보려한다.

 

spring boot 2.7.18
hibernate-core 5.6.15.Final

 

# 이슈상황

- 엔티티 Main 과 엔티티 Sub 는 1:1 관계로 @OneToOne 관계를 맺고있다(fetchType 은 디폴트 설정인 EAGER 를 사용한다). 외래키는 Main 에서 관리한다고 가정한다.

- Main 과 Sub 는 논리적 연관관계만 가질뿐 데이터베이스 설정상으로 외래키 관계를 강제하지는 않는 상태다.

- Main 과 Sub 는 두 엔티티가 모두 정상적으로 생성되어 Main 가 Sub 의 id 를 들고있는 상태에서 해당 id 의 Sub 엔티티를 삭제한다.

- 이때 JPA 에서 Main 엔티티를 조회했을때 조회결과가 존재하지 않는다.

- Sub 를 애초에 저장한적 없는 상태, 즉 Main 의 sub_id 가 null 일경우엔 정상적으로 Main 이 조회된다.

@Entity
@Table(name = "main")
public class Main {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "main_id")
    private Long id;
    @OneToOne
    @JoinColumn(name = "sub_id")
    private Sub sub;
}

@Entity
@Table(name = "sub")
public class Sub {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "sub_id")
    private Long id;
}

 

풀어적어서 오히려 어렵게 느껴질 수 있는데 간단히 얘기하면 A 와 B 가 1:1 관계일때 A 가 B 의 id 를 들고있는 상태에서 해당 id 와 매핑되는 B 가 없는 상황이다.

 

# 쿼리는 어떻게 실행되는가

이 문제를 접했을때 가장 먼저 의심하는건 당연히 쿼리이다. join 쿼리가 어떻게 날아가느냐에 따라 Main 이 Sub 의 id 를 들고있는 상태에서 Main 을 조회할 수 없는 상황이 발생할 수 있기 때문이다. inner join 쿼리가 날아간다면 해당 현상은 지극히 정상적이고, outer join 으로 날아가도록 변경한다면 문제를 해결할 수 있다.

 

@OneToOne 의 fetchType 을 따로 변경해주지 않았기 때문에 JPA 기본 설정인 EAGER 로 동작하게 되고, EAGER 이기 때문에 join 쿼리가 실행된다. 그럼 예상대로 inner join 쿼리가 실행됐을까? 실행되는 쿼리를 살펴보니 left outer join 이었다! 사실 이는 지극히 정상적인데 엔티티 설정의 @JoinColumn 애노테이션은 nullable 이 기본적으로 true 이기 때문이다. 이를 false 로 바꿔준다면 inner join 쿼리가 실행되지만 따로 설정이 없기에 outer join 이 실행된다.

select main0_.main_id as main_id1_0_0_, main0_.sub_id as sub_id3_0_0_, sub1_.sub_id as sub_id1_1_1_ 
from main main0_ 
left outer join sub sub1_ on main0_.sub_id=sub1_.sub_id 
where main0_.main_id=?

 

left outer join 이 실행된다면 첫번째 가정은 틀리게 된다. left outer join 은 매핑되는 sub 가 없더라도 main 의 결과는 조회하는 조인 방식이기 때문이다.

 

# 한번 더 조회

로그를 보던 도중 특이한걸 볼 수 있었는데, 조회쿼리가 한번 더 날아가고 있던 것이다.

select sub0_.sub_id as sub_id1_1_0_
from sub sub0_ 
where sub0_.sub_id=?

 

이미 유명한 n+1 문제일 수 있지만 보통 우리가 아는 n+1 문제는 연관관계가 있는 엔티티들끼리 조인 쿼리가 아니라 단일 조회 쿼리가 실행되는걸 의미한다. 그리고 그 문제의 해결책으로 join 쿼리가 실행되게끔 튜닝한다. 하지만 위에서 보듯이 이미 left outer join 이 날아가고 있는데 또 조회 쿼리가 실행되고 있던 것이다.

 

# TwoPhaseLoad

두번째 쿼리를 실행시키는 주체는 TwoPhaseLoad 라는 객체이다. 해당 객체는 엔티티의 필드를 확인하며 EAGER 관계일 경우 연관관계를 해소하고자 쿼리를 실행시키게된다. 아래는 TwoPhaseLoad 가 남기는 로그이다.

2024-04-28 18:37:46.268 DEBUG 24120 --- [    Test worker] o.h.engine.internal.TwoPhaseLoad         : Processing attribute `sub` : value = 2
2024-04-28 18:37:46.268 DEBUG 24120 --- [    Test worker] o.h.engine.internal.TwoPhaseLoad         : Attribute (`sub`)  - enhanced for lazy-loading? - false

 

value = 2 로 표현된게 Main 이 들고있는 Sub 의 id 이다. value 가 null 이 아니고, lazy-loading 도 아니기에 초기화를 시도하는 로직인 셈이다.

 

# 중간정리

두번째 쿼리를 실행하는 주체는 TwoPhaseLoad 인데 여기서 두가지 궁금증이 들었다.

- left outer join 이 실행됐는데 왜 또 연관관계 해소를 하려하는가

- id 2 인 Sub 의 조회결과가 없다고 왜 Main 의 결과를 없애버리는가

 

이 두가지 궁금증은 그냥 가설정도만 세우고 완전한 분석은 안했는데, Hibernate 코드가 너무 복잡했고 아래에서 다시 얘기하겠지만 Hibernate 6 에선 해결됐기 때문이다.

 

내가 세운 가설은 이렇다.

- 필드를 순회하면서 EAGER 관계를 해소하는건 Hibernate 의 기본적인 엔티티 초기화 로직이다. Hibernate 입장에서 Main 은 Sub 의 id 를 들고있지만 sub 필드는 초기화되지 않은 것이다. 그러니 초기화를 시도한다. 이전에 단일 조회 쿼리가 실행됐는지, left outer join 쿼리가 실행됐는지에 대한 컨텍스트는 알고있지 않다.

- Hibernate 5와 6, JPA 2와 3의 레퍼런스를 일부 훑어봤고, 내가 본 내용하에서는 위 이슈에 대한 스펙이 어디에도 없었다. 해당 경우에 대한 스펙이 없기 때문에 내부구현수준으로 적당히 동작한게 아닐까 추측한다. 내부구현에 해당하기 때문에 Hibernate 6 에서 결과가 달라지는거에 대해서도 특별한 언급이 없을 것으로 추측한다. 아래에서 설명하겠지만 Hibernate 는 위와 같이 연관관계에서 조회가 안되는 경우에 대한 처리방식을 별도의 애노테이션(@NotFound)으로 제공하고있다. @NotFound 를 사용하지 않았을때는 특정한 동작을 보장하지 않는 것 같다.

 

# 해결법

1. 가장 확실하고 명확한 해결책인데, @NotFound 애노테이션을 이용한다. 해당 애노테이션은 딱 이슈가 된 상황에서 필요한 내용인데, 연관관계의 매핑 키는 들고있지만 실제 연관관계 엔티티는 존재하지 않는 경우 동작을 정의한다. 엔티티가 없을때 예외를 던질지, 연관관계를 null 로 초기화할지를 정한다. 해당 애노테이션은 JPA 스펙에는 없으며 Hibernate 스펙이다. 다만 @NotFound 를 달게되면 fetchType 을 LAZY 로 지정했더라도 항상 EAGER 로 동작하게 된다.

 

2.

JPA 나 Hibernate 의 성능 최적화 관련 자료들을 찾아보면 연관관계는 항상 LAZY 로 설정하라는 자료들을 찾아볼 수 있다. EAGER 는 언제 어떤 문제를 야기할지 알 수 없으니 기본적으로 LAZY 로 설정해놓고, 다양한 최적화 방식을 이용해 EAGER 처럼 사용하라는 것이다.

 

Hibernate 자료에선 공식 레퍼런스 곳곳에서 EAGER 를 default 로 사용하는 것에 대해 명시적 LAZY 를 사용하라고 권고하고 있다. fetchType 을 LAZY 로 지정했다면 Sub 가 없다고 Main 까지 조회하지 못하는 사태는 없었을 것이다. 다만 LAZY 를 이용해 Main 을 조회한 후 존재하지 않는 Sub 에 접근하는 순간 예외가 발생한다.

 

3.

Main 과 Sub 의 연관관계가 완성된 이후 Sub 의 삭제가 발생했다면 Main 에서도 확실하게 연관관계를 끊어주는 것이다. 여기서 말하는 연관관계를 끊는다 함은 Main 에서 외래키로 사용하고 있는 Sub id 를 같이 삭제해주는 것이다. 연관관계를 확실히 끊어주지 않는 상태에선 1번 방식을 이용해 LAZY 로 변경했다고 하더라도 Main 엔티티 자체는 잘 조회해오지만 Sub 에 접근하는 순간 EntityNotFoundException 이 발생하기 때문에 연관관계를 확실히 끊어주는게 제일 확실한 방법이다.

 

4.

확실히 끊기지 않은 연관관계가 Hibernate 입장에선 만족스럽지 않겠지만 이런 상황은 현업에서 종종 발생하곤 한다. inner join 으로 조회하는 필수 연관관계가 아니라면 id 는 들고있지만 매핑되는 엔티티가 없다고해서 조회를 못하거나 예외를 발생하는 Hibernate 5 의 행동이 맘에 들지 않을 수 있다. Hibernate 6 에선 조회를 못하지도, 예외를 발생시키지도 않는다. id 는 있지만 매핑 엔티티가 없는 경우 Sub 를 null 로 들고오게 된다. 참고로 spring boot 3 부터 Hibernate 6 를 사용하기 때문에 spring boot 3 애플리케이션이라면 이번 포스팅에 있는 상황을 맞이할 일은 없다.

 

# 참고자료

- Hibernate 5 reference

- Hibernate 6 refernece

- JPA 2.2 reference

- JPA 3.0 reference

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함