# yceffort 🧪 — research

> Full markdown of every published slide. Each slide is separated by a thematic break.

---

# useQuery는 왜 스스로 suspend하지 못할까

Source: https://research.yceffort.kr/slides/why-usequery-cannot-suspend-itself.md

---
title: 'useQuery는 왜 스스로 suspend하지 못할까'
marp: true
paginate: true
theme: midnight
tags:
  - react
  - data-fetching
  - async
date: 2026-10-10
description: '처음 마운트되는 중에 suspend된 컴포넌트는 아무것도 남기지 못한다. 이 사실 하나로 useSuspenseQuery의 렌더 중 요청부터 experimental_prefetchInRender의 도입과 제거까지 실험으로 따라간다.'
published: true
art:
  undraw: throw-away
---

# useQuery는 왜 스스로 suspend하지 못할까

렌더가 버려진다는 사실 하나로 따라가기

<!-- _class: invert -->

@yceffort

<!--
대상: useQuery와 useSuspenseQuery를 써 봤고, Suspense가 promise를 받아 컴포넌트를 다시 렌더한다는 정도를 아는 사람.
목표는 하나다. 다음 장의 네 질문이 같은 사실에서 나온다는 것을 실험으로 확인하는 것.
로그는 전부 Node 24 + jsdom에서 React 19.2.8, @tanstack/react-query 5.101.4와 5.104.1로 직접 실행한 결과다. ms 값은 실행마다 몇 ms씩 달라진다.
-->

---

## 코드를 따라 읽다 보면 생기는 질문 네 개

1. suspend가 풀린 뒤의 재시도는 리렌더일까, 리마운트일까
2. useSuspenseQuery는 렌더 중에 요청을 보낸다. 렌더는 순수해야 하지 않나
3. useQuery에는 왜 렌더 중에 요청하는 옵션이 없을까
4. 그런 옵션(`experimental_prefetchInRender`)이 있었는데 왜 사라졌을까

하나씩 따로 공부해야 할 것처럼 보이지만, 넷 다 같은 사실 하나에서 답이 나온다.

<!-- 질문이 하나 풀릴 때마다 다음 질문이 생기는 경험을 했다면, 그 질문들이 사실은 같은 뿌리였을 가능성이 높다는 이야기로 연다. -->

---

## 먼저 단어 네 개

| 단어    | 이 덱에서의 뜻                                                                                    |
| ------- | ------------------------------------------------------------------------------------------------- |
| 렌더    | React가 컴포넌트 함수를 불러 **무엇을 그릴지 계산**하는 것. 이때는 아직 화면이 바뀌지 않는다      |
| 커밋    | 렌더한 결과를 **실제 DOM에 반영**하는 것. 여기서부터 사용자 눈에 보인다                           |
| 마운트  | 컴포넌트가 **처음으로** 커밋되는 것                                                               |
| suspend | 렌더 도중 promise를 던져 "데이터가 아직이라 못 그린다"고 알리는 것. 그동안 fallback이 대신 보인다 |

렌더는 계산일 뿐이라서, React는 렌더한 결과를 **커밋하지 않고 버릴 수도 있다.** 이 덱 전체가 이 한 가지 성질에서 출발한다.

---

## 그 사실

> 처음 마운트되는 중에 suspend된 컴포넌트는 **아무것도 남기지 못한다.**
>
> 그래서 남아야 하는 것은 **컴포넌트 밖에** 둔다.

풀어 쓰면, 한 번도 커밋되지 못한 컴포넌트는 React가 기억해 주지 않는다. `useState`나 `useRef`에 넣어 둔 값도 렌더 결과와 함께 버려진다.

| 항목                  | 버전                                                     |
| --------------------- | -------------------------------------------------------- |
| React                 | 19.2.8                                                   |
| @tanstack/react-query | 5.101.4 (플래그가 있던 마지막 버전), 5.104.1 (현재 최신) |
| 실행 환경             | Node 24 + jsdom, 요청은 50ms 걸리는 가짜 함수            |

---

## 실험 1: 렌더될 때마다 번호를 매기는 컴포넌트

```jsx
const cache = new Map() // 컴포넌트 밖에 있는 캐시

function read(key) {
  if (!cache.has(key)) cache.set(key, startFetch(key)) // 50ms짜리 요청 시작
  const entry = cache.get(key)
  if (entry.status === 'pending') throw entry.promise // 아직이면 suspend
  return entry.value
}

function Profile({id}) {
  const [inst] = useState(() => ++created) // 초기화될 때마다 #1, #2, #3
  useLayoutEffect(() => log(`커밋 (state #${inst})`), [])
  return <p>{read(id)}</p>
}
```

- `useState` 초기화 함수는 컴포넌트가 **새로 만들어질 때만** 돈다. 번호가 오르면 새로 만들어졌다는 뜻이다
- `useLayoutEffect`는 커밋 직후에 돈다. "커밋" 로그가 찍히면 화면에 나간 것이다

<!-- startFetch는 덱에서 줄인 이름이다. 실제 실험 코드는 {status: 'pending'} 객체를 만들고 sleep(50).then()으로 status와 value를 채운다. 동작은 같다. -->

---

## 실험 1 결과: 렌더 세 번, 초기화도 세 번

```text
   3ms  useState 초기화 → #1   render
   5ms  fallback 커밋
   7ms  useState 초기화 → #2   render
  56ms  useState 초기화 → #3   render
 310ms  커밋 (state #3)
```

- 커밋되기 전까지 Profile은 **세 번** 렌더됐고, 세 번 모두 `useState`가 **새로** 초기화됐다
- 화면에 남은 것은 #3뿐이다. #1과 #2가 들고 있던 값은 그대로 사라졌다
- #1은 첫 시도, #3은 데이터가 온 뒤의 재시도다. #2는 fallback을 띄운 직후 React가 한 번 더 해 본 렌더다. 렌더는 생각보다 자주 일어나고, 언제든 버려질 수 있다

<!--
#2의 정체: React 19부터는 suspend가 일어나면 fallback을 먼저 커밋하고, 같은 트리의 다른 컴포넌트들이 요청을 미리 시작할 수 있도록 suspend된 트리를 한 번 더 렌더한다(pre-warm). 로그에서 fallback 커밋 바로 뒤에 #2가 나오는 것이 그 흔적이다.
56ms에 데이터가 왔는데 커밋이 310ms인 이유: fallback을 너무 빨리 걷어 화면이 깜빡이지 않도록 React가 fallback을 최소 300ms 보여준다(react-dom 19.2.8의 FALLBACK_THROTTLE_MS = 300). 이 덱의 주제와는 상관없으니 질문이 나오면 그때만 짚는다.
-->

---

## 네 가지 경우를 나란히 놓으면

| 상황                       | 렌더 | `useState`               | 화면                                |
| -------------------------- | ---- | ------------------------ | ----------------------------------- |
| 리렌더 (setState)          | 1번  | 그대로 #3                | 그대로                              |
| 커밋된 뒤 다시 suspend     | 3번  | **그대로 #3**            | 기존 DOM을 `display: none`으로 숨김 |
| **처음 마운트 중 suspend** | 3번  | **매번 새로** #1, #2, #3 | fallback                            |
| 리마운트 (key 변경)        | 1번  | 새로 #4                  | 새로 그림                           |

한 번이라도 커밋된 컴포넌트는 React가 기억해 둔다. 그래서 다시 suspend돼도 숨겨 두었다가 그대로 꺼내 쓴다. 한 번도 커밋되지 않은 컴포넌트는 기억할 것이 없어서, 재시도는 사실상 처음부터 다시 마운트하는 것과 같다. 이것이 질문 1의 답이다.

<!-- "커밋된 뒤 다시 suspend"는 id를 a에서 b로 바꾼 동기 업데이트로 측정했다. 이때 숨겨진 트리의 layout effect는 정리됐다가 다시 보일 때 다시 실행되지만, useState 값은 #3 그대로였다. -->

---

## 그러니 남아야 하는 것은 밖에 둔다

실험 1에서 #3이 데이터를 받을 수 있었던 것은 `cache`가 컴포넌트 밖에 있었기 때문이다. 캐시를 컴포넌트 안으로 옮기면 어떻게 될까.

```jsx
function Profile({id}) {
  const cache = useRef(new Map()).current // 캐시를 안으로
  // ...나머지는 실험 1과 같다
}
```

- 2초 동안 `useState` 초기화가 **94번** 돌았고, 화면은 계속 loading이었다
- 렌더할 때마다 새 Map이 생기니 앞 렌더가 시작한 요청을 모른다. 또 요청하고, 그 요청이 끝나 재시도하면 또 새 Map이 생긴다

react-query도 정확히 같은 제약 위에 서 있다.

---

## react-query에서 무엇이 어디에 사나

도서관에 빗대면 이렇다.

| 도서관         | react-query                        | 사는 곳                                 |
| -------------- | ---------------------------------- | --------------------------------------- |
| 책             | **Query**: 데이터와 진행 중인 요청 | QueryCache (**컴포넌트 밖**)            |
| 그 책의 신청서 | **QueryObserver**                  | 컴포넌트의 `useState` (**컴포넌트 안**) |

<!-- prettier-ignore -->
```ts
// react-query 5.104.1, useBaseQuery.ts:83 (타입 생략)
const [observer] = React.useState(
  () => new Observer(client, defaultedOptions),
)
```

신청서는 컴포넌트와 함께 버려지고, 책은 서가에 남는다. 책은 queryKey마다 한 권, 신청서는 useQuery를 부를 때마다 한 장이다.

<!-- react-query 딥다이브 2부의 도서관 비유(QueryClient=도서관, QueryCache=서가, Query=책, QueryObserver=알림 신청)를 그대로 쓴다. -->

---

## useSuspenseQuery는 렌더 중에 요청한다

```ts
// react-query 5.104.1, useBaseQuery.ts:119
if (shouldSuspend(defaultedOptions, result)) {
  throw fetchOptimistic(defaultedOptions, observer, errorResetBoundary)
}

// suspense.ts:49 (타입 생략)
export const shouldSuspend = (defaultedOptions, result) =>
  defaultedOptions?.suspense && result.isPending
```

- 풀어 쓰면: 데이터가 아직 없으면(`isPending`) 그 자리에서 요청을 시작하고, 그 요청의 promise를 던진다(suspend)
- `fetchOptimistic`은 서가(QueryCache)에서 책(Query)을 찾거나 새로 만든 뒤 요청을 보낸다

---

## 실험 2: useSuspenseQuery가 suspend될 때

```text
   3ms  render Profile
   4ms    new QueryObserver #1
   4ms    queryFn 호출 #1
   7ms  render Profile
   7ms    new QueryObserver #2
  56ms  render Profile
  56ms    new QueryObserver #3
 309ms  커밋 Profile
 310ms    Observer #3 구독 시작
```

| 버려진 것 (컴포넌트 안) | 남은 것 (컴포넌트 밖)               |
| ----------------------- | ----------------------------------- |
| QueryObserver #1, #2    | Query 1개, 요청 1번, 받아 온 데이터 |

신청서는 세 장 썼다가 두 장이 버려졌다. 책은 한 권이고, 책을 가지러 간 것도 한 번이다.

<!-- 측정 방법: QueryObserver.prototype.bindMethods(생성자에서 한 번 불린다)와 onSubscribe를 감싸 Observer에 번호를 붙였고, queryFn은 호출 횟수를 셌다. 렌더 세 번의 구조는 실험 1과 같다. -->

---

## 렌더가 세 번인데 요청은 왜 한 번일까

| 렌더 | QueryCache에서 찾은 `['user']` | 그래서                                        |
| ---- | ------------------------------ | --------------------------------------------- |
| #1   | 없음                           | 새로 만들고 요청을 시작한다                   |
| #2   | 있음, 요청 중                  | 새로 요청하지 않고 진행 중인 promise를 받는다 |
| #3   | 있음, 데이터 도착              | 데이터를 바로 읽는다. suspend하지 않는다      |

```ts
// query-core 5.104.1, queryCache.ts:163 build() 발췌
let query = this.get(queryHash) // 같은 키의 Query가 이미 있으면 그것을 쓴다
if (!query) {
  query = new Query({...})
  this.add(query)
}

// query.ts:608 fetch() 발췌. 이미 요청 중이면
return this.#retryer.promise // 진행 중인 promise를 그대로 돌려준다
```

---

## 그래서 렌더 중 요청은 괜찮은가

엄밀히 말하면 규칙 위반이다. React는 렌더 중에 **부수 효과**(네트워크 요청이나 전역 값 변경처럼 계산 바깥을 바꾸는 일)를 하지 말라고 한다. 렌더가 몇 번 돌지, 버려질지 알 수 없기 때문이다.

그래도 react-query의 렌더 중 요청이 문제를 일으키지 않는 것은 그 두 가지 걱정을 모두 피하기 때문이라고 생각한다.

- **몇 번 돌아도 요청은 한 번이다.** 같은 키면 같은 Query, 같은 요청으로 모인다. StrictMode에서 Observer가 6개 만들어져도 queryFn은 1번이었다
- **버려져도 잃는 것이 없다.** 요청과 데이터는 컴포넌트 밖 QueryCache에 남는다

이것이 질문 2의 답이다.

<!-- React 규칙 원문은 react.dev의 "Components and Hooks must be pure" 문서의 "Side effects must run outside of render". 순수하다고 주장하는 것이 아니라, 규칙을 어겨도 관찰 가능한 차이가 생기지 않도록 설계했다는 점을 강조한다. -->

---

## 라이브러리도 "suspend 뒤에는 리마운트된다"고 적어 두었다

```ts
// react-query 5.104.1, suspense.ts:24 발췌
if (defaultedOptions.suspense) {
  // Handle staleTime to ensure minimum 1000ms in Suspense mode
  const MIN_SUSPENSE_TIME_MS = 1000
  // ...staleTime이 1000보다 작으면 1000으로 올린다
}
```

> This prevents unnecessary refetching when components remount after suspending (바로 아래 줄 주석)

- staleTime 기본값은 0이라, 데이터는 받자마자 **stale**(다시 받아야 할 데이터)로 취급된다
- 실험 2에서 #1이 받아 온 데이터를, 나중에 마운트하는 #3은 stale로 보고 또 요청하려 한다
- 그래서 suspense 모드에서만 staleTime을 최소 1초로 올려 이 중복 요청을 막는다. 이 보정이 **suspense 모드에만** 있다는 점은 실험 4에서 다시 나온다

---

## 이제 useQuery: 요청은 언제 나갈까

```text
   3ms    new QueryObserver #1
   4ms  render Profile (status=pending)
   6ms  커밋 Profile
   6ms    Observer #1 구독 시작
   6ms    queryFn 호출
  60ms  render Profile (status=success)
```

```ts
// query-core 5.104.1, queryObserver.ts:110 발췌 (한국어 주석은 덱에서 붙임)
protected onSubscribe(): void { // 구독이 시작될 때 불린다
  if (this.listeners.size === 1) {
    this.#currentQuery.addObserver(this)
    if (shouldFetchOnMount(this.#currentQuery, this.options)) {
      this.#executeFetch() // 요청은 여기서 나간다
```

**구독**은 Observer가 Query에 "바뀌면 알려 달라"고 등록하는 것이다. useQuery에서 구독은 커밋된 뒤에 시작되고, 요청도 그때 나간다. 신청서를 제출해야 사서가 책을 가지러 가는 셈이다.

<!-- 첫 렌더의 결과는 queryFn이 불리기 전인데도 fetchStatus가 'fetching'으로 나온다. getOptimisticResult가 "곧 요청할 것"을 미리 반영하기 때문이다. 실제 queryFn 호출은 구독 시점이다. -->

---

## useQuery를 쓰는 컴포넌트가 스스로 suspend하면

실험 1의 표에 대입해 본다.

1. 처음 마운트되는 중에 suspend되면 커밋되지 않는다
2. 커밋되지 않으면 Observer는 구독을 시작하지 않는다
3. 구독이 없으면 요청도 나가지 않는다
4. 요청이 없으면 promise는 풀리지 않고, 재시도할 계기도 생기지 않는다

신청서를 제출하기도 전에 찢어 버리니, 아무도 책을 가지러 가지 않는 상황이다. useQuery는 "커밋된 뒤에 요청한다"는 전제로 만든 훅이라 렌더 중 suspend와 맞물리지 않는다. 이것이 질문 3의 답이다.

---

## 그래도 시도는 있었다: `experimental_prefetchInRender`

```jsx
// react-query 5.101.4까지
const queryClient = new QueryClient({
  defaultOptions: {queries: {experimental_prefetchInRender: true}},
})

function Profile() {
  const {promise} = useQuery({queryKey: ['user'], queryFn})
  const user = use(promise) // React 19의 use()로 suspend
  return <p>{user.name}</p>
}
```

- 플래그를 켜면 useQuery 결과에 `promise`가 붙는다
- `use(promise)`는 React 19 API다. promise가 아직이면 suspend하고, 끝났으면 값을 돌려준다
- 메인테이너 TkDodo는 2024년 12월에 useSuspenseQuery라는 별도 훅을 없애고 이 방식으로 가는 것을 고민하고 있다고 했다(토론 #8381)

---

## 실험 3: 렌더 중 요청 블록을 지워 보면

플래그가 렌더 중에 요청을 걸어 주지 않았다면 어떻게 됐을지 보려고, 5.101.4에서 그 블록만 지운 사본으로 위 Profile을 실행했다.

```text
   3ms  render Profile
   4ms    new QueryObserver #1
   7ms  render Profile
   7ms    new QueryObserver #2

1.5초 뒤: 구독한 Observer 없음, queryFn 0회, 화면은 loading
```

앞 장의 1~4가 그대로 일어났다. 아무도 요청을 보내지 않으니 fallback에서 나오지 못한다.

<!-- 렌더가 두 번(첫 렌더와 미리 렌더링)에서 멈춘 것은 promise가 영원히 풀리지 않아 재시도가 일어나지 않기 때문이다. -->

---

## 그래서 플래그가 손으로 해 주던 일 두 가지

<!-- prettier-ignore -->
```ts
// react-query 5.101.4, useBaseQuery.ts:153 발췌 (5.102.0에서 삭제)
if (defaultedOptions.experimental_prefetchInRender && ...) {
  const promise = isNewCacheEntry
    ? fetchOptimistic(defaultedOptions, observer, errorResetBoundary) // (1)
    : query?.promise
  promise?.catch(noop).finally(() => {
    observer.updateResult() // (2)
  })
}
```

- (1) **구독을 기다리지 않고 렌더 중에 직접 요청한다.** 신청서를 제출하기 전에 사서에게 먼저 달려가 부탁해 두는 셈이다
- (2) **요청이 끝나면 promise를 직접 풀어 준다.** React가 기다리는 promise는 곧 버려질 Observer #1이 만든 것이다. #1은 구독을 하지 않으니, 원래라면 아무도 이 promise를 풀어 주지 않는다

<!--
(1) 원문 주석: "Fetch immediately on render in order to ensure `.promise` is resolved even if the component is unmounted"
(2) 원문 주석: "`.updateResult()` will trigger `.#currentThenable` to finalize". updateResult가 Observer 내부의 currentThenable(= useQuery 결과의 promise)에 데이터를 넣어 resolve시킨다(5.101.4 queryObserver.ts의 finalizeThenableIfPossible).
-->

---

## 실험 4: 원본 5.101.4에서는

```text
   4ms    new QueryObserver #1
   4ms    queryFn 호출 #1          ← 렌더 중에 요청
   7ms    new QueryObserver #2
  57ms    new QueryObserver #3
 311ms    Observer #3 구독 시작
 312ms    queryFn 호출 #2          ← 같은 데이터를 또 요청
```

- 화면은 제대로 나온다. 렌더 중 요청 블록 덕분이다
- 그런데 요청이 **두 번** 나갔다. #3이 마운트하며 구독할 때 데이터가 이미 stale이었기 때문이다
- useSuspenseQuery였다면 staleTime 1초 보정이 이 요청을 막았을 것이다. useQuery에는 그 보정이 없다. staleTime을 1000으로 주면 1번으로 줄어드는 것도 확인했다

---

## 실험 5: 컴포넌트를 나누면 문제가 사라진다

```jsx
function Parent() {
  const {promise} = useQuery({queryKey: ['user'], queryFn})
  return (
    <Suspense fallback={<Spinner />}>
      <Child promise={promise} />
    </Suspense>
  )
}
const Child = ({promise}) => <p>{use(promise)}</p>
```

| 5.101.4                | Observer | queryFn | 화면 |
| ---------------------- | -------- | ------- | ---- |
| 원본                   | 1개      | 1회     | 정상 |
| 렌더 중 요청 블록 삭제 | 1개      | 1회     | 정상 |

suspend되는 것은 Child뿐이다. 신청서를 가진 Parent는 커밋되고 구독하니, 렌더 중 요청 블록이 없어도 평소처럼 요청이 나간다.

---

## 메인테이너의 결론 (2025-12-30, 이슈 #9988)

> I think we should generally disallow suspending the same component that calls useQuery and get rid of the experimental flag. I think it's too hacky. What you'd need to do is to pass the promise to a separate component and wrap that in suspense.

- useQuery를 호출한 **바로 그 컴포넌트**를 suspend시키는 것은 막아야 한다
- 실험 플래그는 없애야 한다. 너무 꼼수 같다(too hacky)
- promise를 별도 컴포넌트로 넘기고, 그쪽을 Suspense로 감싸야 한다

없애자고 한 것은 꼼수(실험 3, 4)다. 바깥 배치(실험 5)는 오히려 **권장하는 방법**이었다.

---

## 그리고 제거 (5.102.0)

| 날짜       | 일                                                                        |
| ---------- | ------------------------------------------------------------------------- |
| 2025-12-30 | TkDodo, "같은 컴포넌트를 suspend시키는 것은 막고 플래그는 없애야" (#9988) |
| 2026-08-17 | "이 방식은 오래 버티지 못할 것 같다" (토론 #10733)                        |
| 2026-08-20 | PR #11221 "ref: remove experimental_prefetchInRender" 병합                |
| 2026-08-22 | 5.102.0 배포. 플래그와 결과의 `promise` 필드가 함께 사라짐                |

- 플래그만이 아니라 `promise` 필드까지 지웠다(5.104.1에서 직접 확인). 그래서 **바깥 배치(실험 5)도 함께 사라졌다**
- PR 본문과 changeset에는 지웠다는 사실만 있고 이유는 없다. 바깥 배치를 보여 주던 공식 문서 예제(Suspense 가이드)도 이 PR에서 삭제됐다

<!-- 참고로 useSuspenseQuery는 지금도 promise를 직접 던진다(useBaseQuery.ts:120). React 19의 use()로 바꾸는 PR #11237은 2026-10-10 기준 아직 열려 있다. -->

---

## 헷갈리기 쉬운 점: 사라진 것은 두 가지다

|               | 렌더 중 요청 (꼼수)                                        | 결과의 `promise` 필드              |
| ------------- | ---------------------------------------------------------- | ---------------------------------- |
| 무엇을 위해   | 같은 컴포넌트에서 suspend해도 요청이 나가게                | `use(promise)`로 suspend할 수 있게 |
| 문제가 있었나 | 꼼수 없이는 요청이 안 나가고(실험 3), 있어도 두 번(실험 4) | 바깥 배치에서는 없었다(실험 5)     |
| 지운 이유     | "too hacky" (#9988)                                        | **적혀 있지 않다**                 |

- 실험 3, 4에서 본 문제(버려지는 Observer와 중복 요청)는 **왼쪽 열**, 같은 컴포넌트에서 suspend할 때의 이야기다
- 바깥 배치는 멀쩡했는데도 `promise`와 함께 사라졌다. 이유가 기록에 없으니 다음 장은 근거를 붙인 추측이다

---

## 바깥 배치는 왜 같이 사라졌을까 (추측)

**1. 바깥 배치만 골라 남길 방법이 없다**

- `promise`는 useQuery가 돌려주는 값이라, 같은 컴포넌트에서 바로 `use(promise)`를 부르는 코드가 가장 자연스럽다. 토론 #10733에 달린 답변도 그렇게 썼다
- 꼼수만 지우고 `promise`를 남기면, 그 자연스러운 코드는 실험 3처럼 **영원히 loading에 멈춘다**

**2. 바깥 배치로 하던 일은 기존 훅으로 된다**

- 자식이 promise를 받는 대신 같은 키로 useSuspenseQuery를 부르면 된다(다음 장)
- 조건에 따라 suspend하고 싶다면 queryFn 안에서 조건을 검사해 `null`을 돌려주고, 그 조건을 queryKey에 넣는다(TkDodo, #10733)

<!-- 메인테이너가 직접 밝힌 이유가 아니라는 점을 분명히 말한다. 근거로 쓸 만한 정황이 하나 더 있다: 제거 PR이 query-core의 thenable.ts, retryer.ts, hydration.ts까지 고쳤고, useQueries는 끝내 이 기능을 지원하지 못했다(#9988). 유지 비용도 컸다는 뜻이다. -->

---

## 실험 6: promise 없이 같은 배치를 만들면

```jsx
function Parent() {
  useQuery({queryKey: ['user'], queryFn}) // 부모도 데이터가 필요하다면
  return (
    <Suspense fallback={<Spinner />}>
      <Child /> {/* promise를 넘기지 않는다 */}
    </Suspense>
  )
}
function Child() {
  const {data} = useSuspenseQuery({queryKey: ['user'], queryFn}) // 같은 키
  return <p>{data}</p>
}
```

| 5.104.1                               | queryFn | 화면 |
| ------------------------------------- | ------- | ---- |
| 부모 useQuery + 자식 useSuspenseQuery | 1회     | 정상 |
| 자식 useSuspenseQuery만               | 1회     | 정상 |

같은 키는 서가에서 책 한 권으로 모인다. 신청서가 몇 장이든 책을 가지러 가는 것은 한 번이다.

<!--
Observer는 부모 useQuery가 있을 때 5개(구독은 부모 #1과 자식 #5), 자식만 있을 때 3개(구독은 #3)가 만들어졌다. 자식의 Observer가 버려지는 것은 실험 2의 useSuspenseQuery와 똑같다. 즉 Observer가 버려지는 것 자체는 문제가 아니다. 같은 컴포넌트에서 useQuery로 suspend하면 요청을 맡을 Observer가 하나도 남지 않는다는 것이 문제였다.
-->

---

## 질문 네 개, 다시

| 질문                                | 답                                                                                                                     |
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| 1. 재시도는 리렌더인가 리마운트인가 | 한 번도 커밋되지 않았다면 리마운트와 같다. `useState`가 매번 새로 만들어진다                                           |
| 2. 렌더 중 요청이 왜 괜찮은가       | 몇 번 렌더돼도 요청은 한 번이고 결과는 컴포넌트 밖에 남는다. 렌더가 버려져도 잃는 것이 없다                            |
| 3. useQuery에는 왜 그 옵션이 없나   | useQuery는 커밋 뒤 구독할 때 요청한다. 스스로 suspend하면 커밋도, 구독도, 요청도 없다                                  |
| 4. 있던 옵션은 왜 사라졌나          | 같은 컴포넌트 suspend를 메우던 꼼수라서 걷어냈다. 바깥 배치는 멀쩡했지만 `promise`와 함께 사라졌다(이유는 기록에 없다) |

넷 다 "처음 마운트되는 중에 suspend된 컴포넌트는 아무것도 남기지 못한다"에서 나온다.

---

## 확인 문제

<!-- prettier-ignore -->
```jsx
function Providers({children}) {
  const [client] = useState(() => new QueryClient())
  return <QueryClientProvider client={client}>{children}</QueryClientProvider>
}

<Suspense fallback={<Spinner />}>
  <Providers>
    <Profile /> {/* 안에서 useSuspenseQuery를 쓴다 */}
  </Providers>
</Suspense>
```

**Q. 처음 화면을 열면 어떻게 될까?**

1. 요청 1번 뒤 Profile이 보인다
2. 요청이 계속 반복되고 Spinner에서 나오지 못한다

<!-- 힌트 없이 먼저 고르게 한다. 오늘의 문장에 대입하면 풀린다. Providers가 어디에 있는지를 보면 된다. -->

---

## 정답: 2. 요청이 계속 반복된다

| Suspense 경계 위치 | 2초 동안 만들어진 QueryClient | queryFn | 화면    |
| ------------------ | ----------------------------- | ------- | ------- |
| Providers **바깥** | 85~90개                       | 85~90회 | loading |
| Providers **안쪽** | 1개                           | 1회     | 정상    |

- 경계가 바깥에 있으면 Providers도 "처음 마운트 중 suspend된 트리"에 들어간다
- 재시도할 때마다 `useState`가 새로 돌아 QueryClient가 새로 생긴다. 새 QueryClient의 QueryCache는 비어 있으니 앞 요청의 결과를 모르고, 또 요청하고 또 suspend된다. 도서관을 매번 새로 짓는 셈이다
- 공식 SSR 가이드(advanced-ssr.md, 5.104.1)에도 같은 경고가 있다: "Avoid useState when initializing the query client if you don't have a suspense boundary between this and the code that may suspend"

"남아야 하는 것은 밖에 둔다"는 QueryClient 자신에게도 적용된다.

<!-- 바깥 경계 수치는 세 번 실행해 85, 88, 90이 나왔다. 실험 1의 useRef 캐시와 같은 구조의 실패다. -->

---

# 로티 딥다이브 1부: 병목을 찾는 법

Source: https://research.yceffort.kr/slides/lottie-deep-dive-1.md

---
title: '로티 딥다이브 1부: 병목을 찾는 법'
marp: true
paginate: true
theme: midnight
tags:
  - web-performance
  - animation
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
- 에셋 실측값은 실제 서비스 조사 기준이며, 서비스와 식별자는 익명 처리했다

---

# 로티 딥다이브 2부: 렌더러 안에서

Source: https://research.yceffort.kr/slides/lottie-deep-dive-2.md

---
title: '로티 딥다이브 2부: 렌더러 안에서'
marp: true
paginate: true
theme: midnight
tags:
  - web-performance
  - animation
  - browser
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)
- 서비스와 식별자는 익명 처리했다

---

# 외부 SDK를 다시 조립하기: 줄여야 할 코드, 지켜야 할 브라우저 동작

Source: https://research.yceffort.kr/slides/feconf-2026-vendor-sdk.md

---
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의 웹 전송용 번들을 69% 줄인 경험을 말씀드리겠습니다. 무엇을 줄였는지, 그리고 줄이는 과정에서 필요한 브라우저 동작을 어떻게 확인했는지 순서대로 보겠습니다.
-->

---

<!-- _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

저는 사용자가 앱을 켜면 가장 먼저 보는 증권홈 화면을 개발하고 있습니다. 여기 있는 책 3권을 썼고, DAN 24에서도 발표했습니다. 오늘은 홈의 성능을 개선하면서 실제로 사용하던 외부 SDK를 분석하고 재구성한 경험을 공유하겠습니다.
-->

---

<!-- _class: columns -->

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

## 이벤트만 보내는데, A/B 테스트 코드까지 내려받았습니다

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

<!-- -->

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

사내 사용 저장소 **8곳도 대부분 이벤트 전송만** 사용했습니다

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

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

홈에서는 사용자가 어떤 화면에 들어왔고 어떤 버튼을 눌렀는지 수집해야 했습니다. 이 이벤트는 수집 서버를 거쳐 대시보드에 쌓입니다. 그래서 SDK를 제거할 수는 없었습니다.

그런데 이 패키지는 이벤트 수집과 함께 A/B 테스트 평가도 제공했습니다. 평가는 사용자를 어느 실험군에 넣을지 결정하는 기능입니다. 홈에서 그 기능을 호출하지 않아도, SDK를 초기화하는 코드에서는 A/B 테스트 기능을 포함한 여러 구성요소를 만들었습니다.

우리 코드에 이벤트 전송 호출만 보인다고 해서, 다운로드하는 코드도 전송 기능뿐인 것은 아니었습니다. 사내에서 이 SDK를 쓰는 저장소 8곳을 살펴보니 대부분 같은 사용 형태였습니다. 필요한 수집 기능을 유지하면서 다른 기능의 다운로드 비용을 줄일 수 있는지 확인해 보기로 했습니다.
-->

---

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

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

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

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

console.log(Status.DEFAULT)
```

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

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

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

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

확인용으로 만든 코드는 이게 전부였습니다. 패키지에서 상수 하나를 가져와 출력했습니다. 클라이언트를 만들지도 않았고, 전송 메서드를 호출하지도 않았습니다. 그래도 번들이 예상보다 무거웠습니다.

오늘 보여드리는 벤더와 패키지, 내부 이름은 가명입니다. 코드도 의존 관계를 설명하기 위해 줄였습니다. 브라우저 API와 기능의 역할, 측정값은 그대로 설명하겠습니다. 이 작은 예제와 실제로 초기화하는 코드를 각각 번들해서 비교했습니다.
-->

---

<!-- _class: signal -->

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

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

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

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

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

<!--
발표 60초 / 02:30~03:30

실제로 초기화한 번들을 100으로 두었습니다. 상수 하나만 가져온 번들은 raw 기준 98, gzip 기준 97이었습니다. 여기서 raw는 번들 코드의 압축 전 크기이고, gzip은 전송 압축을 적용한 크기입니다. 상수 하나를 읽는 데도 실사용과 거의 같은 양의 코드가 남았습니다.

이 결과로 두 가지 측정 경로를 정했습니다. 하나는 지금 본 최소 import입니다. 쓰지 않는 코드가 얼마나 제거되는지 보기 좋습니다. 다른 하나는 실제 초기화와 이벤트 전송입니다. 홈에서 필요한 동작을 수행할 때의 크기입니다.

두 경로를 같은 조건에서 따로 측정해야 합니다. 최소 예제만 작아졌다고 홈에서 받는 번들도 같은 비율로 줄었다고 볼 수는 없습니다. 곧 보실 실험에서 두 결과가 크게 달라졌습니다.
-->

---

<!-- _class: chapter -->

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

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

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

> 01

<!--
발표 15초 / 03:30~03:45

왜 안 쓰는 코드가 남았을까요? 제거해도 안전한지 알 수 없는 경우와, 초기화 경로에서 실제로 참조하는 경우를 나눠서 보겠습니다.
-->

---

<!-- _class: columns -->

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

## 제거할 수 없는 이유를 두 가지로 나눴습니다

> ### 제거해도 안전한가?
>
> **호출의 부수효과를 확신하지 못함**
>
> 반환값을 쓰지 않아도 호출이 다른 상태를 바꿀 수 있음
>
> `sideEffects`: 모듈 단위<br>`PURE`: 호출 단위의 힌트

<!-- -->

> ### 실행 경로에서 참조하는가?
>
> **초기화 코드가 다른 기능을 참조**
>
> 이벤트 전송을 위한 초기화에서도 A/B 테스트 기능의 생성자에 도달
>
> 엔트리에서 이어지는 **import 관계를 확인**

<!--
발표 75초 / 03:45~05:00

번들러가 코드를 지우려면 프로그램의 동작이 유지되어야 합니다. 왼쪽부터 보겠습니다. 변수의 값을 쓰지 않아도 그 값을 만드는 함수가 전역 상태를 바꾸거나 리스너를 등록할 수 있습니다. 그런 호출을 지우면 동작이 달라지므로, 안전하다고 판단하지 못하면 코드가 남을 수 있습니다. sideEffects는 모듈 단위, PURE는 호출 단위에서 이 판단에 도움을 주는 선언입니다.

오른쪽은 실제 참조입니다. 우리 화면에서 평가 메서드를 부르지 않아도 SDK 초기화 함수가 A/B 테스트 기능의 생성자를 참조한다면, 번들러가 분석하는 경로에는 A/B 테스트 코드가 들어 있습니다. 이 경우에는 해당 구현을 가져오는 import 관계를 살펴봐야 합니다.

두 경우는 같은 번들 안에 함께 있을 수 있습니다. 그래서 설정 하나를 바꾸고 효과를 짐작하지 않고, 먼저 호출에 PURE 힌트를 줬을 때 두 측정 경로가 각각 얼마나 줄어드는지 확인했습니다.
-->

---

<!-- _class: compact -->

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

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

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

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

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

<!--
발표 60초 / 05:00~06:00

배포된 JavaScript에는 이런 형태가 있었습니다. 원래 TypeScript의 클래스였던 코드가 ES5로 내려가면서 함수로 바뀌었습니다. 안에서 생성자를 만들고 메서드를 붙인 다음, 그 생성자를 반환합니다. 마지막 괄호 때문에 파일을 읽는 과정에서 이 함수를 바로 호출합니다.

이 배포본의 해당 호출에는 PURE 표시가 없었습니다. 그래서 이 호출에 힌트를 주면 사용하지 않는 클래스가 얼마나 제거되는지 실험했습니다. 모든 즉시 실행 함수가 반드시 남는다는 일반 규칙을 말하는 것은 아닙니다. 이 SDK의 배포 형태와 이번 번들러 설정에서 측정한 결과를 보겠습니다.

반환된 클래스를 쓰지 않는 최소 예제와, 초기화가 그 클래스를 참조하는 실사용에서 차이가 날 수 있습니다. 두 결과를 함께 보겠습니다.
-->

---

<!-- _class: columns contrast -->

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

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

배포본의 클래스 IIFE에 `/*#__PURE__*/` 주석을 주입했습니다

> ### 상수 하나 import
>
> **−74%**
>
> gzip 크기: 100 → 26

<!-- -->

> ### 실제 초기화
>
> **−1%**
>
> gzip 크기: 100 → 99

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

_초기 분석 스냅샷 / 시나리오별 원본 gzip = 100 / [esbuild PURE 주석](https://esbuild.github.io/api/#pure)_

<!--
발표 90초 / 06:00~07:30

상수 하나만 가져온 경로에서는 gzip 크기가 약 74% 줄었습니다. 원래 크기를 100으로 두면 26만 남았습니다. PURE 주석이 이 배포본에서 사용하지 않는 호출을 제거하는 데 큰 영향을 준 겁니다.

그런데 실제로 클라이언트를 초기화하면 약 1%만 줄었습니다. 100에서 99가 됐습니다. 홈에서는 클라이언트를 만들고 이벤트를 보내야 하므로, 실제로 얻을 수 있는 개선은 이 두 번째 결과를 기준으로 봐야 합니다.

초기화 함수가 여러 기능의 생성자를 참조하고 있었습니다. 호출이 부수효과를 갖지 않는다고 알려줘도, 그 반환값이나 구현이 실제 경로에서 필요하면 제거할 수 없습니다. 최소 예제에서 크게 줄었던 이유를 그대로 실사용에 적용할 수 없었습니다.

이 실험 이후에는 초기화에서 무엇을 참조하는지에 집중했습니다. 화면에서 쓰지 않는 기능을 향하는 import를 전송 경로에서 없애야 했습니다. 따라서 다음 작업은 빌드 힌트를 추가하는 데서 더 나아가, 필요한 구성요소만 조립하는 엔트리를 만드는 것이었습니다.
-->

---

<!-- _class: chapter -->

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

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

## 원본을 확보하고, A/B 테스트 코드로 가는 경로를 끊습니다

> 02

<!--
발표 15초 / 07:30~07:45

이제 필요한 코드만 연결해 보겠습니다. 이벤트 수집을 유지하면서 어떤 코드는 그대로 두고, 어떤 연결을 바꿀지 먼저 정했습니다.
-->

---

<!-- _class: compact -->

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

## 핵심 계산을 유지하고, 조립을 바꿨습니다

> **이벤트와 실험 배정은 대시보드 숫자의 근거입니다**

| 선택지                        | 판단                                                     |
| ----------------------------- | -------------------------------------------------------- |
| 새로 구현                     | 실험 배정 계산까지 원본과 같게 재현해야 함               |
| 공개 저장소 포크              | 원본 저장소가 공개되지 않음                              |
| **배포물에서 복원 후 재조립** | **원본의 핵심 계산을 재사용하고 엔트리와 의존성을 교체** |

보존할 것: **평가와 이벤트 생성의 핵심 규칙**

바꿀 것: **import 관계, 구성요소의 조립, 배포 방식**

<!--
발표 65초 / 07:45~08:50

이 SDK의 이벤트는 대시보드로 들어가 실험 결과를 판단하는 근거가 됩니다. 평가 기능은 사용자를 실험군에 배정합니다. 계산이나 생성 규칙을 바꾸면 교체 전후의 데이터 의미가 달라질 수 있습니다. 그래서 평가와 이벤트 생성의 핵심 규칙은 원본을 유지하기로 했습니다.

웹 전송용을 가장 가볍게 만드는 것이 주된 목적이었지만, 평가를 포함한 다른 엔트리도 함께 재구성했습니다. 같은 API를 새로 구현하면 실험 배정 계산과 조건별 동작을 다시 재현해야 합니다. 공개 저장소를 포크하는 방법은 소스 저장소가 공개되지 않아 사용할 수 없었습니다.

배포물에서 원본 코드를 확보할 수 있다면 핵심 계산을 재사용할 수 있습니다. 여기에 엔트리와 의존성 구성을 바꾸고, 그 변경으로 출력과 동작이 달라지는지는 따로 검증하기로 했습니다. 코드를 보존하는 것만으로 동작까지 같아지는지는 뒤에서 다시 확인하겠습니다.
-->

---

<!-- _class: recovery -->

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

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

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

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

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

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

<!--
발표 60초 / 08:50~09:50

SDK와 함께 배포된 소스맵을 열어 보니 sourcesContent에 원본 TypeScript의 내용이 들어 있었습니다. 소스맵에 파일의 코드 전문까지 포함되어 있었습니다.

브라우저 소스맵에서는 브라우저 런타임 구현을 꺼냈습니다. 거기서 빠진 구현은 Node 배포본의 소스맵에서 보충했습니다. 타입만 정의한 모듈은 JavaScript를 만들지 않으므로, 빠진 타입 전용 모듈은 배포된 선언 파일로 채웠습니다.

이렇게 확보한 코드를 바탕으로 초기화 함수를 읽었습니다. 어디서 전송 구성요소를 만들고, 어디서 A/B 테스트를 비롯한 다른 기능을 만드는지 따라갔습니다. 이번 방법은 소스맵에 코드 전문이 포함되어 있어서 가능했습니다. 원본을 꺼내는 구체적인 순서와 소스맵이 없을 때의 선택지는 부록에 남겼습니다.
-->

---

<!-- _class: compact -->

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

## false를 넘겨도, import는 남았습니다

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

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

`evaluation: false`로 **생성은 막았지만**, 이 빌드에서는 A/B 테스트 코드가 남았습니다

> **실행할 때 기능을 끄는 것과, 빌드할 때 코드를 빼는 것은 다릅니다**

<!--
발표 85초 / 09:50~11:15

처음 시도한 방법은 기능을 켜고 끄는 플래그였습니다. 전송용에서는 evaluation에 false를 넘깁니다. 그러면 if문 안으로 들어가지 않으니 A/B 테스트 기능의 인스턴스를 만들지 않습니다. 실행할 때 불필요한 기능을 생성하지 않는다는 목적은 달성했습니다.

하지만 A/B 테스트 코드를 가져오는 import 문은 공통 모듈의 맨 위에 그대로 있었습니다. 같은 함수를 평가 기능이 필요한 엔트리에서도 사용하고, 옵션은 실행할 때 전달합니다. 이번 빌드에서는 번들러가 이 경로를 제거하지 못했고 A/B 테스트 코드가 전송용 번들에도 남았습니다.

조건문이 있거나 함수 인자를 쓴다는 이유만으로 모든 코드가 남는 것은 아닙니다. 빌드 시점에 값을 확정할 수 있고 번들러가 해당 경로를 분석할 수 있다면 제거되는 경우도 있습니다. 여기서는 그 최적화에 기대지 않고, 전송 엔트리에서 A/B 테스트 코드로 가는 참조를 구조적으로 없애기로 했습니다.

이 장에서 구분할 것은 두 가지입니다. 실행할 때 기능을 끄는 것, 그리고 빌드할 때 그 기능의 코드를 가져오지 않는 것입니다. 두 번째 목적을 달성하려면 이 import를 옮겨야 했습니다.
-->

---

<!-- _class: assembly compact -->

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

## A/B 테스트 기능을 만드는 코드를 별도 파일로 옮겼습니다

```ts
// core.ts: A/B 테스트 코드를 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()})
```

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

<!--
발표 80초 / 11:15~12:35

A/B 테스트 기능을 생성하고 등록하는 코드를 코어 바깥의 조립 파일로 옮겼습니다. 화면 위쪽의 코어는 필요한 구성요소를 인자로 받습니다. A/B 테스트 기능을 직접 가져와 만들지 않습니다.

Components는 구성요소의 타입입니다. import type은 런타임 JavaScript에서 제거되므로, 이 줄을 통해 A/B 테스트 코드를 가져오지는 않습니다. 실제로 A/B 테스트 기능을 import하고 생성하는 코드는 아래 파일에 모았습니다.

이제 평가가 필요한 엔트리만 아래 조립 파일을 가져오면 됩니다. 전송 엔트리는 전송 구성요소만 만들고, 평가 엔트리는 전송과 평가 구성요소를 함께 만들어 코어에 전달합니다. 코어가 모든 구현을 알고 직접 선택하던 일을 각 엔트리가 맡게 됐습니다.

전송 엔트리에서 시작해 import를 따라갔을 때 A/B 테스트 코드로 이어지는 경로가 없어야 합니다. 실제 구현에서도 A/B 테스트 기능과 매처, 실험 배정 계산을 가져오지 않는 전송 전용 코어 생성 경로를 두었습니다.
-->

---

<!-- _class: columns compact -->

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

## 전송 엔트리에서 A/B 테스트 코드로 가는 import를 끊었습니다

> ### 전송용 `./send`
>
> ```text
> index.send.ts
> ├─ assembleTransport.ts
> └─ core.ts
> ```
>
> **A/B 테스트 코드를 가져오는 경로 없음**

<!-- -->

> ### 평가 포함 `./evaluate`
>
> ```text
> index.evaluate.ts
> ├─ assembleTransport.ts
> ├─ assembleEvaluation.ts
> └─ core.ts
> ```
>
> 전송과 평가 구성요소를 함께 조립

**코어는 구성요소를 받고, 엔트리가 필요한 구현을 선택합니다**

<!--
발표 75초 / 12:35~13:50

이제 엔트리에서 출발해 보겠습니다. 왼쪽 전송 엔트리는 전송 조립과 코어를 가져옵니다. 이 경로를 따라가도 A/B 테스트 코드가 나오지 않습니다. 오른쪽 평가 엔트리는 여기에 평가 조립을 더합니다. 실제로 A/B 테스트 코드를 import하는 파일은 그 경로에서만 사용합니다.

전송과 평가에서 공통 코드를 재사용하는 것은 가능합니다. 다만 공통 코어가 다시 A/B 테스트 코드를 import한다면, 왼쪽 경로에도 그 코드가 따라옵니다. 그래서 엔트리만 두 개 만들었는지보다, 그 엔트리에서 어떤 구현까지 도달하는지를 확인해야 했습니다.

앞의 플래그 방식에서는 같은 구현을 가져온 뒤 실행 여부를 골랐습니다. 지금은 엔트리가 필요한 구현을 먼저 선택합니다. 이 차이가 SDK를 쓰는 쪽의 번들에서도 유지되어야 합니다.

여기까지가 소스의 구조입니다. 다음에는 이 모듈 관계를 어떤 형태의 패키지로 전달했는지 보겠습니다.
-->

---

<!-- _class: compact -->

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

## SDK를 쓰는 쪽 번들러에도 나눈 구조를 전달했습니다

```ts
// 원본 모듈 단위로 파일을 나누는 Rollup 출력 설정
const output = {format: 'es', preserveModules: true}
```

| 소스에서 나눈 것   | 배포 패키지에 남길 것              |
| ------------------ | ---------------------------------- |
| 전송과 평가 엔트리 | `exports`의 `./send`, `./evaluate` |
| 파일별 의존 관계   | ESM 파일과 그 사이의 import        |

폴리필과 공통 의존성도 **지원 환경과 출력을 대조하며 정리했습니다**

_의존성 교체의 세부 내용은 부록 / [Rollup preserveModules](https://rollupjs.org/configuration-options/#output-preservemodules)_

<!--
발표 65초 / 13:50~14:55

소스에서 나눈 구조를 배포 패키지에도 남겼습니다. preserveModules는 Rollup이 모듈 단위로 파일을 나눠 출력하는 옵션입니다. ESM으로 출력해서 파일 사이의 import 관계를 유지했고, package.json의 exports에는 전송과 평가의 서브패스를 연결했습니다.

그러면 SDK를 쓰는 프로젝트는 패키지 이름 뒤에 전송용 서브패스를 지정할 수 있습니다. 그 프로젝트의 번들러는 그 엔트리에서 이어지는 모듈을 분석합니다. 앞에서 A/B 테스트 코드로 가는 참조를 끊었기 때문에, 이 배포 구조에서도 전송용 경로를 선택할 수 있습니다.

공통 비용도 함께 줄였습니다. 필요한 메서드와 브라우저 지원 범위를 확인해 폴리필을 정리하고, 일부 의존성은 플랫폼 API로 교체한 뒤 출력을 대조했습니다. 이 작업들도 최종 크기에 포함됩니다. UUID와 base64 등의 교체 조건은 부록에 있습니다.

크기를 잴 때도 dist 파일 경로를 직접 지정하지 않고, SDK를 쓰는 쪽과 똑같이 패키지 이름으로 가져왔습니다. 그래야 exports와 패키지 설정을 거친 실제 결과를 볼 수 있습니다.
-->

---

<!-- _class: columns -->

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

## 이번 크기 측정의 범위는 웹 이벤트 수집입니다

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

<!-- -->

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

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

<!--
발표 25초 / 14:55~15:20

결과를 보기 전에 범위를 짚겠습니다. 여기서 측정한 전송용은 웹 이벤트 수집을 담당합니다. 페이지뷰와 이벤트를 만들고, 웹 사용자 상태를 관리하고, 페이지 이탈 때 남은 이벤트를 처리합니다. 앱 환경과 연결하는 웹뷰 연동은 필요한 서비스에서 별도로 구성하는 범위입니다.
-->

---

<!-- _class: results -->

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

## 웹 전송용 최종 결과: gzip 약 69% 감소

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

| 엔트리               | raw    | gzip   |
| -------------------- | ------ | ------ |
| **./send 웹 전송용** | **26** | **31** |
| ./evaluate 평가 포함 | 45     | 49     |
| . 전체 엔트리        | 69     | 70     |
| 벤더 원본            | 100    | 100    |

엔트리 분리와 빌드, 폴리필, 의존성 변경을 함께 반영한 **최종 측정값**입니다

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

<!--
발표 40초 / 15:20~16:00

웹용 패키지의 최종 측정 결과입니다. 각 열에서 벤더 원본을 100으로 두면, 전송용의 gzip 크기는 31입니다. 약 69% 줄었습니다. 평가 포함 엔트리는 49, 전체 엔트리는 70입니다.

엔트리 분리와 빌드, 폴리필, 공통 의존성 변경을 함께 반영한 값입니다. 첫 진단과는 측정본이 다르므로 두 표의 비율을 이어서 계산하지 않습니다.

이제 이 결과를 믿고 교체하기 위해 무엇을 확인했는지 말씀드리겠습니다. 그 과정에서 자동 이벤트의 연결이 빠진 문제와, 잘못 바꾼 코드를 놓치는 테스트를 발견했습니다.
-->

---

<!-- _class: chapter -->

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

# 작아진 SDK를<br>그대로 교체해도 될까요?

## 브라우저 동작과 원본 출력을 확인한 과정

> 03

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

번들 크기를 줄였어도 필요한 브라우저 동작은 유지해야 합니다. 엔트리를 나누면서 발견한 문제를 통해, 이벤트 발생과 원본 출력, 테스트 자체를 차례로 확인해 보겠습니다.
-->

---

<!-- _class: compact -->

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

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

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

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

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

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

<!--
발표 75초 / 16:15~17:30

전송 함수는 남아 있었습니다. 공개 API가 있는지 확인하는 검사도 통과했습니다. 그런데 SDK에는 직접 전송 함수를 호출하지 않아도 일어나야 하는 동작이 있습니다. 처음 켤 때 세션과 페이지뷰를 만들고, SPA에서 화면이 바뀌면 페이지 변경을 수집하고, 페이지를 떠날 때는 큐에 남은 이벤트를 처리합니다.

엔트리를 나누면서 자동 동작을 등록하고 초기화하는 코드가 전체 엔트리에만 남았습니다. 나눈 엔트리에는 전송 함수가 있어도 그 함수를 실행할 트래커와 리스너의 연결이 빠졌습니다. 등록이 없으면 예외가 발생하지 않습니다. 해당 시점에 이벤트가 만들어지거나 전송되지 않을 뿐입니다.

원본과 대조하던 테스트의 대상은 전체 엔트리였습니다. 나눈 엔트리에서는 공개 API를 확인했기 때문에 이 차이를 놓쳤습니다. 전체 엔트리가 통과한 결과를 기능별 엔트리의 자동 동작까지 확인한 것으로 확대할 수 없었습니다.

그래서 각 엔트리에서 초기화와 라우팅, 페이지 이탈까지 실제로 실행해 보기로 했습니다.
-->

---

<!-- _class: flow -->

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

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

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

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

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

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

페이지 이탈을 예로 보겠습니다. 브라우저에서 pagehide가 발생하면 라이프사이클 매니저가 이벤트를 받고, 등록된 이벤트 프로세서에 알립니다. 그러면 flush가 실행됩니다. beacon을 지원하는 환경에서는 큐의 이벤트를 sendBeacon 요청에 담습니다.

이벤트 프로세서의 구현을 번들에 남겨도, 가운데의 리스너 등록이 빠지면 오른쪽으로 실행이 이어지지 않습니다. 필요한 인스턴스를 만들고, 구독하고, 라이프사이클을 초기화하는 과정이 있어야 합니다. 그래서 공통으로 필요한 리스너 등록을 모든 관련 엔트리가 사용하는 조립으로 옮겼습니다.

세션과 페이지뷰 트래커도 같은 방식으로 복원했습니다. 함수 내부를 유지하는 것에 더해 누가, 어떤 시점에 그 함수를 실행하는지까지 확인했습니다. 이제 이 연결이 계속 유지되는지 검사할 방법이 필요했습니다.
-->

---

<!-- _class: compact -->

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

## 1. 필요한 시점에 이벤트가 나가는가?

```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')
```

초기화 후 **세션과 페이지뷰 생성**도 엔트리별로 확인했습니다

리스너 등록을 일부러 지우면, **이 검사가 실패해야 합니다**

이렇게 소스를 일부러 바꾸고 테스트가 실패하는지 보는 방법이 **뮤테이션 테스트**입니다

<!--
발표 75초 / 18:30~19:45

첫 번째 질문은 필요한 시점에 이벤트가 나가는가입니다. 각 엔트리를 부팅하고 before_leave라는 이벤트를 보냅니다. 이 이벤트는 우선 큐에 들어갑니다. 첫 단언에서는 아직 일반 배치 전송으로 나가지 않았는지 확인합니다.

그다음 pagehide를 발생시키고 예약된 처리를 진행합니다. 마지막 단언은 beacon 요청에서 이 이벤트가 관찰되는지 확인합니다. 리스너가 호출됐다는 기록만으로 통과시키지 않고, 연결이 이어진 끝의 전송 요청을 봅니다.

페이지 이탈 외에도 초기화 후 세션과 페이지뷰가 생성되는지 엔트리마다 확인했습니다. 그리고 리스너 등록을 일부러 지웠을 때 검사가 실패하는지도 확인했습니다. 그래야 해당 연결이 없어졌을 때 감지할 수 있습니다. 이렇게 소스를 일부러 바꾸고 테스트가 실패하는지 보는 방법을 뮤테이션 테스트라고 합니다.

이 검사는 준비한 jsdom 환경에서 생성과 전송 요청을 관찰한 결과입니다. 실제 서버 수집은 뒤에서 별도로 확인합니다. 여기까지 이벤트가 나가는지 봤으니, 다음에는 같은 입력에서 원본과 비교 대상 출력이 일치하는지 보겠습니다.
-->

---

<!-- _class: compact -->

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

## 2. 같은 입력에 출력도 일치하는가?

```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>**설정 응답과 브라우저 조건을 맞추고 반환값과 전송 필드를 대조**

<!--
발표 75초 / 19:45~21:00

두 번째 질문은 같은 입력에 출력도 일치하는가입니다. 재구성한 엔트리에는 전송용 외에 평가와 URL 리다이렉트도 있었습니다. 제가 기대값을 직접 작성하면 원본을 잘못 이해한 내용이 테스트에도 들어갈 수 있습니다. 그래서 원본을 함께 실행하는 대조 장치를 만들었습니다. 여기서는 이 장치를 하네스라고 부릅니다.

원본 UMD는 별도 jsdom에서 실행하고, 재구성본은 테스트의 jsdom에서 실행합니다. 설정 요청에는 같은 응답을 주고, 필요한 브라우저 조건을 맞춘 뒤 같은 시나리오를 실행합니다. 반환값과 전송 요청에서 비교 대상으로 정한 필드를 확인했습니다.

난수 식별자와 시각은 실행마다 달라지므로 값 자체를 같다고 비교할 수 없습니다. URL에서 유래하는 값도 환경을 고려해야 했습니다. 의도적으로 달라진 필드는 차이를 지우고 넘어가지 않고 양쪽 값을 각각 단언했습니다.

이렇게 테스트를 늘리면서 원본과 같은 결과가 나오는지 확인했습니다. 그런데 테스트의 통과만으로는 그 테스트가 잘못된 구현을 구별할 수 있는지 알 수 없습니다. 그래서 다음 질문을 추가했습니다.
-->

---

<!-- _class: mutation compact -->

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

## 3. 잘못 바꾸면 테스트가 실패하는가?

다른 엔트리인 **리다이렉트 경로**에서 URL 조건을 바꿨습니다

> `matches(url) → true`

조건에 맞지 않는 URL에서도 리다이렉트를 시도하도록 변경

```ts
// 원래는 통과하고, 위 변경을 넣으면 실패해야 하는 단언
expect(redirectAttempts).toEqual([])
```

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

<!--
발표 75초 / 21:00~22:15

세 번째 질문은 잘못 바꾸면 테스트가 실패하는가입니다. 여기서는 재구성한 다른 엔트리인 리다이렉트 경로를 보겠습니다. 실험 대상 URL이면 다른 페이지로 보내고, 반복해서 이동하지 않도록 가드 쿠키를 남기는 기능입니다.

기존 테스트는 조건에 맞지 않는 URL을 사용했습니다. 그래서 리다이렉트 시도가 빈 배열이어야 합니다. 이 상태에서 URL 조건 판정을 무조건 true로 바꿨습니다. 이제 조건에 맞지 않는 URL에서도 리다이렉트를 시도해야 하니, 빈 배열을 기대하는 단언은 실패해야 합니다.

그런데 테스트가 계속 통과했습니다. 앞에서 리스너 등록을 지운 것과 같은 뮤테이션 테스트입니다. 이번에는 검출되지 않는 변경을 통해 검사 범위의 문제를 발견했습니다.

바로 검증 로직을 늘리기 전에 실행 순서를 따라갔습니다. 변경한 조건문에 도달하는지, 어디서 리다이렉트가 실행되는지, 테스트는 어느 인스턴스를 관찰하는지 확인했습니다. 먼저 SDK가 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>다음 테스트의 **라우팅에도 반응했습니다**

<!--
발표 60초 / 22:15~23:15

SDK는 SPA 이동을 감지하기 위해 history.pushState와 replaceState를 감쌌습니다. 먼저 원래 함수를 실행하고, 그다음 자기 인스턴스에 위치가 바뀌었다고 알리는 구조입니다. 이 래퍼의 클로저에는 특정 인스턴스가 남습니다.

client.close를 호출해도 이 패치는 원래대로 돌아오지 않았습니다. 테스트마다 새 인스턴스를 만들면 이미 감싼 함수를 다시 감싸게 됩니다. 이전 테스트의 인스턴스를 참조하는 함수가 history에 계속 남았습니다.

다음 테스트는 부팅 과정에서 URL을 바꾸려고 pushState를 호출합니다. 그때 이전 인스턴스도 위치 변경에 반응했습니다. 모듈을 다시 읽거나 스토리지를 비우는 것으로는 history에 대입한 함수 참조가 복원되지 않습니다. 이 상태에서 조건을 무조건 true로 바꾸면 어떤 순서로 실행되는지 보겠습니다.
-->

---

<!-- _class: compact -->

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

## 이번 인스턴스는 조건문에 도달하지 못했습니다

| 순서 | 실행 주체               | 일어난 일                         |
| ---- | ----------------------- | --------------------------------- |
| 1    | 다음 테스트의 부팅 코드 | `pushState`로 URL 변경            |
| 2    | 이전 인스턴스           | 변경된 판정으로 리다이렉트 시도   |
| 3    | 이전 인스턴스           | **가드 쿠키 기록** (관찰 범위 밖) |
| 4    | 이번 인스턴스           | 쿠키를 보고 **조건문 전에 종료**  |
| 5    | 이번 테스트             | 리다이렉트 시도 없음으로 통과     |

> **바꾼 조건문을 실행하지 않아도, 기대한 관찰값은 나왔습니다**

<!--
발표 90초 / 23:15~24:45

먼저 다음 테스트가 부팅하면서 URL을 바꿉니다. 아직 SDK의 새 인스턴스로 확인하려는 시나리오를 실행하기 전입니다. 그런데 history에는 이전 인스턴스를 참조하는 래퍼가 남아 있습니다.

이전 인스턴스가 먼저 URL 변경에 반응합니다. 지금은 조건이 무조건 true가 되도록 바꾼 상태이므로 리다이렉트를 시도하고, 반복 실행을 막는 가드 쿠키를 기록합니다. 이 실행은 이번 테스트의 관찰 범위 밖에서 먼저 일어났습니다.

이어서 이번 테스트의 인스턴스가 실행됩니다. 가드 쿠키가 있으니 이미 리다이렉트를 처리했다고 판단하고 조기 종료합니다. 화면의 4행입니다. 우리가 바꾼 URL 조건문에 도달하기도 전에 끝났습니다.

테스트가 관찰하는 이번 인스턴스에는 리다이렉트 시도가 없습니다. 따라서 빈 배열이라는 기대값과 일치합니다. 바꾼 조건문을 검사하지 않았는데도 테스트는 통과한 겁니다.

변경하기 전에는 이전 인스턴스의 URL 조건이 맞지 않아 가드 쿠키를 남기지 않았습니다. 고의 변경을 넣자 실행 경로가 달라졌고, 바로 그 변경을 테스트가 놓쳤습니다. 그래서 고칠 곳은 테스트 사이에 남아 있는 history 상태였습니다.
-->

---

<!-- _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 동작은 원본과 맞춰 유지했습니다

<!--
발표 45초 / 24:45~25:30

SDK가 패치하기 전에 원래 pushState와 replaceState를 보관했습니다. 그리고 매번 테스트를 부팅하기 전에 원래 함수로 돌려놓았습니다. 그러면 이전 인스턴스를 참조하던 래퍼를 통해 다시 실행되지 않습니다.

복원한 환경에서 기존 테스트를 확인하고, URL 판정을 다시 무조건 true로 바꿨습니다. 이번에는 의도한 테스트가 실패했습니다. 변경한 조건을 실행했고, 그 결과의 차이를 관찰한 것입니다.

여기서 수정한 것은 테스트 격리입니다. SDK의 close 동작은 원본에 맞춰 유지했습니다. 원본 동작을 재현하는 일과, 각 테스트가 독립된 상태에서 그 동작을 검사하게 하는 일을 함께 해야 했습니다.
-->

---

<!-- _class: chapter -->

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

# 실제로 설치하는<br>패키지에서도 확인했습니다

## 빌드 결과, 타입, 수집과 유지 비용

> 04

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

이제 실제로 설치하는 패키지에서도 같은 검사를 이어가겠습니다. 빌드 결과와 타입, 실제 수집까지 확인했습니다.
-->

---

<!-- _class: compact -->

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

## 배포할 패키지를 별도 프로젝트에서 확인했습니다

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

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

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

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

<!--
발표 70초 / 25:40~26:50

앞에서 본 소스의 실행과 실제 배포 패키지의 실행 사이에는 빌드 과정이 있습니다. 이번에는 exports와 출력 구조도 바꿨기 때문에, 소스 테스트만으로 SDK를 쓰는 쪽이 같은 경로를 가져가는지 알 수 없었습니다.

그래서 별도 프로젝트에 배포할 패키지를 놓고, 화면처럼 패키지 이름과 서브패스로 import했습니다. 상대 경로로 소스 파일이나 dist 파일을 직접 가리키지 않았습니다. 실제 패키지 설정을 거쳐 어떤 엔트리가 선택되는지 확인하기 위해서입니다.

크기는 이 프로젝트의 코드를 번들해서 측정했습니다. 앞에서 보여드린 69%도 이 방식의 결과입니다. 동작은 빌드된 엔트리별 대표 시나리오를 원본과 대조했습니다. 타입은 별도 프로젝트가 배포된 선언 파일을 해석할 수 있는지 검사했습니다.

우리 소스의 타입 검사가 통과하는 것과, SDK를 쓰는 쪽에서 선언 파일을 읽는 것은 서로 다른 확인입니다. 소스에서 시작한 검증을 실제로 전달할 패키지까지 이어갔습니다.
-->

---

<!-- _class: resources -->

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

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

- [외부 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와 시각 결과를 보존하며 애니메이션 구동부 교체
- [number-flow 성능 개선기: 애니메이션을 합성 스레드로 옮기기](https://yceffort.kr/2026/10/number-flow-compositor-performance)<br>인터럽트 동작을 유지하며 애니메이션을 합성 스레드로 이동
- [실행하지 않는 JavaScript를 10MiB까지 늘려봤다](https://yceffort.kr/2026/09/unused-javascript-cost)<br>크기와 파싱, 컴파일, 초기화 비용을 분리한 측정 기록

<!--
발표 20초 / 26:50~27:10

이 SDK 재구성은 홈 성능 개선 작업의 한 부분이었습니다. 함께 진행한 배너 프레임드랍 개선, 구형 브라우저의 숫자 애니메이션 이식과 합성 스레드 이전, 실행하지 않는 JavaScript의 비용 실험도 블로그에 글로 남겼습니다. 이 작업들을 함께 적용한 홈 전체 결과를 보겠습니다.
-->

---

<!-- _class: outcomes compact -->

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

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

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

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

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

**여러 개선을 함께 적용했으며, SDK만의 기여도는 분리하지 않았습니다**

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

이 작업들을 함께 적용한 홈 전체 결과입니다. 모바일 에뮬레이션에서 CPU를 네 배 늦추고, 개선 전 네 번과 후 다섯 번의 중앙값을 비교했습니다. 첫 화면이 표시되는 FCP는 44.3%, 내려받는 JavaScript는 38.2%, 초기 5초의 CPU 지표는 30% 줄었습니다. 여러 개선을 함께 적용한 결과라서, SDK만의 기여도는 따로 나누지 않았습니다.
-->

---

<!-- _class: compact -->

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

## 확인한 결과와 남은 범위를 구분했습니다

| 확인한 것                  | 확인 방법                                     |
| -------------------------- | --------------------------------------------- |
| 대표 동작과 비교 대상 출력 | 소스와 빌드 산출물을 원본과 대조              |
| 의도적인 출력 차이         | 설정 메타데이터 등 양쪽 값을 각각 단언        |
| 실제 수집과 기존 지표      | 샌드박스 화면 조작, 대시보드와 평소 수치 확인 |

**복원:** 배포된 소스맵에 원본 코드 전문이 있어서 가능했습니다

**크기:** 웹 수집 범위의 결과이며, 서비스의 웹뷰 연동은 별도입니다

**동작:** 준비한 입력과 실행 환경의 결과이며, 모든 입력과 환경의 동등성을 보장하지는 않습니다

<!--
발표 60초 / 27:45~28:45

확인한 내용을 범위별로 정리했습니다. 대표 동작과 비교 대상 출력은 소스와 빌드 산출물에서 원본과 대조했습니다. 의도적인 출력 차이는 양쪽 값을 각각 확인했습니다. 예를 들어 전송 전용 경로는 워크스페이스 설정을 받지 않아 일부 설정 메타데이터가 원본과 다릅니다.

실제 수집은 분리된 샌드박스에서 화면을 조작해 이벤트를 발생시키고 대시보드에 기록되는지 확인했습니다. 평소 수치와 수집에 미치는 영향도 함께 살폈습니다. jsdom에서 요청을 관찰한 결과와 서버에서 데이터를 확인한 결과를 나눠서 봤습니다.

원본 복원은 배포된 소스맵에 코드 전문이 들어 있었기 때문에 가능했습니다. 이 발표의 크기는 웹 이벤트 수집 범위입니다. 서비스에서 구성하는 웹뷰 연동의 크기는 포함하지 않습니다. 동작 대조도 준비한 입력과 실행 환경에서 확인한 결과입니다. 모든 입력과 모든 브라우저 환경에서 동등하다고 확대하지 않고, 확인한 범위와 의도적인 차이를 남겼습니다.
-->

---

<!-- _class: takeaways compact -->

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

## 다음 버전의 검증까지 유지 비용입니다

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

<!--
발표 45초 / 28:45~29:30

재구성한 SDK는 이후 유지 비용도 직접 감당해야 합니다. 벤더 버전이 바뀌면 핵심 계산과 전송 필드뿐 아니라 초기화와 리스너 등록이 바뀌었는지도 봐야 합니다. 그런 다음 원본 대조, 의도적인 차이, 빌드 결과, 배포 패키지의 타입과 크기를 다시 확인합니다.

소스맵을 다시 추출한다고 갱신이 끝나지는 않습니다. 우리가 수정한 파일을 덮을 수도 있습니다. 원본 배포물과 수정 내역을 관리하고, 문제가 생기면 원본으로 되돌릴 경로도 준비해야 합니다. 최적화로 얻은 결과와 이 유지 작업을 함께 고려해야 합니다.
-->

---

<!-- _class: takeaways -->

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

## import는 줄이고, 실행 연결은 지킵니다

1. **최소 예제와 실사용을 따로 잽니다**<br>PURE의 효과가 74%와 1%로 달랐습니다
2. **엔트리에서 불필요한 구현으로 가는 import를 끊습니다**<br>실행할 때 끄는 플래그에서 파일별 의존 관계로 바꿨습니다
3. **실제 동작을 보고, 틀리게 바꿔도 봅니다**<br>자동 이벤트와 이탈 전송을 확인하고, 테스트가 차이를 잡는지 검사했습니다

> **불필요한 코드는 줄이고, 필요한 브라우저 동작은 검증하며 유지했습니다**

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

오늘 내용을 세 가지로 정리하겠습니다. 먼저 최소 예제와 실사용을 따로 측정합니다. 같은 PURE 주석을 붙여도 상수 예제는 74%, 실제 초기화는 1% 줄었습니다. 실제 사용 경로를 함께 봐야 다음에 바꿀 지점을 정할 수 있었습니다.

다음은 import 관계입니다. 실행할 때 기능을 끄는 플래그로는 이번 번들에서 A/B 테스트 코드를 제거하지 못했습니다. 전송 엔트리에서 그 코드로 가는 참조를 끊고, 그 구조를 배포 패키지에 남겼습니다.

마지막은 실제 동작과 검사의 검출 능력입니다. 자동 이벤트와 페이지 이탈 전송을 확인하고, 원본과 출력을 대조했습니다. 일부러 틀리게 바꿨을 때도 통과하는 테스트를 통해 격리 문제를 찾아냈습니다.

불필요한 구현을 가져오는 연결은 줄이고, 필요한 브라우저 동작을 실행하는 연결은 검증하며 유지했습니다. 외부 SDK를 바꾸는 작업에서도, 직접 만드는 라이브러리의 엔트리를 나누는 작업에서도 이 두 관계를 함께 확인하시면 좋겠습니다.
-->

---

<!-- _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초 / 30:35~30:55

소스 복원과 구현, 검증의 상세 내용은 화면의 글에 정리했습니다. 오늘 다루지 않은 측정 조건과 의존성 교체 과정은 부록에 있습니다. 지금까지 카카오페이증권 김용찬이었습니다. 들어주셔서 감사합니다.
-->

---

<!-- _class: columns compact hidden-slide trace -->

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

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

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

> ### 검색한 파일 목록
>
> `util/base64.ts`<br>`fetcher/MetadataLoader.ts`<br>**`fetcher/ConfigLoader.ts`**

<!-- -->

> ### 실제 번들에서 따라간 경로
>
> `index.send.ts`<br>→ … → **`ConfigLoader.ts`**<br>→ `base64.ts`

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

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

<!--
질의응답용 / 본 발표 30분에 포함하지 않음

예상 질문: 사용처 검색과 번들 분석은 무엇이 다른가?

사용처 검색은 해당 이름이 나오는 파일을 보여줍니다. 엔트리에서 그 파일까지 어떤 경로로 이어지는지는 별도 정보입니다. 이 사례에서는 전송 엔트리에서 설정을 읽는 모듈을 거쳐 base64에 도달했습니다. 해당 엔트리를 실제로 번들하고 metafile의 inputs와 imports를 따라가 확인했다고 설명합니다.
-->

---

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

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

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

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

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

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

<!--
질의응답용 / 본 발표 30분에 포함하지 않음

예상 질문: 엔트리 분리 외에 무엇을 줄였는가?

폴리필은 사용하는 메서드와 지원 브라우저를 확인한 뒤 제거했습니다. UUID는 형식을 유지하고, base64는 원본 라이브러리와 결과를 대조했습니다. 쿼리 문자열도 반복 키와 공백 인코딩을 확인했습니다. 69%는 이 변경들을 함께 반영한 결과이며 각 변경의 절감 기여도를 분리한 수치는 아닙니다. 외부 런타임 의존성 0개가 웹 API 지원 조건까지 없어진다는 뜻은 아닙니다.
-->

---

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

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

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

Next.js 16 + Turbopack의 **한 빌드 안에** 라우트 3개를 두었습니다

이 실험은 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와 초기화를 **별도 빌드**해서 비교해야 합니다

<!--
질의응답용 / 본 발표 30분에 포함하지 않음

예상 질문: Next.js에서도 상수 하나가 큰 청크를 가져왔는가?

한 빌드 안의 최소 import 라우트와 초기화 라우트가 같은 SDK 청크를 가져오는 것을 확인했습니다. 이 실험에서는 앱 연동용 래퍼 패키지를 사용했습니다. 공유 청크의 크기만으로 개별 코드의 제거 가능성을 판단할 수는 없습니다. 그래서 본문의 원인 구분은 같은 엔트리를 최소 import와 실제 초기화로 각각 별도 빌드한 결과를 사용합니다.
-->

---

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

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

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

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

폴리필 import를 제거하고 **빌드 문법 타깃을 ES2017**로 맞췄습니다

다만 `TextEncoder`, `btoa`, Web Crypto는 실행 환경이 제공해야 합니다

**ES2017로 빌드했다는 사실만으로 브라우저 호환성이 보장되지는 않습니다**

그래서 각 API의 지원 하한을 문서에서 확인하고 팀 browserslist 타깃과 대조했습니다

<!--
질의응답용 / 본 발표 30분에 포함하지 않음

예상 질문: 폴리필을 제거해도 브라우저에서 실행되는가?

사용처와 지원 타깃을 대조했습니다. flat과 flatMap 3곳을 치환한 뒤 폴리필 import를 제거했습니다. ES2017은 출력 문법의 타깃이며 TextEncoder, btoa, Web Crypto의 지원을 대신 보장하지 않습니다. 해당 API의 문서상 지원 하한을 팀 browserslist와 확인한 범위라고 답합니다. 모든 구형 브라우저를 지원한다고 확대하지 않습니다.
-->

---

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

<!-- _class: compact 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건도 같은 방식으로 대조
```

<!--
질의응답용 / 본 발표 30분에 포함하지 않음

예상 질문: base64 교체는 어떻게 확인했는가?

문자열을 UTF-8 바이트로 만들고 btoa가 받을 수 있는 바이트 문자열로 바꾼 뒤 URL 안전 형식으로 변환합니다. 같은 입력의 결과를 원본 라이브러리와 직접 비교했습니다. 길이 0~64의 패딩 경계와 시드를 고정한 유니코드 입력 2000건을 포함합니다. 이 검사는 준비한 입력에 대한 대조이며 모든 가능한 문자열을 전수 검증한 것은 아닙니다.
-->

---

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

<!-- _class: compact 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` 기반 폴백을 뒀습니다

<!--
질의응답용 / 본 발표 30분에 포함하지 않음

예상 질문: randomUUID만 쓰지 않고 폴백을 둔 이유는?

randomUUID의 노출에는 secure context 제약이 있습니다. 해당 함수가 없을 때 getRandomValues로 16바이트를 만들고 버전과 변형 비트를 설정해 UUID v4 형식을 유지했습니다. 원본과 난수 값이 같다는 주장은 하지 않습니다. 확인 대상은 식별자의 형식이며, 실행 환경에 getRandomValues가 제공되어야 합니다.
-->

---

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

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

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

**지원 버전뿐 아니라 실행 환경의 제약까지 확인해야 합니다**

MDN 본문과 스펙에 secure context 제약이 명시되어 있습니다

사용 환경에서 `crypto.getRandomValues`도 제공되는지 확인해야 합니다

<br>

**라이브러리 크기와 출력 계약은 별개입니다**

더 가벼운 생성기를 쓰더라도 식별자의 문자 집합과 길이, 구조가 달라지면 기존 계약을 만족하는지 다시 확인해야 합니다

<br>

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

<!--
질의응답용 / 본 발표 30분에 포함하지 않음

예상 질문: 다른 작은 ID 생성기를 쓰면 안 되는가?

선택할 수는 있지만 기존 식별자의 문자 집합과 길이, 구조가 바뀌면 그 식별자를 쓰는 쪽이 기대하는 형식도 다시 확인해야 합니다. 이 작업에서는 UUID 형식을 유지했습니다. 지원 버전과 secure context 같은 환경 조건도 따로 확인해야 합니다. 라이브러리의 크기만으로 교체의 적합성을 판단하지 않았다고 설명합니다.
-->

---

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

<!-- _class: flow compact 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)_

<!--
질의응답용 / 본 발표 30분에 포함하지 않음

예상 질문: 소스맵에서 원본을 어떻게 꺼냈는가?

배포 JavaScript가 가리키는 소스맵을 찾고, sources의 경로와 같은 인덱스의 sourcesContent를 연결해 파일로 저장합니다. 이 작업에서는 여러 배포본의 맵으로 구현을 확보한 뒤 타입 전용 모듈을 선언 파일로 보충했습니다. 구현이 없는 선언 파일로 런타임 코드를 대신하면 안 됩니다. sourcesContent가 없으면 mappings만으로 원본 코드 전문을 복원할 수 없습니다.
-->

---

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

<!-- _class: compact 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)

<!--
질의응답용 / 본 발표 30분에 포함하지 않음

예상 질문: 소스맵이 없는 SDK에도 적용할 수 있는가?

이번 복원 방식은 코드 전문이 포함된 소스맵이 있어야 가능합니다. 원본을 확보할 수 없다면 비압축 JavaScript의 호출 경로를 분석하거나, 벤더에 경량 엔트리를 요청하는 방법을 검토할 수 있습니다. 사용 화면 제한과 지연 로딩도 선택지지만 초기 수집 시점이 달라질 수 있으므로 필요한 이벤트가 유지되는지 확인해야 합니다.
-->

---

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

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

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

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

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

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

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

<!--
질의응답용 / 본 발표 30분에 포함하지 않음

예상 질문: 원본 대조만으로 놓치는 경우가 더 있는가?

검증용 설정에 중복 키를 넣어 기존 설정을 덮은 적이 있습니다. 양쪽 SDK 모두 검사할 설정을 받지 못하니 같은 결과를 반환했고 대조가 통과했습니다. 그래서 입력 데이터의 키가 겹치면 실패하는 검사를 추가했습니다. 원본과 출력이 같다는 사실과, 의도한 시나리오를 실제로 실행했다는 사실을 함께 확인해야 합니다.
-->

---

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

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

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

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

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

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

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

<!--
질의응답용 / 본 발표 30분에 포함하지 않음

예상 질문: 뮤테이션 테스트 결과를 어떻게 해석했는가?

직접 선택한 변경 약 100개를 각각 적용하고 약 2천 개의 테스트를 실행했습니다. 단언 실패가 나온 경우에는 실패한 테스트 이름까지 확인했습니다. 실행이나 리포트 오류는 검출 성공에 포함하지 않았습니다. 안 잡힌 변경은 현재 입력과 단언이 차이를 구분하지 못한 결과입니다. 사람이 선택한 변경 지점에 대한 검사이므로 전체 결함의 검출률로 확대하지 않습니다.
-->

---

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

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

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

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

**같은 5MiB의 메인 스레드 작업:** 미호출 함수 81.5ms / 최상위 초기화 915.3ms

**스크립트 준비 시간, 메인 스레드 작업 시간, FCP는 서로 다른 지표입니다**

CPU 감속은 특정 휴대전화에 맞춰 보정한 값이 아니며 SDK의 절감 ms로 환산하지 않았습니다

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

<!--
질의응답용 / 본 발표 30분에 포함하지 않음

예상 질문: 같은 JavaScript 크기라도 CPU 비용이 다른가?

별도 합성 실험에서는 같은 raw 5MiB, CPU 4배 감속 조건에서 미호출 함수와 최상위 초기화 코드를 비교했습니다. 메인 스레드 작업의 중앙값은 각각 81.5ms와 915.3ms였습니다. 각 15회, VM 내부 통신, 압축 없는 환경의 결과입니다. 코드 형태의 차이를 본 실험이며 이 SDK에서 절감한 시간으로 환산하지 않습니다.
-->

---

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

<!-- _class: compact 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 웹용 산출물의 패키지 소비 측정을 구분했습니다

화면 수치는 대략값이며, 테스트는 준비한 입력과 실행 환경의 결과입니다

<!--
질의응답용 / 본 발표 30분에 포함하지 않음

예상 질문: 규격이나 도구의 근거는 어디에서 확인할 수 있는가?

Rollup의 모듈 보존 옵션, esbuild의 트리셰이킹과 PURE 주석, 뮤테이션 결과 해석, Web Crypto의 실행 조건은 화면의 공식 문서에서 확인할 수 있습니다. 초기 진단과 최종 크기 표는 서로 다른 측정본입니다. SDK의 내부 이름은 가명이고, 화면의 수치는 반올림한 값입니다.
-->

---

# CDN과 HTTP 캐시 2: 전략과 문제 해결

Source: https://research.yceffort.kr/slides/cdn-and-http-cache-2.md

---
title: 'CDN과 HTTP 캐시 2: 전략과 문제 해결'
marp: true
paginate: true
theme: midnight
tags:
  - caching
  - networking
  - web-performance
date: 2026-09-11
description: 'HTML·JS·이미지·폰트·API의 캐시 정책을 정하고, 배포 순서와 DevTools로 캐시 문제를 진단하는 실무 강의'
published: true
art:
  undraw: connected-world
---

# CDN과 HTTP 캐시 2

전략과 문제 해결: 서비스에 캐시 적용하기

<!-- _class: invert -->

@yceffort

---

## 2편의 목표와 순서

**파일마다 캐시 정책을 정하고, 배포 후 옛 화면이 보이는 이유를 찾아 고칠 수 있게** 되는 것.

1. **자산별 전략** : HTML·JS·이미지·폰트·API, URL 버저닝과 CDN 무효화
2. **함정과 도구** : 개인화 응답, 새로고침, DevTools, 여러 종류의 캐시
3. **실무 적용** : 배포 순서, 코드 리뷰, 장애 신고 진단

중간 점검을 풀고, 마지막에 **실무 상황 네 문제**로 연결해 본다.

[1편: 원리와 규칙](/slides/cdn-and-http-cache)에서 배운 내용을 다음 한 장으로 복습한다.

<!-- 기준은 1편과 같다. HTTP 캐시는 RFC 9111, immutable은 RFC 8246, stale-while-revalidate는 RFC 5861을 따른다. yceffort.kr의 Next.js 16.3.1/Vercel 실측은 2026-09-11, 우리 서비스 실측은 2026-09-16 기준이다. 브라우저는 Chrome을 기준으로 하고 차이가 있는 곳은 별도로 표시한다. -->

---

## 1편 복습 : 이 규칙을 가지고 시작한다

**브라우저와 CDN은 서로 다른 캐시다.** 같은 응답도 각자의 규칙과 설정으로 판단한다.

| 확인할 것        | 기억할 규칙                                                 |
| ---------------- | ----------------------------------------------------------- |
| 저장 여부        | `no-store`는 저장 금지, `private`은 공유 캐시 저장 금지     |
| 재사용 전에 확인 | `no-cache`는 저장할 수 있지만 매번 확인                     |
| 신선 기간        | `max-age`에서 이미 지난 시간인 `Age`를 고려                 |
| 변경 여부        | ETag를 `If-None-Match`로 보내고, 같으면 `304`로 본문 재사용 |
| 같은 사본의 범위 | `Vary`와 CDN 캐시 키가 사본을 구분                          |

이제 **변경 빈도·URL 버저닝·개인화 여부**에 따라 이 규칙을 조합한다.

---

## Part 1 : 자산별 캐시 전략

<!-- _class: invert -->

---

## 왜 파일 종류마다 다르게 다루는가

캐시 정책을 정할 때 보는 축은 셋이다.

| 자산             | 얼마나 자주 바뀌나     | URL이 내용을 식별하나   | 개인화되나  |
| ---------------- | ---------------------- | ----------------------- | ----------- |
| HTML             | 배포마다, 또는 매 요청 | 아니오 (`/`는 항상 `/`) | 때때로      |
| JS / CSS         | 배포마다               | **예** (해시 파일명)    | 아니오      |
| 빌드 포함 이미지 | 거의 안 바뀜           | 예 (해시 파일명)        | 아니오      |
| 업로드 이미지    | 사용자가 바꿈          | 대개 아니오             | 아니오      |
| 폰트             | 거의 안 바뀜           | 예                      | 아니오      |
| API 응답         | 매 요청                | 아니오                  | **대개 예** |

"URL이 내용을 식별하는가"가 가장 중요한 축이다. 그렇다면 **무한히 캐시**해도 되고, 아니라면 **바뀔 수 있는 주기 안에서** 잘라야 한다.

---

## HTML : 짧게, 그러나 CDN은 길게

HTML은 진입점이다. 개인정보가 없는 공통 HTML은 **최신 여부를 확인하되, 같은 본문을 반복 전송하지 않게** 한다.

```text
Cache-Control: no-cache            (또는 max-age=0, must-revalidate)
ETag: "..."                        ← 응답 서버가 HTML의 버전을 구분해 생성
```

- 브라우저는 매번 확인하되, 안 바뀌었으면 `304`로 본문을 아낀다
- **ETag 생성과 조건부 요청 처리는 응답 서버에 맡긴다.** 정적 파일 서버의 기본 기능이나 프레임워크의 재검증 기능을 우선 사용한다

공개 HTML의 갱신 지연을 허용한다면 CDN용 정책을 따로 줄 수도 있다.

```text
Cache-Control: public, max-age=0, must-revalidate,
               s-maxage=300, stale-while-revalidate=86400
```

이 예시는 CDN에서 5분간 신선하게 쓰고, 이후에는 최대 하루 동안 낡은 응답을 주며 백그라운드에서 갱신하도록 허용한다.

<!-- no-cache + ETag는 브라우저가 매번 확인하면서 본문 전송을 줄이는 기본 예시다. 아래 코드는 공유 캐시에 갱신 지연을 허용하는 별도 선택지이며 위 no-cache 예시에 덧붙이는 지시어가 아니다. s-maxage=300은 오리진 요청을 정확히 5분에 한 번으로 보장하지 않는다. 엣지·캐시 키·퇴출·CDN 정책에 따라 달라진다. ETag 값은 본문 버전과 일치해야 하며, 배포 ID만 같아도 HTML의 데이터나 import map이 달라질 수 있다면 동일한 태그를 써서는 안 된다. -->

---

## ETag는 HTML을 응답하는 곳에서 만든다

| HTML을 제공하는 방식      | 권장하는 설정·구현                                           |
| ------------------------- | ------------------------------------------------------------ |
| Nginx가 정적 HTML 제공    | `etag on;`으로 자동 생성. 기본값도 `on`                      |
| S3가 정적 HTML 제공       | S3가 ETag와 조건부 GET을 제공. `Cache-Control`을 별도로 설정 |
| 서버가 HTML을 조립·렌더링 | 프레임워크의 ETag·조건부 GET 기능을 우선 사용                |

Nginx가 `index.html`을 직접 제공하는 경우의 예시다.

```nginx
location = /index.html {
    etag on;
    add_header Cache-Control "no-cache";
}
```

직접 구현한다면 **완성된 HTML의 해시 등으로 ETag를 만들고**, `If-None-Match`가 현재 값과 일치할 때 **본문 없는 `304`**를 반환해야 한다.

**검증:** 같은 본문 + 받은 ETag로 요청 → `304`. HTML 변경 후 옛 ETag로 요청 → `200` + 새 본문·새 ETag.

<!-- 위 Nginx 예시는 서버의 root 등이 이미 설정되어 정적 index.html을 직접 제공하는 경우다. etag on은 프록시 뒤에서 동적으로 생성된 HTML에 ETag를 자동으로 만들어 주는 설정이 아니다. /kkb/home이 어느 계층에서 HTML을 조립하는지는 헤더만으로 확인하지 않았으므로 운영 서버 종류를 먼저 확인한다. 직접 구현할 때는 고정 문자열이나 모든 응답에 같은 배포 ID를 무조건 쓰지 않는다. 최종 내용과 언어·인코딩 등 표현의 차이를 식별할 수 있어야 한다. If-None-Match의 약한 비교, 태그 목록, * 처리와 304의 ETag/Cache-Control/Vary 등 메타데이터까지 직접 다루기보다 검증된 서버 기능을 우선 사용한다. Next.js의 generateEtags는 기본 활성화지만, 로컬 Next.js 16.3.1의 send-payload.js는 result.isDynamic인 스트리밍 응답에서 payload를 null로 두고 해당 ETag 생성 경로를 건너뛴다. 설정 하나로 모든 HTML에 ETag가 생긴다고 보장하지 않는다. ETag 때문에 스트리밍 전체를 버퍼링하면 첫 바이트 전송이 늦어질 수 있다. 출처: https://nginx.org/en/docs/http/ngx_http_core_module.html#etag , https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html , https://httpwg.org/specs/rfc9110.html#field.etag , https://nextjs.org/docs/app/api-reference/config/next-config-js/generateEtags -->

---

## JS / CSS : 내용 해시 + 1년 + immutable

```text
/_next/static/immutable/chunks/2kzkiswgqacx4.js
Cache-Control: public, max-age=31536000, immutable
```

파일명 `2kzkiswgqacx4`는 내용의 해시다. 이 한 가지 사실에서 전략 전체가 나온다.

- 내용이 바뀌면 이름이 바뀐다. 그러니 **같은 URL의 내용은 영원히 같다**
- 영원히 같으니 1년(HTTP가 권고하는 상한) 동안 신선하다고 해도 되고, 새로고침 때 확인도 필요 없다(`immutable`)
- **무효화가 필요 없는 캐시**다. 바꾸고 싶으면 새 이름으로 새 파일을 올리면 된다

번들러(webpack, Vite, Turbopack)가 해시 파일명을 만드는 이유가 이것이다. 해시 없는 `app.js`에 1년 캐시를 주면 배포가 사용자에게 도달하지 않는다.

<!-- 1년은 RFC 9111이 "1년을 넘는 값은 1년으로 간주해도 된다"고 하는 데서 온 관행이다. -->

---

## 쿼리스트링 버저닝은 반쪽짜리다

해시 파일명 대신 `app.js?v=42`를 쓰는 프로젝트가 아직 많다.

- 브라우저는 쿼리스트링을 캐시 키에 넣으니 **브라우저 캐시는 갱신된다**
- 그러나 CDN 중에는 쿼리스트링을 무시하도록 설정된 곳이 있다(CloudFront의 관리형 정책 CachingOptimized가 그렇다). 그러면 `?v=42`도 `?v=41`도 같은 사본이다
- 반대로 CDN이 쿼리를 키에 넣으면 `?utm_source=...` 값마다 사본이 갈라져 HIT율이 떨어진다

파일명에 해시를 넣으면 이 양쪽 문제가 모두 없다. 쿼리 버저닝을 유지해야 한다면 CDN 캐시 정책에서 **버전 파라미터만** 키에 포함하도록 지정한다.

---

## 해시 파일명이 만드는 배포 순서 문제

```text
t0  사용자가 HTML(v1)을 열어 둔 상태. 화면은 app.a1b2.js를 로드해 두었다
t1  v2 배포. HTML(v2)은 app.c3d4.js를 참조. 서버에서 app.a1b2.js는 삭제됨
t2  사용자가 v1 화면에서 페이지 이동 → 아직 안 받은 청크 chunk-7.a1b2.js 요청
    → 404 → ChunkLoadError
```

첫 슬라이드의 두 번째 질문("일부 사용자만 ChunkLoadError")의 정체다. 새로고침하면 HTML(v2)을 받아 사라지니 재현이 안 된다.

해법은 세 겹으로 둔다.

1. **자산을 먼저 올리고 HTML을 나중에 바꾼다.** 새 HTML이 참조하는 파일이 없는 순간을 없앤다
2. **이전 배포의 자산을 한동안 남긴다.** Vercel은 이전 배포를 지우지 않고, S3 배포라면 정리 주기를 며칠로 둔다
3. **버전 불일치를 감지하면 새로고침을 유도한다.** 청크 로드 실패 시 한 번 `location.reload()`

<!-- Vercel의 "skew protection"은 3번을 플랫폼이 대신 해 주는 기능이다. 요청에 배포 ID를 실어 옛 배포의 자산을 옛 배포에서 가져온다. -->

---

## 이미지 : 두 종류를 나눠서 본다

**빌드에 포함된 이미지** (로고, 아이콘, `import logo from './logo.png'`)

- 번들러가 해시 파일명을 붙인다. JS와 같은 전략: 1년 + `immutable`

**런타임에 정해지는 이미지** (사용자 업로드, CMS)

- URL이 `/uploads/profile.jpg`처럼 고정이면 교체해도 캐시가 옛것을 준다. 첫 슬라이드의 세 번째 질문이다
- 해법은 둘 중 하나. **업로드마다 새 키**(`/uploads/8f3a...jpg`)를 만들거나, URL을 고정할 거면 **짧은 TTL + ETag**

```text
Next.js 16의 /_next/image 기본값:
  정적 import 이미지  → public, max-age=31536000, immutable   (실측)
  원격 URL 이미지     → minimumCacheTTL 14400초(4시간) 기준으로 max-age 결정
```

---

## 이미지 : 크기와 포맷마다 사본이 갈라진다

이미지 CDN(next/image, Cloudinary, imgix)은 한 원본에서 여러 변형을 만든다.

```text
/_next/image?url=/media/profile.jpeg&w=48&q=75    Accept: image/avif → avif 735B
/_next/image?url=/media/profile.jpeg&w=48&q=75    Accept: */*        → jpeg 975B
/_next/image?url=/media/profile.jpeg&w=384&q=75   ...
```

- 캐시 키는 `URL(원본, w, q) + Accept`다. `Vary: Accept`가 그 마지막 조각을 키에 넣는다
- 변형 수가 많을수록 **첫 사용자는 매번 MISS**를 맞는다. 1편에서 잰 MISS 372ms가 그런 사례다. `sizes`와 `deviceSizes`를 줄여 변형 수를 통제하는 것이 HIT율을 올리는 방법이다

---

## 이미지 도메인 분리 : 장점과 비용

`img.example.com`처럼 호스트를 나누면 **독립적으로 운영하기 쉬워지지만, 연결과 설정의 비용이 늘 수 있다.**

| 관점 | 장점                                       | 비용·조건                               |
| ---- | ------------------------------------------ | --------------------------------------- |
| 운영 | 이미지 CDN·배포·캐시 정책을 따로 관리      | DNS·인증서·장애 감시 대상이 늘어남      |
| 쿠키 | 메인 서비스 쿠키의 전송 범위에서 제외 가능 | 쿠키의 `Domain` 설정도 맞춰야 함        |
| 출처 | 사용자 업로드를 메인 앱과 다른 출처에 둠   | CSP 허용 목록, 폰트·canvas의 CORS 점검  |
| 연결 | 별도 이미지 CDN을 선택할 수 있음           | DNS 조회·연결·TLS 비용이 추가될 수 있음 |

---

## 도메인을 나눠도 쿠키는 따라올 수 있다

**쿠키는 자동으로 분리되지 않는다.** `Domain=example.com`인 쿠키는 `img.example.com`에도 갈 수 있다. 호스트 전용 쿠키나 별도 도메인으로 범위를 나눠야 한다.

**선택 기준:** 별도 CDN이나 업로드 출처 격리가 필요하면 분리한다. TTL만 다르면 같은 호스트의 `/images/*` 경로별 정책도 검토한다.

HTTP/2에서는 분리 자체가 속도 향상을 보장하지 않는다. 분리한다면 **주요 이미지 호스트에만 `preconnect`를 적용하고 실제 LCP를 비교**한다.

<!-- Domain 속성이 없는 쿠키는 발급한 호스트에만 전송된다. 부모 도메인의 Domain 쿠키는 조건이 맞는 하위 호스트에도 전달되므로 서브도메인 분리만으로 쿠키가 없어지지는 않는다. 우리 예시의 kakaopaysec.com 쿠키는 별도 도메인인 t1.kakaocdn.net에는 전달되지 않지만, kakaocdn.net 자체의 쿠키까지 없다는 뜻은 아니다. HTTP/2는 한 연결에서 여러 요청을 다중화하고 조건이 맞으면 출처 간 연결도 재사용할 수 있으므로 추가 연결 비용이 항상 발생한다고 단정하지 않는다. 일반 img 표시에 CORS가 항상 필요한 것은 아니며, 폰트 로드나 canvas 픽셀 읽기 등에서 별도 점검한다. preconnect는 비용을 없애는 것이 아니라 미리 치르게 하는 힌트이며 남발하면 연결 자원을 낭비한다. 정책만 분리하는 목적이라면 CloudFront의 경로별 Behavior 등으로 같은 호스트에서도 가능하다. 출처: https://httpwg.org/specs/rfc6265.html#attribute-domain , https://httpwg.org/specs/rfc9113.html#reuse , https://developer.chrome.com/docs/lighthouse/performance/uses-rel-preconnect , https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS -->

---

## 폰트 : immutable에 CORS까지

```text
$ curl -sI https://yceffort.kr/_next/static/immutable/media/\
03bda585a99c6450-s.p.0glcd8n2pdab0.woff2
cache-control: public,max-age=31536000,immutable
access-control-allow-origin: *
content-type: font/woff2
```

- 폰트 파일은 해시 파일명이고 거의 안 바뀐다. JS와 같은 전략이다
- `@font-face`의 폰트 요청은 명세상 **anonymous CORS 모드**다. 다른 출처의 폰트는 응답의 `Access-Control-Allow-Origin`이 허용해야 사용할 수 있다
- `<link rel="preload" as="font" crossorigin>`는 실제 폰트 요청과 모드를 맞춘다. **같은 출처의 폰트를 preload할 때도** 붙인다
- S3에서 CORS 응답을 만든다면 버킷에 허용 출처를 설정한다. **출처별로 응답이 달라지면** CloudFront 캐시 키에도 `Origin`을 넣는다

<!-- @font-face의 anonymous 모드는 credentials: same-origin에 해당한다. 같은 출처의 폰트에는 ACAO가 필요하지 않지만, preload는 실제 폰트 요청과 CORS·credentials 모드가 일치해야 재사용된다. 공개 폰트에 항상 ACAO: *를 붙이는 구성이라면 CORS 응답을 나누기 위해 Origin을 캐시 키에 넣을 필요는 없다. S3가 Origin에 따라 CORS 헤더를 생성하는 구성과 구분한다. 출처: https://www.w3.org/TR/css-fonts-3/#font-fetching-requirements , https://html.spec.whatwg.org/multipage/links.html#link-type-preload -->

---

## 폰트는 왜 서버의 사용 허락을 받을까?

**남의 사이트가 내 폰트 URL을 그대로 가져다 쓰면, 내 서버가 그 트래픽까지 감당한다.**

- 초기 웹폰트 표준화에서는 이런 **무단 직접 연결(hotlinking)** 과 대역폭 비용이 논의됐다. 특정 사이트에만 허용한 폰트의 라이선스 문제도 함께 다뤄졌다
- 그래서 기본은 같은 출처에서 사용하고, **다른 출처에서 쓰려면 폰트 서버가 CORS 헤더로 허락**하도록 정했다. 지금은 관행이 아니라 CSS Fonts 명세의 요구사항이다

```text
폰트 응답: Access-Control-Allow-Origin: https://shop.example

shop.example에서 사용   → 허용
other.example에서 사용  → CORS 검사 실패, 폰트 적용 안 됨
```

**복제 방지 장치는 아니다.** 파일을 내려받아 다른 서버에 올리는 것까지 막지는 못한다.

<!-- 2011년 W3C WebFonts WG 논의에는 bandwidth 비용과 single-site 라이선스를 위한 가벼운 제한이라는 관점이 함께 나온다. 2014년 논의는 same-origin/CORS 요구사항이 WOFF에서 모든 웹폰트에 적용되는 CSS Fonts로 옮겨졌고, deep linking과 bandwidth hijacking 방지가 배경이었다고 설명한다. 개별 메일을 단일한 공식 도입 사유로 단정하지 않는다. CORS는 서버에 요청 자체가 도달하지 않게 하는 기능이 아니며, 허용되지 않은 출처에서 응답을 폰트로 사용하는 것을 브라우저가 막는다. 출처: https://lists.w3.org/Archives/Public/public-webfonts-wg/2011Feb/0066.html , https://lists.w3.org/Archives/Public/public-webfonts-wg/2014Mar/0004.html , https://www.w3.org/TR/css-fonts-3/#font-fetching-requirements -->

---

## API 응답 : 기본은 저장 금지, 예외만 열어 준다

```text
GET /api/me                  → Cache-Control: private, no-store
GET /api/products?page=1     → Cache-Control: public, s-maxage=60,
                               stale-while-revalidate=600
```

- 사용자별로 다른 응답은 **`private` 또는 `no-store`** 다. CDN에 남으면 다른 사용자에게 나간다
- 모든 사용자에게 같은 공개 데이터(상품 목록, 환율, 공지)는 CDN에 짧게 두고 SWR로 갱신한다. 오리진 부하가 1/N로 준다

RFC 9111의 함정: `Authorization` 요청의 응답은 공유 캐시가 저장하지 않는 것이 기본이지만, 응답에 **`public`, `s-maxage`, `must-revalidate` 중 하나라도 있으면 저장한다.** 인증 API에 "일단 public" 습관이 위험한 이유다.

CloudFront의 함정: `Authorization`을 오리진 요청 정책으로 전달하면서 캐시 키에 넣지 않으면, 문서의 경고 그대로 "인증된 사용자와 안 된 사용자에게 같은 사본"이 나간다. 인증 API는 CachingDisabled 동작으로 뺀다.

---

## 자산별 전략 한 장 요약

| 자산                          | Cache-Control                         | 검증자 | 갱신 방법           |
| ----------------------------- | ------------------------------------- | ------ | ------------------- |
| HTML                          | `no-cache` (+ CDN용 `s-maxage`, SWR)  | ETag   | 다음 요청에 자동    |
| JS / CSS / 빌드 이미지 / 폰트 | `public, max-age=31536000, immutable` | 불필요 | 새 해시 파일명      |
| 업로드 이미지                 | 새 키, 또는 `max-age=짧게` + ETag     | ETag   | 새 키 또는 TTL 만료 |
| 개인화 API                    | `private, no-store`                   | 없음   | 캐시 없음           |
| 공개 API                      | `public, s-maxage=N` + SWR            | ETag   | TTL 또는 CDN purge  |

한 문장으로 줄이면: **URL이 내용을 식별하면 영원히, 아니면 짧게 확인하며, 개인화면 저장하지 않는다.**

---

## 무효화(purge)와 버저닝의 결정적 차이

"CDN 캐시를 비우면 되지 않나요?"

```text
purge      CDN API로 엣지의 사본을 지운다      →  브라우저 캐시는 그대로다
버저닝     URL을 바꾼다                        →  브라우저도 새 URL을 새로 받는다
```

- CDN은 내 것이라 지울 수 있지만, **사용자 브라우저의 캐시는 내 손이 닿지 않는다.** 만료되거나 URL이 바뀌어야만 갱신된다
- 그래서 "HTML은 짧게, 자산은 URL 버저닝으로 길게" 조합이 표준이다. 브라우저 캐시를 지울 방법이 없다는 전제 위에 세운 설계다
- purge는 HTML처럼 URL을 못 바꾸는 응답을 **CDN 쪽에서만** 급히 갱신할 때 쓴다. 브라우저 쪽은 `no-cache`가 알아서 확인한다

<!-- "사용자에게 캐시 지워 달라고 안내"는 해법이 아니다. 그 안내가 필요한 시점에 설계가 틀린 것이다. -->

---

## CloudFront 무효화(invalidation)

```bash
aws cloudfront create-invalidation --distribution-id E1ABC2DEF3 \
  --paths "/index.html" "/images/*"
```

- 엣지의 사본을 지운다. 다음 요청은 오리진으로 간다. 반영까지 보통 수십 초에서 몇 분
- 요금은 **경로 수** 기준이다. 한 달 1,000경로까지 무료, 넘으면 경로당 과금. `/images/*`처럼 와일드카드 한 줄은 파일이 수천 개여도 **1경로**로 센다
- 그래서 `--paths "/*"` 한 줄이 파일 열 개를 나열하는 것보다 싸다

AWS 문서도 무효화보다 **버전 파일명을 우선 쓰라**고 권한다. 이유는 앞 슬라이드와 같다. 무효화는 사용자 브라우저와 회사 프록시의 사본을 못 건드린다. 배포 파이프라인에서 무효화가 필요한 대상은 `index.html` 같은 고정 URL뿐이다.

---

## S3 설정 : 파일에 캐시 규칙을 붙여 저장한다

**S3는 파일 저장소, CloudFront는 그 앞에서 사본을 제공하는 CDN이다.** 먼저 원본 파일을 올릴 때 응답에 사용할 헤더를 정한다.

```bash
aws s3 cp build/static/ s3://my-bucket/static/ --recursive \
  --cache-control "public, max-age=31536000, immutable"
aws s3 cp build/index.html s3://my-bucket/index.html \
  --cache-control "no-cache"
```

- 첫 명령: **해시 파일명인 JS/CSS**를 `/static/`에 올리면서 1년 캐시를 지정한다
- 둘째 명령: **고정 이름인 HTML**은 저장하되, 다시 쓸 때 최신인지 확인하게 한다
- `--cache-control` 값은 **S3 객체의 메타데이터**로 저장되고, S3가 파일을 응답할 때 HTTP 헤더로 나간다

**아직 절반이다.** 이제 CloudFront도 파일 종류에 맞는 정책을 쓰도록 설정해야 한다.

<!-- build/static/에는 내용을 바꾸면 파일명도 달라지는 공개 자산만 있다고 가정한다. 두 명령은 예시이며 실제 AWS에 실행하지 않는다. S3 메타데이터를 바꿔도 이미 CloudFront나 브라우저에 저장된 사본이 즉시 바뀌지는 않는다. 고정 URL은 기존 TTL과 필요 시 CDN 무효화를 함께 고려한다. 출처: https://docs.aws.amazon.com/cli/latest/reference/s3/cp.html , https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingMetadata.html -->

---

## CloudFront 설정 : URL 경로별로 규칙을 연결한다

CloudFront의 **Behavior(동작)는 URL 경로별 규칙**이다. 요청을 보낼 원본 서버와 적용할 캐시 정책을 여기서 고른다.

| 요청 경로        | 원본 서버     | 연결할 캐시 정책과 이유                                     |
| ---------------- | ------------- | ----------------------------------------------------------- |
| `/static/*`      | S3            | `CachingOptimized` — 해시 파일의 긴 캐시 활용               |
| `/api/*`         | API 서버(ALB) | `CachingDisabled` — 개인화 응답의 CDN 캐시 끄기             |
| `*` (기본, HTML) | S3            | 사용자 지정: Min TTL `0`, Default TTL `0` — `no-cache` 존중 |

예를 들어 `/index.html`은 기본 규칙 → S3로 가고, S3의 `no-cache`에 따라 **CDN도 사본을 다시 쓰기 전에 확인**한다.

**헤더가 빠졌을 때는 정책의 Default TTL이 적용된다.** `CachingOptimized`는 24시간, 위 HTML 정책은 0초다. 항상 24시간인 것은 아니다.

<!-- 공개 S3 정적 HTML용 사용자 지정 정책 예시: Min TTL 0, Default TTL 0, Max TTL 31536000, 쿠키·쿼리스트링·뷰어 Host는 캐시 키에서 제외, 필요 시 gzip/brotli 활성화. 쿼리나 헤더별로 내용이 달라지는 응답에는 이 예시를 그대로 쓰지 않는다. 만료 정보가 없는 응답의 CDN 신선도는 Default TTL로 정하며, 브라우저는 별도의 HTTP 캐시 규칙을 따른다. Behavior는 먼저 일치하는 경로가 적용되고 기본 동작은 마지막이다. CachingDisabled는 CDN 캐시만 끄므로 개인화 API 응답의 no-store와 필요한 인증 정보의 오리진 전달 설정도 별도로 유지한다. 정적 폰트에 출처별 CORS 응답을 쓴다면 CachingOptimized를 그대로 쓰지 말고 Origin을 포함한 캐시 정책을 검토한다. 출처: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html , https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/DownloadDistValuesCacheBehavior.html -->

---

## Part 1 정리

- 정책은 세 축으로 정한다: 변경 빈도, **URL이 내용을 식별하는가**, 개인화 여부
- HTML은 `no-cache` + `ETag`로 매번 확인하되 본문은 아끼고, CDN에는 `s-maxage` + SWR로 길게 둔다
- JS/CSS/폰트/빌드 이미지는 해시 파일명 + 1년 + `immutable`. 무효화 대신 새 이름을 쓴다
- 업로드 이미지는 새 키를 만들거나 짧은 TTL, 이미지 변형은 `Vary: Accept`로 키가 갈라진다
- 개인화 API는 `private, no-store`. `public`은 인증 응답까지 CDN에 남긴다
- purge는 CDN만 비운다. 브라우저 캐시는 URL 변경이나 만료로만 갱신된다
- S3 + CloudFront는 업로드 때 `--cache-control`, 경로별 동작, 무효화는 `/*` 한 경로. AWS 문서도 버전 파일명을 우선하라고 한다

---

## 중간 점검 1-1

어제 받은 `/main.js`가 브라우저 캐시에 남아 있다. 오늘 **같은 URL에 새 JS를 덮어쓰고 CDN purge도 완료**했다.

```text
Cache-Control: public, max-age=31536000, immutable
```

사용자가 일반 방문으로 다시 들어왔다. 캐시는 아직 신선하다. 어떤 일이 생길 수 있는가?

1. CDN을 비웠으므로 모든 사용자가 새 JS를 받는다
2. 서버 파일이 바뀌었으므로 브라우저가 자동으로 알아챈다
3. 브라우저가 요청을 보내지 않고 어제 JS를 그대로 쓴다
4. 브라우저가 반드시 ETag로 확인하고 새 JS를 받는다

<!-- 같은 캐시 파티션이고 다른 Vary 조건도 같으며 강력 새로고침, DevTools 캐시 비활성화, 서비스 워커 개입은 없다고 가정한다. immutable 자체가 새로고침의 모든 형태를 막는다고 설명하지 않는다. -->

---

## 중간 점검 1-1 정답 : ③

- 브라우저의 사본은 아직 신선하다. **CDN까지 요청을 보내지 않을 수 있으므로** purge 여부도 알 수 없다
- ETag는 확인 요청을 보낼 때 쓰는 검증자다. 서버의 변경을 브라우저에 알려 주는 알림 장치가 아니다

**이렇게 바꾸자**

- 새 JS는 **`main.새해시.js`**로 올리고, HTML이 그 URL을 참조하게 한다. 새 URL이면 브라우저도 새 파일을 받는다
- HTML은 **`no-cache` + ETag**로 최신 참조를 확인하게 한다. 이전 JS는 이미 열린 화면을 위해 보존한다

같은 이름을 꼭 유지해야 한다면 다음부터 짧은 TTL이나 재검증 정책을 쓴다. **지금 서버 헤더만 바꿔도 이미 저장된 1년짜리 사본은 갱신되지 않는다.**

---

## 중간 점검 1-2

`shop.example`이 `cdn.example`의 폰트를 불러온다. 응답에 **`Access-Control-Allow-Origin`이 없고**, 콘솔에는 CORS 오류가 나온다.

```text
HTTP/2 200
Content-Type: font/woff2
Cache-Control: public, max-age=31536000, immutable
```

CDN에는 파일이 있는데 폰트가 적용되지 않는다. **이 응답에서 먼저 고칠 것은?**

1. `max-age`를 0으로 줄인다
2. 폰트 응답에 `shop.example`을 허용하는 CORS 헤더를 넣는다
3. `immutable`을 지운다
4. HTML에 폰트 preload만 추가한다

---

## 중간 점검 1-2 정답 : ②

**파일을 받았는지와 그 출처에서 사용할 수 있는지는 별개다.** `200`이나 CDN HIT만으로 CORS 검사까지 통과한 것은 아니다.

**이렇게 바꾸자**

```text
Access-Control-Allow-Origin: https://shop.example
```

- 폰트 응답에 허용 헤더를 넣는다. 모든 사이트에 공개할 폰트라면 `*`도 가능하다
- 허용 출처별로 응답이 달라지면 **`Vary: Origin`과 CloudFront의 `Origin` 캐시 키**도 맞춘다. 서로 다른 허용 응답이 섞이는 것을 막는다
- CDN에 헤더 없는 사본이 남아 있다면 갱신하고 다시 확인한다. **해시 파일의 긴 TTL은 유지**해도 된다

<!-- CORS 헤더는 응답 헤더 정책이나 오리진에서 설정할 수 있다. 요청 Origin을 무조건 반사하지 말고 허용 목록을 확인한다. CDN purge는 브라우저의 기존 캐시를 지우지 않으므로, 저장 상태에 따라 검증용 캐시 비활성화나 새 파일 URL이 필요할 수 있다. 출처: https://www.w3.org/TR/css-fonts-3/#font-fetching-requirements , https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/controlling-the-cache-key.html -->

---

## 중간 점검 1-3

공개 HTML인 `/index.html`을 S3에 올렸고, 매번 최신인지 확인하게 하고 싶다.

```text
S3 응답                 Cache-Control: no-cache
CloudFront HTML 정책    Minimum TTL: 60초
```

배포 직후에도 CDN이 확인 없이 이전 HTML을 줄 수 있다. **어떤 설정이 목적에 맞는가?**

1. S3의 `no-cache`를 `immutable`로 바꾼다
2. CloudFront의 Minimum TTL을 1년으로 늘린다
3. 배포할 때마다 purge만 하고 정책은 그대로 둔다
4. HTML의 Minimum TTL을 0으로 바꾸고 `no-cache`를 유지한다

---

## 중간 점검 1-3 정답 : ④

- **Minimum TTL이 60초면**, CloudFront는 오리진의 `no-cache`보다 그 최소 신선 기간을 우선한다
- **Minimum TTL을 0으로 바꿔야** HTML의 `no-cache`에 따라 재사용 전에 확인할 수 있다

**이렇게 바꾸자**

- HTML 전용 정책은 **Min TTL 0, Default TTL 0**으로 둔다. 헤더가 빠졌을 때도 의도치 않은 긴 캐시를 피한다
- **ETag를 유지**하면 내용이 그대로일 때 `304`로 확인하고 기존 본문을 재사용할 수 있다
- 해시 JS/CSS는 별도 경로에서 긴 캐시를 유지한다. HTML 때문에 모든 자산의 캐시를 끌 필요는 없다

**확인:** 배포 후 HTML이 새 JS URL을 참조하는지 보고, 변경이 없을 때 조건부 요청이 `304`를 받는지도 확인한다.

<!-- Minimum TTL 0 자체가 재검증을 강제하는 것은 아니다. 이 문제는 no-cache 응답과 결합했을 때를 묻는다. Default TTL은 응답에 유효 기간 정보가 없는 경우의 기본값이며 no-cache를 덮어쓰는 값이 아니다. 기존 캐시가 정책 변경만으로 즉시 모두 사라진다고 가정하지 말고 필요 시 CDN 무효화로 전환한다. 출처: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Expiration.html , https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html -->

---

## Part 2 : 함정과 도구

<!-- _class: invert -->

---

## 함정 1 : 개인화 응답이 공유 캐시에 남는다

2015년 12월 25일, Steam 상점이 DDoS 대응 중 캐시 설정을 잘못 적용해 약 한 시간 동안 **다른 사용자의 계정 페이지(이메일, 주소 일부, 구매 내역)** 를 보여 준 사고가 있었다. Valve가 공식 설명을 냈다.

메커니즘은 단순하다.

```text
GET /account  (사용자 A, 쿠키 있음)  →  오리진 응답, 헤더에 private 없음
                                     →  CDN이 저장
GET /account  (사용자 B)              →  CDN HIT, A의 페이지가 B에게
```

막는 규칙도 단순하다.

- 사용자별 응답에는 **반드시 `private` 또는 `no-store`**. 프레임워크가 세션을 읽는 페이지에 자동으로 붙여 주는지 확인한다
- CloudFront에서 이 사고가 나는 조합은 **오리진 요청 정책으로 쿠키를 전달하면서 캐시 정책에는 쿠키를 안 넣는 것**이다. 캐시는 되는데 키에 사용자가 없다

---

## 함정 2 : 새로고침은 한 종류가 아니다

같은 페이지를 다시 여는 방법이 넷이고, 브라우저는 각각 다르게 요청한다.

| 동작                        | 메인 HTML                 | JS/CSS/이미지              |
| --------------------------- | ------------------------- | -------------------------- |
| 링크 클릭, 주소창 Enter     | 캐시 규칙대로             | 캐시 규칙대로              |
| 일반 새로고침 (Cmd+R)       | 재검증 (`max-age=0`)      | 캐시 규칙대로 (Chrome 54+) |
| 강력 새로고침 (Cmd+Shift+R) | `Cache-Control: no-cache` | 전부 `no-cache`, 캐시 무시 |
| DevTools "Disable cache"    | 캐시 무시                 | 캐시 무시 (열려 있는 동안) |

- "제 브라우저에선 되는데요"의 절반은 DevTools를 열어 둔 채 테스트해서 그렇다. 사용자는 DevTools가 없다
- Chrome이 일반 새로고침에서 서브리소스 재검증을 끊은 것이 `immutable`을 구현하지 않은 이유다. 대신 Firefox/Safari는 `immutable`이 있어야 이 동작을 한다

<!-- 재현할 때는 시크릿 창 + DevTools 닫고 + 일반 새로고침으로 사용자 조건을 맞춘다. -->

---

## 함정 3 : DevTools Network 탭 읽기

| Status | Size 열            | 뜻                                                        |
| ------ | ------------------ | --------------------------------------------------------- |
| 200    | `(memory cache)`   | 이 탭이 이미 메모리에 들고 있던 것. 네트워크 안 나감      |
| 200    | `(disk cache)`     | 디스크 캐시에서 읽음. fresh라 서버에 묻지 않았다          |
| 304    | 수백 B             | 서버에 물었고 "같다"고 해서 본문은 캐시에서 씀            |
| 200    | 전체 크기          | 실제로 다시 받았다. 캐시 없음, 만료, 또는 검증자 없음     |
| 200    | `(prefetch cache)` | `<link rel=prefetch>`나 라우터 프리페치로 미리 받아 둔 것 |

확인하는 순서를 정해 두면 빠르다.

1. Size 열이 캐시 표시인가 → 그렇다면 헤더는 **저장 당시** 것이다
2. 200 전체 크기인데 캐시를 기대했다면 → 응답 헤더의 `Cache-Control`, `Age`, 검증자 유무
3. CDN이 개입했나 → `x-vercel-cache`, `cf-cache-status`, `x-cache`

---

## 함정 4 : 브라우저가 보는 헤더가 CDN이 따른 규칙은 아니다

같은 사이트의 HTML이다.

```text
$ curl -sI https://yceffort.kr/
cache-control: public, max-age=0, must-revalidate
age: 2945
x-vercel-cache: HIT
x-nextjs-stale-time: 300
```

- 브라우저 입장: `max-age=0`이니 받자마자 낡았다. 다음 방문 때 반드시 서버에 확인한다
- 그런데 `age: 2945`, `HIT`. CDN은 49분째 이 사본을 쓰고 있다

오리진(Next.js)이 내보낸 헤더에는 `s-maxage=300, stale-while-revalidate`가 있었다. Vercel CDN은 그 값을 **자기 규칙으로 쓰고 브라우저에는 지운 뒤** 보낸다(벤더 문서에 명시된 동작).

> DevTools의 `Cache-Control`은 **브라우저용 최종본**이다. CDN이 따른 규칙은 `Age`와 HIT/MISS 헤더로 역추적한다.

---

## 함정 5 : 공용 CDN의 라이브러리는 더 이상 공짜가 아니다

"jQuery를 `cdnjs`에서 불러오면 다른 사이트에서 이미 받아 둔 캐시를 재사용한다"는 조언이 오래 돌았다.

- Chrome 86(2020), Firefox 85, Safari(더 이전)부터 브라우저 캐시 키에 **최상위 사이트**가 들어간다(캐시 파티셔닝). `a.com`에서 받은 `cdnjs.../jquery.js`와 `b.com`에서 받은 같은 파일은 **다른 캐시 항목**이다
- 이유는 프라이버시다. 캐시 적중 여부로 사용자가 어떤 사이트를 방문했는지 알아낼 수 있었다

남는 건 비용뿐이다. 외부 도메인이니 DNS + TLS가 한 번 더 들고, 그 CDN 장애가 내 사이트 장애가 된다. 라이브러리는 **번들에 넣어 내 정적 자산 도메인에서** 서빙한다.

---

## 함정 6 : bfcache는 HTTP 캐시가 아니다

뒤로가기를 눌렀을 때 페이지가 즉시 뜨는 것은 HTTP 캐시가 아니라 **bfcache(back/forward cache)** 다. 페이지 전체(DOM, JS 힙)를 메모리에 얼려 두었다가 그대로 되살린다.

- HTTP 캐시가 아무리 잘 돼 있어도 bfcache에 못 들어가면 뒤로가기가 느리다
- 못 들어가는 대표 원인: `unload` 이벤트 핸들러, 열린 `IndexedDB` 트랜잭션, 그리고 **`Cache-Control: no-store`가 붙은 HTML** (Chrome은 최근 일부 조건에서 허용하기 시작했지만 여전히 보수적이다)
- 확인은 DevTools Application 탭 → Back/forward cache → Test back/forward cache

HTML에 습관적으로 `no-store`를 붙이면 HTTP 캐시가 아니라 bfcache를 잃는다. 개인정보가 없는 페이지라면 `no-cache`가 맞다.

---

## 함정 7 : 서비스 워커는 세 번째 캐시다

```text
[페이지] ──▶ [서비스 워커 fetch 핸들러] ──▶ [HTTP 캐시] ──▶ [CDN] ──▶ [오리진]
```

- 서비스 워커가 등록돼 있으면 모든 요청이 **HTTP 캐시보다 먼저** 워커의 `fetch` 이벤트를 지난다. 워커가 `caches.match()`로 응답하면 HTTP 캐시 규칙은 아예 적용되지 않는다
- 반대로 워커 안에서 `fetch()`를 호출하면 그때는 HTTP 캐시를 정상적으로 탄다. 그래서 워커의 Cache API에 넣은 응답이 **HTTP 캐시에서 온 낡은 사본**일 수 있다
- 워커 스크립트(`sw.js`) 자체는 브라우저가 24시간마다 강제로 재검증한다. 여기에 1년 캐시를 줘도 24시간 뒤에는 확인한다

"캐시를 다 지웠는데 옛 화면"이라면 Application 탭에서 서비스 워커를 먼저 본다. 이 주제는 별도 시리즈(서비스 워커 캐싱 딥다이브)에서 다룬다.

---

## 함정 8 : Vary와 쿠키가 캐시를 조용히 죽인다

의도치 않게 HIT율을 0으로 만드는 헤더들이다.

```text
Vary: User-Agent          UA 문자열 수만큼 사본이 갈라진다. 실질적으로 캐시 불가
Vary: Cookie              사용자마다 사본. 공유 캐시가 무의미해진다
Vary: *                   "어떤 요청도 같지 않다". 명세상 캐시 불가
Set-Cookie: ...           대부분의 CDN이 이 응답을 저장 대상에서 뺀다
```

- 정적 자산 응답에 세션 쿠키가 실려 나가는 서버 설정(세션 미들웨어가 전 경로에 걸린 Express 앱 등)은 CDN HIT율을 그대로 깎는다
- 디바이스별 응답이 필요하면 `Vary: User-Agent` 대신 CDN의 디바이스 분류 헤더(예: CloudFront의 `CloudFront-Is-Mobile-Viewer`)로 키를 두세 가지로 제한한다

---

## Part 2 정리

- 개인화 응답에 `private`/`no-store`가 빠지면 공유 캐시가 다른 사용자에게 준다. Steam 2015가 그 사례다
- 새로고침은 네 종류다. 일반 새로고침은 Chrome에서 서브리소스를 재검증하지 않고, DevTools "Disable cache"는 사용자 조건이 아니다
- Network 탭은 Size 열부터 본다. `(disk cache)`면 헤더는 저장 당시 것이다
- 공용 CDN 캐시 공유는 파티셔닝으로 끝났다. bfcache는 HTTP 캐시와 별개고 `no-store`가 막는다
- 서비스 워커는 HTTP 캐시 앞에 선다. `Vary: User-Agent`, `Vary: Cookie`, 정적 자산의 `Set-Cookie`는 HIT율을 없앤다

---

## 중간 점검 2

사용자가 "배포했는데 옛날 화면"이라고 한다. 다음 중 **가장 먼저** 확인할 것은?

1. CDN purge를 실행한다
2. 사용자에게 강력 새로고침을 안내한다
3. 사용자 브라우저에서 HTML 응답의 Size 열과 `Cache-Control`, 그리고 서비스 워커 등록 여부를 본다
4. `max-age`를 전부 0으로 바꿔 재배포한다

---

## 중간 점검 2 정답 : ③

- ①은 브라우저 캐시를 못 지우고, ②는 그 사용자 한 명만 고치며, ④는 문제를 못 찾은 채 성능만 버린다
- 진단 순서: HTML이 `(disk cache)`면 HTML 캐시 정책 문제(휴리스틱 캐시 가능성), `200` 전체 크기인데 옛 화면이면 CDN 사본(`Age`, HIT) 문제, 둘 다 아니면 서비스 워커가 응답한 것

> 원칙: 캐시 문제는 **어느 캐시**인지 먼저 특정한다. 브라우저, CDN, 서비스 워커 중 하나다.

---

## Part 3 : 실무에서는 이렇게

<!-- _class: invert -->

---

## 새 프로젝트를 시작할 때 정하는 기본값

**먼저 프레임워크와 번들러가 이미 해 둔 것을 확인한다.** Next.js, Vite, CRA는 전부 해시 파일명을 만들고, Vercel이나 Amplify 같은 플랫폼은 헤더까지 붙인다. 이걸 덮어쓰는 설정이 없는지부터 본다.

직접 배포한다면 이 네 줄을 인프라 코드(CloudFront 동작, nginx 설정)에 처음부터 넣는다.

```text
해시 파일명 자산  (/static/*, /_next/static/*)   public, max-age=31536000, immutable
HTML                                             no-cache  + ETag
API 기본값                                        private, no-store
공개 API만 예외                                   public, s-maxage=N,
                                               stale-while-revalidate=M
```

- 정적 자산과 API는 **경로 접두사로 분리**해 둔다. CDN 동작을 경로 패턴으로 나누는 것이 전부 여기서 시작된다
- 캐시 정책의 **키에 무엇이 들어가는지**(쿼리스트링, 쿠키, 헤더)를 문서에 한 줄로 적어 둔다. 6개월 뒤의 내가 가장 먼저 찾는 정보다

---

## 배포 파이프라인에 넣는 순서

```text
1. 자산 업로드      해시 파일명 파일을 먼저 올린다. 이전 배포 파일은 지우지 않는다 (--delete 금지)
2. HTML 교체        새 자산을 참조하는 HTML을 올린다
3. 무효화           고정 URL만 (index.html, /). 자산은 무효화할 필요가 없다
4. 검증             curl -sI 로 HTML, JS, 이미지 하나씩 헤더를 찍어 파이프라인 로그에 남긴다
5. 정리             N일 지난 이전 배포 자산을 별도 작업으로 지운다
```

배포 스크립트 끝에 이런 확인을 붙여 두면 "헤더가 어느 순간 사라졌다"를 그날 안다.

```bash
curl -sI https://example.com/ | grep -i cache-control     # no-cache 여야 한다
curl -sI https://example.com/static/main.abc123.js \
  | grep -i cache-control                                 # immutable 이어야 한다
```

앱 쪽에는 **청크 로드 실패 시 한 번 새로고침**하는 코드를 둔다. 배포 순서를 지켜도 오래 열어 둔 탭은 남는다.

---

## 코드 리뷰에서 보는 것

캐시 헤더가 들어간 PR에서 묻는다. 하나라도 "예"면 머지 전에 고친다.

| 질문                                           | 왜                                         |
| ---------------------------------------------- | ------------------------------------------ |
| 해시 없는 파일명에 1년 캐시를 붙였나           | 배포가 사용자에게 도달하지 않는다 (Part 1) |
| 사용자별 응답에 `public`이나 `s-maxage`가 있나 | 다른 사용자에게 나간다 (Part 2)            |
| `Vary: User-Agent`, `Vary: Cookie`를 붙였나    | HIT율이 0에 가까워진다                     |
| 정적 자산 응답에 세션 쿠키가 실려 나가나       | CDN HIT율이 무너진다                       |
| 업로드 이미지 URL이 고정인데 교체가 가능한가   | 교체해도 옛 이미지가 보인다                |
| HTML에 습관적으로 `no-store`를 붙였나          | bfcache를 잃는다 (Part 2)                  |

헤더 값을 외울 필요는 없다. **"이 URL의 내용이 바뀔 수 있는가"** 하나만 물으면 대부분 걸린다.

---

## "옛날 화면이 보여요"를 받았을 때

순서를 지키면 30분 안에 어느 캐시인지 특정된다. 순서를 건너뛰고 purge부터 하면 원인을 영영 모른다.

```text
1. 재현 조건 맞추기     시크릿 창, DevTools 닫기, 일반 새로고침. 사용자와 같은 조건인지 확인
2. 브라우저 캐시        Network 탭 Size 열. (disk cache) 면 브라우저,
                     HTML 응답의 Cache-Control 확인
3. CDN 캐시             curl -sI 로 Age 와 HIT/MISS. Age 가 크고 HIT 면 CDN 사본
4. 서비스 워커          Application 탭 → Service Workers.
                     등록돼 있으면 여기가 응답했을 가능성
5. 그 다음에 purge      어느 캐시인지 알았을 때만. purge 는 CDN 만 비운다는 것을 기억
```

- 사용자에게 "캐시를 지워 보세요"라고 안내하게 됐다면, 그 자체가 **설계가 틀렸다는 신호**다. 안내가 아니라 URL 버저닝을 고친다
- 재발 방지는 헤더 수정이 아니라 **배포 파이프라인의 검증 단계**(앞 슬라이드)에 넣는다

---

## 유지하기 : 무엇을 보고, 누구에게 무엇을 묻나

**대시보드에서 보는 숫자**

- **CDN HIT율** : 정적 자산은 95% 이상이 정상이다. 갑자기 떨어지면 캐시 키가 넓어졌다(쿼리스트링, 헤더 추가)는 뜻
- **오리진 요청 수** : HIT율의 거울. 배포 직후 잠깐 오르는 것은 정상, 계속 높으면 TTL이 짧거나 키가 쪼개진 것
- **이미지 변형 수** : `sizes`, `deviceSizes`를 늘릴 때마다 첫 사용자가 MISS를 맞는다

**인프라 담당에게 묻는 것**

- 캐시 정책의 **키에 무엇이 들어가나** (쿼리스트링을 통째로 무시하나, 특정 파라미터만 보나)
- **`Set-Cookie`가 있는 응답**을 저장하나, 쿠키를 오리진에 전달하면서 키에는 안 넣는 동작이 있나
- **무효화 권한과 자동화** : 배포 파이프라인이 `index.html`만 무효화할 수 있나
- 브라우저에 가는 `Cache-Control`을 **CDN이 고쳐서 보내나** (Vercel처럼 `s-maxage`를 벗기는지)

---

## 종합 퀴즈

<!-- _class: invert -->

이미지 교체, 배포 직후 오류, 인증 응답, 캐시 진단.

**네 문제**로 정책 선택과 문제 해결을 연습한다.

---

## 퀴즈 1 : 이미지 교체 (Part 1)

프로필 이미지 URL이 `/uploads/user-42.jpg`로 고정이고 응답은 `Cache-Control: public, max-age=86400`이다. 사용자가 이미지를 바꿨는데 하루 동안 옛 이미지가 보인다. **가장 근본적인** 해법은?

1. `max-age`를 60으로 줄인다
2. 업로드할 때 CDN purge를 호출한다
3. 업로드마다 새 파일 키(`/uploads/user-42-8f3a1c.jpg`)를 만들고 프로필 데이터에 그 URL을 저장한다
4. 응답에 `Vary: Cookie`를 붙인다

---

## 퀴즈 1 정답 : ③

- ①은 CDN HIT율을 깎으면서도 최대 1분은 여전히 옛 이미지다
- ②는 CDN만 비운다. 사용자 브라우저의 하루짜리 사본은 남는다
- ④는 사용자마다 사본을 쪼개 캐시를 무력화할 뿐 갱신과 무관하다
- ③은 URL이 내용을 식별하게 만든다. 그러면 `max-age`를 1년으로 늘려도 안전하다

> 브라우저 캐시를 갱신하는 방법은 만료와 URL 변경뿐이다.

---

## 퀴즈 2 : 배포 직후 오류 (Part 1)

SPA를 S3 + CloudFront로 배포한다. 빌드 스크립트가 `aws s3 sync --delete`로 이전 파일을 전부 지우고 새 파일을 올린다. HTML은 `no-cache`, JS는 해시 파일명 + `immutable`이다.

배포 직후 **이미 사이트를 열어 둔** 사용자에게 무슨 일이 생기는가? 헤더는 올바른데 왜 그런가?

---

## 퀴즈 2 정답 : 페이지 이동 시 `ChunkLoadError`

- 열어 둔 화면은 옛 HTML이 참조하는 옛 청크 이름을 알고 있다. 아직 안 받은 청크를 요청하는 순간 `--delete`로 지워진 파일이라 404다
- 헤더는 옳다. 문제는 **옛 배포의 자산을 지운 것**이다. 해시 파일명은 "새 이름을 추가"하는 전략이지 "옛 이름을 지워도 되는" 전략이 아니다
- 해법: `--delete`를 빼고 N일 뒤 정리, 자산 먼저 올리고 HTML 나중에, 청크 로드 실패 시 새로고침 유도

---

## 퀴즈 3 : 공유 캐시와 인증 (Part 1, 2)

`GET /api/orders`는 `Authorization: Bearer ...` 헤더로 인증한다. 응답 헤더는 다음과 같다.

```text
Cache-Control: public, max-age=60
```

CDN이 이 응답을 저장하는가? 저장한다면 무슨 일이 생기는가?

---

## 퀴즈 3 정답 : 저장한다. 다른 사용자의 주문 내역이 나간다

- RFC 9111은 `Authorization` 요청의 응답을 공유 캐시가 저장하지 않도록 하지만, **`public`, `s-maxage`, `must-revalidate`가 있으면 예외**다. 여기엔 `public`이 있다
- 캐시 키는 URL이다. `Authorization` 값은 `Vary`에 없으니 키에 안 들어간다. 60초 동안 첫 사용자의 응답이 모두에게 간다
- 고치는 법: `private, no-store`. 정말 CDN에 두고 싶다면 `Authorization`을 캐시 키에 넣고 HIT율을 포기한다
- CloudFront라면 GET의 `Authorization`이 기본으로 제거돼 오리진이 401을 내고, 그 401이 60초 캐시된다. 유출은 아니지만 장애다

> `public`은 "공개해도 되는 데이터"라는 뜻으로 붙이는 단어가 아니다. "공유 캐시에 저장하라"는 지시다.

---

## 퀴즈 4 : 진단 (Part 2)

DevTools Network 탭에서 `main.a1b2c3.js` 행이 이렇게 보인다.

```text
Status 200   Size 412 kB   Time 850 ms
응답 헤더: cache-control: public, max-age=31536000, immutable
           age: 0
           x-vercel-cache: MISS
```

이 사용자는 두 번째 방문이고, 파일은 어제 배포된 것 그대로다. 세 개의 캐시 중 무엇이 빠졌고, 무엇이 빠지지 않았나?

---

## 퀴즈 4 정답 : 브라우저 캐시도 CDN 캐시도 비어 있었다

- Size가 `(disk cache)`가 아니라 실제 크기이니 **브라우저 캐시에 없었다.** 두 번째 방문인데도 없다면 DevTools "Disable cache"가 켜져 있거나, 시크릿 창이거나, 사용자가 캐시를 지웠거나, 어제 방문 때 이 청크를 로드하지 않은 경로였다
- `MISS`, `age: 0`이니 **이 엣지에도 없었다.** 어제 배포 이후 이 지역에서 아무도 이 파일을 요청하지 않았거나, 엣지가 용량 정책으로 내보냈다
- 헤더 자체는 정상이다. 이 요청이 사본을 채웠으니 다음부터는 양쪽 다 HIT다

> 850ms 중 얼마가 캐시 부재 때문인지는 Timing 탭의 Waiting(TTFB)이 답한다. 헤더만 보고 "캐시가 안 된다"고 결론내지 않는다.

---

## 2편 정리 : 정책부터 진단까지

1. **URL이 내용을 식별하는 자산**은 해시 파일명 + 긴 `max-age` + `immutable`로 둔다
2. **고정 URL의 공통 HTML**은 `no-cache` + ETag로 최신 참조를 확인한다. CDN의 TTL 정책도 맞춘다
3. **개인화 응답**은 저장 요구사항에 따라 `private`이나 `no-store`를 선택하고, 공유 캐시 정책을 함께 점검한다
4. **배포는 자산 먼저, HTML 나중에.** 이전 자산을 보존하고, CDN purge가 브라우저 캐시까지 지우지는 않는다는 점을 기억한다
5. **옛 화면 신고는 어느 캐시인지부터.** DevTools의 Size·응답 헤더와 서비스 워커 개입 여부를 보고 원인을 좁힌다

---

## 참고 자료

- [RFC 9111 : HTTP Caching](https://www.rfc-editor.org/rfc/rfc9111) (+ [RFC 8246 immutable](https://www.rfc-editor.org/rfc/rfc8246), [RFC 5861 stale-while-revalidate](https://www.rfc-editor.org/rfc/rfc5861)) : 저장 조건, 신선도, `Authorization` 예외의 원문
- [MDN : HTTP caching](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Caching) : 지시어별 브라우저 동작 정리
- Chrome : [Reload, reloaded](https://blog.chromium.org/2017/01/reload-reloaded-faster-and-leaner-page_26.html), [Partitioning the HTTP cache](https://developer.chrome.com/blog/http-cache-partitioning), [bfcache](https://web.dev/articles/bfcache)
- [CloudFront : Manage how long content stays in the cache](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Expiration.html) : TTL 합성 표와 Min TTL 경고문
- CloudFront : [Managed cache policies](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html), [Request and response behavior](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/RequestAndResponseBehaviorCustomOrigin.html), [Invalidate files](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Invalidation.html)
- [Vercel Docs : Edge Cache](https://vercel.com/docs/edge-cache) : `s-maxage`를 브라우저 응답에서 제거하는 동작
- [Steam : Update on Christmas Issues (2015)](https://store.steampowered.com/news/post/1978855) : 캐시 설정 오류 사고의 공식 설명

---

# 감사합니다

<!-- _class: invert -->

[1편: 원리와 규칙 다시 보기](/slides/cdn-and-http-cache)

@yceffort

---

# CDN과 HTTP 캐시 1: 원리와 규칙

Source: https://research.yceffort.kr/slides/cdn-and-http-cache.md

---
title: 'CDN과 HTTP 캐시 1: 원리와 규칙'
marp: true
paginate: true
theme: midnight
tags:
  - caching
  - networking
  - web-performance
date: 2026-09-11
description: 'CDN의 동작과 HTTP 캐시 규칙을 익히고, 우리 서비스의 응답 헤더로 저장·신선도·재검증을 해석하는 강의'
published: true
art:
  undraw: connected-world
---

# CDN과 HTTP 캐시 1

원리와 규칙: 응답 헤더를 읽는 법

<!-- _class: invert -->

@yceffort

---

## 배포 직후에 자주 받는 질문 세 가지

1. "배포했는데 사용자 화면은 아직 옛날 버전이에요"
2. "일부 사용자만 `ChunkLoadError`가 나요. 새로고침하면 돼요"
3. "이미지를 교체했는데 계속 옛 이미지가 나와요. 파일명은 같아요"

세 질문의 답은 전부 같은 곳에 있다. **파일이 사용자에게 오는 길 어딘가에 사본이 남아 있다.**

<!-- 손들기: 셋 중 하나라도 직접 겪어 본 사람? 거의 전원이 든다. 그 사본이 어디에, 왜, 얼마나 남는지가 오늘 주제. -->

---

## 1편의 목표

```text
200  (disk cache)  main-2kzkiswg.js  age: 151772  x-vercel-cache: HIT
304  0 B           /                 cache-control: max-age=0, must-revalidate
```

DevTools Network 탭의 이런 한 줄을 읽고 **어느 캐시가 응답했고, 왜 재사용하거나 확인했는지 설명할 수 있게** 되는 것.

마지막에는 **우리 서비스의 HTML·JS·API·이미지 헤더**를 직접 해석한다.

자산별 정책을 정하고 배포 문제를 해결하는 방법은 [2편](/slides/cdn-and-http-cache-2)에서 이어 간다.

---

## 1편에서 다루는 것

1. **CDN** : 왜 필요하고, 요청이 어떻게 가까운 서버로 가고, 그 서버가 무엇을 하는가
2. **HTTP 캐시 규칙** : `Cache-Control` 지시어, 신선도 계산, 재검증, `Vary`

각 부 끝에 **중간 점검**이 있다. 2부에서는 개념 확인 두 문제를 풀고, **우리 서비스 헤더 퀴즈 네 문제**로 마무리한다.

[2편: 자산별 전략과 문제 해결](/slides/cdn-and-http-cache-2)에서는 이 규칙을 실제 배포와 운영에 적용한다.

---

## 기준

- 캐시 규칙은 **RFC 9111 (HTTP Caching)** 기준. `immutable`은 RFC 8246, `stale-while-revalidate`는 RFC 5861
- `yceffort.kr`(Next.js 16.3.1, Vercel) 실측은 **2026-09-11**, 중간 점검 2의 우리 서비스 실측은 **2026-09-16**에 `curl`로 직접 GET 요청한 결과다. 각 예시에 응답 상태와 확인 범위를 표시한다
- AWS 동작은 **CloudFront 개발자 가이드**에서 확인했고, 실측 헤더는 `awsstatic.com`(S3 + CloudFront)에서 받았다
- 브라우저 동작은 **Chrome** 기준으로 설명하고, Firefox/Safari가 다른 지점은 따로 표시한다
- 다루지 않는 것: 서비스 워커의 Cache API, react-query 같은 애플리케이션 캐시, CDN 벤더별 설정 화면

<!-- 구두 보충: CDN 벤더(Cloudflare, CloudFront, Vercel)는 설정 UI만 다르고 아래 규칙은 전부 같은 RFC를 따른다. 규칙을 알면 어느 벤더 문서든 읽힌다. -->

---

## Part 1 : CDN은 무엇을 해결하는가

<!-- _class: invert -->

---

## 요청 하나가 이동하는 거리

서울에서 미국 버지니아까지 약 11,000km, 광섬유 속 빛은 약 200,000km/s다.

```text
편도 이론값     55ms
왕복(RTT) 이론값 110ms
실제 RTT       180 ~ 200ms   (라우터, 경로 우회, 큐잉)
```

게다가 HTTPS는 첫 바이트까지 왕복이 한 번이 아니다.

```text
TCP 핸드셰이크   1 RTT   유실과 순서 꼬임에 대비해 양쪽이 순번을 맞춘다
TLS 핸드셰이크   1 RTT   서버 인증서를 확인하고 암호 키를 합의한다 (TLS 1.3)
HTTP 요청/응답   1 RTT   그제야 실제 요청이 나간다
─────────────────────
첫 바이트까지    3 RTT ≈ 550ms
```

이게 HTML 한 장 값이고, 그 HTML이 부르는 JS와 CSS가 다시 낸다.

---

## 같은 요청을 가까운 곳에서 받으면

서울 사무실에서 `yceffort.kr`의 JS 청크 하나를 요청한 `curl` 타이밍이다.

```text
dns=0.002  connect=0.007  tls=0.025  ttfb=0.065  total=0.144
```

- TCP 연결 **7ms**, TLS까지 **25ms**. 서버가 물리적으로 가깝다는 뜻이다
- 이 서버는 내 파일을 만든 서버(오리진)가 아니라, 오리진의 **사본을 들고 있는 서울의 서버**다

CDN(Content Delivery Network)은 이렇게 **전 세계에 흩어진 캐시 서버들의 네트워크**다. 각 서버를 엣지(edge) 또는 PoP(Point of Presence)라고 부른다.

---

## 요청은 어떻게 가까운 엣지로 가는가

같은 도메인인데 서울 사용자는 서울 엣지로, 도쿄 사용자는 도쿄 엣지로 간다. 방법은 둘이다.

- **DNS 기반** : DNS는 도메인 이름을 IP 주소로 바꿔 주는 단계다. 이때 물어본 리졸버의 위치를 보고 지역마다 다른 IP를 돌려준다
- **Anycast** : IP 하나는 보통 서버 한 대를 가리키는데, 여러 엣지가 **같은 IP**를 동시에 광고해 인터넷 라우팅(BGP)이 그중 가장 가까운 곳으로 보내게 한다. Cloudflare, Vercel이 이 방식이다

어느 엣지에 도착했는지는 응답 헤더에 남는다.

```text
$ curl -sI https://yceffort.kr/ | grep x-vercel-id
x-vercel-id: icn1::icn1::kb2hv-1789123883690-2280c10516e7

$ curl -s https://www.cloudflare.com/cdn-cgi/trace | grep colo
colo=ICN
```

`icn`, `ICN`은 인천공항 코드다. CDN 업계는 PoP 이름에 공항 코드를 쓴다.

---

## 엣지가 하는 일

```text
[브라우저] ──▶ [엣지 icn1] ──▶ [오리진]
                  │
                  ├─ 캐시에 있고 아직 유효하다 ─▶ HIT : 사본을 바로 준다
                  ├─ 캐시에 없다 ─────────────▶ MISS : 오리진에서 받아 저장하고 준다
                  └─ 있지만 만료됐다 ─────────▶ STALE : 오리진에 확인하거나 일단 준다
```

- 캐시를 찾는 열쇠(**캐시 키**)는 기본적으로 `호스트 + 경로 + 쿼리스트링`이다. 여기에 `Vary`로 지정한 요청 헤더가 추가된다(Part 2)
- 그래서 `/posts/1`과 `/posts/1?utm_source=x`는 **다른 키**다. 내용이 같아도 엣지에는 사본이 둘 생긴다
- 단 CloudFront는 기본 캐시 키가 **`도메인 + 경로`뿐**이라 위 둘이 같은 키가 된다. 쿼리스트링을 넣으려면 캐시 정책에 직접 지정한다
- 결과는 벤더마다 다른 이름의 헤더로 알려준다: `x-vercel-cache`, `cf-cache-status`, CloudFront는 `x-cache`

<!-- HIT/MISS/STALE 외에 벤더별로 BYPASS, REVALIDATED, PRERENDER 같은 값이 더 있지만 셋만 알면 나머지는 문서로 읽힌다. -->

---

## AWS로 보면 : CloudFront의 구조

```text
[브라우저] ──▶ [엣지 ICN80] ──▶ [리전 엣지 캐시] ──▶ [오리진 : S3 버킷, ALB, EC2]
```

- **배포(distribution)** 하나가 CDN 한 벌이다. 만들면 `d1234.cloudfront.net` 주소가 나오고, `cdn.example.com` 같은 내 도메인을 대체 도메인 이름으로 등록한 뒤 DNS에서 이 주소로 연결한다
- **오리진** : 사본의 출처. 정적 파일은 S3 버킷, 동적 응답은 ALB나 EC2
- **동작(behavior)** : 경로 패턴별 규칙. `/static/*`는 S3로, `/api/*`는 ALB로 보내고 각각 다른 캐시 정책을 붙인다
- **리전 엣지 캐시** : 엣지와 오리진 사이의 2차 캐시다. 엣지보다 용량이 커서 인기가 식어 엣지에서 밀려난 사본도 여기 남고, 한 리전의 엣지들이 이 캐시를 함께 쓴다
- 그래서 엣지에서 빠져도 오리진까지 안 가는 경우가 많다. 단 동적 요청, `POST` 같은 프록시 메서드, 오리진 S3가 같은 리전에 있는 요청은 여기를 건너뛴다

---

## 엣지 사본은 응답 헤더로 확인한다

```text
$ curl -sI \
  https://a0.awsstatic.com/libra-css/images/logos/aws_logo_smile_1200x630.png
cache-control: max-age=31536000
server: AmazonS3
x-cache: Hit from cloudfront
x-amz-cf-pop: ICN80-P3
age: 4273830            ← 49일째 서울 엣지에 있는 사본
```

---

## MISS와 HIT의 차이를 직접 재 보면

아직 서울 엣지에 없는 이미지 변형(384px)을 네 번 연속 요청했다.

| 순서 | `x-vercel-cache` | TTFB  |
| ---- | ---------------- | ----- |
| 1    | MISS             | 372ms |
| 2    | HIT              | 261ms |
| 3    | HIT              | 60ms  |
| 4    | HIT              | 50ms  |

- 첫 요청은 오리진(이미지 최적화 함수)까지 다녀왔다
- 두 번째는 HIT인데도 느린데, 엣지 안에서 사본이 아직 퍼지는 중이라고 이해하면 된다(벤더 내부 구현이라 단정하지 않는다)
- 이후는 **6~7배** 빠르다. 이 차이가 전 세계 사용자에게, 모든 파일에 곱해진다

---

## 캐시는 하나가 아니다

```text
[브라우저 캐시]  ──▶  [CDN 엣지 캐시]  ──▶  [오리진]
  사설(private)          공유(shared)
  한 사용자 전용          모든 사용자가 공유
```

- **사설 캐시** : 브라우저. 내 컴퓨터에만 있고 나만 쓴다
- **공유 캐시** : CDN, 회사 프록시. 다른 사용자의 응답을 나에게 줄 수 있다

같은 `Cache-Control` 헤더 하나로 둘 다 지시한다. 그래서 지시어에 `private`, `s-maxage`처럼 **어느 캐시를 향한 말인지** 구분하는 것들이 있다.

로그인한 사용자의 페이지가 공유 캐시에 저장되면 다른 사용자에게 그대로 나간다. [2편의 함정과 도구](/slides/cdn-and-http-cache-2)에서 실제 사고를 본다.

---

## 사본은 어디서 오는가 : pull과 push

- **Pull CDN** : 엣지가 오리진 앞에 서 있다가, 요청이 오면 그때 오리진에서 받아 저장한다. Cloudflare, Vercel, CloudFront가 기본으로 이렇게 동작한다
- **Push CDN** : 빌드 시점에 파일을 스토리지(S3 같은)에 올려 두고, CDN이 그 스토리지를 오리진으로 삼는다

실무에서는 보통 둘을 섞는다.

```text
HTML(동적, 자주 바뀜)  ──▶  애플리케이션 서버를 오리진으로 pull
JS/CSS/이미지(정적)    ──▶  빌드 때 스토리지에 push, CDN이 앞에 섬
```

Next.js를 Vercel에 올리면 이 구분이 자동으로 된다. 직접 배포(nginx, S3 + CloudFront)할 때는 이 구조를 손으로 만들어야 한다.

---

## CDN이 캐시 말고 하는 일

이 덱은 캐시만 다루지만, 실무에서 CDN을 켜면 같이 따라오는 것들이다.

- **TLS 종료** : 사용자의 암호 연결이 오리진이 아니라 엣지에서 끝난다는 뜻이다. 인증서를 엣지가 들고 핸드셰이크를 대신 처리한 뒤, 오리진과는 따로 연결한다
- **압축** : Brotli/gzip을 엣지에서 적용한다
- **HTTP/2, HTTP/3** : 오리진이 HTTP/1.1이어도 사용자와는 최신 프로토콜로 통신한다
- **DDoS 완화, WAF** : 오리진에 닿기 전에 걸러낸다
- **이미지 변환** : 크기와 포맷을 요청마다 만들어 캐시한다
- **엣지 함수** : 리다이렉트, A/B 분기 같은 가벼운 로직을 엣지에서 실행한다

---

## "국내 서비스인데 CDN이 왜 필요한가요"

**CDN은 멀어서 쓰는 게 아니다.** 서울에서 서울 리전 S3까지 TCP 연결이 10ms 안팎이다(실측). 거리를 줄이는 장치가 아니라 **오리진 앞에 두는 공유 캐시**다.

1. **오리진 부하 분리** : 같은 파일에 요청이 몰려도 오리진은 엣지마다 TTL에 한 번 응답하면 된다. 트래픽이 튈 때 서버 대신 엣지가 흡수한다
2. **응답을 만드는 비용** : 앞서 잰 MISS 372ms 대 HIT 50ms는 둘 다 서울 엣지에서 잰 값이다. 거리가 아니라 오리진 코드를 실행했느냐의 차이다
3. **전송 비용** : S3에서 CloudFront로 가는 전송은 무료이고, CloudFront 발신이 EC2나 ALB 발신보다 GB당 싸다
4. **가용성** : DDoS 완화와 WAF가 오리진에 닿기 전에 막고, 오리진이 5xx를 뱉어도 엣지가 옛 사본으로 버틸 수 있다(Part 2)

반대로 사내 도구처럼 트래픽이 작고 응답이 전부 개인화돼 캐시할 것이 없다면 CDN은 비용만 든다. 그때는 "커지면 붙인다"가 답이다.

---

## Part 1 정리

- 거리는 RTT로 바뀌고, HTTPS 첫 바이트까지는 RTT가 세 번 든다. 서울에서 미국은 0.5초다
- CDN은 사본을 사용자 가까이 두는 캐시 서버들의 네트워크다. 엣지 도착은 DNS 또는 Anycast로 정해진다
- 엣지는 캐시 키로 사본을 찾아 HIT/MISS/STALE로 응답한다. 실측으로 HIT는 MISS보다 6~7배 빨랐다
- 캐시는 브라우저(사설)와 CDN(공유) 둘이며, 하나의 헤더로 둘 다 지시한다

---

## 중간 점검 1-1

서울 사용자가 `https://shop.example.com/app.js`를 요청했다. 응답 헤더에 `x-cache: HIT`가 있고 TTFB는 40ms였다. 이때 확실히 말할 수 있는 것은?

1. 이 파일은 사용자의 브라우저 캐시에서 왔다
2. 오리진 서버가 서울에 있다
3. 서울 엣지가 이 파일의 사본을 이미 갖고 있었다
4. 이 파일은 앞으로 1년간 갱신되지 않는다

---

## 중간 점검 1-1 정답 : ③

- ① 브라우저 캐시에서 왔다면 네트워크 요청 자체가 없어서 `x-cache` 헤더를 새로 받을 일이 없다. DevTools에는 `(disk cache)`로 표시된다
- ② 엣지가 가까운 것이지 오리진 위치는 알 수 없다
- ④ 얼마나 유지되는지는 `Cache-Control`이 정한다. `HIT`는 "지금 있었다"는 뜻일 뿐이다

> 핵심: CDN 헤더는 **엣지에 있었는지**만 말한다. 브라우저 캐시와 유효 기간은 별개다.

---

## 중간 점검 1-2

마케팅팀이 `?utm_source=kakao`, `?utm_source=x` 같은 파라미터를 붙인 링크를 대량으로 뿌렸다. 어떤 링크로 들어와도 페이지 내용은 똑같은데, 오리진 부하가 눈에 띄게 늘었다.

이 CDN은 쿼리스트링을 캐시 키에 포함한다. 무슨 일이 벌어진 것인가?

---

## 중간 점검 1-2 정답 : 파라미터마다 캐시 키가 달라 전부 MISS였다

- 엣지는 본문을 비교하지 않는다. `?utm_source=kakao`와 `?utm_source=x`는 응답이 같아도 **서로 다른 객체**다
- 링크 종류만큼 MISS가 나고 그만큼 오리진이 실행된다. 엣지에는 같은 내용의 사본이 그 수만큼 쌓인다
- 캐시 키에 넣을 파라미터를 지정할 수 있다면 추적용 파라미터는 빼는 것이 답이다. CloudFront는 반대로 기본이 제외이므로, 응답이 파라미터에 따라 달라지는 페이지에서 반대 방향 사고가 난다

---

## 중간 점검 1-3

서울 사용자 A가 `/app.js`를 처음 요청해 `MISS`를 받았다. 잠시 뒤 도쿄 사용자 B가 같은 파일을 처음 요청했는데 역시 `MISS`였다.

A가 이미 받아 갔는데 왜 B도 MISS인가?

---

## 중간 점검 1-3 정답 : 캐시는 엣지마다 따로다

- A의 요청으로 사본이 생긴 곳은 **서울 엣지**다. 도쿄 엣지에는 아직 없다
- 엣지가 수십 곳이면 배포 직후 오리진은 같은 파일을 그만큼 여러 번 내보낸다. 배포 직후 오리진 부하가 튀는 이유다
- CloudFront처럼 리전 엣지 캐시가 있으면 같은 리전의 엣지끼리 사본을 나눠 쓰므로 오리진까지 가는 횟수가 줄어든다

> 핵심: `MISS`는 "이 엣지에 없었다"는 뜻이지 "어디에도 없었다"가 아니다.

---

## Part 2 : HTTP 캐시 규칙

<!-- _class: invert -->

---

## 캐시가 응답마다 던지는 세 가지 질문

```text
① 저장해도 되는가?              → no-store, private, public
② 저장한 것을 지금 써도 되는가?   → max-age, s-maxage, Age  (신선도)
③ 낡았다면 다시 받지 않고
   확인만 할 수 있는가?          → ETag, Last-Modified   (재검증)
```

브라우저와 CDN 둘 다 이 순서로 판단한다. `Cache-Control` 지시어는 전부 이 세 질문 중 하나에 대한 답이다.

---

## Cache-Control 지시어 : 저장과 신선도

| 지시어       | 뜻                                                            |
| ------------ | ------------------------------------------------------------- |
| `max-age=N`  | 응답 시점부터 N초 동안 신선(fresh)하다                        |
| `s-maxage=N` | 공유 캐시(CDN)에서만 쓰는 max-age. 있으면 CDN은 이쪽을 따른다 |
| `public`     | 공유 캐시가 저장해도 된다 (인증 헤더가 있는 응답까지도)       |
| `private`    | 브라우저만 저장. CDN은 저장하면 안 된다                       |
| `no-store`   | 어디에도 저장하지 마라                                        |
| `no-cache`   | 저장은 해도 되지만, **쓰기 전에 매번 서버에 확인**하라        |

```text
Cache-Control: public, max-age=0, s-maxage=300
   브라우저: 받자마자 낡음 → 매번 확인    CDN: 5분간 신선
```

브라우저가 매번 보내는 확인 요청은 **엣지에서 끝난다.** 엣지가 5분 동안 자기 사본으로 답하므로 오리진까지 가는 요청은 5분에 한 번이다.

---

## no-cache는 캐시를 끄지 않는다

가장 흔한 오해라 따로 한 장을 쓴다.

```text
Cache-Control: no-cache    저장한다. 다만 매번 서버에 "아직 같나요?"를 묻는다
Cache-Control: no-store    저장하지 않는다
```

- `no-cache`는 사실상 `max-age=0, must-revalidate`와 같은 효과다. 파일이 안 바뀌었으면 서버는 `304`만 보내고 본문은 캐시에서 쓴다
- "캐시 때문에 안 바뀌어요"의 해법으로 `no-cache`를 붙이면 **정상 동작**이다. 저장은 되지만 확인을 거치니 항상 최신이다
- 정말 저장을 막아야 하는 것은 개인정보가 담긴 응답이다. 그때만 `no-store`

> 둘 다 사용자는 늘 최신을 본다. 차이는 **매번 다시 받는 양**이다. `no-cache`는 안 바뀌었으면 `304` 헤더만 받고 본문은 캐시에서 쓰고, `no-store`는 매번 본문을 통째로 다시 받는다.

<!-- must-revalidate 단독은 "낡은 뒤에는 반드시 확인하라"이고, 낡기 전까지는 확인 없이 쓴다. no-cache는 낡았든 아니든 확인한다. -->

---

## 신선도 계산 : max-age와 Age

`max-age=300`을 받았다고 300초가 남은 것이 아니다. CDN이 이미 들고 있던 사본일 수 있고, 그 시간이 `Age`에 적혀 온다.

```text
Cache-Control: max-age=300     캐시해도 되는 총 시간은 300초
Age: 120                       그중 120초는 CDN이 이미 썼다
──────────────────────────
브라우저 몫 = 300 - 120 = 180초
```

- 이 180초 동안은 서버에 묻지 않고 쓴다. 브라우저가 들고 있는 시간이 쌓여 180초를 넘기면 그 사본은 **낡았다(stale)**
- 낡아도 버리지 않는다. 서버에 "아직 같나요"를 묻고(재검증), 같다면 받아 둔 본문을 그대로 쓴다

```text
$ curl -sI https://yceffort.kr/_next/static/immutable/chunks/2kzkiswgqacx4.js
cache-control: public,max-age=31536000,immutable
age: 151772                ← 서울 엣지가 1.76일 전에 받아 둔 사본
x-vercel-cache: HIT
```

<!-- RFC 9111 용어로는 총 시간이 freshness_lifetime, 지금 나이가 current_age이고 current_age < freshness_lifetime이면 신선하다. -->

---

## CloudFront의 함정 : no-store만으로는 부족하다

캐시 정책의 **Min TTL > 0이면 `no-store`도 무시된다.** `CachingOptimized`는 Min 1초, Default 24시간, Max 1년이다.

```text
오리진이 max-age=60을 보내면   →  60초. Min과 Max 사이라 그대로
오리진이 헤더를 안 보내면      →  24시간. Default가 대신 정한다
오리진이 no-store를 보내도     →  1초. Min TTL만큼은 캐시된다
```

**피하는 법: CloudFront 배포 → Behaviors(동작)에서 경로별 Cache policy를 지정한다.**

1. **개인화 API `/api/me`** : 별도 동작에 `CachingDisabled`(TTL 모두 0)를 연결하고, 오리진도 `Cache-Control: no-store`를 보낸다. CDN과 브라우저 양쪽의 저장을 막는다
2. **헤더로 캐시할 공개 응답** : `UseOriginCacheControlHeaders`(Min 0, Default 0, Max 1년)를 연결한다. TTL은 오리진 헤더로 정하고, 헤더가 없으면 캐시하지 않는다
3. **설정 배포 후 검증** : A/B 계정으로 같은 `/api/me`를 번갈아 반복 요청한다. 각자 자기 정보가 나오고, `X-Cache: Hit from cloudfront`가 나오지 않는지 확인한다

<!-- 캐시 키가 사용자를 구분하지 않으면 A의 개인 응답이 B에게 나갈 수 있다. 개인화 경로는 겹치는 /api/* 동작보다 위에 둔다(먼저 일치하는 동작이 적용됨). CachingDisabled는 인증 정보 전달을 설정하지 않으므로, 필요한 Cookie/Authorization을 오리진 요청 정책으로 전달해야 한다. 공개 응답도 쿼리에 따라 내용이 다르면 UseOriginCacheControlHeaders-QueryStrings 또는 그에 맞는 사용자 지정 정책을 쓴다. 출처: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html , https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/DownloadDistValuesCacheBehavior.html -->

---

## 재검증 : 서버는 같은 버전인지 어떻게 알까?

캐시가 낡았다는 것은 **다시 확인할 시간이 됐다**는 뜻이다. 서버의 내용은 그대로일 수 있다. 본문을 다시 받지 않고 같은 버전인지 확인하려면?

서버가 응답에 **현재 버전을 구분하는 값 `ETag`**를 붙여 준다. 브라우저는 본문과 함께 저장했다가, 재검증할 때 `If-None-Match`에 그 값을 넣어 보낸다.

```text
처음 응답     200 OK + 본문 + ETag: "v1"
재검증 요청   GET /app.js + If-None-Match: "v1"
              "내가 가진 v1과 다를 때만 본문을 보내 주세요."

같으면 → 304 Not Modified, 본문 없이 → 브라우저는 저장한 본문 재사용
다르면 → 200 OK + 새 본문 + ETag: "v2" → 브라우저는 캐시 갱신
```

수정 시각으로도 확인할 수 있다. 서버가 `Last-Modified`를 주면 브라우저는 `If-Modified-Since`로 돌려보낸다. 이렇게 **같은 응답인지 확인할 근거**를 **검증자**라고 부른다.

<!-- ETag는 서버가 정하는 값이며 반드시 내용의 해시일 필요는 없다. 여기의 v1/v2는 설명용 식별자다. 2026-09-11 yceffort.kr 실측에서는 curl -s -o /dev/null -w "%{http_code} %{size_download}\n" -H 'If-None-Match: "_next/static/immutable/chunks/2kzkiswgqacx4.js"' https://yceffort.kr/_next/static/immutable/chunks/2kzkiswgqacx4.js 요청이 304 0을 반환했다. 이 응답의 ETag는 경로 문자열이었다. 출처: https://httpwg.org/specs/rfc9110.html#field.etag , https://httpwg.org/specs/rfc9110.html#field.if-none-match -->

---

## 검증자가 없으면 재검증도 없다

`yceffort.kr`의 HTML 응답 헤더에는 `ETag`도 `Last-Modified`도 없다.

```text
cache-control: public, max-age=0, must-revalidate
content-type: text/html; charset=utf-8
(etag 없음, last-modified 없음)
```

- `max-age=0`이라 매번 서버에 가야 하는데, 확인할 근거가 없으니 **매번 전체 본문(약 340KB)을 다시 받는다**
- 서버 입장에서는 `304`를 만들 재료가 없다

`max-age=0` 또는 `no-cache`를 쓸 거라면 검증자를 같이 주어야 그 설정이 싸진다. HTML처럼 매번 확인해야 하는 응답일수록 `ETag`가 중요하다.

<!-- 이 사이트의 경우 HTML 재검증이 안 되는 것이 실제 비용인지는 별개 문제다(스트리밍 RSC 응답이라 ETag 계산이 어렵다). 여기서는 "검증자 없는 max-age=0은 매번 풀 다운로드"라는 규칙만 가져간다. -->

---

## 헤더가 아무것도 없으면 : 휴리스틱 캐시

"캐시 헤더를 안 줬는데 왜 캐시되죠?"

- RFC 9111은 `Cache-Control`도 `Expires`도 없고 `Last-Modified`만 있으면, 캐시가 **스스로 신선도를 추정해도 된다**고 허용한다
- 권고 값은 `(응답 시각 - Last-Modified) × 10%`. Chrome과 Firefox가 그렇게 구현한다

```text
Last-Modified: 100일 전   →  10일 동안 확인 없이 캐시에서 쓴다
Last-Modified: 어제       →  약 2.4시간
```

nginx나 S3에서 정적 파일을 그냥 서빙하면 `Last-Modified`가 자동으로 붙는다. 그래서 헤더를 안 정해도 "가끔 안 바뀌는" 현상이 생긴다.

> 캐시 헤더를 정하지 않는 것은 "캐시 안 함"이 아니라 **"브라우저 마음대로"** 다.

---

## Vary : 같은 URL, 다른 응답

`Vary`는 서버가 보내는 **응답 헤더**다. 값에는 **응답을 달라지게 한 요청 헤더의 이름**을 적는다. 정해진 목록은 없으며 `X-Theme` 같은 사용자 정의 헤더도 가능하다.

| Vary에 넣는 이름  | 구분할 요청 헤더 값의 예                 | 응답이 달라지는 이유  |
| ----------------- | ---------------------------------------- | --------------------- |
| `Accept`          | `image/avif`, `image/webp`               | 지원하는 이미지 포맷  |
| `Accept-Encoding` | `br`, `gzip`                             | 지원하는 압축 방식    |
| `Accept-Language` | `ko`, `en`                               | 선호하는 언어         |
| `Origin`          | `https://a.example`, `https://b.example` | 출처별 CORS 허용 응답 |

---

## Vary에 무엇을 넣느냐가 HIT율을 가른다

```text
Vary: Accept, Accept-Encoding
→ 같은 URL이어도 두 요청 헤더 값이 모두 맞아야 재검증 없이 재사용
```

- `User-Agent`, `Cookie`도 가능하지만, 값이 다양하면 사본이 잘게 나뉘어 HIT율이 낮아진다
- **특수 값 `*`** : 어떤 요청과도 일치하지 않아 재검증 없이 재사용할 수 없다. 저장 금지는 `no-store`로 지시한다

<!-- Vary에는 image/avif 같은 값 자체가 아니라 Accept라는 요청 헤더 이름을 쓴다. 실제로 응답 선택에 영향을 주는 헤더만 지정한다. 2026-09-11 yceffort.kr의 동일한 /_next/image URL에 기본 Accept로 요청하면 image/jpeg(975 B), Accept: image/avif,image/webp,*/*로 요청하면 image/avif(735 B)가 왔고 두 응답 모두 Vary: Accept를 포함했다. Vary: Cookie는 쿠키 값별 구분이지 저장 금지 선언은 아니다. CDN별 지원과 설정은 별도 확인하며 다음 페이지에서 CloudFront의 예외를 설명한다. 출처: https://httpwg.org/specs/rfc9110.html#field.vary , https://httpwg.org/specs/rfc9111.html#caching.negotiated.responses -->

---

## CloudFront에서는 Vary만으로 부족하다

이미지 서버가 같은 `/photo`에서 `Accept`에 따라 AVIF나 JPEG를 보내고, 응답에 `Vary: Accept`를 붙인다고 하자. CloudFront에서는 **캐시 정책에도 `Accept`를 넣어야 두 응답을 구분한다.**

| Accept를 넣는 곳                             | CloudFront가 하는 일                                     |
| -------------------------------------------- | -------------------------------------------------------- |
| **캐시 정책** (Cache policy)                 | Accept 값별로 캐시를 구분하고, 오리진에도 전달           |
| **오리진 요청 정책** (Origin request policy) | 캐시 MISS 때 오리진에 전달만 함. 캐시 구분에는 쓰지 않음 |

```text
Accept를 오리진에 전달만 하고, 캐시 키에서는 빼면:
A: Accept: image/avif → MISS → AVIF 응답을 /photo의 사본으로 저장
B: Accept: image/jpeg → HIT → A에게 줬던 AVIF를 받음!
```

**해결:** 캐시 정책의 **Headers에 `Accept`를 포함**하고 해당 경로의 동작(Behavior)에 연결한다. 그러면 A와 B는 서로 다른 사본을 쓴다.

오리진의 `Vary: Accept`도 유지한다. **브라우저의 캐시 구분에는 여전히 필요하다.**

<!-- 예시는 캐시 가능한 공개 응답이고, Accept 외의 캐시 키 요소는 같으며 A의 응답이 아직 신선하다고 가정한다. 캐시 키는 이해를 돕기 위해 /photo로 단순화했다. 캐시 정책에 포함한 값은 오리진에 자동 전달되므로 오리진 요청 정책에 중복 지정할 필요가 없다. 캐시 정책에 없어도 오리진 요청 정책으로 전달할 수 있다. 출처: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/controlling-the-cache-key.html , https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/controlling-origin-requests.html -->

---

## stale-while-revalidate : 낡아도 일단 주고 뒤에서 갱신

```text
Cache-Control: max-age=60, stale-while-revalidate=600
```

```text
0 ~ 60초        fresh. 그냥 준다
60 ~ 660초      stale이지만 일단 옛 사본을 주고, 백그라운드에서 새로 받아 둔다
660초 이후      stale. 새로 받을 때까지 기다린다
```

- 사용자는 재검증을 기다리지 않는다. 대신 최대 10분 낡은 응답을 볼 수 있다
- CDN에서 특히 효과적이다. 사용자 한 명이 옛 사본을 받는 대신 **그 뒤 모든 사용자가 새 사본**을 받는다
- 형제 지시어 `stale-if-error=N`은 오리진이 5xx일 때 N초까지 옛 사본으로 버틴다

Chrome, Firefox 모두 지원한다. Next.js가 ISR 페이지에 내보내는 헤더가 바로 이 조합이다.

---

## immutable : 만료 전엔 확인조차 하지 마라

```text
Cache-Control: public, max-age=31536000, immutable
```

- `max-age`만 있으면 사용자가 **새로고침**할 때 브라우저가 "혹시 바뀌었나" 확인 요청(`If-None-Match`)을 보낸다. 304가 돌아와도 왕복은 든다
- `immutable`은 "이 URL의 내용은 절대 바뀌지 않으니 그 확인도 하지 마라"는 선언이다
- Firefox와 Safari가 구현했다. Chrome은 지시어를 무시하는 대신 **새로고침 동작 자체를 바꿨다**: 일반 새로고침 때 메인 리소스만 재검증하고 서브리소스는 캐시 규칙대로 쓴다(2편에서 자세히 다룬다)

이 지시어를 붙일 수 있는 조건은 하나다. **URL이 내용을 식별해야 한다.** 파일명에 내용 해시가 들어간 빌드 산출물이 그 조건을 만족한다.

---

## 지시어 한 장 요약

| 지시어                   | 브라우저                 | CDN                   |
| ------------------------ | ------------------------ | --------------------- |
| `max-age=N`              | N초 신선                 | `s-maxage` 없으면 N초 |
| `s-maxage=N`             | 무시                     | N초 신선              |
| `private`                | 저장                     | 저장 안 함            |
| `public`                 | 저장                     | 저장 (인증 응답도)    |
| `no-store`               | 저장 안 함               | 저장 안 함            |
| `no-cache`               | 저장 후 매번 재검증      | 저장 후 매번 재검증   |
| `must-revalidate`        | 낡으면 반드시 재검증     | 같음                  |
| `stale-while-revalidate` | 낡아도 주고 뒤에서 갱신  | 같음 (효과 큼)        |
| `immutable`              | 새로고침에도 재검증 생략 | 대부분 무시           |

---

## Part 2 정리

- 캐시는 매 응답에 세 질문을 한다: 저장해도 되나, 지금 신선한가, 낡았다면 확인만 할 수 있나
- 신선도는 `max-age`(CDN은 `s-maxage`)에서 `Age`를 뺀 값이다. `no-cache`는 저장하되 매번 확인이고, 저장 금지는 `no-store`다
- 재검증은 `ETag`/`Last-Modified`가 있어야 가능하고, 없으면 `max-age=0`은 매번 전체 다운로드다
- 헤더가 없으면 휴리스틱(Last-Modified 경과의 10%)으로 캐시된다. `Vary`는 캐시 키를 넓히는 선언이다
- CloudFront는 캐시 정책의 Min/Default/Max TTL로 오리진 헤더를 자르고, 키에는 정책에 넣은 것만 들어간다. Min TTL > 0이면 `no-store`도 무시된다

---

## 개념 확인 1 : 헤더 해석 (Part 2)

```text
HTTP/2 200
Cache-Control: private, max-age=0, no-cache
ETag: "x1"
```

이 응답에 대한 설명으로 **틀린** 것은?

1. 브라우저는 이 응답을 저장한다
2. CDN은 이 응답을 저장하지 않는다
3. 다음 요청에서 브라우저는 서버에 묻지 않고 캐시를 쓴다
4. 서버가 `304`를 돌려주면 브라우저는 캐시된 본문을 쓴다

---

## 개념 확인 1 정답 : ③

- `no-cache`는 저장은 하되 매번 확인이다. ①은 맞고 ③은 틀렸다
- `private`이 있으니 CDN은 저장하지 않는다(②)
- `ETag`가 있으니 확인 요청에 `If-None-Match: "x1"`가 실리고, `304`면 캐시 본문을 쓴다(④)

> `no-cache`는 "캐시 끔"이 아니다. "쓰기 전에 물어봄"이다.

---

## 개념 확인 2 : 신선도 계산 (Part 2)

브라우저가 12:00:00에 다음 응답을 받았다.

```text
Cache-Control: max-age=300
Age: 120
```

12:03:30에 같은 URL을 요청하면 브라우저는 네트워크에 나가는가?

---

## 개념 확인 2 정답 : 나간다

- 신선 기간 300초에서 `Age` 120초를 뺀 **180초**가 브라우저 몫이다. 12:03:00까지만 신선하다
- 12:03:30은 30초 지났으니 stale이다. 검증자가 없으므로 확인 요청이 아니라 **전체 재요청**이다

> `Age`를 빼고 계산한 사람은 "300초 안이니 캐시"라고 답한다. CDN 뒤에서 이 실수가 자주 나온다.

---

## 중간 점검 2-1

우리 [HTML](https://sandbox-app.kakaopaysec.com/kkb/home)과 [API](https://sandbox-api-gw-neo.kakaopaysec.com/faye-bank/v1/api/section/section-list)의 **GET 200 응답**이다. (2026-09-16 실측)

```text
HTML  Cache-Control: no-store
API   Cache-Control: no-cache, no-store, max-age=0, must-revalidate
```

브라우저의 HTTP 캐시는 이 두 응답의 본문을 저장해도 되는가?

1. 둘 다 저장하고, 재사용 전에 서버에 확인한다
2. HTML은 저장하지 않고, API는 저장 후 매번 확인한다
3. 둘 다 저장하면 안 된다
4. HTML은 저장하고, API만 저장하면 안 된다

<!-- HTML은 2026-09-16 08:51:08 GMT에 GET 200, Content-Type: text/html, Content-Length: 79000, Cache-Control: no-store. API는 Kps-User-Agent: KAKAOBANK_APP;spec=1을 넣어 08:57:55 GMT에 GET 200, Content-Type: application/json, Content-Length: 7950, 위 Cache-Control과 Pragma: no-cache, Expires: 0을 확인했다. 두 요청에 Authorization/Cookie/Cache-Control 요청 헤더를 넣지 않았다. HTTP 캐시만을 묻고 SPA 내부 이동, bfcache, 서비스 워커는 가정에서 제외한다. -->

---

## 중간 점검 2-1 정답 : ③

- 두 응답 모두 **`no-store`**가 있으므로 HTTP 캐시에 저장하면 안 된다
- API에 `no-cache`가 함께 있어도 **`no-store`의 저장 금지**는 사라지지 않는다

**우리 서비스에서는 이렇게 검토하자**

- **HTML** : 개인정보가 없는 공통 앱 셸이라면 **`no-cache` + `ETag`**를 검토한다. 매번 최신 여부는 확인하면서, 안 바뀐 HTML의 전체 재전송을 줄일 수 있다
- **API** : 사용자별 데이터라면 **현재 `no-store`를 유지**한다. 채널별 공통 설정만 내려준다면, 채널을 캐시 키에 넣는 짧은 캐시를 검토할 수 있다

**헤더만으로 개인화 여부는 알 수 없다.** 실제 응답 내용과 저장 요구사항을 확인한 뒤 바꾼다.

<!-- HTML에 민감한 사용자 데이터가 포함된다면 no-store를 유지한다. 사용자마다 다르지만 브라우저 저장은 허용할 수 있는 응답은 private와 재검증 정책을 함께 검토할 수 있다. API는 Kps-User-Agent 채널 헤더가 필요했으므로 공통 응답이어도 채널별 차이를 확인해야 한다. 응답 본문의 사용자별 차이나 저장 요구사항은 이번 헤더 측정으로 검증하지 않았다. 따라서 위 제안은 조건부다. no-cache의 재검증 규칙: https://httpwg.org/specs/rfc9111.html#cache-response-directive.no-cache -->

---

## 중간 점검 2-2

우리 [JS 파일](https://sandbox-remote-app.kakaopaysec.com/home/20260915-64a935ecb135/index.js)의 **GET 200 응답**이다. (2026-09-16 실측)

```text
Cache-Control: max-age=31536000, immutable
Vary: Origin
```

5분 뒤 같은 파일이 다시 필요하다. **사본은 아직 신선하고 브라우저 캐시에 남아 있으며, URL과 요청의 Origin도 같다.** 일반 요청에서 가능한 동작은?

1. `304`를 받아야만 저장한 본문을 쓸 수 있다
2. 서버에 요청하지 않고 저장한 본문을 쓸 수 있다
3. `immutable`이 있으므로 본문을 항상 새로 받아야 한다
4. `Vary`가 있으므로 캐시에 저장할 수 없다

<!-- 최초 GET은 2026-09-16 08:51:09 GMT에 200, Content-Type: application/javascript, Content-Length: 311375, ETag: "a263a94c496084807a70bf17fadd545e", Last-Modified: Tue, 15 Sep 2026 04:54:40 GMT, X-Cache: Miss from cloudfront, X-Amz-Cf-Pop: ICN53-P1, Vary: Origin이었다. 첫 응답에 Age는 없었다. curl 자체에는 브라우저 HTTP 캐시가 없으므로 브라우저 동작은 헤더로 해석한 것이다. 문제에서는 강력 새로고침과 DevTools 캐시 비활성화를 하지 않으며, 같은 캐시 파티션에서 다른 Vary 조건도 일치한다고 가정한다. -->

---

## 중간 점검 2-2 정답 : ②

- **신선 기간 1년** 안이고 `Vary: Origin` 조건도 같으므로, 서버 확인 없이 저장한 본문을 쓸 수 있다
- `immutable`은 신선한 동안 내용이 바뀌지 않는다는 약속이다

**우리 서비스에서는 현재 방향을 유지하자 — 조건은 같은 URL의 내용을 바꾸지 않는 것**

- 새 배포는 **새 버전 경로**에 올리고 참조 URL도 바꾼다. 같은 경로에 덮어쓰면 기존 브라우저는 예전 JS를 계속 쓸 수 있다
- **이전 버전 파일도 보존**한다. 이미 열린 화면이 나중에 옛 청크를 요청해도 404가 나지 않아야 한다

이 조건을 지키면 **반복 다운로드를 줄이면서 새 배포도 새 URL로 전달**할 수 있다.

<!-- 실측 URL에는 버전 경로가 있지만 내용 해시인지, 덮어쓰기가 금지되는지, 이전 파일을 얼마나 보존하는지는 확인하지 않았다. 헤더의 방향은 적절하되 배포 규칙까지 점검해야 한다는 제안이다. https://httpwg.org/specs/rfc8246.html , https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/UpdatingExistingObjects.html -->

---

## 중간 점검 2-3

우리 [메뉴 SVG](https://t1.kakaocdn.net/kakaopay_redbull/resources/home/bank_menu/kp_service_ic_collect_solid_orange_24.svg)의 **GET 200 응답**이다. (2026-09-16 실측)

```text
Cache-Control: max-age=21600
Age: 12880
```

이 응답을 받은 브라우저는 **앞으로 몇 초 동안 서버 확인 없이** 이 사본을 쓸 수 있는가?

전송 지연은 무시하고, 사본은 캐시에 남아 있다고 가정한다.

<!-- 2026-09-16 08:51:08 GMT 실측. Content-Type: image/svg+xml, Content-Length: 392, 위 Cache-Control/Age와 Last-Modified: Thu, 16 Jul 2026 06:48:18 GMT, Expires: Wed, 16 Sep 2026 11:16:28 GMT를 확인했다. ETag는 없었다. Age는 이 시점의 고정 스냅샷이며 다시 요청하면 달라진다. 정확한 신선도 계산에는 Date, 전송 지연, 로컬 체류 시간도 들어가지만 문제에서는 전송 지연을 무시한다. https://httpwg.org/specs/rfc9111.html#calculating.age -->

---

## 중간 점검 2-3 정답 : 약 8720초

```text
총 신선 기간     21600초 = 6시간
이미 지난 시간  12880초
──────────────────────────
남은 신선 기간   8720초 = 2시간 25분 20초
```

- 브라우저에 6시간이 새로 주어지는 것이 아니라, **이미 지난 시간을 뺀 나머지**가 남는다

**우리 서비스에서는 아이콘 교체 방식에 맞추자**

- 파일 교체 때 URL도 바꿀 수 있다면 **내용 해시 파일명 + 긴 `max-age` + `immutable`**을 추천한다. 같은 아이콘의 반복 다운로드를 줄이고, 변경은 새 URL로 바로 구분할 수 있다
- 같은 URL을 꼭 유지해야 한다면 **허용할 수 있는 갱신 지연에 맞춰 TTL을 정한다.** 6시간 안에 교체가 보여야 하는 요구라면 현재 TTL을 줄여야 한다

<!-- 고정 URL의 현재 사본에는 약 2시간 25분이 남았지만, 일반적으로 새 사본은 최대 6시간 동안 확인 없이 쓰일 수 있다. "6시간이면 무조건 잘못"이 아니라 요구하는 갱신 주기와의 일치를 판단한다. TTL을 줄여 배포해도 이미 브라우저에 저장된 사본의 남은 시간은 즉시 바뀌지 않는다. 즉시 전환이 필요하면 참조 URL을 새 경로로 바꿔야 한다. https://httpwg.org/specs/rfc8246.html#introduction -->

---

## 중간 점검 2-4

같은 [SVG 응답](https://t1.kakaocdn.net/kakaopay_redbull/resources/home/bank_menu/kp_service_ic_collect_solid_orange_24.svg)에는 아래 헤더가 있었고, `ETag`는 없었다.

```text
Last-Modified: Thu, 16 Jul 2026 06:48:18 GMT
```

이제 캐시가 낡았다. 브라우저가 **그때 받은 파일이 아직 같은지 확인**하려면, 다음 GET 요청에 어떤 헤더를 넣으면 되는가?

<!-- 직전 문제에서 사용한 2026-09-16 SVG 실측과 같은 응답이다. 검증자 이름만 답하는 데서 끝내지 말고, 실제 요청 헤더 이름과 값을 함께 말하게 한다. -->

---

## 중간 점검 2-4 정답 : If-Modified-Since

```text
If-Modified-Since: Thu, 16 Jul 2026 06:48:18 GMT
```

- 응답에서 받은 **`Last-Modified`의 시각**을 요청의 `If-Modified-Since`로 돌려보낸다
- 변경이 없고 서버가 조건을 처리하면 `304`로 본문을 재사용할 수 있지만, 이번 실측은 **`200`과 본문 392 B**였다

**우리 서비스에서는 조건부 요청이 실제로 처리되는지 점검하자**

- 고정 URL을 유지한다면 **본문 변경 여부와 CDN·오리진의 조건부 요청 처리**를 함께 확인한다. 변경이 없을 때 `304`를 반환하면 본문 전송량을 아낄 수 있다
- 다만 이 아이콘은 **392 B**다. `304`도 왕복은 필요하므로, 이 건의 개선 우선순위는 요청량과 실제 절감 효과를 보고 정한다

검증자 헤더의 존재만으로 최적화가 끝났다고 판단하지 말고, **응답 코드와 전송량까지 확인**한다.

<!-- 2026-09-16 08:52:38 GMT에 위 If-Modified-Since를 보낸 별도 GET은 200과 392 B 본문을 반환했고 응답 Last-Modified도 동일했다. 어느 계층에서 조건이 처리되지 않았는지는 이 응답만으로 확정하지 않는다. 같은 시각 JS의 ETag를 If-None-Match로 보낸 별도 GET은 304, 본문 0 B, X-Cache: Hit from cloudfront, Age: 59였다. https://httpwg.org/specs/rfc9110.html#field.if-modified-since -->

---

## 1편 정리 : 헤더를 읽는 순서

1. **어느 캐시인가?** 브라우저 캐시와 CDN 캐시는 서로 다른 사본이다
2. **저장해도 되는가?** `no-store`는 저장 금지, `private`은 공유 캐시 저장 금지다
3. **확인 없이 써도 되는가?** `max-age`와 `Age`로 신선도를 보고, `no-cache`면 재사용 전에 확인한다
4. **같은 응답인가?** `Vary`와 캐시 키로 사본을 구분하고, ETag나 Last-Modified로 변경 여부를 확인한다
5. **CDN 설정도 맞는가?** 오리진 헤더와 CloudFront의 캐시 키·TTL 정책을 함께 본다

이제 [2편](/slides/cdn-and-http-cache-2)에서 **어떤 파일에 어떤 규칙을 붙일지** 결정한다.

---

## 참고 자료

- [RFC 9111 : HTTP Caching](https://www.rfc-editor.org/rfc/rfc9111) : 저장 조건, 신선도, 재검증의 원문
- [RFC 8246 : immutable](https://www.rfc-editor.org/rfc/rfc8246), [RFC 5861 : stale-while-revalidate](https://www.rfc-editor.org/rfc/rfc5861)
- [MDN : HTTP caching](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Caching) : 지시어별 브라우저 동작 정리
- [CloudFront : Manage how long content stays in the cache](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Expiration.html) : TTL 합성 표와 Min TTL 경고문
- CloudFront : [Managed cache policies](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html), [Control the cache key](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/controlling-the-cache-key.html)

---

# 감사합니다

<!-- _class: invert -->

다음 편에서는 HTML·JS·이미지·API의 정책을 정하고, 배포 후 캐시 문제를 진단한다.

[2편: 전략과 문제 해결 →](/slides/cdn-and-http-cache-2)

@yceffort

---

# OG 스크래핑 서버를 Node.js로 짓는다면 1부: 런타임 선택부터 에러율과 지연까지

Source: https://research.yceffort.kr/slides/og-scraping-server-nodejs-1.md

---
title: 'OG 스크래핑 서버를 Node.js로 짓는다면 1부: 런타임 선택부터 에러율과 지연까지'
marp: true
paginate: true
theme: midnight
tags:
  - nodejs
  - networking
  - caching
date: 2026-08-18
post: https://yceffort.kr/2026/08/og-scraping-server-1
description: '링크 미리보기는 왜 태생적으로 실패율이 높은가부터, 런타임 선택이 갈리는 네 지점, User-Agent와 인코딩, 캐시 스탬피드, 히트율 역산까지'
published: true
art:
  undraw: local-server
---

# OG 스크래핑 서버를 Node.js로 짓는다면

런타임 선택부터 에러율과 지연까지

<!-- _class: invert -->

1부 · 전 2부

@yceffort

---

## 어느 팀의 링크 미리보기

- 기능은 3년째 잘 돌아간다. 그런데 **에러율이 10%를 넘는 시간대가 있다**
- 원인을 물으면 "외부 사이트가 이상해서요"라는 답이 돌아온다
- 개선안은 늘 같다. "캐시를 넣죠"

캐시를 넣으면 정말 나아지는가? **답은 "무엇이 실패하고 있는지에 달렸다"이다.**

> 이 시리즈는 사용법이 아니라 **왜 그렇게 설계해야 하는지**를 다룬다.
> 특히 보안은 "이렇게 막으세요"가 아니라 **"어떻게 뚫리는가"**부터 본다.

---

## 이 시리즈가 답하려는 것

1. 링크 미리보기는 왜 태생적으로 실패율이 높은가 (1부)
2. 이 워크로드에서 **런타임 선택이 실제로 갈리는 지점**은 어디인가 (1부)
3. 한국어 사이트의 인코딩은 왜 아직도 문제인가 (1부)
4. 가져온 값을 그대로 화면에 그려도 되는가 (1부)
5. 캐시는 무엇을 고치고, **무엇을 못 고치는가** (1부)
6. 사용자 입력 URL을 서버가 대신 열어주는 일이 **왜 그렇게 위험한가** (2부)
7. SSRF를 원리대로 막으려면 무엇을 검증해야 하는가 (2부)

---

## 시리즈 구성

| 부      | 제목                              | 장                                                                                     |
| ------- | --------------------------------- | -------------------------------------------------------------------------------------- |
| **1부** | 런타임 선택부터 에러율과 지연까지 | 1장 문제의 지형 · 2장 런타임 선택 · 3장 스크래핑 실무 · 4장 캐싱 · 5장 목표 · 6장 정리 |
| 2부     | SSRF는 어떻게 뚫리는가            | 보안: SSRF를 원리부터                                                                  |

지금 보고 있는 것은 **1부**다. 각 부는 따로 읽어도 되지만, 순서대로 읽으면 근거가 이어진다.

---

## 1부 목차

1. 문제의 지형
2. 런타임 선택은 조건부다
3. 스크래핑 실무
4. 캐싱
5. 목표 수치 세우기
6. 정리

> 1·2장에서는 **아직 코드를 짜지 않는다.**
> 무엇이 실패하는지, 그 실패가 런타임 선택을 어떻게 좁히는지부터 본다.

---

<!-- _class: invert -->

# 1장. 문제의 지형

---

## 링크 미리보기가 하는 일

사용자가 URL을 붙여넣으면, 그 페이지의 제목과 이미지를 카드로 보여준다.

```text
사용자 ──"https://example.com/article" 입력──▶ 우리 서버
                                                  │
                                       ① 그 URL로 HTTP 요청
                                                  ▼
                                            example.com
                                                  │
                                       ② HTML 응답
                                                  ▼
                                       ③ <head>에서 og:* 추출
                                                  │
                                       ④ {title, image, ...} 반환
```

---

## 스펙만 보면 3줄이다

```ts
const html = await fetch(url).then((r) => r.text())
const $ = cheerio.load(html)
const title = $('meta[property="og:title"]').attr('content')
```

동작한다. 데모에서는.

**그리고 이 3줄이 프로덕션에서 무너지는 지점이 이 시리즈의 전부다.**

---

## 무너지는 첫 번째 이유: 상대가 우리 편이 아니다

일반적인 API는 우리가 만든 서버와 통신한다.

스크래핑은 **우리가 통제할 수 없는 남의 서버**와 통신한다.

- 응답이 느려도 항의할 곳이 없다
- 우리를 봇으로 판단하고 차단해도 항의할 곳이 없다
- HTML이 망가져 있어도 고쳐달라 할 수 없다
- 내일 갑자기 구조가 바뀌어도 통보받지 못한다

**외부 의존성 중에서도 가장 통제력이 낮은 종류다.**

---

## 실패의 다섯 얼굴

| 실패        | 겉으로 보이는 것  | 실제 원인                   |
| ----------- | ----------------- | --------------------------- |
| 봇 차단     | `403 Forbidden`   | User-Agent 기반 차단        |
| 로그인 벽   | `200`인데 og 없음 | 인증 요구 (X, Instagram 등) |
| 타임아웃    | 응답 없음         | 상대 서버 지연              |
| 인코딩 깨짐 | 제목이 `���`      | EUC-KR/CP949                |
| 리다이렉트  | `301`에서 멈춤    | 자동 추적 미구현            |

겉으로는 전부 **"에러율 10%"** 라는 하나의 숫자로 뭉쳐 보인다.

> 단, 인코딩 깨짐 중에서도 **멀쩡한 다른 글자로 바뀌는 경우**는 에러로조차 안 잡힌다
> (200 응답 + og 태그 있음 + 값만 틀림). 이 조용한 종류가 왜 무서운지는 3장에서 다룬다.

---

## 여기서 첫 번째 원칙

> **"에러율이 높다"는 증상이지 원인이 아니다.**

원인을 분류하지 않고 처방하면 빗나간다. 그리고 스크래핑에서 가장 흔한 오진이 이것이다.

**"에러율이 높다 → 캐시를 넣자"**

---

## 왜 이 처방이 빗나가는가

캐시는 **같은 URL의 반복 요청**을 줄인다. 그런데,

| 실패 원인                         | 캐시의 효과                             |
| --------------------------------- | --------------------------------------- |
| 같은 URL 반복 요청으로 rate limit | **크다**                                |
| User-Agent 봇 차단                | **거의 없다.** 캐시 미스마다 여전히 403 |
| 로그인 벽                         | **없다**                                |
| 타임아웃                          | **없다.** 첫 요청은 언제나 미스         |
| 인코딩 실패                       | **없다**                                |

실패의 대부분이 아래 네 줄에 몰려 있다면, 캐시를 넣어도 숫자는 거의 그대로다.

---

## 그러면 무엇을 먼저 해야 하는가

**측정이다.** 코드 한 줄로 시작할 수 있다.

```ts
logger.warn('og_scrape_failed', {
  reason, // FORBIDDEN | TIMEOUT | DECODE_FAIL | NO_OG_TAG | ...
  statusCode,
  host: url.hostname, // 전체 URL이 아니라 호스트만 (개인정보)
  elapsedMs,
})
```

이 로그 하루치면 다음이 나온다.

- 실패의 **원인별 비율**
- 실패가 **몇 개 도메인에 몰려 있는지** (대개 상위 5개가 절반이다)

**이 두 숫자 없이 세운 개선 목표는 근거가 없다.**

---

## 이 워크로드의 프로파일

한 번의 스크래핑에서 시간이 어디로 가는지 재보면,

```text
DNS 조회        ~10ms   ┐
TCP + TLS       ~50ms   │
요청 전송        ~1ms   ├─  네트워크 대기 (I/O)   ≈ 98%
응답 대기      ~300ms   │
본문 수신       ~50ms   ┘

HTML 파싱        ~5ms   ┐
og 태그 추출     ~1ms   ├─  CPU 작업              ≈  2%
결과 직렬화     ~0.1ms  ┘
```

**전형적인 I/O 바운드 워크로드다.** 이 사실이 2장의 전제가 된다.

---

## 규모도 같이 봐야 한다

링크 미리보기는 대개 트래픽이 작다. 일 5,000건이라고 하면,

```text
5,000 / 86,400초 ≈ 0.06 TPS
피크를 평균의 10배로 잡아도 < 1 TPS
```

이 숫자를 기억해 두자. 뒤에서 **"별도 서버가 필요한가"**를 판단할 때 다시 쓴다.

> 작은 트래픽에 큰 인프라를 붙이는 것도 설계 실패다.
> 규모를 모르면 과설계인지 알 수가 없다.

---

<!-- _class: invert -->

# 2장. 런타임 선택은 조건부다

---

## 먼저 흔한 주장부터 죽이자

> "스크래핑은 I/O 바운드니까 비동기에 강한 Node.js가 유리하다"

**이건 근거가 되지 못한다.**

- JVM에도 논블로킹 I/O가 있다 (Netty, WebClient, Coroutines)
- Go, Rust는 말할 것도 없다
- 심지어 스레드 풀 기반 블로킹 I/O도 1 TPS에서는 아무 문제가 없다

"비동기라서"는 2010년대 초의 논거다. 지금은 어느 런타임이든 한다.

---

## 반대편 주장도 똑같이 약하다

> "팀에 그 런타임 운영 노하우가 없으니 쓰면 안 된다"

이 논거의 문제는 **비대칭**이다.

- Node를 기각할 때는 "FE 팀에 Node **서버** 운영 경험이 없다"를 든다
- 그런데 대안이 "FE가 JVM 서버에 기여한다"이면, **FE의 JVM 경험 부족**도 같은 무게여야 한다

한쪽에만 적용되는 기준은 기준이 아니라 결론이다.

**양쪽 주장을 치우고 나면, 진짜 갈리는 지점이 보인다.**

---

## 실제로 갈리는 네 지점

1. **소켓 직전까지의 통제권** (보안에 직결)
2. **망가진 HTML을 브라우저처럼 파싱하는가**
3. **인코딩** (여기서는 Node가 오히려 불리하다)
4. **누가 이 코드를 소유하는가**

세 번째를 일부러 넣어 두었다. **자기 편만 세는 비교는 신뢰할 수 없다.**

---

## 분기점 1: 소켓 직전까지의 통제권

2부에서 자세히 다루겠지만, 미리 결론만 말하면 SSRF 방어의 핵심은 이것이다.

> **DNS가 해석한 IP를 우리가 검사하고, 그 IP로 직접 연결해야 한다.**

대부분의 HTTP 클라이언트는 이 사이에 끼어들 틈을 주지 않는다.

Node의 `undici`는 준다.

```ts
new Agent({
  connect: {
    lookup(hostname, options, callback) {
      // 이 함수가 반환하는 IP로 소켓이 연결된다
    },
  },
})
```

---

## 왜 이게 큰 차이인가

JVM에서 같은 것을 하려면 보통 아래 중 하나가 필요하다.

- 커스텀 `DnsResolver` 구현 후 HTTP 클라이언트에 주입
- 또는 `SecurityManager` / 네트워크 계층 우회
- 또는 프록시를 세워서 egress를 강제

**불가능하지 않다.** 다만 "한 함수를 넘기면 된다"와 난이도가 다르다.

> 보안 통제를 **애플리케이션 코드로 표현할 수 있느냐**가
> 실제 구현 여부를 가른다. 어려우면 안 하게 된다.

---

## 분기점 2: 망가진 HTML

스크래핑 대상의 HTML은 대부분 규격에 맞지 않는다.

```html
<meta property="og:title" content="제목 />   <!-- 닫는 따옴표가 없다 -->
<meta property="og:image" content="/hero.png" />
<head>
  <p>head 안에 있으면 안 되는 태그</p>
</head>
```

브라우저는 이런 것도 **정해진 규칙대로** 복구해서 파싱한다.
그 규칙이 WHATWG HTML 파싱 알고리즘이고, `parse5`는 그 준거 구현이다.

정규식으로 og 태그를 뽑는 코드가 흔한데, **위 같은 HTML에서 조용히 틀린 값을 낸다.**

---

## 분기점 3: 인코딩 (Node가 불리한 지점)

한국어 사이트에는 아직도 EUC-KR/CP949가 남아 있다.

JVM에는 CP949 디코더가 **표준 JDK에 들어 있다.**

```java
new String(bytes, Charset.forName("x-windows-949"))  // 확장 문자까지 정상
```

> 엄밀히는 `jdk.charsets` 모듈에 있다. 평소엔 그냥 되지만, jlink로 런타임을 최소화해
> 배포하면 이 모듈이 빠져 에러가 날 수 있다(`--add-modules jdk.charsets`로 넣는다).

Node의 내장 `TextDecoder`는 **여기서 조용히 실패한다.**

```ts
new TextDecoder('euc-kr').decode(bytes) // CP949 확장 문자가 깨진다
```

실측 결과는 3장에 있다. `iconv-lite` 의존성이 **반드시** 필요하다.

> 이건 Node의 단점이 맞다. 감출 이유가 없다.
> 다만 의존성 하나로 해결되고, 순수 JS 구현이라 네이티브 빌드 부담도 없다.

---

## 분기점 4: 누가 소유하는가

링크 미리보기는 **UI 스펙에 붙어 있는 기능**이다.

- 제목이 몇 자에서 잘리는가
- 이미지가 없을 때 무엇을 보여주는가
- `og:title`이 없으면 `<title>`로 대체하는가
- 도메인 이름을 카드에 노출하는가

이 결정은 전부 프론트에서 난다. **변경 주도권이 FE에 있는 코드**를 BE 저장소에 두면, 사소한 문구 변경마다 두 팀이 동기화해야 한다.

---

## 그러나 Node를 고르면 안 되는 조건도 있다

정직하게, 아래 중 **둘 이상 해당하면 Node를 고르지 말아야 한다.**

| 조건                          | 이유                                                                  |
| ----------------------------- | --------------------------------------------------------------------- |
| 사내 표준 런타임이 JVM 하나뿐 | 배포, 시크릿, 로깅 파이프라인을 새로 뚫어야 한다                      |
| 온콜 주체가 BE 조직           | 새벽에 깨는 사람이 못 읽는 코드가 된다                                |
| APM/모니터링이 JVM 전용       | 대시보드와 알람을 이중으로 관리하게 된다                              |
| 보안 검토가 언어별로 나뉨     | 새 언어는 검토 주기가 처음부터 시작된다                               |
| 트래픽이 1 TPS 미만           | 런타임 문제가 아니라 **서버를 따로 둘 근거**가 약하다 (아래 슬라이드) |

---

## 마지막 조건은 특히 중요하다

1장에서 계산한 0.06 TPS를 떠올려 보자.

이 규모에서 흔히 드는 분리 근거들을 검증하면,

- **"스크래핑 CPU가 이벤트 루프를 막는다"**
  → 초당 0.06회의 5ms 파싱이다. 점유율 0.03%. **성립하지 않는다**
- **"모니터링을 분리해야 한다"**
  → 라우트별 메트릭 레이블로 해결된다. **서버를 나눌 필요가 없다**
- **"장애 격리가 필요하다"**
  → 이건 성립한다. 다만 타임아웃과 서킷 브레이커로도 상당 부분 된다

**남는 근거는 조직적 이유다.** 그리고 그건 부끄러운 이유가 아니다.

---

## 판단표

| 상황                                                                 | 권장                  |
| -------------------------------------------------------------------- | --------------------- |
| FE가 소유, 사내에 Node 인프라 있음, SSRF 통제를 코드로 표현하고 싶음 | **Node**              |
| 사내 표준이 JVM, 온콜이 BE, 트래픽 작음                              | **기존 BE 서버 안에** |
| 트래픽 극소, 이미 Next.js 있음, 보안 요구 낮음                       | **분리하지 말 것**    |
| 초당 수백 건, 지연에 민감                                            | Go/Rust도 검토        |

> 결론: **Node가 우월해서 고르는 게 아니다.**
> 위 조건표에서 첫 줄에 해당할 때 고르는 것이다.

---

## 여기까지 확정한 것

1. **"에러율 10%"는 증상이다.** 성질이 다른 다섯 종류의 실패가 한 숫자로 뭉쳐 있다
2. **캐시는 그중 일부만 고친다.** 무엇이 실패하는지 먼저 분류해야 처방이 맞는다
3. 이 워크로드는 **I/O 바운드에 저 TPS**다. 이 프로파일이 런타임 후보를 좁힌다
4. 런타임은 네 지점에서 갈린다. 소켓 직전 통제권, 망가진 HTML 파싱, 인코딩, 소유권
5. **Node는 우월해서 고르는 게 아니라 조건에 맞을 때 고른다**

아직 코드는 한 줄도 짜지 않았다. 그런데 **무엇을 짜야 하는지는 좁혀졌다.**

---

## SSRF는 2부로 미룬다

네 분기점 중 첫 번째로 꼽은 "소켓 직전까지의 통제권"이 **왜 필요한지**는 2부 전체가 다룬다.

- 사용자가 준 URL을 서버가 대신 여는 일이 왜 그렇게 위험한가
- 화이트리스트로 막았다고 믿는 코드가 **어떤 여섯 가지 방법으로 뚫리는가**

이 부의 나머지는 뚫리지 않는 법이 아니라 **되게 만드는 법**이다.

---

<!-- _class: invert -->

# 3장. 스크래핑 실무

---

## User-Agent가 에러율을 가른다

앞에서 "실패 원인을 분류하라"고 했다. 실제로 분류해 보면 **403이 압도적**인 경우가 많다.

많은 사이트가 미리보기 봇에만 OG 태그를 내준다.

```text
facebookexternalhit/1.1
Twitterbot/1.0
Slackbot-LinkExpanding 1.0
Discordbot/2.0
```

기본 UA(`undici`, `node-fetch`)로 요청하면 **차단 대상**이 된다.

---

## 그러면 남의 UA를 쓸 것인가

**정직하게 트레이드오프를 적는다.**

| 선택                       | 얻는 것                            | 잃는 것                                          |
| -------------------------- | ---------------------------------- | ------------------------------------------------ |
| `facebookexternalhit` 사칭 | 성공률이 크게 오른다               | 신원 위조다. 상대가 차단 정책을 바꿀 근거를 준다 |
| 자체 UA + 연락처           | 정직하다. 문제 시 연락받을 수 있다 | 초기 성공률이 낮다                               |

권장은 **자체 UA에 연락처를 넣는 것**이다.

```text
MyPreviewBot/1.0 (+https://example.com/bot)
```

그리고 성공률이 문제라면, **차단하는 상위 도메인을 목록으로 뽑아** 개별 대응하는 편이 낫다. 사칭은 되돌리기 어려운 결정이다.

---

## robots.txt: 미리보기는 크롤링인가

이건 기술 문제가 아니라 **정책 문제**다. 정답이 없다는 것을 인정하고 시작해야 한다.

**크롤링이 아니라는 쪽:**
사용자가 명시적으로 붙여넣은 **단일 URL 한 건**을 가져온다. 링크를 따라 순회하지 않는다. 브라우저의 동작에 가깝다.

**크롤링이라는 쪽:**
사람이 아니라 **서버가 자동으로** 요청한다. 그게 봇의 정의다.

실무 관행은 갈린다. 최소한 아래는 지키자.

- 같은 도메인에 **동시 요청 1건**, 초당 요청 상한
- 실패한 도메인에 **재시도를 반복하지 않는다** (4장에서 다룰 negative cache가 여기서도 쓰인다)
- 조직 차원의 정책으로 문서화한다

> 위는 **상대**를 지키는 상한이다. 우리 서버는 "아무 URL이나 대신 열어주는" 창구라,
> 인증·쿼터 없이 열면 제3자 공격·신원 세탁 통로가 된다. **요청하는 쪽(우리 사용자)에도** 상한을 둔다.

---

## 인코딩: 한국어 사이트의 현실

아직도 EUC-KR/CP949로 서비스되는 사이트가 있다. 공공기관, 오래된 언론사, 커뮤니티에 특히 많다. 커뮤니티 **뽐뿌**는 2026년에도 `charset=euc-kr`로 응답한다.

```ts
const html = buffer.toString('utf-8') // ← EUC-KR이면 전부 깨진다
```

`Buffer.toString()`의 기본값은 UTF-8이다. 이 한 줄이 조용히 실패한다.

**"깨진 제목"은 에러로 잡히지도 않는다.** 200 응답에 og 태그도 있고, 값만 `���`다.

---

## WHATWG sniffing 순서

인코딩을 결정하는 표준 순서가 있다. **감지는 맨 마지막이다.**

| 순위 | 근거                            | 이유                                   |
| ---- | ------------------------------- | -------------------------------------- |
| 1    | **BOM**                         | 바이트로 박혀 있다. 모든 선언을 덮는다 |
| 2    | **HTTP `Content-Type` charset** | 전송 계층의 선언                       |
| 3    | **`<meta charset>` 선스캔**     | 문서 자신의 선언 (앞 1024바이트)       |
| 4    | 휴리스틱 감지 / 기본값          | **추론이다. 틀릴 수 있다**             |

`jschardet` 같은 감지 라이브러리를 1순위에 두는 코드를 자주 보는데, **순서가 뒤집힌 것이다.** 명시적 선언이 있는데 추측할 이유가 없다.

---

## 코드로 옮기면

```ts
function resolveCharset(head: Buffer, contentType?: string): string {
  // 1. BOM
  if (head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf) return 'utf-8'
  if (head[0] === 0xfe && head[1] === 0xff) return 'utf-16be'
  if (head[0] === 0xff && head[1] === 0xfe) return 'utf-16le'

  // 2. HTTP 헤더
  const fromHeader = contentType?.match(/charset\s*=\s*"?([\w-]+)/i)?.[1]
  if (fromHeader) return fromHeader.toLowerCase()

  // 3. 앞 1024바이트를 latin1로 훑는다
  const prescan = head.subarray(0, 1024).toString('latin1')
  const fromMeta = prescan.match(/<meta[^>]+charset\s*=\s*["']?([\w-]+)/i)?.[1]
  if (fromMeta) return fromMeta.toLowerCase()

  // 4. 여기까지 오면 그때 추론한다
  return 'utf-8'
}
```

---

## 왜 하필 `latin1`로 선스캔하는가

닭과 달걀 문제다. **인코딩을 알려면 meta 태그를 읽어야 하는데, meta 태그를 읽으려면 인코딩을 알아야 한다.**

`latin1`이 이 고리를 끊는다.

- 바이트와 문자가 **1:1 대응**한다 (0x00–0xFF → U+0000–U+00FF)
- 어떤 바이트 열이 와도 **예외를 던지지 않는다**
- ASCII 범위가 그대로 보존된다. `<meta charset="euc-kr">`는 전부 ASCII다

즉 **손실 없이 훑어보기만** 하는 용도다. 실제 디코딩은 charset을 확정한 뒤 원본 바이트에 다시 한다.

`utf-8`로 선스캔하면 EUC-KR 바이트가 치환 문자로 바뀌면서 위치가 밀릴 수 있다.

---

## `euc-kr` 라벨의 함정

한국어권에서 반드시 알아야 하는 지점이다.

- **EUC-KR**은 KS X 1001 완성형이다. 한글 2,350자만 표현한다
- **CP949(UHC)**는 그 확장이다. 한글 11,172자 전부를 담는다
- 그런데 현실의 HTML은 **CP949 문자를 쓰면서 `charset=euc-kr`이라고 선언**한다

엄격한 EUC-KR 디코더를 쓰면 "뷁", "똠" 같은 확장 영역 글자가 깨진다.

WHATWG Encoding 표준은 이 현실을 반영해서 `euc-kr` 라벨을 CP949 확장까지 덮도록 정의한다.

**그런데 Node의 내장 `TextDecoder`는 그 정의를 따르지 않는다.**

---

## 실측: Node 24 내장 `TextDecoder`

파이썬으로 CP949 인코딩한 바이트를 `new TextDecoder('euc-kr')`에 넣어 봤다.

| 문자 | CP949 바이트 | 영역           | 결과       |
| ---- | ------------ | -------------- | ---------- |
| 한   | `c7d1`       | KS X 1001 기본 | `한` ✅    |
| 글   | `b1db`       | KS X 1001 기본 | `글` ✅    |
| 뷁   | `94ee`       | CP949 확장     | `` **❌**  |
| 똠   | `8c63`       | CP949 확장     | `c` **❌** |
| 꼃   | `8458`       | CP949 확장     | `X` **❌** |
| 펲   | `bc84`       | CP949 확장     | `` **❌**  |

`windows-949`, `ks_c_5601-1987` 라벨도 **결과가 같다.** `cp949` 라벨은 `RangeError`를 던진다.

**`똠`이 `c`가 되는 게 특히 고약하다.** 깨진 글자(``)가 아니라 **멀쩡해 보이는 다른 글자**가 된다. 로그를 봐도 이상한 줄 모른다.

---

## 그래서 `iconv-lite`가 필요하다

같은 바이트를 `iconv-lite`에 넣으면 전부 복원된다.

```ts
import iconv from 'iconv-lite'

function decode(bytes: Buffer, charset: string): string {
  if (iconv.encodingExists(charset)) {
    return iconv.decode(bytes, charset) // 'euc-kr', 'cp949' 둘 다 CP949로 처리
  }
  return iconv.decode(bytes, 'utf-8') // 모르는 라벨이면 후퇴
}
```

`charset="unicode"`, `charset="ks_c_5601-1987"` 같은 값이 실제로 존재하므로
**`encodingExists` 검사는 생략하면 안 된다.**

> 2장에서 "Node가 불리한 지점"이라고 했던 게 이 자리다.
> JVM은 `Charset.forName("x-windows-949")`로 표준 라이브러리 안에서 끝난다.

---

## 이 버그가 무서운 이유

정리하면 이렇다.

```text
① 응답은 200이다
② og 태그도 정상적으로 있다
③ 파싱도 성공한다
④ 제목만 "똠방각하" 대신 "c방각하"가 된다
```

**어떤 계층에서도 에러가 발생하지 않는다.**

에러율 대시보드는 깨끗하고, 알람도 울리지 않는다.
사용자가 제보하기 전까지 아무도 모른다.

> 4장에서 "커버리지를 따로 재라"고 말할 텐데, 그 이유가 이것이다.
> **성공 응답 중에도 실패가 숨어 있다.**

---

## `<head>`만 읽고 끊는다

og 태그는 **대부분** `<head>` 안에 있다. `</head>`가 나오면 멈춰 본문 전체를 안 받는다.
트리를 만드는 `parse5`(2장) 대신, 조각을 그때그때 읽어 멈출 수 있는 **htmlparser2**(스트리밍 파서)를 쓴다.

```ts
import {Parser} from 'htmlparser2'
let headDone = false
const parser = new Parser({
  onopentag(name, attribs) {
    if (name === 'meta' && attribs.property?.startsWith('og:'))
      result[attribs.property] = attribs.content
  },
  onclosetag(name) {
    if (name === 'head') headDone = true
  },
})
```

---

## 그리고 그 자리에서 수신을 끊는다

`</head>`를 만나는 순간 소켓을 닫는다. 남은 본문은 아예 받지 않는다.

```ts
for await (const chunk of res.body) {
  parser.write(decode(chunk))
  if (headDone) {
    res.body.destroy()
    break
  }
}
```

---

## `pause()`로는 다운로드가 안 멈춘다

**`parser.pause()`만으로는 다운로드가 안 멈춘다.** 파서가 콜백을 잠시 멈출 뿐,
서버가 보내는 바이트는 계속 쌓인다. 진짜로 멈추려면 위처럼 응답 스트림을 `destroy()`로 끊어야 한다.

효과가 둘이다.

- **속도**: 대부분의 페이지에서 앞 몇 KB만 읽으면 끝난다
- **안전**: 응답 크기 상한에 도달할 일이 줄어든다

단, **트레이드오프**다. og가 (드물게) `<head>` 밖으로 밀려난 페이지에서는 일찍 끊으면 그 값을 놓친다
4장의 "커버리지"를 조금 깎는 셈이다. 커버리지가 더 중요하면 상한(512KB)까지 계속 읽는다.

> (EUC-KR 같은 멀티바이트 글자가 조각 경계에 걸릴 수 있어, 조각마다 바로 디코딩하면 깨진다.
> 실무에선 head 구간을 모아 한 번에 디코딩하거나 `iconv` 스트림 디코더를 쓴다. 여기선 단순화했다.)

---

## 여기까지는 "가져오는" 방향이었다

2부에서 지킬 것은 **우리 서버가 나가는** 방향이다. 남이 준 URL로 아무 데나 요청하지 않도록.

이제 방향이 반대다. **가져온 값이 우리 화면으로 들어온다.**

```text
[2부]  사용자 URL ──▶ 우리 서버 ──▶ 외부 사이트     (나가는 방향: SSRF)
[여기] 외부 사이트 ──▶ 우리 서버 ──▶ 사용자 브라우저 (들어오는 방향)
```

두 방향은 위험의 성격이 다르다. 그런데 이유는 같다.

> **상대는 우리 편이 아니다.**

---

## 스크랩한 값은 사용자 입력이다

`og:title`을 누가 정하는가. **우리가 요청한 그 사이트다.**

- 그 사이트는 아무 문자열이나 넣을 수 있다
- 우리는 그 문자열을 받아서 **우리 도메인의 화면에 그린다**
- 즉 남이 쓴 글자가 우리 페이지 안에서 살아난다

폼 입력값을 검증하는 습관은 다들 있다. 그런데 스크래핑 결과는 "데이터를 가져온 것"처럼 느껴져서 그 습관이 잘 작동하지 않는다.

> **`og:title`은 API 응답이 아니라 사용자 입력이다.**
> 입력 필드에 넣지 않았을 뿐, 신뢰도는 똑같다.

---

## 파서가 이미 디코딩해서 준다

여기서 오해가 하나 생긴다. "HTML 속성에 들어 있으니 `&lt;script&gt;` 형태로 오겠지"라는 생각이다.
**아니다.** 4장의 그 `htmlparser2`가 엔티티를 풀어서 준다.

```ts
const html =
  '<meta property="og:title" content="A &amp; B &lt;script&gt; &#48156;">'
// onopentag 에서 attribs.content 를 그대로 받으면
```

| 원본 HTML        | 우리가 받는 값 |
| ---------------- | -------------- |
| `A &amp; B`      | `A & B`        |
| `&lt;script&gt;` | `<script>`     |
| `&#48156;`       | `발`           |

> htmlparser2 `12.0.0` 기준이고 `decodeEntities`가 **기본으로 켜져 있다.**
> 꺾쇠는 이미 진짜 꺾쇠다. **값 안에 태그가 들어 있는 상태로 우리 손에 온다.**

---

## 그러면 옵션을 끄면 되는가

`decodeEntities: false`로 끄면 `A &amp; B`가 그대로 남는다. 그런데 이건 해법이 아니다.

- 정상적인 제목 `삼성 & LG`가 사용자에게 **`삼성 &amp; LG`로 보인다**
- 화면에 그리는 자리가 HTML이 아닐 수도 있다 (모바일 앱, 슬랙 카드, 푸시 알림)

**받는 단계에서 형태를 비틀어 막으려는 시도는 거의 항상 다른 곳을 망가뜨린다.**

> 원칙은 이렇다. **막는 자리는 검증하는 자리가 아니라 쓰는 자리다.** 2부에서도 같은 원칙이 반복된다.
> 값은 디코딩된 원문 그대로 들고 있다가, **화면에 넣는 순간** 그 자리에 맞게 처리한다.

---

## "React 쓰는데 안전하지 않나요"

절반만 맞다. React·Vue 같은 프레임워크는 **텍스트 자리**를 자동으로 이스케이프한다.

```tsx
<h3>{og.title}</h3> // 안전. <script>는 글자로 보인다
```

문제는 자동으로 처리되지 않는 자리가 생각보다 많다는 것이다.

```tsx
<div dangerouslySetInnerHTML={{__html: og.title}} />   // 위험
<a href={og.url}>                                      // 스킴 검사 필요
<img src={og.image} />                                 // 아래에서 따로 본다
```

그리고 **화면 밖**은 프레임워크의 보호가 아예 닿지 않는다.

- 서버에서 문자열로 조립하는 이메일 HTML, 슬랙·디스코드 카드
- OG 이미지를 SVG로 만들어 굽는 코드
- `innerHTML`을 직접 쓰는 레거시 화면

> "우리는 React를 쓴다"는 답이 아니다. **그 값이 지나가는 자리를 전부 세어 봤는가**가 답이다.

---

## 자리마다 규칙이 다르다

같은 문자열이라도 어디에 넣느냐에 따라 위험한 글자가 달라진다.

| 넣는 자리           | 예                     | 필요한 처리                              |
| ------------------- | ---------------------- | ---------------------------------------- |
| HTML 텍스트         | `<h3>여기</h3>`        | `< > & " '` 이스케이프 (프레임워크 담당) |
| HTML 속성           | `<img alt="여기">`     | 이스케이프 + 반드시 따옴표로 감싸기      |
| URL 자리            | `<a href="여기">`      | **스킴 검사** (이스케이프로는 부족)      |
| 쿼리스트링          | `?q=여기`              | `encodeURIComponent`                     |
| JSON 안에 넣어 전달 | `<script>window.__D=…` | 자리 자체를 피한다                       |

주니어 시절 가장 헷갈리는 지점이 세 번째다. `javascript:alert(1)`은 **이스케이프해도 여전히 `javascript:`다.** 특수문자가 없기 때문이다.

> 이스케이프는 "글자를 글자로 만드는" 처리다. **주소가 주소로 해석되는 문제는 못 막는다.**

---

## `og:image`는 문자열이 아니라 주소다

`og:image`는 화면에 그리기 전에 한 번 더 생각할 값이다. 이유가 셋이다.

**1. 스킴이 http/https라는 보장이 없다**
`data:`로 수십 MB짜리 이미지를 박아 넣을 수 있다. 상대 경로면 최종 URL 기준으로 풀어야 한다.

**2. 그대로 브라우저에 넘기면 상대 서버가 사용자를 본다**
우리 페이지를 여는 모든 사용자의 IP와 User-Agent가 그 사이트 로그에 남는다.

**3. 그렇다고 우리 서버가 대신 받아오면 2부의 문제가 그대로 재현된다**
이미지 프록시는 **사용자 입력 URL을 서버가 여는 일**이다. 2부의 검증을 통째로 다시 적용해야 한다.

> 셋 중 무엇을 고르든 답이다. 다만 **고르지 않고 넘어가는 것**은 답이 아니다.
> (이 시리즈는 재호스팅을 비목표로 두었다. 그렇다면 1번과 2번은 명시적으로 정해야 한다.)

---

## 길이와 개수도 값이다

마지막으로 사소해 보이지만 실제로 겪는 것들이다.

- `og:title`이 **수백 KB**로 오는 경우가 있다. 저장 전에 자른다 (200자 안팎이면 충분하다)
- 같은 `og:` 태그가 여러 번 나오면 어느 것을 쓸 것인가. **먼저 나온 것**으로 정하고 문서화한다
- 값이 빈 문자열인 경우와 태그 자체가 없는 경우를 구분한다. 4장의 negative caching에서 둘의 수명이 다르다
- 제어문자와 줄바꿈을 걸러낸다. 로그와 카드 레이아웃이 같이 깨진다

> 자르는 위치도 조심한다. **UTF-16 기준으로 자르면 이모지가 반토막 난다.**

---

## 가져온 값을 다루는 원칙

- [ ] 스크랩 결과를 **사용자 입력과 같은 등급**으로 취급한다
- [ ] 파서가 엔티티를 디코딩해 준다는 사실을 전제로 코드를 읽는다
- [ ] 받는 단계에서 비틀지 않고, **쓰는 자리에서** 그 자리에 맞게 처리한다
- [ ] 값이 지나가는 자리를 전부 센다 (화면, 이메일, 슬랙 카드, OG 이미지, 로그)
- [ ] URL 자리는 이스케이프가 아니라 **스킴 검사**로 막는다
- [ ] `og:image`를 프록시할 것인지 정하고, 프록시한다면 2부 검증을 그대로 적용한다
- [ ] 길이 상한과 중복 태그 규칙을 정한다

> 2부에서는 41장을 들여 "남이 준 URL을 믿지 말라"고 말하게 된다.
> **그 URL이 돌려준 답도 남이 쓴 것이다.**

---

<!-- _class: invert -->

# 4장. 캐싱

---

## 캐시는 무엇을 고치고 무엇을 못 고치는가

1장에서 미뤄둔 이야기를 정리한다.

| 지표                                            | 캐시의 효과         |
| ----------------------------------------------- | ------------------- |
| 외부 서버로 나가는 요청 수                      | **크게 준다**       |
| 캐시 히트 시 응답 시간                          | **수백 ms → 수 ms** |
| API 전체 에러율 (실패응답 / 전체요청)           | **부분적** (아래)   |
| **URL 커버리지** (미리보기 성공 URL / 시도 URL) | **개선되지 않는다** |

에러율 칸이 "부분적"인 이유: 캐시는 **같은 URL이 반복돼서 생기는 실패**(rate limit, 일시적 장애)만 줄인다.
403·로그인 벽·인코딩처럼 그 URL이면 늘 실패하는 것은, negative caching으로 캐시해도 여전히 실패 응답이라 에러율이 내려가지 않는다(1장 표 참조).

마지막 줄이 핵심이다. **처음 보는 URL은 언제나 캐시 미스**이고, 그때 실패하면 사용자는 여전히 깨진 카드를 본다.

---

## 그래서 목표를 두 개로 쪼개야 한다

```text
① API 에러율     = 실패 응답 / 전체 API 요청     ← 반복 요청발 실패만 캐시로 개선
② 커버리지       = 성공한 고유 URL / 시도한 고유 URL  ← 스크래핑 품질로 개선
```

①만 목표로 잡으면, 캐시 히트율이 오르는 것만으로 숫자가 좋아진다.
**사용자 경험은 그대로인데 지표만 좋아지는** 전형적인 함정이다.

②를 올리려면 UA 조정, 리다이렉트 처리, 인코딩 대응처럼 **3장의 것들**을 해야 한다.

---

## 캐시 스탬피드 정의를 바로잡자

흔히 이렇게 설명한다.

> "캐시가 만료되어 **DB**에 요청이 몰리는 현상"

스크래핑 서버에서 뒷단은 DB가 아니라 **외부 사이트**다. 정확히는 이렇다.

> **어떤 캐시 키가 만료되는 순간, 그 키를 기다리던 동시 요청이 전부 원본으로 나가는 현상**

인기 있는 링크(예: 방금 올라온 뉴스)일수록 심하다. **그리고 상대는 그걸 공격으로 인식한다.**

---

## 스탬피드가 왜 특히 나쁜가

```text
t=0     캐시 만료
t=0ms   요청 50건 도착 → 전부 미스 → 전부 외부로 나간다
        │
        ├─ 외부 서버: "같은 IP에서 50건? 차단"
        ├─ 429 또는 403 응답
        └─ 50건 전부 실패 + 실패를 캐시 안 하면 다음 초에 또 50건
```

**캐시를 넣었는데 에러율이 오르는** 상황이 실제로 생긴다.
그래서 캐시는 넣는 것보다 **어떻게 넣는가**가 중요하다.

> 1장의 평시 규모(0.06 TPS)로는 이런 순간이 잘 안 온다. 문제는 평균이 아니라
> **인기 링크 하나에 트래픽이 확 몰리는 순간**(바이럴)이다. 위의 "50건"은 그 순간을 그린 것이다.

---

## 그 전에: 캐시 키를 정한다

캐시를 논하기 전에 **"같은 URL"이 무엇인지** 먼저 정해야 한다.
아래는 사람 눈엔 같은 페이지지만, 문자열로는 전부 다르다.

```text
https://Example.com/a?b=1&c=2
https://example.com/a?c=2&b=1
https://example.com/a?b=1&c=2#section
https://example.com/a?b=1&c=2&utm_source=twitter
```

정규화 없이 URL을 그대로 키로 쓰면 이 넷이 **각각 따로 캐시**된다 → 히트율이 뚝 떨어지고,
single-flight도 "같은 키"로 못 묶어 스탬피드 방어가 헐거워진다.

---

## 캐시 키, 최소한 이 정도는 맞춘다

- `#fragment` 제거 (서버 응답과 무관하다)
- 호스트 소문자화
- `utm_*` 같은 **알려진** 추적 파라미터 제거 (임의 파라미터는 내용이 달라질 수 있으니 건드리지 않는다)
- 쿼리 파라미터 정렬

> 2부에 나올 "주소 정규화를 직접 하지 말라"와 헷갈리지 말 것.
> **보안 검사에는 원문을**(BlockList가 판단), **캐시 키에는 정규화본을** 쓴다. 목적이 다르니 규칙도 다르다.

---

## 대응 1: single-flight

같은 키의 동시 요청 중 **하나만** 원본으로 보내고, 나머지는 그 결과를 공유한다.

```ts
const inflight = new Map<string, Promise<OgResult>>()

function once(key: string, fn: () => Promise<OgResult>) {
  const running = inflight.get(key)
  if (running) return running // 이미 누가 가져오는 중이다

  const p = fn().finally(() => inflight.delete(key))
  inflight.set(key, p)
  return p
}
```

**가장 싸고 가장 효과가 크다.** 50건이 1건이 된다.

한계: **프로세스 안에서만** 동작한다(인스턴스 N개면 최대 N건). 그리고 대표 1건이 실패하면
**대기하던 나머지도 같은 실패를 받는다**. 실패 공유가 싫으면 "성공만 공유"하는 변형을 쓴다.

---

## 대응 2: stale-while-revalidate

만료된 값을 **일단 돌려주고**, 갱신은 백그라운드에서 한다.

```text
요청 도착
   │
   ├─ 캐시에 값이 있다 (만료됨)
   │     ├─ 즉시 반환 (사용자 대기 0ms) ✓
   │     └─ 백그라운드로 갱신 시작
   │
   └─ 캐시에 값이 없다
         └─ 원본에서 가져온다 (여기서만 기다린다)
```

OG 데이터는 **몇 분 낡아도 아무 문제가 없다.** 이 워크로드에 가장 잘 맞는 전략이다.

single-flight과 함께 쓰면, 백그라운드 갱신도 키당 1건으로 묶인다.

---

## 대응 3: negative caching

**실패도 캐시해야 한다.** 이게 빠지면 실패 URL이 매 요청마다 외부로 나간다.

에러율이 10%인 상황에서 이건 큰 누수다.

```ts
function ttlFor(result: OgResult): number {
  if (result.ok) return 60 * 60 // 성공: 1시간

  switch (result.reason) {
    case 'NOT_FOUND':
      return 60 * 30 // 404는 잘 안 바뀐다
    case 'FORBIDDEN':
      return 60 * 10 // 봇 차단. UA/차단 정책이 바뀌면 풀릴 수 있어 404보다 짧게
    case 'RATE_LIMITED':
      return result.retryAfter ?? 60 // Retry-After를 존중한다
    case 'TIMEOUT':
      return 30 // 일시적일 수 있으니 짧게
    default:
      return 60
  }
}
```

---

## 왜 실패 종류별로 TTL을 나누는가

전부 같은 TTL을 주면 **둘 중 하나가 잘못된다.**

- 짧게 통일하면 → `404`처럼 잘 안 바뀔 실패를 계속 재시도한다
- 길게 통일하면 → 일시적 타임아웃 때문에 멀쩡한 링크가 오래 죽어 있다

**실패의 성질이 다르니 수명도 달라야 한다.** 1장에서 실패를 원인별로 분류하라고 한 것이 여기서 쓰인다.

> 원인 분류는 관측만을 위한 게 아니다. **설계 결정의 입력**이다.

---

## 로컬 캐시가 무너지는 지점

`Map` 하나로 만든 인메모리 캐시는 인스턴스가 하나일 때만 잘 동작한다.

인스턴스가 N개로 늘면,

| 항목                             | 인스턴스 1개 | 인스턴스 N개 (균등 라우팅)         |
| -------------------------------- | ------------ | ---------------------------------- |
| 같은 URL이 같은 캐시를 만날 확률 | 100%         | 약 1/N                             |
| 외부로 나가는 요청               | 1건          | 최대 N건                           |
| 배포 시                          | 캐시 소실    | 캐시 소실                          |
| 오토스케일 아웃                  | 해당 없음    | 새 인스턴스는 **캐시가 비어 있다** |

**"인메모리 캐싱을 적용한다"와 "다중 인스턴스로 운영한다"는 같이 쓸 때 서로를 갉아먹는다.**

---

## 그러면 항상 Redis인가

아니다. **판단 기준은 인스턴스 수와 고유 URL 분포다.**

| 상황                        | 권장                                     |
| --------------------------- | ---------------------------------------- |
| 인스턴스 1~2개, 트래픽 작음 | **로컬 캐시로 충분하다.** Redis는 과설계 |
| 인스턴스 3개 이상           | 분산 캐시를 검토                         |
| 인기 URL이 소수에 집중      | **2계층** (로컬 + Redis)이 가장 효율적   |
| 배포가 잦음                 | 분산 캐시 (로컬은 배포마다 비워진다)     |

2계층 구성이 실무에서 가장 흔하다. 로컬이 hot key를 흡수하고, Redis가 나머지를 받는다.

---

<!-- _class: invert -->

# 5장. 목표 수치 세우기

---

## "P95 1초 미만"이라는 목표

흔히 이렇게 목표를 쓴다.

> 응답 시간을 P95 기준 1초 미만으로 줄인다

**나쁜 목표는 아니다. 다만 달성 가능한지 계산해 본 적이 있는가?**

계산할 수 있다. 필요한 건 **캐시 히트율 하나**다.

---

## P95를 히트율에서 역산한다

캐시 히트는 항상 빠르다고 하자 (약 5ms). 히트율을 `h`라 하면,

전체 요청을 지연 순으로 정렬했을 때 **앞쪽 `h` 비율이 히트 구간**이다.

**경우 1: `h > 0.95`**
95번째 백분위가 히트 구간 안에 들어온다. **P95 ≈ 5ms.** 목표는 자동 달성된다.

**경우 2: `h < 0.95`**
P95는 미스 구간에 있다. 미스 분포에서 몇 번째 백분위인가?

```text
q = (0.95 - h) / (1 - h)
```

---

## 역산표

`q`는 **외부 서버 응답 시간 분포에서 우리가 만족시켜야 하는 분위수**다.

| 히트율 `h` | 필요한 분위수 `q` | 무엇을 만족해야 하는가                          |
| ---------- | ----------------- | ----------------------------------------------- |
| 0.96 이상  | 해당 없음         | **목표 자동 달성** (P95가 히트 구간 안에 있다)  |
| 0.95       | 경계              | 근소한 변동으로 미스 구간에 걸린다. 여유가 없다 |
| 0.90       | 0.500             | 외부 응답 **중앙값** < 1s                       |
| 0.80       | 0.750             | 외부 **P75** < 1s                               |
| 0.70       | 0.833             | 외부 **P83** < 1s                               |
| 0.50       | 0.900             | 외부 **P90** < 1s                               |

200만 건 시뮬레이션에서 실측과 **오차 0.3% 이내**로 일치한다.

> 오른쪽 칸은 상대 서버의 응답 시간, 즉 **우리가 통제할 수 없는 값**이다.
> "P95 1초"를 약속하기 전에 **히트율을 약속할 수 있는지** 먼저 물어야 한다.

---

## 그러면 히트율은 어떻게 추정하는가

히트율은 **고유 URL 비율(unique ratio)**에서 나온다.

```text
히트율 ≈ 1 - (TTL 기간 내 고유 URL 수 / TTL 기간 내 전체 요청 수)
```

이 숫자는 **지금 당장 측정할 수 있다.** 액세스 로그에서 URL을 해시해 세기만 하면 된다.

```bash
# TTL을 1시간으로 잡았을 때의 상한 추정
# $7 = URL이 들어 있는 칸 번호. 자기 로그 포맷에 맞는 번호로 바꿀 것
cat access.log | grep og_scrap | awk '{print $7}' \
  | sort | uniq -c | awk '{total+=$1; uniq++} END {print 1 - uniq/total}'
```

**이 한 줄을 돌려보기 전에 P95 목표를 정하면 안 된다.**

---

## 목표는 이렇게 쓴다

**나쁜 목표**

> 에러율을 5% 미만으로, P95를 1초 미만으로 줄인다

**좋은 목표**

> **선행 측정**: 실패 원인별 비율, 상위 실패 도메인, 1시간 TTL 기준 고유 URL 비율
>
> **목표 1 (커버리지)**: 시도한 고유 URL 중 미리보기 성공 비율을 현재 `X%`에서 `Y%`로.
> 주 수단은 UA 조정과 리다이렉트 처리 (캐시가 아니다)
>
> **목표 2 (지연)**: 캐시 히트율 `0.85` 달성 시 P95 `Z ms`.
> 히트율이 `0.8` 미만이면 P95 목표를 재설정한다
>
> **비목표**: 동적 렌더링 지원, 이미지 재호스팅

차이는 **숫자가 아니라 근거의 유무**다.

---

<!-- _class: invert -->

# 6장. 정리

---

## 설계 체크리스트

**보안 (2부)**

- [ ] 스킴, 포트, URL 자격증명 검사
- [ ] DNS 해석 IP를 대역 검사 (IPv4 + IPv6)
- [ ] **주소 정규화를 직접 하지 않기** (`BlockList`에 원문 그대로 넘긴다)
- [ ] **검사한 IP로 직접 연결** (`lookup` 훅)
- [ ] `fetch()` 대신 `undici.request()` (리다이렉트 기본 동작이 다르다)
- [ ] 리다이렉트 홉마다 전체 검증 반복
- [ ] 응답 크기 상한 (실제 바이트 카운트)
- [ ] 단계별 타임아웃 + 전체 상한
- [ ] 보안그룹 아웃바운드 차단, IMDSv2 강제
- [ ] **우회 케이스를 테스트로 명세화** (16진 IPv4-mapped, NAT64, 경계값)

---

## 설계 체크리스트 (이어서)

**스크래핑 (3장)**

- [ ] UA 정책 결정 (사칭 여부를 문서화)
- [ ] BOM → HTTP charset → meta 선스캔 → 추론 순서
- [ ] **`iconv-lite` 사용** (내장 `TextDecoder`는 CP949 확장을 못 읽는다)
- [ ] `encodingExists`로 미지 라벨 폴백
- [ ] `</head>`에서 파싱 중단
- [ ] **스크랩 결과를 사용자 입력과 같은 등급으로 취급** (쓰는 자리에서 처리)
- [ ] 값이 지나가는 자리를 전부 세기 (화면, 이메일, 카드, OG 이미지, 로그)
- [ ] URL 자리는 스킴 검사, `og:image` 프록시 여부 결정
- [ ] 길이 상한과 중복 태그 규칙

---

## 설계 체크리스트 (이어서)

**캐싱 (4장)**

- [ ] single-flight
- [ ] stale-while-revalidate
- [ ] **negative caching** (실패 종류별 TTL)
- [ ] 인스턴스 수에 맞는 캐시 계층 선택

**목표 (5장)**

- [ ] 실패 원인별 비율을 먼저 측정
- [ ] 고유 URL 비율에서 히트율 추정
- [ ] 에러율을 API 에러율과 커버리지로 분리
- [ ] P95 목표를 히트율에서 역산해 검증

---

## 언제 Node를 고르지 말아야 하는가

이 시리즈는 Node를 권하지 않는다. **조건을 권한다.**

**고를 만한 조건**

- FE 조직이 이 기능을 소유하고, 변경이 잦다
- 사내에 Node 배포/모니터링 경로가 이미 있다
- SSRF 통제를 애플리케이션 코드로 명시하고 싶다

**고르지 말아야 할 조건** (아래 중 여럿이 겹칠 때)

- 사내 표준 런타임이 하나뿐이고 그게 JVM이다
- 새벽에 깨는 사람이 BE 조직이다
- **트래픽이 1 TPS 미만이다**. 정확히는 Node를 버리라는 게 아니라 **서버를 따로 두지 말라**는 신호다

마지막 줄이 가장 자주 무시된다. **분리하지 않는 것도 선택지다.**

---

## 마지막으로

이 시리즈에서 반복된 형태가 하나 있다.

> **증상을 원인으로 착각하지 말 것**

- "에러율이 높다"는 증상이다. 원인은 403일 수도, 인코딩일 수도 있다
- "느리다"는 증상이다. 원인은 히트율일 수도, 외부 응답일 수도 있다
- "Node가 좋다/나쁘다"는 결론이다. 조건이 근거다
- "화이트리스트로 막는다"는 선언이다. **어떤 우회를 막는지**가 근거다 (2부)

**근거를 적을 수 없는 결정은 아직 결정이 아니다.**

---

## 이 시리즈의 주장은 실측했다

글을 쓰고 나서 코드를 전부 돌려봤고, **내가 처음에 쓴 것 중 넷이 틀렸다.**
그중 셋은 2부(SSRF)에 있고, 이 부의 것은 이 하나다.

| 처음에 쓴 것                            | 실측 결과                                        |
| --------------------------------------- | ------------------------------------------------ |
| 내장 `TextDecoder('euc-kr')`면 충분하다 | **CP949 확장이 깨진다.** `iconv-lite`가 필요하다 |

검증 환경은 Node `v24.14.1`, undici `8.10.0`, htmlparser2 `12.0.0`이다.

> **이게 이 시리즈가 하려는 말 그 자체다.**
> 그럴듯한 코드와 동작하는 코드는 다르다. 차이는 **돌려봤는가**이다.

---

## 직접 재현하려면

```bash
npm i iconv-lite htmlparser2

# CP949 확장 문자
node -e "const b=Buffer.from('94ee','hex');
console.log('TextDecoder:',new TextDecoder('euc-kr').decode(b));
console.log('iconv-lite :',require('iconv-lite').decode(b,'euc-kr'))"
```

기대 출력은 각각 ``와 `뷁`이다.
**앞은 에러를 내지 않는다.** 200 응답에 og 태그도 멀쩡하고, 값만 틀린다.

```bash
# 파서가 엔티티를 디코딩해서 넘긴다
node -e "const{Parser}=require('htmlparser2');let v;
new Parser({onopentag(n,a){if(n==='meta')v=a.content}})
  .end('<meta property=\"og:title\" content=\"&lt;script&gt;\">');
console.log(JSON.stringify(v))"
```

출력은 `"<script>"`다. **꺾쇠가 이미 진짜 꺾쇠다.**

---

## 2부에서 이어지는 것

2장에서 "소켓 직전까지의 통제권"을 첫 번째 분기점으로 꼽고 답을 미뤄뒀다.
그 통제권이 **왜 필요한지**가 2부의 내용이다.

- 사용자가 준 URL을 서버가 대신 여는 일이 왜 그렇게 위험한가
- 화이트리스트로 막았다고 믿는 코드가 **어떤 여섯 가지 방법으로 뚫리는가**
- 그것을 원리대로 막으려면 무엇을 검증해야 하는가

> **2부. SSRF는 어떻게 뚫리는가**

---

## 참고

**표준**

- WHATWG HTML Standard, _Determining the character encoding_
- WHATWG Encoding Standard, _Legacy multi-byte Korean encodings_
- WHATWG URL Standard
- OWASP, _Cross Site Scripting Prevention Cheat Sheet_ (자리별 이스케이프 규칙)

**라이브러리**

- `undici` Dispatcher / Agent 문서 (`connect.lookup`)
- `iconv-lite`
- `parse5`, `htmlparser2`

**캐싱**

- Vattani et al., _Optimal Probabilistic Cache Stampede Prevention_ (VLDB 2015)
  본문에 없는 확률적 조기 갱신(XFetch). 다중 인스턴스에서 single-flight를 보완한다.
- RFC 5861 (`stale-while-revalidate`)

---

# OG 스크래핑 서버를 Node.js로 짓는다면 2부: SSRF는 어떻게 뚫리는가

Source: https://research.yceffort.kr/slides/og-scraping-server-nodejs-2.md

---
title: 'OG 스크래핑 서버를 Node.js로 짓는다면 2부: SSRF는 어떻게 뚫리는가'
marp: true
paginate: true
theme: midnight
tags:
  - security
  - networking
  - nodejs
date: 2026-08-18
post: https://yceffort.kr/2026/08/og-scraping-server-2
description: '사용자가 준 URL을 서버가 대신 여는 일의 위험, 화이트리스트를 뚫는 우회 여섯 가지와 그것을 막는 방어 원리 다섯 개'
published: true
art:
  undraw: firewall
---

# OG 스크래핑 서버를 Node.js로 짓는다면

SSRF는 어떻게 뚫리는가

<!-- _class: invert -->

2부 · 전 2부

@yceffort

---

## 1부에서 여기까지 왔다

- 링크 미리보기의 "에러율 10%"는 증상이고, 성질이 다른 실패 다섯 종류가 뭉쳐 있었다
- 이 워크로드는 I/O 바운드에 저 TPS라, 런타임 후보가 좁혀졌다
- 런타임이 갈리는 첫 번째 지점으로 **"소켓 직전까지의 통제권"**을 꼽았다

그 통제권이 왜 필요한가. **이 부가 그 답이다.**

> 1부를 읽지 않아도 된다. 필요한 전제는 이 한 줄이다.
> **우리 서버는 사용자가 준 URL을 대신 열어 준다.**

---

## 이 부가 답하려는 것

1. 사용자 입력 URL을 서버가 대신 여는 일이 **왜 그렇게 위험한가**
2. 화이트리스트로 막았다고 믿는 코드는 **어떤 방법으로 뚫리는가** (여섯 가지)
3. 원리대로 막으려면 **무엇을 검증해야 하는가** (다섯 가지)
4. 애플리케이션 코드로 막을 수 없는 것은 무엇인가

> 순서가 중요하다. **막는 법보다 뚫리는 법을 먼저 본다.**
> 어떤 우회를 막는지 모르는 방어 코드는 방어가 아니라 선언이다.

---

<!-- _class: invert -->

# 보안: SSRF를 원리부터

여기가 본론이다

---

## 한 문장 정의

> **SSRF(Server-Side Request Forgery)는 공격자가 서버로 하여금
> 공격자가 원하는 곳에 요청을 보내게 만드는 취약점이다.**

핵심은 "요청을 보낸다"가 아니라 **"서버가 보낸다"**에 있다.

---

## 왜 "서버가 보낸다"가 위험한가

공격자의 브라우저와 우리 서버는 **네트워크 위치가 다르다.**

```text
  공격자 브라우저                    우리 서버
       │                                │
       │  ✗ 방화벽에 막힘                │  ✓ 내부망 안에 있다
       ▼                                ▼
 ┌──────────────────────────────────────────────┐
 │  내부망                                       │
 │   • 169.254.169.254  (클라우드 메타데이터)    │
 │   • 10.0.x.x         (내부 어드민, DB, Redis) │
 │   • 127.0.0.1        (같은 호스트의 서비스)   │
 └──────────────────────────────────────────────┘
```

SSRF는 **공격자에게 우리 서버의 네트워크 위치를 빌려준다.**
방화벽은 아무 역할도 하지 못한다. 요청이 안에서 나가기 때문이다.

---

## 그리고 스크래핑은 SSRF의 교과서적 조건이다

SSRF가 성립하려면 두 가지가 필요하다.

1. 서버가 **외부에서 온 URL**로 요청을 보낸다
2. 그 URL을 **공격자가 정할 수 있다**

링크 미리보기는 이 둘을 **기능 명세로 갖고 있다.**

> 취약점이 실수로 생기는 게 아니라, **기능 자체가 취약점의 모양**이다.
> 그래서 "조심해서 짠다"로는 안 되고, 구조로 막아야 한다.

---

## 공격 시나리오: 단계별로

가장 흔한 AWS 환경의 예다.

**① 공격자가 게시글에 링크를 붙여넣는다**

```text
http://169.254.169.254/latest/meta-data/iam/security-credentials/
```

**② 우리 서버가 미리보기를 만들려고 그 URL을 연다**

**③ 응답에 IAM 역할 이름이 들어 있다**

```text
og-scraper-role
```

---

## 공격 시나리오: 마무리

**④ 공격자가 한 번 더 붙여넣는다**

```text
http://169.254.169.254/latest/meta-data/iam/security-credentials/og-scraper-role
```

**⑤ 응답**

```json
{
  "AccessKeyId": "ASIA...",
  "SecretAccessKey": "...",
  "Token": "..."
}
```

**⑥ 이 크레덴셜로 S3, DynamoDB에 직접 접근한다**

미리보기 카드에 에러 메시지만 떠도, 응답 본문이 어딘가에 로깅되면 끝이다.

---

## 왜 하필 169.254.169.254인가

이 대역(`169.254.0.0/16`)은 **link-local**이다. 라우팅되지 않고, 같은 링크 안에서만 유효하다.

클라우드 사업자들은 여기에 인스턴스 메타데이터 서비스를 올린다.

| 사업자  | 주소              | 추가 조건                           |
| ------- | ----------------- | ----------------------------------- |
| AWS     | `169.254.169.254` | IMDSv2면 PUT으로 토큰 선취득        |
| GCP     | `169.254.169.254` | `Metadata-Flavor: Google` 헤더 필요 |
| Azure   | `169.254.169.254` | `Metadata: true` 헤더 필요          |
| Alibaba | `100.100.100.200` |                                     |

---

## IMDSv2가 있으면 안전한가

**절반만 그렇다.**

IMDSv2는 토큰을 먼저 받아야 한다.

```text
PUT /latest/api/token
X-aws-ec2-metadata-token-ttl-seconds: 21600
```

스크래핑은 GET만 보내므로 **단순 SSRF로는 뚫리지 않는다.**

그러나,

- IMDSv1이 아직 켜져 있는 인스턴스가 많다 (기본값이 오래도록 v1 허용이었다)
- SSRF가 메서드나 헤더를 조작할 수 있는 형태라면 여전히 가능하다
- **메타데이터만이 표적이 아니다.** 내부 어드민, Redis, Elasticsearch, k8s API가 남아 있다

> `hop limit = 1`과 IMDSv2 강제는 **반드시** 해두되, 그것을 방어의 전부로 삼지 말 것.

---

## 그래서 화이트리스트면 되는가

가장 흔한 대답이 이것이다.

> "허용 도메인 목록을 두고 그 안에서만 스크래핑하면 됩니다"

**방향은 맞다. 그런데 그것만으로는 뚫린다.**

지금부터 우회 기법 여섯 가지를 본다. 이걸 알아야 방어 원리가 왜 그런 모양인지 이해할 수 있다.

---

## 우회 1: IP 표기 변형

위험한 주소를 **문자열로** 걸러내는 검사가 있다고 하자.

```ts
if (rawUrl.includes('127.0.0.1')) throw new Error('blocked')
```

아래는 이 검사를 **전부 통과한다.** 그리고 전부 `127.0.0.1`로 연결된다.

| 붙여넣는 URL              | 표기                    |
| ------------------------- | ----------------------- |
| `http://2130706433/`      | 32비트 십진수           |
| `http://0x7f000001/`      | 16진수                  |
| `http://0177.0.0.01/`     | 8진수                   |
| `http://127.1/`           | 축약형 (중간 옥텟 생략) |
| `http://127.000.000.001/` | 0 패딩                  |

---

## 우회 1: 그런데 이건 가벼운 편이다

`new URL()`로 **파싱하는 것만으로 사라진다.** WHATWG 파서가 파싱 단계에서 정규화한다.

```ts
new URL('http://2130706433/').hostname // '127.0.0.1'
new URL('http://0x7f000001/').hostname // '127.0.0.1'
new URL('http://127.1/').hostname // '127.0.0.1'
```

**교훈:** IP 표기법을 외우라는 게 아니다. **검증은 원본 문자열이 아니라 파싱한 값으로** 한다.

> 이걸 맨 앞에 둔 이유는 여기서부터다. **남은 다섯은 파싱만으로 막히지 않는다.**

---

## 우회 2: DNS로 사설 IP 가리키기

그럼 파싱해서 얻은 `hostname`을 검사하면 되는가. **그것만으로는 안 된다.**

도메인 이름은 아무 IP나 가리킬 수 있다. 공격자가 자기 도메인의 A 레코드를 이렇게 두면,

```text
evil.example.com.   IN  A   127.0.0.1
```

`evil.example.com`은 `hostname`을 아무리 뜯어봐도 **정상적인 공인 도메인**이다. 그런데 연결되는 곳은 로컬이다.

도메인을 살 필요조차 없다. 이름에 적힌 IP를 그대로 돌려주는 공개 서비스가 있다.

```text
127.0.0.1.nip.io      →  127.0.0.1
10.0.0.1.nip.io       →  10.0.0.1
```

**교훈:** **이름은 목적지에 대해 아무것도 말해주지 않는다.** 봐야 하는 건 해석된 IP다.

---

## 우회 3: DNS rebinding

그럼 이름 대신 **해석된 IP**를 검사하면 되는가. **그것도 뚫린다.**

여섯 중 가장 교묘하고, 가장 많이 놓치는 것이다. 방어 코드가 보통 이렇게 생겼다고 하자.

```ts
const ip = await dns.resolve(url.hostname) // ① 검사용 조회
if (isPrivate(ip)) throw new Error('blocked')

await fetch(url) // ② 실제 요청 (또 조회한다!)
```

①과 ②는 **각각 DNS를 조회한다.** 그 사이에 응답이 바뀔 수 있다.

이 틈을 **TOCTOU(Time-Of-Check to Time-Of-Use)**라고 부른다.

---

## rebinding 타임라인

공격자는 TTL을 0으로 둔 도메인을 준비한다.

```text
t=0ms   ① 검사용 DNS 조회
        evil.com  →  93.184.216.34  (공인 IP)     ✓ 통과

t=1ms   isPrivate() 검사 통과

t=2ms   ② 실제 요청 직전 DNS 재조회
        evil.com  →  169.254.169.254            ✗ 이미 검사 끝났다

t=3ms   메타데이터 서비스로 요청이 나간다
```

공격자가 이걸 할 수 있는 이유는 **자기 도메인의 DNS 서버를 자기가 들고 있기** 때문이다.
TTL이 0이면 캐시되지 않으므로, **두 번의 조회가 다른 답을 받는 것이 정상 동작**이다. 규칙을 어긴 것이 하나도 없다.

**교훈:** 검사한 것과 사용한 것이 같아야 한다. 이름을 검사하면 안 되고, **주소를 검사한 뒤 그 주소를 써야 한다.**

---

## 우회 4: 리다이렉트

여기까지를 전부 지켰다고 하자. 파싱했고, 해석된 IP를 검사했고, 그 IP로 연결했다.
그런데 검사한 것은 **첫 번째 요청 하나뿐**이다.

허용 목록에 `example.com`이 있다고 하자. 공격자가 아래를 붙여넣는다.

```text
https://example.com/redirect?to=http://169.254.169.254/
```

- 첫 요청의 호스트는 `example.com`이다. **검사를 통과한다**
- 서버가 `302 Location: http://169.254.169.254/`를 응답한다
- HTTP 클라이언트가 **자동으로** 따라간다
- 이때는 아무도 검사하지 않는다

Open redirect가 있는 사이트는 흔하다. 그리고 공격자가 **자기 도메인**을 화이트리스트에 넣게 만들 필요도 없다. 이미 허용된 아무 사이트의 open redirect면 된다.

**교훈:** 리다이렉트 자동 추적을 끄고, **홉마다 처음부터 다시 검사**해야 한다.

---

## 우회 5: 검증한 값과 요청하는 값이 다를 때

앞의 넷은 **무엇을 검사할까**의 문제였다. 이건 다르다.
제대로 검사하고도 **검사한 것과 다른 것을 요청에 넘기는** 경우다.

```ts
if (!rawUrl.includes('allowed.com')) throw new Error('blocked')

await request(rawUrl) // 이 요청은 어디로 가는가
```

실제로 넣어 보면 이렇게 갈린다.

| 입력                              | `allowed.com` 문자열 | 실제 호스트   |
| --------------------------------- | -------------------- | ------------- |
| `http://allowed.com@evil.com/`    | 있다                 | `evil.com`    |
| `http://evil.com#@allowed.com/`   | 있다                 | `evil.com`    |
| `http://allowed.com%2f@evil.com/` | 있다                 | `evil.com`    |
| `http://allowed.com\@evil.com/`   | 있다                 | `allowed.com` |

---

## 우회 5: 그래서 파싱은 한 번만

- 1행은 URL의 `user@host` 문법이다. `allowed.com`은 **호스트가 아니라 사용자 이름**이다
- 2행은 `#` 뒤라 **프래그먼트**이고 목적지와 무관하다
- 3행은 `%2f`까지 포함해 사용자 이름으로 들어간다

문자열 검사는 넷 다 통과시키지만, **실제 목적지는 셋이 `evil.com`**이다.

4행은 방향이 반대라 더 눈여겨볼 만하다. 백슬래시가 들어가면 WHATWG 파서는 그것을 **경로 구분자**로 보고 호스트를 `allowed.com`으로 준다. 즉 **같은 문자열을 두고 파서마다 답이 갈린다.**

**교훈: 파싱은 한 번만 하고, 파싱한 그 객체를 그대로 요청에 넘긴다.**

---

## 우회 6: IPv6

마지막은 **잊어버려서** 생기는 구멍이다. IPv4 사설 대역만 검사하면 아래가 전부 통과한다.

| 주소               | 의미                                                |
| ------------------ | --------------------------------------------------- |
| `::1`              | 루프백                                              |
| `::ffff:127.0.0.1` | **IPv4-mapped.** 실제로는 127.0.0.1                 |
| `::ffff:a9fe:a9fe` | **`169.254.169.254`의 16진 표기.** 점 표기가 아니다 |
| `fe80::/10`        | link-local                                          |
| `fc00::/7`         | ULA (사설 대역에 해당)                              |
| `64:ff9b::/96`     | NAT64 (IPv4로 변환된다)                             |

**IPv4-mapped**가 특히 까다롭다. IPv6 형태지만 커널은 IPv4로 연결한다.

---

## 우회 6: 표기가 하나가 아니다

점 표기로 적어 넣어도, **파서는 16진 표기로 돌려준다.**

```ts
const u = new URL('http://[::ffff:169.254.169.254]/')
u.hostname // '[::ffff:a9fe:a9fe]'
```

`a9fe:a9fe`가 `169.254.169.254`를 16진수로 쓴 것이다 (`0xa9 = 169`, `0xfe = 254`).
그러니 `::ffff:169.254.169.254`를 문자열로 막아둔 코드는 **이 주소를 그냥 놓친다.**
우회 1과 같은 이야기가 IPv6에서 한 번 더 반복되는 셈이다.

> 그리고 위 출력에 하나가 더 보인다. **`hostname`에 대괄호가 남아 있다.**
> 이걸 주소로 착각하고 검사에 넘기면 어떻게 되는지는 뒤에서 본다.

---

## 방어 원리 다섯 줄

여섯 가지 우회를 관통하는 원리는 이렇게 정리된다.

1. **이름이 아니라 주소를 검증한다** (우회 1, 2, 6 대응)
2. **검증한 주소로 직접 연결한다** (우회 3 대응)
3. **리다이렉트는 홉마다 1~2를 반복한다** (우회 4 대응)
4. **한 번 파싱한 객체만 쓴다** (우회 5 대응)
5. **애플리케이션 방어를 믿지 않는다** (전부 대응)

하나씩 코드로 옮긴다.

---

## 원리 1: 주소를 검증한다

Node에는 `net.BlockList`가 내장되어 있다. 직접 비트 연산할 필요가 없다.

```ts
import {BlockList, isIP, isIPv4} from 'node:net'

const blocked = new BlockList()

blocked.addSubnet('0.0.0.0', 8, 'ipv4') //  this network
blocked.addSubnet('10.0.0.0', 8, 'ipv4') //  RFC1918
blocked.addSubnet('100.64.0.0', 10, 'ipv4') //  CGNAT
blocked.addSubnet('127.0.0.0', 8, 'ipv4') //  loopback
blocked.addSubnet('169.254.0.0', 16, 'ipv4') //  link-local (메타데이터)
blocked.addSubnet('172.16.0.0', 12, 'ipv4') //  RFC1918
blocked.addSubnet('192.168.0.0', 16, 'ipv4') //  RFC1918
blocked.addSubnet('192.0.0.0', 24, 'ipv4') //  IETF 프로토콜 할당 (DS-Lite 등)
blocked.addSubnet('198.18.0.0', 15, 'ipv4') //  벤치마크
blocked.addSubnet('224.0.0.0', 4, 'ipv4') //  멀티캐스트
blocked.addSubnet('240.0.0.0', 4, 'ipv4') //  reserved
```

---

## IPv6도 같이 막는다

```ts
blocked.addAddress('::', 'ipv6') //  unspecified
blocked.addAddress('::1', 'ipv6') //  loopback
blocked.addSubnet('64:ff9b::', 96, 'ipv6') //  NAT64 (RFC 6052)
blocked.addSubnet('64:ff9b:1::', 48, 'ipv6') //  NAT64 local-use (RFC 8215)
blocked.addSubnet('100::', 64, 'ipv6') //  discard
blocked.addSubnet('fc00::', 7, 'ipv6') //  ULA
blocked.addSubnet('fe80::', 10, 'ipv6') //  link-local
blocked.addSubnet('fec0::', 10, 'ipv6') //  site-local (deprecated, 구형 스택 대비)
blocked.addSubnet('ff00::', 8, 'ipv6') //  멀티캐스트
```

> 이 목록이 막아야 할 IPv6 대역의 **전부는 아니다.** IPv4 주소를 IPv6 안에 숨겨 넣는
> 방식이 여럿 있어서(바로 위 NAT64가 그런 예다), IPv6를 실제로 쓰는 환경이라면
> "IANA IPv6 Special-Purpose Address Registry"(예약 대역 공식 목록)를 한 번 보고 빠진 것을 더한다.

---

## 검사 함수는 짧게, 단 fail-closed로

검사 함수는 **이렇게 짧아야 한다.**

```ts
export function isPublicAddress(ip: string): boolean {
  if (isIP(ip) === 0) return false // ⚠️ IP 형식이 아니면 일단 막는다
  return !blocked.check(ip, isIPv4(ip) ? 'ipv4' : 'ipv6')
}
```

**첫 줄이 왜 필요한가.** `blocked.check()`는 IP가 아닌 문자열(`"localhost"`, `""`,
`"999.1.1.1"` 등)을 받으면 에러를 내는 게 아니라 **"막지 않음"으로 통과**시킨다.
그래서 IP 형식이 아닌 값은 우리가 먼저 잘라낸다. 애매하면 막는 쪽(fail-closed)이 안전하다.

그런데 여기서 **한 걸음 더 나가서 주소를 직접 쪼개 변환하고 싶어지는데, 그게 함정이다.**
다음 장에서 왜인지 본다.

---

## 손으로 언랩하면 오히려 뚫린다

앞 장의 IPv4-mapped 설명을 읽고 나면 이런 코드를 쓰고 싶어진다.

```ts
// ⚠️ 이 코드는 취약하다
const addr = ip.startsWith('::ffff:') ? ip.slice(7) : ip
return !blocked.check(addr, isIPv4(addr) ? 'ipv4' : 'ipv6')
```

Node 24에서 실제로 돌려본 결과다.

| 입력                     | `slice(7)` 결과   | 판정        |
| ------------------------ | ----------------- | ----------- |
| `::ffff:169.254.169.254` | `169.254.169.254` | 차단 ✅     |
| `::ffff:a9fe:a9fe`       | `a9fe:a9fe`       | **통과** ❌ |
| `::ffff:7f00:1`          | `7f00:1`          | **통과** ❌ |

16진 표기를 벗기면 **IPv4도 IPv6도 아닌 문자열**이 된다.
`BlockList.check()`는 이런 입력에 예외를 던지지 않고 **`false`(차단 안 함)를 반환한다.**

---

## `BlockList`는 이미 알아서 한다

같은 환경에서 **언랩하지 않고** 넣어보면,

| 입력                         | 결과    |
| ---------------------------- | ------- |
| `::ffff:169.254.169.254`     | 차단 ✅ |
| `::ffff:a9fe:a9fe`           | 차단 ✅ |
| `::ffff:7f00:1`              | 차단 ✅ |
| `64:ff9b::a9fe:a9fe` (NAT64) | 차단 ✅ |

Node의 `BlockList`는 **`::ffff:…` 형태(IPv6 껍데기만 씌운 IPv4, "IPv4-mapped")는 알아서 IPv4로 보고 검사한다.**
점 표기든 16진이든 가리지 않는다.

---

## 다만 자동인 것은 `::ffff:`뿐이다

**`64:ff9b::…` 형태(NAT64)는 알아서 풀어주지 않는다.** 위 표에서 이게 막힌 건
앞의 코드에 `64:ff9b::/96` 한 줄을 직접 넣어줬기 때문이다. 그 줄을 빼면 `64:ff9b::a9fe:a9fe`는
그냥 통과한다. 정리하면 **`::ffff:`만 자동이고, 나머지 "IPv4를 안에 품은 IPv6"는 대역마다 직접 막아야 한다.**

> **교훈이 둘이다.**
> ① 주소 정규화는 직접 하지 말고 **런타임이 제공하는 것을 쓴다**
> ② 보안 코드는 **반드시 우회 케이스로 테스트한다.** 위 표가 그 테스트다

---

## 그래서 테스트가 명세다

보안 코드는 "잘 동작한다"가 아니라 **"이것들을 막는다"**로 표현해야 한다.

```ts
describe('isPublicAddress', () => {
  const blockedCases = [
    '127.0.0.1',
    '169.254.169.254',
    '10.1.2.3',
    '172.16.0.1',
    '192.168.1.1',
    '100.64.0.1',
    '0.0.0.0',
    '::1',
    '::',
    'fe80::1',
    'fd00::1',
    'ff02::1',
    '::ffff:127.0.0.1',
    '::ffff:169.254.169.254',
    '::ffff:a9fe:a9fe',
    '::ffff:7f00:1', // 16진 표기
    '64:ff9b::a9fe:a9fe', // NAT64
    'localhost', // IP가 아닌 값이라 막아야 한다
    '', // 빈 문자열
    '999.1.1.1', // IP처럼 보이지만 유효하지 않다
    '127.0.0.1 ', // 뒤에 공백
  ]
```

---

## 허용 케이스도 같이 적는다

```ts
const allowedCases = [
  '93.184.216.34',
  '1.1.1.1',
  '2606:4700::1',
  '172.32.0.1',
  '100.128.0.1',
  '11.0.0.0', // 경계 바로 바깥
]

it.each(blockedCases)('%s 를 차단한다', (ip) =>
  expect(isPublicAddress(ip)).toBe(false),
)
it.each(allowedCases)('%s 를 허용한다', (ip) =>
  expect(isPublicAddress(ip)).toBe(true),
)
```

**경계 바로 바깥(`172.32.0.1`)을 넣는 이유:** 과차단도 버그다.

---

## 원리 2: 검증한 주소로 직접 연결한다

여기가 rebinding을 막는 지점이다. `undici`의 `lookup` 훅을 쓴다.

```ts
import {Agent} from 'undici'
import {lookup as dnsLookup} from 'node:dns'

export const scrapeAgent = new Agent({
  connect: {
    lookup(hostname, options, callback) {
      dnsLookup(hostname, {all: true}, (err, addresses) => {
        if (err) return callback(err, '', 0)

        // 하나라도 사설이면 전부 거부한다.
        // 공격자는 A 레코드를 여러 개 줄 수 있다.
        if (!addresses.every((a) => isPublicAddress(a.address))) {
          return callback(new Error('BLOCKED_PRIVATE_ADDRESS'), '', 0)
        }

        // ⚠️ undici는 이 콜백을 "주소 배열"로 답하길 기대한다(Node 20+ 기본 동작).
        //    { all: true } 결과를 그대로 넘기면 된다. 주소 하나만 넘기면
        //    첫 요청부터 "Invalid IP address: undefined"로 죽는다. (실측)
        if (options.all) return callback(null, addresses)
        callback(null, addresses[0].address, addresses[0].family)
      })
    },
  },
})
```

---

## 왜 이게 rebinding을 막는가

`lookup`이 반환한 주소로 **소켓이 곧바로 연결**되기 때문이다.

```text
기존 방식                          lookup 훅 방식
─────────                          ──────────────
① DNS 조회 → 검사                  ① DNS 조회
② fetch()                         ② 검사
   → 내부에서 DNS 재조회 ⚠️         ③ 검사한 그 IP로 소켓 연결 ✓
   → 다른 답이 올 수 있다              (재조회 없음)
```

**검사 시점과 사용 시점 사이에 DNS 조회가 한 번도 없다.** TOCTOU 창이 닫힌다.

> 이때도 요청에 실리는 호스트 이름(`Host` 헤더, HTTPS 인증서 확인용 이름)은 원래대로 유지된다.
> 즉 **한 IP에 여러 사이트가 얹힌 경우나 인증서 검증이 다 정상 동작한다.**

> 한 가지 더: 이 검사는 **새 연결을 열 때만** 돈다. 이미 열린 연결을 재사용(keep-alive)하면
> 다시 조회하지 않는데, 그 IP는 조금 전 검사를 통과한 것이라 안전하다.

---

## 왜 `every`인가, `find`가 아니라

공격자가 A 레코드를 이렇게 줄 수 있다.

```text
evil.com.  IN  A  93.184.216.34    ← 공인
evil.com.  IN  A  169.254.169.254  ← 사설
```

`find(isPublic)`으로 공인 하나만 골라 쓰면 당장은 안전해 보인다.

그러나 **정상적인 도메인이 사설 IP를 함께 반환할 이유가 없다.** 그런 응답 자체가 공격 신호다.

`every`로 전부 거부하는 편이 **의도를 정확히 표현**하고, 조회 순서나 캐시 상태에 따른 흔들림도 없앤다.

---

## 원리 3: 리다이렉트를 직접 따라간다

```ts
const MAX_HOPS = 3

async function fetchGuarded(startUrl: URL) {
  let url = startUrl

  // 리다이렉트 체인 전체에 거는 데드라인. 홉이 몇 번이든 이 시간은 넘기지 않는다.
  const overall = AbortSignal.timeout(8_000)

  for (let hop = 0; hop <= MAX_HOPS; hop++) {
    assertAllowedUrl(url) // 스킴, 포트, 도메인 화이트리스트

    const res = await request(url, {
      dispatcher: scrapeAgent, // redirect 인터셉터를 붙이지 않는다
      // 홉 하나당 5초 + 체인 전체 8초. 둘 중 먼저 걸리는 쪽에서 끊긴다
      signal: AbortSignal.any([overall, AbortSignal.timeout(5_000)]),
    })

    if (res.statusCode < 300 || res.statusCode >= 400) return res

    const location = res.headers.location
    if (!location) return res

    await res.body.dump() // 소켓을 반드시 비운다
    url = new URL(location, url) // 상대 경로도 처리된다
  }

  throw new Error('TOO_MANY_REDIRECTS')
}
```

---

## 진짜 위험한 건 `fetch()` 쪽이다

`undici.request()`는 **기본적으로 리다이렉트를 따라가지 않는다.** 따라가려면 인터셉터를 명시적으로 붙여야 한다.

```ts
new Agent().compose(interceptors.redirect({maxRedirections: 3})) // 이래야 따라간다
```

반면 **전역 `fetch()`는 기본이 `redirect: 'follow'`다.**

```ts
await fetch(userUrl) // ⚠️ open redirect를 그대로 따라간다
await fetch(userUrl, {redirect: 'manual'}) // 이렇게 꺼야 한다
```

---

## 실측: 같은 로컬 서버에 302를 물었다

| 호출                                 | 302 응답에 대해                      |
| ------------------------------------ | ------------------------------------ |
| `undici.request(url)`                | 302를 그대로 반환 ✅                 |
| `fetch(url)`                         | **따라가서 내부 본문을 가져왔다** ❌ |
| `fetch(url, { redirect: 'manual' })` | 302를 그대로 반환 ✅                 |

> 구버전 문서에 나오는 `maxRedirections: 0`은 undici 8에서 **0만 허용되고 그 외 값은 예외**다.
> 어차피 기본값이 추적 안 함이므로, **`fetch()`를 쓰지 않는 것**이 실질적인 방어다.

---

## 이 코드에서 놓치기 쉬운 두 줄

**`await res.body.dump()`**

본문을 읽지 않고 버리면 소켓이 커넥션 풀에 반납되지 않는다. 리다이렉트가 잦으면 커넥션이 고갈된다. `dump()`는 본문을 버리고 소켓을 정리한다.

**`new URL(location, url)`**

`Location` 헤더는 상대 경로일 수 있다 (`/login`, `../other`). 두 번째 인자로 기준 URL을 주면 표준대로 해석된다. 그리고 **이렇게 만든 `URL` 객체가 다음 루프에서 다시 검사받는다.**

---

## 원리 4: 스킴과 포트도 잠근다

`assertAllowedUrl`이 실제로 해야 하는 일이다.

```ts
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:'])
const ALLOWED_PORTS = new Set(['', '80', '443'])

function assertAllowedUrl(url: URL) {
  if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
    throw new BlockedError('SCHEME_NOT_ALLOWED')
  }
  if (!ALLOWED_PORTS.has(url.port)) {
    throw new BlockedError('PORT_NOT_ALLOWED')
  }
  if (url.username || url.password) {
    throw new BlockedError('CREDENTIALS_IN_URL') // user@host 우회 차단
  }
  if (!isAllowedHost(url.hostname)) {
    throw new BlockedError('HOST_NOT_ALLOWED')
  }
}
```

---

## 스킴을 왜 막는가

`http`, `https`가 아닌 스킴은 전혀 다른 공격면을 연다.

| 스킴        | 위험                                           |
| ----------- | ---------------------------------------------- |
| `file://`   | `/etc/passwd`, 애플리케이션 시크릿 파일        |
| `gopher://` | 임의 TCP 페이로드 전송 (Redis, SMTP 명령 주입) |
| `dict://`   | 내부 서비스 배너 수집                          |
| `ftp://`    | 내부 FTP 접근                                  |
| `data:`     | 파서 혼동 유발                                 |

`undici`는 `http`/`https`만 지원하므로 상당 부분 자동으로 막히지만, **명시적으로 거부하는 편이 안전하다.** 나중에 클라이언트를 교체해도 방어가 남는다.

---

## 포트는 왜 막는가

80, 443 외의 포트는 대개 **내부 서비스**다.

```text
6379  Redis
9200  Elasticsearch
5432  PostgreSQL
27017 MongoDB
8080  내부 어드민
2375  Docker daemon
```

IP 대역을 막았다면 이미 상당 부분 커버되지만, **DNS가 공인 IP를 가리키면서 그 뒤에 프록시가 있는 경우** 등에서 한 겹 더 필요하다.

> 보안은 겹으로 쌓는다. 한 겹이 뚫리는 것을 전제로 설계한다.

---

## 도메인 화이트리스트, 매칭에서 실수가 난다

앞의 `assertAllowedUrl`이 부르는 `isAllowedHost`는 생각보다 틀리기 쉽다.

```ts
const ALLOWED_HOSTS = new Set(['example.com'])

function isAllowedHost(hostname: string): boolean {
  if (ALLOWED_HOSTS.has(hostname)) return true // 정확히 일치
  // 서브도메인 허용 시 반드시 "." 경계를 붙인다
  return [...ALLOWED_HOSTS].some((d) => hostname.endsWith('.' + d))
}
```

**가장 흔한 실수:** `.` 없이 `endsWith("example.com")`로 검사하는 것.
그러면 `notexample.com`, `evil-example.com`이 통과한다. 공격자가 이런 도메인 하나만 사면 뚫린다.

서브도메인을 허용한다면 더 조심할 것: 아무도 관리 안 하는 서브도메인을 **공격자가 가로채는 경우**(subdomain
takeover)와, `exаmple.com`처럼 **눈엔 같지만 다른 글자**(키릴 `а`)를 쓴 도메인.

---

## 원리 5: 애플리케이션 방어를 믿지 않는다

지금까지 쓴 코드는 전부 **애플리케이션 레이어**에 있다.

- 라이브러리를 교체하면 사라진다
- 다른 개발자가 `fetch()`를 직접 쓰면 우회된다
- 우리가 모르는 우회 기법이 내일 나올 수 있다

**네트워크 레이어에서 한 번 더 막아야 한다.**

| 계층             | 통제                                             |
| ---------------- | ------------------------------------------------ |
| 인스턴스         | IMDSv2 강제, hop limit 1                         |
| 보안 그룹 / NACL | 아웃바운드에서 내부 대역 차단                    |
| Egress 프록시    | 모든 외부 요청을 한 지점으로 강제, 거기서 필터링 |
| VPC 설계         | 스크래핑 워크로드를 별도 서브넷으로 격리         |

---

## 다층 방어를 그림으로

```text
사용자 URL
    │
    ▼
┌───────────────────────────┐
│ ① 스킴/포트/자격증명 검사  │  ← 파싱된 URL 객체
├───────────────────────────┤
│ ② 도메인 화이트리스트      │  ← 정책 계층
├───────────────────────────┤
│ ③ DNS 해석 + IP 대역 검사  │  ← 보안 계층 (핵심)
├───────────────────────────┤
│ ④ 검사한 IP로 소켓 연결    │  ← rebinding 차단
├───────────────────────────┤
│ ⑤ 리다이렉트마다 ①~④ 반복 │
└───────────────────────────┘
    │
    ▼
┌───────────────────────────┐
│ ⑥ 보안그룹 아웃바운드 차단 │  ← 코드가 뚫려도 남는다
└───────────────────────────┘
```

---

## 화이트리스트: IP 기반 대 도메인 기반

여기서 흔한 오해 하나를 짚는다.

> "IP 화이트리스트가 도메인보다 안전하다"

**스크래핑에서는 그렇지 않다.**

| 방식        | 장점                  | 실제 문제                                                  |
| ----------- | --------------------- | ---------------------------------------------------------- |
| IP 기반     | 이름 조작에 영향 없음 | 대상이 CDN 뒤에 있으면 **그 CDN의 모든 사이트가 허용된다** |
| 도메인 기반 | 정책 표현이 정확함    | 해석된 IP를 검사하지 않으면 무의미하다                     |

---

## 둘은 목적이 다르다

| 목적                                                   | 맞는 도구                  |
| ------------------------------------------------------ | -------------------------- |
| **콘텐츠 정책** (어떤 사이트를 미리보기 허용할 것인가) | 도메인 화이트리스트        |
| **네트워크 보안** (내부망으로 못 나가게 한다)          | IP 대역 차단 + egress 통제 |

**둘 중 하나를 고르는 게 아니라, 둘 다 필요하다.**

도메인 화이트리스트는 "리딩방 링크를 막는다" 같은 **정책**을 표현한다.
IP 대역 차단은 "메타데이터를 못 읽는다"는 **보안**을 표현한다.
서로를 대체하지 못한다.

---

## 응답 크기 상한: 잊기 쉬운 DoS 벡터

공격자가 이런 URL을 준다.

```text
https://evil.com/infinite   →  응답이 끝나지 않는다
https://evil.com/10gb.html  →  거대한 HTML
```

`.text()`나 `.body()`로 전부 읽으면 **메모리가 터진다.**
크기를 세면서 읽다가, 상한을 넘으면 소켓을 끊어야 한다.

---

## 세면서 읽고, 넘으면 끊는다

```ts
const MAX_BYTES = 512 * 1024 // og 태그는 <head>에 있다. 512KB면 충분하다

async function readCapped(body: Readable): Promise<Buffer> {
  const chunks: Buffer[] = []
  let total = 0

  for await (const chunk of body) {
    total += chunk.length
    if (total > MAX_BYTES) {
      body.destroy() // 소켓을 끊는다
      throw new BlockedError('RESPONSE_TOO_LARGE')
    }
    chunks.push(chunk)
  }
  return Buffer.concat(chunks)
}
```

---

## `Content-Length`를 믿으면 안 된다

```ts
if (Number(res.headers['content-length']) > MAX_BYTES) throw ... // 불충분
```

이 검사만으로는 안 되는 이유가 셋이다.

1. `Transfer-Encoding: chunked`면 헤더가 **아예 없다**
2. 헤더 값이 **거짓일 수 있다** (상대 서버가 공격자의 것이다)
3. 압축된 경우 헤더는 압축 크기이고, **압축 해제 후 폭증**할 수 있다 (zip bomb)

**실제로 읽은 바이트를 세는 것만이 확실하다.** 헤더 검사는 조기 차단용 보조 수단으로만 쓴다.

---

## 타임아웃은 하나가 아니다

`timeout: 5000` 하나로 끝내면 안 된다. 단계별로 나눠야 원인 분류가 된다.

```ts
new Agent({
  connectTimeout: 2_000, // TCP + TLS 핸드셰이크
  headersTimeout: 3_000, // 요청 후 응답 헤더까지
  bodyTimeout: 3_000, // 본문 청크 사이의 최대 간격
})

// 그리고 전체 상한을 따로 건다
request(url, {signal: AbortSignal.timeout(8_000)})
```

**`bodyTimeout`은 청크(응답 조각) 사이의 간격이지 전체 시간이 아니다.** 응답을 1바이트씩 아주 느리게 흘려 연결을 오래 붙잡는 응답(slowloris형)은 `bodyTimeout`만으로 못 막는다. 그래서 전체 상한이 따로 필요하다.

그리고 이 전체 상한은 **요청 하나가 아니라 리다이렉트 체인 전체에 하나만** 건다. 앞의 `fetchGuarded`에서 루프 밖에 둔 `overall`이 그 역할이다. 홉마다 새로 5초를 주면, 리다이렉트가 3번 이어질 때 최악 20초까지 늘어난다.

---

## 보안 체크리스트

| 항목                           | 막는 것                                  |
| ------------------------------ | ---------------------------------------- |
| 스킴을 http/https로 제한       | `file://`, `gopher://`                   |
| 포트를 80/443으로 제한         | 내부 서비스 직접 접근                    |
| URL 자격증명 거부              | `user@host` 파서 혼동                    |
| 도메인 화이트리스트            | 콘텐츠 정책                              |
| **DNS 해석 IP 대역 검사**      | 사설망, 메타데이터                       |
| **IPv4-mapped / NAT64**        | `::ffff:a9fe:a9fe` (직접 언랩하지 말 것) |
| **검사한 IP로 연결 (pinning)** | DNS rebinding                            |
| **리다이렉트 홉별 재검증**     | open redirect 경유                       |

---

## 보안 체크리스트 (이어서)

| 항목                        | 막는 것                               |
| --------------------------- | ------------------------------------- |
| 응답 크기 상한              | 메모리 고갈                           |
| 단계별 타임아웃 + 전체 상한 | slowloris                             |
| 보안그룹 아웃바운드 차단    | 위가 전부 뚫렸을 때                   |
| IMDSv2 강제, hop limit 1    | 크레덴셜 탈취                         |
| 요청자별 rate limit / 인증  | 우리 서버를 익명 프록시·중계기로 악용 |

---

## 2부의 주장은 실측했다

글을 쓰고 나서 코드를 전부 돌려봤고, **이 부에서만 셋이 틀렸다.**

| 처음에 쓴 것                             | 실측 결과                                                                    |
| ---------------------------------------- | ---------------------------------------------------------------------------- |
| lookup 훅에서 주소를 **하나만** 돌려준다 | **첫 요청부터 죽는다.** undici는 `{ all: true }`로 부르고 배열 답을 기대한다 |
| `::ffff:` 를 손으로 벗겨 검사한다        | **16진 표기에서 뚫린다.** `BlockList`에 그대로 넘겨야 한다                   |
| `maxRedirections: 3` 으로 제한한다       | undici 8에서 **예외.** redirect 인터셉터를 써야 한다                         |

검증 환경은 Node `v24.14.1`, undici `8.10.0`이다.

> **이게 이 시리즈가 하려는 말 그 자체다.**
> 그럴듯한 보안 코드와 동작하는 보안 코드는 다르다. 차이는 **돌려봤는가**이다.

---

## 직접 재현하려면

```bash
npm i undici

# IPv4-mapped 16진 표기 우회
node -e "const{BlockList,isIPv4}=require('net');
const b=new BlockList();b.addSubnet('169.254.0.0',16,'ipv4');
const bad=ip=>{const a=ip.startsWith('::ffff:')?ip.slice(7):ip;
  return !b.check(a,isIPv4(a)?'ipv4':'ipv6')};
const good=ip=>!b.check(ip,isIPv4(ip)?'ipv4':'ipv6');
console.log('손수 언랩:',bad('::ffff:a9fe:a9fe'),'(true면 취약)');
console.log('그대로   :',good('::ffff:a9fe:a9fe'),'(false면 안전)')"
```

손으로 언랩한 쪽만 통과시킨다. **그게 이 부의 요지다.**

---

## 되게 만드는 법은 1부에 있다

2부는 **뚫리지 않는 법**이었다. 반대쪽인 **되게 만드는 법**은 1부 3~5장에서 다뤘다.

- 왜 User-Agent 하나가 에러율을 가르는가
- 한국어 사이트의 인코딩은 왜 아직도 깨지는가 (여기서 Node가 불리하다)
- 그렇게 가져온 값을 그대로 화면에 그려도 되는가 (이 부와 반대 방향의 위험)
- 캐시는 무엇을 고치고 무엇을 못 고치는가, 스탬피드는 왜 특히 나쁜가
- "P95 1초 미만"이라는 목표를 히트율에서 역산하는 법

**두 부가 이 시리즈의 전부다.**

---

## 참고

**보안**

- OWASP, _Server Side Request Forgery Prevention Cheat Sheet_
- AWS, _Use IMDSv2_ (EC2 User Guide)
- RFC 1918 (사설 주소), RFC 6598 (CGNAT), RFC 4193 (IPv6 ULA)

**표준**

- WHATWG URL Standard

**라이브러리**

- `undici` Dispatcher / Agent 문서 (`connect.lookup`)
- Node.js `net.BlockList`

---

# react-query 딥다이브 1부: 사용법과 캐시의 생명주기

Source: https://research.yceffort.kr/slides/react-query-deep-dive-1.md

---
title: 'react-query 딥다이브 1부: 사용법과 캐시의 생명주기'
marp: true
paginate: true
theme: midnight
tags:
  - data-fetching
  - caching
  - react
  - async
date: 2026-08-14
description: '서버 상태라는 문제, useQuery의 상태 모델, staleTime과 gcTime — 캐시 항목의 일생을 따라가는 react-query 강의 1부'
published: true
art:
  undraw: database-tables
---

# react-query 딥다이브 1부

사용법과 캐시의 생명주기

<!-- _class: invert -->

@yceffort

---

## 이 코드에는 문제가 몇 개 있을까

```jsx
function UserProfile({userId}) {
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    setLoading(true)
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((data) => setUser(data))
      .finally(() => setLoading(false))
  }, [userId])

  if (loading) return <Spinner />
  return <div>{user.name}</div>
}
```

리뷰에서 한 번쯤 봤을 법한 코드다. 겉보기엔 멀쩡하다.

<!-- 손들기로 진행: 1개? 2개? 3개 이상? 대부분 에러 처리 누락 정도만 찾는다. -->

---

## 최소 네 개

1. **에러 처리가 없다** — 요청이 실패하면 `user`는 영원히 `null`, 스피너만 사라진다
2. **레이스 컨디션** — `userId`가 1 → 2로 바뀌었는데 1의 응답이 늦게 도착하면, 화면에는 1의 데이터가 남는다
3. **응답이 버려진다** — 언마운트 뒤 도착한 데이터는 쓸 곳 없이 사라진다 (React 17까지는 "setState on unmounted" 경고가 뜨던 지점)
4. **캐시가 없다** — 다른 화면에 갔다 돌아올 때마다, 같은 데이터를 쓰는 컴포넌트마다 매번 다시 요청한다

전부 고치면 이 컴포넌트는 수십 줄이 더 필요하다. 그리고 다음 컴포넌트에서 **또 반복**된다.

react-query는 이 반복을 없애기 위해 존재한다.

---

## 이 시리즈에서 다루는 것

**1부 (오늘)**

0. **배경** — 서버 상태는 왜 useState로 관리하면 안 되는가
1. **useQuery 기본기** — queryKey, queryFn, 상태 모델
2. **캐시의 생명주기** — fresh, stale, inactive와 staleTime/gcTime
3. **useMutation과 무효화** — 쓰기 작업 후 캐시를 갱신하는 법

각 파트 끝에는 이해를 확인하는 **중간 점검 퀴즈**가 있다.

**2부**

4. **내부 동작** — QueryClient, Query, QueryObserver의 구독 모델
5. **Suspense와 SSR** — useSuspenseQuery, HydrationBoundary
6. **실전 함정 모음** + **종합 퀴즈**

---

## 기준

- 라이브러리: **TanStack Query v5.101.4** (react-query의 현재 이름, v4부터 개명)
- 동작 설명 중 코드 인용은 전부 `TanStack/query` **v5.101.4 태그** 소스 기준
- 캐시 동작 예제는 **Node.js 24에서 query-core로 직접 실행해 확인**했다
- 다루지 않는 것: useInfiniteQuery(무한 스크롤), 오프라인 지원(networkMode 상세), devtools 사용법

<!-- 구두 보충: v3(react-query 패키지 시절) 자료가 검색에 많이 걸린다. cacheTime이라는 단어가 보이면 v4 이하 자료다(v5에서 gcTime으로 개명). 이 덱과 내용이 다르면 버전부터 의심할 것. -->

---

## Part 0 — 서버 상태라는 문제

<!-- _class: invert -->

---

## 클라이언트 상태 vs 서버 상태

| 구분      | 클라이언트 상태           | 서버 상태                       |
| --------- | ------------------------- | ------------------------------- |
| 예시      | 모달 열림, 입력값, 탭     | 유저 정보, 상품 목록, 주문 내역 |
| 소유자    | 내 앱                     | **서버** (내 것이 아니다)       |
| 최신 보장 | 항상 최신 (내가 바꾸니까) | **모르는 사이 낡는다**          |
| 접근 방식 | 동기                      | 비동기 (지연, 실패 가능)        |
| 공유      | 컴포넌트 트리 안          | 여러 화면, 여러 사용자          |

- `useState`/`useReducer`는 왼쪽 열을 위한 도구다
- 오른쪽 열은 성격이 다른 문제인데, 같은 도구로 풀려다 보니 Part 시작의 그 코드가 나온다

---

## 서버 상태의 본질: 캐시

서버 상태를 화면에 그리는 순간, 그것은 이미 **원본이 아니라 사본**이다.

```text
[ 서버 DB (원본) ] ──── 요청/응답 ────▶ [ 내 화면 (사본) ]
                                          ▲
                              언제 다시 동기화할 것인가?
```

그래서 서버 상태 관리의 진짜 질문은 "어떻게 저장하나"가 아니라:

- 사본을 **언제까지 신선하다고 볼 것인가**
- 낡은 사본을 **언제, 어떤 계기로 다시 동기화할 것인가**
- 안 쓰는 사본을 **언제 버릴 것인가**

react-query는 fetch 라이브러리가 아니라 **이 질문들에 답하는 캐시 관리자**다. 요청 자체는 여전히 fetch/axios로 내가 한다.

---

## react-query가 대신 해주는 것

Part 시작의 문제 네 개를 다시 보면:

| 수동 fetch의 문제     | react-query의 처리                              |
| --------------------- | ----------------------------------------------- |
| 에러 처리 누락        | `status: 'error'`로 상태화, 재시도 내장         |
| 레이스 컨디션         | 이전 응답은 이전 키의 캐시로 격리, 화면 못 덮음 |
| 언마운트 뒤 응답 유실 | 캐시에 저장 — 다음 방문 때 재사용               |
| 캐시 없음, 중복 요청  | 키 기반 캐시 + 동시 요청 **중복 제거**          |

여기에 백그라운드 갱신, 창 포커스 시 동기화, 로딩/에러 상태 표준화까지 따라온다.

이 목록을 외울 필요는 없다. 이 덱이 끝나면 각각이 **캐시 구조에서 당연히 나오는 결과**로 보이게 된다.

---

## Part 1 — useQuery 기본기

<!-- _class: invert -->

---

## 최소 설정

```jsx
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'

const queryClient = new QueryClient()

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <UserProfile userId={1} />
    </QueryClientProvider>
  )
}
```

- `QueryClient`가 캐시(QueryCache)를 들고 있는 **본체**다. 앱 전체에 하나
- Provider는 이 캐시를 컨텍스트로 내려보내는 통로
- 브라우저 앱에서는 모듈 스코프에 하나 만들면 된다 (SSR에서는 다르다 — 2부)

---

## useQuery 첫 예제

```jsx
import {useQuery} from '@tanstack/react-query'

function UserProfile({userId}) {
  const {data, status, error} = useQuery({
    queryKey: ['users', userId],
    queryFn: () => fetchUser(userId),
  })

  if (status === 'pending') return <Spinner />
  if (status === 'error') return <ErrorView error={error} />
  return <div>{data.name}</div>
}
```

- 인트로의 코드가 이걸로 바뀐다. 문제 네 개가 전부 사라진다
- 필수 옵션은 둘: **queryKey**(캐시의 주소)와 **queryFn**(데이터 가져오는 법)
- 이 둘의 규약만 정확히 알면 useQuery의 절반은 끝난다

---

## queryKey — 캐시의 주소

```js
useQuery({queryKey: ['users'], ...})              // 유저 목록
useQuery({queryKey: ['users', userId], ...})      // 유저 상세
useQuery({queryKey: ['users', userId, 'orders'], ...}) // 유저의 주문
```

- 배열이다. 캐시는 이 키를 해시한 문자열을 주소로 항목을 저장한다
- **키가 같으면 같은 캐시**를 본다. 다른 컴포넌트라도, 다른 화면이라도
- **키가 다르면 완전히 다른 항목**이다. `['users', 1]`과 `['users', 2]`는 남남
- 계층적으로 설계하면 나중에 "users로 시작하는 전부 무효화" 같은 **프리픽스 매칭**을 쓸 수 있다 (Part 3)

---

## queryKey 해시의 규칙

키는 내부에서 문자열로 해시된다. 규칙 네 가지 (전부 v5.101.4로 직접 확인):

```js
// ① 객체의 키 순서는 무관 — 같은 캐시
hashKey([{page: 1, filter: 'a'}]) === hashKey([{filter: 'a', page: 1}]) // true

// ② 배열의 순서는 유관 — 다른 캐시
hashKey(['todos', 1]) === hashKey([1, 'todos']) // false

// ③ 값이 undefined인 속성은 없는 것과 같다
hashKey([{a: 1, b: undefined}]) === hashKey([{a: 1}]) // true

// ④ 타입은 구분한다
hashKey(['todos', 1]) === hashKey(['todos', '1']) // false
```

④가 실무에서 제일 아프다. `useParams()`의 id는 **문자열**이고, 다른 곳에서 숫자 id로 키를 만들면 **캐시가 둘로 갈라진다.**

---

## queryFn의 규약 ① — 성공과 실패

queryFn은 **Promise를 반환하는 함수**면 무엇이든 된다. 규약은 하나: **실패는 throw로 알린다.**

```js
queryFn: async () => {
  const res = await fetch(`/api/users/${userId}`)
  if (!res.ok) {
    throw new Error(`요청 실패: ${res.status}`) // 이게 없으면?
  }
  return res.json()
}
```

- `fetch`는 404, 500에도 **reject하지 않는다**. `res.ok` 검사 없이 쓰면 서버가 죽어도 `status: 'success'`다
- axios는 4xx/5xx에서 throw하므로 이 검사가 필요 없다
- throw된 에러가 `status: 'error'`와 `error` 객체가 된다

---

## queryFn의 규약 ② — undefined 반환 금지

```js
queryFn: async () => {
  const res = await fetch('/api/config')
  const json = await res.json()
  return json.data // json.data가 없으면?
}
```

```text
Error: ["config"] data is undefined     ← 실제 throw되는 에러
// dev 모드 콘솔에는 안내가 함께 찍힌다:
// Query data cannot be undefined. Please make sure to return
// a value other than undefined from your query function. ...
```

- `undefined`를 반환하면 성공이 아니라 **에러로 처리된다** (실행해 확인. 친절한 안내는 dev 전용이고 프로덕션에는 위의 짧은 에러만 남는다)
- "데이터 없음"을 표현하려면 `null`을 반환할 것
- 캐시 입장에서 `undefined`는 "항목 없음"과 구분이 안 되기 때문에 스펙으로 금지했다

---

## 상태 모델 ① — status

쿼리의 **데이터 관점** 상태는 셋뿐이다.

```text
            성공 ──▶  success  (data 있음)
pending ──┤
            실패 ──▶  error    (error 있음)
```

| status      | 의미                           | 보조 불리언 |
| ----------- | ------------------------------ | ----------- |
| `'pending'` | 아직 **데이터가 한 번도 없다** | `isPending` |
| `'success'` | 데이터가 있다                  | `isSuccess` |
| `'error'`   | 시도가 실패했다                | `isError`   |

주의: `pending`은 "로딩 중"이 아니라 "**보여줄 데이터가 없다**"는 뜻이다. 로딩 여부는 다음 슬라이드의 다른 축이 담당한다.

---

## 상태 모델 ② — fetchStatus, 두 번째 축

**네트워크 활동 관점**의 축이 따로 있다: `fetchStatus` — `'fetching' | 'paused' | 'idle'`

|                     | fetching               | idle                          |
| ------------------- | ---------------------- | ----------------------------- |
| **status: pending** | 첫 로딩 중 (스피너)    | 시작 안 함 (`enabled: false`) |
| **status: success** | **백그라운드 갱신 중** | 평온한 상태                   |
| **status: error**   | 실패 후 재시도 중      | 실패로 끝남                   |

- 두 축이 분리된 이유: react-query에서는 "데이터가 있으면서 동시에 요청 중"인 상태가 **정상 운영 상태**이기 때문 (Part 2의 백그라운드 갱신)
- `paused`는 오프라인일 때. 이 덱에서는 다루지 않는다

---

## isPending, isLoading, isFetching

셋 다 "로딩"처럼 들리지만 전부 다르다.

```text
isPending  = status === 'pending'        (보여줄 데이터가 없다)
isFetching = fetchStatus === 'fetching'  (지금 요청이 날아가고 있다)
isLoading  = isPending && isFetching     (데이터도 없는데 첫 요청 중)
```

실무 기준:

- **첫 진입 스피너** → `isPending` (또는 `isLoading`)
- **갱신 중 인디케이터** (데이터는 이미 떠 있음) → `isFetching`
- 백그라운드 갱신마다 화면 전체를 스피너로 덮고 있다면, `isFetching`을 스피너 조건으로 쓴 것이다

<!-- 구두 보충: v4의 isLoading이 v5에서 isPending으로 개명됐고, v5의 isLoading은 위처럼 재정의됐다. 구버전 코드 마이그레이션 시 주의. -->

---

## 파라미터는 queryKey에 넣는다

```jsx
function OrderList({page, filter}) {
  const {data} = useQuery({
    queryKey: ['orders', {page, filter}], // 의존성이 곧 키
    queryFn: () => fetchOrders(page, filter),
  })
}
```

- `page`가 1 → 2로 바뀌면 **다른 캐시 항목**을 보게 된다. 같은 항목을 수동으로 다시 요청(refetch)하는 게 아니다
- 2페이지가 처음이면 요청하고, 이미 캐시에 있으면 **즉시 그 데이터**부터 보여준다
- 1페이지로 돌아가면? 캐시가 살아 있으므로 **요청 없이 즉시** 뜬다
- `useEffect`의 의존성 배열과 같은 원리다: **queryFn이 참조하는 값은 전부 queryKey에**

이 규칙 하나로 인트로의 레이스 컨디션이 사라진다. 키가 바뀌는 순간 이전 키의 응답은 이전 캐시 항목으로 갈 뿐, 현재 화면을 덮어쓰지 못한다.

---

## enabled — 조건부 실행과 종속 쿼리

```jsx
const {data: user} = useQuery({
  queryKey: ['users', email],
  queryFn: () => fetchUserByEmail(email),
})

const userId = user?.id

const {data: orders} = useQuery({
  queryKey: ['orders', userId],
  queryFn: () => fetchOrders(userId),
  enabled: userId !== undefined, // user가 오기 전엔 시작하지 않는다
})
```

- `enabled: false`인 동안 queryFn은 **호출되지 않는다** (`status: 'pending'` + `fetchStatus: 'idle'`)
- 훅 자체를 if문으로 감싸는 것은 훅 규칙 위반이다. **조건은 enabled로** 표현한다
- 뒤 쿼리가 앞 쿼리의 결과를 필요로 하는 "종속 쿼리"의 표준 패턴

---

## Part 1 정리

- 필수는 둘: **queryKey**(캐시 주소)와 **queryFn**(가져오는 법, 실패는 throw)
- 키 해시: 객체 키 순서 무관, 배열 순서 유관, `undefined` 무시, **타입 구분** (`1` ≠ `'1'`)
- 상태는 두 축: **status**(데이터가 있나) × **fetchStatus**(요청 중인가)
- 첫 스피너는 `isPending`, 갱신 표시는 `isFetching`
- **queryFn이 쓰는 값은 전부 queryKey에** — 파라미터 변경은 refetch가 아니라 캐시 항목 이동
- 조건부 실행은 훅을 감싸지 말고 `enabled`

---

## 중간 점검 ① — 서버가 500을 내려줬다

```js
useQuery({
  queryKey: ['config'],
  queryFn: async () => {
    const res = await fetch('/api/config') // 서버가 500을 반환
    return res.json()
  },
})
```

서버가 500과 함께 `{"message": "Internal Server Error"}`를 응답했다. 이 쿼리의 `status`는 무엇일까?

<!-- 진행: pending / error / success 손들기. error가 다수인 편 — fetch의 reject 조건을 되물으며 반전. -->

---

## 중간 점검 ① — 정답: success

- `fetch`는 404, 500에도 **reject하지 않는다** — reject는 네트워크 단절 수준에서만
- `res.json()`도 성공한다 (에러 응답도 유효한 JSON이므로)
- queryFn이 throw하지 않았으니 `status: 'success'`, `data`는 `{message: 'Internal Server Error'}`
- 화면은 에러 객체를 정상 데이터처럼 그리다 깨지고, 에러 분기는 끝까지 침묵한다

> 실패는 throw로 알린다 — fetch를 쓴다면 `res.ok` 검사는 선택이 아니라 규약의 일부다.

---

## 중간 점검 ② — 두 축으로 읽기

```jsx
const {status, fetchStatus, isLoading} = useQuery({
  queryKey: ['orders', userId],
  queryFn: () => fetchOrders(userId),
  enabled: false,
})
```

이 쿼리의 `status`, `fetchStatus`, `isLoading`은 각각 무엇일까?

---

## 중간 점검 ② — 정답: pending / idle / false

- 데이터가 한 번도 없었으니 `status: 'pending'`
- 요청이 날아가고 있지 않으니 `fetchStatus: 'idle'`
- `isLoading = isPending && isFetching`이므로 **false**
- "pending = 로딩 중"으로 외웠다면 여기서 무너진다 — pending은 "보여줄 데이터가 없다"일 뿐, 로딩 여부는 fetchStatus의 몫

> 스피너를 isPending에만 걸면 enabled: false인 화면은 영원히 스피너다. isLoading이 이 경우를 걸러낸다.

---

## Part 2 — 캐시의 생명주기

<!-- _class: invert -->

여기가 react-query의 심장이다. staleTime과 gcTime을 설명할 수 있으면 이 라이브러리를 아는 것이다.

---

## 캐시 항목의 일생

```text
 요청 ──▶ [ fresh ] ──staleTime 경과──▶ [ stale ]
             신선함                        낡음
                                            │
        구독자(사용하는 컴포넌트)가 0이 되면  ▼
 삭제 ◀──gcTime 경과── [ inactive ] ◀──────┘
```

캐시 항목 하나는 네 단계를 지난다.

1. **fresh** — 신선하다. 이 데이터를 그대로 믿는다
2. **stale** — 낡았다. 여전히 보여주되, 기회가 오면 다시 받아온다
3. **inactive** — 아무 컴포넌트도 안 쓴다. 화면에 없다
4. **삭제** — 메모리에서 제거

단계 전환을 결정하는 두 개의 시계가 **staleTime**과 **gcTime**이다.

---

## staleTime — 신선함의 유효기간

**fresh인 동안은 자동 계기로는 요청이 발생하지 않는다.** 캐시를 그냥 쓴다. (명시적 명령인 invalidate·refetch는 예외 — Part 3)

```js
useQuery({
  queryKey: ['user'],
  queryFn: fetchUser,
  staleTime: 60_000, // 1분 동안 fresh
})
```

```js
// Node 실측 — fetchQuery는 컴포넌트 없이 쿼리를 실행하는 코어 API
await client.fetchQuery(opts) // 요청 1회
await client.fetchQuery(opts) // fresh → queryFn 호출 없음, 캐시 반환
// queryFn 호출 횟수: 1
```

- 같은 키를 쓰는 컴포넌트가 열 개 마운트되어도, fresh라면 **요청 0회**
- staleTime이 지나도 데이터가 사라지는 게 아니다. "**낡음**" 딱지가 붙을 뿐이다

---

## stale이 되면: 보여주고, 뒤에서 갱신한다

stale 캐시를 만나면 react-query는 둘 중 하나를 고르지 않는다. **둘 다 한다.**

```text
[ 컴포넌트 마운트 ]
       │
       ├──▶ ① 캐시의 낡은 데이터를 즉시 보여준다   (빈 화면 없음)
       │
       └──▶ ② 백그라운드에서 다시 요청한다         (isFetching: true)
                     │
                     └──▶ ③ 도착하면 조용히 교체    (최신화)
```

- HTTP 캐싱의 **stale-while-revalidate**와 같은 전략
- 사용자는 항상 "일단 뭔가"를 보고, 데이터는 곧 최신이 된다
- Part 1에서 `status: 'success'` + `fetchStatus: 'fetching'` 조합이 정상 운영 상태라고 한 이유

---

## 그럼 갱신은 정확히 언제 일어나는가

stale 딱지는 그 자체로는 아무것도 하지 않는다. **계기**가 와야 한다. 계기는 네 가지다.

| 계기                              | 옵션                   | 기본값 |
| --------------------------------- | ---------------------- | ------ |
| 새 구독자 마운트                  | `refetchOnMount`       | true   |
| 탭이 다시 보임 (visibilitychange) | `refetchOnWindowFocus` | true   |
| 네트워크 재연결                   | `refetchOnReconnect`   | true   |
| 주기적 갱신 (폴링)                | `refetchInterval`      | 없음   |

판정 핵심 한 줄 (`queryObserver.ts`의 `shouldFetchOn` 발췌, 2부에서 상세):

```js
value === 'always' || (value !== false && isStale(query, options))
```

mount/focus/reconnect는 **stale일 때만** 갱신한다 (`'always'` 설정 시 예외). 단, **`refetchInterval`은 이 판정을 거치지 않는다** — 폴링은 fresh여도 돈다.

---

## "포커스만 바꿔도 API가 호출돼요"

주니어가 react-query를 도입하고 가장 먼저 놀라는 지점.

- **staleTime의 기본값은 0**이다 (`query.ts`의 `isStaleByTime(staleTime = 0)`)
- 즉 기본 설정에서 모든 데이터는 **도착하는 순간 이미 stale**
- 그래서 탭을 전환했다 돌아올 때마다(`refetchOnWindowFocus`) 요청이 나간다

이것은 버그가 아니라 **의도된 기본값**이다: "낡은 데이터를 보여줄 바엔 요청을 한 번 더 한다."

```js
// 데이터 성격에 맞게 staleTime을 선언하는 것이 올바른 대응
staleTime: 0 // 주문 상태, 재고 — 항상 최신이어야 함
staleTime: 60_000 // 목록, 프로필 — 1분쯤 낡아도 됨
staleTime: Infinity // 국가 코드, 설정값 — 세션 내 불변
```

`refetchOnWindowFocus: false`로 계기를 끄는 것은 증상 치료다. "이 데이터는 얼마나 낡아도 되는가"에 답하는 staleTime이 원인 치료.

<!-- 진행: "겪어본 사람?" 손들기 → "refetchOnWindowFocus: false로 껐던 사람?" → 그게 왜 증상 치료인지(다른 계기들은 그대로 남는다, 진짜 질문은 신선도)로 연결하면 토론이 잘 붙는 슬라이드. -->

---

## gcTime — 안 쓰는 캐시의 수명

구독자가 0이 된(inactive) 항목은 gcTime이 지나면 **메모리에서 삭제**된다.

```text
[ 목록 화면 ]──이동──▶[ 상세 화면 ]──5분 내 복귀──▶[ 목록 화면 ]
 ['orders'] 구독        구독자 0                    캐시 생존: 즉시 표시
                        (inactive, GC 카운트다운)    + stale이면 백그라운드 갱신
```

- 기본값: **5분** — 소스(`removable.ts`): `newGcTime ?? (environmentManager.isServer() ? Infinity : 5 * 60 * 1000)`
- Node 실측: 구독 해제 후 gcTime 경과 → `getQueryData()`가 `undefined`
- gcTime이 지나 삭제된 뒤 돌아오면? 빈손에서 시작하므로 **스피너부터** 다시 본다
- 구독자가 하나라도 있는 한 GC 대상이 아니다. 구독자들이 서로 다른 gcTime을 주면 `Math.max`로 **가장 긴 값**이 적용된다 (같은 소스의 바로 윗줄)

---

## staleTime vs gcTime — 한 장 비교

| 구분        | staleTime                       | gcTime                              |
| ----------- | ------------------------------- | ----------------------------------- |
| 결정하는 것 | 언제부터 **낡은 취급**할 것인가 | 안 쓰는 캐시를 언제 **버릴** 것인가 |
| 시계 시작   | 데이터 **도착** 시점            | 구독자가 **0이 된** 시점            |
| 지나면      | 계기가 올 때 백그라운드 갱신    | 캐시 삭제 (다음엔 스피너)           |
| 기본값      | **0** (즉시 낡음)               | **5분**                             |
| 관장하는 것 | **요청 횟수** (신선도 정책)     | **메모리** (보관 정책)              |

- 서로 독립적인 두 시계다. "staleTime < gcTime이어야 한다" 같은 제약은 없다
- 다만 staleTime > gcTime이면, 낡기도 전에 캐시가 사라질 수 있으니 보통은 staleTime ≤ gcTime으로 둔다

---

## 시나리오로 확인

`staleTime: 60초, gcTime: 5분`인 주문 목록. 사용자의 7분 30초:

```text
00:00  목록 진입           → 요청, 데이터 도착 (fresh)
00:30  상세 갔다가 복귀     → fresh → 요청 없음, 즉시 표시
01:30  다른 탭 갔다가 복귀  → stale + 포커스 계기
                           → 캐시 즉시 표시 + 백그라운드 갱신
02:00  상세 화면으로 이동   → 구독자 0 (inactive, GC 카운트다운 시작)
07:30  목록으로 복귀        → gcTime(5분, 07:00 만료) 경과, 캐시 삭제됨
                           → 스피너 + 새 요청
```

- 사용자가 빈 화면을 본 것은 처음과 마지막뿐
- 요청은 5번의 화면 전환 동안 3번만 나갔다

---

## 요청 중복 제거 — 캐시 구조의 보너스

같은 키를 쓰는 컴포넌트 셋이 **동시에** 마운트되면?

```js
// Node 실측: 동시에 세 번 요청해도
await Promise.all([
  client.fetchQuery(opts),
  client.fetchQuery(opts),
  client.fetchQuery(opts),
])
// queryFn 호출 횟수: 1
```

- 캐시 항목 하나에 요청 상태가 있으므로, **이미 진행 중인 요청이 있으면 거기에 합류**한다
- staleTime이 0이어도 그렇다 (중복 제거는 신선도와 무관한, 동시성 차원의 동작)
- 헤더, 사이드바, 본문이 제각각 `['me']` 키를 구독해도 네트워크에는 **한 번**

컴포넌트는 "이 데이터가 필요하다"만 선언하고, 요청을 누가 언제 보낼지는 캐시가 조율한다.

---

## retry — 실패했을 때

실패한 queryFn은 자동으로 재시도된다.

- 기본 **3회**, 지수 백오프(1초 → 2초 → 4초, 최대 30초)
- `status: 'error'`는 **모든 재시도가 소진된 뒤**에야 된다 — "에러가 늦게 뜨는" 이유
- 소스(`retryer.ts` v5.101.4)의 기본값에는 조건이 하나 숨어 있다:

```ts
const retry = config.retry ?? (environmentManager.isServer() ? 0 : 3)
```

- **서버에서는 0회**다. SSR 중 3회 재시도는 응답 지연으로 직결되기 때문 (2부에서 다시)
- 4xx는 재시도해도 결과가 같으므로 함수형으로 걸러내는 것이 실무 패턴:

```js
// 주의: error.status는 queryFn에서 throw할 때 직접 실어야 존재한다
retry: (failureCount, error) => error.status >= 500 && failureCount < 3
```

---

## placeholderData vs initialData

첫 로딩의 스피너를 없애는 두 옵션. **캐시에 들어가느냐**가 가른다 (Node 실측).

| 구분        | placeholderData                   | initialData                      |
| ----------- | --------------------------------- | -------------------------------- |
| 캐시 기록   | **안 됨** (관측용 가짜)           | **됨** (진짜 데이터 취급)        |
| status      | `success`                         | `success`                        |
| 요청        | 나감 (데이터 없는 pending이므로)  | staleTime 내면 **안 나감**       |
| 구분 플래그 | `isPlaceholderData: true`         | 없음 (진짜와 구분 불가)          |
| 용도        | 스켈레톤 대체, 목록→상세 미리보기 | 이미 확보한 **진짜** 데이터 주입 |

- 기준: 그 데이터가 **진짜인가?** 진짜(다른 쿼리 응답의 일부, SSR로 받은 값)면 initialData, 그럴싸한 모양만 있으면 placeholderData
- initialData에 가짜를 넣으면 staleTime 동안 **가짜가 진짜 행세**를 한다

---

## Part 2 정리

- 캐시 항목의 일생: **fresh → stale → inactive → 삭제**
- **staleTime**(기본 0): fresh인 동안 자동 계기로는 요청이 없다. 요청량을 결정하는 다이얼
- stale이면 **캐시를 먼저 보여주고 백그라운드에서 갱신** (stale-while-revalidate)
- 갱신의 계기는 mount / focus / reconnect — 단, **stale일 때만** (폴링 `refetchInterval`은 예외, fresh여도 돈다)
- **gcTime**(기본 5분): 구독자 0이 된 뒤의 보관 기간. 메모리를 결정
- 동시 요청은 **자동 중복 제거**, 실패는 3회 재시도(서버는 0회), 에러는 재시도 소진 후에야 표시

---

## 중간 점검 ① — 뒤집힌 두 시계

`staleTime: 5분, gcTime: 1분`으로 설정된 목록 쿼리가 있다.

```text
00:00  목록 진입          → 요청, 데이터 도착 (fresh)
00:30  상세 화면으로 이동  → 구독자 0
02:30  목록으로 복귀       → ?
```

02:30 복귀 시점에 무슨 일이 일어날까? staleTime 기준으로 데이터는 아직 fresh다(5분 안 지남).

<!-- 진행: "즉시 표시 / 스피너" 손들기. "fresh니까 즉시"가 다수 — 두 시계의 독립성으로 반전. -->

---

## 중간 점검 ① — 정답: 스피너 + 새 요청

- 두 시계는 **서로 독립**이다 — staleTime은 신선도, gcTime은 보관
- 구독자가 0이 된 00:30부터 gcTime 1분 카운트다운 → **01:30에 캐시 삭제**
- "아직 fresh였다"는 사실은 도움이 안 된다. 신선하고 말고 할 **캐시 자체가 없다**
- 02:30 복귀는 빈손 시작 → 스피너 + 새 요청

> staleTime > gcTime이 금지는 아니지만, 낡기도 전에 버려지는 설정이다. 보통 staleTime ≤ gcTime으로 둔다.

---

## 중간 점검 ② — 폴링과 신선도

```js
useQuery({
  queryKey: ['stock', itemId],
  queryFn: fetchStock,
  staleTime: Infinity, // 절대 낡지 않는다
  refetchInterval: 5_000, // 5초 폴링
})
```

staleTime이 Infinity면 데이터는 영원히 fresh다. 5초 폴링은 돌까, 멈출까?

---

## 중간 점검 ② — 정답: 돈다

- mount/focus/reconnect 계기는 `shouldFetchOn` 판정을 거친다 — **stale일 때만** 갱신
- **`refetchInterval`은 이 판정을 거치지 않는다** (실측 확인) — fresh여도 인터벌마다 요청이 나간다
- 그래서 이 조합은 모순이 아니라 유효한 선언이다: "자동 계기로는 갱신하지 말고, 5초마다는 무조건 받아라"

> 네 가지 계기 중 폴링만 신선도와 무관하다. "갱신 = 계기 × stale" 공식의 유일한 예외.

---

## Part 3 — useMutation과 캐시 무효화

<!-- _class: invert -->

---

## 읽기가 아닌 것: useMutation

생성/수정/삭제는 useQuery를 쓰지 않는다.

```jsx
const mutation = useMutation({
  mutationFn: (newTodo) => api.post('/todos', newTodo),
})

<button
  disabled={mutation.isPending}
  onClick={() => mutation.mutate({title: '우유 사기'})}
>
  {mutation.isPending ? '추가 중...' : '추가'}
</button>
```

- useQuery는 마운트되면 **알아서** 실행된다. mutation은 **`mutate()`를 불러야** 실행된다
- 캐시도, 재시도도, 중복 제거도 기본으로는 없다 — 쓰기 작업은 두 번 실행되면 사고이기 때문에 전부 반대가 기본값이다

---

## mutation은 캐시를 건드리지 않는다

```jsx
const mutation = useMutation({mutationFn: addTodo})
mutation.mutate(newTodo)
// 서버에는 저장됐다. 그런데 화면의 목록에는 안 보인다.
```

- `['todos']` 캐시는 mutation이 일어난 걸 **모른다**. 화면은 여전히 옛 목록
- react-query는 mutationFn 안의 POST가 어떤 쿼리를 낡게 만드는지 **알 방법이 없다**
- 그 연결은 내가 선언해야 한다. 도구가 **invalidateQueries**

```jsx
const queryClient = useQueryClient()
const mutation = useMutation({
  mutationFn: addTodo,
  onSuccess: () => {
    queryClient.invalidateQueries({queryKey: ['todos']})
  },
})
```

---

## invalidateQueries가 실제로 하는 일

이름과 달리 캐시를 지우지 않는다. **두 가지**를 한다 (Node 실측):

1. 매칭되는 캐시를 **즉시 stale로** 만든다 — staleTime이 60초 남았어도 무시
2. 그중 **화면에 있는(active) 쿼리는 즉시 refetch**한다

```js
// 실측: staleTime 60초짜리 active 쿼리
await client.invalidateQueries({queryKey: ['active']})
// → queryFn이 즉시 다시 호출됐다 (호출 수 1 → 2)

// inactive 쿼리는 stale 표시만 되고 refetch는 없다
// → 다음에 화면에 등장할 때 갱신된다
```

- 화면에 있는 데이터는 바로 최신화, 화면에 없는 데이터는 **필요해질 때** 최신화
- 낭비 없이 전부 갱신되는, 생명주기 모델과 정확히 맞물린 설계다

---

## 프리픽스 매칭 — 키 설계가 여기서 회수된다

`invalidateQueries`는 키를 **앞에서부터** 매칭한다 (Node 실측):

```js
// 캐시에 세 항목이 있을 때
;['todos', 'list'] // ①
;['todos', 'detail', 1] // ②
;['users'] // ③

client.invalidateQueries({queryKey: ['todos']})
// → ①② stale, ③은 그대로
```

- 할 일을 추가하면 목록도, 상세도, 통계도 낡는다 — `['todos']` 한 방이면 전부 커버
- Part 1에서 "키를 계층적으로 설계하라"고 한 이유
- 정확히 한 항목만 집으려면 `exact: true`

---

## 콜백의 위치 — useMutation vs mutate

콜백은 두 곳에 걸 수 있고, 역할이 다르다.

```jsx
const mutation = useMutation({
  mutationFn: addTodo,
  // ① 정의부: 이 mutation의 본질적 후처리 (항상 실행됨)
  onSuccess: () => queryClient.invalidateQueries({queryKey: ['todos']}),
})

mutation.mutate(newTodo, {
  // ② 호출부: 이 호출 한정의 UI 후처리
  onSuccess: () => navigate('/todos'),
})
```

- 실행 순서는 ① → ②
- 주의: ②는 **컴포넌트가 언마운트되면 실행되지 않는다**. 캐시 무효화처럼 반드시 일어나야 하는 일은 ①에, 화면 전환·토스트처럼 그 화면에서만 의미 있는 일은 ②에

---

## 낙관적 업데이트 — 응답을 기다리지 않기

체크박스 토글마다 서버 응답까지 기다리면 UI가 굼뜨다. **성공을 가정하고 먼저 그린다.**

```text
[ 클릭 ] ──▶ 캐시를 즉시 수정 (화면 반영) ──▶ 서버 요청
                                                │
                             성공 ──▶ 무효화로 서버 진실과 동기화
                             실패 ──▶ 수정 전 스냅샷으로 롤백
```

세 단계가 전부다:

1. **onMutate**: 진행 중인 refetch를 멈추고(`cancelQueries`), 현재 캐시를 **스냅샷**으로 떠 둔 뒤, `setQueryData`로 캐시를 미리 수정
2. **onError**: 스냅샷으로 **롤백**
3. **onSettled**: 성공이든 실패든 **무효화**(invalidateQueries)로 서버 진실과 최종 동기화

---

## 낙관적 업데이트 — 코드

<!-- prettier-ignore -->
```jsx
useMutation({
  mutationFn: toggleTodo,
  onMutate: async (toggled) => {
    await queryClient.cancelQueries({queryKey: ['todos']}) // 진행 중 refetch 차단
    const snapshot = queryClient.getQueryData(['todos'])   // 롤백용 스냅샷
    queryClient.setQueryData(['todos'], (old) =>
      old.map((t) => (t.id === toggled.id ? {...t, done: !t.done} : t)),
    )
    return {snapshot} // context로 전달된다
  },
  onError: (err, toggled, context) => {
    if (context) queryClient.setQueryData(['todos'], context.snapshot) // 롤백
  },
  onSettled: () =>
    queryClient.invalidateQueries({queryKey: ['todos']}), // 최종 동기화
})
```

`setQueryData`의 갱신 함수는 **불변으로** 새 배열을 만들어야 한다. 캐시를 직접 mutate하면 리렌더가 누락된다.

---

## Part 3 정리

- 쓰기는 **useMutation** — 자동 실행도, 캐시도, 재시도도 없는 것이 기본값 (쓰기는 두 번이면 사고)
- mutation과 쿼리 캐시의 연결은 자동이 아니다 — **onSuccess에서 invalidateQueries**
- invalidate = **stale 표시 + active만 즉시 refetch**. inactive는 다음 등장 때
- 키는 **프리픽스로 매칭**된다 — 계층적 키 설계가 여기서 회수된다
- 필수 후처리는 **useMutation 쪽** 콜백에 (mutate 쪽은 언마운트 시 증발)
- 낙관적 업데이트 3박자: **스냅샷 → 미리 그리기 → 실패 시 롤백**, 마무리는 무효화

---

## 중간 점검 ① — 무효화가 증발한다

```jsx
const mutation = useMutation({mutationFn: addTodo})

mutation.mutate(newTodo, {
  onSuccess: () => {
    queryClient.invalidateQueries({queryKey: ['todos']})
    navigate('/todos')
  },
})
```

저장 버튼을 누르자마자 사용자가 뒤로가기로 이 화면을 떠났다. 서버 저장은 성공했다. `['todos']` 무효화는 실행될까?

---

## 중간 점검 ① — 정답: 실행되지 않는다

- `mutate()` **호출부의 콜백은 컴포넌트가 언마운트되면 실행되지 않는다**
- 무효화가 증발했으니 목록 화면은 stale 표시조차 안 된 옛 캐시를 그대로 보여준다
- 반드시 일어나야 하는 후처리는 **useMutation 정의부에**, 그 화면에서만 의미 있는 후처리는 호출부에

<!-- prettier-ignore -->
```jsx
const mutation = useMutation({
  mutationFn: addTodo,
  onSuccess: () =>
    queryClient.invalidateQueries({queryKey: ['todos']}), // 항상 실행
})
mutation.mutate(newTodo, {onSuccess: () => navigate('/todos')}) // 이 화면 한정
```

---

## 중간 점검 ② — 캐시는 바뀌었는데 화면이 그대로

```js
queryClient.setQueryData(['todos'], (old) => {
  old.push(newTodo) // 캐시 배열에 직접 추가
  return old
})
```

`getQueryData`로 확인하면 새 항목이 분명히 들어 있다. 그런데 화면의 목록에는 나타나지 않는다. 왜일까?

---

## 중간 점검 ② — 정답: 참조가 그대로다

- `old.push()`는 배열을 직접 수정한다 — 반환된 것은 **이전과 같은 참조**
- react-query는 **참조 비교로 변화를 감지**한다. 같은 참조 = "바뀐 게 없다" → 구독자에게 알림이 가지 않는다
- 갱신 함수는 불변으로 새 배열을 만들어야 한다:

```js
queryClient.setQueryData(['todos'], (old) => [...old, newTodo]) // 새 배열
```

> "참조가 같으면 같은 데이터"는 react-query의 리렌더 최적화가 깔고 있는 전제다 — 왜 그런지는 2부 Part 4(structural sharing)에서 회수한다.

---

## 1부 전체 요약

1. 서버 상태는 내 것이 아닌 **원본의 사본**이다 — 문제는 저장이 아니라 **동기화 시점**
2. **queryKey가 캐시의 주소** — queryFn이 쓰는 값은 전부 키에 넣는다
3. 상태는 두 축 — status(데이터가 있나) × fetchStatus(요청 중인가)
4. **staleTime이 요청량을, gcTime이 메모리를** 결정한다 — 기본값은 0과 5분
5. stale 캐시는 **먼저 보여주고 뒤에서 갱신**된다 — 계기는 mount/focus/reconnect
6. 쓰기 후에는 **invalidateQueries** — stale 표시 + 화면에 있는 것만 즉시 갱신

---

## 2부 예고

1부는 "어떻게 쓰는가"였다. 2부는 "어떻게 돌아가는가".

- useQuery를 호출하면 내부에서 무슨 일이 벌어지는가 — QueryClient, QueryCache, Query, QueryObserver
- 데이터가 그대로면 리렌더도 없다 — structural sharing과 tracked properties, 그리고 useSuspenseQuery
- 실전에서 자주 틀리는 패턴 여섯 — 구조를 알면 왜 틀렸는지 보인다
- **SSR** — 서버 프리페치와 HydrationBoundary, 그리고 **종합 퀴즈**

---

## 참고 자료

- [TanStack Query 공식 문서](https://tanstack.com/query/v5/docs/framework/react/overview) — v5 기준
- [TanStack/query v5.101.4 소스](https://github.com/TanStack/query/tree/v5.101.4) — 이 덱의 인용 기준 태그
- [Practical React Query](https://tkdodo.eu/blog/practical-react-query) — 메인테이너 TkDodo의 실전 시리즈
- [Caching Examples](https://tanstack.com/query/v5/docs/framework/react/guides/caching) — 공식 문서의 생명주기 예제

---

# 1부 끝

<!-- _class: invert -->

2부에서 내부로 들어간다.

@yceffort

---

# react-query 딥다이브 2부: 내부 동작, 실전 함정, SSR

Source: https://research.yceffort.kr/slides/react-query-deep-dive-2.md

---
title: 'react-query 딥다이브 2부: 내부 동작, 실전 함정, SSR'
marp: true
paginate: true
theme: midnight
tags:
  - data-fetching
  - react
  - caching
date: 2026-08-14
description: 'QueryObserver 구독 모델, structural sharing, HydrationBoundary — 내부 동작에서 실전 함정, SSR까지 react-query 강의 2부'
published: true
art:
  undraw: real-time-sync
---

# react-query 딥다이브 2부

내부 동작, 실전 함정, SSR

<!-- _class: invert -->

@yceffort

---

## 1부 복습 — 여섯 줄

1. 서버 상태는 원본의 사본 — 문제는 저장이 아니라 **동기화 시점**
2. **queryKey**가 캐시의 주소, queryFn이 쓰는 값은 전부 키에
3. 상태는 두 축: status(데이터가 있나) × fetchStatus(요청 중인가)
4. **staleTime**(기본 0)이 요청량을, **gcTime**(기본 5분)이 메모리를 결정
5. stale 캐시는 먼저 보여주고 뒤에서 갱신 — 계기는 mount/focus/reconnect
6. 쓰기 후에는 **invalidateQueries** — stale 표시 + active만 즉시 refetch

지금까지는 외워야 하는 규칙이었다. 2부가 끝나면 **구조에서 유도**할 수 있게 된다.

---

## 퀴즈 ① — 기다려줄까

```jsx
const {data: user} = useQuery({queryKey: ['user'], queryFn: fetchUser})
const {data: orders} = useQuery({
  queryKey: ['orders', user?.id],
  queryFn: () => fetchOrders(user.id),
})
```

첫 렌더에서 두 번째 쿼리에 무슨 일이 일어날까? `user`는 아직 도착하지 않았다.

<!-- 진행: "user를 기다린다"가 다수. 훅에 기다림 같은 건 없다는 점부터 짚는다. -->

---

## 퀴즈 ① — 정답: 기다려주지 않는다

- queryFn이 첫 렌더에 그대로 호출돼 `user.id`에서 **TypeError**가 나고, 쿼리는 error로 끝난다
- 키도 갈라진다. 배열 원소의 `undefined`는 `null`로 직렬화되어 `["orders",null]`이라는 **별도 캐시 항목**이 생긴다 (1부의 "undefined 무시"는 **객체 속성** 한정이다)
- user가 도착하면 `['orders', 7]`로 옮겨가고, 앞의 error 항목은 캐시에 그대로 남는다

```js
enabled: user?.id !== undefined // 조건은 훅을 if로 감싸는 게 아니라 여기에
```

> 종속 쿼리에서 enabled를 빠뜨리면 "가끔 에러"가 아니라 **매번** 에러다. 첫 렌더는 언제나 있기 때문이다.

---

## 퀴즈 ② — 네 값을 불러보자

목록이 이미 화면에 떠 있고, 탭에서 돌아오며 백그라운드 갱신이 도는 중이다.

```jsx
const {status, fetchStatus, isPending, isFetching} = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
})
```

네 값은 각각 무엇일까?

---

## 퀴즈 ② — 정답: success / fetching / false / true

- 데이터가 있으니 `status`는 success, 요청이 날아가고 있으니 `fetchStatus`는 fetching
- `isPending`은 "보여줄 데이터가 없다"는 뜻이므로 여기서는 **false**
- 두 축이 분리된 이유가 바로 이 상태다. "데이터가 있으면서 동시에 요청 중"은 예외가 아니라 **정상 운영 상태**다

> 스피너를 `isFetching`에 걸면 갱신할 때마다 화면이 통째로 스피너가 된다. 첫 진입은 isPending, 갱신 표시는 isFetching.

---

## 퀴즈 ③ — 401을 받았다

```js
useQuery({
  queryKey: ['me'],
  queryFn: async () => {
    const res = await fetch('/api/me')
    if (!res.ok) return
    return res.json()
  },
})
```

서버가 401을 내려줬다. 이 쿼리의 `status`는 무엇일까?

<!-- 진행: error / success 손들기. 답은 error인데, 이유를 물으면 대부분 틀린다. -->

---

## 퀴즈 ③ — 정답: error, 단 이유가 다르다

- **401이라서가 아니다.** queryFn이 `undefined`를 반환해서다
- 에러 메시지도 `["me"] data is undefined`라 서버 상태와 아무 관련이 없다
- 401이든 500이든 이 코드는 같은 에러를 낸다. 화면은 원인을 구분할 방법이 없다

```js
if (!res.ok) throw new Error(`${res.status}`) // 실패는 throw로 알린다
```

> `return`은 성공이고, 그중 `undefined`만 금지다. "데이터 없음"은 `null`로 표현한다.

---

## 퀴즈 ④ — gcTime을 무한으로

재방문할 때마다 스피너가 뜨는 게 싫어서 이렇게 뒀다.

```js
useQuery({queryKey: ['todos'], queryFn: fetchTodos, gcTime: Infinity})
```

이 쿼리의 네트워크 요청은 줄어들까?

---

## 퀴즈 ④ — 정답: 줄지 않는다

- `staleTime`이 기본 0이면 데이터는 도착 즉시 낡는다. 마운트와 포커스 복귀마다 백그라운드 갱신이 그대로 나간다
- gcTime이 막은 것은 **캐시 삭제**뿐이다. 사용자는 스피너 대신 옛 데이터를 먼저 보지만, 뒤에서 요청은 똑같이 나간다
- 덤으로 이 설정은 안 쓰는 캐시가 **영원히 남는다**는 뜻이기도 하다

> 요청량의 손잡이는 **staleTime**, 메모리의 손잡이는 **gcTime**. 서로 독립인 두 시계다.

---

## 퀴즈 ⑤ — 에러 화면은 언제 뜨나

API 서버가 죽어서 모든 요청이 500으로 떨어진다.

```jsx
const {status, error} = useQuery({queryKey: ['todos'], queryFn: fetchTodos})
if (status === 'error') return <ErrorView error={error} />
```

사용자는 이 에러 화면을 얼마나 빨리 보게 될까?

<!-- 진행: "바로 뜬다"가 다수. 기본 retry 값을 되물으면 표정이 바뀐다. -->

---

## 퀴즈 ⑤ — 정답: 빨라야 7초 뒤

- 기본 `retry: 3`에 지수 백오프가 붙는다 — 1초 → 2초 → 4초, 여기에 요청 시간이 더해진다
- `status: 'error'`는 **재시도가 전부 소진된 뒤**에야 된다. 그동안 status는 계속 pending이라 화면은 스피너다
- 4xx는 세 번 더 물어봐도 답이 같다. 보통은 조건을 건다

```js
retry: (failureCount, error) => error.status >= 500 && failureCount < 3
```

> 서버에서는 기본값이 0회다. SSR 중 3회 재시도는 응답 지연으로 직결되기 때문이다.

---

## 퀴즈 ⑥ — 추가는 됐는데

```jsx
const {mutate} = useMutation({mutationFn: createTodo})
// 목록은 다른 컴포넌트에서
useQuery({queryKey: ['todos'], queryFn: fetchTodos})
```

서버가 201을 내려줬고 mutation은 success다. 목록 쿼리는 저절로 갱신될까?

---

## 퀴즈 ⑥ — 정답: 갱신되지 않는다

- mutation은 **캐시에 대해 아무것도 모른다**. 서버에 요청을 보내고 결과를 알려줄 뿐이다
- `['todos']`와 `createTodo` 사이에 연결고리는 없다 — react-query는 데이터 간 의존성을 모른다
- 읽기와 쓰기를 잇는 다리는 자동으로 놓이지 않는다

```js
useMutation({
  mutationFn: createTodo,
  onSuccess: () => queryClient.invalidateQueries({queryKey: ['todos']}),
})
```

> 쓰기 뒤에 무엇을 무효화할지는 자동으로 정해지지 않는다. 키 설계가 여기서 회수된다.

---

## 이 덱에서 다루는 것

4. **내부 동작** — useQuery 한 줄 뒤에서 벌어지는 일 (QueryClient, Query, QueryObserver), 그리고 useSuspenseQuery
5. **실전 함정 모음** — 리뷰에서 반복해서 만나는 잘못된 패턴 여섯 가지
6. **SSR** — 서버 프리페치, dehydrate, HydrationBoundary. 그리고 서버에서 달라지는 것들

1부처럼 파트 끝마다 **중간 점검 퀴즈**가 있고, 마지막에 **종합 퀴즈 6문제** (1부 내용 포함).

기준은 1부와 같다: **TanStack Query v5.101.4**, 소스 인용은 v5.101.4 태그, 동작 확인은 Node.js 24 + query-core 실측.

---

## Part 4 — useQuery 한 줄의 뒤편

<!-- _class: invert -->

---

## 그 전에 — 도서관 하나

이름부터 외울 필요는 없다. 내부 배치는 **도서관**과 같다.

| 도서관                        | react-query   |
| ----------------------------- | ------------- |
| 도서관 (운영 주체)            | QueryClient   |
| 서가 — 청구기호로 정리된 책장 | QueryCache    |
| 책 한 권                      | Query         |
| 그 책에 걸어둔 알림 신청      | QueryObserver |

- 내가 집에 가도 **책은 서가에 남는다** → 언마운트해도 캐시가 남는 이유
- 같은 책을 셋이 신청해도 사서는 **한 번만** 꺼내온다 → 요청 중복 제거
- 아무도 찾지 않는 책은 정해둔 기간 뒤 폐기 → gcTime

1부에서 외웠던 규칙들이, 여기서부터는 **구조를 보면 당연해진다**.

---

## 등장인물 네 명

```text
[ useQuery ]  ──만들고 구독──▶  [ QueryObserver ]   훅 호출당 1개
                                      │ 구독
                                      ▼
[ QueryClient ] ──보관──▶ [ QueryCache ] ──보관──▶ [ Query ]   키당 1개
     지휘자                 Map(해시 → Query)         상태+데이터 본체
```

| 객체          | 정체                                        | 수명                                   |
| ------------- | ------------------------------------------- | -------------------------------------- |
| QueryClient   | 전체 지휘자. invalidate 등 명령의 진입점    | 앱과 함께                              |
| QueryCache    | `Map<키 해시, Query>` 그 자체               | 앱과 함께                              |
| Query         | 키 하나의 데이터·상태·요청을 들고 있는 본체 | gcTime까지                             |
| QueryObserver | Query를 구독하는 관찰자                     | 훅 호출과 함께 (useQuery 두 번이면 둘) |

---

## Query — 컴포넌트 밖에서 산다

1부의 규칙들이 유도되는 첫 지점: **데이터의 주인은 컴포넌트가 아니라 Query 객체**다.

```text
[ Query ('["todos"]') ]
   ├─ state: { data, status, fetchStatus, dataUpdatedAt, ... }
   ├─ 진행 중인 요청 (retryer)     ← 재시도도 여기서
   ├─ GC 타이머                    ← gcTime도 여기서
   └─ observers: [ 관찰자 목록 ]   ← 구독자가 0이면 GC 시작
```

- 캐시(`QueryCache`)는 키 해시로 Query를 찾는 Map이다 — 소스(`queryCache.ts`): `this.#queries.get(query.queryHash)`
- 컴포넌트가 언마운트돼도 Query는 남는다 → **돌아오면 즉시 표시**가 가능한 이유
- 요청 상태도 Query가 가진다 → 같은 키의 요청이 **자동으로 하나로 합쳐지는** 이유 (1부의 중복 제거)

---

## 잠깐 — 옵저버 패턴이란

유튜브 채널 구독과 같다. 내가 계속 새로고침하지 않고, **구독해 두면 채널이 알려준다**.

```js
class Channel {
  #subscribers = new Set()
  subscribe(fn) {
    this.#subscribers.add(fn)
    return () => this.#subscribers.delete(fn) // 구독 해제 함수를 돌려준다
  }
  upload(video) {
    this.#subscribers.forEach((fn) => fn(video)) // 바뀌면 구독자 전원에게 알린다
  }
}
const off = channel.subscribe((v) => console.log('알림:', v)) // 구독 신청
off() // 해제. 이제 upload가 일어나도 조용하다
```

- 바뀌는 쪽(채널)이 **구독자 목록**을 들고, 바뀌는 순간 목록을 돌며 알린다. 보는 쪽은 기다릴 뿐
- 채널은 구독자가 몇 명인지 안다. 그래서 "아무도 안 본다"를 **판단할 수 있다**

---

## react-query는 이 패턴 그대로다

| 옵저버 패턴      | react-query                                                   |
| ---------------- | ------------------------------------------------------------- |
| 채널 (바뀌는 쪽) | Query                                                         |
| 구독자           | QueryObserver (해제 함수는 언마운트 때 호출)                  |
| 구독자 0명       | 마지막 구독자가 떠나면 `scheduleGc()`. gcTime 타이머의 출발점 |

```ts
// query.ts (v5.101.4) 요지. 앞 장 그림의 observers가 곧 구독자 목록이다
removeObserver(observer) {
  this.observers = this.observers.filter((x) => x !== observer)
  if (!this.observers.length) this.scheduleGc() // 아무도 안 보면 GC 예약
}
#dispatch(action) {
  this.observers.forEach((observer) => observer.onQueryUpdate()) // 전원에게 알림
}
```

1부에서 외웠던 "구독자가 0이면 GC 시작"은 규칙이 아니라 이 패턴의 **자연스러운 결과**다.

---

## QueryObserver — 컴포넌트의 대리인

컴포넌트가 Query를 직접 보지 않는다. 사이에 **관찰자**가 있다.

```text
<TodoList />  ──▶  QueryObserver A ──┐
<Header />    ──▶  QueryObserver B ──┼──구독──▶  Query ('["todos"]')
<Sidebar />   ──▶  QueryObserver C ──┘
```

QueryObserver가 하는 일:

- 자기 컴포넌트의 **옵션**(staleTime, enabled, select...)을 들고 있다 — 같은 Query를 봐도 컴포넌트마다 옵션이 다를 수 있다
- Query의 상태 변화를 받아 **자기 컴포넌트만** 다시 그리게 한다
- 마운트 시 "다시 요청이 필요한가"를 판정한다 (1부의 `shouldFetchOn` 판정을 이 객체의 `shouldFetchOnWindowFocus`/`shouldFetchOnReconnect`가 호출한다)

---

## useQuery는 데이터를 가져오지 않는다

소스를 보기 전에 문장으로. useQuery가 하는 일은 셋뿐이다.

```text
① 만든다   — 이 컴포넌트 몫의 QueryObserver를 하나 만든다
② 구독한다 — 그 Observer를 통해 Query의 변화를 듣는다
③ 받는다   — 지금 상태를 {data, status, ...} 모양으로 돌려받는다
```

- useQuery는 **캐시에 붙는 코드**지 요청을 보내는 코드가 아니다
- 요청, 재시도, GC는 전부 Query가 한다 — 도서관 비유로는 사서의 일
- 같은 키로 열 번 호출해도 요청이 한 번인 이유: 신청서만 열 장 걸렸을 뿐이다

다음 장에서 이 셋을 실제 소스로 확인한다. 코드는 낯설어도 하는 일은 위의 셋 그대로다.

---

## useQuery = 만들고, 구독하고, 반환한다

앞 장의 셋을 `useBaseQuery.ts`(v5.101.4)에서 그대로 확인할 수 있다:

```js
// ① Observer를 만든다 (훅 호출당 하나, 리렌더 간 유지)
const [observer] = React.useState(() => new QueryObserver(client, options))

// ② useSyncExternalStore — React 바깥의 값을 구독하는 React 18 공식 API
React.useSyncExternalStore(
  (cb) => observer.subscribe(notifyManager.batchCalls(cb)),
  () => observer.getCurrentResult(),
)

// ③ 결과를 그대로 주지 않고 trackResult로 한 번 감싸 반환한다
return observer.trackResult(result)
```

- 여기서 "바깥의 값"이 곧 Query다. 로직은 useQuery가 아니라 프레임워크 무관한 **query-core**에 있고 React는 구독만 한다 (Vue/Svelte/Solid 어댑터가 존재하는 이유)
- 외울 이름은 둘. ②의 `batchCalls`와 ③의 `trackResult`가 다음 장들의 주인공이다

---

## 리렌더 최적화 ① — tracked properties

Query가 바뀔 때마다 구독한 컴포넌트를 전부 다시 그리면 낭비다. Observer는 **컴포넌트가 실제로 읽은 필드**만 추적한다.

```jsx
const {data} = useQuery({queryKey: ['todos'], queryFn: fetchTodos})
// data만 구조분해했다
// → 백그라운드 갱신으로 isFetching이 true/false로 바뀌어도
//   data가 그대로면 이 컴포넌트는 리렌더되지 않는다
```

- 소스(`queryObserver.ts`): `trackResult()`가 결과 객체를 **Proxy로 감싸**, 필드를 읽는 순간 그 이름을 구독 목록에 등록한다
- 읽지 않은 필드의 변화는 알림 대상이 아니다. 단, **아무 필드도 읽지 않았다면** 안전을 위해 모든 변화에 알림이 온다
- 반대로 `isFetching`을 읽으면 갱신 인디케이터가 가능해지는 대신, 요청 시작/종료마다 두 번의 리렌더를 지불한다 — **읽는 것에만 비용을 낸다**

---

## 리렌더 최적화 ② — structural sharing

**구조 공유**: 새 응답을 옛 데이터와 깊이 비교해, 바뀐 가지만 새로 만들고 나머지는 **옛 참조를 그대로 재사용**한다. JSON 파싱은 매번 새 객체를 뱉지만 캐시에 들어가는 것은 그 결과가 아니다. Node 실측:

```js
// 내용이 완전히 같은 응답을 두 번 받으면: data1 === data2 (참조 유지)

// id 2의 done만 바뀐 응답이라면, 바뀐 가지만 새 객체가 된다
prev.list[0] === next.list[0] // true  (안 바뀐 항목: 참조 유지)
prev.list[1] === next.list[1] // false (바뀐 항목만 교체)
prev.meta === next.meta // true
```

- 소스: `replaceEqualDeep()` — 새 데이터를 옛 데이터와 깊이 비교해 같은 부분은 옛 참조를 재사용
- tracked properties와 합쳐지면: **내용이 같으면 `data` 참조가 같고, 참조가 같으면 리렌더가 없다**
- `useMemo`/`useEffect`의 deps에 `data`를 넣어도 안전한 이유

---

## notifyManager — 알림은 모아서

응답 하나에 Query의 상태는 여러 번 바뀐다. 그대로 흘려보내면 리렌더가 곱셈으로 불어난다.

```text
응답 도착 ─▶ Query 상태가 연달아 바뀜 (data 반영 → fetching 종료)
                  │   그대로 흘리면: 변경 2 × 구독자 3 = 최대 6번 리렌더
                  ▼
        [ notifyManager ]  ← 알림을 모았다가 한 틱 뒤에 한 번에
                  │
                  ▼
        React 18의 automatic batching ─▶ 리렌더 1번
```

- 소스: `notifyManager.ts` — 변경 알림을 모아뒀다가 다음 틱에 몰아서 flush한다
- 리렌더를 실제로 합치는 주체는 **React 18의 automatic batching**이다. notifyManager는 알림들이 같은 틱에 떨어지게 만들어 그 조건을 갖춰준다
- 외울 것은 한 줄: **알림은 뭉쳐서 오고, React가 한 번에 그린다**

<!-- 구두 보충: flush는 setTimeout(0), 즉 프레임이 아니라 매크로태스크 단위다. -->

---

## select — 컴포넌트 전용 가공

Observer가 useQuery 호출마다 있다는 사실의 활용: **같은 캐시, 다른 모양**.

```jsx
// 캐시에는 전체 목록 하나만 있다
const {data: count} = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  select: (todos) => todos.filter((t) => !t.done).length,
})
// count는 숫자. 이 컴포넌트는 "미완료 개수가 바뀔 때만" 리렌더
```

- select는 **캐시를 바꾸지 않는다**. Observer가 반환 직전에 가공할 뿐
- 결과가 이전과 같으면(참조/값) 리렌더 없음 — 목록 순서만 바뀌어도 개수가 같으면 조용하다
- 주의: select에 **인라인으로 무거운 계산**을 넣으면 리렌더마다 재실행된다. 무거우면 `useCallback`이나 컴포넌트 밖 함수로

---

## useSuspenseQuery — 분기 없는 세계

useQuery가 Observer를 **구독**하는 훅이라면, 같은 Observer를 다른 방식으로 소비하는 훅이 하나 더 있다.

```jsx
function TodoList() {
  const {data} = useSuspenseQuery({queryKey: ['todos'], queryFn: fetchTodos})
  return <ul>{data.map(...)}</ul> // data는 항상 있다. 타입도 Todo[] (undefined 없음)
}

// 로딩과 에러는 바깥에서 선언한다
<ErrorBoundary fallback={<ErrorView />}>
  <Suspense fallback={<Spinner />}><TodoList /></Suspense>
</ErrorBoundary>
```

- `status === 'pending'` 분기가 사라진다. 데이터가 아직 없으면 컴포넌트가 **Promise를 throw**하고, React가 그것을 받아 가장 가까운 `<Suspense>`의 fallback을 대신 그린다. Promise가 풀리면 그 컴포넌트만 다시 렌더한다
- 에러도 같은 방식으로 throw되어 ErrorBoundary가 받는다. 대가로 `enabled`, `placeholderData`, `throwOnError`는 **타입에서 빠졌다** — "데이터는 항상 있고 에러는 항상 던져진다"는 모델과 모순이기 때문이다

---

## Suspense의 함정 — 요청이 직렬이 된다

```jsx
function Dashboard() {
  const {data: user} = useSuspenseQuery({queryKey: ['user'], ...})
  // ↑ 여기서 suspend. 아래 줄은 user가 도착해야 실행된다
  const {data: stats} = useSuspenseQuery({queryKey: ['stats'], ...})
}
```

```text
useQuery 2개:          [ user ──────]
                       [ stats ─────]        총 1초

useSuspenseQuery 2개:  [ user ──────][ stats ─────]  총 2초
```

- suspend는 **함수 실행을 그 줄에서 중단**하는 것이다. 두 번째 훅은 첫 응답 전엔 호출조차 안 된다 (Promise 덱의 "순차 await" 함정과 같은 구조)
- 서로 독립인 쿼리 여럿은 **`useSuspenseQueries`**로 병렬 선언한다

---

## Part 4 정리

- 데이터의 주인은 컴포넌트가 아니라 **Query** — 언마운트 후 생존, 요청 합류, GC가 전부 여기서 나온다
- 컴포넌트는 **QueryObserver**를 통해 구독한다 — Observer는 훅 호출당 하나, 옵션은 호출마다, 데이터는 키마다
- useQuery는 **useSyncExternalStore 구독**일 뿐, 로직은 프레임워크 무관한 query-core에 있다
- **tracked properties**: 읽은 필드만 리렌더 사유가 된다
- **structural sharing**: 내용이 같으면 참조도 같다 → deps에 data를 넣어도 안전
- **select**: 캐시는 하나, 컴포넌트마다 다른 단면 — 가공 결과가 같으면 리렌더 없음
- **useSuspenseQuery**: 같은 Observer, pending이면 Promise를 throw → 분기가 사라진다. 한 컴포넌트에 여럿이면 **직렬**, 독립 쿼리는 `useSuspenseQueries`

---

## 중간 점검 ① — 몇 개나 만들어질까

```jsx
function Header() {
  const {data} = useQuery({queryKey: ['me'], queryFn: fetchMe})
}
function Sidebar() {
  const {data} = useQuery({queryKey: ['me'], queryFn: fetchMe})
  const {data: unread} = useQuery({
    queryKey: ['me'],
    queryFn: fetchMe,
    select: (me) => me.unreadCount,
  })
}
```

두 컴포넌트가 같은 화면에서 함께 마운트된다. Query와 QueryObserver는 각각 몇 개 만들어지고, 네트워크 요청은 몇 번 나갈까?

---

## 중간 점검 ① — 정답: Query 1개, Observer 3개, 요청 1번

- Query는 **키당 1개** — 셋 다 `['me']`이므로 본체는 하나
- Observer는 **useQuery 호출당 1개** — Header 1 + Sidebar 2 = 3개 ("컴포넌트당 1개"가 아니다)
- 동시 요청은 같은 Query에 합류하므로 **1번** (1부의 중복 제거)
- select는 Observer의 일이다 — 캐시는 하나인데 Sidebar의 두 번째 훅만 숫자를 받는 이유

> 데이터와 요청은 Query(키)마다, 옵션과 가공은 Observer(호출)마다.

---

## 중간 점검 ② — 목록이 바뀌었는데 조용하다

```jsx
const {data: count} = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  select: (todos) => todos.filter((t) => !t.done).length,
})
```

백그라운드 갱신이 일어났고, 응답에서 **완료된 항목 하나의 제목이 바뀌었다** (미완료 개수는 그대로). 이 컴포넌트는 리렌더될까?

<!-- 진행: "캐시 데이터는 분명히 바뀌었다"를 먼저 합의시키고 손들기 — select가 어느 층에서 일하는지로 반전. -->

---

## 중간 점검 ② — 정답: 리렌더되지 않는다

- 캐시의 data는 바뀌었다 — 제목이 바뀐 항목은 structural sharing을 거쳐도 새 참조가 된다
- 하지만 이 Observer가 컴포넌트에 반환하는 것은 **select를 거친 숫자**이고, 미완료 개수는 그대로다
- 가공 결과가 이전과 같으면 알림이 없다 — "이 컴포넌트의 관심사는 미완료 개수뿐"이라고 select로 선언했기 때문

> 리렌더 방어는 겹겹이다: 읽은 필드만(tracked properties), 내용이 같으면 같은 참조(structural sharing), 가공 결과가 같으면 침묵(select).

---

## 중간 점검 ③ — 1초짜리 요청 셋

```jsx
function Dashboard() {
  const {data: user} = useSuspenseQuery({queryKey: ['user'], ...})
  const {data: stats} = useSuspenseQuery({queryKey: ['stats'], ...})
  const {data: news} = useSuspenseQuery({queryKey: ['news'], ...})
}
```

세 요청은 각각 1초 걸리고 서로 독립이다. 화면이 뜨기까지 몇 초일까? 어떻게 고칠까?

---

## 중간 점검 ③ — 정답: 3초. useSuspenseQueries로 1초

- suspend는 **그 줄에서 함수 실행을 중단**한다 — 두 번째 훅은 user가 도착하기 전엔 호출조차 안 된다
- 1초 × 3 직렬 = 3초 (워터폴)
- 서로 독립인 쿼리는 `useSuspenseQueries`로 한 번에 선언 → 병렬 1초

단, stats가 user의 결과를 필요로 하는 **종속 쿼리라면 직렬이 정답**이다 — 그때는 워터폴이 아니라 의존성이다.

---

## Part 5 — 실전에서 반복되는 함정 여섯

<!-- _class: invert -->

전부 실제 코드 리뷰에서 반복해서 만나는 패턴들이다.

---

## 함정 1 — 흩어진 queryKey

```js
// list.tsx
useQuery({queryKey: ['todos', 'list'], ...})
// mutation.ts
queryClient.invalidateQueries({queryKey: ['todo', 'list']}) // todos가 아니라 todo
```

- 오타·단복수 불일치는 에러가 아니다. **조용히 아무 일도 안 일어난다** (없는 키를 무효화했을 뿐)
- 해결: 키와 fn을 한 곳에 선언하는 **queryOptions 헬퍼**

```ts
// queries/todos.ts — 키의 유일한 출처
export const todoListOptions = queryOptions({
  queryKey: ['todos', 'list'],
  queryFn: fetchTodos,
})

useQuery(todoListOptions)
queryClient.invalidateQueries({queryKey: todoListOptions.queryKey})
```

타입 추론도 따라온다. v5에서 키 관리의 사실상 표준.

<!-- 구두 보충: "사실상 표준"의 근거 — queryOptions는 v5 공식 문서의 TypeScript 가이드가 권하는 패턴이고, 메인테이너 TkDodo의 "The Query Options API" 글이 배경 설명. 팩토리 객체(todoKeys.list() 류)로 더 키우는 팀도 많다. -->

---

## 함정 2 — 파라미터 바꾸고 refetch()

```jsx
// ❌ page는 클로저로, 갱신은 수동으로
const {data, refetch} = useQuery({
  queryKey: ['orders'],
  queryFn: () => fetchOrders(page),
})
useEffect(() => {
  refetch()
}, [page])
```

- `['orders']` 캐시 하나를 페이지마다 **덮어쓴다** — 1페이지로 돌아가도 즉시 표시가 안 된다
- 키와 fn이 어긋난 상태라 중복 제거, invalidate, 그리고 Part 6의 SSR hydrate까지 전부 오작동의 씨앗

<!-- prettier-ignore -->
```jsx
// ✅ 파라미터는 키에. refetch는 필요 없다
useQuery({queryKey: ['orders', page], queryFn: () => fetchOrders(page)})
```

`refetch`의 정당한 용도는 "**같은 키를 명시적 계기로 다시**"(새로고침 버튼)뿐이다.

---

## 함정 3 — data를 useEffect로 복사

```jsx
// ❌ 서버 상태를 클라이언트 상태로 복사
const {data} = useQuery({queryKey: ['todos'], queryFn: fetchTodos})
const [todos, setTodos] = useState([])
useEffect(() => {
  if (data) setTodos(data)
}, [data])
```

- 렌더가 한 박자 늦고, 백그라운드 갱신이 `todos`에 반영 안 되는 순간이 생기고, "어느 쪽이 진실인가" 문제가 시작된다
- 파생값은 **렌더 중 계산**하거나 **select**로:

```jsx
const {data: undone} = useQuery({
  ...todoListOptions,
  select: (todos) => todos.filter((t) => !t.done),
})
```

- 예외는 편집 화면의 로컬 초안뿐 — "서버 값은 초기값"이라고 선을 긋고 복사한다

---

## 함정 4 — invalidate를 기다리지 않기

```jsx
// ❌ 폼 제출 → 목록으로 이동. 그런데 옛 목록이 잠깐 보인다
onSuccess: () => {
  queryClient.invalidateQueries({queryKey: ['todos']})
  navigate('/todos') // invalidate의 refetch는 아직 진행 중
}
```

- `invalidateQueries`는 **Promise를 반환**한다 — active 쿼리들의 refetch가 끝날 때까지
- 갱신 완료를 보장하고 이동해야 한다면 await:

```jsx
onSuccess: async () => {
  await queryClient.invalidateQueries({queryKey: ['todos']})
  navigate('/todos')
}
```

- 반대로 "이동 먼저, 갱신은 백그라운드"가 낫다면 await 없이 — **선택이라는 것을 알고 고르는 것**과 모르는 것의 차이

<!-- 구두 보충: await하면 그만큼 버튼이 pending에 오래 머문다는 트레이드오프를 꼭 언급. 목록 화면이 어차피 스피너/기존 캐시를 보여줄 수 있으면 await 없는 쪽이 체감이 좋은 경우도 많다. -->

---

## 함정 5 — 낙관적 업데이트에서 cancelQueries 생략

1부의 낙관적 업데이트에서 첫 줄이 왜 `cancelQueries`였는가.

```text
t0  포커스 계기 → ['todos'] 백그라운드 갱신 시작 (옛 목록 응답 대기)
t1  사용자가 체크박스 클릭 → setQueryData로 캐시 미리 수정 (화면 반영)
t2  t0의 응답 도착 → 서버의 "옛" 목록이 캐시를 덮어쓴다
    → 체크가 풀린 것처럼 보인다. 잠시 후 무효화가 최신을 가져와 돌아온다 (깜빡임)
```

- 낙관적 수정과 진행 중이던 백그라운드 갱신의 **레이스 컨디션**이다
- `await queryClient.cancelQueries({queryKey: ['todos']})`가 t0의 응답을 버리게 해서 이 창을 닫는다
- 1부 인트로 코드의 레이스 컨디션을 react-query가 없애줬지만, **캐시를 직접 만지는 순간 레이스는 내 책임으로 돌아온다**

---

## 함정 6 — 에러 처리를 컴포넌트마다 복붙

- v5는 useQuery의 `onError` 콜백을 **제거**했다 — 구독하는 useQuery 호출마다 실행돼, 같은 토스트가 구독 수만큼(Part 4 그림이라면 세 번) 뜨는 문제 때문
- 전역 처리는 **QueryCache 레벨**에 한 번만 건다:

```js
const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error, query) => {
      if (query.state.data !== undefined) {
        // 데이터가 이미 화면에 있는 백그라운드 갱신 실패만 토스트
        toast.error(`갱신 실패: ${error.message}`)
      }
    },
  }),
})
```

- 첫 로딩 실패는 컴포넌트의 `status === 'error'` 분기(또는 ErrorBoundary)가, 백그라운드 실패는 전역 토스트가 — 역할 분담이 선명해진다

---

## Part 5 정리 — 체크리스트

리뷰할 때 이 여섯을 순서대로 본다.

- [ ] queryKey가 **queryOptions 헬퍼**로 한 곳에 모여 있는가
- [ ] `refetch()` 호출이 있다면 — 파라미터가 키에 안 들어간 신호 아닌가
- [ ] `useEffect`로 data를 **useState에 복사**하고 있지 않은가
- [ ] invalidate 후 이동/후속 동작 — **await가 필요한 상황**인가
- [ ] `setQueryData` 앞에 **cancelQueries**가 있는가
- [ ] 에러 토스트가 컴포넌트마다 있는가 — **QueryCache onError** 한 곳이면 되는가

---

## Part 6 — SSR

<!-- _class: invert -->

---

## SSR — 문제 정의

지금까지의 모델은 전부 브라우저 안 이야기였다. 서버 렌더링이 끼면 문제가 생긴다.

```text
[ 서버 ] HTML 렌더                    [ 브라우저 ] hydrate 후
   QueryClient A                         QueryClient B
   캐시: (요청해서 채움)                  캐시: 비어 있음!
        │                                    │
        └── HTML엔 데이터가 박혀 있는데 ──────┘
            클라이언트 캐시는 빈손 → 스피너 → 재요청 (깜빡임)
```

서버가 이미 받은 데이터를 **클라이언트 캐시에 넘겨주는 통로**가 필요하다.

react-query의 답: 서버 캐시를 **직렬화(dehydrate)** 해서 HTML에 실어 보내고, 클라이언트에서 **복원(hydrate)** 한다. (React가 HTML에 이벤트를 붙이는 hydration과는 별개의, 캐시 쪽 용어다)

---

## 표준 패턴 — prefetch, dehydrate, HydrationBoundary

Next.js App Router의 서버 컴포넌트에서:

```jsx
export default async function Page() {
  const queryClient = new QueryClient() // 요청마다 새로 (뒤에서)
  await queryClient.prefetchQuery({queryKey: ['todos'], queryFn: fetchTodos})

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <TodoList /> {/* 'use client' 컴포넌트 */}
    </HydrationBoundary>
  )
}
```

- `prefetchQuery`: 서버 캐시를 채운다. `await`를 붙여야 dehydrate 시점에 데이터가 들어 있다
- `dehydrate(queryClient)`: 그 캐시를 **JSON으로 직렬화**해 HTML에 싣는다
- `HydrationBoundary`: 자식이 렌더되기 전에 그 JSON을 **브라우저 캐시에 부어 넣는다**

---

## 클라이언트는 아무것도 몰라도 된다

```jsx
'use client'
function TodoList() {
  const {data} = useQuery({queryKey: ['todos'], queryFn: fetchTodos})
  // 서버에서 hydrate된 캐시가 이미 있다
  // → 첫 렌더부터 data 있음, 요청 없음, 스피너 없음
}
```

- 컴포넌트 코드는 CSR일 때와 **완전히 같다**. "서버에서 미리 받았는지"는 캐시 상태의 문제일 뿐
- **키가 연결 고리다**: prefetch한 키와 useQuery의 키가 다르면 hydrate는 무의미해지고 조용히 재요청한다 (함정 1의 queryOptions가 이 병을 막는다)
- hydrate된 데이터도 평범한 캐시다 — `dataUpdatedAt`은 서버에서 받은 시각, staleTime이 지났으면 마운트 계기로 백그라운드 갱신. 1부의 생명주기가 그대로 적용된다. 그래서 SSR에서는 **staleTime을 0보다 크게** 두는 것이 정석이다 (방금 받은 데이터를 hydrate 직후 또 요청하는 낭비 방지)

---

## 클라이언트가 둘이라는 것

SSR에서 QueryClient는 **두 개**다. 이름이 같을 뿐 하는 일이 다르다.

| 구분 | 서버의 QueryClient                        | 브라우저의 QueryClient         |
| ---- | ----------------------------------------- | ------------------------------ |
| 정체 | dehydrate할 데이터를 담는 **일회용 그릇** | 앱이 사는 동안의 **진짜 캐시** |
| 개수 | prefetch하는 서버 컴포넌트마다            | 앱 전체에 하나                 |
| 수명 | 응답을 보내면 버려진다                    | 탭을 닫을 때까지               |

- 서버 쪽은 **캐시로 쓸 물건이 아니다**. 담고, 직렬화하고, 버린다. 그게 전부다
- Provider용 클라이언트는 공식 `getQueryClient()`가 한 파일에서 분기한다: `isServer`면 매번 새로, 브라우저면 `browserQueryClient ??= ...`로 하나만 유지

<!-- 구두 보충: 공식 문서는 Provider 클라이언트를 useState로 만들지 말라고 경고한다. suspend할 수 있는 코드와 사이에 Suspense 경계가 없으면 첫 렌더에서 React가 클라이언트를 버리기 때문. -->

---

## QueryClient는 요청마다 새로

```js
// ❌ 모듈 스코프 싱글턴 — 브라우저에선 정석, 서버에선 사고
const queryClient = new QueryClient()

// ✅ 서버 컴포넌트 안에서 생성
export default async function Page() {
  const queryClient = new QueryClient()
  ...
}
```

- **안전**: 서버 프로세스는 모든 사용자가 공유한다. 모듈 스코프 캐시에 A의 `['me']`가 남아 있으면 **B의 렌더에 A의 개인정보**가 실려 나간다
- **잃는 것도 없다**: 어차피 직렬화하고 버릴 그릇이라 재사용해서 얻을 게 없다. 공식 가이드도 한 요청에서 queryClient를 여러 개 만들어 각각 dehydrate해도 된다고 명시한다
- 한 요청 안의 여러 서버 컴포넌트가 하나를 공유하게 하려면 React의 `cache()`로 감싼다 — `cache(() => new QueryClient())`는 **요청 스코프**라 요청 사이로는 새지 않는다

---

## 서버에서는 기본값이 다르다

1부에서 미뤄둔 소스를 회수한다. query-core는 서버 환경을 감지해 기본값을 바꾼다.

```ts
// retryer.ts — 재시도
const retry = config.retry ?? (environmentManager.isServer() ? 0 : 3)

// removable.ts — GC. 구독자들이 다른 gcTime을 주면 최댓값이 이긴다
this.gcTime = Math.max(
  this.gcTime || 0,
  newGcTime ?? (environmentManager.isServer() ? Infinity : 5 * 60 * 1000),
)
```

- **retry 0**: SSR 중 3회 재시도는 곧 응답 지연이다. 서버는 빠르게 실패하고, 클라이언트에서 다시 시도하는 편이 낫다
- **gcTime Infinity**: 서버의 QueryClient는 요청과 함께 버려진다. GC 타이머를 걸어봐야 실행될 일이 없고, 타이머 자체가 누수가 된다

<!-- 구두 보충: isServer 판정이 environmentManager로 감싸져 있는 이유 — 테스트 등에서 setIsServer로 런타임 오버라이드가 가능하도록 v5.101에서 간접화됐다. -->

---

## Part 6 정리

- SSR의 통로: **prefetchQuery → dehydrate → HydrationBoundary** — 클라이언트 코드는 그대로
- hydrate된 데이터도 평범한 캐시 — 1부의 생명주기가 그대로 적용
- 서버의 QueryClient는 **요청마다 새로** — 싱글턴은 사용자 간 데이터 유출
- 서버 기본값: retry 0, gcTime Infinity. 그리고 SSR에서는 **staleTime > 0**으로 hydrate 직후 재요청을 막는 것이 정석

---

## 중간 점검 — prefetch가 무시된다

```jsx
// 서버 컴포넌트
await queryClient.prefetchQuery({queryKey: ['todos'], queryFn: fetchTodos})

// 클라이언트 컴포넌트
const {data} = useQuery({queryKey: ['todos', 'list'], queryFn: fetchTodos})
```

프리페치도, HydrationBoundary도 정상 동작했다. 그런데 첫 화면에 스피너가 뜨고 요청이 다시 나간다. 왜일까?

---

## 중간 점검 — 정답: 키가 다르다

- hydrate는 잘 됐다 — 클라이언트 캐시에 `['todos']` 항목이 들어 있다
- 하지만 useQuery가 보는 주소는 `['todos', 'list']` — **다른 캐시 항목**이므로 빈손에서 시작한다
- 에러도 경고도 없다. 프리픽스 관계여도 소용없다 (프리픽스 매칭은 invalidate의 기능이고, 조회는 정확한 키)
- 예방: 서버와 클라이언트가 **같은 queryOptions 헬퍼**를 import하게 한다 (함정 1의 queryOptions)

> SSR 최적화가 통째로 무시되는데 화면은 멀쩡히 돌아간다 — 가장 발견이 늦는 종류의 버그다.

---

## 종합 퀴즈

<!-- _class: invert -->

여섯 문제. 1부 내용도 포함이다.

---

## 퀴즈 1 — 요청이 나갈까 (1부 Part 2)

```jsx
// A 컴포넌트 (이미 마운트되어 화면에 있음)
useQuery({queryKey: ['user'], queryFn: fetchUser, staleTime: 60_000})

// 10초 뒤, B 컴포넌트가 새로 마운트된다
useQuery({queryKey: ['user'], queryFn: fetchUser, staleTime: 60_000})
```

B가 마운트될 때 네트워크 요청이 나갈까? staleTime이 0이었다면?

<!-- 진행: "나간다/안 나간다" 손들기. "마운트하면 무조건 요청"이라고 아는 사람이 많아 "나간다"가 우세한 편 — shouldFetchOn의 stale 조건으로 반전시킨다. -->

---

## 퀴즈 1 — 정답

**나가지 않는다.** staleTime 0이었다면 **나간다.**

- 데이터 도착 10초 뒤 = 아직 fresh → 마운트 계기가 와도 `shouldFetchOn`은 **stale일 때만** 통과시킨다
- B는 캐시를 즉시 받는다 (요청 0회)
- staleTime 0이면 도착 즉시 stale → 마운트 계기에 백그라운드 갱신. 단, 이때도 B는 캐시부터 즉시 보여준다 (스피너 없음)

> 갱신을 결정하는 것은 "계기 × 신선도"의 곱이다. 계기만으로는 아무 일도 일어나지 않는다.

---

## 퀴즈 2 — 같은 캐시일까 (1부 Part 1)

```js
// ① 과 ②, ③ 과 ④ — 각각 같은 캐시 항목을 볼까?
useQuery({queryKey: ['todos', {page: 1, size: 10}], ...}) // ①
useQuery({queryKey: ['todos', {size: 10, page: 1}], ...}) // ②

useQuery({queryKey: ['users', 42], ...})   // ③
useQuery({queryKey: ['users', '42'], ...}) // ④
```

---

## 퀴즈 2 — 정답: ①②는 같고, ③④는 다르다

- 해시는 객체의 **키를 정렬한 뒤** 직렬화한다 → 객체 속성 순서는 무관 (①=②)
- 타입은 구분한다 → 숫자 42와 문자열 '42'는 **다른 캐시** (③≠④)
- ③④가 실무 단골 사고다: `useParams()`는 문자열을 주고, 직접 만든 키는 숫자를 쓰면 같은 유저가 캐시 두 벌로 갈라진다

> 키를 queryOptions 헬퍼로 모으면 타입까지 한 곳에서 강제된다 (함정 1).

---

## 퀴즈 3 — invalidate의 사정거리 (1부 Part 3)

```js
// 화면에 있는 것: ['todos', 'list']
// 화면에 없는 것(캐시만 생존): ['todos', 'detail', 7], ['stats']

queryClient.invalidateQueries({queryKey: ['todos']})
```

각 캐시 항목에 무슨 일이 일어날까?

---

## 퀴즈 3 — 정답

| 항목                     | 매칭         | 일어나는 일                            |
| ------------------------ | ------------ | -------------------------------------- |
| `['todos', 'list']`      | ✓ (프리픽스) | stale 표시 + **즉시 refetch** (active) |
| `['todos', 'detail', 7]` | ✓ (프리픽스) | stale 표시만 — **다음 마운트 때** 갱신 |
| `['stats']`              | ✗            | 아무 일 없음                           |

- invalidate = "낡았다고 선언" — 갱신 실행 여부는 **화면에 있는가**가 결정한다
- stats도 낡았다면? 그건 invalidate 호출부가 `['stats']`도 지목해야 한다. react-query는 데이터 간 의존성을 모른다

---

## 퀴즈 4 — 리렌더는 몇 번 (2부 Part 4)

```jsx
function TodoCount() {
  const {data} = useQuery({queryKey: ['todos'], queryFn: fetchTodos})
  return <span>{data?.length}</span>
}
```

포커스 계기로 백그라운드 갱신이 일어났고, **응답 내용은 이전과 완전히 같다.** 이 컴포넌트는 몇 번 리렌더될까?

<!-- 진행: 0번/1번/2번 손들기. 0번을 고르는 사람이 거의 없다 — "isFetching이 두 번 바뀌니 2번"이 다수인데, "이 컴포넌트는 isFetching을 읽지 않았다"를 짚으며 반전시킨다. -->

---

## 퀴즈 4 — 정답: 0번

두 겹의 방어가 모두 작동한다.

1. **structural sharing**: 내용이 같으므로 `data`는 **이전과 같은 참조** (실측: `data1 === data2`)
2. **tracked properties**: 이 컴포넌트가 읽은 것은 `data`뿐. 갱신 동안 `isFetching`이 두 번 바뀌었지만 **읽지 않은 필드**라 알림 대상이 아니다

> 만약 `{data, isFetching}`을 구조분해했다면? isFetching의 true/false 전환으로 2번 리렌더된다. 읽는 것에만 비용을 낸다.

---

## 퀴즈 5 — 롤백이 되지 않는다 (1부 Part 3 + 2부 Part 5)

낙관적 업데이트를 구현했다. cancelQueries도 잊지 않았다. 그런데 서버가 실패했을 때 **롤백해도 화면이 원래대로 돌아오지 않는다.**

```js
onMutate: async (toggled) => {
  await queryClient.cancelQueries({queryKey: ['todos']})
  queryClient.setQueryData(['todos'], (old) => toggle(old, toggled.id))
  const snapshot = queryClient.getQueryData(['todos'])
  return {snapshot}
},
onError: (err, toggled, context) => {
  if (context) queryClient.setQueryData(['todos'], context.snapshot)
},
```

무엇이 잘못됐을까?

<!-- 진행: 함정 5 직후라 다들 cancelQueries부터 찾는다 — "이번엔 있다"고 미리 못박고 코드를 줄 단위로 읽게 유도. -->

---

## 퀴즈 5 — 정답: 스냅샷을 뜨는 시점

- 스냅샷을 **캐시를 수정한 뒤에** 떴다 — `snapshot`에 담긴 것은 원본이 아니라 **낙관적으로 수정된 목록**이다
- onError의 롤백은 그 수정본을 다시 쓰는, **아무것도 되돌리지 않는 코드**가 된다
- 순서를 바꾸면 끝: **스냅샷 먼저, 수정은 그 다음**

```js
const snapshot = queryClient.getQueryData(['todos']) // ① 원본 확보
queryClient.setQueryData(['todos'], (old) => toggle(old, toggled.id)) // ② 수정
```

> 낙관적 업데이트의 3박자(스냅샷 → 미리 그리기 → 롤백)는 순서 자체가 계약이다.

---

## 퀴즈 6 — 서버에서 생긴 일 (2부 Part 6)

SSR 프리페치 코드. 배포 후 "가끔 첫 화면이 다른 사람의 장바구니"라는 제보가 왔다.

```js
const queryClient = new QueryClient() // 모듈 스코프

export default async function Page() {
  await queryClient.prefetchQuery({queryKey: ['cart'], queryFn: fetchCart})
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>...</HydrationBoundary>
  )
}
```

무엇이 잘못됐을까?

---

## 퀴즈 6 — 정답: 요청 간 캐시 공유

- 모듈 스코프의 QueryClient는 서버 프로세스의 **모든 요청이 공유**한다
- A의 요청이 `['cart']`를 채운 뒤 B의 요청이 오면, dehydrate에 **A의 장바구니가 실려 나간다**
- 수정: `new QueryClient()`를 **핸들러 안으로** — 요청마다 새 캐시

> 브라우저에서 "앱 전체에 하나"가 정석인 바로 그 패턴이, 서버에서는 개인정보 유출이 된다. 실행 환경이 캐시의 공유 범위를 결정한다.

---

## 전체 요약 — 여섯 문장

1. react-query는 fetch 라이브러리가 아니라 **서버 상태의 캐시 관리자**다
2. **queryKey가 모든 것의 축**이다 — 캐시 주소, 중복 제거, 무효화, hydrate가 전부 키로 연결된다
3. **staleTime이 요청량을, gcTime이 메모리를** 결정한다 — 갱신은 "계기 × stale"일 때만 (폴링은 예외)
4. 데이터의 주인은 **Query 객체**고 컴포넌트는 Observer로 구독한다 — 참조가 같으면, 읽지 않았으면 리렌더도 없다
5. 쓰기 후에는 무효화, 캐시를 직접 만지면 **cancelQueries부터** — 레이스는 내 책임이 된다
6. 서버에서 QueryClient는 **요청마다 새로** — 환경이 바뀌면 기본값도 정석도 바뀐다

---

## 참고 자료

- [TanStack Query 공식 문서](https://tanstack.com/query/v5/docs/framework/react/overview) — v5 기준
- [TanStack/query v5.101.4 소스](https://github.com/TanStack/query/tree/v5.101.4) — 이 덱의 인용 기준 태그
- [Practical React Query](https://tkdodo.eu/blog/practical-react-query) — 메인테이너 TkDodo의 실전 시리즈
- [Advanced Server Rendering](https://tanstack.com/query/v5/docs/framework/react/guides/advanced-ssr) — App Router 통합 공식 가이드
- [Inside React Query](https://tkdodo.eu/blog/inside-react-query) — 내부 구조(Part 4)를 더 깊게

---

# 감사합니다

<!-- _class: invert -->

@yceffort

---

# Promise 딥다이브: 동작 원리와 실행 순서

Source: https://research.yceffort.kr/slides/promise-deep-dive.md

---
title: 'Promise 딥다이브: 동작 원리와 실행 순서'
marp: true
paginate: true
theme: midnight
tags:
  - javascript
  - async
  - error-handling
date: 2026-08-13
description: '상태 머신, 마이크로태스크, 체이닝, 조합기, 에러 처리 — Promise의 동작 원리 정리와 실행 순서 퀴즈'
published: true
art:
  undraw: completion-progress
---

# Promise 딥다이브

동작 원리와 실행 순서

<!-- _class: invert -->

@yceffort

---

## 이 덱의 목표

```js
const user = fetchUser()
console.log(user) // Promise { <pending> }
console.log(user.name) // undefined
```

- Promise는 값이 아니라 **미래의 값을 감싼 객체**다
- 동작 원리를 모르면 실행 순서를 예측할 수 없고, `await`를 붙여 해결해도 이유를 설명할 수 없다

목표: **어떤 Promise 코드든 실행 순서를 예측할 수 있게 되는 것**

---

## 이 덱에서 다루는 것

다섯 부분으로 나눠 다룬다.

1. **배경** — Promise가 해결하려던 문제
2. **상태 머신** — pending, fulfilled, rejected와 "한 번 정해지면 불변"
3. **마이크로태스크** — then 콜백은 정확히 _언제_ 실행되는가
4. **체이닝** — then이 매번 새 Promise를 만든다는 것의 의미
5. **조합기와 실패** — all/race/any, 에러 전파, 실무 패턴

마지막에 **종합 퀴즈 12문제**로 전체를 점검한다.

---

## 기준

- 동작 설명은 **ECMA-262 스펙**과 **HTML 이벤트 루프 명세** 기준
- 모든 실행 순서 예제는 **Node.js 24 / Chrome 139**에서 직접 실행해 확인했다
- 다루지 않는 것: RxJS 같은 스트림 추상화, 제너레이터 기반 코루틴, Web Worker

<!-- 구두 보충: 예제가 "브라우저마다 다르지 않냐"는 질문이 자주 나오는데, Promise의 실행 순서는 스펙에 정의되어 있어 모던 런타임에서는 전부 동일하다. 다른 건 대부분 태스크 큐(타이머) 쪽 구현 차이. -->

---

## Part 0 — 배경: 콜백 방식의 한계

<!-- _class: invert -->

---

## 콜백 방식

```js
getUser(userId, function (err, user) {
  if (err) return handleError(err)
  getOrders(user.id, function (err, orders) {
    if (err) return handleError(err)
    getShipping(orders[0].id, function (err, shipping) {
      if (err) return handleError(err)
      render(user, orders, shipping)
    })
  })
})
```

흔히 "콜백 지옥"이라 부르지만, 들여쓰기는 사실 부차적인 문제다.

---

## 진짜 문제: 제어의 역전

```js
analytics.trackPurchase(order, function () {
  chargeCreditCard(order) // 결제 함수를 콜백으로 넘겼다
})
```

내 함수(`chargeCreditCard`)의 실행 권한을 **남의 라이브러리에 통째로 넘겼다.**

- 콜백을 한 번도 부르지 않으면 결제가 되지 않는다
- 콜백을 두 번 부르면 결제가 두 번 된다
- 에러 전달 방식, 동기/비동기 호출 여부가 전부 **라이브러리 구현에 달려 있다**

콜백 방식에는 이런 동작을 강제할 규약이 없다.

---

## Promise가 보장하는 것

Promise는 "미래의 값"을 담는 객체이면서, 다음 네 가지를 보장한다.

| 콜백 방식의 문제           | Promise의 보장                              |
| -------------------------- | ------------------------------------------- |
| 안 부르거나 여러 번 부름   | 결과는 **정확히 한 번**만 정해진다          |
| 성공/실패 전달 방식 제각각 | `then`/`catch`라는 **표준 인터페이스**      |
| 동기/비동기 뒤섞임         | 콜백은 **항상 비동기로** 호출된다           |
| 이미 끝난 뒤 등록하면 유실 | 언제 등록해도 결과를 받는다 (**시점 무관**) |

이후 파트에서 이 보장들이 어떻게 구현되는지 다룬다.

---

## Part 1 — 상태 머신으로서의 Promise

<!-- _class: invert -->

---

## 세 가지 상태

```text
              resolve(value)
   pending ──────────────────▶ fulfilled (값과 함께)
      │
      │       reject(reason)
      └──────────────────────▶ rejected (이유와 함께)
```

- 시작은 항상 **pending**
- fulfilled 또는 rejected가 되는 것을 **settled**(확정)라고 부른다
- 핵심 규칙: **한 번 settled되면 절대 다시 바뀌지 않는다**

내부적으로 Promise 객체는 상태(`[[PromiseState]]`)와 결과(`[[PromiseResult]]`)를 숨겨진 슬롯에 들고 있다. 외부에서 직접 읽거나 바꿀 방법은 없고, 오직 `resolve`/`reject` 호출과 `then` 등록만 가능하다.

---

## executor는 즉시, 동기로 실행된다

```js
console.log('1')

const p = new Promise((resolve) => {
  console.log('2') // new 하는 순간 바로 실행
  resolve('완료')
})

console.log('3')
```

```text
1 → 2 → 3
```

- `new Promise(executor)`의 executor는 **그 자리에서 동기 실행**된다
- "Promise를 만들면 비동기가 된다"는 흔한 오해 — 비동기인 건 executor가 아니라 **then 콜백 쪽**이다

---

## resolve는 한 번만 유효하다

```js
const p = new Promise((resolve, reject) => {
  resolve('첫 번째')
  resolve('두 번째') // 무시
  reject(new Error('에러!')) // 이것도 무시
})

p.then((v) => console.log(v)) // "첫 번째"
```

- 최초의 `resolve`/`reject` 호출만 유효하고, 이후 호출은 **조용히 무시**된다
- 에러도 안 난다. 그냥 아무 일도 일어나지 않는다
- 이것이 "콜백을 두 번 부르면?" 문제의 해답 — **구조적으로 두 번 확정될 수 없다**

---

## resolve ≠ fulfilled

미묘하지만 중요한 구분. `resolve`에 무엇을 넘기느냐에 따라 결과가 다르다.

```js
new Promise((resolve) => resolve(42))
// → 42로 fulfilled

new Promise((resolve) => resolve(Promise.reject(new Error('실패'))))
// → rejected! fulfilled가 아니다
```

- `resolve(값)` → 그 값으로 fulfilled
- `resolve(다른 Promise)` → 그 Promise를 **따라간다**. 그 Promise가 실패하면 함께 rejected
- 그래서 이름이 "fulfill"이 아니라 "resolve"(결정)다 — **어느 쪽으로 갈지 위임할 수도 있다**

이 "따라가기"는 Part 3에서 다시 다룬다.

---

## then 콜백은 항상 비동기다

```js
const p = Promise.resolve('이미 완료된 Promise')

p.then((v) => console.log('A:', v))
console.log('B')
```

```text
B
A: 이미 완료된 Promise
```

- 이미 fulfilled인 Promise에 `then`을 붙여도 콜백은 **지금 당장 실행되지 않는다**
- 항상 "현재 실행 중인 코드가 전부 끝난 뒤"에 실행된다
- 동기/비동기가 상황에 따라 뒤섞이면 코드의 실행 순서를 예측할 수 없기 때문에, 스펙이 **항상 비동기**로 못박았다

그럼 "현재 코드가 끝난 뒤"란 정확히 언제인가? → Part 2

---

## Part 1 정리

- executor는 `new` 하는 순간 **동기 실행**
- 상태는 pending → fulfilled/rejected 단방향, **한 번 확정되면 불변**
- 두 번째 `resolve`/`reject`는 조용히 무시
- `resolve(Promise)`는 fulfilled가 아니라 **그 Promise를 따라간다**
- `then` 콜백은 이미 끝난 Promise라도 **항상 비동기로** 호출

---

## Part 2 — 마이크로태스크: then은 언제 실행되는가

<!-- _class: invert -->

---

## 이벤트 루프 30초 복습

JavaScript는 싱글 스레드. 한 번에 하나의 코드만 실행한다.

```text
[ 콜 스택 ]  ← 지금 실행 중인 코드
     ▲
     │  스택이 비면 큐에서 하나 꺼내 실행
     │
[ 태스크 큐 (매크로태스크) ]  ← setTimeout, 클릭 이벤트, ...
```

여기까지가 흔히 아는 그림. 그런데 Promise 콜백은 **이 태스크 큐로 가지 않는다.**

---

## 마이크로태스크 큐

Promise 콜백을 위한 **별도의 우선순위 큐**가 있다.

```text
[ 콜 스택 ]
     ▲
     │  ① 스택이 비면 마이크로태스크 큐를 먼저, 전부 비운다
     │
[ 마이크로태스크 큐 ]  ← Promise 콜백, queueMicrotask
     ▲
     │  ② 그 다음에야 태스크를 하나 꺼낸다
     │
[ 태스크 큐 ]  ← setTimeout, 이벤트, ...
```

- 태스크는 **한 번에 하나**씩, 마이크로태스크는 **큐가 빌 때까지 전부**
- 실행 도중 새로 추가된 마이크로태스크도 그 자리에서 이어서 처리된다

---

## 퍼즐 1 — 기본기

```js
console.log('A')

setTimeout(() => console.log('B'), 0)

Promise.resolve().then(() => console.log('C'))

console.log('D')
```

출력 순서는?

<!-- 손들기로 진행: A B C D / A D B C / A D C B. 대부분 setTimeout 0이면 바로 실행된다고 생각하는 지점을 짚는다. -->

---

## 퍼즐 1 — 풀이

```text
A D C B
```

| 단계 | 실행                     | 마이크로태스크 큐 | 태스크 큐 |
| ---- | ------------------------ | ----------------- | --------- |
| 1    | `A` 출력                 |                   |           |
| 2    | setTimeout 등록          |                   | `B`       |
| 3    | then 등록                | `C`               | `B`       |
| 4    | `D` 출력, 스택 비움      | `C`               | `B`       |
| 5    | 마이크로태스크 먼저: `C` |                   | `B`       |
| 6    | 그 다음 태스크: `B`      |                   |           |

> `setTimeout(fn, 0)`의 0은 "즉시"가 아니라 "**현재 코드와 모든 마이크로태스크가 끝난 뒤** 최대한 빨리"다.

---

## 퍼즐 2 — 체인은 한 칸씩

```js
Promise.resolve()
  .then(() => console.log('A'))
  .then(() => console.log('B'))

Promise.resolve()
  .then(() => console.log('1'))
  .then(() => console.log('2'))
```

출력 순서는?

---

## 퍼즐 2 — 풀이

```text
A 1 B 2
```

- `A B 1 2`가 아니다. 체인이 통째로 실행되는 게 아니라, **then 하나가 마이크로태스크 하나**다
- `A`가 실행되어야 그 다음 `.then(B)`이 큐에 들어간다. 그 사이에 이미 대기 중이던 `1`이 먼저 실행된다

```text
큐의 변화: [A, 1] → [1, B] → [B, 2] → [2]
```

> 두 체인이 **한 칸씩 번갈아** 전진한다.

---

## await의 정체

`async/await`는 새로운 메커니즘이 아니라 **then의 문법 설탕**이다.

```js
async function main() {
  console.log('1')
  await someAsyncWork()
  console.log('2') // ← 여기부터 아래 전부가...
}

// ...대략 이렇게 변환된다
function main() {
  console.log('1')
  return someAsyncWork().then(() => {
    console.log('2') // then 콜백이 된다
  })
}
```

`await`에서 멈추는 것은 **이 async 함수 하나뿐**이다. 제어는 즉시 호출부로 돌아가 바깥 코드가 계속 실행되고, 함수의 나머지는 마이크로태스크로 예약된다. **JavaScript 엔진이 멈춰서 기다리는 것이 아니다.**

---

## 퍼즐 3 — await와 실행 순서

```js
async function main() {
  console.log('1')
  await null // Promise가 아니어도 await 가능
  console.log('2')
}

main()
console.log('3')
```

출력 순서는?

---

## 퍼즐 3 — 풀이

```text
1 3 2
```

- `main()` 호출 → `1` 출력 (async 함수도 **await 전까지는 동기 실행**)
- `await null` → `null`을 `Promise.resolve(null)`로 감싸고, 나머지(`2` 출력)를 마이크로태스크로 예약
- 함수 밖으로 나와 `3` 출력
- 스택이 비면 마이크로태스크 실행 → `2`

> "async 함수는 전부 비동기"가 아니다. **await를 만나기 전까지는 일반 함수와 똑같이 동기**로 돈다.

---

## 마이크로태스크의 함정: 굶주림

마이크로태스크는 "빌 때까지 전부" 처리되기 때문에, 스스로를 계속 추가하면 **태스크 큐와 렌더링이 영원히 실행되지 못한다.**

```js
function loop() {
  Promise.resolve().then(loop) // 마이크로태스크가 마이크로태스크를 낳는다
}
loop()

setTimeout(() => console.log('실행되지 않는다'), 0)
```

- `setTimeout`이라면 반복 사이사이에 렌더링과 다른 태스크가 끼어들 수 있지만, 마이크로태스크 재귀는 **화면을 완전히 얼린다**
- 실무에서 만나는 형태: 재귀적으로 자신을 다시 예약하는 잘못된 폴링, 상태 갱신 루프

---

## Part 2 정리

- Promise 콜백은 태스크 큐가 아닌 **마이크로태스크 큐**로 간다
- 스택이 비면: **마이크로태스크 전부** → 태스크 **하나** → 반복
- `then` 하나 = 마이크로태스크 하나. 체인은 **한 칸씩** 전진한다
- `await` = 나머지 코드를 then 콜백으로 바꾸는 문법 설탕. **await 전까지는 동기**, 멈추는 것은 **그 함수 하나뿐**
- 마이크로태스크 재귀는 렌더링을 얼린다

---

## Part 3 — 체이닝의 실체

<!-- _class: invert -->

---

## then은 매번 새 Promise를 만든다

```js
const p1 = fetch('/api/user')
const p2 = p1.then((res) => res.json())
const p3 = p2.then((user) => user.name)

console.log(p1 === p2) // false
console.log(p2 === p3) // false
```

- `then`은 기존 Promise를 변형하는 게 아니라 **새 Promise를 만들어 반환**한다
- 새 Promise의 운명은 **콜백이 무엇을 반환하느냐**로 결정된다
- "체이닝"이란 이 새 Promise들을 계속 이어받는 것

그렇다면 콜백의 반환값에 따라 무슨 일이 벌어지는가?

---

## 콜백 반환값의 네 갈래

새 Promise의 상태는 콜백이 **무엇을 반환하느냐**로 정해진다. 경우는 네 가지뿐이다.

<!-- prettier-ignore -->
```js
const p = Promise.resolve(1)

p.then((v) => v + 1) //             ① 값 반환      → 2로 fulfilled
p.then((v) => {}) //                ② 반환 없음    → undefined로 fulfilled
p.then((v) => { throw new Error('x') }) // ③ throw → 그 에러로 rejected
p.then((v) => fetchUser()) //       ④ Promise 반환 → 그 Promise를 따라간다
```

- ①②: 다음 `then`의 성공 콜백이 그 값을 받는다
- ③: 프로그램이 죽는 게 아니라 **다음 Promise가 rejected가 된다** — 에러가 체인을 타고 catch까지 흐르는 이유
- ④: 그 Promise가 끝날 때까지 기다렸다가 결과를 그대로 이어받는다

---

## 흔한 실수 1 — return을 빼먹는다

```js
fetchUser()
  .then((user) => {
    fetchOrders(user.id) // return이 없다!
  })
  .then((orders) => {
    console.log(orders) // undefined
  })
```

- 콜백이 아무것도 반환하지 않았으므로 다음 Promise는 `undefined`로 fulfilled
- `fetchOrders`는 실행은 되지만 결과가 **유실된다**. 실패해도 catch에 잡히지 않는다

```js
  .then((user) => {
    return fetchOrders(user.id) // Promise를 반환해야 "따라간다"
  })
```

---

## 흔한 실수 2 — then 중첩

```js
// 콜백 중첩과 같은 구조
fetchUser().then((user) => {
  fetchOrders(user.id).then((orders) => {
    fetchShipping(orders[0].id).then((shipping) => { ... })
  })
})
```

Promise를 반환하면 "따라간다"는 성질 덕분에 **평평하게 펼 수 있다**:

```js
fetchUser()
  .then((user) => fetchOrders(user.id))
  .then((orders) => fetchShipping(orders[0].id))
  .then((shipping) => { ... })
  .catch(handleError) // 세 단계 어디서 실패해도 여기로
```

중첩 버전은 에러 처리도 단계마다 따로 해야 한다. 중첩된 then은 대부분 평평한 체인으로 바꿀 수 있다.

---

## catch와 finally는 then의 변형이다

```js
p.catch(onError)
// = p.then(undefined, onError)

p.finally(onDone)
// ≈ 성공/실패 양쪽에 onDone을 끼워넣되, 결과는 그대로 통과
```

- `catch`도 **새 Promise를 반환**한다. 그래서 catch 뒤에 then을 또 붙일 수 있다
- `finally` 콜백은 인자를 받지 않고, 반환값도 (throw만 아니면) **결과에 영향을 주지 않는다**

```js
Promise.resolve(42)
  .finally(() => '이 값은 무시된다')
  .then((v) => console.log(v)) // 42
```

---

## catch의 위치가 의미를 바꾼다

```js
// A: 요청 실패 시 → parse는 실행 안 됨, 최종 fallback 사용
fetchData()
  .then(parse)
  .catch(() => DEFAULT)

// B: 요청 실패 시 → 먼저 DEFAULT로 복구되고, parse(DEFAULT)가 실행됨
fetchData()
  .catch(() => DEFAULT)
  .then(parse)
```

- catch는 "이전 단계까지의 실패"를 잡고, **fulfilled 상태의 새 Promise를 반환**한다 (복구)
- 즉 catch 뒤의 체인은 **아무 일 없었다는 듯 계속 진행**된다
- "어디까지의 실패를, 어느 시점에 복구할 것인가"가 catch 위치의 의미

---

## 더 깊이 — return Promise의 숨은 비용

```js
Promise.resolve()
  .then(() => {
    console.log('1')
    return Promise.resolve('2') // 그냥 '2'를 return하면 결과가 달라진다
  })
  .then((v) => console.log(v))

Promise.resolve()
  .then(() => console.log('3'))
  .then(() => console.log('4'))
  .then(() => console.log('5'))
```

```text
1 3 4 5 2   ← '2'가 '4' 다음이 아니라 '5' 다음에 나온다
```

콜백이 **Promise를 반환하면** "따라가기" 절차(then 호출 예약 + 결과 전달)에 마이크로태스크 **2틱**이 추가로 든다. `return '2'`였다면 `1 3 2 4 5`.

<!-- 구두 보충: 이걸 외울 필요는 없다는 것을 강조. "Promise를 반환하면 즉시 이어지는 게 아니라 흡수 절차를 거친다" 정도만 가져가면 된다. async 함수의 return await도 같은 원리로 틱이 추가된다. -->

---

## Part 3 정리

- `then`/`catch`/`finally`는 모두 **새 Promise를 반환**한다
- 다음 Promise의 운명 = 콜백의 반환값 (값 / throw / Promise 따라가기)
- **return을 빼먹으면** 결과와 에러가 모두 유실된다
- 중첩 대신 **평평한 체인** — 에러 처리가 한 곳으로 모인다
- catch는 실패를 잡고 **복구**한다. 위치가 곧 복구 범위

---

## Part 4 — 조합기: 여러 Promise 다루기

<!-- _class: invert -->

---

## 네 가지 조합기

여러 Promise를 하나로 묶는 정적 메서드 4종. **성공/실패 판정 기준**이 다르다.

| 메서드               | 성공 조건          | 실패 조건      | 결과                          |
| -------------------- | ------------------ | -------------- | ----------------------------- |
| `Promise.all`        | **전부** 성공      | 하나라도 실패  | 성공값 배열 (순서 보장)       |
| `Promise.allSettled` | 항상 성공          | 없음           | `{status, value/reason}` 배열 |
| `Promise.race`       | **첫 확정**이 성공 | 첫 확정이 실패 | 가장 빠른 것의 결과           |
| `Promise.any`        | 하나라도 성공      | **전부** 실패  | 첫 성공값                     |

---

## Promise.all — 전부 아니면 무효

```js
const [user, orders, config] = await Promise.all([
  fetchUser(),
  fetchOrders(),
  fetchConfig(),
])
```

- 결과 배열의 순서는 **완료 순서가 아니라 입력 순서**
- 하나라도 실패하면 **그 즉시** 전체가 rejected (fail-fast)
- 주의: 실패해도 **나머지 Promise가 취소되는 건 아니다**. 결과만 버려질 뿐, 요청은 계속 돈다

```js
// 실패를 허용하려면 allSettled
const results = await Promise.allSettled([...])
const ok = results.filter((r) => r.status === 'fulfilled')
```

---

## race와 any — 헷갈리는 한 끗

```js
// race: 성공이든 실패든 "가장 빠른 결과"를 따라간다
Promise.race([fetchData(), timeout(5000)])
// → 5초 안에 응답 없으면 timeout의 reject가 이긴다 (타임아웃 패턴)

// any: "가장 빠른 성공"을 기다린다. 실패는 무시
Promise.any([cdnA.fetch(), cdnB.fetch(), cdnC.fetch()])
// → 셋 중 하나만 성공하면 OK (미러/폴백 패턴)
```

- `race`는 첫 **확정**(settled), `any`는 첫 **성공**(fulfilled)
- `any`는 전부 실패했을 때만 rejected — 모든 실패 이유를 담은 `AggregateError`를 던진다

---

## 순차 vs 병렬 — await의 대표적 함정

```js
// 순차: 앞이 끝나야 뒤가 시작. 총 2초
const user = await fetchUser() // 1초
const config = await fetchConfig() // 1초, user와 무관한데도 기다림

// 병렬: 두 요청을 먼저 시작해 두고 기다린다. 총 1초
const [user2, config2] = await Promise.all([fetchUser(), fetchConfig()])
```

- `await`는 그 async 함수의 진행을 그 줄에서 멈춘다. 의존성 없는 작업을 await로 줄줄이 쓰면 **불필요한 직렬화**
- 반대로 뒤 작업이 앞 결과를 **필요로 하면** 순차가 정답이다
- 판단 기준: **"이 요청이 앞 요청의 결과를 쓰는가?"**

---

## 실무 패턴 — 동시성 제한

대량 작업을 전부 동시에 시작하지 않고, "동시에 최대 N개"를 유지하는 풀 패턴:

<!-- prettier-ignore -->
```js
async function pool(tasks, limit) {
  const results = []
  const running = new Set()
  for (const task of tasks) {
    const p = task().finally(() => running.delete(p))
    running.add(p)
    results.push(p)
    if (running.size >= limit) await Promise.race(running) // 자리가 날 때까지
  }
  return Promise.all(results)
}

await pool(urls.map((url) => () => upload(url)), 5) // 동시 5개
```

`race`로 "가장 먼저 끝나는 것"을 기다렸다가 다음 작업을 투입한다. p-limit 같은 라이브러리의 핵심이 이 구조다.

---

## Part 4 정리

- `all` 전부 / `allSettled` 무조건 / `race` 첫 확정 / `any` 첫 성공
- `all`의 결과 순서는 **입력 순서**, 실패는 fail-fast (단, 취소는 아님)
- 의존성 없는 작업을 await로 직렬화하지 말 것 — **먼저 시작해 두고 기다리기**
- 대량 작업은 `race`를 활용한 **동시성 제한 풀**

---

## Part 5 — 에러 처리

<!-- _class: invert -->

---

## 에러는 체인을 타고 흐른다

```js
fetchUser()
  .then((user) => fetchOrders(user.id)) // ① 여기서 실패하면
  .then((orders) => render(orders)) // ② 건너뛰고
  .then(() => logSuccess()) // ③ 건너뛰고
  .catch((err) => showError(err)) // ④ 여기서 잡힌다
```

- rejected 상태는 **성공 콜백들을 건너뛰며** 아래로 전파된다
- 가장 가까운 catch(또는 then의 두 번째 인자)가 잡을 때까지
- try/catch의 비동기 버전이라고 생각하면 정확하다 — 실제로 `async/await`에서는 진짜 `try/catch`로 잡는다

```js
try {
  const orders = await fetchOrders(user.id)
} catch (err) {
  showError(err) // 같은 동작
}
```

---

## 아무도 안 잡으면: unhandled rejection

```js
Promise.reject(new Error('fail'))
// 브라우저 콘솔: Uncaught (in promise) Error: fail
```

- catch 없는 rejection은 **unhandledrejection** 이벤트를 발생시킨다
- 판정 시점: rejection 발생 직후가 아니라, **마이크로태스크 큐를 비운 뒤에도 핸들러가 없을 때**
- 그래서 같은 흐름 안에서 catch를 붙이면 경고가 없다:

```js
const p = Promise.reject(new Error('x'))
p.catch(handleError) // 같은 틱에 붙였으므로 경고 없음
```

- Node.js에서는 처리되지 않은 rejection이 **프로세스를 종료**시킨다 (v15+ 기본값). "나중에 잡을 거니까"는 통하지 않는다

<!-- 구두 보충: window.addEventListener('unhandledrejection', ...)로 전역 로깅을 걸 수 있고, Sentry 같은 도구가 기본으로 하는 일이 바로 이것. -->

---

## async 함수의 rejection 유실

```js
// 이벤트 핸들러, useEffect 등에서 흔한 패턴
button.addEventListener('click', async () => {
  await submitOrder() // 실패하면? 아무도 모른다
})
```

- async 함수는 Promise를 반환하지만, 이 반환값을 **아무도 받지 않는다**
- `submitOrder`가 실패하면 그대로 unhandled rejection
- 호출부가 결과를 받지 않는 async 함수는 **내부에서 스스로 에러를 처리**해야 한다

```js
button.addEventListener('click', async () => {
  try {
    await submitOrder()
  } catch (err) {
    showToast('주문에 실패했습니다')
  }
})
```

---

## 취소 — AbortController

Promise에는 **취소 기능이 없다.** 한 번 시작된 작업은 스스로 멈추지 않는다.
취소가 필요하면 취소 신호를 별도 채널로 전달하는 `AbortController`를 쓴다:

```js
const controller = new AbortController()

fetch('/api/search?q=keyword', {signal: controller.signal})
  .then((res) => res.json())
  .catch((err) => {
    if (err.name === 'AbortError') return // 취소는 에러가 아니다
    throw err
  })

// 사용자가 검색어를 바꾸면
controller.abort()
```

- 검색 자동완성처럼 **이전 요청이 무의미해지는** 상황의 표준 패턴
- 취소하지 않으면 다음 슬라이드의 race condition이 발생할 수 있다

---

## race condition — 응답 순서 역전

```js
input.addEventListener('input', async (e) => {
  const results = await search(e.target.value)
  render(results) // 문제의 줄
})
```

"ab" 검색 직후 "abc"를 검색했는데 **"ab"의 응답이 더 늦게 도착하면?** 화면에는 "ab"의 결과가 남는다.

```js
let latest = 0
input.addEventListener('input', async (e) => {
  const id = ++latest
  const results = await search(e.target.value)
  if (id !== latest) return // 최신 요청이 아니면 버린다
  render(results)
})
```

요청마다 번호표를 붙이고 **응답이 왔을 때 아직 최신인지** 확인한다. AbortController로 이전 요청을 끊는 방식과 병행하면 더 좋다.

---

## Part 5 정리

- rejection은 성공 콜백을 건너뛰며 **가장 가까운 catch까지 전파**
- catch 없는 rejection = unhandled rejection. **Node에서는 프로세스가 죽는다**
- 반환값을 아무도 안 받는 async 함수는 **내부에서 try/catch**
- Promise는 취소가 없다 — **AbortController**로 신호를 보낸다
- 연속 요청은 **응답 시점에 최신 여부를 검사** (번호표 or abort)

---

## 종합 퀴즈

<!-- _class: invert -->

열두 문제. 문제마다 대응하는 Part를 표시했다.

---

## 퀴즈 1 — 실행 순서 (Part 2)

```js
console.log('start')

setTimeout(() => console.log('timeout'), 0)

Promise.resolve()
  .then(() => console.log('then1'))
  .then(() => console.log('then2'))

console.log('end')
```

출력 순서는?

---

## 퀴즈 1 — 정답

```text
start → end → then1 → then2 → timeout
```

- 동기 코드 먼저: `start`, `end`
- 스택이 비면 **마이크로태스크 전부**: `then1`, 그로 인해 예약된 `then2`까지
- 마지막에 태스크: `timeout`

> 핵심: 마이크로태스크는 "전부", 태스크는 "하나씩". `then2`가 `timeout`보다 먼저다.

---

## 퀴즈 2 — executor의 동작 (Part 1)

```js
const p = new Promise((resolve) => {
  console.log('A')
  resolve('B')
  console.log('C')
  resolve('D')
})

p.then((v) => console.log(v))
console.log('E')
```

출력 순서는?

---

## 퀴즈 2 — 정답

```text
A → C → E → B
```

- executor는 **동기 실행**: `A` 출력, `resolve('B')`로 확정, `C`도 계속 출력
- `resolve('D')`는 이미 확정된 뒤이므로 **무시**
- `then` 콜백은 항상 비동기 → `E`가 먼저, 그 다음 `B`

> resolve를 호출해도 executor의 나머지 코드는 계속 실행된다. resolve는 return이 아니다.

---

## 퀴즈 3 — 체이닝의 반환값 (Part 3)

```js
Promise.resolve(10)
  .then((v) => {
    v * 2
  })
  .then((v) => console.log('결과:', v))

Promise.reject(new Error('실패'))
  .catch(() => 99)
  .then((v) => console.log('복구:', v))
```

각각 무엇이 출력될까?

---

## 퀴즈 3 — 정답

```text
결과: undefined
복구: 99
```

- 첫 번째: `return`이 없으므로 다음 Promise는 `undefined`로 fulfilled. `20`은 유실
- 두 번째: catch가 `99`를 반환 → **fulfilled로 복구** → 다음 then은 성공 콜백 실행

> catch는 "잡고 끝"이 아니라 "잡고 **복구**"다. catch 뒤의 체인은 정상 진행된다.

---

## 퀴즈 4 — Promise.all의 실패 (Part 4)

```js
const p1 = Promise.resolve('첫째')
const p2 = Promise.reject(new Error('둘째 실패'))
const p3 = new Promise((r) => setTimeout(() => r('셋째'), 1000))

Promise.all([p1, p2, p3])
  .then((values) => console.log('성공:', values))
  .catch((err) => console.log('실패:', err.message))
```

무엇이, 대략 언제 출력될까? 그리고 `p3`의 타이머는 어떻게 될까?

---

## 퀴즈 4 — 정답

```text
실패: 둘째 실패   (1초를 기다리지 않고 즉시)
```

- `all`은 fail-fast — `p2`의 실패가 확인되는 즉시 전체가 rejected
- `p3`를 1초 기다리지 않는다
- 하지만 `p3`의 타이머는 **취소되지 않고 계속 돈다**. 결과만 버려질 뿐

> "빨리 실패를 알려주는 것"과 "나머지 작업을 멈추는 것"은 다르다. 멈추려면 AbortController가 필요하다.

---

## 퀴즈 5 — 몇 초 걸릴까 (Part 4)

```js
const delay = (ms, v) => new Promise((r) => setTimeout(() => r(v), ms))

async function run() {
  const p1 = delay(1000, 'a')
  const p2 = delay(1000, 'b')

  const a = await p1
  const b = await p2
  console.log(a, b)
}
```

`run()`은 완료까지 약 몇 초 걸릴까? ① 약 1초 ② 약 2초

---

## 퀴즈 5 — 정답: ① 약 1초

- 두 타이머는 `delay()`를 **호출한 시점**에 이미 시작됐다
- `await p1`으로 1초 기다리는 동안 `p2`의 타이머도 **같이 흐른다**
- `await p2`에 도달했을 땐 이미 (거의) 끝나 있다

```js
// 이랬다면 2초 — 호출 자체가 뒤로 밀리므로
const a = await delay(1000, 'a')
const b = await delay(1000, 'b')
```

> 소요 시간은 await의 위치가 아니라 **작업을 시작한 시점**이 결정한다.

---

## 퀴즈 6 — try/catch와 비동기 에러 (Part 5)

```js
async function handleClick() {
  try {
    setTimeout(() => {
      throw new Error('fail')
    }, 100)
  } catch (err) {
    console.log('잡았다:', err.message)
  }
}
```

`'잡았다'`가 출력될까?

---

## 퀴즈 6 — 정답: 출력되지 않는다

- `setTimeout` **등록**은 성공적으로 끝났고, try 블록은 이미 통과했다
- 100ms 뒤 콜백이 실행될 때의 호출 스택에는 try/catch가 **존재하지 않는다**
- 이 에러는 uncaught error가 되어 전역으로 터진다

```js
// try/catch가 잡으려면 "await하는 Promise"로 만들어야 한다
try {
  await new Promise((_, reject) =>
    setTimeout(() => reject(new Error('fail')), 100),
  )
} catch (err) {
  console.log('잡았다:', err.message) // 잡힌다
}
```

> try/catch는 **같은 호출 스택**의 에러만 잡는다. 비동기 에러를 잡으려면 Promise로 연결해야 한다.

---

## 퀴즈 7 — async 함수 두 개 (Part 2)

```js
async function foo() {
  console.log('A')
  await bar()
  console.log('B')
}

async function bar() {
  console.log('C')
}

foo()
console.log('D')
```

출력 순서는?

---

## 퀴즈 7 — 정답

```text
A → C → D → B
```

- `foo()` 호출 → `A` 출력. `bar()` 호출도 **동기로 진행** → `C` 출력
- `await`는 `bar()`가 반환한 Promise에 도달한 뒤, `foo`의 나머지(`B`)를 마이크로태스크로 예약하고 호출부로 복귀
- `D` 출력 후 스택이 비면 `B`

> async 함수 **호출** 자체는 동기다. 실행이 넘어가는 지점은 호출이 아니라 **await**다.

---

## 퀴즈 8 — resolve에 Promise 넘기기 (Part 1)

```js
const inner = Promise.reject(new Error('inner fail'))

const outer = new Promise((resolve) => {
  resolve(inner) // reject가 아니라 resolve를 호출했다
})

outer
  .then(() => console.log('fulfilled'))
  .catch((e) => console.log('rejected:', e.message))
```

무엇이 출력될까?

---

## 퀴즈 8 — 정답: `rejected: inner fail`

- `resolve(Promise)`는 fulfilled 확정이 아니라 그 Promise를 **따라가겠다는 위임**이다
- `inner`가 rejected이므로 `outer`도 rejected가 된다
- `resolve`를 호출했다는 사실이 최종 상태를 보장하지 않는다

---

## 퀴즈 9 — catch의 위치 (Part 3)

```js
Promise.resolve()
  .catch((e) => console.log('catch:', e.message))
  .then(() => {
    throw new Error('fail')
  })
  .then(() => console.log('done'))
```

무엇이 출력될까?

---

## 퀴즈 9 — 정답: 아무것도 출력되지 않는다

- catch는 **이전 단계까지의 실패**만 잡는다. 앞의 Promise가 fulfilled이므로 catch 콜백은 실행되지 않고 통과한다
- 뒤의 then에서 던진 에러는 잡아줄 catch가 없다 → **unhandled rejection**
- `done`도 출력되지 않는다 (rejected는 성공 콜백을 건너뛴다)

> 이런 이유로 catch는 체인의 **마지막**에 두는 것이 기본이다.

---

## 퀴즈 10 — finally의 통과 규칙 (Part 3)

```js
Promise.resolve(1)
  .finally(() => 2)
  .then((v) => console.log('A:', v))

Promise.reject(new Error('fail'))
  .finally(() => console.log('B'))
  .then((v) => console.log('C:', v))
  .catch((e) => console.log('D:', e.message))
```

무엇이, 어떤 순서로 출력될까?

---

## 퀴즈 10 — 정답

```text
B → A: 1 → D: fail
```

- finally의 반환값 `2`는 무시되고 원래 값 `1`이 통과한다 → `A: 1`
- rejection도 finally를 그대로 통과해 catch까지 간다 → `D: fail` (`C`는 건너뜀)
- `B`가 가장 먼저인 이유: finally 콜백도 **마이크로태스크 한 칸**이고, 두 체인이 한 칸씩 번갈아 실행되기 때문 (첫 칸: 위 체인의 finally, 둘째 칸: `B`)

---

## 퀴즈 11 — race vs any (Part 4)

```js
const slowOk = new Promise((r) => setTimeout(() => r('성공'), 100))
const fastFail = new Promise((_, rj) =>
  setTimeout(() => rj(new Error('실패')), 50),
)

Promise.race([slowOk, fastFail]) // ?
Promise.any([slowOk, fastFail]) // ?
```

`race`와 `any`는 각각 어느 쪽으로 확정될까?

---

## 퀴즈 11 — 정답: race는 rejected, any는 fulfilled

| 조합기 | 결과                          | 이유                                            |
| ------ | ----------------------------- | ----------------------------------------------- |
| `race` | 50ms 뒤 `실패`로 **rejected** | 첫 **확정**을 따라간다. 실패가 먼저 확정됐다    |
| `any`  | 100ms 뒤 `성공`으로 fulfilled | 첫 **성공**을 기다린다. 그 전의 실패는 무시한다 |

- 타임아웃 패턴에 `race`를 쓰는 이유이자, 폴백 패턴에 `any`를 쓰는 이유

---

## 퀴즈 12 — 태스크와 마이크로태스크 중첩 (Part 2)

```js
setTimeout(() => console.log('A'), 0)

Promise.resolve().then(() => {
  console.log('B')
  setTimeout(() => console.log('C'), 0)
})

Promise.resolve().then(() => console.log('D'))

console.log('E')
```

출력 순서는?

---

## 퀴즈 12 — 정답

```text
E → B → D → A → C
```

- 동기 코드: `E`
- 마이크로태스크 전부: `B`, `D` — `B` 안에서 등록한 `C`는 태스크 큐의 `A` 뒤에 줄을 선다
- 태스크 하나씩: `A`, 그 다음 `C`

> 마이크로태스크 안에서 등록한 태스크는 **이미 대기 중인 태스크 뒤**로 간다.

---

## 전체 요약 — 다섯 문장

1. Promise는 결과를 **정확히 한 번, 항상 비동기로, 표준 인터페이스로** 전달한다
2. 콜백은 태스크가 아닌 **마이크로태스크** — 스택이 비면 전부, 태스크보다 먼저
3. `then`은 새 Promise를 만들고, 그 상태는 **콜백의 반환값**이 정한다
4. 의존성 없는 작업은 **먼저 시작해 두고** `all`로 기다린다
5. 잡히지 않은 rejection은 프로세스 종료나 미탐지 오류로 이어진다 — **경계마다 catch를 둔다**

---

## 참고 자료

- [ECMA-262 — Promise Objects](https://tc39.es/ecma262/#sec-promise-objects) — 상태, 반응(reaction), 잡(job)의 공식 정의
- [HTML Standard — Event loops](https://html.spec.whatwg.org/multipage/webappapis.html#event-loops) — 태스크/마이크로태스크 처리 모델
- [MDN — Using Promises](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises)
- [V8 블로그 — Faster async functions and promises](https://v8.dev/blog/fast-async) — await의 내부 최적화 이야기
- Jake Archibald — [In The Loop (JSConf.Asia)](https://www.youtube.com/watch?v=cCOL7MC4Pl0) — 이벤트 루프 시각화 발표

---

# 감사합니다

<!-- _class: invert -->

@yceffort

---

# Sentry 딥다이브 2부: 서버에서 이슈까지

Source: https://research.yceffort.kr/slides/sentry-deep-dive-2.md

---
title: 'Sentry 딥다이브 2부: 서버에서 이슈까지'
marp: true
paginate: true
theme: midnight
tags:
  - observability
date: 2026-08-06
description: 'Relay 파이프라인부터 Debug ID, 그루핑 해시, 운영 규칙까지 — 서버에 도착한 이벤트가 이슈가 되는 과정'
published: true
art:
  undraw: server-error
---

# Sentry 딥다이브 2부

서버에서 이슈까지 — 에러 하나가 이슈가 되기까지 ②

<!-- _class: invert -->

@yceffort

---

## 1부 복습 — 지도

```text
throw
  │ ① 포착      ✅ 1부 — 전역 핸들러(onerror 교체) + 몽키패치
  ▼
이벤트 조립     │ ② 조립      ✅ 1부 — 3-스코프 병합, processors, beforeSend
  ▼
전송           │ ③ 전송      ✅ 1부 — envelope, rate limit, client report
  ▼
Sentry 서버    │ ④ 수신      ← 오늘 여기부터
  ▼
그루핑         │ ⑤ 그루핑    "같은 에러" 판정 → 이슈
```

- 1부 한 줄 요약: SDK는 **unhandled만 줍고**, 이벤트는 스코프 병합 → 필터 → beforeSend를 거쳐 **envelope에 실려 떠났다**
- 오늘: 도착한 이벤트가 이슈가 되기까지 + **원리에서 도출되는 운영 규칙**과 **꿀팁 부록**

---

## 기준 버전

이 덱의 코드는 전부 실제 소스에서 가져왔다.

- SDK: `getsentry/sentry-javascript` **v10.69.0** (2026-07-29)
- 서버: `getsentry/sentry`, `getsentry/relay` master (2026-01 기준) + 공식 문서
- 그루핑 설정: `all-platforms@2026-01-20` enhancement config

<!-- 버전을 박아두는 이유: Sentry SDK는 v8에서 아키텍처가 크게 바뀌었다(Hub 제거). 구버전 블로그 글과 이 덱의 내용이 다르면 버전부터 의심할 것 -->

---

## Part 4 — 수신

Relay 파이프라인과 소스맵 복원

---

## 이벤트가 도착한 뒤의 여정

self-hosted 문서 기준 실제 파이프라인:

```text
SDK
 └→ Relay          (DSN 검증, inbound filter, rate limit, 정규화, PII 스크러빙)
     └→ Kafka
         └→ ingest consumer → preprocess_event
             └→ Symbolicator  (소스맵 복원 ← 여기!)
                 └→ 저장(nodestore) + 그루핑 → ClickHouse(snuba)
                     └→ post_process  (알림, 이슈 후처리)
```

- 이벤트가 도착하면 제일 먼저 **정문 경비실**을 통과한다 — 이 관문 서비스의 이름이 **Relay**다
- 경비실이 하는 일: 신원 확인(DSN 검증), 잡상인 차단(필터), 출입량 제한(rate limit), 반입 금지품 제거(PII 스크러빙). **여기서 걸러지면 뒤 단계는 아예 시작되지 않는다**
- 그래서 순서가 중요하다: **필터 → 소스맵 복원 → 그루핑**

<!-- 구두: Relay는 Rust로 만든 별도 오픈소스 서비스(getsentry/relay)라는 것 정도만. self-hosted나 데이터 주권 이슈로 자체 Relay를 중간에 둘 수도 있다는 건 심화 주제 -->

---

## 서버에도 필터가 있다: inbound data filters

Project Settings의 Inbound Filters — 구현은 Relay에 있다 (`relay-filter/src/`):

- `browser_extensions.rs` — 확장 프로그램 에러
- `legacy_browsers.rs`, `web_crawlers.rs`, `localhost.rs`
- React hydration error, ChunkLoadError 전용 필터도 있음

SDK 필터(1부의 `ignoreErrors`)와의 결정적 차이 두 가지:

1. **"Filtered events do not consume quota"** — 서버 필터는 쿼터를 아낀다
2. **"exclusively applied at ingest time"** — symbolication **전**에 적용된다
   → 복원된 원본 파일명 기준으로는 필터할 수 없다

> 그래서 실무 전략: 확실한 노이즈는 SDK에서(전송량 절약), 대시보드에서 켤 수 있는 것은 서버에서(쿼터 절약), 둘 다 쓴다.

---

## 소스맵 매칭의 구세대: release + URL

전통 방식은 "release가 같고 파일 URL이 같으면 이 소스맵을 써라"였다.

문제점 (공식 문서 원문):

> "when you changed the subpath of where your javascript files are served...
> the corresponding sourcemap would not be able to be found by Sentry."

- CDN 경로 변경, 쿼리스트링, 프록시… URL은 **신뢰할 수 없는 키**였다
- release를 잊으면 매칭 자체가 불가능

그래서 현재 표준은 **Debug ID** 방식이다 (SDK 7.47+).

---

## Debug ID: 파일 내용이 곧 신원

번들러 플러그인(`@sentry/bundler-plugins`)이 빌드 시점에:

```ts
// 청크 내용의 SHA-256에서 만드는 결정적(deterministic) UUID
const debugId = stringToUUID(code) // rollup/vite 기준. webpack은 청크 hash를 씀
```

1. 소스맵 JSON에 `debug_id` 필드 주입
2. 번들 끝에 `//# debugId=<uuid>` 주석 추가
3. 그리고 번들 **앞**에 이런 스니펫을 주입한다:

<!-- prettier-ignore -->
```js
!function(){try{var e=window;
  var n=(new e.Error).stack;
  n&&(e._sentryDebugIds=e._sentryDebugIds||{},
      e._sentryDebugIds[n]="<uuid>",
      e._sentryDebugIdIdentifier="sentry-dbid-<uuid>")
}catch(e){}}();
```

**`new Error().stack`을 맵의 키로 쓴다.** 왜?

---

## new Error().stack 트릭

빌드 시점에는 이 파일이 **어떤 URL로 서빙될지 알 수 없다.**

- 스니펫이 런타임에 실행되면, 그 순간의 스택에는 **배포된 실제 URL**이 들어 있다
  (`at https://cdn.example.com/assets/main-8f3a2.js:1:1`)
- SDK는 이벤트 조립 시(`applyDebugIds`) 이 스택 문자열들을 파싱해서
  `{ 파일 URL → debugId }` 맵을 만들고, 스택 프레임마다 `debug_id`를 붙인다
- 최종적으로 `event.debug_meta.images`에 `{code_file, debug_id}` 목록으로 실려 간다

```ts
// packages/core/src/utils/prepareEvent.ts
frame.debug_id = filenameDebugIdMap[frame.filename]
```

> 서버는 URL이 아니라 debug_id로 소스맵을 찾는다.
> **"creating a release is no longer required"** — 경로가 어떻게 바뀌어도 매칭된다.

---

## Part 4 정리

- 파이프라인: Relay(필터) → Kafka → symbolication → 그루핑 → 저장
- 서버 필터는 쿼터를 안 먹지만, symbolication **전**이라 원본 파일명 기준 필터 불가
- Debug ID = 청크 내용의 해시. `new Error().stack`으로 런타임 URL과 연결
- release 없이도 소스맵이 매칭된다 — 다만 release는 회귀 추적 때문에 여전히 필요 (Part 6)

---

## 호기심 퀴즈 — Part 5 예고

<!-- _class: invert -->

소스맵은 "스택을 읽기 좋게" 만들어주는 도구다.

**그런데 소스맵 업로드가 이슈가 묶이는 방식(그루핑)에도 영향을 줄까?**

답이 "그렇다"라면, 어떤 경로로?

<!-- 준다. 다음 Part의 핵심 — 그루핑은 symbolication 이후의 프레임으로 해시를 만들고, JS는 특히 context line(그 줄의 소스 코드)을 재료로 쓰기 때문. 답을 여기서 주지 말고 Part 5로 넘어가는 동기로 쓸 것 -->

---

## Part 5 — 그루핑

이슈는 어떻게 묶이는가

---

## 그루핑의 우선순위

공식 문서 원문:

> "All versions consider the **fingerprint** first, the **stack trace** next,
> then the **exception**, and then finally the **message**."

실제 서버 설정 (`src/sentry/grouping/strategies/configurations.py`):

```python
strategies=[
    "chained-exception:v1",  # This handles single exceptions, too
    "threads:v1",
    "stacktrace:v1",
    "template:v1", "csp:v1", ...,
    "message:v1",
],
# "The first strategy to produce a result will become the winner."
```

- SDK가 fingerprint를 보냈으면 알고리즘 결과는 **해시에 반영되지 않는다** (계산은 되지만 비기여 마킹)
- 스택이 없으면 exception type+value → message 순으로 폴백

---

## JS 스택은 무엇으로 해시되는가

플랫폼마다 해시에 넣는 프레임 요소가 다르다 (develop 문서 기준):

| 플랫폼     | 해시 재료                                             |
| ---------- | ----------------------------------------------------- |
| JavaScript | **module + filename(소문자 basename) + context line** |
| Python     | module + function + context line                      |
| Native     | 사실상 function 이름만 (강한 정규화)                  |

- 함수명은 **소스맵으로 복원된 context line이 있을 때만 무시**된다 — 소스맵이 없으면 minified 함수명이 그대로 해시에 들어간다
- context line을 쓰는 플랫폼은 서버 코드에 하드코딩되어 있다:

```python
initial_context={
    "contextline_platforms": ("javascript", "node", "python", "php", "ruby"),
}
```

---

## 따라서: 소스맵이 없으면 그루핑이 파괴된다

해시 재료가 어떻게 바뀌는지 따라가 보자.

- 소스맵 O → **원본 파일명 + 원본 context line** → 빌드가 바뀌어도 동일 → 안정적 그루핑
- 소스맵 X → 한 줄짜리 minified 코드는 **120자 초과라 context line이 해시에서 버려지고**
  ("ignored because line is too long"), 남는 재료가 **해시 붙은 파일명**(`main-8f3a2.js`)과
  **minified 함수명**(`a`, `Xu`) → 빌드마다 달라짐 → 같은 버그가 배포마다 새 이슈

> 1부 도입에서 본 "같은 에러인데 이슈 40개"의 1번 원인.
> 소스맵은 "스택을 읽기 위한 것"이기 전에 **그루핑의 전제 조건**이다.

<!-- 부가(구두 설명용): 해시는 app hash(in-app 프레임만)와 system hash(전체 프레임) 두 개가 만들어지고, 하나라도 기존 이슈와 일치하면 그 이슈에 붙는다 (GroupHash 모델) -->

---

## 어떤 프레임이 그루핑에 참여하는가: enhancement rules

Sentry에는 내장 규칙 파일이 있다 (`all-platforms@2026-01-20.txt`, 약 500줄):

```text
## js
path:**/node_modules/** -app

### transpilers and polyfills are just noise, be more aggressive
module:@babel/** -app -group
module:core-js/** -app -group
module:tslib/** -app -group

## (전 플랫폼 공통 섹션) 예외를 던진 지점 자체는 노이즈
category:throw ^-group -group ^-app -app
```

- 문법: `matcher:expression action…` — `+app/-app`(in-app 여부), `+group/-group`(해시 참여 여부), `^`(위 프레임에), `v`(아래 프레임에)
- node_modules는 in-app이 아니고, babel/core-js 프레임은 **해시에서 제외**된다
- 같은 문법으로 프로젝트 설정에 **커스텀 규칙**을 쓸 수 있다

---

## 뭉친 이슈 나누기: fingerprint

공통 에러 핸들러가 한곳에서 throw하면 스택이 전부 핸들러를 가리킨다 → 이슈 하나에 버그 7개.

**SDK 측** — 이벤트에 fingerprint를 실어 보낸다:

```ts
Sentry.captureException(error, {
  fingerprint: ['api-error', error.endpoint, String(error.status)],
})
// 기본 그루핑에 축만 추가하려면:
fingerprint: ['{{ default }}', error.endpoint]
```

**서버 측** — 배포 없이 대시보드에서 규칙으로:

```text
error.type:DatabaseUnavailable         -> system-down
stack.function:"query_database"        -> {{ default }}, {{ transaction }}
```

변수: `{{ default }}`, `{{ error.type }}`, `{{ message }}`, `{{ stack.function }}`, `{{ transaction }}`, `{{ tags.x }}` 등 — SDK/서버 양쪽에서 동일하게 사용 가능.

---

## 쪼개진 이슈 막기: 메시지 위생

그루핑 폴백이 message까지 내려간 상황이라면:

```ts
// 안티패턴: 이슈가 유저 수만큼 생긴다
throw new Error(`User ${userId} not found`)

// 권장: 메시지는 고정, 가변 값은 컨텍스트로
Sentry.captureException(new Error('User not found'), {extra: {userId}})
```

- 서버가 message에서 파라미터를 일부 제거해주지만, 완전하지 않다
- **에러 메시지 = 에러의 종류, 컨텍스트 = 개별 상황의 값** — 이 분리가 원칙

부가: 신규 이슈 생성 시에는 AI 그루핑(Seer)이 임베딩 유사도로 한 번 더 개입한다. 완전 커스텀 fingerprint면 우회되지만, `{{ default }}` 하이브리드면 그 부분에는 여전히 적용된다.

---

## Part 5 정리

- 우선순위: fingerprint → stack trace → exception → message. fingerprint가 있으면 알고리즘은 실행 안 됨
- JS 해시 재료는 module + filename + **context line** — 그래서 소스맵이 그루핑의 전제
- babel/core-js/node_modules 프레임은 내장 enhancement 규칙이 이미 걸러준다
- 뭉침은 fingerprint로 나누고, 쪼개짐은 메시지 위생으로 막는다

---

## 랩업 퀴즈 — Part 5

<!-- _class: invert -->

**Q1.** 소스맵 없이 운영하다가 나중에 업로드를 시작하면, 기존 이슈들은 어떻게 될까?

**Q2.** `throw new Error(\`User ${userId} not found\`)`가 이슈를 수백 개 만들었다. 그루핑의 어느 단계까지 내려갔길래 이렇게 됐을까?

---

## 정답 — Part 5

<!-- _class: invert -->

**A1.** 해시 재료가 minified에서 원본으로 바뀌므로 **신규 이슈로 갈라진다**. 그루핑 설정 변경과 마찬가지로 "이슈 세대 교체"가 일어난다.

**A2.** 스택 그루핑이 실패해 **message 폴백**까지 내려간 상황. 서버가 파라미터를 일부 제거해주지만 완전하지 않다 — 메시지 고정 + `extra`로 해결한다.

---

## Part 6 — 원리에서 나오는 운영 규칙

지금까지의 내부 동작이 그대로 실무 수칙이 된다

---

## 규칙 1. 소스맵 + release는 협상 불가

- Debug ID 덕에 소스맵 매칭에 release가 필수는 아니지만 (Part 4),
  release는 **다른 세 가지** 때문에 여전히 필요하다:
  - "이 에러는 어느 배포부터인가" (회귀 추적)
  - **Resolved in next release** — 문서 원문: "Fix is in `main`, prod is still on `1.8.0` — only a recurrence in `1.9.0+` reopens it"
  - Releases 화면의 신규 이슈 감지

```ts
Sentry.init({
  release: process.env.NEXT_PUBLIC_GIT_SHA,
  environment: process.env.NEXT_PUBLIC_STAGE,
})
```

- 소스맵은 스택 가독성이 아니라 **그루핑 때문에** 필수다 (Part 5)

---

## 규칙 2. 필터는 2중으로, 각자의 자리에

| 계층                      | 실행 위치           | 특징                                 |
| ------------------------- | ------------------- | ------------------------------------ |
| `ignoreErrors`/`denyUrls` | SDK event processor | 전송량 자체를 줄임. 코드 배포 필요   |
| `beforeSend`              | SDK, processors 뒤  | 동적 조건·마스킹. 최종 이벤트를 본다 |
| Inbound filters           | Relay (서버)        | **쿼터 미소모**. 대시보드에서 즉시   |

- SDK에는 `DEFAULT_IGNORE_ERRORS`가 이미 있다 (1부) — 중복 추가로 목록을 오염시키지 말 것
- 서버 필터는 symbolication 전이라 원본 파일명 조건은 불가 (Part 4)
- 판단 기준: 항구적 노이즈는 SDK에, 긴급 차단은 서버에

---

## 규칙 3. 쿼터는 "미리" 지켜야 한다

1부 Part 3과 이번 Part 4에서 본 사실들:

- 쿼터 초과 이벤트는 **수락 자체가 안 되고 복구 불가** (문서: "will not be accepted")
- rate limit 발동 시 SDK는 이벤트를 **로컬에서 버린다** — 나중에 안 온다
- spike protection이 발동해도 마찬가지로 드랍

따라서:

```ts
Sentry.init({
  sampleRate: 1.0, // 에러는 전량 — 샘플링할 이유가 없다
  tracesSampleRate: 0.05, // 쿼터를 먹는 건 대부분 이쪽
  replaysSessionSampleRate: 0.01,
  replaysOnErrorSampleRate: 1.0,
})
```

- 얼마나 버려지고 있는지는 client report(Stats → Client Discards)로 감시할 수 있다 (1부)

---

## 규칙 4. try/catch 뒤에는 손으로 보내라

1부에서 본 원리: SDK는 **unhandled만** 자동으로 잡는다.

```ts
try {
  await submitOrder(payload)
} catch (e) {
  toast.error('주문에 실패했습니다')
  Sentry.captureException(e, {
    tags: {feature: 'checkout'},
    extra: {orderId},
  })
}
```

- fetch의 4xx/5xx는 JS 관점에서 에러가 아니다 — reject되지 않으므로 안 잡힌다
- `captureMessage`에 가변 문자열을 넣으면 message 그루핑으로 폴백되어 이슈가 분열한다 (Part 5) — **Error 객체 + 고정 메시지 + 컨텍스트**가 항상 정답

---

## 규칙 5. 이슈 상태를 원리에 맞게 쓰기

현재 이슈 상태: **New, Ongoing, Escalating, Regressed, Archived, Resolved**

- **Resolve는 "in next release"로** — release 설정(규칙 1)이 되어 있어야 다음 릴리스에서의 재발만 이슈를 다시 연다
- **Archive는 기본이 "until escalating"** — 발생량이 예측 임계치를 넘으면 자동으로 다시 올라온다. 판정은 직전 1주 데이터 기반 forecast (7일 미만 이슈는 시간당 최대치 × 10)
- 그래서 "혹시 몰라서 안 닫는" 습관은 근거가 없다 — **닫아도 시스템이 다시 열어준다**

알림은 이 상태 전이에 걸어야 한다: 새 이슈 / **Escalating** / **Regressed** / 핵심 경로 태그. "발생했다"는 알림 대상이 아니다.

---

## 규칙 6. 주간 트리아지 15분을 루틴으로

도구가 아니라 습관이 Sentry를 살린다. 주 1회, 돌아가면서:

1. 지난 주 **신규 이슈**를 훑는다 — 필터: `is:unresolved firstSeen:-7d`
2. 각 이슈를 셋 중 하나로 보낸다: **고친다(assign) / 안 고친다(archive) / 노이즈(필터 PR)**
3. 이벤트 수 상위 10개를 확인한다 — 방치된 고빈도 이슈가 쿼터를 먹는다 (규칙 3)

- 15분 안에 안 끝난다면 노이즈 필터(규칙 2)가 부족하다는 신호다
- 새 팀원 온보딩 과제로도 좋다 — 서비스의 약한 곳을 가장 빨리 배운다

<!-- 데모: 실제 프로젝트 대시보드를 열고 신규 이슈 하나를 같이 트리아지해보기 -->

---

## 부록 — 알아두면 좋은 것들

내부 동작을 알아야 보이는 꿀팁 다섯 가지

---

## 꿀팁 1. 에러 화면에 event_id를 노출하라

1부에서 본 것처럼 event_id는 **서버 응답을 기다리지 않고 클라이언트에서 즉시** 생성된다. 그래서 에러가 난 그 자리에서 바로 쓸 수 있다:

```tsx
<Sentry.ErrorBoundary
  fallback={({eventId}) => (
    <ErrorPage message={`문의 시 이 코드를 알려주세요: ${eventId}`} />
  )}
>
```

- CS 문의가 들어오면 event_id로 대시보드에서 **해당 이벤트를 바로 검색**
- "재현이 안 되는데요"라는 대화가 "이벤트 열어보니 breadcrumb에 다 있네요"로 바뀐다
- `Sentry.lastEventId()`로 어디서든 직전 이벤트 id를 얻을 수도 있다

---

## 꿀팁 2. 애드블로커에 막히면 tunnel

애드블로커 상당수가 `*.sentry.io` 요청을 차단한다 — 에러가 많이 나는 환경일수록 리포팅이 누락되기 쉽다.

```ts
// @sentry/nextjs — 자기 도메인의 라우트로 우회
export default withSentryConfig(nextConfig, {
  tunnelRoute: '/monitoring',
})
```

- 1부에서 본 원리: tunnel 사용 시 envelope 헤더에 dsn이 들어가므로, 중계 서버(Next.js가 자동 생성)는 그걸 보고 Sentry로 포워딩한다
- 도입 전후로 이벤트 볼륨을 비교해보면 그동안 얼마나 누락됐는지 보인다

---

## 꿀팁 3. error.cause를 쓰면 체인이 펼쳐진다

기본 integration 중 **linkedErrorsIntegration**(1부)이 `error.cause`를 따라가며 원인 에러를 이벤트에 같이 싣는다.

```ts
try {
  await fetchUser(id)
} catch (e) {
  // 원인을 버리지 않고 도메인 에러로 감싼다
  throw new UserLoadError('failed to load user', {cause: e})
}
```

- 대시보드에서 `UserLoadError`와 원인(`TypeError: Failed to fetch` 등)의 **스택이 둘 다** 보인다
- 도메인 에러로 감싸면 그루핑 단위도 "기능" 기준으로 정리된다 — fingerprint 없이 뭉침을 줄이는 가장 값싼 방법

---

## 꿀팁 4. extra의 객체가 `[Object]`로 잘려 있다면

1부의 조립 파이프라인 마지막 단계가 normalize였다 — 기본 **depth 3**에서 자른다.

```ts
Sentry.captureException(e, {
  extra: {order}, // order.items[0].product.name → 잘림
})

// 방법 1: 필요한 값만 평평하게
Sentry.captureException(e, {
  extra: {orderId: order.id, itemCount: order.items.length},
})

// 방법 2: 정말 깊은 구조가 필요하면
Sentry.init({normalizeDepth: 5})
```

- 순환 참조 제거와 페이로드 폭발 방지를 위한 안전장치라, 올리더라도 적당히
- 애초에 "디버깅에 실제로 쓸 값만 골라 넣는" 쪽이 항상 낫다

---

## 꿀팁 5. 에러 헬퍼가 스택 맨 위를 차지한다면 framesToPop

`assert()` 같은 헬퍼를 만들면 모든 에러의 스택 최상단이 헬퍼 자신이 된다 — 그루핑 재료가 오염된다(Part 5).

```ts
function assert(condition: unknown, message: string): asserts condition {
  if (!condition) {
    const error = new Error(message)
    // Sentry 스택 파서가 이 개수만큼 위에서 프레임을 버린다
    ;(error as any).framesToPop = 1
    throw error
  }
}
```

- SDK 스택 파서가 지원하는 관례다 (`parseStackFrames`) — invariant 같은 라이브러리들이 쓰는 방식
- 스택 최상단이 "실제 호출한 곳"이 되어 가독성과 그루핑이 같이 좋아진다

---

## 번외. @sentry/nextjs는 구조가 조금 다르다

Next.js에서는 서버·엣지·클라이언트 **세 런타임에서 SDK가 따로 초기화**된다:

```text
instrumentation.ts          서버·엣지 config 로드 (register())
                            + onRequestError = Sentry.captureRequestError
instrumentation-client.ts   브라우저 SDK 초기화 ← 1부에서 본 세계
```

- **Server Component에서 난 에러**는 브라우저가 아니라 **서버 런타임의 SDK**가 잡는다 — `onRequestError` 훅 경유. 1부(브라우저 계측)가 아니라 Node 계측의 영역이다
- 클라이언트 렌더 에러는 ErrorBoundary(`global-error.tsx`) → 브라우저 SDK로
- 소스맵 업로드와 tunnel은 `withSentryConfig`가 담당한다 (규칙 1, 꿀팁 2)

<!-- 데모: 같은 throw를 server component와 client component에 각각 넣고, 이벤트가 어느 런타임으로 잡히는지 비교하면 효과적 -->

---

## 전체 체크리스트

- [ ] 번들러 플러그인으로 소스맵 업로드 (Debug ID 자동 주입)
- [ ] `release`(커밋 SHA) + `environment` 설정
- [ ] `tracesSampleRate` 조정, `sampleRate`는 1.0 유지
- [ ] SDK 필터: 프로젝트 고유 노이즈만 (기본 목록과 중복 금지)
- [ ] 서버 Inbound filters: 확장 프로그램·크롤러 필터 활성화
- [ ] try/catch 안의 중요 에러에 `captureException` + 고정 메시지 + 컨텍스트
- [ ] 공통 에러 핸들러에 `fingerprint` 설계
- [ ] 알림: 새 이슈 / Escalating / Regressed / 핵심 경로, production 한정
- [ ] Stats → Client Discards 주기적 확인 (조용함 ≠ 에러 없음)
- [ ] Resolve는 "in next release"로, Archive는 "until escalating"으로
- [ ] 주간 15분 트리아지를 팀 캘린더에

---

## 마지막 한 장

이 시리즈에서 따라간 여정:

> `throw` → onerror(프로퍼티 교체) → 3-스코프 병합 → processors → beforeSend
> → envelope(JSON 라인) → Relay(필터) → Symbolicator(Debug ID) → 그루핑(context line) → 이슈

운영 수칙은 외울 것이 아니라 **이 파이프라인에서 도출되는 것**이다.

- 소스맵을 올리는 이유 = context line 해시
- 필터를 2중으로 두는 이유 = 쿼터는 Relay 앞에서만 절약된다
- Archive를 겁내지 않는 이유 = escalating forecast가 지켜본다

파이프라인이 머리에 있으면, 처음 보는 문제도 위치를 특정할 수 있다.

---

# 감사합니다

<!-- _class: invert -->

기준: sentry-javascript v10.69.0 / getsentry/sentry, relay (2026-01)

@yceffort

---

# Sentry 딥다이브 1부: 브라우저에서 서버까지

Source: https://research.yceffort.kr/slides/sentry-deep-dive-1.md

---
title: 'Sentry 딥다이브 1부: 브라우저에서 서버까지'
marp: true
paginate: true
theme: midnight
tags:
  - observability
  - error-handling
date: 2026-08-05
description: 'window.onerror 후킹부터 envelope 전송까지 — 실제 SDK 소스로 따라가는 에러 포착과 조립의 여정'
published: true
art:
  undraw: connection-lost
---

# Sentry 딥다이브 1부

브라우저에서 서버까지 — 에러 하나가 이슈가 되기까지 ①

<!-- _class: invert -->

@yceffort

---

## 어느 팀의 Sentry

- 미해결 이슈 **31,842개**, 아무도 안 읽는 알림 채널
- 스택을 열면 `a.b.c @ main-8f3a2.js:1:482910`
- 같은 버그인데 이슈는 40개, 다른 버그 7개가 이슈 1개에 뭉쳐 있다

"설정을 잘못해서"라고 하기엔, **뭘 잘못했는지 설명할 수 있는 사람이 없다.**

> 이 강의는 사용법이 아니라 **동작 원리**를 다룬다.
> 원리를 알면 위의 문제들이 전부 "당연한 결과"로 보이게 된다.

---

## 이 시리즈에서 다루는 것

에러 하나의 여정을 다섯 구간으로 나눠 실제 소스로 따라간다.

**1부 (오늘)**

0. **워밍업** — 이벤트/이슈 모델, SDK가 잡는 것과 못 잡는 것 (개념만, 코드 없음)
1. **포착** — 브라우저에서 에러는 어떻게 잡히는가
2. **조립** — 이벤트는 어떻게 만들어지는가 (Scope, 콜 체인, 필터)
3. **전송** — envelope, transport, rate limit

**2부**

4. **수신** — Relay 파이프라인과 소스맵 복원 (Debug ID)
5. **그루핑** — 이슈는 어떻게 묶이는가 + **운영 규칙**과 **꿀팁 부록**

---

## 기준 버전

이 덱의 코드는 전부 실제 소스에서 가져왔다.

- SDK: `getsentry/sentry-javascript` **v10.69.0** (2026-07-29)
- 서버: `getsentry/sentry`, `getsentry/relay` master (2026-01 기준) + 공식 문서
- 그루핑 설정: `all-platforms@2026-01-20` enhancement config

<!-- 버전을 박아두는 이유: Sentry SDK는 v8에서 아키텍처가 크게 바뀌었다(Hub 제거). 구버전 블로그 글과 이 덱의 내용이 다르면 버전부터 의심할 것 -->

---

## 이 시리즈가 다루지 않는 것

**에러 이벤트의 파이프라인**에만 집중한다. 아래는 의도적으로 뺐다:

- **성능 모니터링** (트랜잭션, 스팬, Web Vitals) — 파이프라인은 비슷하지만 별도 주제
- **Session Replay** — 쿼터 이야기에서 샘플링 설정으로만 잠깐 등장한다
- **알림 채널 연동, 대시보드 세부 기능** — 운영 규칙(2부)에서 원리만 다룬다

> "에러 하나가 이슈가 되기까지"를 이해하면,
> 나머지는 필요해질 때 문서만으로 충분히 따라갈 수 있다.

---

## Part 0 — 워밍업

딥다이브 전에 필요한 개념 네 가지 (여기는 코드 없음)

---

## 개념 1. 이벤트와 이슈

Sentry의 데이터 모델은 딱 두 층이다.

- **이벤트(event)**: 에러가 한 번 발생할 때마다 전송되는 개별 리포트
  - 스택 트레이스, 브라우저 정보, URL, 유저, 직전 행동 기록…
- **이슈(issue)**: "같은 에러"라고 판단된 이벤트들의 **묶음**
  - 대시보드 목록의 한 줄 = 이슈 하나
  - 같은 에러가 10만 번 나도 이슈는 1개

> "같은 에러인지"를 판단하는 과정이 **그루핑** — 이 시리즈의 최종 보스다 (2부에서 다룬다).
> 도입부에서 본 "같은 버그인데 이슈 40개"는 그루핑이 망가진 결과다.

---

## 개념 2. Sentry는 "버려진 에러"만 자동으로 줍는다

- 자동으로 잡히는 것: **아무도 처리하지 않은** 에러
  - `throw` 했는데 아무도 안 잡은 것
  - promise가 reject됐는데 `.catch()`가 없는 것
- 자동으로 잡히지 **않는** 것:
  - `try/catch`로 잡은 에러 — 이미 "처리된" 에러라서
  - fetch의 4xx/5xx 응답 — JS 관점에서는 에러가 아니라서 (reject되지 않는다)

```ts
try {
  await submitOrder(payload)
} catch (e) {
  toast.error('주문에 실패했습니다')
  Sentry.captureException(e) // 이 줄이 없으면 Sentry는 이 실패를 영원히 모른다
}
```

---

## 개념 3. 직접 보내는 API는 사실상 두 개

- `captureException(error)` — Error 객체를 보낸다. **스택이 남는다.** 기본 선택지
- `captureMessage(string)` — 문자열만. 스택이 없어서 디버깅도 그루핑도 약하다

> 원칙 한 줄: **Error 객체가 있으면 무조건 captureException.**

여기에 상황 정보를 붙이는 도구들(tags, extra, breadcrumb)이 있는데, 각각 언제 쓰는지는 여정을 따라가며 자연스럽게 나온다.

---

## 개념 4. 오늘 따라갈 지도

```text
throw
  │ ① 포착      브라우저 어딘가에서 Sentry가 줍는다
  ▼
이벤트 조립     │ ② 조립      스택 파싱, tags/유저 정보 합치기, 필터
  ▼
전송           │ ③ 전송      묶어서 서버로 쏜다
  ▼
Sentry 서버    │ ④ 수신      필터 → 소스맵 복원          (2부)
  ▼
그루핑         │ ⑤ 그루핑    "같은 에러" 판정 → 이슈      (2부)
```

- 1부는 ①~③, 브라우저 쪽 이야기다. 각 구간을 실제 소스 코드로 내려간다
- **중간에 길을 잃으면 이 지도로 돌아오면 된다** — 구간별 "정리" 슬라이드만 이어 읽어도 전체 흐름은 잡힌다

---

## Part 1 — 포착

브라우저에서 에러는 어떻게 잡히는가

이번 파트가 코드가 가장 많다. 슬라이드마다 요점은 마지막 한두 줄이니, 코드가 버거우면 **그 줄만 챙겨도 뒤따라오는 데 문제없다.**

---

## 아무도 안 잡은 에러의 종착지

브라우저에는 "처리되지 않은 에러"가 도달하는 전역 지점이 두 개 있다.

- 동기 코드의 throw → `window.onerror`
- `.catch()` 없는 promise reject → `unhandledrejection` 이벤트

Sentry의 **globalHandlersIntegration**이 이 둘을 후킹한다.
그런데 후킹 방식이 특이하다 — `addEventListener`가 아니라 **프로퍼티 교체**다.

---

## window.onerror를 교체하는 실제 코드

```ts
// packages/core/src/instrument/globalError.ts
function instrumentError(): void {
  _oldOnErrorHandler = GLOBAL_OBJ.onerror

  // Note: The reason we are doing window.onerror instead of
  // window.addEventListener('error') is that we are using this handler
  // in the Loader Script, to handle buffered errors consistently
  GLOBAL_OBJ.onerror = function (msg, url, line, column, error) {
    triggerHandlers('error', {column, error, line, msg, url})

    if (_oldOnErrorHandler) {
      return _oldOnErrorHandler.apply(this, arguments)
    }
    return false
  }
}
```

- 기존 핸들러를 보관했다가 **체이닝** — 다른 라이브러리의 onerror를 깨뜨리지 않는다
- 왜 `addEventListener('error')`가 아닐까? 주석의 **Loader Script** 때문 (다음 장)

---

## 잠깐, Loader Script가 뭔데?

SDK 본체(수백 KB)를 CDN에서 **비동기로 늦게 로드**하는 초소형 스니펫이다.

```text
페이지 로드 ──→ Loader Script 실행 ──→ ... ──→ SDK 본체 로드 완료
                (자기 onerror 설치)     ↑
                                  이 사이에 난 에러는?
```

- Loader는 자신의 `window.onerror`를 걸어 그동안의 에러를 **큐에 모아둔다**
- 본체가 로드되면 같은 `onerror` 자리를 **교체하며 이어받고**, 큐에 쌓인 에러를 재처리한다
- `addEventListener`였다면? 리스너는 추가만 되고 교체가 안 되므로, "로더의 핸들러를 본체가 승계한다"는 구조를 만들 수 없다

> 그래서 주석이 "buffered errors를 일관되게 처리하려고 onerror를 쓴다"고 말한 것이다.

---

## onerror 인자로 이벤트 만들기

<!-- prettier-ignore -->
```ts
// packages/browser/src/integrations/globalhandlers.ts
const {msg, url, line, column, error} = data

const event = _enhanceEventWithInitialFrame(
  eventFromUnknownInput(stackParser, error || msg, undefined, attachStacktrace, false),
  url, line, column,
)

captureEvent(event, {
  originalException: error,
  mechanism: {handled: false, type: 'auto.browser.global_handlers.onerror'},
})
```

- `error || msg` — 보통은 Error 객체가 오지만, 크로스 오리진 스크립트 에러처럼 문자열 `msg`만 올 때가 있다. 그때는 메시지로라도 이벤트를 만든다
- 스택이 아예 없으면 "어느 파일 몇 번째 줄"(`url/line/column`)짜리 프레임 하나를 만들어 넣는다
- `mechanism` — "어떤 경로로 잡았나" 꼬리표. `handled: false`라서 대시보드에 **unhandled** 배지가 붙는다

<!-- 덤: setupOnce()에서 Error.stackTraceLimit = 50으로 올려둔다 (V8 기본값 10). 스택을 더 길게 받아두려는 것 -->
<!-- 크로스 오리진 구두 보충: CDN 스크립트가 CORS 헤더 없이 서빙되면 브라우저가 정보를 숨겨 "Script error."만 온다. Part 2의 기본 필터 목록에서 다시 만난다 -->

---

## 그 유명한 "Non-Error promise rejection captured"

reject된 값이 **Error가 아니라 primitive일 때** 만들어지는 메시지다.

```ts
// packages/browser/src/integrations/globalhandlers.ts
const event = isPrimitive(error)
  ? _eventFromRejectionWithPrimitive(error)
  : eventFromUnknownInput(stackParser, error, undefined, attachStacktrace, true)
```

```ts
{
  type: 'UnhandledRejection',
  value: `Non-Error promise rejection captured with value: ${String(reason)}`,
}
```

- `Promise.reject('oops')` 처럼 문자열을 reject하면 이게 된다 — **스택도 없다**
- Error가 아닌 객체면: `Object captured as promise rejection with keys: ...`
- 이 이슈가 자주 보인다면 범인은 대부분 서드파티 스크립트이거나, `reject(new Error(...))`를 안 쓴 우리 코드다

---

## setTimeout 콜백의 에러는 어떻게 잡을까

문제: async 콜백은 등록한 코드의 **콜 스택 밖**에서 실행된다 (왜 그런지는 다음 장).
onerror로도 잡히지만, **어떤 API의 콜백이었는지** 정보가 없다.

**browserApiErrorsIntegration** (구 TryCatch)의 답: 브라우저 API를 몽키패치한다.

```ts
// packages/browser/src/integrations/browserapierrors.ts — setupOnce()
fill(WINDOW, 'setTimeout', _wrapTimeFunction)
fill(WINDOW, 'setInterval', _wrapTimeFunction)
fill(WINDOW, 'requestAnimationFrame', _wrapRAF)
fill(XMLHttpRequest.prototype, 'send', _wrapXHR)
eventTarget.forEach((target) => _wrapEventTarget(target, _options))
```

- `addEventListener`는 EventTarget, Node, WebSocket, Worker 등 **약 30종의 prototype**에 대해 래핑된다
- `removeEventListener`도 같이 패치해서 래핑본까지 해제해준다

---

## 왜 콜백은 "콜 스택 밖"인가

```ts
try {
  setTimeout(() => {
    throw new Error('1초 뒤의 에러') // ②
  }, 1000)
} catch (e) {
  // 절대 실행되지 않는다
}
// ① setTimeout은 콜백을 "등록"만 하고 즉시 리턴한다
```

- ①이 리턴하는 순간 이 코드의 콜 스택은 비워진다 — 걸어둔 try/catch도 함께 사라진다
- 1초 뒤 이벤트 루프가 콜백을 **완전히 새로운 콜 스택**에서 실행한다. ②의 throw가 위로 올라가 봐야 받아줄 사람이 없다
- 그래서 async 콜백의 에러는 등록부를 감싼 try/catch를 그냥 지나쳐 전역 `onerror`로 직행한다

> 던진 "시점"에 정보를 붙이려면 방법은 하나 — **콜백 자체를** try/catch로 감싸는 것.
> 앞 장의 몽키패치가 콜백을 바꿔치기한 이유이고, 그 감싸는 함수가 다음 장의 `wrap()`이다.

---

## wrap()의 트릭: 잡고, 보내고, 다시 던진다

<!-- prettier-ignore -->
```ts
// packages/browser/src/helpers.ts
const sentryWrapped = function (...args) {
  try {
    return fn.apply(this, args.map(arg => wrap(arg, options)))
  } catch (ex) {
    ignoreNextOnError()
    withScope((scope) => {
      scope.addEventProcessor((event) => {
        addExceptionMechanism(event, options.mechanism)
        return event
      })
      captureException(ex)
    })
    throw ex // 앱의 원래 동작 보존
  }
}
```

**rethrow하면 onerror가 또 잡아서 중복 리포팅되지 않을까?**

---

## ignoreNextOnError: 카운터와 이벤트 루프

```ts
export function ignoreNextOnError(): void {
  // onerror should trigger before setTimeout
  ignoreOnError++
  setTimeout(() => {
    ignoreOnError--
  })
}
```

- rethrow된 에러는 **동기적으로** onerror에 도달한다
- setTimeout 콜백(카운터 감소)은 그 **다음 틱**에 실행된다
- 그 사이에 globalHandlers가 `shouldIgnoreOnError()`로 카운터를 보고 스킵

> 이벤트 루프의 실행 순서를 정확히 알아야 성립하는 설계다.
> "브라우저에서 중복 없이 에러 잡기"가 얼마나 지저분한 문제인지 보여준다.

---

## breadcrumb도 전부 몽키패치다

에러 상세에서 보는 "유저 행동 타임라인"의 출처:

| 소스    | 계측 방식                                                             |
| ------- | --------------------------------------------------------------------- |
| fetch   | `fill(GLOBAL_OBJ, 'fetch', ...)` — 전역 fetch 교체                    |
| xhr     | `XMLHttpRequest.prototype.open/send`를 **Proxy**로 교체               |
| 클릭/키 | document 버블 리스너 + `addEventListener` 패치 (stopPropagation 대비) |
| console | `console.log/warn/error/...` 각 레벨을 fill                           |
| 라우팅  | `history.pushState/replaceState` fill + popstate 리스너               |

- Sentry 자신의 전송은 `url.match(/sentry_key/) && method === 'POST'`로 제외
- 클릭 breadcrumb의 셀렉터 문자열은 기본 80자, 옵션으로 늘려도 1024자 캡 — "breadcrumb 100개가 이벤트 1MB를 넘지 않도록"

---

## Sentry.init이 기본으로 켜는 것들

```ts
// packages/browser/src/sdk.ts — getDefaultIntegrations()
inboundFiltersIntegration(),      // ignoreErrors/denyUrls (뒤에서 자세히)
functionToStringIntegration(),
conversationIdIntegration(),
browserApiErrorsIntegration(),    // API 몽키패치
breadcrumbsIntegration(),         // 행동 기록
globalHandlersIntegration(),      // onerror/unhandledrejection
linkedErrorsIntegration(),        // error.cause 체인 펼치기
dedupeIntegration(),              // 직전 이벤트와 동일하면 드랍
httpContextIntegration(),
cultureContextIntegration(),
browserSessionIntegration(),
```

`Sentry.init({dsn})` 한 줄이 실제로는 **전역 핸들러 교체 + 수십 개 API 몽키패치**다.

---

## Part 1 정리

- unhandled 에러의 입구는 둘: `window.onerror`(프로퍼티 교체 방식), `unhandledrejection`
- "Non-Error promise rejection" = primitive를 reject한 것. 스택이 없다
- async 콜백은 `wrap()`이 try/catch로 감싸 잡고 **다시 던진다** — 중복은 카운터 + 이벤트 루프 순서로 방지
- breadcrumb은 fetch/XHR/console/history 몽키패치의 산물

---

## 랩업 퀴즈 — Part 1

<!-- _class: invert -->

**Q1.** `try { ... } catch (e) { console.error(e) }` 안에서 난 에러는 Sentry에 갈까?

**Q2.** `Promise.reject('oops')`는 대시보드에서 어떤 모습으로 보일까?

---

## 정답 — Part 1

<!-- _class: invert -->

**A1.** 안 간다. catch로 처리된 에러는 onerror에 도달하지 않는다 — console breadcrumb으로만 남는다. Sentry에 알리려면 `captureException`을 직접 불러야 한다.

**A2.** `UnhandledRejection` 타입에 "Non-Error promise rejection captured with value: oops" — **스택 없는 이슈**가 된다. `reject(new Error(...))`를 써야 하는 이유.

---

## Part 2 — 조립

이벤트는 어떻게 만들어지는가

---

## tags와 user는 어디에 쌓이는가: 3-Scope 모델

`setTag`, `setUser`로 심은 정보는 이벤트에 실리기 전까지 **스코프(Scope)**라는 보관함에 쌓인다. 스코프는 세 층이다:

- **globalScope** — 프로세스 전체 싱글턴. "applied to _all_ events"
- **isolationScope** — 실행 단위(서버라면 요청)별 격리. Node에서는 `AsyncLocalStorage`로 구현
- **currentScope** — `withScope()`가 만드는 가장 좁은 단위

```ts
// packages/core/src/exports.ts — 우리가 매일 쓰는 API의 실제 대상
export function setTag(key: string, value: Primitive): void {
  getIsolationScope().setTag(key, value)
}
export function setUser(user: User | null): void {
  getIsolationScope().setUser(user)
}
```

`Sentry.setTag`/`setUser`는 **isolationScope**에 쓴다.

<!-- 구두: 구글링하다 만나는 옛 자료의 "Hub"가 바로 이 역할을 하던 개념인데, v8에서 제거되고 스코프 모델로 대체됐다. Hub가 나오는 글은 오래된 글이라는 신호다 -->

---

## 스코프 병합: 누가 이기는가

```ts
// packages/core/src/utils/scopeData.ts
export function getCombinedScopeData(isolationScope, currentScope): ScopeData {
  const scopeData = getGlobalScope().getScopeData()
  isolationScope && mergeScopeData(scopeData, isolationScope.getScopeData())
  currentScope && mergeScopeData(scopeData, currentScope.getScopeData())
  return scopeData
}
```

- 병합 순서: global → isolation → current. **나중이 이긴다**: current > isolation > global
- 브라우저에서 isolationScope는 사실상 전역 하나 — `withIsolationScope`가 포크하지 않고 기존 것을 반환한다 (요청 격리가 필요 없는 환경이라서)

> 흥미로운 사실: client 인스턴스조차 전역이 아니라 **Scope에 붙어 있다.**
> `getClient()` = `getCurrentScope().getClient()`

---

## captureException의 여정 (1) — 스코프를 지나 클라이언트로

```ts
// ① exports.ts: getCurrentScope()로 위임 → ② scope.ts:
const syntheticException = new Error('Sentry syntheticException')
this._client.captureException(
  exception,
  {
    originalException: exception,
    syntheticException,
    ...hint,
    event_id: eventId,
  },
  this,
)

// ③ client.ts: 같은 Error 객체는 두 번 캡처하지 않는다
if (checkOrSetAlreadyCaught(exception)) return // error.__sentry_captured__ = true 마킹
```

- **syntheticException** — 스택 없는 값(`captureException('문자열')`)을 위해 SDK 진입 시점의 스택을 미리 만들어 둔다
- event_id(uuid)도 이 시점에 생성 — 서버 응답을 기다리지 않는다
- `__sentry_captured__` 마킹 덕에 rethrow되어 전역 핸들러를 거쳐도 **1번만 전송**된다
- 이 마킹은 **non-enumerable 속성**으로 찍는다 — `Object.keys`, `JSON.stringify`, 스프레드에 잡히지 않는 숨김 속성이라, 에러를 로깅·직렬화해도 흔적이 안 보인다

<!-- 구두: ignoreNextOnError(카운터), dedupeIntegration까지 합치면 SDK의 중복 방지 장치는 세 겹이다 -->

---

## captureException의 여정 (2) — 스택 파싱

Error의 `stack`은 **표준이 아니다.** 브라우저마다 포맷이 다르다.

```ts
// packages/browser/src/stack-parsers.ts (TraceKit 포크)
// Chromium: "at commitLayoutEffects (react-dom.development.js:23426:1)"
const chromeRegex =
  /^\s*at (?:(.+?\)(?: \[.+\])?|.*?) ?\((?:address at )?)?.../i

// Gecko/Safari: "func@url:row:col"
const geckoREgex = /^\s*(.*?)(?:\((.*?)\))?(?:^|@)?.../i

export const defaultStackParser = createStackParser(
  chromeStackLineParser,
  geckoStackLineParser,
)
```

- 기본은 **라인 파서 2개** (Chromium용, Gecko/Safari용 — 내부 정규식으로는 6개)
- 안전장치: 라인당 1024자 컷(ReDoS 방지), 프레임 상한 50
- 파싱 후 SDK 내부 프레임을 제거하고 **역순 정렬** — Sentry 프로토콜은 oldest-frame-first

---

## captureException의 여정 (3) — 조립 파이프라인

`Client._processEvent`가 실행하는 순서. **순서 자체가 중요하다:**

```text
1. sampleRate 랜덤 샘플링        ← 에러 이벤트만, 가장 먼저
2. prepareEvent
   ├─ applyClientOptions          (environment/release 채우기)
   ├─ applyDebugIds               (스택 프레임에 debug_id 태깅 — 2부)
   ├─ 3-스코프 병합 → applyScopeDataToEvent  (tags/user/breadcrumbs 주입)
   ├─ event processors            (client 것 먼저, scope 것 나중에)
   └─ applyDebugMeta + normalize  (순환 참조 제거, depth 3)
3. beforeSend                     ← 모든 처리가 끝난 "최종 이벤트"를 받는다
4. sendEvent → envelope → transport
```

- `ignoreErrors` 필터(inboundFilters)는 2단계의 **event processor**로 동작한다
- 각 단계에서 드랍되면 사유가 기록된다: `sample_rate`, `event_processor`, `before_send`…

---

## ignoreErrors의 실제 구현 — 그리고 숨겨진 기본 목록

```ts
// packages/core/src/integrations/eventFilters.ts
const DEFAULT_IGNORE_ERRORS = [
  /^Script error\.?$/, // CORS로 읽지 못한 외부 스크립트 에러
  /^ResizeObserver loop completed with undelivered notifications.$/,
  /^Cannot redefine property: googletag$/, // GTM + 애드블로커
  /can't redefine non-configurable property "solana"/, // 지갑 확장
  /Can't find variable: _AutofillCallbackHandler/, // Instagram 웹뷰
  // ...
]
```

- **SDK가 기본으로 버리는 에러 목록이 이미 있다** — 우리가 넣는 `ignoreErrors`는 여기에 합쳐진다
- `denyUrls`는 **크래시가 난 프레임(스택 최상단)의 filename** 기준으로 매칭한다
  (소스 주석: "we only want to match against the top frame of the 'root'")
- 서버에도 같은 종류의 필터가 또 있다 (2부) — 필터는 2중 구조다

---

## Part 2 정리

- 스코프는 3층: global / isolation / current — 병합은 current가 이긴다
- `setTag`/`setUser` → isolationScope. 브라우저에서 isolationScope는 사실상 전역
- 중복 방지 3중: `__sentry_captured__` 마킹, ignoreNextOnError 카운터, dedupeIntegration
- 파이프라인 순서: **sampleRate → processors(ignoreErrors 포함) → beforeSend**
- `ignoreErrors`에는 SDK 내장 기본 목록이 이미 합쳐져 있다

---

## 랩업 퀴즈 — Part 2

<!-- _class: invert -->

**Q1.** `beforeSend`에서 받은 event 객체에는 breadcrumbs가 들어 있을까?

**Q2.** `Sentry.withScope(scope => scope.setTag(...))`로 찍은 tag는 어떤 이벤트까지 적용될까?

---

## 정답 — Part 2

<!-- _class: invert -->

**A1.** 있다. beforeSend는 스코프 병합, event processor, normalize까지 전부 끝난 **최종 이벤트**를 받는다 — 파이프라인의 마지막 단계다.

**A2.** withScope **콜백 안에서 캡처된 이벤트에만**. 콜백이 끝나면 clone된 스코프가 pop되어 사라진다. 반면 `Sentry.setTag`는 isolationScope에 쓰므로 이후 모든 이벤트에 붙는다.

---

## Part 3 — 전송

envelope, transport, rate limit

---

## Envelope: 전송의 포장 단위

Sentry가 서버로 보내는 모든 것은 **envelope(봉투)**라는 규격으로 포장된다.
종류가 다른 데이터들을 **한 번의 HTTP 요청에 묶어 보내기 위한** 포장 단위다.

```text
{"event_id":"9ec7...","sent_at":"2026-08-05T..."}     ← 봉투 헤더: 누가·언제
{"type":"event"}                                      ← 아이템 헤더: 내용물의 종류
{"exception":{...},"tags":{...},"breadcrumbs":[...]}  ← 페이로드: 내용물
```

- 개행으로 구분된 JSON 라인 — 아이템(헤더+페이로드)을 여러 개 이어 붙일 수 있다
- 에러 이벤트, 첨부파일, client report, replay가 **전부 이 봉투 하나의 규격**으로 나간다 — 데이터 종류가 늘어나도 전송 계층은 규격 하나만 알면 된다

전송 주소: `https://{host}/api/{projectId}/envelope/?sentry_version=7&sentry_key=...`

---

## 인증이 헤더가 아니라 쿼리스트링인 이유

```ts
// packages/core/src/api.ts — 주석 원문
// "Sending auth as part of the query string and not as custom HTTP headers
//  avoids CORS preflight requests."
```

- sentry.io는 우리 페이지와 **다른 도메인**이다 → 모든 전송이 cross-origin 요청
- 브라우저는 cross-origin 요청이 "안전한 형태"(GET/POST + 기본 헤더)를 벗어나면,
  본 요청 전에 **OPTIONS 요청으로 서버의 허락부터 구한다** — 이것이 **preflight**
- 커스텀 인증 헤더를 붙이는 순간 "안전한 형태"를 벗어난다 → **전송마다 왕복이 2배**
- 쿼리스트링은 요청 형태를 바꾸지 않으니 preflight가 없다 — 페이지 이탈 직전에도 보내야 하는 에러 리포팅에서 왕복 1회는 아깝다

---

## 애드블로커에 막힐 때: tunnel

애드블로커 상당수가 `*.sentry.io` 요청 자체를 차단한다 — SDK가 아무리 잘 잡아도 **전송 단계에서 증발**한다.

**tunnel 옵션**: 전송 목적지를 sentry.io 대신 **자기 도메인의 경로**로 바꾼다. 중계 서버가 받아서 Sentry로 포워딩하는 구조다.

```ts
// 이때는 중계 서버가 최종 목적지를 알아야 하므로
// envelope 헤더에 dsn이 들어간다 (packages/core/src/utils/envelope.ts)
...(!!tunnel && dsn && {dsn: dsnToString(dsn)}),
```

> Next.js라면 `tunnelRoute` 옵션 한 줄로 중계 라우트까지 자동 생성된다 (2부 부록).

---

## Transport: 생각보다 아무것도 안 해준다

```ts
// packages/browser/src/transports/fetch.ts
keepalive: pendingBodySize <= 60_000 && pendingCount < 15,
```

- **재시도가 없다.** 네트워크 실패 = 이벤트 유실 (`network_error`로 집계만)
- 버퍼 40개 초과분은 그냥 버린다 (`queue_overflow`)
- `keepalive`는 페이지 이탈 중에도 전송을 보장하지만, 스펙상 총 64KiB 제한이 있어 조건부로만 켠다
- 계측된 fetch가 아니라 **네이티브 fetch**를 쓴다 — 자기 전송이 자기 breadcrumb이 되는 것을 방지

> 재시도가 필요하면 opt-in인 `makeBrowserOfflineTransport` — IndexedDB에 넣고 5초 → 최대 1시간 지수 백오프.

---

## Rate limit: 서버가 시키면 클라이언트가 버린다

서버 응답에 이 헤더가 오면:

```text
X-Sentry-Rate-Limits: 60:error;transaction:organization
```

```ts
// packages/core/src/utils/ratelimit.ts
// <retry_after>:<categories>:<scope>:<reason_code>:<namespaces> 형식
updatedRateLimits[category] = now + delay
// 헤더 없이 429만 오면: 전체 카테고리 60초
```

- SDK는 카테고리별(`error`, `transaction`, `replay`…) "전송 금지 시각"을 기록
- 그 시각까지의 신규 이벤트는 **전송 시도조차 없이 로컬에서 드랍** (`ratelimit_backoff`)
- 재시도 큐 같은 것은 없다 — 그냥 사라진다

> "쿼터 초과 시간대의 에러는 복구 불가"인 이유가 프로토콜 수준에서 정해져 있다.

---

## SDK는 자기가 버린 것을 자백한다: client report

로컬에서 드랍된 모든 이벤트는 사유별로 집계된다:

```ts
// {"type": "client_report"} envelope 아이템
{
  timestamp: ...,
  discarded_events: [
    {reason: 'ratelimit_backoff', category: 'error', quantity: 3},
    {reason: 'before_send', category: 'error', quantity: 12},
  ]
}
```

- 사유: `sample_rate`, `event_processor`, `before_send`, `ratelimit_backoff`, `network_error`, `queue_overflow`…
- 전송 타이밍이 재밌다: **탭이 hidden으로 바뀔 때** flush (`visibilitychange`)
- 대시보드의 Stats → Client Discards가 이 데이터다

> "Sentry가 조용한데 정말 에러가 없는 걸까?"의 답을 여기서 확인할 수 있다.

---

## Part 3 정리

- envelope = JSON 라인 포맷. 인증은 쿼리스트링 (CORS preflight 회피)
- tunnel은 애드블로커 우회 — envelope 헤더의 dsn으로 목적지 식별
- 기본 transport는 재시도 없음. rate limit은 "전송 전 로컬 드랍"
- SDK가 버린 이벤트는 client report로 자백된다 — Stats 화면에서 확인 가능

---

## 랩업 퀴즈 — Part 3

<!-- _class: invert -->

**Q1.** 429를 받은 뒤 60초 안에 발생한 에러는, 60초가 지나면 전송될까?

**Q2.** Sentry는 왜 인증 정보를 HTTP 헤더가 아니라 쿼리스트링으로 보낼까?

**Q3.** `tracesSampleRate: 0.05`로 내리면 에러도 5%만 올까?

---

## 정답 — Part 3

<!-- _class: invert -->

**A1.** 안 된다. `ratelimit_backoff`로 로컬 드랍되고 끝 — 재시도 큐가 없다. offline transport를 쓰지 않는 한 복구 불가.

**A2.** 커스텀 헤더는 전송마다 CORS preflight(OPTIONS)를 유발하기 때문. 페이지 이탈 직전에도 보내야 하는 에러 리포팅 특성상 왕복 1회가 아깝다.

**A3.** 아니다. `tracesSampleRate`는 성능 트랜잭션 전용이고 에러는 `sampleRate`가 관장한다. 단 `sampleRate`를 1.0 아래로 내리면 **이벤트 단위 랜덤 추첨**이라 특정 유저의 에러가 무작위로 빠진다 — 에러 샘플링을 하지 말라는 이유.

---

## 1부 정리 — 여기까지의 여정

> `throw` → onerror(프로퍼티 교체) → 3-스코프 병합 → processors → beforeSend
> → envelope(JSON 라인) → 전송

- 에러는 전역 핸들러와 몽키패치로 **포착**되고
- 스코프 병합과 필터를 거쳐 **이벤트로 조립**되고
- envelope에 실려 **서버로 떠났다**

**2부 예고** — 서버에 도착한 이벤트는 어떻게 "이슈"가 되는가:
Relay 관문, Debug ID 소스맵 복원, 그루핑 해시, 그리고 원리에서 도출되는 운영 규칙.

---

# 감사합니다

<!-- _class: invert -->

2부에서 계속 — 기준: sentry-javascript v10.69.0

@yceffort

---

# Vite 번들 최적화 실전 가이드

Source: https://research.yceffort.kr/slides/vite-bundle-optimization.md

---
title: Vite 번들 최적화 실전 가이드
marp: true
paginate: true
theme: default
tags:
  - bundler
  - web-performance
date: 2026-08-05
description: '내 앱의 번들은 무엇으로 결정되는가: 네 가지 질문으로 나눠서 점검하고 고치는 법'
published: true
art:
  undraw: static-assets
---

# Vite 번들 최적화 실전 가이드

내 앱의 번들은 무엇으로 결정되는가

<!-- _class: invert -->

@yceffort

---

## 이 글이 답하려는 질문

번들이 크다는 건 알겠는데, **뭘 건드려야 줄어드는가?**

흔한 답("코드 스플리팅 하세요", "트리셰이킹 되게 하세요")은 대부분 순서가 틀렸거나, 그 기법이 실제로 무엇을 줄이는지 오해하고 있다.

이 글은 번들 크기를 **네 개의 파트**로 분해하고, 파트마다 무엇을 점검하고 어떤 규모의 이득을 기대할 수 있는지 정리한다.

수치는 두 개의 Vite 앱 **A**(라우트 23개 게시판형)와 **B**(단일 스크롤 화면)를 `vite build` before/after로 잰 값이다. **절대값이 아니라 "이 기법은 이 정도 규모"라는 감각으로 보면 된다.**

측정 환경: Vite 8 (Rolldown 기반). 이 글의 옵션 이름은 rolldown의 `codeSplitting` rename 이후 기준이라 버전에 민감하다. Q&A에 Vite 7 이하 대응표가 있다.

---

## 번들 크기를 결정하는 네 가지 질문

| Part                     | 질문                       | 주요 수단                          | 단독 SPA라면                |
| ------------------------ | -------------------------- | ---------------------------------- | --------------------------- |
| **1. 무엇이 들어오는가** | 이 모듈이 왜 번들에 있나   | `external`, dedupe, `define`, 타깃 | 부분 해당 (`external` 제외) |
| **2. 얼마나 걷히는가**   | 안 쓰는 JS가 남아 있나     | 트리셰이킹, `sideEffects`          | 해당                        |
| **3. CSS**               | 안 쓰는 스타일이 남아 있나 | subpath import, entry CSS          | 해당                        |
| **4. 언제 받는가**       | 첫 화면에 필요한 것만 받나 | 코드 스플리팅, entry 분리          | 해당                        |

---

## 본론에 들어가기 전에 못 박아둘 것

- **압축(minify·gzip)은 최적화 항목이 아니라 측정의 전제다.** Part 0에서 다룬다
- **네 파트는 서로 독립이 아니다.** 같은 바이트를 겨냥한 기법끼리는 이득이 합산되지 않는다 (Part 5)
- **Part 4는 크기를 줄이지 않는다.** 받는 시점을 옮길 뿐이다. 이걸 크기 최적화로 착각하는 게 가장 흔한 실수다

---

## 어디부터 봐야 하나

```text
0. 측정 환경이 맞는지 확인 (미니파이·gz)  ← 여기서 틀리면 나머지가 전부 무의미
   ↓
1. 안 들어와도 되는 게 있나      (Part 1)  : 코드 한 줄, 효과 큼
   ↓
2. 들어온 것 중 안 쓰는 게 있나  (Part 2)  : 설정 몇 줄, 효과 큼
   ↓
3. 안 쓰는 CSS가 통째로 오나     (Part 3)  : import 경로 변경, 효과 중간
   ↓
4. 받는 시점을 옮길 수 있나      (Part 4)  : 구조 변경, 크기 이득 없음
```

**위에서부터 내려온다.** 4번부터 시작하는 팀이 많은데, 가장 비싸고 크기 이득은 0이다.

---

# Part 0. 측정

무엇을 재고 있는지부터 확인한다

---

## 재기 전 확인 ①: 미니파이된 산출물인가

같은 코드가 **두 배 차이로 측정된다.** 여기서 틀리면 뒤의 모든 비교가 무의미하다.

```bash
head -c 200 dist/assets/index-*.js   # 변수명이 길면 미니파이 안 된 것
wc -l dist/assets/index-*.js         # 수천 줄이면 안 된 것
```

Vite 8의 `build.minify` 기본값은 `'oxc'`다(CSS는 lightningcss). **그냥 두면 mode와 무관하게 항상 켜져 있다.** 그런데도 이 함정에 빠지는 팀이 많다.

---

## 왜 꺼지는가: config가 기본값을 덮는다

```ts
export default defineConfig(({mode}) => {
  const isProd = mode === 'production'
  return {
    build: {
      minify: isProd, // ← 기본값을 mode에 묶었다
      sourcemap: isProd ? 'hidden' : true,
    },
  }
})
```

배포 환경이 하나면 드러나지 않는다. `vite build`의 mode 기본값이 `production`이라 `isProd`가 항상 참이기 때문이다.

---

## mode가 늘어나는 순간 갈라진다

```bash
vite build                      # mode=production → minify O
vite build --mode staging       # mode=staging    → minify X  ⚠️
vite build --mode qa            # mode=qa         → minify X  ⚠️
```

**환경별 API 주소를 바꾸려고 `--mode`를 도입했을 뿐인데 미니파이가 같이 꺼진다.**

---

## 그래서 어떻게 할 것인가

`minify`를 mode에 묶을 이유는 없다. 디버깅이 필요하면 `sourcemap`만 켜면 된다.

```ts
build: {
  // minify는 건드리지 않는다 (기본값 'oxc')
  sourcemap: isProd ? 'hidden' : true,
}
```

두 가지가 따라온다.

- **측정 신뢰성**: 어느 모드로 빌드하든 같은 조건으로 잰다
- **QA 정합성**: QA가 스테이징을 본다면, 프로덕션과 같은 크기의 번들을 받게 된다

참고로 `'hidden'`은 **`.map` 파일을 생성하되 JS 끝의 참조 주석만 뺀 것**이다. 파일은 나오므로, 뒤에서 볼 소스맵 귀속 분석의 재료가 된다.

---

## 재기 전 확인 ②: raw가 아니라 gz로 본다

- **raw**: 디스크에 놓인 파일 크기
- **gz**: gzip 압축 후 크기. 사용자가 실제로 받는 양에 가깝다 (서버·CDN이 압축해서 보내므로)

의존성 중복 버전을 정리한 실측이다.

```text
before  패키지 A ×4 · B ×3 · C ×2      합계 232.0 kB raw
after                                  합계  79.8 kB raw   (−152.2 kB)
gz 이득                                −2.25 kB
```

**raw로 세운 기대치는 거의 항상 과대평가다.** 이 사례는 raw Δ의 1/60만 전송량에 반영됐다.

---

## 잠깐: gzip은 어떻게 압축하나

핵심은 "**이미 나온 바이트열이 또 나오면, 뒤로 가리키는 참조로 치환**한다"는 것이다.

```text
function add(a,b){return a+b}     ← 원본 그대로 기록
...
function add(a,b){return a+b}     ← "3,000바이트 앞의 30바이트와 동일"이라는 참조 몇 바이트로 치환
```

단, 뒤돌아볼 수 있는 범위(슬라이딩 윈도우)가 **직전 32KB**로 제한된다.

- 반복이 32KB 안에서 일어나면 → 참조 몇 바이트로 줄어든다
- 두 사본 사이가 32KB를 넘으면 → 앞의 사본은 이미 윈도우 밖이라, 두 번째 사본도 처음 보는 데이터처럼 통째로 압축한다

"사본이 멀리 떨어져 있다"는 건 **한 파일 안에서 두 사본 사이의 바이트 거리가 32KB를 넘는다**는 뜻이다.

---

## 단, 두 가지로 일반화하면 안 된다

**① "중복은 gzip이 알아서 지워주니 dedupe는 무의미하다" (✗)**

방금 봤듯 gzip은 직전 32KB 안의 반복만 참조로 줄일 수 있다. 미니파이된 번들에서 중복 패키지 두 벌은 대개 그보다 멀리 배치되므로, raw Δ와 gz Δ의 비율은 사본의 배치에 따라 케이스마다 다르다. 그래서 결론은 "dedupe 하지 마라"가 아니라 "**기대치를 gz로 재고 나서 판단하라**"다.

**② 크기가 0이어도 정리해야 하는 중복이 있다 (✗)**

`react`처럼 **상태를 가진 패키지의 중복은 크기가 아니라 정합성 문제**다. 두 벌 로드되면 훅이 깨진다. 이건 gz 이득과 무관하게 정리 대상이다. Part 1의 external 절에서 다시 나온다.

---

## 재기 전 확인 ③: "왜 들어왔는지"를 볼 수 있는가

`rollup-plugin-visualizer`류의 트리맵은 기본이 raw 면적이라 위 함정을 재현한다. 그리고 더 큰 한계가 있다. **모듈이 왜 들어왔는지 알려주지 않는다.**

크기를 줄이려면 "이게 왜 있지?"에 답할 수 있어야 한다. 소스맵이 그 출발점이다.

```js
// 이 청크에 어느 패키지의 모듈이 몇 개 들어갔는지 1차 스캔
const map = JSON.parse(fs.readFileSync('dist/chunks/foo.js.map'))
const byPkg = {}
for (const src of map.sources) {
  const pkg = src.includes('node_modules')
    ? src.split('node_modules/').pop().split('/').slice(0, 2).join('/')
    : '(app)'
  byPkg[pkg] = (byPkg[pkg] ?? 0) + 1
}
```

---

## 주의: 이 스캔은 모듈 개수지, 바이트가 아니다

바이트 단위 귀속은 소스맵 `mappings` 디코딩이 필요하고, `source-map-explorer`·`sonda` 같은 도구가 해준다.

**앞 스크립트로 수상한 청크를 찾고, 도구로 바이트를 확인하는 순서다.**

---

## 측정 루프

```bash
# 1. 깨끗한 워크트리에서 베이스라인
git stash && pnpm install --frozen-lockfile
vite build --mode production
cp -r dist /tmp/baseline

# 2. 레버 하나만 적용
git stash pop
vite build --mode production

# 3. diff
node scripts/diff-dist.mjs /tmp/baseline dist
```

**한 번에 레버 하나씩.** 두 개를 같이 적용하면 어느 쪽이 효과를 냈는지 모른다. 합산 가능 여부도 따로 확인해야 한다. **두 기법이 같은 바이트를 겨냥하면 합산되지 않는다.**

---

## diff 스크립트 ①: 해시 정규화와 스캔

산출물 파일명에는 content hash가 들어가서(`index-Ab3xK9.js`) **이름 그대로는 before/after가 매칭되지 않는다.** 해시를 정규화하고 디렉토리를 스캔한다.

```js
import {gzipSync} from 'node:zlib'
import {readFileSync, readdirSync, statSync} from 'node:fs'

const norm = (f) => f.replace(/-[\w-]{8,}(?=\.\w+$)/, '') // index-Ab3xK9.js → index.js

const scan = (dir) => {
  const out = {}
  for (const f of readdirSync(dir, {recursive: true})) {
    if (!statSync(`${dir}/${f}`).isFile()) continue
    const buf = readFileSync(`${dir}/${f}`)
    out[norm(String(f))] = [buf.length / 1024, gzipSync(buf).length / 1024]
  }
  return out
}
```

---

## diff 스크립트 ②: 비교

한쪽에만 있는 파일(신규·삭제)은 0으로 친다.

```js
const [a, b] = [scan(before), scan(after)]
for (const f of new Set([...Object.keys(a), ...Object.keys(b)])) {
  const [rawA = 0, gzA = 0] = a[f] ?? []
  const [rawB = 0, gzB = 0] = b[f] ?? []
  console.log(f, (rawB - rawA).toFixed(2), (gzB - gzA).toFixed(2))
}
```

Vite 콘솔의 gz 값과 `zlib` 기본 레벨 값은 1~2KB 다르다. **절대값이 아니라 Δ로 본다.**

---

# Part 1. 무엇이 들어오는가

가장 싸고 가장 확실한 지점

---

## 번들에 안 들어와도 되는 것들

같은 코드를 두 번 넣고 있지 않은지 본다. 네 가지 패턴이 흔하다.

| 패턴                  | 증상                              | 수단                     |
| --------------------- | --------------------------------- | ------------------------ |
| 런타임이 이미 제공    | CDN·호스트 앱이 주는 걸 또 번들   | `external`               |
| 같은 패키지 여러 버전 | `react@18`과 `react@19`가 공존    | `overrides` / `dedupe`   |
| 개발 전용 코드        | mock, 디버그 패널이 프로덕션에    | 조건부 import + `define` |
| 타깃·폴리필 과잉      | 안 쓰는 legacy 번들·폴리필을 서빙 | browserslist 정책        |

**첫 번째가 압도적으로 이득이 크다.** 코드 변경량은 배열에 문자열 하나다. 다만 성립 조건이 있어서, 단독 SPA라면 두 번째부터 보면 된다.

---

## `external`: 런타임이 주는 건 빼기

```ts
build: {
  rolldownOptions: {
    external: ['react', 'react-dom', 'react/jsx-runtime'],
  },
}
```

번들에서 빠지고, 런타임이 제공하는 인스턴스를 쓴다. 이게 성립하려면 **런타임에 그 모듈이 실제로 있어야 한다.**

---

## external이 성립하는 상황

- **마이크로 프론트엔드(MFE)**: 호스트 셸 하나가 여러 리모트 앱을 조립하는 구조. 호스트가 import map으로 공통 의존성을 싱글톤 제공
- **라이브러리 빌드**: `peerDependencies`(소비자가 직접 설치해서 제공하는 의존성)는 소비자가 갖고 있음
- **CDN import map**: `<script type="importmap">`으로 bare import(`'react'`)를 URL에 직접 매핑

일반 SPA 단독 배포라면 이 절은 대부분 해당이 없다. 뒤의 dedupe·개발 전용 코드부터 보면 된다.

---

## 실측: 문자열 한 줄이 얼마나 되나

MFE 환경에서 HTTP 클라이언트 패키지 하나를 `external`에서 뺐다 넣었다 하며 측정했다.

```text
external에 있을 때   shared.js   43.08 kB gz
빼면                shared.js   76.58 kB gz     (+33.50)
```

**단일 기법 중 가성비 최고다.** 다만 공짜는 아니다.

---

## external 후보를 고르는 기준은 크기가 아니다

앱 B에서 후보를 하나씩 `external`로 옮겨가며 측정했다.

| 대상                   |       Δ gz | 조율 범위                          |
| ---------------------- | ---------: | ---------------------------------- |
| 디자인시스템 JS 전체   | **−31.81** | 런타임 + 전 소비자 버전 단일화     |
| HTTP 클라이언트        |     −33.50 | config 1줄 (런타임이 이미 제공 중) |
| 데이터 페칭 라이브러리 |     −11.83 | 런타임 import map에 신규 노출      |
| 브릿지 유틸            |      −4.89 | config 1줄                         |

**크기 순으로 착수하면 안 된다.** 디자인시스템(−31.81)은 소비자 전체가 같은 버전으로 정렬돼야 해서 몇 개 팀이 붙어야 하고, HTTP 클라이언트(−33.50)는 이미 런타임이 서빙 중이라 문자열 하나면 끝난다.

> **`Δ ÷ 조율 비용`으로 정렬한다.** config 1줄짜리부터 전부 털고 나서 조율이 필요한 걸 본다.

---

## `external`의 함정: 조용한 버전 다운그레이드

**런타임이 제공하는 버전과 로컬 lockfile 버전이 semver를 만족하는지 확인해야 한다.**

```text
런타임 제공  : @platform/http@0.19.0
로컬 lockfile: 0.100.2
```

이 조합에서 `external`로 빼면 앱은 **0.19.0을 쓰게 된다.** 빌드는 통과하고, 런타임에 없는 API를 호출하다 터진다.

---

## external 체크리스트

1. 런타임이 실제 제공하는 버전 확인 (기록 말고 실물)
2. 로컬 버전과 semver 정합성 확인
3. **`external`은 빌드 옵션이다.** dev 서버는 번들링을 하지 않으므로 `build.rolldownOptions.external`이 적용되지 않는다. dev에서 멀쩡했다는 건 아무것도 검증하지 않은 것이다
4. 그러므로 반드시 **빌드 산출물로** 배포 전 런타임 스모크

---

## 크기가 아니라 정합성이 목적인 경우

`external`을 크기 때문에만 쓰는 게 아니다.

```text
리모트 6개가 각자 WebSocket 클라이언트 번들 → 연결 6개
external + 런타임 싱글톤                    → 연결 1개
```

React가 `external` 1순위인 것도 같은 이유다. **두 벌 로드되면 훅이 깨진다.** 상태를 가진 라이브러리(라우터, 쿼리 클라이언트, 스토어)는 크기와 무관하게 싱글톤이어야 한다.

> 크기 이득은 부수 효과로 따라온다. Part 0에서 본 "gz가 0이어도 정리해야 하는 중복"이 바로 이것이다.

---

## 중복 버전은 gz로 먼저 재본다

`pnpm why`로 같은 패키지의 여러 버전이 들어오는지 본다.

```bash
pnpm why react
pnpm dedupe --check
```

정리하면 raw는 확실히 준다. 하지만 Part 0에서 봤듯 **gz 이득은 그 1/60 수준일 수 있다.**

모노레포에서 `overrides`로 강제 정렬하면 **전체 패키지의 의존성 해석이 바뀐다.** 전 패키지 typecheck·build를 다시 돌려야 하고 런타임 회귀 위험을 떠안는다.

**먼저 gz로 재고, 그 값이 비용을 정당화하는지 보고 결정한다.** 단, 상태를 가진 패키지(react, 스토어류)의 중복은 gz와 무관하게 정리한다.

---

## 개발 전용 코드: 조건부 동적 import + 상수 치환

mock 서버, 디버그 패널, 데모 데이터가 프로덕션 번들에 들어 있는 경우가 있다.

```ts
// entry
if (import.meta.env.DEV) {
  const {worker} = await import('./mocks/browser')
  await worker.start()
}
```

- `import.meta.env.DEV`는 빌드 시 `false` 리터럴로 치환된다 → 조건문째 데드 코드로 제거되고, **동적 import라 청크도 아예 안 생긴다**
- 최상단에 정적 `import {worker} from './mocks/browser'`로 올려두면, 조건문이 지워져도 모듈 평가가 남아 번들에 포함될 수 있다. **반드시 조건부 동적 import로**
- 커스텀 플래그가 필요하면 `define: {__DEBUG_PANEL__: 'false'}`로 같은 효과를 낸다

---

## 타깃·폴리필: 조용히 두 배가 되는 지점

- **`@vitejs/plugin-legacy`**: 번들을 modern/legacy 두 벌로 만들고 core-js 폴리필까지 얹는다. 지원 브라우저 정책이 정말 legacy 빌드를 요구하는지부터 확인한다. 정책에 없는 브라우저를 위해 전 사용자가 두 벌 분량의 빌드를 유지하는 경우가 있다
- **`build.target` 하향**: 문법 다운레벨링으로 코드가 팽창한다. 반대로 상향의 이득은 실측 −0.48 kB에 그쳤다 (Part 5). **어느 방향이든 지원 브라우저 정책 결정이 먼저다**
- **수동 폴리필 import**: 타깃 브라우저가 이미 지원하는 폴리필을 들고 있지 않은지 본다

---

# Part 2. 얼마나 걷히는가

트리셰이킹이 왜 안 되는가

---

## 먼저: 부수효과란 무엇인가

**모듈이 "평가되는 것만으로" 프로그램 상태를 바꾸는 것.**

```js
// 이 파일은 import 되는 순간 바깥 세계를 바꾼다
window.__APP__ = {} // 전역 오염
Array.prototype.at ??= function () {} // 폴리필
registry.register('button', Button) // 전역 레지스트리 등록
import './styles.css' // 스타일시트 주입
```

반대로 이런 파일은 부수효과가 없다.

```js
// export만 있고, 평가해도 바깥에 아무 일도 안 일어난다
export function Button(props) { ... }
```

**부수효과가 없는 모듈은 export를 아무도 안 쓰면 통째로 지워도 된다.** 있는 모듈은 지우면 안 된다. 폴리필이 사라지고 스타일이 없어진다.

---

## `sideEffects`는 그걸 알려주는 필드

번들러는 정적 분석만으로 부수효과 유무를 100% 판정할 수 없다. 그래서 패키지가 직접 선언한다.

```jsonc
// package.json
{
  "sideEffects": false, // 모든 모듈을 안전하게 지워도 됨
  "sideEffects": ["**/*.css"], // CSS만 부수효과 있음
  "sideEffects": ["./src/polyfill.js"],
}
```

webpack이 도입했고, esbuild·Rolldown은 네이티브로, Rollup은 `@rollup/plugin-node-resolve` 계층에서 존중한다. Vite를 쓴다면 버전과 무관하게 동작한다고 보면 된다.

**문제는 기본값이다. 필드가 없으면 `true`로 간주한다.** "전부 부수효과 있음, 아무것도 지우지 마."

---

## 배럴에서 왜 재앙이 되는가

배럴(barrel)은 re-export만 하는 `index.js`다.

```js
// @ds/ui/index.js
export {Button} from './button'
export {Badge} from './badge'
// ... 컴포넌트 100개
```

```ts
import {Button} from '@ds/ui' // 하나만 썼는데
```

---

## 하나만 썼는데 100개가 남는 과정

1. `Button`을 쓰려면 번들러는 배럴 `index.js`를 평가해야 한다
2. 배럴은 100개 하위 모듈을 참조한다
3. `sideEffects` 선언이 없으니 **하위 모듈 100개가 전부 "부수효과 있음"**
4. 부수효과 있는 모듈은 **export를 안 써도 평가는 해야 한다** → 드롭 불가
5. 100개가 전부 번들에 남는다

**배럴 자체에 부수효과가 있는 게 아니다.** 배럴이 끌어오는 모듈들이 "지워도 되는지 모르겠다"로 표시돼 있는 것뿐이다.

---

## 내 앱에 이 문제가 있는지 확인하기

```bash
# 1. 배럴로 import하는 무거운 패키지의 sideEffects 선언 확인
cat node_modules/@ds/ui/package.json | grep -A3 sideEffects
# → 아무것도 안 나오면 = 전부 부수효과 있음으로 취급됨

# 2. 그 패키지가 번들에서 차지하는 비중 확인 (소스맵 귀속, Part 0)
# 3. 배럴 import 지점 세기
grep -roh "from '@ds/ui'" src | wc -l
```

선언이 없고 + 비중이 크고 + 쓰는 컴포넌트가 일부라면, 아래 두 수단이 후보다.

---

## 수단 ①: `moduleSideEffects`로 로컬 오버라이드

업스트림이 선언 안 해주면 우리가 대신 선언한다.

```ts
build: {
  rolldownOptions: {
    treeshake: {
      moduleSideEffects: [
        { test: /@ds[\\/](?:ui|headless|icon)[\\/].*\.css$/, sideEffects: true },
        { test: /@ds[\\/](?:ui|headless|icon)[\\/]/,          sideEffects: false },
      ],
    },
  },
}
```

**CSS만은 부수효과로 남겨야 한다.** `import '@ds/ui/css'` 같은 bare import가 드롭되면 스타일이 통째로 사라진다.

실측: 이 오버라이드를 끄면 **+38.65 kB gz.**

---

## 오버라이드 전에 확인할 것

- **그 패키지 JS에 진짜 부수효과가 없는지 먼저 확인한다.** bare import와 JS 내 CSS import를 grep으로 세어보는 정도면 충분하다
- 함수형 `moduleSideEffects: (id) => ...`도 동작하지만 모듈마다 Rust→JS 왕복이 생긴다. rolldown 타입 정의가 룰 배열을 권장한다

---

## 수단 ②: subpath import, 되면 가장 깔끔

```ts
// before
import {Button} from '@ds/ui'

// after: 배럴을 거치지 않음
import {Button} from '@ds/ui/button'
```

패키지가 subpath `exports`를 제대로 선언했다면 이게 정공법이다. 설정 없이 import 경로만 바꾸면 된다.

**다만 안 되는 패키지가 있다.** `exports`의 subpath `types` 조건이 per-subpath 선언이 아니라 배럴 `index.d.mts`를 가리키는데 런타임 모듈은 `default`로만 export하는 경우:

| 작성                                     | 결과                                     |
| ---------------------------------------- | ---------------------------------------- |
| `import Button from '@ds/ui/button'`     | `TS2613: has no default export`          |
| `import { Button } from '@ds/ui/button'` | tsc 통과 → **런타임 `undefined` 크래시** |

두 번째가 무섭다. **타입 체크를 통과하고 런타임에 터진다.** 이럴 땐 수단 ①로 우회한다.

---

## 안 되는 패키지는 이렇게 생겼다

```jsonc
// @ds/ui/package.json
"exports": {
  "./button": {
    "types": "./dist/index.d.mts", // ← 타입은 배럴 하나로 퉁침
    "import": "./dist/button/index.mjs"
  }
}
```

```ts
// dist/index.d.mts: tsc가 보는 파일. named로 선언돼 있다
export declare const Button: ButtonComponent

// dist/button/index.mjs: 런타임이 실행하는 파일. default뿐이다
export default Button
```

`import {Button} from '@ds/ui/button'`을 쓰면 **tsc는 위 파일로 통과시키고, 런타임은 아래 파일을 실행한다.** 타입과 값이 서로 다른 파일을 보고 있는 것이다. 런타임 모듈에 named `Button`이 없으니 `undefined`가 된다.

---

## 트리셰이킹이 원천적으로 안 되는 패턴: 동적 참조

```tsx
<Icon icon="arrow-right" />
// 내부: registry[icon]
```

`registry`가 120개 아이콘을 전부 참조하므로 **하나만 써도 120개가 다 번들된다.** 문자열은 정적 분석이 불가능하다.

같은 모양의 다른 사례들:

```js
require(`./locales/${lang}.json`) // 전 언어 번들
components[type] // 전 컴포넌트 번들
await import(`./icons/${name}.svg`) // 전 아이콘 청크 생성
```

**"키로 조회하는 레지스트리"를 보면 트리셰이킹을 의심한다.**

---

## 해결: 정적 참조로 바꾸는 얇은 어댑터

```tsx
import { Common, Navigation } from '@ds/icon'

export const Icon = ({ icon: Svg, size, color }) => (
  <Svg width={size} height={size} className={`ds-color-${color}`} />
)

// 사용처: 문자열이 아니라 컴포넌트를 넘긴다
<Icon icon={Navigation.ArrowRight} size={16} />
```

실측: 아이콘 120개 → **실제 번들 13개.**

---

## 왜 이 어댑터는 걷히는가

`@ds/icon`이 `export * as Navigation`처럼 네임스페이스로 내보내고, 번들러는 네임스페이스에 대한 **정적 property access**(`Navigation.ArrowRight`)를 추적할 수 있다. 패키지 내부가 런타임 객체 리터럴 레지스트리(`const Navigation = {ArrowRight, ...}`)라면 같은 코드를 써도 안 걷힌다. 따라 하기 전에 패키지의 export 형태부터 확인한다.

> 이 수단은 수단 ①과 짝이다. 동적 참조를 없애도 `sideEffects` 선언이 없으면 안 걷히고, 오버라이드만 있고 `registry[icon]`이 남아 있어도 안 걷힌다. **그래서 개별 기여도를 분리 측정하기 어렵다.** 위의 `+38.65 kB`에도 아이콘 몫이 섞여 있다.

---

## 걷어내기 전에: 낯선 모듈 = 죽은 코드가 아니다

번들을 뜯어보면 이런 게 나온다.

```text
디자인시스템 청크 구성
  embla-carousel + wheel-gestures   71.9 KB   ← 우리 화면에 캐러셀 없는데?
```

"미사용 Carousel이 끌고 온 죽은 코드"라고 결론 내리기 쉽다. 그렇게 판단하고 −29 KB를 기대했다가, 빌드해보니 **−7 KB**였다.

---

## 누가 끌고 왔는지 추적한다

```bash
grep -rl "embla" node_modules/@ds/headless/dist/
# → dist/tab-group/TabGroup.mjs
```

```text
TabGroup          ← 앱이 실제로 쓰는 컴포넌트
  └ Carousel      ← TabGroup 내부 구현
      └ embla-carousel
```

**탭 전환을 캐러셀로 구현한 컴포넌트**를 쓰고 있었다. 정당한 의존성이라 트리셰이킹으로 걷힐 리가 없다.

> 걷어낼 계획을 세우기 전에 `grep -rl`로 누가 끌고 왔는지 확인한다.

---

## 잠깐, 지금까지의 수단은 전부 워크어라운드다

이 파트에서 쓴 기법들을 다시 보자.

| 우리가 한 것                        | 원래 누가 했어야 하나                    |
| ----------------------------------- | ---------------------------------------- |
| `moduleSideEffects` 로컬 오버라이드 | 패키지가 `sideEffects` 선언              |
| subpath 스왑 시도 → 실패            | 패키지가 `exports.types`를 per-subpath로 |
| 아이콘 어댑터 작성                  | 패키지가 컴포넌트를 named export         |
| CSS `dist/` 사적 경로 참조          | 패키지가 컴포넌트별 CSS subpath 제공     |

**전부 소비자가 패키지의 배포 실수를 뒤집어쓴 것이다.** 워크어라운드는 업스트림 버전업마다 깨질 수 있다.

내부 디자인시스템을 만드는 쪽이라면, 소비자가 이런 짓을 하게 두지 않을 수 있다.

---

## 트리셰이킹 가능한 패키지의 조건

```jsonc
{
  // ① CSS만 부수효과로 선언: 가장 중요
  "sideEffects": ["**/*.css"],
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
    },
    "./button": {
      // ② 배럴이 아니라 per-subpath 타입
      "types": "./dist/button/index.d.ts",
      "import": "./dist/button/index.js",
    },
    // ③ CSS도 컴포넌트 단위로 노출
    "./button/css": "./dist/button/index.css",
    "./css": "./dist/index.css",
  },
}
```

---

## 조건 ④: 런타임 export는 named로

subpath가 `default`만 내보내면 타입은 배럴에서 오고 값은 default라 어긋난다. 이게 앞에서 본 `TS2613` / 런타임 `undefined`의 정체다.

```ts
// ✗ subpath가 default만 내보냄: 배럴 타입과 어긋난다
export default Button

// ✓ named export
export {Button}
```

---

## 아이콘·아이콘류는 특히 조심

```tsx
// ✗ 문자열 키: 정적 분석 불가, 전체 레지스트리가 번들에 남음
;<Icon name="arrow-right" />

// ✓ 컴포넌트 export: 쓴 것만 남음
import {ArrowRight} from '@ds/icon'
;<ArrowRight size={16} />
```

문자열 API가 편해 보이지만, **소비자 번들에 아이콘 전체를 강제로 넣는 대가**를 치른다. 편의를 유지하려면 codegen으로 named export를 함께 내보내면 된다.

같은 원리가 로케일·차트 타입·컴포넌트 팩토리 전반에 적용된다.

---

## 왜 사내 디자인시스템에서 이게 자주 깨지나

- **소비자가 하나일 때는 안 드러난다.** 첫 앱이 컴포넌트 대부분을 쓰면 죽은 코드가 없다
- **번들러 라이브러리 모드 기본값**에는 `sideEffects` 선언이 없다. 명시적으로 넣어야 한다
- **타입 생성 도구가 배럴 `.d.ts` 하나만 뱉는 경우**가 많다. 런타임은 subpath로 나뉘는데 타입은 안 나뉜다
- 소비자가 늘어난 뒤에는 **breaking change 없이 고치기 어렵다**

> 릴리스 체크에 `sideEffects` 선언 여부와 subpath 타입 정합성을 넣어두면 대부분 예방된다.

---

# Part 3. CSS

JS와 다른 파이프라인, 다른 함정

---

## CSS에는 트리셰이킹이 없다

```scss
@use '@ds/ui/css'; // dist/index.css (492 KB raw)
```

번들러는 어떤 셀렉터가 실제로 쓰이는지 알 수 없다. 클래스명이 런타임에 조합될 수도 있기 때문이다. **그래서 CSS 배럴은 통째로 남는다.**

실제로 쓰는 컴포넌트가 15~24개인데 안 쓰는 `app-bar`(75KB), `textfield`, `chip`, `dialog`, `snackbar` … **~208 KB가 죽은 CSS**였다.

Part 2의 배럴 문제와 같은 모양이지만, JS와 달리 `sideEffects`로 풀 수 없어서 파트를 따로 뒀다.

---

## 문제: 공개된 CSS 통로가 배럴 하나뿐

`exports`는 패키지가 "이 경로만 공개한다"고 선언하는 **화이트리스트**다. 선언되지 않은 경로는 import가 차단된다.

```jsonc
// @ds/ui/package.json
"exports": {
  "./css": "./dist/index.css", // ← 공개된 CSS 경로는 배럴 하나뿐
  // "./button/css" 같은 컴포넌트 단위 경로는 선언돼 있지 않다
}
```

컴포넌트별 CSS 파일은 `dist/` 안에 실제로 존재한다. 그런데 공식 통로가 없으니, **배럴 492KB를 통째로 쓰는 것 말고는 방법이 없어 보이는 상황**이다.

---

## 우회로: sass `@use`는 `exports`를 안 거친다

JS의 import는 번들러가 `exports` 화이트리스트로 검사한다. 그런데 **sass 컴파일러는 자체 resolver로 node_modules 안의 실제 파일을 직접 찾는다.** 화이트리스트 검사가 없다.

```scss
// 안 됨: exports에 선언된 공개 경로가 아니다
@use '@ds/ui/button/css';

// 됨: 디스크에 실제로 있는 파일 경로를 그대로 적으면 찾아간다
@use '@ds/ui/dist/button/index.css';
@use '@ds/ui/dist/badge/index.css';
```

실측:

```text
CSS   763.74 → 460.51 kB raw   |   49.36 → 30.37 kB gz   (−18.99)
JS    바이트 완전 동일
```

**같은 패키지인데 JS에서 막힌 게 CSS에서는 통한다.**

---

## 대신 구조적 리스크를 안는다

방금 쓴 `dist/<component>/index.css`는 패키지가 공개를 약속한 경로가 아니라 **내부 폴더 구조를 직접 파고든 것**이다. 공개 API가 아니라 구현 디테일에 의존하는 셈이다.

그래서 이런 일이 생길 수 있다.

- 업스트림이 dist 폴더 구조를 바꾸면 (파일명 하나만 바뀌어도) 빌드가 깨진다
- 새 컴포넌트를 쓰기 시작할 때 `@use` 한 줄을 잊으면, 그 컴포넌트만 스타일이 빠진 채 배포된다
- 그래서 버전업 때마다 이 경로들이 살아있는지 확인해야 한다

**−19 KB gz와 이 리스크를 저울질하는 건 팀의 판단이다.** 모르고 넣는 것과 알고 넣는 건 다르다.

---

## 동적 마운트 앱이라면: CSS를 entry에서 import

```text
entry(index.ts) → 동적 import → App.tsx → import './app.css'
```

이 구조에서는 CSS가 **entry가 아니라 동적 청크에 딸린다.** 마운트/언마운트를 직접 제어하는 앱(MFE 리모트, 위젯)에서는 스타일이 안 걷히거나 첫 페인트에 스타일이 없는 증상이 나온다.

```ts
// entry에서 직접 import
import './styles/global.scss'
export {mount, unmount} from './app'
```

일반 SPA는 Vite가 `index.html`에 `<link>`를 넣어주므로 해당 없다.

---

## 참고: entry가 여러 개면 CSS도 갈린다

```text
index.js  / index.css    ← 메인 화면
detail.js / detail.css   ← 상세 화면
```

`detail.css`는 메인 화면 첫 페인트에 포함되지 않는다. **라우트 lazy 없이 entry 분리만으로 얻은 결과다.**

Part 4로 이어지는 지점이다.

---

# Part 4. 언제 받는가

크기는 안 줄어든다

---

## 먼저 못 박기: 스플리팅은 크기 기법이 아니다

수동 청크 그룹을 **꺼보고** 재봤다.

```text
켬:  index 33.04 + ds-ui 31.87 + shared 43.08 + runtime 0.41 = 108.40 gz
끔:  index 57.60 +                shared 50.58              = 108.18 gz
```

**전송량은 사실상 동일하다 (−0.22 KB).** 당연하다. 같은 모듈을 어느 파일에 담느냐의 문제이지 모듈이 없어지는 게 아니다.

그럼 왜 하는가. 두 가지다.

- **첫 화면에 필요 없는 걸 나중에 받기** (초기 로드 감소)
- **배포 간 캐시 분리** (앱 코드만 바뀌면 vendor는 재다운로드 안 함)

**KB 절감으로 계산하면 안 된다.**

---

## 그런데 잘못 나누면 파일이 폭발한다

같은 `vite.config` 베이스를 공유하는 두 앱을 비교했다.

|                 |      앱 A |    앱 B |
| --------------- | --------: | ------: |
| JS 파일         | **164개** | **5개** |
| CSS 파일        |      44개 |     3개 |
| `React.lazy`    |      23개 |     0개 |
| 동적 `import()` |      27개 |     5개 |

A는 라우트를 전부 `React.lazy`로 나눴고, B는 라우트 2개를 빌드 타임 entry로 나눴다.

---

## 왜 164개가 되는가

Rolldown(과 Rollup)의 기본 청킹은 **"동일한 동적 진입점 집합에서 참조되는 모듈"을 한 청크로 묶는다.**

```text
라우트 A만 쓰는 모듈       → 청크 1
라우트 B만 쓰는 모듈       → 청크 2
라우트 A·B가 공유하는 모듈 → 청크 3
라우트 A·C가 공유하는 모듈 → 청크 4
...
```

동적 진입점이 27개면 그 **공유 조합**마다 청크가 생긴다. 앱 A에서 2곳 이상이 공유하는 청크가 **134개** 나왔다.

**`lazy`를 하나 추가할 때마다 조합의 밑이 하나 늘어난다.**

---

## 파편의 정체: vendor가 아니다

앱 A의 청크 중 소스맵으로 귀속 가능한 161개를 분류했다.

```text
node_modules 전용 : 50개 / 2,792 KB
앱 소스 전용      : 93개 /   599 KB   ← 평균 6.4 KB
혼합             : 18개 / 3,277 KB
```

흔히 "vendor를 안 묶어서 그렇다"고 진단하지만, 이 앱에서는 이미 잘 묶여 있었다.

```text
디자인시스템 ui : 2개 청크에만 분포
아이콘         : 1개 청크
```

**개수의 주범은 6KB짜리 앱 소스 조합 청크 93개다.**

---

## 파편의 진짜 비용

```text
초기(entry 정적)     13개 파일 / 1,480 KB
─────────────────────────────────────────
목록 화면 진입      +79개 파일 / 3,164 KB
게시글 상세         +66개 파일 / 3,075 KB
```

HTTP/2 멀티플렉싱이 있어도 공짜가 아니다. 정확히 하자.

- Vite는 동적 청크의 **정적 의존 청크 목록을 빌드 타임에 알고**, 프리로드 헬퍼로 병렬 fetch한다. "받아봐야 다음 파일을 안다"는 식의 워터폴은 생각보다 얕다
- 그래도 남는 비용: **요청 시작 자체가 클릭 이후**라는 것, **동적 import가 중첩된 체인**은 병렬화가 안 된다는 것, 그리고 요청 수십 개 분량의 스케줄링·캐시 조회 오버헤드

모바일 네트워크에서는 이 남은 왕복이 그대로 체감이 된다.

---

## "페이지마다 lazy"가 왜 안티패턴인가

`React.lazy` 자체는 좋은 도구다. 문제는 **라우트 개수만큼 기계적으로 거는 것**이다.

**① 요청이 사용자 클릭 이후에 시작된다**

```text
정적 import : 앱 로드 시점에 이미 받아둠     → 클릭 즉시 렌더
lazy        : 클릭 → 청크 요청(정적 의존은 병렬) → 렌더
```

라우트 전환마다 최소 한 번의 네트워크 왕복이 낀다. 첫 로드를 줄인 만큼 **전환이 느려진다.**

**② 조합 폭발**

앞에서 본 그대로다. lazy를 하나 추가할 때마다 공유 조합의 밑이 하나 늘어난다.

---

## ③ 여러 화면을 도는 사용자는 손해를 본다

라우트 A와 B가 공통 모듈을 쓴다고 하자.

```text
안 나눴을 때:  A+B 한 청크        → 요청 1개
나눴을 때  :  A청크 + B청크 + 공유청크 → 요청 3개
```

**총 바이트는 같은데 요청만 늘었다.** 게시판 앱처럼 사용자가 목록↔상세↔작성을 오가는 구조라면 거의 모든 청크를 결국 다 받는다.

**④ 첫 화면을 lazy로 하면 순손해**

랜딩 라우트를 lazy로 감싸면 `entry → route chunk` 2-hop이 무조건 발생한다. 첫 화면은 정적으로 두는 게 맞다.

**⑤ Suspense fallback 깜빡임**

전환마다 스켈레톤이 스쳐 지나간다. 100ms짜리 로딩은 UX를 개선하지 않는다.

---

## 그럼 lazy는 언제 쓰나

**"이 코드를 안 받고도 대부분의 사용자가 목적을 달성하는가?"** 에 예라고 답할 수 있을 때.

| 대상               | lazy | 이유                           |
| ------------------ | ---- | ------------------------------ |
| 리치 텍스트 에디터 | ✅   | 글 쓰는 사용자만 필요, 수백 KB |
| 차트 라이브러리    | ✅   | 특정 탭에서만                  |
| PDF 뷰어           | ✅   | 첨부 열 때만                   |
| 결제 모듈          | ✅   | 구매 플로우에서만              |
| 관리자 전용 화면   | ✅   | 대부분의 사용자가 안 감        |

---

## 반대로 lazy가 손해인 곳

| 대상               | lazy | 이유                    |
| ------------------ | ---- | ----------------------- |
| 목록 / 상세 / 작성 | ❌   | 앱의 주 동선            |
| 첫 진입 화면       | ❌   | 무조건 2-hop 손해       |
| 상시 노출 섹션     | ❌   | 청크만 늘고 전송량 동일 |

**"무겁고, 일부만 쓰고, 지연돼도 되는 것."** 셋 다 맞을 때만.

---

## 순수 SPA라면: 라우트가 아니라 묶음 단위로

entry를 나눌 수 없는 구조에서는 lazy를 쓰되 **경계를 굵게** 잡는다.

```ts
// ✗ 라우트마다: 진입점 23개
const BoardList = lazy(() => import('./pages/board/list'))
const BoardDetail = lazy(() => import('./pages/board/detail'))
const BoardWrite = lazy(() => import('./pages/board/write'))
// ... 20개 더

// ✓ 기능 묶음마다: 진입점 3개
const BoardRoutes = lazy(() => import('./features/board/routes'))
const ProfileRoutes = lazy(() => import('./features/profile/routes'))
const AdminRoutes = lazy(() => import('./features/admin/routes'))
```

게시판에 들어온 사용자는 목록·상세·작성을 다 볼 가능성이 높다. **함께 쓰이는 것은 함께 받는 게 낫다.**

조합의 밑이 23에서 3으로 줄고, 전환 시 네트워크 왕복도 사라진다.

---

## 남은 전환 지연은 prefetch로 상쇄한다

경계를 굵게 잡았어도 lazy 경계를 넘는 첫 전환에는 왕복 한 번이 남는다. 이건 숨길 수 있다.

```tsx
const preloadBoard = () => import('./features/board/routes')

<Link to="/board" onMouseEnter={preloadBoard} onFocus={preloadBoard}>
  게시판
</Link>
```

- `import()`는 같은 청크를 두 번 받지 않는다. **hover 시점에 미리 불러두면 클릭 시점엔 이미 캐시에 있다**
- 링크가 뷰포트에 들어올 때(IntersectionObserver) 미리 부르는 것도 같은 원리다
- 단, 첫 화면에서 전부 preload하면 lazy를 한 의미가 없다. **다음에 갈 확률이 높은 경계만** 골라서 건다

---

## entry 분리란 무엇인가

**라우트 분할을 런타임(`import()`)이 아니라 빌드 타임에 하는 것.**

```ts
build: {
  rolldownOptions: {
    input: {
      main:  'src/entries/main.ts',
      admin: 'src/entries/admin.ts',
    },
  },
}
```

각 entry는 **독립된 번들 그래프의 루트**다. `admin.ts`에서 도달할 수 없는 코드는 `admin.js`에 없다. 반대도 마찬가지다.

---

## entry 분리의 산출물

```text
dist/
  main.js   / main.css      ← 일반 사용자용
  admin.js  / admin.css     ← 관리자용
  chunks/shared-*.js        ← 둘 다 쓰는 것만
```

`lazy`와 달리 **런타임 요청 왕복이 없다.** 해당 진입점을 로드하는 순간 필요한 게 정적으로 다 딸려 온다.

---

## entry 분리가 가능한 구조인가

| 구조                       | 가능? | 어떻게 나뉘나                               |
| -------------------------- | ----- | ------------------------------------------- |
| **MPA**                    | ✅    | 페이지마다 HTML → 각 HTML이 다른 entry 로드 |
| **마이크로 프론트엔드**    | ✅    | 호스트가 경로별로 다른 리모트를 마운트      |
| **위젯 / 임베드**          | ✅    | 삽입 지점마다 다른 번들                     |
| **어드민·유저 분리 SPA**   | ✅    | 도메인·경로로 갈리므로 HTML을 나눌 수 있음  |
| **단일 SPA (라우터 하나)** | ❌    | 진입 HTML이 하나라 나눌 방법이 없음         |

**마지막 줄이 중요하다.** `index.html` 하나로 서비스되는 일반 SPA는 entry를 나눠도 브라우저가 결국 하나만 로드한다. 그 경우엔 앞 슬라이드의 **묶음 단위 lazy + prefetch**가 답이다.

---

## 수단 ①: 임계값으로 파편 억제

```ts
build: {
  rolldownOptions: {
    output: {
      codeSplitting: {
        minSize: 20 * 1024,      // 이보다 작으면 청크로 안 뺌
        minShareCount: 2,        // N개 이상 진입점이 참조할 때만 분리
        maxSize: 200 * 1024,     // 이보다 크면 다시 쪼갬
      },
    },
  },
}
```

6KB짜리 조합 청크가 100개라면 이게 가장 직접적이다.

`minSize`에 걸려 탈락한 모듈은 사라지는 게 아니라 **자동 청킹으로 폴백**한다. 자기를 쓰는 청크로 흡수된다.

---

## 수단 ②: `groups`로 캐시 경계 만들기

```ts
codeSplitting: {
  groups: [
    {
      name: 'ds',
      test: /node_modules[\\/](?:\.pnpm[\\/])?@ds(?:\+|[\\/])(?:ui|headless|icon)/,
      priority: 10,
    },
  ],
}
```

**pnpm을 쓴다면 `.pnpm` 디렉토리와 `+` 구분자를 정규식에 넣어야 한다.** `@ds/ui`는 디스크에서 `node_modules/.pnpm/@ds+ui@2.4.0/node_modules/@ds/ui/...`로 존재한다. 빠뜨리면 그룹이 아무것도 못 잡는다.

Windows 대응으로 경로 구분자는 `[\\/]`를 쓰라고 rolldown 문서가 권고한다.

---

## 설정으로는 한계가 있다

`minSize`·`groups`는 **이미 생긴 파편을 사후에 묶는** 수단이다. 조합이 27개 진입점에서 나온다는 사실 자체는 그대로다.

```text
설정 수단 (minSize / groups)   →  파편을 묶어 개수를 줄임
구조 수단 (묶음 lazy / entry)   →  조합의 밑을 줄임
```

**둘 다 필요하지만 순서가 있다.** 구조를 먼저 정하고, 남는 파편을 설정으로 정리한다. 반대로 하면 정규식만 늘어난다.

---

## Part 4 판단 기준

| 상황                                          | 선택                                    |
| --------------------------------------------- | --------------------------------------- |
| 라우트 2~3개, 단일 화면                       | **아무것도 안 함.** entry 하나          |
| 진입 HTML이 갈리는 구조 (MPA·MFE·어드민 분리) | **entry 분리**                          |
| 단일 SPA, 라우트가 많음                       | **기능 묶음 단위 lazy** (라우트 단위 ✗) |
| 무겁고 일부만 쓰는 라이브러리                 | 그 지점만 `lazy`                        |
| lazy 전환이 느리다는 불만                     | hover·뷰포트 **prefetch**               |
| 전 화면 공통 vendor                           | `groups`로 캐시 분리                    |
| 6KB짜리 파편이 100개                          | `minSize` / `minShareCount`             |

**"모든 라우트를 lazy로"는 기본값이 아니다.** 에디터·차트·PDF처럼 무겁고 일부만 쓰는 것에 먼저 쓴다.

---

# Part 5. 기대치 관리

측정해서 기각한 것들

---

## 이미 되어 있는 걸 또 하지 않기

| 시도                     | 왜 무의미한가                                  |
| ------------------------ | ---------------------------------------------- |
| `output.comments: false` | production은 미니파이가 이미 주석을 전부 제거  |
| cssnano 등 CSS 압축 추가 | Vite 8은 lightningcss로 이미 압축 (1줄로 나옴) |
| gzip 플러그인            | 압축은 보통 CDN·서버가 한다                    |

> production CSS의 raw가 개발 빌드보다 **큰** 경우가 있다. lightningcss가 legacy 브라우저 타깃으로 다운레벨링하기 때문이지 미압축이 아니다. **raw만 보고 판단하면 또 틀린다.**

---

## 이득이 비용을 못 넘는 것들

| 시도                           |     이득 | 비용                         |
| ------------------------------ | -------: | ---------------------------- |
| `classnames` → `clsx`          |  −0.3 KB | 43개 파일 수정               |
| 금융 계산 라이브러리 경량 대체 |  −1.5 KB | **금액 계산 정확성 리스크**  |
| 중복 버전 dedupe               | −2.25 KB | 모노레포 전체 의존성 재해석  |
| `build.target` 상향            | −0.48 KB | 지원 브라우저 정책 결정 선행 |

**번들 크기는 정확성 앞에서 후순위다.** 금융 계산을 1.5KB 때문에 바꾸는 건 나쁜 거래다.

---

## 효과가 없는 것들

- **상시 노출 컴포넌트 `lazy`**: 첫 화면에 무조건 보이는 걸 lazy로 만들면 청크만 늘고 전송량은 그대로다. 오히려 요청 시작만 늦어진다
- **이미 트리셰이킹된 패키지에 추가 조치**: `sideEffects: false`가 선언된 패키지는 그냥 두면 된다

---

## 레버는 서로 독립이 아니다

도입부에서 예고한 그 얘기다. 디자인시스템을 통째로 `external`로 빼는 계획의 기대치가 처음엔 **−89 KB gz**였다. 나중에 실제로 재보니 **−31.81 KB**였다.

계획을 세운 시점과 실행 시점 사이에 **트리셰이킹(Part 2)을 먼저 적용했기 때문이다.**

```text
Part 2 적용 전 : 디자인시스템 청크 89.99 kB gz   ← external로 빼면 −89
Part 2 적용 후 : 디자인시스템 청크 31.87 kB gz   ← external로 빼면 −31.81
```

**같은 바이트를 겨냥하는 기법끼리는 먼저 적용한 쪽이 이득을 가져간다.** 죽은 코드를 이미 걷어냈으면, 그걸 외부화해도 걷어낼 게 없다.

---

## 그래서 순서가 중요하다

두 가지가 따라온다.

**① 개별 Δ를 단순 합산하면 과대평가된다**

각각 재서 `−38 + −33 + −31 + −19`를 더하면 실제 총합보다 크다. 로드맵을 세울 때는 **적용 순서대로 누적 측정**해야 한다.

**② 싼 것부터 하는 게 유리한 또 다른 이유**

비싼 레버(조율이 필요한 것)의 기대치가 깎이더라도, 그건 **이미 싼 레버로 이득을 확보했다는 뜻**이다. 반대로 비싼 것부터 하면 여러 팀을 동원해 −89를 얻고, 그 다음 싼 레버는 −7밖에 안 나온다.

> 총량은 비슷해도 **비용 구조가 완전히 다르다.**

---

## 기각 목록을 남기는 이유

측정해서 기각한 항목을 기록해두면:

- 6개월 뒤 같은 제안이 올라왔을 때 재측정 없이 답한다
- 신규 입사자가 "이건 왜 안 했지?"에 스스로 답을 찾는다
- **추정만으로 착수하는 문화를 막는다**

기각 사유에는 **숫자와 측정 조건**을 남긴다. "작아서 안 함"이 아니라 "production 빌드 기준 −0.3 KB gz, 43파일 수정 필요".

---

# Part 6. 검증

"줄었다"를 어떻게 증명하나

---

## CSS 제거는 셀렉터 diff로 교차검증

CSS를 걷어낼 때 가장 무서운 건 **쓰는 셀렉터를 지우는 것**이다. 빌드는 통과하고 화면만 깨진다.

```text
클래스 셀렉터 : 589 → 465   (제거 124, 추가 0)
CSS 변수 선언 : 13 → 9

검사                                        결과
─────────────────────────────────────────────────
제거 클래스가 빌드된 JS 문자열에 등장        0건
제거 클래스의 접두사가 JS에 등장(동적 조합)   0건
제거 클래스를 소스(SCSS/TSX)가 참조          0건
예상치 못하게 추가된 클래스                  0건
```

접두사 검사가 중요하다. `` `ds-color-${color}` `` 같은 **런타임 조합**은 완전 문자열 검색으로 안 잡힌다.

---

## 이름이 애매한 건 정의 파일까지 추적

```text
ds-container-color-*, ds-placement-*  → floating-layout (미사용)
ds-type-solid/line, ds-check*         → checkbox (미사용)
ds-size-compact, ds-text-size-*       → textfield (미사용)
```

"이 클래스가 우리 게 아닌 것 같은데"에서 멈추지 않고 **어느 컴포넌트 소유인지**까지 확인한다.

정적 분석은 시각 회귀 테스트를 대체하지 못한다. 다만 시각 회귀를 돌릴 수 없을 때 **할 수 있는 만큼은 한다.**

---

## 안 잰 건 안 쟀다고 쓴다

측정 기록에 이 섹션을 반드시 넣는다.

> **확인이 남은 불확실성**
>
> 1. CSS 변경의 시각 회귀 미실시: 셀렉터 diff 정적 분석만
> 2. 두 기법의 개별 기여도 미분리: 커플링돼 있어 합산치만 측정
> 3. 런타임 스모크 미실시: 빌드 + `tsc --noEmit`까지

**"−19 KB 줄었다"와 "−19 KB 줄어드는 것으로 측정됐고 시각 검증은 안 했다"는 다른 문장이다.**

---

## 정리: 착수 순서

| 순서 | Part        | 확인                               | 기대 규모       |
| ---- | ----------- | ---------------------------------- | --------------- |
| 0    | 측정        | 미니파이 켜져 있나, gz로 보고 있나 | -               |
| 1    | 들어오는 것 | 런타임이 주는 걸 또 번들하나       | **수십 KB**     |
| 2    | 걷히는 것   | 배럴 + `sideEffects` 미선언        | **수십 KB**     |
| 3    | CSS         | CSS 배럴을 통째로 로드하나         | **십수 KB**     |
| 4    | 받는 시점   | 첫 화면에 필요 없는 게 있나        | **크기 이득 0** |

**Part 1~3은 앱 단독 설정 변경이고, Part 4는 구조 변경이다.** 싸고 확실한 것부터.

---

## 누적으로 보면 이렇게 간다

앱 B를 조율 비용 순으로 밟았을 때의 실측 누적이다. 첫 진입 전송량 기준(JS + CSS, gz).

```text
베이스라인                                    165.04
  + CSS 배럴 정리(−18.99) · 중복버전 dedupe(−2.25)
                                              143.80   (−12.9%)  ← 앱 단독
  + 브릿지 유틸 external(−4.89)
                                              138.91             ← config 1줄
  + 데이터 페칭 라이브러리 external(−11.83)
                                              127.08             ← 런타임 협조
  + 디자인시스템 JS external(−31.81)
                                               95.27   (−42%)    ← 대규모 조율
```

**앞의 두 단계(−26.13)는 다른 팀에 요청할 게 없다.** 여기까지가 협상 없이 갈 수 있는 지점이고, 그 아래부터는 조율 비용이 급격히 올라간다.

---

## 이 표에서 읽어야 할 것

- **첫 두 줄이 전체 이득의 37%**: 앱 단독 변경만으로
- 마지막 한 줄이 나머지의 대부분인데, **런타임과 전 소비자의 버전 단일화**가 선행돼야 한다
- Part 4(코드 스플리팅)는 이 표에 **한 줄도 없다**: 크기를 안 줄이니까

> 조직에 "번들 42% 줄입니다"를 약속하기 전에, 그 42% 중 얼마가 내 결정권 안에 있는지부터 본다.

---

# Q&A

---

## Q. 청크는 적을수록 좋은가?

아니다. 기준이 세 개다.

- **전송량**: 청크 개수와 무관
- **캐시 효율**: 자주 바뀌는 앱 코드와 안 바뀌는 vendor를 분리하면 이득
- **요청 수 / 왕복**: 너무 잘게 쪼개면 손해

"5개가 164개보다 좋다"가 아니라 **164개가 될 만한 이유가 있었는지**를 물어야 한다. 라우트를 전부 lazy로 만든 결과라면, 의도한 분할이 맞는지 한번 돌아볼 필요가 있다.

---

## Q. Vite 7 이하(Rollup)를 쓰고 있다면?

개념은 그대로 옮겨진다. 이름만 다르다.

| Rollup (Vite 5~7)                 | Rolldown (Vite 8)             |
| --------------------------------- | ----------------------------- |
| `build.rollupOptions`             | `build.rolldownOptions`       |
| `output.manualChunks`             | `output.codeSplitting`        |
| `output.experimentalMinChunkSize` | `codeSplitting.minSize`       |
| (없음)                            | `codeSplitting.minShareCount` |
| `treeshake.moduleSideEffects`     | 동일 (룰 배열 형태 추가)      |

`manualChunks`와 `advancedChunks`는 rolldown 타입 정의에 `@deprecated`로 명시돼 있다. `codeSplitting`은 `advancedChunks`를 rename한 최근 옵션이므로, **rolldown 버전이 오래됐다면 이름부터 확인한다.** 새로 쓴다면 `codeSplitting`이다.

---

## Q. gz가 아니라 brotli로 봐야 하지 않나?

CDN이 brotli를 서빙한다면 그쪽이 실제 전송량에 가깝다. 다만:

- **Δ의 방향과 상대 크기는 gz와 거의 같다**: 판단이 뒤집히는 경우는 드물다
- gz는 `zlib` 내장이라 스크립트가 간단하다
- Vite 콘솔 출력이 gz 기준이라 눈으로 대조하기 쉽다

성능 예산을 절대값으로 계약해야 하는 상황이면 brotli로 재고, **레버를 비교하는 단계에서는 gz로 충분하다.**

---

## Q. 어디까지 줄여야 하나

**목표는 KB가 아니라 사용자 지표여야 한다.** LCP, TBT, 첫 상호작용까지의 시간.

번들 최적화는 그 지표를 개선하는 여러 수단 중 하나다. 크기 레버를 다 훑고 나면 대개 병목은 다른 쪽(API 워터폴, 캐시 설정 누락)에 있다.

**165 KB를 145 KB로 줄이는 것보다, 순차 요청 하나를 병렬로 바꾸는 게 체감이 클 수 있다.** 크기만 보다 보면 이걸 놓친다.

---

# 감사합니다

<!-- _class: invert -->

@yceffort

---

# Suspense & ErrorBoundary 딥다이브: 던지는 컴포넌트의 세계

Source: https://research.yceffort.kr/slides/suspense-error-boundary-deep-dive.md

---
title: 'Suspense & ErrorBoundary 딥다이브: 던지는 컴포넌트의 세계'
marp: true
paginate: true
theme: yceffort
tags:
  - react
  - error-handling
  - async
date: 2026-07-29
description: 'throw 하나로 이해하는 선언적 로딩과 에러 처리 — 개념부터 reconciler 내부 동작, 실무 경계 설계, React Query 연계까지'
published: true
art:
  undraw: loading-ui
---

# Suspense & ErrorBoundary 딥다이브

던지는 컴포넌트의 세계 — throw 하나로 이해하는 선언적 로딩과 에러 처리

<!-- _class: invert -->

@yceffort

---

## 이 강의에서 다루는 것

1. 왜 이 둘을 **같이** 배우는가
2. **ErrorBoundary** — 개념, 동작, 잡지 못하는 것들
3. **Suspense** — fallback 뒤에서 벌어지는 일
4. **원리 딥다이브** — React 내부에서 throw는 어떻게 처리되는가
5. **실무** — 경계 배치, 깜빡임, waterfall, 리셋, 로깅
6. **React Query**와 조합하기

Part 1~5가 끝날 때마다 **정리와 퀴즈**가 있다.

---

## 이 강의의 메시지

> Suspense와 ErrorBoundary는 별개의 기능이 아니다.
> **컴포넌트가 렌더링을 포기하고 무언가를 throw했을 때**, 그것을 받아주는 하나의 메커니즘의 두 얼굴이다.

- promise를 throw하면 → 가장 가까운 **Suspense**가 받는다
- Error를 throw하면 → 가장 가까운 **ErrorBoundary**가 받는다

이 한 문장을 이해하면, 두 기능의 동작이 전부 같은 그림 위에 놓인다.

---

## Part 0 — 왜 이 둘을 같이 배우는가

---

## 명령형 방식: 우리가 매일 쓰던 코드

```jsx
function UserProfile({id}) {
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    setLoading(true)
    fetchUser(id)
      .then(setUser)
      .catch(setError)
      .finally(() => setLoading(false))
  }, [id])

  if (loading) return <Spinner />
  if (error) return <ErrorMessage error={error} />
  return <Profile user={user} />
}
```

---

## 이 코드의 문제

- **모든 컴포넌트마다 반복**된다 — loading/error 분기가 성공 화면보다 길다
- 로딩 상태의 **조합**이 컴포넌트 수만큼 늘어난다 (스피너 3개가 각자 돈다)
- race condition, 언마운트 후 setState… 부수적인 버그의 온상
- "성공했을 때 어떤 화면인가"가 코드에서 잘 안 보인다

> 로딩과 에러는 **개별 컴포넌트의 관심사가 아니라, 화면 영역의 관심사**다.

<!-- race condition 구두 예시: id가 1→2로 빠르게 바뀌면 늦게 도착한 1의 응답이 2의 화면을 덮는다(응답 순서 역전). Suspense+캐시 세계에서는 "지금 어떤 promise를 보여줄까"가 렌더의 입력으로 정리되어 이 경쟁이 구조적으로 사라진다. -->

---

## 선언적 경계라는 발상

```jsx
<ErrorBoundary fallback={<ErrorMessage />}>
  <Suspense fallback={<Spinner />}>
    <Profile /> {/* 성공한 화면만 그린다 */}
  </Suspense>
</ErrorBoundary>
```

- `Profile`은 **데이터가 이미 있다고 가정하고** 성공 화면만 그린다
- 로딩 중이라면? → 렌더를 **포기하고 promise를 throw** → Suspense가 받는다
- 실패했다면? → 렌더를 **포기하고 Error를 throw** → ErrorBoundary가 받는다

분기가 컴포넌트 밖으로, **트리 구조**로 올라갔다.

---

## 두 경계의 대칭

| 관점        | Suspense                  | ErrorBoundary                  |
| ----------- | ------------------------- | ------------------------------ |
| 받는 것     | promise                   | Error                          |
| 의미        | "아직 준비 안 됨"         | "실패함"                       |
| 보여주는 것 | `fallback` prop           | fallback UI (직접 구현)        |
| 복귀 방법   | promise resolve → 자동    | 리셋 (수동)                    |
| 탐색 방향   | 가장 가까운 조상 Suspense | 가장 가까운 조상 ErrorBoundary |

**같은 구조, 다른 페이로드.** Part 3에서 React 소스로 이 대칭을 직접 확인한다.

---

## Part 1 — ErrorBoundary: 개념과 동작

---

## 렌더 중 에러가 나면 무슨 일이 벌어지는가

```jsx
function Product({item}) {
  // item이 undefined면? → TypeError
  return <div>{item.name}</div>
}
```

ErrorBoundary가 하나도 없다면:

- React는 **트리 전체를 언마운트**한다 (React 16부터)
- 사용자는 **빈 흰 화면**을 본다
- "일부가 깨진 UI를 남겨두는 것이 더 위험하다"는 판단 — 결제 화면에서 금액이 잘못 보이는 것보다 아무것도 안 보이는 게 낫다

> 경계가 없으면, 버튼 하나의 에러가 **앱 전체를 지운다**.

---

## ErrorBoundary 최소 구현

```jsx
class ErrorBoundary extends React.Component {
  state = {error: null}

  static getDerivedStateFromError(error) {
    return {error} // 이 state로 fallback을 그린다
  }

  componentDidCatch(error, info) {
    logError(error, info.componentStack) // 로깅은 여기서
  }

  render() {
    if (this.state.error) return this.props.fallback
    return this.props.children
  }
}
```

자식 트리에서 렌더 중 에러가 throw되면, 이 컴포넌트가 받아서 **자기 영역만** fallback으로 바꾼다.

<!-- 클래스가 처음인 수강생용 30초 글로스: state 필드는 useState 초기값, render()는 함수 컴포넌트의 본문, this.props는 props 인자, static은 인스턴스 없이 React가 직접 부르는 메서드(그래서 this가 없고 순수해야 함). 라이프사이클 = React가 정해진 시점에 대신 불러주는 메서드들. -->

---

## 두 라이프사이클의 역할 분리

| 구분      | `getDerivedStateFromError`     | `componentDidCatch`            |
| --------- | ------------------------------ | ------------------------------ |
| 실행 시점 | **render phase**               | **commit phase**               |
| 하는 일   | fallback을 그릴 state 반환     | 로깅 등 사이드이펙트           |
| 순수해야? | ✅ (static, 사이드이펙트 금지) | ❌ (자유롭게)                  |
| 받는 것   | `error`                        | `error`, `info.componentStack` |

**render phase**는 무엇을 그릴지 계산하는 단계, **commit phase**는 그 결과를 실제 DOM에 반영하는 단계다. render phase는 동시성 모드에서 중단·재실행될 수 있어서, "무엇을 그릴까"(순수)와 "무엇을 기록할까"(사이드이펙트)를 분리해 놓은 것.

---

## 왜 아직도 클래스 컴포넌트인가

표면적으로는 "`getDerivedStateFromError`에 대응하는 훅이 없어서". (2018년 훅 발표 때 "곧 추가될 예정"이라던 그 훅이 8년째 없다)

진짜 이유는 **실행 모델의 불일치**:

- 훅은 **내 함수가 실행되는 동안** 동작하는 API
- 에러 캐치는 정반대 — **내 렌더가 끝난 뒤**, 자식이 던졌을 때 React가 나를 다시 불러줘야 한다
- 클래스에는 렌더 사이에도 살아있는 **인스턴스**와, React가 임의 시점에 호출할 **메서드**가 있다. 함수형은 렌더 = 함수 호출 그 자체라 그 자리가 없다

(Part 3에서 React가 클래스 컴포넌트를 찾아 올라가는 코드로 이를 확인한다)

실무 결론: **클래스를 직접 쓰지 말고 `react-error-boundary`를 쓴다.** 앱에서 클래스가 정당한 거의 유일한 자리가 여기다.

<!-- Q&A 대비: 함수형 ErrorBoundary RFC 논의는 있었지만 우선순위가 낮다 — react-error-boundary가 사실상 공백을 메우고 있고, React 19의 루트 옵션(onCaughtError 등)이 로깅 쪽 수요를 흡수했다. -->

---

## 잡지 못하는 것 1 — 이벤트 핸들러

```jsx
function BuyButton() {
  const handleClick = () => {
    throw new Error('결제 실패') // ErrorBoundary에 안 잡힌다!
  }
  return <button onClick={handleClick}>구매</button>
}
```

- ErrorBoundary는 **렌더링 중** 던져진 에러만 잡는다
- 이벤트 핸들러는 렌더링이 끝난 뒤, 브라우저 이벤트에 반응해 실행된다 — React의 렌더 try/catch 바깥이다
- 화면이 깨질 일이 없으니 fallback으로 바꿀 이유도 없다는 설계
- 안 잡힌 에러는 **전역(window)으로 흘러가** 콘솔에 찍힐 뿐, 이미 커밋된 화면은 그대로다 — 렌더 에러처럼 흰 화면이 되지는 않는다

핸들러 안에서는 **직접 try/catch** 해야 한다. (경계로 보내는 방법은 Part 4에서)

---

## 잡지 못하는 것 2 — 비동기 콜백

```jsx
useEffect(() => {
  setTimeout(() => {
    throw new Error('타이머 안') // ❌ 안 잡힌다
  }, 1000)
  fetchUser(id).then(() => {
    throw new Error('then 안') // ❌ 안 잡힌다
  })
}, [id])
```

`setTimeout`, promise 콜백이 실행될 시점에 렌더링은 이미 끝나 있다. **호출 스택이 다르다.**

단, 헷갈리지 말 것:

```jsx
useEffect(() => {
  throw new Error('effect 본문') // ✅ 이건 잡힌다
}, [])
```

effect 본문의 동기적 throw는 React가 커밋 과정에서 처리하므로 **경계에 잡힌다**.

---

## 잡지 못하는 것 3, 4

**자기 자신의 에러**

```jsx
class ErrorBoundary extends React.Component {
  render() {
    if (this.state.error) return <Fallback data={undefined.foo} /> // 💥
    ...
  }
}
```

fallback 자체가 던지면 **그 위의** 경계로 올라간다. 경계는 자식만 보호한다.

**SSR 에러** — 서버 렌더 중 에러는 클래스 ErrorBoundary의 라이프사이클로 처리되지 않는다. 스트리밍 SSR에서는 다른 메커니즘(가장 가까운 Suspense까지 지우고 클라이언트에서 재시도)이 동작한다. (Part 3 후반)

---

## 실무에서는: react-error-boundary

```jsx
import {ErrorBoundary} from 'react-error-boundary'

function Fallback({error, resetErrorBoundary}) {
  return (
    <div role="alert">
      <p>문제가 생겼습니다: {error.message}</p>
      <button onClick={resetErrorBoundary}>다시 시도</button>
    </div>
  )
}

;<ErrorBoundary FallbackComponent={Fallback} onError={logError}>
  <Profile />
</ErrorBoundary>
```

클래스 보일러플레이트 없이 fallback, 로깅, **리셋**(Part 4)까지 제공한다. 사실상 표준.

<!-- 배경 보충(구두): 만든 사람이 React 코어 팀 출신(Brian Vaughn) — 사실상 이 문제의 레퍼런스 구현. 런타임 의존성 없이 1KB 남짓, 주간 다운로드 천만 회 이상. 수치는 발표 시점에 npm/bundlephobia로 재확인 (2026-07 기준 주간 약 1,450만 회, 의존성 없음은 v6 기준). -->

---

## Part 1 정리

- 경계 없는 렌더 에러 = **트리 전체 언마운트** (흰 화면)
- `getDerivedStateFromError` → 무엇을 그릴까 (render phase, 순수)
- `componentDidCatch` → 무엇을 기록할까 (commit phase, 사이드이펙트)
- 잡는 것: **렌더링, 라이프사이클, 생성자, effect 본문**의 에러
- 못 잡는 것: **이벤트 핸들러, 비동기 콜백, 자기 자신, SSR**
- 직접 만들지 말고 `react-error-boundary`

---

## 퀴즈 1 — ErrorBoundary에 잡힐까?

각 코드의 에러가 부모 ErrorBoundary에 잡히면 O, 아니면 X.

```jsx
function A() {
  return <div>{data.name}</div> // (1) 렌더 — data는 undefined
}

const handleClick = () => {
  throw new Error() // (2) 버튼의 클릭 핸들러
}

useEffect(() => {
  throw new Error() // (3) effect 본문
}, [])

useEffect(() => {
  fetch('/api').then(() => {
    throw new Error() // (4) promise 콜백
  })
}, [])
```

<!-- 정답 공개 전에 손들기로 진행. (3)이 갈리는 문제 — effect 본문과 비동기 콜백의 차이를 짚는 게 목적. -->

---

## 퀴즈 1 — 정답

| 코드 | 정답  | 이유                                                |
| ---- | ----- | --------------------------------------------------- |
| (1)  | **O** | 렌더 중 throw — 경계의 본래 목적                    |
| (2)  | **X** | 이벤트 핸들러 — 렌더 바깥의 호출 스택               |
| (3)  | **O** | effect **본문**의 동기 throw는 커밋 과정에서 잡힌다 |
| (4)  | **X** | promise 콜백 — 렌더도 커밋도 아닌 나중 시점         |

> 판별 기준은 "어디에 썼나"가 아니라 **"React가 실행해주는 흐름 안에서 던져졌나"**.

---

## Part 2 — Suspense: 개념과 동작

---

## Suspense 기본형

```jsx
<Suspense fallback={<Skeleton />}>
  <Profile />
</Suspense>
```

- `Profile`(또는 그 아래 누군가)이 "아직 준비 안 됨"을 선언하면 `fallback`을 대신 보여준다
- 준비가 끝나면 **자동으로** 실제 콘텐츠로 교체된다
- 로딩 상태를 **어디에 보여줄지**를 트리 구조로 선언하는 컴포넌트

그런데 — "아직 준비 안 됨"을 **누가, 어떻게** 선언하는가?

---

## 무엇이 suspend를 일으키는가

| 소스                                                                      | suspend? |
| ------------------------------------------------------------------------- | -------- |
| `React.lazy()` 코드 스플리팅                                              | ✅       |
| `use(promise)` (React 19)                                                 | ✅       |
| Suspense 지원 프레임워크·라이브러리 (Next.js RSC, TanStack Query, Relay…) | ✅       |
| 컴포넌트 안에서 직접 `fetch`                                              | ❌       |
| `useEffect` 안의 데이터 페칭                                              | ❌       |

**fetch를 쓴다고 저절로 suspend되지 않는다.** suspend는 특정한 프로토콜을 따르는 코드만 일으킨다. (표는 **클라이언트 컴포넌트 기준** — async 서버 컴포넌트의 `await`은 서버 렌더러가 같은 신호로 바꿔준다.) 그 프로토콜이 —

---

## suspend의 정체: promise를 throw한다

```jsx
function Profile() {
  const user = resource.read()
  // read()의 내부:
  //   데이터 있음   → 값 반환
  //   로딩 중      → throw promise   ← 이것이 suspend
  //   실패         → throw error     ← ErrorBoundary로
  return <h1>{user.name}</h1>
}
```

- JavaScript의 `throw`는 Error 전용이 아니다 — **아무 값이나 던질 수 있다**
- React는 렌더 중 잡힌 값이 **thenable**(then 메서드를 가진 객체)이면 "에러"가 아니라 "**기다려 달라는 신호**"로 해석한다

이 한 가지 트릭이 Suspense의 전부다.

---

## 가짜 Suspense 리소스 — 직접 만들어보기

```jsx
function createResource(promise) {
  let state = {status: 'pending', result: null}
  promise.then(
    (value) => (state = {status: 'success', result: value}),
    (error) => (state = {status: 'error', result: error}),
  )
  return {
    read() {
      if (state.status === 'pending') throw promise // Suspense가 받는다
      if (state.status === 'error') throw state.result // ErrorBoundary가 받는다
      return state.result
    },
  }
}
```

이 몇 줄로 두 경계에 모두 연결됐다. 라이브러리들이 하는 일도 본질적으로 이것(+ 캐싱)이다.

<!-- 실제 프로덕션에서 이 패턴을 직접 쓰라는 게 아니라는 점을 구두로 강조. 메커니즘 이해용. promise identity·캐시·키 변경 시 재페칭 관리가 곧 라이브러리의 일이라, 실전은 Suspense 지원 페처(React Query 등)나 라우터 loader 도입이 사실상 전제다. -->

---

## promise가 resolve되면?

```text
1. Profile 렌더 → throw promise
2. React: 가장 가까운 Suspense를 찾아 fallback을 화면에 표시
3. React: 잡아둔 promise에 "끝나면 알려줘"라고 .then을 걸어둠
4. 데이터 도착 (promise resolve)
5. React: Profile을 처음부터 다시 렌더링
6. 이번엔 read()가 값을 반환 → fallback 대신 진짜 콘텐츠 표시
```

핵심: **멈춘 곳에서 이어지는 게 아니라, 처음부터 다시 실행된다.**

자바스크립트 함수는 중간에 일시정지했다가 재개할 수 없다. 그래서 React가 할 수 있는 일은 컴포넌트 함수를 한 번 더 호출하는 것뿐이다. 이때 리소스가 결과를 기억해두지 않았다면 read()는 또 promise를 던지고, 화면은 fallback에서 벗어나지 못한다. 결과를 캐시하는 리소스/라이브러리가 필요한 이유가 이것이다.

---

## fallback이 보이는 동안, 아래 트리는?

두 가지 상황을 구분해야 한다.

**① 최초 마운트 중 suspend** — 아직 아무것도 커밋된 적 없음. DOM도 state도 없는 상태에서 fallback만 커밋된다.

**② 이미 보이던 트리가 다시 suspend** (동기 업데이트)

```html
<div style="display: none !important;">기존 콘텐츠</div>
<div>fallback</div>
```

- 기존 콘텐츠를 **언마운트하지 않는다** — `display: none`으로 숨긴다
- 컴포넌트 **state는 그대로 유지**된다
- 준비되면 다시 보여주기만 하면 된다

이 "숨기기" 메커니즘이 내부의 Offscreen(현 Activity) 처리다.

---

## use() — React 19의 공식 입구

```jsx
import {use, Suspense} from 'react'

function Profile({userPromise}) {
  const user = use(userPromise) // pending이면 suspend
  return <h1>{user.name}</h1>
}

// 부모 (예: 서버 컴포넌트 — 리렌더가 없어 promise는 요청당 한 번)
;<Suspense fallback={<Skeleton />}>
  <Profile userPromise={fetchUser(id)} />
</Suspense>
```

- 앞의 `createResource` 패턴을 React가 정식 API로 만든 것
- 단, **promise의 identity가 안정적이어야 한다** — 렌더마다 새 promise를 만들면 재시도할 때마다 다시 suspend되어 영원히 끝나지 않는다 ("uncached promise" 경고)
- 그래서 promise는 **렌더 바깥에서** 만들어 내려보내거나, 캐시를 통해 얻는다

<!-- "클라이언트 props는 직렬화 가능해야 한다던데 promise를 넘겨도 되나?" — promise는 특별 취급이다. RSC 프로토콜이 resolve 값을 스트리밍으로 이어 보내 클라이언트의 use()가 이어받는다. 상세는 별도 덱('use client' 딥다이브)에서. -->

---

## React.lazy — 이미 쓰고 있던 suspend

```jsx
const Settings = lazy(() => import('./Settings'))

;<ErrorBoundary FallbackComponent={ChunkError}>
  <Suspense fallback={<PageSkeleton />}>
    <Settings />
  </Suspense>
</ErrorBoundary>
```

- lazy는 **모듈을 기다리는 promise를 throw**한다 — 기다리는 게 데이터가 아니라 코드일 뿐, 프로토콜은 `use()`와 같다
- 로드 **실패**(배포 직후 청크 404, 오프라인)는 **Error throw** → ErrorBoundary가 받는다
- 한 기능이 두 경계를 모두 쓰는 가장 흔한 실전 사례 — **코드 스플리팅에도 에러 경계가 필요한 이유**다

<!-- 배포 후 옛 청크가 사라져 ChunkLoadError가 나는 시나리오를 구두로: 에러 fallback에서 새로고침을 유도하는 것이 흔한 대응. next/dynamic도 내부는 lazy + Suspense 래핑으로 같은 프로토콜이고, 청크 404도 같은 원리로 가장 가까운 에러 경계(error.tsx 포함)가 받는다. -->

---

## Part 2 정리

- suspend = **promise를 throw하는 것.** fetch가 아니라 이 프로토콜이 Suspense를 작동시킨다
- React는 thenable을 "기다려 달라"로, Error를 "실패"로 해석한다
- resolve 후에는 **재개가 아니라 재시도** — 그래서 캐시가 필수
- 이미 보이던 트리는 fallback 동안 **`display: none`으로 숨겨질 뿐, state가 유지**된다
- `use()`는 이 프로토콜의 공식 입구. promise identity를 안정시킬 것

---

## 퀴즈 2 — 입력값은 살아남을까?

```jsx
function Page() {
  const [submitted, setSubmitted] = useState(null)
  const inputRef = useRef(null)
  return (
    <Suspense fallback={<Spinner />}>
      <input ref={inputRef} /> {/* uncontrolled — 값은 DOM에만 있다 */}
      <button onClick={() => setSubmitted(inputRef.current.value)}>검색</button>
      {submitted && <SearchResult query={submitted} />}
    </Suspense>
  )
}
```

**Q. `"react"`를 입력하고 검색을 눌렀다. suspend로 Spinner가 보였다가 결과가 도착했다 — input에는 무엇이 남아 있을까?**

1. 빈 문자열 — 언마운트됐다 리마운트됐다면 DOM 값은 사라진다
2. `"react"` — 숨겨졌을 뿐이라면 값이 그대로 남는다

<!-- input이 uncontrolled(값이 React state가 아니라 DOM에만 있음)임을 먼저 짚을 것 — controlled였다면 리마운트돼도 value로 복원되어 실험이 성립하지 않는다. 힌트 없이 먼저 투표받기. -->

---

## 퀴즈 2 — 정답: ② "react"가 유지된다

- 한 번 커밋된 트리가 다시 suspend하면, React는 **언마운트하지 않고 `display: none`으로 숨긴다**
- uncontrolled input의 값은 React state가 아니라 **DOM에만** 있다 — 값이 남아있다는 것 자체가 DOM이 살아있었다는 증거
- fallback이 걷히면 숨겨졌던 DOM이 다시 나타난다

**그런데 UX 관점의 진짜 문제**: 검색을 누를 때마다 방금 입력한 검색어까지 통째로 Spinner에 **가려진다** —

이것이 Part 3에서 다룰 **useTransition이 존재하는 이유**다.

---

## Part 3 — 원리 딥다이브: React 내부에서는

---

## 이 파트의 지도

이제 React 소스에서 이 흐름을 직접 따라간다. (이하 **react@19.2.0** 태그 기준, `packages/react-reconciler/src/`)

```mermaid
flowchart LR
    A["render 중 throw 발생"] --> B["workLoop의 try/catch (ReactFiberWorkLoop.js)"]
    B --> C{"handleThrow: 값 분류"}
    C -->|thenable| D["Suspense 경로 (ReactFiberThrow.js)"]
    C -->|Error| E["ErrorBoundary 경로 (ReactFiberThrow.js)"]
```

목표는 함수 이름 암기가 아니라, "**두 경계가 정말 한 곳에서 갈라진다**"를 눈으로 확인하는 것.

다 소화하지 못해도 괜찮다 — **Part 4의 실무는 이 파트 없이도 따라갈 수 있다.** 챙길 것은 마지막의 대칭 구조 한 장이다.

---

## 입구: workLoop의 try/catch

React의 렌더는 fiber(컴포넌트마다 하나씩 만드는 내부 작업 객체) 트리를 순회하는 루프다. 이 루프 전체가 try/catch로 감싸여 있다.

```js
// ReactFiberWorkLoop.js (단순화)
do {
  try {
    workLoopConcurrent() // 컴포넌트 함수들을 실행
    break
  } catch (thrownValue) {
    handleThrow(root, thrownValue) // 모든 throw가 여기로
  }
} while (true)
```

> 코드를 건너뛰어도 이것만 — 무엇을 던지든 **일단 전부 여기서 잡히고**, `handleThrow`가 "대기"인지 "실패"인지 분류표를 붙인다. 판별 기준은 다음 장에서.

<!-- 분류명: use()의 센티널이면 SuspendedOnImmediate, thenable 직접 throw면 SuspendedOnDeprecatedThrowPromise, 아니면 SuspendedOnError. 직접 throw 경로의 분류명에 'Deprecated'가 박혀 있다 — promise 직접 throw는 레거시 경로이고 use()가 공식 경로라는 것이 소스 레벨에 새겨져 있는 셈. -->

---

## 분류: thenable인가?

```js
// 판별의 본질 (단순화)
if (
  thrownValue !== null &&
  typeof thrownValue === 'object' &&
  typeof thrownValue.then === 'function'
) {
  // "기다려 달라"는 신호 → Suspense 경로
} else {
  // 진짜 에러 → ErrorBoundary 경로
}
```

- 덕 타이핑이다 — `then` 메서드가 있으면 thenable
- React 19의 `use()`는 `SuspenseException`이라는 센티널 값(정상 값과 구분하는 표식용 고유 객체)을 던지고 진짜 thenable은 따로 보관하지만, "**대기인지 실패인지 분류한다**"는 본질은 같다

> 코드를 건너뛰어도 이것만 — 판별 기준은 단 하나, **`.then`이 있는가**. Part 0의 대칭표가 여기서 코드가 된다.

---

## Suspense 경로: 경계 찾기

분류가 끝나면 `ReactFiberThrow.js`의 `throwException`이 처리를 맡는다.

```js
// throwException (단순화)
const suspenseBoundary = getSuspenseHandler()
if (suspenseBoundary !== null) {
  suspenseBoundary.flags |= ShouldCapture // "네가 fallback을 그려라"
  attachPingListener(root, wakeable, rootRenderLanes)
}
```

- 렌더가 Suspense 컴포넌트를 지날 때마다 **핸들러 스택**에 쌓아둔다 — `getSuspenseHandler()`는 그 스택의 맨 위, 즉 **가장 가까운 조상 Suspense**를 돌려준다
- 그 경계에 `ShouldCapture` 표시 → 다음 렌더 패스에서 fallback을 그린다

> 코드를 건너뛰어도 이것만 — **가장 가까운 Suspense를 찾아 "네가 fallback을 그려라" 표시**를 해 둔다.

---

## Suspense 경로: ping과 재시도

```js
// attachPingListener (단순화)
const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes)
wakeable.then(ping, ping)
```

- 던져진 promise(wakeable)에 `.then(ping, ping)`을 걸어둔다
- resolve되면 `pingSuspendedRoot` → 스케줄러에 **해당 렌더를 다시 예약**
- 새 렌더에서 컴포넌트 함수가 처음부터 다시 실행되고, 이번엔 값이 준비돼 있으니 통과

> 코드를 건너뛰어도 이것만 — promise가 끝나면 **처음부터 다시 렌더**한다(재개가 아니라 재시도). reject여도 다시 렌더해야, 이번엔 Error가 던져져 ErrorBoundary로 간다.

---

## ErrorBoundary 경로: 같은 곳에서 갈라진다

`throwException`의 나머지 절반. thenable이 아니면 —

```js
// throwException (단순화)
let workInProgress = returnFiber
do {
  const ctor = workInProgress.type
  if (
    workInProgress.tag === ClassComponent &&
    typeof ctor.getDerivedStateFromError === 'function' // 또는 componentDidCatch
  ) {
    workInProgress.flags |= ShouldCapture // getDerivedStateFromError 실행 예약
    return
  }
  workInProgress = workInProgress.return // 부모로 한 칸씩
} while (workInProgress !== null)
```

> 코드를 건너뛰어도 이것만 — `return` 포인터로 **부모 방향으로 한 칸씩 올라가며** 에러 라이프사이클을 가진 클래스를 찾고, 루트까지 못 찾으면 트리 전체 언마운트.

---

## 대칭 구조 — 한 장으로

```mermaid
flowchart LR
    T["컴포넌트가 throw"] --> W["workLoop try/catch"] --> H{"thenable?"}
    H -->|promise| S["가장 가까운 Suspense + ping 예약"]
    H -->|Error| E["가장 가까운 ErrorBoundary + getDerivedStateFromError 예약"]
    S --> SF["fallback 커밋"] --> SR["resolve → 재시도 (자동)"]
    E --> EF["fallback 커밋"] --> ER["리셋 → 재시도 (수동)"]
```

> 아래에서 무언가를 요청하면, 위 어딘가의 핸들러가 응답한다.

<!-- 이 설계의 사고방식은 Dan Abramov의 'Algebraic Effects for the Rest of Us'에 잘 담겨 있다 — 링크는 더 읽을거리(심화)에 있음. -->

---

## 왜 state가 유지되는가

Part 2 퀴즈의 내부 답.

- fallback을 보여줄 때 React는 기존 자식 트리를 **삭제하지 않는다**
- Suspense는 자식을 숨김 모드로 전환할 뿐 — 각 컴포넌트의 fiber와 hook state(`memoizedState`)가 전부 살아있다
- DOM도 삭제 대신 `display: none` — 커밋 단계에서 스타일만 바꾼다
- 단, layout effect는 예외 — 숨겨질 때 **cleanup되고** 다시 보일 때 재실행된다. 일반(passive) effect는 유지된다 — 구독·타이머는 계속 돌고, deps가 그대로면 재실행도 없다

> state는 fiber에 산다. fiber가 살아있는 한 state도 살아있다.

(React 19.2에서 이 숨김 메커니즘은 `<Activity />`로 공식 API가 됐다 — 같은 인프라다.)

---

## useTransition과 Suspense — 이미 보인 화면을 지키기

퀴즈 2의 UX 문제(검색어까지 Spinner에 가려짐)의 해법.

```jsx
const [isPending, startTransition] = useTransition()
function handleChange(e) {
  setQuery(e.target.value) // 급한 업데이트: input은 즉시 반영
  startTransition(() => {
    setSearchQuery(e.target.value) // 느긋한 업데이트
  })
}
```

- `startTransition`으로 감싼 업데이트가 **transition**이다. React에 "급하지 않다"고 표시해 두는 것
- 이 표시가 Suspense의 동작을 바꾼다. transition이 일으킨 렌더가 suspend하면, React는 **보이던 화면을 fallback으로 되돌리지 않고 그대로 둔다**. 준비되면 교체하고, 기다리는 중이라는 표시는 `isPending`으로 하면 된다
- 퀴즈 2라면? `setSubmitted(...)`를 `startTransition`으로 감싸면 Spinner 후퇴가 사라진다
- 값 단위 버전인 `useDeferredValue(query)`도 있다 — **원인이 값 하나면** 더 간단하다

> **fallback은 "처음 나타나는 콘텐츠"의 자리다** — 이미 보여준 것을 도로 감추는 용도가 아니다.

---

## 곁가지: transition이 열어둔 문, tearing

transition 렌더는 화면을 유지한 채 뒤에서, 중간중간 브라우저에 양보(yield)하며 **끊어서** 진행된다. 그 틈에 값이 바뀌면?

```text
1. 렌더 시작: <A>가 store를 읽음 → 1
2. React가 잠시 양보 → 그 틈에 이벤트가 store를 2로 변경
3. 렌더 재개: <B>가 같은 store를 읽음 → 2
4. 커밋: 한 화면에 A는 1, B는 2 (tearing)
```

- `useState`·props는 안전하다. React가 렌더 한 번 동안 **일관된 스냅샷을 보장**한다
- 문제는 **React 바깥의 가변 store**를 렌더 중 직접 읽을 때다 (모듈 변수, 직접 만든 store)
- 해법이 **useSyncExternalStore**다. 렌더 중 store가 바뀐 것을 감지하면 동기 재렌더로 화면을 맞춘다
- Redux(v8+)·Zustand는 내부에서 이미 이것을 쓴다. store를 직접 만들 때만 기억하면 된다

<!-- 곁가지임을 밝히고 시작: transition을 쓰는 순간 렌더가 "끊길 수 있는 것"이 되면서 생기는 부작용이라는 연결만 전달. uSES는 일관성을 위해 감지 시 동기(블로킹) 재렌더로 후퇴한다 = 동시성 이득 일부를 포기하는 트레이드오프라는 점을 구두로. 상세는 reactwg 'What is tearing?' 참고. -->

---

## fallback 깜빡임과 300ms 스로틀

중첩된 Suspense가 차례로 준비되면, fallback → 콘텐츠 → 또 fallback… 화면이 연쇄적으로 튄다.

React의 내장 방어: **FALLBACK_THROTTLE_MS = 300** (ReactFiberWorkLoop.js)

- fallback을 보여준 직후 새 콘텐츠가 준비되어도, **300ms 단위로 묶어서** 공개한다
- 순식간에 여러 번 바뀌는 대신, 한 박자로 정리해 보여주는 것

주의: 이건 **"공개(reveal)"의 스로틀**이다. "로딩이 짧으면 fallback을 아예 안 보여주는" 지연 기능이 아니다 — 그 문제의 해법은 Part 4에서.

<!-- 300ms는 공개 API가 아닌 내부 상수(react@19.2.0 기준)라 버전에 따라 바뀔 수 있음을 구두로 언급할 것. -->

---

## SSR 스트리밍 + selective hydration — 지도만

Suspense는 서버에서 한 번 더 일한다. (`renderToPipeableStream`, Next.js가 쓰는 것)

1. 서버: 준비 안 된 경계는 **fallback인 채로 먼저 HTML 전송** — 나머지 준비되는 대로 스트리밍
2. 서버에서 경계 안이 **에러나면**: 그 경계까지만 지우고 fallback 전송 → 클라이언트에서 재시도 (Part 1의 "SSR 에러" 답)
3. 클라이언트: 경계 단위로 **나눠서 hydration** — 사용자가 클릭한 영역을 먼저 hydrate (selective hydration)

hydration은 서버가 보낸 정적 HTML에 이벤트 핸들러를 붙여 **동작하는 앱으로 만드는 과정**이다. 경계를 어디에 두느냐가 **스트리밍 단위이자 hydration 단위**가 된다는 것만 기억하자. 상세는 별도 덱('use client' 딥다이브)에서.

---

## Part 3 정리

- 모든 throw는 **workLoop의 try/catch → handleThrow** 한 곳으로 모인다
- **thenable이면 Suspense 경로, Error면 ErrorBoundary 경로** — 분기점은 하나
- Suspense: 핸들러 스택에서 가장 가까운 경계 + promise에 **ping** → 재시도
- ErrorBoundary: `return` 포인터로 조상을 거슬러 탐색, 못 찾으면 전체 언마운트
- fallback 동안 fiber는 살아있다 → **state 유지**
- transition 중에는 이미 보인 콘텐츠를 fallback으로 되돌리지 않는다
- 중첩 fallback 공개는 300ms 스로틀

---

## 퀴즈 3 — React는 어떻게 반응할까?

렌더 중 각 값이 throw됐다. React의 반응을 골라 연결하라.

**던져진 것**

1. `fetch()`가 반환한 promise
2. `new Error('boom')`
3. `'문자열'`
4. promise — 단, 조상에 Suspense가 하나도 없다

**보기**

- (a) 가장 가까운 ErrorBoundary가 잡는다
- (b) 가장 가까운 Suspense가 fallback을 보여준다
- (c) 루트까지 아무도 못 잡으면 트리 전체 언마운트
- (d) 커밋을 보류하고 promise가 끝날 때까지 화면을 미룬다

<!-- 3번이 함정 — thenable 판별은 instanceof Error가 아니라 .then 유무라는 것. 문자열은 Error는 아니지만 thenable도 아니므로 에러 경로. -->

---

## 퀴즈 3 — 정답

| 던져진 것            | 정답      | 이유                                                               |
| -------------------- | --------- | ------------------------------------------------------------------ |
| 1. promise           | **(b)**   | thenable → Suspense 경로                                           |
| 2. `new Error()`     | **(a→c)** | 에러 경로. 경계가 없으면 전체 언마운트                             |
| 3. `'문자열'`        | **(a→c)** | `then`이 없으니 thenable이 아니다 — **Error가 아니어도 에러 경로** |
| 4. 경계 없는 promise | **(d)**   | 루트가 대기를 떠안는다 — 초기 렌더 커밋 자체가 미뤄진다            |

> 분류 기준은 단 하나: **`.then`이 있는가.** Error 클래스인지는 보지 않는다.

실무 결론: `use()`·`useSuspenseQuery` 위에는 **항상** 명시적 `<Suspense>`를 두자 — 경계가 없으면 (d)처럼 앱 전체가 하나의 암묵적 경계가 된다.

---

## Part 4 — 실무에서 알아야 할 것들

---

## 경계 배치 전략 — 통짜 vs 조각

```jsx
// A. 페이지 전체를 하나로
<Suspense fallback={<PageSkeleton />}>
  <Header /> <Feed /> <Sidebar />
</Suspense>

// B. 영역별로
<Header />
<Suspense fallback={<FeedSkeleton />}><Feed /></Suspense>
<Suspense fallback={<SidebarSkeleton />}><Sidebar /></Suspense>
```

- **A**: 화면이 한 번에 뜬다. 가장 느린 데이터가 전체를 붙잡는다
- **B**: 준비된 것부터 보여준다. 화면이 조각조각 나타난다

정답은 없고 **디자인의 문제**다. 판단 기준 →

---

## 배치 판단 기준

1. **디자이너의 스켈레톤 단위를 따르라** — 로딩 UI가 설계된 단위가 곧 경계 단위. 코드 사정으로 경계를 긋는 게 아니라 **사용자가 인식하는 화면 영역**으로 긋는다
2. **함께 떠야 의미 있는 것은 한 경계에** — 상품 가격과 구매 버튼이 따로 뜨면 오히려 어색하다
3. **핵심 콘텐츠 vs 부가 콘텐츠 분리** — 본문은 기다리더라도, 추천 위젯이 본문을 붙잡게 하지 마라
4. ErrorBoundary도 같은 논리 — **"이 영역이 죽어도 나머지는 살아야 하는가?"** 위젯 하나의 에러가 페이지를 지우면 안 된다

> 모든 컴포넌트를 감싸는 것도, 루트 하나만 두는 것도 답이 아니다. **화면 설계 단위 = 경계 단위.**

---

## fallback 깜빡임 — 200ms 로딩에 스피너가 번쩍

짧은 로딩에 스피너가 번쩍하는 건 없느니만 못하다. React에 "짧으면 fallback 생략" 기능은 **없다** (Part 3의 스로틀은 공개 쪽 이야기). 대책 셋:

1. **스피너 대신 스켈레톤** — 콘텐츠와 같은 자리·크기라 전환이 덜 튄다
2. **CSS로 fallback 등장을 지연**

```css
.spinner {
  animation: fade-in 0ms 200ms both; /* 200ms 뒤에야 나타남 */
}
@keyframes fade-in {
  from {
    opacity: 0; /* delay 동안 both(backwards fill)가 이 값을 적용 */
  }
}
```

3. **navigation·갱신에는 useTransition** — 애초에 fallback으로 되돌리지 않는 게 최선

---

## waterfall — 중첩이 만드는 순차 로딩

```jsx
function Post({id}) {
  const post = use(getPost(id)) // 렌더가 페칭을 시작 — 1초
  return (
    <>
      <h1>{post.title}</h1>
      {/* post 도착 후에야 렌더 시작 */}
      <Comments postId={post.id} />
    </>
  )
}
// Comments 안에서 또 use(getComments(postId)) → +1초 = 총 2초
```

- suspend는 렌더를 멈추므로, **아래 컴포넌트의 페칭은 시작조차 못 한다**
- 부모 1초 + 자식 1초 = 순차 2초. 경계를 잘게 쪼갤수록 이 함정에 빠지기 쉽다

(전제: `getPost`류는 같은 인자에 **같은 promise를 돌려주는 캐시된 fetcher**다 — 아니면 Part 2의 uncached promise 문제가 그대로 난다)

---

## waterfall 해법 — 페칭을 렌더에서 분리

**render-as-you-fetch**: 렌더가 페칭을 시작하는 게 아니라, **이미 시작된 페칭을 렌더가 소비**하게 한다.

```jsx
// 라우터 loader·서버 컴포넌트 등 렌더 밖에서 미리 출발시킨 promise를 받는다
function Page({postPromise, commentsPromise}) {
  return (
    <Suspense fallback={<PostSkeleton />}>
      <Post postPromise={postPromise} />
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments commentsPromise={commentsPromise} />
      </Suspense>
    </Suspense>
  )
}
```

병렬 1초. 서버 컴포넌트·라우터 loader·React Query prefetch가 전부 이 원리다.

<!-- React Query라면: 라우트 진입 콜백이나 클릭 핸들러에서 queryClient.prefetchQuery({queryKey, queryFn})를 먼저 호출해 두고, 컴포넌트의 useSuspenseQuery가 같은 queryKey로 이미 출발한 요청을 소비한다. 부모·자식이 각자 useSuspenseQuery만 부르면 waterfall 그대로라는 점을 구두 강조. -->

---

## ErrorBoundary 리셋 — "다시 시도" 만들기

에러는 resolve가 없다. **누군가 state를 되돌려줘야** 재시도된다.

```jsx
<ErrorBoundary
  FallbackComponent={Fallback} // resetErrorBoundary를 받는다
  onReset={() => refetch()} // 리셋 시 원인도 해소
  resetKeys={[userId]} // 이 값이 바뀌면 자동 리셋
>
  <Profile userId={userId} />
</ErrorBoundary>
```

- `resetErrorBoundary()` 호출 → 경계 state 초기화 → 자식 재렌더
- **주의**: 원인(실패한 요청, 잘못된 캐시)을 그대로 두고 리셋만 하면 **즉시 다시 에러**다. `onReset`에서 원인을 함께 해소할 것 — React Query와의 연결은 Part 5에서
- `resetKeys`: "다른 사용자를 보러 갔으면 이전 에러는 무의미" — 상태 변화에 따른 자동 복구

---

## key로 리셋하는 트릭

라이브러리 없이도, React의 기본기로 같은 효과를 낼 수 있다.

```jsx
<ErrorBoundary key={userId}>
  <Profile userId={userId} />
</ErrorBoundary>
```

- `key`가 바뀌면 React는 **다른 컴포넌트로 간주하고 언마운트 후 새로 마운트**한다
- 경계의 에러 state도 함께 사라진다 — 사실상 리셋
- Suspense에도 같은 트릭이 통한다: `key`를 바꾸면 유지되던 자식 state를 **의도적으로 버리고** 처음부터 다시 시작

단순하지만 강력하고 — **남용하면 state를 다 날리는** 양날의 검.

---

## 에러 로깅 — 놓치는 에러 없이

**경계에서**: `onError` (= `componentDidCatch`)에서 Sentry 등으로 전송.

```jsx
<ErrorBoundary onError={(error, info) => {
  Sentry.captureException(error, {extra: {componentStack: info.componentStack}})
}}>
```

**루트에서**: 경계 유무와 무관하게 한 곳에서 잡는다.

```jsx
createRoot(container, {
  onUncaughtError: (error, info) => {}, // 어떤 경계에도 안 잡힌 에러 (React 19 신규)
  onCaughtError: (error, info) => {}, // 경계에 잡힌 에러 (React 19 신규)
  onRecoverableError: (error, info) => {}, // hydration 불일치 등 자동 복구 (React 18부터)
})
```

- 경계가 잡았는데 **콘솔에 에러가 찍히는 건 정상** — 그 기본 로그의 주인이 `onCaughtError`다
- 둘 다 못 받는 에러(핸들러·비동기 — 퀴즈 1의 X들)는 `window` **전역 핸들러** 몫 — Sentry 기본 설정이 잡는다

<!-- Next에서는 createRoot 루트 옵션에 직접 접근할 수 없다. 서버·RSC 에러는 instrumentation.ts의 onRequestError, 클라이언트는 전역 핸들러 + Sentry Next SDK가 접점. -->

---

## 이벤트 핸들러 에러 — 경계로 보내는 법

Part 1의 미해결 문제. 핸들러·비동기 에러도 fallback UI로 처리하고 싶다면 —

```jsx
import {useErrorBoundary} from 'react-error-boundary'

function BuyButton() {
  const {showBoundary} = useErrorBoundary()
  const handleClick = async () => {
    try {
      await purchase()
    } catch (error) {
      showBoundary(error) // 렌더 흐름에 태워 경계로 던진다
    }
  }
  return <button onClick={handleClick}>구매</button>
}
```

원리는 단순하다: 에러를 state에 넣고, **다음 렌더에서 throw**한다. 렌더 중의 throw가 되므로 경계가 잡을 수 있다.

<!-- showBoundary는 react-error-boundary의 컨텍스트 전용이라 Next의 error.tsx 경계에는 통하지 않는다. error.tsx로 보내려면 이 슬라이드의 원리 그대로(에러를 state에 넣고 다음 렌더에서 throw) 직접 구현한다. -->

---

## Part 4 정리

- 경계 배치 = **화면 설계 단위.** 스켈레톤 단위, "이 영역이 죽어도 나머지는 살아야 하나"
- 깜빡임: 스켈레톤, CSS 등장 지연, **useTransition**
- waterfall: suspend는 아래 페칭을 막는다 → **페칭을 렌더보다 먼저 출발**시켜라
- 리셋: `resetErrorBoundary` + `onReset`으로 **원인까지 해소**, `resetKeys`/`key`로 자동 리셋
- 로깅: 경계 `onError` + 루트 `onUncaughtError`/`onCaughtError`
- 핸들러 에러는 `showBoundary`로 렌더 흐름에 태운다

---

## 퀴즈 4 — 경계를 어디에 둘까

쇼핑몰 상품 페이지. 요구사항:

- **상품 정보**(이미지·가격·구매 버튼): 이 페이지의 존재 이유. 가격과 구매 버튼이 따로 뜨면 안 됨
- **리뷰 목록**: 무거운 데이터. 늦게 떠도 되고, 실패해도 페이지는 살아야 함
- **추천 상품**: 서드파티 API. 자주 죽는다. 죽으면 그냥 안 보여도 됨

```jsx
<ProductInfo />   // 이미지·가격·구매 버튼
<Reviews />
<Recommended />
```

**Q. Suspense와 ErrorBoundary를 어떻게 감싸겠는가?** (1분 설계)

<!-- 정답은 하나가 아님을 미리 말해두기. 각자 그려보게 한 뒤 다음 장과 비교. -->

---

## 퀴즈 4 — 설계 예시

```jsx
<ErrorBoundary FallbackComponent={PageError}>
  <Suspense fallback={<ProductSkeleton />}>
    <ProductInfo />
  </Suspense>
  <ErrorBoundary FallbackComponent={ReviewsError}>
    <Suspense fallback={<ReviewsSkeleton />}>
      <Reviews />
    </Suspense>
  </ErrorBoundary>
  <ErrorBoundary fallback={null}>
    <Suspense fallback={<RecommendedSkeleton />}>
      <Recommended />
    </Suspense>
  </ErrorBoundary>
</ErrorBoundary>
```

상품 정보는 한 경계로 **함께**, 리뷰는 **죽어도 페이지는 살게**, 추천은 `fallback={null}`로 **조용히**. 상품 정보에 전용 경계가 없는 것도 의도다 — **핵심이 죽으면 페이지 전체가 에러인 게 맞다**.

<!-- fallback={null}로 접히면 layout shift가 생긴다. 자리 유지가 중요한 레이아웃이면 min-height 예약이나 빈 자리 대체 UI를 두는 트레이드오프가 있다고 구두 보충. -->

---

## Part 5 — React Query와 함께 쓰기

---

## useSuspenseQuery — 페칭을 suspend로

```jsx
import {useSuspenseQuery} from '@tanstack/react-query'

function Profile({id}) {
  const {data} = useSuspenseQuery({
    queryKey: ['user', id],
    queryFn: () => fetchUser(id),
  })
  return <h1>{data.name}</h1> // data는 항상 있다 — 타입도 undefined 아님
}
```

- pending이면 내부에서 **promise를 throw** — Part 2에서 만든 `createResource`의 프로덕션 버전 (TanStack Query v5부터, React 18 가능)
- `isLoading` 분기가 사라지고, `data`의 타입에서 `undefined`가 사라진다. 로딩 UI는 부모의 `<Suspense>`가 담당
- 갱신 중 표시는? fallback은 다시 뜨지 않는다 — `isFetching`으로 **인라인** 표시. 초기 로드는 경계, 갱신은 인라인

<!-- Q&A 대비: React 18에서 가능 — Suspense/ErrorBoundary/lazy/useTransition/useSuspenseQuery(v5). 불가 — use(), Activity, onCaughtError류 루트 옵션(React 19). v4는 useQuery의 suspense:true 옵션 시절. 서버 페칭이 기본인 App Router에서는 상호작용 후 갱신·실시간·클라 전용 영역에 쓰고, SSR 결합은 서버 prefetchQuery + HydrationBoundary가 공식 패턴(TanStack SSR 가이드). -->

---

## throwOnError — 에러를 경계로

```jsx
const {data, error} = useQuery({
  queryKey: ['user', id],
  queryFn: () => fetchUser(id),
  throwOnError: (error) => error.response?.status >= 500,
})
```

- 기본 `useQuery`는 에러를 **반환값으로** 준다 (`error` 필드) — 경계까지 가지 않는다
- `throwOnError: true`면 렌더 중 **throw** → ErrorBoundary가 잡는다
- 함수를 주면 **선별**할 수 있다: 위 예시는 "500번대(서버 장애)는 경계로, 404 같은 것은 컴포넌트에서 직접" — 실무에서 가장 유용한 형태
- `useSuspenseQuery`는 **보여줄 캐시 데이터가 없을 때** 에러를 throw한다 (있으면 stale 데이터 유지) — `throwOnError` 옵션 자체가 없어 이 동작은 끌 수 없다

---

## 완성형: 경계 3종 세트

```jsx
// @tanstack/react-query + react-error-boundary
<QueryErrorResetBoundary>
  {({reset}) => (
    <ErrorBoundary onReset={reset} FallbackComponent={Fallback}>
      <Suspense fallback={<Skeleton />}>
        <Profile id={id} />
      </Suspense>
    </ErrorBoundary>
  )}
</QueryErrorResetBoundary>
```

- 리셋할 땐 **원인(실패한 쿼리)도 함께** (Part 4) — `reset`을 `onReset`에 연결하면 "다시 시도"가 refetch까지 한다
- 순서에도 이유가 있다 — promise는 ErrorBoundary를 **그냥 통과**하지만(Part 3), ErrorBoundary가 바깥이어야 **fallback 자체의 에러까지** 받는다
- 이 3종 조합이 Suspense 기반 데이터 페칭의 사실상 표준 패턴

<!-- "영역마다 매번 감싸야 하나?" — 반복이 부담이면 셋을 묶은 공통 래퍼(이른바 AsyncBoundary 패턴)를 만들어 팀 컨벤션으로 쓰는 것이 일반적. children 자리의 함수는 render prop 패턴이고, 훅 대안으로 useQueryErrorResetBoundary도 있다. -->

---

## Part 5 정리 — 배운 메커니즘 그대로

| 이 강의에서 배운 것         | React Query에서의 모습       |
| --------------------------- | ---------------------------- |
| promise throw (Part 2)      | `useSuspenseQuery`의 pending |
| Error throw (Part 1)        | `throwOnError`               |
| 재시도엔 캐시 필수 (Part 2) | QueryCache가 해결            |
| 리셋 시 원인 해소 (Part 4)  | `QueryErrorResetBoundary`    |

새로운 개념은 하나도 없다. **라이브러리는 프로토콜의 구현체**일 뿐 — 메커니즘을 알면 어떤 라이브러리든 같은 그림으로 읽힌다.

---

## 퀴즈 5 — 3종 세트, 제대로 이해했나

완성형 코드(`QueryErrorResetBoundary` + `ErrorBoundary` + `Suspense`) 기준으로:

1. `useSuspenseQuery`가 pending이면 → 어느 경계가 받나?
2. `queryFn`이 reject되면 (보여줄 캐시도 없음) → 어느 경계가 받나?
3. `QueryErrorResetBoundary` 없이 `resetErrorBoundary()`만 누르면?
4. `ErrorBoundary`와 `Suspense`의 **중첩 순서를 서로 바꾸면** 무엇이 달라지나?

<!-- 4번이 핵심 — Part 3의 메커니즘에서 답을 유도할 수 있는지 확인하는 문제. 정답 공개 전에 각자 답하게 할 것. -->

---

## 퀴즈 5 — 정답

1. **Suspense** — pending은 promise throw다 (Part 2)
2. **ErrorBoundary** — 캐시가 없으면 Error throw다 (Part 1·5)
3. **즉시 다시 에러** — 실패한 쿼리가 캐시에 그대로라, 재렌더에서 또 throw된다. Part 4의 "원인까지 해소" 그대로
4. **둘 다 그대로 동작한다** — thenable은 핸들러 스택으로, Error는 `return` 포인터로 **각자 따로** 자기 경계를 찾는다 (Part 3). 차이는 에러 시 **대체되는 UI 범위**뿐 (ErrorBoundary가 바깥이면 로딩 경계까지 함께 error fallback으로 교체)

---

## 전체 체크리스트

**설계할 때**

1. 경계 단위 = 스켈레톤 단위 = "죽어도 되는" 단위인가?
2. 최상단에 최후의 ErrorBoundary가 있는가?
3. 페칭이 렌더보다 먼저 출발하는가? (waterfall 점검)

**구현할 때**

4. 핸들러·비동기 에러는 try/catch 또는 `showBoundary`로 처리했는가?
5. "다시 시도"가 원인(쿼리)까지 리셋하는가?
6. 경계에 잡힌 에러도 로깅되는가?

**다듬을 때**

7. 짧은 로딩에 fallback이 번쩍이거나, 보이던 화면이 fallback으로 후퇴하지 않는가? (스켈레톤·`useTransition`)

---

## 한 줄 요약

> 컴포넌트는 **성공한 화면만** 그리고,
> 준비 안 됐으면 promise를, 실패했으면 Error를 **던진다**.
> 위 어딘가의 경계가 받아서, **그 영역만** 로딩과 실패를 대신 그린다.

나머지는 전부 이 문장의 각주다.

---

## Q&A

<!-- _class: invert -->

---

## Q. Next.js의 loading.tsx / error.tsx와는 무슨 관계인가요?

**그게 바로 이 강의 내용입니다.** App Router는 `loading.tsx`/`error.tsx`를 둔 세그먼트마다 자동으로 경계를 감싼다:

```text
<ErrorBoundary fallback={<Error />}>      ← error.tsx
  <Suspense fallback={<Loading />}>       ← loading.tsx
    <Page />
  </Suspense>
</ErrorBoundary>
```

- `error.tsx`가 클라이언트 컴포넌트여야 하는 이유 → ErrorBoundary는 클래스 컴포넌트 기반 (Part 1)
- `error.tsx`가 받는 `reset` prop → Part 4의 리셋 패턴. 단 `reset()`은 재렌더만 하므로, **원인까지 해소**하려면 `router.refresh()`와 함께 쓴다
- `loading.tsx`가 스트리밍 단위가 되는 이유 → Part 3의 SSR 스트리밍

파일 컨벤션은 설탕이고, 알맹이는 오늘 배운 두 경계다.

<!-- 클래스는 렌더 사이에 살아있는 인스턴스가 필요해, 요청당 한 번 실행되고 끝나는 서버 컴포넌트가 될 수 없다 — error.tsx에 'use client'가 필수인 이유. error.tsx를 하나도 안 두면 Next의 기본 global-error가 최후에 받는다(흰 화면은 아니지만 앱 전체가 죽는 건 같다). -->

---

## Q. 그럼 모든 useQuery를 useSuspenseQuery로 바꿔야 하나요?

아니다. 선택 기준:

| 상황                           | 선택                              |
| ------------------------------ | --------------------------------- |
| 화면 영역 전체가 데이터에 의존 | `useSuspenseQuery`                |
| 조건부 페칭 (`enabled`)이 필요 | `useQuery` — suspense 버전엔 없다 |
| 있으면 좋고 없어도 되는 데이터 | `useQuery` + 자체 분기            |
| 병렬 여러 쿼리                 | `useSuspenseQueries`              |

`useSuspenseQuery`를 한 컴포넌트에 연달아 쓰면 첫 suspend에서 **함수 실행이 멈춰** 둘째는 시작도 못 한다 — 그래서 병렬은 `useSuspenseQueries`다.

두 방식은 **한 페이지 안에서도 공존한다** — 신규 화면·독립 위젯부터 경계 단위로 넓혀가면 된다. suspend는 "이 데이터 없이는 못 그린다"는 선언이니, 아닌 데이터까지 suspend시키면 화면만 늦어진다.

---

## 더 읽을거리 — 필수

- [\<Suspense\> — React 공식](https://react.dev/reference/react/Suspense)
- [Catching rendering errors with an error boundary — React 공식](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary)
- [use — React 공식](https://react.dev/reference/react/use)
- [react-error-boundary — GitHub](https://github.com/bvaughn/react-error-boundary)

### 심화

- [Algebraic Effects for the Rest of Us — Dan Abramov](https://overreacted.io/algebraic-effects-for-the-rest-of-us/)
- [New Suspense SSR Architecture in React 18 — reactwg](https://github.com/reactwg/react-18/discussions/37)
- [What is tearing? — reactwg](https://github.com/reactwg/react-18/discussions/69)

---

## 더 읽을거리 — 내부 & React Query

### 소스 (react@19.2.0)

- [ReactFiberWorkLoop.js — handleThrow](https://github.com/facebook/react/blob/v19.2.0/packages/react-reconciler/src/ReactFiberWorkLoop.js)
- [ReactFiberThrow.js — throwException](https://github.com/facebook/react/blob/v19.2.0/packages/react-reconciler/src/ReactFiberThrow.js)
- [How Suspense works internally in Concurrent Mode — JSer.dev (react@18.2 기준)](https://jser.dev/react/2022/04/02/suspense-in-concurrent-mode-1-reconciling)
- [How does useTransition() work internally — JSer.dev](https://jser.dev/2023-05-19-how-does-usetransition-work/)

### React Query

- [Suspense — TanStack Query 공식](https://tanstack.com/query/latest/docs/framework/react/guides/suspense)
- [React Query Error Handling — tkdodo](https://tkdodo.eu/blog/react-query-error-handling)

---

# 감사합니다

<!-- _class: invert -->

@yceffort

---

# 'use cache' 디렉티브 딥다이브: 캐시 경계의 끝까지

Source: https://research.yceffort.kr/slides/use-cache-deep-dive.md

---
title: "'use cache' 디렉티브 딥다이브: 캐시 경계의 끝까지"
marp: true
paginate: true
theme: default
tags:
  - nextjs
  - caching
  - react
  - web-performance
date: 2026-05-15
description: '"use cache" 한 줄이 만드는 빌드 타임 변환, 캐시 키, cacheLife, 그리고 Cache Components까지'
published: true
post: https://yceffort.kr/2026/05/use-cache-deep-dive
art:
  undraw: cloud-files
---

# `'use cache'` 디렉티브 딥다이브 ⏱

## 시간의 경계

> 디렉티브 3부작 — Part 3 (마지막)
>
> 한 줄 뒤에 숨은 SWC 변환과 cacheHandler

---

## 오늘 다룰 것

1. `'use cache'`는 함수를 **캐시 엔트리 lookup**으로 치환한다
2. SWC가 함수를 다시 쓰고, 클로저는 bound args로 추출
3. 키는 `[buildId, id, args]` — Flight로 직렬화
4. `cacheLife` 7개 프로필, nested 룰 주의
5. `'use cache: private'`은 서버 캐시가 아니다

---

## 시작은 한 줄

```tsx
async function getProducts() {
  'use cache'
  return db.products.findMany()
}
```

이 함수는 더 이상 일반 함수가 아니다.

→ **캐시 핸들러에 키와 함께 위임되는 함수**

---

## 메모이제이션? — 아니다

| 도구                | 스코프            | 키 생성                             | 수명              |
| ------------------- | ----------------- | ----------------------------------- | ----------------- |
| `useMemo(fn, deps)` | 컴포넌트 인스턴스 | 참조 동등성                         | unmount까지       |
| `React.cache(fn)`   | 한 요청           | 참조 동등성 (Map)                   | 응답 끝나면 폐기  |
| `'use cache'`       | 빌드 + 런타임     | **인자 직렬화 + 함수 ID + buildId** | revalidate/expire |

차이의 핵심: **직렬화된 키 + 프로세스를 넘는 저장소.**

---

## 같은 입력 = 같은 출력?

`useMemo`/`React.cache`:

- 같은 객체 `{id: 1}`을 두 번 만들면 **다른 캐시**

`'use cache'`:

- 같은 의미의 `{id: 1}`이면 **같은 캐시 키**
- → 인자가 직렬화되어 해시되기 때문

---

## 새 deploy면 무효화

`'use cache'`는 캐시 키에 **buildId**를 포함한다.

- 코드 바뀌어 새 빌드 → 새 buildId → 모든 엔트리 미스
- "빌드와 빌드 사이를 안정적으로 살아남는 캐시" 모델은 아님
- **요청과 요청 사이**는 살아남는다 (환경에 따라)

---

## 언제 잘 맞는가

7가지 조건:

```text
1. 같은 입력이면 같은 출력
2. 캐시 키 cardinality가 낮음
3. 여러 사용자가 같은 결과를 봄
4. stale data가 잠깐 보여도 사고 아님
5. mutation 후 무효화 경로를 설명할 수 있음
6. 원본 연산이 비싸거나 느리거나 rate limit 있음
7. 권한·개인정보·마스킹 정책과 무관
```

3~4개만 깨져도 안 쓰는 게 맞다.

---

## 엔터프라이즈에서 잘 안 쓰는 이유

엔터프라이즈 화면은 같은 URL이라도 **결과가 다음에 따라 다름**:

- 사용자 권한, 조직, 역할
- feature flag, 계약 조건
- locale, currency, 마스킹 정책
- 방금 한 mutation 결과

→ 키 cardinality 폭발. 잘못 빼면 **권한 누수**.

> 가장 무서운 캐시 버그는 stale이 아니라, 권한 다른 사용자에게 잘못된 결과가 보이는 것.

---

## 그래도 잘 맞는 영역

- 공개 콘텐츠: 문서, FAQ, 약관, 공지
- Reference data: 국가/통화/은행 코드
- Deterministic 비싼 계산: MDX 변환, syntax highlighting
- 여러 컴포넌트가 공유하는 같은 데이터
- static shell 안의 cached island
- Tenant 단위 공통 설정
- Rate-limited 외부 API (`'use cache: remote'`)

---

## 빌드 타임 변환

SWC가 `'use cache'` 함수를 다시 쓴다 (`server_actions.rs`).

```tsx
// 원본
async function getProducts(filter: string) {
  'use cache'
  return db.products.findMany({where: {filter}})
}
```

```tsx
// 변환 후 (개념)
async function $$cache0$$([], filter) {
  return db.products.findMany({where: {filter}})
}

const getProducts = $$reactCache__('default', '<sha1-id>', 0, $$cache0$$)
```

---

## 변환의 4가지 핵심

1. **본문이 hoist** — 모듈 최상위로 끌어올려 별도 함수
2. **첫 인자는 bound args 배열** — 클로저로 잡힌 변수가 명시적 인자로
3. **함수 ID는 SHA1 hash** — 파일·export name·argument mask 등에서 파생
4. **cache_kind가 박힘** — `'default'` / `'private'` / `'remote'`

---

## 클로저는 bound args로 추출

```tsx
// 원본 — userId가 클로저로 잡힘
async function Component({userId}) {
  const getData = async (filter: string) => {
    'use cache'
    return fetch(`/api/users/${userId}?filter=${filter}`)
  }
  return getData('active')
}
```

```tsx
// 변환 후 — userId가 명시적 인자로 추출됨
async function $$cache0$$([userId], filter) {
  /* 본문 */
}
const getData = $$reactCache__('default', '<id>', 1, $$cache0$$, userId)
//                                                 └─ bound count
```

→ `userId`가 자동으로 캐시 키에 들어감.

---

## 함수 ID는 secure hash

- 함수의 위치(파일명) + export/reference name + argument mask
- SHA1로 묶어서 **secure hash** 생성
- 같은 함수라도 파일이 바뀌면 다른 ID

> 단, deploy 단위의 전체 무효화는 함수 ID가 아니라 **buildId**가 담당한다.

---

## 3가지 변형

| 변형                   | 서버 저장                    | 클라이언트 저장 | request API 직접 접근 | 공유 범위        |
| ---------------------- | ---------------------------- | --------------- | --------------------- | ---------------- |
| `'use cache'`          | in-memory 또는 cache handler | router prefetch | 불가                  | 모든 사용자 공유 |
| `'use cache: remote'`  | remote cache handler         | router prefetch | 불가                  | 모든 사용자 공유 |
| `'use cache: private'` | **저장 안 함**               | 브라우저 메모리 | **가능**              | 클라이언트 본인  |

---

## private의 함정

`'use cache: private'`은 **서버 캐시가 아니다**.

> "results are never stored on the server, they're cached only in the browser's memory and do not persist across page reloads" — 공식 문서

- 매 서버 렌더마다 함수가 실행됨
- static shell에서도 제외
- custom cache handler 설정 불가
- v16 시점 experimental

---

## remote의 함정

`'use cache: remote'`는 **공유 캐시**가 핵심 가치.

쓰는 게 좋은 경우:

- Rate-limited API
- Slow backend
- 비싼 연산
- Flaky service

피해야 하는 경우:

- 50ms 미만 빠른 연산
- key가 거의 unique
- **seconds~minutes 단위로 자주 바뀌는 데이터**
- 이미 KV store 앞단이 있음

---

## 런타임: 캐시 래퍼

빌드 타임에 박힌 `$$reactCache__`는 결국 `cache()` 함수를 가리킨다 (`use-cache-wrapper.ts`).

```text
1. 인자 직렬화 → encodeReply (Flight)
2. 캐시 키 구성 → [buildId, id, args, hmrHash?]
3. ResumeDataCache(RDC) 조회
4. (없으면) cacheHandler 조회
5. (없으면) 본문 실행 → 저장
6. SWR: 만료됐으면 백그라운드 재생성
```

---

## 캐시 키

```ts
const cacheKeyParts = hmrRefreshHash
  ? [buildId, id, args, hmrRefreshHash]
  : [buildId, id, args]
```

- `buildId`: deploy마다 자동 무효화
- `id`: 함수 위치·시그니처 hash
- `args`: 인자 배열 (bound + 호출 인자)
- `hmrRefreshHash`: dev에서 HMR로 자동 무효화

---

## 인자 직렬화: encodeReply

`'use server'`와 같은 Flight 직렬화기.

**가능:** 원시값, 일반 객체/배열, `Date`, `Map`, `Set`, `BigInt`, `TypedArray`, `FormData`, React element (pass-through)

**불가능:** 클래스 인스턴스, 일반 함수, `Symbol`, `URL`

> 인자(엄격) vs 리턴값(JSX 허용)의 직렬화기는 **다르다**.

---

## 두 단계 lookup

```ts
// 1단계: RDC (페이지 렌더 단위)
const cached = lookupResumeDataCache(rdc, key)
if (cached) return cached

// 2단계: cacheHandler
const entry = await cacheHandler.get(key)
if (entry && !shouldDiscard(entry)) return entry

// 3단계: 둘 다 미스 → 본문 실행
return generateCacheEntry(...)
```

`'use cache: private'`은 이 흐름을 거의 안 탄다.

---

## Stale-while-revalidate

```ts
const age = Date.now() - entry.timestamp

if (age < entry.revalidate * 1000) {
  return entry  // fresh
}
if (age < entry.expire * 1000) {
  void generateCacheEntry({skipPropagation: true})  // 백그라운드
  return entry  // stale 응답
}
return await generateCacheEntry(...)  // expired — blocking
```

---

## cacheLife — 7개 빌트인

| 프로필    | stale | revalidate | expire |
| --------- | ----- | ---------- | ------ |
| `default` | 5분   | 15분       | 무한   |
| `seconds` | 30초  | 1초        | 1분    |
| `minutes` | 5분   | 1분        | 1시간  |
| `hours`   | 5분   | 1시간      | 1일    |
| `days`    | 5분   | 1일        | 1주    |
| `weeks`   | 5분   | 1주        | 30일   |
| `max`     | 5분   | 30일       | 1년    |

---

## stale은 모두 5분?

**예. 의도적이다.**

- `stale`은 클라이언트 라우터의 prefetch 캐시 유지 시간
- 사용자 네비게이션 패턴에 맞춘 값
- 서버 데이터 갱신 빈도(`revalidate`)와 별개
- 30초 미만은 강제로 30초로 올라감 (prefetch 안전장치)

---

## 인라인 프로필

```tsx
async function getOffer() {
  'use cache'
  cacheLife({
    stale: 60, // 1분
    revalidate: 300, // 5분
    expire: 3600, // 1시간
  })
  return db.offers.findFirst()
}
```

`expire`는 반드시 `revalidate`보다 커야 함.

---

## Nested cacheLife — 명시 outer 우선

```tsx
async function Dashboard() {
  'use cache'
  cacheLife('hours') // 명시 → 1시간

  return <Widget /> // Widget이 'minutes'(5분)이라도 무관
}
```

- 외부 캐시 hit 시 **내부까지 통째로 응답**
- 외부 1시간 동안 내부 lifetime은 관측되지 않음

---

## Nested — 명시 없는 outer는 깎인다

```tsx
async function Dashboard() {
  'use cache'
  // cacheLife 없음 → default (15분)

  return <Widget /> // Widget이 'minutes'(5분) → Dashboard도 5분
}
```

- 명시 없으면 inner의 짧은 lifetime이 propagation
- 반대(내부가 길다고 외부 늘리기)는 일어나지 않음

> 실무 권장: **모든 캐시 함수에 `cacheLife` 명시.**

---

## 짧은 nested cache는 prerender 에러

`revalidate`이 5분 미만이면 dynamic hole이 됨.

- `seconds` 프로필 자동 dynamic
- 외부에 명시 없는 nested에서 짧은 cache → 빌드 에러
- 해결:
  - outer를 명시적으로 길게: `cacheLife('default')`
  - outer를 명시적으로 짧게 + Suspense

---

## cacheTag

```tsx
async function getProduct(id: string) {
  'use cache'
  cacheTag('products', `product-${id}`)
  return db.products.findUnique({where: {id}})
}
```

- 한 엔트리에 여러 태그 가능
- 같은 태그 두 번 = 한 번 (idempotent)
- 1 태그 최대 256자, 1 엔트리 최대 128개

---

## revalidateTag — 두 번째 인자가 핵심!

| 호출                              | 의미                                     |
| --------------------------------- | ---------------------------------------- |
| `revalidateTag(tag, 'max')`       | **권장**, SWR (stale-while-revalidate)   |
| `revalidateTag(tag, {expire: 0})` | webhook용 즉시 만료                      |
| `revalidateTag(tag)`              | **deprecated**, 즉시 만료 + blocking     |
| `updateTag(tag)`                  | Server Action 전용, read-your-own-writes |

---

## 단일 인자는 SWR이 아니다 ⚠️

```tsx
// ❌ 이름과 달리 SWR 아님 — 즉시 만료 + blocking
revalidateTag('posts')

// ✅ SWR
revalidateTag('posts', 'max')

// ✅ webhook용 즉시 만료
revalidateTag('posts', {expire: 0})
```

> 공식 문서: "The single-argument form is deprecated."

---

## updateTag — read-your-own-writes

```tsx
'use server'
export async function createPost(formData: FormData) {
  const post = await db.posts.create({data: formData})
  updateTag('posts') // 즉시 폐기
  updateTag(`post-${post.id}`)
  redirect(`/posts/${post.id}`) // 사용자는 자기 변경을 본다
}
```

Server Action 전용. mutation 직후 SWR로 stale을 보여주면 운영 사고가 나는 화면에 쓴다.

---

## 런타임 API 제약

`'use cache'`는 `cookies()`/`headers()`/`searchParams` **직접 호출 금지**.

```tsx
async function CachedProfile() {
  'use cache'
  const session = (await cookies()).get('session')?.value // ❌ Error
}
```

이유: 캐시는 요청 간 공유 → request-scoped 데이터 들어가면 권한 누수.

---

## 우회 1 — 인자로 추출 (권장)

```tsx
async function ProfilePage() {
  const session = (await cookies()).get('session')?.value
  return <CachedProfile sessionId={session} />
}

async function CachedProfile({sessionId}: {sessionId: string}) {
  'use cache'
  // sessionId가 자동으로 캐시 키에 들어감
  return <div>{await fetchProfile(sessionId)}</div>
}
```

---

## React.cache 격리

```tsx
const store = cache(() => ({current: null}))

function Parent() {
  const shared = store()
  shared.current = 'value'
  return <Child />
}

async function Child() {
  'use cache'
  const shared = store() // null! 외부 store 안 보임
}
```

`'use cache'` 안에서는 별도의 `React.cache` 스코프. **자기 인자만으로 결정 가능**해야 한다는 원칙.

---

## Cache Components & PPR

`cacheComponents: true` 한 줄이 켜는 모델 — 한 페이지가 3 영역으로:

```tsx
export default function Page() {
  return (
    <>
      <header>Static — 빌드 타임 prerender</header>
      <Stats /> {/* Cached — 'use cache' */}
      <Suspense fallback={<Skeleton />}>
        <Notifications /> {/* Dynamic — request 타임 stream */}
      </Suspense>
    </>
  )
}
```

---

## 함정 1: 빌드 hang (50초)

```tsx
async function Cached({promise}) {
  'use cache'
  const data = await promise // request-scoped Promise면 빌드 hang
}
```

prerender 중 캐시 함수가 영원히 resolve 안 되는 Promise를 await하면 50초 timeout.

해결: `cookies()`/`headers()`는 외부에서 **값**을 추출한 뒤 props로 넘기기.

---

## 함정 2: Math.random은 한 번만

```tsx
async function getId() {
  'use cache'
  return crypto.randomUUID() // 캐시 미스 때만 실행 → 모두 같은 값
}
```

요청별로 다른 값 필요하면 캐시 밖으로:

```tsx
import {connection} from 'next/server'

async function DynamicContent() {
  await connection() // dynamic 강제
  return <div>{crypto.randomUUID()}</div>
}
```

---

## 함정 3: 미지원 환경

- **Edge runtime 미지원** — Node.js 전용 모듈에 의존
- **Static export 미지원** — 런타임 cacheHandler 필요
- **클래스 인스턴스, URL 직렬화 불가** — plain object로 변환

---

## unstable_cache → use cache 마이그레이션

```tsx
// 이전
const getCachedUser = unstable_cache(
  async (id) => getUser(id),
  ['my-app-user'],
  {tags: ['users'], revalidate: 60},
)

// 이후
async function getCachedUser(id: string) {
  'use cache'
  cacheTag('users')
  cacheLife({revalidate: 60})
  return getUser(id)
}
```

단, **drop-in replacement는 아니다.** Cache Components 모델로의 전환.

---

## 디버깅

```bash
NEXT_PRIVATE_DEBUG_CACHE=1 next dev
```

- 콘솔에 캐시 hit/miss와 키 출력
- dev에서 캐시 함수의 `console.log`는 `Cache` prefix → replay 표시
- 응답 헤더 `x-nextjs-stale-time`이 클라이언트 stale time

---

## 'use client' / 'use server' / 'use cache'

| 디렉티브       | 변환 대상 | 박히는 ID       | 경계                    |
| -------------- | --------- | --------------- | ----------------------- |
| `'use client'` | 모듈      | 모듈 ID + chunk | RSC → Client (참조)     |
| `'use server'` | 함수      | SHA1 함수 ID    | Client → Server (RPC)   |
| `'use cache'`  | 함수      | SHA1 함수 ID    | Caller → Cache (lookup) |

세 디렉티브 모두 같은 Flight 직렬화기 공유.

---

## 핵심 정리

1. **캐시 lookup으로 치환** — useMemo/React.cache와 다른 키 도메인·수명
2. **SWC 변환** — 본문 hoist + 클로저 bound args 추출 + SHA1 ID
3. **3가지 변형** — `'use cache'` (공유) / `remote` (외부 핸들러) / `private` (브라우저 메모리)
4. **cacheLife는 항상 명시** — nested 룰의 함정 회피
5. **revalidateTag(tag, 'max')만 SWR** — 단일 인자는 deprecated
6. **엔터프라이즈는 좁은 영역만** — 권한 누수 vs hit rate

---

## 시리즈 — 마무리

> 디렉티브 3부작 완결 🎉
>
> ✅ Part 1: `'use client'` — 클라이언트 경계 (rendering layer)
> ✅ Part 2: `'use server'` — 서버 경계 (process)
> ✅ Part 3: `'use cache'` (이번 편) — 캐시 경계 (시간)

세 디렉티브 모두 — **ID + 직렬화 + 메타데이터** 패턴으로 시스템 경계를 넘는다.

---

## 한 문장으로

> `'use cache'`는 **업무 데이터 캐시 도구**가 아니다.
>
> **공유 가능하고 변동성이 낮은 서버 결과**를 static shell 또는 runtime cache에 편입시키는 도구다.

핵심 질문은 **"이 데이터를 캐시할 수 있나?"가 아니라 "이 결과를 공유해도 되는가?"**

---

## 참고

- 블로그 원문: [`'use cache'` 디렉티브 딥다이브](https://yceffort.kr/2026/05/use-cache-deep-dive)
- [Next.js: use cache](https://nextjs.org/docs/app/api-reference/directives/use-cache)
- [Next.js: cacheLife](https://nextjs.org/docs/app/api-reference/functions/cacheLife)
- [Next.js: revalidateTag](https://nextjs.org/docs/app/api-reference/functions/revalidateTag)
- Next.js v16.2.4: `packages/next/src/server/use-cache/`

---

# 'use client' 디렉티브 딥다이브: 클라이언트 경계의 끝까지

Source: https://research.yceffort.kr/slides/use-client-deep-dive.md

---
title: "'use client' 디렉티브 딥다이브: 클라이언트 경계의 끝까지"
marp: true
paginate: true
theme: default
tags:
  - react
  - nextjs
  - bundler
date: 2026-05-15
description: "'use client' 한 줄이 만드는 모듈 경계, 빌드 타임 변환, Flight 직렬화"
published: true
post: https://yceffort.kr/2026/05/use-client-deep-dive
art:
  undraw: responsive
---

# `'use client'` 디렉티브 딥다이브 🚪

## 클라이언트 경계의 끝까지

> 디렉티브 3부작 — Part 1
>
> 한 줄이 만드는 빌드 타임 변환과 Flight 직렬화

---

## 오늘 다룰 것

1. `'use client'`는 **모듈 그래프의 진입점 마커**다
2. 빌드 타임에 같은 모듈이 layer별로 다르게 컴파일된다
3. RSC server는 본문 대신 **참조(reference)** 만 직렬화한다
4. 클라이언트는 `$L` 토큰을 보고 chunk를 lazy load한다
5. SSR과 RSC는 같은 컴포넌트를 다른 트리로 빌드한다

---

## 시작은 한 줄

```tsx
'use client'

import {useState} from 'react'

export function Counter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(count + 1)}>{count}</button>
}
```

이 한 줄이 어떤 일을 하는가? — "클라이언트에서 동작" 표시?

> ❌ 정확히는 **"이 모듈이 클라이언트 번들 그래프의 진입점이다"** 라는 마커다.

---

## 흔한 오해 ❌

> "`'use client'` 적은 컴포넌트만 클라이언트가 되고, import한 컴포넌트는 따로 표시해야 한다"

**정확히 반대다.**

`'use client'`는 **모듈 그래프의 단방향 경계의 시작점**이다. 한 모듈에 적으면 import하는 모든 모듈이 자동으로 클라이언트 번들에 포함된다.

---

## 단방향 경계

```text
app/page.tsx           ← Server Component (default)
  └─ import Layout     ← Server Component
       └─ import Counter   ← 'use client' (경계!)
            └─ import { format } from './utils'
                 └─ import lodash    ← 자동으로 클라이언트
```

- `'use client'` 모듈은 서버 모듈을 **import 못 함**
- 단, `children` prop으로 Server Component를 **받는** 것은 가능

---

## children prop의 비밀

```tsx
// page.tsx (Server)
import {ClientShell} from './ClientShell'
import {ServerContent} from './ServerContent'

export default function Page() {
  return (
    <ClientShell>
      <ServerContent /> {/* OK — props로 전달 */}
    </ClientShell>
  )
}
```

- `ClientShell`은 `ServerContent`를 **import 안 함**
- Server `Page`가 두 컴포넌트를 합치고 children으로 넘김
- import는 단방향, **prop은 양방향**

---

## 빌드 타임 변환

`'use client'`는 런타임 효과가 없다. 진짜 일은 **빌드 타임**에 일어난다.

같은 파일이 webpack의 여러 layer에서 **다른 형태**로 컴파일된다.

| Layer      | 결과물             |
| ---------- | ------------------ |
| RSC server | 본문이 사라진 stub |
| Client     | 원본 그대로        |
| SSR        | 원본 그대로        |

---

## RSC 서버 번들: 본문 제거

```tsx
// 원본
'use client'
export function Counter({initial}) {
  const [count, setCount] = useState(initial)
  return <button onClick={...}>{count}</button>
}
```

→ 빌드 후 RSC 번들에서는:

```js
import {registerClientReference} from 'react-server-dom-webpack/server'

export const Counter = registerClientReference(
  function () {
    throw new Error('Cannot call Counter() from server.')
  },
  'components/Counter.tsx',
  'Counter',
)
```

---

## 세 가지 핵심

1. **함수 본문이 사라졌다** — `useState`, JSX, 이벤트 핸들러는 RSC 번들에 한 글자도 안 들어간다
2. **stub에 메타데이터가 박힌다** — `$$typeof`, `$$id`, `$$async`
3. **stub을 호출하면 에러** — 서버에서 함수처럼 부르면 명확한 에러를 던진다

---

## registerClientReference의 본체

```js
// react-server-dom-webpack/.../ReactFlightWebpackReferences.js
export function registerClientReference(proxyImplementation, id, exportName) {
  return Object.defineProperties(proxyImplementation, {
    $$typeof: {value: CLIENT_REFERENCE_TAG},
    $$id: {value: id + '#' + exportName},
    $$async: {value: false},
  })
}
```

`Object.defineProperties`로 stub에 메타데이터를 박는 게 끝이다.

---

## CJS 모듈은 Proxy로

ESM은 export 단위로 stub을 만들지만, CJS는 모듈 통째로 Proxy화한다.

```js
export function createClientModuleProxy(moduleId) {
  const cache = Object.create(null)
  return new Proxy(
    {},
    {
      get(_, name) {
        return (cache[name] ??= registerClientReference(
          () => {
            throw new Error('...')
          },
          moduleId,
          name,
        ))
      },
    },
  )
}
```

어떤 export를 접근하든 그 자리에서 client reference를 만들어낸다.

---

## flight-client-entry-plugin

webpack 빌드 단계에서 Next.js가 추가하는 플러그인.

- `'use client'` 모듈을 발견하면 **자동으로 클라이언트 entry 추가**
- chunk를 만들고 `client-reference-manifest.json`을 emit
- 이 매니페스트가 `$$id` → chunk URL 매핑의 진실의 원천

---

## 클라이언트 매니페스트의 모양

```json
{
  "components/Counter.tsx#Counter": {
    "id": "5234",
    "chunks": ["app/static/chunks/123.js"],
    "name": "Counter",
    "async": false
  }
}
```

- `id`: webpack 모듈 ID
- `chunks`: 로드할 chunk URL들
- `name`: 모듈 안의 export 이름
- 클라이언트가 이걸 보고 `__webpack_require__`로 모듈을 resolve

---

## 직렬화: serializeClientReference

RSC 서버가 client reference를 만나면, 매니페스트에서 메타데이터를 lookup해 Flight payload에 메타 행을 끼워 넣는다.

```js
// 단순화
function serializeClientReference(request, parent, key, ref) {
  const meta = request.clientManifest[ref.$$id]
  const importId = outlineModel(request, meta)
  return parent[0] === REACT_ELEMENT_TYPE && key === '1'
    ? '$L' + importId.toString(16) // element type 자리
    : '$' + importId.toString(16) // 그 외
}
```

위치에 따라 `$L<hex>` 또는 `$<hex>` 토큰이 나온다.

---

## $L vs $ 토큰

| 위치                               | 토큰      | 의미                                        |
| ---------------------------------- | --------- | ------------------------------------------- |
| element의 `type` 슬롯              | `$L<hex>` | **lazy** — 클라이언트가 React.lazy로 만든다 |
| 그 외 (props, children 안의 값 등) | `$<hex>`  | 일반 import metadata 참조                   |

`$L`이 핵심이다. 이게 React가 컴포넌트로서 lazy하게 들고 있을 표시.

---

## Flight payload 안의 모양

```text
1:I["app/static/chunks/123.js","Counter"]
2:["$","div",null,{"children":["$","$L1",null,{"initial":0}]}]
```

- `1:`는 import metadata 행
- `2:`의 `$L1`이 1번 모듈을 lazy 컴포넌트로 가리킨다
- 클라이언트는 이 stream을 받아 트리를 복원

---

## 클라이언트 측: $L → React.lazy

```js
function parseModelString(response, parentObject, key, value) {
  if (value[0] === '$') {
    if (value[1] === 'L') {
      const id = parseInt(value.slice(2), 16)
      return createLazyChunkWrapper(getOutlinedModel(response, id))
    }
    // ...
  }
}
```

`$L`을 만나면 `React.lazy` 노드를 만들어 그 자리에 끼운다.

---

## chunk 로딩

```js
function preloadModule(metadata) {
  const chunks = metadata[1]
  const promises = []
  for (let i = 0; i < chunks.length; i++) {
    const chunkId = chunks[i]
    if (chunkCache.has(chunkId)) continue // 한 번 받으면 끝
    chunkCache.set(chunkId, __webpack_chunk_load__(chunkId))
    promises.push(chunkCache.get(chunkId))
  }
  return Promise.all(promises)
}
```

`chunkCache`로 dedup. 같은 chunk를 두 번 fetch하지 않는다.

---

## 두 개의 트리: SSR vs RSC

`'use client'`는 SSR도 막지 않는다. 같은 컴포넌트가 **세 곳**에서 빌드된다.

```text
                ┌─► RSC server bundle ──(stub)──► Flight Payload
                │                                       │
   같은 모듈 ───┼─► SSR bundle ────────────────► SSR이 HTML 생성
                │                                       ▲
                └─► Client bundle ────────(hydrate)─────┘
```

---

## ssrModuleMapping

매니페스트에는 RSC payload를 SSR이 소비할 때 쓸 모듈 ID 매핑도 들어있다.

```json
{
  "ssrModuleMapping": {
    "5234": { "id": "ssr-build-7821", ... }
  }
}
```

같은 컴포넌트가 SSR build에서는 다른 ID를 갖기 때문에 변환 테이블이 필요하다.

---

## props 직렬화

`'use client'` 컴포넌트의 props는 매번 RSC payload에 들어간다.

**가능:** 원시값, 일반 객체, 배열, `Date`, `Map`, `Set`, `BigInt`, `TypedArray`, React element
**불가능:** 클래스 인스턴스, 일반 함수 (Server Action은 OK), `Symbol`, `URL`

---

## children prop의 비밀

```tsx
<ClientShell>
  <ServerContent />
</ClientShell>
```

- `<ServerContent />`는 RSC가 먼저 렌더링한 결과
- 그 결과(JSX 트리)가 `children` prop으로 직렬화되어 클라이언트에 전달
- 클라이언트는 그걸 그대로 트리에 끼움
- → **Server 컴포넌트의 출력이 Client 컴포넌트의 children 자리에 들어가는 패턴**

---

## 주의 1: leaf로 미루기

```tsx
// ❌ 페이지 전체를 클라이언트로 끌어내림
'use client'
export default function Page() { ... }

// ✅ 인터랙션 필요한 부분만
export default function Page() {
  return (
    <article>
      <Content />
      <LikeButton /> {/* 이것만 'use client' */}
    </article>
  )
}
```

`'use client'` 위치가 클라이언트 번들 크기를 결정한다.

---

## 주의 2: barrel 파일은 위험

```tsx
// components/index.ts
'use client'
export * from './Button'
export * from './Modal'
export * from './Chart' // 큰 라이브러리 의존
```

barrel에 `'use client'`를 적으면 한 컴포넌트만 써도 **전체가 클라이언트 번들에 들어간다**.

해결: barrel을 쪼개거나, `'use client'`를 개별 파일로 옮긴다.

---

## 주의 3: 서버 코드 import 금지

```tsx
'use client'

import {db} from '@/lib/db' // ❌ 서버 전용
import {SECRET} from '@/lib/env' // ❌ 환경 변수 노출 위험
```

빌드 시점에 에러가 나거나, 더 나쁘게는 **클라이언트 번들에 비밀 정보가 노출**된다.

server-only 패키지를 쓰면 빌드 시점에 강제로 막을 수 있다.

---

## 주의 4: props는 매 RSC payload에 들어간다

```tsx
<HeavyClientComponent
  data={largeArray} // 매번 직렬화
  options={config}
/>
```

- 큰 데이터는 props 대신 클라이언트에서 fetch 권장
- 또는 server-side에서 미리 가공해 작은 형태로 넘기기

---

## 주의 5: Context는 Provider가 클라이언트일 때만

```tsx
// ThemeProvider.tsx
'use client'
export const ThemeContext = createContext()
export function ThemeProvider({children}) {
  return <ThemeContext.Provider value={...}>{children}</ThemeContext.Provider>
}
```

Provider 자체가 `'use client'`여야 동작. 서버 컴포넌트에서는 Context 못 읽는다.

---

## 주의 6: Date/Map/Set은 그대로 넘어간다

```tsx
// Server Component
const now = new Date()
const tags = new Set(['a', 'b'])
return <ClientView createdAt={now} tags={tags} />
```

Flight 직렬화기가 `Date`, `Map`, `Set`을 알고 있어 그대로 복원된다. **JSON.stringify의 한계를 넘는다.**

---

## 성능 — 1: dynamic import

```tsx
'use client'
import dynamic from 'next/dynamic'

const HeavyChart = dynamic(() => import('./HeavyChart'), {
  loading: () => <Skeleton />,
})
```

초기 페이로드에서 분리 → 화면에 보일 때만 chunk 로드.

---

## 성능 — 2: Suspense boundary

```tsx
<Suspense fallback={<Skeleton />}>
  <SlowClientComponent />
</Suspense>
```

- streaming 가시성 ↑
- TTFB 단축, 사용자가 빈 화면을 덜 본다

---

## 성능 — 3: chunk 공유

webpack이 자동으로 처리하므로, **억지로 분리하지 말고 공통 dependency가 자연스럽게 한 chunk에 모이게 두자**.

너무 잘게 쪼개면 HTTP 요청 수만 늘어난다.

---

## Turbopack에서는 무엇이 다른가

같은 점:

- `'use client'` 처리 모델 동일
- 같은 매니페스트 형식
- 같은 Flight 직렬화

다른 점:

- 빌드 트랜스폼이 Rust로 구현됨
- dev 매니페스트가 on-demand로 발행
- chunk URL 형식 약간 다름

**결과물은 사실상 같다.** 이 글의 webpack 인용을 Turbopack으로 읽어도 의미는 통한다.

---

## 'use client' vs 'use server'

|               | `'use client'`        | `'use server'`        |
| ------------- | --------------------- | --------------------- |
| 변환 대상     | 모듈                  | 함수                  |
| 박히는 ID     | 모듈 ID + chunk       | SHA1 함수 ID          |
| 직렬화되는 것 | props                 | 인자 + 결과           |
| 경계의 의미   | RSC → Client (참조로) | Client → Server (RPC) |

---

## 전체 아키텍처

```text
'use client' module
       │
       ▼
┌──────────────────────┐
│  RSC layer: stub     │  ←─ 본문 제거, 메타데이터만
└──────────┬───────────┘
           │  serializeClientReference
           ▼
   Flight payload ($L token)
           │
           ▼
┌──────────────────────┐
│  Client: parseModel  │  ←─ React.lazy 노드
└──────────┬───────────┘
           │  preloadModule
           ▼
   chunk load + chunkCache
           │
           ▼
      실제 컴포넌트
```

---

## 핵심 정리

1. **'use client'는 진입점 마커** — 모듈 그래프의 단방향 경계 시작점
2. **레이어별 다른 빌드** — RSC stub / Client 원본 / SSR 원본
3. **참조의 직렬화** — `Object.defineProperties`로 stub에 메타데이터
4. **`$L` 토큰** — Flight stream의 lazy 표시
5. **chunk dedup** — `chunkCache`로 한 번 받으면 끝
6. **props는 매번 직렬화** — payload 다이어트가 성능

---

## 시리즈 — 다음 편

> 디렉티브 3부작
>
> ✅ Part 1: `'use client'` (이번 편)
> ▶️ **Part 2: `'use server'` — 클라이언트 → 서버 RPC**
> ⏭ Part 3: `'use cache'` — 시간의 경계

서버 → 클라이언트 방향(이번 편)을 이해했다면, 다음은 **반대 방향**의 RPC를 본다.

---

## 참고

- 블로그 원문: [`'use client'` 디렉티브 딥다이브](https://yceffort.kr/2026/05/use-client-deep-dive)
- React v19.2 source: `react-server-dom-webpack/`
- Next.js v16.2 source: `packages/next/src/build/webpack/loaders/next-flight-loader/`
- Overreacted: [What Does 'use client' Do?](https://overreacted.io/what-does-use-client-do/)

---

# useEffect — 안 쓰는 게 최선이다

Source: https://research.yceffort.kr/slides/useEffect-race-condition-stale-closure.md

---
title: useEffect — 안 쓰는 게 최선이다
marp: true
paginate: true
theme: yceffort
tags:
  - react
  - async
date: 2026-04-18
description: '주니어를 졸업하려는 개발자를 위한 useEffect 딥다이브 — 안티패턴, race condition, stale closure, event vs effect, tearing, Suspense까지'
published: true
art:
  undraw: buggy-code
---

# useEffect — 안 쓰는 게 최선이다

안티패턴, race condition, stale closure — 그리고 그 너머

<!-- _class: invert -->

@yceffort

---

## 이 강의에서 다루는 것

1. useEffect의 원래 목적
2. **안 써야 하는 경우** — 안티패턴
3. **Race Condition** — 기본 + optimistic update
4. **Stale Closure** — Reactive vs Latest 값
5. **Event vs Effect** — useEffectEvent의 철학
6. **실행 타이밍 & Tearing**
7. **Suspense · `use()` · 커스텀 훅 설계**

---

## 이 강의의 메시지

> `useEffect`는 **탈출구**(escape hatch).

React의 기본 모델은 "state/props를 선언하면 화면이 따라온다"는 선언적 세계. `useEffect`는 이 세계를 벗어나 **명령형으로 외부(DOM, 네트워크, 타이머…)를 건드려야 할 때** 쓰는 비상구다.

필요 없는데 쓰고 있다면 **원인 불명의 버그**가 숨어 있을 가능성이 높다.

그게 이 강의가 다루는 모든 것들이다.

---

## Part 0 — useEffect란 무엇인가

---

## 공식 정의

> **Effects let you synchronize a component with some external system.**

"외부 시스템":

- 브라우저 API (DOM, timer, observer)
- 네트워크 (fetch, WebSocket)
- 서드파티 라이브러리 (차트, 지도)

> 한 줄: **React 바깥과 맞추는 것.**

---

## 실행 타이밍 (기본)

```text
1. render  — 컴포넌트 함수 실행, 가상 트리 계산 (순수해야 함)
2. commit  — React가 실제 DOM에 반영
3. paint   — 브라우저가 화면에 그림
4. effect  — useEffect 콜백 실행
```

- **render는 순수해야 해서 사이드이펙트 금지.** 외부 동기화는 commit 뒤(= effect)로 미뤄진다.
- Effect 안에서 state를 바꾸면 **리렌더가 한 번 더** 일어난다. (세부 타이밍은 Part 5)

---

## 동기화 사이클

```jsx
useEffect(() => {
  const sub = subscribe() // 시작
  return () => sub.unsubscribe() // 정리
}, [deps])
```

- 처음 마운트
- deps가 바뀔 때마다 **(정리 → 시작)**
- 언마운트 시 정리

**시작과 정리가 짝을 이룬다** — 이게 Effect의 정신.

---

## Part 1 — 안 쓰는 게 최선이다

---

## 안티패턴 4가지

| 상황               | 대안                     |
| ------------------ | ------------------------ |
| props → state 복사 | props 그대로 사용        |
| 파생 state 계산    | 렌더 중 계산 / `useMemo` |
| 이벤트 반응        | 이벤트 핸들러            |
| 자식 → 부모 동기화 | state lifting            |

공통 원칙:

- **계산 가능한 값은 state가 아니다**
- **"사용자가 X했기 때문에"라면 Effect가 아니라 핸들러**

---

## 대표 사례: 파생 state

```jsx
// ❌ effect로 갱신 — 렌더가 한 번 더
function TodoList({todos, filter}) {
  const [visible, setVisible] = useState([])
  useEffect(() => {
    setVisible(todos.filter((t) => t.status === filter))
  }, [todos, filter])
  return <List items={visible} />
}

// ✅ 렌더 중 계산
function TodoList({todos, filter}) {
  const visible = todos.filter((t) => t.status === filter)
  return <List items={visible} />
}
```

---

## 대표 사례: 이벤트 반응

```jsx
// ❌ isBought state → effect로 알림
useEffect(() => {
  if (isBought) {
    showNotification(`${product.name} 구매 완료!`)
  }
}, [isBought])

// ✅ 핸들러에서 바로
const handleBuy = () => {
  showNotification(`${product.name} 구매 완료!`)
}
```

**"왜 이 알림이 떴는가"** 를 코드만 봐도 알 수 있어야 한다.

---

## 판별 플로우차트

```text
렌더 중 계산 가능?     → 렌더 중 (Effect ❌)
사용자 액션 반응?       → 이벤트 핸들러 (Effect ❌)
prop으로 state 동기화?  → state lifting (Effect ❌)
React 바깥과 동기화?    → useEffect ✅
```

이 플로우를 통과해야 비로소 Effect의 정당한 사용이다.

---

## 그럼에도 써야 하는 경우

| 상황                           | Effect? | 비고                                     |
| ------------------------------ | ------- | ---------------------------------------- |
| 브라우저 API (title, observer) | ✅      | cleanup 필수                             |
| 서드파티 라이브러리            | ✅      | 인스턴스 생성/파괴                       |
| 외부 스토어 구독               | △       | `useSyncExternalStore` 우선 (Part 5)     |
| 타이머                         | ✅      | stale closure 주의 (Part 3)              |
| 네트워크                       | △       | Server Component / `use()` 우선 (Part 6) |

---

## Part 2 — Race Condition

---

## 상황 재현

```jsx
useEffect(() => {
  fetch(`/search?q=${query}`)
    .then((r) => r.json())
    .then(setResults)
}, [query])
```

`a` → `ab` 빠르게 입력했을 때:

```text
시간 ──────────────────────────────▶
 a   ├──── fetch "a"  (3초) ──────┤ 응답 도착
 ab    ├── fetch "ab" (1초) ─┤ 응답 도착
화면                         "ab결과" → "a결과" ✗
```

두 요청이 **동시에 살아있고**, 늦게 도착한 `a` 응답이 `ab`의 결과를 덮어쓴다.

---

## 기본 해결: AbortController

```jsx
useEffect(() => {
  const controller = new AbortController()
  fetch(`/search?q=${query}`, {signal: controller.signal})
    .then((r) => r.json())
    .then(setResults)
    .catch((err) => {
      if (err.name !== 'AbortError') throw err
    })
  return () => controller.abort() // deps 바뀌면 이전 요청 취소
}, [query])
```

- `signal`은 fetch에게 넘기는 **취소 리모컨**. `controller.abort()` → fetch가 `AbortError`로 reject.
- cleanup에서 abort → 이전 `query`의 요청이 멈추므로 **덮어쓸 응답 자체가 도착하지 않는다**.
- 부가 효과: 서버 부하 감소, 모바일 데이터 절약.

---

## 한 발 더: 데이터 페칭 라이브러리

```jsx
// TanStack Query
const {data} = useQuery({
  queryKey: ['search', query],
  queryFn: ({signal}) =>
    fetch(`/search?q=${query}`, {signal}).then((r) => r.json()),
})
```

내부적으로는 `queryKey`마다 **AbortController + promise identity check**를 자동으로 걸어준다. 앞의 손으로 짠 패턴을 key 단위로 정규화한 것.

→ 요청 취소, 중복 제거, 캐싱, 재시도까지 한 번에.

---

## 더 미묘한 케이스 1 — Optimistic update

```jsx
// 좋아요: 즉시 UI 반영 → 서버 검증 → 실패 시 롤백
async function toggleLike(postId) {
  setLiked(true) // ① 낙관적 반영
  try {
    await api.like(postId) // ② 서버 요청
  } catch {
    setLiked(false) // ③ 롤백
  }
}
```

빠르게 두 번 눌렀을 때 **②가 엉켜서** 최종 상태가 서버와 어긋날 수 있다.

---

## Optimistic update 해결: 요청에 번호 붙이기

```jsx
// 모듈 스코프 — 렌더마다 초기화되지 않는다 (컴포넌트 안이면 useRef로)
let latestId = 0

async function toggleLike(postId) {
  const myId = ++latestId // 내 요청의 번호표
  setLiked(true)
  try {
    await api.like(postId)
  } catch {
    if (myId === latestId) setLiked(false) // 내가 마지막일 때만 롤백
  }
}
```

아이디어는 AbortController와 같다. **"내가 최신 요청인가"를 확인한 뒤 UI에 반영.** AbortController는 요청 자체를 중단시키고, 이 패턴은 결과가 돌아와도 무시한다.

> race condition은 fetch만의 문제가 아니다. **비동기 결과가 UI 상태를 덮어쓰는 모든 지점**에서 발생한다.

---

## Part 3 — Stale Closure

---

## 먼저 — 클로저 1분 복습

**클로저**: 함수가 선언될 때 주변 스코프의 변수를 **함께 기억**하는 JS의 기본 메커니즘.

```js
function makeCounter() {
  let count = 0
  return () => ++count // ← 바깥의 count를 기억
}
const tick = makeCounter()
tick() // 1
tick() // 2  ← 같은 count를 공유
```

`tick`은 `makeCounter`가 이미 끝났는데도 **그때의 `count` 변수**를 움켜쥐고 산다.

> 함수 = 코드 + 주변 변수의 스냅샷.

---

## Stale closure — 오래된 스냅샷

React 컴포넌트는 state가 바뀔 때마다 **함수 자체가 다시 실행**된다. 즉, 렌더할 때마다 새 클로저가 만들어진다.

하지만 **이전 렌더에서 만들어진 함수**(setInterval 콜백, 이벤트 핸들러, Effect 안의 함수 등)가 아직 살아 있다면, 그 함수는 **과거 렌더의 값**을 여전히 움켜쥐고 있다.

> **Stale closure** = 과거 렌더의 state/props를 캡처한 채 지금도 돌아가는 함수.

모든 React 버그의 흔한 원인 중 하나. 아래 데모가 대표 사례다.

---

## 데모: 카운트가 안 올라간다

```jsx
function Counter() {
  const [count, setCount] = useState(0)
  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1) // 이 count는 "첫 렌더의 0"
    }, 1000)
    return () => clearInterval(id)
  }, [])
  return <div>{count}</div>
}
```

화면은 `0` → `1` 에서 멈춘다. 왜?

---

## 한 단계씩 — 왜 멈추는가

1. **첫 렌더**: `Counter()` 실행 → 이때 `count = 0` → `setInterval`에 콜백 `() => setCount(count + 1)` 등록. 이 콜백은 **JS 클로저로 "그때의 count = 0"을 캡처**한다.
2. 1초 뒤 콜백 실행 → `setCount(0 + 1)` → 리렌더.
3. **두 번째 렌더**: `Counter()`가 다시 실행되고 새로운 `count = 1`이 생기지만, **deps가 `[]`라 Effect는 재실행되지 않는다** → 첫 렌더의 interval 콜백이 그대로 살아 있음.
4. 다음 tick에서도 `setCount(0 + 1)` → 화면 `1`에서 고정.

```text
첫 렌더 → count=0 ─┐
두 번째 렌더 → count=1 (새) │  첫 렌더의 interval 콜백은
세 번째 렌더 → count=1 ──┘  여전히 "count=0"을 움켜쥠
```

---

## 핵심: 매 렌더는 스냅샷

> Dan Abramov: **Each render has its own props, state, effects, and event handlers.**
> ([_A Complete Guide to useEffect_](https://overreacted.io/a-complete-guide-to-useeffect/))

렌더는 `Counter()` 함수가 **다시 실행**되는 것. 매 렌더마다 새로운 `count`, 새로운 클로저가 만들어지지만, 이전 렌더가 만들어둔 함수(interval 콜백, 이벤트 핸들러 등)는 **그 시점의 값을 움켜쥔 채 살아남는다**.

이게 stale closure — 오래된 스냅샷을 보는 함수.

---

## 해결 2종

```jsx
// 1. functional update — "최신 state"를 인자로 받음 (기본)
useEffect(() => {
  const id = setInterval(() => setCount((prev) => prev + 1), 1000)
  return () => clearInterval(id)
}, [])

// 2. ref — 값을 "렌더 바깥 저장소"에 두고 최신 값 읽기
useEffect(() => {
  countRef.current = count
})
useEffect(() => {
  const id = setInterval(() => setCount(countRef.current + 1), 1000)
  return () => clearInterval(id)
}, [])
```

1번이 기본. ref(2번)는 매 렌더가 **같은 객체**를 공유하므로 `.current`로 항상 최신 값을 읽을 수 있다 — state 여러 개를 함께 참조해야 할 때 유용.

---

## 여기서 멈추지 않는다

대부분의 가이드는 "해결 3종" 에서 끝난다. 하지만 진짜 질문은:

> **어떤 값은 deps에 넣고, 어떤 값은 넣지 말아야 하는가?**

이게 Part 4의 주제다.

---

## Part 4 — Reactive vs Latest

---

## 두 종류의 값

Effect 안에서 참조하는 값은 두 부류:

**Reactive value** — 바뀌면 effect를 **다시 실행해야** 하는 값

- 예: `roomId` — 방이 바뀌면 WebSocket을 끊고 **재연결**해야 함

**Latest value** — 값은 최신으로 읽되 **재실행은 안 해야** 하는 값

- 예: `isMuted` — 메시지가 올 때 음소거 여부에 따라 알림음을 낼지 결정. 하지만 **음소거를 토글했다고 연결을 다시 맺을 이유는 없음**.

---

## 기존 방식으로는 분리 불가

```jsx
useEffect(() => {
  const conn = connect(roomId)
  conn.on('message', (msg) => {
    if (!isMuted) playSound() // isMuted도 참조
  })
  return () => conn.disconnect()
}, [roomId, isMuted]) // ← isMuted 넣으면 토글할 때마다 재연결
// isMuted 빼면 → stale closure (과거의 isMuted를 봄)
```

ref로 우회는 가능하지만 수동이고 실수 쉽다.

---

## useEffectEvent

```jsx
// 2026-04 현재 experimental. 실제 import:
// import {experimental_useEffectEvent as useEffectEvent} from 'react'
import {useEffectEvent} from 'react'

const onMessage = useEffectEvent((msg) => {
  if (!isMuted) playSound() // 호출될 때마다 최신 isMuted를 읽음
})

useEffect(() => {
  const conn = connect(roomId)
  conn.on('message', onMessage)
  return () => conn.disconnect()
}, [roomId]) // ✅ isMuted는 deps에 없지만 최신값이 반영됨
```

`useEffectEvent`는 **"reactive가 아닌 값을 읽는 창구"**. 내부 구현은 ref와 비슷하지만 ESLint deps 규칙이 이를 인식해서 "deps 빠졌다"는 경고를 내지 않으므로, 수동 ref보다 안전하다.

---

## 판단 기준

> **"이 값이 바뀌면, 내 effect는 처음부터 다시 시작돼야 하는가?"**

- **YES** → reactive → deps에 포함
- **NO** → latest → `useEffectEvent`로 읽기

참고: [Separating Events from Effects — React 공식](https://react.dev/learn/separating-events-from-effects)

---

## Setup / Cleanup은 쌍을 이뤄야 한다

Effect는 "한 번 실행되는 사이드이펙트"가 아니라 **"유지되는 연결"**. setup이 시작한 것은 cleanup이 정확히 되돌려야 한다.

```jsx
// ❌ setup은 구독을 만드는데 cleanup이 없음 → 리소스 누적
useEffect(() => {
  conn.on('msg', handler)
  // return이 없음
}, [])

// ✅ setup 1회 = cleanup 1회, 완벽한 대칭
useEffect(() => {
  conn.on('msg', handler)
  return () => conn.off('msg', handler)
}, [])
```

대칭이 깨지는 가장 흔한 실수: 구독은 등록했는데 해제는 안 하기, 타이머는 시작했는데 clear는 안 하기.

---

## Strict Mode가 강제하는 규율

```text
dev에서만: mount → unmount → mount (의도적으로 두 번)
```

개발 모드에서 React는 일부러 Effect를 한 번 더 돌려서 **setup/cleanup 쌍이 제대로 맞춰져 있는지** 드러낸다. 여기서 잡히는 버그:

- cleanup 없는 구독 → 두 번째 mount에서 리스너가 이중으로 붙음
- 네트워크 요청의 race condition → 같은 요청이 두 번 날아가 가시화됨
- setup이 모듈 레벨의 상태를 건드리는데 cleanup이 복구 안 함 → 두 번째 mount가 깨짐

프로덕션은 한 번이지만 개발 중 이중 실행은 **대칭이 무너지는 지점을 드러내는 장치**다.

---

## Part 5 — 실행 타이밍 & Tearing

---

## 세 가지 Effect 훅

```text
render → [insertion effect] → DOM mutation → [layout effect] → paint → [effect]
```

| 훅                   | 타이밍                   | 용도                       |
| -------------------- | ------------------------ | -------------------------- |
| `useInsertionEffect` | DOM mutation **전**      | CSS-in-JS 스타일 주입      |
| `useLayoutEffect`    | DOM mutation 후 paint 전 | 레이아웃 측정 → state 반영 |
| `useEffect`          | paint **후**             | 대부분의 사이드이펙트      |

셋 다 commit 단계에 속하지만, **DOM 변경 전 / 변경 후 paint 전 / paint 후**로 세분화된 것.

---

## useLayoutEffect — 언제 필요한가

```jsx
function Tooltip({target}) {
  const [pos, setPos] = useState({top: 0, left: 0})
  const ref = useRef(null)

  useLayoutEffect(() => {
    const rect = target.getBoundingClientRect()
    const h = ref.current.offsetHeight
    setPos({top: rect.top - h, left: rect.left})
  }, [target])

  return (
    <div ref={ref} style={pos}>
      ...
    </div>
  )
}
```

`useEffect`면 → 잘못된 위치로 paint → 재계산 → **깜빡임**. layout effect는 paint 전 동기 실행.

---

## useInsertionEffect — CSS-in-JS의 해결사

```jsx
// styled-components 같은 라이브러리 내부
useInsertionEffect(() => {
  injectStyleSheet(rules) // commit 전에 DOM에 있어야
}, [rules])
```

왜 있는가:

- `useLayoutEffect`에서 스타일 주입하면 자식들이 **스타일 없이 측정**됨
- `useInsertionEffect`는 **DOM mutation 직전**에 실행 → 측정 시 스타일이 이미 있음

실무에서 직접 쓸 일은 거의 없다. **"왜 존재하는지" 이해**가 목적.

---

## Tearing — 동시성 모드의 숨은 적

**전제**: React 18부터 렌더를 여러 조각으로 쪼개 실행할 수 있다 (concurrent rendering). 즉, **한 번의 렌더 패스 중간에 React가 잠시 멈췄다가 이어 그릴 수 있다**.

```text
1. 외부 스토어 값 = 10
2. 컴포넌트 A 렌더 → 10 읽음
3. React가 양보 (yield) — 다른 급한 일 처리
4. 외부 스토어 값 = 20  ← 이 사이에 변경됨
5. 이어서 컴포넌트 B 렌더 → 20 읽음
6. 같은 화면에 A=10, B=20  ← tearing (찢어짐)
```

`useState + useEffect`로 외부 값을 구독하면 렌더 도중 값이 바뀌는 걸 막을 방법이 없어 발생 가능.

---

## 잠깐 — "외부 세계" vs "외부 스토어"

- **외부 세계** — React 밖 전부 (DOM, fetch, 타이머 등). → `useEffect`의 영역
- **외부 스토어** — 외부 세계 중 ① 동기 스냅샷 ② 변경 알림(pub/sub) 을 모두 만족하는 데이터 원천

| 대상                                | 외부 스토어? |
| ----------------------------------- | ------------ |
| Redux / Zustand / Jotai             | ✅           |
| `navigator.onLine`, `matchMedia(…)` | ✅           |
| fetch 응답, `document.title` 쓰기   | ❌ (일회성)  |
| setInterval                         | ❌           |

**외부 스토어 구독**만 `useSyncExternalStore`. 나머지는 `useEffect`.

---

## useSyncExternalStore가 푸는 방식

```jsx
const value = useSyncExternalStore(
  subscribe, // "값이 바뀌면 알려줘" — 변화 알림 채널
  getSnapshot, // "지금 값이 뭐지?" — 동기적으로 현재값 반환
  getServerSnapshot, // SSR 초기값
)
```

왜 함수가 두 개로 쪼개져 있나:

- **변화를 감지하는 것**(pub/sub)과 **현재 값을 읽는 것**은 다른 일. 분리돼야 React가 렌더 도중 원할 때 "지금 값"을 확인할 수 있다.
- `getSnapshot()`을 **동기적으로** 호출 → 같은 렌더 패스 안에서는 항상 같은 값 → tearing 없음.
- 렌더 도중 값이 바뀌면 React가 자동으로 다시 렌더.

Zustand, Jotai, Redux 최신 버전이 내부적으로 사용.

---

## 실사용 예시

```jsx
// navigator.onLine
const isOnline = useSyncExternalStore(
  (cb) => {
    window.addEventListener('online', cb)
    window.addEventListener('offline', cb)
    return () => {
      window.removeEventListener('online', cb)
      window.removeEventListener('offline', cb)
    }
  },
  () => navigator.onLine,
  () => true,
)
```

> **읽기 전용 외부 값을 구독**하는 모든 케이스 → 이걸로.

---

## Part 6 — Suspense · use() · 커스텀 훅

---

## render-as-you-fetch vs fetch-on-render

```text
기존 (fetch-on-render):
  렌더 → mount → useEffect fetch → 로딩 상태
  [부모] → [자식 mount → fetch] → [손자 mount → fetch]  ← waterfall

모던 (render-as-you-fetch):
  렌더 시작과 동시에 fetch → Suspense로 대기
  [부모 렌더 순간 트리 전체의 fetch 트리거]
```

핵심 차이: **waterfall 제거**.

---

## use() hook

```jsx
function UserProfile({userPromise, postsPromise}) {
  const user = use(userPromise) // pending이면 컴포넌트 일시중단
  const posts = use(postsPromise)
  return <Layout user={user} posts={posts} />
}

;<Suspense fallback={<Spinner />}>
  <UserProfile
    userPromise={fetchUser(id)} // 병렬 시작
    postsPromise={fetchPosts(id)}
  />
</Suspense>
```

**동작 원리**: promise가 pending이면 `use()`가 내부적으로 그 promise를 **throw** → 가장 가까운 `<Suspense>`가 캐치해 fallback 표시 → promise가 resolve되면 React가 컴포넌트를 다시 실행. **useEffect도, useState도 없음**, 로딩/에러는 Suspense / ErrorBoundary가 담당.

---

## 커스텀 훅 — 이펙트를 감춰라

```jsx
// ❌ 사용자가 effect를 관리
function useChat(roomId) {
  return {roomId, setMessages: ...}
}
// 사용처에서 useEffect로 subscribe 직접 호출...

// ✅ Effect를 훅 안에 캡슐화
function useChat(roomId) {
  const [messages, setMessages] = useState([])
  useEffect(() => {
    const conn = connect(roomId)
    conn.on('msg', (m) => setMessages((prev) => [...prev, m]))
    return () => conn.disconnect()
  }, [roomId])
  return messages
}
```

---

## 커스텀 훅 설계 원칙

1. **동기화는 훅 안에서 끝내라** — `useEffect`를 훅 내부에서 실행하지 않고, "호출자가 `useEffect`에 넣을 setup 함수"를 반환하는 식으로 설계하지 말 것. 타이밍과 cleanup이 호출자에게 새어나가 의존성 배열이 꼬인다
2. **객체 props는 destructure** — `useHook({a, b})` 받으면 내부에서 꺼내서 각각 deps에
3. **한 훅 = 한 관심사** — 독립된 동기화는 여러 훅으로
4. **cleanup 경계를 명확히** — 훅이 제공한 리소스는 훅의 cleanup에서 회수

참고: [Reusing Logic with Custom Hooks — React 공식](https://react.dev/learn/reusing-logic-with-custom-hooks)

---

## Part 7 — 체크리스트

---

## useEffect 의사결정 순서

```text
1. React 바깥과 동기화? 아니면 → Effect 아님
2. 외부 스토어 구독? → useSyncExternalStore
3. 데이터 페칭? → Server Component / use() / React Query
4. state/props 참조? → reactive vs latest 구분
                       latest는 useEffectEvent
5. cleanup이 idempotent? Strict Mode로 검증?
6. 레이아웃 측정? → useLayoutEffect
7. 한 Effect = 한 동기화. 독립된 건 분리.
```

---

## 한 줄 요약

> useEffect는 도구다. 진짜 실력은 **쓰지 않아도 되는 구조를 만드는 것**,
> 써야 할 때는 **reactive · latest · cleanup** 을 구분하는 것이다.

<!-- _class: invert -->

---

## Q&A

<!-- _class: invert -->

---

## Q. Effect 안에서 async 함수를 바로 쓸 수 없나요?

`useEffect`는 반환값으로 cleanup을 기대하는데 `async`는 Promise를 반환한다.

```jsx
// ❌ 직접 async 불가
useEffect(async () => { /* ... */ }, [])

// ✅ 내부에 async 선언
useEffect(() => {
  const run = async () => { setData(await fetch(...)) }
  run()
}, [])
```

이렇게 해도 race condition은 그대로 — Part 2 해결책과 함께.

---

## Q. deps 배열 ESLint 경고, 그냥 끄면 안 되나요?

대부분 **stale closure 버그를 심는 행위**. 경고가 뜨는 진짜 원인은 보통 둘 중 하나:

- 함수를 Effect 바깥에서 만들고 있다 → Effect 안으로 / `useCallback`
- 최신 state를 읽고 싶다 → `useEffectEvent`

> 비활성화보다 구조를 고치는 쪽을 먼저.

---

## Q. useSyncExternalStore vs useEffect?

| 구분                  | useEffect                | useSyncExternalStore |
| --------------------- | ------------------------ | -------------------- |
| 외부 스토어 구독      | tearing 위험             | ✅ 권장              |
| 브라우저 API 1회 설정 | ✅ (`document.title`)    | 부적합               |
| 네트워크 fetch        | 가능 (라이브러리가 나음) | 부적합               |
| 동시성 안전           | ❌                       | ✅                   |

**읽기 전용 외부 값 구독** → `useSyncExternalStore`, **부수 효과 + cleanup** → `useEffect`.

---

## Q. React Compiler가 나오면 이 내용도 사라지나요?

Compiler는 `useMemo` / `useCallback` 을 자동화하지만 **Effect 관련은 대부분 유효**:

- 안티패턴 (파생 state, 이벤트 로직) — 컴파일러가 고쳐주지 않음
- race condition — 설계 문제, 컴파일러 영역 밖
- reactive vs latest — `useEffectEvent`가 공식 해결책이지 컴파일러가 아님
- tearing — `useSyncExternalStore`가 답

---

## Q. 독립된 동기화를 한 Effect에 묶어도 되나요?

```jsx
// ❌ title과 WebSocket이 같이 묶임
useEffect(() => {
  document.title = `${user.name} - 마이페이지`
  const ws = new WebSocket(`/chat/${chatId}`)
  return () => ws.close()
}, [user.name, chatId])
```

`user.name`이 바뀌면 **WebSocket까지 재연결**됨.

> **각 Effect는 하나의 독립된 동기화 프로세스.**

---

## 독립 동기화는 Effect 쪼개기

```jsx
useEffect(() => {
  document.title = `${user.name} - 마이페이지`
}, [user.name])

useEffect(() => {
  const ws = new WebSocket(`/chat/${chatId}`)
  return () => ws.close()
}, [chatId])
```

Effect를 나누는 건 **성능 최적화가 아니라 정확성**의 문제.

---

## Q. `use(fetchUser(id))` — 매 렌더마다 새 promise 아닌가요?

맞다. 그대로 쓰면 **렌더마다 새 요청**이 나간다.

```jsx
// ❌ 렌더마다 fetchUser 재호출 → 무한 Suspense
function UserProfile({id}) {
  const user = use(fetchUser(id))
  return <div>{user.name}</div>
}
```

해결은 **promise를 렌더 바깥에서 안정시키는 것** — 다음 슬라이드에서.

---

## `use()` — promise를 안정시키는 두 가지 방법

```jsx
// ✅ Server Component — 요청당 한 번만 실행
async function Page({id}) {
  const userPromise = fetchUser(id)
  return <UserProfile userPromise={userPromise} />
}

// ✅ React.cache — 같은 인자면 같은 promise 반환
const getUser = cache((id) => fetch(`/api/user/${id}`).then((r) => r.json()))
```

> `use()`는 promise를 **기다리는 창구**일 뿐. promise를 **언제 만들지**는 우리가 책임져야 한다.

---

## Q. Server Component가 Effect를 대체한다는 게?

과거:

```jsx
function Page() {
  const [data, setData] = useState(null)
  useEffect(() => {
    fetch('/api/posts')
      .then((r) => r.json())
      .then(setData)
  }, [])
  if (!data) return <Spinner />
  return <Posts data={data} />
}
```

---

## Server Component 패턴

```jsx
// 'use client' 없음 = Server Component.
// 서버에서 한 번 실행되고, 결과 HTML(+직렬화 데이터)만 브라우저로 내려간다.
// 브라우저에서는 다시 렌더되지 않음.
async function Page() {
  const data = await fetchPosts() // 서버에서 바로 await
  return <Posts data={data} />
}
```

- **Effect 없음, useState 없음, 로딩 상태 없음** — 렌더 전에 이미 데이터가 있음
- race condition 원천 차단 — 서버에서 딱 한 번 실행
- 번들 사이즈도 감소 — 서버 전용 코드는 클라이언트로 안 내려감

> 데이터 페칭에 한해서는 **Effect 없는 세계**가 이미 와 있다.

---

## 더 읽을거리 — 필수

- [You Might Not Need an Effect — React 공식](https://react.dev/learn/you-might-not-need-an-effect)
- [A Complete Guide to useEffect — Dan Abramov](https://overreacted.io/a-complete-guide-to-useeffect/)
- [Separating Events from Effects — React 공식](https://react.dev/learn/separating-events-from-effects)

### 심화

- [Hooks, Dependencies and Stale Closures — tkdodo](https://tkdodo.eu/blog/hooks-dependencies-and-stale-closures)
- [Simplifying useEffect — tkdodo](https://tkdodo.eu/blog/simplifying-use-effect)

---

## 더 읽을거리 — 최신 & 도구

### React 19 이후

- [useEffectEvent (React 19.2) — LogRocket](https://blog.logrocket.com/react-useeffectevent/)
- [useSyncExternalStore Demystified — Kent C. Dodds](https://www.epicreact.dev/use-sync-external-store-demystified-for-practical-react-development-w5ac0)

### 도구

- [eslint-plugin-react-you-might-not-need-an-effect](https://www.npmjs.com/package/eslint-plugin-react-you-might-not-need-an-effect)
- [Reusing Logic with Custom Hooks — React 공식](https://react.dev/learn/reusing-logic-with-custom-hooks)

---

# 감사합니다

<!-- _class: invert -->

@yceffort

---

# 리액트 렌더링 최적화 가이드

Source: https://research.yceffort.kr/slides/react-rendering-optimization-guide.md

---
title: 리액트 렌더링 최적화 가이드
marp: true
paginate: true
theme: yceffort
tags:
  - react
  - web-performance
date: 2026-04-13
description: '객체 참조, memo, useMemo, useCallback, 그리고 구조적 최적화까지 — 실무에서 바로 쓰는 렌더링 최적화 판단 기준'
published: true
art:
  undraw: performance-comparison
---

# 리액트 렌더링 최적화 가이드

객체 참조, memo, 그리고 "언제 최적화할 것인가"

<!-- _class: invert -->

@yceffort

---

## 이 강의에서 다루는 것

1. 리렌더링은 **언제** 일어나는가
2. 객체 props의 함정
3. 의존성 배열과 객체 — 훅/컴포넌트 만드는 사람이 알아야 할 것
4. `React.memo` 제대로 쓰기
5. `useMemo` / `useCallback`
6. memo 없이 구조로 해결하기
7. React DevTools Profiler로 측정하기

---

## React Compiler 이후의 최적화 지도

React Compiler는 `useMemo` / `useCallback` / `React.memo`를 **자동 삽입**해준다. 그럼 뭐가 남는가?

**자동화되는 영역**: Part 3 (`React.memo`) · Part 4 (`useMemo` / `useCallback`)

**컴파일러 이후에도 남는 영역**

- Part 0 — 리렌더링 원리 (디버깅의 토대)
- Part 2 — 훅 deps 설계 (라이브러리 API 설계)
- Part 5 — 구조적 최적화 (컴파일러 무관)
- Part 6 — 측정

> 컴파일러는 "어떻게 메모할까"를 대신해준다. "어디서부터 렌더가 새어 나오는가"는 여전히 우리가 안다.

---

## Part 0 — 리렌더링은 언제 일어나는가

---

## 리렌더링 = 함수를 다시 실행하는 것

함수형 컴포넌트는 **그냥 함수**다.

```jsx
function Greeting({name}) {
  const message = `안녕하세요, ${name}님`
  return <p>{message}</p>
}
```

리렌더링이 일어나면, React는 이 함수를 **처음부터 다시 호출**한다. `message`도 다시 만들어지고, JSX도 다시 만들어진다. 매번.

> 리렌더링이 많다 = 이 함수가 많이 호출된다.
> 리렌더링이 비싸다 = 이 함수 안에서 하는 일이 많다.

이걸 기억하고 있으면 뒤에 나오는 모든 최적화가 **"이 함수를 덜 호출하거나, 호출됐을 때 할 일을 줄이는 것"**으로 귀결된다.

---

## 흔한 오해

> "props가 바뀌어서 리렌더링되는 거 아닌가요?"

**반쯤 맞고 반쯤 틀리다.** 부모가 리렌더되면 props가 바뀐 것처럼 보이지만, 순서가 거꾸로다.

리렌더링의 실제 트리거는 딱 세 가지:

1. **`setState`** 호출
2. **부모 컴포넌트**가 리렌더링됨
3. **Context** 값이 변경됨

부모가 리렌더되면, 자식은 **props가 같든 다르든** 일단 함수가 다시 호출된다. React는 기본적으로 props를 비교하지 않는다 — 비교를 시키려면 `React.memo`를 명시적으로 걸어야 한다.

---

## 직접 보기

```jsx
function Parent() {
  const [count, setCount] = useState(0)
  console.log('Parent render')
  return (
    <>
      <button onClick={() => setCount((c) => c + 1)}>+</button>
      <Child />
    </>
  )
}

function Child() {
  console.log('Child render') // 매번 찍힘
  return <div>나는 props도 없는데?</div>
}
```

버튼 클릭할 때마다 `Child render`가 찍힌다. `Child`는 props가 없는데도.

---

## 왜 이렇게 동작하나

React의 렌더링은 **트리 탐색**이다.

1. `setState` → 해당 컴포넌트부터 시작
2. JSX를 실행하면서 자식 컴포넌트 함수를 **전부 호출**
3. 반환된 ReactElement 트리를 이전 fiber와 비교 (reconciliation)
4. 차이가 있는 부분만 DOM에 반영 (commit)

2번에서 이미 자식 함수가 호출된다. props 비교는 **기본적으로 하지 않는다.** 비교를 시키려면 명시적으로 `React.memo`를 써야 한다.

---

## 이게 왜 중요한가 — 실시간 데이터

증권 HTS, 코인 거래소, 실시간 대시보드 같은 도메인을 생각해보자.

```text
웹소켓으로 호가 데이터 수신 → setState → 리렌더
```

이게 **초당 수십~수백 회** 일어난다. 컴포넌트 트리가 깊고 자식이 많으면, 한 번의 setState가 수백 개의 함수를 다시 호출하는 셈이다.

일반적인 CRUD 앱에서는 리렌더 최적화가 "있으면 좋은 것"이지만, 이런 도메인에서는 **안 하면 프레임이 뚝뚝 끊기는 것**이다.

---

## Part 1 — 객체 props의 함정

---

## 이 코드의 문제는?

```jsx
function UserCard({user}) {
  return (
    <Card
      style={{padding: 16, border: '1px solid #eee'}}
      config={{showAvatar: true, size: 'large'}}
    >
      {user.name}
    </Card>
  )
}
```

`Card`가 `React.memo`로 감싸져 있어도, **매 렌더마다 리렌더된다.**

---

## `Object.is`와 참조 비교

`React.memo`는 props를 **얕은 비교**한다. 얕은 비교의 핵심은 `Object.is`.

```js
// 매 렌더마다 새 객체가 만들어짐
{ padding: 16 } === { padding: 16 } // false
Object.is({ padding: 16 }, { padding: 16 }) // false

// 같은 참조면 통과
const style = { padding: 16 }
Object.is(style, style) // true
```

인라인 객체 `{}`는 함수가 호출될 때마다 **새 참조**다. 내용이 같아도 `Object.is`는 `false`.

---

## 인라인 배열도 마찬가지

```jsx
// ❌ 매 렌더마다 새 배열
<Select options={['apple', 'banana', 'cherry']} />

// ❌ 매 렌더마다 새 배열
<TagList tags={user.hobbies.filter(h => h.active)} />
```

`[]` 리터럴, `.filter()`, `.map()`, `.slice()` — 전부 새 참조를 만든다.

---

## 해결: 상수 추출

값이 변하지 않는 객체/배열은 **컴포넌트 바깥으로** 빼면 된다.

```jsx
// ✅ 모듈 레벨 상수 — 참조가 절대 안 바뀜
const cardStyle = {padding: 16, border: '1px solid #eee'}
const cardConfig = {showAvatar: true, size: 'large'}

function UserCard({user}) {
  return (
    <Card style={cardStyle} config={cardConfig}>
      {user.name}
    </Card>
  )
}
```

비용: 0. 코드도 더 깔끔해진다. 이건 최적화가 아니라 **좋은 습관**이다.

---

## 해결: 동적 값은 `useMemo`

props나 state에 따라 달라지는 객체는 `useMemo`로 참조를 안정시킨다.

```jsx
function UserCard({user, isCompact}) {
  const cardStyle = useMemo(
    () => ({
      padding: isCompact ? 8 : 16,
      border: '1px solid #eee',
    }),
    [isCompact],
  )

  return <Card style={cardStyle}>{user.name}</Card>
}
```

`isCompact`가 안 바뀌면 같은 참조. `Card`가 memo되어 있다면 리렌더 스킵.

---

## Part 2 — 의존성 배열과 객체

---

## deps에 객체를 넣으면 생기는 일

```tsx
interface AnalyticsConfig {
  event: string
  category: string
  label: string
}

function useAnalytics(config: AnalyticsConfig) {
  // ❌ config 객체의 참조가 바뀔 때마다 실행
  useEffect(() => {
    track(config.event, config.category, config.label)
  }, [config])
}

// 사용하는 쪽
useAnalytics({event: 'click', category: 'button', label: 'submit'})
// 매 렌더마다 새 객체 → effect 매번 실행
```

---

## 해결: destructuring으로 primitive만 의존

```tsx
function useAnalytics({event, category, label}: AnalyticsConfig) {
  // ✅ primitive만 deps에
  useEffect(() => {
    track(event, category, label)
  }, [event, category, label])
}
```

객체를 받더라도, **필요한 필드를 꺼내서** primitive 단위로 deps에 넣는다.

---

## 훅/재사용 컴포넌트를 만드는 사람의 책임

핵심 원칙:

> **내 훅을 쓰는 사람이 참조 안정한 객체를 넘겨줄 거라고 가정하지 말 것.**

```jsx
// ❌ 호출자가 안정적인 options를 넘겨주길 기대
function useSearch(options) {
  useEffect(() => {
    fetch(`/api?q=${options.query}&limit=${options.limit}`)
  }, [options]) // 호출자가 매번 새 객체를 넘기면 무한 fetch
}

// ✅ 방어적으로 destructuring
function useSearch({query, limit}) {
  useEffect(() => {
    fetch(`/api?q=${query}&limit=${limit}`)
  }, [query, limit]) // primitive라서 안전
}
```

---

## 원칙 정리

```text
객체를 받는다 → destructure해서 primitive만 deps에
```

이렇게 하면:

- 호출자가 인라인 객체를 넘겨도 안전
- deps 비교가 예측 가능
- 불필요한 effect/memo 재실행 방지

이건 "최적화"가 아니라, **라이브러리/훅 설계의 기본**이다.

---

## Part 3 — `React.memo` 제대로 쓰기

---

## `React.memo`가 하는 일

```jsx
const MemoizedChild = React.memo(function Child({name, age}) {
  console.log('Child render')
  return (
    <div>
      {name}, {age}
    </div>
  )
})
```

부모가 리렌더될 때:

1. 이전 props와 새 props를 **얕은 비교** (`Object.is`로 각 prop)
2. 전부 같으면 → 함수 호출 스킵, 이전 결과 재사용
3. 하나라도 다르면 → 정상 리렌더

---

## memo가 무력화되는 케이스들

```jsx
function Parent() {
  return (
    <MemoizedChild
      // ❌ 인라인 객체 → 매번 새 참조
      style={{color: 'red'}}
      // ❌ 인라인 함수 → 매번 새 참조
      onClick={() => console.log('click')}
      // ❌ JSX children → 매번 새 ReactElement
      header={<Icon name="user" />}
    />
  )
}
```

이 세 가지 중 **하나라도** 있으면, memo는 매번 비교만 하고 결국 리렌더한다. 비교 비용만 추가되는 셈.

---

## memo를 살리려면

```jsx
const style = {color: 'red'} // 상수 추출

function Parent() {
  const handleClick = useCallback(() => {
    console.log('click')
  }, [])

  const header = useMemo(() => <Icon name="user" />, [])

  return <MemoizedChild style={style} onClick={handleClick} header={header} />
}
```

memo + `useCallback` + `useMemo`가 **세트**로 움직여야 효과가 있다.

---

## 책임 소재: 훅 deps vs memo props

|            | 방어하는 곳           | 방법                               |
| ---------- | --------------------- | ---------------------------------- |
| 훅 deps    | **받는 쪽** (훅 내부) | destructuring → primitive만 deps에 |
| memo props | **넘기는 쪽** (부모)  | 상수 추출 / useMemo / useCallback  |

Card가 내부적으로 아무리 잘 만들어져 있어도, 부모가 `style={{ ... }}`을 인라인으로 넘기면 memo는 매번 뚫린다. **memo의 비교는 Card 함수가 실행되기 전에 일어나니까.**

훅은 받는 쪽이 방어할 수 있지만, memo는 넘기는 쪽이 책임져야 한다.

---

## 언제 memo를 걸어야 하나

걸어야 할 때:

- 리스트의 각 아이템 (`<TodoItem />` × 100)
- 부모가 자주 리렌더되는데, 자식의 렌더링 비용이 비쌀 때
- 이미 Profiler로 불필요한 리렌더를 **확인한** 후

안 걸어도 되는 경우:

- props가 매번 바뀌는 컴포넌트 (비교만 낭비)
- 렌더 비용이 극히 낮은 leaf 컴포넌트
- 자식이 없거나 적은 컴포넌트

---

## Part 4 — `useMemo` / `useCallback`

---

## `useMemo`의 두 가지 용도

```jsx
// 용도 1: 비싼 계산 캐싱
const sorted = useMemo(() => items.sort((a, b) => a.score - b.score), [items])

// 용도 2: 참조 안정성
const filters = useMemo(() => ({status: 'active', role}), [role])
```

용도 1은 직관적이다. 용도 2는 **memo된 자식에 넘기거나, deps에 쓰일 때** 의미가 있다.

---

## `useCallback` — 함수 참조 안정성

```jsx
// ❌ 매 렌더마다 새 함수
<MemoizedList onItemClick={(id) => selectItem(id)} />

// ✅ 참조 유지
const handleItemClick = useCallback(
  (id) => selectItem(id),
  [selectItem],
)
<MemoizedList onItemClick={handleItemClick} />
```

`useCallback`은 `useMemo(() => fn, deps)`의 축약형이다. **memo된 자식에 함수를 넘길 때** 짝으로 쓴다.

---

## 그러면 전부 다 감싸야 하나?

```jsx
// 이렇게 하는 사람 많음
function Form() {
  const name = useMemo(() => 'hello', []) // 🤦 문자열에 useMemo
  const len = useMemo(() => name.length, [name]) // 🤦 사칙연산에 useMemo
  const log = useCallback(() => {
    // 🤦 아무 데도 안 넘기는 함수
    console.log(len)
  }, [len])
  // ...
}
```

`useMemo`/`useCallback` 자체도 비용이 있다:

- deps 배열 생성 + 이전 deps와 비교
- 클로저가 이전 값을 참조하고 있으면 GC 지연

---

## 판단 기준

```text
"이걸 안 쓰면 뭐가 깨지는가?"
```

| 상황                                        | 필요한가? |
| ------------------------------------------- | --------- |
| memo된 자식에 넘기는 객체/함수              | ✅ 필요   |
| deps 배열에 쓰이는 객체                     | ✅ 필요   |
| 배열 정렬/필터, 트리 탐색 등 O(n) 이상 연산 | ✅ 필요   |
| 어디에도 안 넘기는 로컬 변수                | ❌ 불필요 |
| primitive 값                                | ❌ 불필요 |
| 매 렌더마다 어차피 deps가 바뀌는 경우       | ❌ 무의미 |

---

## "비용 0인 좋은 습관" vs "코드 복잡도를 올리는 최적화"

이 강의에서 계속 반복되는 기준이 하나 있다.

> **비용이 거의 0이고 코드도 깔끔해지면 — 그건 최적화가 아니라 좋은 습관이다.**

- 인라인 객체를 상수로 빼기 → 비용 0, 코드 더 깨끗 → **그냥 하는 것**
- 훅에서 객체 destructuring → 비용 0, API가 더 견고 → **그냥 하는 것**
- 리스트 아이템에 memo + useCallback → 약간의 코드 추가, 하지만 효과 명확 → **하는 것**
- 모든 변수에 useMemo → 코드 복잡도 ↑, 효과 불확실 → **하지 않는 것**

판단이 어려우면? **하는 쪽으로 기울어져도 괜찮다.** 다만 그건 측정으로 검증할 것.

---

## Part 5 — 구조로 해결하기

---

## memo 없이도 리렌더 범위를 줄일 수 있다

여기서부터는 `memo`, `useMemo`, `useCallback` 없이 **컴포넌트 구조만으로** 불필요한 리렌더를 제거하는 패턴들.

---

## 패턴 1: State 내리기

```jsx
// ❌ 전체 트리가 리렌더
function Page() {
  const [search, setSearch] = useState('')
  return (
    <>
      <input value={search} onChange={(e) => setSearch(e.target.value)} />
      <HeavyList /> {/* search와 무관한데 매번 리렌더 */}
      <HeavyChart /> {/* search와 무관한데 매번 리렌더 */}
    </>
  )
}
```

---

## State 내리기 — After

```jsx
// ✅ state를 사용하는 곳으로 이동
function SearchInput() {
  const [search, setSearch] = useState('')
  return <input value={search} onChange={(e) => setSearch(e.target.value)} />
}

function Page() {
  return (
    <>
      <SearchInput />
      <HeavyList /> {/* 리렌더 안 됨 */}
      <HeavyChart /> {/* 리렌더 안 됨 */}
    </>
  )
}
```

state가 바뀌어도 `SearchInput` 안에서만 리렌더가 일어난다.

---

## 패턴 2: Children as props — Before

```jsx
// ❌ color가 바뀔 때마다 HeavyContent도 리렌더
function ColorPicker() {
  const [color, setColor] = useState('#000')
  return (
    <div style={{backgroundColor: color}}>
      <input
        type="color"
        value={color}
        onChange={(e) => setColor(e.target.value)}
      />
      <HeavyContent />
    </div>
  )
}
```

`color`가 바뀌면 `ColorPicker`가 리렌더 → 자식인 `HeavyContent`도 리렌더.

---

## 패턴 2: Children as props — After

```jsx
function ColorPicker({children}) {
  const [color, setColor] = useState('#000')
  return (
    <div style={{backgroundColor: color}}>
      <input
        type="color"
        value={color}
        onChange={(e) => setColor(e.target.value)}
      />
      {children}
    </div>
  )
}
```

`HeavyContent`를 자식으로 받도록 바꾼다.

---

## 패턴 2: Children as props — 호출부

```jsx
// ✅ App에서 children으로 넘긴다
;<ColorPicker>
  <HeavyContent />
</ColorPicker>
```

`color`가 바뀌면 `ColorPicker`는 리렌더되지만, `<HeavyContent />`는 **App이 만든 ReactElement**다. App은 리렌더되지 않았으므로 **같은 참조** → 스킵.

---

## 패턴 3: 변하는 부분을 분리

```jsx
// ❌ 스크롤 위치 때문에 전체가 매 프레임 리렌더
function ScrollPage() {
  const [scrollY, setScrollY] = useState(0)
  useEffect(() => {
    const h = () => setScrollY(window.scrollY)
    window.addEventListener('scroll', h)
    return () => window.removeEventListener('scroll', h)
  }, [])

  return (
    <div>
      <ProgressBar progress={scrollY / maxScroll} />
      <Article /> {/* 안 바뀌는데 매 프레임 리렌더 */}
      <Comments /> {/* 안 바뀌는데 매 프레임 리렌더 */}
    </div>
  )
}
```

---

## 변하는 부분을 분리 — After

```jsx
function ScrollProgress() {
  const [scrollY, setScrollY] = useState(0)
  useEffect(() => {
    const h = () => setScrollY(window.scrollY)
    window.addEventListener('scroll', h)
    return () => window.removeEventListener('scroll', h)
  }, [])
  return <ProgressBar progress={scrollY / maxScroll} />
}

function ScrollPage() {
  return (
    <>
      <ScrollProgress />
      <Article /> <Comments /> {/* 리렌더 안 됨 */}
    </>
  )
}
```

자주 바뀌는 state를 별도 컴포넌트로 쪼개면 나머지는 영향을 받지 않는다.

---

## 패턴 4: 구독 기반 상태로 범위 좁히기

```jsx
// ❌ context 하나에 다 넣으면, 뭐 하나만 바뀌어도 모든 consumer 리렌더
<AppContext.Provider value={{user, theme, notifications, cart}}>
```

```jsx
// ✅ zustand/jotai — selector가 반환한 값이 바뀔 때만 리렌더
const userName = useStore((s) => s.user.name)
```

`cart`가 바뀌어도 `userName`만 구독하는 컴포넌트는 영향 없음.

React 빌트인으로는 **`useSyncExternalStore`**가 있다. 외부 스토어(웹소켓, 브라우저 API, 커스텀 이벤트버스 등)를 리렌더와 연결하면서 selector로 범위를 좁히는 표준 API.

> context value를 memo로 감싸는 것보다, 애초에 **필요한 조각만 구독하게** 만드는 게 근본적이다.

---

## 패턴 5: 우선순위로 처리 — `useTransition` / `useDeferredValue`

```jsx
function SearchPage() {
  const [query, setQuery] = useState('')
  const [, startTransition] = useTransition()
  return (
    <>
      <input
        onChange={(e) => startTransition(() => setQuery(e.target.value))}
      />
      <HeavyList query={query} />
    </>
  )
}
```

무거운 렌더를 **낮은 우선순위**로 표시. 더 급한 렌더(input 반응)가 들어오면 React가 리스트 렌더를 중단·재시작한다. `useDeferredValue`는 값 자체에 지연을 걸어 props 전달에 쓰기 좋다.

> "리렌더를 없애는 것"이 아니라 "UX를 막지 않게 밀어두는 것."

---

## 패턴 6: 경계 밖으로 — Server Components

```jsx
// app/page.tsx — 기본값은 서버 컴포넌트
export default async function Page() {
  const posts = await db.posts.findMany()
  return (
    <>
      <PostList posts={posts} /> {/* 서버에서 렌더, 클라 번들 0 */}
      <InteractiveFilter /> {/* 'use client' — 여기만 클라이언트 */}
    </>
  )
}
```

Server Component는 **애초에 클라이언트 리렌더 대상이 아니다.** 번들에도 포함되지 않고, hydration도 없다.

`'use client'` 경계를 **최대한 좁게** 잡는 것이 최상위 구조적 최적화다.

> 리렌더 최적화의 최종 형태: **렌더할 컴포넌트 자체를 줄이는 것.**

---

## 구조적 최적화 정리

| 패턴              | 핵심                                  | memo 필요? |
| ----------------- | ------------------------------------- | ---------- |
| State 내리기      | state를 사용하는 곳으로 이동          | 불필요     |
| Children as props | 부모가 children을 전달                | 불필요     |
| 컴포넌트 분리     | 변하는 부분과 안 변하는 부분을 쪼개기 | 불필요     |
| 구독 기반 상태    | selector로 구독 범위를 좁힘           | 불필요     |
| 우선순위 조정     | `useTransition` / `useDeferredValue`  | 불필요     |
| 서버 경계         | `'use client'` 바깥으로 밀기          | 불필요     |

> memo를 쓰기 전에 먼저 구조를 의심하라.

---

## Part 6 — React DevTools Profiler

---

## Profiler 켜기

1. React DevTools 설치 (Chrome / Firefox 확장)
2. DevTools → **Profiler** 탭
3. ⚙️ 설정에서 **"Record why each component rendered"** 체크
4. 녹화 시작 (⏺) → 동작 수행 → 녹화 중지 (⏹)

---

## Flamegraph 읽는 법

Flamegraph에서 각 바는 하나의 컴포넌트.

- **회색**: 이번 커밋에서 리렌더 안 됨
- **파란~노란색**: 리렌더됨. 노란색에 가까울수록 오래 걸림
- **바의 너비**: 렌더링 소요 시간 (자식 포함)

주의: **회색 바가 아닌 것들이 최적화 대상 후보**다.

---

## "Why did this render?"

컴포넌트를 클릭하면 오른쪽 패널에 렌더 이유가 나온다.

흔한 이유들:

- `The parent component rendered` — 부모 리렌더 연쇄
- `Props changed: (style)` — 특정 prop 참조 변경
- `Hooks changed` — state 또는 context 변경

> "Props changed: (style)" 이 보이면 → 인라인 객체 의심 → Part 1로 돌아가기

---

## Ranked 차트

Ranked 탭은 렌더링 시간 순으로 정렬해준다.

- 가장 오래 걸린 컴포넌트가 위에
- 리렌더 횟수 × 소요 시간 = 실제 체감 영향

Flamegraph로 "어디서" 발생하는지, Ranked로 "뭐가 가장 비싼지" 파악한다.

---

## 수치 기준 — "얼마나 빨라야 충분한가"

- **16ms** — 60fps 프레임 버짓. 한 번의 렌더가 이걸 넘으면 프레임 드롭.
- **50ms** — Long Task 기준. 사용자가 "끊긴다"고 느끼기 시작하는 지점.
- **INP 200ms** — Core Web Vitals의 상호작용 응답성 "Good" 임계값. 클릭/입력 후 다음 페인트까지.

Profiler의 숫자를 이 기준과 비교한다. "막연히 느리다"가 아니라 **"이 인터랙션이 320ms인데 목표가 200ms"**로 바꿔야 우선순위가 정해진다.

---

## 측정 없는 최적화는 최적화가 아니다

최적화를 적용하기 **전에** Profiler로 녹화하고, 적용한 **후에** 다시 녹화해서 비교한다.

- 회색 바가 늘어났는가? → 불필요한 리렌더가 줄었다
- Ranked에서 순위가 내려갔는가? → 렌더 비용이 줄었다
- 체감 차이가 없다면? → 그 최적화는 불필요했다

> "느린 것 같아서"가 아니라 **"Profiler에서 봤더니"**로 시작할 것.

---

## Escape Hatch — 그래도 안 되면 직접 DOM을 건드린다

memo, useMemo, 구조 개선을 전부 했는데도 프레임이 부족하다면? React를 우회하는 것도 방법이다.

```jsx
function Price({ticker}) {
  const ref = useRef(null)

  useEffect(() => {
    const ws = new WebSocket(`wss://api/price/${ticker}`)
    ws.onmessage = (e) => {
      // ✅ setState 없이 직접 DOM 업데이트
      ref.current.textContent = e.data
    }
    return () => ws.close()
  }, [ticker])

  return <span ref={ref} />
}
```

`setState`를 안 부르니까 리렌더가 0번. React가 모르는 사이에 DOM만 바뀐다.

---

## 직접 DOM 업데이트 — 언제 쓰나

증권 호가창, 실시간 차트 틱, 타이머, 애니메이션 카운터 등 **초당 수십 회 이상 값이 바뀌는 곳**에서 유효하다.

주의할 점:

- React가 해당 DOM을 모르므로, **다른 state로 인한 리렌더 시 값이 덮어씌워질 수 있음**
- 복잡한 UI 로직이 필요하면 이 패턴은 한계가 있음
- `ref.current.textContent` 수준의 단순한 업데이트에 한정할 것

> React의 장점을 포기하는 거니까 **최후의 수단**이다. 하지만 필요한 곳에서는 확실히 효과가 있다.

---

## 마무리 — 최적화 체크리스트

---

## 렌더링 최적화 판단 순서

```text
1. 진짜 느린가?
   → Profiler로 측정. 느리지 않으면 여기서 멈춘다.

2. 구조로 해결 가능한가?
   → state 내리기, 컴포넌트 분리, children as props

3. 참조 안정성 문제인가?
   → 인라인 객체 → 상수 추출 또는 useMemo
   → 인라인 함수 → useCallback
   → memo가 무력화되고 있지 않은지 확인

4. memo를 걸어야 하는가?
   → 리스트 아이템, 비싼 서브트리에 한정

5. 다시 측정한다.
```

---

## 비용 0인 좋은 습관 — 항상 하는 것

이건 측정 전에도 그냥 하는 것들이다.

- 변하지 않는 객체/배열은 **컴포넌트 바깥 상수**로
- 훅에서 객체를 받으면 **destructuring해서 primitive만 deps에**
- state는 **필요한 곳에 가까이**
- 변하는 부분과 안 변하는 부분은 **컴포넌트를 쪼개서 분리**

> 이건 최적화가 아니라 **기본**이다. 비용이 0이고 코드가 더 나아진다.

---

## 한 줄 요약

> memo, useMemo, useCallback은 도구다. 진짜 실력은 **쓰지 않아도 되는 구조를 만드는 것**이다.

<!-- _class: invert -->

---

## Q&A

<!-- _class: invert -->

---

## Q. memo를 모든 컴포넌트에 걸면 안 되나요?

걸 수는 있다. 비교 비용은 대부분 무시할 수준이니까.

하지만 그게 답이 아닌 이유:

- 인라인 객체/함수를 넘기면 어차피 매번 뚫림 → **memo만 걸어선 의미 없음**
- 모든 props를 안정시키려면 부모에 useMemo/useCallback이 줄줄이 붙음
- 결국 "모든 곳에 memo" → "모든 곳에 useMemo/useCallback" → **코드 복잡도만 올라감**

memo는 **필요한 곳에, props 안정성이 보장된 상태에서** 거는 것이다.

---

## Q. useCallback 안에서 state를 쓰면 deps에 넣어야 하는데, 그러면 매번 바뀌지 않나요?

```jsx
// ❌ count가 바뀔 때마다 새 함수
const increment = useCallback(() => {
  setCount(count + 1)
}, [count])

// ✅ updater function으로 deps에서 state 제거
const increment = useCallback(() => {
  setCount((prev) => prev + 1)
}, [])
```

`useState`의 setter(`setCount`)는 React가 항상 같은 참조를 보장한다. 그래서 deps에 안 넣어도 된다. 문제는 `count` 값을 클로저로 캡처하는 것인데, `setState(prev => ...)` 패턴을 쓰면 현재 state를 React가 넘겨주므로 `count`를 deps에서 뺄 수 있다.

---

## Q. children as props가 왜 리렌더를 막는지 직관적으로 이해가 안 돼요

Part 0을 떠올려보자. **리렌더링 = 함수를 다시 실행하는 것.**

```jsx
function App() {
  // ← 이 함수가 실행돼야
  return (
    <ColorPicker>
      <HeavyContent /> // ← 이 JSX가 새로 만들어짐
    </ColorPicker>
  )
}
```

`color` state가 바뀌면 `ColorPicker` 함수만 다시 실행된다. `App` 함수는 다시 실행되지 않는다. 그러면 `<HeavyContent />`는 이전 렌더에서 만들어진 그대로다. **함수가 실행 안 됐으니 새 JSX도 안 만들어진 것.**

---

## Q. Context를 쓰면 무조건 전체가 리렌더되나요?

전체는 아니고, 해당 context를 **`useContext`로 구독하는 컴포넌트만** 리렌더된다.

문제는 context value가 객체일 때:

```jsx
// ❌ Provider가 리렌더되면 매번 새 객체
<AuthContext.Provider value={{ user, login, logout }}>
```

Provider의 부모가 리렌더 → value 객체가 새 참조 → 모든 consumer 리렌더.

```jsx
// ✅ value를 memo
const value = useMemo(() => ({ user, login, logout }), [user, login, logout])
<AuthContext.Provider value={value}>
```

그리고 자주 바뀌는 값과 안 바뀌는 값은 **context 자체를 분리**하는 게 낫다.

---

## Q. React Compiler 나오면 이 내용 다 필요 없어지는 거 아닌가요?

React Compiler는 useMemo/useCallback을 **자동으로 삽입**해준다. Part 3~4의 수동 메모이제이션은 대부분 불필요해질 수 있다.

하지만:

- **Part 0** (리렌더링의 원리) — 여전히 알아야 함
- **Part 2** (훅 deps에 객체 넣지 않기) — 컴파일러가 해결해주지 않음
- **Part 5** (구조적 최적화) — 컴파일러와 무관, 여전히 유효
- **Part 6** (Profiler) — 측정은 항상 필요

도구가 자동화해주는 부분과, 설계로 해결해야 하는 부분은 다르다.

---

## Q. 직접 DOM 업데이트하면 React 상태와 꼬이지 않나요?

꼬일 수 있다. 핵심은 **React가 관리하는 영역과 분리**하는 것.

```jsx
// ✅ React는 span의 children을 모름 — 충돌 없음
<span ref={priceRef} />

// ❌ React도 이 값을 관리하고 있음 — 충돌
<span ref={priceRef}>{price}</span>
```

`ref`로 업데이트하는 요소는 **React가 내용을 제어하지 않는 빈 요소**여야 한다. React가 children을 렌더하는 요소에 직접 DOM 조작을 하면 다음 리렌더 시 React가 덮어쓴다.

---

## Q. zustand/jotai 같은 상태관리가 memo를 대체하나요?

상당 부분 대체한다. 이들은 **selector 기반 구독**이라 관심 있는 조각만 리렌더에 연결된다.

```jsx
// context + memo 조합: Provider 리렌더 시 value 안정화가 필요
const value = useMemo(() => ({user, cart}), [user, cart])

// zustand: selector가 반환한 값이 바뀔 때만 리렌더
const user = useStore((s) => s.user)
```

context + memo는 "전역 상태를 한 덩어리로 다루면서 참조를 안정시키는" 접근이고, 구독 기반은 "애초에 필요한 조각만 듣는" 접근이다. 후자가 더 근본적이지만, 라이브러리 의존성과 학습 비용이 추가된다.

---

## Q. Server Components 쓰면 이 내용 다 의미 없어지나요?

의미 없어지지 않는다. 오히려 **무엇이 클라이언트로 넘어가는지 의식하게** 만든다.

- `'use client'` 경계 **안쪽**에서는 이 강의의 모든 원칙이 그대로 적용된다.
- RSC는 "렌더할 컴포넌트 자체를 줄이는" 구조적 최적화의 최상위 형태일 뿐, 클라이언트에서 일어나는 리렌더 문제를 없애지는 않는다.
- "이 컴포넌트가 정말 클라이언트여야 하는가?"를 매번 묻게 되니, 최적화 사고 자체가 강화된다.

---

## Q. useMemo의 캐시가 날아가는 경우가 있다던데?

React 공식 문서에 명시되어 있다:

> React will throw away the cached value if it needs to free memory for offscreen components.

즉, `useMemo`는 **성능 힌트**이지 **보장**이 아니다. 캐시가 날아가도 앱이 정상 동작해야 한다.

실무적으로는 거의 일어나지 않지만, `useMemo`를 **정확성을 위한 수단**(같은 참조 보장)으로 쓰면 안 된다는 의미다. 정확성이 필요하면 `useRef` + 직접 비교를 쓰자.

---

# 감사합니다

<!-- _class: invert -->

@yceffort

---

# Render nothing, faster: <></>, null, 그리고 hook

Source: https://research.yceffort.kr/slides/render-nothing-faster.md

---
title: 'Render nothing, faster: <></>, null, 그리고 hook'
marp: true
paginate: true
theme: default
tags:
  - react
  - web-performance
date: 2026-04-07
description: '아무것도 렌더링하지 않는 두 가지 흔한 패턴을 JSX runtime/reconciler 관점에서 다시 본다'
published: true
art:
  undraw: no-data
---

# Render nothing, faster

`<></>`, `null`, 그리고 훅으로 컴포넌트 없애기

<!-- _class: invert -->

@yceffort

---

## 오늘 다룰 두 가지 안티패턴 🤔

```jsx
// 패턴 A — 빈 fragment를 placeholder로
return condition ? <Foo /> : <></>

// 패턴 B — 화면에 아무것도 안 그리는 컴포넌트
function Analytics({event}) {
  useEffect(() => track(event), [event])
  return null
}
```

> 둘 다 동작은 똑같다. 근데 React가 안에서 하는 일은 좀 다름.

---

## 잠깐, 용어 정리

이야기 따라가려면 두 단어만 구분되면 충분.

- **ReactElement** — JSX가 컴파일되면 만들어지는 _순수한 자바스크립트 객체_. "이 자리에 이런 컴포넌트 그려달라"고 React에게 던지는 **명세서** 같은 것.
- **Fiber** — React가 그 명세서를 보고 내부에 만드는 _작업 단위_. 실제로 렌더/effect/DOM 반영을 수행하는 **작업자**고, 부모/자식/형제 포인터를 가짐.

명세서는 매 렌더마다 새로 만들어지지만, fiber는 가능하면 재사용된다. **우리가 줄여야 할 비용은 두 가지 — 명세서 할당, fiber 생성/유지.**

---

## Part 1 — `<></>` vs `null`

PR 리뷰에서 자주 그냥 지나가는 코드 한 줄.

```jsx
// Before
return condition ? <Toast /> : <></>

// After
return condition ? <Toast /> : null
```

같은 화면이 나오니까 별 차이 없어 보임. 근데 컴파일 결과부터 이미 다르다.

---

## JSX는 누가 변환하나

JSX 자체는 자바스크립트 표준이 아님. Babel(`@babel/plugin-transform-react-jsx`) 또는 SWC가 함수 호출로 바꿔준다.

```js
// JSX
;<></>

// 컴파일 후 (react/jsx-runtime)
_jsx(_Fragment, {})
```

`_jsx`는 실제로 React 패키지 안에 있는 함수 — `react/jsx-runtime`이 export하는 `jsxProd`.

> 출처: [`packages/react/src/jsx/ReactJSXElement.js#L293`](https://github.com/facebook/react/blob/v19.0.0/packages/react/src/jsx/ReactJSXElement.js#L293-L363)

---

## `_jsx`가 매번 만드는 객체

`jsxProd`의 마지막 줄은 `ReactElement(...)` 호출. 이 함수는 **매번 새 객체**를 만든다.

```js
// packages/react/src/jsx/ReactJSXElement.js (요약)
element = {
  $$typeof: REACT_ELEMENT_TYPE,
  type, // <- _Fragment 심볼
  key, // <- null
  ref, // <- null
  props, // <- {} 빈 객체
}
```

> 출처: [`ReactJSXElement.js#L161-L238`](https://github.com/facebook/react/blob/v19.0.0/packages/react/src/jsx/ReactJSXElement.js#L161-L238)

`<></>` 한 번에 객체 2개(element + props) 할당. `null`은 0개. **매 렌더마다.**

---

## reconciler까지 가면 — 단일 자식일 때

근데 사실, `return ... : <></>` 처럼 빈 fragment가 부모의 _단일 자식_으로 들어오는 경우는 fiber까지는 안 만든다. 이런 분기 코드가 있음.

```js
// packages/react-reconciler/src/ReactChildFiber.js
const isUnkeyedTopLevelFragment =
  typeof newChild === 'object' &&
  newChild !== null &&
  newChild.type === REACT_FRAGMENT_TYPE &&
  newChild.key === null
if (isUnkeyedTopLevelFragment) {
  validateFragmentProps(newChild, null, returnFiber)
  newChild = newChild.props.children // <- undefined로 풀어버림
}
```

> 출처: [`ReactChildFiber.js#L1762-L1770`](https://github.com/facebook/react/blob/v19.0.0/packages/react-reconciler/src/ReactChildFiber.js#L1762-L1770)

---

## 그래서 단일 자식일 때 비용은

`undefined`가 된 newChild는 함수 마지막의 fallthrough로 빠진다.

```js
// 같은 파일 L1924
// Remaining cases are all treated as empty.
return deleteRemainingChildren(returnFiber, currentFirstChild)
```

> 출처: [`ReactChildFiber.js#L1924-L1925`](https://github.com/facebook/react/blob/v19.0.0/packages/react-reconciler/src/ReactChildFiber.js#L1924-L1925)

| 단계        | `<></>`                 | `null` |
| ----------- | ----------------------- | ------ |
| 객체 할당   | 2 (element + props)     | 0      |
| 검사·unwrap | 4가지 조건 + props 접근 | 0      |
| fiber 생성  | 0                       | 0      |
| 최종 분기   | deleteRemainingChildren | 동일   |

fiber까진 안 가도 **객체 할당이랑 검사 비용은 매 렌더 발생한다.** "안전하니까 괜찮아"가 아님.

---

## 형제 자리에 끼면 이야기가 달라짐

```jsx
return (
  <>
    <Header />
    {showAd ? <Ad /> : <></>}
    <List />
  </>
)
```

이렇게 형제들 사이에 `<></>` 가 끼면, 얘는 **children 배열의 한 자리를 차지하는 자식**이 된다. 앞에서 본 _빈 fragment를 그냥 통과시키는 단축 경로_는 여기엔 적용 안 됨. 그건 "fragment가 부모의 단일 자식일 때"만 동작하는 분기였음.

---

## 그래서 fragment fiber가 진짜로 만들어짐

```js
// packages/react-reconciler/src/ReactChildFiber.js L1676
if (element.type === REACT_FRAGMENT_TYPE) {
  const created = createFiberFromFragment(
    element.props.children,
    returnFiber.mode,
    lanes,
    element.key,
  )
  // ...
}
```

`<></>` 마다 fragment fiber가 매 렌더 새로 생기고, 다음 렌더에 다시 reconcile되고, 사라질 땐 cleanup도 됨. **자식이 0개여도.**

> 출처: [`ReactChildFiber.js#L1676-L1693`](https://github.com/facebook/react/blob/v19.0.0/packages/react-reconciler/src/ReactChildFiber.js#L1676-L1693)

---

## `<Ad />` ↔ `<></>` 토글의 진짜 비용

```jsx
{
  showAd ? <Ad /> : <></>
}
```

`showAd` 가 토글될 때마다, 같은 자리의 element `type`이 `Ad` 와 `Fragment` 사이를 왔다 갔다 한다.

React 형제 매칭 규칙: **같은 자리에 다른 type이 오면 그 자리의 fiber를 unmount하고 새로 mount**. 토글 한 번마다 일어나는 일:

- 이전 fiber(`Ad` 또는 `Fragment`) destroy + cleanup effect
- 새 fiber 생성 + mount effect

`null`이면 그 자리는 그냥 _비어 있는 자식_. type이 바뀔 일도, fiber를 만들었다 부수는 비용도 없다.

> 빈 fragment는 화면엔 안 보여도 **자리는 차지하는** 자식임.

---

## Part 1 결론

> 렌더할 게 없으면 `<></>` 가 아니라 `null`.

- **단일 자식일 때:** 객체 2개 할당이 사라짐
- **형제 자리에 끼면:** fragment fiber까지 안 만들고, 옆 형제 reconciliation도 안정적
- **의미적으로도 더 명확함** ("아무것도 그리지 않겠다")

단, 오해 금지.

- `return <></>` ❌ — 그냥 `null`
- `return <>{a}{b}</>` ✅ — 여러 자식을 묶을 때의 fragment는 전혀 다른 이야기

---

## Part 2 — `return null` 컴포넌트 vs 훅

이런 컴포넌트, 한 번쯤 본 적 있을 것이다.

```jsx
function KeyboardShortcut({combo, onTrigger}) {
  useEffect(() => {
    const h = (e) => match(e, combo) && onTrigger()
    window.addEventListener('keydown', h)
    return () => window.removeEventListener('keydown', h)
  }, [combo, onTrigger])
  return null
}
```

부모(`CommandHost`) 어딘가에서 `<KeyboardShortcut combo="cmd+k" onTrigger={openPalette} />` 처럼 박아 둔다. **근데 이거 왜 컴포넌트지?**

---

## React가 매 렌더마다 하는 일

부모(`CommandHost`)가 한 번 그려질 때, `<KeyboardShortcut .../>` 한 줄 때문에 일어나는 일:

1. `_jsx(KeyboardShortcut, { combo, onTrigger })` — **ReactElement 객체 1개 할당**
2. reconciler가 child 위치에서 **fiber 1개 생성/갱신** (이전 fiber와 매칭)
3. props 비교 (`combo`, `onTrigger` 얕은 비교)
4. `KeyboardShortcut` 함수 호출 — **콜 스택에 프레임 1개 더**
5. 내부 훅 실행 (`useEffect`)
6. `return null` → 하위 자식 없음으로 마무리
7. DevTools 트리에 노드 1개가 그대로 남음

산출물: **0픽셀.**

---

## 훅으로 옮기면

```jsx
function useKeyboardShortcut(combo, onTrigger) {
  useEffect(() => {
    const h = (e) => match(e, combo) && onTrigger()
    window.addEventListener('keydown', h)
    return () => window.removeEventListener('keydown', h)
  }, [combo, onTrigger])
}

function CommandHost() {
  useKeyboardShortcut('cmd+k', openPalette)
  return <Palette />
}
```

- ReactElement 할당 ❌
- 자식 fiber ❌
- 함수 호출 한 단계 줄어듦 — 부모 fiber의 hook chain에 슬롯 하나 추가될 뿐
- DevTools 트리도 깔끔

---

## 그래서 얼마나 더 가벼워지나

한 컴포넌트를 훅으로 바꿀 때마다 절약되는 것:

- ReactElement 객체 1개 (+ props 객체 1개)
- fiber 노드 1개 (React가 더블 버퍼링으로 같은 fiber를 두 벌 들고 있어서, 메모리상으론 2개)
- 함수 호출 1프레임 + props 얕은 비교 1회
- DevTools 트리 노이즈 1개

**한 곳에서는 미세하다.** 근데:

- 가상 리스트 row마다 박혀 있는 `<RowAnalytics />` 500개 → 그대로 500배
- 매 키 입력마다 리렌더되는 폼이라면 곱셈은 더 가팔라진다

> React에게 일을 시키지 않는 게 가장 빠른 최적화.

---

## 변환 규칙

> 렌더 산출물이 항상 `null` 인 컴포넌트 → **훅으로 옮긴다.**

거의 모든 케이스가 여기 해당함.

- **동적 개수 등록**도 → `useFoos(items)` 처럼 배열 받는 훅 하나로 가능
- **조건부 mount/unmount cleanup**도 → `useFoo(active)` + dep로 가능
- "이건 컴포넌트로만 되는데?" 싶은 사례는 거의 항상 _고민이 부족한 훅 설계_임

---

## 요약

|                         | 안티패턴      | 더 가벼운 쪽 |
| ----------------------- | ------------- | ------------ |
| 빈 자리                 | `<></>`       | `null`       |
| 화면 안 그리는 컴포넌트 | `return null` | 커스텀 훅    |

공통 원칙은 하나.

> **React가 하지 않아도 되는 일은 시키지 않는다.**

작아 보이지만, 반복되는 곳에서는 측정 가능한 차이가 된다.

---

## 참고 — 직접 읽어볼 React 19 소스

- [`jsxProd` — `_jsx`의 본체](https://github.com/facebook/react/blob/v19.0.0/packages/react/src/jsx/ReactJSXElement.js#L293-L363)
- [`ReactElement` — 객체 만드는 factory](https://github.com/facebook/react/blob/v19.0.0/packages/react/src/jsx/ReactJSXElement.js#L161-L238)
- [`reconcileChildFibersImpl` — 자식 reconcile 진입점](https://github.com/facebook/react/blob/v19.0.0/packages/react-reconciler/src/ReactChildFiber.js#L1744-L1926)
- [top-level unkeyed fragment unwrap](https://github.com/facebook/react/blob/v19.0.0/packages/react-reconciler/src/ReactChildFiber.js#L1762-L1770)
- [형제 위치 fragment fiber 생성](https://github.com/facebook/react/blob/v19.0.0/packages/react-reconciler/src/ReactChildFiber.js#L1676-L1693)
- [empty fallthrough — `deleteRemainingChildren`](https://github.com/facebook/react/blob/v19.0.0/packages/react-reconciler/src/ReactChildFiber.js#L1924-L1925)
- React 공식 — [Conditional Rendering](https://react.dev/learn/conditional-rendering), [Reusing Logic with Custom Hooks](https://react.dev/learn/reusing-logic-with-custom-hooks)

---

# 패키지 가이드 4탄) dependencies, devDependencies, peerDependencies

Source: https://research.yceffort.kr/slides/best-practice-for-package-4.md

---
title: 패키지 가이드 4탄) dependencies, devDependencies, peerDependencies
marp: true
paginate: true
theme: default
tags:
  - npm
date: 2026-04-04
description: '세 가지 의존성 필드의 역사, 정의, 그리고 현대적 의미'
published: true
art:
  undraw: organized-content
---

# 패키지 가이드 4탄) dependencies, devDependencies, peerDependencies

<!-- _class: invert -->

@yceffort

---

## 이 발표의 목표

- `dependencies`, `devDependencies`, `peerDependencies`가 **왜 생겼는지** 역사적 배경
- 각 필드의 **정확한 정의**
- 일반적인 서비스 프로젝트에서 **왜 구분이 중요하지 않은지**
- `peerDependencies`의 진짜 의미와 **mocha 플러그인 사례**
- 현대에 와서 **의미가 변질된 이유**
- 패키지 개발자로서 **주의할 점**
- `pnpm`으로 **올바르게 의존성 검사하는 법**

---

## 역사: npm 초기에는 `dependencies` 하나뿐이었다

<style scoped>section { font-size: 22px; }</style>

| 시기 | 버전    | 변화                                                |
| ---- | ------- | --------------------------------------------------- |
| 2010 | npm 0.x | `dependencies`만 존재. 모든 의존성을 한 곳에        |
| 2011 | npm 1.x | `devDependencies` 추가. 개발 도구와 런타임 분리     |
| 2013 | npm 1.2 | `peerDependencies` 추가. 플러그인 문제 해결         |
| 2015 | npm 3   | flat `node_modules` 도입, peerDeps 자동설치 제거    |
| 2020 | npm 7   | peerDeps 자동설치 부활, `peerDependenciesMeta` 추가 |

- 핵심: 처음부터 세 개가 있던 게 아니라, **문제가 생길 때마다 하나씩 추가**된 것

---

## `devDependencies`는 왜 생겼나?

<style scoped>section { font-size: 22px; }</style>

npm 초기에는 테스트 도구(`mocha`), 빌드 도구(`grunt`)도 전부 `dependencies`에 넣었음

```json
{
  "name": "my-library",
  "dependencies": {
    "lodash": "^1.0.0",
    "mocha": "^1.0.0",
    "grunt": "^0.4.0"
  }
}
```

**문제**: `npm install my-library` 하면 `mocha`, `grunt`까지 딸려옴

- 사용자는 `lodash`만 필요한데 테스트/빌드 도구까지 설치됨
- 설치 시간 증가, `node_modules` 비대화
- **해결**: 개발 시에만 필요한 것을 분리할 필드가 필요 → `devDependencies`

---

## `deps` vs `devDeps` 구분이 흐려진 이유

<style scoped>section { font-size: 18px; }</style>

원래 `devDependencies`의 구분은 명확했다: **`node server.js`로 직접 실행**하던 시절, 런타임에 필요 없는 건 빼는 것

```dockerfile
COPY package.json package-lock.json ./
RUN npm ci --omit=dev           # devDependencies 제외 (구: --production)
COPY dist/ ./dist/
CMD ["node", "dist/server.js"]
```

**그런데 webpack(2012)의 등장이 이 경계를 흐렸다:**

- 번들러가 `import` 그래프를 따라가므로, `package.json` 필드 구분과 **무관하게** 모든 의존성이 번들에 포함
- "빌드 시에만 필요한 것"과 "런타임에 필요한 것"의 경계가 번들러 안에서 사라짐
- 프론트엔드 개발자에게 deps/devDeps 구분은 **실질적 차이 없는 관례**가 됨

**현대 배포도 이 흐름을 강화:**

- **Docker 멀티스테이지**: 빌드 스테이지에서 전부 설치 → 결과물만 복사
- **서버리스 / Edge**: 번들된 단일 파일 배포. `node_modules` 자체가 포함 안 됨
- **프레임워크 관리**: Next.js `output: 'standalone'`이 필요한 모듈만 자동으로 추림

---

## 각 필드의 정의

<style scoped>section { font-size: 20px; }</style>

### `dependencies`

> **이 패키지가 동작하기 위해 런타임에 반드시 필요한 패키지**

- `npm install my-package` 시 함께 설치됨
- 예: `lodash`, `express`, `react` (라이브러리가 직접 import하는 것)

### `devDependencies`

> **개발, 테스트, 빌드 시에만 필요한 패키지**

- `npm install my-package` 시 설치되지 **않음**
- `npm install` (패키지 자체 개발 시) 에서만 설치됨
- 예: `typescript`, `jest`, `eslint`, `rollup`

### `peerDependencies`

> **이 패키지가 동작하려면 호스트 프로젝트가 직접 설치해야 하는 패키지**

- 뒤에서 자세히 다룸

---

## 서비스 프로젝트에서는 왜 구분이 덜 중요할까?

<style scoped>section { font-size: 18px; }</style>

```text
  패키지 A ──►┌──────────────┐
  패키지 B ──►│ 서비스(Next.js)│── 번들 ──► 사용자
  패키지 C ──►└──────────────┘
                ▲ 의존성 체인의 "끝"
```

- 서비스 프로젝트는 **누군가에 의해 `npm install`되는 대상이 아님**
- `dependencies`에 넣든 `devDependencies`에 넣든 **빌드 시 전부 설치**됨
- webpack, vite 등 번들러는 `import` 그래프를 따라갈 뿐, `package.json` 필드를 구분하지 않음
- 대부분의 현대 배포(Vercel, Docker 멀티스테이지)는 **전체 설치 → 빌드 → 결과물만 배포**

**단, Node 런타임이 남는 환경에서는 여전히 중요:**

- SSR / API 서버 / CLI / Docker에서 `npm ci --omit=dev`로 배포 시, 런타임 의존성이 devDeps에 있으면 **깨질 수 있음**
- Next.js `output: 'standalone'`도 런타임 파일만 추려내므로 올바른 분류가 필요
- 클라이언트 번들만 배포하는 SPA라면 영향이 상대적으로 작음

> **패키지** 를 만들 때는 이야기가 완전히 달라진다

---

## `peerDependencies`는 왜 생겼나? — mocha로 이해하기

<style scoped>section { font-size: 20px; }</style>

`mocha`는 Node.js 테스트 러너. 요즘의 `jest`나 `vitest` 같은 역할

```js
// test/math.test.js
describe('add 함수', () => {
  it('1 + 2 = 3', () => {
    assert.strictEqual(add(1, 2), 3)
  })
})
```

여기서 핵심: **`describe`와 `it`을 어디서도 import하지 않았다**

- mocha는 테스트 파일을 실행하기 전에 `describe`, `it`, `before`, `after` 등을 **글로벌에 주입**
- 테스트 파일은 mocha가 만들어 놓은 글로벌 환경 위에서 동작
- 이건 jest도 마찬가지 — `describe`, `it`, `expect` 전부 import 없이 사용

> import 없이 동작한다 = **어떤 mocha 인스턴스가 글로벌을 세팅했느냐**가 중요하다

---

## 왜 import 없이 글로벌 주입이었을까?

<style scoped>section { font-size: 20px; }</style>

mocha는 Ruby의 **RSpec**에서 영향을 받았음. Ruby에서 `describe`, `it`은 그냥 메서드 호출:

```ruby
# Ruby — 괄호 없이 메서드 호출 + 블록(do...end) = 자연어처럼 읽힘
describe "Calculator" do
  it "adds numbers" do
    expect(1 + 2).to eq(3)
  end
end
```

- Ruby는 최상위 스코프가 `Object` 인스턴스 → 어디서든 메서드 주입 가능 → **import 개념 자체가 불필요**
- 언어 설계 철학이 "코드가 영어 문장처럼 읽혀야 한다"(Matz) → 글로벌 DSL이 안티패턴이 아니라 **관용적 Ruby**
- mocha, Jasmine이 이 패턴을 JS로 그대로 가져옴
- 현대 JS에서는 글로벌 오염이 안티패턴이므로, `vitest`나 `node:test`는 **명시적 import** 방식으로 전환

---

## mocha 플러그인의 문제

<style scoped>section { font-size: 18px; }</style>

mocha용 플러그인 `mocha-sugar`를 만든다고 가정. mocha의 글로벌 `it`을 확장하는 패키지

```js
// mocha-sugar/index.js — mocha의 글로벌 it을 감싸서 기능을 추가
const originalIt = global.it // ← mocha가 주입한 글로벌 it을 가져옴

global.it.todo = (title) => {
  originalIt(title, function () {
    this.skip() // ← mocha의 skip() API를 사용
  })
}
```

```js
// 사용하는 쪽
require('mocha-sugar')          // 글로벌 it을 확장
describe('my test', () => {
  it.todo('나중에 구현')         // ← mocha-sugar가 추가한 기능
  it('동작하는 테스트', () => { ... })
})
```

**`mocha-sugar`는 mocha를 직접 import하지 않지만, mocha가 세팅한 글로벌 환경에 의존한다**

---

## mocha가 두 벌이면 무슨 일이 생기나?

<style scoped>section { font-size: 18px; }</style>

`mocha-sugar`가 `mocha`를 `dependencies`에 넣으면?

```text
my-project
├── mocha@10.0.0                     ← npx mocha가 실행하는 mocha (글로벌 세팅)
└── node_modules/mocha-sugar/
    └── node_modules/mocha@8.0.0     ← mocha-sugar가 가져온 다른 mocha
```

- `npx mocha`는 mocha@10이 `describe`, `it`을 글로벌에 세팅
- `mocha-sugar`는 자기 `node_modules`의 mocha@8을 참조
- **두 mocha의 글로벌 컨텍스트가 다름** → `this.skip()`이 mocha@10의 러너와 연결 안 됨
- 결과: 플러그인이 정상 동작하지 않거나, 알 수 없는 에러 발생

### peerDependencies로 해결

```json
{
  "name": "mocha-sugar",
  "peerDependencies": {"mocha": ">=6"}
}
```

→ mocha-sugar는 mocha를 직접 갖지 않음 → 호스트의 mocha@10 하나만 존재
→ 글로벌 `it`, `describe`가 **단일 mocha 인스턴스**에서 온 것이 보장됨

---

## peerDependencies의 핵심 원리

<!-- _class: invert -->

> **"나는 이 패키지가 필요하지만, 내가 가져오면 안 된다. 호스트가 가져와야 한다."**

이것이 필요한 패턴:

- **플러그인**: eslint-plugin-\*, babel-plugin-\*, mocha reporter (공식 문서의 대표 예시: chai plugin 계열)
- **프레임워크 확장**: React 컴포넌트 라이브러리, Next.js 플러그인
- **싱글턴이 보장되어야 하는 패키지**: react, react-dom

---

## npm 3 — nested에서 flat으로 (2015)

<style scoped>section { font-size: 20px; }</style>

npm 3에서 `node_modules`를 **nested → flat** 구조로 바꿈

```text
# npm 1~2 (nested) — 패키지마다 자기 의존성을 중첩으로 가짐
node_modules/
├── A/
│   └── node_modules/lodash@3/
└── B/
    └── node_modules/lodash@4/

# npm 3+ (flat) — 같은 패키지를 최상위로 끌어올림 (hoisting)
node_modules/
├── A/
├── B/
└── lodash@4/   ← 하나만 남김. A가 원하는 @3은?
```

flat 구조는 디스크를 절약하지만, **최상위에 어떤 버전을 올릴지** 판단이 복잡해짐

---

## npm 3 — peerDeps 자동 설치 제거

<style scoped>section { font-size: 22px; }</style>

flat 구조에서 peerDeps까지 자동으로 끼워넣으면 호이스팅 충돌이 더 심해짐

- nested 시절에는 각 패키지가 자기 `node_modules` 안에 독립적으로 의존성을 가졌으므로, peerDeps를 자동 설치해도 **다른 패키지에 영향이 없었음**
- flat에서는 최상위 `node_modules`에 **하나의 버전만** 올라가야 하는데, peerDeps가 요구하는 버전까지 끼워넣으면 "최상위에 어떤 버전을 올릴지" 판단이 **더 복잡해지고 충돌 확률이 급증**
- 예: A가 `react@16`을 peerDep으로, B가 `react@15`를 peerDep으로 요구 → 둘 다 최상위에 올릴 수 없음

→ **자동 설치를 제거하고 경고만 출력**하는 것으로 후퇴

```text
npm WARN mocha-junit-reporter@2.0.0 requires a peer of mocha@>=6
but none is installed. You must install peer dependencies yourself.
```

- 결과: 많은 개발자들이 peerDeps 경고를 **무시**하기 시작
- peerDependencies의 의미가 흐려지기 시작한 시점

---

## npm 7 — peerDeps 자동 설치 부활 (2020)

<style scoped>section { font-size: 22px; }</style>

npm 7에서 다시 peerDependencies를 **기본 설치 대상**으로 변경

```bash
npm install mocha-junit-reporter
# → peerDep인 mocha도 설치를 시도하지만, 충돌 시 에러가 발생할 수 있음
```

- peerDeps를 자동 해결하되, **충돌 시 설치 자체가 실패** → `--legacy-peer-deps` 플래그 등장
- 동시에 `peerDependenciesMeta`도 추가

```json
{
  "peerDependencies": {"react": "^18 || ^19"},
  "peerDependenciesMeta": {"react": {"optional": true}}
}
```

이 자동 설치 부활이 **서비스 코드의 무분별한 비대화의 시작점**이기도 하다 — 패키지 제작자는 "어차피 자동 설치되니까"라는 안이함으로 peerDeps를 늘리고, 사용자의 `node_modules`는 의도와 무관하게 불어난다

---

## 현대에 와서 의미가 변질된 이유

<style scoped>section { font-size: 20px; }</style>

### 1. 패키지 매니저마다 동작이 다름

| 패키지 매니저   | peerDeps 자동 설치 | 동작                                         |
| --------------- | ------------------ | -------------------------------------------- |
| npm 3~6         | ❌                 | 경고만                                       |
| npm 7+          | ✅                 | 자동 설치, 충돌 시 에러                      |
| yarn 1          | ❌                 | 경고만                                       |
| yarn berry (2+) | ❌                 | 자동 설치 안 함. PnP에서 누락 시 런타임 에러 |
| pnpm 7          | ❌                 | `auto-install-peers` 기본값 `false`          |
| pnpm 8+         | ✅                 | `auto-install-peers` 기본값 `true`로 변경    |

### 2. 원래 의도와 다른 사용

- 원래: "플러그인이 호스트 패키지를 참조"하기 위한 메커니즘
- 현재: "이 패키지를 같이 설치해야 한다"는 **단순한 알림** 용도로 변질
- `react`를 peerDep에 넣는 것은 원래 의미에 부합하지만, 단순히 "함께 쓰면 좋은 패키지"를 peerDep에 넣는 사례도 증가

---

## 요즘 peerDependencies 사례

<style scoped>section { font-size: 18px; }</style>

### 올바른 사용 — 싱글턴 / 플러그인 패턴

```json
// React 컴포넌트 라이브러리
{ "peerDependencies": { "react": "^18 || ^19", "react-dom": "^18 || ^19" } }
// ESLint 플러그인
{ "peerDependencies": { "eslint": "^8.0.0 || ^9.0.0" } }
// Tailwind 플러그인
{ "peerDependencies": { "tailwindcss": "^3.0.0 || ^4.0.0" } }
```

### 논쟁적 사용 — "같이 쓰면 좋은 것"

```json
// 어떤 상태관리 라이브러리의 React 바인딩
{
  "peerDependencies": {
    "react": "^18 || ^19", // ← 이건 맞음 (싱글턴)
    "immer": ">=9" // ← 이건 논쟁적 (dependencies로도 가능)
  }
}
```

- `immer`가 두 벌이어도 문제는 없지만, 번들 크기 최적화를 위해 peerDep으로 선언하는 경우도 있음

---

## 요즘 peerDependencies 사례 (2) — 사내 공통 패키지

<style scoped>section { font-size: 20px; }</style>

```json
// @my-company/design-system
{
  "peerDependencies": {
    "react": "^19.0.0",
    "@my-company/tokens": "^2.0.0",
    "lodash": "^4.17.0"
  }
}
```

- 여러 서비스가 공유하는 사내 패키지에서, **버전 불일치와 중복 설치를 방지**하고 싶을 때
- `lodash`처럼 두 벌이어도 동작은 하지만, 번들에 중복 포함되면 **크기가 낭비**되는 패키지도 대상

**peerDeps는 책임의 선언이기도 하다**

- `dependencies` = "이 버전은 **내가(패키지 팀이) 책임**진다"
- `peerDependencies` = "이 버전은 **호스트(서비스 팀)가 책임**져라"
- lodash를 peerDep으로 → **"lodash 버전 관리는 각 서비스 팀의 몫"**
- lodash를 dependencies에 → 패키지 팀이 올릴 때마다 모든 서비스에 영향

---

## 패키지 개발자로서 주의할 점 (1)

<style scoped>section { font-size: 18px; }</style>

### 런타임 의존성을 `devDependencies`에 넣지 말 것

```json
// ❌ 잘못된 예
{
  "devDependencies": {
    "lodash": "^4.17.0" // 런타임에 import 하는데 devDeps에 있음
  }
}
```

- 사용자가 `npm install`하면 `lodash`가 설치 안 됨 → **런타임 에러**. 패키지에서는 치명적

### 프레임워크를 `dependencies`에 넣지 말 것

```json
// ❌ 잘못된 예 — React 컴포넌트 라이브러리
{
  "dependencies": {
    "react": "^19.0.0" // 호스트와 다른 버전의 react가 설치될 수 있음
  }
}
```

- React가 두 벌 → hooks 에러, context 공유 불가 등 심각한 버그

---

## 패키지 개발자로서 주의할 점 (1-2)

<style scoped>section { font-size: 22px; }</style>

### `@types/*`를 `dependencies`에 넣지 말 것

```json
// ❌ 잘못된 예
{
  "dependencies": {
    "@types/node": "^20.0.0"
  }
}
```

- `npm install my-package` 시 `@types/node`가 사용자에게 딸려옴
- 사용자 프로젝트에 **의도하지 않은 Node.js 타입이 주입**되어 타입 오염 발생
- `@types/*`는 빌드 시에만 필요하므로 반드시 **devDependencies**에

---

## 패키지 개발자로서 주의할 점 (2)

<style scoped>section { font-size: 20px; }</style>

### peerDeps + devDependencies 패턴

peerDeps에 넣은 패키지는 **개발 시에는** devDependencies에도 넣어야 한다

```json
{
  "peerDependencies": {
    "react": "^18.0.0 || ^19.0.0"
  },
  "devDependencies": {
    "react": "^19.0.0"
  }
}
```

- peerDeps만 선언하면 `npm install`(패키지 개발 시)에서 react가 설치되지 않음
- 로컬 개발, 테스트, 빌드를 위해 **devDependencies에도** 넣어야 함
- 배포 시에는 devDeps가 제외되므로, 사용자에게 중복 설치되지 않음

> peerDeps = "사용자에게 요구하는 것", devDeps = "내가 개발할 때 필요한 것"
> 둘은 역할이 다르므로 **같은 패키지가 양쪽에 있어도 정상**

---

## 패키지 개발자로서 주의할 점 (3)

<style scoped>section { font-size: 18px; }</style>

### peerDependencies 버전 범위는 최대한 넓게, 단 `>=`는 쓰지 말 것

```json
// ❌ 너무 좁은 범위
{ "peerDependencies": { "react": "^19.0.0" } }
// ❌ 검증 없이 넓은 범위 — 미래 메이저 버전까지 보장하는 건 거짓말
{ "peerDependencies": { "eslint": ">=8" } }
// ✅ 실제 테스트한 범위만 명시적으로 나열
{ "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }
```

- 좁은 범위 → 호스트 프로젝트에서 버전 충돌 발생 확률 증가
- `>=`는 **아직 출시되지 않은 메이저 버전까지 지원을 약속**하는 것 — 테스트하지 않은 버전에 대한 보장은 사용자에게 거짓 안전감을 줌
- `^`와 `||`로 **실제 CI에서 검증한 범위만** 명시적으로 나열할 것
- 새 메이저가 나오면 테스트 후 범위에 추가하는 것이 올바른 프로세스

---

## 패키지 개발자로서 주의할 점 (3-2)

<style scoped>section { font-size: 18px; }</style>

### `0.x` 버전은 `^`의 동작이 다르다

semver에서 `0.x`는 "아직 안정되지 않은 API"를 의미하며, `^`의 범위 해석이 달라진다

```text
^1.2.3  →  >=1.2.3 <2.0.0   (메이저 고정)
^0.2.3  →  >=0.2.3 <0.3.0   (마이너 고정 — 훨씬 좁음!)
^0.0.3  →  >=0.0.3 <0.0.4   (패치 고정 — 사실상 고정 버전)
```

- `0.x`에서는 마이너 업데이트도 breaking change가 허용되므로, `^`가 마이너를 고정시킴
- react-native가 오래 `0.x`에 머물렀던 대표적 사례 — `^0.70.0`은 `>=0.70.0 <0.71.0`이므로, 패치만 허용하는 매우 좁은 범위
- 필요 시 `||`로 마이너 범위를 명시적으로 나열할 것

```json
// ❌ ^0.70.0은 0.70.x만 허용 — 의도보다 좁을 수 있음
{ "peerDependencies": { "react-native": "^0.70.0" } }
// ✅ 지원하는 마이너 범위를 명시적으로 나열
{ "peerDependencies": { "react-native": "^0.70.0 || ^0.71.0 || ^0.72.0 || ^0.73.0" } }
```

---

## 패키지 개발자로서 주의할 점 (3-3)

<style scoped>section { font-size: 22px; }</style>

### 선택적 peerDependencies 활용

```json
{
  "peerDependencies": {"webpack": "^5.0.0"},
  "peerDependenciesMeta": {
    "webpack": {"optional": true}
  }
}
```

- "webpack이 있으면 활용하고, 없어도 동작한다"는 의미
- 불필요한 설치 강요를 줄일 수 있음

---

## pnpm으로 올바르게 의존성 검사하기 (1)

<style scoped>section { font-size: 20px; }</style>

### pnpm의 의존성 관리

- npm/yarn의 flat `node_modules`와 달리, pnpm은 **symlink 기반** 구조
- phantom dependency를 **크게 줄여주지만**, `hoist=true`(기본값)에서는 일부 unlisted dependency 접근이 가능할 수 있음

```text
node_modules/
├── .pnpm/                    ← 실제 패키지가 여기에 설치됨
│   ├── lodash@4.17.21/
│   └── express@4.18.2/
├── lodash -> .pnpm/lodash@4.17.21/   ← symlink
└── express -> .pnpm/express@4.18.2/  ← symlink
```

```bash
# npm에서는 되지만 pnpm에서는 에러
import chalk from 'chalk'  // dependencies에 없으면 못 찾음
```

→ pnpm을 쓰는 것 자체가 의존성을 올바르게 선언하도록 **유도**하는 효과 (`hoist=false`로 더 엄격하게 설정 가능)

---

## pnpm으로 올바르게 의존성 검사하기 (2)

<style scoped>section { font-size: 20px; }</style>

### `.npmrc` 설정

```ini
# peerDependencies 자동 설치 끄기 (기본값: true, pnpm v8+)
# 자동 설치는 의도하지 않은 의존성이 슬금슬금 늘어나는 원인
auto-install-peers=false

# peerDependencies 누락/불일치 시 에러로 처리 (기본값: false)
# 개발자가 필요한 peerDep을 명시적으로 설치하도록 강제
strict-peer-dependencies=true
```

- `auto-install-peers=false` → 어떤 패키지가 설치되는지 개발자가 직접 인지하고 결정
- `strict-peer-dependencies=true` → 누락된 peerDep이 있으면 설치 자체가 실패하므로, 빠르게 발견 가능
- 이 조합이 **"무분별한 node_modules 비대화를 막으면서 의존성 누락도 잡는"** 가장 엄격한 설정

---

## pnpm으로 올바르게 의존성 검사하기 (2-2)

<style scoped>section { font-size: 18px; }</style>

### `pnpm catalog` — 모노레포에서 버전을 한 곳에서 관리

```yaml
# pnpm-workspace.yaml
catalog:
  react: ^19.0.0
  react-dom: ^19.0.0
  lodash: ^4.17.0
```

```json
// packages/ui/package.json
{
  "peerDependencies": { "react": "catalog:" },
  "devDependencies": { "react": "catalog:" }
}
// packages/hooks/package.json
{
  "peerDependencies": { "react": "catalog:" }
}
```

- 모든 workspace 패키지가 **동일한 버전 범위**를 참조 → peerDeps 충돌 원천 차단
- 버전 올릴 때 `pnpm-workspace.yaml` **한 곳만** 수정하면 전체 반영
- 단, **모노레포 내부에서만** 유효 — 외부에서 설치 시 resolve된 실제 버전이 들어감

---

### 유용한 검사 명령어

```bash
# 특정 패키지가 왜 설치되었는지 확인
pnpm why react

# 의존성 트리 확인
pnpm ls --depth 2

# 전체 의존성 중 outdated 확인
pnpm outdated

# 보안 취약점 검사
pnpm audit
```

---

## pnpm으로 올바르게 의존성 검사하기 (3)

<style scoped>section { font-size: 18px; }</style>

### `publint` — 패키징 구조 검사 도구

```bash
# 패키지 디렉토리에서 실행
pnpm dlx publint

# 결과 예시
┌ publint results
│ ✗ pkg.main is ./dist/index.js but the file does not exist.
│ ✗ pkg.module is ESM but pkg.type is not "module".
│ ✓ pkg.exports["."].import is valid
└ 1 error, 1 warning
```

- `exports` / `main` / `module` / `types` / 조건부 export 순서 등 **패키징 호환성**을 검사
- dependencies import 일치 검사가 아니라, **패키지 진입점과 파일 존재 여부** 중심

### `arethetypeswrong` (attw) — 타입 패키징 검사

```bash
pnpm dlx @arethetypeswrong/cli --pack .
```

- **타입 패키징과 모듈 해석 문제**를 검사 (CJS/ESM 타입 불일치 등)

---

## 정리

<style scoped>section { font-size: 20px; }</style>

| 필드               | 설치 시점     | 핵심 질문                                     |
| ------------------ | ------------- | --------------------------------------------- |
| `dependencies`     | 항상          | "이 패키지 없이 내 코드가 런타임에 돌아가나?" |
| `devDependencies`  | 개발 시에만   | "빌드/테스트/린트에만 필요한가?"              |
| `peerDependencies` | 호스트가 설치 | "호스트와 같은 인스턴스를 공유해야 하나?"     |

### 서비스 프로젝트

- deps vs devDeps 구분은 **관례적** 의미만 있음. 실질적 차이 없음.

### 패키지 개발

- deps/devDeps/peerDeps 구분이 **치명적으로 중요**
- 잘못 넣으면 사용자의 앱이 깨짐
- pnpm + `publint` + `attw`로 배포 전 반드시 검증할 것

---

<!-- _class: invert -->

## 한 줄 요약

> 서비스에서는 신경 안 써도 되지만,
> 패키지에서는 잘못 넣으면 **남의 앱이 터진다**.

---

## Q&A — 예상 질문

<!-- _class: invert -->

---

## Q. 모노레포에서 workspace 패키지끼리는 deps? peerDeps?

<style scoped>section { font-size: 20px; }</style>

**상황에 따라 다르다**

```json
// packages/ui (사내 디자인 시스템)
{
  "peerDependencies": {"react": "^19.0.0"},
  "dependencies": {"@my-company/tokens": "workspace:*"}
}
```

- **같은 모노레포 안의 유틸 패키지** → `dependencies` + `workspace:*`
  - 항상 함께 배포되고, 버전을 모노레포가 통제하므로 dependencies가 자연스러움
- **호스트가 직접 설치해야 하는 프레임워크** → `peerDependencies`
  - react, vue 등은 모노레포 안이라도 peerDeps가 맞음
- 핵심 기준: "이 패키지를 모노레포 **밖에서** 설치할 때, 누가 버전을 통제해야 하는가?"

---

## Q. `--legacy-peer-deps`를 계속 쓰고 있는데 괜찮나요?

<style scoped>section { font-size: 22px; }</style>

**괜찮지 않다.** 기술 부채를 쌓고 있는 것

```bash
npm install --legacy-peer-deps   # peerDeps 충돌을 무시
```

- 충돌을 무시한다 = 런타임에 **어떤 버전이 쓰일지 보장할 수 없다**
- React 18과 19가 공존하거나, ESLint 플러그인이 섞이는 등의 문제가 잠복
- 당장은 동작하지만, 어느 날 알 수 없는 에러로 터질 수 있음

**해결**: 충돌 원인을 찾아서 해소

```bash
npm ls react              # 어떤 패키지가 어떤 버전을 요구하는지 확인
npm explain react         # 왜 이 버전이 설치되었는지 추적
```

---

## Q. `--legacy-peer-deps` 대신 쓸 수 있는 것은?

<style scoped>section { font-size: 18px; }</style>

`overrides`(npm) / `resolutions`(yarn) / `pnpm.overrides`(pnpm)로 버전을 **명시적으로 해소**:

```json
// npm (package.json) — npm 8.3+
{
  "overrides": {
    "react": "^19.0.0",
    // 특정 패키지의 의존성만 교체할 수도 있음
    "some-ui-lib": {"react": "^19.0.0"}
  }
}
```

```json
// pnpm (package.json)
{
  "pnpm": {
    "overrides": {"react": "^19.0.0"},
    // peerDep 충돌만 허용하고 싶을 때 (설치 버전은 건드리지 않음)
    "peerDependencyRules": {
      "allowedVersions": {"react": "19"}
    }
  }
}
```

---

## Q. `--legacy-peer-deps` 대신 쓸 수 있는 것은? (2)

<style scoped>section { font-size: 20px; }</style>

```json
// yarn berry (package.json)
{"resolutions": {"react": "^19.0.0"}}
```

핵심 차이:

- `--legacy-peer-deps`는 충돌을 **무시** — 어떤 버전이 설치될지 비결정적
- `overrides`는 충돌을 **해소** — "이 버전을 써라"고 명시적으로 선언
- `overrides`는 `package.json`에 남으므로 **의도가 코드로 추적** 가능

---

## Q. 라이브러리가 peerDeps를 안 올려주면?

<style scoped>section { font-size: 18px; }</style>

예: 내 서비스는 React 19인데, 어떤 라이브러리가 `"react": "^18.0.0"`만 peerDep으로 선언

```text
npm warn ERESOLVE Could not resolve dependency:
npm warn peer react@"^18.0.0" from some-ui-lib@3.2.1
```

**매니저별 우회 수단은 있지만, 근본 해결은 upstream 이슈/PR**

| 매니저     | 방법                                  | 용도                      |
| ---------- | ------------------------------------- | ------------------------- |
| npm        | `overrides`                           | 의존성 트리 버전 교체     |
| pnpm       | `peerDependencyRules.allowedVersions` | 특정 peer 버전 허용       |
| pnpm       | `packageExtensions`                   | 누락/잘못된 peerDeps 보완 |
| yarn berry | `resolutions`                         | 버전 고정                 |

- 이런 설정은 라이브러리가 peerDeps를 업데이트해줄 때까지의 **임시 조치**
- 단, 실제로 호환되는지는 **사용하는 쪽이 검증할 책임**이 있음
- 라이브러리에 이슈/PR을 올려서 peerDeps 범위를 넓혀달라고 요청하는 것이 근본적 해결

---

## Q. dependencies와 peerDependencies에 같은 패키지를 넣으면?

<style scoped>section { font-size: 22px; }</style>

```json
{
  "dependencies": {"lodash": "^4.17.0"},
  "peerDependencies": {"lodash": "^4.17.0"}
}
```

**안티패턴이다.** peer + dev 조합을 사용할 것

---

## Q. `optionalDependencies`와 `peerDependenciesMeta.optional`의 차이는?

<style scoped>section { font-size: 18px; }</style>

이름이 비슷해서 혼동하기 쉽지만, **완전히 다른 메커니즘**

### `optionalDependencies` — 설치 실패를 허용

**"실패"란 구체적으로 무엇인가?**

- 현재 OS/아키텍처에 해당하는 바이너리가 **존재하지 않음** (예: Linux ARM용 패키지를 macOS에서 설치)
- 네이티브 애드온 **컴파일 실패** (예: `node-gyp` 빌드에 필요한 C++ 컴파일러가 없음)
- npm registry에서 해당 패키지를 **다운로드할 수 없음** (네트워크, 프라이빗 레지스트리 등)

이 중 하나라도 발생하면, `dependencies`는 전체 설치가 실패하지만, `optionalDependencies`는 **해당 패키지만 건너뛰고 설치를 계속**한다.

---

## `optionalDependencies` — `@swc/core` 사례

<style scoped>section { font-size: 18px; }</style>

```json
// @swc/core의 package.json (실제)
{
  "optionalDependencies": {
    "@swc/core-darwin-arm64": "1.15.24", // macOS Apple Silicon
    "@swc/core-darwin-x64": "1.15.24", // macOS Intel
    "@swc/core-linux-x64-gnu": "1.15.24", // Linux x64 (glibc)
    "@swc/core-linux-x64-musl": "1.15.24", // Linux x64 (musl/Alpine)
    "@swc/core-linux-arm64-gnu": "1.15.24", // Linux ARM64
    "@swc/core-win32-x64-msvc": "1.15.24" // Windows x64
    // ... 12개 플랫폼별 바이너리
  }
}
```

- `npm install @swc/core` → 12개 바이너리를 **모두 시도**하되, 현재 플랫폼과 맞지 않는 건 실패해도 넘어감
- macOS ARM에서 설치하면 `@swc/core-darwin-arm64`만 성공, 나머지 11개는 조용히 스킵
- 코드에서는 런타임에 맞는 바이너리를 `require()`로 로드하고, 없으면 fallback 처리

---

## `peerDependenciesMeta.optional` — `@swc/core` 사례

<style scoped>section { font-size: 20px; }</style>

같은 `@swc/core`에서 이것도 사용:

```json
// @swc/core의 package.json (실제)
{
  "peerDependencies": {"@swc/helpers": ">=0.5.17"},
  "peerDependenciesMeta": {"@swc/helpers": {"optional": true}}
}
```

- `@swc/helpers`는 SWC가 트랜스파일할 때 주입하는 런타임 헬퍼 (`_async_to_generator` 등)
- 모든 프로젝트가 이 헬퍼를 쓰는 건 아님 → **optional**로 선언
- 호스트가 `@swc/helpers`를 설치하지 않아도 경고/에러 없음
- 설치되어 있으면 SWC가 활용하고, 없으면 헬퍼를 인라인으로 생성

---

## 언제 어떤 걸 써야 하나?

<style scoped>section { font-size: 16px; }</style>

|           | `optionalDependencies` | `peerDependenciesMeta.optional` |
| --------- | ---------------------- | ------------------------------- |
| 설치 주체 | **패키지가** 설치 시도 | **호스트가** 직접 설치          |
| 실패 시   | 조용히 넘어감          | 경고/에러 없음                  |
| 주요 용도 | 플랫폼 종속 바이너리   | 선택적 통합                     |
| 버전 통제 | 패키지 팀              | 호스트(서비스 팀)               |

### `@swc/core`로 보는 실전 예시

```json
{
  // 플랫폼별 네이티브 바이너리 — 패키지가 설치를 시도하고, 실패하면 스킵
  "optionalDependencies": {
    "@swc/core-darwin-arm64": "1.15.24",
    "@swc/core-linux-x64-gnu": "1.15.24"
  },
  // 런타임 헬퍼 — 호스트가 설치 여부를 결정
  "peerDependencies": {"@swc/helpers": ">=0.5.17"},
  "peerDependenciesMeta": {"@swc/helpers": {"optional": true}}
}
```

- `optionalDependencies`: "내가 깔아볼 테니, 안 되면 말고" (설치 주체 = 패키지)
- `peerDependenciesMeta.optional`: "필요하면 네가 깔아, 안 깔아도 됨" (설치 주체 = 호스트)

---

## Q. React 19 신규 API를 쓰려면 peerDeps 범위를 좁혀야 하지 않나요?

<style scoped>section { font-size: 18px; }</style>

**꼭 그렇지는 않다.** feature detection으로 넓은 범위를 유지하면서 신규 API를 활용할 수 있음

```js
import * as React from 'react'

// React 18+에서만 존재하는 API
const useStore =
  typeof React.useSyncExternalStore === 'function'
    ? React.useSyncExternalStore
    : require('./shims/useSyncExternalStore') // React 17 이하 폴백

// React 19 전용
function usePromise(promise) {
  if (typeof React.use === 'function') return React.use(promise)
  // React 18 이하 폴백...
}
```

```json
// peerDeps는 넓게 유지
{"peerDependencies": {"react": "^17.0.0 || ^18.0.0 || ^19.0.0"}}
```

- 사용자의 React 업그레이드를 강제하지 않음 → **점진적 개선**

---

## Q. 그래도 peerDeps 범위를 올려야 하는 경우는?

<style scoped>section { font-size: 22px; }</style>

- 신규 API가 핵심 동작에 **필수적**이고, 폴백이 불가능하거나 의미 없을 때
- breaking change로 인해 구버전 동시 지원이 **코드 복잡성 대비 가치가 없을 때**

> 버전 범위를 좁히는 건 최후의 수단. **feature detection이 가능하면 먼저 시도할 것**

---

## Q. 특정 버전에 버그가 있으면 peerDeps에서 제외해야 하나요?

<style scoped>section { font-size: 20px; }</style>

**아니요.** peerDeps는 그런 용도가 아닙니다.

```json
// ❌ 이렇게 하고 싶은 유혹 (실제로 이런 negation 문법은 semver에 존재하지 않음)
{"peerDependencies": {"react": ">=19.0.0 <19.0.1 || >19.0.1"}}
```

**이런 식으로 특정 버전을 제외하면 안 되는 이유:**

- peerDeps는 **호환 가능한 버전 범위**를 선언하는 것이지, 특정 버전의 버그를 차단하는 도구가 아님
- 그 버그가 내 패키지와 관련 없는 곳에서 발생한 것일 수 있음
- 해당 버전이 패치되면 의미 없는 제약만 남음
- 제외할 버전이 쌓이면 peerDeps가 점점 복잡해지고 관리 불가능

**대신:**

- **README / CHANGELOG**에 "X 버전에 알려진 이슈가 있으니 Y 이상으로 업데이트를 권장합니다" 안내
- 심각한 경우 **GitHub 이슈에 pinned comment**로 고정
- peerDeps는 넓게 유지하되, **문서로 소통**하는 것이 올바른 접근

---

## Q. 한 패키지가 여러 버전으로 설치되면 번들러는 어떤 버전을 선택하나요?

<style scoped>section { font-size: 18px; }</style>

번들러는 Node.js module resolution 알고리즘을 따름 — `import`하는 파일 위치에서 **가장 가까운 `node_modules`** 의 버전을 선택

```text
node_modules/
├── lodash@4.17.21/          ← 최상위에 hoisting된 버전
├── A/                       ← lodash@4를 import → 최상위 4.17.21 사용
└── B/
    └── node_modules/
        └── lodash@3.10.0/   ← B가 lodash@3을 import → 이 버전 사용
```

- flat hoisting(npm/yarn)에서는 먼저 설치된 버전이 최상위에 올라감 → 나머지는 nested
- pnpm은 symlink 구조라 각 패키지가 **자기 peerDeps에 맞는 버전만** 접근 가능하므로 더 명확
- **번들 크기 외 문제**: React가 두 벌이면 hooks 에러, context 공유 불가 등 **런타임 버그** 발생 (앞서 mocha 사례 참고)
- 싱글턴이어야 하는 패키지가 두 벌 → **디버깅이 극히 어려운** 유형의 버그

---

## Q. `bundleDependencies`는 실제로 사용한 적 있나요?

<style scoped>section { font-size: 20px; }</style>

**사용한 적 없다.** 현대 패키지 개발에서 쓸 일이 거의 없는 레거시 기능

```json
{
  "bundleDependencies": ["lodash", "express"]
}
```

- `npm pack` 시 해당 의존성을 tarball 안에 **함께 포함**시키는 기능
- 용도: 프라이빗 레지스트리 접근 불가 환경, 오프라인 설치, 특정 버전 고정이 필수인 극단적 상황
- npm, yarn, pnpm 모두 lockfile과 `overrides`가 충분히 발전하여 **이 기능 없이도 버전 통제가 가능**
- 패키지 tarball 크기만 키우므로 일반적으로 권장하지 않음

---

## Q. peerDependencies 경고를 모두 해결하는 게 현실적으로 가능한가요?

<style scoped>section { font-size: 18px; }</style>

**가능하다.** 다만 참여자 모두의 책임이 전제되어야 한다

### 원칙 1 — 패키지 개발자의 책임

- peerDeps **버전 범위를 최대한 넓게** 잡아야 함 (`^17 || ^18 || ^19`)
- 새 메이저가 나오면 CI에서 검증 후 **빠르게 범위에 추가**
- 좁은 범위는 소비자에게 불필요한 충돌을 강요하는 것

### 원칙 2 — 소비자(서비스 개발자)의 책임

- peerDeps 경고를 **무시하지 않고** 항상 의구심을 가질 것
- 범위가 좁거나 업데이트가 안 된 패키지에는 **적극적으로 이슈/PR 제기**
- "경고니까 괜찮겠지"가 아니라, "왜 이 경고가 뜨는지" 추적

### 원칙 3 — 그럼에도 해결 안 되면

- `overrides`(npm) / `resolutions`(yarn) / `pnpm.overrides`로 **명시적으로 해소**
- 이는 `--legacy-peer-deps`와 달리 의도가 코드에 남고, 추적 가능

---

## Q. 라이브러리에서 peerDeps를 정의해도 사용처 npm 버전에 따라 강제가 안 되지 않나요?

<style scoped>section { font-size: 20px; }</style>

**맞다. 그리고 그건 라이브러리의 책임 범위가 아니다.**

- npm 3~6은 경고만, npm 7+는 에러, yarn은 또 다름 — 패키지 매니저별 동작 차이표 참고
- `engines`로 npm 버전을 강제하거나 `postinstall`로 체크하는 방법이 있긴 하지만:
  - `engines`는 패키지 매니저 버전을 강제하는 것이지, peerDeps 자체를 강제하는 게 아님
  - `postinstall` 스크립트는 사용자 경험이 나쁘고, 보안상 꺼져 있는 환경도 많음
- **라이브러리가 할 수 있는 것**: peerDeps를 정확하게 선언하고, README에 명시하는 것까지
- **사용처의 패키지 매니저 동작까지 통제하는 것은 범위 밖**

---

## Q. `--legacy-peer-deps`를 어쩔 수 없이 사용한 경험이 있나요?

<style scoped>section { font-size: 22px; }</style>

**없다.**

- 충돌이 발생하면 원인을 추적해서 `overrides`로 해소하거나, 라이브러리에 이슈를 올려 해결해왔음
- `--legacy-peer-deps`는 충돌을 **무시**하는 것이므로, 어떤 버전이 설치될지 비결정적
- 당장 돌아가더라도 **시한폭탄**을 심는 것과 같음

앞서 설명한 대안을 다시 정리하면:

| 상황                          | 해결 방법                              |
| ----------------------------- | -------------------------------------- |
| 라이브러리가 peerDeps 안 올림 | upstream에 이슈/PR                     |
| 기다릴 수 없음                | `overrides`로 명시적 해소              |
| pnpm 환경                     | `peerDependencyRules.allowedVersions`  |
| 근본적으로                    | 충돌 원인을 `npm ls`/`pnpm why`로 추적 |

---

## Q. `@types/react`가 여러 메이저 버전으로 설치되면 문제가 되나요?

<style scoped>section { font-size: 18px; }</style>

**된다.** `@types/react`도 사실상 **싱글턴이 보장되어야 하는 패키지**

```text
node_modules/
├── @types/react@19/          ← 내 프로젝트
└── some-ui-lib/
    └── node_modules/
        └── @types/react@18/  ← 라이브러리가 가져온 다른 버전
```

- **타입 충돌**: `@types/react@18`과 `@types/react@19`의 인터페이스 시그니처가 다름 → duplicate identifier 에러
- **API 유무 불일치**: `@types/react@18`에는 `React.use()`가 없고, `@types/react@19`에는 있음 → 어떤 파일에서 어떤 버전이 잡히느냐에 따라 타입 체크 결과가 달라짐
- **비결정적**: hoisting 결과에 따라 **환경마다 다른 에러**가 발생할 수 있어 디버깅이 극히 어려움

런타임이 아니라 **타입 레벨에서 깨지는 문제**이므로, 앞서 설명한 "`@types/*`를 `dependencies`에 넣지 말 것" 원칙과 직결됨

---

<!-- _class: invert -->

# 감사합니다

@yceffort

---

# 'use client'는 무슨일을 하는걸까?

Source: https://research.yceffort.kr/slides/use-server-user-client.md

---
title: "'use client'는 무슨일을 하는걸까?"
marp: true
paginate: true
theme: default
tags:
  - react
  - nextjs
date: 2025-05-15
description: '🤔'
published: true
art:
  undraw: web-app
---

# `'use client'` & `'use server'` 제대로 이해하기 🚀

[블로그 "What Does 'use client' Do?" (overreacted.io) 요약](https://overreacted.io/what-does-use-client-do/)

---

## 🤔 기존 클라이언트-서버 통신, 뭐가 문제였을까?

- **API 라우트 정의:** 서버에 `/api/user` 같은 엔드포인트 생성
- **`Workspace` 호출:** 클라이언트에서 문자열 기반 URL로 `Workspace` 사용

  ```javascript
  // Client
  fetch('/api/user', {/* ... */})
  ```

- **분리된 프로그램:** 클라이언트와 서버는 완전히 별개의 세상
- **문자열 기반 타입:** 함수 호출이 아닌, 문자열 (URL)에 의존
- **번거로운 데이터 전달:** 서버에서 클라이언트로 초기 데이터 전달 시 `<script>` 태그에 JSON 주입 등

---

## ✨ 새로운 패러다임: "하나의 프로그램"

`'use client'`와 `'use server'` 지시어는...

- 클라이언트와 서버를 **하나의 프로그램**처럼 모델링
- 네트워크 경계를 **모듈 시스템** 안으로
- **타입 안전성**과 **정적 분석**의 이점

> 저자의 주장: 이 지시어들은 구조화 프로그래밍, 일급 함수, `async/await` 급의 혁신! (본인피셜)

---

## `🚀 'use server'` : 서버 함수를 클라이언트에

서버 모듈 상단에 `'use server';`

```javascript
// /actions/updateUsername.js (Server)
'use server'

export async function updateUsername(userId, newName) {
  // ... DB 업데이트 로직 ...
  return {success: true, newName}
}
```

이렇게 써둔다면...?

---

## 클라이언트에서 그냥 `import` 해서 사용

```javascript
// MyComponent.jsx (Client)
import {updateUsername} from './actions/updateUsername'

async function handleClick() {
  const result = await updateUsername('user123', '새이름')
  console.log(result.newName) // '새이름'
}
```

- **동작 원리:** 클라이언트의 `import`는 실제론 서버 함수를 호출하는 **타입화된 `Workspace` (RPC) 프록시**를 생성.
- **장점:** 코드 간결화, 타입 안전성, "모든 참조 찾기" 등 개발 도구 활용 극대화

---

- 타입화된 `Workspace` (RPC) 프록시: 클라이언트 측에 존재하는 대리 함수로서, 호출 시 타입 시스템의 보호를 받으며 마치 로컬 함수처럼 원격 서버의 특정 함수를 네트워크 요청(마치 Workspace처럼)을 통해 실행시키는 중간 매개체
- 한국어로 된 사이트에서 물건의 스펙(타입)을 보고 주문(함수 호출) 하지만, 실제로는 대행 사이트가 해외 판매처(서버)에 요청(RPC, 네트워크 통신) 을 해서 물건을 가져다 주는느낌
- RPC (Remote Procedure Call - 원격 프로시저 호출): 별도의 원격 제어를 위한 코딩 없이 다른 주소 공간에서 함수나 프로시저를 실행할 수 있게하는 프로세스 간 통신 기술

---

## `🎨 'use client'` : 클라이언트 컴포넌트를 서버에

### 클라이언트 컴포넌트 모듈 상단에 `'use client';`

```javascript
// /components/LikeButton.jsx (Client)
'use client'

export function LikeButton({initialLikes}) {
  const [likes, setLikes] = useState(initialLikes)
  // ... 클릭 핸들러 등 ...
  return <button onClick={handleClick}>{likes} Likes</button>
}
```

---

<style scoped>section { font-size: 22px; }</style>

### 서버(예: RSC)에서 `import` 해서 JSX처럼 사용

```javascript
// app/page.jsx (Server Component)
import { LikeButton } from './components/LikeButton';

export default function Page() {
  const initialLikes = await getLikesFromDB();
  return (
    <div>
      <h1>My Post</h1>
      <LikeButton initialLikes={initialLikes} />
    </div>
  );
}
```

- **동작 원리:** 서버의 `import`는 실제 컴포넌트 코드가 아닌 **"클라이언트 참조"** (예: `/components/LikeButton.jsx#LikeButton`)를 가져옴. 이는 최종적으로 클라이언트에 `<script>` 태그와 직렬화된 props로 전달됨.
- **장점:** 서버 렌더링(초기 HTML) + 클라이언트 인터랙션의 자연스러운 결합, 타입 안전성.

---

## 🚪 두 개의 세계, 두 개의 문

`'use client'`와 `'use server'`는 코드를 "표시"하는 것이 아니라, 두 환경 사이에 **"문"을 여는 것!**

- `'use server'`: **클라이언트 → 서버**로의 문 (서버 함수 호출)
  - 프론트엔드는 서버 함수를 HTTP 호출하는 `async` 함수로 인식.
- `'use client'`: **서버 → 클라이언트**로의 문 (클라이언트 코드/컴포넌트 전송)
  - 백엔드는 클라이언트 코드를 `<script>` 태그로 변환될 수 있는 참조로 인식.
- 'use client' === `<script />`
- 'use server' === `fetch()`

---

## 🌟 핵심 비전 및 이점

- **단일 프로그램 모델:** 클라이언트/서버 앱을 두 환경에 걸친 하나의 프로그램으로.
- **모듈 시스템 내 네트워크 추상화:** `import`를 통해 네트워크 경계를 자연스럽게.
- **리액트를 넘어서:** 자바스크립트, 나아가 다른 언어에도 적용 가능한 패턴.
- **개발 경험 향상:** 타입 안전성, 도구 지원, 모듈화 용이.
- **"두 세계를 엮다":** 양쪽 환경의 로직을 가진 재사용 가능한 추상화 구성.

---

## 💡 결론

- `'use client'`와 `'use server'`는 단순히 리액트 서버 컴포넌트의 일부가 아니라, 클라이언트-서버 아키텍처의 **근본적인 변화**를 가져오는 개념
- 이들은 클라이언트와 서버 간의 **상호작용 방식을 근본적으로 바꾸는** 강력한 컨셉
- 네트워크의 복잡성을 추상화하고, 개발자가 **하나의 통합된 애플리케이션**을 구축하는 데 집중할 수 있도록 돕는다.

> **이것이 모듈 시스템 레벨의 RPC + 클라이언트에 코드 보내기의 혁신?!**

---

## `'use client'` 및 `'use server'` 비판적 시각

`'use client'`와 `'use server'` 지시어 및 그 철학은 클라이언트-서버 개발의 새로운 가능성을 보여주지만, 몇 가지 비판적인 관점과 잠재적인 문제점도 고려해볼 필요가 있다.

---

## 1. **추상화의 이면 (Leaky Abstraction) 및 복잡성 증가:**

- **숨겨진 복잡성:** `import` 한 줄로 네트워크 통신을 처리하는 것은 간결해 보이지만, 그 이면에는 RPC, 직렬화, 역직렬화, 오류 처리, 청크 로딩 등 복잡한 메커니즘이 숨어있음. 문제가 발생했을 때, 이 추상화 계층 때문에 디버깅이 어려워짐. 개발자는 여전히 네트워크의 본질을 이해해야한다.
- **새로운 학습 곡선:** 문법은 단순하지만, 이 모델이 실제로 어떻게 동작하는지, 어떤 경우에 사용하고 어떤 경우에 사용하지 말아야 하는지, 상태는 어떻게 동기화되는지 등 새로운 패러다임에 대한 깊은 이해가 필요. 이는 또 다른 학습 비용.

---

## 2. **강한 결합도 (Tight Coupling):**

- **"단일 프로그램"의 함정:** 클라이언트와 서버를 "하나의 프로그램"으로 간주하는 것은 양날의 검. 이는 두 환경 간의 결합도를 높여, 한쪽의 변경이 다른 쪽에 예기치 않은 영향을 미칠 가능성을 키움.
- **독립적인 진화의 어려움:** 서버 API를 여러 종류의 클라이언트(웹, 모바일 앱, 외부 서비스 등)에 제공해야 하는 경우, 특정 프레임워크의 `import` 시스템에 강하게 결합된 방식은 유연성을 저해할 수 있음. 전통적인 REST나 GraphQL API가 제공하는 명확한 경계와 느슨한 결합이 더 유리할 수 있음.

---

## 3. **도구 및 프레임워크 의존성 (일종의 마법):**

- **빌드 시스템 의존:** `'use client'`나 `'use server'`는 특정 빌드 도구(예: Next.js의 웹팩/터보팩 설정)의 "마법"에 크게 의존. 이 도구 없이는 단순한 주석에 불과하며, 해당 생태계를 벗어나기 어렵게 만드는 일종의 벤더 종속성을 야기할 수 있음.
- **표준의 부재:** 아직 웹 표준이 아니므로, 특정 프레임워크나 라이브러리에 국한된 기능일 수 있음. 이는 장기적인 유지보수나 기술 스택 변경 시 위험 요소가 될 수 있음.

---

## 4. **디버깅 및 오류 추적의 어려움:**

- **모호한 오류 지점:** 클라이언트에서 서버 함수 호출 시 발생하는 오류가 네트워크 문제인지, 직렬화 문제인지, 서버 로직의 문제인지, 아니면 프레임워크 내부의 문제인지 파악하기 어려울 수 있음.

---

## 아래 코드에서 에러가 난다면 뭐가 문젤까?

```js
import {someServerFunction} from './serverActions'

try {
  const result = await someServerFunction(data)
  // 성공 로직
} catch (error) {
  // 오류 처리
  console.error('서버 함수 호출 오류:', error)
}
```

- 네트워크? 직렬화? 그냥 서버액션이 잘못됨? 프레임워크 내부 이슈? 인증?
- 파악해야할 지점이 너무 많음

---

<style scoped>section { font-size: 22px; }</style>

## 그래도 이건 뭐가 문제인지 알기는 쉽지

```js
// 클라이언트 측 코드
try {
  const response = await fetch('/api/someEndpoint', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(data),
  })

  if (!response.ok) {
    // HTTP 상태 코드가 200-299 범위가 아닐 때
    // (예: 404 Not Found, 500 Internal Server Error 등)
    // 여기서 오류는 "서버 엔드포인트" 자체와의 통신에서 발생했음을 알 수 있음
    throw new Error(`HTTP error! status: ${response.status}`)
  }
  const result = await response.json()
  // 성공 로직
} catch (error) {
  // 오류 처리
  console.error('API 호출 오류:', error)
  // 만약 fetch 자체가 실패했다면 (예: 네트워크 연결 없음)
  // error 객체는 보통 TypeError를 가지고 있으며, 이걸로 파악가능
}
```

---

<style scoped>section { font-size: 22px; }</style>

## 5. **직렬화의 한계 및 오버헤드:**

- **전송 가능한 데이터 제약:** 클라이언트와 서버 간에 전달되는 모든 데이터는 직렬화 가능해야 함. 이는 기존에도 마찬가지였지만, 추상화로 인해 개발자가 이 제약을 간과하기 쉬워지며, 함수나 복잡한 객체 (Date, Map, Set 등) 전달 시 예기치 않은 문제를 겪을 수 있음.
- **성능 고려:** 자동으로 처리되는 직렬화/역직렬화 과정이 빈번하거나 데이터 크기가 클 경우 성능에 영향을 줄 수 있음.
- 엥 근데 rest api 도 직렬화 해야 하는거 아님?
  - REST API (명시적): 개발자가 Workspace를 사용하고 JSON.stringify()로 요청 본문을 만들거나, response.json()으로 응답을 파싱하는 등 직렬화/역직렬화 과정을 비교적 명시적으로 인지하고 코드를 작성하는 경우가 대부분. 어떤 데이터가 어떻게 변환되는지, 어떤 형식으로 주고받는지에 대해 더 직접적으로 관여.
  - 'use server' (암묵적/추상화): 프레임워크가 직렬화/역직렬화 과정을 내부적으로 "마법처럼" 처리. 개발자는 마치 로컬 함수를 호출하듯 코드를 작성할 수 있어 편리하지만, 이 과정이 숨겨져 있기 때문에 어떤 값이 직렬화 가능한지, 어떤 값이 문제를 일으킬 수 있는지에 대한 고민을 간과하기 쉬울 수 있음. 이 과정이 추상화 되어 있어서 개발자가 간과하고 실수하기 쉬움

---

## 6. **보안 고려사항 증가:**

- `'use server'`를 통해 서버 함수를 클라이언트에서 쉽게 호출할 수 있게 되면, 의도치 않게 내부 로직이나 민감한 작업을 수행하는 함수가 노출될 위험이 있음.
- 물론 이는 개발자의 주의가 필요한 부분이지만, 추상화로 인해 경계가 모호해지면서 실수의 가능성이 커질 수 있음.
- 입력값 검증 및 권한 부여 로직이 더욱 중요.

---

## 결론의 결론

- `'use client'`와 `'use server'`는 특정 사용 사례(특히 풀스택 프레임워크 내에서의 긴밀한 클라이언트-서버 통합)에서 개발 경험을 크게 향상시킬 잠재력을 가지고 있음.
- 하지만 이것이 모든 문제를 해결하는 "은총알"은 아니며, 도입 시 위에서 언급된 복잡성, 결합도, 의존성, 디버깅 문제 등을 충분히 고려해야 한다.
- 프로젝트의 특성, 팀의 경험, 장기적인 유지보수 계획 등을 종합적으로 검토하여 신중하게 접근할 필요가 있음.
- "마법"처럼 보이는 편리함 뒤에는 항상 그에 상응하는 비용이나 제약이 따를 수 있음을 인지해야 함.

---

# 패키지 가이드 3탄) 패키지 제작을 위한 가이드

Source: https://research.yceffort.kr/slides/best-practice-for-package-3.md

---
title: 패키지 가이드 3탄) 패키지 제작을 위한 가이드
marp: true
paginate: true
theme: default
tags:
  - npm
  - bundler
  - web-performance
date: 2025-03-28
description: '🤔'
published: true
art:
  undraw: guidelines
---

# 패키지 가이드 3탄) 패키지 제작을 위한 가이드

<!-- _class: invert -->

@yceffort

---

## 가능한 파일 구조와 빌드 구조는 유지하는 것이 좋다

<style scoped>section { font-size: 22px; }</style>

- 일반적인 개발자라면 파일 구조를 부수효과를 최소화 하는 조건으로 격리해둘 것임
- 이 구조를 빌드 결과물에 그대로 반영한다면, 파일별로 부수효과를 격리할 수 있고, 트리쉐이킹을 원활하게 할 수 있으며, 나아가 `sideEffects` 필드를 적절하게 활용하는 것도 가능
- 다만 다음과 같은 예외도 존재
  - 배럴파일로 내보내진 CommonJS 의 경우: 배럴파일에서 이미 `require`가 발생하였고, 그 순간 부터 정적 분석이 불가능해지므로 파일구조를 쪼개는 것이 의미가 없어짐. 단, `date-fns/something`과 같이 하위 디렉토리로 나누어져 있다면 파일별로 쪼개는 것이 의미가 있음
  - 브라우저에서 직접 불러와서 사용하는 경우: (<https://unpkg.com/react@18/umd/react.production.min.js>)
    - 파일이 쪼개져있다면 그만큼 요청 수가 증가하여 오버헤드가 발생
    - CDN 은 정적 자원이므로 파일 하나로 만들어두면 한번의 캐싱만으로 사용 가능
    - 모든 자원을 하나로 합쳐두면, 중복된 토큰, 변수명, 코드 패턴 등을 효과적으로 압축할 수 있음

---

## 코드 난독화 (압축)은 선택적으로, 필수는 아님

<style scoped>section { font-size: 22px; }</style>

- `Terser`, `ESBuild` 등을 사용하면 간단한 설정만으로도 코드를 90% 이상 압축할 수 있어 다운로드 속도가 빨라지고 코드를 최적화 할 수 있음
- constant inlining 과 같은 고급 기능은 서비스 번들러인 웹팩에서는 잘되지 않고, 롤업 등 고오급 번들러만 가능하므로 어느 정도 라이브러리가 미리 압축해주는 것도 좋은 전략이 될 수 있음
- 그러나 난독화 및 압축이 꼭 좋은 것 만은 아님
  - 일반적인 압축은 서비스 번들러에서 이미 대부분 수행하므로, 굳이 미리 할 필요가 없음
  - 코드가 난독화 되어 있으면 서비스에서 발생한 패키지 문제를 디버깅하기 매우 까다로움
- 압축이 필요한 경우
  - 보안 민감도가 높은 패키지 (물론 이건 어디까지나 예방차원일 뿐, 100% 방지는 절대안됨)
  - 브라우저에서 직접 불러와서 사용되는 패키지 (CDN)
  - 사용자가 굳이 코드를 따라서 읽지 않아도 되거나 읽을 필요가 없는 패키지 (like 리액트)

---

## 프레임워크는 절대 패키지에 포함시키면 안된다

- 리액트, 뷰와 같은 프레임워크 패키지, 혹은 그에 준하는 패키지들은 절대 패키지에 포함시키면 안됨
- 이 밖에도 서비스에 '단 하나' 만 설치되기 원하는 패키지의 경우에도 마찬가지
- `peerDependencies`를 사용하여 해당 패키지가 프레임워크에 의존하고 있다는 것을 선언하는 것 만으로도 충분

---

## 모던 브라우저를 타겟으로 빌드

- 지원해야할 폴리필이 많고 트랜스파일이 많아진다는 것은 그만큼 패키지의 성능을 키우는 셈
- 사용자 중 매우 '일부' 가 사용하는 낮은 브라우저를 위해서 '대다수' 의 사용자에게 성능 저하를 주는 것은 바람직하지 않음
- 꼭 필요하다면 모던 브라우저 용, 리거시 브라우저 용 으로 나눠서 제공하는 것이 바람직
- `browserslist` 및 `tsconfig`의 `target` 필드를 사용하여 가능한 최신 브라우저를 타겟으로 빌드하고, 패키지를 설치하는 사용자들로 하여금 이 패키지가 어떤 브라우저를 타겟으로 만들어 졌는지 명시적으로 알 수 있도록 하는 것이 좋음
  - <https://ko.legacy.reactjs.org/docs/javascript-environment-requirements.html>

---

## CDN 으로 제공하는 것을 고려한다면

<style scoped>section { font-size: 18px; }</style>

- 대표적인 CDN인 [unpkg](https://unpkg.com/) 와 [jsdelivr](https://www.jsdelivr.com/) 를 사용하여 패키지를 제공하는 것을 고려해보는 것도 좋음
- 두 CDN 에서 패키지를 제공하고 싶다면, `package.json`에 다음과 같이 선언해주면 됨

  ```json
  {
    "unpkg": "./dist/index.umd.js",
    "jsdelivr": "./dist/index.umd.js"
  }
  ```

  ```js
  ;(function (c, x) {
    if (typeof exports === 'object' && typeof module !== 'undefined') {
      // ✅ CommonJS 환경 (예: Node.js)
      x(exports)
    } else if (typeof define === 'function' && define.amd) {
      // ✅ AMD 환경 (예: RequireJS)
      define(['exports'], x)
    } else {
      // ✅ 전역 환경 (예: <script> 태그로 직접 로딩하는 브라우저)
      c = c || self
      x((c.React = {}))
    }
  })(this, function (c) {
    // 여기부터 실제 React 구현
  })
  ```

---

## CSS 파일을 분리하기

- bootstrap, tailwindcss 와 같은 css 라이브러리는 모든 기능을 가지고 있는 하나의 CSS 번들을 제공하는 것이 더 쉬울 수도 있음
- 그러나 CSS 파일이 하나로 합쳐져 있다면, 사용자가 사용하지 않는 CSS 도 함께 제공되서 사잌즈가 매우 커질 수 있음
- 그래서 일반적인 경우에는 다음과 같이 원하는 스타일만 불러올 수 있도록 함
  - [bootstrap](https://getbootstrap.com/docs/5.2/customize/optimize/)
  - [tawilwindcss](https://v3.tailwindcss.com/docs/optimizing-for-production)
- 만약 CSS 가 패키지의 일부 정도 수준이라면, 각 컴포넌트마다 CSS 를 따로 분리해서 별도로 제공하고, 컴포넌트를 사용할 때 마다 해당 스타일을 불러오도록 설정하는 것이 이상적

---

# 패키지 가이드 2탄) 패키지 제작을 위한 타입 제공 가이드

Source: https://research.yceffort.kr/slides/best-practice-for-package-2.md

---
title: 패키지 가이드 2탄) 패키지 제작을 위한 타입 제공 가이드
marp: true
paginate: true
theme: default
tags:
  - typescript
  - npm
date: 2025-03-25
description: '생각보다 잘 모르고 있던 타입스크립트'
published: true
art:
  undraw: markdown-file
---

# 패키지 가이드 2탄) 패키지 제작을 위한 타입 제공 가이드

> with attw cli

<!-- _class: invert -->

@yceffort

---

## are the types wrong (aka attw)

- 타입스크립트가 사실상의 표준이 된 지금, 제대로 된 타입을 제공하지 못하면 사용자들로 부터 외면 받을 수 밖에 없음
- `@types/`와 같은 [DefinitelyTyped](https://github.com/DefinitelyTyped/DefinitelyTyped) 패키지를 통해서 제공하는 방법도 있지만, 패키지 크기 나 내용이 너무나 거대해져서 다루기 어렵고, 사용자 역시 원하는 타입이 필요하다면 별도의 설치가 필요하다는 명확한 단점이 존재
- 앞서 1장에서 살펴보았던 것 처럼, 단순히 타입만 제공해서 되는 것이 아니라, 사용자의 모듈 시스템에 맞는 타입을 적재 적소에 배치하는 것이 중요
- `package.json`의 패키지 내용에 대한 타입을 제공하기 위한 가이드, [attw](https://arethetypeswrong.github.io/)

> attw 가 제공하는 지침에 대해서 간단하게 살펴보겠습니다

---

<style scoped>section { font-size: 20px; }</style>

## 💀 Resolution failed

- `package.json`에 선언한 파일 중, 타입스크립트가 이해할 수 없는 확장자가 존재하거나 이해할 수 없는 구조로 배치되어 있는 경우 해당 오류 발생
- `.css` 와 같이 타입스크립트가 해석할 수 없는 파일이지만, 번들러가 적절히 처리할 수 있어 패키지 입장에서 내보내야 하는 파일인 경우에는 오탐으로 인식될 수 있음
- `moduleResolution: node` (`node10`)을 지원하고자 하는 경우, 해당 알고리즘으로는 `package.json`의 `exports` 을 읽을 수 없어 에러가 발생할 수 있음. `moduleResolution: node`을 지원해야 하는 경우, 서브 패스 방식에 대해서 검토해보거나 `typeVersions`을 사용해 볼 수도 있음

```jsonc
{
  "name": "pkg",
  "main": "./dist/index.js",
  "exports": {
    ".": "./dist/index.js",
    // moduleResolution: node 으로는 여기에 닿을 수 없음.
    "./subpath": "./dist/subpath.js",
  },
}
```

---

## ❌ No types

- 자바스크립트 파일을 찾았지만, 이에 해당하는 타입을 찾는 경우 해당 오류 발생
- 타입스크립트는 node_modules 에서 제공하는 자바스크립트 파일을 분석하지 않고, 자바스크립트 파일을 찾으면 해당하는 타입 파일만 찾아서 분석하기에 발생하는 문제

```jsonc
{
  "name": "pkg",
  "main": "./index.js",
  "types": "./index.d.ts",
  "exports": {
    // 이렇게 되어 있다면, 타입 파일인 ./index.m.dts 를 찾으려고 시도할 것임
    // 실제로 파일이 존재한다면 상관없지만, 파일이 존재하지 않는다면 에러 발생
    // 파일이 존재하더라도, nodejs 모듈 알고리즘과 동일하게 타입 파일을 선언해준다면 굳이 fs 로 파일을 찾지 않아도 됨
    "import": "./index.mjs",
    "require": "./index.js",
  },
}
```

---

## 🎭 Masquerading as CJS

<style scoped>section { font-size: 20px; }</style>

- CommonJS 타입 파일을 찾았지만, 해당하는 자바스크립트 파일은 ESModule로 작성되어 있는 경우 해당 오류 발생
- `moduleResolution: node16`이라면
  - `.d.mts` 파일은 `.mjs` 파일을 찾으려고 시도 (ESModule 로 간주)
  - `.d.cts` 파일은 `.cjs` 파일을 찾으려고 시도 (CommonJS 로 간주)
  - `.d.ts` 파일은 `package.json` `type`필드의 값을 보고 결정
- 타입스크립트는 타입스크립트가 만든 선언파일 (.d.ts)과 Node.js 가 실제로 불러온 js 파일 (.js)가 동일한 모듈 형식을 공유한다는 전제로 동작

```json
{
  "name": "pkg",
  "exports": {
    ".": {
      // 타입스크립트가 .d.ts 를 보고, type 필드도 없기 때문에 CommonJS 로 간주
      // 하지만 Node.js 상에서 import 로 모듈을 불러오는 경우, `import` 필드의 확장자를 봄으로 ESModule 로 간주
      // 불일치 발생
      "types": "./index.d.ts",
      "import": "./index.mjs",
      "require": "./index.js"
    }
  }
}
```

---

<style scoped>section { font-size: 14px; }</style>

- `.d.ts`가 다음과 같이 선언되어 있다고 가정

  ```ts
  export declare const a: string
  export declare function b(): number
  ```

- 타입스크립트는 이를 `CommonJS`로 간주하고, 자바스크립트 파일이 다음과 같이 있다고 상상해보자

  ```js
  Object.defineProperty(exports, '__esModule', {value: true})
  exports.a = '...'
  exports.b = function b() {
    /* ... */
  }
  ```

- 그렇다면 Nodejs 에서는 다음과 같이 default import 로 불러올 수 있다고 판단할 것

  ```js
  import mod from 'pkg'
  mod.a
  mod.b()
  ```

- 하지만 실제로는 ESModule 이었고, 원래 파일은 아마 저렇게 작성되어 있을 것

  ```js
  export const a = '...'
  export function b() {
    /* ... */
  }
  ```

- 이 시점에서, 위처럼 코드를 불러올 수 없으므로 에러!

---

<style scoped>section { font-size: 14px; }</style>

- `.d.ts`가 다음과 같이 선언되어 있다고 가정

  ```ts
  declare function hello(): string
  export default hello
  ```

- 타입스크립트는 이를 `CommonJS`로 간주하고, 자바스크립트 파일이 다음과 같이 있다고 추측

  ```js
  Object.defineProperty(exports, '__esModule', {value: true})
  exports.default = function hello() {
    /* ... */
  }
  ```

- Node.js 에서 `CommonJS`의 `default` `import` 는 `module.exports` 객체를 통채로 가져오는 것과 동일합니다. 따라서 위 구조에서 제대로 접근하기 위해서는, 실제로는 아래와 같이 접근해야 함.

  ```ts
  import mod from 'pkg'
  console.log(mod)
  mod.default()
  ```

- 그러나 사실은 ESModule 코드였습니다. 실제 모듈은 아래와 같이 생겼고, 실행 시점에는 에러가 발생.

  ```js
  export default function hello() {
    /* ... */
  }
  // ..
  import mod from 'pkg'
  mod()
  mod.default // undefined
  ```

---

## 👺 Masquerading as ESM

<style scoped>section { font-size: 20px; }</style>

- ESModule 타입 파일을 찾았지만, 해당하는 자바스크립트 파일은 CommonJS로 작성되어 있는 경우 해당 오류 발생
- 에러 발생 과정은 위와 동일 (하지만 반대로 동작)
- 타입스크립트는 CommonJS 파일 환경에서는 ESModule 은 `require`로 불러올 수 없다는 규칙을 적용함 (Node.js 와 동일) 그러나 타입이 잘못되어 있다면, 실제로는 불러올 수 있는 파일을 못불러온다고 거짓 에러 발생

```json
{
  "name": "pkg",
  "type": "module",
  "exports": {
    ".": {
      //  type module 이므로 해당 파일은 ESModule 로 간주.
      // 그러나 require 로 해당 모듈을 불러오면 index.cjs 를 가져오는데 반해, 이에 맞는 타입파일은 존재하지 않음
      "types": "./index.d.ts",
      "import": "./index.js",
      "require": "./index.cjs"
    }
  }
}
```

---

## ⚠️ Entrypoint is ESM-only

- 앞선 경고 👺 Masquerading as ESM 와는 다르게, 타입 파일과 실제 구현한 파일 모두 ESModule 임에도 불구하고, CommonJS 방식으로 불러오려고 해서 발생하는 문제
- 즉 모듈 자체가 ESModule 로만 작성되어 있어, CommonJS 환경에서는 쓸수 없다는 뜻
- 잘못되었다기 보다는 사용자에게 경고를 해줘야 한다는 의미
- <https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c>

---

## 🐛 Used fallback condition

<style scoped>section { font-size: 18px; }</style>

- Node.js 알고리즘에 따르면, `exports` 객체 내에서 `import` 하고 싶은 키를 찾은 경우 실제 파일이 있건 없건 더 검색하는 동작을 해서는 안되는데 (= 그냥 거기서 끝내야 하는데), [타입스크립트는 이 부분을 잘못 수행 하고 있어 발생하는 문제](https://github.com/microsoft/TypeScript/issues/50762)

```json
{
  "exports": {
    ".": {
      "types": {
        "foo": "./didnt-match.d.ts"
      },
      "import": {
        "types": "./doesnt-exist.d.ts",
        "default": "./exists.mjs"
      }
    }
  }
}
```

1. 해당 패키지의 엔드리에 대한 타입파일을 찾을려고 시도
2. `types`를 찾음. 그러나 `.`가 없고 `foo` 만 있으므로 실패
3. `import`를 찾음. `types`를 찾음. 여기가 원래 찾으려던 위치가 맞음.
   - `Node.js`는 이 파일이 있건 말건 그냥 여기서 끝내야함
   - 타입스크립트는 해당하는 파일이 없으면 fallback 동작을 함 > 다음 default 를 통해서라도 찾으려고 함 (`./exists.m.dts`)
   - 그러나 이는 잘못된 동작임

---

## ⁉️ 퀴즈

<style scoped>section { font-size: 18px; }</style>

- 아래 상황에서는 무슨 버그가 존재할 수 있을까요?

```json
{
  "name": "pkg",
  "main": "./index.js",
  "types": "./index.d.ts",
  "exports": {
    "import": "./index.mjs",
    "default": "./index.js"
  }
}
```

- `import` 조건에 일치한다면, `.mjs` 규칙에 따라 `.m.dts`를 찾으려고 시도합
- `import` 조건에 걸렸으므로 파일 존재 유무와 관계 없이 `index.m.dts`를 토대로 시도하고, 없다면 그냥 에러를 발생시켜야 함.
- 그러나 타입스크립트 버그로, `default`까지 찾으려고 시도하게 됨
- `index.js`가 있고, 이에 해당하는 `index.d.ts`까지 찾을 수 있어 해당 파일을 사용
- 그러나 실제로는 `index.d.ts`는 CommonJS 타입 파일이므로, 결과적으로 에러가 발생

---

<style scoped>section { font-size: 16px; }</style>

## 🤨 CJS default export

- ESModule 로 작성된 모듈을 CommonJS 로 옮겨온다면, `default`를 다음과 같이 해석해서 가져온다.

```js
Object.defineProperty(exports, "__esModule", { value: true });
// default 키워드 유지를 위함
exports.default = /* ... */;
```

```js
import mod from 'pkg'
console.log(mod)
```

- 번들러는 `__esModule`가 있다면, 위 구문에 대해서 `[Function: f]`를 가져오지만 (미리 암묵적으로 약속된 동작), Node.js 는 그런거 없이 `{ default: [Function: f] }`를 가져온다.
- 패키지를 번들러에서 사용하는지, `Node.js`에서 직접 사용하는지에 따라서 다른 결과를 초래
- 위와 같은 상황은 `ESModule`로 작성된 패키지를 특별한 조치 없이 `CommonJS` 로 트랜스파일 하는 경우 발생할 수 있음
  - 가능한 `default`대신 `named`로 내보내는 것을 권장
  - 꼭 필요하다면, 다음과 같은 내보내기를 할 수 있도록 번들

    ```js
    Object.defineProperty(exports, '__esModule', {value: true})
    function f() {
      /* ... */
    }
    module.exports = f
    module.exports.default = f
    ```

---

## ❗️ Incorrect default export

<style scoped>section { font-size: 20px; }</style>

- `CommonJS`의 default export `module.exports = something` 는 내보내기 자체가 `something`이라는 뜻임
- 만약 그에 해당하는 타입을 `export default something` 는 `default` 에 `something`을 할당하는 것이라는 뜻임
- 올바르게 타입을 선언하고 싶다면, 아래와 같은 구조로 작성해야함.

  | JavaScript syntax                                | Type declaration syntax |
  | ------------------------------------------------ | ----------------------- |
  | `module.exports = x`                             | `export = x`            |
  | `exports.default = x; exports.__esModule = true` | `export default x`      |
  | `export default x`                               | `export default x`      |

---

## ❓ Missing `export =`

- 앞서 소개한 내용처럼, default export 호환을 위해서 `module.exports` 와 `module.exports.default`는 동일한 값으로 설정
- 하지만 타입파일에는 `export default`만 선언한다면, `module.exports`가 있다는 사실을 제대로 알려주지 못해서 불필요한 workaround 가 발생할 수 있음
  - `mod` 로 바로 접근해도 되는데, `mod.default`로 접근하는 불편함 발생
- `export = something` 을 사용해서 CommonJS 를 올바르게 반영해야 하고, 추가로 병합해야할 내용들이 있으면 이것 또한 점검해서 확인 필요 (뒤이어 예제에서 설명)

---

## 예제로 확인해보기 (1)

<style scoped>section { font-size: 18px; }</style>

```js
// my-class.js
class MyClass {
  constructor() {
    console.log("I'm MyClass!")
  }

  doSomething() {
    console.log('Doing something...')
  }
}

// CommonJS 주 내보내기
module.exports = MyClass

// 번들러/호환성 위한 패턴: default를 자기 자신으로
MyClass.default = MyClass
```

```ts
// my-class.d.ts
declare class MyClass {
  // `static default: typeof MyClass;`
  // -> 런타임에서 MyClass.default = MyClass; 구조를 반영
  static default: typeof MyClass

  doSomething(): void
}

// `export = MyClass;` -> "이 모듈이 CJS로 MyClass를 내보낸다"는 뜻
export = MyClass
```

---

## 예제로 확인해보기 (2)

<style scoped>section { font-size: 12px; }</style>

```js
// my-lib.js
class Widget {
  constructor(name) {
    this.name = name
  }

  render() {
    console.log(`Rendering ${this.name}`)
  }
}

// 런타임에서도 추가 정보를 담고 싶다면, 예: Widget.WidgetProps 객체
Widget.WidgetProps = {
  color: 'string',
  size: 'number',
}

Widget.default = Widget
module.exports = Widget
```

```ts
// my-lib.d.ts
declare class Widget {
  static default: typeof Widget // 런타임에서 Widget.default = Widget;

  constructor(name: string)
  render(): void
}

// "네임스페이스 병합"을 통해, Widget에 붙는 추가 타입을 함께 선언
declare namespace Widget {
  // 인터페이스 선언 예시
  export interface WidgetProps {
    color: string
    size: number
  }

  // (만약 런타임에도 Widget.WidgetProps = {...}가 있다면  타입 시스템에서 이를 객체 타입으로 선언하는 것도 가능)
  // const WidgetProps: {
  //   color: string;
  //   size: number;
  // };
}

// CJS export
export = Widget
```

---

## `🚭 Unexpected module syntax`

- CommonJS 로 인식되는 파일 내부에 `import` 구문이 발생하거나, ESModule 로 인식되는 구문에 `require`가 존재할 때 발생하는 문제
- 타입필드와 확장자로 모듈 시스템을 구별하지만, 정작 코드 내부가 잘못되어 있는 경우 발생함
- 이러한 문제는 '프론트엔드 번들러' 에서만 동작하는 것을 목표로하는 패키지에서 자주 나타남
  - 프론트엔드 번들러는, Nodejs 의 모듈 알고리즘을 엄격하게 지키지 않고 번들링하기 때문에, 이러한 문제가 발생할 수 있음.
    - 예: (`type: module`이 없는데) js 파일이고, 내부에서 `import`를 사용하는 경우
  - 또는 node 구형 버전을 타겟으로 번들되어 있는 경우

---

## `🥴 Internal resolution error`

- 타입 선언 파일 내부에서 발견한 `import` 구문이 정상적으로 해석되지 않은 경우 발생하는 에러.
- 실제 자바스크립트 파일은 존재하지만, 타입파일이 존재하지 않는 경우에 많이 발생
- 타입스크립트는 '타입파일과 실제 자바스크립트 파일 구조가 일치한다' 라는 전제하에 타입 체크를 수행하기 때문에, 이러한 문제가 발생할 수 있음
- 이런 문제는 `.d.ts`파일과`.js` 파일이 서로 다른 빌드 과정으로 생성되서 불일치가 발생하거나, 수동으로 `.d.ts` 파일을 작성하는 과정에서 종종 발생함
  - `@naverpay/pite` : vite 로 js를 빌드하고 tsup 으로 d.ts를 빌드하기 때문에 자칫하면 이런 문제가 발생할 수 있음
  - `react`: 리액트는 `flow`기반으로 작성되어 있어, `@types/react`를 직접 작성하는데 이 경우에도 발생할 수 있음.

---

## `🕵️‍♂️ Named exports`

- `CommonJS`로 작성된 모듈을 `ESModule`환경에서 named exports 를 사용할 수 있다고 가정하지만, 실제로 파일 실행 시점에는 해당 내보내기 가 존재하지 않는 경우 발생. 이러한 문제가 발생하는 이유는 다음과 같이 크게 두가지
  - 자바스크립트에 없는 `export`를 타입에 선언했을 때에 발생하는 문제
  - 또는 [cjs-module-lexer](https://www.npmjs.com/package/cjs-module-lexer)가 분석할 수 없는 형태로 작성된 `CommonJS` 파일을 `ESModule`에서 `import`하려고 할 때 발생하는 문제
    - `cjs-module-lexer`: `ESModule`이 도입되면서 `CommonJS`를 `ESModule`에서 어떻게 읽어올 것인지에 대한 고민이 있었는데, 이를 해결하기 위해 만들어진 syntax lexer 라이브러리로, `CommonJS` 모듈 내에서 무엇을 내보내는지 분석할 수 있게 도와주는 도구

---

## `cjs-module-lexer`가 분석할수 없는 코드가 뭔데?

<style scoped>section { font-size: 20px; }</style>

```js
// module.cjs
const a = 'I am a'
const b = 'I am b'

module.exports = {
  a,
  b,
}
// index.mjs
import {a} from './module.cjs'
console.log('Value of a:', a) // ok
```

> 최선의 노력으로 `module.exports`에 할당된 객체를 파싱하지만, 실제 파서가 아니므로 모든 경우를 처리할수 없으며 처리할 수 없는 코드가 발생해버리면 그냥 포기 (bailout) 해버립니다.

```js
// module.cjs
module.exports = {
  a: 'I am A!',
  b: 'I am B!',
}
// index.mjs
import {a} from './module.cjs'
// Named export 'a' not found..??
console.log('Value of a:', a)
```

---

<style scoped>section { font-size: 18px; }</style>

```js
// module.cjs
module.exports.a = 'I am a'
module.exports.b = 'I am b'
// index.mjs
import {a} from './module.cjs'
console.log('Value of a:', a) // ok
```

- `module.exports.a`는 `exports` 라는 객체에 `a`를 붙인다는 것을 정적으로 분석 가능하므로 파악 가능
- 근데 문자열은 왜 안됐을까? 🤔
- 문자열을 만나면 (`"`) `cjs-module-lexer`는 이후 분석을 포기해버림
  - 빠르게 동작하는 것을 목표로 만들어져서, AST 와 같이 추상구문트리로 분석하는 것이 아니라, 단순히 토큰단위로 분석하는 정도의 도구 이기 때문
  - `"`를 만났다? > 뒤에 `+`와 같은 연산이 있을수도 있다? > 그럼 그냥 분석열 여기서 부터 포기해버리자

```js
// module.cjs
const a = 'I am a'
const b = 'I am b'

module.exports = {
  a,
  c: 'hello',
  b,
}
// index.mjs
import {a, b} from './module.cjs'
// Named export 'b' not found
console.log('Value of a:', a, b)
```

---

## 요약

<style scoped>section { font-size: 22px; }</style>

- `package.json`의 경로마다 `exports`내부에서 타입을 한방에 바로 찾을 수 있도록 경로 설정 및 타입 파일 선언을 잘 해줘야 한다.
- `export default`와 `module.exports = {}` 의 차이를 극복하고자 다양한 트릭들이 사용되고 있다. 그러나 이러한 트릭들은 이해하기도 까다롭고 다루기도 번거로우므로 그냥 가급적 default exports 를 자제하고 named exports 를 사용해서 내보내는 것이 더 깔끔하고 정신건강에 이롭다.
- Node.js 버전의 최소버전을 ESModule 을 정상적으로 지원하는 16 이상으로 올리는 것이 권장되며, 타입스크립트의 `moduleResolution` 도 `node`가 아닌 `node16` `nodenext`, `bundler` 등으로 사용하는 것이 좋다. 이렇게 해야 타입스크립트와 Node.js 사이에 모듈을 해석하는 방식을 통일할 수 있고, `ESModule`을 읽는 방식을 더욱 엄격하게 수행할 수 있다.
- 타입스크립트와 자바스크립트 빌드 과정을 통합하고 확장자 및 환경에 맞는 모듈 타입을 제공하는 것이 중요하다.
- [@naverpay/pite](https://github.com/NaverPayDev/pite) 많관부!

---

# 패키지 가이드 1탄) 패키지 제작을 위한 package.json 가이드

Source: https://research.yceffort.kr/slides/best-practice-for-package-1.md

---
title: 패키지 가이드 1탄) 패키지 제작을 위한 package.json 가이드
marp: true
paginate: true
theme: default
tags:
  - npm
  - nodejs
date: 2025-03-22
description: 'package.json 만 잘만들어도 반은 먹고 간다.'
published: true
art:
  undraw: setup
---

# 패키지 가이드 1탄) 패키지 제작을 위한 package.json 가이드

<!-- _class: invert -->

@yceffort

---

## 패키지 제작이 어렵게 느껴지는 이유

- 평소에 개발하는 웹 서비스는 이미 프레임워크 형태로 제작되고 있기 때문에 실행 방법이나 진입점을 고려할 필요가 없음
- 그러나 패키지는 별도 프레임워크가 없어 이에 대한 도움을 받기 어려움
- 패키지가 모듈을 찾아가는 방식을 이해하기 위해서는 node module resolution algorithm 을 잘 이해해야 함
  - esm: <https://nodejs.org/api/esm.html#resolution-and-loading-algorithm>
  - cjs: <https://nodejs.org/api/modules.html#all-together>

> 저 내용을 100% 이해하고 개발할 수는 없으니까. 🤔

---

## <!-- fit --> `package.json` 만 이해해도 패키지 개발 반은 먹고 간다

- 서비스 개발시에 쓰는 `package.json` 사실상 `dependencies`, `scripts` 만 사용하고 있다고 봐도 무방
  - `version`, `main`, `repository` 없어도 그만
- 패키지 개발시에는 이야기가 다름
  - `package.json`의 몇몇 필드는 Node.js 가 해당 패키지에서 자바스크립트 모듈을 찾아가는 데 필수적인 정보를 제공
  - 이를 이해하면 패키지 개발이 훨씬 수월해짐

---

## 패키지 개발은 순서를 바꿔서 접근해보자

1. 내가 만들고 싶은 패키지가 무엇인지 생각하기
2. 해당 패키지에서 내보내야할 함수, 변수가 무엇인지 생각하기
3. 👉 2번을 만족할 수 있도록 `package.json` 작성하기 👈
4. 필요한 코드 작성하기
5. 3번에 구조에 맞게 4번 코드를 빌드할 수 있도록 번들러 및 트랜스파일러 설정하기

---

## 1. `name`과 `version`

### `name`

- 패키지의 이름으로, 용도에 맞게 작성
- `@pie` `@financial-auth`와 같이 스코프와 함께 사용 가능

### `version`

- 패키지의 버전으로, [유의적 버저닝](https://semver.org/) 을 따라 작성
- 호스트 패키지에서 해당 패키지가 어떤식으로 변경되었는지 알 수 있는 가장 중요한 정보
- 유의적 버저닝을 만족할 수 있도록 정확하게 버전을 작성하는 것이 중요

---

## 2. `main`과 `module`, `types`

<style scoped>section { font-size: 18px; }</style>

### `main`

- `main` 은 과거 node@12 미만 `CommonJS` 모듈만 지원하던 런타임에서 읽던 필드로, `require('something')` 호출시 가장 읽는 파일
- 뒤이어 설명할 `exports` 필드를 이해 못하는 번들러, 환경 등에서는 `main` 필드를 참조하여 모듈을 찾으므로 필수
- 따라서 여기는
  - `type: commonjs`거나 없다면 `js`파일로 CommonJS 로 작성하거나
  - `cjs` 파일로 작성해야 함

### `module`

- `main`과 비슷하게 `exports`를 이해하지 못하는 번들러나 환경에서 사용하는 필드
- `exports` 필드는 이해 못하지만 `ESModule` 를 지원하는 경우, `main` 대신 `module` 필드를 참조하여 모듈을 찾음
- 따라서 여기는
  - `type: commonjs`거나 없다면 `mjs`파일로 `ESModule` 임을 명시하거나
  - `type: module` 을 사용하여 `.js` 작성해야 함

### `types`

- 위 두 필드와 마찬가지로 `exports`를 이해하지 못하는 번들러나 환경에서 사용하는 필드
- 구형 타입스크립트 (4.8 ?), 구형 번들러 등에서 `main` 또는 `module` 에 매칭되는 타입스크립트 타입을 제공하기 위하여 사용

---

## 3. `files`

- `files`란 실제 npm 패키지에 배포 될 파일들을 지정하는 필드
- `files`에 없어도 몇몇 파일 `package.json` `LICENSE` 등은 기본으로 업로드 됨
- 타입스크립트와 같이 원본 파일이 아닌, `.js`, `.d.ts` 또는 소스맵과 같이 실제 호스트 패키지 입장에서 필요한 파일만 업로드 할 수 있도록 잘 설정해야함
- `./dist`와 같은 경로 이해 못하므로 잘 작성해야함
- `npm publish --dry-run`을 사용하면 실제 배포하지 않고도 배포 대상 파일을 확인할 수 있으므로 한번씩 해보시는 것을 추천

---

## 4. `type`

- `type`은 `.js` 파일을 어떻게 읽을지 Node.js 에 알려주는 힌트
- `commonjs`, `module` 두 가지를 지원하며, 기본값은 `commonjs` 임
- 이 타입과 상관없이 모듈을 강제하고 싶다면 `.mjs`와 `.cjs` 를 사용할 수 있음
- `.js` 에 대한 기본 모듈 동작을 조금이라도 빨리 알려주기 위해 둘 중 하나라도 기재하는 것이 좋음.
- 아니면 `/esm` 과 같이 폴더 레벨로 나누었다면, 해당 폴더에 `type` 만 있는 `package.json` 을 작성하는 것도 방법이 될 수 있음
  - Node.js 는 어떤 모듈인지 알기 위해 가장 가까운 `package.json` 을 읽기 때문
  - 그러나 가독성이 매우 떨어지므로 별로 추천하지는 않음

---

## 5. `sideEffects`

- 이 필드는 Node.js 가 아닌 [번들러](https://webpack.js.org/guides/tree-shaking/#mark-the-file-as-side-effect-free)를 위한 힌트 필드로, 해당 파일이나 패키지가 부수효과가 없는 순수 함수 여부를 알려줄 수 있는 필드
- 해당 필드가 없다면, 번들러는 모든 모듈이 순수하지 않다는 가정하에 다 때려넣어서 번들링을 하게 됨
- `false`를 넣어 모든 함수가 순수 함수임을 알려줄 수 있고, 혹은 배열로 특정 파일을 넣어준다면 그 파일을 제외하고 모두 순수하다는 것을 알려줄 수 있음

---

## 6-1. `exports`

> 진짜 우리가 제대로 알아야 하는 가장 중요한 필드

<style scoped>section { font-size: 18px; }</style>

- 해당 패키지가 제공하고 있는 API 를 선언하는 필드로, 호스트 패키지로 하여금 이 패키지에서 사용할 수 있는 모듈을 알려주는 역할

```json
{
  "main": "./index.js",
  "exports": "./index.js"
}
```

```jsonc
{
  "name": "my-package",
  "exports": {
    ".": "./lib/index.js",
    "./lib": "./lib/index.js",
    "./lib/index": "./lib/index.js",
    "./lib/index.js": "./lib/index.js",
    "./package.json": "./package.json",
  },
}
```

---

## 6-2. `exports`

<style scoped>section { font-size: 18px; }</style>

<!-- prettier-ignore -->
```jsonc

{
  "exports": {
    ".": { //  패키지의 엔트리 포인트. 위에서 부터 탐색하므로 서순이 매우 중요함
      // 롤업, 웹팩에서 사용하는 비공식 필드. import, require 보다 앞에 와야 하며 ESModule을 가리킨다.
      // 이 필드는 확장자가 js 여도 가능하므로, 반드시 require 보다 앞에  있어야 함
      "module": "./dist/index.mjs", 
      "import": { // 해당 경로로 패키지를 'import' 할 때 사용되는 파일
        "types": "./dist/index.d.mts",
        "default": "./dist/index.mjs",
      },
      "require": { // 해당 경로로 패키지를 'require' 할 때 사용되는 파일
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs",
      },
      "default": "./dist/index.mjs", // 어느 조건에도 걸리지 못하는 경우 사용되는 폴백. 
      // 번외로 롤업에서만 쓰는 production, development 도 있다.
    },
    "./package.json": "./package.json", // package.json 도 명시적으로 내보내 주는 것이 요즘 트렌드
  }  
}
```

---

## 6-3. `.d.mts`, `.d.cts` 과연 의미가 있는것일까?

<style scoped>section { font-size: 22px; }</style>

- 파일명에서 유추할 수 있듯, `ESModule`과 `CommonJS` 에 대한 타입스크립트 타입을 의미
- 그러나 실제 대부분 사례에서는 두 파일 내용이 거의 동일 [참고.d.cts](https://app.unpkg.com/date-fns@4.1.0/files/add.d.cts) [참고.d.ts](https://app.unpkg.com/date-fns@4.1.0/files/add.d.ts)
- 그렇다면 두 파일은 실제로 의미가 있는 것일까? 아니면 그냥 두 파일을 만들어 놓은 것일까?
  - 사실 차이가 있음. 타입스크립트 역시 Node.js 와 동일한 알고리즘을 사용하기 위해 확장자를 통해 파일의 모듈 시스템을 구별함 [참고](https://www.typescriptlang.org/docs/handbook/modules/reference.html#module-format-detection)
  - 이러한 내용은 `module`의 값을 `node16` `node18` `nodenext` 등 으로 선언 할 때 사용되며, 실제 타입스크립트가 모듈 시스템을 이해하는데 있어 중요함 (어떤식으로 모듈을 읽어야 하는지 결정해야 하므로)
- 별도 types 필드에 값이 없어도 타입스크립트 컴파일러가 타입을 어떻게든 찾아갈수는 있음.그러나 굉장히 비효율적이라 선언해주는게 나음
  - `/mod.js`: `/mod.ts` > `/mod.tsx` > `/mod.d.ts` > `/mod.js` > `/mode.jsx`
  - .js 파일을 만나면 위와 같은 순서로 타입을 찾아가게 되므로, 반드시 **`types`필드가 `exports` 순서 상단에 있어야 함.**

---

## 7. Wrap up

- `name`, `version`, `files`, `sideEffects` 는 패키지 작성을 위한 필수 필드
- subpath 지원, ESModule 지원, 모던 번들러 지원을 위해서는 `exports` 필드를 잘 작성해주는 것이 매우 중요
- 타입 파일 역시 타입스크립트가 Node.js 와 동일한 모듈 알고리즘을 따라가므로 그에 맞춰서 작성하는 것이 필요
- 위 내용들을 잘 작성할 수 있도록 도와주는 도구
  - 자동완성 `schemastore`: <https://github.com/SchemaStore/schemastore/blob/master/src/schemas/json/package.json>
  - 실제 lint를 도와주는 `publint`: <https://publint.dev/>

