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

이 구조를 코드로 매핑한 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로 설정한 경우입니다.
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을 가져옵니다.

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 구현체는 다음과 같이 동작할 수 있습니다.
대부분의 경우 JPA 구현체는 가능하다면 조인을 사용해서 SQL 한 번에 조회하려고 합니다.
즉시 로딩을 적용하면 예상하지 못한 SQL이 발생할 수 있습니다.
예를 들어, 연관된 테이블이 10개라면, 단순히 find() 한 번으로도 10개의 테이블이 전부 조인되어 성능이 급격히 떨어질 수 있습니다.
즉,
결과적으로 총 1 + N번의 쿼리가 발생하는 것이 바로 N+1 문제입니다.
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();
}
...
}
}

select * from Member;
JPQL select m from Member m 가 그대로 Member 테이블 조회로 변환됩니다.
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까지 가져오는 쿼리를 날리는 것입니다.
같은 JPQL을 실행하더라도 연관관계가 LAZY이면 처음에는 select * from Member 한 번만 실행됩니다.
Team은 실제로 getTeam().getName() 같이 Team의 속성을 사용하는 순간에 별도 쿼리가 나가며 초기화됩니다.
즉, 실제로 접근하기 전까지는 쿼리가 나가지 않는다는 점이 핵심입니다.
- 모든 연관관계 기본은 지연 로딩(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 쿼리로 묶어서 가져오도록 최적화