1. UncaughtExceptionHandler
- 자바 쓰레드의 run() 메서드는 예외를 던질 수 없기 때문에 예외가 발생할 경우 run() 메서드 내에서만 예외를 처리해야 함
- RuntimeException이 발생하더라도 쓰레드 밖에서 예외를 잡고 처리하는 방법 없음
- 자바에서는 쓰레드가 비정상적으로 종료되었거나 특정한 예외를 쓰레드 외부에서 처리할 수 있도록 UncaughtExceptionHandler 인터페이스 제공
- 해당 인터페이스를 통해 어떤 원인으로 인해 쓰레드가 종료되었는지 대상 쓰레드와 예외를 파악 가능
public class ExceptionHandlerExample { | |
private static final Logger LOGGER = Logger.getLogger(ExceptionHandlerExample.class.getName()); | |
public static void main(String[] args) { | |
// 모든 쓰레드의 예외에 대한 기본 핸들러 설정 | |
Thread.setDefaultUncaughtExceptionHandler( | |
(t, e) -> System.out.println(String.format("%s에서 예외 발생: %s", t.getName(), e))); | |
// 예외를 발생시키는 여러 쓰레드 | |
Thread thread1 = new Thread(() -> { | |
throw new RuntimeException("쓰레드 1 예외!"); | |
}); | |
Thread thread2 = new Thread(() -> { | |
throw new RuntimeException("쓰레드 2 예외!"); | |
}); | |
thread2.setUncaughtExceptionHandler((t, e) -> { | |
LOGGER.log(Level.SEVERE, String.format("%s에서 예외가 발생했습니다.", t.getName()), e); | |
sendNotificationToSlack(e); | |
}); | |
thread1.start(); | |
thread2.start(); | |
} | |
private static void sendNotificationToSlack(Throwable e) { | |
System.out.println(String.format("Slack Notification: %s", e.getMessage())); | |
} | |
} |

코드 부연 설명
- 정적 메서드인 setDefaultUncaughtExceptionHandler() 메서드를 통해 모든 쓰레드에서 발생하는 uncaughtException에 대해 디폴트로 처리하는 인터페이스 설정 가능
- setUncaughtExceptionHandler() 메서드를 통해 특정 쓰레드에서 발생하는 uncaughtException을 처리하는 인터페이스 설정 가능
- setUncaughtExceptionHandler() 메서드가 setDefaultUncaughtExceptionHandler()보다 우선순위가 높음
- 쓰레드 2에서 예외가 발생헀을 때 sendNotificationToSlack() 메서드가 호출된 이유
2. 쓰레드를 중지하는 방법
- 자바 1 버전까지는 실행 중인 쓰레드를 임의로 중지하거나 종료할 수 있는 suspend(), stop() API 제공
- 하지만 해당 API들을 호출할 경우 쓰레드 종료 후 처리해야 하는 후처리 작업을 수행할 수 없기 때문에 두 API 모두 자바 1.2부터 Deprecated 처리

- suspend(), stop() API를 호출하는 대신 flag 변수 혹은 interrupt() 메서드를 활용해 쓰레드를 중지할 수 있음
2.1 flag 변수를 통해 쓰레드를 중지하는 방법
- flag 변수의 값이 어떤 조건에 만족할 경우 쓰레드의 실행을 중지하는 방식
- 동시성 문제를 고려하여 flag 변수를 atomic 변수 혹은 volatile 키워드를 사용하는 것을 권장
2.1.1 정상적으로 중지하는 코드
public class FlagThreadStopExample { | |
// volatile 키워드 추가 | |
volatile boolean running = true; | |
public void flagVariableThreadStopTest() { | |
new Thread(() -> { | |
int count = 0; | |
while (running) { // volatile 키워드 없으면 메모리 값에 올라온 running 참고 | |
count++; | |
} | |
System.out.println("Thread 1 종료. Count: " + count); | |
}).start(); | |
new Thread(() -> { | |
try { | |
Thread.sleep(100); | |
} catch (InterruptedException e) { | |
e.printStackTrace(); | |
} | |
System.out.println("Thread 2 종료 중.."); | |
running = false; // volatile 키워드 없을 경우 false 값은 캐시 메모리에만 올라감 | |
}).start(); | |
} | |
public static void main(String[] args) { | |
new FlagThreadStopExample().flagVariableThreadStopTest(); | |
} | |
} |

코드 부연 설명
- Thread2가 flag 변수인 running 상태 값을 false로 설정하고 해당 변수는 volatile 키워드로 선언했으므로 메모리에 상태가 업데이트됨
- Thread 1 또한 메모리 값에 올라온 running을 참고하기 때문에 while문을 탈출하며 쓰레드 종료
2.1.2 동시성 이슈가 발생하는 코드
public class FlagThreadStopExample { | |
boolean running = true; | |
public void flagVariableThreadStopTest() { | |
new Thread(() -> { | |
int count = 0; | |
while (running) { // volatile 키워드 없으면 메모리 값에 올라온 running 참고 | |
count++; | |
} | |
System.out.println(String.format("Thread 1 종료. Count: %d", count)); | |
}).start(); | |
new Thread(() -> { | |
try { | |
Thread.sleep(100); | |
} catch (InterruptedException e) { | |
e.printStackTrace(); | |
} | |
System.out.println("Thread 2 종료 중.."); | |
running = false; // volatile 키워드 없을 경우 false 값은 캐시 메모리에만 올라감 | |
}).start(); | |
} | |
public static void main(String[] args) { | |
new FlagThreadStopExample().flagVariableThreadStopTest(); | |
} | |
} |

코드 부연 설명
- Thread2가 flag 변수인 running 상태 값을 false로 설정했지만 해당 값은 캐시 메모리에만 업데이트됨
- 반면, Thread 1은 메모리 값에 올라온 running을 참고하기 때문에 while문을 탈출할 수 없어 자원이 고갈될 때까지 쓰레드 종료 안됨
2.2 interrupt() 메서드를 활용해 쓰레드를 중지하는 방법
Java 쓰레드 기본 API인 interrupt()를 복습하면 다음과 같습니다.
- interrupt() 메서드를 통해 특정 쓰레드에게 interrupt 신호를 보낼 수 있으며 해당 쓰레드의 실행 중단, 작업 취소, 강제 종료 등을 목적으로 사용
- 쓰레드는 인터럽트 발생 여부를 확인할 수 있는 상태 값인 interrupted를 가지고 있으며 디폴트 값은 false
- interrupted()와 isInterrupted() 메서드 모두 interrupted 상태값을 확인하는 용도
- interrupted()는 상태 값을 확인한 후 interrupted 상태를 false로 초기화시킴
- isInterrupted()는 상태 값을 확인한 후 별도 처리 안 함 (상태값 변경 X)
- sleep(), join() 등으로 인해 실행 중인 쓰레드가 Waiting 상태로 전환되었을 때 interrupt 신호를 받으면 InterruptedException이 발생하며 이때 interrupted 값이 false로 초기화됨
본론으로 돌아와 자바는 아래 코드처럼 interrupt() 메서드를 통해 쓰레드를 중지할 수 있습니다.
public class InterruptThreadStopExample { | |
public static void main(String[] args) { | |
Thread worker = new Thread(() -> { | |
try { | |
while (!Thread.currentThread().isInterrupted()) { | |
// 쓰레드의 작업을 수행합니다. | |
System.out.println("작업 쓰레드가 실행 중입니다."); | |
System.out.println(String.format("인트럽트 상태 1 : %s", Thread.currentThread().isInterrupted())); | |
Thread.sleep(500); | |
} | |
} catch (InterruptedException e) { | |
/** | |
* sleep 메서드가 인터럽트되면 InterruptedException을 던지며 | |
* 이 때 interrupt 상태는 초기화 된다. | |
* 그렇기 때문에 다시 interrupt 를 호출해 줘야 한다. | |
*/ | |
System.out.println(String.format("인트럽트 상태 2 : %s", Thread.currentThread().isInterrupted())); | |
Thread.currentThread().interrupt(); | |
} | |
System.out.println("작업 쓰레드가 중단되었습니다."); | |
System.out.println(String.format("인트럽트 상태 3 : %s", Thread.currentThread().isInterrupted())); | |
}); | |
Thread stopper = new Thread(() -> { | |
try { | |
// 1초 후에 쓰레드를 중지합니다. | |
Thread.sleep(1000); | |
} catch (InterruptedException e) { | |
e.printStackTrace(); | |
} | |
worker.interrupt(); | |
System.out.println("중단 쓰레드가 작업 쓰레드를 중단시켰습니다."); | |
}); | |
worker.start(); | |
stopper.start(); | |
} | |
} |

코드 부연 설명
- stopper 쓰레드가 worker 쓰레드에 interrupt 신호를 보냄
- 아래에 열거할 두 가지 케이스가 발생할 수 있으며 두 케이스 모두 쓰레드를 중지시킴
- worker 쓰레드가 Thread.sleep(500); 코드에 의해 대기 상태로 전환되었고 이 때 interrupt 신호를 받았다면 InterruptedException이 발생하여 쓰레드 중지
- worker 쓰레드가 sleep() 메서드로 인해 전환된 Waiting 상태에서 Runnable 상태로 전환되는 찰나에 interrupt 신호를 받을 경우 `!Thread.currentThread().isInterrupted()` 값이 false가 되어 while문 탈출하고 쓰레드 중지
3. 사용자 쓰레드 vs 데몬 쓰레드
자바 쓰레드는 다음과 같이 크게 두 가지 유형의 쓰레드로 구분 가능합니다.
- 사용자 쓰레드
- 데몬 쓰레드
자바 어플리케이션이 실행되면 JVM은 사용자 쓰레드인 메인 쓰레드와 나머지 데몬 쓰레드를 동시에 생성하고 시작합니다.
3.1 메인 쓰레드
- 어플리케이션에서 가장 중요한 부분으로서 어플리케이션을 실행할 때마다 메인 쓰레드가 생성되어 실행
- 메인 쓰레드는 어플리케이션을 실행하는 최초의 쓰레드이자 어플리케이션 실행을 완료하는 마지막 쓰레드 역할
- 메인 쓰레드에서 여러 하위 쓰레드를 추가로 시작할 수 있고 하위 쓰레드는 또 여러 하위 쓰레드를 시작할 수 있음
- 자식 쓰레드는 부모 쓰레드의 상태를 상속받음
- 메인 쓰레드가 사용자 쓰레드이기 때문에 하위 쓰레드는 모두 사용자 쓰레드
3.2 사용자 쓰레드
- 메인 쓰레드에서 직접 생성한 쓰레드
- 메인 쓰레드를 포함한 모든 사용자 쓰레드가 종료하게 되면 어플리케이션이 종료
- foreground에서 실행되며 높은 우선순위를 가짐
- JVM은 사용자 쓰레드가 스스로 종료될 때까지 어플리케이션을 강제로 종료하지 않고 대기
- 자바가 제공하는 쓰레드 풀인 ThreadPoolExecutor 통해 사용자 쓰레드 생성
3.3 데몬 쓰레드
- JVM에서 생성한 쓰레드이거나 직접 데몬 쓰레드로 생성한 경우
- 모든 사용자 쓰레드가 작업을 완료하면 데몬 쓰레드의 실행 여부에 관계없이 JVM이 데몬 쓰레드를 강제 종료하고 어플리케이션 종료
- background에서 실행되며 낮은 우선순위를 가짐
- 데몬 쓰레드는 사용자 쓰레드를 지원하는 성격을 가진 쓰레드로서 보통 사용자 작업을 방해하지 않으면서 백그라운드에서 자동으로 작동
- 자바가 제공하는 쓰레드 풀인 ForkJoinPool 통해 데몬 쓰레드 생성
3.3.1 데몬 쓰레드 생성 방법
- setDaemon() 메서드를 통해 사용자 정의 쓰레드를 데몬 쓰레드로 설정 가능
- 디폴트 값은 false 즉, 사용자 쓰레드
- 단, 쓰레드가 실행 중인 동안 setDaemon()을 호출할 경우 IllegalThreadStateException 발생
public class DaemonThreadLifeCycleExample { | |
public static void main(String[] args) throws InterruptedException { | |
Thread userThread = new Thread(() -> { | |
try { | |
Thread.sleep(3000); | |
System.out.println("사용자 쓰레드 실행 중.."); | |
} catch (InterruptedException e) { | |
throw new RuntimeException(e); | |
} | |
}); | |
Thread daemonThread = new Thread(() -> { | |
while (true) { | |
try { | |
Thread.sleep(500); | |
System.out.println("데몬 쓰레드 실행 중.."); | |
} catch (InterruptedException e) { | |
throw new RuntimeException(e); | |
} | |
} | |
}); | |
daemonThread.setDaemon(true); | |
userThread.start(); | |
daemonThread.start(); | |
userThread.join(); | |
System.out.println("메인 쓰레드 종료"); | |
} | |
} |

코드 부연 설명
- 데몬 쓰레드는 종료된 적이 없고 계속 실행 중이지만
- 사용자 쓰레드가 종료되자 JVM은 데몬 쓰레드를 강제 종료하고 어플리케이션 종료
4. ThreadGroup
- 자바는 ThreadGroup을 통해 여러 쓰레드를 그룹화하는 기능 제공
- ThreadGroup에는 다른 ThreadGroup을 포함시킬 수 있고 그룹 내의 모든 쓰레드를 한 번에 종료 및 중단 가능
- 쓰레드는 반드시 하나의 쓰레드 그룹에 포함되어야 함
- 명시적으로 쓰레드 그룹에 포함시키지 않을 경우 자신을 생성한 쓰레드가 속해 있는 쓰레드 그룹에 포함됨
- 일반적으로 사용자가 main 쓰레드에서 생성하는 모든 쓰레드는 기본적으로 main 쓰레드 그룹에 속함
4.1 JVM의 쓰레드 그룹 생성 과정
- JVM이 실행되면 최상위 쓰레드 그룹인 시스템 쓰레드 그룹 생성
- JVM 운영에 필요한 데몬 쓰레드들을 생성해서 시스템 쓰레드 그룹에 포함시킴
- 시스템 쓰레드 그룹의 하위 쓰레드 그룹인 메인 쓰레드 그룹을 만들고 메인 쓰레드를 그룹에 포함시킴
- 시스템 에러가 발생하면 메인 쓰레드도 같이 종료되는 이유
public class ThreadGroupInterruptExample { | |
public static void main(String[] args) throws InterruptedException { | |
ThreadGroup topGroup = new ThreadGroup("상위그룹"); | |
ThreadGroup subGroup = new ThreadGroup(topGroup, "하위그룹"); | |
Thread topGroupThread = new Thread(topGroup, () -> { | |
while (true) { | |
System.out.println("상위 그룹 스레드 실행중..."); | |
try { | |
Thread.sleep(500); | |
} catch (InterruptedException e) { | |
e.printStackTrace(); | |
return; | |
} | |
} | |
}, "상위그룹스레드"); | |
Thread subGroupThread = new Thread(subGroup, () -> { | |
while (true) { | |
System.out.println("하위 그룹 스레드 실행중..."); | |
try { | |
Thread.sleep(500); | |
} catch (InterruptedException e) { | |
e.printStackTrace(); | |
return; | |
} | |
} | |
}, "하위그룹스레드"); | |
Thread subGroupThread2 = new Thread(subGroup, () -> { | |
while (true) { | |
System.out.println("하위 그룹 스레드 2 실행중..."); | |
try { | |
Thread.sleep(500); | |
} catch (InterruptedException e) { | |
e.printStackTrace(); | |
return; | |
} | |
} | |
}, "하위그룹스레드2"); | |
topGroupThread.start(); | |
subGroupThread.start(); | |
subGroupThread2.start(); | |
Thread.sleep(3000); // 스레드들이 실행되게 잠시 대기 | |
System.out.println("그룹 스레드들 중지..."); | |
subGroup.interrupt(); | |
Thread.sleep(3000); // 3초 동안 상위 그룹 쓰레드만 실행 | |
topGroup.interrupt(); | |
} | |
} |

코드 부연 설명
- 상위, 하위 그룹 모두 3초간 쓰레드 실행
- 하위 그룹 쓰레드 중지 (subGroupThread, subGroupThread2 모두 종료)
- 상위 그룹만 Running 상태
- 상위 그룹 쓰레드 중지
5. ThreadLocal
- 쓰레드는 오직 자신만이 접근해서 읽고 쓸 수 있는 로컬 변수 저장소 ThreadLocal을 제공
- 각 쓰레드는 고유한 ThreadLocal 객체를 속성으로 가지며 ThreadLocal은 쓰레드 간 격리되어 있음
- 모든 쓰레드가 공통적으로 처리해야 하는 기능이나 객체를 제어해야 하는 상황에서 쓰레드마다 다른 값을 적용해야 하는 경우 사용
- ex) 인증 주체 보관, 트랜잭션 전파, 로그 추적기 등

이미지 부연 설명
- 쓰레드는 ThreadLocal에 있는 ThreadLocalMap 객체를 자신의 threadLocals 속성에 저장
- 쓰레드 생성 시 theadLocals 기본값은 null이며 ThreadLocal에 값을 저장할 때 ThreadLocalMap이 생성되고 threadLocals에 연결
- 쓰레드가 전역적으로 값을 참조할 수 있는 원리는 쓰레드가 ThreadLocal의 ThreadLocalMap에 접근해서 저장된 값을 바로 접근할 수 있기 때문
- ThreadLocalMap은 항상 새롭게 생성되어 쓰레드 스택에 저장되기 때문에 근본적으로 쓰레드 간 데이터 공유가 될 수 없고 이에 따라 동시성 문제 발생 X
5.1 ThreadLocal 사용 시 주의사항
- ThreadPool을 사용하여 쓰레드를 운용할 경우 반드시 ThreadLocal에 저장된 값을 삭제해줘야 함
- 쓰레드 풀은 쓰레드를 재사용하기 때문에 현재 쓰레드가 이전의 쓰레드를 재사용한 것이라면 이전의 쓰레드에서 삭제하지 않았던 데이터를 참조할 수 있기 때문에 문제될 수 있음
public class ThreadPoolThreadLocalExample { | |
private static ThreadLocal<String> threadLocal = new ThreadLocal<>(); | |
public static void main(String[] args) { | |
ExecutorService executor = Executors.newFixedThreadPool(2); // 2개의 스레드를 가진 스레드 풀 생성 | |
// 첫 번째 작업: ThreadLocal 값을 설정 | |
executor.submit(() -> { | |
threadLocal.set("Thread 1의 값"); | |
System.out.println(String.format("%s: %s", Thread.currentThread().getName(), threadLocal.get())); | |
}); | |
// 여러 번의 두 번째 작업: ThreadLocal 값을 설정하지 않고 바로 값을 가져와 출력 | |
for (int i = 0; i < 10; i++) { | |
executor.submit(() -> { | |
System.out.println(String.format("%s: %s", Thread.currentThread().getName(), threadLocal.get())); // 예상치 못한 값 출력 가능 | |
}); | |
} | |
executor.shutdown(); | |
} | |
} |

코드 부연 설명
- ThreadPool에서 운용하는 쓰레드 개수가 두 개뿐이고 첫 번째 작업에서 threadLocal에 저장된 값을 제거하지 않은 상태
- 두 번째 작업에서는 별도로 ThreadLocal을 설정하지 않았기 때문에 get() 메서드를 통해 값을 가져올 경우 null 값이 출력될 것이라고 예상했지만 첫 번째 작업에서 설정한 값이 출력되는 것을 확인할 수 있음
5.2 ThreadLocal 작동원리
- ThreadLocal은 쓰레드와 ThreadLocalMap을 연결하여 쓰레드 전용 저장소를 구현하고 있는데 이 것이 가능한 이유는 Thread.currentThread()를 참조할 수 있기 때문
- Thread.currentThread()는 현재 실행 중인 쓰레드의 객체를 참조하는 것으로 CPU가 오직 하나의 쓰레드만 할당받아 처리하기 때문에 ThreadLocal에서 Thread.currentThread()를 참조하면 현재 실행 중인 쓰레드의 로컬 변수를 저장하거나 참조할 수 있음
5.3 InheritableThreadLocal
- ThreadLocal의 확장 버전으로서 부모 쓰레드로부터 자식 쓰레드로 값을 전달하고 싶을 경우 사용
- 부모 쓰레드가 InheritableThreadLocal 변수에 값을 설정할 경우 해당 부모 쓰레드로부터 생성된 자식 쓰레드들은 부모의 값을 상속
- 반면, 자식 쓰레드가 상속받은 값을 변경하더라도 부모 쓰레드의 값에는 영향 X
public class InheritableThreadLocalExample { | |
public static InheritableThreadLocal<String> inheritableThreadLocal = new InheritableThreadLocal<>(); | |
public static void main(String[] args) { | |
inheritableThreadLocal.set("부모 쓰레드의 값"); | |
Thread childThread = new Thread(() -> { | |
// 부모 쓰레드로부터 값 상속 | |
System.out.println("자식 쓰레드에서 상속받은 값: " + inheritableThreadLocal.get()); | |
// 자식 쓰레드에서 값을 변경 | |
inheritableThreadLocal.set("자식 쓰레드의 새로운 값"); | |
System.out.println("자식 쓰레드에서 설정한 후의 값: " + inheritableThreadLocal.get()); | |
}); | |
childThread.start(); | |
// 자식 쓰레드가 종료될 때까지 대기 | |
try { | |
childThread.join(); | |
} catch (InterruptedException e) { | |
e.printStackTrace(); | |
} | |
// 부모 쓰레드의 값 확인 | |
System.out.println("부모 쓰레드의 값: " + inheritableThreadLocal.get()); | |
} | |
} |

비고
과거에 제가 ThreadLocal 관련하여 작성한 글 참고하시면 좋을 것 같습니다.
https://jaimemin.tistory.com/2007
[SpringBoot] ThreadLocal 간단 정리
개요 스프링 프레임워크 내 Bean들은 스프링 컨테이너에 의해 싱글톤으로 관리됩니다. 정리하자면 인스턴스가 단 하나만 존재한다는 것인데 여러 쓰레드가 동시에 해당 인스턴스를 접근할 경우
jaimemin.tistory.com
참고
자바 동시성 프로그래밍 [리액티브 프로그래밍 Part.1] - 정수원 강사님
'JAVA > 비동기 프로그래밍' 카테고리의 다른 글
[Java] 동기화 기법 (1) | 2024.03.04 |
---|---|
[Java] 동기화 개념 (0) | 2024.02.24 |
[Java] Java 쓰레드 기본 API (0) | 2024.02.05 |
[Java] 쓰레드 생성 및 실행 구조 (0) | 2024.01.31 |
[OS] 운영체제 기초 (0) | 2024.01.19 |