X 캡션 글자수가 딱 2자 초과했다 — 예산 계산에서 빠진 리터럴 문자열 실측
블로그 글을 SNS용 캡션으로 자동 변환하는 스크립트를 돌렸는데, X(트위터) 280자 제한을 넘었다는 경고가 떴다. 실행 로그 그대로다.
✅ quote-check-short-quote-pairs-bug → .social-queue/quote-check-short-quote-pairs-bug/ ⚠️ X 초과(282/280) — 수동 축약 필요
282자. 딱 2자 넘었다. 코드는 분명히 280자 예산을 계산해서 그 안으로 본문을 잘랐는데, 왜 2자가 새는지 실제 값을 그대로 넣고 재현해봤다.
예산 계산 코드
이 스크립트는 블로그 글의 제목·설명·태그를 조합해 X 캡션을 만든다. 핵심은 본문(제목+설명)이 들어갈 자리를 미리 계산해두는 부분이다.
const budget = 280 - 23 - xWeight(tags) - 10;
let desc = fm.description;
while (xWeight(fm.title + "\n\n" + desc) > budget && desc.length > 0) {
desc = desc.slice(0, -1);
}
if (desc !== fm.description) desc = desc.trim() + "…";
const body = `${fm.title}\n\n${desc}\n\n자세히 → ${url}${tags ? "\n" + tags : ""}`;
23은 X가 모든 URL을 t.co 단축링크로 취급해 고정으로 세는 무게다. xWeight(tags)는 해시태그 부분의 실제 글자수(한글은 2로 계산). 그리고 10은 남은 고정 문구(“\n\n자세히 → ” 같은 것들)를 위한 여유분으로 어림잡은 값이다. while 루프는 제목+설명이 이 예산 안에 들어올 때까지 설명을 뒤에서부터 잘라낸다.
문제는 이 루프가 검사하는 문자열이 fm.title + "\n\n" + desc뿐이라는 점이다. 실제로 최종 body에는 이것 말고도 "\n\n자세히 → "와 태그 앞의 "\n"이 더 붙는데, 이 두 조각의 실제 무게는 루프 안에서 한 번도 재보지 않고 -10이라는 상수 하나로 뭉뚱그려 뺀 것이다.
재현 — 실제 프론트매터로 직접 돌려봤다
실제로 경고가 난 글의 프론트매터 값을 그대로 넣고, 같은 계산 로직을 한 줄씩 직접 확인했다.
const tags = "#정규식 #regex"; // hashtags(fm.tags, 2)
const budget = 280 - 23 - xWeight(tags) - 10; // = 233
// 루프가 검사하는 값
xWeight(fm.title + "\n\n" + desc); // = 233 (budget과 정확히 일치, 여기까진 완벽)
// 루프가 안 보는 값들
xWeight("\n\n자세히 → "); // = 11
xWeight("\n"); // = 1 (태그 앞 줄바꿈)
// 최종 결과
xWeight(body.replace(url, "x".repeat(23))); // = 282
title + desc는 정확히 예산(233)에 맞춰 잘렸다. 여기까지는 코드가 의도대로 동작한 것이다. 그런데 루프가 보지 못한 나머지 조각의 실제 무게는 11 + 1 = 12였다. 예산 계산 시점에 이 자리를 위해 미리 빼둔 값은 10. 12와 10의 차이만큼, 딱 2자가 그대로 새어 나갔다. 로그에 찍힌 282/280과 정확히 일치한다.
왜 10으로 어림잡았을까
"\n\n자세히 → "를 글자 수로만 세면 89자 정도로 보인다(줄바꿈 2개, “자세히”, 공백, 화살표, 공백). 문제는 이 계산 함수가 한글·한자·가나 같은 CJK 문자를 가중치 2로 센다는 점이다 — 실제로 X가 그렇게 세기 때문에 이 프로젝트도 맞춰놓은 규칙이다. “자세히”의 세 글자는 각각 2씩, 합쳐서 6이고, 나머지(줄바꿈 2개·공백 2개·화살표 1개)가 5 — 합이 11이다. 눈으로 대충 “89자쯤 되겠지”로 셌다면 CJK 가중치를 놓쳤을 가능성이 높다. 여기에 태그 앞 줄바꿈 1자를 더 빠뜨리면 정확히 12 대 10, 2자 차이가 난다.
정리
280자 제한을 2자 넘긴 걸 보고 처음엔 설명이 아직도 너무 긴가 싶었다. 실제로는 본문 예산 계산은 정확했고, 넘친 자리는 “자세히 →” 같은 사람 눈에는 별로 안 길어 보이는 고정 문구 쪽이었다. 어림값 하나로 여러 조각을 퉁치면, 그 어림값이 어긋난 원인을 코드만 읽어서는 못 찾는다. 실제 값을 넣고 한 줄씩 돌려봐야 어디서 몇 자가 새는지 숫자로 나온다.
자주 묻는 질문
왜 하필 2자였나요? 우연 아닌가요?
우연이 아니다. '\n\n자세히 → ' 문구의 실제 무게는 11(한글 2글자 CJK 가중치 포함), 태그 앞에 붙는 줄바꿈이 1 — 합쳐서 12다. 그런데 예산 계산 코드는 이 자리를 어림값 10 하나로 뺐다. 12와 10의 차이, 정확히 2다.
왜 어림값을 10으로 정했을까요?
아마 '자세히 → '라는 문구를 영문 기준으로 대충 셌을 가능성이 높다. 이 프로젝트의 글자수 계산 함수는 한글·한자·가나 같은 CJK 문자를 가중치 2로 세는데(X가 실제로 그렇게 세기 때문이다), 고정 문구 안의 한글 3글자('자세히')는 어림값을 셀 때 이 가중치를 놓치기 쉽다.
고쳤나요?
코드는 그대로 두었다. X 캡션은 API로 자동 발행하는 게 아니라 사람이 복사해서 올리는 용도라, 2자 초과 경고가 뜨면 그 자리에서 수동으로 줄이면 된다. 원인을 몰랐을 때는 '또 몇 자를 줄여야 하지'를 매번 감으로 했는데, 이제는 초과분이 어디서 새는지 알아서 어느 문장을 줄여야 할지 바로 판단할 수 있다.