JPA 즉시로딩과 지연로딩

임준영·2021년 4월 20일
0

JPA

목록 보기
7/12
post-thumbnail

즉시로딩과 지연로딩

JPA에서 연관관계를 조회할때 가장 중요한 글로벌 패치 전략인 즉시로딩(EAGER)지연로딩(LAZY)에 대해서 살펴보겠습니다.

Member member = em.find(Member.class, "member1");
Team team = member.getTeam(); // 객체 그래프 탐색
System.out.println(team.getName()); // 팀 엔티티 사용

위와 같이 회원 엔티티를 조회할때 조회 시점을 선택할 수 있도록 다음 두 가지 방법을 제공합니다.

  • 즉시 로딩: 엔티티를 조회할 때 연관된 엔티티도 함께 조회합니다.
    em.find(Member.class, "member1")를 호출할 때 회원 엔티티와 연관된 팀 엔티티도 함께 조회합니다.

    • 설정방법: @ManyToOne(fetch = FetchType.EAGER)
  • 지연 로딩: 연관된 엔티티를 실제 사용할 때 조회합니다.
    member.getTeam().getName()처럼 조회한 팀 엔티티를 실제 사용하는 시점에 JPA가 SQL을 호출해서 팀 엔티티를 조회합니다.

    • 설정방법: @ManyToOne(fetch = FetchType.LAZY)

1. 즉시로딩

즉시 로딩을 사용하려면 @ManyToOne의 fetch 속성을 FetchType.EAGER로 지정합니다.

Member.java

@Entity
public class Member {

    @Id
    @Column(name = "member_id")
    private String id;

    private String name;

    private int age;

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "team_id")
    private Team team;
}

//즉시 로딩 실행 코드
Member member = em.find(Member.class, "member1");
Team team = member.getTeam(); // 객체 그래프 탐색

위의 예제코드에서는 회원과 팀을 즉시 로딩으로 설정했습니다. 따라서 em.find(Member.class, "member1")로 회원을 조회하는 순간 팀도 함께 조회합니다. 이때 회원과 팀 두 테이블을 조회해야 하므로 쿼리를 2번 수행할 것 같지만, 대부분의 JPA 구현체는 즉시 로딩을 최적화 하기 위해 가능하면 조인 쿼리를 사용합니다. 여기서는 회원과 팀을 조인해서 쿼리 한번으로 두 엔티티를 모두 조회합니다.

즉시로딩(fetch = FetchType.EAGER)

실제로 JPA가 left outer join 쿼리문을 날려서 회원과 팀을 한 번으로 조회한 것을 알 수 있습니다.
이후 member.getTeam()을 호출하면 이미 로딩된 팀1 엔티티를 반환합니다.

NULL 제약조건과 JPA 전략
즉시 로딩 실행 SQL에서 JPA가 내부 조인이 아닌 외부 조인을 사용하고 있습니다. 그 이유는 현재 회원 테이블에서 TEAM_ID 외래 키는 NULL 값을 허용하고 있습니다. 따라서 팀에 소속되지 않는 회원이 존재할 가능성이 있기 때문에 팀에 소속되지 않는 회원과 팀을 내부조인하면 팀은 물론이고 회원 데이터도 조회할 수 없습니다. JPA는 이런 상황을 고려해서 외부 조인을 사용합니다. 하지만 성능상으로 외부조인보다 내부조인이 최적화에 더 유리합니다. 따라서 외래키에 NOT NULL 제약 조건을 설정하면 값이 있는 것을 보장하기 때문에 이런 경우에는 내부조인만 사용해도 됩니다.

@Entity
public class Member { 

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "TEAM_ID", nullable = false)
    private Team team;
    //...
}

nullable 설정에 따른 조인 전략

  • @JoinColumn(nullable = true): NULL 허용(기본값), 외부 조인 사용
  • @JoinColumn(nullable = false): NULL 허용하지 않음, 내부 조인 사용

또는 아래 코드 처럼 내부 조인을 사용할 수 있습니다.

@Entity
public class Member{

    @ManyToOne(fetch = FetchType.EAGER, optional = false)
    @JoinColumn(name = "TEAM_ID", nullable = false)
    private Team team;
    //...
}

JPA는 선택적 관계이면 외부 조인을 사용하고 필수 관계면 내부 조인을 사용합니다.

내부 조인 결과

2. 지연로딩

지연 로딩을 사용하면 @ManyToOne의 fetch 속성을 FetchType.LAZY로 지정합니다.

// 지연로딩 설정
@Entity
public class Member {

    @Id
    @Column(name = "member_id")
    private String id;

    private String name;

    private int age;

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


//지연 로딩 실행 코드
Member member = em.find(Member.class, "member1");
Team team = member.getTeam(); // 객체 그래프 탐색
team.getName(); // 팀 객체 실제 사용

위의 코드에서는 회원과 팀을 지연 로딩으로 설정했습니다. 따라서 예제코드에서 em.find(Member.class, "member1")를 호출하면 회원만 조회하고 팀은 조회하지 않습니다. 대신에 team 맴버변수에 프록시 객체를 넣어 둡니다.

Team team = member.getTeam(); // 프록시 객체 

반환된 팀 객체는 프록시 객체입니다. 이 프록시 객체는 실제 사용될 때까지 데이터 로딩을 미룹니다. 그래서 지연로딩이라고 합니다.

team.getName() // 팀 객체 실제 사용

위의 코드처럼 실제 데이터가 필요한 순간이 되어서야 데이터베이스를 조회해서 프록시 객체를 초기화 합니다.

em.find(Member.class, "member1") 호출 시 실행되는 SQL은 다음과 같습니다.

SELECT * FROM MEMBER WHERE MEMBER_ID = 'member1';

team.getName() 호출로 프록시 객체가 초기화되면서 실행되는 SQL은 다음과 같습니다.

SELECT * FROM TEAM WHERE TEAM_ID = 'team1';

조회 대상이 영속성 컨텍스트에 이미 있으면 프록시 객체를 사용할 이유가 없습니다. 따라서 프록시가 아닌 실제 객체를 사용합니다.

즉시로딩과 지연로딩 중에 어느것이 더 효율적인가에 대해서는 아무래도 프로젝트 상황에 따라 다를거 같습니다. 애플리케이션 로직에서 회원과 팀 엔티티를 같이 사용한다고 하면 SQL 조인을 사용해서 회원과 팀 엔티티를 한번에 조회하는 것이 더 효율적입니다. 페이스북에서 댓글 더보기 기능처럼 실제로 조회가 필요할때 게시물과 연관된 댓글을 조회하는것도 대표적인 지연로딩 방식입니다.

지연 로딩 활용

사내 주문 관리 시스템을 개발한다고 가정하겠습니다.

  • 회원은 팀 하나에만 소속할 수 있습니다. (N:1)
  • 회원은 여러 주문내역을 가집니다. (1:N)
  • 주문내역은 상품정보를 가집니다. (N:1)

애플리케이션 로직을 분석해보니 다음과 같았습니다.

  • Member와 연관된 Team은 자주 함께 사용되었습니다. 그래서 Member와 Team은 즉시 로딩으로 설정했습니다.

  • Member와 연관된 Order는 가끔 사용되었습니다. 그래서 Member와 Order는 지연 로딩으로 설정했습니다.

  • Order와 연관된 Product는 자주 함께 사용되었습니다. 그래서 Order와 Product는 즉시 로딩으로 설정했습니다.

회원 엔티티

@Entity
@Setter
@Getter
public class Member {

    @Id
    @Column(name = "member_id")
    private String id;

    private String name;

    private int age;

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "team_id")
    private Team team;

    @OneToMany(mappedBy = "member",fetch = FetchType.LAZY)
    List<Order> orders;
}

회원과 팀의 연관관계를 FetchType.EAGER로 설정했습니다. 따라서 회원 엔티티를 조회하면 연관된 팀 엔티티도 즉시 조회합니다.

회원과 주문내역의 연관관계를 FetchType.LAZY로 설정했습니다. 따라서 회원 엔티티를 조회하면 연관된 주문내역 엔티티는 프록시를 조회해서 실제 사용될 때까지 로딩을 지연합니다.

Member member = em.find(Member.class, "member1");

회원 엔티티를 조회하면 아래 그림처럼 엔티티를 로딩합니다.

회원과 팀은 즉시 로딩으로 설정했습니다. 따라서 회원을 조회할 때 하이버네이트는 조인 쿼리를 만들어 회원과 팀을 한 번에 조회합니다.
반면에 회원과 주문내역은 FetchType.LAZY로 설정했으므로 결과를 프록시로 조회합니다. 따라서 SQL문에 전혀 나타나지 않습니다.
회원을 조회한 후에 member.getTeam()을 호출하면 이미 로딩된 팀 엔티티를 반환합니다.

3. Proxy와 컬렉션 래퍼

하이버네이트는 엔티티를 영속 상태로 만들 때 엔티티에 컬렉션이 있으면 컬렉션을 추적하고 관리할 목적으로 원본 컬렉션을 하이버네이트가 제공하는 내장 컬렉션으로 변경하는데 이것을 컬렉션 래퍼라고 합니다. 출력 결과를 보면 컬렉션 래퍼인 org.hibernate.collection.internal.PersistenceBag 이 반환된 것을 확인할 수 있습니다.

엔티티를 지연 로딩하면 프록시 객체를 사용해서 지연로딩을 수행하지만 주문 내역과 같은 컬렉션은 컬렉션 래퍼가 지연 로딩을 처리해줍니다. 컬랙션 래퍼도 컬렉션에 대한 프록시 역할을 하므로 따로 구분하지 않고 프록시로 부르겠습니다.

meber.getOrders()를 호출해도 컬렉션은 초기화되지 않습니다. 컬렉션은 member.getOrders().get(0)처럼 컬렉션에 실제 데이터를 조회할 때 데이터베이스를 조회해서 초기화 합니다.

4. JPA 기본 페치 전략

fetch 속성의 기본 설정값은 아래와 같습니다.

  • @ManyToOne, @OneToOne: 즉시 로딩(FetchType.EAGER)
  • @OneToMany, @ManyToMany: 지연 로딩(FetchType.LAZY)

가장 쉽게 외우는 방법은 xToOne으로 매핑된 엔티티는 글로벌 페치 전략이 즉시 로딩이라고 생각하면 됩니다. 그러면 나머지 xToMany로 끝나는 어노테이션은 자연스럽게 LAZY로 인식이 됩니다.

JPA 기본 페치 전략은 연관된 엔티티가 하나면 즉시 로딩을, 컬렉션이면 지연로딩을 사용합니다. 컬렉션을 로딩하는 것은 비용이 많이 들고 잘못하면 너무 많은 데이터를 로딩할 수 있기 때문입니다. 예를 들어 특정 회원이 연관된 컬렉션에 데이터를 수만 건 등록했는데, 설정한 페치 전략이 즉시 로딩이면 해당 회원을 로딩하는 순간 수만 건의 데이터도 함께 로딩됩니다. 반면에 연관된 엔티티가 하나면 즉시 로딩해도 큰 문제가 발생하지 않습니다.

이 책에서 가장 추천하는 방법은 모든 연관관계에 지연 로딩을 사용하는 것입니다.
그리고 애플리케이션 개발이 어느 정도 완료단계에 왔을 때 실제 사용하는 상황을 보고 꼭 필요한 곳에만 즉시 로딩을 사용하도록 최적화하면 됩니다.

참고로 직접 SQL을 사용하면 이런 유연한 최적화가 어렵습니다. 예를 들어 SQL로 각각의 테이블을 조회해서 처리하다가 조인으로 한 번에 조회하도록 변경하려면 많은 SQL과 애플리케이션 코드를 수정해야 합니다.

컬렉션에 FetchType.EAGER 사용 시 주의점

  • 컬렉션을 하나 이상 즉시 로딩하는 것은 권장하지 않습니다.

컬렉션과 조인한다는 것은 데이터베이스 테이블로 보면 일대다 조인입니다. 일대다 조인은 결과 데이터가 다 쪽에 있는 수만큼 증가하게 됩니다. 문제는 서로 다른 컬렉션을 2개 이상 조인할 때 발생하는데 예를 들어 A 테이블을 N,M 두 테이블과 일대다 조인하면 실행 결과가 N 곱하기 M이 되면서 너무 많은 데이터를 반환할 수 있고 결과적으로 애플리케이션 성능이 저하될 수 있습니다. JPA는 이렇게 조회된 결과를 메모리에서 필터링해서 반환합니다. 따라서 2개 이상의 컬렉션을 즉시 로딩으로 설정하는 것은 권장되지 않습니다.

  • 컬렉션 즉시 로딩은 항상 외부조인을 사용합니다.

예를 들어 다대일 관계인 회원 테이블과 팀 테이블을 조인할 때 회원 테이블의 외래 키에 not null 제약조건을 걸어두면 모든 회원은 팀에 소속되므로 항상 내부 조인을 사용해도 됩니다. 반대로 팀 테이블에서 회원 테이블로 일대다 관계를 조인할 때 회원이 한 명도 없는 팀을 내부 조인하면 팀까지 조회되지 않는 문제가 발생합니다. 데이터베이스 제약조건으로 이런 상황을 막을 수는 없습니다. 따라서 JPA는 일대다 관계를 즉시 로딩할 때 항상 외부 조인을 사용합니다.

FetchType.EAGER 설정과 조인 전략을 정리하면 다음과 같습니다.

@ManyToOne, @OneToOne

  • (optional = false): 내부 조인
  • (optional = true): 외부 조인

@OneToMany, @ManyToMany

  • (optional = false): 외부 조인
  • (optional = true): 외부 조인

참고 문헌: ORM 표준 JPA 프로그래밍

0개의 댓글