외부 인증 API를 기다리는 동안 애플리케이션보다 프록시가 먼저 연결을 닫는다고 하자. 애플리케이션이 나중에 오류 본문을 작성해도 화면에는 도착하지 않는다. 반대로 바깥 제한만 늘리면 서버가 외부 요청을 끝없이 기다리는 문제는 남는다.
이 글은 어느 계층이 먼저 요청을 끝내고 오류를 전달할 시간을 남겨야 하는가를 묻는다. 브라우저→ALB→NGINX→Spring→외부 인증 API의 예제 경로를 사용한다. 각 제한의 의미를 분리한 뒤 시간 예산을 정한다.
같은 요청의 종료 위치를 연결한다
브라우저 상태, ALB·타깃 상태, NGINX upstream 시간, 애플리케이션 외부 호출 시간과 예외를 같은 요청 식별자로 연결한다. 인증 전문이나 개인정보를 모두 기록할 필요는 없다.
지연 서버로 연결 실패, 헤더 무응답, 본문 중간 정지를 따로 만든다. 애플리케이션 직접 호출과 로드밸런서 경유 호출도 비교한다. 직접 경로만 완료된다면 바깥 계층의 제한과 실제 설정 적용 범위를 먼저 조사할 수 있다.
읽기 간격은 요청의 전체 수명이 아니다
연결 제한은 연결 수립을, idle 제한은 데이터가 없는 간격을, deadline은 시작부터 전체 경과 시간을 제한한다. 클라이언트의 response timeout에 DNS·풀 대기·TLS·본문 읽기가 포함되는지는 구현별로 확인한다.
NGINX proxy_read_timeout은 전체 응답 시간이 아니라 upstream 읽기 사이의 대기 간격이다. 작은 본문이 계속 오면 전체 시간이 더 길어질 수 있다. proxy_send_timeout도 쓰기 사이 간격이며 연결 제한은 별도다. NGINX proxy 모듈의 정의를 기준으로 설정을 나눈다.
ALB 504도 애플리케이션 처리 지연 하나만 뜻하지 않는다. 연결 수립 실패와 타깃 무응답 등 AWS의 504 원인을 로그와 대조한다. 한 상태 코드로 모든 계층의 시간을 바꾸지 않는다.
내부가 종료한 뒤 바깥으로 응답할 여유를 둔다
다음 값은 조용히 외부 응답을 기다리는 흐름에 적용할 예제 정책이다.
| 제한 | 예제 | 책임 |
|---|---|---|
| 외부 연결 수립 | 3초 | 연결 불가를 빠르게 판정 |
| 외부 호출 전체 | 90초 | 한 호출의 총시간 |
| 애플리케이션 요청 | 110초 | 검증·호출·응답 작성 |
| NGINX 읽기 간격 | 125초 | upstream 무응답 정리 |
| ALB idle | 140초 | 바깥 무응답 연결 정리 |
| 브라우저 전체 | 155초 | 화면 대기 상한 |
이 배열은 애플리케이션이 먼저 외부 대기를 종료하고 응답할 시간을 남긴다. idle과 전체 기한은 다른 조건을 보므로 숫자의 대소 관계를 모든 응답 방식에 적용하지 않는다. 스트리밍이면 각각의 관찰도 달라진다.
긴 대기가 필요한 경로만 프록시 정책을 바꾸는 예제는 다음과 같다.
location = /v1/affiliation/health-insurance {
proxy_pass http://api_upstream;
proxy_connect_timeout 3s;
proxy_send_timeout 10s;
proxy_read_timeout 125s;
}
api_upstream은 별도 정의가 필요하다. 실제 URL이 이 location에 맞는지, include·중복 location·배포 도구가 생성한 설정과 충돌하지 않는지 확인한다. 연결까지 길게 늘리거나 일반 API에 같은 대기 시간을 전역 적용할 이유는 별도로 판단한다.
후속 호출에는 남은 시간을 전달한다
검증과 큐 대기로 이미 쓴 시간을 무시하고 외부 호출마다 새 예산을 주면 요청 전체 기한이 무너진다. 진입 시 계산한 기한에서 남은 시간을 구하고 응답 작성 여유를 제외한다.
// java.time.Duration을 사용한다.
// 요청 진입 시 전체 기한을 계산하고 후속 단계에 전달한다.
val deadline = System.nanoTime() + Duration.ofSeconds(110).toNanos()
val remaining = Duration.ofNanos(deadline - System.nanoTime())
val available = remaining.minus(Duration.ofSeconds(5))
if (available.isNegative || available.isZero) {
throw RequestBudgetExceededException()
}
val maximumCall = Duration.ofSeconds(90)
val callBudget = if (available < maximumCall) available else maximumCall
return externalClient.execute(
payload,
TimeoutPolicy(Duration.ofSeconds(3), callBudget),
)
Kotlin/JVM 예제에서 Duration은 java.time.Duration이며 이 호출 구간은 함수 본문에서 사용한다. TimeoutPolicy는 연결 제한과 전체 호출 기한을 구현할 어댑터 계약이다. 코드 앞뒤에서 시간을 재는 것만으로 블로킹 호출이 중단되지는 않는다. 실제 HTTP 라이브러리가 본문 읽기·취소 신호·풀 자원 정리까지 이 계약을 수행해야 한다. 재시도 지연과 다음 시도도 남은 예산 안에 들어가는지 확인한다.
5초의 마무리 여유를 뺀 예산이 없으면 새 호출을 시작하지 않는다. 외부 대기 동안 긴 DB 트랜잭션을 유지하지 않는지도 함께 살핀다.
본문의 예제 값을 사용해 후속 호출의 기한을 계산하면 다음 흐름이 된다. 이미 쓴 시간과 마무리 여유를 제외한 뒤 외부 호출을 시작할지 결정한다.
그림 1 남은 예산으로 외부 호출 기한 계산 — 검증·대기와 마무리 여유를 제외한 예산이 있을 때만 외부 호출을 시작한다.
이 계산은 HTTP 어댑터가 본문 읽기·취소·자원 정리까지 전체 호출 기한을 적용할 때 동작한다. idle 제한은 전체 기한과 다른 조건이며 외부 효과의 완료 여부도 대기 종료만으로 확정하지 않는다.
기다림 종료와 업무 취소를 구분한다
RFC 9110의 408은 완전한 요청을 제한 시간 안에 받지 못한 상태다. upstream 적시 응답을 받지 못한 gateway의 504와 구분해 실제 API 계약을 정한다. 화면에는 재시도 가능 여부와 결과 조회 방법도 제공한다.
브라우저의 AbortController는 fetch를 취소하지만 서버·DB·외부 기관 작업의 취소 완료까지 보장하지 않는다. 외부 요청을 보낸 뒤 응답을 잃었다면 결과가 불명일 수 있다. 재실행 전에 제공자의 조회·멱등 키 계약을 확인한다.
외부 대기의 변동이 크다면 작업 등록과 상태 조회를 분리할 수 있다.
POST /v1/insurance-auth-jobs
Idempotency-Key: user-generated-request-key
HTTP/1.1 202 Accepted
Location: /v1/insurance-auth-jobs/job-123
GET /v1/insurance-auth-jobs/job-123
등록을 영속화한 뒤 202와 조회 주소를 반환하는 대안이다. 스레드만 분리하고 연결을 계속 붙잡는 것과 다르다. 작업 저장과 큐 전달의 유실, 같은 키의 다른 입력, worker 중복 실행, 결과 보관 기간·조회 권한을 설계해야 한다. 짧고 예측 가능한 호출에는 동기 예산이 더 단순할 수 있다.
적용 전에는 헤더 지연·본문 정지·조금씩 오는 응답을 각각 시험해 idle과 deadline을 구분한다. 취소 뒤 서버가 살아 있는지, 오류 본문이 바깥 경로로 전달되는지, 같은 키 재시도가 작업을 늘리지 않는지도 본다. 타임아웃은 가장 긴 숫자를 고르는 일이 아니라 요청 수명과 결과 확인 범위를 정하는 정책이다.
