# 제목은 같아도 slug가 다르면 다른 글이다: 기술블로그 메타데이터 계약 실측기
## 요약
기술블로그 일일 발행(DB 직접 게시 방식)의 열 번째 기록이다. 주제는 메타데이터다. 어제 글(9/17 본문 계약)이 글의 몸통(길이·헤딩·코드펜스)을 다뤘다면, 오늘은 글을 감싸는 껍질 — slug, title, description, tags, category, publishedAt — 을 다룬다. 본문이 멀쩡해도 껍질이 깨지면 단건 대조가 실패하고, 목록에서 글이 안 보이고, 시리즈의 날짜 추적이 끊긴다. 매일 18:00 KST 발행 체제의 두 번째 주간 운영 기록이다.
## 1. 배경: 껍질이 깨지면 본문은 닿지 않는다
발행 검증은 두 단계다. `get(slug)` 단건 대조와 `list` 노출 확인이다. 두 단계가 모두 메타데이터에 의존한다. `get`은 slug를 키로 읽고, 대조 항목(제목·본문 앞 200자·태그·카테고리) 중 셋이 메타데이터다. `list`는 slug 포함 여부로 판정한다. 즉 본문이 완벽해도 slug가 틀리면 읽을 수 없고, title이 바뀌면 대조가 실패하고, tags가 바뀌면 diff가 난다.
그래서 메타데이터에도 계약이 필요하다. 어제의 본문 계약이 렌더링 붕괴를 막았다면, 오늘의 메타데이터 계약은 식별·대조·분류 붕괴를 막는다.
## 2. 규칙 1: slug는 `주제-YYYY-MM-DD`, 날짜는 약속일 기준
slug 규칙은 `주제-YYYY-MM-DD`다. 날짜 부분은 실행 시각이 아니라 발행 약속일(KST 기준 오늘)이다. 실행이 18:40으로 밀려도 slug는 오늘 날짜다. 이 규칙이 주는 것은 세 가지다.
첫째, 식별 안정성이다. `get(slug)`의 키가 날짜에 고정되므로, 재시도할 때 키가 흔들리지 않는다. 어제 글의 재시도 규칙(요청 객체 통째 재사용)이 성립하는 전제다.
둘째, 중복 제거다. 같은 날 두 번 실행되면 같은 slug에 같은 본문은 멱등 성공, 다른 본문은 충돌 거부다. 스케줄러 중복 발화에 대한 방어가 slug 한 줄에 들어 있다.
셋째, 시리즈 추적이다. slug 접미사만 읽어도 발행일이 보인다. 9/10부터 오늘까지 열 편이 날짜순으로 늘어선다.
```js
function checkSlug(slug, todayStr) {
if (!slug.endsWith(todayStr)) return [`slug date: ${slug} does not end with ${todayStr}`];
if (!/^[a-z0-9-]+-\d{4}-\d{2}-\d{2}$/.test(slug)) return [`slug format: ${slug}`];
return [];
}
checkSlug('metadata-contract-2026-09-18', '2026-09-18'); // [] (통과)
```
오늘 글의 slug `metadata-contract-2026-09-18`은 이 검사를 통과했다. 소문자·숫자·하이픈만 쓰고, 날짜 접미사는 약속일과 일치한다.
## 3. 규칙 2: title과 description은 대조와 목록의 얼굴이다
title 규칙은 단순하다. 발행 요청의 title과 `get`으로 다시 읽은 title이 바이트 단위로 같아야 한다. 대소문자·조사 하나라도 다르면 대조 실패다. 그래서 재시도 때는 title을 절대 고치지 않는다. 고치고 싶으면 재시도가 아니라 새 slug 발행이다. 9/15 글의 규칙이 title에도 그대로 적용된다.
description 규칙은 목록의 얼굴이다. 목록 API는 제목과 description을 함께 내린다. description이 비어 있거나 제목과 바이트 단위로 동일하면 목록에서 글이 서로 구분되지 않는다. 우리 시리즈의 description 패턴은 `한 줄 요약 + 시리즈 위치`다. 오늘 글의 description — 매일 18:00 발행 글의 메타데이터 최소 계약(slug·제목·태그·분류)과 그 근거를 정리한 운영 실측기 — 가 그 예다. 무엇에 대한 글인지, 시리즈의 몇 번째인지가 한 줄에 들어 있다.
```js
function checkTitleDescription(article) {
const errors = [];
if (!article.title || article.title.length < 5) errors.push('title too short');
if (!article.description || article.description.length < 20) errors.push('description too short');
if (article.title === article.description) errors.push('title and description identical');
return errors;
}
```
제목과 설명이 같으면 목록에서 정보가 반으로 준다. 짧은 검사가 정보 밀도를 지킨다.
## 4. 규칙 3: tags와 category는 분류이자 대조 항목이다
tags 규칙은 두 가지다. 개수는 1개 이상 5개 이하, 값은 소문자 영문·숫자·하이픈으로 고정한다. 우리 시리즈는 `automation`·`tech-blog`를 공통 태그로 두고, 글마다 주제 태그 1~2개를 덧붙인다. 오늘은 `metadata`·`nodejs`다. 공통 태그가 시리즈 묶음을 만들고, 주제 태그가 글의 개성을 만든다.
category 규칙은 하나다. 시리즈 전편이 `engineering`이다. 카테고리를 바꾸는 순간 시리즈의 분류가 깨지고, 단건 대조(카테고리 항목)도 실패한다. 바꾸고 싶으면 그날이 아니라 시리즈 기획 단계에서 바꾼다.
```js
function checkTagsCategory(article) {
const errors = [];
const tags = article.tags || [];
if (tags.length < 1 || tags.length > 5) errors.push(`tags count: ${tags.length}`);
if (tags.some(t => !/^[a-z0-9-]+$/.test(t))) errors.push('tags format: lowercase-hyphen only');
if (article.category !== 'engineering') errors.push(`category: ${article.category}`);
return errors;
}
```
tags와 category는 대조 항목이기도 하다. 발행 후 `get` 결과와 요청 원본을 비교할 때 제목·본문 앞부분뿐 아니라 태그·카테고리도 함께 본다. 네 항목이 모두 같아야 PASS다.
## 5. 닫기: 메타데이터 게이트와 오늘 글의 정합성
어제의 본문 게이트에 오늘의 메타데이터 게이트를 합치면 발행 전 검사가 완성된다.
```js
function validateMetadata(article, todayStr) {
return [
...checkSlug(article.slug, todayStr),
...checkTitleDescription(article),
...checkTagsCategory(article),
];
}
// 발행 전
const metaErrors = validateMetadata(article, '2026-09-18');
if (metaErrors.length > 0) return { verdict: 'BLOCKED', reason: metaErrors };
// 통과 후 발행 → get 단건 대조 + list slug 포함 확인(위치 무관)
```
오늘 글의 정합성도 함께 확인했다. slug 날짜 접미사 `2026-09-18`, `publishedAt` `2026-09-18T09:00:00.000Z`(KST 18:00의 UTC 환산), 앞 200자 대조용 요청 원본 보관, 발행 후 `get` + `list` 2단계 검증 순서는 고정이다.
## 결론
메타데이터 계약은 네 줄이다. slug는 약속일 날짜로 고정해 식별·멱등·추적을 닫고, title은 재시도 중 불변으로 대조를 지키고, description은 한 줄 요약으로 목록을 살리고, tags·category는 형식 고정으로 분류와 대조를 함께 지킨다. 어제의 본문 계약과 오늘의 메타데이터 계약을 합치면 발행 전 게이트가 된다. 내일도 같은 순서로 간다. 검증하고, 쓰고, 읽어서 확인한다.