로드밸런싱·프록시·CDN — 한 대로 안 될 때
지금까지는 한 클라이언트와 한 서버 사이의 이야기였다. 그런데 사용자가 수백만이면 서버 한 대로는 감당이 안 된다. 그래서 여러 서버에 트래픽을 나누고, 사용자 가까이에 콘텐츠를 두는 장치들이 등장한다 — 로드밸런서, 프록시, CDN이다. 이들은 모두 "클라이언트와 서버 사이에 끼어들어" 트래픽을 분산·중계·캐싱한다는 공통점이 있다.
트래픽을 나눈다 — 로드밸런서, L4와 L7
**로드밸런서**는 들어오는 요청을 여러 서버에 분산한다. 어느 계층에서 보느냐로 둘로 갈린다. L4(전송 계층) 로드밸런서는 IP·포트만 보고 빠르게 넘긴다 — 내용을 모르니 단순·고속이다. L7(응용 계층) 로드밸런서는 HTTP 내용까지 들여다봐서 — 경로가 /api면 API 서버로, /img면 이미지 서버로 같은 콘텐츠 기반 라우팅, SSL 종료, 헤더 조작을 한다(더 똑똑하지만 무겁다).
분산 알고리즘도 여럿이다 — 라운드로빈(차례대로), least connections(연결이 적은 서버로), 해시(같은 클라이언트는 같은 서버로). 그리고 로드밸런서는 각 서버에 헬스체크를 보내 — 죽은 서버는 빼고 산 서버로만 보낸다(장애조치). 이때 세션이 문제가 된다 — 로그인 상태를 특정 서버 메모리에 두면, 다음 요청이 다른 서버로 가면 로그인이 풀린다. 그래서 같은 사용자를 같은 서버로 묶거나(스티키 세션), 아예 서버를 무상태로 만들어(세션을 공유 저장소나 토큰으로 빼서) 어느 서버가 받아도 되게 한다 — 수평 확장의 핵심 과제다.
사이에 서는 두 종류 — 리버스·포워드 프록시
프록시는 통신을 중계하는데, 누구를 대신하느냐로 나뉜다. **리버스 프록시**는 서버 앞에 서서 — 클라이언트는 진짜 서버를 모른 채 프록시에게 말하고, 프록시가 SSL 종료·캐싱·압축·로드밸런싱·보안을 대신한다(Nginx가 대표). 포워드 프록시는 클라이언트 앞에 서서 — 회사 망에서 외부로 나가는 요청을 대신하며 필터링·캐싱을 한다. 흔히 말하는 API 게이트웨이도 사실상 똑똑한 L7 리버스 프록시다 — 단일 진입점으로 라우팅·인증·rate limit을 모은다.
거리를 줄인다 — CDN
아무리 분산해도, 서버가 서울에 있으면 뉴욕 사용자는 빛의 속도 한계로 느리다(다음 편). 그래서 **CDN(Content Delivery Network)**은 *정적 콘텐츠(이미지·JS·CSS·영상)*를 전 세계 엣지 서버에 복제해 둔다 — 사용자는 가장 가까운 엣지에서 받아 지연이 확 준다. 처음 요청 때 원본(origin)에서 가져와 엣지에 캐싱하고, 이후엔 엣지가 답한다(캐시 편의 그 발상이 지리적으로 펼쳐진 것이다).
실무에서는 — 리버스 프록시·로드밸런서를 거치면 서버가 보는 클라이언트 IP가 프록시 IP가 되어, 진짜 IP는
X-Forwarded-For헤더로 전달받는다(로그·rate limit에서 챙겨야 한다). SSL 종료를 로드밸런서에서 하면 뒤쪽 통신은 평문이 될 수 있어 *내부 구간도 암호화(mTLS)*할지 정해야 한다. CDN은 *캐시 무효화(purge)*와 TTL·캐시 키 설계가 핵심이고(잘못 캐싱하면 옛 콘텐츠가 전 세계에 퍼진다), 정적/동적 콘텐츠를 경로로 분리해 캐싱 가능한 것만 CDN에 태운다.
실제로 눈으로 보기
정말 나뉘고 있나
for i in $(seq 1 10); do curl -s https://api.example.com/whoami; done
# srv-a
# srv-b
# srv-a
# srv-c
...
응답에 자기 식별자를 실어 주는 엔드포인트를 두면 분산이 눈으로 확인된다. 한 서버로만 가고 있다면 세션 고정이 걸려 있거나, 클라이언트가 연결을 재사용하고 있는 것이다.
연결 재사용이 특히 자주 놓치는 함정이다. L4 는 연결 단위로 분배하므로, 한 번 맺은 연결을 계속 쓰면 그 뒤 요청은 전부 같은 서버로 간다. 부하가 안 나뉘는 이유가 대개 이것이다.
원래 클라이언트가 누구였나
curl -s https://api.example.com/echo-headers
# {
# "x-forwarded-for": "203.0.113.7, 10.0.1.5",
# "x-forwarded-proto": "https",
# "x-real-ip": "203.0.113.7"
# }
L7 프록시를 거치면 서버가 보는 출발지 IP 는 프록시의 것이다. 원래 주소는 헤더로 전달된다.
X-Forwarded-For는 쉼표로 이어 붙는다 — 프록시를 여러 번 거치면 계속 추가된다. 그래서 가장 왼쪽이 원래 클라이언트인데, 이 값은 클라이언트가 위조할 수 있다. 신뢰할 수 있는 것은 우리가 관리하는 프록시가 붙인 마지막 항목들뿐이다. 접근 제어에 이 헤더를 그대로 쓰면 우회당한다.
L4 와 L7 은 무엇을 볼 수 있나
L4 IP·포트만 본다 빠르다. TLS 를 풀지 않는다
→ 경로별 라우팅 불가, 헤더 기반 분배 불가
L7 요청 내용을 본다 /api 는 A 로, /img 는 B 로
→ TLS 를 풀어야 하므로 인증서를 프록시가 갖는다
curl -s -o /dev/null -w '%{remote_ip} %{time_connect}\n' https://api.example.com
L7 은 TLS 를 종료한다는 점이 설계에 영향을 준다 — 인증서 관리가 프록시로 모이고, 프록시 뒤 구간은 평문이거나 별도 암호화가 필요하다.
죽은 서버를 언제 빼나
watch -n1 'curl -s -o /dev/null -w "%{http_code}\n" http://10.0.1.5:8080/healthz'
헬스체크에서 두 가지를 구분해야 한다.
- liveness — 프로세스가 살아 있나. 죽었으면 재시작
- readiness — 지금 요청을 받을 준비가 됐나. 아니면 빼기만 하고 죽이지는 않는다
기동 중이거나 일시적으로 바쁠 때 readiness 만 내리면 트래픽이 빠지고 회복되면 돌아온다. 이 구분이 없으면 기동 중인 서버로 요청이 가서 실패하거나, 잠깐 바쁜 서버를 죽여 상황을 악화시킨다.
CDN 이 실제로 캐시했나
curl -sI https://cdn.example.com/app.js | grep -iE 'x-cache|age|cf-cache-status'
# x-cache: HIT
# age: 3421 ← 캐시에 들어간 지 3421초
HIT이면 원본까지 안 갔다는 뜻이다. MISS가 계속 나오면 캐시 헤더가 잘못됐거나(Cache-Control: no-store), URL 에 매번 다른 쿼리가 붙어 같은 파일이 다른 키로 취급되고 있는 것이다.
정리하면, 한 대로 안 될 때 — 로드밸런서가 트래픽을 여러 서버에 나누고(L4 빠름 / L7 똑똑함, 헬스체크로 장애조치, 무상태/스티키로 세션 처리), *리버스 프록시*가 서버 앞에서 SSL·캐싱·보안을 대신하며(게이트웨이의 토대), *CDN*이 정적 콘텐츠를 사용자 가까운 엣지에 둬 지연을 줄인다. 이들은 모두 클라이언트와 서버 사이에 끼어들어 확장과 성능을 만든다 — 그 성능의 물리적 한계와 측정이 마지막 편의 주제다.