Fetch Join과 ON 구문, WHERE 조건
SELECT lh FROM lent_history
LEFT JOIN User u ON u.user_id = lh.user_id
LEFT JOIN Cabinet c ON c.cabinet_id = lh.cabinet_id
WHERE lh.ended_at IS NULL;
SQL
복사
일반적으로 SQL 구문을 작성할 때, 이와 같이 Join이 필요하다면 결합되는 조건을 ON 구문에 추가하여 사용한다.
하지만 이처럼 DB에서 사용하던 그대로 JPA에 적용하게 되면,
컴파일 에러가 발생한다.
조금 더 아래의 에러 메세지를 살펴보면 연관 관계를 fetch로 가져올 때는 with 구문이 허용되지 않는다는 문구가 있다. with 구문을 쓰지도 않았는데 갑자기 왠 With 구문?이라는 의문이 든다.
디버깅을 해보며 ON 구문이 어떻게 파싱 되는지 살펴보자.
작성한 쿼리문인 hql을 파서에 넘겨주고, 파싱을 맡기게 된다.
파서는 내부 로직에 의해 ON 구문을 With 구문으로 치환한다.
ON 구문이 WITH 구문으로 치환된다고 하면, 왜 JPA는 WITH 구문을 Fetch Join에서 못 쓰게 할까?
JPA 2.1에는 위와 같이 설명하고 있다. 일반적으로 JPQL 사용자는 Join이 어떤 관계를 가지고 결합하는지 알 필요 없이 사용하도록 의미한다는 것이다.
Fetch Join은 연관된 Entity나 Collection을 전부 가져오기를 기대하고 사용하는데, ON 절이나 WITH 구문은 Join 되기 전 데이터를 필터링 하기 위해서 사용되기 때문이다. 즉, 데이터 일관성을 위해서 필터링을 막아둔 것이다.
이와 같은 맥락에서 Fetch Join으로 가져오는 Entity에는 별칭과 Where 조건을 사용하면 안된다. 데이터 무결성이 깨진다는게 어떤 의미인지 예시로 알아보자.
List<Cabinet> cabinets = cabinetRepository.test1(List.of(1L, 2L, 3L));
cabinets.forEach(cabinet -> System.out.println(
"cabinet.getLentHistories() = " + cabinet.getLentHistories()));
Java
복사
위와 같이 Cabinet과 LentHistory를 조회하여 출력하는 로직을 작성하고,
먼저 Fetch Join에 cabinet 조건만 걸어서 가져온다.
Id 1, 2, 3을 가진 Cabinet 3개를 조회했고, 각 Cabinet마다 3개씩의 LentHistory를 저장해둔 상태이다.
cabinet.getLentHistories() = [LentHistory(version=1, lentHistoryId=2148, startedAt=2022-05-16T22:15:23, expiredAt=2022-06-16T23:59:59, endedAt=2022-06-16T17:21:04, userId=235, cabinetId=1),
LentHistory(version=1, lentHistoryId=2541, startedAt=2022-06-16T17:21:18, expiredAt=2022-07-17T23:59:59, endedAt=2022-07-17T21:42:30, userId=235, cabinetId=1),
LentHistory(version=1, lentHistoryId=2861, startedAt=2022-07-17T21:42:49, expiredAt=2022-08-16T23:59:59, endedAt=null, userId=235, cabinetId=1)]
cabinet.getLentHistories() = [LentHistory(version=2, lentHistoryId=18456, startedAt=2023-10-18T13:51:13.324131, expiredAt=9999-12-31T23:59:59, endedAt=2023-11-19T23:27:01.094197, userId=1067, cabinetId=2),
LentHistory(version=4, lentHistoryId=19396, startedAt=2023-11-28T13:10:08.863680, expiredAt=2024-02-10T23:59:59.643286, endedAt=null, userId=1057, cabinetId=2),
LentHistory(version=4, lentHistoryId=19397, startedAt=2023-11-28T13:10:08.863680, expiredAt=2024-02-10T23:59:59.643286, endedAt=2023-12-24T00:04:19.643286, userId=1045, cabinetId=2)]
cabinet.getLentHistories() = [LentHistory(version=3, lentHistoryId=19038, startedAt=2023-11-16T17:26:54.028403, expiredAt=2023-12-26T23:59:59.390181, endedAt=2023-12-24T01:23:55.390181, userId=1094, cabinetId=3),
LentHistory(version=4, lentHistoryId=19039, startedAt=2023-11-16T17:26:54.393496, expiredAt=2023-12-25T23:59:59.067161, endedAt=2023-12-24T01:25:26.067161, userId=1076, cabinetId=3),
LentHistory(version=3, lentHistoryId=19040, startedAt=2023-11-16T17:27:00.220737, expiredAt=2023-12-25T23:59:59.067161, endedAt=null, userId=1091, cabinetId=3)]
Java
복사
그럼 이처럼 Cabinet 3개 * LentHistory 3개의 총 9개의 LentHistory를 조회해온다.
List<Cabinet> cabinets = cabinetRepository.test2(List.of(1L, 2L, 3L));
cabinets.forEach(cabinet -> System.out.println(
"cabinet.getLentHistories() = " + cabinet.getLentHistories()));
Java
복사
그럼 이제 Fetch Join 해오는 LentHistory에 별칭과 Where 조건을 추가해 조회를 해보자.
cabinet.getLentHistories() = [LentHistory(version=1, lentHistoryId=2861, startedAt=2022-07-17T21:42:49, expiredAt=2022-08-16T23:59:59, endedAt=null, userId=235, cabinetId=1)]
cabinet.getLentHistories() = [LentHistory(version=4, lentHistoryId=19396, startedAt=2023-11-28T13:10:08.863680, expiredAt=2024-02-10T23:59:59.643286, endedAt=null, userId=1057, cabinetId=2)]
cabinet.getLentHistories() = [LentHistory(version=3, lentHistoryId=19040, startedAt=2023-11-16T17:27:00.220737, expiredAt=2023-12-25T23:59:59.067161, endedAt=null, userId=1091, cabinetId=3)]
Java
복사
의도한 대로 여러 Cabinet + LentHistory 데이터 중 Where 조건에 맞는 데이터만 가져온다.
그럼 이대로 잘 쓰면 되는거 아닌가? 라고 생각할 수 있지만, 이는 문제가 될 수 있다.
List<Cabinet> cabinets2 = cabinetQueryService.test2();
System.out.println("cabinets2 = " + cabinets2);
cabinets2.forEach(cabinet -> System.out.println(
"cabinet2.getLentHistories() = " + cabinet.getLentHistories()));
List<Cabinet> cabinets1 = cabinetQueryService.test1();
System.out.println("cabinets1 = " + cabinets1);
cabinets1.forEach(cabinet -> System.out.println(
"cabinet1.getLentHistories() = " + cabinet.getLentHistories()));
Java
복사
이와 같이 Fetch Join + Where 조건으로 먼저 조회 후 Where 조건이 없는 조회를 하게 되면,
cabinets2 = [Cabinet(cabinetId=1, visibleNum=89, status=FULL, lentType=SHARE, maxUser=4, statusNote=, grid=org.ftclub.cabinet.cabinet.domain.Grid@30fd4187, title=, memo=null), Cabinet(cabinetId=2, visibleNum=90, status=FULL, lentType=SHARE, maxUser=4, statusNote=, grid=org.ftclub.cabinet.cabinet.domain.Grid@706d7af1, title=0/3, memo=null), Cabinet(cabinetId=3, visibleNum=91, status=FULL, lentType=SHARE, maxUser=4, statusNote=, grid=org.ftclub.cabinet.cabinet.domain.Grid@6de2cbcd, title=, memo=null)]
cabinet2.getLentHistories() = [LentHistory(version=1, lentHistoryId=2861, startedAt=2022-07-17T21:42:49, expiredAt=2022-08-16T23:59:59, endedAt=null, userId=235, cabinetId=1)]
cabinet2.getLentHistories() = [LentHistory(version=4, lentHistoryId=19396, startedAt=2023-11-28T13:10:08.863680, expiredAt=2024-02-10T23:59:59.643286, endedAt=null, userId=1057, cabinetId=2)]
cabinet2.getLentHistories() = [LentHistory(version=3, lentHistoryId=19040, startedAt=2023-11-16T17:27:00.220737, expiredAt=2023-12-25T23:59:59.067161, endedAt=null, userId=1091, cabinetId=3)]
Java
복사
cabinets1 = [Cabinet(cabinetId=1, visibleNum=89, status=FULL, lentType=SHARE, maxUser=4, statusNote=, grid=org.ftclub.cabinet.cabinet.domain.Grid@30fd4187, title=, memo=null), Cabinet(cabinetId=2, visibleNum=90, status=FULL, lentType=SHARE, maxUser=4, statusNote=, grid=org.ftclub.cabinet.cabinet.domain.Grid@706d7af1, title=0/3, memo=null), Cabinet(cabinetId=3, visibleNum=91, status=FULL, lentType=SHARE, maxUser=4, statusNote=, grid=org.ftclub.cabinet.cabinet.domain.Grid@6de2cbcd, title=, memo=null)]
cabinet1.getLentHistories() = [LentHistory(version=1, lentHistoryId=2861, startedAt=2022-07-17T21:42:49, expiredAt=2022-08-16T23:59:59, endedAt=null, userId=235, cabinetId=1)]
cabinet1.getLentHistories() = [LentHistory(version=4, lentHistoryId=19396, startedAt=2023-11-28T13:10:08.863680, expiredAt=2024-02-10T23:59:59.643286, endedAt=null, userId=1057, cabinetId=2)]
cabinet1.getLentHistories() = [LentHistory(version=3, lentHistoryId=19040, startedAt=2023-11-16T17:27:00.220737, expiredAt=2023-12-25T23:59:59.067161, endedAt=null, userId=1091, cabinetId=3)]
Java
복사
아까의 결과와는 달리 Where 조건이 없는 조회의 결과가 9개가 아닌, 이처럼 3개만 선택된다. 즉 우리가 의도한 데이터의 일관성이 지켜지지 않는 것이다.
원인은 영속성 컨텍스트의 1차 캐시 때문이다. JPA의 영속성 컨텍스트 내부에는 Entity를 보관하는 1차 캐시 저장소가 있는데, 조회하는 모든 엔티티들은 1차 캐시에 저장된다. 위와 같이 조회하면 이미 1차 캐시에 해당 엔티티가 있기 때문에 DB에서 조회를 수행하지 않고 해당 데이터를 꺼내온다.
Fetch Join의 본래 목적은 연관 관계를 가지는 엔티티의 모든 값을 가지고 오는 것이다. 때문에 Fetch Join에 Where 조건을 걸어 데이터를 조회하는 것 자체로 데이터의 일관성이 깨지는 것이다. 영속성 컨텍스트는 일반적으로 트랜잭션 범위에서만 유지되고 같은 트랜잭션 내에서는 같은 엔티티를 반복해서 조회하는 경우가 별로 없지만, 그럼에도 영속성 컨텍스트 유지 기간을 트랜잭션 범위를 벗어나게 설정할 수도 있고 2차 캐시 등의 문제도 있기 때문에 이처럼 데이터 무결성이 깨지는 사례가 존재하기 때문에 주의해야한다.
Fetch Join이 무결성이 깨지않게 사용하는 방법은 3가지 정도 있다.
1.
Stateless로 가져오기
EntityManager의 persist와 flush 같은 기능들을 이용해, 1차 캐시의 값을 지워버리거나 무효화하여 사용하면 위와 같은 상황을 피할 수 있다.
2.
DTO를 통해 가져오기
Projection을 통해 DTO 형태로 데이터를 받아오면, Entity를 조회한 것이 아니라서 영속성 컨텍스트에서 1차 캐시에 두고 관리하지 않는다. 이러한 이유로 다시 조회를 하더라도 캐시에서 꺼내는 것이 아니라, 말 그대로 DB에서 다시 조회를 해오기 때문에 문제가 없다.
3.
반대로 조회하기
OneToMany에서 조회하는 것이 문제가 되는 상황이므로, ManyToOne에서 조회를 수행하면 된다. ManyToOne은 연관관계의 주인이기 때문에, 항상 상대 테이블 Id를 FK로 들고 있어 조회하는 것이 가능하다.
Fetch Join의 본 의도는 연관관계를 가지는 데이터를 전부 가져오기를 기대한다는 점을 기억하자.
OneToMany의 조회 시 Entity-Collection 형태로 데이터를 가져오는데, Join 테이블의 값을 Where 조건을 걸면 Collection 전체를 들고 오지 않기 때문에 무결성이 깨진다. 때문에 OneToMany 관계에서는 Fetch Join 해온 엔티티를 Where 조건에 사용하면 대부분 무결성이 깨진다.
ManyToOne의 조회 결과는 Entity-Entity 형태로 조회를 하기 때문에, Join 테이블의 값을 Where 조건을 걸어도 무결성이 깨지지 않을 수 있다. Inner Join이나 Right Join으로 조회를 하는 경우에는 Where 조건으로 연관 테이블이 필터링이 되더라도 무결성에는 문제가 전혀 없다. 하지만 Left Join으로 조회를 하는 경우 주 테이블의 엔티티는 보장되어야 하는데, 연관 테이블에 의해 필터링 되는 순간 무결성이 깨지게 된다.
추가적으로 데이터 무결성이 깨지더라도, 단순한 조회 용도 목적에서 깊게 주의를 기울이며 사용한다면 문제가 발생하지 않을 수 있다. 하지만 2차 캐시를 적용하게 되면 또 다시 무결성이 깨질 수 있기 때문에, 가급적 사용을 자제하는 것이 좋다.
잘 못 사용하면 큰 장애가 발생할 수 있는 이런 문제를 아직까지 고치지 않은 이유도, 대부분의 장애는 수정과 삭제에서 발생하기도 하고 개발자가 이러한 문제를 충분히 숙지하고 주의해서 사용한다면 전혀 문제 없이 사용 가능하기 때문일 것으로 추측된다.
다중 Fetch Join
Hibernate에서 Fetch Join으로 결합할 수 있는 OneToMany는 최대 1개이다.
cabinet.getLentHistories() = [LentHistory(version=1, lentHistoryId=2148, startedAt=2022-05-16T22:15:23, expiredAt=2022-06-16T23:59:59, endedAt=2022-06-16T17:21:04, userId=235, cabinetId=1),
LentHistory(version=1, lentHistoryId=2541, startedAt=2022-06-16T17:21:18, expiredAt=2022-07-17T23:59:59, endedAt=2022-07-17T21:42:30, userId=235, cabinetId=1),
LentHistory(version=1, lentHistoryId=2861, startedAt=2022-07-17T21:42:49, expiredAt=2022-08-16T23:59:59, endedAt=null, userId=235, cabinetId=1)]
cabinet.getLentHistories() = [LentHistory(version=2, lentHistoryId=18456, startedAt=2023-10-18T13:51:13.324131, expiredAt=9999-12-31T23:59:59, endedAt=2023-11-19T23:27:01.094197, userId=1067, cabinetId=2),
LentHistory(version=4, lentHistoryId=19396, startedAt=2023-11-28T13:10:08.863680, expiredAt=2024-02-10T23:59:59.643286, endedAt=null, userId=1057, cabinetId=2),
LentHistory(version=4, lentHistoryId=19397, startedAt=2023-11-28T13:10:08.863680, expiredAt=2024-02-10T23:59:59.643286, endedAt=2023-12-24T00:04:19.643286, userId=1045, cabinetId=2)]
cabinet.getLentHistories() = [LentHistory(version=3, lentHistoryId=19038, startedAt=2023-11-16T17:26:54.028403, expiredAt=2023-12-26T23:59:59.390181, endedAt=2023-12-24T01:23:55.390181, userId=1094, cabinetId=3),
LentHistory(version=4, lentHistoryId=19039, startedAt=2023-11-16T17:26:54.393496, expiredAt=2023-12-25T23:59:59.067161, endedAt=2023-12-24T01:25:26.067161, userId=1076, cabinetId=3),
LentHistory(version=3, lentHistoryId=19040, startedAt=2023-11-16T17:27:00.220737, expiredAt=2023-12-25T23:59:59.067161, endedAt=null, userId=1091, cabinetId=3)]
Java
복사
위에서 조회한 결과처럼 OneToMany를 Fetch Join으로 불러오면 Where 조건에 따라 컬렉션 * 컬렉션 형태로 데이터를 조회하여, 가져오는 데이터의 수가 카테시안 곱만큼 늘어난다.
이러한 OneToMany를 Fetch Join 조회 2번 이상 반복하면 데이터가 기하급수적으로 늘어나게 된다. 때문에 Hibernate에서는 OneToMany 관계의 테이블을 Fetch Join으로 결합하여 가져오는 것을 최대 1번까지 제한하고, 그 이상 사용하게 되면 에러를 발생시킨다.
Fetch Join과 Pagination
이전의 N+1 문제를 해결하던 도중 Fetch Join과 Pagination을 같이 사용하면 오류가 발생하는 상황을 해결한 적이 있었다.
해당 글의 내용처럼 Fetch Join과 Pagination을 같이 사용하면, Count 쿼리를 별도로 작성해주어야 한다. count 쿼리를 작성하면 문제 없이 잘 동작하지만, 이를 사용할 때도 주의가 필요하다.
OneToOne이나 ManyToOne과 같이 Join 해오는 엔티티가 주체 엔티티 당 1개씩 존재하는 경우에는 예상하는 그대로 count 쿼리를 그대로 작성하더라도 무방하다.
하지만 OneToMany의 경우에는 위에서 언급한 것처럼 Join으로 인해 데이터 수가 카테시안 곱만큼 늘어나기 때문에, 조회 후의 데이터 개수가 몇 개가 될 지 예측하기가 어렵다. 때문에 작성한 count 쿼리로 센 데이터의 개수와 조회 수행 결과의 데이터 수가 다를 수 있다. 이러한 이유로 Pagination이 필요한 데이터는 OneToMany에서 Fetch Join과 Pagination을 같이 사용한다는 것보다는, 가급적 반대측(ManyToOne)에서 쿼리를 작성해서 조회하는 것이 권장된다.