블로그 구축기 연재의 후속 글이다. 구축기에서는 블로그를 만들고 운영 서버에 올리기까지를 다뤘다. 그 뒤로 글을 실제로 쓰기 시작하면서, 쓰는 사람 입장에서 필요한 기능들이 하나씩 보였다.

가장 큰 변화는 글을 쓰는 방식이었다. 한 편씩 써서 바로 올리는 대신, 연재를 몇 편씩 미리 써 두고 발행일시를 미래로 정해 예약 발행하기 시작했다. 그러자 세 가지가 불편했다.

  1. 발행 전에 글이 실제로 어떻게 보이는지 확인할 방법이 마땅치 않았다.
  2. 관리자 글 목록에서 이미 공개된 글과 공개를 기다리는 글이 섞여 보였다.
  3. 연재 1편에서 "다음 편"으로 링크를 걸면, 다음 편이 공개되기 전까지 그 링크는 404였다.

이 글은 이 세 가지를 어떻게 풀었는지가 중심이고, 끝에 같은 시기에 손본 작은 기능들을 짧게 묶었다.

저장 전 내용을 공개 글 화면 그대로

편집 화면에는 원래 마크다운 미리보기 칸이 있었다. 본문 마크다운을 HTML로 바꿔 보여 주는 정도라, 실제 글 화면의 레이아웃, 코드 하이라이트, 표 스타일, 태그 위치까지는 알 수 없었다. 발행 전에 "방문자가 보게 될 화면"을 그대로 보고 싶었다.

방법은 단순하게 잡았다. 공개 글 화면을 그리는 뷰(Post/Detail.cshtml)를 그대로 쓰고, 모델만 저장되지 않은 편집 내용으로 채운다. 미리보기용 뷰를 따로 만들면 공개 화면이 바뀔 때마다 두 벌을 맞춰야 한다. 같은 뷰를 쓰면 그럴 일이 없다.

버튼 하나, 같은 폼

편집 화면의 폼은 그대로 두고, 제출 버튼 하나만 더했다. formaction과 formtarget으로 이 버튼만 다른 주소로, 새 탭에 제출되게 했다.

<button type="submit" formaction="/admin/posts/preview-page" formtarget="_blank" data-preview
        class="ui-btn ui-btn-secondary">
    미리보기 (새 탭)
</button>

폼 전체가 제출되므로 제목, 본문, 요약, 카테고리, 태그, 썸네일, 발행일시가 모두 따라간다. 별도의 스크립트로 값을 모아 보낼 필요가 없고, 위조 방지 토큰도 폼에 있는 것을 그대로 쓴다.

한 가지 걸리는 점이 있었다. 편집 화면에는 저장하지 않고 떠나려 하면 경고를 띄우는 코드가 있는데, 폼이 제출되면 "저장했다"고 보고 경고를 끈다. 미리보기도 폼 제출이라 이대로면 미리보기를 한 번 누른 뒤에는 저장하지 않고 나가도 경고가 뜨지 않는다. 그래서 어떤 버튼으로 제출했는지 보고 미리보기면 상태를 건드리지 않게 했다.

form.addEventListener('submit', function (e) {
    // 미리보기(새 탭)는 저장이 아니므로 '저장 안 함' 경고를 유지한다
    if (e.submitter && e.submitter.hasAttribute('data-preview')) {
        return;
    }
    dirty = false;
});

저장하지 않은 값으로 Post 엔티티를 만든다

컨트롤러는 받은 편집 모델로 Post 엔티티를 메모리에서만 만들고, 공개 글 뷰에 넘긴다. DB에는 아무것도 쓰지 않는다.

// POST /admin/posts/preview-page → RenderPreview(model, saved: false)
private IActionResult RenderPreview(PostEditModel model, bool saved)
{
    var category = _posts.GetCategories().FirstOrDefault(c => c.Id == model.CategoryId);
    var now = DateTime.Now;

    var post = new Post
    {
        Id = model.Id,
        Title = string.IsNullOrWhiteSpace(model.Title) ? "(제목 없음)" : model.Title.Trim(),
        Slug = string.IsNullOrWhiteSpace(model.Slug) ? "preview" : model.Slug.Trim(),
        Summary = model.Summary?.Trim(),
        ThumbnailUrl = model.ThumbnailUrl?.Trim(),
        ContentMarkdown = model.ContentMarkdown,
        ContentHtml = _blog.ResolvePostLinks(_markdown.ToHtml(model.ContentMarkdown)), // 공개 글 화면과 같게
        Category = new Category { Name = category?.Name ?? "(카테고리 없음)", Slug = category?.Slug ?? "" },
        PublishedAt = model.PublishedAt ?? now,
        // ... CreatedAt, UpdatedAt, ViewCount
    };
    // 태그: 있는 태그는 슬러그·순서를 찾아 붙이고, 새 태그는 슬러그 없이

    ViewData.SetPageMeta(new PageMeta { Title = "[미리보기] " + post.Title, NoIndex = true, SuppressAds = true });
    ViewBag.IsPreview = true;
    ViewBag.PreviewSaved = saved;
    return View("~/Views/Post/Detail.cshtml",
        new PostDetailViewModel { Post = post, Related = new List<PostListItem>() });
}

몇 가지를 의도적으로 정했다.

  • 본문 HTML은 저장할 때와 같은 렌더러로 만든다. 글을 저장할 때도 같은 IMarkdownRenderer로 HTML을 만들어 둔다. 그래서 미리보기와 실제 공개 화면의 본문이 어긋나지 않는다. ResolvePostLinks는 뒤에서 설명할 공개 전 링크 처리다.
  • 비어 있어도 화면은 그린다. 제목이 없으면 (제목 없음), 카테고리를 안 골랐으면 (카테고리 없음)으로 채운다. 쓰다 만 글도 모양을 볼 수 있어야 하기 때문이다.
  • 이전·다음 글과 관련 글은 비운다. 아직 목록에 없는 글이라 의미가 없다.
  • 광고와 색인은 끈다. SuppressAds로 광고 자리를 비우고, NoIndex로 메타 태그를 넣는다. 관리자 컨트롤러는 공통 기반 클래스에서 응답에 X-Robots-Tag: noindex와 Cache-Control: no-store를 붙이므로 응답 헤더 쪽도 같이 막힌다. 주소가 /admin 아래라 방문 통계에도 잡히지 않고, 조회수 증가 코드는 공개 글 컨트롤러에만 있으니 건드리지 않는다.

태그는 조금 까다로웠다. 편집 화면에서 태그는 쉼표로 구분한 문자열이다. 이미 있는 태그는 DB에서 슬러그와 순서를 찾아 붙이고, 이번에 처음 쓰는 태그는 슬러그를 비우고 순서를 맨 뒤로 둔다. 뷰에서는 슬러그가 비어 있으면 /tag/{slug} 링크 대신 글자만 그린다. 공개 글에서는 슬러그가 빈 태그가 나올 일이 없으니, 이 분기는 미리보기에서만 쓰인다.

화면 맨 위에는 노란 안내 띠를 띄운다. ViewBag.IsPreview가 있을 때만 나오는 블록이라, 같은 뷰를 쓰는 공개 글 화면에는 영향이 없다.

저장해 둔 임시저장·예약 글도 미리보기

편집 중인 내용은 볼 수 있게 됐지만, 이미 저장해 둔 예약 글을 다시 확인하려면 편집 화면을 열고 미리보기를 눌러야 했다. 발행된 글은 글 목록의 「보기」로 공개 주소를 열면 되는데, 임시저장이나 예약 글은 공개 주소로 가면 404다.

그래서 저장된 글을 같은 방식으로 그리는 주소를 하나 더 만들었다. 앞에서 RenderPreview를 따로 뺀 이유가 이것이다. DB에서 편집 모델을 읽어 같은 함수에 넘기기만 한다.

// GET /admin/posts/{id}/preview
[HttpGet]
[Route("posts/{id:int}/preview")]
public IActionResult SavedPreview(int id)
{
    var model = _posts.GetForEdit(id);
    if (model == null)
    {
        return AdminNotFound();
    }
    return RenderPreview(model, saved: true);
}

saved 값에 따라 달라지는 것은 수정일시를 저장된 값으로 쓰는 것과 안내 문구 정도다. 저장된 글을 볼 때는 "저장된 글을 공개 화면 모양으로 보여 줍니다"라는 문구와 함께 편집 화면으로 가는 링크를 붙였다.

글 목록에서는 상태에 따라 버튼을 나눴다. 공개된 글은 「보기」(공개 주소), 그 밖의 글은 「미리보기」(위 주소)다.

글 목록에 예약 탭

글 목록의 탭은 원래 전체, 발행, 임시저장 세 개였다. 이 블로그에서 예약 글은 "발행 버튼을 눌렀지만 발행일시가 아직 오지 않은 글"이다. 즉 IsPublished는 참이고 PublishedAt이 미래다. 그래서 발행 탭에 이미 공개된 글과 예약 글이 함께 나왔다. 연재를 몇 편씩 예약해 두면 발행 탭만 봐서는 지금 무엇이 공개돼 있는지 알기 어려웠다.

탭을 전체, 발행, 예약, 임시저장 네 개로 나누고, 조건을 이렇게 정리했다.

탭 조건 정렬
발행 IsPublished && (PublishedAt == null \|\| PublishedAt <= now) 최근 수정 순
예약 IsPublished && PublishedAt > now 공개될 순서 (발행일시 오름차순)
임시저장 !IsPublished 최근 수정 순
else if (filter == "scheduled")
{
    query = query.Where(p => p.IsPublished && p.PublishedAt > now);
}

// 예약 탭은 공개될 순서대로, 나머지는 최근 수정 순
var ordered = filter == "scheduled"
    ? query.OrderBy(p => p.PublishedAt).ThenBy(p => p.Id)
    : query.OrderByDescending(p => p.UpdatedAt).ThenByDescending(p => p.Id);

예약 탭만 정렬을 바꾼 것이 실제로 쓸 때 가장 편했다. 예약 탭을 열면 위에서부터 다음에 나갈 글, 그다음 글 순서로 보인다. 연재 순서가 꼬였는지, 날짜가 비는 날이 있는지 한눈에 확인된다.

공개 전 글로 가는 본문 링크

연재를 예약해 두면 이런 상황이 생긴다. 1편 본문 끝에 "다음 편에서는 ~를 다룬다"며 2편 링크를 걸어 두었는데, 1편이 먼저 공개되고 2편은 며칠 뒤에 공개된다. 그 사이 방문자가 링크를 누르면 404가 뜬다.

2편이 공개된 뒤 1편을 다시 고치는 방법은 매번 잊지 않아야 하고, 공개 전 글을 주소로 열리게 하면 예약의 의미가 없어진다. 그래서 글을 보여 줄 때 링크 대상이 공개됐는지 확인해서, 아직이면 링크 대신 공개 날짜를 보여 주기로 했다. 본문을 쓸 때는 평소처럼 링크를 걸어 두면 된다.

저장할 때가 아니라 보여 줄 때 바꾼다

이 블로그는 글을 저장할 때 마크다운을 HTML로 바꿔 ContentHtml 열에 넣어 둔다. 링크 처리를 저장 단계에서 하면 2편이 공개되는 순간 1편의 저장된 HTML은 이미 낡은 것이 된다. 그래서 저장된 HTML은 건드리지 않고, 글 화면을 그리기 직전에 처리하기로 했다. 공개 시각이 지나면 1편을 다시 저장하지 않아도 그다음 요청부터 링크가 살아난다.

// GET /post/{slug}
var post = _blog.FindPublishedPost(slug);
// ...
// 아직 공개 전인 글로 가는 링크는 404 대신 공개 날짜로 (조회는 AsNoTracking 이라 저장되지 않는다)
post.ContentHtml = _blog.ResolvePostLinks(post.ContentHtml);

엔티티의 ContentHtml을 바꾸지만, 공개 글 조회는 AsNoTracking으로 읽기 때문에 변경 추적이 없다. 같은 요청에서 조회수를 올리는 코드도 별도 쿼리로 숫자만 올리므로, 바꾼 HTML이 DB에 저장될 일은 없다.

앞에서 본 미리보기의 RenderPreview에도 같은 함수를 끼웠다. 미리보기와 공개 화면이 다르게 보이면 미리보기를 만든 의미가 없기 때문이다.

링크를 찾고, 한 번에 조회하고, 바꾼다

처리 자체는 세 단계다.

// Markdig 이 만드는 내부 글 링크: <a href="/post/slug">글자</a> (#앵커·쿼리는 슬러그에서 뺀다)
private static readonly Regex PostLink = new Regex(
    "<a href=\"/post/([^\"#?/]+)[^\"]*\">(.*?)</a>", RegexOptions.Singleline | RegexOptions.Compiled);

public string ResolvePostLinks(string contentHtml)
{
    if (string.IsNullOrEmpty(contentHtml) || !contentHtml.Contains("href=\"/post/"))
    {
        return contentHtml;   // 내부 글 링크가 없으면 DB 조회 없이 끝
    }

    // 1) 본문의 /post/{slug} 링크에서 슬러그를 모은다
    var slugs = PostLink.Matches(contentHtml)
        .Select(m => Uri.UnescapeDataString(m.Groups[1].Value))
        .Distinct(StringComparer.OrdinalIgnoreCase)
        .ToList();

    // 2) 그중 "아직 공개되지 않은 글"만 한 번의 쿼리로 가져온다
    var now = DateTime.Now;
    var pending = _db.Posts.AsNoTracking()
        .Where(p => slugs.Contains(p.Slug) && !(p.IsPublished && p.PublishedAt <= now))
        .Select(p => new { p.Slug, p.IsPublished, p.PublishedAt })
        .ToList()
        .ToDictionary(p => p.Slug, StringComparer.OrdinalIgnoreCase);
    if (pending.Count == 0)
    {
        return contentHtml;
    }

    // 3) 해당 링크만 바꾼다
    return PostLink.Replace(contentHtml, m =>
    {
        if (!pending.TryGetValue(Uri.UnescapeDataString(m.Groups[1].Value), out var target))
        {
            return m.Value; // 공개 글이거나 없는 글은 그대로
        }
        var text = m.Groups[2].Value;
        if (target.IsPublished && target.PublishedAt != null)
        {
            var at = target.PublishedAt.Value;
            return text + " <span class=\"text-gray-500 dark:text-gray-400\">(" + at.Month + "월 " + at.Day + "일 공개)</span>";
        }
        return text;   // 임시저장 글은 글자만
    });
}

정리하면 결과는 이렇다.

링크 대상 화면에 보이는 모습
공개된 글 그대로 링크
예약 글 링크 글자 (10월 15일 공개) — 링크 없음
임시저장 글 링크 글자 — 링크 없음
없는 글 그대로 링크

설계하면서 정한 점이 몇 가지 있다.

  • 정규식은 Markdig의 출력 형식에 맞췄다. 본문 HTML은 직접 쓰는 것이 아니라 마크다운 렌더러가 만든다. 마크다운 [글자](/post/slug)는 항상 <a href="/post/slug">글자</a> 꼴로 나온다. 그래서 범용 HTML 파서를 쓰지 않고, 이 형식만 정확히 잡는 정규식으로 충분했다. #앵커나 ?쿼리가 붙은 링크도 슬러그 부분만 떼어 조회한다.
  • 조회는 글마다 한 번이다. 링크가 여러 개여도 슬러그를 모아 IN 조건 하나로 가져온다. 내부 글 링크가 아예 없는 글은 문자열 검사 한 번으로 끝나고 DB에 가지 않는다.
  • "공개된 글"의 기준을 한 곳과 맞췄다. 조건 IsPublished && PublishedAt <= now는 공개 목록과 글 화면이 쓰는 Published() 쿼리와 같다. 예약 탭의 기준과도 같다. 기준이 하나라서 목록에 안 보이는 글은 본문 링크에서도 공개 전으로 처리된다.
  • 없는 글은 건드리지 않는다. 슬러그 오타일 수 있다. 링크를 지워 버리면 오타가 눈에 띄지 않으니, 그대로 두어 404로 드러나게 했다.

연재 글을 예약할 때는 이제 순서대로 써서 발행일시만 정해 두면 된다. 1편이 먼저 나가 있는 동안 2편 링크 자리에는 "(10월 15일 공개)"가 붙고, 그 시각이 지나면 링크가 된다.

그 밖에 손본 것들

카테고리·태그 순서를 드래그로

카테고리 관리 화면에는 순서 숫자를 직접 입력하는 칸이 있었다. 하나를 앞으로 옮기려면 여러 행의 숫자를 고쳐야 했다. 이것을 행 왼쪽 손잡이를 끌어다 놓는 방식으로 바꿨다. 드래그는 SortableJS를 썼고, 손잡이에 포커스를 두고 ↑·↓ 키로도 옮길 수 있게 했다.

놓는 순간 화면 순서대로 id 목록을 서버에 보내고, 서버는 1부터 순서를 다시 매긴다. 다른 창에서 카테고리를 추가하거나 지운 상태일 수 있으므로, 전체 목록이 빠짐없이, 중복 없이 오지 않으면 400과 함께 "새로고침한 뒤 다시 시도하세요"를 돌려준다.

저장에 실패하면 화면도 원래 순서로 되돌린다. 화면과 DB의 순서가 어긋난 채로 남지 않게 하려는 것이다.

태그는 원래 이름순이라 순서 값이 없었다. Tag에 SortOrder를 추가하는 마이그레이션을 만들고, 기존 태그에는 지금 보이는 이름순 그대로 1부터 번호를 매겼다. 드래그 스크립트는 admin-reorder.js로 분리해 카테고리와 태그 화면이 같이 쓴다. 정한 태그 순서는 글 하단의 태그 표시와 편집 화면의 태그 입력값에도 적용된다.

상단 메뉴 카테고리는 일부만

카테고리가 늘면서 상단 메뉴가 넘치기 시작했다. 상단 메뉴에는 글이 있는 카테고리만 보이게 하고, 화면 폭이 1024px 이상이면 4개, 1280px 이상이면 6개까지만 펼친 뒤 나머지는 「더보기」 드롭다운에 넣었다. 지금 보고 있는 카테고리가 더보기 안에 있으면 더보기 버튼에 밑줄을 그어 위치를 알 수 있게 했다.

글 올리기 스크립트의 따옴표 버그

글은 마크다운 파일 맨 위 front matter에 제목, 슬러그, 카테고리 등을 적고 PowerShell 스크립트로 올린다. 값을 따옴표로 감싸면 그 필드가 통째로 빠지는 문제가 있었다. 원인은 PowerShell의 자동 변수 $Matches였다. 줄마다 키: 값을 -match로 나눈 뒤, 값이 따옴표로 감싸져 있으면 한 번 더 -match로 벗기고, 마지막에 $Matches[1]을 키로 썼다.

-match는 성공할 때마다 $Matches를 덮어쓴다. 따옴표를 벗기려고 한 두 번째 -match가 성공하면, 그다음 줄의 $Matches[1]은 더 이상 키가 아니라 따옴표 안의 값이다. 그래서 title: "제목"이 $meta['제목'] = '제목'으로 들어가고, title 필드는 없는 것이 됐다. 키를 먼저 변수에 받아 두는 것으로 고쳤다.

$key = $Matches[1].ToLowerInvariant()   # 따옴표 검사 전에 받아 둔다
$value = $Matches[2]
if ($value -match '^"(.*)"$' -or $value -match "^'(.*)'$") { $value = $Matches[1] }
$meta[$key] = $value

같은 스크립트의 글 정보 출력에는 썸네일 주소도 보이게 했다. 글을 올린 직후 썸네일이 제대로 연결됐는지 출력만 보고 확인할 수 있다.

정리하며

  • 미리보기는 공개 화면과 같은 뷰로 그린다. 저장하지 않은 값으로 엔티티를 메모리에서만 만들어 넘기면, 미리보기용 화면을 따로 관리할 필요가 없다.
  • 시간에 따라 바뀌는 것은 저장할 때가 아니라 보여 줄 때 판단한다. 공개 전 글로 가는 링크를 렌더링 직전에 처리하니, 예약 시각이 지나면 다른 글을 다시 저장하지 않아도 링크가 살아난다.
  • "공개됐다"의 기준은 한 곳에 맞춘다. 공개 목록, 예약 탭, 본문 링크 처리가 같은 조건을 쓰니 서로 어긋나지 않는다.

글을 미리 써 두는 방식으로 바꾸고 나니, 쓰는 일과 내보내는 일을 나눠서 생각할 수 있게 됐다. 이번에 더한 기능들은 대부분 그 사이를 안심하고 비워 둘 수 있게 해 주는 장치다.