관리자 태그 화면의 글 수 칸에 0 이 섞여 있었다.
연재 기능을 넣은 뒤로 원고를 올리는 일이 잦아졌다. 초안 파일을 고쳐서 다시 올리고, 태그 이름을 다듬고, 예약해 둔 편을 바꿔 끼웠다. 그러는 사이 아무 글에도 붙어 있지 않은 태그가 하나둘 남았다. 「AdSense」와 「애드센스」처럼 같은 뜻을 원고마다 다르게 적은 태그를 한쪽으로 합쳐 다시 올리면, 글은 새 태그로 옮겨 가는데 옛 태그는 그 자리에 그대로 있었다. 한 번 날을 잡아 정리해 보니 같은 뜻으로 겹친 태그가 여덟 개였고, 그것들을 합친 뒤에 글이 하나도 없는 빈 태그가 열한 개 남았다. 이 열한 개는 손으로 지웠다.
빈 태그는 겉으로 티가 나지 않는다. 그런데 주소는 살아 있다. /tag/{slug}로 들어가면 404 가 아니라 「아직 이 태그에 발행된 글이 없습니다」라는 빈 목록이 200 으로 나온다. 검색엔진이 이미 수집한 태그 주소라면 내용 없는 페이지가 색인에 남는다. 그리고 같은 화면 아래쪽에는 tag- 뒤에 숫자가 붙은 슬러그들이 줄지어 있었다. 한글로만 된 태그의 주소였다.
다 지우면 되는 줄 알았다
처음 떠오른 방법은 단순했다. 글 수가 0 인 태그를 전부 지우면 된다. 태그 테이블에서 글과의 연결이 없는 행을 찾아 한 번에 지우는 쿼리 한 줄이다.
그 한 줄을 쓰기 직전에 사이트 프로필이 생각났다. 이 블로그 코드는 경제 블로그도 함께 돌린다. 경제 블로그는 사이트 프로필에 초기 카테고리와 태그를 시드로 들고 있고, 새 DB 에 글이 하나도 없고 태그도 없을 때 그 태그를 넣는다. 금리, 기준금리, 한국은행 같은 태그가 39개다. 이 태그들은 「금리」가 interest-rate, 「한국은행」이 bank-of-korea인 식으로 영문 슬러그까지 프로필에 미리 정해 두었다.
시드 태그는 처음부터 글이 0 개다. 글이 쌓이면서 하나씩 쓰일 태그를 미리 깔아 둔 것이다. "글 수 0 이면 지운다"를 걸면 경제 블로그에서 첫 글을 저장하는 순간 그 글에 쓰지 않은 시드 태그 38개가 한꺼번에 사라진다. 관리자 화면에서 미리 만들어 둔 태그도 마찬가지다.
글이 없는 태그는 두 종류였다. 처음부터 글이 없던 태그와, 글이 있다가 잃은 태그. 지워야 하는 건 뒤쪽뿐이다.
마지막 글을 잃은 태그만
둘을 가르는 기준은 "이번 저장에서 이 글이 떼어 낸 태그인가"다. 글을 저장하기 전에 그 글에 붙어 있던 태그 ID 를 적어 두고, 저장이 끝난 뒤 그 ID 들 중에서 글이 하나도 없는 것만 지운다. 이번 글과 아무 관계가 없던 태그는 쿼리에 들어오지도 않는다.
private void RemoveOrphanTags(IList<int> tagIds)
{
if (tagIds.Count == 0)
{
return;
}
var orphans = _db.Tags.Where(t => tagIds.Contains(t.Id) && !t.Posts.Any()).ToList();
if (orphans.Count > 0)
{
_db.Tags.RemoveRange(orphans);
_db.SaveChanges();
}
}
순서가 중요하다. ApplyTags는 글의 태그 컬렉션을 비우고 새로 채우는데, 이 변경은 SaveChanges가 끝나야 DB 의 연결 테이블에 반영된다. 그 전에 t.Posts.Any()를 물으면 방금 떼어 낸 연결이 아직 남아 있어서 아무것도 지워지지 않는다. 그래서 SaveChanges로 저장을 한 번 끝내고, 그다음에 저장 전에 적어 둔 ID 목록으로 RemoveOrphanTags를 부른다.
글을 삭제할 때도 같다. 삭제 전에 태그 ID 를 모아 두고, 글을 지운 뒤 같은 메서드를 부른다. 관리자 화면에서 저장하든 관리 API 로 초안을 올리든 같은 서비스를 거치므로 두 길 모두 이 규칙을 따른다.
t.Posts.Any()는 발행 여부를 보지 않는다. 임시저장 글이나 예약 글에만 붙어 있는 태그는 아직 공개된 글이 없어도 지우지 않는다. 예약 편이 공개되는 순간 태그가 이미 있어야 하기 때문이다.
이 방식은 이미 남아 있던 빈 태그까지 찾아 지우지는 않는다. 그런 태그는 관리자 태그 화면에서 글 수를 보고 지우면 되고, 자동으로 막아야 하는 건 앞으로 새로 생기는 빈 태그였다.
tag-숫자는 이름이 아니다
태그를 만들 때 슬러그는 이름에서 만든다. 영문 소문자와 숫자만 남기고 나머지는 하이픈으로 바꾸는 Slugify다. "ASP.NET Core"는 asp-net-core가 된다. 문제는 한글이다. 한글만 있는 태그는 남는 글자가 없어서 빈 문자열이 되고, 그러면 일단 임시 슬러그로 저장한 뒤 ID 를 붙여 tag- 뒤에 숫자를 단 주소로 바꿨다.
주소로 쓰는 데는 문제가 없다. 하지만 주소만 보고는 무슨 태그인지 알 수 없다. 검색 결과에 주소가 찍힐 때도, 공유한 링크를 볼 때도 숫자만 남는다. 관리자 화면에서 슬러그를 직접 고칠 수는 있었지만 새 한글 태그를 만들 때마다 들어가서 고치는 건 매번 잊는 일이었다.
그래서 두 가지를 넣었다. 하나는 원고에서 슬러그를 직접 지정하는 표기다. 태그 칸에 추천(recommendation)처럼 괄호 안에 영문 슬러그를 적으면 이름은 「추천」, 슬러그는 recommendation으로 만든다. 이 글 머리의 태그 줄도 그렇게 쓰여 있다.
private static readonly Regex TagWithSlug =
new Regex(@"^(?<name>.+?)\s*\((?<slug>[a-z0-9]+(?:-[a-z0-9]+)*)\)$", RegexOptions.Compiled);
괄호 안이 슬러그 규칙, 즉 영문 소문자·숫자·하이픈에 맞을 때만 슬러그로 읽는다. 「닷넷(.NET)」처럼 괄호가 이름의 일부인 태그는 패턴에 맞지 않으니 괄호까지 통째로 이름이 된다. 저장할 때와 관리자 미리보기가 같은 파서를 쓰게 해서, 미리보기에서도 괄호가 떨어진 이름이 보인다.
괄호 표기는 새 태그를 만들 때만 의미가 있다. 이미 있는 태그는 이름으로 찾아서 그대로 붙이고, 괄호 안의 슬러그는 무시한다. 원고에 무심코 다른 슬러그를 적었다고 기존 태그의 주소가 바뀌면 곤란하기 때문이다.
음절을 자모로 쪼개는 산수
다른 하나는 괄호를 적지 않은 한글 태그를 로마자로 바꾸는 것이다. 국어의 로마자 표기법을 따르되, 라이브러리를 들이지 않고 직접 짰다. 유니코드의 한글 음절은 규칙적으로 배치되어 있어서 산수 몇 번이면 초성·중성·종성이 나온다.
'가'(U+AC00)부터 '힣'까지 11,172자는 초성 19개 × 중성 21개 × 종성 28개(받침 없음 포함)의 조합 순서대로 놓여 있다. 그러니 '가'에서 떨어진 거리를 588(21 × 28)로 나누면 초성, 그 나머지를 28 로 나누면 중성, 28 로 나눈 나머지가 종성이다.
var index = ch - 0xAC00;
var initial = index / 588;
var medial = index % 588 / 28;
var final = index % 28;
// ㄹ 받침 뒤 ㄹ 은 l (달력 → dallyeok)
sb.Append(previousFinal == RieulFinal && initial == RieulInitial ? "l" : Initials[initial]);
sb.Append(Medials[medial]);
sb.Append(Finals[final]);
previousFinal = final;
나머지는 표 세 개다. 초성은 ㄱ → g, ㄲ → kk, ㄹ → r 처럼, 중성은 ㅓ → eo, ㅢ → ui 처럼 표기법의 표를 그대로 옮겼다. 받침은 대표음으로 적는다. ㄱ·ㄲ·ㅋ·ㄳ 은 k, ㄷ·ㅅ·ㅆ·ㅈ·ㅊ·ㅌ·ㅎ 은 t, ㅂ·ㅍ·ㅄ 은 p 다. 그래서 「식단서랍 개발기」는 sikdanseorap-gaebalgi가 된다. 띄어쓰기는 뒤이어 Slugify가 하이픈으로 바꾼다.
음운 변화는 하나만 반영했다. ㄹ 받침 뒤에 오는 초성 ㄹ 은 r 이 아니라 l 로 적는다. 「달력」이 dalryeok이 아니라 dallyeok이 되는 규칙이다. 앞 글자의 종성을 기억해 두었다가 다음 글자의 초성을 고를 때 본다.
나머지 음운 변화, 즉 연음이나 자음 동화는 넣지 않았다. 「식물」은 표기법대로라면 singmul이지만 이 코드는 sikmul을 만든다. 「국어」도 gugeo가 아니라 gukeo다. 소리 나는 대로 적으려면 앞뒤 글자의 조합마다 규칙이 붙는데, 태그 주소에서 그 정확도가 꼭 필요하지는 않았다. 글자 단위로 되돌려 읽을 수 있는 편이 낫다고 봤다. 마음에 들지 않는 결과가 나오는 태그는 괄호 표기로 덮으면 된다.
만든 슬러그는 46자에서 자르고, 이미 같은 슬러그가 있으면 뒤에 -2, -3을 붙인다. 슬러그 칸이 50자라서 번호 붙일 자리를 남겨 둔 것이다. 영문도 한글도 하나 없이 기호만 있는 태그는 여전히 tag- 숫자 주소로 간다. 이제 그런 경우에만 남는 마지막 길이다.
이미 있는 주소는 건드리지 않았다
새 규칙이 생겼으니 예전 tag- 숫자 태그도 한꺼번에 로마자로 바꾸고 싶어진다. 로마자 일괄 변환은 하지 않았다. 새 규칙은 새로 만드는 태그에만 적용된다. 대신 이미 있던 tag- 숫자 슬러그는 그 전에 한 번, 뜻에 맞는 영어 단어로 손으로 정리했다. 「블로그 구축」은 blog-building, 「회고」는 retrospective, 「단위 테스트」는 unit-testing 이 되는 식이다. 로마자보다 영어 단어가 주소로 읽기 쉬웠기 때문이다.
태그 페이지는 FindTag(slug)로 슬러그가 같은 태그를 찾고, 없으면 404 를 돌려준다. 옛 슬러그에서 새 슬러그로 넘겨 주는 장치는 없다.
슬러그를 바꾸는 순간 검색엔진에 들어가 있던 예전 /tag/ 주소, 다른 곳에 걸려 있던 링크가 전부 404 가 된다. 그 손 정리 때 바뀐 예전 주소들은 404 가 되는 걸 받아들였다. 블로그를 연 지 얼마 안 돼 밖에서 걸린 태그 링크가 거의 없을 때라 할 수 있었던 일이다. 그래서 원고의 괄호 표기도 이미 있는 태그의 슬러그는 바꾸지 않게 해 둔 것이다. 슬러그는 처음 만들 때 제대로 정하는 수밖에 없다.
연재 끝에 붙는 앱 카드
연재 기능은 글을 묶었는데, 앱을 만든 이야기를 끝까지 읽은 독자가 그 앱을 받으러 가는 길은 없었다. 본문 어딘가에 스토어 주소를 적어 두긴 했지만 편마다 위치가 달랐고, 주소가 바뀌면 글을 하나하나 고쳐야 했다.
그래서 주소를 글이 아니라 연재에 두었다. 연재에 앱 이름, Google Play 주소, 웹 주소, 웹 버튼 문구 네 칸을 더했다. 마이그레이션은 이 네 열을 null 허용으로 추가하는 것뿐이라 기존 연재는 아무것도 바뀌지 않는다. 주소가 하나라도 있는 연재면 그 연재의 모든 글 본문 끝과 연재 페이지 머리말 아래에 「이 연재의 앱」 카드가 붙는다. 주소가 하나도 없으면 아무것도 출력하지 않는다. 대부분의 연재와 경제 블로그는 이 경우다.
카드 제목의 앱 이름은 비워 둘 수 있다. 연재 이름을 「오늘 누가 쏘냐?」 개발기처럼 앱 이름을 낫표로 감싸 짓는 관례가 있어서, 앱 이름을 따로 적지 않으면 낫표 안의 글자를 꺼내 쓴다. 낫표가 없으면 연재 이름을 그대로 쓴다. 웹 버튼 문구도 비우면 「웹사이트 열기」가 되고, 서비스에 따라 「웹에서 바로 쓰기」 같은 문구로 바꿀 수 있다.
Google Play 버튼에 공식 배지 이미지는 쓰지 않았다. 배지는 외부 이미지라 글을 열 때마다 바깥으로 요청이 하나 더 나가고, 블로그의 보안 헤더 정책에도 걸린다. 대신 사이트의 다른 버튼과 같은 모양의 버튼에 인라인 SVG 로 단순한 재생 삼각형을 그리고 「Google Play에서 받기」라고 적었다. 스토어 로고를 흉내 내지 않고, 다크 모드에서도 다른 버튼과 같이 색이 바뀐다.
가장 신경 쓴 건 주소 검사다. 이 주소는 공개 화면에 그대로 버튼의 href로 나간다. Razor 는 속성 값을 인코딩해 주지만 javascript: 같은 스킴까지 막아 주지는 않는다. 그래서 저장 단계에서 https 절대 주소만 받았다.
private static bool IsHttpsUrl(string value, string host)
{
return Uri.TryCreate(value, UriKind.Absolute, out var uri)
&& uri.Scheme == Uri.UriSchemeHttps
&& !string.IsNullOrEmpty(uri.Host)
&& (host == null || string.Equals(uri.Host, host, StringComparison.OrdinalIgnoreCase));
}
웹 주소는 https 이기만 하면 되고, Google Play 칸은 호스트가 play.google.com이어야 한다. 「Google Play에서 받기」라고 적힌 버튼이 다른 곳으로 가면 안 되기 때문이다. 상대 주소, http, 다른 호스트는 모두 저장 단계에서 오류로 돌려보낸다. 관리자 화면과 관리 API 가 같은 저장 서비스를 거치므로 검사도 한 곳에만 있다.
관리 API 의 연재 PUT은 앱 칸을 생략하면 기존 값을 유지하고, 빈 문자열을 보내면 지운다. 스크립트도 그 구분을 그대로 따르게 했다. set-series에 -AppName, -PlayStoreUrl, -WebsiteUrl, -WebsiteLabel을 더하고, 옵션을 아예 적지 않은 것과 -WebsiteUrl ""처럼 빈 값을 적은 것을 $PSBoundParameters.ContainsKey로 가려서 적은 칸만 요청 본문에 넣는다.
이제 스토어 주소가 바뀌어도 연재 하나만 고치면 그 연재의 모든 글이 함께 바뀐다.
지금은 「오늘 누가 쏘냐?」와 「식단서랍」 연재에 Google Play 버튼이, 「네온 다이브」에 브라우저에서 바로 하는 버튼이, 「Cliply」 두 연재에 내려받기 페이지 버튼이 붙어 있다.
세 가지 모두 큰 기능은 아니었다. 다만 글이 수십 편을 넘어가니, 한 편씩 쓸 때는 보이지 않던 것들이 관리 화면 구석에 쌓이기 시작했다. 다음 편에서도 블로그를 운영하며 손본 것들을 이어서 적는다.