EKS에서 Spring Batch를 HTTP로 트리거하다 만난 504 — ALB idle timeout 진단기

EKS에 올린 Spring Boot 서비스에서 특정 API 하나만 간헐적으로 504 Gateway Timeout을 냈다. 그 API는 관리자 화면에서 배치 작업을 즉시 실행하는 용도였다. 평소엔 잘 되다가 데이터가 많은 날만 실패했다.

결론은 ALB idle timeout 60초였다. 그런데 거기까지 가는 데 며칠이 걸렸다. 왜 돌아갔는지까지 포함해서 기록한다.

증상

  • POST /admin/batch/run 호출 시 가끔 504
  • 504가 난 뒤에도 배치는 정상적으로 끝까지 실행됨 (DB 결과가 반영돼 있었다)
  • 파드 로그에 예외 없음
  • 데이터가 많은 월초·월말에 집중

마지막 세 가지가 힌트였는데, 처음엔 못 봤다.

잘못 잡은 방향 — 파드 리소스

처음 세운 가설은 “배치가 CPU를 많이 먹어서 파드가 throttling 걸리고 응답이 늦어진다”였다. 근거는 그럴듯했다. 데이터 많은 날만 실패하니까.

그래서 했던 것들:

  • CPU limit 상향
  • JVM 힙 옵션 조정
  • HPA 임계값 조정
  • readiness probe 주기 변경

전부 소용없었다. 504는 여전히 났고, 여전히 배치는 끝까지 돌았다. 이 시점에서 “파드가 죽는 게 아니라 응답이 안 도착하는 것”이라는 걸 인정했어야 했는데, 가설에 매달려 시간을 더 썼다. (이 경험은 LLM 아첨 사례로도 정리해뒀다. 내가 가설을 얹어서 물어보니 AI도 그 방향으로만 답했다.)

제대로 본 것 — ALB 액세스 로그

방향을 바꿔서 ALB 액세스 로그를 S3에 남기고 504 요청을 찾았다.

... "POST https://.../admin/batch/run HTTP/1.1" ... 504 ...
request_processing_time  0.001
target_processing_time  -1
response_processing_time -1
elb_status_code 504
target_status_code -

target_processing_time -1, target_status_code -. 타겟이 응답을 주기 전에 ALB가 연결을 끊었다는 뜻이다. 그리고 그 요청들의 소요 시간이 정확히 60초 부근에 몰려 있었다.

ALB 속성을 확인하니 idle_timeout.timeout_seconds = 60. 기본값 그대로였다.

원인 정리

관리자 브라우저 ─▶ ALB ─▶ Ingress/Service ─▶ Pod (Spring Boot)
                   │                              │
                   │  60초 동안 응답 바이트 없음    │ 배치 실행 중 (2~5분)
                   │                              │
                   └── 연결 종료, 504 반환          └── 배치는 계속 돌아서 정상 종료

배치를 동기 HTTP 요청 안에서 실행하고 있었다. 컨트롤러가 jobLauncher.run()을 호출하고 끝날 때까지 기다린 뒤 응답을 돌려주는 구조였다. 데이터가 적을 땐 60초 안에 끝나서 문제가 없었고, 많은 날엔 넘겨서 ALB가 끊었다. 파드는 요청이 끊긴 걸 모르고(정확히는 알아도 배치를 멈출 이유가 없어서) 끝까지 실행했다.

증상 세 가지가 전부 설명됐다. 예외가 없는 이유, 배치는 끝나는 이유, 데이터 많은 날만 나는 이유.

해결 — 두 가지 선택지

임시: idle timeout 늘리기

yaml

# Ingress annotation (AWS Load Balancer Controller)
alb.ingress.kubernetes.io/load-balancer-attributes: idle_timeout.timeout_seconds=600

바로 효과는 있다. 하지만 근본 해결이 아니다. 배치가 10분을 넘기면 또 같은 일이 나고, 관리자 브라우저를 몇 분씩 붙잡아두는 것도 이상하다. 다른 API까지 긴 idle timeout을 공유하게 되는 것도 부담이다.

근본: 비동기 트리거 + 상태 조회

배치 실행 요청과 실행 자체를 분리했다.

java

@PostMapping("/admin/batch/run")
public ResponseEntity<BatchRunResponse> run(@RequestBody BatchRunRequest req) {
    JobParameters params = new JobParametersBuilder()
            .addString("requestId", UUID.randomUUID().toString())
            .addLong("timestamp", System.currentTimeMillis())
            .toJobParameters();

    JobExecution execution = asyncJobLauncher.run(job, params); // 비동기 런처
    return ResponseEntity.accepted()
            .body(new BatchRunResponse(execution.getId(), "STARTING"));
}

@GetMapping("/admin/batch/{executionId}")
public BatchStatusResponse status(@PathVariable Long executionId) {
    JobExecution execution = jobExplorer.getJobExecution(executionId);
    return new BatchStatusResponse(
            execution.getId(),
            execution.getStatus().name(),
            execution.getExitStatus().getExitCode()
    );
}

asyncJobLauncher는 TaskExecutor를 꽂은 JobLauncher다.

java

@Bean
public JobLauncher asyncJobLauncher(JobRepository jobRepository) throws Exception {
    TaskExecutorJobLauncher launcher = new TaskExecutorJobLauncher();
    launcher.setJobRepository(jobRepository);
    launcher.setTaskExecutor(new SimpleAsyncTaskExecutor("batch-"));
    launcher.afterPropertiesSet();
    return launcher;
}

흐름은 이렇게 바뀐다.

POST /admin/batch/run     → 즉시 202 Accepted + executionId   (수 ms)
GET  /admin/batch/{id}    → 화면에서 주기적으로 상태 조회

Spring Batch의 JobRepository가 이미 실행 상태를 DB에 기록하고 있으니 상태 저장소를 따로 만들 필요도 없다. 관리자 화면은 executionId로 폴링해서 STARTED → COMPLETED / FAILED를 보여주면 된다.

추가로 한 것:

  • 같은 Job이 동시에 두 번 실행되지 않도록 실행 전 jobExplorer.findRunningJobExecutions(jobName) 확인
  • 파드가 배치 중간에 종료될 수 있으므로 terminationGracePeriodSeconds를 배치 최대 소요 시간에 맞춰 조정하고, preStop에서 새 실행을 막도록 처리

배운 것

  1. HTTP 요청 안에서 수십 초 이상 걸리는 작업을 동기로 돌리지 않는다. 로드밸런서·프록시·브라우저 모두 어딘가에 타임아웃이 있다. 60초는 ALB뿐 아니라 여러 곳의 기본값이다.
  2. “작업은 완료되는데 응답만 실패한다”는 증상은 중간 계층을 의심하라는 신호다. 파드를 아무리 봐도 안 나온다.
  3. 로드밸런서 액세스 로그를 먼저 켜둔다. target_processing_time -1 한 줄이 며칠을 아껴줬을 것이다.
  4. 가설은 증거 다음에 세운다. 가설을 먼저 세우면 증거를 가설에 맞춰 읽게 된다. 사람도, AI도.

정리

항목내용
증상배치 트리거 API가 데이터 많은 날 504, 배치 자체는 정상 완료
오진파드 CPU/메모리 부족
실제 원인ALB idle timeout 60초 초과 (동기 HTTP 안에서 배치 실행)
결정적 증거ALB 액세스 로그의 target_processing_time -1, 60초 부근 집중
임시 조치Ingress annotation으로 idle_timeout 상향
근본 조치비동기 JobLauncher로 202 즉시 응답 + executionId 상태 조회

다른 환경에 적용할 때 확인할 조건

액세스 로그의 -1 값만으로 idle timeout을 확정하지 않습니다. 타깃 연결 실패 등 다른 상황에서도 나타날 수 있으므로 상태 코드, 요청 시간, 타깃 건강 상태와 애플리케이션 로그를 같은 요청 기준으로 대조합니다. 이 사례에서는 60초 부근의 반복과 실제 ALB 속성이 함께 근거였습니다. ALB 로그 필드 설명.

운영 비동기 런처에는 실행 동시성·대기열 한도를 둡니다. findRunningJobExecutions 조회 뒤 실행하는 절차만으로 동시 요청의 경쟁 조건을 완전히 막을 수는 없으므로, 업무 식별 키·JobParameters와 원자적인 중복 실행 방지 정책을 함께 확인합니다. 상태 조회는 존재하지 않는 ID의 404 처리와 요청자의 작업 조회 권한도 검증해야 합니다.

환경을 재현할 때 Spring Boot·Batch·AWS Load Balancer Controller 버전, ALB idle timeout, 배치 처리량과 동시 요청 수를 기록하세요. 기존 사례의 수치와 아직 측정하지 않은 일반 환경을 구분하는 기준입니다.

이어서 확인할 문제 해결 글

504 진단 시 ALB idle timeout과 프록시 keepalive timeout의 구간 차이에는 Ingress LB 와 WAS 구간의 Session Disconnect 발생 원인 “KeepAlive TimeOut 설정” 글을 함께 참고하세요.

HTTP 동기 배치에서 CronJob 병렬 실행 구조로 확장할 때에는 Spring Batch 작업을 Kubernetes CronJob으로 병렬 처리할 때의 고려사항과 커넥션 오류 대응 전략 글을 함께 참고하세요.

관련 글

답글 남기기