
백엔드 개발을 하다 보면 SQL을 직접 짜는 것보다 객체를 다루는 것에 집중하고 싶을 때가 많습니다. 이때 등장하는 것이 바로 ORM입니다. 하지만 편한 만큼 "N+1 문제"라는 성능 함정이 기다리고 있죠.
많은 분들이 "지연 로딩(Lazy Loading)을 쓰면 해결되는 거 아니야?"라고 오해하시곤 하는데, 오늘은 그 진실까지 포함하여 N+1 문제의 원인과 해결 방법을 완벽하게 파헤쳐 보겠습니다.
ORM은 Object(객체)와 Relational(관계형 데이터베이스)의 데이터를 자동으로 매핑(연결)해주는 프레임워크를 말합니다.
우리는 객체 지향 언어(Java, Python 등)를 쓰지만, 데이터베이스는 관계형(MySQL, Oracle 등)을 주로 사용합니다. 이 둘은 근본적인 패러다임이 다릅니다. (상속, 참조 등)
ORM은 이 "패러다임의 불일치(Impedance Mismatch)"를 해결해 주는 "통역사" 역할을 합니다.
Java 진영에서는 ORM의 기술 표준으로 JPA를 사용합니다.
💡 개념 잡기
JPA는 인터페이스(Interface)이고, Hibernate는 구현체(Implementation)입니다.
JPA는 "자바 어플리케이션에서 관계형 데이터베이스를 어떻게 사용할지 정의한 명세(Spec)"이고, 이를 실제로 코드로 구현한 가장 대표적인 프레임워크가 Hibernate입니다.
JPA를 사용하면서 겪는 가장 대표적인 성능 이슈입니다.
조회 시 1개의 쿼리를 날렸는데, 연관된 데이터를 가져오기 위해 N개의 추가 쿼리가 발생하는 현상
예를 들어, Team(팀)과 Member(멤버)가 1:N 관계라고 가정해 봅시다.
"팀 목록을 조회하고, 각 팀에 속한 멤버들의 이름을 출력하고 싶다"는 요구사항이 있습니다.
// 1. 팀 전체 조회 (SELECT * FROM Team) -> 쿼리 1번 발생
List<Team> teams = teamRepository.findAll();
for (Team team : teams) {
// 2. 각 팀의 멤버 목록에 접근
// 여기서 팀의 개수(N)만큼 멤버를 조회하는 쿼리가 추가로 나갑니다.
System.out.println("멤버 수: " + team.getMembers().size());
}
만약 팀이 100개라면?
findAll()로 팀 조회: 1번많은 초보 개발자분들이 "FetchType.EAGER(즉시 로딩)라서 발생한 거니까, FetchType.LAZY(지연 로딩)로 바꾸면 해결되겠지?"라고 생각합니다.
결론부터 말하면 해결되지 않습니다. 발생 시점만 다를 뿐입니다.
| 구분 | EAGER (즉시 로딩) | LAZY (지연 로딩) |
|---|---|---|
| 발생 시점 | findAll() 하는 순간 즉시 발생 | 객체를 조회(getMembers()) 하는 순간 발생 |
| 동작 | 팀을 가져오면서, 연관된 멤버 데이터를 무조건 다 가져옴 | 팀만 가져오고, 멤버는 가짜 객체(Proxy)로 채워둠 |
| 결과 | N+1 발생 | 루프를 돌며 데이터를 꺼낼 때 N+1 발생 |
즉, Lazy Loading은 "필요 없는 데이터를 안 가져와서" 성능을 최적화하는 것이지, "필요한 데이터를 가져올 때 발생하는 N+1 문제"를 막아주는 기술은 아닙니다.
그렇다면 진짜 해결책은 무엇일까요?
핵심은 "필요한 데이터를 처음부터 한 번에(Join) 가져오는 것"입니다.
JPQL을 사용하여, 조회를 할 때 연관된 데이터까지 한 번의 쿼리로(Inner Join) 묶어서 가져오는 방법입니다.
public interface TeamRepository extends JpaRepository<Team, Long> {
// 일반적인 join이 아니라 'join fetch'를 사용
@Query("select t from Team t join fetch t.members")
List<Team> findAllWithMembers();
}
이렇게 하면 루프를 돌 때 이미 members 데이터가 메모리에 로딩되어 있으므로 추가 쿼리가 나가지 않습니다. (쿼리 1방으로 해결)
JPQL 작성이 번거로울 때 Spring Data JPA의 기능을 활용합니다. attributePaths에 같이 가져올 필드명을 적어줍니다.
public interface TeamRepository extends JpaRepository<Team, Long> {
@EntityGraph(attributePaths = {"members"})
List<Team> findAll();
}
내부적으로 Outer Left Join을 사용하여 가져옵니다.
Fetch Join을 쓰기 애매하거나, 페이징 처리가 필요할 때 유용한 옵션입니다. IN 쿼리를 사용하여 쿼리 개수를 획기적으로 줄입니다.
# application.yml
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 1000 # 한 번에 in query로 가져올 개수
이 설정을 하면 100개의 팀 멤버를 조회할 때, 100번의 쿼리가 아니라
SELECT * FROM Member WHERE team_id IN (1, 2, ... 100) 처럼 1번의 쿼리로 묶어서 가져옵니다.
무조건적인 Lazy Loading 설정에 안심하지 말고, 실제 쿼리가 어떻게 나가는지 로그를 확인하는 습관을 들여야합니다🙏