← all posts
DEV 2026.05.02 · 15 min read Intermediate

트래픽을 제어하는 다섯 가지 원칙

L4/L7 분기 선택부터 서킷 브레이커의 Half-Open 탐침까지, 네트워크 계층별 트래픽 제어 패턴이 공유하는 하나의 설계 철학을 추적한다.


네트워크 계층에서 트래픽을 다루는 기술들은 겉으로 보면 제각각이다. L4/L7 로드 밸런서, Nginx 리버스 프록시, 세션 관리, Rate Limiting, 서킷 브레이커. 그런데 이 다섯 가지는 같은 질문에 대한 다른 대답이다. “어떻게 하면 일부 컴포넌트의 실패가 전체 시스템으로 번지지 않게 할 수 있는가?”

계층이 다르면 보이는 것이 다르다

L4 로드 밸런서는 IP와 포트만 본다. 패킷의 목적지를 DNAT으로 바꿔 백엔드로 전달하는 것이 전부다. HTTP 헤더도, URL도, 쿠키도 읽지 않는다. 덕분에 처리 비용이 극히 낮고, TCP/UDP를 막론하고 어떤 프로토콜이든 통과시킨다.

L7 로드 밸런서는 한 단계 더 내려간다. TLS를 종단하고, HTTP 요청을 파싱하고, URL과 헤더와 쿠키를 읽은 뒤 라우팅 결정을 내린다. 클라이언트와 LB 사이의 연결, LB와 백엔드 사이의 연결이 완전히 분리된다. 이 분리가 “느린 클라이언트가 백엔드 스레드를 점유하는” 문제를 막는다.

L4: 클라이언트 ↔ LB ↔ 서버   (TCP 레벨 연결 공유)
L7: 클라이언트 ↔ LB / LB ↔ 서버  (완전히 독립된 두 연결)

선택 기준은 단순하다. URL 라우팅, TLS 종단, WAF, 쿠키 기반 Sticky Session이 필요하면 L7(ALB). 비HTTP 프로토콜, 낮은 지연, 클라이언트 IP 직접 확인이 필요하면 L4(NLB). 그리고 둘 다 필요하면 인터넷 → NLB → Nginx(L7) → 백엔드 형태로 겹친다.

Nginx는 두 연결을 분리한다

Nginx가 수만 개의 동시 연결을 처리하는 비결은 Redis의 단일 스레드 이벤트 루프와 같은 구조다. Worker 프로세스 수는 CPU 수에 맞추고, 각 Worker는 epoll로 수천 개 소켓을 비동기로 감시한다. 준비된 소켓에만 CPU를 쓰므로 스레드가 I/O에 묶이지 않는다.

upstream keepalive는 이 구조에서 TCP 핸드쉐이크 비용을 제거한다. 초당 1000 요청이 들어올 때 keepalive 없이는 초당 1000번의 TCP 연결이 맺어진다. keepalive 32를 설정하면 Nginx가 최대 32개의 유휴 연결을 풀에 유지하며 재사용한다.

upstream backend {
    server tomcat:8080;
    keepalive 32;
    keepalive_requests 1000;
    keepalive_timeout 60s;
}

server {
    location /api/ {
        proxy_pass http://backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";  # keep-alive 강제
    }
}

버퍼링은 Nginx의 또 다른 보호 장치다. 기본적으로 Nginx는 백엔드 응답을 버퍼에 모두 받은 뒤 백엔드 연결을 즉시 해제한다. 2G 네트워크 클라이언트가 응답을 천천히 받더라도 백엔드 스레드는 이미 해방되어 다음 요청을 처리 중이다. proxy_buffering off는 SSE나 WebSocket처럼 실시간 스트리밍이 필요한 경우에만 켜야 한다.

로그에서 request_time이 크고 upstream_response_time이 작다면, 백엔드는 빠른데 클라이언트에게 전달이 느린 것이다. 버퍼링이 정상 작동하고 있다는 뜻이다. 반대로 upstream_response_time이 크다면 애플리케이션 코드나 DB를 살펴볼 차례다.

세션은 중앙에 두어야 한다

Sticky Session은 수평 확장의 발목을 잡는다. IP Hash 방식은 NAT 뒤의 수천 명을 같은 서버로 몰고, 서버를 추가할 때마다 해시 재계산으로 세션이 흩어진다. 쿠키 기반 Session Affinity가 IP Hash보다 낫지만, 서버 하나가 죽으면 그 서버를 쿠키에 박고 있던 사용자들의 세션은 사라진다.

근본적인 해결책은 세션을 서버 메모리 밖으로 꺼내는 것이다. Spring Session + Redis를 쓰면 어느 서버로 라우팅되어도 Redis에서 같은 세션을 꺼낸다. 배포 시 서버를 재시작해도 세션은 유지된다. Sticky Session Draining 같은 복잡한 배포 절차도 필요 없어진다.

JWT Stateless 설계는 Redis 의존성 자체를 없앤다. 서명 검증만으로 인증하므로 DB 조회가 없고, 어느 서버로 요청이 가든 동일하게 처리된다. 단점은 토큰의 즉각 폐기가 어렵다는 것이다. 실무 표준은 Access Token(JWT, 15분 만료) + Refresh Token(Redis 저장, 30일 만료) 조합이다. 로그아웃 시 Refresh Token만 폐기하면 새 Access Token 발급이 차단되고, 기존 Access Token은 최대 15분 내에 자연 만료된다.

트레이드오프

서버 세션(Redis)은 즉각 무효화가 가능하지만 Redis가 SPOF가 된다. JWT는 완전한 Stateless이지만 토큰 폐기가 즉각적이지 않다. 보안 민감도에 따라 선택하거나, 두 가지를 조합(JWT Access + Redis Refresh)한다.

Rate Limiting은 알고리즘이 다르다

Fixed Window는 구현이 단순하지만 경계 시점에 2배 burst가 생긴다. 윈도우 마지막 1초에 100개, 다음 윈도우 첫 1초에 100개 — 분당 100개 제한인데 2초에 200개가 통과한다.

Token Bucket은 이 문제를 우아하게 해결한다. 토큰이 일정 속도로 충전되고, 요청이 들어올 때 소비된다. 토큰이 쌓여 있으면 burst를 허용하고, 소진되면 거부한다. Nginx의 limit_req_zone이 이 방식이다.

limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

location /api/ {
    limit_req zone=api burst=20 nodelay;
    # rate=10r/s: 초당 10개 토큰 충전
    # burst=20: 최대 20개 burst 허용
    # nodelay: burst 내에서 지연 없이 즉시 처리
}

분산 환경에서 서버별 로컬 카운터는 부정확하다. 서버 A와 B가 각각 100 req/s를 허용하면 사용자는 200 req/s를 쓸 수 있다. Redis + Lua 스크립트로 INCR과 EXPIRE를 원자적으로 처리해야 정확한 분산 Rate Limiting이 된다. Bucket4j + Redis 조합이 Spring 생태계의 표준이다.

서킷 브레이커는 장애를 격리한다

서킷 브레이커는 가장 정교한 트래픽 제어 메커니즘이다. 외부 서비스 장애 시 타임아웃만으로는 스레드가 30초씩 묶인다. 100개 동시 요청 × 30초 = 3000초 분량의 스레드 고갈이다. 서킷 브레이커는 이 연쇄를 끊는다.

세 상태를 순환한다. CLOSED(정상)에서 실패율이 임계값을 넘으면 OPEN으로 전환해 모든 요청을 즉시 차단하고 Fallback을 실행한다. wait-duration 경과 후 Half-Open으로 전환해 제한된 수의 탐침 요청을 통과시킨다. 탐침이 성공하면 CLOSED로 복귀, 실패하면 다시 OPEN으로 돌아간다.

Fallback은 단순해야 한다. Fallback 안에서 또 다른 외부 서비스를 호출하면 두 번째 장애가 생긴다. 로컬 캐시, 정적 기본값, 비동기 큐 저장이 안전한 패턴이다.

Retry와 조합할 때는 순서가 중요하다. CB(Retry(호출))이 아니라 Retry(CB(호출))이어야 한다. CB 안에 Retry를 두면 재시도 3회가 모두 CB 실패 카운터에 잡혀 CB가 너무 빨리 OPEN된다.

정리

다섯 챕터를 관통하는 원칙은 하나다. “느린 것, 실패하는 것, 폭발하는 것을 빠르게 격리하라.”

  • L4는 IP:Port만 보고 빠르게 분배한다. L7은 내용을 보고 정교하게 분배하되, 두 연결을 분리해 느린 클라이언트를 격리한다.
  • Nginx keepalive와 버퍼링은 백엔드를 클라이언트 속도로부터 격리한다.
  • 세션을 Redis로 중앙화하면 서버 장애가 세션 소실로 번지는 것을 막는다.
  • Token Bucket은 burst를 허용하면서 평균 처리율 초과를 격리한다.
  • 서킷 브레이커는 외부 서비스 장애가 내 서비스로 번지는 것을 격리한다.

각 도구의 파라미터(keepalive 크기, Rate Limit 임계값, CB wait-duration)는 트레이드오프의 손잡이다. 기본값으로 시작하고, 실제 트래픽 패턴을 측정한 뒤 조정하는 것이 가장 실용적인 접근이다.