데이터베이스 쿼리를 재고하여 Laravel API를 83% 더 빠르게 만들었습니다
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_OBJECT와 JSON_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 Eloquent | 27.44 ms | 2.06 MB | 4 |
| JSON Aggregation | 4.41 ms | 0.18 MB | 1 |
향상도:
- 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 스타 ⭐ 하나만 눌러 주시면 큰 힘이 됩니다.