# 본문이 길다고 좋은 글이 아니다: 기술블로그 content 계약(길이·구조·코드펜스) 실측기
## 요약
기술블로그 일일 발행(DB 직접 게시 방식)의 아홉 번째 기록이다. 주제는 본문(content)이다. 앞선 여덟 글(9/10 파이프라인, 9/11 사전 검증, 9/12 사후 검증, 9/13 멱등 재시도, 9/14 발행 가드·목록 함정, 9/15 재시도·백오프, 9/16 타임스탬프 정합성)은 발행의 앞뒤(언제 쓰고 어떻게 확인하는가)를 다뤘다. 오늘은 가운데, 즉 글 자체가 갖춰야 할 최소 계약을 다룬다. 길이 하한, 섹션 구조, 코드펜스 닫힘. 세 가지가 깨지면 발행 호출은 성공해도 독자에게는 실패다. 매일 18:00 KST 발행 체제의 두 번째 주간 운영 기록이다.
## 1. 배경: 발행 성공과 글 성공은 다르다
발행 파이프라인의 PASS 조건은 단건 대조 일치 + 목록 노출이다. 제목이 같고, 본문 앞 200자가 같고, 태그·카테고리가 같고, slug가 목록에 있으면 PASS다. 그런데 이 조건은 본문 전체를 보지 않는다. 앞 200자만 대조하므로, 201자 이후가 깨져도 검증은 통과한다. 코드펜스가 안 닫혔거나, 섹션이 하나뿐이거나, 분량이 300자인데도 앞부분만 멀쩡하면 PASS가 나온다.
그래서 content 계약이 필요하다. 발행 전에 본문 전체를 검사하는 세 가지 규칙이다. 이 규칙들은 9/11 글의 `validateArticle` 7개 규칙 안에 이미 들어 있었지만, 왜 그 값인지 설명한 적이 없다. 오늘은 그 근거를 적는다.
## 2. 규칙 1: 본문 500자 이상 — 잘라먹힘을 검출하는 하한
하한이 있는 이유는 잘라먹힘을 잡기 위해서다. 에디터에서 복사하다 끝이 잘리거나, 템플릿 변수 치환이 중간에 멈추거나, API 요청 본문이 잘려 나가면 본문이 짧아진다. 짧아진 본문의 앞 200자는 멀쩡할 수 있다. 잘린 것은 대개 뒷부분이기 때문이다. 그래서 앞부분 대조만으로는 잘라먹힘을 못 잡는다. 전체 길이에 하한을 두면 잡힌다.
500이라는 숫자는 엄밀한 정보이론 값이 아니라 운영 경험치다. 우리 시리즈의 글 한 편이 요약·배경·본론 2~3개·닫기 구조로 3000~6000자 나오는 것을 관찰한 뒤, 그 10분의 1 수준에서 잘라먹힘을 검출할 수 있는 가장 낮은 선으로 500을 잡았다. 정상 글은 여유 있게 넘고, 잘린 글은 걸린다. 경계값 튜닝이 아니라 오류 검출용 하한이다.
```js
function checkContentLength(content) {
return content && content.length >= 500
? []
: [`content too short: ${content ? content.length : 0} chars (min 500)`];
}
```
오늘 글도 이 검사를 통과했다. 발행 전 로컬에서 `content.length`를 재고 500 이상임을 확인한 뒤 발행 호출을 한다. 길이는 품질이 아니라 무결성의 지표다.
## 3. 규칙 2: 섹션 구조 — `##` 헤딩 3개 이상
헤딩 개수 규칙이 있는 이유는 구조 붕괴를 잡기 위해서다. 본문이 한 덩어리 텍스트로 합쳐지면 원인은 대개 개행 소실이다. `\n`이 `\s`로 바뀌거나, 마크다운 렌더러가 아닌 경로로 본문이 한 번 감싸이면 섹션 구분이 사라진다. 제목·앞부분 대조는 통과하는데, 독자에게는 벽돌 한 장이 보인다.
`##` 헤딩 3개 이상이라는 규칙은 요약·본론·결론의 최소 뼈대를 강제한다. 오늘 글을 보자. 요약, 1. 배경, 2. 규칙 1, 3. 규칙 2, 4. 규칙 3, 5. 닫기, 결론. 헤딩이 7개다. 규칙을 만족하는 것을 넘어서, 헤딩 목록만 읽어도 글의 흐름이 보인다.
```js
function checkContentStructure(content) {
const headings = (content.match(/^##\s+.+/gm) || []).length;
return headings >= 3
? []
: [`content structure: only ${headings} h2 headings (min 3)`];
}
```
정규식 `^##\s+`는 `###` 하위 헤딩도 셈에 넣는다. `##`로 시작하는 줄이면 수준 무관하게 구조 요소로 본다. 엄격한 아웃라인 검사가 아니라 붕괴 검출용이다. 헤딩이 0~2개면 어딘가에서 개행이 소실됐을 가능성이 크다.
## 4. 규칙 3: 코드펜스 닫힘 — 홀수 개의 ```` ``` ````는 발행 중단
코드펜스 규칙이 있는 이유는 렌더링 붕괴를 막기 위해서다. 여는펜스만 있고 닫는펜스가 없으면, 그 뒤의 모든 본문이 코드 블록으로 렌더링된다. 결론·태그 설명·다음 글 예고가 전부 등폭 글꼴 회색 상자로 들어간다. 발행 검증(앞 200자 대조)은 앞부분만 보므로 이 붕괴를 못 잡는다. 렌더링은 독자 쪽에서 깨진다.
검사는 단순하다. 본문에서 ```` ``` ```` occurrences를 세어 짝수인지 본다. 홀수면 어딘가 펜스가 안 닫힌 것이다.
```js
function checkCodeFences(content) {
const fences = (content.match(/```/g) || []).length;
if (fences % 2 !== 0) return ['content: unclosed code fence'];
// 언어 태그 없는 펜스도 경고 대상에서 제외하지는 않지만 차단하지 않는다
return [];
}
```
오늘 글의 펜스는 세 블록(길이 검사·구조 검사·펜스 검사 함수), 여섯 개 마커로 짝수다. 언어 태그(`js`) 유무는 차단 조건이 아니다. 태그가 없어도 렌더링은 깨지지 않기 때문이다. 깨지는 것과 못생긴 것을 구분한다. 차단するのは 깨지는 쪽뿐이다.
## 5. 닫기: 세 검사를 묶은 사전 게이트
세 검사는 발행 전 게이트의 일부로 묶인다. 9/11 글의 `validateArticle`에 오늘의 근거를 덧씌운 형태다.
```js
function validateContent(content) {
return [
...checkContentLength(content),
...checkContentStructure(content),
...checkCodeFences(content),
];
}
// 발행 전
const contentErrors = validateContent(article.content);
if (contentErrors.length > 0) return { verdict: 'BLOCKED', reason: contentErrors };
// 통과 후 발행 → get 단건 대조 + list slug 포함 확인(위치 무관)
```
BLOCKED와 FAIL의 구분은 그대로다. 본문이 계약을 못 맞추면 쓰기 전에 멈추므로 BLOCKED(발행 미시도), 썼는데 읽기가 어긋나면 FAIL(노출 미확인)이다. 9/12 사건이 FAIL이었던 이유가 여기에 있다. 쓴 것은 성공했고 읽기가 늦었던 것이다. 본문 계약 위반은 그보다 앞단에서 걸린다.
오늘 글의 메타데이터 정합성도 함께 확인했다. slug 날짜 접미사 `2026-09-17`, `publishedAt` `2026-09-17T09:00:00.000Z`(KST 18:00의 UTC 환산), 앞 200자 대조용 요청 원본 보관, 발행 후 `get` + `list` 2단계 검증 순서는 고정이다.
## 결론
본문 계약은 세 줄이다. 500자 이상으로 잘라먹힘을 잡고, 헤딩 3개 이상으로 구조 붕괴를 잡고, 코드펜스 짝수로 렌더링 붕괴를 막는다. 길이는 품질이 아니라 무결성의 지표이고, 헤딩은 미학이 아니라 개행의 증거이며, 펜스는 취향이 아니라 렌더링의 경계다. 내일도 같은 순서로 간다. 검증하고, 쓰고, 읽어서 확인한다.