[spring] graphql에서의 api call 발생시의 thread 분할정복 (N+1 대처방안)

sujin·2023년 6월 5일
0

spring

목록 보기
2/13

N+1 문제

N+1문제는 언제 발생하는가? 연관관계가 복잡하게 얽혀있을 때 주로 발생을 할 것이다.
예를 들어서, reviewService에 특정 Show와 관련된 review list를 제공하는 서비스인 reviewsForShows가 존재한다고 해보자!
method 이름에서도 알 수 있듯이 shows 대해서 reviews를 제공한다. 그렇다는 것은 review의 ids(list)가 넘어가고 결과적으로 각각의 show id가 List<Review>를 담고 있는 형태인 Map이 return 될 것이다.

아래는 reviewsForshows 로 서비스 영역이다.

override fun reviewsForShows(showIds: List<Int>): Map<Int, List<Review>> {
    logger.info("Loading reviews for shows ${showIds.joinToString()}")

    return reviews.filter { showIds.contains(it.key) }
}

이때, 코드에서 여러개의 show에 대한 여러개의 review를 loading해야한다. 만약에 코드가 관계형 데이터 베이스를 사용한다고 해보자! 이때는 모든 각 single show에대해서 한번(Map return의 key값) query를 할 때 그것과 관련된 reveiw list(Map return에서의 value값)을 조회하게 될 것이다.

여기서 single query라고했다. 이때 1번의 query가 나가게 될 것이다.
그렇다면 그 하나의 show query에 대해서 관련된 review를 찾기 위해서 review가 전체 n개라면 n번을 조회하게 될 것이다.
따라서, 여기서는 관계형 데이터베이스에서 1+n의 문제가 발생하게 될 것이다.

해결방안

해결방안은 dataloader를 만드는 것이다.
dataloader를 바구니라고 생각하면 쉽다. 한번에 처리하기 위해서 N개의 쿼리를 모아놓고 한번에 날려버리는 것이다.
어떻게 이것이 가능할까? 그것은 비동기로 병렬처리를 진행하기 때문에 한번에 여러 작업이 가능하기 때문이다.

아래는 위의 서비스에 대해서 N+1문제를 해결하기 위해서 생성한 dataloader이다.

@DgsDataLoader(name = "reviews")
class ReviewsDataLoader(val reviewsService: ReviewsService): MappedBatchLoader<Int, List<Review>> {
    /**
     * This method will be called once, even if multiple datafetchers use the load() method on the DataLoader.
     * This way reviews can be loaded for all the Shows in a single call instead of per individual Show.
     */
    override fun load(keys: MutableSet<Int>): CompletionStage<Map<Int, List<Review>>> {
        return CompletableFuture.supplyAsync { reviewsService.reviewsForShows(keys.stream().toList()) }
    }

}

load를 사용해서 service를 구현하면 된다. ReviewsDataLoader.load(ids)를 fetcher에서 반환하면 될 것이다.
위에서 중요한 것은 CompletationStage & supplyAsync 의 조합이다.

두개를 사용함으로써, 하나의 cpu 작업을 fork로 자식 process를 만들어서 thread를 분리시키고 이후에 thread를 병렬로 asynce 처리를 진행하기 위해서 supplyAsync를 사용한다. 이때 CompletationStage 의 경우에는 스레드 분할 정복을 제공하는데 지금과 같은 (n+1)문제가 M번 발생이 가능한 경우처럼 query가 엄청나게 늘어날 수 있다면 분할하여 처리하면 성능에 도움을 줄 것이기 때문에 사용한다.

다만, mutation처럼 update action 수행이 아닌, 단순한 get과 같은 query를 하는데 불필요하게 thead를 분리시키면 그 작업에 있어 비용이 발생하기 때문에 오히려 시간이 더 많이 들 수 있다.

그리고 CompletableFuture의 경우에는 runAsync와 supplyAsync로 구현을 할 수 있는데 어떤 종류의 바구니를 사용하는지를 결정한다고 보면 될 것 같다:)

  • runAsync
    반환값이 존재하지 않는 경우(Void type)에 비동기로 작업 실행 콜 목적일 경우 사용
  • supplyAsync
    반환값이 있는 경우에 비동기로 작업 실행 콜 목적일 경우 사용

둘다 비동기인것은 당연한데 반환값의 유무에 따라서 선택하면 될 것같다.!
위에서는 CompletionStage<Map<Int, List<Review>>>으로 반환값을 제공하기 때문에 반환값이 존재하고 supplyAsync를 사용하면 된다.

정리

정리하자면, query를 날릴 때 항상 1+N의 문제와 같이 최악의 경우를 생각하고 이를 대비하기 위한 방안을 마련해놔야한다는 것이다.

사실 dataloader는 graphql에서의 종속성을 가지고 있지 않지만, graphql에서 주로 많이 사용한다고 한다. 종속성이 없으니, 더욱 사용하기에 적합하다고 생각이 든다!

위의 코드는 설명을 위하여 나도 처음에 graphql 학습을 진행할 때 N+1문제 해결방안을 kotlin과 graphql의 공식사이트 project 예제 tutorial을 통해 진행했는데 여기에서 확인이 가능하다. 현재는 kotlin이지만 java코드 역시 확인이 가능하다!

0개의 댓글