[JPA] 즉시로딩과 지연로딩 (with N+1문제)

Rose·2025년 8월 27일

JPA

목록 보기
1/1

JPA에서 엔티티를 조회할 때 연관된 엔티티를 언제 불러올지는 중요한 주제입니다. 이를 결정하는 방식이 바로 지연 로딩즉시 로딩입니다. 이번 포스팅에서는 두 로딩 방식의 동작 차이를 코드 예제와 함께 살펴보고, 여기서 자주 발생하는 N+1문제까지 연결해서 정리해보겠습니다.

1. 지연 로딩 LAZY를 사용하여 프록시로 조회

먼저 이번 예제에서 사용할 연관관계 구조를 그림으로 정리하면 다음과 같습니다.

이 구조를 코드로 매핑한 Member 엔티티는 다음과 같습니다.

Member엔티티 코드

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;

@Entity
@Getter
@Setter
public class Member extends BaseEntity {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "MEMBER_ID")
    private Long id;

    @Column(name = "USERNAME")
    private String username;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn
    private Team team;
}

위 코드는 fetch = FetchType.Lazy로 설정한 경우입니다.

실행 코드(Main)

package hellojpa;

import jakarta.persistence.*;
import org.hibernate.Hibernate;

import java.util.List;

public class JpaMain {

    public static void main(String[] args) {

        System.out.println("MARKER >>>>>>>>>>>>>>>>>>");

        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();

        EntityTransaction tx = em.getTransaction();
        tx.begin(); //트랜잭션 시작

        try {
            Team team = new Team();
            team.setName("teamA");
            em.persist(team);

            Member member1 = new Member();
            member1.setUsername("member1");
            member1.setTeam(team);

            em.persist(member1);

            em.flush();
            em.clear();

            Member m = em.find(Member.class, member1.getId());

            System.out.println("m = " + m.getTeam().getClass());

            tx.commit();
        } catch (Exception e) {
            tx.rollback();
            e.printStackTrace();
        } finally {
            em.close();
        }
        emf.close();
    }

}

Member만 먼저 조회되고(select * from MEMBER …), Team은 실제 속성 접근 시 쿼리가 나가며 초기화됩니다.
즉, “Member 조회 시 Member만 조회 / Team은 프록시로 보관 → 사용하는 순간 DB 조회”가 지연 로딩의 핵심 동작입니다.

그럼 Team은 언제 실제로 불러올까요? 🤔

아래처럼 실행 코드를 조금 바꿔서 getName()을 호출해보면, 이 시점에 프록시가 초기화되며 쿼리가 실행되는 것을 확인할 수 있습니다.

try {
            ...

            System.out.println("m = " + m.getTeam().getClass());

            System.out.println("====================");
            m.getTeam().getName(); //초기화 시점
            System.out.println("====================");

            tx.commit();
        }

team의 어떤 속성을 사용하는(실제 team을 사용하는 시점. team을 가져오는 시점이 아닙니다.) 시점에 프록시 객체가 초기화되면서 db에서 Team을 가져옵니다.

2. 즉시 로딩(EAGER)을 사용해서 함께 조회

Member와 Team을 자주 함께 사용하는 경우에는 즉시로딩(EAGER)를 사용해서 함께 조회할 수 있습니다.

    @ManyToOne(fetch = FetchType.EAGER) //지연로딩(LAZY: team을 proxy로 조회)
    @JoinColumn
    private Team team; //연관관계 주인

즉시 로딩을 사용하는 경우, em.find()를 호출하면 Member와 Team을 한 번에 직접 조회합니다.

따라서 연관 엔티티가 프록시가 아닌 실제 엔티티로 로딩됩니다.

m.getTeam().getClass()를 확인해보면 class hellojpa.Team이 출력되며, m.getTeam().getName()을 호출하면 지연 없이 바로 teamA가 출력됩니다.

즉, member1을 조회하면 곧바로 team1 엔티티까지 함께 가져옵니다.

즉시 로딩을 사용하는 경우 JPA 구현체는 다음과 같이 동작할 수 있습니다.

  • 조인을 사용하여 SQL 한 번에 함께 조회합니다.
  • 또는 두 테이블을 각각 조회하여 데이터를 채울 수 있습니다.

대부분의 경우 JPA 구현체는 가능하다면 조인을 사용해서 SQL 한 번에 조회하려고 합니다.

3. 프록시와 즉시로딩 주의

1) 실무에서는 가급적 지연 로딩만 사용해야 합니다.

즉시 로딩을 적용하면 예상하지 못한 SQL이 발생할 수 있습니다.

예를 들어, 연관된 테이블이 10개라면, 단순히 find() 한 번으로도 10개의 테이블이 전부 조인되어 성능이 급격히 떨어질 수 있습니다.

2) 즉시 로딩은 JPQL에서 N+1 문제를 일으킵니다.

즉,

  • 1번: select * from Member
  • N번: 각 Member의 Team을 가져오기 위한 select * from Team where TEAM_ID = ?

결과적으로 총 1 + N번의 쿼리가 발생하는 것이 바로 N+1 문제입니다.

예제 코드 (Member 1명, Team 1개 / EAGER 가정)

public class JpaMain {

		...
        try {
            Team team = new Team();
            team.setName("teamA");
            em.persist(team);

            Member member1 = new Member();
            member1.setUsername("member1");
            member1.setTeam(team);

            em.persist(member1);

            em.flush();
            em.clear();

			// JPQL: 루트 엔티티(Member)목록 조회
            List<Member> members = em.createQuery("select m from Member m", Member.class).getResultList();

            tx.commit();
        } 
        ...
    }

}

로그 해석

  1. 쿼리 1실행 (Member 조회)
select * from Member;

JPQL select m from Member m 가 그대로 Member 테이블 조회로 변환됩니다.

  1. 쿼리 2실행 (Team 조회)
select * from Team where TEAM_ID = xxx; //xxx에는 각 Member가 가진 TEAM_ID값이 바인딩

Member 엔티티의 team 필드가 EAGER로 지정되어 있기 때문에, 조회된 Member의 연관 Team을 즉시 초기화하기 위해 추가 SQL이 실행됩니다.

즉, Member 1번 + Team 1번 = 총 2번의 쿼리가 실행됩니다.
(여기서 Team이 프록시가 아닌 실제 엔티티로 채워집니다.)

요약

  • Member 엔티티 목록을 조회하는 JPQL 한 줄이 실제로는 Member 쿼리 1번 + Team 쿼리 N번을 발생시킵니다.
  • 이 때문에 총 1 + N번의 쿼리(N+1 문제)가 실행됩니다.
  • 즉, em.createQuery 결과 리스트를 만들 때 이미 Member와 Team이 모두 채워져 있어야 하므로 JPA가 자동으로 Team까지 가져오는 쿼리를 날리는 것입니다.

LAZY로 지정했을 경우

같은 JPQL을 실행하더라도 연관관계가 LAZY이면 처음에는 select * from Member 한 번만 실행됩니다.
Team은 실제로 getTeam().getName() 같이 Team의 속성을 사용하는 순간에 별도 쿼리가 나가며 초기화됩니다.

즉, 실제로 접근하기 전까지는 쿼리가 나가지 않는다는 점이 핵심입니다.

3) N+1문제 해결 방법

  • 모든 연관관계 기본은 지연 로딩(LAZY)로 설정
    (@ManyToOne, @OneToOne은 기본이 EAGER → 꼭 fetch = FetchType.LAZY로 변경)
  • 필요할 때만 fetch join으로 가져오기
    : Member는 LAZY지만, JPQL에서 join fetch를 사용했으므로 Member와 Team을 한 번에 조인해서 가져옵니다.
# JpaMain.java
List<Member> members = em.createQuery(
    "select m from Member m join fetch m.team", Member.class)
    .getResultList();
  • 엔티티 그래프(EntityGraph)
    : 애노테이션이나 동적 API를 통해 특정 시점에 연관 엔티티까지 함께 조회하도록 설정 가능
  • 배치사이즈 방법
    : 여러 개의 연관 엔티티를 한 번의 IN 쿼리로 묶어서 가져오도록 최적화

실무 권장 사항

  • 모든 연관관계는 기본적으로 LAZY로 설정하는 것을 추천합니다.
  • 실제로 함께 조회가 꼭 필요한 상황에서는 fetch join 또는 엔티티 그래프를 활용하는 것이 좋습니다.
  • 즉시 로딩(EAGER)은 사용하지 말 것 → 의도하지 않은 조인이나 N+1 문제가 발생할 수 있습니다.
profile
개발자를 꿈꾸며, 하루하루 쌓아가는 로제의 지식 아카이브입니다.

0개의 댓글