JPA 공부하다 보면 어느 순간 마주치는 이슈가 있다.
겉으론 잘 작동하는데, 로그를 보면 쿼리가 엄청 많이 날아가고 있다면?
그게 바로 N+1 문제다.

이 글은 내가 공부하면서 정리한 내용을 바탕으로,
N+1 문제의 개념부터 실무에서 자주 쓰는 해결 방법까지 간단히 정리한 기록이다.


✅ N+1 문제란?

🔍 개념 요약

  • 1 쿼리: 예를 들어 부서 목록을 한 번에 조회함
  • N 쿼리: 각 부서마다 직원 리스트를 다시 조회함 → 총 1 + N 쿼리 발생
List<Department> departments = departmentRepository.findAll();

for (Department dept : departments) {
    for (Employee emp : dept.getEmployees()) {
        System.out.println(emp.getName());
    }
}

부서가 3개라면 총 4번의 쿼리, 100개면 101번의 쿼리가 실행된다.


💡 왜 이런 문제가 생길까?

JPA에서는 @OneToMany, @ManyToOne 같은 연관 관계가 기본적으로 지연 로딩(LAZY) 설정이다.

@OneToMany(mappedBy = "department", fetch = FetchType.LAZY)
private List<Employee> employees;
  • 연관 객체를 실제로 접근하는 시점에 쿼리가 실행된다
  • 루프 안에서 .getEmployees()가 실행될 때마다 쿼리가 날아간다

🔎 실무에서는 어떻게 확인할까?

N+1 문제가 발생했는지 확인하려면 SQL 로그를 직접 확인하는 게 가장 확실하다.

# application.yml 예시
spring:
  jpa:
    show-sql: true
    properties:
      hibernate.format_sql: true

그리고 아래처럼 연관 객체에 접근하는 시점에 쿼리가 나가는지 확인해본다.

dept.getEmployees().size(); // 이때 쿼리가 발생하는지 로그로 체크

🔧 해결 방법

1. Fetch Join

@Query("SELECT DISTINCT d FROM Department d JOIN FETCH d.employees")
List<Department> findAllWithEmployees();
  • 한 번의 쿼리로 연관 데이터까지 모두 조회
  • DISTINCT 사용 안 하면 중복 데이터 생길 수 있음
  • 컬렉션 fetch join은 페이징 불가능 (주의)

2. EntityGraph

@EntityGraph(attributePaths = "employees")
List<Department> findAll();
  • 쿼리 수정 없이 fetch join 효과
  • 간단한 조회에는 좋지만, 복잡한 조건 쿼리에는 한계 있음

3. Batch Size 설정

@OneToMany(mappedBy = "department", fetch = FetchType.LAZY)
@BatchSize(size = 10)
private List<Employee> employees;

혹은 application.yml에서 전역 설정 가능:

spring:
  jpa:
    properties:
      hibernate.default_batch_fetch_size: 10
  • 지연 로딩은 유지하면서도 성능 문제를 완화할 수 있음
  • 여러 건을 한 번에 IN 쿼리로 가져오는 방식
  • 페이징과 잘 어울리는 방식

4. QueryDSL 사용

QDepartment d = QDepartment.department;
QEmployee e = QEmployee.employee;

queryFactory
    .selectFrom(d)
    .leftJoin(d.employees, e).fetchJoin()
    .fetch();
  • 동적 쿼리, 조건 필터, DTO 매핑까지 다양하게 활용 가능
  • 설정이 좀 번거롭고 진입장벽이 있는 편

🚫 흔한 실수: EAGER로 바꾸면 되지 않나?

@OneToMany(fetch = FetchType.EAGER)

처음엔 EAGER로 바꾸면 다 해결될 것 같지만,
실제로는 언제 어디서든 쿼리가 튀어나와서 제어하기 어려워진다.

  • 쿼리 타이밍 예측이 어려움
  • 예상치 못한 조인, 쿼리 폭발 발생
  • 유지보수 난이도 ↑

그래서 대부분의 경우엔 LAZY를 유지하면서 명시적으로 fetch를 제어하는 게 정석이다.


🗂 상황별 정리

상황추천 해결책
단순 조회만 필요할 때Fetch Join / EntityGraph
페이징도 함께 써야 할 때Batch Size
조건이 복잡한 검색 쿼리일 때QueryDSL
유지보수와 확장성까지 고려 시QueryDSL + Batch Size 조합 추천

📝 마무리

JPA에서 N+1 문제는 흔하게 발생하는 성능 이슈다.
처음엔 막연했지만, 공부하다 보니 여러 해결 방법이 있다는 걸 알게 됐다.

  • 지금은 잘 모르겠어도 “이런 문제가 있다” 정도만 알고 있어도
  • 실무에 나가거나 프로젝트를 할 때 분명 도움이 될 거라고 생각한다.

0개의 댓글