03강: N+1 쿼리 폭발의 정체 & 즉시 로딩(Eager Loading) 극적 최적화
실습 프로젝트: 지니샵(JinyShop) 온라인 도서 쇼핑몰
주제: N+1 쿼리 병목 진단(Telescope), 즉시 로딩(with), 서평 개수 최적화(withCount), Lazy Loading 전면 차단(preventLazyLoading)
등장인물: 🐱 지니(시니어 멘토), 👧 도로시(주니어 개발자), 🐶 토토(개발 보조견)
공식 문서 연계: Eloquent 관계 (Eager Loading)
💬 1. 지니와 도로시의 티키타카 회의
👧 도로시: “지니! 큰일 났어요! 도서 카탈로그 화면에 책 제목 옆에 ‘저자 이름’과 ‘출판사 이름’을 띄우려고 Blade 루프에서 {{ $book->author->name }}을 적었더니, Telescope 대시보드가 온통 빨간 불로 뒤덮였어요! 화면 하나 띄우는데 SQL 쿼리가 101개나 실행되면서 서버가 덜덜 떨리고 있어요!”
🐱 지니: “하하, 도로시! 드디어 모든 웹 백엔드 개발자들의 통과의례인 ‘N+1 쿼리 폭발 문제’를 만났구나! 책 50권을 가져오는 쿼리 1개(1)에, 책마다 저자를 하나씩 따로 조회하는 쿼리 50개(N)가 루프 안에서 무차별적으로 터져 나온 거란다. 하지만 걱정 마렴! 우리에겐 마법의 치료약 즉시 로딩(Eager Loading, with())이 있거든. 101개였던 쿼리를 단 3개로 압축해 100배 빠른 번개 응답을 만들어 줄 수 있단다!”
🐶 토토: “멍멍! AppServiceProvider에 Model::preventLazyLoading()을 켜두면, 개발 중에 깜빡하고 N+1 실수를 저질렀을 때 라라벨이 ‘도로시! 즉시 로딩을 빼먹었어!’ 하고 즉시 경고창을 띄워준다멍!”
🎯 2. 실습 목표 및 서점 시나리오
- Telescope를 통해 N+1 쿼리 폭발 현상을 직접 재현하고 병목 원인을 분석합니다.
with(['author', 'publisher', 'category'])즉시 로딩으로 쿼리 수를 극적으로 줄입니다.- 메모리를 절약하며 서평 개수만 세는
withCount('reviews')를 적용합니다. - 개발 환경에서 N+1 실수를 원천 차단하는
preventLazyLoading가드를 구축합니다.
🛠️ 3. 단계별 실습 절차 (Step-by-Step)
[Step 1] N+1 쿼리 폭발 현상 재현
routes/web.php에 고의로 지연 로딩(Lazy Loading)을 발생시키는 테스트 라우트를 작성합니다:
// routes/web.php
use App\Models\Book;
Route::get('/test-n-plus-one', function () {
// 1. 책 30권 조회 (쿼리 1회)
$books = Book::published()->take(30)->get();
$output = [];
foreach ($books as $book) {
// 루프 돌 때마다 author와 publisher를 매번 개별 SQL로 질의! (쿼리 30회 + 30회 = 총 61회 폭발!)
$output[] = [
'title' => $book->title,
'author' => $book->author->name,
'publisher' => $book->publisher->name,
];
}
return response()->json([
'message' => 'N+1 쿼리 재현 완료!',
'count' => count($output),
]);
});
브라우저에서 http://127.0.0.1:8000/test-n-plus-one 을 호출한 뒤, Telescope 대시보드(http://127.0.0.1:8000/telescope/queries)를 확인합니다:
Telescope 관측 결과:
select * from books limit 30(1회)
select * from authors where id = 1
select * from publishers where id = 1
… (동일한 쿼리가 30번 반복 실행되며 총 61개 쿼리 폭발!)
[Step 2] with() 즉시 로딩(Eager Loading)으로 100배 압축
컨트롤러 쿼리에 with(['author', 'publisher'])를 단 한 줄 추가합니다:
Route::get('/test-eager-loading', function () {
// 단 3개의 쿼리로 30권의 책과 연관 저자, 출판사를 일괄 사전 적재!
$books = Book::query()
->published()
->with(['author', 'publisher']) // 🚀 즉시 로딩!
->take(30)
->get();
$output = [];
foreach ($books as $book) {
// 이미 메모리에 로드되어 있으므로 추가 SQL 쿼리가 0개 발생!
$output[] = [
'title' => $book->title,
'author' => $book->author->name,
'publisher' => $book->publisher->name,
];
}
return response()->json([
'message' => '즉시 로딩 최적화 완료!',
'count' => count($output),
]);
});
Telescope 쿼리 재확인:
select * from books where status = 'published' limit 30;select * from authors where id in (1, 3, 7, ...);(IN 쿼리 1회)select * from publishers where id in (2, 4, 8, ...);(IN 쿼리 1회)
총 쿼리 수: 61개 $\rightarrow$ 단 3개로 압축 (95% DB 부하 감소!)
[Step 3] 서평 개수 최적화 (withCount)
도서 카드에 ‘서평 12개’를 표시하기 위해 수천 개의 서평 텍스트를 메모리에 다 올리면 메모리(OOM)가 고갈됩니다.
withCount('reviews')를 쓰면 서평 데이터는 읽지 않고 개수(reviews_count)만 서브쿼리로 가져옵니다:
$books = Book::query()
->published()
->with(['author', 'publisher'])
->withCount('reviews') // $book->reviews_count 컬럼 자동 생성!
->paginate(12);
Blade 화면:
<span class="text-xs text-slate-400">
💬 서평 ({{ $book->reviews_count }}개)
</span>
[Step 4] N+1 방지 가드 가동 (preventLazyLoading)
app/Providers/AppServiceProvider.php에 로컬 개발 중 Lazy Loading을 만나면 즉시 예외를 터뜨려 개발자에게 경고하는 안전장치를 설치합니다:
// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
// 로컬 개발 환경에서는 N+1(Lazy Loading) 발생 시 즉시 빨간 에러 화면을 띄워 실수 방지!
Model::preventLazyLoading(! app()->isProduction());
}
이제 개발 중 누군가 with()를 빼먹고 루프를 돌면, 라라벨이 친절하게 다음 에러를 띄워줍니다:
Attempted to lazy load [author] on model [App\Models\Book] but lazy loading is disabled.
🔍 4. 라라벨 공식 문서 원리 심층 분석 (Deep Dive)
중첩 관계 즉시 로딩 (Nested Eager Loading)
- 도서 $\rightarrow$ 저자 $\rightarrow$ 저자의 소속 단체까지 깊은 관계를 가져와야 할 때도 점 표기법으로 한 번에 로드할 수 있습니다:
Book::with(['author.agency', 'reviews.user'])->get(); - 라라벨 내부의 관계 해결 엔진이 외래키를 수집하여 알아서 가장 최적화된 최소한의
IN (?, ?, ?)쿼리 세트로 치환합니다.
🐶 5. 토토의 트러블슈팅 & 주의사항
🐶 토토의 멍멍 가이드!
- 별점이 5점인 서평만 골라서 가져오고 싶어요!
클로저를 넘겨주면 된다멍!
Book::with(['reviews' => fn ($q) => $q->where('rating', 5)])->get();처럼 관계 쿼리에 조건을 걸 수 있다멍!
💡 6. 3강 자가진단 과제
BookController@index메서드에with(['author', 'publisher', 'category'])와withCount('reviews')를 적용하세요.- 브라우저에서
/books페이지를 열고 Telescope에서 12권의 책을 표시하는 데 발생한 총 쿼리 수가 4개 이하인지 직접 확인하세요.