728x90

# 들어가기전

Kotlin + Dgs framework 를 사용하고 있습니다.

https://netflix.github.io/dgs/

 

Home - DGS Framework

How did this project begin? The DGS framework project started at Netflix in 2019 as internal teams began developing multiple GraphQL services. As 2020 wrapped up, Netflix decided to open source the framework and build a community around it. Is it productio

netflix.github.io

 

Graphql 에서는 왜 N + 1 문제가 발생하는가 ?

query {
    posts {
        comment {
            content
        }
        content
    }
}

 

위와 같은 query 를 Client 가 Server 로 요청을 한다고 가정해봅시다.

graphql 은 Type 의 field 에 resovler 를 달아 놓으면 해당 클라이언트에서 해당 Field 를 조회 할때 field 의 resolver 가 실행됩니다.

 

정말 유연하고 간편하긴 하지만 단점도 있습니다.

해당 게시글에 comment 가 10개가 있다고 한다면 해당 게시글은 comment 를 조회하기 위해 select 쿼리를 10번 날릴 것입니다.

 

Graphql 을 접한지 얼마 되지 않았거나 graphql 이 처음이신분들은 이 개념이 생소 하실수 있습니다.

Graphql 은 재귀적으로 설계되어 있기 때문에 발생되는 문제인데요

 

https://velog.io/@onikss793/GraphQL

 

Basic GraphQL

Restful...? graphQl에 대해 이야기하기 앞서, 우선 REST API에 대해서 간략하게 이야기해야 할 것 같다. REST API는 백앤드 서버와 클라이언트 서버가 분명하게 나뉘어지기 시작하면서, 효율적인 양 서버

velog.io

 

이것이 바로 N + 1 문제 입니다. 이것을 해결하기 위해서 한번에 조회 하기 위해서 Dataloader 라는게 등장했습니다.

dataloader 문서를 읽으면 Batch 라는 단어가 나오는데 함께 묶어서 처리한다는 뜻입니다.

 

https://netflix.github.io/dgs/data-loaders/

 

Data Loaders (N+1) - DGS Framework

Data Loaders (N+1) Data loaders solve the N+1 problem while loading data. The N+1 Problem Explained Say you query for a list of movies, and each movie includes some data about the director of the movie. Also assume that the Movie and Director entities are

netflix.github.io

 

직접 구현해보기

type Query {
    shows(titleFilter: String): [Show]
}

type Show {
    id: Int!
    title(format: TitleFormat): String!
    releaseYear: Int
    reviews: [Review]
    artwork: [Image]
}

input TitleFormat {
    uppercase: Boolean
}

type Review {
    username: String
    starScore: Int
    submittedDate: DateTime
}

type Image {
    url: String
}

graphql 스키마 입니다.

Show 안에 Reviews 를 N + 1 문제를 해결 하는 예제입니다.

 

showDataFetcher.kt

/**
 * 이 datafetcher는 쿼리의 'shows' 필드를 확인합니다.
 * @InputArgument를 사용하여 정의된 경우 쿼리에서 titleFilter 를 가져옵니다.
 * 구현 세부 사항으로 Kotlin Coroutine을 출력 유형으로 활용합니다.
 *
 */
@DgsQuery
suspend fun shows(@InputArgument titleFilter: String?): List<Show> = coroutineScope {
    if (titleFilter != null) {
        showsService.shows().filter { it.title.contains(titleFilter) }
    } else {
        showsService.shows()
    }
}

 

shows datafetcher 를 만듭니다.

 

@DgsComponent
class ReviewsDataFetcher {

    /**
     * 이 데이터 페처는 쇼의 "리뷰" 필드를 해결하기 위해 호출됩니다.
     * 각 개별 쇼에 대해 호출되므로 10개의 쇼를 로드하면 이 메서드는 10번 호출됩니다.
     * N 1 문제를 피하기 위해 이 datafetcher 는 DataLoader 를 사용합니다.
     * 각각의 개별 쇼 ID에 대해 DataLoader 가 호출되지만, ReviewsDataLoader 의 "load" 메소드에 대한 단일 메소드 호출로 실제 로드를 일괄 처리합니다.
     * 이것이 제대로 작동하려면 datafetcher 가 CompletableFuture 를 반환해야 합니다.
     */
    @DgsData(parentType = DgsConstants.SHOW.TYPE_NAME, field = DgsConstants.SHOW.Reviews)
    fun reviews(dfe: DgsDataFetchingEnvironment): CompletableFuture<List<Review>> {
        // DataLoader 를 이름으로 로드하는 대신 DgsDataFetchingEnvironment 를 사용하고 DataLoader 클래스 이름을 전달할 수 있습니다.
        val reviewsDataLoader: DataLoader<Int, List<Review>> = dfe.getDataLoader(ReviewsDataLoader::class.java)

        // 리뷰 필드가 Show 에 있기 때문에 getSource() 메서드는 Show 인스턴스를 반환합니다.
        val show: Show = dfe.getSource()

        // DataLoader 에서 리뷰를 로드합니다. 이 호출은 비동기식이며 DataLoader 메커니즘에 의해 일괄 처리됩니다.
        return reviewsDataLoader.load(show.id)

    }
}

 

ReviewsDataFetcher 를 만듭니다.

 

요청 쿼리

query {
    shows {
        reviews {
            username
        }
    }
}

 

요청쿼리는 다음과 같고 호출순서는 Shows 의 datafetcher 가 먼저 호출되고 그다음 ReviewsDataFetcher 가 실행됩니다.

 

@DgsDataLoader(name = "reviews")
class ReviewsDataLoader(val reviewsService: ReviewsService) : MappedBatchLoader<Int, List<Review>> {
    /**
     * 이 메서드는 여러 datafetcher 가 DataLoader 에서 load() 메서드를 사용하는 경우에도 한 번 호출됩니다.
     * 이렇게 하면 개별 쇼가 아닌 한 번의 호출로 모든 쇼에 대한 리뷰를 로드할 수 있습니다.
     */
    override fun load(keys: MutableSet<Int>): CompletionStage<Map<Int, List<Review>>> {
        return CompletableFuture.supplyAsync { reviewsService.reviewsForShows(keys.stream().toList()) }
    }

}

 

그다음은 Dataloader 입니다. MappedBatchLoader 는 첫번째 인자값으로는 PK 값 두번째 인자값으로는 리턴할 데이터 입니다.

CompletableFuture 와 supplyAsync 로 비동기적으로 데이터를 호출합니다.

 

import com.github.javafaker.Faker
import io.jongyun.graphinstagram.types.Review
import org.reactivestreams.Publisher
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import reactor.core.publisher.ConnectableFlux
import reactor.core.publisher.Flux
import reactor.core.publisher.FluxSink
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.concurrent.TimeUnit
import java.util.stream.IntStream
import javax.annotation.PostConstruct
import kotlin.streams.toList

interface ReviewsService {
    fun reviewsForShow(showId: Int): List<Review>?
    fun reviewsForShows(showIds: List<Int>): Map<Int, List<Review>>
    fun getReviewsPublisher(): Publisher<Review>
}

/**
 * 이 서비스는 데이터 저장소를 에뮬레이트합니다.
 * 데모의 편의를 위해 메모리에 리뷰를 생성하지만 예를 들어 데이터베이스가 이를 뒷받침한다고 상상해 보십시오.
 * 이것이 실제로 데이터베이스에 의해 지원된다면 N 1 문제를 피하는 것이 매우 중요할 것입니다. 즉, 이 클래스를 호출하기 위해 DataLoader 를 사용해야 함을 의미합니다.
 */
@Service
class DefaultReviewsService(private val showsService: ShowsService) : ReviewsService {
    private val logger = LoggerFactory.getLogger(ReviewsService::class.java)

    private val reviews = mutableMapOf<Int, MutableList<Review>>()
    private lateinit var reviewsStream: FluxSink<Review>
    private lateinit var reviewsPublisher: ConnectableFlux<Review>

    @PostConstruct
    fun createReviews() {
        val faker = Faker()

        //For each show we generate a random set of reviews.
        showsService.shows().forEach { show ->
            val generatedReviews = IntStream.range(0, faker.number().numberBetween(1, 20)).mapToObj {
                val date =
                    faker.date().past(300, TimeUnit.DAYS).toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime()
                Review(
                    username = faker.name().username(),
                    starScore = faker.number().numberBetween(0, 6),
                    submittedDate = OffsetDateTime.of(date, ZoneOffset.UTC)
                )
            }.toList().toMutableList()

            reviews[show.id] = generatedReviews
        }

        val publisher = Flux.create<Review> { emitter ->
            reviewsStream = emitter
        }


        reviewsPublisher = publisher.publish()
        reviewsPublisher.connect()

    }


    /**
     * Hopefully nobody calls this for multiple shows within a single query, that would indicate the N+1 problem!
     */
    override fun reviewsForShow(showId: Int): List<Review>? {
        return reviews[showId]
    }

    /**
     * 이것은 여러 쇼에 대한 리뷰를 로드할 때 호출하려는 메서드입니다.
     * 이 코드가 관계형 데이터베이스에서 지원되는 경우 단일 SQL 쿼리에서 요청된 모든 쇼에 대한 리뷰를 선택합니다.
     */
    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) }
    }


    override fun getReviewsPublisher(): Publisher<Review> {
        return reviewsPublisher
    }
}
import io.jongyun.graphinstagram.types.Show
import org.springframework.stereotype.Service

interface ShowsService {
    fun shows(): List<Show>
}

/**
 * 이 서비스는 쇼의 고정 인메모리 컬렉션을 제공합니다.
 * 보다 현실적인 구현에서는 데이터 저장소에서 쇼를 로드할 수 있습니다.
 */
@Service
class BasicShowsService : ShowsService {
    override fun shows(): List<Show> {
        return listOf(
            Show(id = 1, title = "Stranger Things", releaseYear = 2016),
            Show(id = 2, title = "Ozark", releaseYear = 2017),
            Show(id = 3, title = "The Crown", releaseYear = 2016),
            Show(id = 4, title = "Dead to Me", releaseYear = 2019),
            Show(id = 5, title = "Orange is the New Black", releaseYear = 2013)
        )
    }
}

 

id list 가 한번에 넘어와서 N + 1 문제없이 조회된걸 확인 할수 있습니다.

 

728x90

'Kotlin > GraphQL' 카테고리의 다른 글

WebFlux spring graphql 에서 Header Handling 하기  (0) 2022.10.01
GraphQL 에서 Resolver 의 역활  (0) 2022.07.28