ALB IP 캐시 문제를 고치다 SSO 로그인을 절반 죽여버린 이유
IP가 고정이 아닌 ALB를 nginx proxy_pass로 프록시하다 겪은 DNS 캐시 타임아웃과 쿼리스트링 탈락, 그리고 upstream과 resolve로 정리한 기록
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 변경에 따른 타임아웃
발생 현상
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
변경 내용
문제 ①이 재발하지 않게 하려고 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-stg → docengine-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의 returnURL에 req_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는 문법만 확인해줄 뿐 런타임 동작까지 보증하지는 않는다. 쿼리스트링이나 헤더가 백엔드까지 실제로 전달되는지는 반드시 실측해야 한다는 걸 이번에 다시 확인했다.
