CodeIgniter 4로 블로그 만들기 #17 — slug 기반 URL과 테스트 정리
섹션 4(글 작성 CRUD)의 마지막 회차입니다. 지금까지 우리는 한 가지 임시방편을 안고 왔습니다. 글을 저장할 때 컨트롤러가 post- 뒤에 랜덤 문자열을 붙인 임시 slug를 채워 넣었죠.
// ep13~16 의 create()
$data['slug'] = 'post-' . bin2hex(random_bytes(6));
posts.slug가 NOT NULL UNIQUE라 값을 비워 둘 수 없어 급한 대로 넣은 것이었습니다. 하지만 /posts/post-a1b2c3d4e5f6 같은 주소는 사람에게도 검색엔진에게도 무의미합니다. 이번 회차에서 이걸 제목으로부터 자동 생성되는 의미 있는 slug로 바꿉니다. 그리고 이 로직을 컨트롤러가 아니라 모델의 콜백에 두는 이유도 짚습니다.
이번 회차의 목표
PostModel에beforeInsert/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() 대신 직접 규칙을 세웠습니다.
- 소문자로 바꾸고 앞뒤 공백 제거
- 공백을 하이픈으로
- 소문자 영문·숫자·완성형 한글(가-힣)·하이픈만 남기고 나머지 제거
- 연속된 하이픈을 하나로 축약, 앞뒤 하이픈 정리
- 그래도 결과가 비면(예: 특수문자만 있는 제목)
'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를 읽어 쓰는" 방식으로 바꿨습니다. 구현이 바뀌어도 계약(권한 있는 자에게 버튼이 보인다)만 지켜지면 통과하도록, 테스트를 세부에서 떼어 냈습니다.
마무리 — 커밋과 태그
이번 회차에서 한 일:
PostModel에beforeInsert/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 테이블과 post–comments 관계를 만들고, 글 상세 화면에 댓글 목록을 표시합니다. 이후 저장 백엔드, 인증 가드, 삭제 권한까지 글과 비슷한 흐름으로 댓글 기능을 쌓아 갑니다.
이번 회차 요약
- 다루는 파일:
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 정책을 직접 세우고,slugify와permittedURIChars의 문자 집합을 일치시킨다. 테스트는 실제 생성된 slug를 읽어 쓴다.
다음: 댓글 표시
댓글 0
아직 댓글이 없습니다.