블로그 구축기의 후속 기능이다. 구축기를 마친 뒤 블로그에 글이 쌓이면서, 한 주제를 여러 편에 나눠 쓰는 일이 많아졌다. 개발기 한 편이 여덟, 아홉 편까지 이어지기도 한다.

문제는 이 글들이 서로 묶여 있지 않다는 점이었다. 글 하단의 이전·다음 글은 발행일 기준이라, 개발기 3편을 읽고 "다음 글"을 누르면 전혀 다른 연재의 글이 나왔다. 독자가 1편부터 순서대로 읽으려면 카테고리나 태그 목록을 뒤져야 했다.

그래서 연재(시리즈)를 블로그의 정식 기능으로 넣었다. 지금 읽고 있는 「업데이트 노트」도 이 기능으로 묶여 있다. 글 제목 아래에 있는 연재 상자가 이번 글에서 만든 것이다.

무엇을 만들었나

위치 동작
글 상단 접혀 있는 연재 목차. 「N편 / 전체 M편」만 보이고, 펼치면 전체 편
글 하단 발행일 기준 이전·다음 글 대신 연재의 이전·다음 편
/series 연재 목록
/series/{slug} 한 연재의 회차순 목차, 「1편부터 읽기」 버튼
글 목록 카드 「연재 · 이름 N편」 배지
상단 메뉴·사이드바 「연재」 메뉴, 연재별 편 수
관리자 연재 관리 화면, 글 편집에서 연재·회차 선택
관리 API·스크립트 연재 생성·수정·삭제, 글 저장 시 연재·회차 지정

데이터 모델: 연재 하나, 회차 하나

설계에서 먼저 정한 것은 "글은 연재 하나에만 속한다"였다. 태그처럼 다대다로 두면 한 글이 여러 연재에 들어갈 수 있지만, 그러면 "몇 편인가"가 연재마다 달라져 목차와 이전·다음 편이 복잡해진다. 한 글이 두 연재에 걸칠 일도 없다고 봤다.

연재는 카테고리와도 따로 두었다. InfraWatch 개발기처럼 한 연재의 글이 SSL·메일 서버·네트워크·IIS 카테고리에 흩어지는 경우가 있어서, 카테고리에 연재를 얹으면 맞지 않는다.

public class Series
{
    public int Id { get; set; }
    public string Name { get; set; }          // 표시 이름 (최대 100자)
    public string Slug { get; set; }          // URL 슬러그 (고유)
    public string Description { get; set; }   // 연재 소개 (최대 300자)
    public ICollection<Post> Posts { get; set; }
}

글(Post)에는 두 필드를 더했다. 둘 다 null 을 허용해서 연재가 아닌 글은 그대로 둔다.

public int? SeriesId { get; set; }     // 연재 (없으면 null)
public Series Series { get; set; }
public int? SeriesOrder { get; set; }  // 연재 안의 회차 (1부터)

관계 설정에서 신경 쓴 것은 삭제 동작이다. 연재를 지운다고 글까지 지워지면 안 된다. 외래 키를 SetNull로 두어 연재가 사라지면 글은 남고 연재에서만 빠지게 했다.

post.HasOne(p => p.Series)
    .WithMany(s => s.Posts)
    .HasForeignKey(p => p.SeriesId)
    .OnDelete(DeleteBehavior.SetNull)
    .HasConstraintName("FK_Post_Series_SeriesId");

외래 키는 SeriesId만 비우고 회차 값은 남긴다. 연재에서 빠진 글에 회차가 남아 있으면 헷갈리므로, 연재 삭제 서비스에서는 회차까지 함께 비운 다음 연재를 지운다.

// FK 는 SET NULL 이지만 회차도 함께 비운다
_db.Posts.Where(p => p.SeriesId == id)
    .ExecuteUpdate(s => s.SetProperty(p => p.SeriesId, (int?)null)
                         .SetProperty(p => p.SeriesOrder, (int?)null));
_db.Series.Remove(series);
_db.SaveChanges();

마이그레이션은 AddSeries 하나다. Post에 SeriesId·SeriesOrder 열을 추가하고, Series 테이블과 슬러그 고유 인덱스(IX_Series_Slug), Post.SeriesId 인덱스, 외래 키를 만든다. 기존 글은 두 열이 모두 null 로 들어가므로 배포 직후에도 화면이 바뀌지 않는다. 연재를 만들고 글을 묶은 뒤부터 연재 상자가 나타난다.

저장할 때는 슬러그뿐 아니라 이름도 겹치지 않게 검사했다. 사이드바와 목록에서 같은 이름의 연재가 두 줄 보이면 독자가 구분할 수 없기 때문이다.

회차를 비우면 마지막 편 다음

글을 연재에 넣을 때마다 회차를 직접 세는 것은 번거롭다. 새 편은 대부분 마지막 편 다음이므로, 회차를 비워 두면 그 연재의 가장 큰 회차에 1을 더해 넣었다.

post.SeriesId = seriesId;
// 회차를 비워 두면 그 연재의 마지막 편 다음 번호 (연재가 없으면 회차도 없음)
post.SeriesOrder = seriesId == null ? null
    : model.SeriesOrder ?? (_db.Posts.Where(p => p.SeriesId == seriesId && p.Id != model.Id)
        .Max(p => p.SeriesOrder) ?? 0) + 1;

자기 자신(p.Id != model.Id)을 빼고 계산하는 것이 요점이다. 이미 연재에 들어 있는 글을 다시 저장할 때 자기 회차를 기준으로 번호가 하나씩 밀리는 일을 막는다. 관리자 글 편집 화면에도 연재 드롭다운 옆에 회차 칸을 두고, 빈칸에는 「자동」이라고 표시했다.

목차를 만드는 쿼리

글 상단 목차와 연재 페이지는 같은 데이터를 쓴다. GetSeriesToc 하나가 연재의 편을 회차 순서로 가져온다.

var now = DateTime.Now;
var posts = _db.Posts.AsNoTracking()
    .Where(p => p.SeriesId == seriesId && (p.IsPublished || p.Id == currentPostId))
    .OrderBy(p => p.SeriesOrder == null) // 회차를 안 정한 편은 맨 뒤에 발행일 순으로
    .ThenBy(p => p.SeriesOrder)
    .ThenBy(p => p.PublishedAt)
    .ThenBy(p => p.Id)
    .Select(p => new { p.Id, p.Title, p.Slug, p.PublishedAt,
                       IsPublic = p.IsPublished && p.PublishedAt <= now })
    .ToList();

조건을 하나씩 보면 이렇다.

  • 임시저장 글은 넣지 않는다. IsPublished가 false 인 글은 목차에 없다. 아직 쓰는 중인 글의 제목이 독자에게 보이면 안 된다.
  • 예약 글은 넣는다. 발행 처리는 했지만 발행일이 미래인 글은 목차에 남기되 IsPublic = false로 표시한다. 연재가 몇 편까지 이어질지 독자가 미리 알 수 있다.
  • 현재 글은 상태와 상관없이 넣는다. 관리자 미리보기에서 임시저장 글을 열어도 그 글이 목차의 어디에 들어가는지 보여야 하기 때문이다.

목차의 번호(Number)는 SeriesOrder 값을 그대로 쓰지 않고, 정렬된 순서대로 1부터 다시 매긴다. 회차를 1, 2, 5 처럼 건너뛰어 저장했거나 비어 있는 편이 있어도 목차는 1, 2, 3 으로 이어진다. 순서는 회차가 정하고, 번호는 화면이 정하는 셈이다.

Items = posts.Select((p, i) => new SeriesTocItem
{
    Title = p.Title,
    Slug = p.Slug,
    Number = i + 1,
    PublishedAt = p.PublishedAt,
    IsPublic = p.IsPublic
}).ToList(),
CurrentIndex = posts.FindIndex(p => p.Id == currentPostId)

글 상단 목차: 접어 두고 위치만

글 머리에 목차를 펼쳐 두면 본문이 한참 아래로 밀린다. 그래서 HTML <details>로 접어 두고, 접힌 상태에서는 연재 이름과 「3편 / 전체 8편」만 보이게 했다. 스크립트 없이 펼치고 접힌다.

펼친 목차에서는 편마다 세 가지로 나눠 그린다.

상태 표시
지금 읽는 글 굵은 제목 + 「지금 읽는 글」
공개된 편 글로 가는 링크
예약된 편 흐린 제목 + 「(10월 15일 공개)」, 링크 없음
else if (item.IsPublic)
{
    <a href="/post/@item.Slug">@item.Title</a>
}
else
{
    <span class="text-gray-400">@item.Title
        <span class="ml-1 text-xs">(@item.PublishedAt?.ToString("M월 d일") 공개)</span></span>
}

예약된 편에 링크를 걸지 않은 것은 의도한 것이다. 공개 전 글의 주소는 아직 404 를 돌려준다. 글 상세 조회는 발행 여부와 발행일이 모두 지난 글만 찾기 때문이다.

private IQueryable<Post> Published()
{
    var now = DateTime.Now;
    return _db.Posts.AsNoTracking().Where(p => p.IsPublished && p.PublishedAt <= now);
}

목차가 now를 기준으로 계산되므로, 공개 시각이 지나면 글을 다시 저장하지 않아도 다음 요청부터 그 편이 링크로 바뀐다.

하단은 연재의 이전·다음 편

연재 글이면 하단의 발행일 기준 이전·다음 글을 연재의 이전·다음 편으로 바꿨다. 둘을 같이 보여 주면 어느 쪽이 연재 순서인지 헷갈리므로, 연재 글에서는 연재 쪽만 보인다.

public SeriesTocItem Previous => CurrentIndex > 0 ? Items[CurrentIndex - 1] : null;

public SeriesTocItem Next => CurrentIndex >= 0 && CurrentIndex < Items.Count - 1
    ? Items[CurrentIndex + 1] : null;

다음 편 칸은 세 경우로 나뉜다.

  1. 다음 편이 공개됨: 「다음 편 (4편) →」과 제목, 링크.
  2. 다음 편이 예약됨: 점선 테두리 상자에 「다음 편 (4편) · 10월 15일 공개 예정」. 링크는 없다.
  3. 마지막 편: 「연재의 마지막 편입니다」와 「전체 목차 보기 →」로 연재 페이지에 연결.

연재는 여러 편을 미리 예약해 두는 경우가 많아서 2번을 따로 두었다. 최신 편까지 읽은 독자에게 "다음 편은 언제 나온다"를 알려 줄 수 있다.

목차에 현재 글이 없을 때(CurrentIndex가 -1)는 연재 쪽 이전·다음을 그리지 않고 기존 발행일 기준 이전·다음 글을 그대로 보여 준다.

연재 페이지와 404

/series/{slug}는 연재 하나의 목차 페이지다. 소개 문구, 「공개 5편 · 공개 예정 3편」 같은 편 수, 「1편부터 읽기」 버튼, 그리고 회차순 카드 목록이 나온다. 예약된 편은 점선 카드에 「M월 d일 공개 예정」으로 표시한다.

공개된 편이 하나도 없는 연재는 404 로 처리했다.

[Route("series/{slug}")]
public IActionResult Detail(string slug)
{
    var series = _blog.FindSeries(slug);
    var toc = series == null ? null : _blog.GetSeriesToc(series.Id, 0);
    if (toc == null || toc.PublicCount == 0)
    {
        return NotFoundView();
    }
    ...
}

연재를 미리 만들고 1편을 예약해 두면, 1편이 공개되기 전까지는 예약 편 제목만 늘어선 빈 페이지가 생긴다. 검색엔진이 이런 페이지를 먼저 수집하는 것도 원하지 않았다. 그래서 공개 편이 생기기 전까지는 없는 페이지로 둔다. 현재 글 ID 로 0 을 넘기므로 이 목차에는 임시저장 글이 끼어들 수 없다.

같은 기준을 다른 곳에도 맞췄다. /series 목록, 사이드바, 상단 메뉴, sitemap 이 모두 GetSeriesLinks를 쓰는데, 여기서 공개 편 수가 0 인 연재는 빠진다.

PostCount = s.Posts.Count(p => p.IsPublished && p.PublishedAt <= now),
LatestAt  = s.Posts.Where(p => p.IsPublished && p.PublishedAt <= now).Max(p => p.PublishedAt),
...
.Where(s => s.PostCount > 0)
.OrderByDescending(s => s.LatestAt)

공개된 연재가 하나도 없으면 상단의 「연재」 메뉴도 나타나지 않는다. 목록은 최근에 새 편이 나온 연재부터 보여 준다. sitemap 에는 /series와 각 연재 페이지를 넣고, 마지막 수정일로 그 연재의 가장 최근 공개일을 썼다.

글 목록 배지

홈과 카테고리 목록의 글 카드에는 날짜·카테고리 옆에 「연재 · 블로그 구축기 8편」 같은 배지를 달았다. 누르면 연재 페이지로 간다. 목록에서 연재 글인지 바로 알 수 있고, 중간 편을 먼저 발견한 독자가 1편으로 돌아가기 쉽다.

배지 숫자는 저장된 회차(SeriesOrder)를 쓴다. 목록 쿼리에서 글마다 연재 전체를 다시 세지 않기 위해서다. 회차를 1부터 빈틈없이 매겨 두면 목차 번호와 같아진다.

관리 API 와 스크립트

글은 대부분 마크다운 초안 파일을 관리 API 로 올려서 발행한다. 그래서 연재도 화면뿐 아니라 API 와 업로드 스크립트(blog-api.ps1)에서 다룰 수 있어야 했다.

메서드·경로 동작
GET /api/series 연재 목록
PUT /api/series/{slug} 있으면 수정(200), 없으면 생성(201). 본문 slug로 슬러그 변경
DELETE /api/series/{slug} 삭제. 글은 남고 연재에서만 빠짐
PUT /api/posts/{slug} 본문에 series, seriesOrder 추가

글을 저장할 때 없는 연재 슬러그를 넣으면 저장하지 않고 오류로 돌려준다. 오타 하나로 글이 조용히 연재에서 빠지는 것을 막기 위해서다.

var seriesSlug = request.Series?.Trim();
var series = string.IsNullOrEmpty(seriesSlug) ? null : _posts.FindSeries(seriesSlug);
if (!string.IsNullOrEmpty(seriesSlug) && series == null)
{
    errors["series"] = $"'{seriesSlug}' 연재가 없습니다. PUT /api/series/{seriesSlug} 로 먼저 만드세요.";
}

seriesOrder를 생략했을 때의 규칙도 정했다. 같은 연재에 이미 있던 글이면 기존 회차를 유지하고, 새로 들어오는 글이면 비워 두어 저장 단계에서 마지막 편 다음 번호가 붙는다. 초안을 고쳐서 다시 올릴 때마다 회차가 바뀌면 안 되기 때문이다.

SeriesOrder = request.SeriesOrder
    ?? (series != null && current?.SeriesId == series.Id ? current.SeriesOrder : null),

글 PUT은 전체 교체라서 series를 빼고 올리면 그 글은 연재에서 빠진다. 이 동작은 배포 문서에 따로 적어 두었다.

스크립트에는 series, set-series, delete-series 세 명령을 더했다.

.\scripts\blog-api.ps1 series                       # 연재 목록
.\scripts\blog-api.ps1 set-series infrawatch -Name "「InfraWatch」 개발기" -Description "연재 소개"
.\scripts\blog-api.ps1 delete-series infrawatch     # 글은 남고 연재에서만 빠짐

초안 파일은 front matter 에 두 줄을 더 쓰면 된다. 이 글의 머리에도 그대로 들어 있다.

series: updates
seriesOrder: 4

스크립트는 front matter 를 읽을 때 두 값이 있으면 요청 본문에 넣는다. 회차는 정수로 바꿔서 보낸다.

if ($meta['series']) { $post.series = $meta['series'] }
if ($meta['seriesorder']) { $post.seriesOrder = [int]$meta['seriesorder'] }

push나 post 명령이 글 정보를 출력할 때도 「updates 4편」처럼 연재와 회차를 같이 보여 주게 했다. 올린 뒤 바로 연재에 제대로 들어갔는지 확인할 수 있다.

관리자 미리보기

관리자 글 편집 화면의 미리보기도 연재를 반영한다. 저장된 목차를 가져와서 현재 글의 제목만 편집 중인 제목으로 바꿔 끼운다.

var series = model.SeriesId == null ? null : _blog.GetSeriesToc(model.SeriesId.Value, model.Id);
if (series != null && series.CurrentIndex >= 0)
{
    series.Items[series.CurrentIndex].Title = post.Title;
}

목차 쿼리가 현재 글은 임시저장이어도 넣어 주므로, 아직 공개 전인 편을 미리보기로 열어도 상단에 「4편 / 전체 8편」이 보인다. 다만 처음 만드는 글이나 방금 연재를 바꾼 글은 아직 저장되지 않아 목차에 없다. 이 경우 미리보기에는 「전체 N편」만 나오고, 저장한 뒤부터 위치가 표시된다.

정리

연재 기능은 테이블 하나와 글의 필드 두 개로 시작했지만, 실제 일은 "언제 무엇을 보여 줄 것인가"를 정하는 데 있었다. 임시저장 편은 숨기고, 예약 편은 제목과 공개 예정일만 보여 주고, 공개 편이 없는 연재는 없는 페이지로 둔다. 이 기준을 목차, 이전·다음 편, 연재 페이지, 메뉴, sitemap 에 똑같이 맞췄다.

이제 연재를 이어 쓸 때는 초안 머리에 series와 seriesOrder 두 줄만 적으면 된다. 목차와 다음 편 안내는 공개 시각에 맞춰 알아서 바뀐다.