[Server] ORM N+1 문제

Sungjin Cho·2025년 9월 12일

Server

목록 보기
8/8

N+1 문제의 발생 원인, Fetch Join 등을 이용한 해결 방법

N+1 문제란?

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번 반복)

N+1 문제의 핵심 원인

지연 로딩(Lazy Loading)으로 설정된 연관 엔티티의 데이터에 실제로 접근하는 시점에 추가 쿼리가 발생하는 것이 문제의 핵심이다.

N+1 문제 해결 방법
핵심은 연관된 데이터를 처음부터 함께 조회(JOIN)하여 추가 쿼리가 발생할 여지를 없애는 것이다.

1. Fetch 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;

2. @EntityGraph

JPQL 없이 애너테이션만으로 Fetch Join 효과를 낼 수 있는 방법이다.

@Override
@EntityGraph(attributePaths = {"team"})
List<Member> findAll();

findAll을 호출할 때 attributePaths에 명시된 team 엔티티를 함께 조회하라는 의미이며, 내부적으로 Fetch Join과 동일하게 동작한다.

결론

N+1 문제는 ORM 사용 시 쉽게 발생할 수 있는 대표적인 성능 병목 지점이다. 그 원인은 지연 로딩(Lazy Loading)과 반복문 내 연관 객체 접근의 조합에 있다.

가장 좋은 해결책은 Fetch Join 또는 @EntityGraph를 사용하여 필요한 데이터를 한 번의 쿼리로 가져오는 것이다. ORM을 효율적으로 사용하기 위해서는 쿼리 실행 계획을 항상 확인하여 의도치 않은 쿼리가 발생하는 것을 방지하는 습관이 중요하다.

0개의 댓글