Member와 Team이라는 두 엔티티가 있다고 가정해보자. 이 둘 사이에 JPA 연관관계가 설정되어 있다면, Member를 조회할 때 자동으로 Team도 Join 을 통해 함께 조회되도록 설정할 수 있다.
하지만 항상 Member를 조회할 때마다 Team까지 함께 조회해야 할까? 실제로는 Team 정보가 필요 없는 경우도 있을 수 있다.
이처럼 연관된 엔티티를 꼭 즉시 불러오지 않고, 실제로 필요한 시점에 조회할 수 있도록 지원하는 방식이 바로 지연로딩(Lazy Loading) 이다. 이번 글에서는 JPA에서 제공하는
즉시로딩(Eager Loading) 과 지연로딩(Lazy Loading) 전략에 대해 알아보자.
지연로딩이 적용된 연관관계 필드는 처음 조회할 때 실제 엔티티 대신 프록시 객체(Proxy) 를 반환한다. 이 프록시는 가짜 객체처럼 보이지만, 실제로는 내부에 아무 데이터도 없고, 단지 영속성 컨텍스트와 식별자(ID)만을 가지고 있다. 실제로 필드나 메서드에 접근하는 순간, 그때 DB를 조회하고 진짜 엔티티 데이터를 채워 넣는다.
즉, 지연로딩은 "연관된 엔티티를 꼭 사용할 때만 불러오자" 는 전략으로, 성능 최적화에 매우 유용하다. 대용량 데이터를 다루거나, 연관관계가 많은 복잡한 모델에서는 불필요한 조인을 줄여 성능을 높일 수 있다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "TEAM_ID")
private Team team;
위와 같이 fetch = FetchType.LAZY 를 통해 지연로딩 설정을 할 수 있다.
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();
Member m = em.find(Member.class, member1.getId());
System.out.println("m1 = " + m.getTeam().getClass());
먼저 실습을 위해 Team을 만들고, Member를 만들어 Team에 세팅해줬다.
em.find로 member1의 id를 찾아와서 해당 id로 team의 class를 찾아보면

위와 같은 프록시 객체로 출력되는걸 볼 수 있다. 추가로 em.find로 Member클래스를 조회 시 Team에 대한 조회쿼리는 발생되지 않는걸 알 수 있다.
Member m = em.find(Member.class, member1.getId());
System.out.println("m1 = " + m.getTeam().getClass());
System.out.println("==================");
m.getTeam().getName();
System.out.println("==================");
tx.commit();

위와 같이 직접 Team 엔티티의 데이터에 접근하려고 하는 시점에 Team에대한 조회쿼리가 발생한다.
여기서 궁금한 점이 하나 생긴다.
지연로딩이 설정된 연관관계는 해당 엔티티에 직접 접근하기 전까지는 실제 객체 대신 프록시 객체가 반환된다.
그리고 연관된 엔티티에 실제로 접근하는 시점 team.getName()과 같이 데이터를 조회하려고 하면,
JPA는 영속성 컨텍스트를 통해 프록시 내부를 초기화하여 실제 엔티티의 데이터를 조회하고 연결해 준다.
이 과정을 통해 이후에는 프록시를 통해 실제 엔티티에 접근할 수 있게 된다.
그럼, 프록시가 초기화된 이후에는 더 이상 프록시가 아닌 실제 엔티티가 되는 걸까?
아니면 프록시 상태는 유지되면서 내부만 초기화되는 걸까?
결론부터 말하자면, 프록시객체는 계속 프록시객체이다.
다만, 그 프록시 내부에 실제 엔티티의 데이터가 로딩되어 있을 뿐이다.
Member m = em.find(Member.class, member1.getId());
Team team = member.getTeam(); // 아직은 프록시 객체
System.out.println("m1 = " + m.getTeam().getClass());

System.out.println(team.getName()); // 이 순간 DB에서 조회 발생
만약 Member와 Team을 자주 함께 써야하는 상황이라면?
무작정 지연로딩을 사용하면 오히려 성능상 손해를 볼 수도 있다.
지연로딩과 반대되는 개념이 바로 즉시로딩(Eager Loading) 이다. 즉시로딩 은 엔티티를 조회하는 시점에 연관된 엔티티도 함께 즉시 조회하는 전략이다.
예를 들어 Member 엔티티에서 Team 을 즉시로딩으로 설정하면, Member 를 조회할 때 Team 도 즉시 조인(Join) 쿼리를 통해 함께 불러온다.
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "TEAM_ID")
private Team team;
Member m = em.find(Member.class, member1.getId());

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

프록시객체가 아닌 실제 엔티티가 출력되는걸 확인할 수 있다.
JPA의 로딩 전략은 잘 사용하면 성능 최적화에 큰 도움이 되지만, 잘못 사용하면 N+1 문제, 불필요한 쿼리, 예외 등의 문제가 발생할 수 있다. 아래는 지연로딩(프록시)과 즉시로딩을 사용할 때 각각 주의해야 할 점들이다.
N+1 문제는 JPA에서 지연로딩(LAZY) 설정과 연관관계 매핑이 함께 사용될 때 자주 발생하는 성능 이슈다.
처음 1번의 쿼리(JPQL 또는 Criteria 등)를 통해 N개의 엔티티를 조회한 후,
그 각각의 엔티티에 연관된 데이터를 조회하기 위해 추가적으로 N개의 쿼리가 발생하는 현상.
즉, 총 1 + N번의 쿼리가 나가는 구조이기 때문에 N+1 문제라고 부른다.
예를들어 Member Team 사이에 Team에 EAGER로 설정되어있는 상황이다.
List<Member> members = em.createQuery("select m from Member m", Member.class)
.getResultList();
멤버와 팀을 각각 설정해주고 실행해보면 처음에 Member 조회쿼리가 한 번 발생하고 그 이후에 Member와 연관된 Team쿼리가 멤버 수만큼 발생한다.

그럼, LAZY로 설정하면 N+1 문제를 완벽하게 방지할 수 있을까?
결론부터 말하자면 No 위처럼 단순하게 조회하는 쿼리는 추가 쿼리가 발생하지 않는다 하지만
N+1 문제의 원인은 LAZY + 반복 접근 패턴
List<Member> members = em.createQuery("select m from Member m", Member.class)
.getResultList();
for (Member m : members) {
System.out.println(m.getTeam().getName());
}
즉, 접근 시점에서 LAZY가 개별 쿼리를 유발하면서 N+1 문제가 발생한다. LAZY라고 해도 N+1문제가 생기지 않는건 아니다.
| 구분 | 설명 |
|---|---|
| EAGER | 무조건 조인해서 가져오므로 과도한 쿼리 발생 위험 |
| LAZY | 쿼리를 늦추긴 하지만, 반복 접근 시 N+1 문제 유발 가능 |
| 해결법 | Fetch Join, EntityGraph, BatchSize 등의 전략 활용 |
N+1 문제를 해결하는 가장 대표적인 방법은 Fetch Join을 사용하는 것이다.
Fetch Join은 JPA의 JPQL에서 제공하는 기능으로, 연관된 엔티티를 함께 한 번에 조회할 수 있도록 해준다.
List<Member> members = em.createQuery("select m from Member m join fetch m.team", Member.class)
.getResultList();
// 추가 쿼리 발생 안 함
for (Member m : members) {
System.out.println("username = " + m.getUsername());
System.out.println("team = " + m.getTeam().getName());
}

N+1 문제는 연관 엔티티가 즉시로딩이거나, 지연로딩 설정과 반복적인 접근이 결합될 때 발생한다.
이를 해결하기 위해서는 Fetch Join을 사용하여 연관된 엔티티를 함께 조인 조회하면 된다.
주의할 점은 Fetch Join은 성능 최적화의 중요한 도구이지만, 남용하면 불필요한 조인이 늘어날 수 있으므로 꼭 필요한 상황에만 사용하는 것이 좋다.
EntityGraph는 JPQL 없이도 연관된 엔티티를 함께 조회할 수 있게 해주는 JPA의 기능이다.
특히 Spring Data JPA와 함께 쓰면 매우 간편하게 사용 가능하다.
@NamedEntityGraph(
name = "Member.withTeam",
attributeNodes = @NamedAttributeNode("team")
)
public class Member extends BaseEntity {
public interface MemberRepository extends JpaRepository<Member, Long> {
// N+1 문제 없이 team까지 같이 조회됨
@EntityGraph(value = "Member.withTeam", type = EntityGraph.EntityGraphType.LOAD)
@Query("select m from Member m")
List<Member> findAllWithTeam();
}
List<Member> members = memberRepository.findAllWithTeam();
// 추가 쿼리 없이 동작
for (Member member : members) {
System.out.println("member: " + member.getUsername());
System.out.println("team: " + member.getTeam().getName());
}
| 방식 | 설명 | 장점 | 단점 |
|---|---|---|---|
join fetch | JPQL에 직접 fetch join 명시 | 직관적이고 강력 | 쿼리가 길어짐, 재사용 어려움 |
@EntityGraph | 선언적으로 연관 로딩 지정 | 재사용성 높음, 깔끔한 코드 | 복잡한 조인에는 한계 |