연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터의 갯수 (n개) 만큼 조회 쿼리가 추가로 발생하여 데이터를 읽어오는 현상이다.
적은 양의 데이터를 조회할 때는 문제가 되지 않겠지만 대용량의 데이터를 조회하는 경우 N+1문제가 발생한다면 대량의 쿼리가 실행되고, 성능 저하로 이어질 수 있다.
멤버는 하나의 팀에만 속할 수 있고, 팀에는 여러 명의 멤버가 가입할 수 있다.
DB에 NBA의 5개의 팀에 선수를 4명씩, 총 20명의 선수를 추가했다.
![]()
findAll()=============================
Hibernate: select team0_.id as id1_1_, team0_.name as name2_1_ from team team0_
Hibernate: select members0_.team_id as team_id1_2_0_, members0_.members_id as members_2_2_0_, member1_.id as id1_0_1_, member1_.name as name2_0_1_, member1_.team_id as team_id3_0_1_, team2_.id as id1_1_2_, team2_.name as name2_1_2_ from team_members members0_ inner join member member1_ on members0_.members_id=member1_.id left outer join team team2_ on member1_.team_id=team2_.id where members0_.team_id=?
Hibernate: select members0_.team_id as team_id1_2_0_, members0_.members_id as members_2_2_0_, member1_.id as id1_0_1_, member1_.name as name2_0_1_, member1_.team_id as team_id3_0_1_, team2_.id as id1_1_2_, team2_.name as name2_1_2_ from team_members members0_ inner join member member1_ on members0_.members_id=member1_.id left outer join team team2_ on member1_.team_id=team2_.id where members0_.team_id=?
Hibernate: select members0_.team_id as team_id1_2_0_, members0_.members_id as members_2_2_0_, member1_.id as id1_0_1_, member1_.name as name2_0_1_, member1_.team_id as team_id3_0_1_, team2_.id as id1_1_2_, team2_.name as name2_1_2_ from team_members members0_ inner join member member1_ on members0_.members_id=member1_.id left outer join team team2_ on member1_.team_id=team2_.id where members0_.team_id=?
Hibernate: select members0_.team_id as team_id1_2_0_, members0_.members_id as members_2_2_0_, member1_.id as id1_0_1_, member1_.name as name2_0_1_, member1_.team_id as team_id3_0_1_, team2_.id as id1_1_2_, team2_.name as name2_1_2_ from team_members members0_ inner join member member1_ on members0_.members_id=member1_.id left outer join team team2_ on member1_.team_id=team2_.id where members0_.team_id=?
Hibernate: select members0_.team_id as team_id1_2_0_, members0_.members_id as members_2_2_0_, member1_.id as id1_0_1_, member1_.name as name2_0_1_, member1_.team_id as team_id3_0_1_, team2_.id as id1_1_2_, team2_.name as name2_1_2_ from team_members members0_ inner join member member1_ on members0_.members_id=member1_.id left outer join team team2_ on member1_.team_id=team2_.id where members0_.team_id=?
After findAll()=======================
findAll()=============================
많은 join문과 함께 깔끔하게 N+1이 구현(?)되었다.
findAll()=============================
Hibernate: select team0_.id as id1_1_, team0_.name as name2_1_ from team team0_
findAll()=============================
N+1이 구현되지 않았다. 그러나 이것은 발생 시기의 차이지, N+1문제를 해결한 것은 아니라고 하였다.
실제로 findAll()을 통해 가져온 members를 사용하려고 하면 N+1문제가 발생한다고 한다.
findAll()=============================
Hibernate: select team0_.id as id1_1_, team0_.name as name2_1_ from team team0_
After findAll()=======================
Hibernate: select members0_.team_id as team_id1_2_0_, members0_.members_id as members_2_2_0_, member1_.id as id1_0_1_, member1_.name as name2_0_1_, member1_.team_id as team_id3_0_1_ from team_members members0_ inner join member member1_ on members0_.members_id=member1_.id where members0_.team_id=?
Hibernate: select members0_.team_id as team_id1_2_0_, members0_.members_id as members_2_2_0_, member1_.id as id1_0_1_, member1_.name as name2_0_1_, member1_.team_id as team_id3_0_1_ from team_members members0_ inner join member member1_ on members0_.members_id=member1_.id where members0_.team_id=?
Hibernate: select members0_.team_id as team_id1_2_0_, members0_.members_id as members_2_2_0_, member1_.id as id1_0_1_, member1_.name as name2_0_1_, member1_.team_id as team_id3_0_1_ from team_members members0_ inner join member member1_ on members0_.members_id=member1_.id where members0_.team_id=?
Hibernate: select members0_.team_id as team_id1_2_0_, members0_.members_id as members_2_2_0_, member1_.id as id1_0_1_, member1_.name as name2_0_1_, member1_.team_id as team_id3_0_1_ from team_members members0_ inner join member member1_ on members0_.members_id=member1_.id where members0_.team_id=?
Hibernate: select members0_.team_id as team_id1_2_0_, members0_.members_id as members_2_2_0_, member1_.id as id1_0_1_, member1_.name as name2_0_1_, member1_.team_id as team_id3_0_1_ from team_members members0_ inner join member member1_ on members0_.members_id=member1_.id where members0_.team_id=?
findAll()=============================
findAll()로 가져온 데이터에서 member를 사용하려는 시점(After findAll())에서 바로 N+1문제가 발생함을 알 수 있다. 즉, 지연로딩은 N+1문제의 해결책은 아니고 즉시로딩과 발생시점의 차이를 가진다.
그러나, Lazy와 EAGER은 차이가 존재한다. 즉시로딩시에는 team을 조회하는 시점에서 곧 바로 member까지 불러오기 때문에 member를 사용하지 않더라도 추가적으로 쿼리문이 발생한다.
만약 member의 연관관계가 5~6개 이상인 경우라면 사용하지 않을 예정인 데이터도 바로 불러올 뿐더라, JPQL을 통해 한번에 여러 개의 Join이 일어날 것이다.
따라서 실제 프로젝트에서는 가급적이면 Lazy를 사용하는게 성능에 있어서 유용하다.
N+1문제가 발생하는 이유는 JPA가 JPQL을 분석해서 SQL을 생성할 때는 Fetch 전략을 참고하지 않고 JPQL만 사용하기 때문이라고 한다.
select t from Team t 라는 JPQL 문이 생성되고 SQL이 생성되어 실행select * from member where team_id = ? SQL 구문 생성 (N+1문제 발생)select t from Team t 라는 JPQL 문이 생성되고 SQL이 생성되어 실행select * from member where team_id = ? SQL 구문 생성 (N+1문제 발생)가장 대표적으로 많이 쓰이는 것은 Fetch Join이다. 외에는 Batch Size가 있다.
JPQL으로 데이터를 가져올 때 처음부터 연관된 데이터까지 가져오게 하는 방법이다.
Fetch Join은 SQL에서 사용하는 조인의 종류는 아니다. JPQL에서 성능 최적화를 위해 제공되는 조인의 종류이다. 따라서, 해당 메서드가 실행되면 join fetch구문은 inner join으로 변경되어 실행된다.
@Query 어노테이션을 사용해서 join fetch 엔티티.연관관계엔티티를 하면 된다.
findAll()=============================
Hibernate: select team0_.id as id1_1_0_, member2_.id as id1_0_1_, team0_.name as name2_1_0_, member2_.name as name2_0_1_, member2_.team_id as team_id3_0_1_, members1_.team_id as team_id1_2_0__, members1_.members_id as members_2_2_0__ from team team0_ inner join team_members members1_ on team0_.id=members1_.team_id inner join member member2_ on members1_.members_id=member2_.id
After findAll()=======================
findAll()=============================
추가 쿼리문 없이 inner join으로 한번에 불러오는데 성공했다.
이 옵션은 N+1문제를 안 일어나게 하는 방법은 아니고 N+1 문제가 발생하더라도 select * from member where team_id =? 가 아닌 select * from member where team_id in (?,?,?) 방식으로 문제가 생기게 하는 방식이다.
이렇게 하면 100번 작성될 SQL 쿼리문을 1번만 더 조회하는 방식으로 성능을 최적화할 수 있다.
해당 방법은 코드에 작성하는 것이 아닌 yml, properties파일에 적용하는 방식이다.
application.yml
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 1000
findAll()=============================
Hibernate: select team0_.id as id1_1_, team0_.name as name2_1_ from team team0_
After findAll()=======================
Hibernate: select members0_.team_id as team_id1_2_1_, members0_.members_id as members_2_2_1_, member1_.id as id1_0_0_, member1_.name as name2_0_0_, member1_.team_id as team_id3_0_0_ from team_members members0_ inner join member member1_ on members0_.members_id=member1_.id where members0_.team_id in (?, ?, ?, ?, ?)
findAll()=============================
findAll()을 통해 데이터를 불러온 뒤, 실제로 사용하려할 때 1번만 더 조회를 하는 결과를 가져왔다.
findAll()=============================
Hibernate: select team0_.id as id1_1_0_, member2_.id as id1_0_1_, team0_.name as name2_1_0_, member2_.name as name2_0_1_, member2_.team_id as team_id3_0_1_, members1_.team_id as team_id1_2_0__, members1_.members_id as members_2_2_0__ from team team0_ inner join team_members members1_ on team0_.id=members1_.team_id inner join member member2_ on members1_.members_id=member2_.id
After findAll()=======================
findAll()=============================
findAll()=============================
Hibernate: select team0_.id as id1_1_, team0_.name as name2_1_ from team team0_
Hibernate: select members0_.team_id as team_id1_2_2_, members0_.members_id as members_2_2_2_, member1_.id as id1_0_0_, member1_.name as name2_0_0_, member1_.team_id as team_id3_0_0_, team2_.id as id1_1_1_, team2_.name as name2_1_1_ from team_members members0_ inner join member member1_ on members0_.members_id=member1_.id left outer join team team2_ on member1_.team_id=team2_.id where members0_.team_id in (?, ?, ?, ?, ?)
After findAll()=======================
findAll()=============================
즉시로딩도 한 번에 예쁘게 데이터를 받아온다. 특히나 FetchJoin시에는 LAZY나 EAGER이나 별 차이가 없어보인다. 하지만 이는 TeamRepository에서 findAll()을 할 시 members를 FetchJoin 하도록 했기 때문이다. 즉, 불러오는 시점에서 members를 불러오도록 했기 때문에 같아 보이는 것.
만약 다른 연관관계가 있다면 불필요한 Join까지 이루어 질 것이다.
JPA의 기본전략
@XXXToOne인 경우 : 즉시 로딩 (FetchType.EAGER)
@XXXToMany인 경우 : 지연 로딩 (FetchType.LAZY)
@XXXToOne인 경우 지연로딩으로 바꾸기 위함도 있지만, 내 코드를 읽는 사람이 한눈에 편하게 알아보도록 하기위해서 모든 경우에 FetchType을 쓰는게 좋은 것 같다.
가장 효율적으로 쓴다면 아무래도 최초 한 번만 불러오는 FetchJoin을 필요한 구문에만 쓰는 것이 가장 이상적인 방법일 것 같다. 연관관계가 여러 개라고 모두 FetchJoin을 걸어버리면 EAGAR과 다를게 없으니까...
여러 개의 관계를 가진 엔티티, 일반 Join과 한방 FetchJoin 비교
(여러 개의 연관관계를 가진 경우 FetchJoin 한방쿼리는 오히려 안좋을 수도...?)
하지만 아직 실무 경험이 없어서 어떤 것이 올바른 방법인지에 대해서는 잘 모르겠다.
그러나 연관관계가 존재한다면, FetchJoin이나 BatchSize 둘 중 어떤 것이든 작성해서 N+1 문제를 막는 것이 쓰는 것이 효율적이며 연관관계가 굳이 필요하지 않다면 관계를 끊어버리는 것도 좋은 선택일 것 같다.
개인적으로는 연관관계가 많은 경우 FetchJoin으로 적절하게 끊어가져올 수 없다면 BatchSize를 통해 필요할 때마다 한 번씩만 다시 조회하는 게 좋아보인다.