스레드를 관리하고 종료하는 것은 다중 스레드 프로그래밍에서 매우 중요한 부분입니다. 과거 자바의 Thread 클래스에는 스레드를 멈추거나 일시 중지하는 메서드인 stop()과 suspend()가 제공되었지만, 이들은 현재 모두 Deprecated(사용 중지 권고) 상태입니다. 이 메서드들이 왜 권장되지 않으며, 스레드를 안전하게 종료하기 위한 올바른 방법은 무엇인지 살펴보겠습니다.
Deprecated 메서드의 문제점
stop()과 suspend() 메서드가 사용 중지 권고된 주된 이유는 애플리케이션의 안정성과 데이터 무결성을 심각하게 해칠 수 있기 때문입니다.
1. stop() 메서드의 위험성
stop() 메서드는 호출되는 즉시 스레드의 실행을 강제로 중단시킵니다. 이 과정에서 스레드가 현재 어떤 작업을 수행하고 있는지, 락(lock)을 획득했는지 여부와 관계없이 갑작스럽게 종료됩니다. 이는 다음과 같은 심각한 문제를 야기할 수 있습니다.
- 데이터 불일치: 스레드가 복잡한 연산 도중에 중단되면, 해당 연산이 원자적으로(atomic) 완료되지 못하여 공유 데이터가 예측 불가능한 상태에 빠질 수 있습니다.
- 리소스 해제 실패: 스레드가 파일 핸들, 데이터베이스 연결, 네트워크 소켓 등 중요한 시스템 리소스를 획득한 상태에서 중단되면, 해당 리소스가 제대로 해제되지 않아 리소스 누수(leak)로 이어질 수 있습니다.
- 교착 상태(Deadlock): 스레드가 획득한 락을 해제하지 않고 강제로 종료되면, 이 락을 기다리던 다른 스레드들이 영원히 대기하는 교착 상태에 빠질 수 있습니다.
다음 예제는 stop() 메서드가 데이터 불일치를 어떻게 유발하는지 보여줍니다. 두 개의 변수 balanceA와 balanceB의 합은 항상 10이어야 합니다. 스레드는 이 두 변수 간의 값을 이동시키며 합이 유지되도록 합니다.
public class UnsafeThreadTermination {
private int balanceA = 10;
private int balanceB = 0;
private final Object balanceLock = new Object();
public void demonstrateStopIssue() {
Runnable transferTask = () -> {
// balanceA + balanceB = 10 이 항상 성립해야 함
while (true) {
synchronized (balanceLock) { // 원자적 작업을 위해 락 사용
balanceA--;
try {
Thread.sleep(200); // 작업 모방
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 인터럽트 상태 복원
break;
}
balanceB++;
}
}
};
Thread workerThread = new Thread(transferTask, "Worker-Stop");
workerThread.start();
try {
Thread.sleep(1300); // 워커 스레드가 어느 정도 실행되도록 대기
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
workerThread.stop(); // 스레드를 강제로 종료
System.out.println("balanceA: " + balanceA);
System.out.println("balanceB: " + balanceB);
System.out.println("Total: " + (balanceA + balanceB));
}
public static void main(String[] args) {
new UnsafeThreadTermination().demonstrateStopIssue();
}
}
예상되는 출력은 다음과 같이 합이 10이 아닌 상태를 보여줄 수 있습니다.
balanceA: 3
balanceB: 6
Total: 9
결과에서 보듯이, balanceA와 balanceB의 합이 10이 아닙니다. stop() 메서드가 synchronized 블록 내부에서 balanceB++가 실행되기 전에 스레드를 강제로 중단시켰기 때문입니다. 이처럼 stop()은 작업의 원자성을 보장하지 못하며 데이터 불일치를 초래합니다.
2. suspend() 메서드의 위험성
suspend() 메서드는 스레드를 일시 중지시키지만, stop()과는 달리 스레드를 죽이지 않습니다. 문제는 suspend()가 호출될 때 스레드가 획득한 락을 해제하지 않는다는 것입니다.
스레드가 락을 가진 상태에서 일시 중지되면, 해당 락을 필요로 하는 다른 스레드들은 영원히 대기하게 됩니다. 이는 교착 상태를 유발하며 전체 시스템의 응답성을 마비시킬 수 있습니다. suspend()는 반드시 resume() 메서드와 짝을 이루어 사용되어야 하지만, 락 문제로 인해 안전하게 사용하기 어렵습니다.
다음 코드는 suspend()가 교착 상태를 어떻게 유발할 수 있는지 보여줍니다. 두 스레드가 동일한 락을 사용하여 작업을 수행합니다.
public class UnsafeThreadControl {
private int sharedCount = 10;
private int processedCount = 0;
private final Object resourceLock = new Object();
public void demonstrateSuspendIssue() {
Runnable operationTask = () -> {
for (int i = 0; i < 5; i++) {
synchronized (resourceLock) {
sharedCount--;
System.out.println(Thread.currentThread().getName() + " - sharedCount: " + sharedCount);
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
processedCount++;
}
}
};
Thread threadAlpha = new Thread(operationTask, "Thread-Alpha");
Thread threadBeta = new Thread(operationTask, "Thread-Beta");
threadAlpha.start();
threadBeta.start();
try {
Thread.sleep(500); // Thread-Alpha가 락을 잡고 실행되도록 대기
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
threadAlpha.suspend(); // Thread-Alpha를 일시 중지 (락을 해제하지 않음)
while (true) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Shared: " + sharedCount + ", Processed: " + processedCount +
" | Alpha active: " + threadAlpha.isAlive() +
", Beta active: " + threadBeta.isAlive());
}
}
public static void main(String[] args) {
new UnsafeThreadControl().demonstrateSuspendIssue();
}
}
출력 결과에서 Thread-Alpha가 일시 중지된 후에도 Thread-Beta가 더 이상 진행되지 못하고 대기하는 것을 볼 수 있습니다. Thread-Alpha가 resourceLock을 계속 소유하고 있기 때문입니다.
Thread-Alpha - sharedCount: 9
Thread-Alpha - sharedCount: 8
Thread-Alpha - sharedCount: 7
Shared: 7, Processed: 2 | Alpha active: true, Beta active: true
Shared: 7, Processed: 2 | Alpha active: true, Beta active: true
... (반복)
만약 suspend()와 resume()을 함께 사용하면 스레드가 다시 실행될 수는 있습니다.
// ... 이전 코드 생략 ...
threadAlpha.suspend();
int resumeCounter = 0;
while (true) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
resumeCounter++;
if (resumeCounter == 3) {
threadAlpha.resume(); // 3초 후 Thread-Alpha 재개
}
System.out.println("Shared: " + sharedCount + ", Processed: " + processedCount +
" | Alpha active: " + threadAlpha.isAlive() +
", Beta active: " + threadBeta.isAlive());
}
// ...
이 경우 Thread-Alpha가 재개되고 락을 해제한 후 Thread-Beta가 이어서 작업을 수행할 것입니다. 하지만 이처럼 락과 관련된 복잡한 상호작용이 있을 때 suspend()와 resume()을 안전하게 사용하는 것은 매우 어렵습니다.
interrupt() 메서드의 역할
Thread.interrupt() 메서드는 스레드를 강제로 종료하는 메서드가 아닙니다. 대신, 스레드에게 "중단 요청" 신호를 보냅니다.
- 블로킹 상태의 스레드:
sleep(),wait(),join()또는 일부 I/O 작업과 같이 블로킹 상태에 있는 스레드에interrupt()를 호출하면InterruptedException이 발생하며 블로킹 상태에서 벗어나게 됩니다. 개발자는 이 예외를 catch하여 스레드를 종료하거나 다른 로직을 수행할 수 있습니다. - 비-블로킹 상태의 스레드: 스레드가 블로킹 상태가 아닌 경우,
interrupt()를 호출해도 즉시 스레드의 실행에 영향을 미치지 않습니다. 대신 스레드의 "인터럽트 상태" 플래그를true로 설정합니다. 스레드는Thread.interrupted()또는isInterrupted()메서드를 통해 이 플래그를 주기적으로 확인하여 스스로 종료할지 여부를 결정할 수 있습니다.
따라서 interrupt()는 스레드에게 "이제 멈추거나 다른 작업을 해야 할 시간이다"라고 알리는 메커니즘이며, 스레드 스스로가 이 신호를 인지하고 적절히 대응하도록 하는 방식입니다.
스레드를 안전하게 종료하는 방법
스레드를 안전하게 종료하는 가장 중요한 원칙은 스레드 스스로가 종료 시점을 결정하도록 하는 것입니다. 이를 위한 두 가지 주요 방법이 있습니다.
1. 태스크 완료 후 자연스럽게 종료
스레드가 할당된 작업을 모두 마쳤을 때, run() 메서드가 자연스럽게 종료되도록 설계하는 것입니다. 이는 스레드가 유한한 작업을 수행할 때 가장 간단하고 안전한 방법입니다.
public class NaturalTermination {
public void executeTask() {
Thread worker = new Thread(() -> {
for (int i = 0; i < 5; i++) { // 5번 반복 후 run() 메서드 종료
System.out.println(Thread.currentThread().getName() + " - Step " + (i + 1));
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println(Thread.currentThread().getName() + " interrupted.");
return; // 인터럽트 시 종료
}
}
System.out.println(Thread.currentThread().getName() + " finished naturally.");
}, "Worker-Natural");
worker.start();
// 메인 스레드는 워커 스레드가 끝날 때까지 기다릴 수 있음
try {
worker.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Main thread finished.");
}
public static void main(String[] args) {
new NaturalTermination().executeTask();
}
}
2. 종료 플래그(Termination Flag) 사용
스레드가 무한 루프나 장시간 실행되는 작업을 수행할 때, 외부에서 스레드에게 종료를 요청할 수 있도록 volatile 플래그를 사용하는 것이 일반적입니다. 스레드는 루프의 각 반복마다 이 플래그의 상태를 확인하고, 플래그가 종료를 나타내면 run() 메서드에서 return 하여 스스로 종료합니다.
volatile 키워드는 플래그 변수의 변경 사항이 모든 스레드에 즉시 가시화되도록 보장하여, 캐시 불일치로 인해 스레드가 종료 요청을 인지하지 못하는 문제를 방지합니다.
public class FlaggedTermination {
private volatile boolean shutdownRequested = false; // volatile 키워드 사용
public void startMonitoredThread() {
Thread processingThread = new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " started.");
while (!shutdownRequested) { // 종료 플래그를 주기적으로 확인
// 실제 작업 수행
System.out.println(Thread.currentThread().getName() + " is processing...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println(Thread.currentThread().getName() + " interrupted, preparing to shut down.");
// 인터럽트 발생 시에도 플래그를 통해 종료 로직 수행 가능
// shutdownRequested = true; // 필요에 따라 플래그 설정
break; // 루프 탈출
}
}
// 스레드 종료 전 마무리 작업 (예: 리소스 해제, 데이터 저장)
System.out.println(Thread.currentThread().getName() + " performing cleanup...");
System.out.println(Thread.currentThread().getName() + " gracefully shut down.");
}, "Processing-Thread");
processingThread.start();
// 메인 스레드에서 일정 시간 후 종료 요청
try {
Thread.sleep(5000); // 5초간 작업 수행 후 종료 요청
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Main thread requesting shutdown for Processing-Thread.");
shutdownRequested = true; // 종료 플래그 설정
try {
processingThread.join(); // 워커 스레드가 종료될 때까지 대기
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Main thread finished.");
}
public static void main(String[] args) {
new FlaggedTermination().startMonitoredThread();
}
}
종료 플래그 방식은 스레드가 루프의 다음 반복으로 진입하기 전에 종료 상태를 확인할 수 있으므로, 현재 진행 중인 원자적 작업을 안전하게 완료하고 락을 적절히 해제한 후 종료할 수 있도록 합니다.
ExecutorService.shutdown()의 의미
ExecutorService의 shutdown() 메서드는 스레드 풀에 새로운 태스크 제출을 중단하도록 지시하지만, 이미 제출된 태스크나 실행 중인 태스크를 즉시 중지시키지는 않습니다. 대신 현재 실행 중인 태스크들이 완료될 때까지 기다리고, 대기 중인 태스크를 처리한 후 스레드 풀을 종료합니다. 이는 스레드 풀 관리와 관련된 것이며, 개별 스레드를 강제로 중지하는 방법과는 다릅니다. 즉시 강제 종료가 필요하다면 shutdownNow()를 사용해야 하지만, 이는 stop()과 유사하게 데이터 일관성 문제를 야기할 수 있으므로 주의해야 합니다.