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에서 새 실행을 막도록 처리
배운 것
- HTTP 요청 안에서 수십 초 이상 걸리는 작업을 동기로 돌리지 않는다. 로드밸런서·프록시·브라우저 모두 어딘가에 타임아웃이 있다. 60초는 ALB뿐 아니라 여러 곳의 기본값이다.
- “작업은 완료되는데 응답만 실패한다”는 증상은 중간 계층을 의심하라는 신호다. 파드를 아무리 봐도 안 나온다.
- 로드밸런서 액세스 로그를 먼저 켜둔다.
target_processing_time -1한 줄이 며칠을 아껴줬을 것이다. - 가설은 증거 다음에 세운다. 가설을 먼저 세우면 증거를 가설에 맞춰 읽게 된다. 사람도, AI도.
정리
| 항목 | 내용 |
|---|---|
| 증상 | 배치 트리거 API가 데이터 많은 날 504, 배치 자체는 정상 완료 |
| 오진 | 파드 CPU/메모리 부족 |
| 실제 원인 | ALB idle timeout 60초 초과 (동기 HTTP 안에서 배치 실행) |
| 결정적 증거 | ALB 액세스 로그의 target_processing_time -1, 60초 부근 집중 |
| 임시 조치 | Ingress annotation으로 idle_timeout 상향 |
| 근본 조치 | 비동기 JobLauncher로 202 즉시 응답 + executionId 상태 조회 |