· nambak80 Blog 로그인
CodeIgniter4

CodeIgniter 4로 블로그 만들기 #17 — slug 기반 URL과 테스트 정리

CodeIgniter 4로 블로그 만들기 #17 — slug 기반 URL과 테스트 정리

섹션 4(글 작성 CRUD)의 마지막 회차입니다. 지금까지 우리는 한 가지 임시방편을 안고 왔습니다. 글을 저장할 때 컨트롤러가 post- 뒤에 랜덤 문자열을 붙인 임시 slug를 채워 넣었죠.

// ep13~16 의 create()
$data['slug'] = 'post-' . bin2hex(random_bytes(6));

posts.slugNOT NULL UNIQUE라 값을 비워 둘 수 없어 급한 대로 넣은 것이었습니다. 하지만 /posts/post-a1b2c3d4e5f6 같은 주소는 사람에게도 검색엔진에게도 무의미합니다. 이번 회차에서 이걸 제목으로부터 자동 생성되는 의미 있는 slug로 바꿉니다. 그리고 이 로직을 컨트롤러가 아니라 모델의 콜백에 두는 이유도 짚습니다.

이번 회차의 목표

  • PostModelbeforeInsert/beforeUpdate 콜백을 달아 제목으로 slug를 자동 생성한다.
  • 한국어 제목도 다룰 수 있는 slug 정책(slugify)과, 중복 시 -2, -3을 붙이는 처리(uniqueSlug)를 만든다.
  • 컨트롤러의 임시 slug 생성을 제거한다.
  • 한글 slug URL을 허용하도록 Config\App을 손보고, 라우트 순서를 정리한다.
  • 자동 slug로 바뀐 것에 맞춰 기존 테스트를 수선한다.

1. 왜 모델 콜백인가

slug 생성을 컨트롤러에 둘 수도 있습니다. 하지만 글은 컨트롤러의 create()에서만 저장되는 게 아닙니다. 시더, 테스트, 나중에 추가될 다른 진입점에서도 $model->insert()가 호출될 수 있습니다. slug 생성을 컨트롤러에 두면 그 모든 곳에 같은 코드를 흩뿌려야 하고, 하나라도 빠뜨리면 slug 없는 글이 생깁니다.

모델의 beforeInsert/beforeUpdate 콜백에 두면, 어디서 저장하든 slug가 자동으로 채워집니다. 데이터의 무결성 규칙(글에는 반드시 slug가 있다)을 데이터 계층이 스스로 지키게 하는 것 — 이게 콜백으로 옮기는 핵심 이유입니다. 실제로 이번 회차에서 테스트의 makePost()slug를 넘기지 않게 되는데, 모델이 알아서 만들어 주기 때문입니다.

2. PostModel — 콜백과 slug 로직

app/Models/PostModel.php에 콜백 등록과 세 개의 메서드를 추가합니다. 먼저 콜백을 어느 시점에 부를지 선언합니다.

    // 저장/수정 직전에 제목으로 slug 를 자동 생성한다.
    // (이전까지 컨트롤러가 임시 slug 를 채우던 것을 모델로 옮긴다.)
    protected $beforeInsert = ['generateSlug'];
    protected $beforeUpdate = ['generateSlug'];

$beforeInsert/$beforeUpdate는 각각 insert/update 직전에 실행할 메서드 이름 배열입니다. 둘 다 같은 generateSlug를 부릅니다.

콜백 본체입니다.

    /**
     * 콜백: title 이 들어오면 slug 를 만들어 $data 에 채운다.
     * title 이 없는 부분 수정에서는 기존 slug 를 건드리지 않는다.
     */
    protected function generateSlug(array $data): array
    {
        if (! isset($data['data']['title'])) {
            return $data;
        }

        $base = $this->slugify((string) $data['data']['title']);

        // 수정 시 자기 자신은 중복 검사에서 제외한다.
        $excludeId = isset($data['id'][0]) ? (int) $data['id'][0] : null;

        $data['data']['slug'] = $this->uniqueSlug($base, $excludeId);

        return $data;
    }

CI4 모델 콜백은 $data라는 특수한 배열을 받고 그대로 돌려줘야 합니다. 실제 컬럼 값은 $data['data']에 들어 있고, update의 경우 대상 id는 $data['id']에 담깁니다. 이 구조를 이해하는 게 콜백을 다루는 열쇠입니다.

  • title이 없으면 그냥 반환 — 제목을 건드리지 않는 부분 수정(예: 나중에 이미지만 바꾸는 update)에서는 기존 slug를 유지해야 합니다. 제목이 없는데 slug를 새로 만들면 URL이 이유 없이 바뀌어 버립니다.
  • $excludeId — 수정 시엔 자기 자신이 이미 그 slug를 갖고 있으니, 중복 검사에서 자신은 빼야 합니다. $data['id'][0]에서 대상 id를 꺼냅니다.

이제 제목을 slug로 바꾸는 slugify().

    /**
     * 제목을 URL 안전한 slug 로 바꾼다.
     * 한국어 제목은 url_title()이 빈 문자열이 되므로, 글자/숫자(한글 포함)는
     * 살리고 공백은 하이픈으로 바꾼다. 결과가 비면 'post' 로 대체한다.
     */
    private function slugify(string $title): string
    {
        $slug = mb_strtolower(trim($title));
        $slug = preg_replace('/\s+/u', '-', $slug);             // 공백 → 하이픈
        // 허용: 소문자 영문·숫자·완성형 한글·하이픈 (Config\App::$permittedURIChars 와 동일 집합)
        $slug = preg_replace('/[^a-z0-9가-힣\-]+/u', '', $slug);
        $slug = preg_replace('/-+/', '-', $slug);               // 연속 하이픈 축약
        $slug = trim($slug, '-');

        return $slug !== '' ? $slug : 'post';
    }

여기서 이번 회차의 가장 실무적인 판단이 나옵니다. CI4에는 slug를 만드는 url_title() 헬퍼가 있지만, 한국어 제목을 넣으면 빈 문자열이 됩니다(ASCII만 남기기 때문). 우리 블로그는 한글 제목이 대부분이므로, url_title() 대신 직접 규칙을 세웠습니다.

  1. 소문자로 바꾸고 앞뒤 공백 제거
  2. 공백을 하이픈으로
  3. 소문자 영문·숫자·완성형 한글(가-힣)·하이픈만 남기고 나머지 제거
  4. 연속된 하이픈을 하나로 축약, 앞뒤 하이픈 정리
  5. 그래도 결과가 비면(예: 특수문자만 있는 제목) 'post'로 대체

정규식의 가-힣가 핵심입니다. 완성형 한글을 허용 문자에 넣어 코드이그나이터-블로그 같은 slug가 살아남게 합니다.

마지막으로 중복을 피하는 uniqueSlug().

    /**
     * slug 가 이미 있으면 -2, -3 … 을 붙여 유일하게 만든다.
     */
    private function uniqueSlug(string $base, ?int $excludeId = null): string
    {
        $slug   = $base;
        $suffix = 2;

        while (true) {
            $builder = $this->where('slug', $slug);
            if ($excludeId !== null) {
                $builder->where('id !=', $excludeId);
            }

            // countAllResults() 는 기본적으로 쿼리 빌더를 초기화하므로
            // 다음 루프의 조건이 누적되지 않는다.
            if ($builder->countAllResults() === 0) {
                return $slug;
            }

            $slug = $base . '-' . $suffix++;
        }
    }

같은 제목의 글이 둘이면 slug가 겹칩니다(NOT NULL UNIQUE 위반). 그래서 이미 쓰이는 slug면 -2, -3 … 을 붙여 빈 자리를 찾을 때까지 반복합니다. 수정 시엔 $excludeId로 자기 자신을 제외해, "내가 이미 그 slug다"라는 이유로 무한히 숫자가 붙는 걸 막습니다.

주석의 countAllResults()가 쿼리 빌더를 자동 초기화한다는 점이 중요합니다. 덕분에 루프를 돌 때마다 where 조건이 누적되지 않고 매번 깨끗한 상태에서 검사합니다.

3. 컨트롤러 — 임시 slug 제거

모델이 slug를 만들어 주니, create()의 임시 slug 코드를 지웁니다.

        // 현재 로그인한 사용자를 작성자로 묶는다.
        $data['user_id'] = auth()->id();

        // slug 는 PostModel 의 beforeInsert 콜백이 제작한다.

$data['slug'] = 'post-' . bin2hex(...) 줄이 통째로 사라졌습니다. 컨트롤러는 이제 slug를 전혀 신경 쓰지 않습니다. slug의 책임이 온전히 모델로 넘어간 것입니다.

4. 한글 slug URL 허용 — Config\App

slug에 한글을 넣기로 했으니, URL에도 한글이 들어옵니다. CI4는 보안상 URL에 허용할 문자를 permittedURIChars로 제한하는데, 기본값에는 한글이 없습니다. app/Config/App.php에서 완성형 한글을 추가합니다.

    // 한글 slug(예: /posts/코드이그나이터-블로그)를 허용하려고 완성형 한글(가-힣)을 추가한다.
    public string $permittedURIChars = 'a-z 0-9~%.:_\-가-힣';

slugify()에서 허용한 문자 집합과 정확히 같게 맞추는 게 포인트입니다. slug를 만들 때 허용한 문자를, URL에서도 허용해야 앞뒤가 맞습니다. 이 값을 안 바꾸면 한글 slug로 접속할 때 CI4가 URL을 거부합니다.

5. 라우트 순서 정리

slug 라우트가 숫자 쓰기 라우트와 충돌하지 않도록 순서를 주석으로 못 박습니다. app/Config/Routes.php.

// 글 상세는 slug 기반(:segment). 위의 (:num) 쓰기 라우트보다 아래에 둬
// 'posts/5' 같은 숫자 경로가 먼저 매칭되도록 한다.
$routes->get('posts/(:segment)', 'Posts::show/$1');

라우트는 위에서부터 매칭됩니다. posts/(:num)/edit이나 posts/(:num)(수정 저장) 같은 숫자 기반 쓰기 라우트가 먼저 오고, 그 아래에 posts/(:segment)(slug 상세)가 와야 합니다. 만약 slug 라우트가 위에 있으면 (:segment)edit 같은 문자열까지 삼켜 버려 수정 폼이 안 열립니다.

6. 테스트 수선 — 자동 slug에 맞춰

slug를 이제 모델이 만드므로, 고정 slug(post-to-delete)를 넘기고 그걸로 조회하던 PostDeleteTest를 손봐야 합니다. 먼저 makePost()에서 slug를 빼고 모델에 맡깁니다.

        // 제목/본문에 '삭제'가 들어가면 버튼 노출 검사와 헷갈리므로 피한다.
        // slug 는 ep17부터 PostModel 이 제목으로 자동 생성한다.
        $model->insert([
            'user_id' => $userId,
            'title'   => '권한 테스트 글',
            'body'    => '권한 테스트 본문',
        ]);

그리고 버튼 노출 테스트들은, 고정 slug 대신 실제로 생성된 slug를 DB에서 다시 읽어 조회합니다.

    public function testDeleteButtonVisibleToAuthor(): void
    {
        $author = $this->makeUser('author', 'author@example.com');
        $id     = $this->makePost($author->id);
        $slug   = model(PostModel::class)->find($id)->slug;

        $result = $this->actingAs($author)->call('GET', 'posts/' . $slug);

        $result->assertSee('삭제');
    }

    public function testDeleteButtonHiddenFromNonAuthor(): void
    {
        $author = $this->makeUser('author', 'author@example.com');
        $id     = $this->makePost($author->id);
        $other  = $this->makeUser('other', 'other@example.com');
        $slug   = model(PostModel::class)->find($id)->slug;

        $result = $this->actingAs($other)->call('GET', 'posts/' . $slug);

        $result->assertDontSee('삭제');
    }

find($id)->slug로 방금 저장된 글의 진짜 slug를 꺼내 그 주소로 요청합니다. 테스트가 slug 생성 규칙의 세부(한글 처리, -2 접미사)에 의존하지 않고 실제 생성 결과를 따라가므로, slug 정책이 나중에 조금 바뀌어도 테스트가 깨지지 않습니다. 이게 "테스트를 구현 세부에 묶지 않는" 좋은 습관입니다.

수정 후 composer test를 돌려 빨간불이던 테스트들이 하나씩 초록으로 바뀌는 걸 확인하면 이번 회차는 끝입니다.

핵심 개념 — 왜 이렇게 하는가

1. 무결성 규칙은 데이터 계층에. "모든 글엔 slug가 있다"는 데이터의 규칙입니다. 이걸 컨트롤러(요청 처리 계층)가 아니라 모델(데이터 계층)의 콜백에 두면, 저장 경로가 여럿이어도 규칙이 한 번에 지켜집니다. 책임을 올바른 층에 두는 것입니다.

2. 한국어 현실을 반영한 slug 정책. 관용적인 url_title()이 한글에서 무용지물이 되는 문제를, 허용 문자 집합에 가-힣를 넣어 해결했습니다. 그리고 그 집합을 slugify·permittedURIChars 두 곳에서 일치시켜 slug 생성과 URL 접근이 어긋나지 않게 했습니다.

3. 테스트는 결과를 따라간다.* 고정 slug를 가정하던 테스트를 "실제 생성된 slug를 읽어 쓰는" 방식으로 바꿨습니다. 구현이 바뀌어도 계약(권한 있는 자에게 버튼이 보인다)만 지켜지면 통과하도록, 테스트를 세부에서 떼어 냈습니다.

마무리 — 커밋과 태그

이번 회차에서 한 일:

  • PostModelbeforeInsert/beforeUpdate 콜백 + slugify(한글+ASCII) + uniqueSlug(중복 -N)
  • 컨트롤러의 임시 slug 생성 제거
  • Config\App::$permittedURIChars가-힣 추가
  • 라우트 순서 주석 정리
  • 자동 slug에 맞춰 PostDeleteTest 수선
git add .
git commit -m "feat: slug 기반 URL과 테스트 정리"
git tag ep17

이걸로 섹션 4 — 글 작성 CRUD가 완성됐습니다. 검증·CSRF·권한·플래시·의미 있는 URL까지, 실전 블로그의 글쓰기에 필요한 뼈대가 다 섰습니다.

다음 회차

다음 글부터는 섹션 5 — 댓글로 넘어갑니다. 먼저 comments 테이블과 postcomments 관계를 만들고, 글 상세 화면에 댓글 목록을 표시합니다. 이후 저장 백엔드, 인증 가드, 삭제 권한까지 글과 비슷한 흐름으로 댓글 기능을 쌓아 갑니다.


이번 회차 요약

  • 다루는 파일: app/Models/PostModel.php(beforeInsert/beforeUpdate 콜백·slugify·uniqueSlug) / app/Controllers/Posts.php(임시 slug 제거) / app/Config/App.php(permittedURIChars가-힣) / app/Config/Routes.php(순서 주석) / tests/Feature/PostDeleteTest.php(자동 slug 수선)
  • 핵심: slug 생성은 모델 콜백으로 옮겨 어디서 저장하든 자동 적용. 한글 제목은 url_title()이 못 다루므로 가-힣를 허용하는 slug 정책을 직접 세우고, slugifypermittedURIChars의 문자 집합을 일치시킨다. 테스트는 실제 생성된 slug를 읽어 쓴다.

다음: 댓글 표시

댓글 0

아직 댓글이 없습니다.

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

← 목록으로