---
title: '로티 딥다이브 2부: 렌더러 안에서'
marp: true
paginate: true
theme: midnight
tags:
  - lottie
  - performance
  - wasm
  - rendering
  - intersection-observer
date: 2026-09-17
description: 'dotlottie-web의 인스턴스 생애와 프레임 루프, freezeOnOffscreen의 3중 방어를 번들 코드로 읽는다. 우리가 직접 만든 최적화가 왜 중복이었는지, 기본값을 덮어써서 오히려 비용을 올린 곳은 어디인지'
published: true
art:
  undraw: optimize-image
---

# 로티 딥다이브 2부

렌더러 안에서: 기본값이 이미 하고 있던 일

<!-- _class: invert -->

@yceffort

---

## 1부에서 본 것

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

| 발견                          | 값                  |
| ----------------------------- | ------------------- |
| 코덱이 외부 공용 CDN에서 온다 | 471,947 B (br)      |
| 로티 6종의 98.5%가 임베드 PNG | 524,581 / 532,607 B |
| WebP 전환으로 줄어드는 양     | 311~401 KB          |

처방 셋(자체 호스팅, WebP 전환, 캐시 연장)도 **렌더링 코드를 건드리지 않는 것들**이었다.

---

## 2부가 답할 것

1부의 제보에는 두 갈래가 있었다. "그림이 늦게 뜬다"와 **"스크롤이 버벅인다"**.

앞의 것은 1부에서 다뤘다. 뒤의 것은 렌더링 비용이라 코드를 읽어야 한다.

그리고 이 화면에는 그 비용을 줄이려고 **직접 만든 최적화가 이미 들어가 있었다.**

그게 실제로 필요한 것이었는지 확인한다.

---

## 2부에서 다루는 것

1. **인스턴스의 생애** : 컴파일은 한 번, 나머지는 매번
2. **프레임 루프** : `tick(delta)`와 시간 관리
3. **`freezeOnOffscreen`** : 라이브러리가 이미 하고 있던 일
4. **덮어쓴 기본값** : 우리가 비용을 올린 곳
5. **기법 다섯과 착수 순서**

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

읽는 코드는 전부 `@lottiefiles/dotlottie-web` 0.79.1 배포 번들이다.

---

## 먼저 열어 둘 것

오늘 다루는 네 가지 설정을 **켜고 끄면서 직접 재 보는 페이지**를 같이 띄워 둔다.

### [/lab/lottie](/lab/lottie)

노드 26개를 재현했고, 체크박스로 축을 하나씩 바꿀 수 있다.

| 축                      | 바꾸는 것           |
| ----------------------- | ------------------- |
| `devicePixelRatio` 명시 | 캔버스 해상도       |
| PNG / WebP              | 에셋 전송량         |
| 즉시 / 지연 마운트      | 만드는 인스턴스 수  |
| `src` / `data`          | 요청 수와 첫 프레임 |

각 절이 끝날 때마다 해당 축을 켜고 숫자를 확인한다.

---

<!-- _class: invert -->

# 1장. 인스턴스의 생애

로티 하나를 띄우면 무슨 일이 일어나는가

---

## 한 번만 하는 일과 매번 하는 일

1부에서 wasm 로더가 Promise를 캐시한다는 것을 봤다.

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

**컴파일은 세션당 한 번**이다. 첫 로티가 그 비용을 치르고, 나머지는 만들어진 모듈을 쓴다.

그럼 두 번째 로티부터는 공짜인가? 아니다. **인스턴스마다 반복하는 일이 따로 있다.**

---

## 인스턴스가 하는 일

`new DotLottie({ canvas, src })` 하나가 이 순서를 밟는다.

```text
1. src fetch                     네트워크 (또는 HTTP 캐시)
2. arrayBuffer()                 메모리로 읽기
3. zip 해제                      CPU
4. JSON 파싱                     CPU
5. 이미지 디코딩                 CPU
6. 캔버스 크기 맞추기            DOM 읽기 + 쓰기
7. 렌더러에 등록                 wasm
```

1번은 HTTP 캐시가 막아 줄 수 있다. **3번부터는 캐시가 없다.**

---

## 같은 파일을 26번 푼다

`_fetchData`의 실물이다.

```text
async _fetchData(url, signal) {
  const res = await fetch(url, { signal })
  if (!res.ok) throw Error(...)
  const buf = await res.arrayBuffer()
  return isDotLottie(buf) ? buf : new TextDecoder().decode(buf)
}
```

**URL 단위 캐시가 없다.** 같은 `box_gift_lock.lottie`를 쓰는 노드가 여럿이면 각자 fetch하고 각자 푼다.

네트워크는 브라우저 HTTP 캐시가 막아 준다. 하지만 zip 해제와 파싱은 **인스턴스 수만큼 반복**되고, 그동안 메인 스레드가 묶인다.

---

## 중간 점검 ①

주차별 노드 26개 중 20개가 같은 잠금 상태라 `box_gift_lock.lottie` 하나를 공유한다.

이 파일에 대한 **네트워크 요청**은 몇 번이고, **zip 해제**는 몇 번인가?

---

## 중간 점검 ①: 정답

**네트워크는 사실상 1회, zip 해제는 20회다.**

라이브러리는 20번 다 `fetch`를 부른다. 다만 두 번째부터는 브라우저 HTTP 캐시가 받아 주므로 실제 요청은 거의 나가지 않는다.

`arrayBuffer()` 이후는 다르다. 캐시에서 온 바이트든 네트워크에서 온 바이트든 **똑같이 20번 풀고 20번 파싱한다.**

여기가 뒤에서 다룰 기법 하나의 출발점이다.

---

<!-- _class: invert -->

# 2장. 프레임 루프

무엇이 매 프레임 돌고 있는가

---

## 루프의 구조

로티 재생은 `requestAnimationFrame` 루프다.

```text
_animationLoop(now) {
  if (코어 없음) { 멈춤; return }
  if (재생 중 아님) { 멈춤; return }

  const delta = _lastFrameTime === null ? 0 : now - _lastFrameTime
  _lastFrameTime = now
  core.tick(delta)          // 경과 시간만큼 애니메이션을 진행시킨다
  ...
}
```

**`tick`에 넘기는 것은 프레임 번호가 아니라 경과 시간(ms)**이다. 이게 뒤에서 중요해진다.

---

## 루프를 켜는 조건

```text
_startAnimationLoop() {
  if (_animationFrameId === null      // 이미 돌고 있지 않고
      && _dotLottieCore               // 코어가 있고
      && !_isFrozen                   // 얼어 있지 않고
      && status() === Playing)        // 재생 상태이면
    _animationFrameId = requestAnimationFrame(...)
}
```

가드가 넷이다. 하나라도 어긋나면 루프가 안 켜진다.

특히 마지막 조건 때문에 **`pause()` 상태에서는 무엇을 해도 루프가 안 돈다.**

---

## 루프를 끄면 시간도 지워진다

```text
_stopAnimationLoop() {
  if (_animationFrameId !== null) {
    cancelAnimationFrame(_animationFrameId)
    _animationFrameId = null
  }
  _lastFrameTime = null      // ← 시간 기준점을 버린다
}
```

마지막 줄이 핵심이다. 루프를 끌 때마다 **시간 기준점을 지운다.**

그래서 다시 켜졌을 때 첫 `delta`가 0이 되고, `tick(0)`은 애니메이션을 진행시키지 않는다.

---

## 멈췄다 돌아와도 프레임이 안 튄다

```text
[루프 중지]   _lastFrameTime = null
   ...        (30초 경과)
[루프 재개]   delta = 0            ← 30,000ms가 아니다
              tick(0)             ← 멈춘 그 프레임에서 재개
```

시간 기준을 안 지웠다면 복귀 첫 프레임에서 `tick(30000)`이 되어 애니메이션이 훌쩍 뛰었을 것이다.

**멈춤과 재개가 시각적으로 자연스러운 이유가 이 한 줄에 있다.**

---

## 중간 점검 ②

`pause()`와 `freeze()`가 따로 있다. 문서는 `freeze()`를 "재생 상태를 바꾸지 않고 렌더 루프만 멈춘다"고 설명한다.

그렇다면 화면 밖에 30초 있다가 돌아온 로티는, 둘 중 어느 쪽으로 멈췄느냐에 따라 **다른 프레임**에서 재개되는가?

---

## 중간 점검 ②: 정답

**같은 프레임에서 재개된다.**

`freeze()`도 내부에서 `_stopAnimationLoop()`을 부르고, 거기서 `_lastFrameTime`이 지워지기 때문이다. 복귀 첫 `delta`는 둘 다 0이다.

차이는 **재생 상태(`status`)를 유지하느냐**에 있다.

|            | 루프 | `status`        |
| ---------- | ---- | --------------- |
| `pause()`  | 멈춤 | `Paused`로 바뀜 |
| `freeze()` | 멈춤 | `Playing` 유지  |

`status`가 `Paused`면 `_startAnimationLoop`의 가드에 걸려 **저절로 재개되지 않는다.**

---

<!-- _class: invert -->

# 3장. freezeOnOffscreen

화면 밖 렌더링은 누가 막고 있었나

---

## 이 화면에 들어가 있던 최적화

1부에서 본 네 작업 중 하나가 이것이었다.

> 단계 노드마다 플레이어가 `autoplay` + `loop`로 붙어 있어, 최대 26개 노드가 화면 밖에 있어도 캔버스 렌더링을 계속 수행하고 있었음

그래서 `IntersectionObserver`로 화면 밖 노드를 `pause()` 하도록 고쳤다.

진단은 그럴듯하다. **그런데 전제가 사실인지 확인해야 한다.**

---

## 기본값부터 본다

인스턴스 생성자다.

```text
this._renderConfig = {
  ...config.renderConfig,
  devicePixelRatio: config.renderConfig?.devicePixelRatio || 기본값(),
  freezeOnOffscreen: config.renderConfig?.freezeOnOffscreen ?? true,
}
```

`?? true`. **명시하지 않으면 켜져 있다.**

그리고 우리 코드는 `renderConfig`에 `autoResize`와 `devicePixelRatio`만 넘기고 있었다. 즉 **기본값 그대로 동작 중이었다.**

---

## 방어는 한 군데가 아니다

`freezeOnOffscreen`이 켜져 있으면 세 지점이 화면 밖 렌더링을 막는다.

| 지점             | 코드                                             |
| ---------------- | ------------------------------------------------ |
| 로드 완료 직후   | `observe(canvas, this), R(canvas) \|\| freeze()` |
| `play()` 호출 끝 | `freezeOnOffscreen && !R(canvas) && freeze()`    |
| 교차 상태 변화   | 공유 `IntersectionObserver` 콜백                 |

`R(canvas)`는 마진 없는 뷰포트 교차 판정이다.

---

## 지점 1: 로드되자마자 판정한다

```text
if (freezeOnOffscreen) {
  Observer.observe(canvas, this)
  R(canvas) || this.freeze()          // 화면 밖이면 즉시 freeze
}
```

로티가 로드된 시점에 이미 화면 밖이면 **그 자리에서 얼린다.** 한 프레임도 그리지 않는다.

우리 코드에도 같은 일을 하는 핸들러가 있었다. 로드 완료 콜백에서 화면 밖이면 `pause()`를 부르는 것이었다.

---

## 지점 2: play() 안에서 다시 판정한다

```text
play() {
  _stopAnimationLoop()
  const ok = core.play()
  if (ok || status() === Playing) {
    _isFrozen = false
    _startAnimationLoop()             // ← 여기서 루프를 켰다가
  }
  if (freezeOnOffscreen && !R(canvas))
    this.freeze()                     // ← 화면 밖이면 같은 호출 안에서 다시 끈다
}
```

화면 밖에서 `play()`를 불러도 **루프가 남아 돌지 않는다.** 켰다가 그 자리에서 끈다.

---

## 지점 3: 옵저버는 하나뿐이다

```text
static _initializeObserver() {
  if (!Observer._observer)
    Observer._observer = new IntersectionObserver(entries => {
      entries.forEach(e => {
        const inst = Observer._observedCanvases.get(e.target)
        if (inst) e.isIntersecting ? inst.unfreeze() : inst.freeze()
      })
    }, { threshold: 0 })
}
```

정적 멤버다. **모든 인스턴스가 옵저버 하나를 공유**하고, 캔버스는 `Map`으로 자기 인스턴스를 찾는다.

마지막 캔버스가 빠지면 `disconnect()`하고 참조를 비운다.

---

## 그래서 전제가 성립하지 않는다

"26개 노드가 화면 밖에서도 렌더링을 계속한다"는 진단은 **이 버전에서 성립하지 않는다.**

- 기본값이 `true`이고 우리는 끄지 않았다
- 로드 시점, `play()` 시점, 교차 변화 시점에서 각각 막는다
- 옵저버도 이미 하나로 공유된다

직접 만든 옵저버가 한 일에는 **전부 라이브러리 대응물이 있었다.**

---

## 그럼 무엇이 실제로 개선됐나

같은 화면에 나중에 들어간 다른 변경이 있었다. **화면 근처에 오기 전에는 로티 대신 정적 이미지를 그리는 것**이다.

이건 라이브러리가 대신 해 주지 않는다.

|                     | 무엇을 아끼나                          |
| ------------------- | -------------------------------------- |
| `freezeOnOffscreen` | 이미 만들어진 인스턴스의 **렌더 루프** |
| 지연 마운트         | **인스턴스 자체를 만들지 않는다**      |

fetch, zip 해제, 파싱, 캔버스 준비가 통째로 뒤로 밀린다. 1장에서 본 "인스턴스마다 반복하는 일"이 그대로 절약된다.

---

## 죽은 가설 하나

이 코드를 읽으면서 처음에 이렇게 판단했다.

`play()`가 `_isFrozen = false`로 되돌리니, 화면 밖 200px 지점에서 미리 `play()`를 부르면 **라이브러리의 freeze가 풀려 오히려 손해**가 아닌가?

틀렸다. 같은 `play()` 안에서 `!R(canvas) && this.freeze()`가 뒤따른다.

**한 문장의 앞부분만 읽고 결론을 냈다.** 압축된 번들은 줄바꿈이 없어서 한 문장이 수백 자가 되는데, 어디서 끝나는지 확인하지 않으면 반대 결론이 나온다.

---

## 중간 점검 ③

`freezeOnOffscreen`이 이미 막고 있으니 우리 옵저버는 지워도 되는가?

성능만 보면 그렇다. 그런데 **남겨야 할 이유가 하나 있다면** 무엇이겠는가?

---

## 중간 점검 ③: 정답

**도착 시점의 재생 준비**다.

라이브러리 옵저버는 마진이 0이라 화면에 들어온 **그 순간** 해동한다. 우리 옵저버는 200px 앞에서 `play()`를 부르므로, 그 시점에 코어의 `status`가 `Playing`이 된다.

`status`가 `Paused`면 `unfreeze()`가 불려도 `_startAnimationLoop`의 가드에 걸려 재개되지 않는다. 그 차이가 도착 시 체감을 만들 수 있다.

**다만 그건 측정으로 보여야 한다.** 지금은 근거가 없다.

---

## 둘을 같이 쓸 때의 함정

우리 옵저버만 쓰려면 라이브러리 쪽을 명시적으로 꺼야 한다.

```text
renderConfig={{ autoResize: true, freezeOnOffscreen: false }}
```

그런데 `setRenderConfig`의 구현이 이렇다.

```text
freezeOnOffscreen: given ?? true
```

**값을 명시하지 않고 호출하면 매번 `true`로 복원된다.** 리사이즈 콜백처럼 `setRenderConfig`를 부르는 경로가 있다면, 껐다고 생각한 설정이 되살아날 수 있다.

---

<!-- _class: invert -->

# 4장. 덮어쓴 기본값

우리가 비용을 올린 곳

---

## 캔버스 픽셀은 제곱으로 늘어난다

`devicePixelRatio`는 CSS 픽셀 하나를 실제 화면 픽셀 몇 개로 그릴지를 정한다.

```text
canvas.width  = CSS 너비  × dpr
canvas.height = CSS 높이  × dpr
```

면적이므로 **제곱**이다. CSS 104x104 캔버스 기준이다.

| dpr | 캔버스  | 픽셀 수 |
| --- | ------- | ------- |
| 2   | 208x208 | 43,264  |
| 2.5 | 260x260 | 67,600  |
| 3   | 312x312 | 97,344  |

---

## 라이브러리 기본값은 dpr이 아니다

기본값을 만드는 함수다.

```text
function 기본dpr() {
  return 1 + (window.devicePixelRatio - 1) * 0.75
}
```

`window.devicePixelRatio`를 그대로 쓰지 않는다. **1을 기준으로 초과분의 75%만 반영한다.**

| 기기 dpr | 라이브러리 기본값 |
| -------- | ----------------- |
| 1        | 1                 |
| 2        | 1.75              |
| 3        | **2.5**           |

선명함과 비용 사이에서 라이브러리가 이미 한 번 타협해 둔 값이다.

---

## 그런데 타입 문서는 다르게 적혀 있다

같은 패키지의 `index.d.ts`다.

```text
/**
 * Pixel density multiplier for high-DPI displays.
 * Higher values increase quality but use more memory.
 * Defaults to window.devicePixelRatio.        ← 실제와 다르다
 */
devicePixelRatio?: number
```

문서만 읽었다면 "기본값이 어차피 `window.devicePixelRatio`이니 명시해도 같다"고 판단했을 것이다.

**옵션 타입을 읽는 것은 출발점이지 끝이 아니다.** 숫자가 걸린 판단이라면 구현을 확인한다.

---

## 그런데 우리가 덮어쓰고 있었다

```text
renderConfig={{ autoResize: true, devicePixelRatio: window.devicePixelRatio }}
```

이 한 줄이 라이브러리의 타협값을 **원래 dpr로 되돌린다.**

dpr 3 기기에서 2.5로 그릴 것을 3으로 그리게 만든 것이다.

---

## 실측

Chrome 154, `deviceScaleFactor: 3`, CSS 104x104 캔버스다.

| 설정                          | 캔버스  | 픽셀 수 | 기준 대비 |
| ----------------------------- | ------- | ------- | --------- |
| **미지정** (기본 2.5)         | 260x260 | 67,600  | **69.4%** |
| `window.devicePixelRatio` (3) | 312x312 | 97,344  | 100%      |
| 상한 2                        | 208x208 | 43,264  | 44.4%     |

**그 줄을 지우기만 해도 픽셀이 30.6% 줄어든다.** 화질은 라이브러리가 고른 값으로 돌아간다.

---

## 확정과 미확정을 갈라 두자

**확정(산수와 실측)**: 캔버스 픽셀 수가 30.6% 줄어든다. GPU 메모리도 같은 비율로 준다.

**미확정**: 그래서 프레임 시간이 30.6% 줄어드는지는 **모른다.** 렌더 시간에는 픽셀 칠하기 외에 벡터 경로 계산과 레이어 합성이 섞여 있고, 그중 일부는 해상도와 무관하다.

재려면 Performance 패널의 `Frames` 트랙에서 16.7ms를 넘는 프레임 비율을 적용 전후로 비교한다.

---

## 중간 점검 ④

캔버스 렌더 비용을 낮추려 한다. 손잡이는 넷이다.

| 선택지                        | 무엇을 바꾸나                                                                              |
| ----------------------------- | ------------------------------------------------------------------------------------------ |
| 1. `Math.min(dpr, 2)`         | 기기 dpr에 상한을 걸어 2로 그린다                                                          |
| 2. `devicePixelRatio` 줄 삭제 | 라이브러리 기본값(2.5)으로 되돌린다                                                        |
| 3. `quality: 50`              | `RenderConfig`의 렌더 품질(0~100)을 낮춘다. 해상도가 아니라 **그리는 정밀도**를 떨어뜨린다 |
| 4. CSS 크기 축소              | 캔버스를 화면에서 더 작게 보이게 한다                                                      |

**가장 먼저 해 볼 것**은 무엇인가?

---

## 중간 점검 ④: 정답은 2

**2번**이 먼저다. 코드가 한 줄 줄고, 라이브러리가 이미 고른 값으로 돌아가며, dpr 3 기기에서 픽셀 30.6%를 얻는다. **되돌리는 것이라 화질을 새로 논의할 일도 없다.**

**1번**은 그다음이다. 2.5에서 2로 더 내리면 추가로 36%가 준다. 다만 라이브러리 판단을 넘어서는 선택이라 실기기에서 화질을 봐야 한다.

**3번**은 축이 다르다. 해상도가 아니라 그리는 정밀도를 낮추는 것이라, 얇은 선이나 곡선이 먼저 거칠어진다. 효과와 부작용을 따로 재야 한다.

**4번**은 기술 결정이 아니라 디자인 결정이다.

---

<!-- _class: invert -->

# 5장. 기법 다섯과 착수 순서

---

## 기법 1: 지연 마운트

화면 근처에 오기 전에는 로티 대신 정적 이미지를 그린다.

인스턴스를 만들지 않으므로 fetch, zip 해제, 파싱, 캔버스 준비가 전부 뒤로 밀린다.

**라이브러리가 대신 해 주지 않는 유일한 절약**이다. `freezeOnOffscreen`은 이미 만들어진 인스턴스의 루프만 멈춘다.

이 화면에는 이미 적용돼 있다.

---

## 기법 2: 애니메이션 데이터 공유

1장에서 본 `_fetchData`에 URL 캐시가 없다는 문제다.

```text
const cache = new Map()   // url -> Promise<ArrayBuffer>

function loadLottie(url) {
  if (!cache.has(url))
    cache.set(url, fetch(url).then(r => r.arrayBuffer()))
  return cache.get(url)
}
```

**`Map`에 Promise 자체를 담는 것**이 요점이다. 완료된 값을 담으면 "아직 받는 중" 구간에 요청이 또 나간다.

받은 `ArrayBuffer`는 `src` 대신 `data`로 넘긴다.

---

## 기법 2: 공짜가 아니다

26개 노드를 재현해 **이 축만 바꿔** 7회 재 봤다. 나머지 조건은 같고 캐시는 매번 비웠다.

|                          | `src` 각자 | `data` 공유 |
| ------------------------ | ---------- | ----------- |
| 로티 요청 수             | 26         | **3**       |
| 전송 바이트              | 692,200    | **108,048** |
| 첫 프레임까지 (중앙값)   | **44 ms**  | 77 ms       |
| 마지막 노드까지 (중앙값) | 68 ms      | 77 ms       |

요청과 전송량은 확실히 준다. 그런데 **첫 프레임이 33 ms 늦어졌다.**

---

## 왜 첫 프레임이 늦어지나

공유하려면 **우리가 먼저 받아서 `ArrayBuffer`를 손에 쥐어야** 한다. `src`를 그냥 넘기면 라이브러리가 그 자리에서 시작한다.

```text
src 각자   : new DotLottie({src})  →  라이브러리가 즉시 fetch 시작
data 공유  : await fetch(url)      →  다 받은 뒤에야
             new DotLottie({data})     인스턴스를 만든다
```

첫 프레임 범위는 32~~68 ms 대 64~~78 ms로 거의 겹치지 않았다. **재현되는 차이다.**

마지막 노드까지는 49~~83 ms 대 65~~79 ms로 겹친다. 이쪽은 **차이를 확정할 수 없다.**

---

## 그래서 어디에 쓰나

**전송량이 문제인 화면**에 쓴다. 같은 파일을 여러 노드가 공유하고, 회선이 느리고, 첫 노드가 몇십 ms 늦어도 괜찮은 경우다.

**첫 화면이 곧바로 보여야 하는 자리**에는 쓰지 않는다. 다이얼로그나 오버레이처럼 한 번에 하나만 뜨는 곳은 애초에 공유할 상대가 없다.

측정은 로컬 서버 기준이라 전송량 절감이 시간으로 나타나지 않았다. **실제 회선에서는 결론이 달라질 수 있다.**

---

## 기법 2: 착수 전에 잴 것

zip 해제와 파싱이 20회에서 1회로 준다. 다만 **한 번에 얼마가 드는지 재지 않았다.**

```text
performance.mark('parse-start')
// ... 로드 ...
performance.measure('parse', 'parse-start')
```

노드마다의 `measure`를 합쳐 총량을 먼저 본다. 합계가 몇 ms라면 공개 인터페이스를 바꿀 값이 없다.

플레이어 컴포넌트는 여러 곳에서 쓰이고, 다이얼로그처럼 한 번에 하나만 뜨는 자리는 이득이 없다. `src` 경로를 남기고 `data`를 선택으로 받는 편이 낫다.

---

## 기법 3: devicePixelRatio

4장에서 다뤘다. **줄을 지우는 것**이 1순위다.

- 미지정으로 되돌리면 dpr 3 기기에서 픽셀 30.6% 감소
- 상한 2까지 내리면 추가로 36% 감소, 대신 실기기 화질 확인 필요

코드가 줄어드는 유일한 기법이다.

---

## 기법 4: 워커 렌더링

`DotLottieWorker`는 `OffscreenCanvas`로 렌더를 워커 스레드에 넘긴다. 총 CPU는 비슷하거나 늘지만 **메인 스레드가 한가해진다.**

`workerId`는 기본값이 `defaultWorker`라, 지정하지 않아도 인스턴스들이 워커 하나를 공유한다.

걸림돌이 하나 있다. 이 화면은 매 프레임 오는 `onFrame` 이벤트로 툴팁 위치를 맞춘다. 워커를 거치면 그 이벤트에 지연이 생겨 **툴팁이 로티보다 늦게 따라올 수 있다.**

난이도가 가장 높고 기대값이 가장 불확실하다. 마지막에 둔다.

---

## 기법 5: 로티 개수 줄이기

지금까지는 전부 "26개를 어떻게 싸게 처리할까"였다. 질문을 바꿀 수도 있다.

현재 단계와 직전 단계만 움직이고 나머지는 정적 이미지로 두면 인스턴스가 **26개에서 두세 개**로 준다.

앞의 기법들이 각각 줄이려던 것을 한 번에 없앤다. 대신 기술 판단이 아니라 기획과 디자인 합의가 필요하다.

성능 논의에서 **요구사항을 바꾸는 선택지를 테이블에 올리는 것**은 엔지니어의 몫이다.

---

## 착수 순서

| 순위 | 기법                       | 근거                           | 비용            |
| ---- | -------------------------- | ------------------------------ | --------------- |
| 1    | `devicePixelRatio` 줄 삭제 | 실측 30.6%                     | 한 줄           |
| 2    | 로티 개수 줄이기           | 인스턴스 90% 감소              | 기획 합의       |
| 3    | 데이터 공유                | 전송 84% 감소, 첫 프레임 +33ms | 인터페이스 변경 |
| 4    | 우리 옵저버 정리           | 중복 제거                      | 측정 선행       |
| 5    | 워커 렌더링                | 미측정                         | 툴팁 검증       |

2번 합의를 먼저 열어 두고, 기다리는 동안 1번을 넣는 것이 효율적이다.

---

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

1. **`renderConfig`에서 `devicePixelRatio` 줄을 지운다.** 실기기에서 화질을 눈으로 확인한다
2. **Performance 패널로 스크롤을 녹화한다.** 16.7ms를 넘는 프레임 비율을 적용 전후로 비교한다
3. **`.lottie` 해제와 파싱 비용을 `performance.measure`로 잰다.** 합계를 보고 기법 2의 착수를 판단한다
4. **디자이너와 "몇 개가 실제로 움직여야 하는가"를 확정한다**
5. **우리 옵저버는 그대로 둔다.** 지우려면 도착 시 체감 차이를 먼저 측정한다

1번 외에는 전부 **재는 것이 먼저**다.

---

<!-- _class: invert -->

# 6장. 정리

---

## 종합 퀴즈 ①

동료가 이렇게 말한다.

> "화면 밖 로티가 계속 렌더링돼서 스크롤이 버벅여요. `IntersectionObserver`로 막읍시다."

무엇을 먼저 확인해야 하나?

---

## 종합 퀴즈 ①: 정답

**라이브러리가 이미 하고 있는지 확인한다.**

`dotlottie-web`은 `freezeOnOffscreen` 기본값이 `true`이고, 로드 시점과 `play()` 시점과 교차 변화 시점 세 군데에서 막는다. 옵저버도 모든 인스턴스가 하나를 공유한다.

확인하는 법은 두 가지다. 타입 정의(`RenderConfig`)를 읽거나, 1부에서 한 것처럼 배포 번들을 읽는다.

성능 작업을 시작하기 전에 쓰는 라이브러리의 옵션 타입을 한 번 훑는 습관이 이런 중복을 막는다.

---

## 종합 퀴즈 ②

화면 밖에 30초 있던 로티가 돌아왔다. 애니메이션이 30초어치를 건너뛰어 있을까?

그리고 그 답을 만드는 코드는 어느 줄인가?

---

## 종합 퀴즈 ②: 정답

**건너뛰지 않는다.**

`_stopAnimationLoop()`의 마지막 줄이 `_lastFrameTime = null`이기 때문이다.

```text
const delta = _lastFrameTime === null ? 0 : now - _lastFrameTime
```

복귀 첫 프레임의 `delta`가 0이 되고, `tick(0)`은 애니메이션을 진행시키지 않는다. 멈춘 그 프레임에서 이어진다.

`tick`이 프레임 번호가 아니라 **경과 시간**을 받기 때문에 생기는 동작이다.

---

## 종합 퀴즈 ③

`renderConfig`에 `devicePixelRatio: window.devicePixelRatio`를 명시하고 있다.

이 줄을 지우면 무슨 일이 생기나? 그리고 **확정해서 말할 수 있는 것**은 무엇인가?

---

## 종합 퀴즈 ③: 정답

라이브러리 기본값 `1 + (dpr - 1) × 0.75`로 돌아간다. dpr 3 기기에서 3이 아니라 **2.5로 그린다.**

**확정**: 캔버스 픽셀이 97,344에서 67,600으로 30.6% 줄어든다. 실측값이다.

**미확정**: 프레임 시간이 그만큼 줄어드는지는 재 봐야 안다. 렌더 비용에는 해상도와 무관한 부분이 섞여 있다.

---

## 2부에서 얻을 것 둘

**하나. 라이브러리가 이미 하는 일을 다시 만들지 않는다.**

이 화면의 옵저버는 `freezeOnOffscreen`과 중복이었다. 옵션 타입을 한 번 읽었으면 알 수 있었다.

**둘. 기본값을 덮어쓰고 있지 않은지 본다.**

`devicePixelRatio` 한 줄이 라이브러리의 타협값을 원래 dpr로 되돌려 픽셀을 44% 늘리고 있었다. 되돌리는 것이 가장 싼 개선이었다.

---

## 아직 확인하지 않은 것

- 스크롤 버벅임의 원인이 로티 렌더인지. 화면 밖이 이미 막혀 있으니 원인이라면 **화면 안 서너 개**가 범인이어야 한다
- 프레임 시간이 픽셀 수에 얼마나 비례하는지
- `.lottie` 해제와 파싱 1회당 소요 시간
- 200px 앞에서 미리 `play()` 하는 것이 도착 시 체감에 차이를 만드는지
- 워커 경유 시 `onFrame` 지연이 툴팁 동기화를 깨는지

**여기 없는 숫자는 쓰지 않는다.**

---

## 두 편을 합치면

|              | 1부                     | 2부                          |
| ------------ | ----------------------- | ---------------------------- |
| 본 것        | 파일과 네트워크         | 렌더러 코드                  |
| 도구         | `curl`, `grep`, `unzip` | 번들 읽기, 실측              |
| 가장 큰 발견 | wasm 472 KB, PNG 524 KB | 기본값을 덮어쓰고 있었다     |
| 공통 교훈    | 재기 전에 고치지 않는다 | 만들기 전에 이미 있는지 본다 |

두 편에서 실제로 코드를 바꾼 것은 **한 줄 삭제**뿐이다.

나머지는 에셋 교체와 설정 변경이었다.

---

## 참고

- `@lottiefiles/dotlottie-web` 0.79.1 배포 번들 (인용한 코드는 전부 여기서 읽고 가독성을 위해 이름과 줄바꿈만 손봤다)
- dpr 실측: Chrome 154, `deviceScaleFactor: 3`, CSS 104x104 캔버스
- 데이터 공유 실측: 26개 노드 재현, 캐시 무시 7회, 중앙값 기준
- 비교 데모: [/lab/lottie](/lab/lottie) (네 축을 따로 켜고 끌 수 있다)
- [1부: 병목을 찾는 법](/slides/lottie-deep-dive-1)
- 서비스와 식별자는 익명 처리했다
