느린 엔드포인트가 하나인데 전부 느리다
관리자 페이지에 CSV 내보내기 기능을 붙였다. 하루 몇 번 쓰는 기능이고 응답에 2초쯤 걸려도 아무도 불평하지 않는 화면이다. 배포한 뒤 대시보드를 보니 이상한 게 있다. CSV와 무관한 엔드포인트들의 p99가 같이 올라갔다.
// api/export.ts — 관리자만 쓰는 엔드포인트
app.get("/admin/export", async (req, res) => {
const rows = await db.query("select * from orders where created_at > $1", [since])
const csv = rows.map(toCsvLine).join("\n") // 20만 행 문자열 결합
const signed = crypto.pbkdf2Sync(csv, SALT, 100_000, 64, "sha512") // 동기 서명
res.type("text/csv").send(csv + "\n#" + signed.toString("hex"))
})하루 다섯 번 호출되는 이 핸들러가 결제 API의 지연시간을 끌어올린다. 원인은 명확하다. pbkdf2Sync가 도는 동안 프로세스는 다른 어떤 요청도 처리하지 못한다. 이 코드는 자기 요청만 느리게 만드는 게 아니라 그 시간 동안 도착한 전부를 느리게 만든다.
이 편은 왜 역사 축에서 벗어나는가
지금까지 13편은 시대 순이었다. 모놀리식에서 경계가 사라지고, SOA에서 파이프가 두꺼워지고, MSA에서 경계가 잘못 그어지고, 서버리스에서 상태가 사라졌다. 전부 구조의 문제였다.
이 편은 다르다. 여기서 다루는 것은 실행 모델의 문제다. 같은 아키텍처, 같은 경계여도 런타임에 따라 전혀 다른 실패가 나온다. 앞의 CSV 핸들러를 Java 서블릿 컨테이너에 올리면 스레드 하나가 2초 붙잡힐 뿐 나머지 요청은 다른 스레드에서 돌아간다. 같은 코드가 Node.js에서만 전역 장애가 된다. 예제 스택이 Node.js인 이상, 런타임 고유의 실패 양식을 짚지 않으면 앞의 조언들이 실제 코드에서 작동하지 않는다.
전제부터 복원하자. Node.js의 단일 스레드 이벤트 루프는 I/O 대기가 지배적인 워크로드를 위한 설계다. 요청 처리 시간의 대부분이 DB나 HTTP 응답을 기다리는 시간이라면 스레드를 요청마다 하나씩 할당하는 건 낭비다. 기다리는 동안 다른 요청을 처리하면 되고, 이걸 스레드 없이 하는 게 이벤트 루프다. 1편의 표현을 빌리면 이것이 이 해법의 맥락이다. 그 맥락이 깨지는 지점, 즉 CPU가 지배적인 작업이 들어오는 순간이 안티패턴의 자리다.
블로킹은 한 요청이 아니라 전체에 과금된다
수치로 보면 차이가 분명해진다. 초당 1,000 요청을 받는 서비스에서 이벤트 루프가 100ms 동안 블로킹된다고 하자.
그 100ms 동안 100개의 요청이 도착한다(1,000 × 0.1). 이 요청들은 소켓 버퍼와 대기 큐에 쌓인 채 아무 처리도 받지 못한다. 블로킹 직후 도착한 요청은 100ms를 통째로 기다리고 끝 무렵 도착한 요청은 거의 안 기다리므로, 평균 추가 지연 50ms, 최악 100ms다. 그리고 이 100개가 블로킹이 풀린 뒤 한꺼번에 처리되면서 그 시점 부하가 치솟는다.
이게 간헐적이면 p99만 오른다. 문제는 블로킹이 요청마다 발생할 때다. 요청 하나가 CPU를 100ms 쓰면 그 프로세스의 처리량 상한은 초당 10 요청이다(1초 ÷ 0.1초). 도착률 1,000/s에 처리율 10/s면 큐는 초당 990개씩 무한히 자란다. 응답시간이 늘어나는 게 아니라 발산한다. 1,000 req/s를 감당하려면 프로세스가 100개 필요하다.
스레드 기반 서버에서 블로킹의 비용은 그 요청 하나가 낸다. 이벤트 루프에서는 프로세스 전체가 낸다.
CPU가 이벤트 루프를 붙잡는 네 가지 통로
1. 거대 페이로드의 JSON 직렬화. JSON.parse와 JSON.stringify는 동기 API다. 비동기 버전이 없다. 10MB짜리 JSON을 파싱하면 파싱 자체뿐 아니라 수십만 개의 객체와 문자열을 힙에 할당하는 비용이 붙어 수십 ms에서 100ms를 넘는 구간까지 간다. 외부 API 응답을 await res.json()으로 받는 코드에서, await은 네트워크 대기만 양보하고 파싱은 양보하지 않는다.
2. 동기 crypto. crypto.pbkdf2Sync, crypto.scryptSync, bcrypt.hashSync. bcrypt의 코스트 파라미터 12는 2¹² = 4,096회 반복을 뜻하고, 이 값은 하드웨어에서 수백 ms가 걸리도록 의도적으로 설계된 것이다. 느린 게 버그가 아니라 기능이다. 문제는 그 수백 ms가 동기일 때다. 해시가 250ms라면 그 프로세스의 로그인 처리량 상한은 초당 4건이고, 그동안 로그인과 무관한 모든 요청도 멈춘다. pbkdf2와 scrypt는 기본 크기 4인 libuv 스레드 풀에서 도는 비동기 버전이 있고, bcrypt도 hash(비동기)와 hashSync가 따로 있다. Sync 네 글자는 상한을 4배로 올리고, 멈추는 범위를 요청 하나로 줄인다.
3. 정규식 백트래킹(ReDoS). 중첩 수량자가 있는 패턴이 위험하다. /^(a+)+$/에 "a".repeat(30) + "!"를 넣으면 매칭 실패가 확정되기까지 지수 시간이 걸린다. (a+)+는 n개의 a를 안쪽 a+ 그룹들로 나누는 모든 분할을 시도한다. n개를 순서 있는 그룹으로 나누는 경우의 수는 2ⁿ⁻¹이고, n = 30이면 2²⁹ = 536,870,912가지다. 마지막 ! 때문에 매칭은 반드시 실패하는데, 백트래킹 엔진은 실패를 확정하려고 이 경우의 수를 전부 소진한다. 입력 30글자가 이벤트 루프를 수십 초 붙잡는다. 입력이 사용자에게서 오면 그대로 서비스 거부 벡터다.
4. 동기 파일·압축 API. fs.readFileSync, zlib.gzipSync. 설정 파일을 부팅 시 한 번 읽는 거라면 무해하지만, 요청 경로에 있으면 디스크 지연이 그대로 전역 정지가 된다.
async를 붙였는데 동시성이 생기지 않을 때
여기가 더 흔하다. CPU 작업이 없는데도 느린 경우다.
순차 await. for 루프 안의 await은 앞 작업이 끝나야 다음이 시작된다.
// services/enrich.ts — Before: async지만 동시성은 0이다
export async function enrichAll(ids: string[]) {
const out: Profile[] = []
for (const id of ids) {
out.push(await fetchProfile(id)) // 100건 × 50ms = 5,000ms
}
return out
}각 호출이 50ms라면 100건에 5,000ms다. Promise.all로 한 번에 던지면 이론상 50ms다. 100배 차이가 코드 한 줄에서 나온다. 이 루프가 CPU를 쓰지 않는다는 점이 중요하다. 이벤트 루프는 내내 놀고 있고, 느린 이유는 오직 대기를 직렬화했기 때문이다.
무제한 Promise.all. 그렇다고 전부 한 번에 던지면 반대쪽으로 넘어간다. Promise.all(ids.map(fetchProfile))에 10,000건을 넣으면 소켓 10,000개를 동시에 열려 시도한다. 파일 디스크립터 한도에 걸리거나, 각 요청의 버퍼가 힙을 압박하거나, 다운스트림 서비스가 순간적으로 10,000 동시 요청을 받는다. 마지막 것이 12편의 Thundering Herd다. 우리 쪽 코드 한 줄이 남의 서비스를 쓰러뜨린다.
정답은 동시성 상한이 있는 병렬 실행이다.
// lib/map-limit.ts — After: 워커 수가 곧 동시성 상한이다
export async function mapLimit<T, R>(
items: readonly T[],
limit: number,
fn: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
const results = new Array<R>(items.length)
let cursor = 0
const worker = async () => {
for (let i = cursor++; i < items.length; i = cursor++) {
results[i] = await fn(items[i], i)
}
}
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, worker),
)
return results
}
// 100건을 동시성 10으로: 10라운드 × 50ms ≈ 500ms. 소켓은 항상 10개만 쓴다
const profiles = await mapLimit(ids, 10, fetchProfile)5,000ms에서 500ms로 줄면서, 동시에 다운스트림이 받는 부하는 10 동시 요청으로 고정된다. 여기서 구분해야 할 것이 하나 있다. await은 I/O 대기를 양보하지만 CPU 작업은 양보하지 않는다. async 함수 안에서 10MB JSON을 파싱하면 그 함수가 async라는 사실은 아무 도움이 되지 않는다. 양보는 await 지점에서만 일어나고, 동기 코드 구간은 통째로 이벤트 루프를 점유한다.
스트림을 쓰지 않는 습관과 백프레셔
500MB 파일을 fs.readFile로 통째로 읽으면 500MB Buffer가 한 번에 잡힌다. 여기에 .toString()을 붙이면 UTF-16 문자열이 추가로 생겨 최대 1GB가 더 붙는다. 힙 압박은 곧 GC 정지이고, GC 정지는 이벤트 루프 정지다. 동시 요청 4개면 같은 계산이 4배가 된다.
스트림은 이 문제를 청크 단위 처리로 바꾼다. 그리고 스트림을 쓸 때 빠뜨리기 쉬운 것이 백프레셔다. writable.write()는 내부 버퍼가 가득 차면 false를 반환하는데, 이 반환값을 무시하고 계속 쓰면 읽기 속도와 쓰기 속도의 차이만큼 데이터가 메모리에 쌓인다. 디스크에서 초당 500MB를 읽어 초당 50MB짜리 네트워크로 보내면 초당 450MB가 힙에 적재된다. stream.pipeline은 백프레셔 전파와 에러 시 자원 해제를 함께 처리하므로, 직접 pipe를 엮는 것보다 이쪽이 기본값이어야 한다.
같은 계열의 누수가 둘 더 있다. 처리되지 않은 promise rejection은 최신 Node.js에서 프로세스를 종료시키는 기본 동작이라 배포 후에야 드러난다. 그리고 요청마다 emitter.on(...)을 등록하고 해제하지 않으면 리스너가 단조 증가한다. Node.js는 리스너가 기본 임계값 10개를 넘으면 경고를 찍는다. 이 경고 자체가 메모리 누수 탐지기 역할을 하도록 만들어진 것이므로 로그에 묻히게 두면 안 된다.
탈출 경로와 그 대가
CPU 작업은 worker_threads로 옮긴다. 다만 무조건 이득은 아니다. 워커로 데이터를 보내는 postMessage는 구조화 복제를 하므로 전송 비용이 데이터 크기에 비례한다. 판단 기준은 CPU 작업 시간이 직렬화·전송 오버헤드를 충분히 넘는가다. 대략 10ms 미만의 작업은 옮기는 비용이 더 크고, 100ms 이상이면 거의 항상 이득이다. 큰 버퍼는 ArrayBuffer를 transfer해서 복제를 피할 수 있다. 대가는 코드 구조다. 함수 호출이 메시지 교환이 되고, 에러 전파와 타임아웃과 워커 풀 관리가 전부 직접 짤 코드가 된다.
동시성 리미터를 기본값으로 둔다. 앞의 mapLimit처럼 외부 호출의 동시성 상한을 코드에 명시한다. 대가는 상한값 자체가 튜닝 대상이 된다는 것이다. 너무 낮으면 처리량을 버리고, 너무 높으면 다운스트림을 때린다.
ReDoS는 정규식 바깥에서 푼다. 입력 길이 상한을 먼저 걸고(길이 제한은 지수 폭발의 지수를 직접 자른다), 중첩 수량자를 제거하고, 구조가 복잡한 입력은 정규식 대신 파서로 처리한다.
이벤트 루프 지연을 메트릭으로 내보낸다. 이게 가장 먼저 해야 할 일이다. 블로킹은 CPU 사용률로는 잘 안 보인다. 단일 스레드가 100% 도는 상태가 코어 8개 장비에서는 12.5%로 보이기 때문이다.
// observability/loop.ts
import { monitorEventLoopDelay } from "node:perf_hooks"
const histogram = monitorEventLoopDelay({ resolution: 10 }) // 10ms 간격 샘플링
histogram.enable()
setInterval(() => {
const p99Ms = histogram.percentile(99) / 1e6 // 나노초로 나오므로 ms 환산
const maxMs = histogram.max / 1e6
metrics.gauge("event_loop_delay_p99_ms", p99Ms)
if (p99Ms > 100) logger.warn({ p99Ms, maxMs }, "이벤트 루프 지연 임계 초과")
histogram.reset() // 창을 비워야 직전 10초의 값이 된다
}, 10_000).unref()이 지표가 있으면 "어느 엔드포인트가 느린가"가 아니라 "언제 루프가 막혔는가"를 볼 수 있고, 그 시각을 배포·트래픽·특정 요청과 대조할 수 있다. 대가는 계측 자체의 비용과, 이 값을 어디로 보낼 것인가라는 다음 질문이다(15편).
판단 기준 — 동기 API가 오히려 정답인 경우
스크립트, CLI, 빌드 도구. 프로세스가 한 가지 일만 하고 끝난다면 블로킹은 개념적으로 존재하지 않는다. 막을 다른 요청이 없기 때문이다. 마이그레이션 스크립트에서 fs.readFileSync와 순차 await은 더 단순하고 더 읽기 쉽고 에러 처리도 명확하다. 여기에 워커 스레드와 동시성 리미터를 얹는 건 과잉 설계다.
서버 부팅 시점의 초기화. 설정 파일 읽기, 인증서 로드, 스키마 컴파일은 리스닝을 시작하기 전에 동기로 끝내는 게 맞다. 아직 받을 요청이 없으므로 막을 것이 없고, 비동기로 만들면 "초기화가 끝나기 전에 요청이 들어오면?"이라는 상태를 새로 관리해야 한다.
작업 큐 워커. 한 번에 한 작업만 처리하고 그 작업이 CPU 바운드라면 이벤트 루프를 점유하는 게 정상 동작이다. 동시성이 필요하면 프로세스 수를 늘린다. 웹 서버와 같은 프로세스에 두지 않는 것이 조건이다.
동시성을 의도적으로 1로 두는 경우. 순서가 의미를 갖는 처리(순차 마이그레이션, 감사 로그 적재)에서 Promise.all은 정답이 아니라 버그다.
요약
| 항목 | 내용 |
|---|---|
| 증상 | 무관한 엔드포인트의 p99가 같이 오른다. 특정 요청이 아니라 프로세스가 멈춘다 |
| 붕괴 기전 | 이벤트 루프는 I/O 대기 지배 워크로드용 설계다. CPU 작업이 들어오면 비용이 전역화된다 |
| 수치 | 요청당 CPU 100ms면 프로세스 처리량 상한은 10 req/s. 1,000 req/s면 큐가 발산한다 |
| ReDoS | /^(a+)+$/에 30글자 입력 → 2²⁹ = 5.4억 가지 분할을 소진한다 |
| async의 함정 | 순차 await 100건 × 50ms = 5초 / 동시성 10이면 500ms / 무제한이면 Thundering Herd |
| 탈출 | worker_threads(10ms 이상일 때), 동시성 리미터, 스트림+pipeline, 루프 지연 계측 |
| 대가 | 워커는 메시지 기반 구조와 직렬화 비용, 리미터는 상한값 튜닝 부담을 추가한다 |
| 오히려 정답 | CLI·스크립트·부팅 초기화·전용 CPU 워커. 서버가 아니면 블로킹은 문제가 아니다 |
다음 편 — 15편. 관측 불가능한 시스템 — 로그만 있는 분산 시스템
이벤트 루프 지연을 계측했다면 그 값을 어디로 보낼 것인가라는 질문이 남는다. 4막의 마지막 편은 여기서 시작한다. 서비스 10개가 초당 100줄씩 뱉는 로그에서 요청 하나의 흔적 15줄을 찾는 일이 왜 불가능한지, 평균 응답시간 200ms인 대시보드가 어떻게 p99 5초를 숨기는지, 그리고 메트릭 레이블 하나가 어떻게 시계열 100만 개를 만드는지를 계산으로 따진다.