Kotlin GraphQL Data Fetching Guide (With DGS)
Introduce
GraphQL은 rest api와 달리 하나의 엔드포인트를 가지며 클라이언트는 정의된 스키마에 의해 원하는 필드를 자유롭게 조회할 수 있다.
예를들어, 아래와 같이 스키마가 정의되어 있다면
schema.graphqls
type Query {
product(id: ID!): Product!
}
type Product {
id: ID!
name: String!
shop: Shop!
}
type Shop {
id: ID!
name: String!
}
클라이언트는 다양한 방식의 쿼리가 가능하다.
{
product(i: "1"){
id
name
shop{
id
name
}
}
}
...
{
product(id: "1"){
name
}
}
...
{
product(id: "1"){
id
shop{
id
name
}
}
}
다양한 쿼리에 응답하기위해 서버는 항상 모든 필드의 데이터를 조회해야할 필요는 없다(graphql의 장점)
graphql 서버는 쿼리 요청에 포함되지않은 필드에 대해 연산을 하지 않게 만들 수 있다.
DataFetcher
응답에 필요한 데이터를 가져오는 방식은 2가지가 있다.
1. 모든 필드를 포함한 데이터를 생성하여 반환
응답을 만드는 가장 쉬운 방법은 product 쿼리 요청에 대해서 Product의 모든 필드를 만들어 응답하는 방식이다.
모든 데이터를 반환해도 프레임워크에서 요청된 필드만 잘라 보내준다.
@DgsComponent
class ProductResolver{
@DgsQuery
fun product(@InputArgument id: ID): Product {
return Product(id = id, name = "나이키 에어맥스", shop = getShop(id))
}
fun getShop(id: ID): Shop {
...
}
}
이 방법은 간단하지만 아래와 같이 shop 필드가 없는 요청에도 불필요한 shop조회가 항상 이뤄지는 문제가 있다.
{
product(i: "1"){
id
name
}
}
대부분의 요청이 shop없이 조회 하고, shop을 호출하는 비용이 상대적으로 큰 경우 모든 데이터를 가져와 반환하는 방식은 오히려 shop포함 여부를 나눠 만든 2개의 rest api보다 효율적이지 못하다.
2. @DgsData를 활용한 data fetching
다음은 dgs에서 data fetching을 할 수 있는 방법이다.
@DgsComponent
class ProductResolver{
@DgsQuery
fun product(@InputArgument id: ID): Product {
return Product(id = id, name = "name_$id", shop = null)
}
@DgsData(parentType = DgsConstants.PRODUCT.TYPE_NAME, field = DgsConstants.PRODUCT.Shop)
fun shop(dfe: DgsDataFetchingEnvironment): Shop {
val product: Product = dfe.getSource()
return getShop(product.id)
}
...
}
@DgsData를 활용하면 함수를 data fetcher로 만든다. data fetcher는 DgsDataFetchingEvironment를 가져올 수 있다. 여기에는 다양한 데이터를 포함한다. (parentType 객체, request관련 데이터등)
이 data fetcher는 schema와 매핑되어 특정 필드가 요청될 때 실행된다.
예를 들어 @DgsData의 parentType = Product, field = Shop인경우 Product.shop필드를 호출할때 이 함수가 실행된다.
data fetcher 사용 필드를 포함하는 객체 생성
위의 예시 코드는 사실 약간의 오류가 있다.
type Product {
id: ID!
name: String!
shop: Shop!
}
스키마상 shop은 조회를 했을때 항상 존재해야한다. codegen 플러그인으로 이 타입을 자동 생성해도 shop은 non-null타입으로 생성된다.
ex) com.netflix.dgs.codegen 을 사용하여 자동 생성된 class
public data class Product(
@JsonProperty("id")
public val id: String,
@JsonProperty("name")
public val name: String,
@JsonProperty("shop")
public val shop: Shop
) {
public companion object
}
non-null 타입으로 생성되기 때문에 Product객체를 반환할때 Shop객체가 반드시 필요하다.
@DgsQuery
fun product(@InputArgument id: ID): Product {
return Product(id = id, name = "name_$id", shop = ???)
}
data fetcher를 통해 shop을 가져오는 것은 그 다음 순서이다.
lateinit을 사용한 data fetch대상 지연 초기화
kotlin은 lateinit이 존재한다. lateinit을 사용하면 값을 넣어주지 않아도 선언이 가능하고 사용 시점에 값이 초기화가 되지않았다면 예외를 반환한다.
data class ProductResponse(
@JsonProperty("id")
val id: String,
@JsonProperty("name")
val name: String,
) {
@JsonProperty("shop")
lateinit var shop: Shop
}
data fetcher를 사용해서 가져오는 shop은 lateinit으로 선언하여 값을 넣어주지 않아도 ProductResponse객체 생성에 문제가 없도록 할 수 있다.
함수형 타입을 사용한 default값 설정
kotlin에서는 함수형 타입이 존재하므로 이러한 방식으로 처리할 수도 있다.
data class ProductResponse(
@JsonProperty("id")
val id: String,
@JsonProperty("name")
val name: String,
@JsonProperty("shop")
val shop: () -> Shop = { throw FieldInitializeException(...) }
)
shop을 함수형으로 선언하고 예외를 반환하는 함수를 default로 넣어준다.
이렇게 설정하면 사용시점에 값이 변경되지않았으면(초기화되지않았으면) 해당 예외를 발생시킨다.
dgs의 codegen의 옵션중generateKotlinNullableClasses=true 로 설정하고 codegen을 해보면 모든 필드를 초기화 되지않으면 예외를 발생시키는 함수형 타입으로 만들어준다.
하지만 이 옵션은 권장하지않는다. 모든 타입이 default로 선언되어있다면 객체 생성시 필요한 데이터가 쉽게 누락될 수 있고 이런 사실을 누락된 필드를 쿼리하는 시점에 예외가 발생하여 확인가능하다.
ex) generateKotlinNullableClasses=true 생성된 예시
@JsonTypeInfo(use = JsonTypeInfo.Id.NONE)
@JsonDeserialize(builder = Product.Builder::class)
public class Product(
id: () -> String = idDefault,
name: () -> String = nameDefault,
shop: () -> Shop = shopDefault,
) {
...
@get:JvmName("getId")
public val id: String
get() = _id.invoke()
@get:JvmName("getName")
public val name: String
get() = _name.invoke()
@get:JvmName("getShop")
public val shop: Shop
get() = _shop.invoke()
public companion object {
private val idDefault: () -> String =
{ throw IllegalStateException("Field `id` was not requested") }
private val nameDefault: () -> String =
{ throw IllegalStateException("Field `name` was not requested") }
private val shopDefault: () -> Shop =
{ throw IllegalStateException("Field `shop` was not requested") }
}
...
}
schema에 해당되는 class를 만들어주는건 꽤 노가다 작업이다.
기본적으로 codegen을 통해 자동으로 생성되는 클래스를 사용하고
data fetcher를 구성해줘야하는 상황에는 커스텀 클래스를 만드는 방식을 사용하는 편이 낫다.
data fetcher를 부분적으로 사용하고 싶은 경우
어떤 쿼리에서는 연산을 지연시키고 싶을 수 있지만 때로는 한번에 데이터 전체를 만들어 내리고 싶은 상황이 있을 수 있다. (예를들어 데이터를 전부 들고오는 비용이 적거나, 각각 조회하는 비용이 더 큰 경우)
아쉽게도 data fetcher는 graphql schema type과 매핑된다.
예를들어 각각의 쿼리가 Product.shop타입을 사용한다면 해당 타입에 매핑된 data fetcher로직을 공유한다.
이런 상황이라면 이미 데이터가 존재해도 data fetcher에서 다시 데이터를 가져오는 상황이 발생한다.
이 문제의 해결책으로 data fetch로직에 필드 초기화 여부를 검사하여 skip하는 방식을 적용할 수 있다.
ex)
query에서는 이미 값이 다 존재하므로 data fetcher를 통해 가져올 필요가 없다.
@DgsQuery
fun product(@InputArgument id: ID): ProductResponse {
val product = productService.getProductWithShop(id)
return ProductResponse(id = id, name = product.name, shop = product.getShop)
}
응답 타입에는 초기화 여부를 확인할 수 있는 함수를 작성해준다.
data class ProductResponse(
@JsonProperty("id")
val id: String,
@JsonProperty("name")
val name: String,
) {
@JsonProperty("shop")
lateinit var shop: Shop
fun isShopInitialized() = ::shop.isInitialized // laitinit 초기화 여부를 확인
}
이미 값이 초기화된 경우 존재하는 shop을 return한다.
@DgsData(parentType = DgsConstants.PRODUCT.TYPE_NAME, field = DgsConstants.PRODUCT.Shop)
fun shop(dfe: DgsDataFetchingEnvironment): Shop {
val product: ProductResponse = dfe.getSource()
if(product.isShopInitialized()) return product.shop // getShop로직 skip
return getShop(product.id)
}
N + 1
위의 과정을 통해서 필드 요청에 따라 연산을 지연시키는 방법과 non-null 타입 필드의 초기화를 지연시켜 객체를 생성하는 방식을 알아봤다. 다만, data fetcher를 사용할 때 주의해야하는 사항이 있는데 익히 알려진 N+1문제이다.
product_list를 조회하려고한다.
schema.graphqls
type Query {
product(id: ID!): Product!
product_list(id_list: [ID!]!): Product!
}
type ProductList {
total_count: Int!
item_list: [Product!]!
}
type Product {
id: ID!
name: String!
shop: Shop!
}
type Shop {
id: ID!
name: String!
}
다음과 같은 쿼리의 요청이 들어온다고 가정해보자.
{
product_list(idList: ["1", "2", "3", "4", "5"]){
total_count
item_list{
id
name
shop{
id
name
}
}
}
}
data fetcher에서는 product마다 getShop이 호출된다 (shop조회가 N번 호출된다)
product id [1, 2, 3, 4, 5] request!
getShop ("1")
getShop ("2")
getShop ("3")
getShop ("4")
getShop ("5")
data loader
graphQL에서는 data loader를 사용하여 이런 조회를 batch단위로 처리할 수 있다.
@DgsDataLoader(name = "shop")
class ShopDataloader : MappedBatchLoader<ID, Shop> {
override fun load(ids: Set<ID>): CompletionStage<Map<ID, Shop>> {
return CompletableFuture.supplyAsync{ getShopMap(ids)}
}
fun getShopMap(Ids: Collection<ID>): Map<ID, Shop> {
... batch 단위 처리로직 구현
}
}
MappedBatchLoader의 load함수는 set을 파라미터로 받아온다.
여기서 배치 단위로 넘어온 값을 통해 batch단위 조회 로직을 만들어서 적용하면된다.
변경된 data fetching로직은 다음과 같다.
@DgsData(parentType = DgsConstants.PRODUCT.TYPE_NAME, field = DgsConstants.PRODUCT.Shop)
fun shopLoader(dfe: DgsDataFetchingEnvironment): CompletableFuture<Shop> {
val loader: DataLoader<ID, Shop> = dfe.getDataLoader(ShopDataloader::class.java)
val product: ProductResponse = dfe.getSource()
if (product.isShopInitialized()) return CompletableFuture<Shop>().completeAsync{product.shop}
return loader.load(product.id)
}
다시한번 동일한 쿼리를 요청해보면
{
product_list(idList: ["1", "2", "3", "4", "5"]){
total_count
item_list{
id
name
shop{
id
name
}
}
}
}
data fetcher에서는 구현한 data loader에 맞춰 get shop이 배치단위로 실행된다.
product id [1, 2, 3, 4, 5] request!
getShopInBatch [1, 2, 3, 4, 5]
dgs data loader에서는 batch 단위로 처리할 수 있는 기능 이외에도 max batch size조절, 캐싱등을 지원한다