Contents
see List왜 취소 설계를 따로 해야 하나
Java 서버에서 오래 걸리는 작업은 단순히 스레드를 만들고 실행하는 것으로 끝나지 않습니다. 사용자가 요청을 취소하거나, 배포 중 애플리케이션이 종료되거나, 외부 API가 지연될 때 작업을 멈추는 기준이 필요합니다. 이 기준이 없으면 이미 필요 없어진 작업이 DB 커넥션과 스레드를 계속 잡고, 배치 서버는 종료 신호를 받았는데도 프로세스가 내려가지 않으며, 장애 상황에서 큐에 쌓인 작업이 다음 장애를 만듭니다.
Java에서 작업 취소의 기본 신호는 interrupt입니다. 다만 interrupt는 스레드를 강제로 죽이는 명령이 아니라, 작업 코드가 확인하고 협조해야 하는 중단 요청입니다. 따라서 운영 코드에서는 Future.cancel(true), Thread.currentThread().isInterrupted(), InterruptedException 처리, ExecutorService 종료 순서를 하나의 흐름으로 설계해야 합니다.
취소 가능한 작업의 기본 원칙
취소 가능한 Java 작업은 세 가지 규칙을 지켜야 합니다. 첫째, 반복문 안에서 주기적으로 중단 상태를 확인합니다. 둘째, sleep, BlockingQueue, Future.get처럼 InterruptedException을 던지는 API를 사용할 때는 예외를 삼키지 않습니다. 셋째, 정리 작업은 finally에서 처리해 파일 핸들, 락, 커넥션, 임시 상태가 남지 않게 합니다.
- CPU 작업: 루프 단위로 isInterrupted()를 확인하고 빠르게 반환합니다.
- 대기 작업: InterruptedException을 받으면 Thread.currentThread().interrupt()로 상태를 복원한 뒤 종료 경로로 이동합니다.
- I/O 작업: 소켓, HTTP 클라이언트, JDBC 드라이버의 타임아웃을 별도로 설정합니다. interrupt만으로 모든 I/O가 즉시 중단된다고 가정하면 안 됩니다.
- 공유 상태: 취소 후 재시도될 수 있으므로 작업 결과 저장은 멱등적으로 설계합니다.
실전 코드: Future 취소와 안전한 종료
아래 예제는 ExecutorService에 작업을 제출하고, 제한 시간 안에 끝나지 않으면 cancel(true)로 중단을 요청합니다. 작업 내부에서는 interrupt 상태를 확인하고, InterruptedException이 발생하면 상태를 복원한 뒤 finally에서 정리 로그를 남깁니다. 중요한 점은 cancel(true)가 호출되더라도 작업 코드가 중단 신호를 무시하면 즉시 멈추지 않는다는 것입니다.
import java.time.Duration;
import java.util.concurrent.*;
public class CancellableJobExample {
private final ExecutorService executor = Executors.newFixedThreadPool(4);
public String runWithTimeout() throws Exception {
Future<String> future = executor.submit(() -> {
try {
for (int page = 1; page <= 10_000; page++) {
if (Thread.currentThread().isInterrupted()) {
return "cancelled";
}
// 실제 업무에서는 DB 조회, API 호출, 파일 처리 등이 들어간다.
processPage(page);
}
return "done";
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "cancelled";
} finally {
releaseJobResources();
}
});
try {
return future.get(Duration.ofSeconds(3).toMillis(), TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
future.cancel(true);
throw new IllegalStateException("job timeout", e);
}
}
private void processPage(int page) throws InterruptedException {
TimeUnit.MILLISECONDS.sleep(50);
}
private void releaseJobResources() {
// 커넥션 반환, 임시 파일 삭제, 작업 상태 갱신 등
}
public void shutdown() {
executor.shutdown();
try {
if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
}
}
InterruptedException을 처리할 때 흔한 실수
가장 흔한 문제는 catch 블록에서 InterruptedException을 로그만 남기고 계속 진행하는 것입니다. 이렇게 하면 호출자는 작업이 취소되었다고 생각하지만 실제 작업은 계속 실행될 수 있습니다. 특히 배치, 메시지 컨슈머, 파일 변환 작업처럼 긴 루프를 도는 코드에서 이 실수는 종료 지연과 중복 처리로 이어집니다.
- 나쁜 처리: catch에서 예외를 무시하고 다음 루프를 계속 실행합니다.
- 권장 처리: Thread.currentThread().interrupt()로 중단 상태를 복원하고 메서드를 빠져나갑니다.
- 상위 계층에 알려야 하는 경우: 업무 예외로 감싸되, 중단 상태 복원은 유지합니다.
- 정말 계속해야 하는 경우: 왜 중단을 무시해도 되는지 코드 주석과 운영 기준을 남깁니다.
ExecutorService 종료 순서
서버 종료나 배포 시점에는 새 작업 접수를 먼저 막고, 이미 실행 중인 작업이 정상 종료될 시간을 준 뒤, 그래도 남은 작업에 강제 중단 요청을 보내야 합니다. 이 흐름이 shutdown(), awaitTermination(), shutdownNow()입니다. shutdownNow()도 스레드를 강제로 죽이는 기능이 아니라 대기 중인 작업을 반환하고 실행 중인 작업에 interrupt를 요청하는 기능입니다.
운영 환경에서는 종료 대기 시간을 업무 성격에 맞춰 정해야 합니다. 짧은 API 보조 작업은 수 초면 충분하지만, 정산이나 파일 업로드처럼 복구 비용이 큰 작업은 체크포인트를 저장하고 다음 실행에서 이어받는 설계가 필요합니다. 단순히 종료 대기 시간을 길게 잡는 방식은 배포 지연을 만들 뿐 근본 해결이 아닙니다.
외부 API와 DB 작업의 취소
interrupt만 믿고 외부 I/O를 설계하면 장애가 길어질 수 있습니다. HTTP 호출에는 연결 타임아웃과 응답 타임아웃을 설정하고, JDBC 쿼리에는 쿼리 타임아웃 또는 트랜잭션 타임아웃을 둡니다. 작업 취소는 애플리케이션 내부 신호이고, 네트워크와 데이터베이스에는 각 계층의 제한 시간이 별도로 필요합니다.
- HTTP 클라이언트: connectTimeout, request timeout, retry backoff를 함께 설정합니다.
- DB 쿼리: 대량 조회는 페이지 단위로 나누고, 각 페이지 사이에서 중단 상태를 확인합니다.
- 파일 처리: 큰 파일은 청크 단위로 처리하고 중간 결과를 안전하게 정리합니다.
- 메시지 큐: 취소된 작업의 ack, nack, 재시도 정책을 명확히 분리합니다.
운영 체크리스트
작업 취소 설계는 장애 대응 코드가 아니라 정상 운영 코드입니다. 새 기능을 만들 때부터 중단 가능한 지점, 타임아웃, 정리 작업, 재시도 기준을 같이 설계하면 배포와 장애 복구가 훨씬 단순해집니다. 최종 점검은 다음 순서로 하면 됩니다.
- 긴 반복문마다 isInterrupted() 또는 취소 플래그 확인 지점이 있는가.
- InterruptedException을 잡은 뒤 중단 상태를 복원하고 빠져나오는가.
- Future.cancel(true)를 호출한 뒤에도 작업이 정리될 수 있도록 finally가 있는가.
- ExecutorService 종료가 shutdown, awaitTermination, shutdownNow 순서로 구성되어 있는가.
- HTTP, DB, 파일, 큐 같은 외부 자원에는 별도 타임아웃과 복구 기준이 있는가.
- 취소된 작업이 재실행되어도 데이터가 중복 반영되지 않도록 멱등성이 확보되어 있는가.