관리 메뉴

The Nirsa Way

[Spring Data JPA] Eager Loading vs Lazy Loading (N+1 문제 해결 방법 - JPQL Fetch Join, EntityGraph, DTO Projection) 본문

Development/Spring Data JPA

[Spring Data JPA] Eager Loading vs Lazy Loading (N+1 문제 해결 방법 - JPQL Fetch Join, EntityGraph, DTO Projection)

KoreaNirsa 2025. 7. 17. 13:21
반응형

 

Eager Loading vs Lazy Loading

Eager Loading(이하 즉시 로딩)과 Lazy Loading(이하 지연 로딩)은 연관된 엔티티를 언제 불러올 것인지에 대해 결정하게 됩니다. 각각 연관 관계에 따라 기본값을 가지는게 다릅니다. 

참고 : https://jakarta.ee/specifications/persistence/3.1/jakarta-persistence-spec-3.1.html#a15583

 


즉시 로딩(Eager Loading)

즉시 로딩은 EAGER를 사용 합니다. ManyToOne은 즉시 로딩을 기본값을 가지기 때문에 fetch를 설정하지 않아도 되지만, 예시 코드임으로 명시적으로 하기 위해 작성하였습니다.

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    private String name;

    @ManyToOne(fetch = FetchType.EAGER) // 즉시 로딩 설정
    @JoinColumn(name = "team_id")
    private Team team;

    public Team getTeam() {
        return team;
    }
}

 

즉시 로딩의 경우 연관된 엔티티를 조회할 때 join을 수행하여 로딩됩니다. 하지만 즉시 로딩의 경우 불필요한 조인이 발생하며, 로딩이 어느 시점에 실행되어야 할지 개발자가 직접 결정이 불가능하여 성능상 이슈가 발생할 가능성이 있습니다.

또한 컬렉션을 조회하게 된다면 반복문이 돌때마다 JOIN이 발생하며 양방향 연관관계일 경우 직렬화 충돌 발생 가능성이 있기 때문에 즉시 로딩은 정말 필요한 경우가 아니라면 되도록 사용하지 않는 것이 좋습니다.

public void getMemberLazy(Long id) {
    System.out.println("== findById 호출 ==");
    // 즉시 로딩 발생 시점(Team을 JOIN 하여 SELECT 실행)
    Member member = memberRepository.findById(id)
            .orElseThrow(() -> new RuntimeException("Member not found"));

    System.out.println("회원 이름: " + member.getName());

    System.out.println("팀 이름: " + member.getTeam().getName());
}

지연 로딩(Lazy Loading)

아래는 지연 로딩을 설정한 엔티티 입니다.

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    private String name;

    @ManyToOne(fetch = FetchType.LAZY) // 지연 로딩 설정
    @JoinColumn(name = "team_id")
    private Team team;

    public Team getTeam() {
        return team;
    }
}
@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;

    private String name;
}

 

지연 로딩의 경우 해당 연관된 엔티티를 사용할 때 로딩이 되는데, 아래의 시점에서는 팀 이름을 호출 할 때(연관된 엔티티 Team이 사용될 때) 로딩되어 SELECT가 발생합니다.

public void getMemberLazy(Long id) {
    System.out.println("== findById 호출 ==");
    Member member = memberRepository.findById(id)
            .orElseThrow(() -> new RuntimeException("Member not found"));

    System.out.println("회원 이름: " + member.getName());

    // 지연 로딩 발생 시점(Team SELECT 실행)
    System.out.println("팀 이름: " + member.getTeam().getName());
}

 

이러한 동작 방식으로 인해 대표적인 문제가 N+1 문제입니다. 지연 로딩은 연관된 엔티티를 사용할 때 로딩되므로 만약, 연관된 엔티티가 여러 번 반복적으로 호출된다면 추가적인 N개의 쿼리가 발생합니다. 

public void getMemberLazy(Long id) {
    System.out.println("== findById 호출 ==");
    Member member = memberRepository.findById(id)
            .orElseThrow(() -> new RuntimeException("Member not found"));

    System.out.println("회원 이름: " + member.getName());

    for (Member member : members) {
        System.out.println("팀 이름 : "member.getTeam().getName()); // 여기서 N개의 추가 쿼리 발생
    }
}

 


지연 로딩의 N+1 문제 해결 (1) - JPQL fetch join

지연 로딩의 N+1 문제를 해결하는 가장 대표적인 방법은 JPQL fetch join을 사용하여 한번의 쿼리로 연관된 데이터까지 함께 가져오는 방식을 많이 사용합니다. repository에 아래와 같은 JPQL을 작성하여 한번의 호출로 관련된 데이터까지 한번에 호출하는 방식입니다.

아래의 쿼리는 team으로 fetch join을 사용하되 member (m) 테이블의 id와 매개변수로 받은 id가 일치할 때 가져오는 JPQL입니다.

@Query("SELECT m FROM Member m JOIN FETCH m.team WHERE m.id = :id")
Member findMemberWithTeam(@Param("id") String id);
※ Fetch Join 이란?
연관된 엔티티를 JOIN FETCH를 사용하여 함꼐 조회하는 JPQL 문법으로써 객체 그래프 탐색을 위해 SQL을 따로 실행하지 않고 미리 JOIN하여 가져오도록 하는 문법

지연 로딩의 N+1 문제 해결 (2) - @EntityGraph

@EntityGraph는 Spring Data JPA에서 지원하는 어노테이션으로써 findAll()과 같은 기본적으로 제공해주는 메서드에 적용이 가능합니다. EntityGraph가 내부적으로 fetch join을 사용하여 동작합니다

하지만 Spring Data JPA에서 지원하는 어노테이션이다보니 직접 SQL에 관여하여 튜닝이 불가능하기에 간단히 사용할때는 좋지만, 조금 더 복잡한 쿼리를 작성해야 할 때는 JPQL fetch join을 쓰는 것이 더 좋습니다.

@EntityGraph(attributePaths = {"team"})
List<Member> findAll();

지연 로딩의 N+1 문제 해결(3) - DTO 직접 조회(DTO Projection)

JPQL에서 DTO를 직접 사용하는 방식은 원하는 필드만 선택적으로 조회할 수 있기 때문에 성능 상 이점을 가져가며 불필요한 영속성 컨텍스트 관리를 피할 수 있어 읽기(select) 전용에 유리합니다.

단, 패키지 명과 DTO 및 필드명을 직접 작성해주어야 한다는 단점이 존재합니다.

@Query("SELECT new com.example.dto.MemberTeamDTO(m.name, t.name) " +
       "FROM Member m JOIN m.team t")
List<MemberTeamDTO> findMemberTeamDTOs();
public class MemberTeamDTO {
    private String memberName;
    private String teamName;

    public MemberTeamDTO(String memberName, String teamName) {
        this.memberName = memberName;
        this.teamName = teamName;
    }
}
반응형