데이터베이스 쿼리를 재고하여 Laravel API를 83% 더 빠르게 만들었습니다

발행: (2025년 12월 15일 오후 03:36 GMT+9)
8 min read
원문: Dev.to

Source: Dev.to

N+1 쿼리 문제를 전통적인 eager loading 대신 JSON 집계로 해결한 방법

지난달, 느린 관리자 대시보드를 디버깅하고 있었습니다. 페이지는 파트너 레코드 500개와 그들의 프로필, 국가, 프로모션 코드를 로드했습니다. 각 페이지 로드에 2 초 이상이 걸렸습니다. 원인? 고전적인 N+1 쿼리 문제였습니다.

모두가 알고 있는 문제

Laravel의 eager loading을 사용해도 요청당 데이터베이스를 5번이나 호출했습니다:

$partners = Partner::with(['profile', 'country', 'promocodes'])->get();

이 코드는 다음과 같은 쿼리를 생성합니다:

SELECT * FROM partners;                         -- Query 1
SELECT * FROM profiles WHERE partner_id IN ...; -- Query 2
SELECT * FROM countries WHERE id IN ...;          -- Query 3
SELECT * FROM promocodes WHERE partner_id IN ...;-- Query 4

각 쿼리는 데이터베이스와의 라운드‑트립을 추가합니다. 레코드 50개라면 4번의 네트워크 라운드‑트립이 발생하고, 각각 15‑20 ms의 지연이 추가됩니다.

“아하!” 순간

스스로에게 물었습니다: “모든 데이터를 ONE 쿼리로 로드할 수 있을까?”
MySQL의 JSON_OBJECTJSON_ARRAYAGG 함수가 이를 가능하게 했습니다: 관계들을 SQL 안에서 바로 JSON으로 집계합니다.

해결책: JSON 집계

이 기능을 구현한 Laravel 패키지를 만들었습니다:

$partners = Partner::aggregatedQuery()
    ->withJsonRelation('profile')
    ->withJsonRelation('country')
    ->withJsonCollection('promocodes')
    ->get();

이 코드는 단일 최적화된 쿼리를 생성합니다:

SELECT 
    base.*,
    JSON_OBJECT('id', profile.id, 'name', profile.name) AS profile,
    JSON_OBJECT('id', country.id, 'name', country.name) AS country,
    (SELECT JSON_ARRAYAGG(JSON_OBJECT('id', id, 'code', code))
     FROM promocodes WHERE partner_id = base.id) AS promocodes
FROM partners base
LEFT JOIN profiles  profile  ON profile.partner_id = base.id
LEFT JOIN countries country ON country.id = base.country_id;

한 번의 쿼리. 모든 데이터.

결과

2,000개의 파트너와 각각 4개의 관계(레코드 50개 조회)로 구성된 데이터셋에 대한 벤치마크:

방법시간메모리쿼리 수
Standard Eloquent27.44 ms2.06 MB4
JSON Aggregation4.41 ms0.18 MB1

향상도:

  • 83 % 빠른 응답 시간
  • 91 % 적은 메모리 사용량
  • 75 % 감소한 데이터베이스 쿼리 수

오타가 아닙니다. 83 % 빠름입니다.

왜 이렇게 빠른가?

1. 네트워크 지연 (≈ 80 %의 개선)

데이터베이스 라운드‑트립은 비용이 많이 듭니다. 로컬호스트에서도 각 쿼리는 5‑10 ms, 원격 DB에서는 15‑20 ms가 추가됩니다.

전: 4 쿼리 × 15 ms = 60 ms 네트워크 시간
후: 1 쿼리 × 15 ms = 15 ms 네트워크 시간

2. Eloquent 하이드레이션 회피 (≈ 15 %의 개선)

평범한 배열을 반환하면 객체 인스턴스화, 속성 캐스팅, 관계 바인딩, 이벤트 발생 등을 건너뛸 수 있습니다.

3. 최적화된 SQL (≈ 5 %의 개선)

데이터베이스는 PHP 루프 대신 고도로 최적화된 C 코드로 집계를 수행합니다.

실제 영향

하루에 10,000개의 API 요청을 처리하는 대시보드 기준:

  • 40,000개의 데이터베이스 쿼리 감소
  • 총 4분의 응답 시간 절감
  • 19 GB 적은 메모리 사용
  • 서버 자원 활용도 향상

작동 방식

설치

composer require rgalstyan/laravel-aggregated-queries

설정

모델에 트레이트를 추가합니다:

use Rgalstyan\LaravelAggregatedQueries\HasAggregatedQueries;

class Partner extends Model
{
    use HasAggregatedQueries;

    public function profile()
    {
        return $this->hasOne(PartnerProfile::class);
    }

    public function promocodes()
    {
        return $this->hasMany(PartnerPromocode::class);
    }
}

사용법

// 기존 eager loading
$partners = Partner::with(['profile', 'promocodes'])->get();

// 집계 쿼리
$partners = Partner::aggregatedQuery()
    ->withJsonRelation('profile', ['id', 'name', 'email'])
    ->withJsonCollection('promocodes', ['id', 'code', 'discount'])
    ->where('is_active', true)
    ->get();

출력

반환되는 구조는 예측 가능합니다:

[
    'id'      => 1,
    'name'    => 'Partner A',
    'profile' => [
        'id'    => 10,
        'name'  => 'John',
        'email' => 'john@example.com',
    ],
    'promocodes' => [
        ['id' => 1, 'code' => 'SAVE10'],
        ['id' => 2, 'code' => 'SAVE20'],
    ],
];
  • 관계는 항상 array 혹은 null입니다.
  • 컬렉션은 항상 array이며 (null이 아님)

언제 사용해야 할까?

✅ 완벽한 경우

  • 여러 관계를 포함하는 API 엔드포인트
  • 복잡한 쿼리를 요구하는 관리자 대시보드
  • 밀리초 단위가 중요한 모바일 백엔드
  • 읽기 비중이 높은 애플리케이션 (90 % 이상 읽기)
  • 최적화가 필요한 고트래픽 서비스

⚠️ 권장되지 않는 경우

  • 쓰기 작업(표준 Eloquent 사용)
  • 모델 이벤트/옵저버가 필요한 상황
  • 깊게 중첩된 관계( v1.1에서 지원)

성능 vs. Eloquent 모델

패키지는 두 가지 모드를 제공합니다:

// 배열 모드 (기본, 가장 빠름 – 83 % 빠름)
$partners = Partner::aggregatedQuery()->get();

// Eloquent 모드 (여전히 빠름 – 약 27 % 개선)
$partners = Partner::aggregatedQuery()->get('eloquent');

배열 모드는 Eloquent의 하이드레이션 오버헤드를 건너뛰어 가장 빠릅니다. Eloquent 모드에서도 하나의 데이터베이스 쿼리를 절감하므로 눈에 띄는 성능 향상이 있습니다.

트레이드‑오프

잃는 것

  • 모델 이벤트(created, updated, deleted)
  • 배열 모드에서는 접근자/변형자
  • save() 또는 update() 호출(읽기 전용)

얻는 것

  • 83 % 빠른 응답 시간
  • 91 % 적은 메모리 사용량
  • 더 간단하고 예측 가능한 데이터 구조
  • 읽기‑중심 워크로드에 대한 확장성 향상

앞으로의 계획

v1.1.0 개발 중이며 다음을 포함합니다:

  • 중첩 관계(profile.company.country)
  • 쿼리 제약을 활용한 조건부 로딩
  • 관계 별칭
  • 향상된 디버깅 도구

직접 사용해 보세요!

Laravel으로 API나 대시보드를 구축한다면 이 패키지를 한번 써보세요. 피드백을 환영합니다—댓글에 결과나 다른 N+1 해결책을 공유해 주세요.

P.S. 이 패키지는 Laravel News에 소개되었습니다! 유용하다면 GitHub 스타 ⭐ 하나만 눌러 주시면 큰 힘이 됩니다.

Back to Blog

관련 글

더 보기 »

리팩토링 없이 레거시 Laravel 코드 테스트

실제 PHP 프로젝트를 위한 실용적인 전략 레거시 코드베이스는 삶의 일부입니다. 대부분 우리는 그린필드 프로젝트에 참여하지 않습니다. 우리는 애플리케이션을 물려받습니다.