[CS300 #129] 분산 시스템의 8가지 오해 — 네트워크는 함수 호출이 아니다
컴퓨터공학 300 주제 시리즈의 129번째 글이다. 전체 지도는 여기.
한 줄 요약
“분산 컴퓨팅의 오해(Fallacies of Distributed Computing)” 는 처음 분산 시스템을 만드는 사람이 무심코 믿는 여덟 가지 가정이다. 네트워크는 믿을 만하다, 지연은 0이다, 대역폭은 무한하다, 네트워크는 안전하다, 토폴로지는 바뀌지 않는다, 관리자는 한 명이다, 전송 비용은 0이다, 네트워크는 균질하다. 여덟 개 모두 틀렸고, 하나라도 믿으면 언젠가 장애로 돌아온다.
왜 필요한가
Part 7 앞부분은 한 기계 안의 이야기였다. 파이프, 공유 메모리, 스레드, 이벤트 루프. 한 기계 안에서는 함수를 부르면 함수가 실행되고, 메모리에 쓰면 메모리에 있다. 실패하면 프로세스 전체가 같이 죽는다.
기계가 두 대가 되는 순간 이 전제가 무너진다. 원격 호출은 성공할 수도, 실패할 수도, 그리고 성공했는지 모를 수도 있다. 상대가 죽었는지 느린지 구별할 수 없다. 일부만 실패하는 부분 실패(partial failure)가 기본이 된다.
이 목록은 썬 마이크로시스템즈에서 피터 도이치(L. Peter Deutsch)가 일곱 개를 정리하고 제임스 고슬링(James Gosling)이 여덟 번째를 더한 것으로 알려져 있다. 오래된 목록이지만 마이크로서비스, 쿠버네티스, 클라우드 시대에 오히려 더 자주 인용된다. 서비스 경계마다 네트워크가 끼어들기 때문이다.
핵심 개념
여덟 가지 오해와 그 결과
| # | 오해 | 현실 | 무시하면 생기는 일 |
|---|---|---|---|
| 1 | 네트워크는 믿을 만하다 | 패킷은 사라지고, 연결은 끊기고, 스위치는 재부팅된다 | 타임아웃 없는 호출이 영원히 매달린다 |
| 2 | 지연은 0이다 | 같은 데이터센터도 왕복 수백 μs, 대륙 간은 수십~수백 ms | 루프 안의 원격 호출(N+1)이 응답을 수 초로 늘린다 |
| 3 | 대역폭은 무한하다 | 링크마다 상한이 있고 공유된다 | 큰 페이로드·과한 로그가 다른 트래픽을 굶긴다 |
| 4 | 네트워크는 안전하다 | 내부망도 도청·위조가 가능하다 | 평문 내부 통신, 인증 없는 내부 API |
| 5 | 토폴로지는 바뀌지 않는다 | 파드 IP 는 재시작마다 바뀌고 노드는 들고 난다 | IP 하드코딩, 오래된 DNS 캐시 |
| 6 | 관리자는 한 명이다 | 팀·조직·클라우드 업체가 각자 일부를 관리한다 | 방화벽·설정 변경을 아무도 모른다 |
| 7 | 전송 비용은 0이다 | 직렬화 CPU 비용과 클라우드 송신 요금이 있다 | 존(zone) 간 트래픽 요금 폭탄, 직렬화가 CPU 병목 |
| 8 | 네트워크는 균질하다 | 기기·OS·프로토콜·MTU 가 제각각 | 특정 경로에서만 깨지는 패킷, 이상한 지연 |
가장 근본적인 것: 부분 실패와 불확실성
원격 호출이 타임아웃으로 끝났다. 가능한 경우는 셋이다.
1) 요청이 가는 길에 사라졌다 → 서버는 아무것도 안 했다
2) 서버가 처리하다 죽었다 → 일부만 했을 수 있다
3) 서버는 처리했고 응답이 사라졌다 → 다 했다
클라이언트는 이 셋을 구별할 수 없다. 이 불확실성이 분산 시스템 문제 대부분의 뿌리다. 다시 보내면 중복 실행이 되고(→ 멱등성), 안 보내면 유실이 된다. 상대가 죽은 건지 느린 건지 모르니 장애 감지가 확률 문제가 된다(→ 장애 감지). 누가 리더인지 서로 다르게 믿을 수 있다(→ 리더 선출, 합의). Part 7 의 나머지 글은 거의 전부 이 한 그림에서 출발한다.
원격 호출을 지역 호출처럼 보이게 하는 것의 위험
RPC 프레임워크는 원격 호출을 함수 호출처럼 보이게 해 준다. 편리하지만 위험하다. 짐 왈도(Jim Waldo) 등은 1994년 썬 연구소 보고서 “A Note on Distributed Computing” 에서, 지연·메모리 접근·부분 실패·동시성의 차이 때문에 지역 객체와 원격 객체를 같은 것처럼 다루면 안 된다고 주장했다. 인터페이스가 같아 보여도 실패 모델이 다르다.
오해를 피하는 설계 습관
- 모든 원격 호출에 타임아웃을 둔다. 기본값이 무한인 클라이언트 라이브러리가 많다.
- 재시도는 멱등한 연산에만, 지수 백오프와 지터를 섞어서 한다.
- 원격 호출을 루프 밖으로 빼고 묶어서(batch) 보낸다.
- 서비스 주소는 이름(DNS, 서비스 디스커버리)으로 찾는다.
- 내부 통신도 TLS·인증을 기본으로 한다.
- 지연은 평균이 아니라 꼬리(p99) 로 본다. 사용자 요청 하나가 원격 호출 여러 개를 거치면 그중 하나만 느려도 전체가 느리다.
직접 해 보기
2% 의 메시지가 사라지고 1% 가 2초씩 늦는 가짜 네트워크를 만들고, “네트워크를 믿는” 클라이언트와 “타임아웃 + 재시도” 클라이언트를 비교한다.
import random
random.seed(7)
def network_call():
"""요청 한 번. (성공 여부, 걸린 시간 ms) 를 돌려준다."""
if random.random() < 0.02: # 2% 는 요청이나 응답이 사라진다
return False, float("inf")
latency = random.lognormvariate(3.0, 0.5) # 대개 20ms 안팎, 가끔 긴 꼬리
if random.random() < 0.01:
latency += 2000 # 1% 는 GC·재전송 등으로 2초 지연
return True, latency
def naive(): # 오해 1·2: 네트워크는 믿을 만하고 빠르다
return network_call() # 사라지면 영원히 기다린다(inf)
def careful(timeout=200, retries=2): # 타임아웃 + 재시도
spent = 0.0
for _ in range(retries + 1):
ok, t = network_call()
if ok and t <= timeout:
return True, spent + t
spent += timeout # 타임아웃까지 기다리고 포기
return False, spent
for name, fn in (("naive", naive), ("careful", careful)):
res = [fn() for _ in range(10000)]
fails = sum(1 for ok, _ in res if not ok)
hung = sum(1 for _, t in res if t == float("inf"))
ts = sorted(t for ok, t in res if ok)
p50, p99 = ts[len(ts)//2], ts[int(len(ts)*0.99)]
print(f"{name:8} 실패 {fails:4} 영원히 대기 {hung:4} p50 {p50:6.1f}ms p99 {p99:7.1f}ms max {ts[-1]:7.1f}ms")
naive 실패 217 영원히 대기 217 p50 20.2ms p99 2010.7ms max 2110.7ms
careful 실패 1 영원히 대기 0 p50 20.3ms p99 225.7ms max 436.6ms
중앙값은 둘이 같다. 평소에는 차이가 안 보인다는 뜻이다. 차이는 꼬리에서 난다. 순진한 클라이언트는 1만 번 중 217번을 영원히 기다리고, p99 가 2초다. 타임아웃과 재시도를 넣은 클라이언트는 매달리는 요청이 없고 p99 가 0.23초로 내려간다. 단, 이 재시도는 요청이 멱등할 때만 안전하다. “응답만 사라진” 경우 서버는 같은 일을 두 번 하게 된다.
현업에서는
- 쿠버네티스. 파드 IP 는 재시작하면 바뀐다(오해 5). 그래서 Service 와 DNS 이름으로 접근한다. 노드 사이 통신은 오버레이 네트워크(VXLAN 등)를 거치며 MTU 가 줄어드는데, 이를 무시하면 큰 패킷만 사라지는 이상한 장애가 난다(오해 8).
- 홈랩 클러스터. 와이파이로 붙은 노드가 있는 소규모 클러스터에서는 순간적인 신호 저하가 노드 NotReady 나 etcd 리더 재선출로 이어지기도 한다. “네트워크는 믿을 만하다” 가 가장 먼저 깨지는 곳이 가정용 네트워크다.
- 클라우드 비용. 같은 리전의 다른 가용 영역 사이 트래픽에 요금이 붙는 클라우드가 많다(오해 7). 복제 트래픽이 많은 데이터베이스나 메시지 브로커는 배치에 따라 비용이 크게 달라진다.
- 연쇄 장애. 구글 SRE 책은 느린 의존 서비스가 호출자의 스레드·커넥션을 붙잡아 장애가 위로 번지는 연쇄 장애를 다룬다. 타임아웃, 재시도 예산, 부하 차단이 대책이다.
확인 문제
- 원격 호출이 타임아웃으로 끝났을 때 클라이언트가 구별할 수 없는 세 가지 경우는?
- 중앙값 지연은 같은데 p99 가 크게 다른 두 시스템이 있다. 사용자 체감은 어떻게 다를까?
- 파드 IP 를 설정 파일에 직접 적으면 어떤 오해를 범한 것인가?
- 위 실험의
careful재시도가 위험해지는 조건은?
풀이
- 요청이 가다가 사라짐, 서버가 처리 도중 죽음, 서버가 처리했지만 응답이 사라짐.
- 요청 하나가 여러 원격 호출을 거치면 그중 하나라도 꼬리에 걸릴 확률이 커져, p99 가 큰 시스템은 자주 느리게 느껴진다.
- “토폴로지는 바뀌지 않는다” (오해 5).
- 요청이 멱등하지 않을 때다. 응답만 사라진 경우 재시도가 같은 작업(예: 결제)을 두 번 실행한다.
더 읽을거리 (References)
- Arnon Rotem-Gal-Oz, “Fallacies of Distributed Computing Explained” (PDF)
- Google SRE Book, Chapter 22 — Addressing Cascading Failures
- RFC 1925 — The Twelve Networking Truths
- Jim Waldo, Geoff Wyant, Ann Wollrath, Sam Kendall, “A Note on Distributed Computing”, Sun Microsystems Laboratories Technical Report SMLI TR-94-29, 1994.