티스토리 뷰
JPA의 사실과 오해 (사실은 JPQL과 Fetch Join의 오해)
20.05.25 JPA 스터디 하며 깨닫게 된 오해
JPA를 꽤나 오래 써왔는데(그래봤자 1년이긴 하지만), 이번에 스터디를 하며 크게 잘못 알고 있던 개념 몇가지를 정정하고, 스스로 부끄럽기도 하고 나름의 컬쳐쇼크를 받았다.
1. N+1은 연관 엔티티가 Collection인지가 중요한게 아니다.
( 주의 - JPQL 기준, SQL X )
사실 왜 (당시 스터디하던) 우리 모두가 N+1 문제를 연관 엔티티의 컬렉션 여부와 관련지어 생각해왔던 건지 모르겠다. 대부분의 교재나 링크들에서 Member -* Orders
와 같은 일대다의 관계를 예시로 들며 설명하기 때문일까.
나는 이제까지 N+1 이 member.getOrders() 의 컬렉션 프록시의 초기화와 관련해, member.getOrders() 혹은 member.getOrders().get(0) 을 호출할 때, orders 컬렉션의 size 만큼 쿼리가 더 나가는, 그래서 그걸 방지하기 위해 Fetch Join을 걸어야하는 그런 걸로 생각하고 있었다.
(이부분은 좀더 내 개인적인 부분인데, 개인적으로 N+1 이라는 용어를 초반에 패치 조인을 안걸어서 비즈니스 로직 중간에 의도치 않은 쿼리가 나가는 현상 전체를 가리키는, 좀더 넓은 범위의 용어로 써오기도 했었다. 내 머리속에선)
결론은 틀렸다.
Member -* Orders
의 관계 예시에서, Member의 Order 필드가 컬렉션인지는 중요한게 아니다. 즉, order 인지 orders 인지가 중요한게 아니다.
초점은 오히려 Order 엔티티가 아닌 Member 엔티티다.
혼란을 방지하기 위해, Member - Order
의 일대일 관계로 예시를 수정해보자.
member1 인스턴스를 가져오는 상황이라고 가정하자.
추가로 N+1 은 Eager인지 Lazy인지도 중요하지 않다
는 사실도 안다고 가정. (지연 로딩이어도, 나중에 해당 데이터가 사용되는 시점에 프록시가 초기화되며 쿼리가 나간다)
member1 을 가져오는데, 패치 조인을 걸지 않으면 member1.order 값도 가져오기 위해 추가로 쿼리를 날려야 한다. 이 경우, 날리는 쿼리의 횟수는 1+1 = 2회다.
이 때, 애초에 member1을 가져올 때 패치 조인을 걸면 member1.order 값도 함께 (한 쿼리로) 가져올 수 있다. 그렇게 되면, 날리는 쿼리의 횟수는 1+1 = 2회에서 1회로 줄어든다.
이게 그렇게 유명한 패치 조인
의 효과다.
겨우 2→1 회 줄어들었는데, 무슨 차이일까?
만약, 애초에 상황이 member1 인스턴스만 가져오는게 아니라, member1, member2, member3 ... member5000 까지, 5000명의 멤버를 가져오는 상황이였다면 달라진다.
이 경우, 1+5000 → 1 로 크게 줄어드는 것이다.
1+5000 → 1 을 부연 설명하면,
1 (member1~member5000 가져오기) + 5000 (member1.order 가져오기, ... , member5000.order 가져오기) → 1 (앞의 데이터 모두 한번에 가져오기. 물론 리턴값은 List<Member> 즉 member1~member5000 그대로, 대신 member1.order~member5000.order의 데이터는 영속성 컨텍스트 안에 존재)
이게 패치 조인의 효과다. 괜히 여기저기서 패치조인, 패치조인하며 자주 사용되던게 아니었다.
그렇다면, 이걸 다 이해했다고 치고 평소에 편하게 개발할 땐, 무엇을 생각하고 기준으로 삼으며 판단하면 될까 ?
[ 결론 ]
가져오려는 엔티티의 연관 엔티티가 컬렉션인지 or 단일 객체인지에 상관없이,
(물론 연관 엔티티 데이터를 쓴다는 전제하에) 리턴 타입이 List<?>면 패치 조인을 거의 무조건 걸어야 한다고 생각하자.
왜냐면 리턴 타입이 단일 객체라면 패치 조인을 써봤자 겨우 2→1로 1회 줄어들 뿐이다. 반면 리턴 타입이 리스트라면, 리턴된 리스트의 개수가 적으면 어쩔 수 없지만 얼마나 많을지도 모르는 것이기 때문에, 아까 1+5000 처럼 성능 저하의 위험성을 방지하기 위해 패치 조인을 걸어주는 것이 맞다.
물론 리턴 타입이 단일 객체라고 굳이 패치 조인을 안 쓸 이유는 없는 것 같다.
더 바깥 비즈니스 로직에서, 반복문을 돌면서 member1 부터 member5000 을 차례로 가져온다면,
(1+1)*5000 회의 쿼리를 1*5000 회로 줄일수는 있는거니까.
2. JPQL) OneToMany 패치 조인 에서 distinct를 걸지 않으면, 값이 중복되어 가져와진다.
믿고 싶지도 않고 믿기지도 않지만 사실이다. 이 사실을 믿는데 어느정도의 시간이 걸린 이유는,
우리가 JPQL이 당연히 해줄것이라고 생각했던 것, 다시 말하면 JPQL이 생각처럼 좋진 않고 원시적이라는 것이다.
(현재 논점과 다른 이야기긴 하지만, 사실 JPQL이 SQL을 만들때 연관 관계를 고려하지 않고 막 만든다는 사실도, 우리의 JPQL에 대한 환상?을 깨뜨리기도 했다)
이 중복 문제의 근본적인 원인을 먼저 말하면, SQL과 JPQL이라는 근본적 차이 때문이기도 하다.
이슈를 먼저 이야기 해보자.
member1 은 order1 과 order2 를 가진 객체이다.
이때 member1을 조회하는 JPQL 쿼리에서 패치 조인으로 member.orders 까지 같이 가져온다고 상황을 가정해보자.
그저 member.orders를 패치조인으로 함께 가져왔을 뿐인데, 놀랍게도 결과는 똑같은! member1 이 2개 들어있다. 믿기지 않다면 직접 테스트 해보자.
이유는 JPQL이 그리 똑똑하진 않아서, 자동으로 중복 제거를 해주지 않기 때문이다.
사실 SQL이라면 위의 결과는 당연한거다 !
SQL 에선 SELECT 절에 단일 객체, 즉 엔티티를 넣을 수 없다. select m.id, m.name ... from Member m where ~
와 같이 써야한다.
- member1.id - member1.name - order1.id - order1.name
- member1.id - member1.name - order2.id - order2.name
와 같은 테이블을 결과로 가져오는 것이 당연한거다. (마크다운 테이블 만들기 귀찮음)
JPQL은 위 테이블에서 좌측 멤버 부분을 객체로 SELECT 하기 때문에,
JPQL이 내부적으로 중복 제거를 하는게 아니라면(그렇다), 첫번째 row의 member1 인스턴스와 두번째 row의 member1 인스턴스가 (똑같은 놈임에도 불구하고) 중복되어 반환된다.
어찌보면 중복되는게 당연한거다.
무튼, 따라서 애플리케이션 레이어의 우리 개발자 입장에선 distinct를 매번 걸어줘야 하는 수 밖에 없다. 인정하자
다행히도 JPQL의 distinct는 (저 테이블, 즉 DB단의 distinct로 해결되지 않는) 애플리케이션단의 distinct 효과를 내준다고 한다.
[ 결론 ] 이 부분도 평소에 개발할 땐, 어떤 기준을 가지고 있으면 될까 ?
JPQL을 쓰면서 패치 조인을 하는데, (일대일도 다대일도 아닌) 일대다를 패치 조인한다면,
꼭 distinct 를 써야 한다 !
(어짜피 안쓰면 비즈니스 로직상 에러날 것.)
[ 오해 주의 ]
distinct 는 SELECT 대상(Member)에 대해서 중복제거를 하는 것이다.
패치 조인하는 대상(Order)에 대해 하는 게 아니다.
-> member1 한개지만, size 2로 나온다. distinct 붙이면 해결.
3. JPQL) OneToMany 패치 조인을 한번에 2개이상 할 수 없다
아까 2번에서 꼭 distinct를 써줘야 했다던, JPQL에서 일대다 패치 조인 - 한번에 2개 걸어서 가져올 수 없다.
(책에선 JPQ 구현체에 따라 되는 놈도 있다는데, Hibernate는 안되는 걸로.)
이유는
컬렉션 * 컬렉션이라는 Cartesian 곱이 만들어지기 때문.
org.hibernate.loader.MultipleBagFetchException
을 만나게 될 것이다.
'DB, JPA, SQL' 카테고리의 다른 글
JPA 양방향 연관관계 Entity 값 저장 (0) | 2019.12.19 |
---|---|
JPA) LazyInitializationException 이슈 (0) | 2019.12.10 |
프로그래머스) SQL 고득점 Kit 문제 풀이 (0) | 2019.09.10 |
- Total
- Today
- Yesterday
- webhacking.kr
- Stack
- sort
- Android Studio
- mysql
- git
- 회고
- OneToMany
- brute-force
- socket
- JPA
- 우아한 테크코스
- FRAGMENT
- C
- 개발자
- 해외여행
- Data Structure
- 웹해킹
- 프로그래머스
- Vo
- Android
- graph
- reversing
- Java
- 리버싱
- dfs
- Algorithm
- javascript
- queue
- bfs
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |