DB 성능 개선을 위한 해결책 : 조회 쿼리 N+1문제 해결하기

밀크야살빼자·2024년 3월 23일
0

아이돔을 리팩토링 하면서 쿼리가 다중으로 발생하는 것을 확인했습니다. 이러한 상황에서는 N + 1 문제가 발생할 수 있어 성능 저하를 발생시킬 수 있습니다. 따라서 이 문제를 해결하기 위해 N + 1문제에 대해 알아보고 개선 방법을 찾아 적용해보려고 합니다.

1. N + 1란

ORM 기술에서 연관 관계가 설정된 데이터 엔티티 사이에서 한 엔티티를 조회할 때, 조회된 엔티티의 개수(N 개) 만큼 연관된 엔티티를 조회하기 위해 추가적인 쿼리가 발생하는 문제입니다.

  • 첫 번째 쿼리(1): 기본 엔티티를 조회합니다. 예를 들어, 10개의 게시글(Post)을 조회합니다.
  • 두 번째 쿼리(N): 각 기본 엔티티에 대해 서브 엔티티를 조회합니다. 예를 들어, 각 게시글에 대한 댓글(Comment)을 조회합니다. 이 때, 10개의 게시글 각각에 대해 추가 쿼리가 발생하게 됩니다.

N + 1이 발생하는 근본적인 원인은 관계형 데이터베이스와 객체지향 언어 간의 패러다임 차이로 인해 발생합니다. 객체는 연관 관계를 통해 레퍼런스를 가지고 있으면 언제든지 메모리 내에서 Random Access를 통해 연관 관계 객체에 접근할 수 있지만, RDB인 경우에는 Select 쿼리를 통해서만 조회할 수 있기 때문입니다.

언제 발생하는가

  • JPA Fetch 전략이 EAGER로 설정된 경우 데이터를 조회
  • JPA Fetch 전략이 LAZY로 설정된 경우, 이후 연관된 하위 엔티티를 다시 조회

즉시 로딩

엔티티 조회시 연관관계에 있는 데이터까지 한번에 조회해오는 기능입니다. (fetch = FetchType.EAGER)

  • 장점

    • 쿼리가 한 번만 나갑니다.
    • 추가적인 쿼리가 발생하지 않아 응답 시간이 짧아질 수 있습니다.
  • 단점

    • 사용하지 않는 엔티티를 같이 조회하여 메모리 소비가 많을 수 있습니다.
    • 연관된 데이터를 모두 가져와야해서 성능 문제가 발생할 수 있습니다.

N+1 발생 과정

  1. select 절로 Post 조회
  2. DB에서 결과를 받아 Post 인스턴스 생성
  3. Post와 연관되어 있는 Comment 조회하기 위해 영속성 컨텍스트에서 연관된 Post가 있는지 확인
  4. 없다면 Post 인스턴스 개수에 맞게 select 쿼리 발생

지연 로딩

엔티티 조회 시점이 아닌 엔티티 내 연관관계를 참조할 때(즉, 요청할때만) 연관된 데이터를 조회하는 기능입니다. (fetch = FetchType.LAZY)

  • 장점
    • 필요한 시점에 연관된 데이터를 가져오기 때문에 초기 조회 시 필요하지 않는 데이터를 가져오지 않습니다.
    • 필요한 경우에만 데이터를 로드해서 가져오기 때문에 메모리 소비가 줄어듭니다.
    • 필요한 경우에 데이터를 로드해서 가져오기 때문에 쿼리 실행 시간이 줄어듭니다.
  • 단점
    • 연관된 데이터에 접근할 때마다 추가적인 쿼리가 발생하므로 응답 시간이 증가할 수 있습니다.
    • 영속성 컨텍스트(Session 또는 EntityManager 등)가 활성화된 상태에서 연관된 데이터를 접근해야 합니다.

N+1 문제 발생 과정

  1. select 절로 Post 조회
  2. DB에서 결과를 받아 Post 인스턴스 생성
  3. Post의 Comment를 사용하려고 할 때 영속성 컨텍스트에 있는지 확인
  4. 없다면 Post 인스턴스 개수에 맞게 Select 쿼리 발생

2. 해결 방법

1. Fetch Join

Fetch Join으로 N + 1 문제 해결

Fetch Join은 대상 엔티티를 조회할 때 Lazy Loading으로 설정 되어 있는 연관 관계를 Join 쿼리를 발생시켜 한번에 조회할 수 있는 기능입니다.

Join과 Fetch Join의 차이점

  • Join
    • 엔티티에 join을 걸어도 실제 쿼리에서 SELECT 하는 엔티티는 오직 JPQL에서 조회하는 주체가 되는 엔티티만 조회하여 영속화합니다.
    • 조회의 주체가 되는 엔티티만 SELECT해서 영속화하기 때문에 데이터는 필요하지 않지만 연관 엔티티가 검색조건에는 필요한 경우에 주로 사용됩니다.
      대상 엔티티에 대한 컬럼만 SELECT 합니다.
    • 예를 들어, Post엔티티를 조회할 때, join으로 쿼리를 실행하면 N+1문제는 없어지지만 실제로 영속성 컨텍스트에는 comment 엔티티는 들어오지 않습니다. -> LazyInitinalizationException이 발생한다.
      @Query("SELECT distinct t FROM Team t JOIN t.members")
      public List<Team> findAllWithMembersUsingJoin();
  • Fetch Join
    • 조회의 주체가 되는 Entity 이외에 Fetch Join이 걸린 연관 Entity도 함께 Select하여 모두 영속화합니다.
    • Fetch Join이 걸린 Entity 모두 영속화하기 때문에 FetchType이 Lazy인 Entity를 참조하더라도 이미 영속성 컨텍스트에 들어있기 때문에 쿼리가 실행되지 않은 채로 N + 1 문제가 해결됩니다. Fetch Join이 걸려있는 엔티티를 포함한 컬럼만 함께 SELECT 합니다.
      @Query("SELECT distinct t FROM t JOIN FETCH t.members")
      public List<Team> findALlWithMembersUsingFetchJoin();

2. default_batch_fetch_size, @BatchSize

Lazy Loading시 프록시 객체를 조회할 때 한 번에 조회할 수 있게 해주는 옵션은 Hibernate의 default_batch_fetch_size 옵션입니다. 이 옵션은 전역적으로 설정할 수 있으며, 엔티티나 관계에 대한 모든 지연 로딩 프록시를 단일 쿼리로 가져올 수 있습니다.

spring:
  jpa:
    properties:
        default_batch_fetch_size: 100

위 설정은 모든 지연 로딩 프록시에 대해 100개의 엔티티를 단일 쿼리로 가져오도록 설정합니다.또한, @BatchSize를 사용하여 특정 연관 관계에 대한 Batch Size를 지정할 수도 있습니다.

Batch Size는 일반적으로 100에서 1000 정도로 설정하는 것이 적절합니다. 그러나 DBMS에 따라서는 where in 절에 대한 제한이 있어서 1000까지만 처리할 수 있는 경우도 있습니다. 큰 Batch Size를 사용하면 WAS는 한 번에 더 많은 데이터를 메모리에 로딩해야 하지만, DB에 부담을 줄 수 있습니다. 따라서, 적절한 Batch Size를 선택하는 것이 중요합니다.

A엔티티가 지연 로딩된 ~ToMany 관계의 B엔티티 컬렉션을 최초로 조회할 때, 이미 조회한 A엔티티들의 ID를 수집하여, Where B.A_ID IN(?, ?, ?, ?, …)와 같은 SQL IN 구문에 담아 B엔티티 데이터를 조회하는 쿼리가 발생합니다. 이는 A엔티티들이 필요로 하는 모든 B엔티티 데이터를 한 번에 조회합니다.

Batch Size 옵션에 설정하는 숫자는 IN 구문에 넣을 부모 엔티티의 키(ID)의 최대 개수를 의미합니다.

3. Fetch Join vs @BatchSize

Batch SizeFetch Join의 한계를 일부 해결할 수 있습니다.

  • CollectionFetch Join 시에는 Paging 문제나 단일 Fetch Join의 제한을 해결할 수 있습니다.
    • FetchJoin을 Collection 연관관계에 적용할 경우 1쪽에 중복 데이터가 발생합니다. 이를 방지하기 위해서 Distinct를 사용하여 중복을 방지해야 합니다.
    • Paging은 JPA가 DB에 있는 테이블을 그대로 가져오려고 하기 때문에, 페이징을 설정하면 예상과 다른 데이터를 받아올 수 있습니다. 또한, JPA에서 Join을 사용해 데이터를 가져오고 이를 JPQL 관점에서 주 엔티티를 기준으로 페이징하려고 할 경우, 모든 데이터를 메모리에 로딩해야 하므로 Out Of Memory가 발생할 수 있습니다.
  • 쿼리 개수 측면에서는 Fetch Join이 한 번의 쿼리로 모든 관련 데이터를 가져올 수 있기 때문에 Batch Size보다 쿼리 수가 적습니다. 그러나 데이터 전송량 측면에서는Batch Size를 사용하면 중복 데이터를 줄일 수 있으며, Fetch JoinJoin 후에 데이터를 가져오기 때문에 중복 데이터를 많이 가져와야 합니다.

Fetch Join 경우

레코드MemberPost
1Member1Post1
2Member1Post2
3Member2Post3
4Member2Post4

BatchSize 경우

레코드Member
1Member1
2Member2
레코드Post
1Post1
2Post2
3Post3
4Post4

4. @EntityGraph

EntityGraph vs FetchType

EntityGraphFetchType을 즉시로딩으로 변환하는 방식으로 Outer Left Join을 수행하여 데이터를 가져오지만, Fetch Join의 경우에는 명시적으로 Outer Join을 사용하지 않는한 Inner Join을 수행합니다. 이는 Fetch Join의 단점을 피할 수 있습니다. 특히 1:N 컬렉션 Join 시 하나만 Join할 수 있는 제약과 중복 데이터 문제를 피하기 위해 distinct를 사용합니다.

이는 EntityGraphFetchType을 변환하여 조회하는 개념이기 때문입니다. 그래서 Fetch Join보다 EntityGraph를 사용하는 것이 더 효과적인 경우가 많습니다. EntityGraph를 사용하면 Fetch Join의 단점을 피하면서도 데이터를 효율적으로 가져올 수 있습니다.

@EntityGraph 동작 방식

FetchType.LazyFetchType.Eagerstatic 정보로서 runtime에 변경할 수 없습니다. 그러나 EntityGraph를 사용하면 이를 변경할 수 있게 됩니다. 예를 들어, FetchType.Lazy로 설정해둔 경우에도 EntityGraph를 활용하여 FetchType.Eager로 사용할 수 있습니다.

EntityGraph vs FetchJoin

Fetch Join에서 1:N 연관관계로 조회할 때의 제약사항인 1개의 컬렉션까지만 같이 조회할 수 있는 부분이나 distinct가 필요한 단점을 극복할 수 있게 해줍니다.

따라서, EntityGraph를 사용하면 FetchType의 설정을 유연하게 변경하여 필요에 따라 데이터를 효율적으로 조회할 수 있습니다. FetchType.Lazy로 설정된 엔티티에 대해서도 필요한 경우에는 EntityGraph를 활용하여 FetchType.Eager로 변경하여 사용할 수 있습니다.

5. 필요한 정보를 담아서 DTO로 반환하기

일반 Join을 사용하고 DTO를 만들어서 객체를 받아오는 방식입니다.

  1. Bean() : getter, setter, 디폴트 생성자 필요합니다.

    List<PostDto> result = queryFactory
    .select(Projections.bean(PostDto.class,
    							post.title,
    							post.content,
    							post.isAnonymous,
    							post.likeCount))
    .from(post)
    .fetch();
  2. 필드 직접 접근 : getter, setter 필요 없으며 바로 주입이 가능합니다.

    List<PostDto> result = queryFactory
    .select(Projections.fileds(PostDto.class,
    							post.title,
    							post.content,
    							post.isAnonymous,
    							post.likeCount))
    .from(post)
    .fetch();

    DTO의 이름이 다르다면?

    ExpressionUtils.as(source, alias) : 필드나 서브 쿼리에 별칭 적용

    List<PostDto> result = queryFactory
    .select(Projections.fileds(PostDto.class,
    							post.title.as("title"),
    							post.content,
    							post.isAnonymous,
    							post.likeCount))
    .from(post)
    .fetch();
  3. 생성자 필요

    List<PostDto> result = queryFactory
    .select(Projections.constructor(PostDto.class,
    							post.title,
    							post.content,
    							post.isAnonymous,
    							post.likeCount))
    .from(post)
    .fetch();
  4. @QueryProjection
    DTO 생성자에 @QueryProjection을 붙여주면 DTO도 Q파일로 생성됩니다.

     import com.querydsl.core.annotations.QueryProjection;
    
    	public class PostDto {
    		private String title;
    		private String content;
    		private boolean isAnonymous;
    	
    		public PostDto (){}
    	
    		@QueryProjection
    		public PostDto (String titie, String content, boolean isAnonymous) {
    			this.title = title;
    			this.content = content;
    			this.isAnonymous = isAnonymous;
    		}
    	}

    위와 같이 작성하면 Q파일이 생성되며, 이 파일을 사용하면 됩니다.

     queryFactory
    	.select(new QPostDto(post.title, post.content, post.isAnonymous))
    	.from(post)
    	.fetch();
    • 장점
      • 컴파일 오류를 잡아낼 수 있습니다.
      • 컴파일 시점에 타입 체크, 파라미터 갯수체크가 가능합니다.
    • 단점
      • DTO에 QueryDsl에 대한 의존성이 생깁니다.
      • DTO는 Service 계층, Controller 계층 등 여러 계층을 넘어다니는 객체임으로 아키텍처 전반적으로 QueryDsl에 대한 의존성이 생기는 것이 큰 단점입니다.
      • 영속성 컨텍스트와 무관하게 동작합니다.

3. 적용

1. 문제 코드 사진 및 원인 파악


제가 작성한 게시글 조회 시, getComments()를 호출할 때마다 추가적으로 댓글 엔티티가 게시글 개수만큼 쿼리가 발생하는 것을 확인했습니다. 이는 N+1 문제로, 이대로 방치할 경우 운영 단계에서 데이터베이스 부하가 늘어나고 응답 속도가 느려지며, 동일한 데이터를 반복해서 가져오는 문제가 발생할 수 있습니다. 또한 데이터 불일치 문제도 발생할 수 있습니다.

2. 적용

| 해결 방법 1 : Fetch Join + Lazying 적용

먼저 Fetch Join과 Lazying을 적용해서 조회해 올 때, 한번에 commets까지 가져오도록 했습니다.

Post 엔티티와 그에 연관된 commentsINNER JOIN FETCH를 사용하여 comments를 함께 가져오고 있으며, DISTINCT를 사용하여 중복된 Post 엔티티가 반환되지 않도록 하고 있습니다.

이 방법을 사용하면 Fetch Join을 통해 Post와 관련된 comment를 한 번의 쿼리로 함께 조회할 수 있습니다. Lazy Loading이 활성화되어 있는 경우에는 코드를 실행할 때마다 Post 엔티티의 comment가 실제로 사용될 때 조회됩니다. 그러나 실제로 Fetch Join을 여러 컬렉션에 동시에 사용하려고 하면 MultipleBagFetchException을 만날 수 있습니다. 이는 Fetch Join이 여러 컬렉션에 동시에 적용되지 않기 때문입니다. 따라서 postPhoto에 대한 Fetch Join을 적용할 수 없었습니다. 이 문제를 해결하기 위해 batch_size를 사용하여 최적화했습니다.

| 해결 방법 2 : default_batch_fetch_size을 1000으로 설정하기


batch_size를 1000으로 설정하여 100개에서 3개로 감소했습니다. 예를 들어, 설정값이 30으로 되어 있다면 이제는 한 번의 쿼리로 30개의 게시글에 연관된 댓글 엔티티를 한꺼번에 가져올 수 있습니다. 하지만, 만약 조회할 게시글 수가 1000개를 넘어가면 다시 쿼리가 여러번 실행됩니다. 이러한 문제를 해결하기 위해 DTO로 결과를 받는 방법을 선택했습니다.

| 해결 방법 3 : DTO로 받기

QueryDsl에서 바로 DTO로 받아올 수 있게 수정하였습니다.

1. QueryDsl로 DTO 받기

DTO로 바로 받아와서 쿼리가 한번만 발생하여 N+1문제가 발생하지 않았습니다.

2. JPQL로 DTO 받기

querylDsl을 사용했던것과 같이 쿼리 하나만 나가는 것을 확인할 수 있었습니다.

3. 성능 테스트

💡 적용 전 테스트
초당 Request 요청: 1000회
테스트 시간: 60초
최소: 37ms, 최대: 179ms
http.200 응답률: 100%

💡 적용 후 테스트
초당 Request 요청: 1000회
테스트 시간: 60초
최소: 5ms, 최대: 40ms
http.200 응답률: 100%

N+1가 발생했을 때의 성능 테스트를 해보았습니다. 현재 최소 37m, 최대 179ms가 발생했었습니다.

N+1 문제를 해결하고 나서 성능 테스트 결과, 최소 응답 시간은 5ms로 32ms 감소했고, 최대 응답 시간은 40ms로 139ms 감소했습니다. 이는 각각 약 86.49%와 77.65%의 개선을 보였습니다.

4. 마무리

N+1 문제에 대해 조사하고 해결 방법을 적용하는 과정을 살펴보았습니다. 특히, 특정 엔티티의 1:N 관계에서 모든 컬렉션을 즉시 로딩하는 것이 불가능하다는 점이 N+1 문제를 발생시킬 수 있음을 인지했습니다. 이에 대응하여 아이돔 프로젝트에서는 DTObatch size를 함께 활용하여 N+1 문제를 해결했습니다. 이러한 접근 방식은 성능을 향상시키고 데이터베이스 부하를 줄일 수 있었습니다.


참고 자료

profile
기록기록기록기록기록

0개의 댓글