아키텍처
아키텍처"n+1 문제" 억제하기

"n+1 문제" 억제하기

Gato GraphQL이 아키텍처 설계를 통해 어떻게 "n+1 문제"를 완전히 방지하는지 알아보겠습니다.

"n+1 문제"란 무엇인가

"n+1 문제"란 기본적으로, 데이터베이스에 대해 실행되는 쿼리의 수가 그래프의 노드 수만큼 커질 수 있다는 문제입니다.

어떤 의미일까요? 예시를 통해 확인해 보겠습니다. 감독 목록을 가져오고, 각 감독의 영화를 다음 쿼리로 가져오고 싶다고 가정해 보겠습니다.

{
  query {
    directors(first: 10) {
      name
      films(first: 10) {
        title
      }
    }
  }
}

효율적으로 처리하려면 데이터베이스에서 데이터를 가져오기 위해 쿼리를 2번만 실행하면 됩니다. 1번은 감독 데이터를 가져오고, 1번은 모든 감독의 모든 영화 데이터를 가져옵니다.

그러나 이 쿼리를 충족시키기 위해 GraphQL은 데이터베이스에 대해 "n+1"번의 쿼리를 실행해야 합니다. 먼저 N명의 감독(이 경우 10명) 목록을 가져오기 위해 1번, 그리고 N명의 감독 각각에 대해 영화 목록을 가져오기 위해 1번씩 실행합니다. 이 예시에서는 총 1+10=11번의 쿼리를 실행해야 합니다.

이 문제가 발생하는 이유는 GraphQL 리졸버가 같은 종류의 모든 객체를 한꺼번에 처리하는 것이 아니라, 한 번에 1개의 객체만 처리하기 때문입니다. 이 예시에서는 (루트 타입인) Query 타입의 객체를 처리하는 리졸버가 모든 Director 객체 목록을 가져오기 위해 처음 1번 호출되고, 그 후 Director 타입을 처리하는 리졸버가 각 Director 객체에 대해 1번씩 호출되어 각각의 영화 목록을 가져옵니다.

즉, GraphQL 리졸버는 나무만 보고 숲을 보지 못한다는 것입니다.

이 문제는 처음에 보이는 것보다 실제로 더 심각합니다. 그래프의 노드 수는 그래프 레벨 수에 대해 지수함수적으로 증가하기 때문입니다. 따라서 "n+1"이라는 이름은 2레벨 깊이의 그래프에만 유효합니다. 3레벨 깊이의 그래프에서는 "N2+n+1 문제"라고 불러야 할 것입니다! 그 이후도 마찬가지입니다.

예를 들어, 위의 예시에 이어 각 영화의 배우/여배우 목록도 쿼리에 추가해 보겠습니다.

{
  query {
    directors(first: 10) {
      name
      films(first: 10) {
        title
        actors(first: 10) {
          name
        }
      }
    }
  }
}

이 경우, 데이터베이스에 대해 실행되는 쿼리는 다음과 같습니다. 먼저 10명의 감독 목록을 가져오기 위해 1번, 다음으로 10명의 감독 각각의 영화 목록을 가져오기 위해 1번씩, 마지막으로 10명의 감독 각각의 10개 영화마다 배우/여배우 목록을 가져오기 위해 1번씩 실행됩니다. 합계 1+10+100=111번의 쿼리가 됩니다.

이 동작을 인식한 후, "n+1 문제"는 GraphQL의 가장 큰 성능 장애물로 간주됩니다. 방치하면 몇 레벨 깊이의 그래프에 대한 쿼리가 매우 느려져서 GraphQL이 사실상 쓸모없게 될 수 있습니다.

"n+1 문제"의 일반적인 해결책

"n+1 문제"의 표준 해결책은 처음에 유틸리티 DataLoader에 의해 제공되었습니다. 그 전략은 매우 간단합니다. 쿼리의 세그먼트 해결을 나중 단계까지 지연시켜, 같은 종류의 모든 객체를 단일 쿼리로 함께 해결하는 것입니다. "배치 처리"라고 불리는 이 전략은 "n+1" 문제를 효과적으로 해결합니다.

또한, DataLoader는 객체를 가져온 후 캐시하므로, 후속 쿼리가 이미 로드된 객체를 로드해야 할 경우 실행을 건너뛰고 캐시에서 객체를 가져올 수 있습니다. "캐싱"이라고 불리는 이 전략은 주로 "배치 처리" 위에 추가되는 최적화입니다.

"배치 처리/지연" 해결책의 문제점

기술적으로 말하면, "배치 처리"나 "지연" 전략에는 아무런 문제가 없습니다. 단순히 작동합니다.

(이제부터 이 전략을 "지연"으로만 부르겠습니다.)

그러나 문제는 이 전략이 사후 대책이라는 점입니다. 개발자는 먼저 서버를 구현하고, 그 후 쿼리 해결 속도가 느리다는 것을 알아차리고 지연 메커니즘 도입을 결정하게 됩니다. 따라서 리졸버 구현에 불필요한 단계가 생겨 개발 프로세스에 마찰이 발생합니다. 또한 개발자가 "지연" 메커니즘의 작동 방식을 이해해야 하므로, 구현이 본래보다 더 복잡해집니다.

이 문제는 전략 자체에 있는 것이 아니라, GraphQL 서버가 이 기능을 애드온으로 제공한다는 점에 있습니다. 그것 없이는 쿼리가 너무 느려져 GraphQL이 사실상 쓸모없게 될 수 있음에도 불구하고 말입니다.

이 문제의 해결책은 명확합니다. "지연" 전략은 애드온이 아니라 GraphQL 서버 자체에 내장되어야 합니다. "일반"과 "지연"이라는 2가지 쿼리 실행 전략을 갖는 것이 아니라, "지연" 1가지만 있어야 합니다. 그리고 개발자가 "일반" 방식으로 리졸버를 구현해도 GraphQL 서버가 "지연" 메커니즘을 실행해야 합니다 (즉, 추가 복잡성은 개발자가 아닌 GraphQL 서버가 담당합니다).

바로 그것이 Gato GraphQL이 하는 일입니다.

"지연"을 GraphQL 서버가 실행하는 유일한 전략으로 만들기

대부분의 GraphQL 서버의 문제는 객체 타입(object, union, interface)을 객체로 해결하는 책임이, 이 작업을 데이터 로딩 엔진에 위임하는 대신, 부모 노드를 처리할 때 리졸버 자체가 담당한다는 점입니다 (예: films => directors).

Gato GraphQL은 이 책임을 리졸버에서 서버의 데이터 로딩 엔진으로 이전합니다.

  1. 리졸버는 부모 노드와 자식 노드 사이의 관계를 해결할 때 객체가 아닌 ID를 반환합니다
  2. 특정 타입의 ID 목록이 주어지면, DataLoader 엔티티가 해당 타입의 대응하는 객체를 가져옵니다
  3. 서버의 데이터 로딩 엔진은 이 두 부분을 연결하는 역할을 합니다. 먼저 리졸버로부터 객체 ID를 가져오고, 관계에 대한 중첩 쿼리를 실행하기 직전(그 시점까지 특정 타입에 대해 해결해야 할 모든 ID가 누적되어 있음)에, DataLoader를 통해 해당 ID들의 객체를 가져옵니다(이를 통해 모든 ID를 단일 쿼리에 효율적으로 포함시킬 수 있습니다).

이 접근 방식은 다음과 같이 요약할 수 있습니다. "객체가 아닌 ID로 처리한다".

이 새로운 접근 방식을 시각화하기 위해 앞서의 예시를 사용하겠습니다. 아래 쿼리는 감독 목록과 그들의 영화를 가져옵니다.

{
  query {
    directors(first: 10) {
      name
      films(first: 10) {
        title
      }
    }
  }
}

각 감독에서 가져올 2개의 필드 namefilms에 주목하고, 그것들이 어떻게 다른지 확인해 보세요.

필드 name스칼라 타입입니다. Director 타입의 객체가 감독의 이름을 담고 있는 string 타입의 name 프로퍼티를 포함하고 있을 것으로 기대할 수 있으므로, 즉시 해결 가능합니다. 따라서 Director 객체를 가져온 후, 이 프로퍼티를 해결하기 위해 추가 쿼리를 실행할 필요가 없습니다.

반면, 필드 films객체 타입목록입니다. 일반적으로 즉시 해결할 수 없습니다. Film 타입의 객체 목록을 참조하며, 1번 이상의 추가 쿼리를 통해 데이터베이스에서 가져와야 하기 때문입니다. 따라서 개발자는 이를 위해 "지연" 메커니즘을 구현해야 합니다.

이제 다른 동작을 고려해 보겠습니다. 필드 films를 객체 목록이 아닌 ID 목록으로 해결합니다. Director 객체가 모든 영화의 ID를 담고 있는 filmIDs 프로퍼티를 포함하고 있을 것으로 기대할 수 있으므로 (ID가 문자열로 표현된다고 가정하면 array of string 타입), 이 필드도 "지연" 메커니즘을 구현하지 않고 즉시 해결할 수 있습니다.

마지막으로, ID에 추가하여 리졸버는 기대하는 객체의 타입이라는 추가 정보를 제공해야 합니다 (이 예시에서는 [(Film, 2), (Film, 5), (Film, 9)]와 같은 형태가 됩니다). 그러나 이 정보는 내부적인 것으로, 엔진에 전달될 뿐 쿼리에 대한 응답에 출력될 필요는 없습니다.

코드로 구현하는 적응된 접근 방식

Gato GraphQL이 PHP 코드로 이 접근 방식을 어떻게 구현하는지 살펴보겠습니다. 아래 코드는 다양한 리졸버를 보여줍니다 (명확성을 위해 아래의 모든 코드는 편집되었습니다).

FieldResolvers

FieldResolvers는 특정 타입의 객체를 받아 해당 필드를 해결합니다. 관계의 경우, 해결하는 객체의 타입도 나타내야 합니다. 이것이 그 계약입니다.

interface FieldResolverInterface
{
  public function resolveValue($object, string $field, array $args = []);
  public function resolveFieldTypeResolverClass(string $field, array $args = []): ?string;
}

구현은 다음과 같습니다.

class PostFieldResolver implements FieldResolverInterface
{
  public function resolveValue($object, string $field, array $args = [])
  {
    $post = $object;
    switch ($field) {
      case 'title':
        return $post->title;
      case 'author':
        return $post->authorID; // This is an ID, not an object!
    }
 
    return null;
  }
 
  public function resolveFieldTypeResolverClass(string $field, array $args = []): ?string
  {
    switch ($field) {
      case 'author':
        return UserTypeResolver::class;
    }
 
    return null;
  }
}

프로미스/지연 객체를 처리하는 로직을 제거함으로써, 필드 author를 해결하는 코드가 얼마나 단순하고 간결해졌는지 주목해 주세요.

TypeResolvers

TypeResolvers는 특정 타입을 다루는 객체입니다. 타입의 이름과 해당 타입의 객체를 로드하는 TypeDataLoader 등을 알고 있습니다.

데이터 로딩 엔진은 필드를 해결할 때 특정 TypeResolver 클래스로부터 ID를 받습니다. 그런 다음, 해당 ID들의 객체를 가져올 때, 데이터 로딩 엔진은 해당 객체를 로드하기 위해 어떤 TypeDataLoader 객체를 사용할지 TypeResolver에 문의합니다.

계약은 다음과 같이 정의됩니다.

interface TypeResolverInterface
{
  public function getTypeName(): string;
  public function getTypeDataLoaderClass(): string;
}

이 예시에서 클래스 UserTypeResolverUser 타입의 데이터를 클래스 UserTypeDataLoader를 통해 로드해야 한다고 정의합니다.

class UserTypeResolver implements TypeResolverInterface
{
  public function getTypeName(): string
  {
    return 'User';
  }
 
  public function getTypeDataLoaderClass(): string
  {
    return UserTypeDataLoader::class;
  }
}

TypeDataLoaders

TypeDataLoaders는 특정 타입의 ID 목록을 받아 해당 타입의 대응하는 객체를 반환합니다. 이것이 그 계약입니다.

interface TypeDataLoaderInterface
{
  public function getObjects(array $ids): array;
}

사용자 가져오기는 다음과 같이 수행됩니다.

class UserTypeDataLoader implements TypeDataLoaderInterface
{
  public function getObjects(array $ids): array
  {
    $userAPI = UserAPIFacade::getInstance();
    return $userAPI->getUsers($ids);
  }
}

(정말) 큰 쿼리 실행하기

이 전략이 작동하는지 확인해 보겠습니다. Gato GraphQL의 GraphiQL 클라이언트로 이동하여 아래 쿼리를 실행해 보세요. 이 쿼리는 10레벨 깊이의 그래프(posts => author => posts => tags => posts => comments => author => posts => comments => author)를 포함하며, "n+1 문제"가 발생하고 있었다면 적절한 시간 내에 해결할 수 없었을 것입니다.

query {
  posts(pagination:{ limit:10 }) {
    excerpt
    title
    url
    author {
      name
      url
      posts(pagination:{ limit:10 }) {
        title
        tags(pagination:{ limit:10 }) {
          slug
          url
          posts(pagination:{ limit:10 }) {
            title
            comments(pagination:{ limit:10 }) {
              content
              date
              author {
                name
                posts(pagination:{ limit:10 }) {
                  title
                  url
                  comments(pagination:{ limit:10 }) {
                    content
                    date
                    author {
                      name
                      username
                      url
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

결과를 스크롤하면 응답이 얼마나 크고, 얼마나 많은 엔티티를 포함하며, 몇 레벨이나 가져왔는지 확인할 수 있습니다. 그럼에도 불구하고 아무런 어려움 없이 신속하게 실행되었습니다.