“업무 시스템의 배치를 Java로 병렬화하고 싶다”, “Spring으로 만든 웹 애플리케이션에서 공유 캐시가 가끔 깨진다”, “new Thread로 가득한 오래된 Swing 애플리케이션을 이어받았다” ── Java는 JDK 1.0부터 멀티스레드를 언어에 내장하고 있고, java.util.concurrent라는 성숙한 도구 상자를 갖추었으며, 나아가 JDK 21의 가상 스레드(virtual thread)로 병행 처리의 상식을 한 단계 다시 썼습니다. 도구가 풍부한 만큼 “무엇을 선택할 것인가”의 판단이 그대로 설계 품질로 이어집니다.
이 글은 멀티스레드 실무 시리즈의 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 바운드의 병렬화는 종전과 마찬가지로 코어 수 정도의 플랫폼 스레드(고정 풀이나 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는 최선의 노력(표준 구현에서는 인터럽트를 통해)이므로, 작업 쪽이 인터럽트에 응답하는 것이 전제입니다.1- Swing의 UI는 EDT(이벤트 디스패치 스레드)의 전유물입니다. 다른 스레드에서는
SwingUtilities.invokeLater로 의뢰합니다.7
2. 왜 멀티스레드는 어려운가 ── 경쟁 상태·데드락·메모리 모델
멀티스레드가 가져오는 문제는 언어를 불문하고 파고들면 두 가지입니다.
경쟁 상태(race condition)는 여러 스레드가 특정 코드에 어떤 순서로 도달하는지에 따라 결과가 달라지는 버그입니다. 대표적인 예가 공유 카운터로, count++라는 하나의 식은 실제로는 “읽기 → 덧셈 → 다시 쓰기”의 3단계로 나뉩니다. 두 스레드가 이 3단계에 동시에 들어가면 한쪽의 덧셈이 다른 쪽의 다시 쓰기에 덮어써져 사라집니다. 실행할 때마다 결과가 달라지고, 어떤 결과가 나올지는 예측할 수 없습니다.
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++의 3단계 사이에 다른 스레드가 끼어들면 나중에 다시 쓴 쪽이 덮어쓴다
데드락은 두 스레드가 서로 상대방이 가진 락을 기다리며 서로 앞으로 나아가지 못하는 상태입니다. 스레드 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 바운드는 가상 스레드, CPU 바운드는 종래형 풀로 분담한다
CPU 바운드 쪽에는 한 가지 주의할 점이 있습니다. Executors.newFixedThreadPool은 스레드 수는 제한하지만, 대기 큐는 무제한입니다. 투입이 처리를 계속 웃도는 상주 서비스에서는 코어 수로 제한한 것이 스레드뿐이어서, 큐에 쌓인 작업과 그 데이터가 계속 메모리를 잡아먹습니다. 이런 구성에서는 ThreadPoolExecutor를 직접 사용해 용량이 있는 큐 + 거부 정책을 구성하거나, 투입 쪽에 Semaphore 등의 입장 제한을 두어 배압(back pressure)을 걸 수 있는 형태로 만드십시오(4장의 큐 이야기와 같은 원칙입니다).
3.2. 가상 스레드의 사용법을 틀리지 않는다
가상 스레드는 OS 스레드에서 분리된 경량 스레드로, JDK의 블로킹 연산(표준 라이브러리의 I/O·락·sleep 등) 동안 OS 스레드를 내놓기 때문에 하나의 JVM에서 수백만 개를 실행할 수 있습니다. 다만 “어떤 블로킹에서든 내놓을 수 있다”는 것은 아닙니다. 네이티브 코드(JNI)나 foreign function 실행 중에 블로킹한 경우, 가상 스레드는 캐리어 스레드에 고정된 채로 남습니다. JDK 24(뒤에서 다룰 JEP 491)가 해소한 것은 synchronized에 의한 고정이지, 네이티브 경계에서의 고정은 남아 있기 때문에 JNI를 경유하는 드라이버나 디바이스 API에서 오래 블로킹하는 처리를 대량의 가상 스레드에 태우면 캐리어 스레드가 고갈됩니다. 다만 공식 가이드가 강조하듯 “빠른 스레드”는 아닙니다. 코드의 실행 속도는 변하지 않으며, 제공하는 것은 스케일(처리량)입니다.3
사용법의 규율은 세 가지입니다.3
- 풀링하지 않는다. 가상 스레드는 저렴한 일회용이며, “작업의 수 = 가상 스레드의 수”가 올바른 상태입니다.
newFixedThreadPool에 가상 스레드를 넣는 것은 잘못이고,try (var executor = Executors.newVirtualThreadPerTaskExecutor())형태로 사용합니다. - 동시 실행 수의 제한은
Semaphore로. “외부 API에 동시 10개 접속까지”와 같은 제한을, 풀의 크기가 아니라 세마포어로 표현합니다. - CPU 바운드 작업에는 쓰지 않는다. 계산의 병렬화는 종전과 마찬가지로 코어 수 정도의 플랫폼 스레드가 적합합니다.
또한 가상 스레드 안에서 동작하는 것은 평범한 동기 코드입니다. .NET의 async/await처럼 코드를 다시 작성하는 것이 아니라, “요청 하나에 스레드 하나”라는 순진한 코드를 그대로 대량으로 실행할 수 있게 하는 것이 가상 스레드의 설계 사상입니다.2
3.3. 오해하지 말아야 할 것 ── “풀 불필요”는 가상 스레드만의 이야기
“풀링하지 않는다”는 규율을 “Java에는 스레드 풀이라는 구조가 없다(또는 비효율적이다)”로 읽지 마십시오. 실제로는 정반대로, Java의 풀은 JDK 5(2004년)부터 표준 라이브러리에 있는 성숙한 도구입니다. 세밀하게 구성할 수 있는 범용 풀인 ThreadPoolExecutor(Executors의 각 팩토리로 만드는 것), 워크 스틸링(work-stealing) 방식의 ForkJoinPool(그 공통 인스턴스인 commonPool()은 parallel stream이나 CompletableFuture의 기본 실행처), 주기 실행용 ScheduledThreadPoolExecutor ── CPU 바운드 작업에서는 지금도 이들이 주역입니다.
풀이란 본래 “OS 스레드의 생성·유지가 비싸기 때문에 재사용한다“는 최적화입니다. 가상 스레드는 생성 비용을 거의 0으로 만들어 이 전제를 없앴기 때문에 재사용할 이유가 사라진 것입니다 ── 풀이 비효율적인 것이 아니라, 풀이라는 최적화가 필요 없을 만큼 가벼워졌다는 것이 정확한 이해입니다. 게다가 가상 스레드의 발밑에서는 JDK의 스케줄러가 워크 스틸링 방식의 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이 블로킹되어 자연스러운 배압이 되며, .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 스레드에 고정된다(OS 스레드를 내놓지 못해 스케일의 이점을 잃는다)는 제약이 있어서, 빈번하거나 장시간의 블로킹이 발생하는 부분은 ReentrantLock으로 교체하는 것이 권장되었습니다.3 이 제약은 JDK 24의 JEP 491에서 모니터 구현이 다시 작성되면서 해소되어, synchronized가 더 이상 가상 스레드를 고정시키지 않게 되었습니다.4 JDK 24 이후를 사용하고 있다면, 고정 대책으로서의 기계적인 교체는 필요하지 않습니다. 사내의 오래된 가이드라인이 JDK 21 시점의 주의 사항 그대로 남아 있지 않은지 확인할 가치가 있습니다.
5.3. Atomic 계열과 volatile의 위치
단일 변수의 원자적 갱신은 AtomicInteger / AtomicLong / AtomicReference(고빈도로 더하기만 하는 통계값이라면 경쟁에 강한 LongAdder)가 담당합니다. volatile은 가시성과 순서(happens-before)의 보장이지, 복합 연산의 원자성을 주지 않습니다.5 .NET 편·C++ 편과 같은 결론이 Java에도 성립합니다: 플래그와 단일 값은 Atomic 계열, 복합 상태는 락, volatile 단독으로 버티지 않는다.
6. 멈추는 방법의 설계 ── 인터럽트라는 공통 언어
6.1. interrupt의 작법
Java의 정지·취소는 인터럽트(interruption)로 통일되어 있습니다. t.interrupt()는 대상 스레드의 인터럽트 상태를 세우고, 대상이 sleep / wait / join 등으로 블로킹되어 있으면 InterruptedException을 던져 즉시 깨웁니다(이때 인터럽트 상태는 지워집니다).6 과거의 강제 수단이었던 Thread.stop / suspend / resume은 본질적으로 안전하지 않기 때문에, 현재는 호출하면 UnsupportedOperationException이 됩니다.6
flowchart TB
OWNER["멈추는 쪽: t.interrupt 호출"] --> ST["인터럽트 상태가 세워짐"]
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: 인터럽트에 의한 협조적 정지. InterruptedException을 묵살하면 정지 신호가 소멸한다 ── catch했다면 “끝난다”와 “복원한다” 중 하나를 택해야 한다
실무의 규율은 하나만 기억하면 충분합니다. InterruptedException을 catch해서 아무것도 하지 않는 코드를 쓰지 않는다. 자신의 책임 범위 안에서 종료할 수 있다면 거기서 끝내고, 그럴 수 없다면 Thread.currentThread().interrupt()로 상태를 복원해 호출자에게 신호를 전달합니다(FAQ 참조).
6.2. ExecutorService의 2단계 셧다운
ExecutorService의 정지 API는 인터럽트 모델 위에 올라가 있습니다. shutdown()은 새 접수를 멈추고 이미 투입된 작업을 완주시키며, shutdownNow()는 실행 중인 작업의 정지를 시도합니다. 인터페이스 사양상 이는 최선의 노력(best effort)이며, 표준 구현(ThreadPoolExecutor 등)은 전형적으로 Thread.interrupt()로 취소한다고 명시되어 있습니다 ── 즉 인터럽트에 응답하지 않는 작업은 shutdownNow로도 멈추지 않으며, 독자적으로 구현한 Executor를 사용하는 경우에는 그 취소 방법(인터럽트를 보내는지 여부)을 구현 문서에서 확인할 필요가 있습니다.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(); // 자신의 인터럽트 상태도 복원한다
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/>인터럽트에 응답하지 않는 작업이 용의자"]
그림 5: ExecutorService의 2단계 셧다운. “차분히 기다린다 → 인터럽트로 요청한다 → 그래도 끝나지 않으면 이상으로 관측한다”는 단계별 설계
또한 shutdownNow()가 반환하는 Runnable의 취소에는 한 가지 한계가 있습니다. 반환되는 것은 실행 큐에 있던 객체로, 단순한 submit이라면 그것이 이용자에게 넘긴 FutureTask 자신이지만, ExecutorCompletionService 등의 래퍼를 통해 투입한 작업에서는 큐 안의 래퍼일 뿐 이용자의 Future와는 별개입니다. 그런 구성에서는 위의 취소가 이용자 쪽의 Future를 완료시키지 못하므로, 투입 시에 직접 Future의 목록을 보관해 두고 셧다운 시에 그쪽을 취소하는(또는 내려진 작업을 소유자에게 돌려주는) 설계로 하십시오.
JDK 19 이후의 close()(AutoCloseable)는 “shutdown하고 완료까지 기다린다”를 try-with-resources로 쓸 수 있게 한 것으로, 가상 스레드의 newVirtualThreadPerTaskExecutor와 조합한 try (var executor = ...)가 현대의 기본형입니다.1 다만 close()는 위의 2단계 패턴을 대체하는 것이 아닙니다. 타임아웃 없이 완료를 기다리기 때문에, 인터럽트에 응답하지 않는 작업이나 끝나지 않는 작업이 하나라도 있으면 닫으려던 스레드가 영원히 블로킹됩니다. 스코프 내의 작업이 유한하고 완주가 보장되는 상황(그 자리에서 던지고 그 자리에서 기다리는 사용법)에 적합한 도구이며, 애플리케이션의 셧다운 경로처럼 “반드시 유한한 시간 안에 끝내고 싶은” 곳에는 기한이 있는 2단계 패턴을 사용하십시오. 개별 작업의 취소는 Future.cancel(true)가 마찬가지로 인터럽트를 통해 수행합니다.
7. UI 스레드 ── Swing의 EDT
데스크톱 애플리케이션에는 언어나 프레임워크를 불문하고 “UI는 그것을 관리하는 스레드의 전유물”이라는 규칙이 있습니다. Swing에서는 그 전유 스레드가 이벤트 디스패치 스레드(EDT)이며, Swing 컴포넌트의 메서드는 원칙적으로 스레드 세이프하지 않아서 여러 스레드에서 건드리면 스레드 간섭이나 메모리 일관성 오류를 초래합니다. 다른 스레드에서의 화면 갱신은 SwingUtilities.invokeLater로 EDT에 의뢰하고, 반대로 EDT 위에서 오래 걸리는 처리를 하면 UI가 멈추므로 무거운 작업은 SwingWorker 등으로 워커 스레드에 내보냅니다.7 JavaFX에서도 구도는 같아서, UI 갱신은 Platform.runLater로 애플리케이션 스레드에 의뢰합니다.
8. 검증과 디버깅 ── 스레드 덤프라는 무기
경쟁 버그는 테스트에서 발견되기를 기대할 수 없습니다. 통상적인 테스트는 “우연히 경쟁이 일어나지 않은” 실행을 성공으로 세기 때문입니다. 대비는 3단계로 생각합니다.
첫 번째 방어선은 설계입니다. 리뷰에서는 “공유하고 있는 가변 데이터는 무엇인가”, “각각을 어느 락이 지키는가(5.1의 대응표)”, “락의 획득 순서는 유일한가”, “InterruptedException을 묵살하는 catch는 없는가”, “정지 경로(shutdown/인터럽트)가 모든 작업에 도달하는가”를 표로 확인합니다.
두 번째로, 스레드 덤프를 능숙하게 다룹니다. 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(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 바운드와 CPU 바운드로 실행 수단을 나누고 있는가(그림 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++ 편 ── RAII와 jthread로 사고를 구조적으로 없애기
- 멀티스레드 실무 베스트 프랙티스 C 언어 편 ── Win32 API의 방식으로 안전하게 작성하기
- C# async/await의 베스트 프랙티스 - Task.Run과 ConfigureAwait의 판단표
관련 상담 영역
합동회사 코무라소프트에서는 Java로 만든 업무 시스템·배치 처리의 멀티스레드 설계 리뷰, 공유 상태의 손상이나 “가끔 멈추지 않는다·행(hang)이 걸린다”와 같은 병행 처리에 기인한 불량 조사(스레드 덤프 분석), 가상 스레드 도입에 관한 기술 상담을 다루고 있습니다.
참고 링크
-
Oracle, ExecutorService (Java SE 21 & JDK 21 API). shutdown()이 이미 투입된 작업을 완주시키면서 새 접수를 멈춘다는 것, shutdownNow()가 실행 중인 작업의 정지를 시도하고 대기 중인 작업의 목록을 반환하지만, 전형적인 구현은 Thread.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 모드로 동작하는 워크 스틸링 방식의 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 스레드로의 고정을 일으키기 때문에 빈번하거나 장시간인 경우 ReentrantLock으로의 교체가 안내되었다는 것, -Djdk.tracePinnedThreads로 고정을 검출할 수 있다는 것에 대해. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
OpenJDK, JEP 491: Synchronize Virtual Threads without Pinning. JDK 24에서 JVM의 모니터 구현이 가상 스레드 대응으로 다시 작성되어, synchronized 블록・메서드 안에서 블로킹해도 가상 스레드가 캐리어 스레드에 고정되지 않게 되었다는 것, 이로써 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()가 인터럽트 상태를 세우고 sleep / wait / join으로 블로킹 중인 스레드에는 InterruptedException을 던져 깨운다는 것(이때 인터럽트 상태는 지워짐), 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). 구조화된 병행성 API가 관련된 서브 작업들을 하나의 작업 단위로 취급하여 오류 전파와 취소를 구조화한다는 것, StructuredTaskScope가 정적 팩토리 메서드(open)로 여는 형태로 바뀌었다는 것, JDK 25 시점에 5차 프리뷰이며 정식 기능이 아니라는 것에 대해. ↩
-
OpenJDK, JEP 525: Structured Concurrency (Sixth Preview). 구조화된 병행성이 JDK 26에서도 6차 프리뷰로 계속되고 있다는 것, 즉 2026년 8월 시점의 현행 JDK에서도 여전히 프리뷰 기능이며 사용하려면 프리뷰 기능의 활성화가 필요하다는 것에 대해. ↩
-
OpenJDK, JEP 506: Scoped Values. Scoped Values가 JDK 25에서 정식화되었다는 것, 스레드 내 및 스레드 간에 불변 컨텍스트 데이터를 안전하고 효율적으로 공유하는 구조이며, ThreadLocal의 문제점(가변성, 수명 관리, 상속 비용)에 대한 해결책이라는 것에 대해. ↩
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
멀티스레드 실무 베스트 프랙티스 C++ 편 ── RAII와 jthread로 사고를 구조적으로 없애기
C++의 멀티스레드는 데이터 경쟁이 미정의 동작이 되는 세계입니다. std::thread 소멸자의 함정, jthread와 stop_token에 의한 정지 설계, scoped_lock의 데드락 회피, atomic의 올바른 위치, Win32 동기화 ...
멀티스레드 실무 베스트 프랙티스 C 언어 편 ── Win32 API의 방식으로 안전하게 작성하기
C 언어 × Win32의 멀티스레드는 _beginthreadex를 이용한 스레드 생성, SRW 잠금과 조건 변수, Interlocked, 정지 이벤트+WaitForMultipleObjects의 정지 설계가 정석입니다. TerminateThread...
멀티스레드 실무 베스트 프랙티스 .NET 편 ── 스레드를 늘리기 전에 정해둘 것
「스레드를 만들면 가끔 죽거나 멈춘다」를 막는 설계의 정석을 .NET/C# 대상으로 정리합니다. 스레드를 직접 만들지 않고 Task에 올라타기, 공유 가변 상태 줄이기, 락의 규율, CancellationToken을 통한 정지 설계, UI 스레드...
Windows 앱의 웹 전환, 하지 않는 편이 나은 경우 ── 판단표와 '분할'이라는 현실적 해법
'사내 Windows 앱을 웹으로 만들고 싶다'는 요청이 늘고 있지만, 장치 연동·로컬 파일 처리·오프라인 운용·고속 입력 UI를 갖춘 앱에서는 웹 전환이 오히려 비용 증가와 기능 저하를 부를 수 있습니다. 웹 전환에 적합한 경우와 부적합한 경우...
Windows I/O의 심층(제1회) ── 모든 읽기·쓰기는 IRP가 된다: I/O 시스템의 전체상
Windows의 I/O 시스템을 밑바닥부터 해설하는 연재의 제1회입니다. 오브젝트 관리자의 이름공간, 드라이버·디바이스·파일이라는 3가지 오브젝트, IRP의 라이프사이클, CloseHandle의 이면까지를 그림으로 정리합니다.
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
기술 상담 & 설계 리뷰
설계 방향, 아키텍처 경계, 수명 관리, 기존 Windows 자산 처리 방법을 정리하는 데 도움을 드립니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- 가상 스레드가 있다면 이제 스레드 풀(ExecutorService)은 필요 없나요?
- 용도에 따라 다릅니다. 가상 스레드는 I/O 대기가 주를 이루는 작업을 대량으로 실행하기 위한 구조로, 코드를 빠르게 만드는 것이 아니라 처리량(스루풋)을 높이는 것입니다. I/O 바운드 작업에는 작업 하나당 가상 스레드 하나를 사용하며(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 이후라면 고정을 이유로 한 교체는 필요하지 않습니다.
- volatile을 붙이면 스레드 세이프해지나요?
- 그렇지 않습니다. Java의 volatile은 해당 변수에 대한 쓰기와 읽기 사이에 happens-before 관계를 만들어 가시성(다른 스레드에서 최신 쓰기가 보이는 것)과 순서를 보장하지만, '읽고, 계산하고, 다시 쓰는' 복합 연산의 원자성은 보장하지 않습니다. volatile int 카운터에 여러 스레드에서 ++ 연산을 하면 덧셈이 유실됩니다. 카운터에는 AtomicInteger / AtomicLong(빈번한 집계라면 LongAdder)을, 여러 변수를 함께 지켜야 한다면 락을 사용하십시오. volatile이 적절한 경우는 단순한 상태 플래그처럼 '하나의 스레드가 쓰고 다른 스레드는 읽기만 하는' 상황에 거의 국한됩니다.
- InterruptedException은 catch해서 무시해도 되나요?
- 안 됩니다. 인터럽트는 Java의 표준적인 정지·취소 신호이며, 이를 묵살하면 '멈추지 않는 스레드'를 만들게 됩니다. InterruptedException이 던져진 시점에 인터럽트 상태는 이미 지워져 있으므로, 스스로 처리를 끝낼 수 없다면 Thread.currentThread().interrupt()로 상태를 복원하여 호출자에게 신호를 남기거나, 예외를 그대로 위로 던지십시오. catch해서 아무것도 하지 않는 빈 블록은 셧다운이 먹히지 않는다거나 shutdownNow가 무시된다거나 하는 문제의 전형적인 원인입니다.
- Thread.stop으로 스레드를 멈출 수는 없나요?
- 멈출 수 없습니다. Thread.stop은 본질적으로 안전하지 않아서(락을 부정한 상태로 해제하고, 손상된 객체가 다른 스레드에 보이게 됨) 오랫동안 비권장되어 왔으며, 현재 Java에서는 호출하면 UnsupportedOperationException이 던져집니다. Thread.suspend / resume도 마찬가지입니다. 스레드를 멈추는 정당한 수단은 인터럽트(interrupt)에 의한 협조적 정지뿐입니다. ExecutorService를 사용하는 경우, shutdown은 새 작업 접수를 멈추고 완주를 기다릴 뿐 실행 중인 작업에 인터럽트를 보내지 않습니다. 실행 중인 작업의 정지를 시도하는 것은 shutdownNow이며, 이는 최선의 노력(표준 구현에서는 인터럽트를 통해)입니다.