[JPA] ORM이란? N+1 문제의 원인과 해결법 (Feat. Lazy Loading의 진실)

sammy·2026년 1월 26일

Dev Knowledge

목록 보기
3/12
post-thumbnail

백엔드 개발을 하다 보면 SQL을 직접 짜는 것보다 객체를 다루는 것에 집중하고 싶을 때가 많습니다. 이때 등장하는 것이 바로 ORM입니다. 하지만 편한 만큼 "N+1 문제"라는 성능 함정이 기다리고 있죠.

많은 분들이 "지연 로딩(Lazy Loading)을 쓰면 해결되는 거 아니야?"라고 오해하시곤 하는데, 오늘은 그 진실까지 포함하여 N+1 문제의 원인과 해결 방법을 완벽하게 파헤쳐 보겠습니다.


1. ORM (Object-Relational Mapping)이란?

ORMObject(객체)Relational(관계형 데이터베이스)의 데이터를 자동으로 매핑(연결)해주는 프레임워크를 말합니다.

우리는 객체 지향 언어(Java, Python 등)를 쓰지만, 데이터베이스는 관계형(MySQL, Oracle 등)을 주로 사용합니다. 이 둘은 근본적인 패러다임이 다릅니다. (상속, 참조 등)
ORM은 이 "패러다임의 불일치(Impedance Mismatch)"를 해결해 주는 "통역사" 역할을 합니다.

ORM의 장점

  • 생산성 향상: SQL 쿼리를 직접 작성하지 않고, 메서드 호출만으로 CRUD가 가능합니다.
  • 유지보수 용이: DB 스키마가 바뀌어도 코드 수정이 상대적으로 적습니다.
  • 객체 지향적 코드: DB에 종속되지 않고 비즈니스 로직에 집중할 수 있습니다.

대표적인 ORM 종류

  • Java: JPA (Hibernate)
  • Python: Django ORM
  • Node.js: Sequelize, TypeORM

2. JPA (Java Persistence API)

Java 진영에서는 ORM의 기술 표준으로 JPA를 사용합니다.

💡 개념 잡기
JPA는 인터페이스(Interface)이고, Hibernate는 구현체(Implementation)입니다.

JPA는 "자바 어플리케이션에서 관계형 데이터베이스를 어떻게 사용할지 정의한 명세(Spec)"이고, 이를 실제로 코드로 구현한 가장 대표적인 프레임워크가 Hibernate입니다.


3. N+1 문제 (N+1 Problem) 🚨

JPA를 사용하면서 겪는 가장 대표적인 성능 이슈입니다.

정의

조회 시 1개의 쿼리를 날렸는데, 연관된 데이터를 가져오기 위해 N개의 추가 쿼리가 발생하는 현상

예를 들어, Team(팀)Member(멤버)가 1:N 관계라고 가정해 봅시다.
"팀 목록을 조회하고, 각 팀에 속한 멤버들의 이름을 출력하고 싶다"는 요구사항이 있습니다.

상황 예시 (Code)

// 1. 팀 전체 조회 (SELECT * FROM Team) -> 쿼리 1번 발생
List<Team> teams = teamRepository.findAll();

for (Team team : teams) {
    // 2. 각 팀의 멤버 목록에 접근
    // 여기서 팀의 개수(N)만큼 멤버를 조회하는 쿼리가 추가로 나갑니다.
    System.out.println("멤버 수: " + team.getMembers().size()); 
}

만약 팀이 100개라면?

  1. findAll()로 팀 조회: 1번
  2. 루프를 돌며 각 팀의 멤버 조회: 100번 (N)
  3. 총 쿼리 수: 101번 (1 + N) -> 성능 폭락의 주범

4. 잠깐! Lazy Loading(지연 로딩)이 해결책이 될까? ❌

많은 초보 개발자분들이 "FetchType.EAGER(즉시 로딩)라서 발생한 거니까, FetchType.LAZY(지연 로딩)로 바꾸면 해결되겠지?"라고 생각합니다.

결론부터 말하면 해결되지 않습니다. 발생 시점만 다를 뿐입니다.

EAGER vs LAZY 동작 비교

구분EAGER (즉시 로딩)LAZY (지연 로딩)
발생 시점findAll() 하는 순간 즉시 발생객체를 조회(getMembers()) 하는 순간 발생
동작팀을 가져오면서, 연관된 멤버 데이터를 무조건 다 가져옴팀만 가져오고, 멤버는 가짜 객체(Proxy)로 채워둠
결과N+1 발생루프를 돌며 데이터를 꺼낼 때 N+1 발생

즉, Lazy Loading은 "필요 없는 데이터를 안 가져와서" 성능을 최적화하는 것이지, "필요한 데이터를 가져올 때 발생하는 N+1 문제"를 막아주는 기술은 아닙니다.

그렇다면 진짜 해결책은 무엇일까요?


5. N+1 문제 진짜 해결 방법 🛠️

핵심은 "필요한 데이터를 처음부터 한 번에(Join) 가져오는 것"입니다.

해결책 1: Fetch 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방으로 해결)

해결책 2: @EntityGraph

JPQL 작성이 번거로울 때 Spring Data JPA의 기능을 활용합니다. attributePaths에 같이 가져올 필드명을 적어줍니다.

public interface TeamRepository extends JpaRepository<Team, Long> {

    @EntityGraph(attributePaths = {"members"})
    List<Team> findAll();
}

내부적으로 Outer Left Join을 사용하여 가져옵니다.

해결책 3: Batch Size (보완책)

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번의 쿼리로 묶어서 가져옵니다.


6. 결론

  1. ORM/JPA는 생산성을 높여주지만 N+1 문제를 주의해야 한다.
  2. Lazy Loading은 N+1 문제의 해결책이 아니다. (발생 시점만 뒤로 미룰 뿐)
  3. 해결 방법:
  • Fetch Join을 사용하여 한방 쿼리로 가져온다. (Best)
  • Batch Size를 설정하여 쿼리 횟수를 최적화한다.

무조건적인 Lazy Loading 설정에 안심하지 말고, 실제 쿼리가 어떻게 나가는지 로그를 확인하는 습관을 들여야합니다🙏

profile
누군가에게 도움을 주기 위한 개발자로 성장하고 싶습니다.

0개의 댓글