Loading the catalog…
Loading the catalog…
velog
오늘은, JPA N+1 문제에 대해서 알아보자 단순하게, LAZY 로딩 ( 지연로딩 ) 때문에 생기는게아니라, JPQL 이 무엇을 조회했고 연관관계가 어떤식으로 로딩이 되어지고, Proxy 초기화를 할때 어떤 SQL 이 실행되는지를 알아보자. 축구 팀과, 선수가 있다고 하자. @Entity public class Team { @Id private Long id; private String name; @OneToMany(mappedBy = "team", fetch = FetchType.LAZY) private List<Member> members = new ArrayList<>(); } @Entity public class Member { @Id private Long id; private String nickname; @ManyToOne(fetch = FetchType.LAZY) private Team team; } 현재 팀은 100개가 존재하고, 팀들을 조회하는 코드를 실행했다고 하자. List<Team> teams = teamRepository.findAll(); SQL 한번이면 당연히 가능할것같다. SELECT * FROM team; 실제로는, 당연히 해당 쿼리 한번으로 모든 팀들을 조회할 수 있다. 문제는, 다음과 같은 코드를 사용할때이다. for (Team team : teams) { System.out.println(team.getMembers().size()); } 이때, 갑자기 예상하지못한 SQL 들이 실행될 수 있다. SELECT * FROM member WHERE team_id = 1; SELECT * FROM member WHERE team_id = 2; SELECT * FROM member WHERE team_id = 3; ... SELECT * FROM member WHERE team_id = 100; 결과적으로는, 해당 코드들을 실행한다고 했을때 팀 조회 1번 회원 조회 100번 ---------------- 총 101번 이게, JPA 문제들중 대표적인 N + 1 문제이다. N + 1 문제를 잘 알아야하는 이유는, 문제가 어디서 발생했는지 잘 안보인다는것이다. 작성한 코드는 다음 두가지 뿐이다. List<Team> teams = teamRepository.findAll(); team.getMembers() Java 코드 2줄을 작성했지만, 실제 DB 에 날리는 SQL 은 수십~수백개 까지 많은 양의 sql 이 실행될 수 있다. 데이터가 적고, 개발중인 환경이라면 문제가 별로 심각해보이지는 않는것처럼 보인다. 예를들어, 팀이 3개뿐이라면 위에있는 코드들은 N+1 문제를 동반한다고해도, 쿼리가 4개만 나갈뿐이다. ( 팀들 조회 1번 + 팀에 있는 멤버들 조회 3번 ) 하지만, 실제 운영하는 환경에서는 팀이 3개인것처럼, 데이터가 적지않다. 수천개에서 많게는 수천만개까지도 있을수도있다. 이때, N + 1 문제가 발생한다고 생각해보자. 기대한 쿼리갯수는 1개인데, 잘못하면 쿼리가 수천만개가 나가는 최악의 상황이 발생할 수 있다. 각 SQL 에 대해서 DB 와의 왕복시간이 매우 적게걸린다고 생각해도 ( 예를들어, 5ms 이라고 가정하자. ) 해당 쿼리가 수천만개가 나간다면 잡아먹히는 시간은 엄청나게 오래걸린다. 그렇기에, N + 1 문제는 성능개선을 위한 필수적인 단계로 해석된다. 내부에서 어떤일들이 벌어지는 걸까? findAll() 을 실행했다. List<Team> teams = teamRepository.findAll(); Spring Data JPA 가, Hibernate 를 통해서 Team 을 조회한다. [[ DB => MySQL 을 사용한다고 하자. ]] Repository ↓ EntityManager ↓ Hibernate ↓ JPQL ↓ SQL ↓ MySQL ( DB ) SELECT t.id, t.name FROM team t; 해당 쿼리로, team 들을 조회한다. 결과로는 Team 1 Team 2 Team 3 ... Team 100 여기까지는 SQL 이 1번만 실행됐다. 위에있는 Team 엔티티를 다시한번 살펴보자. @Entity public class Team { @Id private Long id; private String name; @OneToMany(mappedBy = "team", fetch = FetchType.LAZY) private List<Member> members = new ArrayList<>(); } members 는 fetch = LAZY 로 잡혀져있다. LAZY 의 핵심은 Team 을 조회하는 순간에는, Member 까지는 반드시 DB 에서 조회하고 가져오지는 않는다. 즉, SELECT t.id, t.name FROM team t; 해당 sql 을 통해서 Team 을 조회했다면, 해당 Team 은 다음과 같이 설정되어져있을것이다. Team #1 id = 1 name = "FC Seoul" members = Hibernate PersistentCollection ( Proxy ... -> 실제 Member 가 아님.) 위처럼, LAZY 연관관계로 잡혀져있는 객체는 Team 을 만들때에는 우선, Hibernate 가 관리하는 지연로딩용 객체로 들어갈 수 있다. 아직, 멤버들을 조회하는 SELECT * FROM member WHERE team_id = 1; 해당 sql 쿼리는 실행되지 않았다. getMembers() 사용 이제, 다음 코드를 실행했다고 하자. team.getMembers().size(); Hibernate 입장에서는, members 의 실제 Member 데이터들에 대한것들이 필요하네? 라고 판단한다. 이때, 컬렉션을 초기화 해야한다. ( members -> Proxy 가 아닌, 실제 Member ... ) LAZY Collection ↓ 초기화 필요 ↓ Hibernate ↓ SELECT Member 에 대한 데이터가 필요하기때문에, 이제 해당하는 SQL 을 DB 에 날리게된다. SELECT m.id, m.nickname, m.team_id FROM member m WHERE m.team_id = 1; team_id. = 1 인 Team 에 속한 members 들을 가지고 와야하니, 해당 쿼리가 실행이된다. 이후, team_id = 1 인 Team 의 Member 들이 로딩된다. 이제, team_id = 1 인 팀 뿐만아니라, team_id. = 2 / team_id = 3 / team_id.= 4 .... team_id = 100 까지 members 가 필요하다고 하자. 각각의 팀들에 대해서, 해당 코드가 실행되는것이다. team.getMembers().size(); 그렇다면, 위team_id = 1 인 팀과 마찬가지로, 팀들을 가지고왔을때에는 Member 에 대한 값이 Hibernate 가 관리하는 지연로딩용 객체가 들어가있기때문에, 실제 Member 에 대한 데이터를 가지고오기위해 DB 에 쿼리를 날리게 된다. SELECT * FROM member WHERE team_id = 2; SELECT * FROM member WHERE team_id = 3; SELECT * FROM member WHERE team_id = 4; ... SELECT * FROM member WHERE team_id = 100; 결국, SELECT Team │ ├── Team 1 → SELECT Member ├── Team 2 → SELECT Member ├── Team 3 → SELECT Member │ ... └── Team 100 → SELECT Member 다음과 같은 상태가 된다. 그래서, 이름이 N + 1 ( 1 + N ) 이다. 즉, 메인인 쿼리 1개를 실행했는데, 부가적인 쿼리가 N 개가 발생한것이다. 그렇다면, 지연로딩때문에 발생한 문제이니까. LAZY -> EAGER 로 바꾸면 되는거아닌가? 라고 생각할 수 있다. @OneToMany(fetch = FetchType.LAZY) private List<Member> members; ↓ @OneToMany(fetch = FetchType.EAGER) private List<Member> members; 하지만, 이렇게 해결해서는 안된다. EAGER 는 항상 한번의 JOIN SQL 로 가져온다는 의미가 아니기때문이다. JPA 에게 Eager 상태로 되어져있는 것은, 이 연관관계는 반드시 로딩된 상태여야 한다. 라는것과 가깝다. JPA 구현체인 Hibernate 는, 해당 상태를 만족하기 위해 상황에 따라 추가적인 SQL 을 실행해서, EAGER 요구사항을 충족할 수도 있기때문에, EAGER 로 N+1 문제를 해결하려고 해서는 안된다. 또한, 더 큰 문제가 존재한다. 어떤 API 에서는, Member 가 필요하지 않을수도있다. 즉, Team 에 대한 정보만 필요할뿐 Member 에 대한것은 필요하지 않은 상황이다. { "id": 1, "name": "Footmatch FC" } 그런데, EAGER 를 사용하게되면 어쨋든, 사용하지도 않은 Member 에 대한 데이터를 항상 가져올 가능성이 생기므로, 해당 상황이라면 불필요하다고 생각된다. 그렇기에, 보통 N + 1 문제를 해결하기 위해 우선은, 연관관계를 LAZY 로 두고 필요한 곳에서만 fetch 전략을 명시적으로 작성하자. 로 해결한다. Fetch Join 이번 API 에서는 Team / members ( Member ) 가 필요하다고 하자. 그러면, 연관관계는 LAZY 로 두고 @OneToMany(fetch = FetchType.LAZY) private List<Member> members; 필요한곳에서 JPQL Fetch Join 을 사용하면 된다. @Query(""" select distinct t from Team t join fetch t.members """) List<Team> findAllWithMembers(); @Entity public class Team { @Id private Long id; private String name; @OneToMany(mappedBy = "team", fetch = FetchType.LAZY) private List<Member> members = new ArrayList<>(); } 핵심은, join fetch 이다. 일반 JOIN 과는 달리, 연관관계로 잡혀져있는 해당 Entity 까지 같이 로딩하라 ( 같이 가지고와라 ) 라는 의미이다. SELECT t.id, t.name, m.id, m.nickname, m.team_id FROM team t JOIN member m ON t.id = m.team_id; 즉, team / member 를 조인해서, Team 을 가지고올때 member 에 대한것도 같이 가지고오는것이다. 그렇다면, Team 조회 1번 + Member 조회 N번 이렇게 쿼리가 발생했던 것이, JOIN SQL 1번 즉, SELECT t.id, t.name, m.id, m.nickname, m.team_id FROM team t JOIN member m ON t.id = m.team_id; 해당 쿼리 1번만 나가게 된다. JPQL 로 작성한 쿼리를 한번 더 봐보자. @Query(""" select distinct t from Team t join fetch t.members """) List<Team> findAllWithMembers(); 왜 select distinct t 일까? 왜 Distinct 키워드가 들어간걸까? 이는, Join 으로 인한 데이터 뻥튀기때문이다. 예를들어, Team_id = 1 인 Team 이 다음과같다고 하자. team_id | team_name | member_id -------------------------------- 1 | A FC | 10 1 | A FC | 11 1 | A FC | 12 해당 예시의 DB 의 결과에서는 Team 에 대한 정보는 Member 의 숫자에 따라 계속해서 반복된다. 즉, team_id = 1 인 팀에 member 가 3명이 있기때문에 team_id | team_name -------------------------------- 1 | A FC 1 | A FC 1 | A FC 이렇게, team_id / team_name 이 중복되어서 나타난다. 그래서, Distinct 로 중복을 제거했다. 이는, 관계형 DB 에서 Join 으로 인한 데이터 뻥튀기가 존재하므로 나타나는 문제이다. 실제 코드로 예시를 살펴보자. @Entity public class Team { @Id private Long id; private String name; @OneToMany(mappedBy = "team", fetch = FetchType.LAZY) private List<Member> members = new ArrayList<>(); } 팀 목록 API 가 다음과 같이 있다고 하자. @Transactional(readOnly = true) public List<TeamResponse> getTeams() { return teamRepository.findAll() .stream() .map(team -> new TeamResponse( team.getId(), team.getName(), team.getMembers().size() )) .toList(); } teamRepository 를 통해, 모든 team 들을 조회하고, 가져온 팀들에 대한 정보들을 응답 DTO 로 바꾸고, 해당 dto 들을 List 로 반환하는 코드이다. 해당 코드는 당연히 자연스럽다. 팀들을 조회했고, stream 을 통해 각 Team 들에 대해서 원하는 dto 로 변환하고 List 로 합쳐서 반환하고 ... 매우매우 자연스럽다. 하지만, team.getMembers().size() 가 중요하다. ( members -> List<Member) .. fetch = LAZY ) ) findAll() ↓ SELECT team ↓ TeamResponse 변환 ↓ getMembers() ↓ LAZY 초기화 ↓ SELECT member WHERE team_id = ? ↓ 팀마다 반복 N + 1 이 발생할 수 있다. ( findAll -> jpql fetch Join 을 적용하지 않았다고 하자. ) Fetch Join 을 적용해보자. @Query(""" select distinct t from Team t join fetch t.members """) List<Team> findAllWithMembers(); team 을 가지고 올때, members 에 대한 Member 들도 같이 가지고온다. @Transactional(readOnly = true) public List<TeamResponse> getTeams() { return teamRepository.findAllWithMembers() .stream() .map(team -> new TeamResponse( team.getId(), team.getName(), team.getMembers().size() )) .toList(); } 이제, Team 을 가지고올때 members 또한 같이 가지고오므로, getMembers() 에서 팀마다 멤버를 가지고오기위한 Select 쿼리를 날릴 필요가없게됐다. 전체적인 흐름은 다음과 같다. findAll() ↓ SELECT Team ← 1 ↓ Team 1 ── getMembers() ── SELECT Member Team 2 ── getMembers() ── SELECT Member Team 3 ── getMembers() ── SELECT Member ... Team N ── getMembers() ── SELECT Member ← N 총 SQL = 1 + N Fetch Join 을 통해 ... Team \ JOIN ─── Member / ↓ SELECT 한 번
What RADAR observed and classified to build this opportunity. It is what the source published, not a verification that the offer is still active.
[Spring/DB/Java DAY6] - JPA N+1 문제 : findAll() 을 한번 호출했는데 왜 SQL 은 1번이 아니라 여러번 실행이 되는걸까?. 오늘은, JPA N+1 문제에 대해서 알아보자 단순하게, LAZY 로딩 ( 지연로딩 ) 때문에 생기는게아니라, JPQL 이 무엇을 조회했고 연관관계가 어떤식으로 로딩이 되어지고, Proxy 초기화를 할때 어떤 SQL 이 실행되는지를 알아보자. 축구 팀과, 선수가 있다고 하자. @Entity public class Team { @Id private Long id; private String name; @OneToMany(mappedBy = "team", fetch = FetchType.LAZY) private List members = new…
Open source