[GraphQL] N+1 문제와 DataLoader 알아보기

양예진양예진
4 min read

이번 포스팅에서는 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

A diagram showing the followup request needed for each listing's amenities

지금은 데이터가 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를 호출하는 배칭 기능을 제공한다.

A diagram showing listing Ids being batched by a data loader

기존 데이타소스의 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' ]

참고

0
Subscribe to my newsletter

Read articles from 양예진 directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

양예진
양예진