0Pricing
PHP Academy · 강의

성능: N+1과 DataLoader

필드 해석을 일괄 처리하고 캐시해 빠른 속도를 유지합니다.

성능: N+1과 DataLoader은(는) CoddyKit의 무료 PHP Academy 강의입니다. 이것은 4개 중 4번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 PHP Academy 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. PHP Academy 강의에는 총 4개의 강의가 포함되어 있습니다.

조용한 성능 저하 요인: N+1

GraphQL의 가장 큰 성능 함정은 N+1 쿼리 문제입니다. 각각의 리졸버만 따로 보면 문제가 없어 보이기 때문에 조용히 발생하지만, 목록과 항목별 필드를 중첩하면 목록을 위한 쿼리 하나에 항목마다 쿼리 하나씩이 추가됩니다. 항목이 100개라면 왕복이 101번 발생합니다. 이 강의에서는 DataLoader를 사용해 이를 소수의 일괄 처리 쿼리로 통합하는 방법을 알아봅니다.

N+1을 구체적으로 살펴보기

{ posts { author { name } } }을 생각해 보겠습니다. posts 리졸버는 쿼리 하나를 실행합니다. 그런 다음 각 게시물에 대해 author 리졸버가 자체적으로 쿼리를 실행합니다. 아래의 단순한 코드에서는 이 비용이 명확하게 드러납니다.

<?php
// 1 query for posts...
$posts = [['id'=>1,'author_id'=>7],['id'=>2,'author_id'=>7],['id'=>3,'author_id'=>9]];

$queries = 1;
foreach ($posts as $post) {
    // ...then 1 query PER post to fetch its author
    $queries++;
    // SELECT * FROM users WHERE id = $post['author_id']
}
echo "Total DB queries: {$queries}\n"; // Total DB queries: 4

핵심 통찰: 단계별 일괄 처리

GraphQL은 단계별로 해결합니다. 게시물 목록의 모든 author 리졸버는 같은 실행 주기에 실행됩니다. 각 작성자 조회를 지연하고, 요청된 식별자를 모은 다음 단일 WHERE id IN (...)을 실행할 수 있다면 N개의 쿼리를 하나로 바꿀 수 있습니다. 이러한 지연 처리를 바로 GraphQL\Deferred가 제공합니다.

<?php
$ids = [7, 7, 9];
$unique = array_values(array_unique($ids));
// One query instead of three:
echo 'SELECT * FROM users WHERE id IN (' . implode(',', $unique) . ")\n";
// SELECT * FROM users WHERE id IN (7,9)

간단한 버퍼와 로더

DataLoader의 핵심은 다음과 같습니다. 식별자를 누적하는 버퍼가 있고, 일괄 처리 함수로 식별자를 한 번만 로드하며, 결과는 캐시에서 제공합니다. 같은 키를 두 번 요청해도 한 번만 로드되므로 자동으로 중복이 제거됩니다.

<?php
class UserLoader {
    private array $queue = [];
    private array $cache = [];
    public function __construct(private \Closure $batchFn) {}

    public function add(int $id): void { $this->queue[$id] = true; }

    public function loadOnce(): void {
        $missing = array_diff(array_keys($this->queue), array_keys($this->cache));
        if ($missing) {
            foreach (($this->batchFn)(array_values($missing)) as $id => $row) {
                $this->cache[$id] = $row;
            }
        }
        $this->queue = [];
    }

    public function get(int $id): mixed { return $this->cache[$id] ?? null; }
}

리졸버에 연결하기

author 리졸버는 식별자를 대기열에 넣고 Deferred를 반환합니다. graphql-php는 현재 단계를 마친 후 모든 지연 처리를 실행하므로, 클로저가 실행될 때는 전체 목록의 모든 작성자 식별자가 대기열에 들어간 상태입니다. loadOnce()를 한 번 호출하면 일괄 처리 쿼리 하나만 실행됩니다.

<?php
use GraphQL\Deferred;

$authorField = [
    'type' => $userType,
    'resolve' => function ($post, $args, $context) {
        /** @var UserLoader $loader */
        $loader = $context['userLoader'];
        $loader->add($post['author_id']);
        return new Deferred(function () use ($loader, $post) {
            $loader->loadOnce();              // batches across all posts
            return $loader->get($post['author_id']);
        });
    },
];

overblog/dataloader 라이브러리 사용하기

이 기능을 직접 구현하는 경우는 거의 없습니다. overblog/dataloader-php는 Facebook의 DataLoader를 포팅한 널리 사용되는 구현입니다. 키 배열을 받으며 같은 순서의 값을 담은 프라미스를 반환해야 하는 일괄 처리 함수를 전달하면 됩니다. 캐싱, 중복 제거, 프라미스 해결을 라이브러리가 처리합니다.

composer require overblog/dataloader-php

DataLoader 생성하기

일괄 처리 함수의 계약은 엄격합니다. [k1, k2, k3]가 주어지면 위치에 맞춰 [v1, v2, v3]로 해결되어야 합니다. 데이터베이스 행을 키로 색인한 다음 입력 순서에 맞춰 다시 매핑하여, 없는 키에는 null이 들어가게 하십시오.

<?php
use Overblog\DataLoader\DataLoader;
use GraphQL\Executor\Promise\Adapter\SyncPromiseAdapter;
use Overblog\PromiseAdapter\Adapter\WebonyxGraphQLSyncPromiseAdapter;

$adapter = new WebonyxGraphQLSyncPromiseAdapter(new SyncPromiseAdapter());

$userLoader = new DataLoader(function (array $ids) use ($adapter, $db) {
    $rows = $db->usersByIds($ids);          // SELECT ... WHERE id IN (...)
    $byId = [];
    foreach ($rows as $r) { $byId[$r['id']] = $r; }
    // MUST return values in the SAME ORDER as $ids
    $ordered = array_map(fn($id) => $byId[$id] ?? null, $ids);
    return $adapter->createFulfilled($ordered);
}, $adapter);

로더를 통한 해결

리졸버에서는 간단히 $loader->load($id)를 호출하면 되며, 이 호출은 프라미스를 반환합니다. graphql-php는 어댑터를 통해 이러한 요청을 모은 뒤 실행 주기가 끝날 때 자동으로 일괄 처리를 시작합니다. 수동으로 버퍼링할 필요가 없습니다.

<?php
$authorField = [
    'type' => $userType,
    'resolve' => fn($post, $args, $context) =>
        $context['userLoader']->load($post['author_id']),
];

요청별 수명이 중요합니다

DataLoaders는 키를 기준으로 캐시하므로 요청마다 새로 생성해야 합니다. 여러 요청에서 공유하는 로더는 오래된 데이터를 제공하고 메모리를 누수시킬 수 있습니다. 요청 $context를 구성할 때 로더를 만들고, 요청이 끝나면 폐기하십시오.

<?php
// Per request: brand new loaders, attached to context
function buildContext($db, $currentUser): array {
    return [
        'db' => $db,
        'user' => $currentUser,
        'userLoader' => makeUserLoader($db),  // fresh, not a singleton
        'postLoader' => makePostLoader($db),
    ];
}

그 밖의 성능 보호 장치

DataLoader는 N+1 읽기 문제를 해결하지만, 악의적이거나 부주의한 쿼리는 여전히 시스템에 부담을 줄 수 있습니다. 다음 보호 장치를 함께 적용하십시오.

  • 쿼리 깊이 제한 — QueryDepth 규칙이 비정상적으로 깊게 중첩된 쿼리를 거부합니다.
  • 쿼리 복잡도 — QueryComplexity가 필드별 비용 예산을 할당합니다.
  • 저장된 쿼리 — 허용 목록에 있는 작업만 허용합니다.
  • 페이지 매김 — 크기 제한이 없는 목록을 절대 해결하지 말고 커서 연결을 사용하십시오.
<?php
use GraphQL\Validator\Rules\QueryDepth;
use GraphQL\Validator\Rules\QueryComplexity;
use GraphQL\Validator\DocumentValidator;

DocumentValidator::addRule(new QueryDepth(10));
DocumentValidator::addRule(new QueryComplexity(200));

개선 효과 측정하기

개선 효과는 항상 수치로 확인하십시오. 테스트에서 데이터베이스 계층을 감싸 쿼리 수를 센 다음, 목록 크기와 관계없이 일괄 처리 버전이 제한된 수의 쿼리만 실행하는지 단언하십시오. 이렇게 하면 누군가 단순한 중첩 리졸버를 추가해 N+1 문제가 조용히 다시 발생하는 회귀를 방지할 수 있습니다.

<?php
class CountingDb {
    public int $queries = 0;
    public function usersByIds(array $ids): array {
        $this->queries++;            // one batched call
        return array_map(fn($id) => ['id' => $id], $ids);
    }
}

$db = new CountingDb();
$db->usersByIds([7, 9, 11, 13]);     // 4 authors
echo "Queries for 4 authors: {$db->queries}\n"; // Queries for 4 authors: 1

빠른 확인

DataLoaders를 요청마다 생성해야 하는 이유는 무엇입니까?

정리

GraphQL의 가장 심각한 성능 함정을 제거했습니다.

  • 중첩된 목록 필드는 N+1을 일으켜 항목마다 쿼리를 하나씩 실행합니다.
  • graphql-php는 단계별로 해결하므로, 지연 처리를 이용하면 모든 키를 하나의 IN (...) 쿼리로 일괄 처리할 수 있습니다.
  • GraphQL\Deferred는 기본 요소이고, overblog/dataloader-php는 일괄 처리, 키별 캐싱, 중복 제거를 하나로 제공합니다.
  • 일괄 처리 함수는 입력 키와 같은 순서로 값을 반환해야 합니다.
  • 로더는 요청별로 사용하며, 추가 보호 장치로 깊이 및 복잡도 제한과 페이지 매김을 적용하십시오.

자주 묻는 질문

“성능: N+1과 DataLoader” 강의는 무료인가요?

네 — “성능: N+1과 DataLoader” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 PHP Academy 강의 전체를 잠금 해제할 수 있습니다. PHP Academy 강의에는 총 4개의 강의가 포함되어 있습니다.

“성능: N+1과 DataLoader”에서 뭘 배우나요?

필드 해석을 일괄 처리하고 캐시해 빠른 속도를 유지합니다. 브라우저에서 직접 실행하는 실습 코드로 PHP Academy을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.

PHP Academy을(를) 시작하는 데 경험이 필요한가요?

사전 경험은 필요하지 않습니다. CoddyKit의 PHP Academy은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 4번째 강의입니다.

“성능: N+1과 DataLoader” 강의는 얼마나 걸리나요?

대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.

이 PHP Academy 강의에서 코드를 작성하고 실행할 수 있나요?

네. 모든 PHP Academy 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.

이 강의의 모든 강의

  1. GraphQL과 REST 비교
  2. graphql-php로 스키마 구축
  3. 리졸버, 변이와 구독
  4. 성능: N+1과 DataLoader
← PHP Academy(으)로 돌아가기