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()
    );
}

asyncJobLauncherTaskExecutor를 꽂은 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 상태 조회

관련 글

답글 남기기