즉시 로딩과 지연 로딩
즉시로딩(Immediate Loading)과 지연로딩(Lazy Loading)은 데이터베이스에서 데이터를 조회하는 방식 중의 하나로, 객체 간의 연관관계를 어떻게 로딩하고 관리할 것인지에 대한 개념이다.
- 즉시로딩(Immediate Loading): 엔티티를 조회할 때 해당 엔티티와 연관된 모든 엔티티를 조회하는 방식
- A 엔티티와 B 엔티티가 연관되어 있을 때 A를 조회하면 B도 함께 조회되고, 이로 인해 객체 간의 관계가 필요한 시점에 바로 사용할 수 있다
- 조인 등의 복잡한 쿼리가 생성될 수 있고, 불필요한 데이터 로딩으로 인해 성능 문제가 발생할 수도 있다
- 지연 로딩(Lazy Loading): 객체를 Proxy로 가져온 후 실제 해당 객체를 사용하는 시점에 초기화, 연관된 엔티티를 처음에는 조회하지 않고, 실제로 해당 엔티티가 필요한 시점에 조회하는 방식
- A 엔티티를 조회해도 B 엔티티는 초기에 조회되지 않고, B 엔티티를 실제로 사용할 때 데이터베이스에서 조회되고, 이를 통해 쿼리의 최적화와 성능 향상을 이룰 수 있다
- 연관 엔티티를 사용하는 과정에서 데이터베이스 쿼리가 추가적으로 발생하게 될 수 있다
지연로딩은 대부분의 JPA 구현체에서 지원되는 기능이며, 엔티티 클래스의 연관 관계 필드에 @ManyToOne, @OneToOne, @OneToMany와 같은 어노테이션을 사용할 때 fetch 속성을 지정하여 조절할 수 있다.
지연로딩을 사용하면 데이터베이스 트랜잭션 내에서 연관된 엔티티에 접근해야만 데이터베이스 조회가 일어난다.
각각의 상황과 필요에 다라 선택해서 전략적으로 사용할 수 있다
즉시로딩(Immediate Loading)
- 객체 간의 관계를 활용하기 편리: 즉시로딩을 사용하면 객체를 조회할 때 연관된 모든 객체가 한 번에 로딩되므로 객체 간의 관계를 편리하게 활용할 수 있다. 모든 연관된 데이터가 이미 로딩되어 있으므로 어떤 객체를 사용할 때 별다른 데이터베이스 조회 없이 객체 그래프를 따라 이동할 수 있다.
- 복잡한 조회를 단순화: 데이터베이스에서 조인을 사용하여 복잡한 연관관계를 해결할 필요 없이, 즉시 로딩으로 모든 데이터를 한 번에 가져올 수 있다.
지연로딩(Lazy Loading)
- 성능 최적화 : 즉시로딩은 모든 연관된 데이터를 한 번에 가져오기 때문에, 필요하지 않은 데이터까지 불필요하게 로딩될 수 있고 이는 성능 저하를 야기할 수 있다. 지연로딩은 필요한 시점에만 데이터를 로딩하기 때문에 성능을 최적화할 수 있다.
- 데이터 접근을 최적화: 사용자가 실제로 해당 데이터를 사용할 때만 로딩하므로 데이터베이스 접근이 최적화 됨, 따라서 시스템 전체적으로 데이터 로딩에 대한 부하가 분산될 수 있다.
- 순환 참조 방지: 지연로딩을 사용하면 객체 간의 연관관계에서 순환 참조가 발생할 확률이 줄어든다, 객체를 조회할 때 실제로 필요한 데이터만 로딩되므로 무한한 순환 참조를 방지할 수 있다.
- 메모리 사용 최적화: 즉시로딩은 연관된 모든 데이터를 로딩하기 때문에 메모리를 많이 사용할 수 있다, 지연로딩은 필요한 데이터만 로딩하기 때문에 메모리 사용을 최적화할 수 있다.
즉시로딩과 지연로딩 각각의 상황에 따라 선택되며, 객체의 연관관계가 어떻게 사용되는지, 어떤 데이터가 실제로 필요한지 등을 고려하여 결정해야 한다.
JPA를 사용하는 경우 애플리케이션의 성능, 데이터 접근 패턴, 메모리 사용 등을 고려하여 최적의 로딩 전략을 선택하는 것이 중요하다.
Proxy와 즉시로딩에서의 주의점
실무에서는 지연로딩만 사용하는 것이 좋음, 즉시 로딩을 적용하게 되면 예상하지 못한 SQL이 발생할 수 있다.
- N + 1 쿼리 문제: 즉시로딩을 사용하면 부모 객체를 가져올 때 연관된 모든 자식 객체들도 함께 가져오게 된다, 이로 인해 부모 객체의 수만큼 추가 쿼리가 발생할 수 있으며, 이를 N+1 쿼리 문제라고 한다. 이는 데이터베이스 부하와 성능 저하를 초래할 수 있으며 이런 상황을 피하기 위해서는 지연로딩이나, 패치 조인 등을 활용해야 한다.
- 메모리 부하: 즉시로딩을 사용하면 연관된 모든 자식 객체들도 함께 메모리에 로딩된다, 이는 메모리 부하를 증가시킬 수 있으며, 필요한 경우에만 로딩되도록 지연로딩을 고려하거나 패치 조인을 활용하여 필요한 데이터만 가져오도록 할 수 있다.
- 데이터 무결성: 프록시를 사용한 즉시로딩은 객체의 연관 관계를 보장하며, 데이터베이스의 무결성을 유지하기 위해 자식 객체들도 부모 객체와 함께 관리되어야 한다. 만약 부모 객체와 자식 객체 간의 데이터 무결성이 깨진다면, 프록시 즉시로딩을 사용하는 것보다는 지연로딩과 명시적인 트랜잭션 처리를 고려해야 한다.
- 데이터베이스 성능: 즉시로딩은 데이터베이스에서 모든 필요한 데이터를 한 번에 가져오기 때문에 성능에 영향을 줄 수 있다, 데이터베이스의 쿼리 성능을 고려하여 필요한 경우 쿼리 최적화를 수행하고 인덱스를 활용하는 등의 작업을 진행해야 한다.
N + 1 문제
연관관계가 설정된 엔티티 사이에서 한 엔티티를 조회하였을 때, 조회된 엔티티의 개수(N 개)만큼 연관된 엔티티를 조회하기 위해 추가적인 쿼리가 발생하는 문제를 의미
- 엔티티 조회 쿼리(1번) + 조회된 엔티티의 개수(N 개)만큼 연관된 엔티티를 조회하기 위한 추가 쿼리(N 번)
원인
근본적인 원인은 관계형 데이터베이스와 객체지향 언어간의 패러다임 차이로 인해 발생한다, 객체는 연관관계를 통해 레퍼런스를 가지고 있으면 언제든지 메모리 내에서 Random Access를 통해 연관 객체에 접근할 수 있지만 RDB의 경우 select 쿼리를 통해서만 조회할 수 있기 때문이다.
해결 방법
N + 1 문제를 해결하는 방법
fetch join
즉시로딩과 지연로딩 두 경우 다 N + 1 문제가 발생할 수 있다, 즉시로딩은 커스텀할 수 없기 때문에 사용을 권장하지 않는다.
지연로딩 과정에서 사용할 객체에 대해서는 join을 걸 수 있도록 조정해야 하며 그 방법 중 하나가 fetch join이다.
일반적인 fetch join
join문에 fetch를 걸어주는 것으로 fetch 는 지연로딩이 걸려있는 연관관계에 대해서 한 번에 같이 즉시로딩해주는 구문이다.
Fetch Join과 일반 Join의 차이
근본적인 차이는 Fetch Join은 ORM에서의 사용을 전제로 DB Schema를 Entity로 자동 변환 해주고 영속성 컨텍스트에 영속화 해준다는 부분에 있음
- Fetch Join을 통해 조회 하면 연관 관계는 영속성 컨텍스트 1차 캐시에 저장되어 다시 엔티티 그래프를 탐색하더라도 조회 쿼리가 수행 되지 않는다
- 일반 Join쿼리는 단순히 데이터를 조회 하는 개념으로 영속성 컨텍스트나 Entity와는 무관하다
- 가능하다면 Fetch Join을 활용해야 ORM을 활용하여 관계형 데이터베이스와의 패러다임 차이를 줄일 수 있다
Entity Graph
JPQL에서 Fetch Join을 하게 되면 하드코딩을 하게 된다는 단점이 있으며, 이를 최소화하기 위해 @EntityGraph를 사용할 수 있다.
Hibernate의 JPQL 구문에서의 fetch는 존재하지 않지만 기존과 마찬가지로 fetch join을 통해 바로 조회하는 것이 가능하다.
Collection 연관관계 Fetch Join 시 주의사항
Fetch Join을 Collection에 대해서 할 경우 SQL Native Join 쿼리가 발생하게 되고 이 경우 1:N 관계이기 때문에 1쪽의 데이터는 중복된 상태로 조회하게 된다.
Collection Fetch Join은 하나까지만 가능하다
- 여러 Collection에 대해서 Fetch Join을 하게 되면 잘못된 결과가 발생하기 때문에 꼭 하나까지만 Fetch Join 해야 한다
Paging 처리하면 안 된다(Out Of Memory 발생 가능)
- Collection Fetch Join에서 Paging을 할 경우 applying in memory, 인 메모리를 적용해서 조인을 한다.
- 즉 List의 모든 값을 select해서 인 메모리에 저장하고, application 단에서 필요한 페이지만큼 반환을 알아서 처리하는 것으로 Paging 처리한 이유가 없어지는 것임
- 예를 들어 100만 건의 데이터가 있을 때 그 중 10건의 데이터만 paging하려 해도 100만 건의 데이터를 메모리에 가져오기 때문에 OOM(Out Of Memory)이 발생할 확률이 매우 높아진다.
@Batch Size, default_batch_fetch_size
- 연관된 객체들을 미리 정의된 크기의 그룹으로 나누어 한 번에 불러오며, 네트워크 비용과 데이터베이스 로드를 줄일 수 있음
- Lazy Loading 시 프록시 객체를 조회할 때 where in 절로 묶어서 한 번에 조회할 수 있게 해주는 옵션
- yml에 전역 옵션으로 적용할 수 있고 @BatchSize를 통해 연관관계 BatchSize를 다르게 적용할 수 있다
spring:
jpa:
properties:
default_batch_fetch_size: 100
- Batch Size는 100~1000 정도로 적용하고 DBMS에 따라서 where in 절은 1000까지 제한하는 경우가 있으므로 1000 이상은 잘 설정하지 않는다.
- DB에서는 부담이 될 수 있기 때문에 적절하게 조절해야 한다
Fetch Join vs Batch Size
Fetch Join의 한계를 Batch Size를 통해 해결할 수 있다.
- Collection Fetch Join 시 paging 문제나 1개까지만 Fetch Join을 할 수 있는 문제를 해결할 수 있다
쿼리 개수 관점
- 쿼리 개수는 Fetch Join이 유리하다, Batch Size의 경우 몇 번의 쿼리가 더 발생될 수 있다
데이터 전송량 관점
- 데이터 전송량 관점에서는 Batch Size가 유리하다, Fetch Join은 Join을 하고 나서 가져오기 때문에 중복 데이터를 많이 가져와야 되기 때문이다.
- Fetch Join의 경우
- BatchSize의 경우
사용 예제
@BatchSize(size = 100)
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private Set<Article> articles = emptySet();
User 1 , Article N 의 연관 관계일 때 Batch Size를 사용한 경우
기존의 지연로딩에 대해서는 객체를 조회할 대 그때그때 쿼리문을 날려서 N+1 문제가 발생하지만, BatchSize는 객체를 조회하는 시점에 쿼리 하나만 날리는 게 아니라 해당하는 Article에 대해서 쿼리를 날리는 것(Batch Size 개)
- batch size는 Article이 아닌 User의 갯수가 기준
Batch Size는 연관관계에서의 데이터 사이즈를 확실하게 알 수 있다면 최적화된 size를 구할 수 있지만, 일반적인 케이스에서는 최적화된 데이터 사이즈를 알기 힘들고 일반적으로 100~1000 사이를 사용하는 것이고 최적화된 사이즈를 모를 경우 안 좋은 방법이 될 수 있다.
주의해야할 점은 batch size에 fetch join을 걸면 안 된다.
fetch join이 우선시되어 적용되기 때문에 batch size가 무시되고 fetch join을 인메모리에서 먼저 진행하여
List가 MultipleBagFetchException가 발생하거나, Set을 사용한 경우에는 Pagination의 인메모리 로딩을 진행한다.
@Fetch(FetchMode.SUBSELECT)
조인을 사용할 수 없거나 조인이 비효율적인 경우, Sub Select를 사용하여 필요한 데이터를 한 번에 가져올 수 있다, ORM에서는 서브 쿼리를 사용하여 연관된 엔티티를 미리 로드할 수 있다.
Batch Size vs Sub Select
Batch Size
- 장점: 데이터베이스에 보내는 쿼리의 수를 크게 줄일 수 있으며, 네트워크 지연 시간과 쿼리 실행 오버헤드를 최소화할 수 있다.
- 단점: 한 번에 가져오는 데이터의 양이 많아질 수 있으므로 메모리 사용량이 증가할 수 있다. 또한, 필요하지 않은 데이터까지 불러올 가능성이 있다.
Sub Select
- 장점: 연관된 데이터를 정확하게 필요한 만큼만 조회할 수 있다. 데이터 접근이 동적이고 조건이 복잡한 경우 유용하다.
- 단점: 주 쿼리 결과에 따라 별도의 쿼리가 매번 실행되므로, 주 쿼리의 결과가 큰 경우 많은 수의 서브 쿼리가 발생할 수 있어 성능 저하를 일으킬 수 있다.
어느 것을 사용하는 게 좋은가?
배치 페치와 서브 셀렉트 중 어느 것이 더 나은지는 사용하는 데이터의 양과 접근 패턴에 따라 달라진다. 배치 페치는 데이터 접근 패턴이 예측 가능하고 일괄 처리할 수 있는 경우에 효과적이다. 반면, 서브 셀렉트는 결과가 동적이거나 연관된 데이터가 크게 변동하는 상황에서 더 적합할 수 있다.
성능 테스트와 애플리케이션의 요구 사항을 정확히 분석한 후 적절한 기법을 선택하는 것이 중요하며, 각 기법의 성능을 직접 측정하여 구체적인 상황에 맞는 최적의 방식을 선택하는 것이 좋다.
'항해 99 > Spring' 카테고리의 다른 글
낙관적 락 & 비관적 락 (0) | 2024.04.19 |
---|---|
Spring Actuator (1) | 2024.04.18 |
Spring PSA (0) | 2024.04.12 |
Spring MVC, IoC/DI (0) | 2024.04.11 |
Spring Context (0) | 2024.04.10 |