들어가며
요즘카페 프로젝트를 진행하면서 개발자가 의도하지 않은 쿼리가 JPA로 인해 발생하는 문제점이 발생했다. 이를 개선하고자 한다.
엄청난 쿼리 카운트 ..................... 이걸 수정해보자
N+1
우리는 1개의 쿼리를 날렸을때 N개의 쿼리가 추가로 발생하는 것을 N+1 문제 라고 한다. ORM 기술인 JPA가 등장하면서 쿼리가 자동화되면서 발생하는 문제점이라고 할 수 있다.
객체에 대해 조회했을 경우에 연관관계매핑으로 관계 맺어진 다른 객체가 조회되는 것이다.
발생 이유
N+1 문제가 발생하는 이유는 JPA가 JPQL에서 SQL 생성할 때 Fetch 전략을 참고하지 않고 JPQL 자체만을 사용하기 때문에 발생한다.
fetch = FetchType.LAZY 일 경우
- findAll() 을 수행하면
select c.image, c.detail .. from Cafe c
라는 JPQL 구문이 생성된다. - JPQL 로부터
select * from Cafe
SQL이 생성되어 실행된다. - db로부터 sql 결과물을 받아 인스턴스를 생성한다.
- 프로그램 실행 중 cafe 의 image, detail 객체 조회,사용 시점에 영속성 컨텍스트에 image, detail 이 있는지 확인한다.
- 영속성 컨텍스트에 존재하지 않다면 3번에 만들어진 image, detail 개수에 맞게
select * from image where cafe_id = ?
SQL 구문이 생성되어 실행된다. - 5번 과정에서 N+1 문제가 발생한다.
그대로 두면 안되는가 ?
이를 그대로 두게 된다면 DB에 대해 여러번의 추가 쿼리를 발생시킬 수 있다. 당장 1 ~ 2개의 데이터에서는 크게 문제를 못 느낄 수 있다.
아래는 간단한 우리 프로젝트의 Cafe 구조이다.
DB에는 Cafe, AvailbleTime, Image 스키마가 생성되었다.
우리가 Cafe를 가져와서 Image와 AvailableTime을 조회 및 사용 하려고 한다.
FetchType Eager
엔티티를 즉시로딩 하면 읽어올 때 모든 엔티티를 조회해서 가져온다. 이 과정에서 jpa가 내부적으로 join쿼리문을 만들어서 반환해준다. 물론 OneToMany 2개의 관계로 이루어져 있어서 select문이 한개 더 나가긴 했지만 즉시로딩으로도 충분히 문제가 없어보인다!
하지만 문제는 Cafe를 조회할 경우 무조건적으로 모든 availableTime, image가 조회된다는 것이다. 필요없는 상황에도 모든 데이터를 가져온다는 것은 필요 없다.
FetchType Lazy
그럼 Lazy로 사용하면 너무 좋은 것 아닐까 ?
하지만 이경우에는 availableTime, image를 사용하고자 할 때 쿼리가 발생한다. LAZY 로딩을 할 경우 availableTime, image는 프록시 객체로 가져오고 그 외의 값들만 가져온다. 이후 조회, 사용 하고자 할 때 N+1 문제가 발생할 수 있다.
분명 나는 Cafe 의 모든 값을 가져오는 조회 쿼리만을 기대했는데 이후 Lazy의 image, availableTime 프록시를 조회 하다 보니 의도치 않은 쿼리가 발생했다. 만약 조회할 카페의 개수가 수십, 수백개가 된다면 그에 따라 의도치 않은 쿼리도 기하급수적으로 증가할 것이다.
이럴 경우 많은 문제가 있을 것 같다.
- DB 부하
- DB에는 대량의 추가쿼리가 실행되기 때문에 서버에 부하가 일어날 수 있다. DB 리소스 사용량도 증가할 것이다.
- 애플리케이션 문제점
- 애플리케이션에서 불필요한 쿼리 실행으로 응답 시간을 증가 시킬 수 있다.
- 데이터 일관성 문제
- 쿼리 실행 순서, 타이밍을 모르는 상태라면 일관성이 깨지는 문제가 발생할 수 있을 것이다.
문제점
현재 프로젝트에서 크게 문제가 되었던 부분은 크게 카페, 멤버, 메뉴, 메뉴판 이었다.
카페
사용자가 카페를 조회할 경우 카페가 가진 각각의 image, availbletime에 대해 단일 쿼리를 발생시킨다.
상단의 사진은 일부만 가지고 왔는데 N개의 카페를 조회할 경우 1+(N)+(N) 개의 쿼리가 더 발생하는 것이다 !!
당장 5개의 카페만 조회하더라도 11개의 쿼리가 발생한다. 100개라면 201개의 쿼리가 발생할 여지가 생기는것이다.
멤버
우리 도메인에서는 아래 그림처럼 Member가 OneToMany 관계매핑을 하고 있다.
좋아요한 카페를 조회하고자 할 때 아래 그림처럼 많은 쿼리가 발생 할 수 있다. 하단 예시는 2개의 testCase만을 가지고 한 것인데 실제 서비스에서는 15개를 조회 하기에 최소 1+(15)개의 쿼리가 발생할 수 있다.
시청하지 않은 카페를 도메인에서 조회할 일이 있는데 이 경우에도 추가 쿼리가 발생하는 것을 확인할 수 있다. Member를 읽어오는 과정에서 시청하지 않은 카페를 읽을 수 있는데 이 경우 N+1 문제가 발생한다.
메뉴/메뉴판
처음에는 메뉴/메뉴판이 ManyToOne으로 Cafe를 가지고 있어서 당연히 N+1이 발생하겠다 였다. 하지만 막상 확인해보니 현재 사용하는 로직에서는 Cafe를 사용하지도 않고 DTO에도 반영을 해주지 않았다! 그러다 보니 필요 없었다.
💡 무지성으로 걸지 말고 .. 실제로 사용하는지 꼭 확인해보자
어떻게 해결할까 ?
N+1 문제는 결국에는 연관된 데이터를 같이 가지고 오면 해결될 수 있다.
단순 JOIN
처음에는 그냥 단순 join으로 값을 가져오면 되지 않을까? 라고 생각했다. 이럴 경우 오직 JPQL에서 조회되는 Entity만 조회하여 영속화를 하는 것이다. 나는 특정 entity와 연관된 다른 entity들도 영속화를 하고 싶었기 때문에 단순하게 join을 통해서 처리하는것은 포기했다.
Cafe를 조회하면 Cafe만 영속화 시켜주는 것이다.
EntityGraph
@EntityGraph 라는 어노테이션을 이용해서 같이 조회할 연관 엔티티를 받아올 수 있다.
@EntityGraph(attributePaths = {"images"})
EntityGraph와 FetchJoin의 차이점은 Join 방식의 차이 따로 SQL문을 작성하지 않아도 된다는점이다. 이를 살려보려고 했지만 내가 무언가를 잘못한 것인지 join 쿼리가 실행되지 않는 문제가 발생했다. 그래서 결국 FetchJoin을 통해 데이터를 가져오기로 하였다.
이건 아직도 모르겠다 . .🥲
하지만 난 사용 가능하더라도 EntityGraph를 사용안했을 것 같다. 쿼리 뒤에 join fetch만 추가하면 되는데 다른 어노테이션과 속성까지 관리해야되는게 불편할 수 있을 것 같아서이다.
Fetch Join
JPQL을 사용하여 DB에서 값을 가져올 때 JOIN처럼 연관된 데이터를 가지고 오는 방법이다. 이 방법은 위에서 연관된 데이터 영속화 까지 진행시켜준다.
@Query("select distinct c from Cafe c left join fetch c.images.urls")
위와 같이 SQL문을 작성해줘야 한다.
진행을 하다 보니 2가지 어려움에 봉착했다.
2개 이상의 컬렉션 FetchJoin의 MultipleBagFetchException
현재 우리 구조는 밑의 그림처럼 OneToMany 관계가 2개 이상이다. 나는 이것을 fetchJoin을 통해서 둘다 가져오고 싶었다.
실제로 진행을 하다 보니 MultipleBagFetchException 이 발생했다. 이는 중복을 방지하기 위해 터진다고 한다. 두개의 컬렉션을 join 할 때 발생하는 카테시안 곱 발생을 막기 위해서 이렇게 진행한다. 데이터가 얼마 없을 경우에는 성능에 커다란 영향을 미치지 않겠지만 많아질 경우 문제가 많이 발생할 수 있었다.
이를 해결하는 방법은
- List를 Set으로 설정
- BatchSize 설정
1번 과 같은 경우는 내가 자료구조의 변경을 원치 않고 순서도 없으며 get()을 통해 한번에 가져오지도 못하는 자료구조적 이슈때문에 변경되어야할 코드의 양이 많아진다는 많은 단점하에 skip 하였다.
그래서 2번을 통해 해결하기로 하였다. 해결하고자 2개 컬렉션 중 1개는 fetchJoin을 통해 가져오고 1개는 BatchSize in절을 통해 가져오기로 하였다.
이럴 경우 어떤 것을 fetchJoin으로 가져오고 어떤 것을 BatchSize를 통해 가져오는가 ?
나 같은 경우에는 urls, availbleTimes 중에 데이터가 가장 많을 수 있는 곳에 fetchJoin을 걸기로 하였다. BatchSize같은 경우에는 지정한 size만큼 in절로 가져오게되는데 이 경우에 데이터가 많아질수록 쿼리가 증가할 수 있기에 더 적은 데이터가 있는 곳을 batchsize를 걸기로 하였다. 우리 프로젝트에서는 N개의 카페가 최대 10개 사진, 7개의 영업시간을 가지고 있기에 image.urls에 걸었다.
다른 문제는 Paging처리하는 로직이 많은데작동을 안한다는 것이었다!!
@Query("select distinct c from Cafe c left join fetch c.images.urls")
Slice<Cafe> findSliceBy(Pageable pageable);
난 당연하게도 이런식으로 진행하면 paging처리는 알아서 될줄 알았다. 근데 쿼리를 확인해보니
limit, offset 쿼리는 없고
괴상한 경고만 있다. Collection을 fetch 한결과에 대해 인메모리를 적용해서 조인을 진행했다 라는 것이다. 이는 모든 데이터를 메모리에 가져다가 놓고 메모리 상에서 페이징 처리를 해준다.. 라는 의미인 것 같은데 이러면 우리가 페이징을 사용하는 의미가 하나도 없다! out of memory가 발생할 가능성이 높아진다.
아마도 페이징의 생명은 데이터 갯수 대로 딱딱 잘라서 나눠주는 것인데 fetch를 하다보니 많은 카테시안 곱이 생겼고 그래서 어떤 것을 페이징 처리해야되는지 모르는 것이 문제인 것 같다. 그래서 페이징 처리는 BatchSize를 이용해서 처리하기로 하였다.
Batch Size
batchSize는 N+1 문제를 아예 없앨 수는 없다. 하지만 한꺼번에 지정한 size 만큼 SQL의 IN절을 통해서 조회해온다.
spring.jpa.properties.hibernate.default_batch_fetch_size=1000
현재 이런식으로 최대 1000개의 in절 조회를 하도록 설정하였다.
BatchSize의 원리는 처음에 Cafe 100개를 조회하고, availbleTime을 조회할 때 Cafe id 100개를 모두 알고 있으니 이를 in절을 사용하여 availbleTime을 조회하는 것이다.
크기는 얼마로 설정하는게 좋을까 ?
이 고민을 진짜 많이 했다. 확실하게 어떻게 하는게 좋겠다 라는 근거를 찾지는 못했다.
다만 BatchSize가 너무 크면 한번에 많은 엔티티 1000개의 엔티티가 메모리에 로딩되어 문제가 발생할 수 있고, 적을 경우에는 1+(데이터개수)/1000 처럼 추가적으로 나가는 쿼리의 양이 많아질 수 있다.
적당히 알아서 애플리케이션에 맞게 설정해야되는 것 같다 . .
그래서 얼마나 좋아짐 ?
현재 우리 서비스에서 발생하는 상황이다. 카페, 멤버 조회만 보자면
5개의 카페 조회 시 쿼리 개수 1+10 → 1 + 1 , 소요시간 :0.723 → 0.7
5개의 멤버 조회 시 쿼리 개수 1+5 → 1 + 1 , 소요시간 :0.4 → 0.33
훨씬 많은 데이터를 조회해보았을 때 차이가 커진다.
1000 개의 카페 조회 시 쿼리 개수 1+2001 → 1 + 2, 소요 시간 0.9 -> 0.159
쿼리는 단 두개로 끝냈다 거의 2000개의차이가 발생했다.
나가며
N+1 문제에 대해 정확하게 인지하고 이를 실제로 해결하면서 개선이 되어진 것을 보니 뿌듯하고 재미있었다. 막상 코드적으로 수정된 것 보다 공부한 시간이 훨씬 많았던 것 같다.
이 글을 작성하면서 생긴 궁금증이 있다. 미처 해결하지는 못했는데 BatchSize를 사용해서 처리하는 것이 N+1 문제도 터지지 않고 좋아보이는데 JPA에서는 왜 기본 값으로 설정하지 않은 걸까 ?
'코코코딩공부' 카테고리의 다른 글
테스트를 더 빠르게 진행시켜보자 (0) | 2023.10.21 |
---|---|
TestContainer 사용기 & 테스트 격리 (1) | 2023.10.16 |
DB Replication (1) | 2023.10.15 |
DataSource 라우팅이 안되는 이유. OSIV (1) | 2023.10.09 |
Flyway 적용해보기 (0) | 2023.09.23 |