2026년 10월 29일 · @martin
들어가며
결론부터 말하면, 미단재에 시리즈 기능을 붙였습니다. 글 상단에 "이 글은 ○○ 연재의 3/5편"이라는 상자가 뜨고, 펼치면 연재 전체 목록과 연재 안에서의 이전·다음 글이 나옵니다. 테이블 하나와 컬럼 두 개, 뷰 조각 하나로 끝나는 작업입니다.
이 글은 "블로그 기능을 하나씩 직접 만들기" 연재의 첫 편입니다. 예전에 블로그에 붙일 기능 후보를 정리한 적이 있습니다. 시리즈 내비게이션, 글 목차(TOC), RSS, 글별 OG 이미지 자동 생성이 그 목록에 있었습니다. 이 중 RSS와 글별 OG 이미지는 이미 돌고 있으니, 아직 없는 것부터 하나씩 만들고 만들 때마다 한 편씩 남기려 합니다.
시리즈부터 고른 이유는 단순합니다. 10월 20일부터 열흘 남짓한 사이에 웅파 게시판 글이 60편을 넘었습니다. 그중 상당수가 사실상 연재입니다. Claude Code 실전기, "AI가 만든 게시판, 글이 쌓이자 드러난 것들" 같은 글들이 그렇습니다. 그런데 독자가 이 글들을 이어서 읽을 방법은 "다음글" 링크뿐이었고, 그 링크는 연재 순서가 아니라 글 번호 순서였습니다.
무엇을 만들 것인가
지금 글 하단의 "이전글 / 다음글"은 같은 게시판에서 글 번호가 바로 앞뒤인 글을 보여줍니다. 하루에 열 편 넘게 쓰다 보니, 1858번(스쿼시 머지) 다음 글이 1859번(PG 결제 연동)처럼 주제가 전혀 다른 글로 이어집니다. 연재 2편을 읽은 사람이 3편으로 가려면 목록에서 다시 찾아야 합니다.
그래서 시리즈 내비게이션이 할 일을 다섯 가지로 정했습니다.
|
요구사항 |
내용 |
|---|---|
|
위치 표시 |
글 상단에 시리즈 이름과 "3/5" 같은 현재 위치 |
|
전체 목록 |
펼치면 연재 글 전체, 현재 글은 강조 |
|
연재 안의 이전·다음 |
글 번호가 아니라 연재 순서 기준 |
|
시리즈 소개 페이지 |
|
|
기존 링크 유지 |
하단의 글 번호 기준 이전·다음 글은 그대로 둠 |
마지막 줄은 일부러 넣었습니다. 연재가 아닌 글도 많고, 게시판을 시간순으로 넘겨 보는 독자도 있습니다. 두 내비게이션은 역할이 다르니 바꾸지 않고 하나를 더 얹습니다.
반대로 하지 않기로 한 것도 있습니다. 한 글이 여러 시리즈에 동시에 속하는 구조는 만들지 않습니다. 연재는 글쓴이가 순서를 정하는 묶음이고, 주제로 여러 갈래를 묶는 일은 나중에 만들 태그의 몫입니다. 이 결정 하나로 다대다 피벗 테이블이 사라지고 설계가 절반으로 줄었습니다.
데이터 설계: 테이블 하나, 컬럼 둘
한 글은 최대 한 시리즈에만 속하므로, 시리즈 정보는 별도 테이블에 두고 게시글에는 "어느 시리즈의 몇 번째"만 적습니다.
|
테이블 |
컬럼 |
설명 |
|---|---|---|
|
|
|
연재 자체. |
|
|
|
속한 시리즈. 없으면 NULL |
|
|
|
연재 안에서의 순서 |
posts는 이 글에서 쓰는 이름이고, 실제 게시판 테이블 이름은 각자 환경에 맞추면 됩니다.
// app/Database/Migrations/2026-10-29-000001_AddSeries.php
public function up(): void
{
$this->forge->addField([
'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
'slug' => ['type' => 'VARCHAR', 'constraint' => 100],
'title' => ['type' => 'VARCHAR', 'constraint' => 200],
'description' => ['type' => 'TEXT', 'null' => true],
'status' => ['type' => 'ENUM', 'constraint' => ['ongoing', 'completed'], 'default' => 'ongoing'],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addPrimaryKey('id');
$this->forge->addUniqueKey('slug');
$this->forge->createTable('series');
$this->forge->addColumn('posts', [
'series_id' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
'series_order' => ['type' => 'SMALLINT', 'unsigned' => true, 'null' => true],
]);
$this->db->query('CREATE INDEX idx_posts_series ON posts (series_id, series_order)');
}
두 가지를 일부러 하지 않았습니다.
(series_id, series_order)에 유니크 제약을 걸지 않았습니다. 순서를 바꿀 때 두 글의 번호를 맞바꾸는 순간 제약에 걸립니다. 유니크 대신 일반 인덱스를 두고, 순서가 겹치면 글 번호로 한 번 더 정렬합니다.- 외래 키를 걸지 않았습니다. 시리즈를 지울 때 글은 남아야 하므로, 삭제 시
series_id를 NULL로 돌리는 처리를 모델에서 합니다.ON DELETE SET NULL로도 되지만, 캐시 무효화를 같은 자리에서 하려고 코드로 뒀습니다.
CI4 구현: 모델 하나, 뷰 조각 하나
화면부터 보면 이렇습니다. 글을 열면 제목 아래에 접힌 상자가 있고, 누르면 연재 전체 목록과 연재 안의 이전·다음 편이 펼쳐집니다. 하단의 글 번호 기준 이전·다음 글은 그대로입니다.

모델: 내비게이션 데이터를 한 번에
글 상세 화면에 필요한 건 "이 시리즈의 공개된 글 목록"과 "그 안에서 현재 글의 위치"뿐입니다. 쿼리 한 번으로 목록을 가져오고 위치와 이전·다음은 PHP에서 계산합니다. 연재는 길어야 수십 편이라 이쪽이 단순합니다.
// app/Models/SeriesModel.php
public function navigation(int $seriesId, int $postId): ?array
{
$nav = cache()->remember("series_nav_{$seriesId}", 3600, function () use ($seriesId) {
$series = $this->find($seriesId);
if ($series === null) {
return null;
}
$posts = db_connect()->table('posts')
->select('id, title')
->where('series_id', $seriesId)
->where('is_published', 1)
->orderBy('series_order', 'ASC')
->orderBy('id', 'ASC')
->get()->getResultArray();
return ['series' => $series, 'posts' => $posts];
});
if ($nav === null) {
return null;
}
$ids = array_column($nav['posts'], 'id');
$index = array_search($postId, $ids);
return $nav + [
'current' => $index === false ? null : $index + 1,
'total' => count($ids),
'prev' => ($index !== false && $index > 0) ? $nav['posts'][$index - 1] : null,
'next' => $index !== false ? ($nav['posts'][$index + 1] ?? null) : null,
];
}
캐시에는 "시리즈별 목록"만 넣고 현재 위치는 매번 계산합니다. 글마다 캐시 키를 따로 만들면, 연재에 글 하나를 추가할 때 지워야 할 키가 편 수만큼 늘어납니다. 시리즈 단위 키 하나면 글 저장·삭제 때 cache()->delete("series_nav_{$id}") 한 줄로 끝납니다.
컨트롤러와 뷰
글 상세 컨트롤러에는 두 줄이 늘어납니다.
$data['seriesNav'] = $post['series_id']
? model(SeriesModel::class)->navigation((int) $post['series_id'], (int) $post['id'])
: null;
뷰는 조각(partial)으로 분리해 글 제목 아래에서 불러옵니다. 접고 펼치는 건 자바스크립트 없이 <details>로 처리했습니다. 키보드와 스크린 리더 지원이 기본으로 따라옵니다.
<?php // app/Views/board/_series_nav.php ?>
<?php if ($seriesNav !== null && $seriesNav['current'] !== null): ?>
<details class="series-nav">
<summary>
<span class="series-label">SERIES</span>
<strong><?= esc($seriesNav['series']['title']) ?></strong>
<span class="series-pos"><?= $seriesNav['current'] ?> / <?= $seriesNav['total'] ?></span>
</summary>
<ol>
<?php foreach ($seriesNav['posts'] as $i => $p): ?>
<li<?= $i + 1 === $seriesNav['current'] ? ' aria-current="page"' : '' ?>>
<a href="<?= site_url('board/up0/' . $p['id']) ?>"><?= esc($p['title']) ?></a>
</li>
<?php endforeach ?>
</ol>
<nav class="series-pager">
<?php if ($seriesNav['prev']): ?><a href="<?= site_url('board/up0/' . $seriesNav['prev']['id']) ?>">← 이전 편</a><?php endif ?>
<?php if ($seriesNav['next']): ?><a href="<?= site_url('board/up0/' . $seriesNav['next']['id']) ?>">다음 편 →</a><?php endif ?>
</nav>
</details>
<?php endif ?>
지난 "AI가 만든 게시판" 글에서 HTML 엔티티 이중 이스케이프로 한 번 데였기 때문에, 제목은 저장된 순수 텍스트를 뷰에서 esc() 한 번만 거치게 했습니다.
하나 더: 검색엔진에도 연재라고 알려주기
상세 페이지의 JSON-LD Article에 isPartOf로 시리즈를 넣었습니다. 시리즈 소개 페이지 주소를 url로 주면, 검색엔진이 이 글을 연재의 일부로 이해할 단서가 하나 더 생깁니다.
"isPartOf": {
"@type": "CreativeWorkSeries",
"name": "블로그 기능을 하나씩 직접 만들기",
"url": "https://jblove.net/series/blog-features"
}
글쓰기 화면과 기존 글 묶기
글쓰기 화면에는 "시리즈" 선택 상자와 "순서" 칸을 추가했습니다. 시리즈를 고르면 순서 칸에 그 시리즈의 마지막 번호 + 1이 미리 채워집니다. 연재는 보통 뒤에 한 편씩 붙으니, 대부분은 시리즈만 고르고 저장하면 됩니다.
// 순서를 비워 두면 맨 뒤로
if ($seriesId && empty($data['series_order'])) {
$max = db_connect()->table('posts')->selectMax('series_order')
->where('series_id', $seriesId)->get()->getRow()->series_order;
$data['series_order'] = (int) $max + 1;
}
이미 쓴 60여 편 중 연재로 묶을 글은 손으로 골랐습니다. 첫 묶음은 이렇습니다.
|
시리즈 |
묶은 글 |
상태 |
|---|---|---|
|
Claude Code 실전기 |
dev-issue 스킬, 이슈-배포 흐름, 토큰 사용량, CLAUDE.md 정리 등 |
연재중 |
|
AI가 만든 게시판, 글이 쌓이자 드러난 것들 |
페이지네이션, HTML 엔티티 이중 이스케이프 |
연재중 (3편 예정) |
|
블로그 기능을 하나씩 직접 만들기 |
이 글 |
연재중 |
묶는 작업 자체는 SQL 몇 줄이지만, 순서를 정하는 데는 생각보다 시간이 들었습니다. 글 번호 순서와 읽어야 하는 순서가 다른 경우가 있었기 때문입니다. 나중에 쓴 글이 개념 설명이라 앞에 와야 하는 경우가 그렇습니다. 이 판단은 AI에게 맡기지 않고 제가 목록을 보면서 정했습니다.
구현은 평소처럼 이슈 하나를 열고 Claude Code에 맡겼습니다. 다만 CLAUDE.md에 두 줄을 먼저 적었습니다. "한 글은 한 시리즈에만 속한다", "하단 이전·다음 글은 건드리지 않는다". 이 두 줄이 없으면 AI는 다대다 피벗 테이블을 만들고, 기존 이전·다음 링크를 시리즈 기준으로 바꿔 버리기 쉽습니다. 요구사항에서 "하지 않을 것"을 적어 두는 일이 AI와 일할 때 가장 값이 싸고 효과가 큰 작업이라는 걸 이번에도 확인했습니다.
마무리
시리즈 기능은 화려하지 않습니다. 테이블 하나, 컬럼 둘, 뷰 조각 하나입니다. 그래도 글이 60편을 넘은 블로그에서는 체감이 큽니다. 연재 2편을 읽은 사람이 목록으로 돌아가지 않고 3편으로 넘어갈 수 있게 됐습니다.
만들면서 남은 생각은 하나입니다. 기능의 크기는 "하지 않기로 한 것"이 정합니다. 다대다 구조를 버리자 설계가 절반이 됐고, 기존 링크를 그대로 두기로 하자 회귀 걱정이 사라졌습니다.
다음 편은 글 목차(TOC)입니다. 7~13분짜리 긴 글이 많아서, 본문의 h2·h3에서 목차를 뽑아 옆에 붙이고 읽는 위치를 표시하려 합니다. 이 연재 상자에서 2번 칸의 "예정" 딱지가 떨어지는 날 이어서 쓰겠습니다.

댓글 0개