본문으로 건너뛰기

Pointly 05. 데모 사이트 제거와 cross-origin iframe 캔버스 전환

·37 min read·5 / 7

Pointly는 개발자가 아닌 사이트 오너가 바꾸고 싶은 부분을 클릭하고, 원하는 결과를 말로 적고, 미리 보고, 배포하는 도구다. 이 저장소에서 가장 큰 하루였다. 편집기가 보여주던 데모 사이트를 지우고, 그 자리에 오너의 진짜 파일을 넣었다.

사이트의 진실을 블록 데이터에서 코드 파일로 옮겼다

MVP를 "오너가 자기 사이트를 만들고 고치고 게시하고 호스팅되는 것"으로 놓고 설계서를 썼다. 여섯 가지를 정했는데, 앞의 두 가지가 나머지를 다 정했다.

제품은 빌더가 아니라 바이브코딩이다. 사이트의 진실은 블록 데이터가 아니라 코드 파일이다. 그래서 BLOCK_KINDS, BlockRecord, SiteRecord.pages가 죽었다. 조립할 것이 없어졌기 때문이다.

사이트는 빌드 없는 정적 파일이다. 빌드를 얹으면 "게시했더니 흰 화면"이라는, 오너가 스스로 복구할 수 없는 실패가 하나 늘어난다.

남은 네 가지는 이렇게 정했다. 캔버스는 cross-origin iframe이고 조작은 사이트 안 브릿지가 한다. AI는 편집 표식을 지킨다. 코드는 console·host·site-bridge·templates·data로 가른다. 호스팅은 서브도메인을 쓴다. *.localhost가 dev와 운영을 같은 코드로 만들기 때문이다.

"정적이면 요즘 프레임워크의 이점을 못 받는 것 아니냐"

같은 물음이 다시 올 것이므로 답을 문서에 남겼다.

  • 요즘 감각은 프레임워크가 아니라 CSS가 만든다. :has()와 컨테이너 반응형은 이미 전 브라우저에서 되고, 스크롤 애니메이션과 페이지 전환은 지원 브라우저에서 된다. React가 해 주는 것은 없다.
  • 이점을 누가 받는가로 가르면, 유저에게 닿는 세 가지 중 두 가지는 프레임워크 없이 되고 하나는 MVP 밖이다. SEO와 성능은 정적이 이긴다.
  • 진짜 약점은 공통 레이아웃 중복이다. 페이지가 다섯이면 헤더가 다섯 벌이고, AI가 셋만 고치고 둘을 빠뜨리는 실패가 생긴다. 비용을 내는 쪽은 유저가 아니라 AI다.
  • 탈출구는 Next가 아니라 Astro다. 출력이 기본값으로 JS 0이고(아일랜드 방식이라 인터랙티브 컴포넌트만 골라서 하이드레이트한다), SEO 반대 논거가 통하지 않는 유일한 후보다.

장점만 적은 문서는 다음에 같은 논쟁을 다시 하게 만든다. 약점을 적어야 답이 된다.

남의 빌드 산출물 위에 세우지 않는다

@pointly/editor-core를 tgz로 벤더해 쓰고 있었다. dist 전체가 121줄인데, DOM 유틸 하나 때문에 외부 의존을 지고 있었다.

벤더된 것은 소스가 아니라 빌드 결과물이라 변수명이 d, p, m, b로 뭉개져 있었다. 읽을 수도, 고칠 수도, 이 저장소의 주석 밀도로 설명할 수도 없었다.

src/editor/dom.ts로 들여오면서 결과물을 붙여넣지 않고 소스로 다시 썼다. 판정의 근거를 주석에 남겼다. 시맨틱 태그를 먼저 믿는 이유, main을 뒤로 미루는 이유, 페이지 높이 80%를 블록으로 보지 않는 이유, CSS 경로를 8단계에서 끊는 이유. 테스트 19건도 새로 붙였다. 벤더된 동안에는 이것들의 테스트가 이 저장소에 없었다.

지금 들여오는 이유는 브릿지가 이 프리미티브 위에 서기 때문이다. 브릿지를 짜다 이 네 가지를 고쳐야 할 때 고칠 수 있어야 한다.

사이트가 디스크의 파일이 되기까지

이 단계를 S1a로 잡았다. 사이트 하나가 디스크의 디렉터리 하나가 되고, 콘솔이 그 디렉터리를 만들고 지우는 데까지다.

경계는 디렉터리보다 먼저 세운다

게시 서버 apps/host/는 뒤 단계인 S3에서 만든다. 그런데 ESLint 경계 규칙은 지금 세웠다.

규칙을 그때 같이 쓰면, 첫 파일을 쓰는 사람이 아직 규칙이 없는 상태에서 @/auth/session을 한 줄 가져오고 그것이 통과한다. 게시 서버는 공개고 인증이 없으므로 그 한 줄로 세션·쿠키·AI 호출 코드가 공개 번들에 실려 나간다. 되돌릴 수 없는 종류의 실수다.

디렉터리가 없는 동안 이 블록은 아무 파일에도 걸리지 않는다. 비용 0에 보험 하나.

그리고 console을 apps/console로 내렸다. 동작은 한 줄도 안 바뀌는 이동인데, 조용히 깨진 두 곳이 딸려 나왔다.

  • .npmrc@types/* 루트 호이스트. @phosphor-icons/react@types/react를 peer로 선언하지 않아서, 앱이 내려가 루트 자리가 비면 아이콘의 className이 통째로 사라진다.
  • eslint.config.jsprojectService.defaultProject. 지정하지 않으면 node:path와 vite가 해석 불가가 되어 no-unsafe-* 규칙 위반이 262건 쏟아진다.

이동은 안전하다고 생각했는데 아니었다.

사이트 하나에 디렉터리 하나

data/sites/{id}/ 안에 게시될 결과물인 public/과 서버만 읽는 요약인 site.json을 둔다.

경로 계산부터 순수 함수로 떼어냈다. id는 URL에서 오는 사용자 입력이고, ..가 하나 새면 data/ 밖의 파일을 읽고 쓴다. 경로 계산을 디스크를 만지는 코드 안에 섞어 두면 그 검증을 테스트할 방법이 없다.

site.jsonpublic/을 가른 이유도 미리 적었다. 게시는 public/을 통째로 복사하는 일이어야 하고, 한 디렉터리에 섞여 있으면 복사할 때마다 무엇을 빼야 하는지 기억해야 한다.

템플릿은 코드가 아니라 데이터로 뒀다. templates/{id}/를 빌드하지 않고 그대로 복사하고, 디렉터리 이름이 곧 PresetId라 매핑 테이블을 두지 않는다. 표를 두면 프리셋을 더할 때 고칠 곳이 둘이 된다.

미들웨어에서는 테스트 범위를 명시적으로 골랐다. 테스트는 디스크를 만지는 함수에만 붙인다. 미들웨어는 배선이고, 배선이 맞는지는 테스트가 아니라 실제 요청이 답한다. curl로 여섯 갈래를 확인했다.

저장소를 쿠키에서 디스크로 옮겼는데, 클라이언트 store.ts의 시그니처가 한 줄도 안 바뀌었다. 처음부터 비동기로 둔 이유가 그것이었다.

블록 어휘는 걷어냈지만 섹션 이름은 남겼다. 렌더의 근거가 아니라 설명용 데이터로서다. 타입이 닫힌 유니온에서 Record<string, string>이 되면서, 타입이 지켜주던 것을 테스트가 대신 지킨다. "프리셋이 쓰는 모든 이름은 BLOCK_LABELS에 있다"는 테스트다.

데모 사이트를 정적 HTML로 옮겼다

DemoSite.tsx를 기본 문구, 기본 디자인으로 렌더한 결과를 templates/studio/로 옮겼다.

손으로 옮겨 쓰지 않았다. data-copy 경로가 하나라도 틀리면 그 문구의 편집이 조용히 거부되므로, 실제 렌더 결과에서 기계로 옮겼다.

대조 결과 문구 106건이 경로·텍스트·태그까지 전부 일치했고, 표식은 data-copy 하나만 늘었다. <title>도 편집 대상이어야 하니까. CSS는 lightningcss(Rust로 쓰인 CSS 파서 겸 트랜스포머)로 규칙 단위 대조해 선언이 다른 규칙 0건을 확인했다.

옮기면서 두 가지를 정했다.

  • 반응형을 클래스에서 미디어쿼리로 바꿨다. 게시본은 진짜 뷰포트를 쓴다. 모바일 블록은 태블릿 뒤에 둔다. 클래스였을 때는 둘 중 하나만 붙었지만 미디어쿼리는 719px 이하에서 둘 다 참이다.
  • 디자인 기본값은 CSS에 둔다. 루트의 인라인 style을 걷어냈다. 지금은 값이 같아 차이가 없지만, 두 곳에 두면 인라인이 이긴다. 나중에 "배경을 어둡게"가 style.css를 고쳤을 때 아무 일도 일어나지 않는 상태가 된다.

빈 문서 템플릿에서는 반대 방향의 고민을 했다. 비어 있지만 문법적으로 완전한 문서여야 한다. AI가 첫 편집에서 문서 뼈대부터 세우게 하면 매번 다르게 세운다.

빈 종이가 못생기면 오너는 제품이 못생겼다고 읽는다. "아직 아무것도 안 만들었으니 당연하다"고 생각해주지 않는다. 웹폰트는 부르지 않았다. 빈 문서는 아직 성격이 없고, 서체를 고르면 그 결정이 이미 내려진 것처럼 보인다.

만들기 전에 무엇이 깔리는지 보여준다

생성 흐름을 폼, 요약, 만들기로 나눴다.

템플릿을 고르는 것은 파일이 깔리는 결정이다. 보여주지 않고 만들면 오너는 만들고 나서야 자기가 무엇을 골랐는지 알고, 되돌리려면 지우고 다시 만들어야 한다. blank만 예외다. 보여줄 레이아웃이 없으면 요약본이 빈 화면이 된다.

단계 분기를 컴포넌트 useState가 아니라 순수 함수로 뒀다. 특히 creating에서 더 나아가지 않는다는 것은 두 번 눌러 사이트가 두 개 생기는 것을 막는 계약이고, 클릭으로만 확인하기에는 너무 조용히 깨진다.

요약본의 와이어프레임은 새로 만들지 않고 생성 화면 카드와 같은 그림을 쓴다. 고를 때 본 것과 확인할 때 보는 것이 다르면 무엇을 확인하는 것인지 알 수 없다.

여기서 테스트 오탐 하나를 찾았다. 버튼을 부분 일치로 찾고 있었는데 프리셋 카드의 라벨과 부제가 이어 붙어 "…에이전시작업물…"이 되면서 /시작/이 엉뚱한 카드에 걸렸다. 요약본이 없어도 통과하는 테스트가 둘 있었다.

캔버스를 iframe으로 갈아 끼웠다

S1b를 둘로 쪼갠 첫 조각이 S1b-1이다. 캔버스가 렌더하던 React 데모를 진짜 사이트 iframe으로 바꾸는 것까지다.

주고받을 말부터 굳혔다

protocol.ts를 먼저 썼다. DOM도 React도 없는 상태에서. 이 프로토콜이 양쪽이 공유하는 유일한 것이라 여기가 틀리면 나머지가 전부 틀린다.

이 저장소의 규칙 하나가 여기서 생겼다. 검증을 타입이 아니라 함수로 둔다. 사이트 프레임 안에서는 AI가 쓴 스크립트도 돌고 postMessage는 어느 창에서든 아무 값이나 보낸다. 타입은 컴파일하면 사라지므로 런타임에 아무것도 막지 못한다.

거르는 것 중 두 가지는 이유가 특별하다.

  • NaNInfinity를 거른다. 그 값이 오버레이 style로 들어가면 박스가 에러 없이 사라진다. 조용히 사라지는 것이 가장 찾기 어려운 실패다.
  • source 표식을 양쪽에 두고 값을 다르게 한다. 표식이 없으면 남의 메시지를 우리 것으로 읽고, 값이 같으면 방향이 섞인다.

두 origin을 Host로 가른다

flowchart LR
    A["요청 Host 판별"] --> B["localhost:4317 → 콘솔 SPA, 그대로 흘려보냄"]
    A --> C["{siteId}.localhost:4317 → data/sites/{siteId}/public/ 서빙 + 브릿지 주입"]

Host로 가르면 콘솔의 세션 쿠키가 localhost에 host-only로 붙는다. Domain 속성이 없는 쿠키는 정확히 그 호스트에만 전송되므로 {siteId}.localhost로는 넘어가지 않는다. cross-origin을 고른 이유가 이 격리이고, 설정 없이 성립한다.

주입은 서빙 시점에만 한다. 디스크의 index.html에 쓰면 게시본에 편집기 스크립트가 실려 나간다.

injectBridge</head>를 전제하지 않는다. 이 HTML은 AI가 쓴다. </head>를 전제하면 없는 날 브릿지가 조용히 안 붙고, 편집기가 아무것도 짚지 못하는데 이유가 화면에 보이지 않는다.

curl로 확인한 것 중 마지막 줄이 중요했다. 경로 탈출 403이 세 건, 없는 파일 404, guarded가 잡은 것 0건. 모든 실패가 의도한 4xx로 나갔고 그물에 걸린 것이 없다.

origin을 실제로 조인다

브릿지를 처음 붙였을 때는 사이트가 부모 origin을 알 방법이 없어 표식으로만 가르고 targetOrigin'*'로 뒀다. 그 경로를 만들 자리가 여기였다.

콘솔이 ?parent=로 자기 origin을 실어 보내고, 브릿지가 그 값으로 event.origin을 검사한다. 표식 검증은 그 위에 남는다. origin만 보면 콘솔 안의 다른 스크립트가 통과하고, 표식만 보면 아무 창이나 흉내낼 수 있다.

new URL(value).origin === value로 글자까지 대조한다. 느슨하게 받으면 값에 경로나 후행 슬래시가 섞여도 postMessage는 그대로 나간다. targetOrigin은 URL 파서를 거쳐 origin으로 치환되기 때문이다. 대신 event.origin과의 등호 비교가 어긋나 채널이 붙지 않는다.

쿼리가 없으면 '*'로 떨어지지 않고 아예 붙지 않는다. 그러면 조인 것이 아니다. 그리고 이것은 조용한 죽음이 아니다. 콘솔이 "연결 중"에 머무르는 것이 화면에 드러난다.

콘솔이 사이트에 대해 아는 전부가 훅 하나다

높이, 지금 가리키는 사각형, 지금 선택된 것. 요소 참조를 하나도 들고 있지 않다. 그것이 이 슬라이스의 성공 조건이다.

cross-origin iframe은 origin이 다르면 DOM에 직접 손대는 길이 막히고 postMessage로만 통신한다. 그래서 보안 계약을 세 겹으로 갈랐다. origin(브라우저가 채우는 값이라 위조할 수 없다), source(우리 프레임의 contentWindow인가), 모양(isBridgeEvent). 셋을 각각 제거하는 돌연변이로 검사마다 정확히 1건씩 죽는 것을 확인했다.

훅의 자리도 여기서 정해졌다. 계획서는 src/editor/를 지정했지만 거기는 엔진 스코프라 React를 값으로 import할 수 없다. 그 제약이 언제든 패키지로 떼어낼 수 있다는 보증이다.

iframe 컴포넌트에는 sandbox를 두지 않기로 했다. cross-origin이 이미 격리를 하고 있고, sandbox는 반대로 사이트 자기 스크립트를 막는다. 브릿지가 그 스크립트다.

srclocation.host를 그대로 쓴다. dev 서버가 4317을 못 잡으면 4318로 올라가고(검증 중에 실제로 그랬다), 그때 주소가 틀리면 프레임이 빈 채로 남는다.

갈아 끼우고 나서 드릴다운이 사라졌다

캔버스가 DemoSite를 렌더하는 대신 {siteId}.localhost를 iframe으로 띄운다. 캔버스에서 DOM을 직접 만지던 접점을 다 걷어냈다.

DOM 접점캔버스에 남은 곳
querySelector0
getBoundingClientRect0
MutationObserver0
ResizeObserver0

코드 줄 수는 1239에서 996으로 줄었다. 캔버스 DOM 접점 22곳을 표로 하나씩 적고 각각 지움·옮김·끔으로 처분했다.

이중 모드를 만들지 않았다. 계획서가 절반만 옮겨진 상태가 가장 위험하다고 한 것이 그것이다.

꺼진 것도 조용히가 아니라 이유를 말하게 했다. 자리 그리기는 토스트로("사이트 프레임으로 옮기는 중이에요"), 문구 편집과 AI 편집은 refuse()로. dev-ai.tssrc/site/를 겨누는데 그 파일은 더 이상 캔버스에 없다. 성공했다고 말하고 화면이 그대로인 것이 가장 나쁜 실패다.

그리고 세 가지 간극을 미결로 올렸다. 드릴다운 없음, "페이지 전체"가 즉시 적용되지 않음, measure가 선택을 덮어씀.

드릴다운이 사라진 것을 사용자가 바로 알아챘다. 정상으로 넘길 수 있는 손실이 아니었다.

브릿지에 상태를 주지 않는다. 그 상태를 브릿지에도 두면 콘솔과 어긋난 순간 화면이 거짓말을 하고 어느 쪽이 진실인지 알 방법이 없다. 그래서 클릭 한 번에 판정 재료를 다 보내고(조상 사슬 전체), 깊이는 콘솔이 고른다. blockIndex만 브릿지가 정한다. pickBlock의 마지막 규칙이 실제 레이아웃 높이를 읽는데 콘솔은 사각형만 받으므로 재현할 수 없다.

방어값은 바깥쪽에 뒀다. 넓게 잡히면 "왜 이게 잡혔지"로 끝나지만, 좁게 잡히면 짚지도 않은 <span>이 잡혀 무엇을 고른 것인지 알 수 없다.

resolveBlock(체험 랜딩용, DOM 기반)과 pickFromChain(데이터 기반)이 갈라지지 않았는지 규칙을 한 줄씩 표로 대조했다. 세 가지 간극이 함께 닫혔다.

여기서도 틀린 전제로 통과하던 테스트를 하나 찾았다. protocol.test.ts가 모양 틀린 selection을 type: 'selected'로 검사했는데, selected가 사라져 "모르는 type"으로 거부되면서 통과하고 있었다. selection 검증을 전혀 보지 않게 된 것이다.

1440px 프레임이 한 화면에 안 들어갔다

사용자가 한 화면에 안 보인다고 했다. 데스크톱 프레임 1440px이 캔버스 약 956px에 안 들어간다.

그 전에 데스크톱 뷰가 데스크톱 폭을 갖고 있지 않았다. DEVICE_CANVAS_WIDTH.desktop'100%'였다. 편집기가 클래스로 반응형을 켜던 시절에는 맞는 값이었는데, 그 전제가 두 번에 걸쳐 사라졌다. 템플릿 반응형이 미디어쿼리가 되고, 캔버스가 iframe이 되면서. 1440px로 고정하고 테스트를 계약으로 바꿨다. 셋 다 고정 px인가, 순서가 맞는가, 태블릿과 모바일이 사이트 중단점 안쪽인가.

배율을 어디에 거는가

site-frame-shelltransform: scale(s)transform-origin: top left를 걸었다. 오버레이와 iframe이 그 셸 안에 함께 있으므로 둘이 같이 축소되고, 브릿지가 보내는 문서 좌표를 콘솔이 그대로 쓴다. 오버레이 좌표에 배율을 곱하는 코드가 한 줄도 없다.

테스트가 단정하는 것은 배율 1, 0.75, 0.5, 0.25에서 style.left가 전부 '120px'이라는 것이다. 배율을 곱한 값이 아니다. 좌표에 배율을 곱하는 틀린 구현을 심으면 그 테스트가 죽는 것도 확인했다.

이름 칩만 배율을 되돌린다. 사각형은 프레임과 같이 작아지는 것이 맞지만 글자는 아니다. 66%면 11px이 7px이 되어 읽히지 않는다.

그리고 배율을 화면에 보여준다. 없으면 오너는 "사이트 글씨가 왜 이렇게 작지"로 읽는다. 축소된 보기라는 사실이 화면에 없으면 그것은 사이트의 문제로 보인다.

오너가 고르게 한다

맞춤, 50%, 75%, 100%. 기계장치는 이미 서 있었고 바뀐 것은 s를 누가 정하느냐뿐이라, 자동 계산이 "맞춤" 한 갈래로 들어갔다.

명시값은 캔버스 폭에 흔들리지 않는다. 75%를 골랐는데 창을 줄였다고 배율이 바뀌면 고른 것이 무슨 뜻인지 사라진다.

viewport.ts 재사용도 하나씩 판단했다. clampZoom은 쓰고, fitViewTransformTransform은 안 쓴다. 그쪽은 2축 pan/zoom이라 항상 0인 x/y를 들고 다니게 되고, 그 죽은 필드는 언젠가 누가 채워 넣는다.

리플로우 폭풍과 스크롤바 피드백 루프

두 증상의 원인이 서로 달랐고, 네 가지를 고쳐야 둘이 함께 멎었다.

transition-[width] duration-350이 있었다. 프레임이 같은 document의 div였을 때는 사실상 공짜였는데, iframe 폭은 진짜 브라우저 뷰포트라 애니메이션하면 350ms 동안 매 프레임 미디어쿼리가 재평가되고 문서 전체가 리플로우된다. 60fps면 한 번의 뷰포트 전환에 약 20회다. 같은 한 줄인데 문맥이 바뀌면서 비용이 달라졌다. 되돌리지 않게 주석과 테스트로 못박았다.

나머지는 스크롤바 피드백 루프였다. 측정 대상이 측정 결과에 영향받고 있었다.

flowchart LR
    A["콘텐츠가 딱 맞음"] --> B["스크롤바 등장"]
    B --> C["contentRect 약 15px 감소"]
    C --> D["배율 축소"]
    D --> E["콘텐츠 좁아짐"]
    E --> F["스크롤바 사라짐"]
    F --> G["contentRect 복구"]
    G --> H["배율 확대"]
    H --> A

네 가지를 겹쳤다. contentRect 대신 borderBoxSize(스크롤바는 content box를 줄이지만 border box는 건드리지 않는다), scrollbar-gutter: stable(스크롤바가 들어갈 자리를 미리 비워 두는 선언), 여유 24px과 내림, rAF 병합.

내림을 고른 이유는 이렇다. 올리면 콘텐츠가 가용 폭을 넘어 가로 스크롤바가 생기고, 그것이 같은 루프의 방아쇠다. 남는 쪽으로만 틀린다.

하지 않은 것도 적었다. 캔버스 서브트리 분리는 하지 않았다. 원인이 이미 사라졌고, 그 리팩터는 효과를 측정할 수 없다. 원인이 사라진 문제를 위해 검증 불가능한 리팩터를 하는 것은 근거 없이 위험만 더한다.

못 덮은 것도 적었다. 피드백 루프 자체는 테스트로 재현하지 못했다. jsdom은 레이아웃을 하지 않아 스크롤바가 없고, 스크롤바가 없으면 방아쇠가 없다. 대신 루프를 막는 계약을 덮었다.

확대를 넣었는데 화면이 그대로였다

150%와 200%를 목록에 더했는데 화면은 그대로였다. 배율을 거는 자리에 조건이 남아 있었다.

style={scale < 1 ? { transform: `scale(${scale})`, transformOrigin: "top left" } : undefined}

scale < 1은 축소만 있던 시절의 최적화다. 확대가 들어오면서 전제가 깨졌는데 조건은 그대로였다.

증상이 조용한 거짓말이었다. 배지는 200%라 말하고, 래퍼는 자리를 두 배로 잡는데, 셸에는 transform이 붙지 않는다. 세 곳이 서로 다른 것을 믿고 있었다.

왜 테스트가 잡지 못했는지가 중요했다. fit.ts는 단위 테스트가 촘촘하지만 그 값을 style에 꽂는 자리는 App.tsx이고, 저장소는 결정으로 App.tsx를 렌더하지 않는다. 계산은 덮여 있고 적용은 덮여 있지 않았다.

그래서 배율을 거는 두 겹을 CanvasScaler로 뗐다. EditorBreadcrumb을 뗀 것과 같은 이유다. 사이트가 <div>라 눌리지 않았고, App.tsx 안에 있는 동안은 그 결함을 테스트로 막을 방법이 없었다.

초록불이 아무것도 보증하지 않게 두지 않는다

테스트 위생 문제를 네 가지 고쳤고, 결론은 다 달랐다.

흔들리는 테스트는 시계를 돌려 없앴다. updatedAt이 밀리초까지만 적어서 연속 생성 시 동점이 됐다. 재어 보니 연속 생성 40쌍 중 26쌍이 같은 밀리초였다. 동점이 예외가 아니라 정상이었고, 그동안은 readdirSync가 우연히 맞는 순서를 주고 있었다. 흔들리는 테스트는 없는 테스트보다 나쁘다.

재현하지 못한 것은 고치지 않았다. 34회 중 1회 실패한 인증 가드 테스트를 26회 더 돌렸는데 전부 통과했다. 재현하지 못한 원인을 추측으로 고치면 고치지 않은 것을 고친 척하게 된다.

대신 테스트가 전제를 먼저 단정하게 했다. 그때까지는 도착 경로만 봐서, 실패했을 때 쿠키가 기대와 달랐는지와 가드가 엉뚱한 곳으로 보냈는지가 구별되지 않았다. 한 번 터진 정보가 그대로 버려지고 있었다.

대상과 검증이 갈라진 테스트를 맞췄다. 랜딩 테스트가 대상은 live 사이트에서, 검증은 동결 사본에서 하고 있었다. 무해한 상태가 아니었다. live에 문구를 하나 더하면 랜딩 테스트가 2건 빨개진다.

포맷 부채 일곱 개를 정리했다. format:check가 저장소에 온 시점부터 일곱 파일에서 실패해 왔다. 실패하는 검사는 아무도 보지 않게 되고, 그러면 새로 들어오는 포맷 어긋남도 이 잡음에 섞여 들어온다. 저장소 전체에 --write를 돌리지 않고 실패하던 일곱 개에만 돌렸다. 다른 작업이 열려 있는 파일이 함께 재포맷되면 그쪽 diff에 남의 변경이 섞인다.

빨간불이던 테스트가 옳았다

그린 자리가 아니라 놓일 자리를 그린다. 흐름 삽입은 형제들 사이를 비집고 들어가므로 대개 새 줄이 된다. 튀는 것은 한 번 보는 놀람이고, 어긋나는 것은 매번 겪는 배신이다.

아이콘만 든 버튼이 삽입 컨테이너로 잡혔다. deepestContainer가 잎을 children.length로 판정하는데 eligibleChildren은 인라인 SVG를 뺀다. 자식이 <svg> 하나뿐인 버튼은 전체 1, 적격 0이 되어 걸러지지 않고 컨테이너가 됐다. 확정되는 지시가 "버튼 안 0번째 앞에 전폭으로 넣어라"가 된다. 그런 자리는 없다.

재현 테스트를 축약 픽스처로 만들지 않았다. 축약 픽스처가 현실적인 마크업 모양을 갖지 않으면 같은 종류의 결함이 다시 들어와도 잡히지 않는다.

체험 랜딩에는 제품 구멍이 하나 있었다. 히어로 표식 판을 세 번 눌러 들어가면 commandsFor가 아무것도 돌려주지 않았다. copyVariants에 art로 시작하는 세 줄은 있고 mark로 시작하는 세 줄은 없었다. 표식 판이 나중에 더해지면서 마크업만 따라가고 표를 못 받은 것이다.

copyVariants 바로 위 주석이 이 상태를 미리 적어 두었다. "표가 없으면 선택은 되는데 할 수 있는 게 하나도 없는 자리가 된다." 그 주석이 말한 자리가 실제로 하나 생겨 있었다.

빨간불이던 테스트가 옳았다. 고친 곳은 판정 로직이 아니라 데이터였고, 그날 처음으로 전체가 초록이 됐다.

이날 한 일을 관통하는 것을 하나 고르면 "조용히 틀리는 것을 소리 나게 만든다"였다.

  • 확대가 안 되는데 배지는 200%라고 말하고 있었다
  • 성공했다고 말하고 화면은 그대로인 편집이 가능했다
  • NaN이 오버레이로 들어가면 박스가 에러 없이 사라진다
  • 브릿지가 안 붙어도 편집기는 그냥 고장난 것처럼 보였다
  • 테스트 둘은 틀린 전제로 통과하고 있었다
  • 포맷 검사는 처음부터 실패해서 아무도 안 보고 있었다

조용한 실패는 두 번 비싸다. 한 번은 그것이 일어났을 때, 또 한 번은 그게 일어났다는 걸 아무도 모르는 동안 그 위에 코드가 쌓일 때.

그래서 이날 고친 것의 상당수가 실제로는 같은 일을 했다. 결과를 바꾼 게 아니라, 결과가 틀렸을 때 그것이 드러나게 만든 것이다.