본문 바로가기
삵
Nginx

Nginx upstream으로 백엔드 로드밸런싱하기

빅빅토르최·2026년 10월 12일·조회 0

Tomcat 앞에 Nginx를 리버스 프록시로 두고 운영하다 보면 한 대로는 금방 한계가 온다. WAS를 두세 대로 늘리고 Nginx가 그 앞에서 요청을 나눠주게 하는 구성은 거의 기본기에 가깝다. 그런데 막상 upstream 블록을 처음 쓰면 "분산은 되는데 한 대가 죽으면 어떻게 되나", "배포하려고 한 대 내릴 때 사용자 요청이 끊기지 않게 하려면" 같은 질문에서 멈추게 된다. 이 글은 그 지점들을 하나씩 짚는다.

결론부터 말하자면, 분산 방식은 상태 없는 API면 least_conn, 세션을 WAS 메모리에 들고 있으면 ip_hash를 쓴다. 장애 노드 자동 배제는 max_fails와 fail_timeout으로 하고, 무중단 배포로 노드를 뺄 때는 오픈소스 1.29.6부터 들어온 drain을 쓴다. 그 이전 버전이면 down으로 대신한다.

upstream 블록이 하는 일

upstream은 같은 역할을 하는 백엔드 서버들을 하나의 그룹으로 묶는 지시자다. 이 그룹에 이름을 붙여두고, location에서 proxy_pass로 그 이름을 가리키면 Nginx가 요청이 올 때마다 그룹 안의 서버 중 하나를 골라 넘긴다. 가장 기본 형태는 이렇다.

upstream tomcat_backend {
    server 10.0.1.11:8080;
    server 10.0.1.12:8080;
    server 10.0.1.13:8080;
}

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

    location / {
        proxy_pass http://tomcat_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;
    }
}

이 상태에서 분산 방식을 따로 지정하지 않으면 기본값인 가중 라운드 로빈(weighted round-robin)으로 돈다. 요청이 1번, 2번, 3번 서버 순서로 돌아가면서 배분된다. weight를 붙이면 비율을 바꿀 수 있다. 예를 들어 server 10.0.1.11:8080 weight=3;으로 두면 그 서버가 다른 서버의 세 배를 받는다. 기본 weight는 1이다.

least_conn과 ip_hash 중 무엇을 쓰나

라운드 로빈은 요청을 순서대로만 나눈다. 요청마다 처리 시간이 들쭉날쭉하면 특정 서버에 느린 요청이 쏠려도 Nginx는 그걸 모른 채 계속 순서대로 보낸다. 이걸 보완하는 게 least_conn이다.

least_conn - 활성 연결이 가장 적은 서버로

least_conn은 현재 활성 연결 수가 가장 적은 서버로 요청을 보낸다(1.3.1부터). weight도 함께 고려한다. 느린 요청이 한 서버에 쌓이면 그 서버의 활성 연결이 많아지므로 자연스럽게 다음 요청은 다른 서버로 간다. 처리 시간이 균일하지 않은 API 백엔드라면 이걸 기본으로 두는 게 맞다.

upstream tomcat_backend {
    least_conn;
    server 10.0.1.11:8080;
    server 10.0.1.12:8080;
    server 10.0.1.13:8080;
}

ip_hash - 같은 클라이언트를 같은 서버로

문제는 세션이다. 로그인 세션을 각 Tomcat의 JVM 메모리(HttpSession)에 들고 있는 구성이라면, 사용자의 다음 요청이 다른 서버로 가는 순간 세션이 없어 로그인이 풀린다. ip_hash는 클라이언트 IP를 해시해서 같은 IP를 항상 같은 서버로 보낸다. IPv4는 앞 세 옥텟, IPv6는 전체 주소를 키로 쓴다.

upstream tomcat_backend {
    ip_hash;
    server 10.0.1.11:8080;
    server 10.0.1.12:8080;
    server 10.0.1.13:8080;
}

여기서 한 번 걸리는 지점이 있다. 사내망이나 모바일 캐리어 NAT 뒤에 수많은 사용자가 같은 공인 IP로 나오면, 그 IP 전부가 한 서버로 몰린다. 분산이 고르지 않아진다. 그래서 세션 고정이 꼭 필요한 게 아니라면 ip_hash는 권하지 않는다.

현장에서 쓰는 방향은 이렇다. 세션을 Redis 같은 외부 저장소로 빼서 WAS를 상태 없게 만들고, 분산은 least_conn으로 돌리는 구성이 깔끔하다. 그러면 어느 서버가 요청을 받든 세션이 조회되므로 IP 고정에 기댈 필요가 없다. 세션 외부화가 당장 어려우면 그때 ip_hash를 쓴다.

참고로 ip_hash와 backup은 함께 쓸 수 없고, weight 지정에도 제약이 있다. 가중치를 세밀하게 주고 싶으면 least_conn 쪽이 자유롭다.

max_fails와 fail_timeout으로 장애 노드 자동 배제

Nginx 오픈소스에는 주기적으로 핑을 쏘는 능동형 헬스체크가 없다. 대신 수동(passive) 방식으로 동작한다. 실제 사용자 요청을 백엔드로 넘기다가 실패가 쌓이면 그 서버를 일정 시간 빼는 식이다. 이걸 조절하는 파라미터가 max_fails와 fail_timeout이다.

  • max_fails - fail_timeout 시간 안에 이 횟수만큼 실패하면 서버를 다운으로 간주한다. 기본값 1.
  • fail_timeout - 실패를 세는 구간이자, 다운으로 판정된 서버를 다시 시도하지 않고 빼두는 시간. 기본값 10초.
upstream tomcat_backend {
    least_conn;
    server 10.0.1.11:8080 max_fails=3 fail_timeout=15s;
    server 10.0.1.12:8080 max_fails=3 fail_timeout=15s;
    server 10.0.1.13:8080 max_fails=3 fail_timeout=15s;
}

위 설정은 "15초 안에 3번 실패하면 그 서버를 15초 동안 뺀다"는 뜻이다. 15초가 지나면 Nginx가 요청 하나를 다시 그 서버로 보내 살아났는지 확인한다. 성공하면 복귀, 다시 실패하면 또 빠진다.

무엇을 "실패"로 칠지는 proxy_next_upstream이 정한다. 기본은 연결 오류와 타임아웃 정도다. HTTP 500이나 503 같은 응답까지 실패로 보고 다음 서버로 넘기고 싶으면 명시해야 한다.

location / {
    proxy_pass http://tomcat_backend;
    proxy_next_upstream error timeout http_502 http_503 http_504;
    proxy_connect_timeout 2s;
}

여기서 함정 하나. upstream 그룹에 서버가 한 대뿐이면 max_fails와 fail_timeout이 무시된다. 그 한 대는 절대 다운으로 간주되지 않는다. 그래서 "장애 배제가 왜 안 되지" 하고 들여다보면 알고 보니 서버가 한 대만 떠 있는 경우가 있다.

또 하나, 수동 헬스체크는 요청이 실제로 그 서버로 가서 실패해야 감지한다. 다운된 서버로 간 그 요청은 실패를 겪고 다음 서버로 재시도된다. 능동형 헬스체크처럼 사전에 걸러주지는 못한다는 점을 감안해야 한다. 능동형 체크(health_check 지시자)가 필요하면 NGINX Plus 구독이거나 별도 모듈을 써야 한다.

backup 서버로 평소엔 놀리다가 비상시 투입

backup을 붙인 서버는 평소에는 요청을 받지 않고, 주 서버가 전부 다운됐을 때만 투입된다. 예비 장비나 예비 리전을 평소에 놀려두다가 전면 장애 때만 받게 하는 구성에 쓴다.

upstream tomcat_backend {
    least_conn;
    server 10.0.1.11:8080;
    server 10.0.1.12:8080;
    server 10.0.1.21:8080 backup;
}

앞서 적었듯 backup은 ip_hash, hash, random 방식과는 함께 쓸 수 없다. 기본 라운드 로빈이나 least_conn과 조합한다.

무중단 배포를 위한 노드 드레이닝

배포할 때가 가장 까다롭다. Tomcat 한 대를 내려서 새 WAR를 올리고 다시 띄우는 동안, 그 서버로 가던 요청이 그대로 끊기면 사용자가 에러를 본다. 이상적인 순서는 이렇다. 새 요청은 더 이상 그 서버로 보내지 않되, 이미 처리 중이거나 세션이 묶인 요청은 끝까지 마치게 두고, 비면 내린다. 이렇게 서서히 빼는 걸 드레이닝이라 한다.

drain - 1.29.6부터 오픈소스에서 동작

drain 파라미터는 서버를 "draining" 상태로 둔다. 새 요청은 안 받고, sticky 지시자로 이미 묶인 요청만 계속 받는다. 이 파라미터는 오랫동안 상용 구독 전용이었는데, 공식 문서에 1.29.6 이전에는 상용 구독의 일부로만 제공됐다는 주석이 붙었다. 바꿔 말해 1.29.6 이상 오픈소스 Nginx라면 설정 파일에서 직접 쓸 수 있다.

# 배포 대상 노드를 drain으로 전환
upstream tomcat_backend {
    least_conn;
    server 10.0.1.11:8080 drain;
    server 10.0.1.12:8080;
    server 10.0.1.13:8080;
}

설정을 바꾼 뒤에는 무중단 리로드로 반영한다.

$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
$ sudo nginx -s reload

본인이 쓰는 버전이 1.29.6 이상인지는 먼저 확인한다.

$ nginx -v
nginx version: nginx/1.29.6

버전이 낮으면 down으로

1.29.6 미만 오픈소스라면 drain이 적용되지 않는다. 이때는 down으로 대신한다. down은 해당 서버를 완전히 빼서 새 요청을 안 보낸다. 다만 drain처럼 묶인 요청만 남겨두는 식의 세밀함은 없고, 신규 배분을 끊는 수준이다.

upstream tomcat_backend {
    least_conn;
    server 10.0.1.11:8080 down;
    server 10.0.1.12:8080;
    server 10.0.1.13:8080;
}

실무 순서는 이렇게 둔다. 대상 노드를 down(또는 drain)으로 바꾸고 nginx -s reload, 그 노드의 활성 연결이 빠질 때까지 잠깐 기다린 뒤 Tomcat을 재시작해 배포, 올라온 걸 확인하고 다시 설정에서 down을 떼고 리로드한다. 서버를 한 대씩 돌려가며 이 과정을 반복하면 롤링 배포가 된다.

slow_start는 오픈소스에 없다

복귀한 노드에 트래픽을 한 번에 몰지 않고 가중치를 0에서 서서히 올려 워밍업하는 slow_start 파라미터가 있다. 캐시나 JIT가 덥혀지기 전에 요청이 쏟아지는 걸 막는 용도다. 다만 이건 지금도 NGINX Plus 상용 구독 전용이다. 공식 문서에서 slow_start는 "commercial subscription" 항목 아래에 그대로 있고, drain에 붙은 것 같은 1.29.6 버전 주석이 없다. 오픈소스에서 slow_start=30s를 적어도 스케줄러에 반영되지 않는다. 복귀 노드가 워밍업된다고 믿고 배포를 돌리면 사고로 이어지니 주의한다.

오픈소스에서 비슷한 효과를 보려면 복귀 직후 짧게 weight를 낮춰 리로드했다가 안정되면 올리는 수동 방식이나, 애플리케이션 단의 워밍업 엔드포인트를 두드려 덥힌 뒤 투입하는 방식을 쓴다.

keepalive로 백엔드 연결 재사용

분산 설정을 마쳤으면 백엔드 연결 재사용도 같이 챙긴다. upstream 안에 keepalive를 두면 Tomcat으로의 연결을 매 요청마다 새로 맺지 않고 재사용한다. TCP 핸드셰이크 비용이 줄어든다.

upstream tomcat_backend {
    least_conn;
    server 10.0.1.11:8080;
    server 10.0.1.12:8080;
    keepalive 16;
}

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

1.29.7부터는 keepalive가 기본으로 켜지며 워커당 32개가 기본값이다. 그 이전 버전에서는 라운드 로빈이 아닌 분산 방식(least_conn 등)을 쓸 때 그 지시자를 keepalive보다 먼저 적어야 했다. 위 예시처럼 least_conn을 위에 두면 버전과 무관하게 안전하다.

정리

상태 없는 백엔드는 least_conn, 메모리 세션을 쓰면 ip_hash를 택하되 가능하면 세션을 외부화하고 least_conn으로 가는 게 낫다. 장애 배제는 max_fails와 fail_timeout으로 하고, 서버가 한 대뿐이면 이 설정이 무시된다는 점을 기억한다. 무중단 배포의 노드 드레이닝은 1.29.6 이상이면 drain, 그 미만이면 down으로 한다. slow_start는 오픈소스에서 동작하지 않으니 워밍업이 필요하면 다른 방법으로 대체한다. 버전 번호와 기본값은 공식 문서를 기준으로 삼되, 쓰는 빌드에 따라 다를 수 있으니 nginx -v로 먼저 확인한다.

자주 묻는 질문

least_conn과 ip_hash 중 무엇을 써야 하나?

처리 시간이 균일하지 않은 상태 없는 API 백엔드라면 활성 연결이 가장 적은 서버로 보내는 least_conn이 적합하다. 로그인 세션을 각 WAS의 JVM 메모리에 들고 있어 같은 사용자를 같은 서버로 묶어야 한다면 ip_hash를 쓴다. 다만 NAT 뒤 다수 사용자가 한 IP로 몰리면 분산이 치우치므로, 가능하면 세션을 Redis 등으로 외부화하고 least_conn으로 가는 편을 권한다.

max_fails와 fail_timeout의 기본값은 얼마인가?

max_fails 기본값은 1, fail_timeout 기본값은 10초다. fail_timeout 시간 안에 max_fails 횟수만큼 실패하면 서버를 fail_timeout 동안 배제하고, 그 시간이 지나면 요청 하나로 복구 여부를 다시 확인한다.

upstream에 서버가 한 대뿐인데 장애 배제가 안 된다

그룹에 서버가 하나뿐이면 max_fails, fail_timeout, slow_start 파라미터가 무시되고 그 서버는 절대 다운으로 간주되지 않는다. 공식 문서에 명시된 동작이다. 장애 배제를 보려면 서버를 두 대 이상 등록해야 한다.

drain은 오픈소스 Nginx에서 쓸 수 있나?

1.29.6부터 오픈소스에서 설정 파일로 쓸 수 있다. 공식 문서에 'Prior to version 1.29.6, the parameter was available only as part of our commercial subscription' 주석이 붙어 있다. 1.29.6 미만이면 drain이 적용되지 않으므로 down으로 대체한다.

slow_start로 복귀 노드를 워밍업할 수 있나?

오픈소스에서는 불가능하다. slow_start는 지금도 NGINX Plus 상용 구독 전용이며 공식 문서의 commercial subscription 항목에 있고 drain 같은 1.29.6 버전 주석이 없다. 오픈소스에서 slow_start를 적어도 반영되지 않으니, 복귀 직후 weight를 낮췄다 올리거나 애플리케이션 워밍업 엔드포인트로 덥힌 뒤 투입하는 방법을 쓴다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.