JPA/Hibernate N+1 문제 진단 및 실무에서의 쿼리 성능 개선 전략

지난 포스팅에서는 데이터베이스와 애플리케이션 사이의 통로 역할을 하는 커넥션 풀(HikariCP)의 최적화 방법을 알아보았습니다. 하지만 통로를 아무리 넓게 뚫어놓아도, 한 번에 끝낼 수 있는 심부름을 백 번, 천 번에 걸쳐서 한다면 효율은 바닥으로 떨어질 것입니다.

현대의 Java 백엔드 생태계에서는 SQL을 직접 작성하기보다 JPA(Hibernate)와 같은 ORM(Object-Relational Mapping) 기술을 주로 사용합니다. 생산성은 비약적으로 높아졌지만, ORM의 동작 원리를 정확히 이해하지 못하면 데이터베이스를 무너뜨리는 치명적인 성능 저하를 유발하게 됩니다. 그중 가장 대표적인 악명 높은 이슈가 바로 ‘N+1 문제’입니다. 이번 글에서는 N+1 문제가 무엇인지 진단하고, 실무에서 이를 우아하게 해결하는 성능 개선 전략을 알아보겠습니다.

1. 성능 저하의 주범: N+1 문제란 무엇인가?

N+1 문제란, ‘한 번의 쿼리로 N개의 데이터를 가져왔을 때, 그 데이터들과 연관된 하위 데이터를 가져오기 위해 N번의 추가 쿼리가 발생하는 현상’을 말합니다.

예를 들어, ‘회원(Member)’과 회원이 소속된 ‘팀(Team)’ 엔티티가 있다고 가정해 보겠습니다. 게시판에서 10명의 회원 목록을 조회하기 위해 SELECT * FROM Member라는 쿼리 1번(1)을 날렸습니다. 그런데 화면에 회원의 이름과 함께 ‘소속 팀 이름’을 출력하려고 로직을 실행하는 순간, 10명의 회원 각각의 팀 정보를 가져오기 위해 SELECT * FROM Team WHERE member_id = ?라는 쿼리가 10번(N) 추가로 날아가게 됩니다.

데이터가 10건일 때는 표가 나지 않지만, 만약 대량의 데이터베이스 쿼리 리스트를 조회해 JSON 파일로 내려주는 배치성 작업에서 10만 건의 데이터를 조회한다면? 단 한 번의 API 호출에 10만 1번의 쿼리가 DB에 폭격처럼 쏟아지며 서버와 DB 모두가 뻗어버리게 됩니다.

2. 지연 로딩(Lazy Loading)과 즉시 로딩(Eager Loading)의 함정

많은 초보 개발자들이 JPA의 글로벌 페치 전략을 EAGER(즉시 로딩)로 설정하면 한 번에 쿼리를 조인(Join)해서 가져올 것이라 오해합니다. 하지만 JPQL은 개발자가 작성한 쿼리 문맥 자체를 먼저 번역하기 때문에, 일단 Member를 조회하는 쿼리를 날린 후, 연관된 Team이 EAGER로 설정된 것을 보고 급하게 연관 쿼리를 N번 더 날리게 됩니다.

따라서 실무에서의 대원칙은 “모든 연관 관계는 반드시 지연 로딩(LAZY)으로 설정한다”입니다. 지연 로딩은 팀 정보가 실제로 화면에 쓰일 때까지 쿼리 실행을 늦추는(프록시 객체 반환) 방법입니다. 하지만 이 역시 N+1 문제의 근본적인 해결책은 아닙니다. 지연 로딩 상태에서도 결국 연관된 데이터를 꺼내 쓰기 위해 루프(Loop)를 도는 순간 N+1 문제는 어김없이 발생합니다.

3. 실무적인 해결 전략: Fetch Join과 EntityGraph

N+1 문제를 해결하는 가장 확실하고 널리 쓰이는 정석은 미리 조인(Join)하여 한 번의 쿼리로 연관 데이터까지 모두 가져오는 것입니다.

  • Fetch Join (페치 조인): JPQL을 작성할 때 join fetch 키워드를 사용하는 방법입니다. SELECT m FROM Member m JOIN FETCH m.team 이렇게 작성하면 JPA는 내부적으로 일반 조인이 아닌 SQL의 INNER JOIN (또는 OUTER JOIN)을 수행하여, Member와 Team의 데이터를 단 한 번의 쿼리로 메모리에 모두 끌고 올라옵니다.
  • @EntityGraph: Spring Data JPA를 사용할 때, 별도의 JPQL을 길게 작성하지 않고 어노테이션만으로 페치 조인과 동일한 효과를 내는 방법입니다. @EntityGraph(attributePaths = {"team"})처럼 설정하면 손쉽게 연관 객체를 한 번에 로드할 수 있습니다.

4. 페이징(Paging) 처리와 컬렉션 Fetch Join의 무서운 경고

하지만 페치 조인(Fetch Join)이 만능은 아닙니다. 가장 주의해야 할 함정이 바로 ‘컬렉션(OneToMany 관계)에 대한 페치 조인과 페이징 처리를 함께 사용할 때’입니다.

하나의 팀(Team)이 여러 명의 회원(List)을 가지는 1:N 관계에서 10건씩 페이징을 하려고 join fetchsetMaxResults(10)을 함께 사용하면, JPA는 데이터베이스 단위에서 페이징을 하지 않고 경고 로그(WARN)를 띄우며 모든 데이터를 몽땅 메모리에 끌고 올라와 애플리케이션 단에서 메모리 페이징을 수행해 버립니다. 이는 치명적인 OutOfMemory(OOM)의 직격탄이 됩니다.

이러한 컬렉션 페이징 N+1 문제를 해결하기 위해 실무에서는 페치 조인 대신 @BatchSize (또는 hibernate.default_batch_fetch_size)를 사용합니다. 이 옵션을 100~1000 정도로 설정하면, JPA가 N번 날릴 쿼리를 SQL의 IN 절로 묶어서 단 1번의 쿼리(예: WHERE team_id IN (?, ?, ...))로 최적화하여 훌륭하게 해결해 줍니다.

결론: Hibernate SQL 로그 확인은 필수다

N+1 문제는 에러를 뱉지 않는 ‘침묵의 살인자’입니다. 애플리케이션 코드는 정상적으로 작동하기 때문에 로그를 확인하지 않으면 운영 서버에 배포되기 전까지 눈치채기 힘듭니다. 개발 환경에서는 반드시 spring.jpa.show-sql=true 옵션을 켜두고, 내가 의도한 1번의 API 호출이 진짜 1번의 쿼리로 끝나는지 콘솔 창을 모니터링하는 습관을 들여야 합니다.

데이터베이스 I/O를 최소화하는 방법을 배웠으니, 다음 포스팅에서는 아예 데이터베이스까지 가지 않도록 서버 부하를 획기적으로 줄여주는 기술, ‘[23번] 캐시(Cache) 시스템 도입을 통한 Java 백엔드 서버 부하 감소 기법’에 대해 본격적으로 알아보겠습니다.

댓글 남기기