작업을 제출하면 서버는 작업을 큐에 올리기만 하고, 202 Accepted 와 함께 작업 ID·상태 조회 URL 을 바로 돌려줍니다. 실제 처리는 이후에 백그라운드 스레드가 합니다.
200 OK 대신 202 를 쓰는 이유는 의미가 다르기 때문입니다. 200 은 "요청한 일을 다 끝냈다"는 뜻이고, 202 는 "접수는 했지만 아직 끝나지 않았다"는 뜻입니다. 클라이언트는 이 차이로 폴링이 필요하다는 걸 알 수 있습니다.
작업은 다섯 가지 상태 중 하나를 가집니다.
| 상태 | 의미 |
|---|---|
| PENDING | 큐에 있고 아직 시작 전 |
| RUNNING | 처리 중 |
| DONE | 정상 완료 |
| FAILED | 처리 중 예외 발생 |
| CANCELLED | 사용자가 취소함 |
진행률은 보통 processed / total 건수와 사람이 읽을 메시지("3/100건 처리 중") 두 가지를 함께 보여줍니다. 건수만 있으면 화면에 막대바는 그릴 수 있어도, 지금 뭘 하는 중인지는 알기 어렵습니다.
세 방식 모두 "지금 상태가 어떤지" 클라이언트에 전달하는 수단이지만 성격이 다릅니다.
| 방식 | 방향 | 적합한 경우 |
|---|---|---|
| 폴링 | 클라이언트가 요청 | 구현 단순, 진행률 갱신 빈도 낮아도 됨 |
| SSE | 서버가 단방향 push | 진행률 실시간 표시, 서버→클라이언트만 필요 |
| WebSocket | 양방향 | 채팅처럼 클라이언트도 계속 보내야 함 |
사내 관리자 화면 정도라면 폴링만으로 충분한 경우가 많습니다. 몇 초에 한 번 상태를 물어봐도 사용자 체감에는 큰 차이가 없습니다.
진행률 바를 부드럽게 보여줘야 하거나 폴링 트래픽이 부담될 정도로 사용자가 많다면 SSE 가 낫습니다. WebSocket 은 이 용도에는 과합니다. 클라이언트가 서버로 보낼 말이 없는데 양방향 연결을 유지할 이유가 없습니다.
SSE 는 오래 열려 있는 HTTP 연결인데, nginx 는 기본적으로 응답을 버퍼에 모았다가 한꺼번에 보냅니다. 이 상태로 두면 브라우저에 이벤트가 뚝뚝 끊겨 도착하거나 아예 안 옵니다.
location /jobs/ {
proxy_pass http://backend;
proxy_buffering off;
proxy_read_timeout 3600s;
add_header X-Accel-Buffering no;
}proxy_buffering off 는 nginx 가 응답을 즉시 클라이언트로 흘려보내게 합니다. X-Accel-Buffering: no 는 서버 응답 헤더로도 같은 효과를 줄 수 있어, 이 레슨의 Main 도 이 헤더를 직접 붙입니다. proxy_read_timeout 은 기본값(보통 60초)보다 길게 잡아야 연결이 중간에 끊기지 않습니다.
이 레슨의 JobManager 는 작업을 ConcurrentHashMap 에 담습니다. 서버 인스턴스가 하나뿐이고 재기동이 드물다면 이것으로 충분합니다.
인스턴스가 여러 대라면 문제가 생깁니다. A 서버가 접수한 작업을 B 서버가 조회하면 "그런 작업 없음"이 됩니다. 이때는 작업 상태를 DB 의 JOB 테이블에 저장하고, 결과 파일은 공유 스토리지나 각 서버가 접근 가능한 경로에 둡니다.
서버가 재기동되면 메모리에 있던 RUNNING 작업 정보는 사라집니다. DB 로 관리한다면, 기동 시점에 RUNNING 으로 남아 있는 행을 FAILED 로 일괄 정리하는 배치를 넣어야 합니다. 그러지 않으면 사용자 화면에 영원히 "처리 중"인 작업이 남습니다.
사용자가 버튼을 두 번 누르거나, nginx 504 뒤 브라우저가 재시도하면 같은 작업이 중복 실행될 수 있습니다. 가장 간단한 방어는 "같은 사용자가 실행 중인 작업이 있으면 새 제출을 409 Conflict 로 거절"하는 것입니다.
더 엄격하게 하려면 클라이언트가 요청마다 고유한 멱등키(idempotency key)를 헤더로 보내고, 서버가 같은 키로 이미 처리한 요청이면 새로 실행하지 않고 기존 결과를 그대로 돌려주는 방식을 씁니다. 결제·정산처럼 중복 실행 피해가 큰 작업에 적합합니다.
자바 스레드는 강제로 죽일 안전한 방법이 없습니다. Thread.stop() 은 오래전에 사용이 금지됐습니다. 그래서 취소는 "그만해 달라고 부탁하고, 작업 스스로 확인해서 멈추는" 협력적 방식으로 합니다.
작업 루프 안에서 매 단계마다 취소 플래그를 확인하고, 켜져 있으면 하던 일을 정리하고 빠져나옵니다. 이미 일부 쓴 결과 파일이 있다면 삭제해서 반쪽짜리 결과가 남지 않게 합니다. Thread.interrupt() 를 함께 걸어두면 Thread.sleep 이나 블로킹 I/O 중에도 더 빨리 깨어날 수 있습니다.
작업 처리용 스레드 풀은 요청 처리용 풀과 분리합니다. 크기를 무제한으로 두면 동시 제출이 몰릴 때 스레드가 폭증해 서버 전체가 느려집니다. 고정 크기 풀 + 큐 한도를 두고, 큐까지 가득 차면 새 제출을 거절하는 편이 안전합니다.
서버를 내릴 때는 진행 중인 작업을 갑자기 끊지 않고 완전 종료해야 합니다. ExecutorService.shutdown() 으로 새 작업 접수를 막고, awaitTermination 으로 진행 중인 작업이 끝나거나 정해진 시간이 지날 때까지 기다린 뒤, 그래도 안 끝나면 shutdownNow() 로 인터럽트를 겁니다.
| JDK 이 레슨 | Spring Boot |
|---|---|
ExecutorService 수동 생성 |
@EnableAsync + ThreadPoolTaskExecutor |
| 작업 메서드 직접 큐잉 | 메서드에 @Async |
| SSE 수동 스트리밍 | SseEmitter 반환 |
| 풀 크기 설정 하드코딩 | spring.task.execution.pool.* |
@Async 는 같은 클래스 안에서 this.메서드() 로 호출하면 프록시를 안 거쳐서 동작하지 않는 self-invocation 함정이 있습니다. 반드시 다른 빈을 통해 호출해야 합니다. spring.task.execution.pool.core-size, max-size, queue-capacity 로 풀과 큐 크기를 설정합니다.