· nambak80 Blog 로그인
CodeIgniter4

CodeIgniter 4로 블로그 만들기 #08 — 페이지네이션과 쿼리 최적화

CodeIgniter 4로 블로그 만들기 #08 — 페이지네이션과 쿼리 최적화

지난 회차에서 글 목록을 그렸습니다. 그런데 컨트롤러를 다시 보면 findAll()글을 한 번에 전부 읽고 있습니다. 시더 글이 6건일 땐 아무 문제 없지만, 글이 수백·수천 건으로 늘면 매 요청마다 그 전부를 메모리로 끌어오게 됩니다. 목록 첫 화면에 필요한 건 고작 몇 건인데 말이죠.

이번 회차의 목표는 **페이지네이션(pagination)**입니다. 한 페이지에 5건씩만 끊어 읽고, 페이지를 넘기는 UI를 붙입니다. 그리고 이 과정에서 "쿼리를 얼마나, 어떻게 날리는가"를 디버그 툴바로 눈으로 확인하는 습관을 들입니다.

이번 회차 목표

  • findAll() 대신 paginate(5)로 한 페이지 5건씩 읽는다.
  • 뷰에 $pager->links()로 페이지 이동 UI를 그린다.
  • 페이지네이션이 실제로 페이지를 나누는지 Feature 테스트로 검증한다.
  • 디버그 툴바로 쿼리를 관찰하는 감각을 익힌다.

1. 컨트롤러 — findAll을 paginate로

app/Controllers/Posts.phpindex()를 바꿉니다.

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에 **LIMITOFFSET**을 붙여, 요청된 페이지에 해당하는 5건만 읽어 옵니다. 현재 페이지 번호는 ?page=2 같은 GET 파라미터에서 자동으로 읽습니다. 이게 이번 회차의 핵심 최적화입니다. 글이 아무리 늘어도 한 번에 5건 + 전체 개수(count) 정도만 조회합니다.
  • $model->pagerpaginate()를 부르고 나면 모델에 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에서 .envdevelopment로 바꾸면 화면 오른쪽 아래 디버그 툴바가 뜬다고 했습니다. 그 툴바의 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_idnull입니다. 존재하지 않는 테이블을 미리 조인하는 건 미래 회차의 코드를 앞당겨 끌어오는 일이라, 이 강좌의 원칙(회차 순서대로, 미래 코드를 미리 쓰지 않기)에 어긋납니다.

그래서 작성자 조인을 통한 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페이지에 최신 글이 보인다(당연).
  • testFirstPageExcludesOldestPost1페이지에는 가장 오래된 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

아직 댓글이 없습니다.

로그인 후 댓글을 남길 수 있습니다.

← 목록으로