[GraphQL] N+1 문제와 DataLoader 알아보기
이번 포스팅에서는 N+1 문제가 무엇이며, 이 문제를 DataLoader로 어떻게 해결하였는지 살펴본다.
N+1 문제
Listing
타입을 배열로 반환하는 루트 쿼리인 featuredListings
가 정의된 GraphQL 스키마가 있다.
type Listing {
id: ID!
"The listing's title"
title: String!
"The listing's description"
description: String!
"The amenities available for this listing"
amenities: [Amenity!]!
}
type Amenity {
id: ID!
"The amenity category the amenity belongs to"
category: String!
"The amenity's name"
name: String!
}
type Query {
"A curated array of listings to feature on the homepage"
featuredListings: [Listing!]!
}
이 스키마를 바탕으로 GetFeaturedListingsAmenities
쿼리를 호출해보자.
query GetFeaturedListingsAmenities {
featuredListings {
id
title
amenities {
id
name
category
}
}
}
맨 처음에는 리졸버의 Query.featuredListings
를 호출하게 된다.
import { Resolvers } from "./types";
export const resolvers: Resolvers = {
Query: {
featuredListings: (_, __, { dataSources }) => {
return dataSources.listingAPI.getFeaturedListings();
},
},
...
};
하지만 이 리졸버는 amenities 필드에 id
만 반환하게 된다.
[0] amenities: [
[0] { id: 'am-15' },
[0] { id: 'am-16' },
[0] { id: 'am-17' },
[0] { id: 'am-4' },
[0] { id: 'am-5' },
[0] { id: 'am-6' },
[0] { id: 'am-7' }
[0] ]
amenities에 대해 name
, category
필드를 더 채워오기 위해 GraphQL은 Listing.amenities
리졸버를 호출하게 된다.
export const resolvers: Resolvers = {
...
Listing: {
amenities: (data, _, { dataSources }) => {
return dataSources.listingAPI.getAmenities(data.id);
}
}
};
그럼 이제 GetFeaturedListingsAmenities
쿼리를 호출 했을 때, DataSource를 통해 API 호출이 총 몇 번 발생했는지 console.log를 통해 확인해보자.
import { RESTDataSource } from "@apollo/datasource-rest";
import { Listing, Amenity } from "../types";
export class ListingAPI extends RESTDataSource {
baseURL = "https://rt-airlock-services-listing.herokuapp.com/";
getFeaturedListings(): Promise<Listing[]> {
console.log("Calling for featured listings");
return this.get<Listing[]>("featured-listings");
}
getAmenities(listingId: string): Promise<Amenity[]> {
console.log("Making a follow-up call for amenities with ", listingId);
return this.get<Amenity[]>(`listings/${listingId}/amenities`);
}
}
터미널에서는 GetFeaturedListingsAmenities
쿼리를 호출했을 때, Query.featuredListings
에 대해서는 1번 호출하게 된다. 하지만 이 쿼리에서 listingAPI 데이타소스의 getFeaturedListings
함수가 반환한 데이터의 갯수가 3개이므로, 각 데이터에 대해 amenities를 채워오기 위해, 총 3번의 추가적으로 Listing.amenities
를 호출하게 되는 것을 알 수 있다.
Calling for featured listings
Calling for amenities for listing listing-1
Calling for amenities for listing listing-2
Calling for amenities for listing listing-3
지금은 데이터가 3개였으므로, 추가적으로 3번의 API 호출이 발생하였다. 만약 이 데이터가 10,000개라면? 우리는 쿼리를 한 번 호출했지만, 내부적으로는 각 데이터 별로 10,000번의 API 호출이 추가적으로 더 발생하게 된다. 처음 쿼리를 요청한 결과에 따라서 N번의 추가적인 요청이 발생할 수 있다. 이것을 N+1 문제라고 한다.
DataLoader
N번의 API 호출에서 얻을 수 있는 결과를 1번의 API 호출에서 똑같이 가져올 수 있다면, 1번만 API를 호출하는게 낫다. 단순히 GraphQL을 사용해서는 이렇게 처리할 수 없다. GraphQL은 각 데이터에서 필요한 정보를 모두 채울 때까지 연쇄적으로 리졸버를 호출하는 리졸버 체이닝이 발생할 수 있기 때문이다.
GraphQL은 이 때 필요한 것이 DataLoader인데, DataLoader는 GraphQL의 N+1 문제를 해결해준다. DataLoader는 N번의 요청들을 모아서 한 번만 API를 호출하는 배칭 기능을 제공한다.
기존 데이타소스의 getAmenities
함수는 listingId
한 개만 인자로 받아서 API 호출을 하였다. DataLoader를 추가하여, 한 쿼리 호출에서 getAmenities
함수를 호출 한 것들을 모아서 DataLoader에 전달하게 된다.
import { RESTDataSource } from "@apollo/datasource-rest";
import { Listing, Amenity } from "../types";
import DataLoader from "dataloader";
export class ListingAPI extends RESTDataSource {
private batchAmenities = new DataLoader(
async (listingIds): Promise<Amenity[][]> => {
console.log("Making one batched call with ", listingIds);
const amenitiesLists = await this.get<Amenity[][]>("amenities/listings", {
params: {
ids: listingIds.join(","),
}
});
console.log(amenitiesLists);
return amenitiesLists;
}
);
baseURL = "https://rt-airlock-services-listing.herokuapp.com/";
getFeaturedListings(): Promise<Listing[]> {
console.log("Calling for featured listings");
return this.get<Listing[]>("featured-listings");
}
getAmenities(listingId: string): Promise<Amenity[]> {
console.log("Passing listing ID to the data loader: ", listingId);
return this.batchAmenities.load(listingId);
}
}
터미널을 통해 console.log가 어떻게 찍혔는지 확인해보자. 총 3번의 getAmenities 함수 호출이 있었지만, 이 함수에서 dataloader를 한 번만 호출한 것을 알 수 있다.
[0] Calling for featured listings
[0] Passing listing ID to the data loader: listing-1
[0] Passing listing ID to the data loader: listing-2
[0] Passing listing ID to the data loader: listing-3
[0] Making one batched call with [ 'listing-1', 'listing-2', 'listing-3' ]
참고
Subscribe to my newsletter
Read articles from 양예진 directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by