본문 바로가기

Spring boot

[ JPA ] Eager, Lazy Fetching

728x90
반응형

Spring Data JPA에 @ManyToOne(N:1)으로 연관관계가 설정되어 있는 2개의 Entity가 존재할 때, 데이터베이스 입장에서 보면 Join이 필요하다.

2023.11.29 - [Database] - [데이터베이스] Join

 

[데이터베이스] Join

데이터베이스에서 Join은 서로 다른 테이블들을 연결하여 하나의 테이블로 결과를 보여주는 중요한 작업이다. 우선 MySQL로 데이터베이스 테이블과 샘플 데이터를 설정해보자. -- employees CREATE TABLE

hocci-0222.tistory.com

💡 @ManyToOne의 경우 외래키 쪽의 엔티티를 가져올 때 기본키 쪽의 엔티티도 같이 가져오게 되는데, 실무에서는 서비스의 규모가 대부분 크기 때문에 연관된 데이터를 한번에 가져오는 행동은 부담이 크다.

👨‍💻 JPA는 참조하는 객체들의 데이터를 가져오는 시점을 정할 수 있는데 이것을 Fetch Type이라고 한다. 

@Entity
@Table(name="item_img")
@Getter @Setter
public class ItemImg extends BaseEntity{

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "item_id")
    private Item item;

    public void updateItemImg(String oriImgName, String imgName, String imgUrl){
        this.oriImgName = oriImgName;
        this.imgName = imgName;
        this.imgUrl = imgUrl;
    }
}

➡️ Eager Fetching(즉시 로딩)

Eager 로딩은 부모 엔티티를 로드할 때 관련된 자식 엔티티들을 즉시 로드하는 전략이다. 이는 해당 엔티티와 연관된 모든 데이터를 한 번의 쿼리로 로드하고자 할 때 유용하다. 하지만, 필요하지 않은 데이터까지 로드할 수 있어 성능 문제를 야기할 수 있다.

🤔 특정 엔티티를 조회할 때 연관된 모든 엔티티를 조인을 통해 함께 조회하는 방식

@Target({ElementType.METHOD, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ManyToOne {
    Class targetEntity() default void.class;

    CascadeType[] cascade() default {};

    FetchType fetch() default FetchType.EAGER;

    boolean optional() default true;
}

☑️ FetchType.Eager 순서

  1. JPQL에서 만든 SQL을 통해 데이터를 조회한다.
  2. 이후 JPA에서 Fetch 전략을 가지고 해당 데이터의 연관 관계인 하위 엔티티들을 추가로 조회한다.
  3. 2번 과정으로 n + 1 문제 발생

➡️ Lazy Fetching(지연 로딩)

Lazy 로딩은 부모 엔티티를 로드할 때 관련된 자식 엔티티들을 즉시 로드하지 않고 그 관계가 실제로 접근될 때까지 로드를 지연하는 전략이다. 이는 성능 최적화에 도움을 줄 수 있지만, 관계가 필요할 때마다 추가 쿼리를 발생할 수 있다.

🤔 자신과 연관된 엔티티를 실제로 사용할 때 연관된 엔티티를 조회하는 방식

@Target({ElementType.METHOD, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ManyToOne {
    Class targetEntity() default void.class;

    CascadeType[] cascade() default {};

    FetchType fetch() default FetchType.EAGER;

    boolean optional() default true;
}

☑️ FetchTpye.Lazy 순서

  1. JPQL에서 만든 SQL을 통해 데이터를 조회한다.
  2. JPA에서 Fetch전략을 가지지만 지연 로딩이기 때문에 추가 조회는 하지 않는다.
  3. 하지만 하위 엔티티를 가지고 작업하게 되면 추가 조회가 발생하기 때문에 결국 n + 1 문제 발생

⁉️ 그럼 여기서 n + 1 문제는 무엇일까?

ORM(Object-Relational Mapping)을 사용하는 애플리케이션에서 관계형 데이터베이스와의 데이터 조회 작업에서 발생할 수 있는 성능 문제이다.

n + 1문제는 한 번의 초기 쿼리 실행으로 가져온 데이터를 사용하는 도중 추가로 n번의 쿼리를 실행해야 하는 상황을 말한다. 이로 인해 데이터베이스와의 불필요한 네트워크 통신이 발생하며, 성능 저하와 부하를 초래할 수 있다.

⁉️ 발생 원인은 무엇일까?

JpaRepository에 정의한 인터페이스 메서드를 실행하면 JPA는 메서드 이름을 분석해서 JPQL을 생성하여 실행하게 된다. JPQL은 SQL을 추상화한 객체지향 쿼리 언어로서 특정 SQL에 종속되지 않고 엔티티 객체와 필드 이름을 가지고 쿼리를 조회한다. 그렇기 때문에 JPQL은 findAll()이란 메소드를 수행하였을 때 해당 엔티티를 조회하는 select * from test 쿼리만 실행하게 되는 것이다.

JPQL 입장에서는 연관관계 데이터를 무시하고 해당 엔티티 기준으로 쿼리를 조회하기 때문이다. 그렇게 때문에 연관된 엔티티 데이터가 필요한 경우, FetchType으로 지정한 시점에 조회를 별도로 호출하게 된다.

💡 해결 방법

🔗 Fetch Join

JPA에서 사용되는 기능으로 연관된 엔티티나 컬렉션을 한 번의 쿼리로 함께 가져오는 방법

@Entity
public class User {
    @Id
    private Long id;
    private String name;
    ...
    @OneToMany(mappedBy = "user")
    private List<Order> orders = new ArrayList<>();
    ...
}

@Entity
public class Order {
    @Id
    private Long id;
    private String orderDetails;
    ...
    @ManyToOne
    @JoinColumn(name = "user_id")
    private User user;
}
//Fetch Join 사용
public List<User> findAllUsersWithOrders() {
    return entityManager.createQuery(
        "SELECT u FROM User u JOIN FETCH u.orders", User.class)
        .getResultList();
}

➡️ User 엔티티를 조회할 때 Order 엔티티를 함께 조회한다. join fetch 구문은 연관된 orders 컬렉션을 즉시 로드하도록 지시한다. 이렇게 하면 각 User에 대해 별도의 쿼리를 실행하지 않고 관련 Order 객체에 접근할 수 있다.

🔥 주의 사항

  • fetch join은 연관된 엔티티가 많을 때 한 번에 가져오기 때문에 성능 저하가 발생할 수 있다.
  • 컬렉션 fetch join은 결과 집합을 중복시킬 수 있다.(distinct 중복제거)

💡 Entity Graph

JPA에서 제공하는 기능으로 특정 엔티티를 로딩할 때 어떤 연관된 엔티티나 컬렉션을 함께 로드할지 세밀하게 정의할 수 있다.

@Entity
public class User {
    @Id
    private Long id;
    private String name;
	...
    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
    private List<Order> orders = new ArrayList<>();
    ...
}

@Entity
public class Order {
    @Id
    private Long id;
    private String orderDetails;
    ...
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;
}
//Entity Graph
@EntityGraph(attributePaths = {"orders"})
public List<User> findAllUsersWithOrders() {
    return entityManager.createQuery(
        "SELECT u FROM User u", User.class)
        .setHint("javax.persistence.loadgraph", entityGraph)
        .getResultList();
}

➡️ @EntityGraph 어노테이션은 User 엔티티를 로드할 때 orders 컬렉션도 함께 로드하도록 지정한다. attributePaths속성에 지정된 경로에 따라 연관된 엔티티들이 로드된다. 이 방식은 User 객체를 조회할 때 Order객체도 함께 로드되도록 한다.

🔥 주의 사항

  • 복잡한 쿼리에 대한 세밀한 제어를 제공한다. 오용하면 성능 문제 발생 가능
  • fetch join과 주된 차이점은 사용 용도와 유연성에 있다. Entity Graph는 더 동적이고 유연한 방식으로 데이터 로딩 전략을 제어할 수 있다.

💡 BatchSize

Hibernate에서 제공하는 어노테이션으로 연관된 엔티티나 컬렉션을 로드할 때 지정된 크기의 배치로 로드하는 기능을 제공한다. 이는 Lazy 로딩을 사용할 때 n+1 쿼리 문제를 완화하는 데 유용하다. Hibernate는 필요할 때마다 한 번에 여러개의 연관된 엔티티를 한 번의 쿼리로 로드하여 효율성을 높인다.

@Entity
public class User {
    @Id
    private Long id;
    private String name;
    ...
    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
    @BatchSize(size = 10)
    private List<Order> orders = new ArrayList<>();
    ...
}

@Entity
public class Order {
    @Id
    private Long id;
    private String orderDetails;
    ...
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;
}

➡️ 여기서 Batchsize(size = 10) 어노테이션은 User 객체가 로드되고 나서 그 객체의 Order 들이 접근될 때 한번에 최대 10개의 Order 객체를 쿼리로 로드한다. 이는 각 Order에 대해 별도의 쿼리를 발생시키는 것보다 데이터베이스에 대한 부담을 줄여준다.

🔥 고려 사항

  • 배치 크기 선정 : 배치 크기는 애플리케이션의 성능과 메모리 사용에 여향을 미친다. 적당한 크기의 배치 크기를 사용해야 한다.
  • 적용 범위 : 엔티티 또는 컬렉션 뿐만 아니라 클래스 레벨에도 적용될 수 있다. 클래스 레벨에서 적용하면 해당 타입의 모든 연관 관계에 대해 동일한 배치 크기가 적용된다.

 💡 QueryBuilder

JPA나 Hibernate와 같은 ORM 라이브러리에서 제공하는 기능으로 프로그래밍 방식으로 쿼리를 구성하고 실행할 수 있게 해준다. 이 도근 복잡한 쿼리를 동적으로 구성하는 데 유용하며 코드의 가독성과 유지 보수성을 향상시킨다.

@Entity
public class User {
    @Id
    private Long id;
    private String name;
    ...
}

@Entity
public class Order {
    @Id
    private Long id;
    private LocalDate orderDate;
    private BigDecimal amount;
    ...
    @ManyToOne
    @JoinColumn(name = "user_id")
    private User user;
}
//Query Builder
// EntityManager를 통한 CriteriaBuilder 인스턴스 생성
CriteriaBuilder cb = entityManager.getCriteriaBuilder();

// CriteriaQuery 및 Root 생성
CriteriaQuery<Order> cq = cb.createQuery(Order.class);
Root<Order> order = cq.from(Order.class);

// 조건 생성
Predicate userPredicate = cb.equal(order.get("user").get("id"), userId);
Predicate datePredicate = cb.greaterThan(order.get("orderDate"), startDate);

// 쿼리에 조건 추가
cq.select(order).where(cb.and(userPredicate, datePredicate));

// 쿼리 실행
List<Order> orders = entityManager.createQuery(cq).getResultList();

➡️ User id가 userId인 사용자의 Order 중 orderDate가 startDate 보다 큰 주문들을 조회한다. CriteriaBuilder와 Root를 사용하여 쿼리를 구성하고 Predicate를 사용하여 조건을 추가한다.

🔥 장점

  • 동적인 쿼리 생성 : 프로그래밍 방식으로 쿼리를 구성하여 런타임에 다양한 조건을 적용할 수 있다.
  • 타입 안전성 : 문자열 기반의 쿼리보다 오류를 더 쉽게 포착할 수 있다.

다대다 관계를 다대이 관계로 푸는 방식

@Entity
public class Student {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    @ManyToMany
    @JoinTable(
        name = "enrollment",
        joinColumns = @JoinColumn(name = "student_id"),
        inverseJoinColumns = @JoinColumn(name = "course_id")
    )
    private List<Course> courses = new ArrayList<>();
    ...
}
@Entity
public class Course {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;

    @ManyToMany(mappedBy = "courses")
    private List<Student> students = new ArrayList<>();
	...
}

Student와 Course 엔티티는 서로 @ManyToMany 관계를 가지고 있다. @JoinTable 로 다대다 관계를 위한 연결 테이블을 정의한다. 위처럼 다대다 엔티티는 성능과 관리 문제, 복잡성으로 인해 일대다 관계로 분리하는 것이 권장된다.

@Entity
public class Student {
    @Id
    private Long id;
    private String name;
    ...
    @OneToMany(mappedBy = "student")
    private List<Enrollment> enrollments = new ArrayList<>();
}

@Entity
public class Course {
    @Id
    private Long id;
    private String title;
    ...
    @OneToMany(mappedBy = "course")
    private List<Enrollment> enrollments = new ArrayList<>();
}

@Entity
public class Enrollment {
    @Id
    private Long id;
    
    @ManyToOne
    @JoinColumn(name = "student_id")
    private Student student;
    
    @ManyToOne
    @JoinColumn(name = "course_id")
    private Course course;
    ...
}

🤔 데이터베이스 관점

정규화 : 데이터의 중복을 방지하고 데이터 무결성을 유지한다.

유연성 : 두 엔티티 사이의 관계에 추가적인 정보를 저장할 수 있는 공간을 제공한다.

쿼리 성능 : 적절한 인덱스와 함께 사용하면 다대다 관계를 효율적으로 여튼 다대다는 가급적 피하자!


참고 자료 : https://github.com/devSquad-study/2023-CS-Study/blob/main/JPA/jpa_n_vs_m.md

728x90
반응형

'Spring boot' 카테고리의 다른 글

iOS In-App Purchase 서버 검증 구현하기 (JWS 방식)  (0) 2025.12.01
@Valid 로 DTO 검증  (0) 2024.01.25
[ Spring ] @Bean  (1) 2024.01.11
회원 가입 기능 구현하기  (0) 2023.09.12
스프링 시큐리티(Spring Security)  (0) 2023.09.06