---
title: 'OG 스크래핑 서버를 Node.js로 짓는다면 1부: 런타임 선택부터 에러율과 지연까지'
marp: true
paginate: true
theme: midnight
tags:
  - nodejs
  - web
  - performance
  - deep-dive
date: 2026-08-18
post: https://yceffort.kr/2026/08/og-scraping-server-1
description: '링크 미리보기는 왜 태생적으로 실패율이 높은가부터, 런타임 선택이 갈리는 네 지점, User-Agent와 인코딩, 캐시 스탬피드, 히트율 역산까지'
published: true
---

# 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을 붙여넣으면, 그 페이지의 제목과 이미지를 카드로 보여준다.

```
사용자 ──"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개가 절반이다)

**이 두 숫자 없이 세운 개선 목표는 근거가 없다.**

---

## 이 워크로드의 프로파일

한 번의 스크래핑에서 시간이 어디로 가는지 재보면,

```
DNS 조회        ~10ms   ┐
TCP + TLS       ~50ms   │
요청 전송        ~1ms   ├─  네트워크 대기 (I/O)   ≈ 98%
응답 대기      ~300ms   │
본문 수신       ~50ms   ┘

HTML 파싱        ~5ms   ┐
og 태그 추출     ~1ms   ├─  CPU 작업              ≈  2%
결과 직렬화     ~0.1ms  ┘
```

**전형적인 I/O 바운드 워크로드다.** 이 사실이 2장의 전제가 된다.

---

## 규모도 같이 봐야 한다

링크 미리보기는 대개 트래픽이 작다. 일 5,000건이라고 하면,

```
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 태그를 내준다.

```
facebookexternalhit/1.1
Twitterbot/1.0
Slackbot-LinkExpanding 1.0
Discordbot/2.0
```

기본 UA(`undici`, `node-fetch`)로 요청하면 **차단 대상**이 된다.

---

## 그러면 남의 UA를 쓸 것인가

**정직하게 트레이드오프를 적는다.**

| 선택                       | 얻는 것                            | 잃는 것                                          |
| -------------------------- | ---------------------------------- | ------------------------------------------------ |
| `facebookexternalhit` 사칭 | 성공률이 크게 오른다               | 신원 위조다. 상대가 차단 정책을 바꿀 근거를 준다 |
| 자체 UA + 연락처           | 정직하다. 문제 시 연락받을 수 있다 | 초기 성공률이 낮다                               |

권장은 **자체 UA에 연락처를 넣는 것**이다.

```
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")`로 표준 라이브러리 안에서 끝난다.

---

## 이 버그가 무서운 이유

정리하면 이렇다.

```
① 응답은 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로 아무 데나 요청하지 않도록.

이제 방향이 반대다. **가져온 값이 우리 화면으로 들어온다.**

```
[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은 언제나 캐시 미스**이고, 그때 실패하면 사용자는 여전히 깨진 카드를 본다.

---

## 그래서 목표를 두 개로 쪼개야 한다

```
① API 에러율     = 실패 응답 / 전체 API 요청     ← 반복 요청발 실패만 캐시로 개선
② 커버리지       = 성공한 고유 URL / 시도한 고유 URL  ← 스크래핑 품질로 개선
```

①만 목표로 잡으면, 캐시 히트율이 오르는 것만으로 숫자가 좋아진다.
**사용자 경험은 그대로인데 지표만 좋아지는** 전형적인 함정이다.

②를 올리려면 UA 조정, 리다이렉트 처리, 인코딩 대응처럼 **3장의 것들**을 해야 한다.

---

## 캐시 스탬피드 정의를 바로잡자

흔히 이렇게 설명한다.

> "캐시가 만료되어 **DB**에 요청이 몰리는 현상"

스크래핑 서버에서 뒷단은 DB가 아니라 **외부 사이트**다. 정확히는 이렇다.

> **어떤 캐시 키가 만료되는 순간, 그 키를 기다리던 동시 요청이 전부 원본으로 나가는 현상**

인기 있는 링크(예: 방금 올라온 뉴스)일수록 심하다. **그리고 상대는 그걸 공격으로 인식한다.**

---

## 스탬피드가 왜 특히 나쁜가

```
t=0     캐시 만료
t=0ms   요청 50건 도착 → 전부 미스 → 전부 외부로 나간다
        │
        ├─ 외부 서버: "같은 IP에서 50건? 차단"
        ├─ 429 또는 403 응답
        └─ 50건 전부 실패 + 실패를 캐시 안 하면 다음 초에 또 50건
```

**캐시를 넣었는데 에러율이 오르는** 상황이 실제로 생긴다.
그래서 캐시는 넣는 것보다 **어떻게 넣는가**가 중요하다.

> 1장의 평시 규모(0.06 TPS)로는 이런 순간이 잘 안 온다. 문제는 평균이 아니라
> **인기 링크 하나에 트래픽이 확 몰리는 순간**(바이럴)이다. 위의 "50건"은 그 순간을 그린 것이다.

---

## 그 전에: 캐시 키를 정한다

캐시를 논하기 전에 **"같은 URL"이 무엇인지** 먼저 정해야 한다.
아래는 사람 눈엔 같은 페이지지만, 문자열로는 전부 다르다.

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

만료된 값을 **일단 돌려주고**, 갱신은 백그라운드에서 한다.

```
요청 도착
   │
   ├─ 캐시에 값이 있다 (만료됨)
   │     ├─ 즉시 반환 (사용자 대기 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는 미스 구간에 있다. 미스 분포에서 몇 번째 백분위인가?

```
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)**에서 나온다.

```
히트율 ≈ 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`)
