한 줄이 숨기는 것
주문 조회 핸들러에 이런 줄이 있다.
// services/order/get-order.ts
const user = await userClient.getUser(order.userId)타입은 Promise<User>다. 에디터에서 로컬 함수와 구분되지 않고, 실패는 try/catch로 잡힌다. 언어가 주는 모든 단서가 "이건 함수 호출이다"라고 말한다.
이 줄이 실제로 하는 일은 다르다. DNS를 조회하고, TCP 연결을 맺거나 풀에서 꺼내고, TLS 핸드셰이크를 하고, 요청을 직렬화하고, 물리적 거리를 왕복하고, 응답을 역직렬화한다. 각 단계에서 실패할 수 있고, 그중 상당수는 성공도 실패도 아닌 상태로 끝난다.
6편에서 파이프의 로직을 걷어내면 서비스는 서로 직접 호출하게 된다. 그 호출이 이렇게 생겼다면, 중앙 장치를 없앤 대가로 감춰진 네트워크를 코드베이스 전체에 뿌린 셈이다.
투명성은 버그가 아니라 설계 목표였다
원격 호출을 로컬 호출처럼 보이게 만드는 것은 실수가 아니라 1980~90년대 분산 컴퓨팅의 명시적 목표였다. RPC, CORBA, DCOM, Java RMI가 모두 위치 투명성(location transparency), 즉 객체가 어디 있든 같은 방식으로 호출한다는 것을 약속했다.
이 약속에는 근거가 있었다. 통신 세부사항으로 덮인 코드에서는 비즈니스 로직을 읽을 수 없고, 인터페이스 뒤로 숨기면 로직이 드러난다. 지금 HTTP 클라이언트를 래핑하는 이유와 같다.
반론도 같은 시기에 나왔다. Jim Waldo, Geoff Wyant, Ann Wollrath, Sam Kendall이 1994년 Sun Microsystems에서 쓴 A Note on Distributed Computing은 로컬 객체와 원격 객체를 하나의 인터페이스로 통합하려는 시도 자체가 잘못됐다고 주장했다. 이유는 성능이 아니라 부분 실패(partial failure)와 동시성이었다. 이 둘은 인터페이스 뒤로 숨길 수 있는 종류의 차이가 아니다.
같은 무렵 Sun의 L. Peter Deutsch가 정리하고 James Gosling이 한 항목을 더한 것으로 알려진 목록이 Fallacies of Distributed Computing이다. 분산 시스템을 처음 만드는 사람이 반드시 한 번은 믿는 여덟 가지 전제다.
여덟 가지를 장애 시나리오로 번역하면
1. 네트워크는 신뢰할 수 있다. 이 오류의 실제 형태는 "패킷이 유실된다"가 아니라 요청이 도달했는지 알 수 없다는 것이다. POST /charges를 보내고 타임아웃이 났다면 결제는 됐을 수도, 안 됐을 수도 있다. 로컬 호출에는 성공과 예외 두 상태뿐이지만 원격 호출에는 세 번째가 있다.
타임아웃은 실패를 뜻하지 않는다. "모른다"를 뜻한다.
이 구분이 재시도의 안전성을 결정한다. 실패라면 재시도해도 되지만 "모른다"에 재시도를 걸면 이중 청구가 된다.
2. 지연시간은 0이다. 지연시간의 하한은 물리 법칙이다. 서울과 버지니아의 대권 거리는 약 11,000km이고 광섬유 내 빛의 속도는 진공의 약 2/3인 초속 20만 km다. 편도 55ms, 왕복 110ms가 이론적 하한이고, 경로가 직선이 아니라 실측은 대개 이보다 크다. 코드 최적화로 줄일 수 없는 값이다.
같은 가용 영역 안이면 왕복이 1ms 미만이지만, 문제는 이 값이 아니라 호출 횟수와의 곱이다(8편).
3. 대역폭은 무한하다. 응답 하나가 200KB이고 초당 1,000건이면 200MB/s, 약 1.6Gbps다. 인스턴스 한 대의 한도에 닿는다. 대역폭 포화는 지연시간 증가로 나타나므로, 원인을 찾을 때 네트워크가 아니라 애플리케이션을 뒤지게 된다.
4. 네트워크는 안전하다. 사설망 안이라는 전제로 서비스 간 인증을 생략하면, 침입자가 한 서비스를 잡는 순간 나머지 전부에 인증 없이 접근한다. 침해 반경이 서비스 하나에서 시스템 전체로 바뀐다.
5. 토폴로지는 변하지 않는다. 이 오류가 가장 조용하게 터진다. Node.js의 HTTP Agent는 keepAlive로 소켓을 재사용하고 DNS 결과는 어딘가에 캐시된다. 오케스트레이터가 파드를 재배치해 IP가 바뀌었는데 풀이 옛 IP의 소켓을 들고 있으면, 그 소켓으로 나간 요청만 실패한다.
증상은 "간헐적으로 5%가 실패"다. 재현이 안 되고, 재배포하면 사라지고, 며칠 뒤 돌아온다. 오토스케일링이 일상인 환경에서 토폴로지는 분 단위로 바뀐다.
6. 관리자는 한 명이다. 의존하는 외부 API의 인증서 갱신 일정, 레이트 리밋 정책, 점검 시간은 다른 조직이 내 배포 캘린더와 무관하게 정한다.
7. 전송 비용은 0이다. 비용은 두 갈래다. 하나는 CPU다. JSON 직렬화·역직렬화는 페이로드 크기에 선형이고, Node.js에서는 이 작업이 이벤트 루프를 점유한다(14편). 200KB 응답을 초당 1,000번 파싱하면 그만큼의 CPU가 비즈니스 로직에서 빠진다.
다른 하나는 청구서다. 가용 영역이나 리전을 넘는 전송에는 GB당 요금이 붙는다. 초당 200MB면 시간당 720GB, GB당 0.02달러 수준이면 시간당 14.4달러, 월 약 10,500달러다. 서비스를 쪼개면서 같은 데이터를 두 번 왕복시키는 설계는 이 항목을 두 배로 만든다.
8. 네트워크는 균질하다. MTU, 프록시의 최대 헤더 길이, 유휴 연결 종료 시간이 경로마다 다르다. 개발 환경에서 통과한 큰 요청이 운영의 특정 경로에서만 잘린다.
꼬리 지연이 평균을 지배한다
여덟 가지 중 2번이 가장 크게 틀리고, 틀리는 방식이 특이하다. 평균을 봐서 틀린다.
요청 하나를 처리하려고 백엔드 N개를 호출하면 전체 응답시간은 가장 느린 하나로 결정된다. 각 백엔드가 독립적으로 p99 = 10ms, 즉 1% 확률로 10ms를 넘긴다면, N개 중 적어도 하나가 넘길 확률은 1 − 0.99^N이다.
| N | 1 − 0.99^N | 의미 |
|---|---|---|
| 1 | 1.0% | p99가 p99대로 동작 |
| 10 | 9.6% | 10건 중 1건이 꼬리 |
| 100 | 63.4% | 요청의 다수가 꼬리 |
N = 100이면 63%다. 개별 백엔드의 p99가 전체 요청의 중앙값을 결정한다. 각 백엔드 평균이 1ms여도 사용자가 보는 시간은 10ms 쪽이다. Jeffrey Dean과 Luiz André Barroso가 2013년 The Tail at Scale에서 정리한 구조다.
결론은 두 개다. 첫째, 성능 지표를 평균으로 관리하면 이 현상이 보이지 않는다. 평균은 꼬리를 희석하도록 설계된 통계다. 둘째, 팬아웃 N을 줄이는 것이 개별 서비스를 빠르게 만드는 것보다 효과가 클 때가 많다. N을 100에서 10으로 줄이면 꼬리에 걸릴 확률이 63%에서 9.6%로 떨어진다.
호출이 직렬이면 계산이 달라진다. 지연이 더해지므로 최악값이 아니라 합이 문제가 되고, 재시도가 붙으면 증폭된다. 호출 깊이 3단계에서 각 단계가 3회 재시도하면 최하류는 최대 3³ = 27배의 부하를 받는다. 이 증폭은 12편에서 Retry Storm으로 다룬다.
타임아웃·재시도·멱등성을 API의 1급 요소로
앞의 결제 호출을 다시 보자.
// clients/payment.ts — Before
export async function charge(orderId: string, amount: number) {
const res = await fetch(`${PAYMENT_URL}/charges`, {
method: "POST",
body: JSON.stringify({ orderId, amount }),
})
if (!res.ok) throw new Error("payment failed")
return res.json()
}문제는 셋이다. 타임아웃이 없으므로 하류가 느려지면 이쪽 커넥션 풀이 먼저 마른다. 재시도가 없으므로 일시적 연결 실패가 곧 주문 실패다. 재시도를 넣어도 이 요청은 멱등하지 않아 이중 청구가 된다. 재시도를 안전하게 만드는 것은 재시도 코드가 아니라 서버 쪽 멱등성이다.
// clients/payment.ts — After
export async function charge(orderId: string, amount: number, key: string) {
const TIMEOUT_MS = 800, RETRIES = 2
for (let attempt = 0; ; attempt++) {
const ac = new AbortController()
const timer = setTimeout(() => ac.abort(), TIMEOUT_MS)
try {
const res = await fetch(`${PAYMENT_URL}/charges`, {
method: "POST",
signal: ac.signal,
// 같은 키로 두 번 들어오면 서버가 첫 결과를 그대로 돌려준다
headers: { "idempotency-key": key },
body: JSON.stringify({ orderId, amount }),
})
if (res.status >= 500 || res.status === 429) throw new Retriable(res.status)
if (!res.ok) throw new PaymentRejected(res.status) // 4xx는 재시도하지 않는다
return await res.json()
} catch (e) {
if (attempt >= RETRIES || !isRetriable(e)) throw e
// 지수 백오프 + 지터. 지터가 없으면 재시도가 같은 순간에 몰린다
const base = 100 * 2 ** attempt
await sleep(base / 2 + Math.random() * base)
} finally {
clearTimeout(timer)
}
}
}key는 호출자가 주문 단위로 생성해 전달한다. 재시도마다 새로 만들면 의미가 없다. 지터를 넣는 이유는 재시도가 몰리면 하류 부하가 계단 모양이 되기 때문이고, 4xx를 재시도하지 않는 이유는 그것이 "모른다"가 아니라 확정된 거절이기 때문이다.
타임아웃 800ms는 하류 p99에서 역산한 값이어야 한다. 하류 p99가 300ms면 여유를 둔 500~800ms, 그리고 반드시 상류 타임아웃보다 작아야 한다. 상류가 1초인데 하류가 3초면 상류가 먼저 끊어 하류 작업이 통째로 버려진다.
대가는 세 가지다. 첫째, 서버 쪽에 멱등성 키 저장소가 필요하다. 키와 첫 응답을 함께 저장하고 TTL을 관리해야 하며, 이 저장소 자체가 새 의존성이자 새 장애점이다. 둘째, 타임아웃은 한 번 정하고 끝나지 않는다. 하류 p99가 바뀌면 같이 바뀌어야 하는 계속되는 운영 비용이다. 셋째, 방어 로직이 실제 호출보다 길어진다. 이를 줄이려면 공통 클라이언트로 감싸게 되는데, 그 설계는 12편에서 Circuit Breaker와 함께 다룬다.
판단 기준 — 방어를 얹지 않는 게 맞는 경우
프로세스 내 호출. 같은 프로세스의 함수 호출에 타임아웃과 재시도를 얹는 것은 순손실이다. 부분 실패가 없으므로 방어할 대상이 없고, 코드만 읽기 어려워진다.
같은 노드의 사이드카, 유닉스 소켓. 왕복이 마이크로초 단위이고 네트워크 장비를 지나지 않는다. 타임아웃은 필요하지만 재시도를 여러 겹 쌓을 이유는 없다.
이미 상류에서 방어한 경로. 상류와 하류가 모두 3회 재시도하면 실제 시도는 9회가 되고, 타임아웃 예산은 서로 모르는 채 충돌한다. 재시도는 한 계층에서만 한다.
사용자가 기다리지 않는 배치. 전체 실행 시간이 분 단위인 야간 집계에서 개별 호출의 꼬리 지연을 줄이는 최적화는 우선순위가 낮다.
요약
| 항목 | 내용 |
|---|---|
| 증상 | 원격 호출이 로컬 함수와 같은 모양이라 타임아웃·부분 실패가 코드에 안 보인다 |
| 초기 매력 | 위치 투명성. 통신 세부사항을 감춰야 비즈니스 로직이 읽힌다 |
| 반론 | Waldo 외, A Note on Distributed Computing(1994). Deutsch와 Gosling의 8가지 오류 |
| 핵심 차이 | 로컬은 두 상태, 원격은 "모른다"가 있는 세 상태. 타임아웃은 실패가 아니다 |
| 수치 | 백엔드 p99 10ms, 팬아웃 N=100이면 1−0.99¹⁰⁰ = 63%가 꼬리에 걸린다 |
| 탈출 | 모든 호출에 타임아웃, 재시도는 멱등 연산에만, 멱등성 키는 호출자가 생성, 백오프에 지터 |
| 대가 | 멱등성 키 저장소라는 새 의존성, 끝나지 않는 타임아웃 튜닝 |
| 오히려 정답 | 프로세스 내 호출, 유닉스 소켓, 상류가 이미 방어한 경로, 야간 배치 |
다음 편 — 8편. 계약의 붕괴 — Chatty Interface, 버전 없는 API, Over-fetching
이번 편이 원격 호출 한 번의 비용을 다뤘다면, 다음 편은 그 호출을 몇 번 하게 되는지를 결정하는 API 설계를 다룬다. 목록 50건에 상세 조회 1회씩을 더하면 왕복 5ms 기준으로 250ms가 붙는다. 세 가지 증상을 하나의 원인으로 묶고, Zod로 계약을 코드에 고정하는 데까지 간다.