한 줄 요약 — 세 버그 모두 코드가 "틀린" 게 아니라, 로컬에는 없고 운영에만 있던 것(글 주소의 모양, 쌓여 있던 옛 글, 이관해 온 카테고리)을 코드가 가정하지 못해서 터졌다.

들어가며

AIPicto(그림책·창작 커뮤니티)와 AICreo(기업 홈페이지·CMS 보일러플레이트)는 둘 다 CodeIgniter 4 + TinyMCE 게시판을 쓴다. 9월 말부터 10월 중순까지 이 두 에디터에서 버그가 연달아 세 개 나왔는데, 하나같이 로컬에서는 재현이 안 됐다. 테스트도 통과했고, 직접 글을 써봐도 멀쩡했다. 운영서버에 올라간 실제 글에서만 깨졌다.

처음엔 "운영 환경 설정이 뭔가 다른가" 정도로 생각했는데, 셋을 나란히 놓고 보니 공통점이 분명했다. 운영에는 로컬에 없는 데이터와 주소가 있었다. 하나씩 정리한다.

버그 1. 본문 이미지가 깨진다 — 같은 원인, 두 제품, 두 증상

증상

  • AIPicto (#233, 9/29) — 글 보기에서는 이미지가 잘 나오는데, 그 글을 수정하려고 열면 에디터 안의 이미지가 전부 엑스박스.
  • AICreo (#389, 10/20) — 어떤 주소로 열면 이미지가 나오고, 어떤 주소로 열면 깨진다. 깨진 이미지의 실제 요청 주소를 보니 /index.php/uploads/board/images/... 였다.

3주 간격으로 다른 제품에서 다른 모양으로 나타났지만, 뿌리는 같았다.

원인 — TinyMCE의 relative_urls 기본값

업로드 핸들러는 https://도메인/uploads/board/images/2026/10/a.png 처럼 멀쩡한 주소를 돌려준다. 문제는 TinyMCE가 저장할 때 이 주소를 "지금 에디터가 열린 페이지 기준 상대경로"로 바꿔버린다는 점이다. 기본값이 relative_urls: true 이기 때문이다.

글쓰기 페이지가 /board/free/write 라면, 기준 디렉터리는 /board/free/ 이고 저장되는 값은 이렇게 된다.

<img src="/uploads/board/images/2026/10/a.png">

이 상대경로는 그 글을 여는 주소의 깊이에 따라 해석이 달라진다.

글을 여는 주소 기준 디렉터리 ../../uploads 해석 결과 결과
/board/free/123 /board/free/ /uploads/... 정상
/board/free/123/edit /board/free/123/ /board/uploads/... 깨짐 (AIPicto)
/index.php/board/free/123 /index.php/board/free/ /index.php/uploads/... 깨짐 (AICreo)

상대경로 자체가 시한폭탄이었고, 어떤 주소로 열리느냐가 뇌관이었던 셈이다.

왜 운영에서만 터졌나

AIPicto는 "운영에 수정할 옛 글이 있었다"가 답이다. 로컬에서는 새로 쓰고 바로 보는 흐름만 반복했다. 운영에서는 실제 사용자가 몇 주 전에 쓴 글을 수정하러 들어왔고, 그때 처음으로 한 단계 깊은 주소(/edit)에서 그 상대경로가 해석됐다.

AICreo는 주소에 /index.php/가 붙는 경로가 있었다는 게 답이다. 두 제품의 app/Config/App.php를 비교해보면 차이가 바로 보인다.

// AIPicto
public string $indexPage = '';

// AICreo — CI4 스캐폴드 기본값 그대로
public string $indexPage = 'index.php';

CI4의 redirect()->to('/board/...')는 내부적으로 site_url()을 거치고, site_url()은 indexPage를 붙인다. 그래서 AICreo는 글을 저장한 직후 /index.php/board/free/123로 리다이렉트됐고, 그 주소에서는 ../../uploads가 /index.php/uploads로 풀렸다. 그 요청은 정적 파일이 아니라 CI4 라우터로 들어가 404가 났다.

.htaccess 덕분에 /index.php/가 없어도 사이트는 잘 돌아가니, 이 주소 차이는 평소엔 눈에 띄지 않는다. 로컬에서 목록 → 상세로만 글을 열어봤다면 영영 못 봤을 버그다.

수정

두 제품 모두 같은 두 갈래로 고쳤다. 앞으로 저장될 글은 에디터 설정으로, 이미 저장된 글은 데이터 보정으로.

① 에디터가 주소를 건드리지 않게 한다.

tinymce.init({
    // ...
    // 업로드 주소를 받은 그대로 저장 — 기본값(relative_urls)은 작성 페이지 기준
    // 상대경로로 바꿔 경로 깊이가 다른 화면에서 이미지가 깨진다
    convert_urls: false,
    relative_urls: false,
    images_upload_handler(blobInfo) { /* ... */ },
});

② 이미 저장된 글은 고쳐야 한다. 여기서 두 제품의 선택이 달랐다.

AIPicto는 마이그레이션으로 DB를 한 번에 정규화했다. 운영 DB가 MySQL 8이라 REGEXP_REPLACE를 바로 쓸 수 있었다.

class NormalizeBoardImageUrls extends Migration
{
    // src="/uploads/..." / src="/uploads/..." 처럼 ../ 로만 시작해 uploads/ 로 이어지는 주소
    private const RELATIVE_UPLOAD_SRC = 'src="(\\.\\./)+uploads/';

    public function up(): void
    {
        $pattern = $this->db->escape(self::RELATIVE_UPLOAD_SRC);

        $this->db->table('posts')
            ->set('content', 'REGEXP_REPLACE(content, ' . $pattern . ', ' . $this->db->escape('src="/uploads/') . ')', false)
            ->where('content REGEXP ' . $pattern, null, false)
            ->update();
    }

    public function down(): void
    {
        // 되돌릴 이유가 없는 데이터 보정 — 원래 상대경로는 버그였다
    }
}

AICreo는 출력 시점에 보정하는 헬퍼를 택했다. 워드프레스에서 이관해 온 글이 많아 경로 모양이 ./, ../, index.php/가 뒤섞여 있었고, 원본을 건드리기보다 보여줄 때 고치는 쪽이 안전하다고 판단했다.

function post_content_html(?string $html): string
{
    return (string) preg_replace(
        '~(\s(?:src|href)=["\']?)(?:/?(?:\.\./|\./)*)(?:index\.php/)?(uploads/)~i',
        '$1/$2',
        (string) $html,
    );
}

뷰에서는 <?= $post['content'] ?>를 <?= post_content_html($post['content']) ?>로 바꿨다. 테스트는 "고쳐야 할 것"만큼 "건드리면 안 되는 것"을 같이 넣었다. 외부 CDN의 /uploads/, 본문 텍스트 속 "uploads/ 폴더"라는 글자, /board/uploads/x 같은 다른 경로는 그대로여야 한다.

버그 2. 대체 설명문(캡션)을 입력해도 저장이 안 된다

증상

AIPicto는 #258에서 이미지의 alt를 이미지 아래 캡션으로 항상 보여주게 바꿨다. 그림책 커뮤니티라 "이 그림이 무엇인지"를 글로 남기는 게 중요해서다. 그런데 운영에서 옛 글의 이미지에 대체 설명문을 넣으면, 확인을 누르고 저장해도 alt가 비어 있었다. 새로 올린 이미지는 잘 됐다.

원인 — TinyMCE는 alt=""를 "장식용 이미지"로 본다

접근성 관점에서 alt=""는 "내용 없음"이 아니라 "이 이미지는 장식이니 스크린리더는 건너뛰어라"라는 명시적 신호다. TinyMCE의 이미지 속성창도 이 의미를 존중해서, alt=""(과 role="presentation")이 붙은 이미지를 장식용으로 취급하고 대체 설명문 입력값을 버린다. 사용자가 입력창에 글자를 써도 결과물에는 반영되지 않는다.

왜 운영에서만 터졌나

로컬에서 테스트한 이미지는 전부 방금 붙여넣거나 업로드한 것이라 alt 속성 자체가 없었다. 속성이 없는 이미지는 장식용이 아니니 입력이 잘 들어간다. 운영에는 캡션 기능이 생기기 전에 쓰인 글들이 있었고, 그 이미지들 중 상당수에 alt=""가 붙어 있었다. "속성 없음"과 "빈 문자열"은 HTML에서 전혀 다른 의미인데, 로컬 데이터는 앞의 경우만, 운영 데이터는 뒤의 경우까지 갖고 있었다.

수정 — 그리고 한 번 더 수정

첫 번째 수정(#268)은 속성창을 여는 명령(mceImage) 직전에 빈 alt를 지우는 방식이었다.

editor.on('BeforeExecCommand', e => {
    if (e.command !== 'mceImage') return;
    const img = editor.selection.getNode();
    if (img.nodeName === 'IMG' && img.getAttribute('alt') === '') {
        img.removeAttribute('alt');
        img.removeAttribute('role');
    }
});

더블클릭으로 속성창을 열면 잘 됐다. 그런데 툴바의 이미지 버튼으로 열면 여전히 안 됐다. 툴바 버튼은 mceImage 명령을 거치지 않고 속성창을 바로 열기 때문에 이 훅이 아예 불리지 않았다. 40분 뒤 두 번째 수정(#269)에서 훅의 위치를 "속성창을 열 때"가 아니라 "이미지를 선택할 때"로 옮겼다.

// alt="" 이미지는 TinyMCE 가 '장식용'으로 보고 속성창의 대체 설명문 입력값을 버린다.
// 툴바 버튼은 mceImage 명령을 거치지 않고 속성창을 바로 열므로, 이미지를 선택하는 시점에 비워 둔다.
editor.on('ObjectSelected', e => {
    const img = e.target;
    if (img.nodeName !== 'IMG' || img.getAttribute('alt') !== '') return;
    editor.undoManager.ignore(() => {
        img.removeAttribute('alt');
        img.removeAttribute('role');
    });
});

undoManager.ignore()로 감싼 건, 이미지를 클릭만 했는데 실행 취소 기록이 하나 쌓이는 어색함을 없애기 위해서다. 사용자 입장에서는 아무것도 바꾸지 않았으니까.

버그 3. 카테고리를 고르고 글쓰기에 들어가면 카테고리가 풀린다

증상

AICreo 게시판에서 "일상" 카테고리로 목록을 필터링한 뒤 글쓰기 버튼을 누르면, 글쓰기 화면의 카테고리가 "선택 안 함"으로 돌아가 있었다. 그대로 저장하면 카테고리 없는 글이 되고, 방금 보던 카테고리 목록에서는 그 글이 보이지 않는다.

원인

단순했다. 목록의 글쓰기 링크가 현재 카테고리를 넘기지 않았고, 글쓰기 화면도 받을 생각이 없었다. 셀렉트 박스는 $post['category_id']만 봤는데, 새 글이니 $post는 null이었다.

왜 운영에서만 터졌나

이 버그는 날짜를 보면 이유가 보인다.

  • 9/7 — 워드프레스 이관 구조 개편(#282, #284, #296). 워드프레스 카테고리를 게시판마다 따로 만드는 대신 board_categories로 매핑하도록 바꿨다. 이 날부터 운영 게시판에 카테고리가 실제로 생겼다.
  • 9/10 — 카테고리 유지 버그 수정.

로컬 시드 데이터의 free 게시판에는 카테고리가 없었다. 카테고리가 없으니 필터링할 일도, 글쓰기에서 카테고리를 고를 일도 없었다. 기능은 처음부터 이 모양이었지만, 카테고리가 있는 게시판이 운영에만 있었기 때문에 운영에서만 "버그"가 됐다. 수정할 때 추가한 테스트도 그 사실을 그대로 보여준다. 테스트 안에서 카테고리를 직접 만들고 시작한다.

수정

목록의 글쓰기 링크에 현재 카테고리를 쿼리스트링으로 붙인다.

<a href="/board/<?= esc($board['slug']) ?>/write<?= $currentCategory ? '?category=' . $currentCategory : '' ?>" class="btn btn-primary btn-sm">
    <i class="bi bi-pencil-square" aria-hidden="true"></i> 글쓰기
</a>

컨트롤러는 받은 값을 그대로 믿지 않고, 그 게시판 소속 카테고리인지 확인한 다음 넘긴다.

return $this->render('board/write', [
    'board'              => $board,
    'post'               => null,
    'categories'         => $this->categoryModel->getByBoard($board['id']),
    'selectedCategoryId' => $this->resolveCategoryId($board['id'], $this->request->getGet('category')),
]);

뷰는 수정 화면이면 글의 카테고리, 새 글이면 넘겨받은 카테고리를 기본 선택한다.

<option value="<?= $cat['id'] ?>" <?= (int) ($post['category_id'] ?? $selectedCategoryId ?? 0) === (int) $cat['id'] ? 'selected' : '' ?>><?= esc($cat['name']) ?></option>

테스트는 목록 → 글쓰기로 이어지는 흐름을 한 번에 검증한다.

public function testCategoryFilteredListPreselectsCategoryInWriteForm(): void
{
    $boardId    = $this->boardId('free');
    $categoryId = (int) (new BoardCategoryModel())->insert(['board_id' => $boardId, 'slug' => 'daily', 'name' => '일상'], true);

    $listBody = $this->get("board/free?category={$categoryId}")->getBody();
    $this->assertStringContainsString("/board/free/write?category={$categoryId}", $listBody);

    $writeBody = $this->withSession([
        'user_id'       => 1,
        'user_nickname' => '관리자',
        'user_role'     => 'admin',
    ])->get("board/free/write?category={$categoryId}")->getBody();

    $this->assertStringContainsString("value=\"{$categoryId}\" selected", $writeBody);
}

세 버그를 나란히 놓고 보면

버그 로컬에 없던 것 운영에만 있던 것
이미지 경로 깊이가 다른 주소로 옛 글을 여는 상황 수정하러 들어오는 옛 글, /index.php/가 붙은 글 주소
캡션 저장 alt=""가 붙은 이미지 캡션 기능 이전에 쓰인 글의 이미지
카테고리 누락 카테고리가 있는 게시판 워드프레스에서 이관된 카테고리

세 경우 모두 코드는 로컬에서 정확히 의도대로 동작했다. 틀린 건 코드가 아니라 "데이터는 내가 방금 만든 것과 같은 모양일 것"이라는 암묵적인 가정이었다. Claude Code로 개발하면 이 가정이 더 강해진다. AI는 내가 보여준 화면과 시드 데이터를 기준으로 코드를 짜고, 테스트도 그 데이터로 통과시킨다. 운영에만 있는 모양은 내가 말해주지 않는 한 AI도 모른다.

배운 것

  1. 에디터 라이브러리의 "친절한" 기본값을 의심하자. TinyMCE의 relative_urls와 장식용 이미지 처리는 둘 다 그 자체로는 합리적인 동작이다. 문제는 내 앱의 URL 구조와 데이터가 그 기본값의 전제와 다르다는 것이었다. 새 프로젝트를 시작할 때 convert_urls: false, relative_urls: false는 처음부터 넣는다.
  2. "속성 없음"과 "빈 값"을 테스트 데이터에 둘 다 넣자. alt 없음 / alt="" / alt="설명"은 세 가지 다른 상태다. category_id의 null과 0도 마찬가지다.
  3. CI4 스캐폴드 기본값을 프로젝트 시작 시 정리하자. AIPicto는 indexPage를 비워 뒀고 AICreo는 기본값 그대로였다. 같은 사람이 같은 프레임워크로 만든 제품인데도 보일러플레이트의 출발점이 달라 증상이 갈렸다. baseURL(#268에서 한 번 데였다)과 indexPage는 체크리스트에 올린다.
  4. 이벤트 훅은 "사용자가 그 일을 하는 모든 경로"에 걸자. 캡션 버그는 더블클릭 경로만 막고 툴바 경로를 놓쳐서 두 번 고쳤다. "속성창을 열 때"가 아니라 "이미지를 선택할 때"처럼, 모든 경로가 반드시 지나가는 지점에 거는 게 맞다.
  5. 데이터 보정은 "앞으로"와 "이미 저장된 것"을 나눠서 하자. 설정 수정만으로는 옛 글이 계속 깨져 있다. DB를 한 번에 고칠지(AIPicto), 출력 시점에 보정할지(AICreo)는 이관 데이터가 얼마나 지저분한지로 정한다.

이 순서로

배포 전에 이걸 한 번씩 보는 것만으로도 셋 중 둘은 미리 잡았을 것이다.

  • 운영 DB를 익명화해서 로컬로 가져와 본 적이 있는가? 없다면 운영에서 대표적인 글 5개 정도의 HTML만이라도 테스트 픽스처로 복사해 둔다.
  • 글을 연다는 주소를 전부 나열해 본다. 목록에서 들어오는 주소, 저장 직후 리다이렉트된 주소, 수정 화면 주소, 외부에서 공유된 주소. 이미지와 링크가 모든 주소에서 같은 결과를 내는지 확인한다.
  • 이관이나 구조 변경(카테고리, 게시판 분리 등)을 한 직후에는 그 데이터를 쓰는 화면을 직접 한 바퀴 돈다. 기능은 그대로인데 데이터가 바뀌어 버그가 드러나는 경우가 많다.
  • CLAUDE.md에 한 줄 추가한다. "TinyMCE 초기화 시 convert_urls: false, relative_urls: false 필수. 테스트 데이터에는 alt 없음·빈 값·채운 값을 모두 포함할 것."

돌아보며

로컬에서 완벽하게 돌아가는 코드가 운영에서 깨지면, 처음 드는 생각은 늘 "서버 설정이 뭔가 다른가"다. 이번엔 서버 설정이 아니었다. 운영에는 시간이 쌓여 있었다. 몇 주 전에 쓴 글, 기능이 생기기 전에 만들어진 데이터, 다른 시스템에서 옮겨 온 구조. 로컬은 언제나 오늘 만든 깨끗한 세상이라, 그 시간을 재현하지 못한다.

혼자 제품 여러 개를 돌리다 보면 운영 데이터를 들여다볼 여유가 없다. 그래서 더더욱 "운영에만 있는 것"을 의식적으로 테스트 쪽으로 끌어와야 한다는 걸, 9월 초부터 10월 중순까지 세 번에 걸쳐 배웠다.