CodeIgniter 4로 블로그 만들기 #08 — 페이지네이션과 쿼리 최적화
지난 회차에서 글 목록을 그렸습니다. 그런데 컨트롤러를 다시 보면 findAll()로 글을 한 번에 전부 읽고 있습니다. 시더 글이 6건일 땐 아무 문제 없지만, 글이 수백·수천 건으로 늘면 매 요청마다 그 전부를 메모리로 끌어오게 됩니다. 목록 첫 화면에 필요한 건 고작 몇 건인데 말이죠.
이번 회차의 목표는 **페이지네이션(pagination)**입니다. 한 페이지에 5건씩만 끊어 읽고, 페이지를 넘기는 UI를 붙입니다. 그리고 이 과정에서 "쿼리를 얼마나, 어떻게 날리는가"를 디버그 툴바로 눈으로 확인하는 습관을 들입니다.
이번 회차 목표
findAll()대신paginate(5)로 한 페이지 5건씩 읽는다.- 뷰에
$pager->links()로 페이지 이동 UI를 그린다. - 페이지네이션이 실제로 페이지를 나누는지 Feature 테스트로 검증한다.
- 디버그 툴바로 쿼리를 관찰하는 감각을 익힌다.
1. 컨트롤러 — findAll을 paginate로
app/Controllers/Posts.php의 index()를 바꿉니다.
class Posts extends BaseController
{
// 한 페이지에 보여 줄 글 수
private const PER_PAGE = 5;
public function index(): string
{
$model = model(PostModel::class);
$posts = $model
->orderBy('created_at', 'DESC')
->paginate(self::PER_PAGE);
return view('posts/index', [
'posts' => $posts,
'pager' => $model->pager,
]);
}
}
달라진 곳을 짚어 봅시다.
PER_PAGE = 5상수 — 한 페이지에 몇 건을 보여 줄지 상수로 뽑았습니다. 매직 넘버를 코드 여기저기 흩어 두지 않는 습관입니다.paginate(self::PER_PAGE)—findAll()을 이걸로 바꿨습니다.paginate(5)는 내부적으로 SQL에 **LIMIT과OFFSET**을 붙여, 요청된 페이지에 해당하는 5건만 읽어 옵니다. 현재 페이지 번호는?page=2같은 GET 파라미터에서 자동으로 읽습니다. 이게 이번 회차의 핵심 최적화입니다. 글이 아무리 늘어도 한 번에 5건 + 전체 개수(count) 정도만 조회합니다.$model->pager—paginate()를 부르고 나면 모델에pager객체가 채워집니다. 이걸 뷰로 넘겨서 페이지 이동 링크를 그립니다.
2. 뷰 — 페이지 링크 붙이기
app/Views/posts/index.php에서 목록 아래에 페이저를 추가합니다. 목록을 그리는 부분은 그대로 두고, 반복문이 끝난 자리에 한 줄만 넣습니다.
</ul>
<?= $pager->links() ?>
<?php endif ?>
$pager->links()는 CI4 기본 페이저 템플릿으로 "이전 / 1 / 2 / … / 다음" 형태의 페이지 이동 UI를 자동으로 만들어 줍니다. 클릭하면 ?page=2처럼 쿼리스트링이 붙은 주소로 이동하고, 컨트롤러의 paginate()가 그 번호를 읽어 해당 페이지 글만 읽습니다.
참고: 커리큘럼에는 커스텀 페이저 템플릿(
app/Views/templates/pager_full.php)이 선택 항목으로 들어 있습니다. 지금은 기본 페이저로 충분하니 그대로 씁니다.
3. "쿼리 최적화"를 어떻게 보는가 — 디버그 툴바
ep01에서 .env를 development로 바꾸면 화면 오른쪽 아래 디버그 툴바가 뜬다고 했습니다. 그 툴바의 Database 탭에는 이 요청에서 실제로 실행된 SQL과 쿼리 개수가 찍힙니다.
여기서 findAll()과 paginate()의 차이를 감각적으로 이해해 둡시다.
findAll()은 정렬된 글을 전부 한 방에SELECT합니다. 글이 1,000건이면 1,000건이 통째로 옵니다.paginate(5)는 전체 개수를 세는 쿼리 한 번과,LIMIT 5 OFFSET ...로 딱 그 페이지 5건만 읽는 쿼리로 나뉩니다. 글이 1,000건이든 100만 건이든 한 페이지에 읽는 행 수는 5로 고정됩니다.
목록을 최적화하는 회차에서 이 툴바의 쿼리 수·조회 행 수를 전/후로 비교하는 감각이 앞으로 계속 쓰입니다. 눈으로 확인하지 않으면 "최적화했다"는 말이 그냥 믿음일 뿐이니까요.
N+1 문제는 왜 지금 안 다루는가
목록 최적화를 이야기하면 흔히 함께 나오는 게 N+1 문제입니다. 글 목록 N건을 그리면서, 각 글의 작성자 이름을 얻으려고 글마다 사용자 조회를 한 번씩 더 날리는 상황 말이죠. 그러면 쿼리가 1(목록) + N(작성자)번이 되어, 글이 늘수록 쿼리가 폭증합니다. 해법은 보통 작성자 테이블을 조인하거나 한 번에 일괄 조회해서 추가 쿼리를 없애는 것입니다.
그런데 지금 우리 프로젝트에는 조인할 상대 테이블이 아직 없습니다. 사용자(users) 테이블은 인증 라이브러리 Shield를 도입하는 ep10에서야 생기고, 그전까지 모든 글의 user_id는 null입니다. 존재하지 않는 테이블을 미리 조인하는 건 미래 회차의 코드를 앞당겨 끌어오는 일이라, 이 강좌의 원칙(회차 순서대로, 미래 코드를 미리 쓰지 않기)에 어긋납니다.
그래서 작성자 조인을 통한 N+1 회피는 users 테이블이 생기는 ep10 이후로 미룹니다. 이번 회차의 "쿼리 최적화"는 페이지네이션으로 한 번에 읽는 행 수 자체를 줄이는 것에 집중합니다. N+1의 개념만 여기서 잡아 두고, 실제 조인 코드는 재료(users 테이블)가 갖춰진 뒤에 다룹니다.
4. Feature 테스트 — 페이지가 정말 나뉘는가
ep07의 PostIndexTest를 페이지네이션에 맞게 손봅니다. 판정 기준은 ep06 시더가 만든 정렬입니다. 시더는 글 6건을 하루씩 벌려 넣었으니, 한 페이지 5건 기준으로 1페이지=최신 5건, 2페이지=가장 오래된 1건이 됩니다.
final class PostIndexTest extends CIUnitTestCase
{
use FeatureTestTrait;
use DatabaseTestTrait;
protected $namespace = 'App';
protected $refresh = true;
protected $seed = 'App\Database\Seeds\PostSeeder';
// 시더가 넣는 글 중 가장 최신/가장 오래된 글의 제목
private const NEWEST_TITLE = 'CodeIgniter 4로 블로그 만들기를 시작하며';
private const OLDEST_TITLE = '시더로 현실적인 더미 데이터 채우기';
protected function setUp(): void
{
parent::setUp();
// 페이저는 공유 서비스라 앞 테스트의 currentPage 가 캐시된 채 남는다.
// 매 테스트마다 새로 만들어 페이지 계산이 서로 섞이지 않게 한다.
\Config\Services::resetSingle('pager');
}
public function testIndexReturns200(): void
{
$this->call('GET', 'posts')->assertStatus(200);
}
public function testFirstPageShowsNewestPost(): void
{
$this->call('GET', 'posts')->assertSee(self::NEWEST_TITLE);
}
public function testFirstPageExcludesOldestPost(): void
{
// 한 페이지 5건이면 6번째(가장 오래된) 글은 1페이지에 없어야 한다
$this->call('GET', 'posts')->assertDontSee(self::OLDEST_TITLE);
}
public function testSecondPageShowsOldestPost(): void
{
// 페이지 번호는 GET 파라미터로 넘긴다. 실제 HTTP 쿼리스트링처럼
// 문자열로 줘야 페이저(Superglobals::get)가 올바르게 인식한다.
$this->call('GET', 'posts', ['page' => '2'])->assertSee(self::OLDEST_TITLE);
}
}
테스트가 페이지네이션을 검증하는 방식이 영리합니다. 페이지 내용을 일일이 세는 대신, 경계에 있는 두 글로 판정합니다.
testFirstPageShowsNewestPost— 1페이지에 최신 글이 보인다(당연).testFirstPageExcludesOldestPost— 1페이지에는 가장 오래된 6번째 글이 안 보인다(assertDontSee). 페이지가 잘렸다는 증거입니다.testSecondPageShowsOldestPost—?page=2로 넘어가면 그 오래된 글이 보인다. 다음 페이지가 동작한다는 증거입니다.
두 가지 함정도 코드가 미리 막아 뒀습니다.
setUp()의resetSingle('pager')— 페이저는 CI4의 공유 서비스(싱글턴)라, 앞 테스트가 설정한 현재 페이지 번호가 다음 테스트까지 캐시된 채 남을 수 있습니다. 매 테스트 시작 시 페이저를 새로 만들어 페이지 계산이 서로 섞이지 않게 합니다.['page' => '2']의 문자열'2'— 페이지 번호를 정수2가 아니라 문자열'2'로 넘깁니다. 실제 HTTP 요청의 쿼리스트링은 늘 문자열이라, 페이저가 값을 읽는 경로(Superglobals)와 결이 맞아야 페이지를 올바로 인식합니다.
./vendor/bin/phpunit tests/Feature/PostIndexTest.php
덧붙임 — 범위를 벗어난 page 는 어떻게 되는가
이 회차를 쓰고 한참 뒤에, 검색엔진 색인을 점검하다 이 페이지네이션에서 생각지 못한 것을 발견했습니다. ?page=999처럼 있지도 않은 페이지를 요청하면 무엇이 나올까요?
빈 목록이 나올 것 같지만 아닙니다. CI4의 Pager는 범위를 넘긴 페이지 번호를 마지막 페이지로 조용히 당겨 버립니다.
// system/Pager/Pager.php — store()
$this->groups[$group]['currentPage'] = $page > $pageCount ? $pageCount : $page;
그래서 마지막이 4페이지라면 ?page=5도, ?page=999도 4페이지와 바이트 단위로 똑같은 응답을 200으로 돌려줍니다. 사람에게는 친절한 동작이지만, 검색엔진에게는 같은 내용을 가진 URL이 무한히 있는 것으로 보입니다.
고치는 방법은 간단합니다. 요청한 페이지가 전체 페이지 수를 넘으면 404를 응답하면 됩니다.
$posts = $model->orderBy('created_at', 'DESC')->paginate(self::PER_PAGE);
$requested = (int) ($this->request->getGet('page') ?? 1);
if ($requested > 1 && $requested > $model->pager->getPageCount()) {
throw PageNotFoundException::forPageNotFound();
}
$requested > 1 조건이 없으면 안 됩니다. 글이 하나도 없는 블로그의 첫 페이지까지 404가 되어 목록이 통째로 사라집니다. 없는 것은 페이지가 아니라 글입니다.
참고: "결과가 비었으면 404"로 판정하고 싶어지지만, 위의 클램프 때문에 결과는 애초에 비지 않습니다. 판정 기준은
getPageCount()여야 합니다.
마무리 — 커밋과 태그
git add .
git commit -m "feat: 페이지네이션과 쿼리 최적화"
git tag ep08
다음 회차
목록은 완성됐지만, 제목을 눌러도 아무 데도 가지 않습니다. 다음 글(#09)에서는 글 상세 페이지를 만듭니다. posts/(:segment) 라우트로 slug를 받아 글 한 건을 보여 주고, 없는 slug에는 PageNotFoundException으로 404를 응답합니다. 목록 제목에 상세 링크를 걸어 목록↔상세를 잇습니다.
이번 회차 요약
- 다루는 파일:
app/Controllers/Posts.php,app/Views/posts/index.php,tests/Feature/PostIndexTest.php(모두 수정)- 핵심:
findAll()→paginate(5)로LIMIT/OFFSET을 걸어 한 번에 읽는 행 수를 고정한다.$pager->links()로 이동 UI. 디버그 툴바의 쿼리 수로 전/후를 비교. 작성자 조인을 통한 N+1 회피는 users 테이블이 생기는 ep10 이후로 미룬다.- 다음: 글 상세 페이지와 목록 연결(ep09)
댓글 0
아직 댓글이 없습니다.