· nambak80 Blog 로그인
CodeIgniter4

CodeIgniter 4로 블로그 만들기 #22 — 카테고리 구조

CodeIgniter 4로 블로그 만들기 #22 — 카테고리 구조

댓글까지 붙이고 나니 블로그가 제법 그럴듯해졌습니다. 이번 섹션부터는 글을 분류하고 찾는 기능을 만듭니다. 그 첫걸음이 카테고리입니다.

카테고리를 도입하려면 두 가지가 필요합니다. 하나는 카테고리 자체를 담을 새 테이블, 다른 하나는 "이 글이 어느 카테고리에 속하는가"를 기록할 posts의 새 컬럼입니다. 이번 회차의 진짜 배울 거리는 바로 이 두 번째, 이미 존재하는 테이블에 컬럼을 더하는 "테이블 변경 마이그레이션" 입니다. 지금까지는 테이블을 새로 만들기만 했지, 이미 데이터가 들어 있을 수도 있는 테이블을 뒤늦게 바꾸는 건 처음입니다.

이번 회차의 목표

  • categories 테이블을 새로 만든다(이름 + 유일한 slug).
  • postscategory_id 컬럼을 추가한다(테이블 변경 마이그레이션, null 허용).
  • CategoryModel / Category 엔티티를 만든다.
  • 시더가 카테고리를 채우고, 글을 카테고리에 연결한다.

이번 회차는 화면 변화가 없습니다. 카테고리를 실제로 보여 주고 거르는 건 다음 회차(ep23)의 몫입니다. 지금은 데이터의 뼈대만 세웁니다.

1. categories 테이블 만들기

먼저 카테고리를 담을 테이블입니다. 지금까지 posts, comments를 만들 때 썼던 것과 같은 Forge 마이그레이션입니다.

app/Database/Migrations/2026-06-17-100000_CreateCategoriesTable.php:

<?php

namespace App\Database\Migrations;

use CodeIgniter\Database\Migration;

class CreateCategoriesTable extends Migration
{
    public function up()
    {
        $this->forge->addField([
            'id'         => [
                'type'           => 'INT',
                'constraint'     => 11,
                'unsigned'       => true,
                'auto_increment' => true,
            ],
            'name'       => [
                'type'       => 'VARCHAR',
                'constraint' => 100,
            ],
            'slug'       => [
                'type'       => 'VARCHAR',
                'constraint' => 100,
            ],
            'created_at' => [
                'type' => 'DATETIME',
                'null' => true,
            ],
            'updated_at' => [
                'type' => 'DATETIME',
                'null' => true,
            ],
        ]);

        $this->forge->addPrimaryKey('id');
        // 슬러그는 카테고리 URL(`categories/{slug}`) 식별자라 유일해야 한다.
        $this->forge->addUniqueKey('slug');
        $this->forge->createTable('categories');
    }

    public function down()
    {
        $this->forge->dropTable('categories');
    }
}

포인트는 slug에 건 유니크 키입니다. 다음 회차에서 카테고리 주소가 categories/web, categories/retrospect처럼 slug로 식별되기 때문에, slug가 겹치면 어느 카테고리인지 알 수 없게 됩니다. 그래서 DB 레벨에서 아예 중복을 막아 둡니다.

2. posts에 category_id 더하기 — 테이블 변경 마이그레이션

이번 회차의 핵심입니다. posts 테이블은 이미 ep05에서 만들었고, 시더로 더미 글도 들어가 있습니다. 여기에 컬럼 하나를 뒤늦게 붙여야 합니다. 새 테이블을 만드는 createTable이 아니라, addColumn으로 기존 테이블을 변경합니다.

app/Database/Migrations/2026-06-17-100100_AddCategoryIdToPosts.php:

<?php

namespace App\Database\Migrations;

use CodeIgniter\Database\Migration;

class AddCategoryIdToPosts extends Migration
{
    public function up()
    {
        // 이미 만들어진 posts 테이블에 컬럼을 더하는 "테이블 변경" 마이그레이션.
        // category 가 지워져도 글은 남겨야 하므로 null 을 허용한다.
        // (DB 레벨 외래키는 SQLite·MySQL 간 addColumn 이식성을 위해 걸지 않고,
        //  관계는 모델·시더에서 관리한다. 'after' 는 MySQL 전용이라 SQLite 는 무시한다.)
        $this->forge->addColumn('posts', [
            'category_id' => [
                'type'       => 'INT',
                'constraint' => 11,
                'unsigned'   => true,
                'null'       => true,
                'after'      => 'user_id',
            ],
        ]);

        // 다음 회차의 카테고리 필터가 `WHERE category_id = ?` 로 자주 조회하므로
        // 풀스캔을 피하도록 인덱스를 함께 건다. (이미 있는 테이블에 인덱스 추가)
        $this->forge->addKey('category_id');
        $this->forge->processIndexes('posts');
    }

    public function down()
    {
        // 컬럼을 드롭하면 딸린 인덱스(category_id)도 함께 사라진다(MySQL·SQLite 공통).
        $this->forge->dropColumn('posts', 'category_id');
    }
}

여기서 짚어 둘 결정이 세 가지입니다.

첫째, null 허용. category_id를 null이 가능하도록 열어 뒀습니다. 나중에 카테고리가 삭제돼도 그 글까지 사라지면 안 되고, "분류 없는 글"도 존재할 수 있어야 하기 때문입니다. 만약 null을 막으면, 컬럼을 추가하는 순간 이미 들어 있던 더미 글들이 "값이 없다"며 문제를 일으킬 수 있습니다.

둘째, DB 외래키를 걸지 않았습니다. 관계를 DB의 FOREIGN KEY 제약으로 강제하는 방법도 있지만, 여기서는 걸지 않았습니다. 주석에 적힌 대로 SQLite와 MySQL 사이에서 addColumn 이식성을 확보하기 위해서입니다(테스트 DB는 SQLite 메모리, 개발 DB는 MySQL일 수 있습니다). 대신 "글은 반드시 유효한 카테고리를 가진다"는 무결성은 모델과 시더가 책임집니다.

셋째, 인덱스를 함께 걸었습니다. 다음 회차에서 WHERE category_id = ?로 카테고리를 자주 거를 예정입니다. 인덱스가 없으면 매번 테이블 전체를 훑어야 하므로, 미리 addKey + processIndexes로 인덱스를 붙여 둡니다. createTable이 아니라 이미 있는 테이블에 인덱스를 추가하는 것이라 processIndexes('posts')를 호출한다는 점에 주의하세요.

after 옵션(user_id 뒤에 놓기)은 MySQL에서만 의미가 있고 SQLite는 조용히 무시합니다. 컬럼 위치는 기능에 영향을 주지 않으니 이식성 문제는 없습니다.

마이그레이션을 돌립니다.

php spark migrate

3. CategoryModel과 Category 엔티티

posts에서 익힌 Model + Entity 조합을 그대로 반복합니다. 먼저 엔티티입니다.

app/Entities/Category.php:

<?php

namespace App\Entities;

use CodeIgniter\Entity\Entity;

/**
 * 카테고리 한 건을 나타내는 도메인 객체.
 *
 * 글 목록의 분류 메뉴와 `categories/{slug}` 필터에서 쓰인다.
 */
class Category extends Entity
{
    protected $dates = ['created_at', 'updated_at'];

    /**
     * 이 카테고리로 글 목록을 거르는 URL.
     *
     * 뷰에서 `$category->url` 로 접근한다.
     */
    public function getUrl(): string
    {
        return site_url('categories/' . $this->attributes['slug']);
    }
}

getUrl() 접근자가 있어 뷰에서 $category->url만 쓰면 categories/web 같은 주소가 나옵니다. URL을 만드는 규칙을 엔티티 한 곳에 모아 두면, 나중에 주소 형태가 바뀌어도 여기만 고치면 됩니다.

app/Models/CategoryModel.php:

<?php

namespace App\Models;

use App\Entities\Category;
use CodeIgniter\Model;

class CategoryModel extends Model
{
    protected $table      = 'categories';
    protected $primaryKey = 'id';

    protected $returnType    = Category::class;
    protected $useTimestamps = true;

    protected $allowedFields = [
        'name',
        'slug',
    ];

    protected $validationRules = [
        'name' => 'required|max_length[100]',
        'slug' => 'required|max_length[100]|is_unique[categories.slug,id,{id}]',
    ];

    protected $validationMessages = [
        'name' => [
            'required' => '카테고리 이름을 입력해 주세요.',
        ],
        'slug' => [
            'required'  => '카테고리 슬러그를 입력해 주세요.',
            'is_unique' => '이미 사용 중인 슬러그입니다.',
        ],
    ];

    /**
     * 메뉴·필터에서 쓰는 전체 카테고리 목록(이름순).
     *
     * @return Category[]
     */
    public function menu(): array
    {
        return $this->orderBy('name', 'ASC')->findAll();
    }
}

$returnTypeCategory::class로 지정해 모델이 반환하는 결과가 곧 Category 엔티티가 되게 묶었습니다. slug 검증 규칙의 is_unique[categories.slug,id,{id}]는 DB 유니크 제약과 짝을 이루는 애플리케이션 레벨 검증입니다.

menu() 메서드는 다음 회차에서 분류 메뉴를 그릴 때 쓸 "이름순 전체 카테고리"를 돌려줍니다. 이런 조회 로직을 모델 메서드로 이름 붙여 두면 컨트롤러가 깔끔해집니다.

4. PostModel에 category_id 허용하기

컬럼을 추가했으니, 모델이 그 컬럼에 값을 쓸 수 있도록 $allowedFields에 등록해야 합니다. 대량 할당(mass assignment) 보호 때문에, 여기에 없는 필드는 save()insert()로 저장되지 않습니다.

app/Models/PostModel.php:

    // 대량 할당을 허용할 필드. id 와 타임스탬프는 제외한다.
    protected $allowedFields = [
        'user_id',
        'category_id',
        'title',
        'slug',
        'body',
    ];

category_id 한 줄만 더했습니다. 이걸 빼먹으면 시더가 category_id를 넣어도 조용히 무시돼 관계가 비어 버립니다.

5. 시더 — 카테고리 채우고 글 연결하기

이제 더미 데이터를 카테고리에 맞춰 다시 채웁니다. 먼저 카테고리를 넣는 시더를 새로 만듭니다.

app/Database/Seeds/CategorySeeder.php:

<?php

namespace App\Database\Seeds;

use CodeIgniter\Database\Seeder;

class CategorySeeder extends Seeder
{
    public function run()
    {
        $categories = [
            ['name' => 'CodeIgniter 4', 'slug' => 'codeigniter4'],
            ['name' => '웹 개발',        'slug' => 'web'],
            ['name' => '회고',           'slug' => 'retrospect'],
        ];

        // 반복 실행해도 슬러그 유니크 제약에 걸리지 않도록 이번 슬러그만 먼저 비운다.
        $slugs = array_column($categories, 'slug');
        $this->db->table('categories')->whereIn('slug', $slugs)->delete();

        $now = date('Y-m-d H:i:s');

        foreach ($categories as $category) {
            $this->db->table('categories')->insert([
                'name'       => $category['name'],
                'slug'       => $category['slug'],
                'created_at' => $now,
                'updated_at' => $now,
            ]);
        }
    }
}

세 개의 카테고리(CodeIgniter 4 / 웹 개발 / 회고)를 넣습니다. 삽입 전에 같은 slug를 먼저 delete하는 건, 시더를 두 번 돌려도 유니크 제약에 걸려 터지지 않게 하기 위한 안전장치입니다.

DatabaseSeeder는 카테고리를 먼저 채우도록 순서를 바꿉니다. 글이 카테고리를 참조하니까요.

    public function run()
    {
        // 글이 카테고리를 참조하므로 카테고리를 먼저 채운다.
        $this->call('CategorySeeder');
        $this->call('PostSeeder');
    }

마지막으로 PostSeeder가 각 글에 category slug를 달고, 그걸 실제 category_id로 바꿔 저장합니다. 더미 글 배열에 'category' => 'web'처럼 카테고리 slug를 하나씩 붙인 뒤, 삽입 직전에 slug를 id로 매핑합니다.

        // 글은 카테고리에 의존한다. 단독으로 시드될 때(예: Feature 테스트)도 깨지지 않도록
        // 카테고리가 비어 있으면 먼저 채운다. (DatabaseSeeder 로 돌 땐 이미 있어 건너뛴다.)
        if ($this->db->table('categories')->countAll() === 0) {
            $this->call('CategorySeeder');
        }

        // 카테고리 슬러그 → id 매핑.
        $categoryIds = [];
        foreach ($this->db->table('categories')->get()->getResultArray() as $row) {
            $categoryIds[$row['slug']] = (int) $row['id'];
        }

        // 오타·누락 slug 를 null 로 조용히 삼키면 관계가 빈 채로 저장된다.
        // 무결성은 시더에서 책임지므로, 매핑 안 되는 slug 가 있으면 즉시 멈춘다.
        $missing = array_diff(array_unique(array_column($posts, 'category')), array_keys($categoryIds));
        if ($missing !== []) {
            throw new \RuntimeException('카테고리 slug 매핑 실패(CategorySeeder 먼저 실행 필요): ' . implode(', ', $missing));
        }

그리고 실제 삽입 시 category_id를 채웁니다.

            $this->db->table('posts')->insert([
                'user_id'     => null,
                'category_id' => $categoryIds[$post['category']],
                'title'       => $post['title'],
                'slug'        => $post['slug'],
                'body'        => $post['body'],
                'created_at'  => $createdAt,
                'updated_at'  => $createdAt,
            ]);

여기에 두 가지 학습 의도가 숨어 있습니다.

단독 시드 보강. Feature 테스트는 종종 PostSeeder만 콕 집어 돌립니다($seed = 'App\Database\Seeds\PostSeeder'). 그때 카테고리 테이블이 비어 있으면 매핑이 몽땅 실패합니다. 그래서 카테고리가 하나도 없으면 PostSeeder가 직접 CategorySeeder를 불러 보강합니다. DatabaseSeeder로 돌 땐 이미 채워져 있으니 그냥 건너뜁니다. 덕분에 다음 회차 테스트들이 PostSeeder 하나만으로도 온전히 동작합니다.

매핑 실패 시 즉시 예외. slug에 오타가 있으면 $categoryIds['오타']가 없어서 null이 저장되고, 관계가 조용히 비어 버립니다. 이런 조용한 실패는 나중에 "왜 이 글은 분류가 안 되지?" 하며 한참 헤매게 만듭니다. 그래서 매핑되지 않는 slug가 하나라도 있으면 RuntimeException으로 즉시 멈춥니다. DB 외래키를 걸지 않은 대신, 무결성을 시더가 이렇게 책임집니다.

시더를 돌려 확인합니다.

php spark db:seed DatabaseSeeder

핵심 개념 — 왜 이렇게 했나

  • 테이블 변경 마이그레이션. 새 테이블(createTable)과 달리, 이미 있는 테이블은 addColumn / addKey + processIndexes로 뒤늦게 손봅니다. 데이터가 들어 있을 수 있으니 새 컬럼은 null 허용부터 고려하는 게 안전합니다.
  • 무결성의 주체를 정한다. DB 외래키로 강제할지, 애플리케이션(모델·시더)이 책임질지 선택해야 합니다. 여기서는 이식성을 위해 후자를 택했고, 대신 시더가 매핑 실패를 예외로 막아 "조용한 null"을 방지합니다.
  • 자주 거를 컬럼엔 인덱스. 다음 회차의 필터를 내다보고 미리 category_id에 인덱스를 걸어, 카테고리 조회가 풀스캔이 되지 않게 합니다.

마무리 — 커밋과 태그

이번 회차에서 한 일:

  • categories 테이블 생성(slug 유니크)
  • postscategory_id 추가(테이블 변경 마이그레이션, null 허용, 인덱스 동봉)
  • CategoryModel / Category 엔티티
  • 카테고리 시더 + 글–카테고리 연결
git add .
git commit -m "feat: 카테고리 구조"
git tag ep22

다음 회차

다음 글에서는 이 데이터를 드디어 화면에 씁니다. categories/{slug} 라우트로 카테고리별 글 목록을 거르고, 목록 위에 카테고리 칩 메뉴를 그립니다. 없는 카테고리는 404로 처리하는 것까지, 테스트를 앞세워 만듭니다.


이번 회차 요약

  • 다루는 파일: Migrations/..._CreateCategoriesTable.php·..._AddCategoryIdToPosts.php(추가) / Models/CategoryModel.php·Entities/Category.php(추가) / Models/PostModel.php·Seeds/CategorySeeder.php·Seeds/PostSeeder.php·Seeds/DatabaseSeeder.php(추가·수정)
  • 핵심: 이미 있는 postsaddColumn으로 category_id를 더하는 테이블 변경 마이그레이션. null 허용 + 인덱스 동봉, 무결성은 시더가 책임(매핑 실패 시 예외).

다음: 카테고리 필터와 메뉴

댓글 0

아직 댓글이 없습니다.

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

← 목록으로