· nambak80 Blog 로그인
CodeIgniter4

CodeIgniter 4로 블로그 만들기 #26 — 마크다운 본문 렌더링

CodeIgniter 4로 블로그 만들기 #26 — 마크다운 본문 렌더링

지금까지 우리 블로그의 본문은 평문이었습니다. show.php에서 nl2br(esc($post->body))로, 줄바꿈만 살려서 그대로 뿌렸죠. 하지만 실제 블로그 글에는 제목, 굵은 글씨, 링크, 목록 같은 서식이 필요합니다.

이번 회차의 목표는 본문을 마크다운으로 쓰고, 표시할 때 HTML로 변환하는 것입니다. 여기서 지켜야 할 원칙이 하나 있습니다 — 저장은 원문(마크다운), 표시만 변환(HTML). 그리고 사용자가 쓴 본문을 HTML로 바꾸는 순간 XSS(스크립트 주입) 위험이 따라오므로, 변환기를 안전 옵션으로 설정하는 것이 이번 글의 핵심입니다.

준비물 / 목표

  • 마크다운 → HTML 변환 라이브러리 league/commonmark
  • Post 엔티티에 body_html 접근자 추가(변환 + XSS 차단)
  • 상세 화면(show.php)이 변환된 HTML을 렌더
  • 작성/수정 폼에 "마크다운으로 쓸 수 있다"는 안내
  • 변환과 XSS 차단을 검증하는 단위 테스트

1. CommonMark 라이브러리 설치

마크다운 파서는 직접 만들지 않고 검증된 라이브러리를 씁니다. PHP 진영에서 널리 쓰이는 league/commonmark를 의존성에 추가합니다.

composer require league/commonmark

composer.jsonrequire에 한 줄이 들어갑니다.

"require": {
    "php": "^8.2",
    "codeigniter4/framework": "^4.7",
    "codeigniter4/shield": "^1.3",
    "league/commonmark": "^2.8"
}

2. Post 엔티티에 body_html 접근자

이번 설계의 중심은 변환을 어디서 하느냐입니다. 컨트롤러나 뷰에서 매번 변환 코드를 부르면 중복이 생기고, 뷰가 라이브러리를 직접 아는 것도 깔끔하지 않습니다. 그래서 변환은 도메인 객체인 Post 엔티티가 책임집니다. 뷰에서는 $post->body_html 한 줄이면 끝나게요.

CI4 엔티티는 getXxx() 메서드를 정의하면 $entity->xxx로 접근하는 접근자(accessor) 규칙을 지원합니다. getBodyHtml()을 만들면 $post->body_html이 됩니다.

<?php

namespace App\Entities;

use CodeIgniter\Entity\Entity;
use League\CommonMark\CommonMarkConverter;

class Post extends Entity
{
    protected $dates = ['created_at', 'updated_at'];

    /**
     * 본문 마크다운 원문을 HTML 로 변환해 돌려준다. 뷰에서 $post->body_html.
     *
     * 저장은 원문(body), 표시만 변환한다. 본문은 사용자가 쓴 것이므로
     * 원시 HTML 은 이스케이프하고(html_input=escape) 위험한 링크(javascript: 등)는
     * 막아서(allow_unsafe_links=false) XSS 를 차단한다.
     */
    public function getBodyHtml(): string
    {
        $converter = new CommonMarkConverter([
            'html_input'         => 'escape',
            'allow_unsafe_links' => false,
        ]);

        return $converter->convert((string) ($this->attributes['body'] ?? ''))->getContent();
    }

    // ... excerpt 접근자는 그대로 ...
}

핵심은 CommonMarkConverter에 넘긴 두 옵션입니다.

  • html_input => 'escape' — 마크다운 본문 안에 사용자가 <script> 같은 원시 HTML을 박아 넣어도, 실행 가능한 태그로 통과시키지 않고 이스케이프해 버립니다. 기본값(allow)이면 그대로 살아나므로, 사용자 입력을 다룰 땐 반드시 escape로 둬야 합니다.
  • allow_unsafe_links => false[클릭](javascript:alert(1))처럼 javascript:, data: 같은 위험한 스킴을 쓴 링크의 href를 제거합니다.

원문은 $this->attributes['body']에서 그대로 읽습니다. DB에 저장된 것은 어디까지나 마크다운 원문이고, 이 접근자는 표시할 때만 HTML을 만든다는 점을 다시 확인하세요.

3. 상세 화면에서 변환된 HTML 렌더

posts/show.php에서 평문 출력을 걷어내고 body_html을 씁니다.

<?php // 본문은 마크다운 원문으로 저장하고, 표시할 때 HTML 로 변환한다.
      // 변환은 엔티티(body_html)가 XSS 안전 설정으로 처리하므로 여기선 그대로 출력한다. ?>
<div class="post-body prose">
    <?= $post->body_html ?>
</div>

여기서 esc()를 쓰지 않는 것이 의도적입니다. body_html은 이미 HTML이고, 그 HTML은 이미 안전 옵션으로 만들어졌기 때문입니다. 만약 여기서 esc()로 감싸면 <h1>이 화면에 &lt;h1&gt;로 그대로 보여 버립니다. "안전은 변환 시점에 이미 처리했으니, 출력은 그대로 한다" — 이 흐름을 기억하세요.

.prose 클래스는 변환된 문단·제목·링크에 읽기 좋은 여백과 서식을 주기 위한 것입니다.

4. 작성/수정 폼에 마크다운 안내

사용자가 본문 칸이 마크다운을 지원한다는 걸 알아야 합니다. create.phpedit.php의 본문 라벨 아래에 짧은 힌트를 넣습니다.

<label for="body">본문</label>
<p class="field-hint">마크다운으로 작성할 수 있습니다 — <code># 제목</code>, <code>**굵게**</code>, <code>[링크](https://…)</code></p>
<textarea name="body" id="body" rows="12"><?= esc(old('body')) ?></textarea>

그리고 app.css에 힌트 스타일을 더합니다.

.field-hint { font-size: 12px; color: var(--color-ink-3); margin: 0 0 8px; }
.field-hint code { background: var(--color-paper-warm); padding: 1px 5px; border-radius: 4px; font-family: var(--font-mono); font-size: 11px; }

5. 변환과 XSS를 테스트로 못 박기

XSS 차단은 "설정만 해두고 믿는" 게 아니라 테스트로 증명해야 하는 부분입니다. tests/unit/PostMarkdownTest.php를 만들어, 엔티티만 단독으로(new Post(['body' => ...])) 검증합니다. DB도 컨트롤러도 필요 없는 순수 단위 테스트입니다.

<?php

use App\Entities\Post;
use CodeIgniter\Test\CIUnitTestCase;

final class PostMarkdownTest extends CIUnitTestCase
{
    private function html(string $body): string
    {
        return (new Post(['body' => $body]))->body_html;
    }

    public function testRendersHeading(): void
    {
        $this->assertStringContainsString('<h1>제목</h1>', $this->html('# 제목'));
    }

    public function testRendersBold(): void
    {
        $this->assertStringContainsString('<strong>굵게</strong>', $this->html('**굵게**'));
    }

    public function testEscapesRawHtml(): void
    {
        // 본문에 박힌 원시 <script> 는 실행 가능한 태그로 새어 나오면 안 된다.
        $html = $this->html('안녕 <script>alert(1)</script>');
        $this->assertStringNotContainsString('<script>', $html);
    }

    public function testBlocksUnsafeLinks(): void
    {
        // javascript: 스킴 링크는 href 로 살아 남으면 안 된다.
        $html = $this->html('[클릭](javascript:alert(1))');
        $this->assertStringNotContainsString('javascript:', $html);
    }

    public function testBlocksUnsafeLinksWithMixedCaseScheme(): void
    {
        // 대소문자를 섞어 우회하려는 스킴(JaVaScRiPt:)도 막혀야 한다.
        $html = $this->html('[클릭](JaVaScRiPt:alert(1))');
        $this->assertStringNotContainsStringIgnoringCase('javascript:', $html);
    }

    public function testBlocksDataUriScheme(): void
    {
        // data: 스킴(인라인 HTML 주입 벡터)도 href 로 남으면 안 된다.
        $html = $this->html('[클릭](data:text/html,<script>alert(1)</script>)');
        $this->assertStringNotContainsString('data:text/html', $html);
    }
}

앞의 두 테스트는 마크다운이 제대로 변환되는지(제목·굵게)를 확인하고, 뒤의 네 테스트는 XSS 벡터를 하나씩 막았는지 확인합니다. 특히 대소문자를 섞은 JaVaScRiPt: 우회와 data:text/html 스킴까지 검증하는 점을 눈여겨보세요. "위험 링크 차단"은 한 줄 옵션이지만, 그 옵션이 실제로 여러 우회를 막는다는 걸 테스트가 보증합니다.

composer test

핵심 개념 — 왜 엔티티에서 변환하고, 왜 안전 옵션인가

두 가지를 정리하고 갑니다.

왜 엔티티인가. 변환 로직을 엔티티의 접근자에 두면, 뷰는 $post->body_html만 알면 됩니다. 나중에 변환기를 바꾸거나 옵션을 조정할 때 고칠 곳이 딱 한 군데입니다. 컨트롤러마다, 뷰마다 흩어져 있으면 XSS 옵션을 하나만 빠뜨려도 구멍이 생깁니다.

왜 저장은 원문인가. DB에 HTML을 저장하면 나중에 렌더링 규칙을 바꿀 수 없고, 저장 시점에 변환 버그가 있으면 데이터가 영구히 오염됩니다. 원문(마크다운)을 저장하면 표시 규칙은 언제든 다시 바꿀 수 있습니다. "원본은 그대로 두고, 파생물은 매번 만든다" — 이게 안전한 방향입니다.

왜 이스케이프인가. 본문은 사용자가 쓴 것이지, 우리가 통제하는 콘텐츠가 아닙니다. 신뢰할 수 없는 입력을 HTML로 바꿀 땐, 원시 HTML은 이스케이프하고 위험한 링크 스킴은 잘라내는 것이 기본입니다.

마무리 — 커밋과 태그

git add .
git commit -m "feat: 마크다운 본문 렌더링"
git tag ep26

다음 회차

다음 글에서는 대표 이미지 업로드와 썸네일을 만듭니다. 글에 대표 이미지를 붙이고, 목록에는 작은 썸네일로 보여 줍니다. 파일 업로드 검증, multipart/form-data 폼, CI4의 이미지 조작(Image Manipulation)으로 썸네일을 만드는 흐름을 다룹니다.


이번 회차 요약

  • 다루는 파일: composer.json(+league/commonmark) / app/Entities/Post.php(body_html 접근자) / app/Views/posts/show.php(HTML 렌더) / create.php·edit.php(마크다운 안내) / public/assets/css/app.css(.field-hint) / tests/unit/PostMarkdownTest.php
  • 핵심: 저장은 마크다운 원문, 표시만 HTML 변환. 변환기는 html_input=escape·allow_unsafe_links=false로 XSS를 차단한다.

다음: 대표 이미지 업로드와 썸네일

댓글 0

아직 댓글이 없습니다.

로그인 후 댓글을 남길 수 있습니다.

← 목록으로