개요
대량 푸시 발송 로직을 구현하면서 Semaphore를 이용해 동시 HTTP 요청 수를 제한했다.
이 과정에서 InterruptedException을 처리하며 다음과 같은 코드를 적용했다.
catch (InterruptedException e) {
Thread.currentThread().interrupt()
throw e
}
처음 보면 단순한 예외 처리처럼 보이지만,
이 코드는 자바의 스레드 인터럽트 모델을 정확히 이해했을 때만 의미가 드러난다.
이 글에서는 다음 질문에 답한다.
- 스레드 인터럽트는 왜 발생하는가?
- InterruptedException은 왜 특별한가?
- 왜 인터럽트 플래그를 복구해야 하는가?
- 이 처리가 왜 실무에서 중요한가?
1. 스레드 인터럽트는 왜 존재하는가
스레드 인터럽트는 에러도 아니고, 강제 종료도 아니다.
스레드 인터럽트는“이 작업을 계속할 필요가 없다”는 신호다.
자바는 스레드를 강제로 죽이지 않는다.
그 대신, 스레드에게 “이제 그만해도 된다”는 의사 표현을 전달한다.
이 철학을 보통 협조적 취소(Cooperative Cancellation) 라고 부른다.
2. 인터럽트는 언제, 누가 발생시키는가
중요한 사실 하나는 스레드는 스스로 인터럽트를 발생시키지 않는다. 항상 외부에서 걸린다
대표적인 상황은 다음과 같다.
① ExecutorService 종료
executor.shutdownNow()
- 실행 중인 모든 작업 스레드에 인터럽트 전달
- “지금 중단해라”라는 의미
② 애플리케이션 종료
- Spring Boot 종료
- Kubernetes Pod 종료
- JVM shutdown
→ 실행 중인 스레드에 인터럽트 전파
③ 타임아웃 / 취소
- Future.cancel(true)
- 제한 시간 초과
- 더 이상 결과가 필요 없는 작업
④ 개발자가 의도적으로
thread.interrupt()
즉, 인터럽트는 시스템 또는 상위 제어 로직의 판단 결과다.
3. 강제 종료가 아니라 인터럽트인 이유
만약 스레드를 강제로 종료할 수 있다면 어떤 문제가 생길까?
- 락을 잡은 채 종료
- DB 트랜잭션 미정리
- 파일 핸들 미해제
- Semaphore permit 미반납
시스템 일관성이 깨지므로, 언제 멈출지는 스레드가 스스로 결정해야 한다.
4. InterruptedException의 중요한 특징
여기서 문제가 시작된다.
💡 InterruptedException은 인터럽트 플래그를 지운다
다음과 같은 블로킹 메서드에서:
- Semaphore.acquire()
- Thread.sleep()
- BlockingQueue.take()
인터럽트가 발생하면:
- InterruptedException 발생
- interrupt flag가 자동으로 false로 초기화됨
즉
interrupt flag = true
→ InterruptedException 발생
→ interrupt flag = false
이 현상을 흔히 인터럽트 플래그 소실이라고 한다.
5. 인터럽트 플래그 소실이 왜 위험한가
상위 로직(Executor, 컨테이너)은 보통 이렇게 판단한다.
if (Thread.currentThread().isInterrupted) {
// 종료 처리
}
그런데 하위 코드에서 이렇게 작성하면:
catch (InterruptedException e) {
// 무시
}
결과는?
- 인터럽트는 발생했지만
- flag는 이미 지워짐
- 상위 로직은 종료 신호를 감지하지 못함
즉, “그만하라”는 신호를 하위 코드가 몰래 없애버린 셈이다.
6. 그래서 인터럽트 플래그를 복구해야 한다
이 문제를 해결하는 표준 패턴이 있다.
catch (InterruptedException e) {
Thread.currentThread().interrupt()
throw e
}
의미는 명확하다.
- 인터럽트를 인지했다
- 하지만 그 의미는 상위로 전파되어야 한다
- 그래서 flag를 다시 세운다
이것을 interrupt status 복구라고 부른다.
7. 실제 코드에서의 적용 (Semaphore 예제)
private inline fun <T> Semaphore.withPermit(action: () -> T): T {
try {
acquire() // java.util.concurrent.Semaphore
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
throw e
}
try {
return action()
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
throw e
} finally {
release()
}
}
왜 두 번 처리했는가?
① acquire() 대기 중 인터럽트
- permit 대기 중 블로킹
- 인터럽트 발생
- flag 소실 → 복구 필요
② action() 내부 인터럽트
- HTTP 요청
- RateLimiter 대기
- 외부 I/O 블로킹
- 동일한 문제 발생
그래서 Semaphore 획득 구간과 실행 구간 모두에서 복구했다.
8. 가상 스레드 + Executor 환경에서 더 중요한 이유
이 코드는 다음 환경에서 실행된다.
- Virtual Thread
- ExecutorService
- Semaphore 기반 동시성 제어
이 환경에서 인터럽트를 무시하면:
- shutdownNow()가 즉시 반영되지 않음
- 가상 스레드가 오래 살아남음
- permit 반납 지연
- graceful shutdown 실패
즉, 운영 장애로 이어질 수 있는 문제다.
9. 정리
- 스레드 인터럽트는 에러가 아니라 제어 신호
- InterruptedException은:
- 인터럽트를 예외로 바꾸고
- flag를 지운다
- 그래서, catch했다면 반드시 의미 있게 처리하거나 flag를 복구하고 전파해야 한다
💡 인터럽트를 잡는 순간 그 신호를 책임질 주체는 해당 코드가 된다
이제 이 코드는 이렇게 보일 것이다.
Thread.currentThread().interrupt()
단순한 보일러플레이트가 아니라, “이 스레드는 종료 신호를 끝까지 존중하겠다” 라는 명확한 설계 선언이다.