---
title: '외부 SDK를 다시 조립하기: 줄여야 할 코드, 지켜야 할 브라우저 동작'
marp: true
paginate: true
theme: feconf
header: 'FECONF 2026 / FRONTEND ENGINEERING'
footer: '카카오페이증권  /  김용찬'
transition: glide
tags:
  - web-performance
  - bundler
date: 2026-09-15
description: '상수 하나에서 시작한 외부 SDK 재구성. 초기화 경로의 import를 끊고 엔트리와 배포 구조를 바꿔 웹 전송용 번들의 gzip 크기를 69% 줄였습니다. 엔트리 분리로 빠진 자동 이벤트와 페이지 이탈 전송, history 패치가 숨긴 테스트 실패를 통해 지켜야 할 브라우저 동작까지 살펴봅니다.'
published: true
featured: true
art:
  undraw: app-benchmarks
---

<!-- _class: cover -->

<!-- _header: FECONF 2026 / KAKAOPAY SECURITIES -->

# 외부 SDK를<br>다시 조립했습니다

## 줄여야 할 코드,<br>지켜야 할 브라우저 동작

> **−69%**
>
> 웹 전송용 번들 / gzip

**김용찬** 카카오페이증권 프론트엔드 엔지니어

<!--
발표 25초 / 00:00~00:25

안녕하세요. 카카오페이증권 프론트엔드 개발자 김용찬입니다. 오늘은 반드시 써야 하는 외부 SDK를, 로직은 그대로 둔 채 다시 조립해서 웹 전송용 번들을 줄인 이야기를 하겠습니다. 줄이는 과정에서 브라우저에서 계속 동작해야 할 연결을 어떻게 지켰는지도 함께 말씀드리겠습니다.
-->

---

<!-- _class: profile -->

<!-- _header: FECONF 2026 / PROLOGUE -->

## 사용자가 처음 만나는 화면을 만듭니다

### 김용찬

카카오페이증권 / 홈팀 프론트엔드 엔지니어

- 『모던 리액트 Deep Dive』, 『npm Deep Dive』, 『프런트엔드 성능 최적화 Deep Dive』 저자
- DAN 24 연사

_[yceffort.kr](https://yceffort.kr) / [github.com/yceffort](https://github.com/yceffort)_

<!--
발표 20초 / 00:25~00:45

저는 사용자가 앱을 켜면 가장 먼저 보는 증권홈 화면을 개발합니다. 여기 있는 책 세 권을 썼고, 2024년 DAN에서도 발표했습니다. 오늘은 홈에서 사용하던 외부 의존성을 직접 분석하고 바꾼 경험을 공유하겠습니다.
-->

---

<!-- _class: columns -->

<!-- _header: FECONF 2026 / PROLOGUE -->

## 홈에는 이벤트 수집이 필요했습니다

> ### 홈에서 쓰는 기능
>
> **페이지뷰와 이벤트 전송**
>
> 화면 진입과 사용자 행동을<br>수집 서버에 전달

<!-- -->

> ### 함께 들어온 기능
>
> **A/B 테스트 평가 등 다른 기능**
>
> 홈에서 호출하지 않아도<br>SDK 초기화 경로에 연결

사내에서 이 SDK를 쓰는 저장소 8곳도 **대부분 이벤트 전송만** 사용했습니다

**쓰는 기능의 범위와 내려받는 코드의 범위가 달랐습니다**

<!--
발표 50초 / 00:45~01:35

이 SDK는 이벤트 수집 말고도 A/B 테스트 평가 같은 여러 기능을 한 패키지에서 제공했습니다. 홈에서 필요한 것은 화면 진입과 사용자 행동을 수집하는 경로였습니다. 그런데 그 기능을 쓰려고 SDK를 초기화하면 평가를 비롯한 다른 기능의 코드까지 연결됐습니다.

홈만의 사정도 아니었습니다. 사내에서 이 SDK를 쓰는 저장소 여덟 곳을 살펴보니 대부분 이벤트 전송만 하고 있었습니다. A/B 테스트 평가를 호출하는 곳도 소수였습니다.

SDK는 계속 사용해야 했습니다. 그래서 수집 기능을 유지하면서, 홈에서 쓰지 않는 기능까지 내려받는 비용을 줄일 수 있을지 살펴봤습니다. 이 발표의 출발점은 쓰는 기능과 가져오는 코드 사이의 차이입니다.
-->

---

<!-- _class: code-focus -->

<!-- _header: FECONF 2026 / PROLOGUE -->

## 시작은, 상수 하나였습니다

```ts
import {Status} from '@vendor/sdk'

console.log(Status.DEFAULT)
```

SDK 초기화도, 이벤트 전송도 하지 않았습니다

> **문자열 상수 하나를 읽었는데, 번들이 무거웠습니다**

_벤더와 패키지, 내부 이름은 가명이고 코드는 구조를 설명하기 위한 요약입니다. 브라우저 API와 기능의 역할, 측정값은 그대로 설명합니다_

<!--
발표 25초 / 01:35~02:00

먼저 패키지명과 내부 클래스 이름은 가명으로 설명하겠습니다. 브라우저 표준 API와 기능의 역할은 그대로 쓰고, 측정값과 실패의 원인 관계도 유지했습니다.

문제를 확인하려고 SDK에서 상수 하나만 가져오는 최소 예제를 만들었습니다. 초기화도 이벤트 전송도 하지 않고 문자열 하나를 출력했는데, 번들이 예상보다 훨씬 무거웠습니다.
-->

---

<!-- _class: signal -->

<!-- _header: FECONF 2026 / PROLOGUE -->

## 상수 하나가 실사용만큼 무거웠습니다

> **98%**
>
> 실제 초기화 대비, 상수 하나를 import한 번들의 raw 크기

| 시나리오         | raw (실제 초기화 = 100) | gzip (실제 초기화 = 100) |
| ---------------- | ----------------------- | ------------------------ |
| 상수 하나 import | 98                      | 97                       |
| 실제 초기화      | 100                     | 100                      |

_초기 분석 스냅샷 / esbuild 0.24 / gzip 레벨 9. 크기는 실제 초기화 번들을 100으로 둔 비율입니다_

<!--
발표 30초 / 02:00~02:30

상수만 가져온 번들을 SDK를 실제로 초기화하는 번들과 비교했습니다. raw 기준 98%, gzip 기준 97%가 남았습니다.

다만 이 최소 예제가 가벼워지는 것만으로 작업이 끝나지는 않습니다. 홈에서는 클라이언트를 만들고 이벤트를 보내야 합니다. 그래서 이때부터 최소 import와 실제 초기화를 나눠서 측정했습니다. 같은 변경이 두 시나리오에 어떤 영향을 주는지 보려는 겁니다.
-->

---

<!-- _class: chapter -->

<!-- _header: 01 / DIAGNOSIS -->

# 왜 안 쓰는 코드가<br>번들에 남았을까요?

## 실험으로 원인을 나눠 보겠습니다

> 01

<!--
발표 10초 / 02:30~02:40

상수만 쓰는데 왜 대부분의 코드가 남았을까요? 제거해도 안전한지와 실제로 참조하는지를 나눠 보겠습니다.
-->

---

<!-- _class: columns -->

<!-- _header: 01 / DIAGNOSIS -->

## 번들러가 코드를 남기는 이유를 나눴습니다

> ### 제거해도 안전한가?
>
> **부수효과를 확신하지 못함**
>
> 쓰지 않는 값이라도<br>생성 과정의 호출이 남을 수 있음
>
> `sideEffects`: 모듈 단위<br>`PURE`: 호출 단위의 힌트

<!-- -->

> ### 실행 경로에서 참조하는가?
>
> **초기화 코드에서 도달 가능**
>
> 전송용 초기화가<br>평가기 등 다른 기능의 생성자를 참조
>
> 어떤 엔트리가 무엇을 가져오는지<br>**import 관계를 확인**

<!--
발표 40초 / 02:40~03:20

번들러가 코드를 남기는 이유를 두 가지로 나눠 봤습니다. 첫째는 제거해도 안전한지 확신하지 못하는 경우입니다. 반환값은 쓰지 않아도, 그 값을 만드는 함수 호출이 다른 상태를 바꿀 수 있다면 호출은 남겨야 합니다.

둘째는 실제 실행 경로에서 참조하는 경우입니다. 초기화 함수가 평가기 같은 다른 기능의 생성자를 가져온다면, 그 코드는 초기화에서 도달할 수 있습니다. 이때는 어떤 경로가 그 기능을 끌어오는지 봐야 합니다.

sideEffects나 PURE 같은 힌트로 해결할 수 있는 부분과, 조립 구조를 바꿔야 할 부분을 실험으로 나눠 보겠습니다.
-->

---

<!-- _class: compact -->

<!-- _header: 01 / DIAGNOSIS -->

## 클래스가 배포본에서는 함수 호출이었습니다

```js
// ES5로 내려간 클래스의 형태를 요약
var Evaluator = (function () {
  function Evaluator() {}
  Evaluator.prototype.evaluate = evaluate
  return Evaluator
})()
```

배포본에는 이런 **클래스 IIFE**가 있었고, PURE 표시가 없었습니다

> 이 호출에 힌트를 주면, **실사용 번들도 줄어들까요?**

<!--
발표 35초 / 03:20~03:55

배포본을 열어 보니 클래스가 ES5의 즉시 실행 함수 형태로 바뀌어 있었습니다. 소스에서는 클래스 하나였지만 배포된 JavaScript에서는 함수를 호출해 클래스 값을 만드는 모양입니다.

이 배포본에는 해당 호출에 PURE 표시가 없었습니다. 그래서 호출에 힌트를 넣었을 때 번들 크기가 어떻게 달라지는지 실험했습니다. 여기서 알고 싶은 것은 상수 예제가 줄어드는지만이 아니라, 실제로 초기화하는 경로에서도 효과가 있는지였습니다.
-->

---

<!-- _class: compact -->

<!-- _header: 01 / DIAGNOSIS -->

## 같은 PURE 힌트, 실사용에서는 약 1%

```js
const module = /*#__PURE__*/ createModule()
```

배포본의 클래스 IIFE에 [PURE 주석](https://esbuild.github.io/api/#pure)을 주입했습니다

| 시나리오         | PURE 주입 후 gzip (원본 = 100) | 변화        |
| ---------------- | ------------------------------ | ----------- |
| 상수 하나 import | 26                             | **약 −74%** |
| 실제 초기화      | 99                             | **약 −1%**  |

**홈에서 쓰는 초기화 경로의 참조를 바꿔야 했습니다**

_초기 분석 스냅샷의 실험_

<!--
발표 35초 / 03:55~04:30

결과가 크게 갈렸습니다. 상수만 가져오는 번들은 gzip 크기가 약 74% 줄었습니다. 반면 실제로 클라이언트를 만들면 약 1%만 줄었습니다.

첫 번째 결과만 봤다면 빌드 설정을 바꾸는 것으로 충분하다고 생각했을 겁니다. 그런데 홈에서 필요한 것은 두 번째 경로입니다. 초기화에서 여러 기능을 실제로 참조하니, 힌트만으로는 대부분의 코드가 그대로 남았습니다.

따라서 다음 작업은 초기화 경로를 따라가며 전송에 필요한 부분을 따로 조립하는 것이었습니다.
-->

---

<!-- _class: chapter -->

<!-- _header: 02 / REASSEMBLY -->

# 필요한 코드만<br>연결할 수 있을까?

## 원본을 확보하고, 평가기로 가는 경로를 끊습니다

> 02

<!--
발표 10초 / 04:30~04:40

이제 전송에 필요한 코드만 따로 조립해 보겠습니다. 그 전에 어디까지 손댈지부터 정했습니다.
-->

---

<!-- _class: compact -->

<!-- _header: 02 / REASSEMBLY -->

## 로직은 그대로 두고, 조립만 바꾸기로 했습니다

> **이 SDK가 보내는 이벤트는 실험 판정의 근거입니다**<br>로직을 고치는 순간 대시보드 숫자의 의미가 달라집니다

| 선택지                        | 판단                                              |
| ----------------------------- | ------------------------------------------------- |
| 새로 구현                     | 실험 배정 계산을 원본과 똑같이 재현해야 해서 위험 |
| 포크                          | 원본 저장소가 공개되지 않아 불가                  |
| **배포물에서 복원 후 재조립** | **로직은 원본 그대로, 엔트리와 의존성만 교체**    |

_알려진 결함도 고치지 않고 원본 동작을 유지했습니다_

<!--
발표 50초 / 04:40~05:30

손댈 범위부터 정했습니다. 이 SDK가 보내는 이벤트는 대시보드로 흘러가서 A/B 테스트 판정의 근거가 됩니다. 우리가 로직을 조금이라도 고치면, 그때부터 대시보드의 숫자가 무엇을 뜻하는지 아무도 확신할 수 없습니다. 그래서 알려진 결함까지 포함해 로직은 원본 그대로 두기로 했습니다.

그 조건에서 선택지는 셋이었습니다. 같은 API로 새로 구현하면 가장 깔끔하지만, 사용자를 실험군에 배정하는 계산을 원본과 똑같이 재현해야 합니다. 하나라도 어긋나면 에러 없이 배정이 달라집니다. 포크는 원본 저장소가 공개되어 있지 않아 할 수 없었습니다.

남은 길은 배포된 결과물에서 원본을 복원하고, 로직은 그대로 둔 채 엔트리와 의존성, 빌드만 바꾸는 것이었습니다.
-->

---

<!-- _class: recovery -->

<!-- _header: 02 / REASSEMBLY -->

## 소스맵에서 원본 TypeScript를 확보했습니다

`sourcesContent`에 **원본 코드 전문**이 들어 있었습니다

- **브라우저 소스맵**<br>브라우저 런타임 구현
- **Node 소스맵**<br>브라우저 맵에서 빠진 구현
- **타입 선언**<br>빠진 타입 전용 모듈 보충

> 핵심 로직을 확보하고, **초기화와 의존성의 연결부터 살폈습니다**

_소스맵과 선언을 합쳐 수백 개 파일을 복원했습니다_

<!--
발표 35초 / 05:30~06:05

구조를 바꾸려면 먼저 원본을 봐야 했습니다. SDK와 함께 배포된 소스맵의 sourcesContent에 원본 TypeScript가 들어 있었습니다. 브라우저 소스맵과 Node 소스맵에서 구현을 꺼내고, 빠진 타입 전용 모듈은 선언 파일로 보충했습니다.

핵심 로직을 처음부터 다시 구현할 필요가 없어진 겁니다. 그 코드를 바탕으로 무엇을 유지할지, 어떤 조립과 의존성을 바꿀지 정할 수 있었습니다.

구체적인 추출 순서는 부록에 남겨 두었습니다.
-->

---

<!-- _class: compact -->

<!-- _header: 02 / REASSEMBLY -->

## 플래그를 껐지만, import는 남았습니다

```ts
// 처음 시도한 조립 방식의 요약
import {Evaluator} from './evaluation/Evaluator'

export function createCore(options) {
  if (options.evaluation) registry.register(new Evaluator())
  return buildCore(registry)
}
```

전송용에서는 `evaluation: false`를 넘겼지만,<br>값이 실행할 때 정해지므로 **번들러는 평가기 참조를 남겼습니다**

> 기능의 생성 여부를 바꿔도, **모듈의 연결은 그대로였습니다**

<!--
발표 50초 / 06:05~06:55

처음에는 어떤 평가기를 등록할지 플래그로 받았습니다. 전송용 엔트리에서 false를 넘기면 평가기를 만들지 않으니 번들에서도 빠질 것 같았습니다.

실행할 때 생성하지 않는다는 목적은 달성했습니다. 하지만 플래그 값은 실행할 때 정해지므로 번들러가 이 분기를 제거할 근거가 없습니다. 공통 코어 파일에 평가기를 import하고 생성하는 경로가 그대로 남았습니다.

모든 조건 분기가 트리셰이킹을 막는다는 뜻은 아닙니다. 빌드할 때 값이 정해지는 상수라면 번들러가 분기를 제거할 수 있습니다. 여기서는 값이 함수 인자로 들어오니 그럴 수 없었습니다. 그래서 플래그 값에 기대는 대신 전송 엔트리에서 평가기를 향하는 import 자체를 없애는 쪽으로 바꿨습니다.
-->

---

<!-- _class: assembly compact -->

<!-- _header: 02 / REASSEMBLY -->

## 평가기를 만드는 코드를 별도 파일로 옮겼습니다

```ts
// core.ts: 평가기 구현을 import하지 않음
import type {Components} from './contracts'
export function createCore(parts: Components) {
  return new Core(parts)
}

// assembleEvaluation.ts: 평가가 필요한 쪽에서만 사용
import {Evaluator} from './evaluation/Evaluator'
export const assembleEvaluation = () => ({evaluator: new Evaluator()})
```

**코어는 완성된 구성요소를 받고, 엔트리가 조립을 선택합니다**

<!--
발표 45초 / 06:55~07:40

평가기의 생성과 등록을 코어 바깥의 별도 조립 파일로 옮겼습니다. 코어는 이미 만들어진 구성요소를 받고, 평가기 구현을 직접 import하지 않도록 했습니다.

화면 위쪽 코어의 Components는 타입 참조입니다. 런타임에 평가기 구현으로 이어지는 import가 아닙니다. 실제 평가기를 만드는 코드는 아래 조립 파일에 있습니다.

이제 평가가 필요한 엔트리만 그 조립 파일을 가져오면 됩니다. 실제 구현에서도 전송 전용 코어 생성 경로를 따로 두어 평가기와 매처, 버킷팅 구현을 끌어오지 않게 했습니다. 기능 플래그로 생성 여부를 바꾸던 경계를 파일의 의존 관계로 옮긴 겁니다.
-->

---

<!-- _class: columns compact -->

<!-- _header: 02 / REASSEMBLY -->

## 엔트리마다 가져오는 조립이 달라졌습니다

> ### 전송용 `./send`
>
> ```text
> index.send.ts
>   → assembleTransport.ts
>   → core.ts
> ```
>
> 전송 조립으로 코어 생성<br>**평가기 구현의 import 없음**

<!-- -->

> ### 평가 포함 `./evaluate`
>
> ```text
> index.evaluate.ts
>   → assembleTransport.ts
>   → assembleEvaluation.ts
>   → core.ts
> ```
>
> 전송과 평가 조립으로 코어 생성

**전송 경로에서 평가기 구현으로 가는 import가 사라졌습니다**

<!--
발표 45초 / 07:40~08:25

같은 코어를 쓰더라도 엔트리에서 가져가는 조립은 달라집니다. 왼쪽 전송용은 전송에 필요한 구성요소로 코어를 만듭니다. 오른쪽은 전송에 평가 구성요소를 더해서 만듭니다.

여기서 확인할 것은 메서드 이름이 아닙니다. 왼쪽 파일의 import를 따라가도 평가기 구현에 닿지 않는다는 점입니다. 평가기 생성자를 실제로 참조하는 파일은 오른쪽 엔트리에서만 가져갑니다.

이렇게 공통 코어에서 평가기 구현으로 가는 import를 없애고 나서야 전송용 번들에서 관련 코드가 빠졌습니다. 공개 API를 어디에 노출할지와 내부 구현을 어느 파일에서 가져올지를 함께 나눠야 했습니다.
-->

---

<!-- _class: compact -->

<!-- _header: 02 / REASSEMBLY -->

## 나눈 구조를 배포 산출물에도 남겼습니다

`preserveModules`: **원본 모듈 단위로 파일을 나눠 출력하는 Rollup 옵션**

```ts
// Rollup 출력 설정
const output = {format: 'es', preserveModules: true}
```

| 소스에서 나눈 것   | 패키지를 사용하는 쪽에 전달할 것       |
| ------------------ | -------------------------------------- |
| 전송과 평가 엔트리 | `exports`의 `./send`, `./evaluate`     |
| 파일별 의존 관계   | ESM 모듈과 import 관계를 보존한 산출물 |

**소비자는 패키지 이름과 서브패스로 필요한 경로를 선택합니다**

_[Rollup 공식 문서](https://rollupjs.org/configuration-options/#output-preservemodules)_

<!--
발표 70초 / 08:25~09:35

소스에서 나눈 구조를 패키지로 배포할 때도 살렸습니다. preserveModules는 Rollup이 원본 모듈 단위로 파일을 나눠 출력하게 하는 옵션입니다. 예를 들어 코어와 평가 조립 모듈이 각각의 JavaScript 파일로 남습니다. ESM 형식으로 출력하면 파일 사이의 import 관계도 이어집니다. 쓰지 않는 모듈과 export를 제거하는 트리셰이킹은 계속 적용됩니다.

빌드 타깃은 ES2017로 맞췄고, package.json의 exports에는 기능별 엔트리를 연결했습니다.

그 결과 소비자는 전송용 서브패스를 가져오고, 소비자의 번들러는 그 엔트리에서 이어지는 모듈을 처리할 수 있습니다. 전송과 평가의 경계가 소스 디렉터리에만 있는 것이 아니라 배포된 패키지에도 남는 겁니다.

앞에서 실제 참조를 끊은 구조가 있었기 때문에 이 설정이 의미가 있었습니다. 크기는 dist 파일을 직접 읽는 대신 패키지 이름으로 import해서, 소비자가 받는 번들로 다시 측정했습니다.
-->

---

<!-- _class: compact -->

<!-- _header: 02 / REASSEMBLY -->

## 파일 검색으로 놓친 참조가 있었습니다

base64 사용처만 검색했을 때는 **전송 엔트리와 무관해 보였습니다**

```text
검색한 파일 목록                 실제 번들에서 따라간 경로
util/base64.ts                   index.send.ts
fetcher/MetadataLoader.ts          → … → ConfigLoader.ts
fetcher/ConfigLoader.ts                  → base64.ts
```

```bash
esbuild src/index.send.ts --bundle --metafile=meta.json
```

**metafile의 import 관계로, 엔트리부터 연결된 경로를 확인했습니다**

<!--
발표 45초 / 09:35~10:20

어떤 코드가 따라오는지 판단할 때 한 번 더 틀렸습니다. base64 라이브러리를 걷어내면서 사용처를 검색했는데, 결과에 전송 엔트리 파일이 없었습니다. 그래서 전송용 번들에는 영향이 없을 것으로 예상했습니다.

하지만 실제 번들 그래프를 보니 전송 엔트리에서 설정을 읽는 모듈을 거쳐 base64까지 이어지는 경로가 있었습니다. 사용처 파일 목록과 엔트리에서 출발한 의존 관계는 다른 정보였습니다.

이후에는 해당 엔트리를 빌드하고 metafile의 inputs와 imports를 따라갔습니다. 어떤 기능을 뺐다고 생각했는지와 실제로 어떤 파일이 빠졌는지를 산출물에서 맞춰 보는 과정입니다.
-->

---

<!-- _class: compact -->

<!-- _header: 02 / REASSEMBLY -->

## 공통 비용도 사용처와 출력 규칙을 보고 줄였습니다

| 대상        | 변경 전 확인한 것                | 변경                                |
| ----------- | -------------------------------- | ----------------------------------- |
| 폴리필      | 사용 메서드와 브라우저 지원 범위 | `flat`과 `flatMap` 3곳 치환 후 제거 |
| UUID        | 기존 식별자의 형식               | Web Crypto와 UUID 폴백              |
| base64      | 원본 라이브러리의 출력           | `TextEncoder` + `btoa`              |
| 쿼리 문자열 | 반복 키와 공백 인코딩            | 표준 API로 교체 후 대조             |

외부 런타임 의존성 **0개** / 빌드 문법 타깃 **ES2017**

**문법 타깃과 웹 API 지원은 별개입니다**<br>필요한 API의 지원 하한을 팀 browserslist 타깃과 대조했습니다

<!--
발표 55초 / 10:20~11:15

엔트리를 나눈 다음에는 공통으로 들어가는 비용을 살폈습니다. 폴리필은 사용처를 조사하고, flat과 flatMap을 쓰던 세 곳을 치환한 뒤 제거했습니다.

UUID는 기존 형식을 유지했고, base64는 플랫폼 API로 바꾼 결과를 원본 라이브러리와 비교했습니다. 쿼리 문자열도 반복 키나 공백 인코딩 규칙이 달라질 수 있어 결과를 대조했습니다. 의존성을 지웠다는 사실보다 외부로 나가는 값이 어떤 규칙을 따라야 하는지가 먼저였습니다.

외부 런타임 의존성을 없앴다고 실행 환경의 조건까지 없어지는 것은 아닙니다. ES2017은 문법 타깃이고, TextEncoder나 btoa, Web Crypto는 별도로 지원을 확인해야 합니다. 필요한 API의 지원 하한을 팀의 브라우저 타깃과 대조했습니다.
-->

---

<!-- _class: columns -->

<!-- _header: 02 / REASSEMBLY -->

## 전송용 엔트리의 범위를 정했습니다

> ### 웹 전송용 SDK
>
> **웹 이벤트 수집**
>
> 페이지뷰와 이벤트 생성<br>웹 사용자 상태와 전송<br>페이지 이탈 시 남은 이벤트 처리

<!-- -->

> ### 사용하는 서비스
>
> **필요한 웹뷰 연동**
>
> 앱 환경과 연결하는 코드는<br>필요한 서비스에서 별도로 구성

**웹 수집은 SDK가, 앱 연동은 사용하는 서비스가 맡습니다**

<!--
발표 40초 / 11:15~11:55

여기서 전송용 엔트리의 범위를 분명히 하겠습니다. 이 발표에서 다루는 전송용은 웹 이벤트 수집을 담당합니다. 페이지뷰와 이벤트를 만들고, 웹의 사용자 상태를 다루고, 필요한 시점에 데이터를 보냅니다.

앱 환경과의 웹뷰 연동이 필요한 서비스는 사용하는 쪽에서 그 연결을 별도로 구성하도록 했습니다. 전송용 SDK와 소비하는 서비스 사이의 책임 범위를 이렇게 정했습니다.

여기까지가 무엇을 줄일지 정하고 구조를 바꾼 과정입니다. 그런데 코드를 옮기고 엔트리를 나누는 사이에, 크기와 함께 사라진 것이 있었습니다.
-->

---

<!-- _class: chapter -->

<!-- _header: 03 / BROWSER BEHAVIOR -->

# 코드가 남아 있어도,<br>동작은 빠질 수 있습니다

## 엔트리를 나누며 함께 옮겨야 했던 것

> 03

<!--
발표 10초 / 11:55~12:05

필요한 함수를 남겨 둔 것만으로는 충분하지 않았습니다. 엔트리를 나누는 과정에서 그 함수를 실행시키는 연결을 놓쳤습니다.
-->

---

<!-- _class: compact -->

<!-- _header: 03 / BROWSER BEHAVIOR -->

## 전송 함수는 있는데, 자동 이벤트가 사라졌습니다

엔트리를 나누며 **자동 동작을 연결하는 코드가 전체 엔트리에만 남았습니다**

| 시점        | 유지해야 할 동작            | 빠졌던 연결                  |
| ----------- | --------------------------- | ---------------------------- |
| 초기화      | 세션과 페이지뷰 이벤트 생성 | 자동 이벤트 트래커와 초기화  |
| SPA 이동    | 페이지 변경을 이벤트로 수집 | 라이프사이클과 페이지 리스너 |
| 페이지 이탈 | 큐에 남은 이벤트 전송       | 이벤트 프로세서 리스너       |

> **등록이 빠져도 에러 없이, 이벤트만 조용히 사라졌습니다**

_원본과 대조한 것은 전체 엔트리뿐이었고, 나눈 엔트리는 공개 API만 검사했습니다_

<!--
발표 60초 / 12:05~13:05

전송 함수는 남아 있었고, 공개 API가 있는지 확인하는 검사도 통과했습니다. 그런데 SDK에는 사용자가 직접 전송 함수를 호출하지 않아도 일어나는 동작이 있습니다. 초기화 때 세션과 페이지뷰를 만들고, 화면이 이동하면 페이지 변경을 수집하고, 이탈할 때는 남은 이벤트를 보냅니다.

엔트리를 나누면서 이 동작을 연결하는 코드가 전체 엔트리에만 남았습니다. 다른 엔트리에는 함수가 있어도 트래커와 리스너, 라이프사이클을 여는 연결이 빠져 있었습니다. 등록이 빠지면 에러가 나지 않습니다. 이벤트가 나가지 않을 뿐입니다. 원본과 결과를 대조하는 테스트는 전체 엔트리만 대상으로 했기 때문에, 나눈 엔트리의 자동 동작은 아무도 보고 있지 않았습니다.

그래서 엔트리가 어떤 메서드를 제공하는지와 별도로, 언제 어떤 브라우저 이벤트에 반응하는지를 확인해야 했습니다.
-->

---

<!-- _class: flow -->

<!-- _header: 03 / BROWSER BEHAVIOR -->

## 페이지 이탈에서 전송까지, 연결을 복원했습니다

1. **`pagehide`**<br>브라우저의 이탈 이벤트를<br>라이프사이클로 전달
2. **이벤트 프로세서**<br>리스너가 호출되어<br>`flush(true)` 실행
3. **`sendBeacon`**<br>큐에 남은 이벤트를<br>전송 요청에 담음

이벤트 프로세서의 리스너 등록을 **공통 조립으로 옮겼습니다**

> 전송용 엔트리에도 **생성 → 구독 → 초기화 → 실행**이 이어져야 합니다

<!--
발표 45초 / 13:05~13:50

페이지 이탈을 예로 보겠습니다. 브라우저의 pagehide를 라이프사이클 매니저가 받고, 등록된 이벤트 프로세서가 flush를 호출합니다. beacon을 지원하는 환경에서는 남은 이벤트를 sendBeacon 요청에 담습니다.

이벤트 프로세서 클래스만 번들에 남아 있어서는 이 경로가 이어지지 않습니다. 인스턴스를 만들고, 리스너로 등록하고, 라이프사이클을 초기화해야 합니다. 그래서 모든 관련 엔트리가 가져야 하는 등록을 공통 조립으로 옮겼습니다.

세션과 페이지뷰 트래커도 같은 기준으로 연결을 복원했습니다. 함수 본문을 유지하는 것에 더해 누가 그 함수를 언제 실행하는지까지 옮겨야 했습니다.
-->

---

<!-- _class: compact -->

<!-- _header: 03 / BROWSER BEHAVIOR -->

## 리스너가 아니라, 실제 전송 요청을 검사했습니다

```ts
// 각 엔트리를 부팅한 뒤 실행하는 테스트의 요약
client.send({name: 'before_leave'})
expect(captured.async).not.toContain('before_leave')

window.dispatchEvent(new Event('pagehide'))
await vi.advanceTimersByTimeAsync(0)

expect(captured.beacon).toContain('before_leave')
```

초기화 후 **세션과 페이지뷰 생성**도 엔트리별로 확인했습니다
리스너 등록을 일부러 지우면, **이 검사가 실패해야 합니다**

<!--
발표 40초 / 13:50~14:30

테스트에서는 리스너가 등록됐는지만 확인하지 않았습니다. 각 엔트리를 부팅한 뒤 이벤트 하나를 큐에 넣고, 아직 배치 전송되지 않았는지 봅니다. 그 상태에서 pagehide를 발생시키면 beacon 요청 본문에 그 이벤트가 들어 있어야 합니다.

이렇게 검사하면 전송 함수의 존재뿐 아니라 이탈 이벤트에서 전송까지의 연결을 확인할 수 있습니다. 초기화 때 세션과 페이지뷰 이벤트가 실제로 생성되는지도 엔트리마다 봤습니다.

그리고 해당 리스너 등록을 일부러 지웠습니다. 이때 테스트가 실패해야 이 연결을 정말 보고 있다고 말할 수 있습니다.
-->

---

<!-- _class: compact -->

<!-- _header: 03 / BROWSER BEHAVIOR -->

## 원본과 재구성본을 같은 입력으로 실행했습니다

```ts
// 대조 하네스의 역할을 요약
const original = await bootVendorInJSDOM(config)
const rebuilt = await bootRebuilt(config)

expect(await runScenario(rebuilt, input)).toEqual(
  await runScenario(original, input),
)
```

원본 UMD는 별도 jsdom에서, 재구성본은 테스트의 jsdom에서 실행<br>**설정 응답과 브라우저 조건을 맞추고 반환값과 전송 필드를 대조**

<!--
발표 60초 / 14:30~15:30

전송용뿐 아니라 평가와 URL 리다이렉트 같은 다른 웹 엔트리도 함께 재구성했습니다. 이 엔트리들은 원본과 같은지 확인해야 했는데, 기대값을 제가 직접 적는 것만으로는 알기 어려웠습니다. 원본을 잘못 읽으면 그 오해가 기대값에도 들어갈 수 있기 때문입니다. 그래서 배포된 원본 UMD를 별도 jsdom에서 실행하고, 재구성본은 테스트의 jsdom에서 같은 설정과 입력으로 실행했습니다.

설정 요청에는 준비한 응답을 주고, 필요한 브라우저 조건도 맞췄습니다. 이벤트는 전송 요청에서 정한 필드를 비교했습니다. 난수와 시각, 두 환경의 URL에서 유래하는 값은 비교 방법을 따로 정했고, 의도적인 차이는 각각 단언했습니다.

이 대조 테스트는 AI와 함께 작성했습니다. 시나리오를 늘리는 데 도움이 됐지만, 통과하는 테스트가 실제 차이를 잡는지는 다음 단계에서 따로 확인해야 했습니다.
-->

---

<!-- _class: mutation compact -->

<!-- _header: 03 / BROWSER BEHAVIOR -->

## URL 조건을 무조건 true로 바꿨습니다

리다이렉트 엔트리: **실험 대상 URL이면 다른 페이지로 보내고, 반복을 막는 쿠키를 남깁니다**

> `matches(url)` → `true`

**URL이 맞지 않아도 리다이렉트하도록 일부러 변경했습니다**

```ts
// 실패해야 하는 기존 대조 시나리오
expect(redirectAttempts).toEqual([])
```

그런데 테스트는 계속 통과했습니다

_고의 변경을 넣어 검출 여부를 보는 뮤테이션 테스트였습니다_

<!--
발표 65초 / 15:30~16:35

이번에는 전송용이 아니라 리다이렉트 엔트리입니다. A/B 테스트 대상 URL에 들어온 사용자를 실험 페이지로 보내고, 같은 사용자가 반복해서 이동하지 않도록 쿠키를 남기는 기능입니다. SPA 이동에도 반응하려고 브라우저의 history를 패치합니다. 그래서 이 사례는 SDK가 바꾼 브라우저 상태가 테스트 환경에까지 남은 이야기가 됩니다.

URL이 조건에 맞지 않으면 리다이렉트를 시도하지 않아야 한다는 테스트가 있었습니다. 이 조건 판정을 무조건 true로 바꿨습니다. 이제는 맞지 않는 URL도 리다이렉트를 시도해야 하므로 기존 테스트가 실패해야 합니다.

그런데 통과했습니다. 이렇게 소스를 일부러 바꾸고 어떤 테스트가 실패하는지 보는 것이 뮤테이션 테스트입니다.

이때는 통과했다는 결과만으로 이유를 알 수 없습니다. 변경한 코드까지 갔는지, 결과를 제대로 관찰했는지부터 따라가야 합니다. 원인을 찾아보니 브라우저의 history에 남아 있던 래퍼가 문제였습니다.
-->

---

<!-- _class: compact -->

<!-- _header: 03 / BROWSER BEHAVIOR -->

## close 뒤에도 history의 래퍼가 남았습니다

```ts
// SDK의 라우팅 감지 구조를 요약
const original = history.pushState
history.pushState = function (...args) {
  const result = original.apply(history, args)
  instance.onLocationChange()
  return result
}
```

`client.close()`가 **`pushState`와 `replaceState` 패치를 되돌리지 않았습니다**

> 이전 테스트의 인스턴스가<br>다음 테스트의 **라우팅에도 반응했습니다**

<!--
발표 55초 / 16:35~17:30

SDK는 SPA의 이동을 감지하려고 history.pushState와 replaceState를 감쌌습니다. 래퍼 안에서는 원래 함수를 부른 다음 자기 인스턴스에 위치 변경을 알립니다.

문제는 client.close를 호출해도 이 패치가 되돌아가지 않았다는 점입니다. 테스트가 인스턴스를 만들 때마다 래퍼가 한 겹씩 쌓였고, 래퍼의 클로저는 이전 인스턴스를 계속 잡고 있었습니다.

그래서 다음 테스트가 URL을 설정하려고 pushState를 호출할 때 앞 테스트의 인스턴스도 반응했습니다. 모듈을 다시 읽고 스토리지를 비우는 것만으로는 이 함수 참조가 원래대로 돌아오지 않았습니다. SDK가 바꾼 브라우저 전역 상태는 SDK를 닫아도 남았고, 테스트도 그 영향 밖에 있지 않았습니다.
-->

---

<!-- _class: lanes -->

<!-- _header: 03 / BROWSER BEHAVIOR -->

## 이전 인스턴스가 먼저 리다이렉트했습니다

> ### 이전 인스턴스
>
> `pushState` 래퍼에 남아 있음
>
> 1. 다음 테스트의 URL 변경에 반응
> 2. 바뀐 판정으로 리다이렉트 실행
> 3. 반복 리다이렉트를 막는 쿠키 기록

<!-- -->

> ### 이번 인스턴스
>
> 테스트가 관찰하려던 대상
>
> 4. 가드 쿠키를 보고 조기 종료
> 5. 변경한 URL 조건문에 도달 못 함
> 6. “리다이렉트 시도 없음”으로 통과

> **관찰 대상이 조용했던 이유는, 조건을 올바르게 판정해서가 아니었습니다**

_이전 인스턴스의 실행은 이번 테스트의 관찰 범위 밖에서 먼저 일어났습니다_

<!--
발표 50초 / 17:30~18:20

실행 순서를 따라가 보겠습니다. 다음 테스트가 부팅하면서 URL을 바꾸면, 아직 래퍼에 남아 있는 이전 인스턴스가 먼저 반응합니다. 무조건 true로 바꾼 코드가 적용되어 있으니 리다이렉트를 실행하고, 반복 실행을 막는 가드 쿠키를 남깁니다. 이 동작은 이번 테스트가 관찰하는 범위 밖에서 먼저 일어납니다.

그다음 이번 테스트의 인스턴스가 실행됩니다. 그런데 가드 쿠키가 있으니 이미 처리된 것으로 보고 조기 종료합니다. 제가 망가뜨린 URL 조건문까지 가지도 않습니다.

이번 인스턴스만 보면 리다이렉트 시도가 없습니다. 그래서 테스트가 통과한 겁니다. 원본에서 우연히 정상 경로를 밟던 테스트가 고의 변경을 넣자 다른 경로로 빠졌는데도, 관찰 결과는 기대값과 같았습니다.
-->

---

<!-- _class: compact -->

<!-- _header: 03 / BROWSER BEHAVIOR -->

## 테스트 부팅 전에 history를 복원했습니다

```ts
// SDK가 패치하기 전에 원래 함수를 보관
const nativePushState = history.pushState
const nativeReplaceState = history.replaceState

function restoreHistory() {
  history.pushState = nativePushState
  history.replaceState = nativeReplaceState
}
```

부팅 전에 복원한 뒤 같은 판정을 다시 바꾸자, **테스트가 실패했습니다**

_여기서 고친 것은 테스트 격리입니다. SDK의 close 동작은 원본과 맞춰 유지했습니다_

<!--
발표 40초 / 18:20~19:00

테스트에서 SDK가 패치하기 전의 pushState와 replaceState를 보관하고, 부팅 직전에 원래 함수로 돌려놓았습니다. 이전 인스턴스가 남긴 래퍼 체인을 끊은 겁니다.

그 상태에서 같은 조건을 다시 무조건 true로 바꿨더니 이번에는 테스트가 실패했습니다. 의도한 경로에 도달하고 결과의 차이를 관찰하게 됐습니다.

이 사례에서 수정한 것은 SDK의 close가 아니라 테스트 격리입니다. 원본에 맞춰 유지할 동작과, 그 동작을 검사하기 위해 격리해야 하는 환경을 구분했습니다.
-->

---

<!-- _class: chapter -->

<!-- _header: 04 / EVIDENCE -->

# 줄인 결과를<br>어디에서 확인할까요?

## 소비자가 받는 패키지에서 홈의 화면까지

> 04

<!--
발표 15초 / 19:00~19:15

실행 연결을 확인했으니, 이제 소비자가 받는 패키지와 실제 홈에서 결과를 보겠습니다. 패키지와 크기, 화면 성능, 수집 동작을 차례로 확인했습니다.
-->

---

<!-- _class: compact -->

<!-- _header: 04 / EVIDENCE -->

## 배포할 패키지를 소비자처럼 확인했습니다

```ts
import {createClient} from '@rebuilt/sdk/send'

const client = createClient(config)
client.send({name: 'sample'})
```

| 확인할 것             | 검사 대상                               |
| --------------------- | --------------------------------------- |
| 실제로 가져가는 크기  | 패키지 이름으로 import한 소비자 번들    |
| 배포 후 실행되는 동작 | 빌드 산출물의 엔트리별 대표 시나리오    |
| 소비자가 읽는 타입    | 배포된 `.d.ts`를 사용하는 별도 프로젝트 |

**소스의 통과 결과를 빌드 산출물과 소비자 환경에서도 확인했습니다**

<!--
발표 40초 / 19:15~19:55

지금까지 본 것은 주로 소스와 테스트 환경의 실행입니다. 하지만 소비자는 빌드한 패키지를 받습니다. 그래서 실제로 배포할 패키지를 별도 프로젝트에 놓고 패키지 이름과 서브패스로 가져왔습니다.

크기도 이 소비자 코드를 번들해서 쟀습니다. 빌드된 패키지의 각 엔트리는 대표 시나리오로 원본과 대조했습니다. 타입도 우리 소스가 통과하는지만 보지 않고, 소비자가 배포된 선언 파일을 해석할 수 있는지 별도로 검사했습니다.

exports와 빌드 설정, 선언 파일까지 바꾼 작업이므로 최종 확인 단위도 소비자가 받는 패키지여야 했습니다.
-->

---

<!-- _class: results -->

<!-- _header: 04 / EVIDENCE -->

## 웹 전송용 번들은 gzip 약 69% 줄었습니다

> **−69%**
>
> 벤더 원본 대비

| 가져가는 엔트리        | raw (원본 = 100) | gzip (원본 = 100) | 벤더 대비 gzip 감소율 |
| ---------------------- | ---------------- | ----------------- | --------------------- |
| **`./send` 웹 전송용** | **26**           | **31**            | **약 −69%**           |
| `./evaluate` 평가 포함 | 45               | 49                | 약 −51%               |
| `.` 전체 엔트리        | 69               | 70                | 약 −30%               |
| 벤더 원본              | 100              | 100               | 기준                  |

**엔트리 분리와 빌드, 폴리필, 의존성 변경을 함께 반영한 결과입니다**

_2026-09-05 웹용 측정본 / 패키지 이름으로 import / esbuild 0.24.2 / browser / esm / es2017 / gzip 9 / raw는 minify 후 압축 전 크기_

<!--
발표 30초 / 19:55~20:25

웹용 측정본의 결과입니다. 벤더 원본을 100으로 두면 웹 전송용의 gzip 크기는 약 31입니다. 약 69% 줄었습니다. 평가를 포함한 엔트리는 약 51%, 전체 엔트리도 약 30% 줄었습니다.

엔트리 분리 하나의 효과는 아닙니다. 모듈 구조와 빌드 타깃, 폴리필과 의존성 변경을 함께 적용한 결과입니다. 이 표는 배포할 패키지를 소비자 방식으로 가져와 측정한 값입니다.
-->

---

<!-- _class: outcomes compact -->

<!-- _header: 04 / EVIDENCE -->

# 홈의 첫 화면과 초기 CPU 비용도 줄였습니다

SDK 재구성과 다른 번들 정리, 렌더링 개선을 함께 적용한 홈 전체 결과

| 문서 load  | FCP        | DCL        | 내려받는 JS | 초기 5초 CPU |
| ---------- | ---------- | ---------- | ----------- | ------------ |
| **−47.5%** | **−44.3%** | **−41.6%** | **−38.2%**  | **−30.0%**   |

_모바일 에뮬레이션 / CPU 4배 감속 / 콜드 로드 / 개선 전 4회와 후 5회의 중앙값 비교_

**별도 JavaScript 실험:** 같은 5MiB, CPU 4배 감속에서 메인 스레드 작업은<br>미호출 함수 **81.5ms** / 최상위 초기화 **915.3ms**

_[실험 출처와 조건](https://yceffort.kr/2026/09/unused-javascript-cost) / 각 15회 중앙값, VM 내부 통신_

> 크기와 함께, **로딩 중 실행되는 초기화 코드의 비용**을 봐야 합니다

<!--
발표 60초 / 20:25~21:25

홈에서는 이 SDK 재구성과 다른 번들 정리, 렌더링 개선을 함께 진행했습니다. 같은 조건에서 개선 전 네 번과 후 다섯 번의 중앙값을 비교했습니다. 첫 화면이 표시되는 FCP는 44.3%, 초기 5초의 CPU 지표는 30% 줄었습니다.

그렇다면 바이트 감소를 어떤 비용과 연결해서 봐야 할까요? 별도로 수행한 JavaScript 실험이 있습니다. CPU를 네 배 늦춘 조건에서 같은 5MiB라도, 함수를 호출하지 않으면 메인 스레드 작업이 81.5ms였고 최상위에서 초기화하면 915.3ms였습니다.

실험이 알려주는 것은 어디를 봐야 하는가입니다. 느린 회선에서는 전송할 바이트를, CPU가 느린 환경에서는 로딩 중 컴파일하고 실행하는 초기화 코드를 함께 봐야 합니다. 그래서 번들 크기를 확인한 다음, 홈의 화면 표시와 초기 CPU 비용도 확인했습니다.
-->

---

<!-- _class: limits compact -->

<!-- _header: 04 / EVIDENCE -->

## 운영 반영 전에 확인한 것과 남은 범위

| 확인한 것                  | 근거                                          |
| -------------------------- | --------------------------------------------- |
| 비교 대상 필드와 대표 동작 | 소스와 빌드 산출물을 원본과 대조              |
| 의도적으로 바꾼 필드       | 양쪽 값 각각 단언, 수집 영향 확인             |
| 자동 이벤트와 이탈 전송    | jsdom에서 엔트리별 실행과 요청 관찰           |
| 실제 수집과 기존 지표      | 샌드박스 화면 조작, 대시보드와 평소 수치 확인 |

**이 발표의 숫자와 테스트가 말하지 않는 것**

- 홈 성능 개선 중 SDK만의 몫: 다른 개선과 함께 측정
- 웹뷰 연동을 포함한 크기: 사용하는 서비스에서 별도 구성
- 모든 입력과 환경의 동등성: 준비한 입력과 실행 환경의 결과

_원본 복원은 소스맵에 코드 전문(sourcesContent)이 있어서 가능했습니다. 초기 진단과 결과 표는 서로 다른 측정본입니다_

<!--
발표 65초 / 21:25~22:30

코드 밖에서, 데이터가 실제로 쌓이는 곳까지 확인했습니다. 수집을 분리해 둔 샌드박스에서 Playwright로 화면을 조작해 이벤트를 발생시키고, 대시보드에 기록되는지 봤습니다. 같은 환경의 기존 수치가 흔들리지 않는지도 관찰했습니다.

jsdom에서 전송 요청을 잡았다는 것만으로 서버에 저장됐다고 말할 수는 없으므로 두 확인을 나눴습니다. 의도적으로 달라진 필드도 수집과 집계의 영향을 따로 봤습니다.

이 과정을 거쳐 웹 수집 경로를 운영에 반영했습니다.

앞에서 미뤄 둔 한계도 여기서 한 번에 말씀드리겠습니다. 홈 성능 수치에는 다른 개선이 함께 들어 있어서 SDK만의 몫은 나누지 않았습니다. 크기는 웹 전송용 범위이고 웹뷰 연동은 사용하는 서비스에서 따로 구성합니다. 테스트는 준비한 입력과 실행 환경에서의 결과라서, 모든 경우에 원본과 완전히 같다고 주장하지는 않습니다. 그리고 이 방법 자체가 소스맵에 코드 전문이 들어 있었기 때문에 가능했습니다.
-->

---

<!-- _class: takeaways compact -->

<!-- _header: 04 / EVIDENCE -->

## 다음 버전에서도 같은 경계를 확인해야 합니다

1. **원본 변경을 비교합니다**<br>로직과 전송 필드, 초기화와 리스너 연결을 함께 확인
2. **소비자 기준으로 다시 검증합니다**<br>동작 대조, 의도적 차이, 빌드 산출물, 타입과 번들 크기
3. **원본으로 되돌릴 경로를 준비합니다**<br>재추출은 수정한 소스를 덮을 수 있으며 갱신 작업을 대신하지 않음

<!--
발표 30초 / 22:30~23:00

이제 작아진 SDK의 유지 비용도 직접 감당해야 합니다. 벤더 버전이 바뀌면 로직과 필드뿐 아니라 초기화와 리스너 연결도 다시 봅니다. 원본 대조와 의도적 차이, 빌드 산출물과 소비자 타입, 크기 기준선도 함께 갱신합니다.

소스맵을 다시 추출하는 것으로 갱신이 끝나지는 않습니다. 수정한 소스가 덮일 수 있으니 원본 배포물과 변경 내역을 관리하고, 문제가 생기면 되돌릴 방법까지 준비해야 합니다.
-->

---

<!-- _class: takeaways -->

<!-- _header: FECONF 2026 / TAKEAWAYS -->

## 줄일 코드와 지킬 동작을 함께 찾았습니다

1. **무엇이 따라오는가: import 관계**<br>최소 예제와 실사용을 나눠 재고, 엔트리에서 이어지는 경로를 확인
2. **언제 실행되는가: 브라우저의 연결**<br>초기화, 라우팅, 페이지 이탈에서 실제 동작까지 추적
3. **무엇을 보장하는가: 엔트리의 계약**<br>웹용 범위와 소비자 연동을 나누고, 유지할 동작과 의도적 차이를 검증

> 반드시 써야 하는 외부 SDK라도, **로직은 그대로 두고 조립만 바꿔서** 줄일 수 있었습니다

<!--
발표 55초 / 23:00~23:55

이번 작업에서 함께 봐야 했던 것은 세 가지였습니다. 먼저 import 관계입니다. 안 쓴다고 생각한 기능이 실제로 어느 엔트리에서 따라오는지 측정하고 확인했습니다.

다음은 브라우저의 실행 연결입니다. 함수가 남아 있어도 초기화와 라우팅, 페이지 이탈에서 연결되지 않으면 동작은 사라졌습니다.

마지막은 엔트리의 계약입니다. 웹 전송용이 맡는 범위와 소비자가 연결할 범위를 정하고, 유지할 동작과 의도적으로 바꾼 동작을 나눠 확인했습니다. 이 세 가지를 함께 보고, 로직은 한 줄도 바꾸지 않은 채 조립만 바꿔서 웹 전송용 번들의 gzip 크기를 약 69% 줄였습니다. 반드시 써야 하는 외부 SDK라도 여기까지는 손댈 수 있었습니다. 그리고 이렇게 공격적으로 바꿀 수 있었던 건, 원본과 같다는 것을 확인할 장치를 먼저 갖췄기 때문입니다.
-->

---

<!-- _class: resources -->

<!-- _header: FECONF 2026 / FURTHER READING -->

## 홈 개선을 위해 살펴본 네 가지

- [외부 SDK를 뜯어서 다시 만들기](https://yceffort.kr/2026/08/rebuilding-a-vendor-sdk)<br>이번 발표의 상세 기록: 소스 복원, 번들 재구성, 검증
- [framer-motion 프레임드랍 없애기](https://yceffort.kr/2026/08/framer-motion-banner-frame-drop)<br>원본 모션을 대조하며 레이아웃 비용 줄이기
- [number-flow를 구형 브라우저로 이식하기](https://yceffort.kr/2026/08/number-flow-fork-for-old-browsers)<br>API와 시각 결과를 보존하며 애니메이션 구동부 교체
- [실행하지 않는 JavaScript를 10MiB까지 늘려봤다](https://yceffort.kr/2026/09/unused-javascript-cost)<br>크기와 파싱, 컴파일, 초기화 비용을 분리한 측정 기록

<!--
발표 20초 / 23:55~24:15

홈 개선을 위해 살펴본 네 가지를 글로 남겼습니다. 오늘의 SDK 재구성과 배너 프레임드랍 개선, 구형 브라우저의 숫자 애니메이션 이식, 실행하지 않는 JavaScript의 비용 실험입니다. 관심 있는 주제는 이 링크에서 자세히 보실 수 있습니다.
-->

---

<!-- _class: closing thanks -->

<!-- _header: FECONF 2026 / THANK YOU -->

# 감사합니다

## 외부 SDK를 다시 조립하기

[상세 기록: 소스 복원, 번들 재구성, 검증](https://yceffort.kr/2026/08/rebuilding-a-vendor-sdk)

카카오페이증권 / 김용찬<br>[yceffort.kr](https://yceffort.kr) / [github.com/yceffort](https://github.com/yceffort)

<!--
발표 20초 / 24:15~24:35

외부 라이브러리라고 해서 주어진 그대로 쓸 수밖에 없는 것은 아닙니다. 원본과 같다는 것을 확인할 수 있다면, 생각보다 많은 곳에 손댈 수 있습니다. 지금까지 카카오페이증권 김용찬이었습니다. 감사합니다.
-->

---

<!-- _header: APPENDIX / 01 -->

<!-- _class: hidden-slide -->

## 백업: Next.js 공유 청크의 측정 조건

Next.js 16 + Turbopack의 **한 빌드 안에** 라우트 셋을 두었습니다
이 실험은 SDK를 재export하는 **앱 연동용 래퍼 패키지**를 import합니다

```bash
$ grep -o 'static/chunks/[a-z0-9_-]*\.js' .next/server/app/min.html | sort -u
static/chunks/0k_6yl401mp01.js   # 페이지 고유
static/chunks/2izqmkhuyaa9a.js   # min과 init이 공유하는 SDK 청크
```

| 라우트      | 하는 일            | SDK 청크 raw |
| ----------- | ------------------ | ------------ |
| `/baseline` | SDK 없음           | 없음         |
| `/min`      | 상수 하나 import   | 수백 KiB     |
| `/init`     | 초기화 + 전송 호출 | 같은 청크    |

**상수만 쓰는 페이지도 이 SDK 청크를 로드했습니다**
[공유 청크의 source-map-explorer 분석 이미지](/feconf-2026/source-map-explorer.png)
제거 가능성은 같은 엔트리의 최소 import와 초기화를 **별도 빌드**해서 비교해야 합니다

---

<!-- _header: APPENDIX / 02 -->

<!-- _class: hidden-slide -->

## 백업: 별도 JavaScript 실험의 측정 범위

| 조건                  | 내용                                                   |
| --------------------- | ------------------------------------------------------ |
| 실행 환경             | 2 vCPU / 8GB Ubuntu VM, Chromium 153 headless          |
| 본문에서 인용한 조건  | 동일한 raw 5MiB, CPU 4배 감속, VM 내부 통신, 압축 없음 |
| 표본                  | HTTP 캐시를 끈 상태에서 코드 형태별 15회, 중앙값       |
| 메인 스레드 작업 시간 | 스크립트 준비 전후의 CDP TaskDuration 차이             |

미호출 함수와 선언 직후 호출하는 초기화 코드의 **형태 차이를 비교했습니다**

**스크립트 준비 시간, 메인 스레드 작업 시간, FCP는 서로 다른 지표입니다**
CPU 감속은 특정 휴대전화에 맞춰 보정한 값이 아니며 SDK의 절감 ms로 환산하지 않았습니다

[실험 설명](https://yceffort.kr/2026/09/unused-javascript-cost) / [집계 데이터](https://github.com/yceffort/blog/blob/main/experiments/javascript-size/analysis/summary.json)

---

<!-- _header: APPENDIX / 03 -->

<!-- _class: hidden-slide -->

## 백업: 폴리필 사용처와 실행 환경

| 조사 대상             | 결과                                        |
| --------------------- | ------------------------------------------- |
| Promise, async/await  | 90회 이상, API 지원과 문법 타깃을 각각 확인 |
| `flat` / `flatMap`    | 3곳을 `reduce`로 치환                       |
| 추가 주입 메서드 10종 | 조사한 사용처 0회                           |

폴리필 import를 제거하고 **빌드 문법 타깃을 ES2017**로 맞췄습니다
다만 `TextEncoder`, `btoa`, Web Crypto는 실행 환경이 제공해야 합니다

**ES2017로 빌드했다는 사실만으로 브라우저 호환성이 보장되지는 않습니다**
그래서 각 API의 지원 하한을 문서에서 확인하고 팀 browserslist 타깃과 대조했습니다

---

<!-- _header: APPENDIX / 04 -->

<!-- _class: hidden-slide -->

## 백업: base64 구현과 원본 대조

```ts
// 우리 구현. 플랫폼 내장 TextEncoder와 btoa만 쓴다
export function encodeBase64Url(input: string): string {
  const bytes = new TextEncoder().encode(input) // btoa는 latin1 밖이면 예외
  let bin = ''
  for (const b of bytes) bin += String.fromCharCode(b)
  return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
```

이 결과는 **외부로 전송되는 값**입니다. 인코딩 결과가 원본과 같아야 합니다

```ts
import {Base64} from 'base64-lib' // 걷어낸 원본 라이브러리. devDependency로만 남긴다
for (let n = 0; n <= 64; n++) {
  expect(encodeBase64Url('x'.repeat(n))) // 우리 구현
    .toBe(Base64.encodeUrl('x'.repeat(n))) // 원본 라이브러리
}
// 0~64는 패딩 경계(길이 % 3) 전수. 시드 고정 난수 유니코드 2000건도 같은 방식으로 대조
```

---

<!-- _header: APPENDIX / 05 -->

<!-- _class: hidden-slide -->

## 백업: UUID 형식을 유지하며 바꿨습니다

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

  // 없으면 직접 조립한다: 랜덤 16바이트를 받아서
  const b = new Uint8Array(16)
  c.getRandomValues(b)
  b[6] = (b[6] & 0x0f) | 0x40 // 7번째 바이트 상위 4비트 = 버전(4)
  b[8] = (b[8] & 0x3f) | 0x80 // 9번째 바이트 상위 2비트 = 변형(RFC 4122)
  /* ...hex 문자열로 조립... */
}
```

**왜 폴백이 필요한가?** `randomUUID` 에는 `[SecureContext]` 제약이 있습니다
보안 컨텍스트가 아닌 환경에서는 사용할 수 없어, `getRandomValues` 기반 폴백을 뒀습니다

---

<!-- _header: APPENDIX / 06 -->

<!-- _class: hidden-slide -->

## 백업: 실행 환경과 출력 계약

**지원 버전뿐 아니라 실행 환경의 제약까지 확인해야 합니다**
MDN 본문과 스펙에 secure context 제약이 명시되어 있습니다
사용 환경에서 `crypto.getRandomValues`도 제공되는지 확인해야 합니다

<br>

**라이브러리 크기와 출력 계약은 별개입니다**
더 가벼운 생성기를 쓰더라도 식별자의 문자 집합과 길이, 구조가 달라지면
기존 계약을 만족하는지 다시 확인해야 합니다

<br>

이 작업에서는 **플랫폼 내장과 UUID 형식을 유지하는 폴백**을 선택했습니다

---

<!-- _header: APPENDIX / 07 -->

<!-- _class: hidden-slide -->

## 백업: 출처와 측정 범위

- **모듈 보존:** [Rollup preserveModules](https://rollupjs.org/configuration-options/#output-preservemodules)
- **트리셰이킹과 PURE:** [트리셰이킹](https://esbuild.github.io/api/#tree-shaking), [PURE 주석](https://esbuild.github.io/api/#pure), esbuild 공식 문서
- **결과 분류와 동등한 변경:** [Stryker 문서](https://stryker-mutator.io/docs/mutation-testing-elements/mutant-states-and-metrics/), [동등한 변경 예시](https://stryker-mutator.io/docs/stryker-js/disable-mutants/)
- **UUID 실행 환경:** [MDN randomUUID](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID), [getRandomValues](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues)

크기는 초기 진단과 2026-09-05 웹용 산출물의 패키지 소비 측정을 구분했습니다
화면 수치는 대략값이며, 테스트는 준비한 입력과 실행 환경의 결과입니다

---

<!-- _header: APPENDIX / 08 -->

<!-- _class: hidden-slide -->

## 백업: 소스맵이 없다면 어떻게 할까

**확보할 수 있는 코드와 유지 비용에 맞춰 최적화 방법을 고릅니다**

| 상황                       | 검토할 방법                                                                 |
| -------------------------- | --------------------------------------------------------------------------- |
| 원본 소스를 확보할 수 있다 | 공개 저장소와 패키지에 포함된 소스의 버전을 맞추고 재빌드, 엔트리 분리 검토 |
| 배포된 JavaScript만 있다   | 비압축 배포본이나 포맷팅한 번들의 호출 경로를 분석하고 제한적인 패치 검토   |
| 내부를 바꾸기 어렵다       | 벤더에 경량 엔트리 요청, 지연 로딩과 사용 화면 제한, 대체 SDK 비교          |

포맷팅은 원본 TypeScript와 이름, 파일 구조의 복원을 보장하지 않습니다
지연 로딩은 로딩 시점을 바꾸므로, 초기 수집과 필수 동작이 빠지지 않는지 확인합니다

[배포 코드와 소스맵(Chrome DevTools)](https://developer.chrome.com/docs/devtools/javascript/source-maps) / [코드 분할과 지연 로딩(web.dev)](https://web.dev/articles/optimizing-content-efficiency-javascript-startup-optimization)

---

<!-- _header: APPENDIX / 09 -->

<!-- _class: hidden-slide -->

## 백업: 원본 소스를 꺼내는 순서

1. **빌드의 맵 찾기**<br>배포 JS의<br>`sourceMappingURL`
2. **같은 인덱스 연결**<br>`sources`와<br>`sourcesContent`
3. **파일로 복원하기**<br>경로를 정리하고<br>빠진 타입은 `.d.ts`로 보충

```bash
# 모든 원본 내용이 포함된 맵에서 추출하는 도구 예시
npx shuji@0.8.0 sdk.js.map -o restored -p
```

**`sourcesContent`가 없거나 `null`이면 원본을 별도로 확보해야 합니다**
`mappings`의 위치 정보만으로 코드 전문을 되살릴 수는 없습니다

_[shuji 사용법](https://github.com/paazmaya/shuji#command-line-options) / [소스맵 규격(ECMA-426)](https://tc39.es/ecma426/#sec-source-map-format)_

---

<!-- _header: APPENDIX / 10 -->

<!-- _class: hidden-slide -->

## 백업: 입력이 사라져도 대조는 일치할 수 있습니다

검증용 설정을 늘리다가 **이미 쓰던 키를 다시 붙였습니다**

1. **키 중복**<br>맵에서 뒤 설정이<br>앞 설정을 덮어씀
2. **검사할 설정 소실**<br>원래 검사할 입력이<br>에러 없이 사라짐
3. **출력 일치**<br>양쪽 모두 “설정 없음”<br>→ 대조 테스트 통과

> 같은 잘못된 입력은 **출력 대조만으로 잡을 수 없습니다**

그래서 **입력의 키가 겹치면 실패하는 검사**를 추가했습니다

---

<!-- _header: APPENDIX / 11 -->

<!-- _class: hidden-slide -->

## 백업: 고의 변경의 실행과 결과 분류

**고른 변경 약 100개 × 매번 약 2천 개의 테스트**

| 결과          | 어떻게 해석했나                                 |
| ------------- | ----------------------------------------------- |
| **잡힘**      | 실제 단언 실패, 실패한 테스트 이름까지 확인     |
| **안 잡힘**   | 현재 입력과 단언으로 변경을 구별하지 못함       |
| **실행 오류** | 실행, 수집, 리포트 오류 / 잡힌 것으로 세지 않음 |

> 처음에는 **안 잡히는 변경**이 있었습니다. 그 원인을 추적했습니다

_직접 선택한 변경 지점만 검증한 결과입니다_
