한 줄 요약 — 본문 HTML에서 태그만 벗기고 엔티티는 디코딩하지 않은 채 요약을 잘랐습니다. 그리고 뷰에서 esc()가 &를 한 번 더 이스케이프하면서 —가 글자 그대로 화면에 나왔습니다. 요약을 만들 때 html_entity_decode()를 한 번 거치게 해서 해결했습니다.

증상

페이징을 고치고 나니 목록의 다른 문제가 눈에 들어왔습니다. 요약문이 이렇게 나오고 있었습니다.

한 줄 요약 — 시각은 MySQL TIMESTAMP로만 저장하고…
한 줄 요약 — 테스트가 느린 이유는…

글 상세 페이지에서는 멀쩡한 —와 공백입니다. 목록에서만 깨집니다.

원인

에디터는 본문을 HTML로 저장합니다. 특수문자는 엔티티로 들어갑니다.

 
html
<p>한 줄 요약&nbsp;&mdash; 테스트가 느린 이유는…</p>

클로드코드가 만든 요약 로직은 이랬습니다.

 
php
$summary = mb_substr(strip_tags($post['content']), 0, 100);

strip_tags()는 태그만 지웁니다. 엔티티는 그대로 남습니다. 결과는 한 줄 요약&nbsp;&mdash; 테스트가…라는 문자열입니다.

여기까지는 브라우저가 해석해주니 문제가 없을 수도 있었습니다. 그런데 뷰에서 XSS 방어를 위해 제대로 이스케이프하고 있었습니다.

 
php
<p class="summary"><?= esc($post['summary']) ?></p>

esc()는 &를 &amp;로 바꿉니다. 그래서 &mdash;가 &amp;mdash;가 되고, 브라우저는 이것을 “—라는 글자”로 보여줍니다. 이중 이스케이프입니다.

잘못한 쪽은 esc()가 아닙니다. 뷰에서 이스케이프하는 것은 정석이고, 빼면 안 됩니다. 문제는 “HTML”을 "텍스트"로 바꾸는 단계가 반쯤만 된 것이었습니다. 태그는 벗겼지만 엔티티는 텍스트로 풀지 않았습니다.

숨은 버그가 하나 더 있었습니다. 엔티티가 남은 상태로 mb_substr()로 자르면, 글자 수 경계에서 &md처럼 엔티티가 반 토막 날 수 있습니다. 또 &mdash; 하나가 화면에서는 한 글자지만 자르기 계산에서는 7글자를 차지합니다. 요약 길이가 글마다 들쭉날쭉했던 이유도 이것이었습니다.

수정

HTML → 순수 텍스트 변환을 한 곳에 모았습니다.

 
php
// app/Helpers/text_helper.php
function html_to_summary(string $html, int $length = 100): string
{
    $text = strip_tags($html);
    $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
    $text = str_replace("\u{00A0}", ' ', $text);          // &nbsp; → 일반 공백
    $text = trim(preg_replace('/\s+/u', ' ', $text));     // 줄바꿈·연속 공백 정리

    return mb_strlen($text) > $length
        ? mb_substr($text, 0, $length) . '…'
        : $text;
}

순서가 중요합니다.

  1. 태그 제거
  2. 엔티티 디코딩 → 이제 진짜 텍스트
  3. 공백 정리
  4. 자르기 (텍스트 기준이라 엔티티가 반 토막 나지 않음)
  5. 출력은 뷰에서 esc() (그대로 유지)

&nbsp;는 디코딩하면 일반 공백이 아니라 U+00A0이 됩니다. 그대로 두면 \s 정규식에 안 걸리고 줄바꿈도 안 되므로 따로 일반 공백으로 바꿨습니다.

기존 데이터

요약을 DB 컬럼에 저장하고 있었다면 이미 저장된 값도 깨진 상태입니다. Spark 커맨드로 한 번 재생성했습니다.

 
php
// php spark summary:rebuild
foreach ($model->findAll() as $post) {
    $model->update($post['id'], [
        'summary' => html_to_summary($post['content']),
    ]);
}

배운 것

  1. "이스케이프는 출력할 때 한 번"이 규칙이라면, 저장된 데이터는 반드시 "순수 텍스트"여야 한다. HTML 반, 텍스트 반인 데이터가 생기는 순간 어디에서 이스케이프해도 틀립니다.
  2. AI는 한 줄짜리 관용구를 쓴다. mb_substr(strip_tags(...))는 인터넷에 수만 번 나오는 코드입니다. 그래서 의심 없이 나오고, 에디터가 엔티티를 쓰는지는 고려하지 않습니다. 변환 로직은 헬퍼 하나로 모아두고 "요약은 반드시 html_to_summary()를 쓸 것"이라고 CLAUDE.md에 적었습니다.
  3. 테스트 데이터에 특수문자를 넣자. 지난 글에서 시더로 글을 수백 개 넣자고 했는데, 그 본문이 Lorem ipsum이면 이 버그는 못 잡습니다. —, &, <, ", 줄바꿈, 연속 공백이 들어간 본문을 시더에 섞었습니다.
 
php
public function testSummaryDecodesEntities(): void
{
    $html = '<p>한 줄 요약&nbsp;&mdash; A &amp; B</p>';
    $this->assertSame('한 줄 요약 — A & B', html_to_summary($html));
}