본문 바로가기
← 아티클 목록
실무 가이드Laravel 13Eloquent성능Pest

목록이 늘수록 느려질 때: N+1 쿼리를 재현하고 회귀 테스트로 막기

작성: 라라벨 코리아

발행: 2026년 10월 10일

라라벨 코리아의 매뉴얼 목록을 사례로 관계 조회 7회를 2회로 줄이는 과정을 재현합니다. 외래 키 누락, 관계 메서드 재조회, 측정 범위를 구분하고 Pest로 쿼리 예산을 검증합니다.

먼저 어떤 문제를 해결하는가

목록에 항목이 6개일 때는 괜찮던 화면이 60개에서 느려진다면, 서버 사양을 바꾸기 전에 항목마다 같은 종류의 SQL이 반복되는지 살펴보세요. 이 글에서는 라라벨 코리아의 실제 모델 구조인 ManualPage → ManualSection을 사용합니다. 각 매뉴얼에는 제목과 소속 섹션이 있고, 목록은 둘을 함께 보여줍니다.

목표는 “무조건 쿼리 2개”가 아닙니다. 화면에 필요한 데이터가 동일한 상태에서, 목록 길이에 비례하는 추가 조회를 없애고 그 조건을 테스트로 남기는 것입니다. 여기서 소개하는 숫자는 캐시·인증·페이지네이션을 제외한 목록 조회 구간의 숫자입니다.

적용 환경과 준비

Laravel 13, PHP 8.5, Pest 4, SQLite 테스트 DB를 기준으로 검증합니다. 라라벨 코리아 저장소의 App\Models\ManualPage, ManualSection, Database\Seeders\ManualSeeder를 사용하며, 일반 Laravel 새 프로젝트에 기본으로 있는 모델은 아닙니다. 다른 프로젝트라면 Post → author처럼 실제 관계로 바꾸고 팩토리도 준비해야 합니다.

관계의 핵심은 manual_pages.manual_section_id가 섹션의 id를 가리킨다는 점입니다. 매뉴얼 모델에는 다음 관계가 이미 정의되어 있습니다.

public function section(): BelongsTo { return $this->belongsTo(ManualSection::class, 'manual_section_id'); }

1. 눈에 보이는 반복을 먼저 만든다

다음은 느려질 수 있는 목록 코드입니다. get()은 페이지만 읽고, 반복문 안의 $page->section은 아직 읽지 않은 관계를 필요할 때 조회합니다.

$pages = ManualPage::query()->orderBy('id')->limit(6)->get(); $rows = $pages->map(fn (ManualPage $page): array => [ 'title' => $page->title, 'section' => $page->section->title, ]);

이 사례의 데이터에서는 페이지 목록 1회와 각 행의 섹션 조회 6회가 발생합니다. 같은 섹션을 가리키는 행이 있어도 각 모델 인스턴스의 관계가 자동으로 공유된다고 가정해서는 안 됩니다. 앱에서 자동 사전 로딩을 활성화했다면 결과가 달라질 수 있으므로, 재현 테스트에서는 이를 끄고 명시적 로딩을 비교합니다.

2. 목록 조회 시 필요한 관계를 함께 지정한다

$pages = ManualPage::query() ->with('section:id,title') ->orderBy('id') ->limit(6) ->get(['id', 'manual_section_id', 'title']); $rows = $pages->map(fn (ManualPage $page): array => [ 'title' => $page->title, 'section' => $page->section->title, ]);

수정 후에는 페이지를 읽고, 필요한 섹션들을 한 번에 읽습니다. 관계를 연결하려면 부모 목록의 manual_section_id와 관계 대상의 id를 둘 다 남겨야 합니다. 제목만 선택하면 SQL은 빨라 보이지만 관계가 사라질 수 있습니다.

위 예제는 화면에 표시할 제목만 만듭니다. 실제 링크까지 생성하려면 페이지의 slug와 섹션의 slug도 선택해야 합니다. 이 사이트의 fullSlug()는 두 slug를 사용하므로, 위처럼 제목만 선택한 객체에 호출하면 올바른 링크를 만들 수 없습니다. “선택 컬럼 줄이기”는 사용처를 확인한 다음 적용하세요.

3. 빠르다는 느낌 대신 실패하는 테스트를 만든다

테스트 데이터 준비와 목록 조회를 같은 측정 구간에 넣으면 INSERT와 초기화 쿼리가 섞입니다. 시딩을 끝낸 후 로그를 비우고, 관계에 접근하는 시점까지 측정합니다. 아래 내용을 라라벨 코리아 체크아웃의 tests/Feature/ManualQueryBudgetTest.php에 넣으면 독립적으로 실행할 수 있습니다. 이 저장소의 tests/Pest.php에는 Feature 테스트의 DB 초기화 설정이 있습니다.

<?php use App\Models\ManualPage; use Database\Seeders\ManualSeeder; use Illuminate\Support\Facades\DB; test('manual listing has a two-query budget', function () { $this->seed(ManualSeeder::class); DB::flushQueryLog(); DB::enableQueryLog(); try { $pages = ManualPage::query() ->with('section:id,title') ->orderBy('id') ->limit(6) ->get(['id', 'manual_section_id', 'title']); $rows = $pages->map(fn (ManualPage $page): array => [ 'title' => $page->title, 'section' => $page->section->title, ]); expect($rows)->toHaveCount(6); expect($rows->pluck('section')->filter())->toHaveCount(6); expect(DB::getQueryLog())->toHaveCount(2); } finally { DB::disableQueryLog(); DB::flushQueryLog(); } });
php artisan test --compact tests/Feature/ManualQueryBudgetTest.php

예상 결과는 테스트 성공입니다. with()를 빼면 섹션 조회가 늘어나거나, 지연 로딩 방지가 켜진 앱에서는 예외가 발생하여 테스트가 실패해야 합니다. 빈 배열에서도 2회만 조회했다고 통과하지 않도록 행 수와 섹션 데이터도 함께 확인합니다.

4. 비슷해 보이지만 다른 실패들

관찰한 현상먼저 확인할 내용조치
with()를 썼는데 여전히 행마다 SQL 발생반복문 안에서 section()->first()를 호출하는가사전 로딩한 관계 속성 section을 사용
섹션이 null로 나온다외래 키와 관계 대상 기본 키가 선택되었는가manual_section_id, id 복원
전체 요청은 3회 이상이다페이지 수를 세는 쿼리·로그인 사용자 조회가 있는가측정 구간을 분리하고 전체 예산은 별도로 설정
관계 데이터가 커서 메모리가 증가한다화면은 개수만 필요한가레코드 전체 대신 withCount() 검토
2회가 됐지만 여전히 느리다반환 행 수, 인덱스, 정렬, 렌더링 비용실행 계획과 응답 크기를 별도로 측정

쿼리 개수와 응답 시간은 다른 지표입니다. 큰 JOIN 한 번이 작은 조회 두 번보다 비쌀 수도 있습니다. 이 실습에서 “응답 시간이 몇 배 개선됐다”고 말하지 않는 이유도 실제 운영 데이터와 네트워크를 측정하지 않았기 때문입니다.

실무에 적용할 때의 완료 조건

  • 수정 전후의 화면 데이터가 같고 누락된 관계가 없습니다.
  • 항목 수를 6개에서 더 늘려도 관계 조회 수가 항목 수만큼 늘어나지 않습니다.
  • 테스트는 데이터 준비가 끝난 이후의 구간만 셉니다.
  • 로컬·테스트에서 지연 로딩 방지를 도입한다면 기존 코드의 실패도 먼저 확인합니다.

성능 수정이 끝났다면 배포 후 실제 응답을 검증하는 가이드를 이어서 읽어보세요. 코드에서 쿼리를 줄였더라도 실행 중인 프로세스가 이전 코드를 사용하면 이용자가 보는 결과는 달라지지 않습니다.

출처와 검증 범위

관계 API는 Laravel 13 Eager Loading, 테스트 환경은 Database Testing을 대조했습니다. 사례와 비교 기준은 이 사이트의 매뉴얼 모델에 맞춰 구성했습니다. AI 작성·원문 대조이며, 실제 운영 환경의 성능 측정이나 사람의 편집 검수를 의미하지 않습니다.