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
마무리 — 커밋과 태그
git add .
git commit -m "feat: 페이지네이션과 쿼리 최적화"
git tag ep08
다음 회차
목록은 완성됐지만, 제목을 눌러도 아무 데도 가지 않습니다. 다음 글에서는 글 상세 페이지를 만듭니다. 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 이후로 미룬다.
다음: 글 상세 페이지와 목록 연결
댓글 0
아직 댓글이 없습니다.