---
title: '<em>외부 SDK</em>를 뜯어서 내 맘대로 다시 만들기: 단, 로직은 한 줄도 건드리지 않고'
tags:
  - bundler
  - tree-shaking
  - testing
  - sdk
  - frontend
published: true
date: 2026-08-31 14:00:00
description: '상수 하나를 import 했는데 번들의 97.7%가 따라왔다. 벤더는 고칠 일정이 미정이라기에, 배포된 소스맵을 뜯어 400개가 넘는 TypeScript 파일을 되찾고 빌드와 엔트리와 의존성을 내 맘대로 갈아엎었다. 로직만 빼고. 그래서 /send가 raw -77.5%. 그런데 어려운 건 그다음이었다. 테스트 1932개가 전부 통과했는데, 그중 몇 개는 아무것도 보고 있지 않았다.'
thumbnail: /thumbnails/2026/08/rebuilding-a-vendor-sdk.png
---

## Table of Contents

## 상수 하나에 375KB

실험 플랫폼 SaaS의 웹 SDK를 쓰고 있었다. A/B 테스트와 피처 플래그, 이벤트 수집을 한 패키지가 담당하는 종류의 물건이다. 어느 날 번들을 들여다보다가, 열거형 상수 하나를 import 한 파일이 이상하게 무겁다는 것을 알았다. 확인차 최소 케이스를 만들어 재 봤다.

```ts
// 이게 전부다. createInstance 를 부르지도 않았다.
import {EvaluationReason} from '@vendor/js-sdk'
console.log(EvaluationReason.DEFAULT_RULE)
```

| 시나리오                       | raw       | gzip     |
| ------------------------------ | --------- | -------- |
| 상수 하나만 import             | 383,698 B | 93,628 B |
| 실사용 (`createInstance` 호출) | 392,745 B | 96,851 B |

상수 하나를 가져왔을 뿐인데 실사용의 97.7%가 그대로 남았다. 압축 후로 따져도 96.7%다. 절대량으로는 raw 374.7KB, gzip 91.4KB다. 트리셰이킹이 부분적으로 약한 게 아니라, 사실상 작동하지 않는 상태였다.

이 글은 그 SDK를 소스맵에서 복원해 다시 빌드한 기록이다. 크기를 줄인 이야기가 절반, "로직을 한 줄도 안 고쳤다"는 주장을 어떻게 검증했는가가 나머지 절반이다. 시간을 더 많이 쓴 쪽도, 배운 게 더 많았던 쪽도 뒤쪽이었다.

> 측정은 macOS, esbuild 0.24로 번들한 뒤 `zlib` gzip 레벨 9와 brotli 기본 설정으로 압축한 값이다. 본문의 KB는 전부 1KB를 1024바이트로 계산했고, 바이트 단위 원값을 함께 적었다. 서브패스별 크기 표는 이 글을 쓰면서 Node 24.20.0에서 다시 돌려 기준선과 0.0% 일치를 확인했고, 벤더 원본 행도 같은 esbuild 설정으로 다시 재서 gzip 94.9KB와 brotli 78.8KB가 재현되는 것을 확인했다. 벤더는 익명으로 두었다. 패키지명, 버전, 내부 클래스 이름, 기능 이름, 서브패스 이름은 전부 역할만 남기고 일반화했으므로 실제 이름이 아니다. 수치는 그 이름이 가리키는 엔트리를 실제로 번들해 잰 값 그대로다. 코드 예제 중 벤더 소스에서 온 것은 구조만 남긴 형태다.

## 왜 직접 만들기로 했나

먼저 벤더에 개선을 요청했다. 원인 분석과 실측 수치를 붙여서 보냈고, 답은 "인지하고 있으나 반영 일정은 미정"이었다. 이 답이 비합리적이라고 생각하지는 않는다. SDK의 빌드 파이프라인을 바꾸는 일은 모든 고객사의 런타임에 영향을 주는 변경이라, 우선순위가 밀리는 게 자연스럽다.

그렇다고 기다리는 동안 압축 후 91KB를, 압축 전으로는 375KB를 계속 실어 나를 이유도 없었다. 특히 우리 쪽 소비 실태를 조사해 보니 상황이 더 아까웠다. 이 SDK를 쓰는 저장소 여덟 곳을 훑었더니 대부분은 **이벤트 전송만** 하고 있었다. A/B 테스트 평가를 실제로 호출하는 곳은 소수였고, 메시지 UI는 아무도 안 쓰고 있었다. 안 쓰는 기능이 번들의 대부분을 차지하고 있었다는 뜻이다.

그래서 직접 만들기로 했는데, 여기서 조건을 하나 걸었다. **기능과 로직은 손대지 않는다.** 이건 취향이 아니라 리스크 관리다. 이 SDK가 보내는 이벤트는 대시보드로 흘러가서 실험 판정의 근거가 된다. 우리가 로직을 "개선"하면 그 순간부터 대시보드의 숫자가 무엇을 뜻하는지 아무도 확신할 수 없게 된다. 버그를 고치는 것조차 위험하다. 실제로 이 SDK에는 타임존 프로퍼티가 항상 빈 문자열로 나가는 버그가 있는데, 그것도 그대로 두기로 했다. 뒤에서 다시 나온다.

그러면 선택지는 셋이었다.

**직접 새로 쓴다.** 공개 API가 명세이므로 같은 인터페이스로 구현하면 된다. 가장 깨끗하지만, 평가 엔진의 버킷팅 해시와 스무 종이 넘는 타겟 매칭 연산자를 벤더와 비트 단위로 똑같이 재현해야 한다. 하나라도 틀리면 특정 유저 집합의 실험 배정이 달라진다. 예외도 경고도 없이 달라지기 때문에 배포한 쪽은 아무 신호를 못 받는다.

**포크한다.** 소스 저장소가 공개되어 있지 않아 불가능했다.

**배포본에서 소스를 복원한다.** 배포된 패키지에 소스맵이 함께 들어 있었고, `sourcesContent` 필드에 원본 TypeScript가 통째로 담겨 있었다. 로직을 재현할 필요가 없어진다. 그냥 그 로직 자체를 쓰면 된다.

세 번째를 골랐다. 라이선스는 ISC라 재배포와 수정이 허용되고, 사내 사용이 목적이라 배포 문제도 없었다. 다만 원저작물 고지는 별도 파일로 남겼다. 원 패키지가 배포물에 LICENSE 원문을 넣지 않아서 표준 문안에 선언된 저작자를 적용해 재구성했는데, 그렇게 만든 문안이 원문은 아니라는 사실도 같은 파일에 적어 뒀다.

## 원인은 둘인데, 크기가 다르다

복원을 시작하기 전에 원인부터 정확히 갈랐다. 여기서 잘못 짚으면 몇 주를 엉뚱한 데 쓰게 된다.

**첫째, 도달 가능성이다.** `createInstance` 를 호출하는 순간 화면에 무언가를 그리는 UI 코드, 네이티브 앱과 주고받는 브릿지, 디바이스 정보를 뽑아내는 파서가 전부 도달 가능한 코드가 된다. 번들러 입장에서는 제거할 근거가 없다. 실제로 쓰이는 코드니까. 이건 트리셰이킹의 문제가 아니고, `sideEffects` 선언을 고치거나 번들러를 바꿔서 해결되는 종류도 아니다. 엔트리를 나누는 것 말고는 방법이 없다.

**둘째, 부수효과를 증명할 수 없는 형태다.** tsconfig의 target이 ES5라 클래스가 전부 IIFE로 컴파일되어 있었다. 이런 모양이다.

```js
var Bucketer = /** @class */ (function () {
  function Bucketer(murmur) {
    this.murmur = murmur
  }
  Bucketer.prototype.bucketing = function (bucket, id) {
    /* ... */
  }
  return Bucketer
})()
```

즉시 실행 함수라 번들러는 이게 부수효과가 없다고 증명하지 못한다. `/*#__PURE__*/` 주석도 붙어 있지 않다. 그래서 도달 불가능한 코드마저 남는다. 배포본에서 이 패턴을 세어 보니 500개에 가까웠다.

둘 다 진짜 원인이지만, 크기가 같지 않다. 이걸 확인하려고 배포본에 PURE 주석을 강제로 주입해서 다시 재 봤다.

| 시나리오                       | raw       | gzip     |
| ------------------------------ | --------- | -------- |
| 상수 하나만 import (원본)      | 383,698 B | 93,628 B |
| 상수 하나만 import + PURE 주입 | 58,725 B  | 24,736 B |
| 실사용 (원본)                  | 392,745 B | 96,851 B |
| 실사용 + PURE 주입             | 381,701 B | 95,684 B |

PURE 주석은 상수 하나만 가져오는 합성 케이스에서 73.6%를 줄인다. 극적이다. 반면 실사용에서는 1.2%밖에 못 줄인다. 당연한 결과이긴 하다. 실제로 클라이언트를 만들어 쓰면 대부분의 코드가 도달 가능해지니까, 부수효과 증명은 더 이상 병목이 아니다.

이 표가 알려준 것은 **엔트리 분리가 본체이고 ES 타깃 상향은 그 위에 얹는 개선**이라는 사실이었다. 만약 이걸 재 보지 않고 "IIFE가 범인이다"라는 직관만 따라갔다면, 빌드 타깃만 올려 두고 1.2% 개선에 만족하며 끝냈을 것이다. 원인이 둘일 때 각각의 크기를 따로 재는 일은 생각보다 자주 건너뛰게 되는데, 이 경우엔 그게 방향을 갈랐다.

## 소스맵에서 TypeScript를 되찾기

소스맵은 보통 디버깅용으로만 생각하게 되는데, 사실 원본 소스를 통째로 실어 나를 수 있는 포맷이다. 소스맵 v3 스펙에는 `sources`(원본 파일 경로 목록)와 나란히 `sourcesContent`(각 원본 파일의 전문)라는 선택 필드가 있다. 번들러가 이 필드를 채워서 내보내면, 소스맵 하나에 원본 소스 전체가 들어간다.

```json
{
  "version": 3,
  "sources": ["../src/core/internal/evaluation/bucket/Bucketer.ts", "..."],
  "sourcesContent": [
    "import { Bucket } from \"../../model/model\"\n\nexport class Bucketer {\n...",
    "..."
  ],
  "mappings": "AAAA,OAAO..."
}
```

이 벤더는 `sourcesContent` 를 채운 소스맵을 npm에 함께 배포하고 있었다. 흔한 일이다. rollup과 webpack 모두 소스맵을 켜면 기본적으로 이 필드를 포함하고, 끄려면 따로 설정해야 한다. 그래서 배포물에 소스맵을 넣는 순간 컴파일 전 소스가 같이 나간다.

복원 자체는 그래서 단순하다. 파싱해서 파일로 다시 떨어뜨리면 된다.

```js
const map = JSON.parse(
  readFileSync('vendor/unpacked/package/lib/index.browser.es.js.map', 'utf8'),
)

let count = 0
map.sources.forEach((source, i) => {
  const content = map.sourcesContent?.[i]
  if (!content) return
  if (source.includes('node_modules')) return // 폴리필 등 서드파티는 제외
  const rel = source.replace(/^(\.\.\/)+/, '').replace(/^src\//, '')
  const target = join(OUT, rel)
  mkdirSync(dirname(target), {recursive: true})
  writeFileSync(target, content)
  count++
})
```

여기서 320개 남짓한 파일이 나왔다. 그런데 배포된 `.d.ts` 는 400개가 넘었다. 80개 넘게 비었고, 이유가 둘로 갈렸다.

**첫째, 타입 전용 모듈은 소스맵에 남지 않는다.** 인터페이스와 타입 별칭만 있는 파일은 컴파일 결과 런타임 코드를 한 줄도 만들지 않는다. 그러면 번들러가 그 모듈을 통째로 제거하고, 제거된 모듈은 애초에 번들에 매핑될 구간이 없으니 `sourcesContent` 에도 흔적을 남기지 않는다. 여기 걸린 것이 70여 개였고 그중에는 클라이언트 인터페이스, 이벤트 디스패처, 라이프사이클 리스너 같은 핵심 계약이 들어 있었다. 이건 배포된 `.d.ts` 를 `.ts` 로 그대로 옮겨 채웠다. 내용이 순수 타입 선언이라 확장자만 바꿔도 유효하다.

**둘째, 브라우저 번들에 안 들어가는 코드는 브라우저 소스맵에 없다.** 처음에는 브라우저 ESM 소스맵 하나만 읽었는데, Node 전용 엔트리에 딸린 파일 아홉 개가 계속 비었다. 이 패키지는 브라우저 빌드와 Node 빌드를 따로 내보내고 소스맵도 따로 배포한다. Node 번들의 소스맵을 한 번 더 읽으니 그 아홉 개가 실제 구현으로 채워졌다.

이 순서가 중요하다. 처음에는 그 아홉 개도 `.d.ts` 백필로 채워졌는데, `.d.ts` 백필은 타입만 있고 구현이 없는 껍데기다. 타입체크는 통과하고 빌드도 되니까 한동안 눈치채지 못했다. 소스맵이 여러 개 배포되면 전부 읽고, 이미 채워진 파일은 덮지 않는 순서로 처리하는 게 맞다.

```js
// 브라우저 소스맵으로 채운 뒤, Node 소스맵으로 빈 곳만 메운다
nodeMap.sources.forEach((source, i) => {
  const content = nodeMap.sourcesContent?.[i]
  if (!content || source.includes('node_modules')) return
  const target = join(
    OUT,
    source.replace(/^(\.\.\/)+/, '').replace(/^src\//, ''),
  )
  if (existsSync(target)) return // 브라우저 소스맵이 이미 채운 것은 건드리지 않는다
  writeFileSync(target, content)
})
```

최종적으로 브라우저 번들에서 320여 개, Node 번들에서 아홉 개, 타입 선언 백필로 70여 개가 나왔다. 셋을 합치니 배포된 `.d.ts` 개수와 하나도 남김없이 맞아떨어졌다.

### 개수를 스크립트에 박아 둔 이유

복원 스크립트에는 단계마다 개수 단언이 들어 있다.

```js
if (count !== EXPECTED_RUNTIME_SOURCES) {
  console.error(
    `expected ${EXPECTED_RUNTIME_SOURCES} runtime source files, got ${count}`,
  )
  process.exit(1)
}
```

처음엔 과하다고 생각했는데, 결과적으로 이 세 줄이 재현성의 유일한 근거가 됐다. 이 저장소가 하는 주장 중 가장 근본적인 것이 "이 소스는 우리가 쓴 게 아니라 벤더가 배포한 것"인데, 그 주장을 확인하는 방법은 스크립트를 다시 돌려 같은 결과가 나오는지 보는 것뿐이다. 개수가 하나라도 다르면 그 확인이 무너진다. 실제로 코드 리뷰 단계에서 리뷰어가 `--force` 로 재추출을 직접 돌려 세 갈래의 개수를 독립적으로 재현했고, 그게 이 저장소에서 소스 복원에 대한 가장 강한 근거가 됐다.

같은 이유로 스크립트에 가드를 하나 더 넣었다. 이 스크립트는 출력 디렉터리를 통째로 지우고 다시 만드는데, 복원 이후 우리가 그 경로 아래에 파일을 새로 만들고 편집한다. 무심코 다시 돌리면 그 작업이 전부 날아간다.

```js
if (existsSync(OUT) && !FORCE) {
  console.error(
    `${OUT} already exists.\n` +
      `벤더 소스는 git 에 커밋되어 있고, extract 는 1회성 부트스트랩 도구다.\n` +
      `다시 실행하면 이 경로 아래 로컬 편집분을 전부 지운다.`,
  )
  process.exit(1)
}
```

계획 단계에서는 최종 검증 체인이 `pnpm install && pnpm extract && pnpm build && pnpm test` 였다. 그대로 뒀으면 최종 검증이 그때까지의 작업 산출물을 전부 지우고 실패했을 것이다. 복원은 반복 실행하는 빌드 단계가 아니라 신규 클론에서 한 번 도는 부트스트랩이라는 게 그때 정리됐다.

### 복원한 소스는 수정 대상이 아니다

복원 후에 규칙을 하나 세웠다. `src/` 아래는 건드리지 않는다. 말은 간단한데 실제로는 계속 부딪혔다.

린터를 돌리자 원본 소스에서 correctness 등급 에러가 쏟아졌다. 파서 한 파일에서만 불필요한 정규식 이스케이프가 160건 넘게 나왔다. 처음엔 `--fix` 를 돌렸고, 이게 실수였다. 그 파일은 SDK가 자동으로 수집하는 프로퍼티를 만들어내는 파일이라 벤더와 바이트 단위로 같아야 했다. 정규식을 "정규화"하는 순간 그 동등성 주장이 무너진다. 130줄 가까이 바뀐 것을 되돌렸다.

결국 규칙 다섯 개를 껐다. `no-useless-escape`, `no-document-cookie`, `no-extra-boolean-cast`, `no-wrapper-object-types`, `no-useless-fallback-in-spread` 다. 전부 복원 소스에서만 터지고, 그 코드는 고칠 수 없는 대상이라 구조적으로 조치가 불가능하다. 조치할 수 없는 규칙이 에러로 뜨는 것은 코드의 결함이 아니라 린트 설정의 결함이라고 판단했다. 바이트 동등성이 이기고 린트 설정이 굽는 쪽이 맞다고 봤다.

물론 대가가 있다. 우리가 새로 쓰는 코드에서 같은 위반이 나도 안 잡힌다. 가치가 낮은 다섯 규칙이라 감수했는데, 감수했다는 사실은 설정 파일 주석이 아니라 판단 기록 문서에 남겼다. 껐다는 것만 알면 다음 사람이 할 수 있는 건 다시 켜 보는 것뿐이다. 무엇을 지키려고 껐고 대신 무엇을 잃었는지가 같이 적혀 있어야 켤지 말지를 판단할 수 있다.

### 버그를 몇 개 찾았지만, 고치지 않았다

외부 코드를 이 정도로 오래 들여다보면 이상한 곳이 눈에 들어온다. 몇 개 찾았고, **전부 그대로 뒀다.**

**타임존이 항상 빈 문자열로 나간다.** 디바이스 프로퍼티를 만드는 코드에서 화면 방향 변수가 선언 없이 대입된다. 컴파일된 산출물은 strict mode라 이 줄에서 `ReferenceError` 가 나고, 바로 다음 줄인 타임존 대입에 영영 도달하지 못한다. 그래서 이 SDK를 쓰는 모든 서비스의 대시보드에서 타임존 항목은 계속 비어 있다.

**`close()` 가 전역 패치를 되돌리지 않는다.** 라이프사이클 매니저가 설치 시점에 `history.pushState` 와 `replaceState` 를 래퍼로 감싸는데, 클라이언트를 닫아도 그 래퍼가 그대로 남는다. 한 페이지에서 인스턴스를 여러 번 만들고 닫으면 래퍼가 겹겹이 쌓이고, 죽은 인스턴스가 계속 라우팅 이벤트에 반응한다. 이 결함은 뒤에서 다시 나온다. 우리 테스트를 조용히 망가뜨린 범인이 이것이었다.

**타입 선언이 실제 동작과 어긋난다.** 전송 계층 인터페이스가 beacon 전송기를 `| null` 로 선언해 두었는데, 실제 코드는 지원 여부와 무관하게 항상 생성한다. null이 될 수 없는 값에 null 가능 타입이 붙어 있다.

**디바이스 정보를 이벤트마다 두 번 파싱한다.** 브라우저 프로퍼티 생성기와 디바이스 프로퍼티 생성기가 각각 독립적으로 파서를 호출한다. 그 문자열은 페이지 수명 동안 바뀌지 않고, 그 파서는 900줄 넘는 정규식 테이블이다. 메모이제이션 후보로 아주 그럴듯해 보였다.

넷 다 안 고쳤다. 이유는 하나다. **이 작업의 목표가 "옳게 동작하는 것"이 아니라 "지금 배포된 것과 똑같이 동작하는 것"이기 때문이다.**

타임존을 고치면 어제까지 비어 있던 필드에 갑자기 값이 차기 시작한다. 대시보드에서 보면 그건 개선이 아니라 지표의 불연속이다. 교체 시점을 기준으로 데이터가 갈라지고, 나중에 누가 그 구간을 보면 "이때 무슨 일이 있었나"부터 조사해야 한다. 타입 선언도 마찬가지다. 고치는 순간 그 타입에 기대어 짜인 소비자 코드에서 컴파일 에러가 날 수 있는데, 이건 우리가 약속한 무중단 교체와 어긋난다.

그래서 넣은 개선은 **동작을 하나도 바꾸지 않는 범위**로 한정했다. 빌드 산출물의 형태, 엔트리 구조, 폴리필, 런타임 의존성이다. 넷 다 소비자가 받아 가는 파일의 크기와 모양을 바꿀 뿐, 그 코드가 실행됐을 때 나오는 값은 바꾸지 않는다. 뒤에 나오는 검증이 확인하는 것도 정확히 그 지점이다.

디바이스 정보 파싱 건은 그 경계를 잘 보여준다. 이건 값을 안 바꾸는 순수한 성능 개선이라 넣어도 되는 종류였다. 그래서 넣기 전에 재 봤는데, **호출당 0.0060ms, 이벤트당 0.012ms였다.** 두 번을 한 번으로 줄여서 얻는 것이 이벤트당 6마이크로초다. 캐시 하나를 들이고 그 캐시의 무효화 조건을 앞으로 계속 신경 쓰는 값으로는 너무 싸다. 그래서 안 넣었다.

명백한 개선만 넣는다는 건 이런 뜻이다. 넣기 전에 재 봤고, 재 보니 넣을 이유가 없었다.

## 폴리필을 원점에서 다시 보기

복원한 엔트리 상단에는 이런 두 줄이 있었다.

```ts
import 'core-js/features/promise'
import 'core-js/features/array'
```

이걸 보고 처음 한 생각은 "브라우저 지원 범위 때문에 넣었겠지"였다. 확인해 보니 이 두 줄의 비용이 생각보다 컸다.

배포된 번들을 열어 보면 core-js 3.x가 **인라인되어 있다.** 외부 import로 남아서 소비자가 알아서 처리하는 구조가 아니라, 패키지 안에 들어 있다. 그리고 core-js는 폴리필이라 전역 내장 객체를 패치한다. 번들 안에 `__core-js_shared__` 라는 전역 키가 들어 있는 게 그 증거다.

소스맵의 `sources` 목록으로 비중을 재 봤다. 번들에 들어간 소스 중 core-js 유래가 240여 개 파일에 약 200KB이고, 벤더 자체 소스가 320여 개 파일에 약 790KB다. 압축 전 소스 바이트 기준으로 20%가 폴리필이었다.

> 이 20%는 컴파일과 압축을 거치기 전의 소스 바이트 기준이라 최종 산출물에서의 비중과는 다르다. 폴리필 코드는 반복 패턴이 많아 압축률이 좋은 편이라, gzip 기준 비중은 이보다 낮을 가능성이 높다. 정확한 gzip 기여분은 재지 않았다.

그래서 "무엇을 위한 폴리필인가"를 원점에서 다시 봤다. 순서는 이랬다.

**먼저 무엇을 주입하는지 확인했다.** `core-js/features/*` 는 core-js의 세 갈래 엔트리 중 가장 넓은 것이다. `core-js/es/*` 는 확정된 표준만, `core-js/proposals/*` 는 제안 단계만 담고, `features/*` 는 **둘을 합친 최대 세트**다. 즉 이 두 줄은 확정 표준뿐 아니라 아직 표준이 아닌 제안 단계 메서드까지 전역에 심는다.

**다음으로 실제로 무엇을 쓰는지 셌다.** 복원한 런타임 소스를 전수 조사했다. `features/*` 가 추가로 심어주는 제안 단계 메서드 열 종을 찾아봤는데, 결과가 이랬다.

```text
.group  .groupBy  .groupToMap  .toReversed  .toSorted
.toSpliced  .uniqueBy  .filterOut  .filterReject  .lastItem
                                              모두 0회
```

한 번도 안 쓴다. `features/*` 대신 `es/*` 를 썼어도 아무 차이가 없었다는 뜻이다.

**그리고 실제로 쓰는 내장이 무엇인지, 각각의 네이티브 지원 하한이 어디인지 정리했다.**

| 기능                                     | 사용 횟수 | 표준          | 네이티브 지원 하한              |
| ---------------------------------------- | --------- | ------------- | ------------------------------- |
| Promise, async/await                     | 90+       | ES2015        | IE11 제외 전부                  |
| Map / Set                                | 21        | ES2015        | IE11 제외 전부                  |
| `Array.from`                             | 22        | ES2015        | IE11 제외 전부                  |
| `Array.prototype.find` / `includes`      | 42        | ES2015/ES2016 | IE11 제외 전부                  |
| `Object.entries` / `values` / `assign`   | 18        | ES2017        | Chrome 54, Safari 10.1          |
| `String.prototype.startsWith`/`endsWith` | 7         | ES2015        | IE11 제외 전부                  |
| **`Array.prototype.flat` / `flatMap`**   | **3**     | **ES2019**    | **Chrome 69, Safari 12, FF 62** |

표를 만들고 나니 그림이 분명해졌다. 폴리필 없이도 IE11만 빼면 거의 다 돌아간다. 지원 하한을 혼자 끌어올리고 있는 것은 `flat` 과 `flatMap` 세 곳뿐이었다.

**그래서 그 세 곳을 `reduce` 로 바꿨다.** 컬렉션 유틸 두 곳과 저장소 코드 한 곳이었고, 각각 두세 줄짜리 변경이다.

```ts
// before
return groups.flatMap((it) => it.items)

// after
return groups.reduce<Item[]>((acc, it) => acc.concat(it.items), [])
```

이 치환으로 하한이 `Object.entries` 가 잡는 ES2017(Chrome 54, Safari 10.1)까지 내려갔다. 그리고 빌드의 문법 타깃도 같은 ES2017로 맞췄다. 둘이 어긋나 있으면 "이 SDK가 어느 브라우저까지 도는가"에 답이 두 개가 되고, 문법은 통과하는데 메서드가 없어 죽는 구간이 그 사이에 생긴다.

**결론은 런타임 폴리필을 번들에 넣지 않는 것이었다.** core-js import 두 줄을 지웠고, 우리 빌드 산출물에는 core-js 참조가 0건이다.

이 판단의 근거를 하나 더 적어 두고 싶다. **폴리필은 라이브러리가 아니라 애플리케이션의 책임이라고 생각한다.** 앱이 이미 같은 폴리필을 갖고 있어도 라이브러리 몫이 중복해서 실리고, 앱이 지원하지 않기로 한 브라우저를 위한 코드까지 따라 들어온다. 더 곤란한 쪽은 전역 패치다. 라이브러리를 import 했을 뿐인데 `Array.prototype` 이 바뀐다. 어디까지 지원할지는 앱이 정할 문제인데, 폴리필을 품은 라이브러리는 그 결정을 미리 내려놓은 채로 배포된다.

대신 IE11 지원이 필요한 소비자를 위해 README에 `core-js/es` 를 앱 엔트리에서 import 하는 안내를 뒀다. 필요한 쪽이 필요한 만큼 넣는 게 맞고, 그 선택지를 없애지 않는 것이 라이브러리가 할 일이라고 봤다.

## 의존성을 0으로 만들기

벤더 패키지의 런타임 의존성은 둘이었다. UUID 생성 라이브러리와 base64 라이브러리다.

```json
"dependencies": {
  "js-base64": "^3.x",
  "uuid": "^8.x"
}
```

크기만 보면 큰 건 아니다. 두 패키지를 각각 최소 사용 예제로 번들해서 재 보면 UUID 쪽이 raw 1,180 B에 gzip 619 B, base64 쪽이 raw 3,779 B에 gzip 1,668 B다. 그런데 이 SDK처럼 **여러 저장소가 공통으로 물고 가는 패키지**에서는 의존성의 비용이 바이트로만 계산되지 않는다고 생각한다. 이 SDK를 쓰는 저장소가 여덟 곳이면 그 여덟 곳의 lock 파일에 이 두 패키지가 들어가고, 여덟 곳의 보안 감사 결과에 이 두 패키지의 advisory가 뜨고, 여덟 곳의 버전 충돌 해결 대상이 된다. 공용 패키지일수록 의존성 하나의 파급이 곱해진다.

그래서 둘 다 걷어냈다. 다만 걷어내는 방식이 서로 달랐다.

### UUID: 더 가벼운 라이브러리가 아니라 네이티브로

처음 나온 제안은 `uuid` 를 `nanoid` 로 바꾸는 것이었다. 더 작으니까 자연스러운 발상이다. 다만 이 케이스에는 맞지 않았다.

이 SDK는 UUID를 세 군데에 쓴다. 이벤트마다 붙는 삽입 식별자, localStorage에 영속되는 디바이스 식별자, 그리고 세션 식별자다. 세션 식별자가 특히 문제였다.

```ts
sessionId = `${timestamp}.${uuidv4().slice(0, 8)}` // 소문자 hex 8자
```

UUID v4의 앞 8자를 잘라 쓴다. 즉 소문자 16진수 8자라는 형식이 결과물에 그대로 드러난다. nanoid의 기본 알파벳은 `A-Za-z0-9_-` 라 `V1StGXR8` 같은 값이 나오고, 그러면 세션 식별자의 형식 자체가 바뀐다. 디바이스 식별자도 UUID 문자열 그대로 서버에 전송되니 같은 문제가 있다.

형식을 지키려면 `customAlphabet('0123456789abcdef')` 에 대시와 버전 비트, 변형 비트를 손으로 조립해야 하는데, 그건 nanoid를 난수 공급기로만 쓰는 자체 UUID 생성기다. 그럴 거면 플랫폼이 이미 주는 것을 쓰는 게 낫다.

그래서 `crypto.randomUUID()` 를 택했다. 다만 여기에 함정이 있었다.

```ts
export function v4(): string {
  const c = globalThis.crypto
  if (typeof c.randomUUID === 'function') {
    return c.randomUUID()
  }

  // 폴백: getRandomValues 로 16바이트를 받아 v4 형식으로 조립한다
  const bytes = new Uint8Array(16)
  c.getRandomValues(bytes)
  bytes[6] = (bytes[6] & 0x0f) | 0x40 // 버전 4
  bytes[8] = (bytes[8] & 0x3f) | 0x80 // 변형 비트

  let hex = ''
  for (let i = 0; i < 16; i++) {
    hex += (bytes[i] + 0x100).toString(16).slice(1)
  }
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
```

폴백이 선택이 아니라 필수인 이유는 WebCrypto 스펙의 IDL에 있다.

```text
interface Crypto {
  [SecureContext] readonly attribute SubtleCrypto subtle;
  ArrayBufferView getRandomValues(ArrayBufferView array);
  [SecureContext] DOMString randomUUID();
};
```

`randomUUID` 에는 `[SecureContext]` 가 붙어 있고 `getRandomValues` 에는 없다. 즉 http로 서비스하는 사이트에서는 `randomUUID` 가 아예 존재하지 않는다. 서드파티 SDK는 자기가 어디에 심길지 고를 수 없으므로 이 경로를 반드시 가정해야 한다.

| 지원                | `randomUUID` | `getRandomValues` |
| ------------------- | ------------ | ----------------- |
| Chrome / Edge       | 92           | 11                |
| Firefox             | 95           | 21                |
| Safari / iOS        | **15.4**     | 5                 |
| IE                  | 없음         | 11                |
| secure context 전용 | **예**       | 아니오            |

여기서 하나 배운 게 있다. MDN의 browser-compat-data에는 `randomUUID` 의 `secure_context_required` 가 설정돼 있지 않다. **호환성 표만 보면 이 제약을 놓친다.** 스펙 IDL을 직접 확인하고 나서야 폴백이 필수라는 결론이 나왔다.

### base64: 다섯 줄과 라이브러리 대조

base64 쪽은 더 단순했다. `Base64.encodeURL(JSON.stringify(identifiers))` 형태로 두 군데에서만 쓰고 있었다. base64url 인코딩 하나를 위해 raw 3,779 B, gzip 1,668 B를 싣고 있었던 셈이다.

```ts
export function encodeBase64Url(input: string): string {
  const bytes = new TextEncoder().encode(input)

  let binary = ''
  for (let i = 0; i < bytes.length; i++) {
    binary += String.fromCharCode(bytes[i])
  }

  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
```

`btoa` 에 문자열을 그대로 넘기지 않고 `TextEncoder` 로 UTF-8 바이트를 먼저 만드는 것이 요점이다. 이 값에는 사용자 식별자가 들어가고 거기에는 비ASCII가 들어올 수 있는데, `btoa` 는 latin1 범위를 벗어나면 예외를 던진다.

문제는 이 함수가 원본 라이브러리와 **한 바이트라도 다르면 안 된다**는 것이었다. 결과가 요청 헤더에 실려 서버가 파싱하기 때문이다. 그래서 테스트를 대조 방식으로 짰다. 라이브러리를 출하 의존성에서 걷어내되 devDependency로는 남겨 두고, 우리 구현과 계속 나란히 비교한다.

```ts
it('길이 0~64 의 모든 패딩 경계에서 같다', () => {
  for (let n = 0; n <= 64; n++) {
    const s = 'x'.repeat(n)
    expect(encodeBase64Url(s)).toBe(Base64.encodeURL(s))
  }
})

it('난수 유니코드 문자열 2000개가 전부 같다', () => {
  const rand = lcg(20260830) // 시드 고정. 실행마다 바뀌면 실패를 재현할 수 없다
  for (let i = 0; i < 2000; i++) {
    /* ... 임의 코드포인트로 문자열을 만들어 비교 ... */
  }
})
```

패딩 경계를 0부터 64까지 전부 도는 이유는, base64 패딩이 입력 바이트 길이를 3으로 나눈 나머지에 따라 갈리고 `encodeURL` 은 `=` 를 떼기 때문이다. 세 경우를 다 밟아야 한다. 난수 코퍼스에 시드를 고정한 이유는 실패를 재현할 수 있어야 하기 때문이다. 실행마다 코퍼스가 바뀌면 CI에서 한 번 터진 케이스를 다시 못 만든다.

이 테스트는 뒤에서 이야기할 벤더 대조와는 성격이 다르다. 벤더 배포본과 비교하는 게 아니라 **대체 대상 라이브러리와 직접 비교**한다. 근거의 강도로 치면 이쪽이 오히려 강하다. 같은 입력에 대해 출력이 문자열로 같은지를 2000건까지 확인하니까.

### 보안 감사 숫자에 대해

의존성이 0이 되면서 부수 효과가 하나 생겼다. 출하물의 취약점도 0건이 됐다.

작업 중에 `pnpm audit` 이 7건(critical 1, high 1, moderate 5)을 보고했는데, 열어 보니 여섯 건이 devDependency 전용이었다. 테스트 러너와 번들러와 개발 서버 이야기다. 출하물에 들어가는 건 하나였고, 그마저도 우리가 쓰지 않는 API 경로의 문제였다. UUID 라이브러리가 특정 함수에 버퍼 인자를 줄 때 경계 검사를 빠뜨린다는 내용인데, 이 SDK는 일곱 군데 전부 인자 없이 호출하고 있었다.

**GitHub 경고는 dev와 prod를 구분해서 보여주지 않는다.** 그래서 숫자만 보면 실제보다 급해 보인다. 이 저장소도 처음엔 "취약점 8건, critical 2건"으로 적혀 있었다. 숫자에 눌려서 검증 인프라를 흔드는 메이저 업그레이드를 급하게 하는 것보다, 출하 여부로 먼저 갈라 보는 게 순서라고 생각한다. 실제로 dev 여섯 건을 없애려면 테스트 러너 메이저를 두 단계 올려야 하는데, 그건 뒤에 나올 벤더 대조 하네스를 깨뜨릴 수 있는 변경이라 기능 변경과 같은 커밋에 섞으면 안 되는 종류다.

## 조립만 건드린다

로직이 아니라 **조립**을 건드리는 게 이 작업의 나머지 전부다. 원본 엔트리는 700줄 가까운 한 파일에서 모든 컴포넌트를 순서대로 생성하고 서로 연결하고 있었다. 이걸 기능 단위 팩토리로 쪼갰다.

```text
assembly/base.ts          공통 (설정, 유저 매니저, 전송 계층)
assembly/send.ts          이벤트 전송
assembly/eval.ts          평가 엔진
assembly/messaging.ts     메시지 UI
assembly/integration.ts   외부 연동
assembly/redirect.ts      URL 분기
```

그 위에 엔트리를 여덟 개 얹었다. `.`(전체), `./core`, `./send`, `./eval`, `./config`, `./message`, `./bridge`, `./redirect` 이다. 이벤트만 보내는 소비자는 `./send` 를 가져가고, 평가만 하는 쪽은 메시지 UI를 안 받는다.

조립 순서에는 의미가 있는 곳이 있었다. 예를 들어 세션 매니저에 리스너를 등록하는 순서가 바뀌면 세션 시작 이벤트와 캠페인 처리의 선후가 뒤집힌다. 코드만 봐서는 이게 의미 있는 순서인지 우연인지 알기 어려워서, 순서 자체를 고정하는 테스트를 따로 뒀다. 뒤에 나오는 변이 배터리에도 "리스너 등록 순서를 뒤집는" 항목이 들어가 있다.

## 여기서 한 번 틀렸다

`./send` 엔트리를 만들었는데 평가 엔진이 계속 따라 들어왔다. 이벤트만 보내는데 A/B 테스트 평가기가 왜 필요한가 싶어서 들여다보니, 코어 클래스의 `create()` 가 평가기들을 무조건 등록하고 있었다.

처음 낸 해법은 이랬다. 어떤 평가기를 등록할지 불리언 플래그로 받아서, `./send` 에서는 전부 false로 넘긴다.

```ts
// 이렇게 하면 될 것 같았다
static create(registrations: { evaluator: boolean; messageUi: boolean }) {
  if (registrations.evaluator) {
    registry.register(new VariantEvaluator(...))
  }
  if (registrations.messageUi) {
    registry.register(new MessageUiEvaluator(...))
  }
}
```

동작은 한다. 런타임에 평가기가 만들어지지 않는다. 그런데 번들은 하나도 안 줄었다.

당연한 일이었다. 플래그가 false여도 `new VariantEvaluator(...)` 라는 **정적 참조**는 파일 안에 그대로 남아 있다. 번들러가 보기에 이 모듈은 여전히 평가기 모듈에 도달 가능하다. 내가 바꾼 것은 "생성 여부"라는 런타임 조건이었을 뿐, 모듈 그래프의 간선은 하나도 끊지 못했다.

트리셰이킹을 상대할 때 조건 분기는 아무 힘이 없다. 끊어야 하는 것은 import이고, 그건 코드를 물리적으로 다른 파일로 옮겨야 끊긴다. 결국 평가기 조립을 코어 클래스 바깥의 별도 모듈로 완전히 이동시키고, `create()` 가 이미 조립된 컴포넌트를 인자로 받도록 뒤집었다. 코어 파일 안의 평가기 import가 0개가 되고 나서야 타겟 매칭 코드가 실제로 빠졌다. 압축 전 기준 41KB짜리 덩어리다.

이 실수를 굳이 적는 이유는, 나중에 비슷한 판단을 또 할 뻔했기 때문이다. 어느 엔트리가 어느 모듈을 끌어오는지는 파일을 grep 해서 추정하면 자주 틀린다. 실제로 base64 라이브러리를 걷어낼 때도 grep 결과로 "`./send` 에는 영향 없을 것"이라 예측했다가 정반대 결과를 봤다. 그 라이브러리를 제거하고 가장 크게 줄어든 엔트리가 `./send`(-5.1%)였다. 코호트 조회 모듈이 `./send` 그래프에 실제로 들어 있었기 때문인데, 파일 목록만 봐서는 안 보인다. 그래서 지금은 어느 모듈이 어느 엔트리에 실리는지 궁금하면 그 엔트리를 esbuild로 한 번 말아서 산출물을 뒤진다. grep보다 오래 걸리는 대신 추정이 안 들어간다.

## ES 모듈로 내보내기

엔트리를 나눠도 산출물이 벤더와 같은 모양이면 소용이 없다. 벤더 배포물은 UMD와 ESM, 압축본 몇 가지인데 전부 **단일 프리번들**이다. 파일 하나 안에 모든 코드가 이미 합쳐져 있어서, 소비자의 번들러가 들여다봐도 모듈 경계가 없다. 잘라낼 선이 안 보이는 것이다.

그래서 빌드를 모듈을 보존하는 쪽으로 갈아엎었다.

```ts
build: {
  target: 'es2017',
  minify: false,
  sourcemap: true,
  lib: { entry: entries, formats: ['es', 'cjs'] },
  rollupOptions: {
    external: [],
    output: { preserveModules: true, preserveModulesRoot: 'src' },
  },
}
```

`preserveModules` 가 핵심이다. 소스 트리의 모양을 그대로 유지한 채 340여 개 파일로 내보낸다. 소비자 번들러는 이제 파일 단위로 도달 가능성을 판단할 수 있다.

나머지 설정도 각각 이유가 있다.

**`minify: false`.** 라이브러리는 압축하지 않는다. 압축은 소비자의 번들러가 자기 설정으로 할 일이다. 미리 압축해서 내보내면 소비자 쪽 최적화가 이미 뭉개진 코드 위에서 돌고, 디버깅할 때 소스맵을 거쳐야 한다. 벤더가 압축본을 기본 진입점으로 삼고 있었던 것이 산출물을 프리번들로 만든 이유 중 하나이기도 하다.

**`external: []`.** 런타임 의존성이 없으니 비워 둔다. 다만 이건 "없어서 비워 둔" 것이 아니라 **비워 둬야 드러나기 때문**이다. 실수로 외부 패키지를 import 하면 external 목록이 비어 있어야 빌드가 그것을 번들에 끌어들이려다 실패한다. 목록에 뭔가 적혀 있으면 조용히 외부화되고, 소비자가 설치한 적 없는 패키지를 요구하는 산출물이 나간다.

**`type: "module"` 과 `sideEffects: false`.** 후자가 없으면 번들러가 모듈 하나하나를 "부작용이 있을지도 모른다"고 보고 남긴다. 앞에서 ES5 IIFE 때문에 부작용을 증명하지 못한다고 했는데, 이 선언은 그 증명을 패키지 수준에서 대신해 주는 장치다.

그리고 `exports` 맵으로 여덟 개 엔트리를 열되, 각각에 타입과 ESM과 CJS를 함께 걸었다.

```json
"exports": {
  "./send": {
    "types": "./dist/index.send.d.ts",
    "import": "./dist/index.send.js",
    "require": "./dist/index.send.cjs"
  }
}
```

### 타입만 깨지는 함정

여기서 한 번 걸렸다. 빌드도 되고 테스트도 통과하고 런타임도 멀쩡한데, 어떤 소비자에게서는 타입이 안 잡혔다.

원인은 `.d.ts` 의 상대 import에 확장자가 없다는 것이었다. 타입 선언을 만들어 주는 플러그인은 `from './Bucketer'` 처럼 확장자 없이 내보내는데, 같은 빌드의 `.js` 출력에는 `from './Bucketer.js'` 처럼 확장자가 붙는다. 둘이 어긋난다.

이 어긋남을 `moduleResolution: bundler` 소비자는 눈치채지 못한다. 확장자 없는 지정자를 알아서 풀어 주기 때문이다. 반면 `node16` 이나 `nodenext` 소비자는 엄격해서 TS2835와 TS2307을 낸다. 우리 쪽에서 실제로 24건이 떴다.

소스의 import를 고치는 방법도 있었지만 그쪽은 벤더 복원 코드라 손댈 수 없다. 그래서 산출물만 고치는 스크립트를 빌드 뒤에 붙였다. 확장자가 이미 있는 지정자는 건드리지 않고, 상대 지정자에만 `.js` 를 붙인다. 지금은 빌드마다 377개 파일이 보정된다.

### 타입체크 두 개는 서로 다른 주장이다

이 사고가 남긴 것은 확장자 보정 스크립트가 아니라 그 옆에 생긴 게이트다.

`pnpm typecheck` 은 **우리 소스**를 본다. 소비자가 `dist` 의 `.d.ts` 를 해석할 수 있는지는 보지 않는다. 둘은 다른 주장인데, 하나만 돌리고 있으면 같은 주장으로 착각하게 된다. 실제로 이때 깨진 것은 후자뿐이었고 전자는 계속 초록불이었다.

그래서 소비자 관점 검사를 따로 뒀다. 임시 프로젝트에 패키지를 올리고 여덟 엔트리를 전부 import 해서 `tsc --noEmit` 을 돌리는데, `moduleResolution` 을 `bundler` 와 `node16` 두 가지로 각각 돌린다. 그리고 `skipLibCheck` 를 켜지 않는다. 켜면 이 검사가 잡으려는 것을 정확히 건너뛴다.

"타입체크가 통과한다"는 말은 어느 타입체크냐에 따라 다른 뜻이다. 이 글 뒷부분의 주제가 여기서 한 번 미리 나온다.

## 결과

기준선은 벤더 배포본이다. 서두의 93,628 B와 이 표의 94.9KB가 다른 이유는 시나리오가 달라서다. 서두는 상수 하나만 가져오는 합성 케이스이고, 여기는 패키지를 설치해 실제로 쓰는 소비자 기준이다.

| 서브패스    | raw      | gzip    | brotli  | 벤더 대비 (raw / gzip) |
| ----------- | -------- | ------- | ------- | ---------------------- |
| `/send`     | 86.4 KB  | 26.2 KB | 22.9 KB | **-77.5% / -72.4%**    |
| `/bridge`   | 113.7 KB | 31.1 KB | 27.0 KB | -70.4% / -67.2%        |
| `/config`   | 154.3 KB | 42.0 KB | 35.7 KB | -59.8% / -55.8%        |
| `/eval`     | 158.6 KB | 42.8 KB | 36.3 KB | -58.6% / -54.9%        |
| `/redirect` | 161.8 KB | 43.7 KB | 36.9 KB | -57.8% / -54.0%        |
| `/core`     | 170.8 KB | 45.1 KB | 38.2 KB | -55.5% / -52.5%        |
| `/message`  | 202.0 KB | 52.5 KB | 44.3 KB | -47.3% / -44.7%        |
| `.` (전체)  | 264.3 KB | 65.3 KB | 54.3 KB | -31.1% / -31.2%        |
| 벤더 원본   | 383.7 KB | 94.9 KB | 78.8 KB | 기준                   |

서브패스를 안 쓰고 `.` 만 그대로 import 해도 raw 31.1%, gzip 31.2%가 준다. 엔트리를 나눈 효과와 별개로 산출물 자체가 작아졌기 때문인데, 폴리필과 의존성을 걷어낸 몫이 여기 들어 있다.

측정 방식에 함정이 하나 있었다. 처음에는 빌드된 `dist` 파일을 경로로 직접 가리켜서 쟀는데, 그러면 `exports` 맵과 `sideEffects` 선언을 건너뛰게 되어 2~3% 낙관적인 값이 나온다. 지금은 패키지를 실제로 빌드해 임시 프로젝트의 `node_modules` 에 설치하고, 패키지 이름으로 import 해서 번들한 값을 쓴다. 그 절차를 스크립트가 자동화하고 기준선 파일이 회귀를 막는다. 위 표는 이 글을 쓰면서 다시 돌려 여덟 개 전부 기준선과 0.0% 일치를 확인한 값이다.

구조 쪽 변화도 같이 확인된다.

|                  | 벤더          | 이 저장소          |
| ---------------- | ------------- | ------------------ |
| ES5 IIFE 클래스  | 500개 가까이  | **0개**            |
| 네이티브 `class` | 0개           | 460개 가까이       |
| 모듈 구조        | 단일 프리번들 | 340여 개 모듈 보존 |
| 공개 엔트리      | 1개           | 8개                |
| 런타임 의존성    | 2개           | **0개**            |
| core-js 참조     | 인라인됨      | **0건**            |

## 그런데 같은 물건인가

여기까지가 자랑할 만한 부분인데, 사실 어려운 쪽은 지금부터였다.

지금까지의 이야기는 전부 "우리가 만든 게 더 작다"는 주장이다. 이 주장이 의미를 가지려면 앞에 조건이 하나 붙어야 한다. **그게 같은 물건일 때만** 의미가 있다. 평가 결과가 하나라도 다르면 실험 배정이 달라지고, 이벤트 페이로드의 키가 하나 빠지면 대시보드의 지표가 조용히 어긋난다. 조용히 어긋나는 게 문제다. 에러가 나면 알아채기라도 하지만, 이 종류의 어긋남은 몇 주 뒤에 "이 실험 결과가 좀 이상한데"로 도착한다.

"소스를 복원해서 로직을 안 고쳤으니 같다"는 논증은 생각보다 약하다. 조립 순서를 바꿨고, 엔트리를 쪼갰고, 폴리필을 걷어냈고, 의존성 두 개를 네이티브로 교체했고, `flat`/`flatMap` 세 곳을 `reduce` 로 바꿨다. 각각은 사소해 보이지만, 사소해 보이는 변경이 안전하다는 근거가 "사소해 보인다" 뿐이면 그건 근거가 아니다.

그래서 테스트를 쓰기로 했는데, 여기서 한 번 더 갈라야 했다. 우리가 값을 단언하는 테스트는 **우리 구현이 우리 기대와 일치한다**는 것만 증명한다. 벤더도 그 값을 만든다는 근거는 아니다. 우리 기대 자체가 벤더 코드를 잘못 읽은 결과일 수 있고, 실제로 그런 일이 있었다.

그리고 범위를 먼저 정했다. 이 SDK의 모든 기능을 검증하지는 않기로 했다. **우리가 실제로 쓰는 경로를 벤더와 대조하고, 안 쓰는 영역은 미검증으로 남기되 미검증이라는 사실을 문서에 적는다.** 검증은 공짜가 아니고, 아무도 안 쓰는 기능의 동등성을 증명하는 데 시간을 쓰는 것은 우선순위가 아니라고 봤다. 다만 "안 했다"와 "했는데 통과했다"가 문서에서 구분되지 않으면 그건 나중에 사고가 된다.

## 차분 테스트 하네스

택한 방식은 차분 테스트(differential testing)다. 같은 입력을 두 구현에 나란히 넣고 출력이 같은지만 보는 방식이다.

보통의 테스트와 무엇이 다른지는 **기대값을 어디서 가져오느냐**에 있다.

어떤 테스트든 "이 입력에 대한 옳은 출력은 무엇인가"를 알려주는 기준이 있어야 판정을 할 수 있다. 소프트웨어 테스팅에서는 이 기준을 **오라클**(test oracle)이라고 부른다. 그리고 그 기준을 구하기 어려워서 검증이 막히는 상황을 오라클 문제(oracle problem)라고 한다.

일반적인 테스트에서 오라클은 사람이다. `expect(bucketing(user)).toBe("B")` 처럼 정답을 손으로 적는다. 그러면 테스트의 정확도가 그 정답을 적은 사람이 명세를 얼마나 잘 이해했는지에 묶인다. 명세가 없거나, 있어도 구현과 어긋나 있거나, 출력이 사람이 암산할 수 없는 종류(해시값, 부동소수점 누적, 파서의 AST)면 이 방식이 막힌다.

차분 테스트는 그 오라클을 **다른 구현으로 대신한다.** 정답을 몰라도 된다. 두 구현이 같은 답을 내는지만 보면 된다. 컴파일러를 검증할 때 다른 컴파일러와 결과를 비교하거나, 새 파서를 만들 때 기존 파서와 같은 AST가 나오는지 보는 것이 같은 방식이다.

이 상황에는 이 방식이 잘 맞았다. 애초에 이 작업의 목표가 "옳게 동작하는 것"이 아니라 **"벤더와 똑같이 동작하는 것"** 이기 때문이다. 벤더가 타임존을 빈 문자열로 보내는 버그마저 그대로 재현해야 하는 마당에, 우리가 옳다고 생각하는 값을 손으로 적는 테스트는 오히려 방해가 된다. 여기서 오라클은 명세도 내 이해도 아니고 **벤더가 실제로 배포한 그 파일**이다.

물론 공짜는 아니다. 차분 테스트는 두 구현이 **같은 이유로 똑같이 틀릴 때** 조용히 통과한다. 뒤에서 이 약점에 정확히 걸린 사례가 나온다.

구체적으로는 벤더가 npm에 배포한 UMD 번들을 별도 jsdom에 올려 실행하고, 우리 빌드를 같은 조건으로 부팅한 다음, 같은 입력에 같은 호출을 해서 출력을 대조한다.

여기서 시간을 꽤 쓴 지점이 있다. 처음 계획은 `node:vm` 으로 벤더 번들을 격리 실행하는 것이었는데, 이게 동작하지 않는다. `vm.createContext(window)` 로 만든 컨텍스트 안에 전역이 갇혀서 바깥에서 UMD 전역을 읽을 수 없다. 실제로 통과한 방식은 이랬다.

```ts
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
  url: 'https://example.test/p?q=1',
  runScripts: 'outside-only', // 이게 없으면 window.eval 자체가 없다
})
const w = dom.window

// 스텁은 반드시 eval 전에 심는다. SDK가 로드 시점에 전역을 읽는다.
w.navigator.sendBeacon = () => true
Object.defineProperty(w.navigator, 'userAgent', {value: UA, configurable: true})
Object.defineProperty(w.navigator, 'languages', {
  value: ['ko-KR', 'ko'],
  configurable: true,
})
w.fetch = () => Promise.reject(new Error('offline'))
w.XMLHttpRequest = makeFixtureXHRClass(workspaceConfig, options)

w.eval(readFileSync(VENDOR_UMD, 'utf8'))
const vendorSdk = w.VendorGlobal
```

우리 빌드 쪽은 vitest의 jsdom 환경에서 평범하게 import 하고, 같은 스텁을 `vi.stubGlobal` 로 심는다. 두 클라이언트를 같은 워크스페이스 설정 픽스처로 부팅해서 `onReady` 까지 기다린 뒤 넘겨주는 함수 하나로 정리했다.

```ts
export async function bootDifferential(sdkKey, workspaceConfig, options) {
  restoreHistory()
  resetStorage()
  const {Sdk, window: vendorWindow} = loadVendorSdk(workspaceConfig, options)
  const vendorClient = Sdk.createInstance(sdkKey, options?.clientConfig)
  await new Promise((resolve) => vendorClient.onReady(resolve))

  resetStorage()
  vi.resetModules()
  stubOurGlobals(workspaceConfig, options)
  const ours = await import('../../src/index.browser')
  const ourClient = ours.createInstance(sdkKey, options?.clientConfig)
  await new Promise((resolve) => ourClient.onReady(resolve))

  return {vendorClient, ourClient, vendorWindow, cleanup}
}

// 같은 호출을 양쪽에 그대로 실행해 나란히 돌려준다
export function compare(clients, fn) {
  return {vendor: fn(clients.vendorClient), ours: fn(clients.ourClient)}
}
```

두 SDK가 서로 다른 jsdom에서 도니까, 대조에서 빼야 하는 것들이 생긴다. URL에서 유래하는 프로퍼티(`url`, `host`, `pagePath`, `referrer`, `protocol` 등)는 두 DOM의 주소가 다르므로 정규화 단계에서 제거하고 비교한다. 난수로 만들어지는 식별자도 마찬가지다. 나머지는 스텁을 동일하게 심었으니 값까지 일치해야 한다.

서버 응답은 XHR을 통째로 갈아끼워 픽스처로 고정했다. 설정 요청에는 준비한 워크스페이스를 200으로 돌려주고, 나머지 요청은 상태 0으로 실패시킨다. 양쪽이 각자 이 클래스를 따로 인스턴스화하므로 응답 큐도 서로 독립이다.

이렇게 깔고 나서 대조 범위를 넓혔다. 평가 결정은 유저 40명 × 키 24개를 전수로 돌려 480쌍을 비교했고, 이벤트는 `sendBeacon` 본문을 가로채서 페이로드를 값까지 대조했다. 실험 상태별(진행/초안/일시정지/종료), 타겟 키 여덟 종, URL 분기, 네이티브 브릿지, 메시지 UI의 이벤트와 저장소 규칙까지 붙였다. 최종적으로 테스트 1932개가 통과했고 불일치는 0건이었다.

여기까지 왔을 때, 솔직히 다 끝났다고 생각했다.

## 하네스가 거짓말을 하고 있었다

테스트가 1932개 통과한다는 사실은 무엇을 증명하는가. 엄밀히 말하면 아무것도 증명하지 않는다. 통과는 "테스트가 실패할 조건을 만나지 않았다"는 뜻이고, 실패할 조건을 애초에 만들 수 없는 테스트도 똑같이 통과한다.

이걸 확인하는 방법은 하나뿐이다. **일부러 망가뜨려 보는 것.** 소스의 한 줄을 틀리게 바꾼 다음 테스트를 돌려서, 빨간불이 나는지 본다. 안 나면 그 동작은 검증되고 있지 않은 것이다.

이걸 스크립트로 만들었다. 변이 하나는 이렇게 생겼다.

```js
{
  id: 'bucketing-seed',
  file: `${SRC}/core/internal/evaluation/bucket/Bucketer.ts`,
  find: 'murmurhash3_x86_32(value, seed)',
  replace: 'murmurhash3_x86_32(value, seed + 1)',
  detects: 'vendor-parity-decision: 버킷 분산'
}
```

각 변이를 소스에 주입하고 테스트를 돌린 뒤 원복한다. 하나라도 검출되지 않으면 exit 1이다. 설계에서 신경 쓴 것이 셋 있었다.

**대상 문자열이 정확히 한 번 나타나지 않으면 즉시 에러를 낸다.** 리팩터링으로 소스가 바뀌어서 변이가 아무 데도 적용되지 않으면 테스트는 당연히 통과하고, 배터리는 "미검출"을 보고한다. 그런데 그건 검증의 실패가 아니라 변이 정의가 낡은 것이다. 둘을 구분하지 않으면 배터리 자체가 거짓말을 하기 시작한다.

**원복은 어떤 경로로 끝나든 보장한다.** 원본을 메모리에 들고 `finally` 와 시그널 핸들러에서 되돌린다. 중단된 배터리가 변이된 소스를 저장소에 남기는 게 제일 위험한 실패 모드다.

**기준선을 먼저 검사한다.** 변이 없이 이미 실패하는 상태에서 "변이가 검출됐다"는 아무 의미가 없다.

그리고 이 배터리가, 통과하고 있던 테스트 중에 죽어 있는 것들을 찾아냈다.

### 원본에서만 우연히 옳던 테스트

URL 분기 매처를 `return true` 로 바꿨는데, 벤더 대조 테스트가 통과했다. "URL이 매치하지 않으면 양쪽 다 리다이렉트를 시도하지 않는다"를 검증한다고 적혀 있는 테스트였다.

원인을 따라가 보니 라이프사이클 매니저에 있었다. 이 클래스는 설치 시점에 `history.pushState` 를 자기 인스턴스에 바인딩된 래퍼로 감싼다.

```ts
history.pushState = ((f) =>
  function pushState() {
    var ret = f.apply(history, arguments)
    changeLifecycle('locationChange') // 이 클로저가 특정 인스턴스를 붙잡는다
    return ret
  })(history.pushState)
```

그런데 `client.close()` 는 이 패치를 되돌리지 않는다. 코어와 폴링 동기화만 닫는다. 결과적으로 테스트마다 래퍼가 한 겹씩 쌓이고, **앞 테스트가 만든 클라이언트가 뒤 테스트의 `history.pushState` 에 계속 반응한다.**

임시 로그를 심어 확인한 순서는 이랬다.

1. 테스트 1의 클라이언트가 `pushState` 를 감싼다. `afterEach` 의 정리는 이걸 되돌리지 않는다.
2. 테스트 2가 부팅하면서 `history.pushState` 로 URL을 바꾼다. 죽지 않은 테스트 1의 클라이언트가 여기 반응한다. 변이 상태에서는 매처가 항상 true이므로 리다이렉트를 실행하고 가드 쿠키를 남긴다. 이 시점은 전역 스텁이 걷힌 뒤라 캡처에는 안 잡힌다.
3. 이어서 부팅한 테스트 2 본체의 클라이언트는 첫 줄에서 "이미 리다이렉트했음"을 만나 곧바로 null을 반환한다. **매처에 도달조차 하지 않는다.**
4. 그래서 "리다이렉트 호출이 없어야 한다"도, "가드 쿠키가 없어야 한다"도 전부 통과한다.

변이 없는 원본에서는 2번 단계가 매처 false로 끝나 쿠키를 안 남기고, 그래서 테스트 2가 정상 경로를 밟는다. **원본에서만 우연히 옳게 동작하던 테스트**였던 셈이다.

고친 곳은 하네스 한 군데다. 모듈 로드 시점에 네이티브 `pushState`/`replaceState` 를 캡처해 두고, 부팅 진입 시 복원해서 래퍼 체인을 끊는다. 프로덕션 코드는 건드리지 않았다. `close()` 가 패치를 안 되돌리는 것은 벤더와 동일한 동작이고, 우리가 바꿀 대상이 아니다.

### 픽스처가 연산자 하나만 쓰고 있었다

두 번째는 더 조용한 종류였다. 크거나 작다를 비교하는 매처를 크거나 같다로 바꿔 봤다. 실패 0건이었다. 포함 매처를 시작 매처로 바꿔 봤다. 역시 실패 0건이었다.

원인은 픽스처였다. 워크스페이스 픽스처의 모든 타겟 조건이 `operator: "IN"` 이었다. 480쌍을 전수 대조했다는 그 테스트가, 실제로는 IN 연산자 경로 하나만 480번 밟고 있었다. 나머지 연산자 여덟 종과 `NOT_MATCH` 매치 타입, NUMBER/BOOLEAN/VERSION 값 타입 라우팅은 **벤더와 한 번도 대조된 적이 없었다.**

이건 커버리지 도구로는 안 보인다. 매처 파일들은 다 실행되고 있었으니까. "실행됐다"와 "그 결과를 누가 단언했다"는 다른 이야기다.

픽스처에 연산자별 실험을 열두 개 추가해서 닫았다. 각 실험은 조건 하나만 걸고 기본 규칙이 특정 변형을 직접 가리키게 해서, 조건이 매치하면 변형과 사유가 함께 갈리도록 설계했다. 버킷팅이 개입하지 않아 대조 대상이 순수하게 매칭 결과 하나로 좁혀진다. 크다와 크거나 같다처럼 경계가 한 칸 차이인 쌍은 둘 다 넣어야 서로 맞바꾸는 변이를 잡을 수 있다.

### 픽스처 키가 겹쳐 실험이 사라졌다

세 번째는 그 조합 실험을 추가하다가 생긴 사고다. 새 실험에 키 41~43번을 배정했는데, 변이 세 개가 갑자기 미검출로 돌아섰다.

워크스페이스 DTO는 실험을 키로 맵에 담는다. 키가 겹치면 뒤에 오는 실험이 앞을 조용히 덮는다. 내가 배정한 키 셋이 기존의 컨테이너 실험, 유저 오버라이드, 세그먼트 오버라이드를 덮어버렸고, 그 세 기능은 워크스페이스에서 사라진 상태가 됐다. **그런데 테스트는 전부 통과했다.** 양쪽 SDK가 똑같이 "그런 실험 없음"을 반환하니까 대조는 일치로 판정한다.

비슷한 일이 한 번 더 있었다. 실험 상태 필드에 모델 이름을 넣었는데 실제 와이어 포맷은 다른 코드를 쓰고 있었다. 틀린 코드를 넣으면 해당 실험이 워크스페이스에서 조용히 누락되고, 역시 양쪽이 똑같이 not-found를 반환해 일치로 통과한다. 초안 상태와 종료 상태 경로가 전혀 검증되지 않는다는 사실을 그 통과가 가려 준다.

이건 대조 테스트라는 방식 자체의 약점이다. 양쪽이 **같은 이유로 아무것도 안 하면** 대조는 언제나 통과한다. 픽스처가 잘못되면 그 잘못이 양쪽에 똑같이 적용되니 대조로는 절대 안 잡힌다.

지금은 픽스처 자체를 검사하는 테스트를 뒀다. 실험, 플래그, 메시지 UI, 세그먼트, 버킷의 키와 id 중복을 막는다. 픽스처를 검사하는 테스트는 이 저장소에서 이때 처음 생겼는데, 진작 있었어야 했다고 생각한다.

## 검증의 범위를 다시 잰다

변이는 최종적으로 72개가 됐고 전부 검출된다. 그런데 이 숫자에는 함정이 있다. **72개는 사람이 고른 지점이다.** 내가 의심한 곳만 찌른 것이라, 의심하지 못한 영역은 이 숫자에 아예 안 들어온다.

그래서 대조가 소스의 어디까지 닿았는지를 따로 쟀다. 대조 테스트를 커버리지 계측으로 돌린 결과다.

```text
런타임 소스        350여 개
그중 statement 0%  10개
전체 statement     약 72%
전체 function      약 69%
```

이 측정에서 신뢰할 만한 것은 0% 쪽뿐이다. 실행됐다는 것이 그 결과를 누가 단언했다는 뜻은 아니지만, 0%는 그 코드가 대조에 한 번도 참여하지 않았다는 확정 신호다.

0% 파일 10개를 전부 열어 보니 출하되지 않는 엔트리에 속한 것들이었다. Node 전용 엔트리와 태그 매니저용 엔트리, 그리고 그 둘만 참조하는 파일들이다. 우리 빌드 엔트리는 여덟 개이고 `exports` 맵도 같은 여덟 개라, 소비자가 도달할 경로가 없다. 그래서 "대조가 닿지 않은 출하 코드는 파일 단위로는 없다"가 된다.

여기서 멈추면 안 된다는 게 이 측정의 진짜 소득이었다. **0% 파일 10개를 빼고 남은 부분 실행 파일 340여 개 안에, 미실행 statement가 25.8%, 미호출 function이 32.0% 남아 있다.** 파일 단위로만 보면 이 영역이 통째로 안 보인다. "0% 파일이 열 개뿐"이라는 결과를 "거의 다 대조했다"로 읽으면 안 되는 이유다.

한편 이 총량 집계가 사람이 손으로 적어 둔 미검증 목록과 독립적으로 같은 지점들을 가리켰다는 것도 확인됐다. 사람이 고른 목록에 큰 누락이 없다는 것을 기계가 한 번 더 확인해 준 셈인데, 그게 "다 봤다"는 뜻은 아니다.

## 검증하지 못한 것을 남긴다

그래서 검증 현황 문서를 따로 뒀다. 이 문서의 가치는 무엇이 확인됐는지가 아니라 **무엇이 확인되지 않았는지**를 남기는 데 있다고 생각한다.

근거의 강도를 세 등급으로 나눴다.

| 등급       | 뜻                                                                              |
| ---------- | ------------------------------------------------------------------------------- |
| **대조**   | 벤더 배포본을 같은 jsdom에 올려 나란히 실행하고 출력을 비교했다. 가장 강한 근거 |
| **자체**   | 우리 테스트가 값을 단언한다. "벤더도 그렇다"는 근거가 아니다                    |
| **미검증** | 실행된 적이 없다                                                                |

UUID 교체가 "자체" 등급인 게 이 구분의 쓸모를 보여준다. UUID는 난수라 벤더와 값을 비교할 수 없다. 지킬 수 있는 건 형식뿐이고, 그 형식을 변이 세 개가 지킨다. 대조 1540건이 교체 후에도 통과한다는 사실은 정황 근거는 되지만, 관련 식별자 필드는 애초에 대조 대상에서 제외돼 있으므로 "벤더와 같은 값을 만든다"의 근거는 아니다. 등급을 안 나눴으면 이 항목이 다른 항목과 같은 무게로 읽혔을 것이다. 반대로 base64 교체는 원본 라이브러리와 직접 비교하므로 표에 "라이브러리 대조"로 따로 적었다. 근거의 종류가 다르면 이름도 달라야 한다고 봤다.

닫지 못한 항목들을 처음에는 뭉뚱그려 적었는데, 나중에 다시 갈랐다. **"정말로 수단이 없다"와 "아직 조사하지 않았다"는 다르다.** 다섯 항목 중 정말로 외부 정보가 필요한 것은 네이티브 앱이 브릿지로 돌려주는 응답 하나뿐이었고, 나머지는 조사를 안 한 것이었다. 특히 설정 델타 병합은 폴링 주기가 이미 공개 설정으로 있어서 수단이 없는 게 아니었다. 진짜 장애물은 대조 하네스가 jsdom 두 개를 쓰는 구조와 가짜 타이머의 충돌이었다.

갈래를 뭉뚱그리면 목록이 실제보다 비관적으로 보이고, 할 수 있는 일이 못 하는 일로 굳는다. 이게 문서 정리의 문제가 아니라 판단의 문제라고 생각한다.

마지막으로, 앞에서 안 고치기로 한 벤더 버그들에는 별도의 장치가 붙어 있다. 타임존이 대표적이다. 변이 하나가 "그 버그를 고쳐버린" 상태를 주입하고, 대조 테스트가 그걸 잡는다. 회귀 가드가 아니라 **버그 보존 가드**다.

이게 필요한 이유는 단순하다. "이건 일부러 이렇게 둔 것"이라는 사실은 코드 주석만으로는 반드시 사라진다. 몇 달 뒤 누군가 그 줄을 보고 선의로 고칠 텐데, 그때 빨간불이 켜지지 않으면 아무도 못 잡는다. 안 고치기로 한 결정도 결정이라서, 지켜 줄 게이트가 없으면 유지되지 않는다.

## 만들지 않기로 한 것

같이 다시 만들 계획이던 패키지가 하나 더 있었다. 이 SDK의 React 바인딩이다. Provider와 훅 몇 개로 감싼 얇은 층이다.

시작하자마자 막혔다. **이쪽은 소스맵을 배포하지 않는다.** 복원할 원본이 없으니 재작성밖에 없는데, 재작성은 이 글 전체가 기대고 있는 근거를 못 쓴다는 뜻이다. 벤더 코드를 그대로 쓰는 게 아니라 내가 읽고 다시 짜는 것이므로, "로직을 안 고쳤다"는 주장 자체가 성립하지 않는다.

그래서 만들기 전에 질문을 하나 바꿨다. "어떻게 똑같이 만들까"가 아니라 **"이걸 정말 쓰고 있나"** 였다.

소비 저장소 여덟 곳을 훑어서 세어 봤다. 이 패키지가 내보내는 훅은 열한 개인데, 실제로 쓰이는 것은 Provider와 Context, 그리고 이벤트 전송 훅 셋뿐이었다. 평가 계열 훅은 **사용처가 한 곳도 없었다.** 열한 개 중 열 개가 죽어 있는 패키지를 근거도 약한 방식으로 재작성할 이유가 없었다.

그래서 안 만들기로 했다. 소비 측은 이벤트 전송 엔트리를 직접 쓰고, Provider와 훅 하나는 각자 서른 줄쯤으로 자체 구현한다.

다만 나중에 누군가 이걸 직접 만들 때 밟을 함정 하나는 기록해 뒀다. 워크스페이스 설정이 **마운트 이후에 비동기로 도착한다.** 그래서 클라이언트가 평가 준비를 마쳤다는 이벤트를 구독해 리렌더를 유발하지 않으면, 평가 훅이 영원히 기본값만 돌려준다. 화면에는 아무 에러도 안 뜨고 그냥 A안만 계속 보인다.

그리고 그 구독은 `useSyncExternalStore` 로 해야 한다. 벤더 구현은 `useState` 와 `useEffect` 조합인데, 그건 React 17 시절 코드다. React 18의 동시성 렌더에서는 렌더 도중 외부 값이 바뀌면 같은 화면 안에서 서로 다른 값을 읽는 테어링이 난다.

안 만든 것도 결정이라, 왜 안 만들었고 만들려면 무엇을 알아야 하는지까지 적어 두는 편이 낫다고 봤다.

## 통과했다는 사실이 주는 확신에 대해

정리하면 이 작업이 지금 할 수 있는 주장은 이렇다. **우리가 실제로 쓰는 경로는 벤더 배포본과 나란히 돌려 같은 값을 내는 것을 확인했다.** 이벤트 페이로드, 평가 결정, 타겟 매칭, URL 분기, 네이티브 브릿지가 여기 들어간다. **그리고 우리가 쓰지 않는 영역은 검증하지 않았다.** 메시지 UI 렌더러의 DOM 출력, 원격 평가 모드의 일부 경로, Node 엔트리가 그렇다.

뒤쪽이 부족한 게 아니라 의도한 범위라고 생각한다. 아무도 안 쓰는 기능의 동등성을 증명하는 데 시간을 쓰는 것보다, 쓰는 경로를 확실히 하고 나머지는 범위 밖이라고 적어 두는 편이 정직하다. 다만 그 목록이 사라지면 이야기가 달라진다. "검증했다"와 "검증 안 했다"가 문서에서 구분되지 않는 순간, 누군가는 안 쓰던 기능을 켜면서 이미 검증된 것으로 착각하게 된다.

이 작업에서 가장 오래 남을 것 같은 문장은 크기 표가 아니라 이것이다.

**검증 게이트가 검증 대상과 다른 것을 보고 있으면, 그 게이트는 없는 것보다 나쁘다.**

없으면 최소한 불안하기라도 하다. 있는데 엉뚱한 것을 보고 있으면 통과했다는 사실이 거짓 확신을 준다. 앞에서 적은 세 건은 전부 그 종류였다. 테스트는 있었고, 이름도 정확했고, 초록불이었고, 아무것도 안 보고 있었다.

지금은 검증 명령 하나가 타입체크, 린트, 테스트, 변이 배터리, 크기 게이트, 소비자 타입 해석을 순서대로 돈다. 4분 걸리고 대부분이 배터리다. 이 4분의 가치는 테스트가 통과한다는 확인이 아니라, **테스트가 여전히 무언가를 보고 있다는 확인**에 있다고 생각한다.

물론 이 방식도 만능은 아니다. 양쪽이 같은 이유로 아무것도 안 하면 대조는 통과하고, 변이는 사람이 의심한 지점만 찌른다. 대시보드에 지표가 제대로 반영되는지는 코드로는 끝내 닫을 수 없어서, 샌드박스 워크스페이스에 양쪽을 붙여 며칠 돌려 보는 일이 남아 있다. 그래서 지금 할 수 있는 정직한 주장은 "동일하다"가 아니라 "이만큼까지는 대조했고 나머지는 이 목록에 있다" 정도다. 그 목록을 지우지 않고 들고 다니는 것이, 재작성 같은 일에서는 결과물보다 중요한 산출물일지도 모르겠다.
