JPA를 공부하며, 알지 못하면 치명적인 성능저하 및 장애를 불러일으킬 수 있는 N + 1 문제에 관한 내용과 해결법을 정리해보려고 한다. N + 1 문제는 아주 다양한 상황에서 발생한다. 이 글에서는 @OneToMay 에서 발생하는 N + 1 문제를 다룬다. 코드는 Spring Data JPA를 기준으로 작성하였다.
✔️ N + 1 문제란 무엇일까?
N + 1 문제란 엔티티 간의 연관관계가 설정된 상황에서 발생한다. 나는 분명 1개 의 쿼리를 기대했는데, 연관관계 또한 조회하게 되며 N개만큼 쿼리가 추가적으로 발생하는 상황을 말한다. 사실 이렇게 글로 보면 이해가 잘 안 간다. 바로 코드를 보며 이해해 보자.
✔️ 엔티티 & 코드
Team과 User는 일대다(1:N) 관계이다. 1개의 Team에 여러 명의 User가 속할 수 있는 구조이다. @OneToMany의 default fetch 타입은 LAZY이다. Team과 User의 연관관계 @OneToMany의 fetch 타입을 EAGER로 두어, 즉시 로딩으로 설정했다.
📝 [ Team.java ]
@Entity
@Getter
@NoArgsConstructor
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "team_id")
private Long id;
private String name;
@OneToMany(mappedBy = "team", fetch = FetchType.EAGER) // 즉시로딩
private List<User> users = new ArrayList<>();
public Team(String name) {
this.name = name;
}
}
📝 [ User.java ]
@Entity
@Getter
@NoArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id")
private Long id;
private String username;
@ManyToOne
@JoinColumn(name = "team_id")
private Team team;
public User(String username) {
this.username = username;
}
public void addTeam(Team team) {
this.team = team;
team.getUsers().add(this);
}
}
✔️ 테스트
💡 즉시 로딩 (EAGER)
Team과 User의 정보를 저장한다. 4개의 팀과 각 팀에 5명씩 유저를 담아본다.
@Test
@DisplayName("팀 / 유저 정보 등록")
void exampleTest() {
// 4개의 팀 등록
for (int i = 0; i < 4; i++) {
Team team = new Team("개발팀" + i);
teamRepository.save(team);
// 팀당 유저 임의로 5명씩 추가
for (int j = 0; j < 5; j++) {
User user = new User("이름" + j);
user.addTeam(team);
userRepository.save(user);
}
}
}
그리고 전체 팀을 조회하는 findAll() 메서드를 호출해 보자.
@Test
@DisplayName("Team 조회")
@Transactional
void selectTeamTest() {
System.out.println("============== 팀 조회 시작 ===================");
teamRepository.findAll();
System.out.println("============== 팀 조회 끝 ===================");
}
JPA는 findAll() 메서드로 Team을 전체조회 할 때, 팀을 조회하는 쿼리(1)을 날린 후, 즉시로딩(EAGER)를 감지하고 해당하는 각 Team의 연관관계 데이터(N)들을 조회하게 된다.
팀을 조회하는 쿼리(1) + 각 팀의 연관관계를 조회 쿼리(N) 이기 때문에 N + 1 문제라고 하는 것이다.
이해가 안 간다면 다시, 왜 쿼리가 5(1 + 4)개인지 생각해 보자. 전체 팀이 4개이기 때문에, 4개의 팀에 대한 연관관계를 각각 조회하는 쿼리가 4개가 추가로 나간 것이다.
만약 팀이 100개이면 100개의 연관관계 조회 쿼리가 추가로 나가서 총쿼리의 수는 100 + 1개, 10만 개이면 10만 개의 쿼리가 추가로 나가서 10만 + 1개가 된다.
사실 개인적으로는 1 + N 이 더 와닿기는 하는 것 같다.
💡 지연 로딩 (LAZY)
즉시 로딩을 사용하지 않고 지연 로딩을 사용하면 해결되는 문제 아닌가? 라는 생각을 할 수 있다. @OneToMany는 기본 LAZY이다. 보기 좋게 LAZY를 명시했다.
@Entity
@Getter
@NoArgsConstructor
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "team_id")
private Long id;
private String name;
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY) // 지연 로딩
private List<User> users = new ArrayList<>();
public Team(String name) {
this.name = name;
}
}
지연 로딩을 사용하여, 1개의 쿼리만 나가도록 됐다. 얼추 보면 N + 1 문제가 해결된 듯하다. 하지만 그렇지 않다. 조회한 Team의 User에 접근하면 User 프록시 객체에 대한 쿼리가 발생하게 된다. 아래 코드를 봐보자.
@Test
@DisplayName("Team 조회")
@Transactional
void selectTeamTest() {
System.out.println("============== 팀 조회 시작 ===================");
List<Team> findTeams = teamRepository.findAll();
for (Team findTeam : findTeams) {
findTeam.getUsers().size();
}
System.out.println("============== 팀 조회 끝 ===================");
}
N + 1 문제가 또 다시 발생하게 된다. 이 문제를 어떻게 해결할 수 있을까?
✔️ 해결 방법
💡 Fetch Join
JPQL로 DB를 조회할 때 처음부터 연관관계 데이터까지 한 번에 가져오는 방법이다. fetch join을 사용하면 단 한 번의 쿼리로 연관관계 데이터까지 조회가 가능해진다. 대신, LAZY 로딩이 의미가 없어진다.
@Query("select t from Team t join fetch t.users")
List<Team> findAllFetchJoin();
@Test
@DisplayName("Team 조회")
@Transactional
void selectTeamTest() {
System.out.println("============== 팀 조회 시작 ===================");
List<Team> findTeams = teamRepository.findAllFetchJoin(); // fetch join
for (Team findTeam : findTeams) {
findTeam.getUsers().size();
}
System.out.println("============== 팀 조회 끝 ===================");
}
join을 사용하여 한번에 불러오는 것을 확인할 수 있다.
단점
- 일대다(1:N) 관계 fetch join은 페이징 처리를 절대 하면 안 된다. 아래 코드를 보자.
@Query("select t from Team t join fetch t.users")
Page<Team> findAll(Pageable pageable);
@Test
@DisplayName("Team 조회")
@Transactional
void selectTeamTest() {
System.out.println("============== 팀 조회 시작 ===================");
PageRequest pageRequest = PageRequest.of(0, 2);
Page<Team> teams = teamRepository.findAll(pageRequest);
for (Team team : teams) {
team.getUsers().size();
}
System.out.println("============== 팀 조회 끝 ===================");
}
페이징 처리를 했는데, 쿼리에 offset, limit 가 존재하지 않는다. Full scan을 한 것이다.
또한 아래와 같은 경고가 나온다.
이는 데이터베이스에서 해당 페이징을 처리하는 대신에 모든 결과를 가져와 메모리에서 페이징을 적용하게 됨을 의미하는 것이다. 만약 데이터가 많다면 Out Of Memory가 일어날 수 있는 치명적인 경고이다.
이 경고가 나오는 이유는 일대다(1:N) fetch join으로 중복된 데이터가 생기기 때문에, row 수가 늘어나게 되고, limit에 대한 기준을 세울 수 없게 되기 때문이다. 그래서 Hibernate는 모든 데이터를 애플리케이션 메모리에 올려서 페이징을 수행하게 된다.
그럼 이 페이징과 함께, 컬렉션 엔티티를 함께 조회하는 방법은 무엇일까?
💡 @BatchSize
컬렉션 조인 페이징의 경우는 fetch join을 사용하지 않고, @BatchSize로 해결이 가능하다. @BatchSize는 연관관계 데이터를 몇 개씩 가져올지 IN 구문을 만들어 가져오게 된다. IN구문의 쿼리가 더 나가긴 하지만 PK 값을 기준으로 IN 구문을 만들기 때문에 속도도 빠르며, N + 1 문제도 함께 해결된다. application.yml에 전역적으로 설정도 가능하다.
@BatchSize(size = 100)
@OneToMany(mappedBy = "team")
private List<User> users = new ArrayList<>();
@Test
@DisplayName("Team 조회")
@Transactional
void selectTeamTest() {
System.out.println("============== 팀 조회 시작 ===================");
PageRequest pageRequest = PageRequest.of(0, 2);
Page<Team> teams = teamRepository.findAll(pageRequest);
for (Team team : teams) {
team.getUsers().size();
}
System.out.println("============== 팀 조회 끝 ===================");
}
BatchSize 설정 후, fetch join을 하지 않고 쿼리를 한 결과는 아래와 같다. IN 쿼리가 하나 더 나가긴 했지만, PK 값을 기준으로 IN 쿼리가 날아가기 때문에 속도가 굉장히 빠르다. 이 또한 지연로딩은 무시되고 IN절로 바로 불러오게 된다. IN절의 개수는 연관관계 데이터의 가져올 개수이다. 여기서는 User가 되겠다.
Batchsize가 100이고, 연관관계 User의 수가 100이 넘는다면 어떻게 될까? 100개 단위로 끊어서 IN절이 여러 개 날아가게 된다.
예를 들어, User 수가 200일 경우 -> IN(1,2,3,4 ... 100) / IN(101,102,103 ... 200) 2개의 쿼리가 나가게 된다. 최적의 BatchSize를 설정할 수 있으면 좋겠지만 데이터의 개수는 정확히 알 수 없다. 그래서 보통 100~1000 사이의 사이즈가 쓰인다.
💬 결론
- @OneToMany 연관관계에서 N + 1 문제는 fetch join 으로 해결할 수 있다.
- @OneToMany 연관관계에서 fetch join + 페이징 처리는 절대 하면 안 된다.
- @OneToMany 연관관계에서 N + 1 문제를 해결하며 페이징 처리까지 가능한 @BatchSize를 적용하여 최적화를 할 수 있다.