ALB IP 캐시 문제를 고치다 SSO 로그인을 절반 죽여버린 이유

IP가 고정이 아닌 ALB를 nginx proxy_pass로 프록시하다 겪은 DNS 캐시 타임아웃과 쿼리스트링 탈락, 그리고 upstream과 resolve로 정리한 기록

Infra8 min read

IP가 수시로 바뀌는 대상(ALB)을 nginx proxy_pass로 물렸다가 연달아 두 번 사고를 냈다. 첫 번째를 고치려고 설정을 바꿨는데, 그 변경이 두 번째 사고의 직접 원인이 됐다. 두 사고와 최종적으로 정리한 방식을 기록한다.

구성과 전제

문제가 난 구간은 SSO 연동 구간(/sso_gateway/, /main.jsp, /login.jsp)이다. WAS 서버 두 대의 nginx가 내부 도메인 docengine.nimbustech.app으로 요청을 넘기고, 이 도메인은 ALB(app-alb-01) 뒤의 미들웨어(Tomcat + Spring Boot + SSO 에이전트)로 이어진다.

사용자
  ↓ HTTPS
was-nlb-01 (NLB, IP 고정)

WAS 1·2번 서버 : nginx
  ↓ proxy_pass https://docengine.nimbustech.app   ← ALB. IP 변동
app-alb-01 (ALB)

docengine_middleware (Tomcat + Spring Boot + SSO 에이전트)

핵심 전제는 ALB 노드의 IP가 고정이 아니라는 점이다. AWS는 스케일링이나 유지보수 시 ALB 노드를 교체하고, 이때 기존 공인 IP는 회수되어 AWS 공용 풀로 돌아간다. DNS 레코드의 TTL이 60초로 짧게 잡혀 있는 것도 클라이언트가 주기적으로 재조회할 것을 전제로 한 값이다.

문제 ① ALB IP 변경에 따른 타임아웃

ALB 노드 IP 교체와 nginx 캐시 불일치 타임라인

발생 현상

  • https://worklane.nimbustech.app/sso_gateway/ 접근 시 간헐적 타임아웃
  • 같은 URL에서 가끔 Kubernetes 형식의 401 응답 수신 (사내에는 쿠버네티스 환경이 없음)
{"kind":"Status","apiVersion":"v1","status":"Failure","message":"Unauthorized","reason":"Unauthorized","code":401}

두 번째 증상이 특히 이상했다. 쿠버네티스를 쓰지도 않는데 쿠버네티스 형식의 401이라니, 처음엔 뭘 봐야 할지도 감이 안 왔다.

원인 분석

nginx는 proxy_pass https://docengine.nimbustech.app;처럼 도메인을 리터럴로 지정하면, 기동하거나 리로드하는 시점에 딱 한 번만 DNS를 조회하고 그 결과를 프로세스가 살아 있는 동안 그대로 캐시한다. DNS 레코드의 TTL은 아예 참조하지 않는다.

  • nginx 컨테이너 기동 시점에 당시 ALB 노드 IP(203.0.113.10 포함)를 캐시
  • 이후 AWS가 ALB 노드를 교체하면서 IP가 203.0.113.21 / 203.0.113.22로 바뀌고 203.0.113.10은 회수됨
  • nginx는 이 변화를 알 방법이 없어 캐시해 둔 옛 IP로 계속 접속을 시도

nginx error log에 남은 근거는 다음과 같다.

2026/07/13 00:47:55 [error] upstream timed out (110: Connection timed out) while connecting to upstream,
  request: "GET /sso_gateway/main.jsp?req_key=...",
  upstream: "https://203.0.113.10:443/main.jsp",
  host: "worklane.nimbustech.app", referrer: "https://office.worklane.com/"

upstream: 필드에 찍힌 IP가 그 시점에 dig로 조회한 실제 IP 목록에는 없는 값이었다. nginx가 옛 IP를 붙잡고 있다는 직접적인 증거였다.

회수된 공인 IP는 다른 AWS 계정에 재할당될 수 있다. 캐시된 IP 중 하나는 그냥 무응답이라 타임아웃이 났고, 다른 하나는 제3자의 쿠버네티스 엔드포인트로 재할당돼 쿠버네티스 형식의 401이 돌아온 것으로 보고 있다. nginx가 이 두 IP를 라운드로빈으로 오가면서 증상이 번갈아 나타난 셈이다. (401의 출처는 정황상 추정이고, 직접 재현까지는 못 했다.)

조치 내용

nginx를 리로드해서 DNS를 다시 조회하게 하니 바로 해소됐다. 다만 이건 임시방편이었다. ALB 노드가 다시 교체되면 똑같은 일이 반복될 게 뻔했다. 근본적으로는 DNS를 주기적으로 다시 해석하는 구조로 바꿔야 했다.

문제 ② 쿼리스트링 탈락으로 인한 SSO 500

SSO 왕복 구조에서 쿼리스트링이 사라지는 지점

변경 내용

문제 ①이 재발하지 않게 하려고 proxy_pass를 변수형으로 바꿨다. nginx는 proxy_pass에 변수가 들어가면 리터럴처럼 캐시하지 않고, 런타임에 resolver로 매번 새로 조회한다.

# 변경 전
proxy_pass https://docengine.nimbustech.app/main.jsp;

# 변경 후 (문제를 만든 설정)
set $docengine_upstream docengine.nimbustech.app;
proxy_pass https://$docengine_upstream/main.jsp;

발생 현상

변경한 다음 날 아침 출근 시간대부터 SSO 로그인 시도가 Spring Boot 기본 에러 페이지(Whitelabel Error Page, HTTP 500)로 떨어지기 시작했다. 미들웨어 쪽에서 main.jsp 요청에 대해 500이 95건 발생했다.

org.apache.jasper.JasperException: An exception occurred processing JSP page [/main.jsp] at line [94]
94:  + "?req_key=" + URLEncoder.encode(reqKey, "UTF-8")
Caused by: java.lang.NullPointerException
    at java.net.URLEncoder.encode(URLEncoder.java:204)
    ....

reqKey가 null이라 URLEncoder.encode()에서 NPE가 났다.

원인 분석

nginx의 proxy_pass는 URI 지정 여부와 변수 사용 여부의 조합에 따라 쿼리스트링 처리 방식이 달라진다.

proxy_pass 형태 쿼리스트링
https://docengine.nimbustech.app; (리터럴, URI 없음) 원 요청 URI+쿼리 그대로 전달
https://docengine.nimbustech.app/main.jsp; (리터럴, URI 있음) 자동으로 부착됨
https://$var; (변수, URI 없음) 원 요청 URI+쿼리 그대로 전달
https://$var/main.jsp; (변수 + URI) 부착되지 않음, 탈락

그러니까 /sso_gateway/(URI 없음)는 멀쩡했는데, /main.jsp/login.jsp(둘 다 URI 있음)에서만 쿼리스트링이 사라진 거였다.

SSO는 같은 main.jsp를 두 번 호출하는 왕복 구조이고, 이 두 호출이 서로 다른 nginx location을 거친다.

① worklane.nimbustech.app/sso_gateway/main.jsp?req_key=X
     → nginx (/sso_gateway/, URI 없음 → 쿼리 보존)
     → 미들웨어: SSO 세션 없음 → 302로 SSO 로그인 서버 이동

② sso.worklane.com 에서 인증

③ docs.worklane.com/main.jsp?req_key=X          ← 브라우저 주소창에는 req_key가 그대로 보임
     → nginx (location = /main.jsp, URI 있음 → 쿼리 탈락)
     → 미들웨어: /main.jsp (req_key 없음)
     → 인증 성공 분기의 94번 줄에서 reqKey=null → NPE → 500

②의 returnURL이 docs.worklane.com으로 잡히는 이유는, nginx가 proxy_set_header Host docs.worklane.com으로 Host를 바꿔치기하고 SSO 에이전트가 그 Host를 기준으로 returnURL을 만들기 때문이다. 그 결과 진입(①)과 귀환(③)이 서로 다른 location을 타면서 한쪽만 망가진 상태가 됐다. 브라우저 주소창에는 req_key가 정상적으로 떠 있어서 눈으로만 봐서는 원인을 찾기 어려웠다.

로그를 뜯어보니 같은 req_key에 대해 1초 간격으로 정반대 결과가 찍혀 있었다.

# nginx (was-02): 브라우저가 보낸 요청, req_key 있음
[14/Jul/2026:23:41:58 +0000] "GET /main.jsp?req_key=063f868...&return_url=..." 500

# 미들웨어: nginx가 넘긴 요청, req_key 없음
[15/Jul/2026:08:41:57 +0900] "GET /main.jsp?req_key=063f868...&return_url=..." 302   ← ① 정상
[15/Jul/2026:08:41:58 +0900] "GET /main.jsp" 500                                     ← ③ 쿼리 탈락

nginx access log에는 기본적으로 업스트림 정보가 안 남아서 원인을 좁히는 데 시간이 꽤 걸렸다. log_format$upstream_addr, $upstream_status, $upstream_response_time을 추가해두면 “nginx는 받았는데 백엔드는 못 받은” 구간을 훨씬 빨리 짚어낼 수 있다. 프록시 동작 자체에는 영향이 없는 관측성 개선이라 먼저 반영해둘 만하다.

조치 내용

변수형 proxy_pass 설정을 그대로 원복했다. 원복 후 SSO는 정상으로 돌아왔다.

최종 해결 방안: upstream과 resolve

두 문제를 한 번에 해결하는 방법은 upstream 블록의 resolve 파라미터였다. nginx 1.27.3부터는 오픈소스 버전에서도 쓸 수 있다.

# conf.d/upstream.conf
resolver 10.0.0.2 valid=5s ipv6=off;    # 기존 설정에 이미 있던 값

upstream docengine_backend {
    zone docengine_backend 64k;
    server docengine.nimbustech.app:443 resolve;   # DNS를 주기적으로 다시 해석
    keepalive 0;
}
location = /main.jsp {
    proxy_pass https://docengine_backend/main.jsp;   # 업스트림 이름은 변수가 아니라서 쿼리가 자동으로 붙는다
    proxy_set_header Host docs.worklane.com;          # 필수. 아래 주의사항 참고
    # 나머지 헤더는 기존과 동일
}

docengine_backend는 변수가 아니라 업스트림 이름이라 nginx가 리터럴과 똑같이 취급한다. 그래서 문제 ②의 쿼리 탈락이 구조적으로 생기지 않으면서, resolve로 문제 ①의 DNS 재해석도 함께 챙길 수 있다.

적용 범위

DNS를 다시 해석해야 하는 대상은 ALB를 가리키는 경우로 한정했다.

대상 실체 IP 성격 조치
docengine.nimbustech.app app-alb-01 (ALB) 변동 적용 대상
docs.worklane.com was-nlb-01 (NLB) 고정 불필요
demo.nimbustech.app was-nlb-01 (NLB) 고정 불필요

NLB는 IP가 고정이라 이번 이슈와는 무관하다.

주의사항

  • Host 헤더 명시 필수
  • SNI 변경 금지
  • keepalive 사용 여부는 별도 판단 필요

upstream을 쓰면 $proxy_host가 업스트림 이름(docengine_backend) 그 자체가 된다. nginx의 기본 Host 헤더가 $proxy_host라서, proxy_set_header Host를 빼먹으면 Host: docengine_backend가 그대로 전달되고, 이 상태에서는 SSO 에이전트가 AgentException: Can't find configuration of SSO-PROVIDER corresponding 'docengine_backend' 예외를 던진다.

proxy_ssl_server_name은 기본값이 off이고 지금 이대로 잘 동작하고 있다. 이걸 켜면 SNI로 업스트림 이름이 나가면서 TLS가 깨질 수 있다. 꼭 켜야 한다면 proxy_ssl_name docengine.nimbustech.app;을 반드시 함께 지정해야 한다.

keepalive를 살리려면 proxy_http_version 1.1;proxy_set_header Connection "";가 함께 있어야 한다.

스테이징 검증 결과

스테이징(was-stgdocengine-stg)에서 docengine.stg.nimbustech.app 도메인으로 검증했다.

항목 결과
nginx -t (resolve + zone 문법) 통과
미들웨어 수신 요청 GET /main.jsp?req_key=UPTEST777&..., 쿼리 보존
302 returnURL returnURL=...main.jsp?req_key=UPTEST777..., 기준선과 동일
SSO 에이전트 인식 Host [SSO] HOST=docs.worklane.com, 업스트림 이름 유출 없음
main.jsp 상태코드 분포 200 4건 / 302 7건, 500 0건
실사용 SSO 왕복 진입·귀환 모두 302 정상

판정 기준은 302의 returnURLreq_key가 포함되는지였다. 미들웨어는 req_key를 받은 경우에만 returnURL에 이를 실어 보내기 때문이다. 검증에는 아래 명령을 썼다.

curl -sk -D - -o /dev/null \
  "https://docs.worklane.com/main.jsp?req_key=UPTEST777&return_url=https://example/callback" \
  | grep -i "^location:"

정리하며

ALB처럼 IP가 바뀌는 대상을 nginx가 리터럴 도메인으로 프록시하면 안 된다는 걸 이번에 제대로 배웠다. 기동 시 한 번 조회하고 그걸 영구히 캐시하니, IP가 바뀌는 순간부터 어긋난다. 게다가 회수된 공인 IP가 다른 계정으로 넘어갈 수도 있어서, 오래된 캐시 IP는 단순 타임아웃으로 안 끝나고 제3자 서버로 요청을 보내는 결과까지 만들 수 있다.

변수형 proxy_pass로 급하게 막으려다 놓친 부분도 있었다. URI가 붙은 형태(https://$var/path)는 쿼리스트링을 전달하지 않는다는 걸 몰랐고, 이게 SSO 왕복 구조와 맞물려 더 큰 사고로 번졌다. 결국 정답은 upstream + resolve였다. 리터럴과 똑같은 쿼리 처리를 유지하면서 DNS 재해석까지 확보하는 방식이라, 애초에 이걸 먼저 고려했어야 했다.

nginx -t는 문법만 확인해줄 뿐 런타임 동작까지 보증하지는 않는다. 쿼리스트링이나 헤더가 백엔드까지 실제로 전달되는지는 반드시 실측해야 한다는 걸 이번에 다시 확인했다.