수정 이력(5건, 최종 수정 2026년 08월 22일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- 지식 맵의 관계를 전면 점검해, 본문 설명과 어긋나 있던 관계(방향 반전·과도한 일반화·술어 혼동)를 고쳤습니다. 본문 설명은 바꾸지 않았습니다.
- 지식 맵의 관계를 전면 점검해, 본문 설명과 어긋나 있던 관계(방향 반전·과도한 일반화·술어 혼동)를 고쳤습니다. 본문 설명은 바꾸지 않았습니다.
- 지식 맵의 관계를 전면 점검해, 본문 설명과 어긋나 있던 관계(방향 반전·과도한 일반화·술어 혼동)를 고쳤습니다. 본문 설명은 바꾸지 않았습니다.
- 지식 맵의 관계를 전면 점검해, 본문 설명과 어긋나 있던 관계(방향 반전·과도한 일반화·술어 혼동)를 고쳤습니다. 본문 설명은 바꾸지 않았습니다.
- 지식 맵의 "Java 메모리 모델이 메모리 일관성 오류를 막는다"는 관계를 "메모리 모델은 happens-before 관계를 쓴다"와 "happens-before 관계가 메모리 일관성 오류를 막는다" 두 줄로 나눴습니다. 동기화 없는 관측은 메모리 모델 자신이 허용하기 때문입니다. 본문 설명은 바꾸지 않았습니다.
- 최초 공개
이 글을 인용하기(DOI(등록된 아카이브): 10.5281/zenodo.22175938)
아래 DOI는 이전에 등록된 아카이브를 가리키며 현재 본문과 다를 수 있습니다. 현재 본문을 참조할 때는 이 페이지의 URL을 사용하세요.
Go Komura (2026). 「멀티스레드 실무 베스트 프랙티스 Java 편 ── 가상 스레드 시대의 정석」. 합동회사 코무라소프트. https://comcomponent.com/ko/blog/multithreading-best-practices-java/
- DOI(등록된 아카이브)
- 10.5281/zenodo.22175938
- DOI(마지막 등록 버전)
- 10.5281/zenodo.22175939
“업무 시스템 배치를 Java로 병렬화하고 싶다”, “Spring으로 만든 웹 앱에서 공유 캐시가 가끔 깨진다”, “new Thread로 가득한 오래된 Swing 앱을 이어받았다” ── Java는 JDK 1.0부터 멀티스레드를 언어에 넣었고, java.util.concurrent라는 성숙한 도구 모음을 갖추었으며, 나아가 JDK 21의 가상 스레드로 동시성 처리의 상식을 한 단계 다시 쓴 언어입니다. 도구가 많은 만큼 “무엇을 고를지”의 판단이 곧 설계 품질이 됩니다.
이 글은 멀티스레드 실무 시리즈의 Java 편입니다. Java로 업무 시스템·배치·서버 앱을 작성하는 개발자를 대상으로, 멀티스레드 설계 원칙 ── 스레드를 직접 만들지 않는다, 공유 가변 상태를 줄인다, 락의 규율, 멈추는 방법을 처음에 설계한다 ── 을 Java(LTS인 JDK 21 이후를 주 대상)의 도구로 옮기고, 가상 스레드 시대의 구분과 Java 고유의 함정을 함께, 2026년 8월 시점의 1차 정보를 바탕으로 정리합니다. 이 편만으로 읽을 수 있게 썼습니다. 같은 원칙을 다른 언어로 펼친 “.NET 편”, “C++ 편”, “C 언어 편“도 있습니다.
1. 먼저 결론
- 업무 코드에
new Thread를 쓰지 않는 것은 Java에서도 같습니다. 작업은ExecutorService에 넘기고, 스레드 수명 주기 관리는 라이브러리에 맡깁니다.1 - I/O 대기가 주가 되는 작업은 가상 스레드로. JDK 21에서 정식화된 가상 스레드는 “작업 하나당 가상 스레드 하나”로 쓰며, 절대 풀링하지 않습니다. 동시 실행 수 제한은 풀이 아니라
Semaphore로 합니다.23 - 가상 스레드는 처리량을 위한 도구이지, 계산을 빠르게 하는 도구가 아닙니다. CPU-bound 병렬화는 이전과 같이 코어 수 정도의 플랫폼 스레드(고정 풀이나 parallel stream)가 맡습니다.3
- 락은
private final락 객체나 전용ReentrantLock으로.synchronized(this)나 공개 객체에 대한 락은 외부와 충돌합니다. 타임아웃이 있는 획득(tryLock)이 필요하면ReentrantLock입니다. - JDK 21~23에서는 synchronized 안에서 블로킹하면 가상 스레드가 pinning되는 문제가 있었지만, JDK 24(JEP 491)에서 해소되었습니다. 옛 주의 사항과 지금의 실제를 구분하십시오.34
volatile은 가시성과 순서의 보장이지 원자성이 아닙니다. 카운터는AtomicInteger/LongAdder, 복합 상태는 락입니다.5- 중단은 interrupt에 의한 협력적 중단이 유일한 정답입니다.
Thread.stop/suspend/resume은 현재UnsupportedOperationException을 던집니다.InterruptedException은 삼키지 말고, 상태를 복원하거나 위로 던지십시오.6 ExecutorService중단은 “shutdown → awaitTermination → shutdownNow” 2단계 패턴으로.shutdownNow는 best-effort(표준 구현에서는 interrupt를 통해)이므로, 작업 쪽이 interrupt에 응답하는 것이 전제입니다.1- Swing UI는 EDT(이벤트 디스패치 스레드)만 다룹니다. 다른 스레드에서는
SwingUtilities.invokeLater로 요청합니다.7
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 28건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
2. 왜 멀티스레드는 어려운가 ── 경합 상태·데드락·메모리 모델
멀티스레드가 가져오는 문제는 언어를 가리지 않고 파고들면 두 가지입니다.
경합 상태(race condition)는 여러 스레드가 특정 코드에 어떤 순서로 도달하는지에 따라 결과가 바뀌는 버그입니다. 전형적인 예가 공유 카운터로, count++라는 하나의 식은 실제로는 “읽기 → 더하기 → 다시 쓰기” 세 단계로 나뉩니다. 두 스레드가 이 세 단계에 동시에 들어가면, 한쪽의 가산이 다른 쪽의 다시 쓰기에 덮여 사라집니다. 실행할 때마다 결과가 달라지고, 어떤 결과가 될지는 예측할 수 없습니다.
sequenceDiagram
participant A as 스레드 A
participant M as 공유 변수 count
participant B as 스레드 B
Note over M: count = 10
A->>M: 읽기(10)
B->>M: 읽기(10)
A->>A: 로컬에서 더하기(11)
B->>B: 로컬에서 더하기(11)
A->>M: 다시 쓰기(11)
B->>M: 다시 쓰기(11)
Note over M: 두 번 더했는데 count = 11<br/>스레드 A의 가산이 사라짐
그림 1: 공유 카운터에서 가산이 사라지는 전형적인 경합 상태. count++의 세 단계 사이에 다른 스레드가 끼어들면, 나중에 다시 쓴 쪽이 덮어씁니다
데드락은 두 스레드가 서로 상대가 쥔 락을 기다려, 어느 쪽도 앞으로 가지 못하는 상태입니다. 스레드 A가 락 1을 쥔 채 락 2를 기다리고, 스레드 B가 락 2를 쥔 채 락 1을 기다린다 ── 이것만으로 둘 다 영원히 멈춥니다.
flowchart LR
A["스레드 A<br/>락 1을 보유 중"] -->|"락 2 해제 대기"| B["스레드 B<br/>락 2를 보유 중"]
B -->|"락 1 해제 대기"| A
그림 2: 데드락의 순환 대기. 대기 화살표가 고리를 만드는 순간, 고리 안의 모든 스레드가 영원히 정지합니다
둘 다 타이밍에 의존합니다. 개발 머신에서는 수만 번에 한 번 나올까 말까 한 실행 순서 조합이, 코어 수도 부하도 다른 운영 서버에서는 매일 일어납니다. “디버거를 붙이면 재현되지 않는다”, “로그를 넣었더니 사라졌다” 역시 관측이 타이밍을 바꾸기 때문이며, 경합 버그의 전형적인 모습입니다. 그래서 이 글의 원칙은 모두 “올바르게 동기화하기”보다 먼저 “동기화가 필요한 지점을 줄이기”를 향합니다.
2.1. Java 고유의 전제 ── 메모리 모델과 happens-before
그 위에서 Java에 고유한 점은, 공유 데이터가 어떻게 보이는지가 Java 메모리 모델(JMM)의 happens-before 관계로 정의된다는 것입니다.
동기화 없이 공유 변수를 읽고 쓰면 C++처럼 “undefined behavior”가 되지는 않지만, 오래된 값이 계속 보이거나 쓰기 순서가 뒤바뀌어 보이는 일이 정당하게 일어납니다. “루프 안에서 boolean 플래그를 보고 있는데, 다른 스레드가 바꾼 값이 언제까지고 보이지 않는다”는 메모리 일관성 오류는 JMM이 허용하는 동작이지 JVM 버그가 아닙니다.5 이를 막는 도구가 happens-before를 만드는 장치 ── synchronized, volatile, java.util.concurrent의 각 클래스 ── 입니다. 동시성 컬렉션을 올바르게 쓰면, “갱신 연산과 그 이후 조회 사이의 happens-before”는 라이브러리가 보장합니다.8
즉 Java의 실무 지침은 이렇게 정리됩니다. 날것의 공유 변수로 궁리하지 마십시오. 공유에는 java.util.concurrent의 도구를 쓰고, happens-before는 라이브러리가 만들게 하십시오.
3. 스레드를 만드는 방법 ── ExecutorService와 가상 스레드
3.1. 작업과 실행의 분리
Java에서 “스레드를 직접 만들지 않는다”는 원칙을 맡는 것이 ExecutorService입니다. 일(Runnable / Callable)과 그것을 어떻게 실행할지(몇 개 스레드로, 어떤 큐로)를 분리하고, 스레드의 생성·재사용·폐기는 라이브러리에 맡깁니다.1
JDK 21 이후에는 실행 수단의 선택이 단순한 둘 중 하나가 되었습니다.23
flowchart TB
S["동시에 돌리고 싶은 작업이 있다"] --> Q1{"작업의 주체는?"}
Q1 -->|"I/O 대기가 주체<br/>HTTP 호출·DB·파일"| VT["가상 스레드<br/>Executors.newVirtualThreadPerTaskExecutor()<br/>작업 하나당 하나. 풀링하지 않는다"]
Q1 -->|"CPU를 쓰는 계산"| PT["플랫폼 스레드의 고정 풀<br/>Executors.newFixedThreadPool(코어 수 정도)<br/>또는 parallel stream"]
VT --> LIMIT["외부 서비스에 대한 동시 수 제한은<br/>풀이 아니라 Semaphore로"]
그림 3: JDK 21 이후 실행 수단의 선택. “I/O는 기다리는 방식을 바꾸고, CPU는 병렬화한다”는 선을 먼저 긋고, I/O-bound는 가상 스레드, CPU-bound는 기존 풀이 나눕니다
CPU-bound 쪽에는 한 가지 주의가 있습니다. Executors.newFixedThreadPool은 스레드 수는 제한하지만, 대기열은 무제한입니다. 투입이 처리를 계속 앞서는 상시 가동 서비스에서는, 코어 수로 줄인 것은 스레드뿐이고, 큐에 쌓인 작업과 그 데이터가 메모리를 계속 잡아먹습니다. 그런 구성에서는 ThreadPoolExecutor를 직접 써서 용량이 있는 큐 + 거부 정책을 구성하거나, 투입 쪽에 Semaphore 같은 입장 제한을 두어 backpressure를 걸 수 있는 형태로 만드십시오(4장의 큐 이야기와 같은 원칙입니다).
3.2. 가상 스레드 사용법을 틀리지 않기
가상 스레드는 OS 스레드에서 분리된 경량 스레드로, JDK의 블로킹 연산(표준 라이브러리의 I/O·락·sleep 등) 동안 OS 스레드를 놓아 주기 때문에, 하나의 JVM에서 수백만 개를 돌릴 수 있습니다. 다만 “모든 블로킹에서 놓아 준다”는 뜻은 아닙니다. 네이티브 코드(JNI)나 foreign function 실행 중에 블로킹하면, 가상 스레드는 캐리어 스레드에 pinning된 채로 남습니다. JDK 24(뒤에서 다루는 JEP 491)가 없앤 것은 synchronized에 의한 pinning이지, 네이티브 경계의 pinning은 남습니다. 그래서 JNI를 거치는 드라이버나 디바이스 API에서 오래 블로킹하는 처리를 대량의 가상 스레드에 실으면, 캐리어 스레드가 바닥납니다. 다만 공식 가이드가 강조하듯, “빠른 스레드”가 아닙니다. 코드 실행 속도는 변하지 않고, 제공하는 것은 스케일(처리량)입니다.3
사용 규율은 세 가지입니다.3
- 풀링하지 마십시오. 가상 스레드는 값싼 일회용이며, “작업 수 = 가상 스레드 수”가 올바른 상태입니다.
newFixedThreadPool에 가상 스레드를 넣는 것은 잘못이고,try (var executor = Executors.newVirtualThreadPerTaskExecutor())형태로 씁니다. - 동시 실행 수 제한은
Semaphore로. “외부 API에 동시 10접속까지” 같은 제한을, 풀 크기가 아니라 세마포어로 나타냅니다. - CPU-bound 일에 쓰지 마십시오. 계산의 병렬화는 이전과 같이 코어 수 정도의 플랫폼 스레드가 맞습니다.
참고로, 가상 스레드 안에서 돌아가는 것은 평범한 동기 코드입니다. .NET의 async/await처럼 코드를 다시 쓰는 것이 아니라, “요청 하나당 스레드 하나”라는 단순한 코드를 그대로 대량으로 돌릴 수 있게 하는 것이 가상 스레드의 설계 사상입니다.2
3.3. 오해하지 말아야 할 점 ── “풀이 필요 없다”는 가상 스레드만의 이야기
“풀링하지 않는다”는 규율을 “Java에는 스레드 풀이라는 장치가 없다(또는 비효율적이다)”로 읽지 마십시오. 실제는 반대입니다. Java의 풀은 JDK 5(2004년)부터 표준 라이브러리에 있는 성숙한 도구입니다. 세밀하게 구성할 수 있는 범용 풀 ThreadPoolExecutor(Executors의 각 팩토리로 만드는 것), work-stealing형 ForkJoinPool(공통 인스턴스 commonPool()은 parallel stream이나 CompletableFuture의 기본 실행처), 주기 실행용 ScheduledThreadPoolExecutor ── CPU-bound 일에서는 지금도 이들이 주역입니다.
풀이란 본래 “OS 스레드의 생성·유지가 비싸니 재사용한다“는 최적화입니다. 가상 스레드는 생성 비용을 거의 0으로 만들어 이 전제를 없앴기 때문에, 재사용할 이유가 사라진 것입니다 ── 풀이 비효율적이 된 것이 아니라, 풀이라는 최적화가 필요 없을 만큼 가벼워졌다는 이해가 정확합니다. 게다가 가상 스레드 아래에서는 JDK 스케줄러가 work-stealing ForkJoinPool로서, 코어 수 정도의 캐리어 스레드(OS 스레드)를 운용합니다.2 즉 “소수 OS 스레드 풀로 대량 동시성을 처리한다”는 구도 자체는 유지되고, 그 풀 관리가 개발자 손에서 JVM으로 옮겨 간 것뿐입니다. .NET의 async/await가 await 시점에 스레드를 풀로 되돌리는 것과 같은 도달점을, 코드 형태를 바꾸지 않고 실현하는 것이 Java의 답이라고 할 수 있습니다.
4. 공유 가변 상태를 줄이기 ── 분할·불변·동시성 컬렉션·큐
경합은 “여러 스레드”와 “공유된 가변 데이터”가 갖춰졌을 때만 일어납니다. 스레드 수는 요건으로 정해지므로, 설계로 줄일 수 있는 것은 공유 쪽입니다. 수단은 “분할”, “불변화”, “전달” 세 계통이며, Java에서는 이렇게 씁니다.
분할합니다. 병렬 집계에서는 공유 합계 변수에 각 스레드가 쓰지 않고, 스레드마다 부분 결과를 만들어 마지막에 합칩니다. parallel stream의 reduce / collect는 바로 이 형태를 틀로 제공하고, 뒤에서 다루는 LongAdder도 “내부에서 셀을 나눠 경합을 흩뜨린 뒤 읽을 때 합산한다”는 분할 전략의 구현입니다. 공유에 대한 쓰기 횟수를 줄이는 일이, 동기화를 올바르게 쓰는 일보다 먼저입니다.
불변으로 만듭니다. record와 불변 컬렉션(List.copyOf / Map.copyOf)으로 “만든 뒤 고치지 않는 데이터”를 만들면, 동기화 없이 공유할 수 있습니다. 설정이나 마스터 데이터는 “바꿀 때는 새 객체를 만들고, volatile 참조를 갈아 끼운다”가 정석입니다. 다만 “읽기 전용으로 보인다”와 “불변이다”는 다릅니다. record의 접근자는 구성 요소의 참조를 그대로 반환하고, List.copyOf의 복사도 얕습니다(요소 객체까지는 복제하지 않습니다). 그래서 요소가 가변이면 alias를 가진 누군가가 내용을 고칠 수 있어, 경합은 남습니다. 동기화 없이 공유해도 되는 것은, 요소까지 포함해 객체 그래프 전체가 불변인 경우뿐입니다. 가변 요소가 있으면 깊은 복사를 넘기거나, 요소도 record / 불변 타입으로 맞추십시오.
동시성 컬렉션의 복합 연산을 씁니다. ConcurrentHashMap의 “없으면 만들어 넣기”는 computeIfAbsent를 씁니다. 이 메서드는 호출 전체가 원자적으로 실행되고, 키가 없으면 그 한 번의 호출 안에서 매핑 함수가 딱 한 번만 호출됩니다.8 .NET의 ConcurrentDictionary.GetOrAdd(경합 시 팩토리가 여러 번 돌 수 있음)와 보장이 다르다는 점은, 두 언어를 오가는 사람이 혼동하기 쉬운 지점입니다. 다만 “키의 일생 동안 딱 한 번”은 아닙니다. 함수가 null을 반환하거나 예외를 던지면 매핑이 등록되지 않아, 이후 호출에서 함수가 다시 실행됩니다(등록 뒤에 엔트리를 삭제한 경우도 같습니다). 부작용 중복이 허용되지 않는 초기화는, 함수가 non-null로 성공하는 것까지 포함해 설계하십시오. 다만 원자적인 대가로 계산 중에는 다른 스레드의 일부 갱신이 막히므로, 매핑 함수는 짧게 유지하고, 함수 안에서 이 맵 자체를 갱신하면 안 됩니다(재귀 갱신은 IllegalStateException이 될 수 있습니다).8
// 빈도 카운터의 정석: computeIfAbsent + LongAdder
ConcurrentHashMap<String, LongAdder> freqs = new ConcurrentHashMap<>();
freqs.computeIfAbsent(key, k -> new LongAdder()).increment();
큐로 전달합니다. 스레드 사이 데이터 흐름은 BlockingQueue로 모읍니다. 용량을 지정한 ArrayBlockingQueue라면, 가득 찼을 때 put이 블로킹되어 자연스러운 backpressure가 되고, .NET 편의 bounded 채널과 같은 구도가 됩니다. 가상 스레드 시대에도, 프로듀서/컨슈머 경계를 분명히 하는 이 설계는 유효합니다.
5. 락의 규율 ── synchronized와 ReentrantLock
5.1. 무엇으로 락할지, 락 중에 무엇을 하지 않을지
락의 단위는 “코드 구간”이 아니라 “데이터”로 생각합니다. 지키고 싶은 가변 데이터 집합마다 락 객체를 하나 대응시키고, 그 데이터를 건드리는 모든 곳에서 같은 락을 잡습니다 ── 이 대응표가 무너진 것이 경합 버그의 실체입니다. synchronized(this)나 synchronized(SomeClass.class)는 외부 코드가 같은 객체를 락할 수 있으므로 피하고, 외부에 공개하지 않는 private final Object lock = new Object();를 지키고 싶은 데이터와 1대1로 대응시킵니다.
사용 규율도 두 가지를 더합니다. 첫째, 락 중에 시간이 걸리는 일·외부 일을 하지 마십시오. 쥔 채로 하는 I/O·리스너 호출·알 수 없는 코드 실행은 유지 시간을 늘릴 뿐 아니라, 호출한 쪽이 다른 락을 잡으려다 그림 2의 순환 대기를 만듭니다. 둘째, 여러 락의 획득 순서를 고정하십시오. 둘 이상의 락을 잡는 곳에서는 모든 스레드가 같은 순서로 잡도록 규칙화하고, 순서를 보장할 수 없는 지점에는 뒤에서 다루는 tryLock(timeout)으로 “잡지 못하면 놓고 다시 시도한다”는 경로를 마련합니다.
synchronized로 충분한 것은 “짧고 단순한 상호 배제”입니다. 다음이 필요해지면 ReentrantLock으로 갑니다.
tryLock(timeout)에 의한 타임아웃이 있는 획득(영원한 hang을, 기록하고 대응할 수 있는 실패로 바꿉니다)- 공정성 정책, 여러
Condition, 락의 획득과 해제를 다른 메서드로 나누고 싶은 경우
ReentrantLock을 쓸 때는 lock() 직후에 try, finally에서 unlock() 형태를 무너뜨리지 마십시오(C++ RAII에 해당하는 구문이 없으므로, 이 형태가 규율의 전부입니다).
5.2. 가상 스레드와 pinning ── JDK 24에서 바뀐 주의점
가상 스레드 도입 초기(JDK 21~23)에는, synchronized 블록 안에서 블로킹하면 가상 스레드가 OS 스레드에 pinning된다(OS 스레드를 놓지 못해 스케일 이점이 사라진다)는 제약이 있어, 자주 또는 오래 블로킹하는 지점은 ReentrantLock으로 바꾸는 편이 권장되었습니다.3 이 제약은 JDK 24의 JEP 491에서 모니터 구현이 다시 쓰이면서 해소되어, synchronized는 더 이상 가상 스레드를 pinning하지 않습니다.4 JDK 24 이후를 쓰고 있다면, pinning 대책으로서의 기계적 교체는 필요 없습니다. 사내 옛 가이드라인이 JDK 21 시점의 주의 그대로인지 확인할 가치가 있습니다.
5.3. Atomic 계열과 volatile의 위치
단일 변수의 원자적 갱신은 AtomicInteger / AtomicLong / AtomicReference(빈번히 더하기만 하는 통계값이라면 경합에 강한 LongAdder)가 맡습니다. volatile은 가시성과 순서(happens-before)의 보장이지, 복합 연산의 원자성은 주지 않습니다.5 .NET 편·C++ 편과 같은 결론이 Java에도 성립합니다: 플래그와 단일 값은 Atomic 계열, 복합 상태는 락, volatile 단독으로 버티지 마십시오.
6. 멈추는 방법의 설계 ── interrupt라는 공통 언어
6.1. interrupt의 규칙
Java의 중단·취소는 interrupt(interruption)로 통일되어 있습니다. t.interrupt()는 대상 스레드의 interrupt 상태를 세우고, 대상이 sleep / wait / join 등으로 블로킹 중이면 InterruptedException을 던져 즉시 깨웁니다(이때 interrupt 상태는 지워집니다).6 과거의 강제 수단이던 Thread.stop / suspend / resume은 본질적으로 안전하지 않아, 지금은 호출하면 UnsupportedOperationException이 됩니다.6
flowchart TB
OWNER["멈추는 쪽: t.interrupt()"] --> ST["interrupt 상태가 선다"]
ST --> A["계산 중인 스레드:<br/>Thread.interrupted()를 루프로 확인"]
ST --> B["sleep / wait / join으로 블로킹 중:<br/>InterruptedException이 던져져 즉시 깨어남<br/>(상태는 지워진다)"]
A --> E["뒷정리하고 스스로 끝낸다"]
B --> C{"catch에서 어떻게 할까?"}
C -->|"스스로 끝날 수 있다"| E
C -->|"끝날 수 없다(라이브러리 안 등)"| R["Thread.currentThread().interrupt()<br/>로 상태를 복원해 신호를 남긴다"]
R --> E
그림 4: interrupt에 의한 협력적 중단. InterruptedException을 삼키면 중단 신호가 사라집니다 ── catch했다면 “끝낸다” 또는 “복원한다” 둘 중 하나입니다
실무 규율은 하나만 기억하면 됩니다. InterruptedException을 catch하고 아무것도 하지 않는 코드를 쓰지 마십시오. 자기 책임 범위에서 종료할 수 있으면 거기서 끝내고, 그렇지 않으면 Thread.currentThread().interrupt()로 상태를 복원해 호출 쪽에 신호를 전합니다(FAQ 참조).
6.2. ExecutorService의 2단계 셧다운
ExecutorService의 중단 API는 interrupt 모델 위에 있습니다. shutdown()은 신규 접수를 멈추고 이미 넣은 작업을 끝까지 돌리며, shutdownNow()는 실행 중인 작업의 중단을 시도합니다. 인터페이스 사양상 이는 best-effort이며, 표준 구현(ThreadPoolExecutor 등)은 전형적으로 Thread.interrupt()로 취소한다고 명시되어 있습니다 ── 즉 interrupt에 응답하지 않는 작업은 shutdownNow로도 멈추지 않고, 자체 구현 Executor를 쓰는 경우에는 그 취소 방법(interrupt를 보내는지 여부)을 구현 문서에서 확인할 필요가 있습니다.1 공식 문서가 보이는 중단의 정석이 다음 2단계 패턴입니다.1
/** 중단이 완료되면 true. false인 채로 공유 자원 해제로 넘어가면 안 된다. */
boolean shutdownAndAwaitTermination(ExecutorService pool) {
pool.shutdown(); // 1단계: 신규 접수를 멈추고, 끝까지 돌기를 기다린다
try {
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
// 2단계: 취소를 요청한다. 실행되지 않은 채 내려온 작업이 반환되므로,
// 그 Future를 취소 완료로 만들어 get() 대기 중인 호출 쪽을 깨운다
pool.shutdownNow().forEach(r -> {
if (r instanceof Future<?> f) f.cancel(false);
});
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
System.err.println("Pool did not terminate");
return false; // 중단 미완료. 성공과 구별되는 형태로 전한다
}
}
return true;
} catch (InterruptedException ex) {
pool.shutdownNow().forEach(r -> {
if (r instanceof Future<?> f) f.cancel(false);
});
Thread.currentThread().interrupt(); // 자신의 interrupt 상태도 복원한다
return false; // 이 경로도 중단은 미완료일 수 있다
}
}
flowchart TB
S["shutdown()<br/>신규 작업 접수를 멈춘다"] --> W1{"awaitTermination<br/>으로 끝까지 돌기를 기다린다"}
W1 -->|"기한 안에 완료"| DONE["중단 완료"]
W1 -->|"타임아웃"| NOW["shutdownNow()<br/>실행 중인 작업에 interrupt를 보낸다<br/>(응답할지는 작업 나름)"]
NOW --> W2{"awaitTermination<br/>으로 다시 기다린다"}
W2 -->|"완료"| DONE
W2 -->|"아직 끝나지 않는다"| LOG["이상으로 기록<br/>(interrupt에 응답하지 않는 작업이 용의자)"]
그림 5: ExecutorService의 2단계 셧다운. “차분히 기다린다 → interrupt로 요청한다 → 그래도 끝나지 않으면 이상으로 관측한다”는 단계 설계입니다
또한, shutdownNow()가 반환하는 Runnable의 취소에는 한 가지 한계가 있습니다. 반환되는 것은 실행 큐에 있던 객체로, 평범한 submit이라면 그것이 이용자에게 넘긴 FutureTask 자신이지만, ExecutorCompletionService 같은 래퍼를 통해 넣은 작업에서는 큐 안의 래퍼이지 이용자의 Future와는 별개입니다. 그 구성에서는 위의 취소가 이용자 쪽 Future를 완료시키지 않으므로, 넣을 때 직접 Future 목록을 들고 있다가 셧다운 때 그쪽을 취소하는(또는 내려온 작업을 소유자에게 되돌리는) 설계로 하십시오.
JDK 19 이후의 close()(AutoCloseable)는 “shutdown한 뒤 완료까지 기다린다”를 try-with-resources로 쓸 수 있게 한 것으로, 가상 스레드의 newVirtualThreadPerTaskExecutor와 맞춘 try (var executor = ...)가 지금의 기본형입니다.1 다만 close()는 위의 2단계 패턴을 대체하지 않습니다. 타임아웃 없이 완료를 기다리기 때문에, interrupt에 응답하지 않는 작업이나 끝나지 않는 작업이 하나라도 있으면, 닫으려던 스레드가 영원히 블로킹됩니다. 스코프 안 작업이 유한하고 끝까지 도는 것이 보장되는 장면(그 자리에서 넣고 그 자리에서 기다리는 사용법)에 맞는 도구이며, 앱 셧다운 경로처럼 “반드시 유한 시간 안에 끝내고 싶은” 곳에는, 기한이 있는 2단계 패턴을 쓰십시오. 개별 작업의 취소는 Future.cancel(true)가 마찬가지로 interrupt를 통해 수행합니다.
7. UI 스레드 ── Swing의 EDT
데스크톱 앱에는 언어나 프레임워크를 가리지 않고 “UI는 그것을 관리하는 스레드만 다룬다”는 규칙이 있습니다. Swing에서 그 전담 스레드가 이벤트 디스패치 스레드(EDT)이며, Swing 컴포넌트 메서드는 원칙적으로 스레드 안전하지 않아, 여러 스레드에서 건드리면 스레드 간섭이나 메모리 일관성 오류를 부릅니다. 다른 스레드에서의 화면 갱신은 SwingUtilities.invokeLater로 EDT에 요청하고, 반대로 EDT 위에서 긴 처리를 하면 UI가 멈추므로, 무거운 일은 SwingWorker 등으로 워커 스레드에 넘깁니다.7 JavaFX에서도 구도는 같아서, UI 갱신은 Platform.runLater로 애플리케이션 스레드에 요청합니다.
8. 검증과 디버깅 ── 스레드 덤프라는 무기
경합 버그는 테스트에서 발견되기를 기대할 수 없습니다. 보통의 테스트는 “우연히 경합하지 않은” 실행을 성공으로 세기 때문입니다. 대비는 세 층으로 생각합니다.
첫 번째 방어선은 설계입니다. 리뷰에서는 “공유 중인 가변 데이터는 무엇인가”, “각각 어느 락이 지키는가(5.1의 대응표)”, “락 획득 순서는 유일한가”, “InterruptedException을 삼키는 catch가 없는가”, “중단 경로(shutdown/interrupt)가 모든 작업에 닿는가”를 표로 확인합니다.
둘째, 스레드 덤프를 능숙하게 다룹니다. Java에는 “지금 이 순간 멈춰 있는 스레드의 상태”를 뜨는 표준 도구가 있어, jstack(또는 jcmd <pid> Thread.print)으로 스택 트레이스를, -l 옵션으로 락의 추가 정보까지 출력할 수 있습니다.9 주의점으로, 이 기존 형식 덤프는 플랫폼 스레드용이며, 앱의 가상 스레드는 포함되지 않습니다. 가상 스레드를 쓰는 구성(3장)에서 블로킹된 요청을 쫓을 때는, 가상 스레드까지 포함해 덤프할 수 있는 jcmd <pid> Thread.dump_to_file -format=json <파일>을 쓰십시오.2 hang 조사는 덤프를 몇 초 간격으로 2~3회 떠서, 움직이지 않는 스레드가 어느 락을 기다리고 그 락을 누가 쥐고 있는지를 맞추는 것이 기본 절차입니다. tryLock(timeout)의 시간 초과를 로그에 남겨 두면(5.2), 덤프를 뜨는 계기도 자동화할 수 있습니다.
셋째, 부하로 흔듭니다. 코어 수보다 많은 병렬도로 오래 돌리기, 처리 순서를 무작위화하기, 인공 지연을 넣는 스트레스 테스트는, 개발 머신에서 경합의 “당첨”을 뽑기 쉽게 하는 현실적인 수단입니다. 운영에 가까운 데이터양과 스레드 수로 하는 시험을 릴리스 전에 한 번은 통과시키십시오.
9. 앞으로의 Java 동시성 ── Structured Concurrency
마지막으로 반 걸음 앞 이야기입니다. 가상 스레드를 전제로 “여러 서브태스크를 하나의 작업 단위로 다루고, 실패 시 전파와 취소를 구조화하는” Structured Concurrency(StructuredTaskScope)가 개발 중이며, 2026년 8월 시점에서는 아직 프리뷰 기능입니다. JDK 25의 5차 프리뷰(JEP 505)에서 StructuredTaskScope.open()에 의한 API 형태로 바뀌었고, 현행 JDK 26에서도 6차 프리뷰(JEP 525)로 이어지고 있습니다.1011 한편, ThreadLocal의 문제점을 푸는 불변 컨텍스트 공유 Scoped Values는 JDK 25에서 정식화되었습니다.12 이 글의 원칙(작업 경계를 분명히, 공유는 불변으로, 중단은 협력적으로)은, 이들 새 API가 향하는 방향과도 맞습니다.
10. 정리 ── Java 판 체크리스트
- 업무 코드에
new Thread가 남아 있지 않은가(ExecutorService/ 가상 스레드를 쓰고 있는가) - I/O-bound와 CPU-bound로 실행 수단을 나누었는가(그림 3의 분기)
- 가상 스레드를 풀링하지 않았는가, 동시 수 제한을
Semaphore로 나타냈는가 - 공유 데이터는 불변(
record/List.copyOf)인가,java.util.concurrent의 도구를 쓰는가 synchronized(this)/ 공개 객체에 대한 락이 없는가ConcurrentHashMap의 복합 연산(computeIfAbsent등)을 쓰고, 매핑 함수를 짧게 유지하는가volatile에 원자성을 기대하지 않는가(카운터는 Atomic 계열/LongAdder인가)InterruptedException을 삼키는 catch가 하나도 없는가ExecutorService중단이 기한이 있는 2단계 패턴인가(close()를 쓰는 곳은, 작업이 끝까지 도는 것이 보장되는 스코프로 한정되어 있는가)- Swing/JavaFX의 UI 갱신이 EDT/애플리케이션 스레드로 모여 있는가
Java는 동시성 도구가 가장 잘 갖춰진 언어 중 하나이며, 가상 스레드의 등장으로 “단순한 동기 코드를 그대로 스케일시키는” 길도 열렸습니다. 그래서 도구의 역할 분담 ── 어느 것이 처리량의 도구이고, 어느 것이 상호 배제의 도구이며, 중단 신호는 무엇인가 ── 를 올바르게 잡는 것이, Java에서 멀티스레드 설계의 실질입니다.
관련 글
- 멀티스레드 실무 베스트 프랙티스 .NET 편
- 멀티스레드 실무 베스트 프랙티스 C++ 편
- 멀티스레드 실무 베스트 프랙티스 C 언어 편
- C# async/await 실무 판단표 - Task.Run과 ConfigureAwait
관련 상담 영역
합동회사 코무라소프트에서는 Java로 만든 업무 시스템·배치 처리의 멀티스레드 설계 리뷰, 공유 상태 손상이나 “가끔 멈추지 않는다·굳는다” 같은 동시성에서 비롯된 결함 조사(스레드 덤프 분석), 가상 스레드 도입의 기술 상담을 다룹니다.
참고 링크
-
Oracle, ExecutorService (Java SE 21 & JDK 21 API). shutdown()이 이미 넣은 작업을 끝까지 돌리면서 신규 접수를 멈추는 것, shutdownNow()가 실행 중인 작업의 중단을 시도하고 대기 중 작업 목록을 반환하지만, 전형적 구현은 Thread.interrupt()를 통한 취소이며 best-effort를 넘는 보장은 없고 interrupt에 응답하지 않는 작업은 종료하지 않는 것, awaitTermination으로 완료를 기다릴 수 있는 것, close()(Java 19 이후, AutoCloseable)가 shutdown한 뒤 완료까지 기다리며 try-with-resources로 쓸 수 있는 것, shutdown→awaitTermination→shutdownNow의 2단계 셧다운이 사용 예로 제시된 것에 대해. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
OpenJDK, JEP 444: Virtual Threads. 가상 스레드가 JDK 21에서 정식 기능이 된 것, 높은 처리량의 동시성 앱을 작성·유지보수·관측하는 수고를 크게 줄이는 경량 스레드인 것, “요청 하나당 스레드 하나”라는 단순한 동기 코드를 그대로 스케일시킨다는 설계 사상, JDK의 가상 스레드 스케줄러가 FIFO 모드로 동작하는 work-stealing ForkJoinPool이며 기본 병렬도가 사용 가능한 프로세서 수인 것, 가상 스레드를 포함하는 스레드 덤프의 새 형식이 jcmd Thread.dump_to_file(일반 텍스트 및 JSON 형식)로 추가되었고 기존 스레드 덤프에는 가상 스레드가 포함되지 않는 것에 대해. ↩ ↩2 ↩3 ↩4 ↩5
-
Oracle Java SE Core Libraries, Virtual Threads. 가상 스레드가 Java 런타임에 의해 구현되어 블로킹 I/O 때 OS 스레드를 놓는 경량 스레드인 것, 속도(레이턴시)가 아니라 스케일(처리량)을 위한 기능이며 CPU 집약 처리에는 맞지 않는 것, 가상 스레드는 절대 풀링하지 않고 작업마다 하나를 쓰는 것(newVirtualThreadPerTaskExecutor), 동시 실행 수 제한에는 스레드 풀이 아니라 Semaphore를 쓰는 것, JDK 21 시점에서는 synchronized 안의 블로킹이 OS 스레드 pinning을 일으키므로 자주·장시간인 경우 ReentrantLock로 바꾸라고 안내된 것, -Djdk.tracePinnedThreads로 pinning을 검출할 수 있는 것에 대해. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
OpenJDK, JEP 491: Synchronize Virtual Threads without Pinning. JDK 24에서 JVM의 모니터 구현이 가상 스레드 대응으로 다시 쓰여, synchronized 블록·메서드 안에서 블로킹해도 가상 스레드가 캐리어 스레드에 pinning되지 않게 된 것, 이로써 JDK 21~23 시대의 “synchronized를 ReentrantLock로 바꾼다”는 대책이 원칙적으로 불필요해진 것에 대해. ↩ ↩2
-
Oracle, The Java Tutorials, Memory Consistency Errors. 메모리 일관성 오류가 같은 데이터에 대해 여러 스레드가 일관되지 않은 보기를 함으로써 생기는 것, 이를 피하는 열쇠가 happens-before 관계(어떤 문장의 메모리 쓰기가 다른 문장에서 보이는 것의 보장)인 것, synchronized나 volatile, Thread.start / join 등이 happens-before를 만드는 것에 대해. ↩ ↩2 ↩3
-
Oracle, Thread (Java SE 21 & JDK 21 API). Thread.stop / suspend / resume이 본질적으로 안전하지 않아(잘못된 상태로 락이 풀려 손상된 객체가 보이거나, suspend는 데드락을 부름) 삭제 예정의 비권장이며, 지금은 호출하면 UnsupportedOperationException을 던지는 것, interrupt()가 interrupt 상태를 세우고 sleep / wait / join으로 블로킹 중인 스레드에는 InterruptedException을 던져 깨우는 것(이때 interrupt 상태는 지워짐), interrupted()와 isInterrupted()의 상태 취급 차이에 대해. ↩ ↩2 ↩3
-
Oracle, The Java Tutorials, The Event Dispatch Thread. Swing의 이벤트 처리 코드가 이벤트 디스패치 스레드(EDT) 위에서 도는 것, 대부분의 Swing 객체 메서드는 스레드 안전하지 않아 여러 스레드에서의 호출이 스레드 간섭이나 메모리 일관성 오류를 부르므로 Swing 컴포넌트 접근은 원칙적으로 EDT 위에서 해야 하는 것, 다른 스레드에서는 SwingUtilities.invokeLater / invokeAndWait로 EDT에 작업을 요청하는 것, EDT 위 작업은 짧은 시간에 끝내야 하는 것에 대해. ↩ ↩2
-
Oracle, ConcurrentHashMap (Java SE 21 & JDK 21 API). computeIfAbsent의 메서드 호출 전체가 원자적으로 실행되고, 키가 없을 때 매핑 함수가 딱 한 번만 호출되는 것, 계산 중에는 다른 스레드의 일부 갱신 연산이 막히므로 계산은 짧고 단순하게 유지해야 하는 것, 매핑 함수 안에서 이 맵을 변경하면 안 되며 감지 가능한 재귀 갱신은 IllegalStateException이 되는 것, 조회 연산(get)이 블로킹되지 않고 키별 갱신과 그 이후 조회 사이에 happens-before 관계가 성립하는 것에 대해. ↩ ↩2 ↩3
-
Oracle, The jstack Command (Java SE 21 Tools Reference). jstack이 지정한 Java 프로세스의 모든 스레드 스택 트레이스(클래스명·메서드명·행 번호)를 출력하는 것, -l 옵션으로 락에 관한 추가 정보를 포함한 상세 표시를 할 수 있는 것, jcmd 등 다른 진단 도구와 함께 쓰이는 것에 대해. ↩
-
OpenJDK, JEP 505: Structured Concurrency (Fifth Preview). Structured Concurrency API가 관련된 서브태스크 무리를 하나의 작업 단위로 다루어 오류 전파와 취소를 구조화하는 것, StructuredTaskScope를 정적 팩토리 메서드(open)로 여는 형태로 바뀐 것, JDK 25 시점에 5차 프리뷰이며 정식 기능이 아닌 것에 대해. ↩
-
OpenJDK, JEP 525: Structured Concurrency (Sixth Preview). Structured Concurrency가 JDK 26에서도 6차 프리뷰로 이어지는 것, 즉 2026년 8월 시점의 현행 JDK에서도 프리뷰 기능이며 사용에는 프리뷰 기능 활성화가 필요한 것에 대해. ↩
-
OpenJDK, JEP 506: Scoped Values. Scoped Values가 JDK 25에서 정식화된 것, 스레드 안과 스레드 사이에서 불변 컨텍스트 데이터를 안전하고 효율적으로 공유하는 장치이며, ThreadLocal의 문제점(가변성, 수명 관리, 상속 비용)에 대한 해결인 것에 대해. ↩
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
멀티스레드 실무 베스트 프랙티스 .NET 편 ── 스레드를 늘리기 전에 정해 둘 것
「스레드를 만들었더니 가끔 죽거나 멈춘다」를 막는 설계의 정석을 .NET/C# 대상으로 정리합니다. 스레드를 직접 만들지 않고 Task에 맡기기, 공유 가변 상태 줄이기, 락의 규율, CancellationToken으로 정지 설계하기, UI 스레...
멀티스레드 실무 베스트 프랙티스 C 언어 편 ── Win32 API의 방식으로 안전하게 작성하기
C 언어 × Win32의 멀티스레드는 _beginthreadex로 스레드를 만들고, SRW 락과 조건 변수, Interlocked, 정지 이벤트+WaitForMultipleObjects로 정지를 설계하는 것이 정석입니다. TerminateThre...
멀티스레드 실무 베스트 프랙티스 C++ 편 ── RAII와 jthread로 사고를 구조적으로 없애기
C++ 멀티스레드는 data race가 곧 undefined behavior가 되는 세계입니다. std::thread 소멸자의 함정, jthread와 stop_token으로 멈추는 설계, scoped_lock의 deadlock 회피, atomic...
Windows 앱의 Web화, 하지 않는 편이 나은 경우 ── 판단표와 ‘분할’이라는 현실적 해법
장치 연동·로컬 파일 처리·오프라인 운영이 있는 Windows 앱에서는 Web화가 비용 증가와 기능 저하를 부를 수 있습니다. Web화에 맞는 경우와 맞지 않는 경우의 판단표, 그리고 일부만 Web으로 내보내는 분할 구성이라는 현실적 해법을 정리...
Time Travel Debugging ── 장기 가동에서 재현되지 않는 결함을 「녹화」해서 되감기
한 달에 한 번만 나오는 결함은 크래시 덤프로는 결과밖에 찍히지 않습니다. WinDbg의 Time Travel Debugging(TTD)으로 실행을 녹화해 되감는 방법을 TTD.exe의 녹화 설계, 링 버퍼, TTD.Calls 쿼리, 덤프와의 역...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
기술 상담 & 설계 리뷰
설계 방향, 아키텍처 경계, 수명 관리, 기존 Windows 자산 처리 방법을 정리하는 데 도움을 드립니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- 가상 스레드가 있다면, 이제 스레드 풀(ExecutorService)은 필요 없나요?
- 용도에 따라 다릅니다. 가상 스레드는 I/O 대기가 주가 되는 작업을 대량으로 돌리기 위한 장치이며, 코드를 빠르게 만드는 것이 아니라 처리량을 올립니다. I/O-bound 작업에는 작업마다 가상 스레드를 하나 쓰고(Executors.newVirtualThreadPerTaskExecutor), 가상 스레드를 풀링하면 안 됩니다. 반면 CPU를 한계까지 쓰는 계산의 병렬화에는, 지금까지와 같이 코어 수 정도로 제한한 플랫폼 스레드 풀(또는 parallel stream)이 맞습니다. 외부 서비스에 대한 동시 접근 수를 줄이려면 풀로 줄이지 말고 Semaphore로 제한하는 것이 가상 스레드 시대의 권장입니다.
- synchronized와 ReentrantLock 중 어느 쪽을 써야 하나요?
- 짧고 단순한 상호 배제라면 synchronized로 충분하고 코드도 간결합니다. tryLock으로 타임아웃을 두고 얻는 경우, 공정성 정책, 여러 Condition 같은 기능이 필요할 때 ReentrantLock을 고릅니다. 가상 스레드와 쓸 때는 과거에 주의점이 있었습니다. JDK 21~23에서는 synchronized 블록 안에서 블로킹하면 가상 스레드가 OS 스레드에 pinning되는 문제가 있어, 자주 또는 오래 블로킹하는 지점은 ReentrantLock으로 바꾸는 편이 권장되었습니다. JDK 24(JEP 491)에서 모니터 구현이 다시 쓰이면서 이 제약은 없어졌습니다. JDK 24 이후라면 pinning을 이유로 바꿀 필요는 없습니다.
- volatile을 붙이면 스레드 안전해지나요?
- 그렇지 않습니다. Java의 volatile은 그 변수에 대한 쓰기와 읽기 사이에 happens-before 관계를 만들어 가시성(다른 스레드에서 최신 쓰기가 보이는 것)과 순서를 보장하지만, 읽고 계산한 뒤 다시 쓰는 복합 연산의 원자성은 보장하지 않습니다. volatile int 카운터에 여러 스레드가 ++를 하면 가산이 사라집니다. 카운터에는 AtomicInteger / AtomicLong(빈번한 집계라면 LongAdder)을, 여러 변수를 함께 지키려면 락을 쓰십시오. volatile이 맞는 경우는 단순한 상태 플래그처럼 한 스레드가 쓰고 나머지는 읽기만 하는 장면에 거의 한정됩니다.
- InterruptedException은 catch한 뒤 무시해도 되나요?
- 안 됩니다. interrupt는 Java의 표준 중단·취소 신호이며, 삼키면 멈추지 않는 스레드가 됩니다. InterruptedException이 던져진 시점에 interrupt 상태는 이미 지워져 있으므로, 스스로 처리를 끝내지 못하면 Thread.currentThread().interrupt()로 상태를 복원해 호출 쪽에 신호를 남기거나, 예외를 그대로 위로 던지십시오. catch만 하고 아무것도 하지 않는 빈 블록은 shutdown이 듣지 않거나 shutdownNow가 무시되는 문제의 전형적인 원인입니다.
- Thread.stop으로 스레드를 멈출 수는 없나요?
- 멈출 수 없습니다. Thread.stop은 본질적으로 안전하지 않습니다. 락을 잘못된 상태로 풀어 손상된 객체가 다른 스레드에 보이기 때문에 오래 비권장이었고, 지금의 Java에서는 호출하면 UnsupportedOperationException이 던져집니다. Thread.suspend / resume도 같습니다. 스레드를 멈추는 정당한 수단은 interrupt에 의한 협력적 중단뿐입니다. ExecutorService를 쓰는 경우 shutdown은 신규 접수를 멈추고 이미 넣은 작업이 끝나기만 기다릴 뿐, 실행 중인 작업에 interrupt를 보내지 않습니다. 실행 중인 작업의 중단을 시도하는 것은 shutdownNow이며, 이는 best-effort입니다(표준 구현에서는 interrupt를 통합니다).