ORM(Object-Relational Mapping) 기술 사용 시, 연관 관계를 가진 엔티티를 조회할 때 발생하는 고질적인 성능 저하 문제
1: 특정 엔티티 목록을 조회하기 위해 첫 번째 쿼리가 1번 실행되는 것.
N: 조회된 엔티티 목록의 개수(N개)만큼, 연관된 다른 엔티티를 조회하기 위해 추가적으로 N번의 쿼리가 실행되는 것.
결과적으로, JOIN을 통해 단 한 번의 쿼리로 해결할 수 있는 작업을 총 1 + N번의 쿼리를 실행하여 데이터베이스에 불필요한 부하를 주는 현상이다.
N+1 문제는 어떻게 발생하는가?
Team과 Member가 1:N 관계를 맺고 있는 상황을 가정
@Entity
public class Team {
@Id
@GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
// ...
}
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY) // 지연 로딩 전략
private Team team;
// ...
}
"모든 멤버와 각 멤버가 속한 팀의 이름을 출력"하는 로직에서 문제가 발생한다.
// 1. 모든 Member 조회 (쿼리 1번 발생)
List<Member> members = memberRepository.findAll();
// 2. 각 Member가 속한 Team 이름 출력 (Member 수만큼 쿼리 N번 발생)
for (Member member : members) {
// member.getTeam() 까지는 프록시, .getName()으로 실제 데이터에 접근 시 쿼리 발생
System.out.println("멤버: " + member.getName() + ", 팀: " + member.getTeam().getName());
}
첫 번째 쿼리 (1)
memberRepository.findAll()이 실행되어 Member 테이블의 모든 데이터를 가져온다.
SELECT * FROM member;
추가 쿼리 (N)
for 루프 안에서 member.getTeam().getName()이 호출될 때, 지연 로딩되었던 Team 정보가 필요해진다. Hibernate는 각 Member에 해당하는 Team을 조회하기 위해 N번의 추가 쿼리를 실행하게 된다.
SELECT * FROM team WHERE id = ?; -- 첫 번째 멤버의 팀 조회
SELECT * FROM team WHERE id = ?; -- 두 번째 멤버의 팀 조회
... (N번 반복)
지연 로딩(Lazy Loading)으로 설정된 연관 엔티티의 데이터에 실제로 접근하는 시점에 추가 쿼리가 발생하는 것이 문제의 핵심이다.
N+1 문제 해결 방법
핵심은 연관된 데이터를 처음부터 함께 조회(JOIN)하여 추가 쿼리가 발생할 여지를 없애는 것이다.
JPQL에서 JOIN FETCH 구문을 사용하여 처음부터 연관된 엔티티까지 함께 조회하는 방법이다.
@Query("SELECT m FROM Member m JOIN FETCH m.team")
List<Member> findAllWithTeam();
처음부터 LEFT OUTER JOIN을 사용하여 단 1번의 쿼리로 Member와 Team 데이터를 모두 가져온다.
SELECT m.*, t.* FROM member m LEFT OUTER JOIN team t ON m.team_id = t.id;
JPQL 없이 애너테이션만으로 Fetch Join 효과를 낼 수 있는 방법이다.
@Override
@EntityGraph(attributePaths = {"team"})
List<Member> findAll();
findAll을 호출할 때 attributePaths에 명시된 team 엔티티를 함께 조회하라는 의미이며, 내부적으로 Fetch Join과 동일하게 동작한다.
N+1 문제는 ORM 사용 시 쉽게 발생할 수 있는 대표적인 성능 병목 지점이다. 그 원인은 지연 로딩(Lazy Loading)과 반복문 내 연관 객체 접근의 조합에 있다.
가장 좋은 해결책은 Fetch Join 또는 @EntityGraph를 사용하여 필요한 데이터를 한 번의 쿼리로 가져오는 것이다. ORM을 효율적으로 사용하기 위해서는 쿼리 실행 계획을 항상 확인하여 의도치 않은 쿼리가 발생하는 것을 방지하는 습관이 중요하다.