에디터 본문에 이미지를 넣으면 base64로 박혔다. 목록 API 응답이 비대해졌고, 그걸 고치는 데 티켓 네 장과 엿새가 걸렸다. 코드 변경량만 보면 하루짜리 일이다.

시간이 어디로 갔는지 적어두려고 한다. 대부분은 내가 확신했다가 틀린 자리에 갔다.

네 번 틀렸다고 생각하며 이 글을 쓰기 시작했다. 세어보니 일곱 번이었다.

그런데 일곱 번 다 “확인 안 하고 넘어간” 게 아니다. 일곱 번 다 확인했다. 소스를 검색했고, 서버를 찔러봤고, 코드 리뷰를 여섯 라운드 받았고, 데이터를 다운로드해뒀다.

틀린 건 확인한 대상이었다.

이 구분이 이 글의 전부다. “꼼꼼히 확인하자”는 아무 쓸모가 없다. 나는 꼼꼼했다. 필요한 건 “내가 지금 보고 있는 게 판단의 근거가 될 수 있는 물건인가”를 묻는 습관이다.

그리고 이건 AI와 일할 때 훨씬 중요해진다. AI는 내가 지정한 대상을 정확하게 확인한다. 대상이 틀리면, 정확하게 틀린 답을 준다.


1. 증상은 명확했다

공지 목록 API의 응답이 컸다. 원인도 금방 나왔다 — 본문 HTML 안에 data:image/...;base64, 가 통째로 들어 있었다. 이미지 파일이 문자열이 되어 본문에 박히고, 그 본문이 목록 응답에 실려 나간다.

숫자를 먼저 적어둔다. 목록 API가 내려주는 공지는 142건이었다.

  • 본문에 base64가 들어 있는 공지는 32건, 전체의 22%
  • 그런데 그 32건이 전체 본문 용량의 99%를 차지했다
  • 본문 크기는 중앙값 1,100자, 최대 220만 자. 약 2,000배 차이다
  • 가장 큰 5건이 전체의 62%

목록은 15건씩 끊어서 내려준다. 그래서 페이지에 따라 같은 API의 응답이 30KB에서 4.7MB까지 흔들렸다. 어느 페이지를 넘기느냐에 따라 150배가 왔다 갔다 한다.

그리고 이 응답을 받아가는 곳 중 하나는 데스크톱 공통 푸터의 공지 위젯이다. 제목과 링크와 게시일만 쓴다.

기존 본문을 정리하는 단기 조치를 먼저 했다. 응답이 200배 넘게 줄었다. 그런데 원인이 게시 프로세스가 아니라 에디터라, 이미지가 든 공지를 새로 하나만 등록하면 그대로 돌아온다.

증상이 명확하고 원인이 한 줄이면, 범위도 한 줄일 것 같다. 그게 첫 번째 오해였다.


2. 그래서 base64만 막으려 했다

먼저 막고 나중에 제대로 고치기로 했다. 앞 티켓은 유입 차단, 뒤 티켓은 CDN 업로드 전환. 순서는 지금도 맞다고 생각한다.

문제는 “막는다”의 범위였다.

에디터는 Quill이다. 이 글에 나오는 라이브러리 이름은 그대로 적는다 — Quill, Tailwind, lodash. 회사와 서비스는 익명화하지만 스택은 그럴 이유가 없고, 가려두면 이 글에서 배울 게 사라진다.

처음엔 툴바 버튼 하나만 보였다. 실제로는 세 개였다.

  • 툴바 이미지 핸들러 — 기본 핸들러가 파일을 readAsDataURL 로 읽어 삽입
  • uploader 모듈 — 드래그앤드롭·클립보드의 이미지 파일
  • clipboard matcher — HTML을 붙여넣을 때 딸려 오는 data: 이미지

세 번째까지 오는 데 한참 걸렸다. 앞의 둘은 “이미지를 넣는 행위”라 떠올리기 쉬운데, 세 번째는 텍스트를 붙여넣었을 뿐인데 이미지가 들어오는 경로다. 사용자는 이미지를 넣었다고 생각하지 않는다.

그리고 막고 나니 반대쪽이 깨졌다. 편집 모드로 들어갈 때 기존 본문을 에디터에 밀어넣는 코드가 같은 matcher를 탄다. 그래서 예전 공지를 열기만 해도 그 안의 base64가 로딩 시점에 지워졌다. 차단 하나 넣었더니 데이터가 사라진 것이다.

프로그래매틱 변환에는 플래그를 세워 matcher를 우회시켰다. 이때 처음으로 “이 가드는 무엇을 막을지가 아니라 무엇을 통과시킬지를 정의해야 하는구나”를 알았다. 그 판단이 뒤에서 여섯 번 더 필요해진다.


3. 범위를 넓혀 본 것들

한 번 틀린 뒤로는 세 방향을 전부 봤다.

현재 코드

에디터 컴포넌트가 두 화면에서 재사용되고 있었다. 한쪽만 고치면 다른 쪽으로 그대로 들어온다. 파일 하나를 고치면 둘 다 해결되고, 반대로 두 화면 모두 회귀 확인이 필요하다.

그리고 하나 더 나왔다. 같은 조직 안에 이미 동작하는 참조 구현이 있었다. 다른 앱의 이메일 템플릿 에디터에 커스텀 이미지 핸들러가 이미 붙어 있었다 — 업로드 URL 발급 → 스토리지 PUT → 본문에 CDN 절대 URL 삽입. 구조를 새로 설계할 필요가 없었다.

이걸 못 찾았으면 처음부터 설계했을 것이다. “우리 조직에 이미 이걸 한 사람이 있나”는 코드를 쓰기 전에 물어야 하는 질문인데, 자주 잊는다.

마이그레이션 이전 코드

이 화면은 예전 프레임워크에서 옮겨온 것이다. 옛 저장소를 열어 같은 에디터의 옛 버전(메이저 1)을 봤고, 경로별로 판정이 갈렸다.

유입 경로 옛 버전 현재 판정
툴바 이미지 버튼 base64 삽입됨 base64 삽입됨 기존 동작
이미지 파일 드래그앤드롭 불가 (uploader 모듈 없음) base64 삽입됨 마이그레이션 신규
클립보드 이미지 파일 불가 base64 삽입됨 마이그레이션 신규
HTML 붙여넣기의 data: 삽입됨 삽입됨 기존 동작
저장 시점 가드 없음 없음 기존 동작

드래그앤드롭·클립보드는 메이저 업그레이드로 새로 뚫린 유입구였다. 회귀 복구에 해당한다. 반면 툴바는 원래부터 있던 동작이라, 그걸 막는 건 기능 축소다.

이 구분이 배포 계획을 바꿨다. 차단만 먼저 내보내면 운영자가 본문에 이미지를 넣을 방법이 사라진다. 그래서 두 티켓을 한 브랜치에 담아 함께 내보내기로 했다. “기존 동작인가 신규 유입인가”를 안 나눴으면 이 결정을 못 했다.

운영 데이터 전수

목록 API를 페이지 단위로 끝까지 돌려 142건을 전부 로컬에 저장했다. 표본이 아니라 전수다. 어차피 142건이라 전수가 더 싸다.

받아놓고 나니 할 수 있는 게 많아졌다. 본문 길이 분포, base64 포함 비율, 태그와 속성의 빈도. 1번의 숫자가 전부 여기서 나왔고, 6번의 QA 항목도 여기서 나온다.

이 단계가 이 작업에서 가장 값이 쌌다. API를 페이지 단위로 받아 파일로 떨구는 일은 몇 분이면 된다. 그런데 이후의 모든 판단이 여기 기대게 된다. 반대로 이걸 안 하면 모든 판단이 “아마 이럴 것이다”가 된다.

물론 받아만 두고 안 열어보면 소용없다. 그 얘기는 7번에서 한다.


4. 틀린 판단 세 번

4-1. 검색은 “없다”를 증명하지 못한다

본문 이미지의 폭을 누가 제한하는지 확인해야 했다. 에디터에서 인라인 스타일로 거는 방법은 동작하지 않았다 — Quill의 내부 문서 모델에 없는 속성이라 정규화 과정에서 값이 날아가고 빈 style="" 만 남았다. 그렇다면 렌더하는 쪽이 막아야 한다. 문제는 렌더하는 쪽이 다른 저장소라는 것이었다.

그 저장소를 뒤졌다. 상세 화면의 스타일 컴포넌트를 열어봤다 — 첨부파일 이미지에는 폭 제한이 있는데 본문 HTML이 주입되는 쪽에는 없었다. 전역 스타일 파일도 봤다. img 규칙이 하나 있었지만 display, vertical-align, border 뿐이었다.

증거가 하나 더 나왔다. 같은 파일에 이런 주석이 달려 있었다.

Preflight의 스타일을 제거합니다

Tailwind 문서 링크까지 걸려 있었다. 이 프로젝트는 기본 이미지 스타일을 의도적으로 걷어냈구나.

그리고 로컬에서 재현까지 됐다. 상세 화면을 띄우고 큰 이미지가 든 공지를 열었더니 가로 스크롤이 생겼다. 데스크톱 1440px에서 원본 1104px 이미지가 문서 폭을 1634px까지 밀어냈다.

세 개가 같은 방향을 가리켰다. 검색, 주석, 그리고 실제 재현. 나는 티켓을 하나 새로 만들고, 설명에 근거를 적고, 다른 저장소에 PR을 올렸다. 그리고 앞 티켓의 커밋 메시지에 이렇게 썼다.

전역 CSS 에도 img max-width 가 없어 그 티켓이 유일한 방어선이며, 이 티켓과 같은 배포에 함께 나가야 한다

배포 순서 제약까지 걸었다.

전부 틀렸다.

전역 스타일 파일의 첫 줄은 세 단어였다.

@tailwind base;

이 한 줄이 빌드 시점에 수백 줄의 기본 스타일을 만들어내고, 그 안에 이것이 있다.

img, video { max-width: 100%; height: auto; }

소스 전체를 max-width 로 검색하면 이 규칙은 절대 나오지 않는다. 검색할 문자열 자체가 없기 때문이다. 검색이 실패한 게 아니라 검색이 닿을 수 없는 곳에 있었다.

여기까지는 “grep의 한계”라는 흔한 교훈이다. 그런데 이 건에는 층이 더 있었다.

브라우저에서 스타일 규칙을 열거해도 안 잡힌다. 그 규칙이 든 스타일시트는 다른 오리진에서 서빙되고, CORS 때문에 cssRules 접근이 막혀 있다. 즉 “브라우저에서 확인했다”조차 방법에 따라 같은 오답을 준다.

그리고 로컬 재현. 이게 제일 고약했다. 오버플로가 실제로 눈앞에서 났으니 확정적인 증거처럼 보였다. 나중에 보니 내가 띄운 로컬 라우트가 실제 고객이 보는 경로와 달랐다. 틀린 화면에서 정확하게 재현한 것이다.

결국 답을 준 건 하나였다. 2400×300px짜리 이미지를 본문에 넣은 공지를 만들어서, 실제 QA 환경에서 열고, 그 <img> 의 computed style을 읽는 것.

1200px 뷰포트 → 렌더 폭 700px,  가로 스크롤 없음
 375px 뷰포트 → 렌더 폭 335px,  가로 스크롤 없음
getComputedStyle(img).maxWidth === "100%"

30초 걸렸다.

그 30초를 아낀 대가로, 티켓 하나를 만들었다가 Won’t Do로 닫았고, PR 하나를 올렸다가 코드 반영 없이 닫고 브랜치를 지웠고, 다른 티켓에 인라인 스타일 방어 코드를 넣었다가 걷어냈고, 배포 순서 제약을 하나 걸었다가 풀었다.

어떻게 알아챘나. 사실 이건 프로세스가 잡은 게 아니다. 예전에 그 저장소를 본 적이 있어서 첫 줄이 걸렸다. 운이다. 처음 보는 저장소였으면 그대로 나갔을 것이고, 이 글에는 이 꼭지가 없었을 것이다. 뒤에 나올 규칙들이 필요한 이유가 “이렇게 하면 잘 잡힌다”가 아니라 “운에 기대지 않으려고”인 건 그래서다.

4-2. 확인이랍시고 엉뚱한 문을 두드렸다

업로드에 쓸 API를 정하는 자리에서 막혔다. 그 화면은 첨부파일 업로드를 이미 쓰고 있었고, 본문 이미지도 같은 경로를 재사용하면 새 API가 필요 없었다. 좋은 답으로 보였다.

그런데 그 API 서비스 파일에 주석이 있었다.

운영 게이트웨이에만 존재 — QA/dev 미지원

읽는 순간 계획이 바뀌었다. QA에서 검증이 안 되면 정기배포 QA 의뢰가 막힌다. 그러면 다른 API를 써야 하고, 그 API에는 이 도메인용 정책이 없으니 백엔드에 요청해야 한다.

그리고 나는 확인을 했다. 안 하고 넘어간 게 아니다. QA 게이트웨이 호스트에 그 엔드포인트를 찔러봤다.

qa 게이트웨이  /upload/attachment/  →  404
운영 게이트웨이 /upload/attachment/  →  400 (존재)

깔끔했다. 주석이 맞았다. 나는 이걸 티켓 설명에 「알려진 제약 — QA 환경」이라는 문단으로 적었고, 부모 티켓의 착수 조건으로 올렸고, blocked 라벨을 붙였다. 백엔드 선행 요청이 되었고, 작업이 멈췄다.

전부 틀렸다.

그 업로드 서비스는 환경과 무관하게 항상 운영 게이트웨이를 직접 호출한다. 베이스 URL이 하드코딩되어 있다. 즉 코드는 QA 게이트웨이를 부른 적이 없다. 내가 찔러본 호스트는 이 코드와 아무 상관이 없는 주소였고, 404가 나온 건 당연했다.

주석도 다시 읽으니 다른 말이었다. “운영에만 존재 — QA/dev 미지원”은 “그래서 우리는 운영을 직접 부른다”는 설명이었다. “QA 환경에서 이 기능을 못 쓴다”가 아니라.

실제로 확인하는 방법은 이거였다.

$ curl -X OPTIONS -H "Origin: https://<QA 어드민>" \
       -H "Access-Control-Request-Method: POST" \
       https://<운영 게이트웨이>/upload/attachment/

HTTP/2 200
access-control-allow-origin: https://<QA 어드민>
access-control-allow-credentials: true

운영 게이트웨이가 QA 오리진을 명시적으로 허용하고 있었다. 특정 오리진을 그대로 돌려주는 걸 보면 허용 목록에 들어 있는 것이고, 그건 의도된 설계다. 같은 화면의 첨부파일 기능이 이미 그렇게 동작하고 있었다.

막혀 있던 게 아니었다. 막혀 있다고 적혀 있었고, 나는 엉뚱한 문을 두드려 그 말이 맞다고 확인했다.

그리고 부모 티켓의 할 일 목록에는 처음부터 이렇게 적혀 있었다 — “업로드 API 경유 후 본문에는 CDN URL만 삽입 (첨부파일이 이미 CDN 경로 사용 중)”. 답이 티켓에 있었는데 주석이 그걸 덮었다.

주석은 검증되지 않는 유일한 코드다. 컴파일되지 않고, 테스트되지 않고, 틀려도 아무것도 깨지지 않는다. 그런데 읽을 때의 확신은 코드보다 높다. 사람이 사람에게 남긴 말이기 때문이다.

이 주석은 아마 쓰인 시점엔 사실이었을 것이다. 문제는 그 문장이 티켓 필드와 라벨로 승격됐다는 것이다. 코드 옆의 한 줄이 프로젝트 관리 도구의 상태가 되면, 그때부터는 아무도 원문을 다시 읽지 않는다.

전에 비슷한 걸 한 번 쓴 적이 있다 — 잠정적으로 적어둔 것이 나중에 기준으로 굳는 일. 그때는 내가 임시로 쓴 검증 항목이 스펙 자리에 앉았고, 이번엔 남이 쓴 주석이 착수 조건 자리에 앉았다. 임시로 쓴 문장에는 임시라고 적혀 있지 않다.

나중에 그 주석을 사실에 맞게 고치면서 커밋 메시지에 이렇게 적었다.

문구가 착수 조건으로 굳어 이 작업을 잘못 막은 적이 있다

4-3. 여섯 번 통과한 가드가 데이터를 지우고 있었다

차단 가드를 올린 PR은 리뷰에서 여섯 번 지적을 받았다. 매번 고쳤고, 매번 통과했다.

  1. 개수만 비교하고 있었다. 원본 대비 base64 개수가 늘었을 때 차단했는데, 1개 지우고 1개 넣는 교체는 1 → 1 이라 그대로 통과했다.
  2. 기존 이미지를 옮기면 사라졌다. 예전 공지의 이미지를 잘라내기·붙여넣기로 위치만 옮겨도 붙여넣는 순간 제거됐다.
  3. 두 가드의 기준이 서로 달랐다. 편집 시점 판정은 “있었나”만 봤고, 저장 시점 가드는 개수까지 1:1로 봤다. 주석에는 “저장 가드와 같은 기준”이라고 적혀 있었다. 아니었다.
  4. 한 번에 여러 장을 붙여넣으면 뚫렸다. 판정이 현재 본문의 HTML을 스냅샷으로 세는데, 변환 한 사이클 동안 그 값이 갱신되지 않는다. 그래서 같은 이미지가 여러 장 들어오면 전부 같은 스냅샷으로 판정돼 초과 통과했다.
  5. 판정 로직이 테스트에 닿지 않았다. 핵심 판정이 에디터 초기화 클로저 안에 있어 밖에서 부를 수가 없었다. 그리고 바로 그 자리가 직전(4번)에 회귀한 자리였다.
  6. 덮어쓰기 붙여넣기에서 원본이 소실됐다.

여섯 번째를 좀 더 쓴다.

이미지를 포함한 영역을 선택한 채로 붙여넣으면, 선택 영역은 변환이 끝난 뒤에 지워진다. 그래서 판정하는 순간에는 곧 사라질 이미지가 아직 본문에 남아 있다. 그대로 세면 잔존 개수가 원본 개수와 같아 부당하게 차단되고, 이어서 선택 영역이 지워지면서 이미지가 없어진다.

QA 스모크에서 재현했다. 커밋 메시지에 적힌 그대로다.

QA 스모크에서 재현 확인 (1장 → 0장)

응답 비대함을 막으려고 넣은 가드가, 운영자의 멀쩡한 이미지를 지우고 있었다. 막으려던 문제보다 나쁜 실패였다.

두 가지를 봐야 한다.

하나. 여섯 개가 전부 같은 버그였다. 개수 비교, 재배치, 두 가드 불일치, 다중 붙여넣기, 덮어쓰기 — 표면은 다 다른데 전부 “이 이미지가 원본에 있던 것인가”를 판정하는 기준이 어긋난 것이다. 다섯 번을 고쳤는데 여섯 번째가 또 같은 자리였다. 리뷰는 매번 증상을 정확히 짚었고, 그래서 나는 매번 증상을 고쳤다. 아무도 “이 판정 자체가 잘못된 모양 아니냐”를 묻지 않았다. 나도 안 물었다.

둘. 여섯 번째는 리뷰가 아니라 브라우저가 잡았다. 앞의 다섯은 코드를 읽어서 나온 지적이다. 여섯 번째는 선택 영역이 지워지는 시점이 문제라 코드만 봐서는 안 보인다. 실제로 선택하고, 실제로 붙여넣고, 이미지가 사라지는 걸 봐야 나온다.

리뷰가 부실해서가 아니다. 여섯 라운드는 충분히 성실했다. 코드 읽기로 도달할 수 있는 곳의 끝이 거기였을 뿐이다.


5. 그리고 QA에서 세 개가 더 나왔다

두 티켓을 합쳐 QA에 올리고 실제로 눌러봤다. 세 개가 나왔다. 셋 다 코드 리뷰를 통과한 상태였다.

5-1. 차단 안내가 일부 형식에서만 떴다

드래그앤드롭·클립보드 차단은 Quill의 uploader 모듈 핸들러를 갈아끼워 구현했다. 그런데 PNG와 JPEG를 넣을 때만 안내가 떴다. GIF, WEBP, AVIF, SVG, HEIC, BMP, TIFF는 아무 반응이 없었다.

원인은 내가 안 건드린 옵션에 있었다. 모듈 기본값에 mimetypes: ['image/png', 'image/jpeg'] 가 있고, 업로드 함수는 이 목록으로 파일을 먼저 거른 뒤 남은 게 없으면 핸들러를 아예 호출하지 않는다. 나는 handler 만 덮어썼는데, 옵션 병합이 merge({}, DEFAULTS, 내가_준_값) 이라 mimetypes 는 기본값이 그대로 살아 있었다.

차단 자체는 유효했다 — base64가 들어가진 않았으니까. 다만 안내 없이 조용히 무시됐다. 사용자 입장에서는 “드래그했는데 아무 일도 안 일어나는” 상태다.

확인은 추론이 아니라 실행으로 했다. 노드에서 lodash를 직접 불러 같은 인자로 돌렸고, 그 김에 함정이 하나 더 나왔다. 이 merge배열을 인덱스 단위로 합친다.

merge({}, {m:['image/png','image/jpeg']}, {m:['image/gif']}).m
→ ["image/gif", "image/jpeg"]   // ← jpeg가 살아남는다

그래서 차단 목록을 새로 쓸 때 앞의 두 항목을 지우면 안 된다. 코드에 주석으로 박아뒀다. (그리고 이 주석도 언젠가 낡을 것이다.)

5-2. 업로드가 끝나면 포커스를 뺏어갔다

재현 조건이 특이해서 적어둔다.

  1. 툴바 이미지 버튼으로 900KB짜리 사진을 고른다 (업로드에 2~3초)
  2. 기다리는 동안 위쪽 제목 입력란을 클릭해 타이핑한다
  3. 업로드가 끝나는 순간 캐럿이 제목에서 본문으로 튀고, 화면이 에디터로 스크롤된다
  4. 이어서 친 글자가 제목이 아니라 공지 본문에 들어간다

원인은 삽입 후 커서를 옮기는 한 줄이었다. 그 함수는 포커스가 에디터 밖에 있으면 내부적으로 에디터에 포커스를 준다.

if (!this.hasFocus()) this.root.focus({ preventScroll: true });   // ← 포커스 탈취

처음엔 “변경 이벤트를 조용한 종류로 바꾸면 되겠다”고 생각했다. 아니었다. 스크롤만 막히고 포커스 탈취는 그대로 실행된다. 이것도 실측으로 확인했다.

결국 고친 방식은 이렇다 — 삽입 전에 현재 선택 영역을 읽어두고, 그게 없으면(= 사용자가 에디터 밖에 있으면) 커서를 아예 건드리지 않는다.

+ const currentRange = quill.getSelection();
  quill.insertEmbed(index, 'image', imageUrl, 'user');
- quill.setSelection(index + 1, 0);
+ if (currentRange != null) {
+   quill.setSelection(index + 1, 0);
+ }

일반 경로는 그대로다. 자리를 옮기지 않았다면 선택 영역이 살아 있어 캐럿이 기존처럼 이미지 뒤로 간다.

이 결함은 이번 변경이 만든 게 아니라 그 전부터 있던 동작이다. 다만 이번에 손댄 함수였고, 인덱스를 계산하는 줄은 “커서가 밖일 수 있다”를 전제하면서 바로 다음 줄에서 조건 없이 포커스를 되돌리고 있었다. 두 줄이 서로 모순이었다.

찾은 방법이 좀 특이했다. 증상만으로는 원인 후보가 여럿이었다. 그래서 서로 다른 다섯 관점으로 독립 검토를 돌렸고, 넷이 같은 지점을 지목했다. 그 다음에 브라우저로 가서 재현했다. 정적으로 읽는 단계에서 후보를 좁히고, 확정은 실행으로 한 셈이다.

5-3. 같은 파일명이 서로 덮어쓰고 있었다

이게 제일 위험했다.

업로드 API는 /{경로}/{연}/{월}/{일}/{원본 파일명} 에 저장하고 같은 경로를 그대로 응답한다. 경로에 유일성이 없다. 같은 날 같은 이름의 파일을 올리면 앞의 것을 덮어쓴다.

그런데 CDN은 이 경로를 이렇게 캐싱하고 있었다.

cache-control: public, max-age=15552000, stale-while-revalidate=31536000, immutable
                       └ 180일                                            └ 불변 선언

경로는 가변인데 캐시는 180일 immutable.

무슨 일이 일어나냐면 — 덮어쓴 직후에는 엣지 캐시가 옛 이미지를 계속 준다. 그러다 캐시가 만료되는 시점에 이미 게시된 공지의 그림이 조용히 다른 그림으로 바뀐다. 엣지마다 만료 시점이 다르니 고객마다 다른 이미지를 본다. 그리고 아무도 배포를 하지 않았으므로 원인을 추적할 단서가 없다.

QA에서 실측했다. 같은 이름의 다른 이미지(400×300 → 150×100)를 올려봤다.

응답 URL          : 동일
에디터에 삽입된 것 : 400×300  ← 방금 올린 그림이 아니라 옛 그림
오리진(캐시 우회) : 150×100  ← 이미 교체되어 있음
CDN               : x-cache Hit, age 59  ← 옛 그림을 계속 반환

기존 공지의 그림이 시한부로 남아 있는 상태였다.

왜 지금까지 문제가 없었나. 운영 공지에 이미 들어 있는 이미지들의 경로를 세어봤더니, 전부 서버가 생성한 이름이었다.

2026070809161500000087295.png         (타임스탬프 기반)
11f190961a1454c79645dbf6730dc4f0.png  (해시)
d7fdfd7a…_2019010318314…              (해시 + 타임스탬프)

사람이 지은 이름이 하나도 없었다. 충돌이 구조적으로 불가능했던 것이다. 그런데 우리가 만든 새 경로는 운영자가 올린 파일 이름을 그대로 쓴다. 공지.png, 안내.jpg, 1.png 가 들어온다.

우리가 base64를 없애면서 새로 만든 문제였다. 원래 있던 문제를 발견한 게 아니라.

파일명에 유일성을 붙여 고쳤다.

세 개의 공통점

셋 다 코드 리뷰를 통과한 상태였다. 그리고 셋 다 코드를 아무리 정확하게 읽어도 안 나온다.

  • 5-1은 라이브러리 기본값과 병합 규칙 — 내 코드에 안 적혀 있다
  • 5-2는 비동기 완료 시점의 포커스 상태 — 실행 중에만 존재한다
  • 5-3은 인프라의 캐시 정책 — 저장소에 없다

전부 “코드 밖”이다. 4번의 셋과 같은 종류고, 셋 다 눌러봐서 나왔다.

참고로 이 글을 쓰는 지금, 이 세 건을 고친 PR은 아직 리뷰 전이다. 그러니까 이 글은 끝난 일의 회고가 아니라 진행 중인 작업의 중간 기록이다.


6. QA를 데이터로 짰다

붙여넣기와 서식의 조합은 무한히 만들 수 있다. 운영에 실제로 존재하는 조합은 몇 개 안 된다.

그래서 QA 항목을 상상으로 짜지 않았다. 3번에서 받아둔 142건의 본문을 열어, 거기 실제로 들어 있는 패턴만 뽑았다. 그리고 항목마다 출처 공지 번호를 붙였다.

가장 걱정되는 시나리오를 먼저 정했다. 운영자가 기존 공지를 아무것도 고치지 않고 열어서 저장만 해도 서식이 바뀌는가. Quill은 HTML을 자기 문서 모델로 파싱했다가 다시 HTML로 뱉는다. 그 왕복에서 뭔가 유실되면, 운영자는 건드리지도 않은 공지가 망가지는 걸 보게 된다.

이 걱정에는 근거가 있었다. 바로 전날 삽입 이미지의 인라인 style 이 왕복에서 값만 날아가 style="" 로 남는 걸 봤기 때문이다.

전수 데이터에서 뽑은 항목은 이렇게 됐다.

# 패턴 출처
1 배경색·글자색을 입힌 span 으로 감싼 텍스트 오래된 공지 2건, 거의 모든 문단
2 span 안에 중첩된 <img> 공지 1건
3 <img>width·height 속성 공지 1건
4 프로토콜 상대 URL (src="//...") 공지 1건
5 다른 CDN 호스트 공지 1건
6 <a target="_blank"> 공지 1건
7 &nbsp; 공지 1건
8 zero-width space (이미지 뒤) 공지 1건
9 <strong>, <br> 반복, 빈 문단 거의 전부
10 위를 전부 담은 공지를 편집 없이 저장

10번이 이 표의 결론이다. 나머지는 10번을 위한 재료다.

상상으로는 절대 나오지 않았을 항목이 셋이다.

4번, 프로토콜 상대 URL. src="//호스트/경로" 표기다. 요즘은 거의 안 쓰는데 오래된 공지에 남아 있다. 이걸 모르고 URL 처리를 건드렸으면 그 공지의 이미지가 통째로 깨졌을 것이다.

5번, 다른 CDN 호스트. 우리가 쓰는 호스트는 하나라고 알고 있었다. 그런데 기존 공지에는 다른 호스트가 있었다. 만약 차단 로직을 “우리 호스트가 아니면 막는다”로 짰다면 — 충분히 그럴 뻔했다 — 여기서 터졌다.

8번, zero-width space. 눈에 보이지 않는 문자가 이미지 뒤에 붙어 있다. 이건 떠올릴 수가 없는 종류다. 데이터를 열어보지 않으면 존재 자체를 모른다.

그리고 뺀 것

데이터로 짜면 넣을 것만 정해지는 게 아니라 뺄 것도 정해진다.

QA 중에 옛 어드민으로 가는 우회로를 하나 찾았다. 주소에 특정 쿼리 파라미터를 붙이면 마이그레이션 이전 화면이 그대로 뜨고, 거기엔 우리가 막은 이미지 버튼이 살아 있었다. 즉 차단을 우회해서 base64를 계속 넣을 수 있었다.

처음엔 티켓을 따로 만들었다. 그리고 조사한 뒤 구현하지 않기로 확정했다.

  • 목록의 등록 버튼도, 상세의 수정 버튼도 그 파라미터를 넘기지 않는다. UI 동선으로는 도달할 수 없다. 주소창에 직접 쳐야 한다
  • 그 파라미터는 버그가 아니라 의도된 롤백 동선으로 문서화되어 있다
  • 해당 저장소는 참조용이고 신규 작업 금지이며 종료 예정이다
  • 단기 조치로 이미 긴급도가 떨어져 있었다

그래서 완료 기준을 “UI 동선 기준으로 판정한다”로 다시 쓰고, 주소창 직접 입력은 알려진 예외로 남겼다.

이 판단을 하려면 데이터가 필요했다. “이론상 뚫린다”만 보면 막아야 한다. “실제로 그 경로로 들어오는 사람이 있나”를 봐야 안 막기로 정할 수 있다.

정리하면. QA 항목을 상상으로 짜면 길어지면서 부정확해진다. 있을 법한 걸 나열하게 되고, 정작 실제로 있는 건 빠진다. 데이터로 짜면 짧아지면서 정확해진다. 열 줄이고, 열 줄 전부 실제로 존재하는 공지에서 나왔다. 그리고 안 해도 되는 일 하나를 확실하게 접었다.


7. 그리고 이 글을 쓰다가 하나 더 나왔다

앞 장을 쓰면서 좀 우쭐했던 것 같다. 데이터로 QA를 짰다는 얘기니까.

그 장에 넣을 숫자를 다시 뽑으려고 운영 공지 142건을 한 번 더 돌렸다. 그리고 내 커밋 메시지 하나가 틀렸다는 걸 알았다.

작업 막바지에 버그를 하나 고쳤다. 이미지만 있고 텍스트가 없는 공지가 저장되지 않았다. 빈 값 판정을 getText() 로만 하고 있었는데, 이 함수는 이미지 같은 임베드를 세지 않는다. 그래서 이미지만 넣으면 본문이 빈 문자열로 나가고 필수값 검증에 걸렸다. 이 코드는 원래부터 있었고, 그동안은 이미지 삽입이 막혀 있어서 드러나지 않았을 뿐이다.

고친 건 맞다. 이미지 한 장만 올리는 공지는 정상적인 사용 사례고 막을 이유가 없다. 문제는 내가 커밋 메시지에 적은 근거였다.

이미지만 있는 공지는 정상적인 사용 사례이고, 실제로 목록 API 를 비대하게 만들었던 운영 공지들이 그 형태였다.

앞 문장은 맞다. 뒷 문장이 틀렸다.

텍스트 없이 이미지만 있는 공지는 0건이다. base64든 아니든 이미지가 들어 있는 공지가 36건인데, 그 36건 전부 최소 200자 이상의 텍스트를 갖고 있다. 목록을 비대하게 만든 건 “이미지만 있는 공지”가 아니라 그냥 “이미지가 큰 공지”였다.

어떻게 이걸 썼는지는 안다. 그럴듯했기 때문이다. 응답을 부풀린 공지는 이미지가 크고, 이미지가 큰 공지는 이미지 위주일 것이다 — 말이 된다.

그리고 그 시점에 나는 그 데이터를 이미 갖고 있었다. 3번에서 받아 로컬에 두고 있었다. 6번에서 그걸로 QA 항목을 뽑기까지 했다. 같은 폴더에 있는 파일을, 한 번은 열고 한 번은 안 열었다.

이게 앞의 셋보다 나쁘다.

4-1과 4-2는 남이 오래전에 쓴 문장에서 출발했다. “낡았다”는 변명이 성립한다. 이건 내가 엿새 전에, 데이터를 손에 쥔 채로 쓴 문장이다. 낡아서 틀린 게 아니라 처음부터 틀렸다.

그리고 이걸 잡은 건 리뷰도 QA도 테스트도 아니다. 이 글을 쓰면서 숫자를 다시 뽑은 것이다. 글을 안 썼으면 저 문장은 저장소에 영구히 남았을 것이고, 다음 사람이 그걸 근거로 판단했을 것이다. 4-2에서 내가 당한 그대로.

일곱 번을 나란히 놓으면

  무엇을 확인했나 무엇을 확인했어야 했나
4-1 소스 코드의 CSS 빌드 결과의 computed style
4-2 QA 호스트의 응답 코드가 실제로 부르는 호스트
4-3 코드 리뷰 여섯 라운드 실행 중의 상태 변화
5-1 내가 넘긴 옵션 라이브러리의 기본값
5-2 정적인 코드 비동기가 끝나는 순간의 포커스
5-3 업로드 응답의 URL 그 URL의 캐시 헤더
7번 그럴듯한 추론 손에 이미 있던 데이터

일곱 번 다 확인했다. 일곱 번 다 대상이 틀렸다.

그리고 넷은 문장에서 출발했다. 주석이 검색 결과를 확증해줬고(4-1), 주석이 계획을 바꿨고(4-2), 주석이 두 판정이 같다고 말했고(4-3), 내 커밋 메시지가 확인하지 않은 것을 확인한 것처럼 적었다(7).

주석과 커밋 메시지는 컴파일되지 않고 테스트되지 않는다. 틀려도 아무것도 깨지지 않는다. 그런데 읽을 때의 확신은 코드보다 높다.

그리고 마지막 하나가 알려준 것. 이건 낡은 코드의 문제가 아니다. 오늘 내가 쓰는 문장도 똑같이 검증되지 않는다. 성실하게 쓴 틀린 문장은 대충 쓴 틀린 문장보다 오래 살아남는다. 잘 쓰여 있을수록 다음 사람이 더 믿는다.


8. AI가 한 일과, 하지 못한 일

이 작업은 처음부터 끝까지 AI와 같이 했다. 커밋에 공동 작성자로 남아 있고 숨길 생각도 없다.

못하는 것에는 형태가 있다

일곱 개를 나란히 놓으면 하나로 모인다. 전부 코드에 적혀 있지 않은 것이다.

종류 어디에 있나
빌드가 만들어내는 것 Preflight 규칙은 소스에 문자열로 존재하지 않는다
실행 중에만 있는 것 선택 영역이 지워지는 시점, 업로드가 끝나는 순간의 포커스
인프라가 정하는 것 캐시 헤더는 저장소에 없다
라이브러리의 기본값 내가 안 쓴 옵션은 내 코드에 안 보인다
내가 사실이라고 말한 것 어디에도 안 적혀 있다

앞의 넷은 “찾아볼 곳을 잘못 지정했다”는 얘기다. 고칠 수 있다.

마지막 하나가 다르다. AI는 내 전제를 의심하지 않는다. 내가 “이 데이터는 이런 형태다”라고 말하면 그 위에서 정확하게 추론하고, 그 전제를 잘 다듬어진 문장으로 저장소에 남긴다. 내 추측이 AI를 거치면 기록이 된다. 그리고 잘 쓰인 기록일수록 다음 사람이 더 믿는다.

이건 성능 문제가 아니라서, 모델이 좋아진다고 없어지지 않는다. 자료가 없는 곳에서는 누구도 답을 만들 수 없다.

그래서 뭘 시켰나

거꾸로, 잘 된 것들에도 공통점이 있다. 자료가 있는 곳으로 보냈을 때 잘 됐다. 재사용할 만한 건 네 가지다.

하나 — 넓게 읽히기. 사람이 하면 지루하고 실수가 나는 일. 유입 경로를 라이브러리 소스로 훑어 세 번째를 찾은 것, 옛 저장소와 왕복 대조해 “메이저 업그레이드로 새로 뚫렸다”를 가려낸 것, 조직 안에 이미 동작하는 참조 구현이 있다는 걸 찾아준 것. 마지막 건 설계를 통째로 아꼈다. “우리 조직에 이미 이걸 한 사람이 있나”는 코드를 쓰기 전에 물어야 하는데, 사람은 자주 잊고 AI는 안 잊는다.

둘 — 추론 대신 실행시키기. 5-1이 그랬다. “옵션 병합이 이렇게 될 것이다”로 끝내지 않고 그 라이브러리를 실제로 불러 결과를 찍었다. 문서를 읽는 것보다 빠르고, 문서에 없는 것까지 나온다 — 배열 꼬리가 남는 함정은 그렇게 걸렸다. 실행할 수 있는 질문을 실행 없이 답하게 두지 않는 것.

셋 — 관점을 쪼개서 교차시키기. 5-2가 그랬다. 증상만으로 원인 후보가 여럿일 때, 서로 다른 관점으로 독립 검토를 돌리고 겹치는 지점을 본다. 혼자서는 하기 힘든 종류의 일이다. 다만 교차 지점은 후보일 뿐 결론이 아니다. 넷이 같은 곳을 가리켰어도 확정은 브라우저에서 했다.

넷 — 재현되지 않는 것을 재현되게 만들기. 이게 제일 늦게 배웠다.

타이밍 버그는 평소에 안 걸린다. 조건이 맞아야 나오고, 그 조건이 네트워크 속도 같은 것이면 손으로는 못 맞춘다. 눌러보는 것만으로는 도달할 수 없는 구간이 있다.

그래서 브라우저를 조종해서 조건을 강제했다. 에디터 라이브러리 청크의 응답만 골라 8초 보류시킨 것이다. 그러자 평소엔 스쳐 지나가는 구간이 넓게 벌어졌고, 거기서 “제목은 이미 채워져 있는데 에디터는 아직 없는” 상태가 잡혔다. 그게 결정적인 증거가 됐다.

가끔 나는 버그를 항상 나는 버그로 바꾸는 것. 항상 나면 고칠 수 있고, 고쳤는지도 확인할 수 있다. 5번의 세 개는 눌러봐서 나왔지만, 이건 눌러봐서는 안 나온다.

넷의 공통점: AI를 판정자가 아니라 자료 수집자로 쓴다. 없는 자료는 만들어서라도 준다. 판정은 실행 결과가 한다.

그래서 어디서 끊었나

세 줄이면 된다.

하나. 확인한 대상이 판단 대상과 같은가. 호스트를 찌를 거면 코드가 어느 호스트를 부르는지 먼저 읽는다. 화면을 띄울 거면 그게 고객이 보는 라우트인지 먼저 본다. 4-1과 4-2가 여기서 갈렸다.

이 규칙을 한 번 써먹었다. QA 환경에 있는 공지 88건을 전부 열어 저장했을 때 서식이 바뀌는지 보려고 에디터를 로컬에 띄웠는데, 전수를 돌리기 전에 그 로컬이 진짜 어드민과 같은 결과를 내는지부터 맞췄다. 같은 공지에서 글자 수와 태그 수와 차이가 생기는 위치까지 정확히 일치하는 것을 확인하고 나서야 88건을 돌렸다. 4-1에서 로컬 재현에 속았기 때문이다. 재현 환경도 확인 대상이다.

둘. “없다”는 실행으로만 증명된다. 검색은 있다만 증명한다. 리뷰도 마찬가지다 — 여섯 라운드가 통과시킨 게 첫 클릭에서 깨졌다.

셋. 내가 준 전제도 근거다. AI는 그걸 의심하지 않는다. 내가 말한 사실은 내가 확인해야 한다. 7번이 그 값을 치렀다.

나머지 자잘한 것들은 마지막에 체크리스트로 모아뒀다. 그중에 새로 만들어야 하는 도구는 하나도 없다.


9. 남은 아쉬움 — API

여기까지 하고 나서 응답을 다시 쟀다.

base64는 한 톨도 남지 않았다. 본문에는 CDN URL만 들어간다. 그런데 목록 응답은 여전히 크다.

목록 응답이 주는 필드는 열세 개다. 번호, 유형, 제목, 본문, 시작일, 종료일, 첨부파일, 삭제 여부, 등록 시각, 수정 시각, 노출 여부, 팝업 여부, 상단 고정.

1번에서 적은 그 푸터 위젯이 쓰는 건 셋이다. 제목, 링크, 게시일.

나머지 열 개는 받아서 버린다. 그리고 버려지는 것 중 하나가 본문이고, 본문이 응답 용량의 99%다.

숫자를 나란히 놓으면 이렇게 된다.

  • 화면이 쓰는 필드: 3개
  • 응답이 주는 필드: 13개
  • 그중 한 필드가 차지하는 용량: 99%
  • 목록에서 본문을 렌더하는 곳: 없음

base64는 이 구조에서 증상이었다. 본문을 목록에 실어 보내지 않았다면, 이미지가 어떤 형태로 박혀 있든 목록 응답은 커지지 않는다. 애초에 커질 수가 없다.

내가 엿새 동안 티켓 네 장으로 고친 것은 증상이 심해지는 속도다. 원인을 고친 게 아니다.

진짜 고칠 곳은 명확하고, 어렵지도 않다. 목록 응답에서 본문을 빼면 된다. 그리고 이건 내가 새로 알아낸 것도 아니다. 첫 티켓의 「범위 밖」 문단에 처음부터 그렇게 적혀 있었다.

구조 개선 — 목록 API 를 제목·링크·게시일만 반환하도록 경량화. BE 리소스 필요해 별건.

“별건”이라고 적어놓고 엿새 동안 별건이 아닌 것들을 했다.

그건 내 손에 없다. 그래서 손에 있는 것들을 했고, 그 기록이 앞의 여덟 장이다.

도구를 기다리는 동안

이 글을 다 쓰고 나서 생각이 좀 바뀌었다.

작업하는 내내 “이걸 자동으로 잡아주는 게 있으면 좋겠다”고 생각했다. 배포 전에 화면을 대신 눌러보고, 바뀌면 안 되는 게 바뀌었는지 판정하고, 스펙대로 만들어졌는지 확인해주는 것. 요즘 여기저기서 만들고 있는 종류의 것이다.

그런데 일곱 개를 다시 보면, 그런 게 있었어도 대부분 못 잡았을 것 같다.

자동 판정에는 “무엇이 정답인가”를 적어둔 표가 있어야 한다. 이 화면의 이 요소는 무엇이고, 어디로 들어가고, 무엇을 성공으로 볼 것인지. 표가 비어 있으면 판정기는 그 빈자리를 추측으로 채운다. 추측이니까 돌릴 때마다 답이 달라진다. 어제 통과한 게 오늘 깨지고, 코드는 그대로다.

그리고 그 표를 채우는 일은 내가 6번에서 한 일과 다르지 않다. 운영 데이터를 열고, 실제로 있는 것만 적고, 없는 것은 빼는 것. 프로토콜 상대 URL이 아직 남아 있다는 것, 다른 호스트가 섞여 있다는 것, 이미지 뒤에 눈에 안 보이는 문자가 붙어 있다는 것 — 전부 데이터를 열어야 나온다. 상상으로는 한 줄도 안 나온다.

도구가 그 표를 대신 채워주지는 않는다. 표가 있어야 도구가 도는 것이지, 도구가 생기면 표가 따라오는 게 아니다.

그래서 순서가 있다고 생각한다. 확인 대상을 제대로 고르는 습관이 먼저고, 그 습관이 만든 자료가 다음이고, 그 자료 위에서 도는 도구가 마지막이다. 앞의 둘을 건너뛰고 셋째부터 만들면 도구는 돌지만 판정이 매번 달라진다. 그러면 아무도 그 판정을 믿고 배포하지 않는다. 돌아가는 것과 믿을 수 있는 것은 다르다.

이 순서가 뒤집히면 어떻게 되는지도 이 글 안에 있다. 4-2에서 주석 한 줄은 티켓 설명이 되고, 착수 조건이 되고, 라벨이 되고, 백엔드 요청이 됐다. 문서는 착착 진행됐고 코드는 한 줄도 안 나왔다. 그 며칠 동안 티켓 보드만 보면 일이 잘 굴러가는 것처럼 보였을 것이다.

그때 필요했던 건 승인이 아니라 curl 한 줄이었다.

전에 쓴 글에서 일정은 관리되는데 기술은 관리되지 않는다는 얘기를 했다. 여기서도 같은 모양이다. 관리되는 층과 값이 만들어지는 층이 다르고, 관리되는 쪽이 눈에 더 잘 띈다. 티켓은 상태가 바뀌고 라벨이 붙고 보드에서 칸을 옮겨 다니는데, 표를 채우는 일은 아무 상태도 만들지 않는다.

내 티켓 하나라서 며칠로 끝났다. 더 큰 판에서 같은 순서로 가면 며칠로는 안 끝날 것 같다.

이게 어려운 일이라는 건 이 글이 증명한다. 세 줄짜리 규칙을 정리하는 데 엿새가 걸렸고, 그러고도 일곱 번째는 글을 쓰다가 찾았다. 표를 채우는 건 화려하지도 않고 끝도 잘 안 난다. 그런데 이걸 건너뛸 방법은 아직 못 봤다.

이걸 자동으로 해주는 무언가를 기다리는 동안, 나는 그냥 이렇게 했다.


10. 그래서 내일 할 수 있는 것

큰 것 셋은 앞에 적었다. 한 줄씩만 다시.

  1. 확인한 대상이 판단 대상과 같은가
  2. “없다”는 실행으로만 증명된다
  3. 내가 준 전제도 근거다

나머지는 작은 습관들이다.

  • AI 요약을 받으면 출처를 묻는다 — 코드에서 확인한 건가, 주석에서 읽은 건가
  • 상태·순서가 걸린 코드는 화면에서 한 번 누른다
  • 같은 자리를 두 번 고치면 판정의 모양을 의심한다
  • 라이브러리 동작은 추론하지 말고 실행해본다
  • QA 항목은 상상이 아니라 운영 데이터에서 뽑는다 — 뺄 것도 같이 정해진다
  • “우리 조직에 이미 이걸 한 사람이 있나” 를 코드 쓰기 전에 묻는다
  • 커밋 메시지에 왜를 적고, 거기 사실을 적을 때는 그 사실을 먼저 연다

전부 오늘 할 수 있는 것들이다. 도입할 것도, 승인받을 것도 없다.

마지막 줄은 이 글을 쓰다가 추가됐다. 그러니까 이 목록은 아직 안 끝났다.


그리고 여덟 번째

목록까지 닫고 나서 하나가 더 왔다.

7번에서 일곱 개를 표로 만들었다. 각 줄은 “확인 대상을 잘못 골랐다”는 얘기였다. 그런데 그 표를 한 칸 위에서 다시 보면 줄이 하나 더 있다.

  무엇을 확인했나 무엇을 확인했어야 했나
8 에디터를 어떻게 고칠까 이걸 에디터에서 고치는 게 맞나

나는 엿새 동안 에디터를 정확하게 확인했다. 유입 경로 세 개를 찾았고, 옛 버전과 대조했고, 운영 데이터를 전수로 열었고, 가드를 여섯 번 고쳤고, QA에서 세 개를 더 잡았다. 하나하나 필요한 일이었고, 다 맞게 했다.

그리고 9번에 적은 대로, 이 전부는 목록 응답에서 필드 하나를 빼면 일어나지 않았을 일이다.

AI와 함께 엿새를 썼다. 부지런히, 꼼꼼하게, 일곱 번의 오답을 거쳐서. 그런데 문제는 응답 필드 하나였다.

이게 제일 아픈 대목인데, 동시에 이 글에서 제일 쓸모 있는 규칙이기도 하다.

“이 문제를 여기서 푸는 게 맞나”를 먼저 묻는다.

앞의 일곱 개는 확인 대상을 한 단계 안에서 잘못 골랐다. 여덟 번째는 판을 잘못 골랐다. 그리고 판이 틀리면 그 안에서 아무리 정확하게 일해도 정확하게 틀린 곳에 도착한다. AI와 함께라면 더 빠르게 도착한다.

첫 문단에 적은 문장이 한 층 위에서 그대로 반복된 셈이다.

물론 그 필드는 내가 못 뺀다. 처음부터 알았더라도 안에서 일했을 것이다. 그건 나쁜 선택이 아니라고 생각한다 — 다만 알고 하는 것과 모르고 하는 것은 다르다. 엿새 중 닷새는 몰랐다.

다음에 같은 자리에 서면 여덟 번째를 먼저 묻겠다. 답이 같더라도.


덧 — 올리기 전에

다 쓰고 나서 QA를 한 바퀴 더 돌렸다. 두 개가 더 나왔다.

하나는 첨부파일에도 같은 덮어쓰기 문제가 있었다는 것. 5-3을 고칠 때 본문 이미지만 봤다. 같은 결함이 옆 기능에도 있는지는 안 물었다.

다른 하나가 아프다.

기존 공지를 열었는데 본문이 비어 보이는 경우가 있었다. 에디터 라이브러리 청크가 상세 API 응답보다 늦게 도착하면, 초기화 콜백이 마운트 시점의 빈 값을 붙잡은 채로 에디터를 만든다. 값을 다시 넣어주는 쪽은 그 사이 이미 실행됐는데, 그때는 에디터가 아직 없어서 아무것도 못 하고 지나갔다. 그리고 다시 오지 않는다.

그 상태에서 운영자가 본문을 새로 쓰고 저장하면, 원래 내용이 사라진다.

이건 8번에서 말한 방법으로 잡았다. 청크 응답만 8초 보류시켜 경쟁을 강제했더니, 평소엔 순식간에 지나갈 구간이 벌어지면서 “제목은 채워져 있는데 에디터는 아직 없는” 상태가 그대로 붙잡혔다.

그리고 제일 아픈 대목. 린터가 그 자리를 이미 가리키고 있었다. 의존성이 불완전하다는 경고가 계속 떠 있었다. 다만 info 등급이었고, 통과되는 등급이라 아무도 읽지 않았다. 고치고 나니 그 경고가 사라졌다.

주석이 그랬고(4-2), 리뷰 여섯 라운드가 그랬고(4-3), 이번엔 린터였다.

신호는 있었다. 등급이 낮았을 뿐이다.

이 목록은 아직 안 끝났다고 적은 게 오늘 오전이다.