---
title: '로티 딥다이브 1부: 병목을 찾는 법'
marp: true
paginate: true
theme: midnight
tags:
  - lottie
  - performance
  - wasm
  - webp
  - cdn
  - measurement
date: 2026-09-17
description: '로티 애니메이션이 늦게 뜨는 화면에서 배포 번들과 응답 헤더, 에셋 내부를 직접 뜯어 병목을 특정하는 과정. 추측 대신 측정으로 시작하는 성능 조사'
published: true
art:
  undraw: analysis
---

# 로티 딥다이브 1부

병목을 찾는 법: 배포본을 뜯어 측정하기

<!-- _class: invert -->

@yceffort

---

## 제보는 한 줄로 온다

> "챌린지 상세 화면에서 스크롤이 버벅여요. 그림도 늦게 떠요."

주차별 노드 26개가 세로로 늘어선 화면이다. 노드마다 로티 애니메이션이 하나씩 붙어 있다.

여기서 무엇부터 할 것인가?

<!-- 손들기. "화면 밖 렌더링을 멈추자", "로티 용량을 줄이자" 가 주로 나온다. -->

---

## 이 화면에서 실제로 있었던 일

네 번의 개선 작업이 있었다. 전부 렌더링 제어와 표시 타이밍을 건드렸다.

| 작업 | 내용                               |
| ---- | ---------------------------------- |
| A    | 로컬 에셋을 CDN URL로 전환         |
| B    | 화면 밖 노드의 로티 재생 정지      |
| C    | 로티 지연 마운트, 재생 레이스 수정 |
| D    | 로딩 중 poster 이미지 노출 개선    |

네 작업 어디에도 **가장 큰 파일 두 개는 등장하지 않는다.**

---

## 1부의 목표

```text
$ grep -lE 'jsdelivr|unpkg' *.js
DotLottiePlayer-a1b2c3d4.js
```

배포된 번들에서 이 한 줄을 뽑아내고, 그게 왜 병목인지 **숫자로 설명할 수 있게** 되는 것.

도구는 셋이다. `curl`, `grep`, `unzip`.

렌더러 내부에서 무슨 일이 일어나는지는 [2부](/slides/lottie-deep-dive-2)에서 이어 간다.

---

## 1부에서 다루는 것

1. **로티의 구조** : 벡터 데이터, 렌더러, 컨테이너 세 요소
2. **1차 발견** : 코덱이 외부 공용 CDN에서 온다
3. **2차 발견** : 로티 파일의 정체
4. **WebP 전환** : 용량의 98%를 차지하는 쪽
5. **처방 셋** : 측정 결과에서 바로 나오는 것들
6. **정리** : 종합 퀴즈와 교훈

각 절 끝에 **중간 점검**이 있고, 마지막에 **종합 퀴즈**가 있다.

---

<!-- _class: invert -->

# 1장. 로티가 무엇인가

먼저 구조를 알아야 어디를 잴지 안다

---

## 그림을 저장하는 두 가지 방식

**래스터**는 칠해진 결과를 담는다. 격자로 늘어선 픽셀마다 색을 하나씩 적어 둔다.

**벡터**는 그리는 방법을 담는다. `중심 (146, 150), 반지름 68인 원, 채우기 #fcd88e` 처럼 도형과 속성을 적어 둔다.

|                  | 래스터          | 벡터               |
| ---------------- | --------------- | ------------------ |
| 저장하는 것      | 픽셀마다의 색   | 도형의 좌표와 속성 |
| 용량을 키우는 것 | 해상도          | 도형 개수          |
| 두 배로 확대하면 | 픽셀이 보인다   | 그대로 선명하다    |
| 대표 포맷        | PNG, JPEG, WebP | SVG                |

---

## 벡터로 그리면 무엇이 좋은가

같은 캐릭터 그림을 두 방식으로 저장해 보면 차이가 드러난다.

```text
래스터 293x312 PNG    약 52 KB   dpr 3 기기용으로 다시 만들어야 함
벡터   도형 40개 SVG   약  4 KB   어느 해상도에서도 같은 파일
```

용량이 해상도와 무관하고, 기기마다 다른 파일을 준비하지 않아도 된다.

**이것이 애니메이션에 로티를 쓰는 이유다.**

---

## 벡터가 늘 유리하지는 않다

벡터의 용량은 **도형 개수**에 비례한다. 그림이 복잡할수록 불리해진다.

| 그림                        | 유리한 쪽       |
| --------------------------- | --------------- |
| 아이콘, 로고, 단순한 도형   | 벡터            |
| 질감과 음영이 많은 일러스트 | 그때그때 다르다 |
| 사진                        | 래스터          |

도형 하나하나를 계산해야 하므로 **도형이 많으면 그리는 비용도 같이 오른다.** 그라디언트나 블러가 섞이면 더 비싸다.

---

## 그래서 섞어 쓸 수 있게 만들어져 있다

`.lottie` 컨테이너에 **이미지를 넣을 자리가 있는 것**이 그 때문이다.

배경이나 질감처럼 벡터로 그리기 부담스러운 부분을 이미지로 넣고, 움직임은 벡터로 기술하는 식이다.

그러니 "로티 안에 이미지가 들어 있다"는 사실 자체는 잘못이 아니다.

**문제가 되는 것은 비율이다.** 3장에서 그 비율을 재 본다.

---

## 로티는 벡터에 시간을 더한 것

애니메이션을 GIF나 동영상이 아니라 **프레임마다 벡터가 어떻게 변하는지**로 저장한 것이다.

After Effects에서 만든 결과를 `1번 프레임에 원을 여기, 2번 프레임에 저기` 같은 좌표와 베지어 곡선으로 옮긴 JSON이다.

`.lottie` 확장자는 그 JSON과 이미지, 폰트를 zip으로 묶은 컨테이너다.

---

## 벡터는 공짜가 아니다

래스터는 이미 칠해진 픽셀이라 화면에 옮기기만 하면 된다. 벡터는 **매 프레임 도형을 픽셀로 계산해야** 한다. 이 과정을 래스터화라고 한다.

그래서 로티에는 도형을 읽고 칠해 주는 **렌더러**가 따로 필요하다.

`@lottiefiles/dotlottie-web`은 이 렌더러를 **WebAssembly**로 갖고 있다. C++로 쓰인 벡터 렌더러(thorvg)를 브라우저에서 돌 수 있게 컴파일한 바이너리다.

JS로 계산하는 것보다 빠르기 때문에 이렇게 만든다. 대신 **바이너리를 받아 와야 한다.**

---

## 동영상 플레이어에 대응시키면

| 동영상      | 로티                        | 이 화면에서의 크기 |
| ----------- | --------------------------- | ------------------ |
| 플레이어 앱 | 플레이어 JS 청크            | 17 KB (gz)         |
| **코덱**    | **`dotlottie-player.wasm`** | **472 KB (br)**    |
| 영상 파일   | `*.lottie`                  | 8.9 ~ 72 KB        |

셋이 전부 도착해야 첫 프레임이 나온다.

그리고 제일 큰 건 영상 파일이 아니라 **코덱**이다.

---

## 컨테이너를 직접 열어 보자

`.lottie`는 zip이므로 `unzip`으로 열린다. 공개 샘플 하나를 받아 본 결과다.

```text
$ unzip -l sample.lottie
  Length      Name
---------  ----------------------------------
      145  manifest.json
    31539  animations/rTiT1pikJ9.lottie.json
---------
    31684  2 files
```

파일 크기는 **3,148 B**다. JSON 31,539 B가 deflate로 10분의 1이 됐다.

**JSON은 압축이 아주 잘 듣는다.**

---

## 첫 프레임까지 필요한 것

```text
문서 로드
  → 앱 청크
  → 플레이어 청크 (17 KB gz)
  → wasm 코덱 (472 KB br)     ← 가장 큼
  → .lottie 파일 (8.9~72 KB)
  → 첫 프레임
```

1부는 이 줄에서 **어느 칸이 가장 긴지**를 잰다.

---

## 원칙: 소스가 아니라 배포본을 본다

`package.json`과 소스 코드를 읽어서 추측하면 **라이브러리 기본값이 보이지 않는다.**

우리가 아무것도 설정하지 않았을 때 라이브러리가 무엇을 하는지는, 실제로 배포된 번들에만 적혀 있다.

---

<!-- _class: invert -->

# 2장. 1차 발견

코덱 472 KB가 외부에서 온다

---

## 번들을 받아서 grep 한다

엔트리에서 import를 따라 청크를 전부 내렸다. 75개, 4.8 MB다.

```bash
$ grep -lE 'jsdelivr|unpkg|dotlottie-player\.wasm' *.js
DotLottiePlayer-a1b2c3d4.js

$ grep -oE 'https://(cdn\.jsdelivr\.net|unpkg\.com)[^"]*' \
    DotLottiePlayer-a1b2c3d4.js
https://cdn.jsdelivr.net/npm/@lottiefiles/dotlottie-web@0.79.1/...
https://unpkg.com/@lottiefiles/dotlottie-web@0.79.1/...
```

우리 레포에는 이 URL이 한 글자도 없다.

---

## 중간 점검 ①: 소스에 없는데 번들에 있다

우리 레포 전체를 `grep -rn 'jsdelivr'` 해도 0건이다. 그런데 배포된 번들에는 그 URL이 박혀 있다.

이게 어떻게 가능한가? 그리고 이 상황이 **왜 위험한가?**

---

## 중간 점검 ①: 정답

**어떻게**: 우리가 `setWasmUrl`을 부르지 않으면 라이브러리가 자기 기본값을 쓴다. 그 기본값이 번들에 그대로 들어간다.

**왜 위험한가**: 우리 화면의 첫 프레임이 뜨는 시각이 **남의 CDN 상태에 달려 있다.** 느려도, 막혀도 우리가 할 수 있는 게 없다.

---

## 로더의 실물

배포 번들에서 그대로 옮긴 것이다. 줄바꿈만 넣었다.

```text
function U(){return H??(H=he(j,
  `https://cdn.jsdelivr.net/npm/.../dotlottie-player.wasm`,
  `https://unpkg.com/.../dotlottie-player.wasm`)),H}
```

`H`는 모듈 스코프 싱글턴이다. 그리고 그 안의 `load()`는 Promise를 캐시한다.

```text
load(){if(!r){ ... } return r}
```

**컴파일은 세션당 1회**다. 비용이 한 번뿐이라는 뜻이고, 동시에 **그 한 번이 크다**는 뜻이다.

---

## 받는 방법이 두 가지다

```text
async function a(url) {                    // 스트리밍
  await init({ module_or_path: url })
}

async function o(url) {                    // 버퍼드
  const res = await fetch(url)
  if (!res.ok) throw Error(...)
  await init({ module_or_path: await res.arrayBuffer() })
}
```

URL을 그대로 넘기면 `WebAssembly.instantiateStreaming`으로 간다. **받으면서 동시에 컴파일한다.**

직접 `fetch`해서 ArrayBuffer를 넘기면 **다 받은 뒤에** 컴파일한다. 두 작업이 직렬이라 그만큼 늦다.

---

## 폴백은 네 단계다

`primary`는 jsdelivr, `backup`은 unpkg다. 여기에 방식 두 가지를 곱한다.

| 시도 | 출처     | 방식     | 실패하면                               |
| ---- | -------- | -------- | -------------------------------------- |
| 1    | jsdelivr | 스트리밍 | `Primary WASM load failed` 경고        |
| 2    | unpkg    | 스트리밍 | `Backup WASM load failed` 경고         |
| 3    | jsdelivr | 버퍼드   | `Buffered WASM load failed` 경고       |
| 4    | unpkg    | 버퍼드   | `WASM loading failed from all sources` |

전부 직렬이다. 앞 시도가 **실패로 끝나야** 다음이 시작된다.

---

## 폴백이 걸리는 이유는 응답이 없을 때만이 아니다

스트리밍 컴파일은 응답의 `content-type`이 정확히 `application/wasm`일 때만 동작한다. 아니면 그 자리에서 실패하고 경고를 남긴다.

```text
`WebAssembly.instantiateStreaming` failed because your server
does not serve Wasm with `application/wasm` MIME type.
```

CDN이 살아 있어도 헤더 하나가 틀리면 1번과 2번이 모두 헛돌고 3번으로 내려간다.

---

## 네 번 다 실패하면

```text
r = null
throw Error('WASM loading failed from all sources.')
```

`r`은 앞에서 본 Promise 캐시다. **그 캐시를 비우고** 에러를 던진다.

캐시가 비었으므로 다음에 만들어지는 인스턴스는 네 단계를 **처음부터 다시** 돈다. 화면에 노드가 26개라면 그만큼 반복될 수 있다.

---

## 폴백이 걸리면 무슨 일이 생기나

같은 파일을 두 CDN에서 재 봤다.

| 출처            | 전송량          | `cache-control`                                  |
| --------------- | --------------- | ------------------------------------------------ |
| jsdelivr (br)   | **471,947 B**   | `max-age=31536000, s-maxage=31536000, immutable` |
| jsdelivr (gzip) | 489,759 B       | 같음                                             |
| unpkg           | **1,219,126 B** | `max-age=31536000` (immutable 없음)              |

**unpkg는 brotli를 주지 않는다.** 폴백이 걸리는 순간 2.6배를 받는다.

---

## 바이트를 시간으로 바꾸기

471,947 B = 3,775,576 bit 기준이다.

| 회선        | 대역폭   | wasm 다운로드 |
| ----------- | -------- | ------------- |
| Slow 3G     | 400 Kbps | **9.4초**     |
| Fast 3G     | 1.6 Mbps | **2.4초**     |
| 4G 상당     | 10 Mbps  | 0.38초        |
| 양호한 WiFi | 50 Mbps  | 0.08초        |

성능 논의에서 바이트만 말하면 상대가 체감을 못 한다. **시간으로 환산해서 말한다.**

---

## 그 파일은 지금 어디서 오고 있나

응답 헤더가 알려 준다. 추측할 필요가 없다.

```bash
curl -sS -o /dev/null -D - <url> \
  | grep -iE 'server|cf-ray|x-served-by|x-cache|age'
```

```text
server: cloudflare
cf-ray: a3c49b69c91eea04-ICN
x-served-by: cache-fra-etou8220102-FRA, cache-nrt-rjaa8190053-NRT
x-cache: HIT, HIT
age: 2976755
```

---

## 헤더를 읽는 법

| 헤더                | 읽는 법                                               |
| ------------------- | ----------------------------------------------------- |
| `cf-ray` 끝 세 글자 | 응답한 Cloudflare 엣지의 공항 코드. `ICN`은 인천      |
| `x-served-by`       | 뒤에 놓인 Fastly 계층. `NRT` 도쿄, `FRA` 프랑크푸르트 |
| `x-cache`           | 각 계층의 적중 여부. `HIT, HIT`이면 원본까지 안 갔다  |
| `age`               | 그 사본이 캐시에 있은 시간(초). 약 34일               |

jsdelivr은 여러 CDN을 겹쳐 쓴다. **우리 요청은 인천에서 끝났다.**

---

## 연결 설정 비용을 재 본다

```bash
curl -sS -o /dev/null -w 'dns=%{time_namelookup} tcp=%{time_connect} \
tls=%{time_appconnect} ttfb=%{time_starttransfer}\n' <url>
```

```text
dns=0.0023  tcp=0.0211  tls=0.0390  ttfb=0.0550
```

서울 유선에서 잰 값이다. 누적값이므로 DNS 2ms, TCP까지 21ms, TLS까지 **39ms**, 응답 시작까지 55ms로 읽는다.

DNS가 캐시되지 않은 첫 조회는 68ms까지 나왔다. 모바일 회선에서는 전부 더 커진다.

---

## 그래서 "멀어서 느리다"는 아니다

인천 엣지가 캐시 적중으로 응답한다. 지리적 거리는 이 화면의 문제가 아니었다.

그럼 자체 호스팅으로 얻는 것은 무엇인가?

1. **이미 열려 있는 연결을 쓴다.** 번들을 받은 그 연결로 wasm도 받는다. 연결 설정 한 번이 통째로 사라진다
2. **캐시 정책을 우리가 정한다**
3. **그 CDN이 죽어도 우리 화면은 산다**

"남의 CDN이라 느리다"가 아니라 **"남의 CDN이라 우리가 통제할 수 없다"** 가 정확한 문장이다.

---

## 직렬을 병렬로 바꾼다는 것

```text
[지금]  라우트 청크 로드 ──▶ wasm 다운로드 ──▶ 첫 프레임
[목표]  라우트 청크 로드 ──▶ 첫 프레임
        wasm 다운로드 ──────▶        (겹쳐서 진행)
```

총 다운로드량은 같은데 체감 시간이 줄어든다.

**파일을 작게 만드는 것보다 기다리는 순서를 바꾸는 쪽이 대개 더 싸고 효과가 크다.**

---

## 중간 점검 ②

Fast 3G(1.6 Mbps) 사용자가 이 화면에 들어왔다. jsdelivr이 응답하지 않아 unpkg로 폴백이 걸렸다.

wasm이 도착하기까지 대략 몇 초인가? 그리고 그 시간 동안 화면에는 무엇이 보이나?

---

## 중간 점검 ②: 정답

1,219,126 B = 9,753,008 bit, 1.6 Mbps로 **약 6.1초**다. 여기에 앞선 jsdelivr 실패를 기다린 시간과 연결 설정 두 번이 더 붙는다.

그동안 화면에는 **poster 이미지나 빈 영역**이 보인다. 로티는 한 장도 못 그린다.

---

<!-- _class: invert -->

# 3장. 2차 발견

로티 파일을 열어 보자

---

## 로티는 다 합쳐도 wasm보다 작다

이 화면이 쓰는 로티 6종의 크기다.

| 파일                                    | 크기          |
| --------------------------------------- | ------------- |
| `box_gift_lock.lottie`                  | 8,957 B       |
| `box_gift_open.lottie`                  | 23,150 B      |
| `box_gift_open_character.lottie`        | 47,454 B      |
| `box_gift_received_character.lottie`    | 54,471 B      |
| `box_gift_received_26_character.lottie` | 72,642 B      |
| `intro_character.lottie`                | **325,933 B** |

앞의 다섯을 합쳐도 206,674 B다. 마지막 하나가 그보다 크다.

---

## 그래서 열어 봤다

```text
$ unzip -l box_gift_lock.lottie
  manifest.json
  a/animation.json
  i/image_0.png     ←
  i/image_1.png     ←
```

**PNG가 들어 있다.** 여섯 파일 전부 그랬다.

벡터 애니메이션이라는 전제가 여기서 무너진다.

---

## 이미지가 차지하는 비중

| 파일                             | 최종    | `animation.json` | 이미지  | 이미지 비중 |
| -------------------------------- | ------- | ---------------- | ------- | ----------- |
| `box_gift_lock`                  | 8,957   | 12,966           | 7,535   | 84.1%       |
| `box_gift_open`                  | 23,150  | 5,485            | 21,539  | 93.0%       |
| `box_gift_open_character`        | 47,454  | 7,174            | 45,689  | 96.3%       |
| `box_gift_received_26_character` | 72,642  | 3,867            | 71,295  | 98.1%       |
| `box_gift_received_character`    | 54,471  | 3,520            | 53,532  | 98.3%       |
| `intro_character`                | 325,933 | 4,511            | 324,991 | **99.7%**   |

합계 532,607 B 중 **524,581 B가 이미지**다.

---

## 비율이 이 정도면 이야기가 다르다

1장에서 이미지를 섞는 것 자체는 잘못이 아니라고 했다. **문제는 비율이었다.**

84%에서 99.7%는 "섞었다"가 아니라 **PNG를 로티 컨테이너에 담은 것**이다. 벡터로 그려서 얻는 이점(해상도와 무관한 작은 용량)이 거의 남지 않는다.

도형 데이터는 압축하면 1 KB 안쪽이다.

| 파일                          | `animation.json` 원본 | gzip  |
| ----------------------------- | --------------------- | ----- |
| `box_gift_lock`               | 12,966                | 1,145 |
| `box_gift_open`               | 5,485                 | 972   |
| `box_gift_received_character` | 3,520                 | 581   |
| `intro_character`             | 4,511                 | 717   |

---

## 가장 큰 파일의 정체

`intro_character.lottie` 안이다.

```text
image_0.png  293x312   52,768 B
image_1.png  293x312   52,308 B
image_2.png  293x312   53,966 B
image_3.png  293x312   53,811 B
image_4.png  293x312   52,655 B
image_5.png  293x312   51,996 B
image_6.png  146x46     7,487 B
```

같은 해상도의 52 KB급 PNG가 **6장 연속**이다. 벡터 애니메이션이 아니라 **프레임 시퀀스**다.

---

## 해상도가 과한 건 아니다

렌더 크기는 CSS 기준 104x104다. 293x312는 dpr 3 대응이니 해상도 선택 자체는 합리적이다.

문제는 **같은 크기의 그림이 6장 쌓였다**는 점이다.

벡터였다면 도형은 한 벌이고 프레임마다 좌표만 달라졌을 것이다. 래스터라서 **프레임 수만큼 곱해졌다.**

---

## 죽은 가설 하나

조사 중에 이런 제안을 했다.

> ".lottie는 zip(deflate)이다. `.json`으로 올려서 CDN이 brotli로 압축하게 하면 더 작아지지 않을까?"

위의 실측이 이 가설을 죽였다. **두 가지 이유로 틀렸다.**

---

## 왜 틀렸나

**첫째**, JSON은 압축 후 0.6~1.2 KB다. 여기서 수백 바이트를 더 짜내도 전체 532 KB에 아무 영향이 없다.

**둘째**, `.json`으로 바꾸면 **이미지를 담을 곳이 없어진다.** `i/` 폴더가 사라지니 PNG를 base64 data URI로 JSON 안에 넣어야 하는데, base64는 원본보다 **33% 커진다.**

brotli가 그 팽창을 되돌려도 원본 이하로 내려가기 어렵다. 이 파일들에서 `.json` 전환은 개선이 아니라 **악화**다.

---

## 교훈: 순서가 거꾸로였다

압축 포맷을 고민하기 전에 **무엇이 용량을 차지하는지** 먼저 열어 봐야 했다.

`unzip -l` 한 번이면 되는 일이었다.

---

## 중간 점검 ③

`.lottie` 응답에는 `content-encoding` 헤더가 없다. CDN 설정에서 gzip을 켜면 전송량이 줄어들까?

---

## 중간 점검 ③: 정답

**줄지 않는다. 오히려 커진다.**

`.lottie`는 이미 zip(deflate)으로 압축된 바이너리다. 이미 압축된 것을 다시 압축하면 엔트로피가 없어 크기가 늘고, CPU만 더 쓴다.

`content-encoding`이 없는 것이 **정상**이다.

---

## 두 헤더를 헷갈리지 말 것

|                    | `.lottie`                      | `.wasm`                                              |
| ------------------ | ------------------------------ | ---------------------------------------------------- |
| `content-encoding` | **없는 게 정상**. 이미 zip이다 | br을 걸면 1,219,126 B가 471,947 B로                  |
| `content-type`     | **보지 않는다**                | **`application/wasm`이 아니면 스트리밍 컴파일 실패** |

`.lottie`는 라이브러리가 **다 받은 뒤 앞 4바이트**가 zip 매직 넘버인지 확인한다. 서버가 타입을 틀리게 줘도 동작한다.

`instantiateStreaming`은 **다 받기 전에** 컴파일을 시작하므로 그럴 여유가 없다. 그래서 브라우저가 타입을 강제한다.

---

<!-- _class: invert -->

# 4장. WebP 전환

용량의 98%를 차지하는 쪽을 건드린다

---

## 524 KB를 줄이는 길은 두 갈래다

**장수를 줄인다.** 6장 시퀀스를 두세 장으로 줄이거나 벡터로 다시 그린다. 효과가 가장 크지만 디자인 작업이 새로 필요하다.

**장당 크기를 줄인다.** 같은 그림을 더 작게 저장하는 포맷으로 바꾼다. 그림은 그대로고 파일만 바뀐다.

두 번째는 코드도 디자인도 건드리지 않는다. 그 방법이 **PNG를 WebP로 바꾸는 것**이다.

WebP는 같은 그림을 PNG보다 작게 담고, 투명도(알파)도 지원한다. 이 로티들은 배경이 투명해야 하므로 알파가 필수다.

---

## 그런데 그 이미지는 누가 푸는가

`<img src="...">`로 불러오는 이미지가 아니다. `.lottie` zip 안에 들어 있어서 **브라우저는 그런 파일이 있는지조차 모른다.**

푸는 쪽은 1장에서 본 그 wasm 렌더러, **thorvg**다.

그래서 브라우저가 WebP를 지원하는지는 상관이 없다. **thorvg가 WebP를 읽을 수 있는가**가 전제다.

바이너리에 디코더가 들어 있는지는 문자열로 확인할 수 있다.

```bash
strings -a dotlottie-player.wasm | grep -iE 'webp|vp8' | sort -u
```

---

## 디코더는 있다

```text
deps/thorvg/src/loaders/webp/dec/webp.cpp
deps/thorvg/src/loaders/webp/dec/vp8.cpp     ← 손실
deps/thorvg/src/loaders/webp/dec/vp8l.cpp    ← 무손실
deps/thorvg/src/loaders/webp/dec/alpha.cpp   ← 알파 채널
deps/thorvg/src/loaders/webp/dsp/lossless.cpp
```

손실, 무손실, 알파까지 전부 있다.

**알파가 되는 것이 중요하다.** 이 로티들은 배경이 투명해야 한다.

---

## 폴백이 필요한 곳과 아닌 곳

| 대상                        | 디코더      | 폴백        |
| --------------------------- | ----------- | ----------- |
| `.lottie` 내부 `i/*.webp`   | thorvg wasm | **불필요**  |
| poster `<img src="*.webp">` | 브라우저    | 필요        |
| 배경 `<picture>`            | 브라우저    | 이미 적용됨 |

wasm이 도는 환경이면 읽힌다. 배경 이미지가 jpg 폴백을 두는 이유는 세 번째 줄이지, 첫 번째 줄이 아니다.

---

## 절감 실측

임베드 PNG를 그대로 변환해 크기만 비교한 결과다.

| 파일                             | PNG 합  | WebP 무손실     | WebP q90           |
| -------------------------------- | ------- | --------------- | ------------------ |
| `box_gift_lock`                  | 7,535   | 3,076 (40.8%)   | 3,846              |
| `box_gift_open`                  | 21,539  | 8,982 (41.7%)   | 10,984             |
| `box_gift_open_character`        | 45,689  | 17,368 (38.0%)  | 17,694             |
| `box_gift_received_26_character` | 71,295  | 27,774 (39.0%)  | 27,760             |
| `box_gift_received_character`    | 53,532  | 34,838 (65.1%)  | **8,544 (16.0%)**  |
| `intro_character`                | 324,991 | 123,630 (38.0%) | **57,442 (17.7%)** |
| 합계                             | 524,581 | 215,668 (41.1%) | 126,270 (24.1%)    |

---

## 파일마다 골라야 한다

|            | 현재      | 무손실              | q90                 |
| ---------- | --------- | ------------------- | ------------------- |
| 6개 합     | 532,607 B | 약 221,000 B        | 약 131,600 B        |
| 절약       |           | **약 311 KB (58%)** | **약 401 KB (75%)** |
| intro 단독 | 325,933 B | 약 129,000 B        | 약 62,700 B         |

`box_gift_received_character`와 `intro_character`는 무손실과 q90 차이가 크다(65% 대 16%, 38% 대 17.7%).

반대로 `box_gift_open_character`는 38.0% 대 38.7%로 차이가 없다. **무손실로 두는 편이 낫다.**

---

## 그런데 실제로 렌더가 되는가

디코더가 바이너리에 있다는 것과, **dotLottie 패키징 경로가 WebP를 받아 준다**는 것은 다른 문제다.

그래서 직접 만들어 확인했다.

알파가 있는 293x312 그림 6장으로 프레임 시퀀스 `.lottie`를 만들고, 같은 그림을 PNG와 WebP 두 방식으로 각각 패키징했다.

---

## 크기는 이렇게 줄었다

| 파일        | `.lottie` | 이미지 합 | 비율      |
| ----------- | --------- | --------- | --------- |
| PNG         | 89,634    | 89,251    | 100.0%    |
| WebP 무손실 | 54,973    | 53,624    | 61.3%     |
| WebP q90    | 22,407    | 21,058    | **25.0%** |

q90은 앞 장에서 본 실제 에셋 수치(24.1%)와 거의 같다.

무손실이 61.3%로 더 큰 것은 그림의 성질이 달라서다. 앞 장에서 본 **파일별 편차와 같은 현상**이다.

---

## 셋 다 정상 렌더된다

세 파일을 캔버스에 나란히 물리고 그려진 픽셀을 셌다. 불투명 픽셀 33,374개, 평균 밝기 147로 셋이 같았고 육안으로도 구분되지 않았다.

다만 그 숫자만으로는 **같은 파일을 세 번 그린 경우**와 구분할 수 없다. 그래서 캔버스 픽셀 전체의 해시도 냈다.

```text
seq_png       3c78a8d1
seq_webp_ll   f0ff4ba4
seq_webp_q90  bf27e2a
```

셋이 다르다. 서로 다른 바이트를 디코딩해 같은 그림을 냈다는 뜻이다.

**WebP 임베드는 폴백 없이 가능하다.**

---

## 그래서 무엇을 하면 되나

1. **컨테이너 구조를 먼저 확인한다.** `unzip -l`로 이미지 폴더 이름과 JSON 경로를 본다
2. **전부 무손실로 변환해 크기를 잰다.** 그다음 q90과 차이가 큰 파일만 손실을 검토한다
3. **변환하고 다시 zip으로 묶는다**
4. **`assets[].p`의 확장자를 함께 바꾼다.** 빠뜨리면 그림이 통째로 안 뜬다
5. **로컬에서 렌더를 확인한다.** 플레이어에 물려 첫 프레임이 나오는지 본다
6. **손실을 쓴 파일은 디자이너 확인을 받는다**
7. **CDN에 올린다.** 파일명이 같으면 기존 캐시가 만료될 때까지 반영이 늦는다

**코드는 한 줄도 바뀌지 않는다.** 에셋 교체 작업이다.

---

## 변환 스크립트

```bash
mkdir -p out
for f in *.lottie; do
  rm -rf work && unzip -qo "$f" -d work
  for p in work/i/*.png; do
    cwebp -quiet -lossless "$p" -o "${p%.png}.webp" && rm "$p"
  done
  sed -i '' 's/\.png"/.webp"/g' work/a/animation.json
  (cd work && zip -qr "../out/$f" manifest.json a i)
done
```

경로가 `a/`, `i/`인지 `animations/`, `images/`인지는 파일마다 다르다. 1번에서 확인한 것으로 맞춘다.

손실로 갈 파일은 `-lossless` 대신 `-q 90`을 쓴다.

---

<!-- _class: invert -->

# 5장. 측정에서 바로 나오는 처방

파일과 네트워크 층

---

## 처방 1: wasm을 우리 쪽에서 준다

```text
import wasmUrl
  from '@lottiefiles/dotlottie-web/dist/dotlottie-player.wasm?url'
import { DotLottie } from '@lottiefiles/dotlottie-web'

DotLottie.setWasmUrl(wasmUrl)
```

`?url`을 붙이면 번들러가 파일을 에셋으로 복사하고 그 URL을 준다.

우리 배포 CDN은 경로에 빌드 버전이 들어가고 `immutable`이라, 한 번 받으면 다시 받지 않는다.

---

## 시그니처를 확인하고 쓸 것

`setWasmUrl`과 `preload`를 어디서 가져오는지 헷갈리기 쉽다.

```js
import {setWasmUrl} from '@lottiefiles/dotlottie-react' // 동작
import {DotLottie} from '@lottiefiles/dotlottie-react' // 없는 export
```

두 패키지의 export를 실제로 확인한 결과다.

| 패키지            | export                                                                 |
| ----------------- | ---------------------------------------------------------------------- |
| `dotlottie-web`   | `DotLottie`, `DotLottieWorker` (정적 메서드로 `setWasmUrl`, `preload`) |
| `dotlottie-react` | `DotLottieReact`, `DotLottieWorkerReact`, `setWasmUrl`                 |

`preload`를 쓰려면 **`dotlottie-web`에서 가져와야 한다.**

---

## 처방 1: 필요해지기 전에 컴파일까지 끝낸다

```js
DotLottie.preload() // fetch + WebAssembly 컴파일
```

`<link rel="preload">`는 다운로드만 하고 컴파일은 첫 인스턴스 생성 때 일어난다. `preload()`는 **컴파일까지** 끝낸다.

호출 자리는 앱 마운트 지점이다. 같은 발상의 코드가 대개 그 근처에 이미 있다.

```js
useChallengeLandingPrefetch() // 라우트 청크보다 앞서 조회를 발사
DotLottie.preload() // 같은 자리에 한 줄
```

---

## 처방 1의 함정: content-type

폴백 절에서 본 그 조건이 여기서 다시 걸린다. 응답의 `content-type`이 `application/wasm`이 아니면 스트리밍 컴파일이 실패한다.

jsdelivr은 제대로 준다.

```text
content-type: application/wasm
```

우리 스토리지가 그럴지는 별개 문제다. **배포 전에 `curl`로 확인하고 넘어가야 한다.**

자체 호스팅으로 옮기면서 이 헤더를 놓치면, 파일은 가까워지는데 컴파일은 느려진다.

---

## 처방 2: WebP 전환

앞 장에서 다뤘다. 정리하면 이렇다.

- 절약 **311~401 KB** (58~75%)
- 코드 변경 없음, 렌더러 인터페이스 그대로
- 디코더 존재와 실제 렌더까지 확인 완료
- 파일별로 무손실과 q90을 골라야 함
- 파일명이 같으면 기존 캐시가 만료될 때까지 반영이 늦는다

**절약량이 가장 크고 난이도가 가장 낮다.**

---

## 처방 3: 캐시 기간

에셋 CDN의 응답이다.

```text
cache-control: max-age=21600
```

6시간이다. 매일 출석하는 챌린지 서비스에서 이건 사실상 **매번 새로 받는다**는 뜻이다.

비교로 jsdelivr과 우리 배포 CDN은 이렇다.

```text
cache-control: public, max-age=31536000, s-maxage=31536000, immutable
```

---

## max-age와 s-maxage를 헷갈리지 말 것

| 지시어      | 대상                                                 |
| ----------- | ---------------------------------------------------- |
| `max-age`   | **클라이언트(브라우저) 캐시**                        |
| `s-maxage`  | 공유 캐시(CDN 등)                                    |
| `immutable` | 내용이 안 바뀐다는 약속. 재검증 요청조차 보내지 않음 |

`max-age=21600`은 "CDN만 6시간"이 아니라 **브라우저도 6시간**이다.

문제는 "브라우저 설정이 없다"가 아니라 **브라우저 캐시가 6시간뿐이라는 것**이다.

---

## 캐시 연장에는 전제가 있다

캐시를 1년으로 늘리려면 **내용이 바뀔 때 URL도 바뀌어야 한다.**

우리 배포 CDN은 이미 그렇게 한다.

```text
/app-name/20260915-a1b2c3d4e5f6/index.js
```

에셋 CDN에는 이 버저닝이 없다. 그래서 이 처방은 한 단계가 아니라 두 단계다.

**파일명 버저닝 도입 + 캐시 연장.** 버저닝 없이 캐시만 늘리자는 제안은 반려돼야 한다.

---

## 처방 셋 정리

| 순위 | 처방                       | 절약                           | 난이도           |
| ---- | -------------------------- | ------------------------------ | ---------------- |
| 1    | WebP 전환                  | 311~401 KB                     | 낮음 (에셋 작업) |
| 2    | wasm 자체 호스팅 + preload | 연결 설정 한 번, 재방문 472 KB | 중               |
| 3    | 캐시 연장                  | 재방문 330~430 KB              | 낮음 (코드 밖)   |

셋 다 **로티 렌더링 코드를 건드리지 않는다.**

렌더링 쪽 처방은 2부에서 다룬다.

---

<!-- _class: invert -->

# 6장. 정리

---

## 종합 퀴즈 ①

배포 번들을 grep 했더니 외부 CDN URL이 나왔다. 팀에 보고하려 한다.

다음 중 **지금 당장 말할 수 있는 것**은 무엇인가?

1. "wasm 472 KB를 외부 CDN에서 받고 있다"
2. "이것 때문에 첫 프레임이 2.4초 늦는다"
3. "자체 호스팅하면 연결 설정 한 번이 통째로 사라진다"
4. "자체 호스팅하면 화면이 0.4초 빨라진다"

---

## 종합 퀴즈 ①: 정답은 1과 3

**1**은 `curl`로 잰 값이다. **3**은 연결 단계가 사라진다는 구조적 사실이다.

**2**는 틀렸다. 2.4초는 다운로드 시간이고, 그것이 첫 프레임 지연에 그대로 더해지는지는 워터폴을 봐야 안다.

**4**도 틀렸다. 절약되는 시간은 "겹칠 수 있는 구간의 길이"만큼인데, 라우트 청크 로드 시간을 재지 않았다.

---

## 종합 퀴즈 ②

로티가 유독 늦게 뜬다는 제보를 받고 콘솔을 열었더니 이 경고가 찍혀 있다.

```text
Retrying WASM load with buffered instantiation
```

CDN 상태 페이지는 정상이고, 브라우저에서 그 URL을 직접 열면 파일도 잘 받아진다.

무슨 일이 일어난 것이고, 무엇을 확인해야 하나?

---

## 종합 퀴즈 ②: 정답

이 경고는 **스트리밍 시도 두 번이 모두 실패**하고 세 번째 단계로 내려갔다는 뜻이다. 파일이 안 받아진 게 아니라 **받으면서 컴파일하는 경로가 막힌 것**이다.

확인할 것은 응답의 `content-type`이다.

```bash
curl -sS -o /dev/null -D - <wasm-url> | grep -i content-type
```

`application/wasm`이 아니면 스트리밍 컴파일이 그 자리에서 실패한다. 파일을 직접 열었을 때 잘 받아지는 것과는 별개 문제다.

**CDN이 멀쩡해도 헤더 하나로 폴백이 걸린다.**

---

## 종합 퀴즈 ③

`.lottie` 응답 헤더에 `content-encoding`이 없고, 에셋 CDN은 `max-age=21600`이다.

이 두 가지 중 **고쳐야 하는 것**은 무엇이고, 고칠 때 **먼저 해야 하는 일**은 무엇인가?

---

## 종합 퀴즈 ③: 정답

고쳐야 하는 건 **`max-age`** 쪽이다. `content-encoding`이 없는 건 정상이다(이미 zip).

캐시를 늘리기 전에 먼저 할 일은 **파일명 버저닝**이다.

버저닝 없이 `immutable`을 걸면, 에셋을 고쳐도 사용자가 1년간 옛 파일을 본다.

---

## 종합 퀴즈 ④

wasm에서 `strings`로 WebP 디코더 경로는 찾았는데, PNG 로더 경로는 안 나왔다.

"그럼 PNG는 못 읽는 것 아닌가?" 라는 질문에 어떻게 답하겠는가?

---

## 종합 퀴즈 ④: 정답

**지금 PNG 임베드 로티가 정상 렌더되고 있다.** 그것이 반증이다.

`strings`에 잡히는 문자열은 주로 `assert`에 박힌 파일 경로다. assert를 많이 쓴 코드는 경로가 남고, 아닌 코드는 안 남는다.

---

## 1부에서 얻을 것 둘

**하나. 재기 전에 고치지 않는다.**

이 화면의 실제 병목(wasm 472 KB, 임베드 PNG 524 KB)은 네 번의 개선 작업 어디에서도 언급되지 않았다. 증상에서 출발해 짐작으로 원인을 고른 결과다.

**둘. 포맷을 고민하기 전에 파일을 연다.**

`unzip -l` 한 번이 압축 방식 논의 전체보다 큰 값을 찾아냈다. 나도 이 순서를 거꾸로 했다.

---

## 30분이면 된다

```bash
curl -s <import-map>              # 실제 배포 URL
curl -s <entry> | grep import     # 청크 그래프
grep -lE '<의심 문자열>' *.js      # 소스에 없는 것 찾기
curl -sS -o /dev/null -D - <url>  # 크기와 헤더
unzip -l <asset>                  # 안에 뭐가 들었나
```

이 다섯 줄이 방향을 결정한다.

---

## 2부 예고

1부는 **파일과 네트워크**만 봤다. 코드는 한 줄도 읽지 않았다.

2부에서는 렌더러 안으로 들어간다.

1. wasm 렌더러의 생애: 컴파일은 한 번, 인스턴스는 매번
2. 프레임 루프 해부: `_lastFrameTime`과 `tick(delta)`
3. `freezeOnOffscreen`의 3중 방어
4. **우리가 직접 만든 최적화가 왜 중복이었는가**
5. 내부 동작에서 유도되는 기법 다섯

---

## 참고

- `@lottiefiles/dotlottie-web` 0.79.1 (번들 실측 기준)
- `@lottiefiles/dotlottie-react` 0.19.16 (내부 의존은 `dotlottie-web` 0.80.0)
- thorvg: `deps/thorvg/src/loaders/webp/` (wasm 바이너리 문자열)
- 재현 실험: 293x312 알파 PNG 6장 프레임 시퀀스, Chrome 154
- 에셋 실측값은 실제 서비스 조사 기준이며, 서비스와 식별자는 익명 처리했다
