Nginx 로드밸런싱 설정 TIL — upstream 알고리즘과 헬스체크의 함정

목차

nginx 로드밸런싱 설정은 upstream 블록 몇 줄이면 동작한다. 그런데 실제 트래픽을 받아보면 round robin, ip_hash, least_conn 중 뭘 골라야 하는지, 한 대가 죽었을 때 nginx가 진짜로 떼어내는지에서 막힌다. 프론트만 만지다 백엔드로 넘어와 인프라까지 단독으로 잡고 있는데, 이번에 EC2 3대 뒤에 nginx를 세우면서 공식 문서에 한 줄로만 적혀 있는 함정을 정리해둔다.

이 글에서 다루는 건 nginx 오픈소스 1.24 기준 upstream 모듈, 그리고 흔히 함께 묶이는 헬스체크·세션 유지 동작이다. nginx-plus 전용 기능과 오픈소스에서만 되는 것을 구분해서 적었다. TIL이라고 적은 이유는 거창한 가이드가 아니라 이번에 백엔드 3대 분산을 잡으면서 새로 알게 된 것만 추린 메모에 가깝기 때문이다.

오늘 한 것 — EC2 3대 뒤에 nginx 한 대 세우기

환경은 단순하다. t3.small 3대에 Node.js Express 서버를 각각 띄워두고, 그 앞에 nginx 한 대를 프록시로 둔다. nginx 버전은 1.24.0, OS는 Ubuntu 22.04. AWS 보안그룹은 nginx 인스턴스만 80/443을 외부에 열고, 백엔드 3대는 nginx의 사설 IP만 허용한다. 흔한 구성이라 따로 그릴 것도 없다.

처음 설정 파일은 정말 짧다.

# /etc/nginx/conf.d/upstream.conf
upstream api_backend {
    server 10.0.1.11:3000;
    server 10.0.1.12:3000;
    server 10.0.1.13:3000;
}

server {
    listen 80;
    server_name api.example.com;

    location / {
        proxy_pass http://api_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

nginx -t로 문법 검사하고 systemctl reload nginx. 끝이다. 알고리즘을 명시하지 않으면 기본은 weighted round robin이다. 가중치를 따로 안 주면 1로 통일되니 그냥 라운드로빈이라고 봐도 된다.

검증은 백엔드에 hostname을 돌려주는 /whoami 엔드포인트를 박아두고 curl로 친다.

# 9번 호출하면 3대에 골고루 분배되는지 본다
for i in {1..9}; do curl -s https://api.example.com/whoami; echo; done
# api-1, api-2, api-3, api-1, api-2, api-3, ... 순으로 떨어진다

여기까지는 5분이면 끝난다. 프론트에서 Vite proxy 한 번 만져본 사람이면 proxy_pass는 익숙할 거다. 진짜로 헷갈리는 건 알고리즘 선택과 헬스체크였다.

한편, 한 가지 더 — nginx -t는 문법만 본다. upstream 안의 서버 IP가 실제로 살아 있는지는 확인하지 않는다. 잘못된 IP를 넣어도 nginx -t는 OK를 돌려준다. reload 후에 첫 요청을 직접 쳐보거나, 헬스체크 스크립트가 한 번 돈 다음 로그를 보는 습관이 필요하다.

따라서, DNS resolver 동작도 메모해둔다. upstream의 server 라인에 도메인을 쓰면, nginx는 시작 시점에 한 번만 DNS를 조회한다. 백엔드가 ECS나 Kubernetes 같은 동적 환경이면 IP가 바뀔 수 있는데, nginx는 그걸 모른 채로 끝까지 옛날 주소로 트래픽을 보낸다. 동적 DNS 해석이 필요하면 resolver 지시어와 proxy_pass에서 변수를 같이 쓰는 우회법이 필요하다. 이번엔 EC2 사설 IP를 고정으로 박았기 때문에 문제는 없었다.

새로 알게 된 것 — 알고리즘 셋의 진짜 차이

문서에는 round robin, ip_hash, least_conn 세 줄로 끝나지만, 실제로 트래픽을 흘려보면 셋이 꽤 다르게 행동한다. 한 줄 추가하면 알고리즘이 바뀐다는 사실 자체보다, 각 알고리즘이 어떤 상황에서 망가지는지가 더 중요하다.

Round Robin — 기본값이지만 응답시간이 다르면 의미가 깎인다

기본 round robin은 요청을 순서대로 돌리는 것까지만 한다. 백엔드 한 대가 GC로 잠깐 멈춰서 1초씩 늦게 응답해도, nginx는 그 사실을 모르고 계속 다음 차례에 그 서버를 끼워 넣는다. 응답 시간 분포가 거의 균일한 stateless API라면 round robin이 가장 단순하고 충분하다.

가중치 문법은 이렇게 된다.

upstream api_backend {
    server 10.0.1.11:3000 weight=3;
    server 10.0.1.12:3000 weight=2;
    server 10.0.1.13:3000 weight=1;
}

또한, 스펙이 다른 인스턴스를 섞어 쓸 때만 가중치를 주고, 같은 스펙이면 건들지 마라. 가중치를 잘못 주면 나중에 트래픽 패턴 분석할 때 본인이 헷갈린다.

반면, 또 max_conns 옵션으로 백엔드별 최대 동시 커넥션을 제한할 수 있다(nginx 1.11.5부터 오픈소스에도 들어왔다). 한 대당 일정 수를 넘어가면 응답이 느려지는 백엔드라면, round robin에 max_conns를 더해 분배는 단순하게 가져가되 과부하는 막을 수 있다.

IP Hash — 세션 유지에 쓰지만 모바일 NAT의 함정이 있다

세션을 메모리에 들고 있는 서버라면 IP Hash가 가장 직관적이다. 같은 클라이언트 IP는 항상 같은 백엔드로 간다.

upstream api_backend {
    ip_hash;
    server 10.0.1.11:3000;
    server 10.0.1.12:3000;
    server 10.0.1.13:3000;
}

여기서 모르고 가면 한 번 데이는 부분이 있다. 모바일 캐리어망은 NAT 뒤에 수많은 사용자가 같은 공인 IP로 묶여서 나간다. 한국 LG U+, KT, SKT 모두 캐리어그레이드 NAT를 광범위하게 쓴다. 그래서 모바일 트래픽이 많은 서비스에서 ip_hash를 켜면, 특정 백엔드 한 대로 트래픽이 비정상적으로 쏠리는 현상이 보인다.

또 한 가지, ip_hash는 IPv4 기준으로 앞 3옥텟만 해시한다. 즉 같은 /24 대역 안의 클라이언트는 같은 백엔드로 간다. 사무실 NAT 환경에서 직원들이 전부 한 서버로만 가는 현상이 이 동작 때문이다. IPv6는 전체 주소를 쓴다. 이 차이를 모르고 IPv6 전환했다가 트래픽 패턴이 달라져 당황한 사례가 GitHub 이슈에도 종종 올라온다.

ip_hash는 백엔드 한 대를 늘리거나 줄이면 해시 키 공간이 재분포된다. 결과적으로 기존 세션 일부가 다른 서버로 옮겨가며 풀려버린다. 세션이 진짜로 중요하면 nginx가 아니라 Redis 같은 외부 세션 스토어로 빼두는 게 답이다. nginx의 ip_hash는 어디까지나 "대충 같은 곳으로 보내달라"는 정도의 약한 보장으로 받아들이면 된다.

Least Connections — 응답시간 편차가 클 때 가장 안정적

least_conn은 현재 활성 커넥션이 가장 적은 백엔드로 보낸다.

upstream api_backend {
    least_conn;
    server 10.0.1.11:3000;
    server 10.0.1.12:3000;
    server 10.0.1.13:3000;
}

요청별 처리 시간이 들쭉날쭉한 경우 — 파일 업로드, LLM 호출, 외부 API 프록시 — 라면 round robin보다 훨씬 안정적이다. 느린 요청이 쌓인 백엔드는 자연스럽게 신규 요청을 덜 받게 된다. 프론트에서 데이터 fetch만 하던 시절엔 별 생각이 없었는데, 백엔드로 넘어와 응답시간 분포를 직접 보고 나니 왜 이 옵션이 따로 있는지 비로소 이해됐다.

least_conn을 켜고 가중치도 함께 쓰면 weighted least connections로 동작한다. 가중치가 큰 백엔드에 비례적으로 더 많은 커넥션이 할당된다. 단, nginx는 활성 커넥션 수만 보지 응답시간 자체는 보지 않는다. HAProxy의 leastconn이나 envoy의 least_request와는 미묘하게 다르다는 점도 같이 기억해두면 좋다.

세 가지를 한 표로 정리하면 이렇다.

알고리즘 기본 동작 적합한 경우 주된 함정
Round Robin 순서대로 분배 응답시간 균일한 stateless API 느린 백엔드를 못 피한다
IP Hash 클라이언트 IP 기준 고정 메모리 세션을 쓰는 레거시 모바일 NAT로 한쪽 쏠림, 노드 변경 시 재분포
Least Connections 활성 커넥션 최소 노드 응답시간 편차 큰 API 커넥션 수만 보고 응답시간은 못 본다

세션이 필요하면 ip_hash로 버티지 말고 세션 스토어를 따로 빼자. 결국 가장 안정적이라는 게 이번 작업의 결론 하나다.

헬스체크는 기본이 패시브다 — 오픈소스 nginx의 한계

즉, 이 부분에서 가장 많이 헤맸다. nginx upstream에 서버 세 대를 적어두면 한 대가 죽었을 때 nginx가 알아서 빼주리라 막연히 믿었는데, 실제로는 "요청이 가서 실패해야" 빼준다. 능동적으로 ping을 보내지 않는다. 오픈소스 nginx의 헬스체크는 전부 passive다.

max_fails와 fail_timeout — 죽은 노드를 빼는 진짜 조건

기본값은 max_fails=1, fail_timeout=10s다. 풀어 쓰면 "10초 안에 한 번이라도 실패하면 그 서버를 10초 동안 빼라"는 뜻이다. 직관적으로 들리는데, 트래픽이 적은 시간대에는 실제 장애가 나도 한참 동안 요청이 그 서버로 계속 가는 경우가 생긴다. 트래픽이 곧 헬스체크 신호이기 때문이다.

upstream api_backend {
    least_conn;
    server 10.0.1.11:3000 max_fails=3 fail_timeout=30s;
    server 10.0.1.12:3000 max_fails=3 fail_timeout=30s;
    server 10.0.1.13:3000 max_fails=3 fail_timeout=30s backup;
}

backup 키워드를 붙이면 다른 서버가 전부 실패할 때만 들어가는 예비 노드가 된다. 평소엔 트래픽이 안 가니 실제로 살아 있는지 검증되지 않은 채 대기한다. backup 노드도 별도 모니터링이 꼭 필요하다.

여기서 한 가지 더 — proxy_next_upstream 지시어로 어떤 조건에서 다음 백엔드로 재시도할지를 정한다. 기본값은 errortimeout이라 5xx 응답은 재시도 대상이 아니다. API 백엔드가 500을 뱉으면 클라이언트는 그대로 500을 받는다.

location / {
    proxy_pass http://api_backend;
    proxy_next_upstream error timeout http_502 http_503 http_504;
    proxy_next_upstream_tries 2;
    proxy_next_upstream_timeout 5s;
}

http_500을 켤지 말지는 신중해야 한다. 멱등하지 않은 POST가 재시도되면 중복 처리가 생긴다. GET 위주 서비스라면 켜도 큰 문제 없지만, 결제·주문 라우트가 섞여 있으면 location 단위로 분리하는 편이 안전하다.

액티브 헬스체크는 nginx-plus 전용이다

진짜로 능동 헬스체크 — nginx가 주기적으로 /health를 찔러보고 죽었으면 풀에서 빼는 동작 — 를 원하면 nginx-plus가 필요하다. 상업 라이선스 기준 인스턴스당 연 단위 비용이 든다. 작성 시점(2026년 6월 기준)으로도 이 기능은 오픈소스에 들어와 있지 않다.

그러나, 오픈소스에서 능동 헬스체크를 흉내내려면 옵션이 몇 가지 있다.

첫째, nginx_upstream_check_module 같은 서드파티 모듈을 컴파일해서 넣는다. 동작은 잘 되지만 nginx를 직접 빌드해야 해서 패키지 관리가 번거롭다.

그래서, 둘째, 외부에서 주기적으로 /health를 호출해 실패하면 nginx 설정에서 해당 서버를 down으로 표시하고 reload하는 스크립트를 돌린다. 단순하고 디버깅이 쉽다. 다만 reload는 무중단이긴 해도 너무 자주 하면 별로다.

셋째, 그냥 OpenResty나 HAProxy로 갈아탄다. HAProxy는 오픈소스판도 액티브 헬스체크가 기본이다. 새로 구축하는 단계라면 이쪽도 고려할 만하다.

즉, 이번엔 외부 스크립트 + reload 방식으로 갔다. 30초마다 모든 백엔드에 /health를 찌르고, 실패한 노드는 nginx conf의 server 라인 끝에 down을 붙여 reload한다. 코드는 50줄도 안 된다.

#!/bin/bash
# /usr/local/bin/nginx-healthcheck.sh
# cron으로 30초마다 실행

UPSTREAM_CONF=/etc/nginx/conf.d/upstream.conf
TMP_CONF=$(mktemp)
CHANGED=0

while IFS= read -r line; do
    if [[ $line =~ server[[:space:]]+([0-9.]+):([0-9]+) ]]; then
        ip="${BASH_REMATCH[1]}"
        port="${BASH_REMATCH[2]}"
        if curl -fs --max-time 2 "http://${ip}:${port}/health" > /dev/null; then
            # 살아 있으면 down 제거
            new_line=$(echo "$line" | sed 's/ down//')
        else
            # 죽었으면 down 추가
            [[ ! $line =~ down ]] && new_line="${line%;} down;" || new_line="$line"
        fi
        [[ "$new_line" != "$line" ]] && CHANGED=1
        echo "$new_line" >> "$TMP_CONF"
    else
        echo "$line" >> "$TMP_CONF"
    fi
done < "$UPSTREAM_CONF"

if [[ $CHANGED -eq 1 ]]; then
    mv "$TMP_CONF" "$UPSTREAM_CONF"
    nginx -t && systemctl reload nginx
fi

완벽한 코드는 아니다. 정규식이 단순해서 weight나 max_fails가 붙은 라인은 깨질 수 있고, reload가 30초 간격이라 장애 감지 후 트래픽 전환까지 최대 1분 정도 걸린다. 그래도 nginx-plus 비용을 안 쓰고도 어느 정도 동작은 한다. 더 정밀한 게 필요해지면 HAProxy로 갈아탈 생각이다.

세션 유지를 nginx로만 해결하려 하지 마라

세션 얘기를 한 번 더 정리한다. ip_hash는 "같은 클라이언트가 가능하면 같은 서버로 간다"는 약한 보장이지, 영구적 고정이 아니다. 한 대가 죽으면 그 서버에 매핑됐던 세션은 풀린다. sticky cookie 방식(nginx-plus의 sticky cookie 지시어)을 오픈소스에서 흉내내려면 split_clients 모듈이나 lua-nginx-module을 끼워야 한다.

특히, 프론트에서 토큰 기반 인증을 쓰던 사람이라면, 백엔드로 와서 가장 먼저 세션을 외부 스토어로 빼는 결정을 권장한다. JWT나 Redis 세션을 쓰면 로드밸런서 알고리즘 선택에서 자유로워진다. 이번 작업에서도 결국 Redis 세션 스토어로 바꾸고 알고리즘은 least_conn으로 픽스했다.

프론트 출신이 백엔드 LB를 만지면서 헷갈렸던 것들

전환자 시점에서 처음 막힌 부분 몇 가지를 빠르게 적어둔다. 이미 백엔드를 오래 한 사람한테는 당연한 얘기지만, 프론트에서 fetch만 쓰던 사람한테는 의외로 안 보이는 부분이다.

proxy_pass의 슬래시 한 글자가 라우팅을 바꾼다

proxy_pass http://api_backend/처럼 끝에 슬래시를 붙이면, location prefix가 잘려서 백엔드로 전달된다. 슬래시 없이 proxy_pass http://api_backend로 쓰면 원래 URI가 그대로 붙는다. Vite proxy의 rewrite 옵션과는 동작 방식이 달라서, 처음 보면 404가 나오는 이유를 못 찾는다. 공식 문서의 proxy_pass 항목에 짧게 적혀 있긴 한데, 한 번 깨져 봐야 체감된다.

keepalive를 안 켜면 nginx와 백엔드 사이가 매번 새 커넥션이다

upstream 블록 안에 keepalive 32; 같은 줄을 추가하지 않으면, nginx는 백엔드와의 커넥션을 매 요청마다 새로 연다. TCP 핸드셰이크가 매번 일어난다는 뜻이다. 부하 테스트로 RPS를 올려보면 응답시간이 의외로 길게 나오는데, 이 옵션 하나로 체감상 꽤 줄어든다.

upstream api_backend {
    least_conn;
    server 10.0.1.11:3000;
    server 10.0.1.12:3000;
    server 10.0.1.13:3000;
    keepalive 32;
}

server {
    location / {
        proxy_pass http://api_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
    }
}

proxy_http_version 1.1Connection "" 헤더를 같이 안 써주면 keepalive가 실제로 동작하지 않는다. 세 줄이 한 세트로 묶여 있다.

worker_connections는 LB에서 더 신중하게 잡아야 한다

기본값 worker_connections 1024는 단일 서버 환경에선 충분하지만, LB로 쓰면 각 클라이언트 커넥션마다 백엔드로 가는 커넥션이 하나 더 잡힌다. 즉 동시 1024개 요청을 받으려면 worker는 사실상 2048개 커넥션이 필요하다. 트래픽이 늘면 이 부분이 먼저 병목이 된다. ulimit -n도 함께 올려야 한다.

결국, 이번에 정리한 것 중 당장 실행할 만한 액션 세 가지만 추려둔다.

  • proxy_next_upstreamhttp_502 http_503 http_504를 추가하되, 결제·주문 같은 비멱등 라우트는 별도 location으로 분리해 재시도를 끄자.
  • 모바일 트래픽이 30% 이상이면 ip_hash는 쓰지 말고, 세션은 Redis로 빼자.
  • 액티브 헬스체크가 필요하면 외부 스크립트 + reload로 시작하되, 운영 규모가 커지면 HAProxy 전환을 검토하라.

한편, 공식 문서는 nginx upstream 모듈 문서proxy_next_upstream 항목을 참고하라. nginx 로드밸런싱 설정은 이 두 페이지만 정독해도 절반은 알게 된다. nginx 1.24.0 기준이고, mainline 1.27 계열에서도 upstream 관련 변경사항은 미미한 편이다(출처: nginx CHANGES, 2025년 기준).

그러나, 다음엔 nginx 자체 이중화 — keepalived로 VIP를 띄워 LB 자체가 죽었을 때를 대비하는 부분 — 를 해볼 생각이다.

관련 글