티스토리 뷰
JVM 동시성 프로그래밍 딥다이브 4-1. 스레드 활용 - 예외처리, 유저/데몬 스레드, ThreadGroup
구름뭉치 2026. 1. 18. 14:08스레드 예외 처리 UncaughtExceptionHandler
- 기본적으로 스레드의 run()은 예외를 던질 수 없기 때문에 예외가 발생할 경우 run() 안에서만 예외를 처리해야 한다.
- Thread 밖에서 Thread 내부에서 발생한 RuntimeException을 Try Catch로 잡아봤자 예외는 잡아지지 않고, 사라져버린다.
- 그렇다면 스레드가 비정상적으로 종료되었거나 특정한 예외를 스레드 외부에서 캐치하기 위해서 어떻게 해야할까?
-> UncaughtExceptionHandler 인터페이스를 통해 예외를 핸들링하게 된다.
UncaughtExceptionHandler
- 캐치되지 않는 예외에 의해 Thread가 비정상 종료되었을 때 호출되는 핸들러 인터페이스이다.
@FunctionalInterface
public interface UncaughtExceptionHandler {
/**
* @param t the thread
* @param e the exception
*/
void uncaughtException(Thread t, Throwable e);
}
Thread 에서 제공하는 API
// 변수
private static volatile UncaughtExceptionHandler defaultUncaughtExceptionHandler;
public static void setDefaultUncaughtExceptionHandler(UncaughtExceptionHandler ueh)
- 전역 함수로 모든 Thread에서 발생하는 uncaugthException을 받아서 처리하는 정적 메서드이다.
- 함수명처럼 한번 설정하면 모든 스레드들에 대해 디폴트 핸들러로서 동작한다.
// 변수
private volatile UncaughtExceptionHandler uncaughtExceptionHandler;
public void setUncaughtExceptionHandler(UncaughtExceptionHandler ueh)
- 인스턴스 함수로 특정 스레드에 직접 설정하는 핸들러이다.
- 당연히 setDefaultUncaughtExceptionHandler로 설정한 디폴트 핸들러보다 우선순위가 더 높다.
코드예시
1. uncaughtException을 설정하지 않아서 RuntimeException이 발생했지만 캐치되지 못하고 사라지는 경우
fun main() {
val thread = Thread(MyRun())
try {
thread.start()
} catch (e: Exception) {
printWithThread("예외가 발생하면 알림 제공")
notify(e)
}
}
fun notify(e: Exception) {
printWithThread("메인 스레드에서 예외 알림을 받음: ${e.message}")
}
class MyRun : Runnable {
override fun run() {
for (i in 0..10) {
printWithThread("Thread working: $i in ${Thread.currentThread().name}")
Thread.sleep(1000)
if (i == 5) {
// 의도적으로 예외 발생
printWithThread("Throwing exception from ${Thread.currentThread().name}")
throw NullPointerException("Uncaught Exception in Thread at i = $i")
}
}
}
}

- Try Catch가 걸려있음에도 어떠한 로깅도 찍히지 않았다.
- RuntimeException 예외와 함께 스레드의 비정상 종료만 발생하고 예외는 사라졌다.
2. setDefaultUncaughtExceptionHandler / uncaughtExceptionHandler를 설정하여 예외를 캐치하여 핸들링하는 경우
fun main() {
val caughtSpecificExceptionThread = Thread(MyRun())
val caughtDefaultException = Thread(MyRun())
caughtSpecificExceptionThread.uncaughtExceptionHandler = Thread.UncaughtExceptionHandler { t, e ->
printWithThread("[커스텀 예외핸들러] 스레드에서 예외 알림을 받음: ${e.message} from ${t.name}")
notify(e as Exception)
}
Thread.setDefaultUncaughtExceptionHandler { t, e ->
printWithThread("[디폴트 예외핸들러] 디폴트 예외 핸들러에서 예외 알림을 받음: ${e.message} from ${t.name}")
notify(e as Exception)
}
try {
caughtSpecificExceptionThread.start()
caughtDefaultException.start()
caughtSpecificExceptionThread.join()
caughtDefaultException.join()
} catch (e: Exception) {
printWithThread("[캐치] 메인 스레드에서 예외 알림을 받음: ${e.message}")
notify(e)
}
}
fun notify(e: Exception) {
printWithThread("메인 스레드에서 예외 알림을 받음: ${e.message}")
}
class MyRun : Runnable {
override fun run() {
for (i in 0..10) {
printWithThread("Thread working: $i in ${Thread.currentThread().name}")
Thread.sleep(1000)
if (i == 5) {
// 의도적으로 예외 발생
printWithThread("Throwing exception from ${Thread.currentThread().name}")
throw NullPointerException("Uncaught Exception in Thread at i = $i")
}
}
}
}

- 디폴트 예외 핸들러 / 커스텀 예외 핸들러를 각각 설정했다.
- 디폴트 예외 힌들러와 커스텀 예외 핸들러 둘다 각 스레드에서 정상 동작했다.
- 예외가 던져지고 핸들러에 설정된 로깅과 notify() 함수도 정상적으로 호출된걸 볼 수 있다.
스레드 중지
- 자바에서는 원래 스레드를 중지할 수 있는 stop(), suspend() 메소드를 제공했다.
- 하지만 사이드 이펙트 관리가 어려워져서 더이상 중지 / 종료하는 API를 제공하지 않게 되었다.
- 따라서 개발자는 플래그 변수를 사용하거나 intterrupt()를 활용해서 종료를 구현해야한다.
@Deprecated(since="1.2", forRemoval=true)
public final void suspend() {
throw new UnsupportedOperationException();
}
Flag 변수
스레드가 갖는 플래그 변수의 값이 조건에 만족하면 스레드의 실행을 중지하게 할 수 있다.
이때, 스레드가 갖는 변수는 volatile 키워드를 사용하거나, atomic 변수를 사용해야만한다. 의무!
안그러면 스레드가 캐시에 들고 있는 값만 바라보면서 동시성 이슈나 원하지 않는 동작이 발생하게 된다.
아래에는 3가지 경우에 대해 소개한다.
- 지역변수를 플래그로 사용하는 경우
- volatile 키워드(애노테이션)를 사용하는 경우
- Atomic을 사용하는 경우
// 지역변수 사용
class MyStopper : Runnable {
var stopped = false
fun stop() {
stopped = true
printWithThread("Stop signal sent.")
}
override fun run() {
var i = 0L
while (!stopped) {
i++
}
printWithThread("Thread stopping gracefully in ${Thread.currentThread().name}, loop count: $i")
}
}
// Volatile 키워드 사용
class MyStopper : Runnable {
@Volatile
var stopped = false
fun stop() {
stopped = true
printWithThread("Stop signal sent.")
}
override fun run() {
var i = 0L
while (!stopped) {
i++
}
printWithThread("Thread stopping gracefully in ${Thread.currentThread().name}, loop count: $i")
}
}
// Atomic 변수 사용
class MyStopper : Runnable {
var stopped: AtomicBoolean = AtomicBoolean(false)
fun stop() {
stopped = AtomicBoolean(true)
printWithThread("Stop signal sent.")
}
override fun run() {
var i = 0L
while (!stopped.get()) {
i++
}
printWithThread("Thread stopping gracefully in ${Thread.currentThread().name}, loop count: $i")
}
}
해당 코드를 수행하면 지역변수를 계속 바라보는 1번의 경우 종료되지 않는다. 또한 여러 스레드가 접근하는 환경이라면 동시성 이슈 또한 발생할 수 있다.
다른 스레드도 종료 플래그를 확인하고 종료되어야 함에도 계속 보지 못하고 캐시 데이터를 보거나 동시성 환경이라면 연산 과정 중 사라질 수 있다.
1번 실행

- `Process finished with exit code 0` 이런 종료 로그가 찍히지 않는다.
- `Process finished with exit code 130 (interrupted by signal 2:SIGINT)`
- 강제 종료로 종료해야 했다.
- 실제 변경된 필드의 메모리값을 보지 않고 캐시 값만 바라보므로 변경이 확인되지 못하고 무한루프에 갖히게 되었다.
2번 실행 (Volatile)

- 정상 종료되었다.
- 항시 값을 조회 수정할 때 바로 직접 메모리 공간에 가서 조작과 확인이 이뤄지므로 안전하다.
3번 실행 (Atomic)

- 동시성 환경에서도 동시에 접근하지 못하고 atomic하게 동작이 이뤄지므로 안전하다.
- 참고로 AtomicInteger의 경우 value값이 volatile 키워드 변수다.
interrupted() & isInterrupted()
- 플래그 대신 실행중인 스레드에 interrupt()를 하여 인터럽트 상태를 변경해서 종료 기능을 제공할 수 있다.
1번 Thread.currentThread().isInterrupted() 코드예시
fun main() {
val myInterruptible = MyInterruptible()
val thread = Thread(myInterruptible)
thread.start()
for (i in 1..5) {
printWithThread("... Main thread working: $i")
Thread.sleep(1000)
}
thread.interrupt()
}
class MyInterruptible : Runnable {
override fun run() {
var i = 0L
while (Thread.currentThread().isInterrupted.not()) {
i++
}
printWithThread("interrupted ${Thread.currentThread().name}, loop count: $i")
}
}

- isInterrupted()로 확인한 인터럽트는 상태가 초기화되지 않는다.
- 따라서 interrupt 변수 값은 아직도 계속 인터럽트 == true인 상태로 인터럽트 상태이다.
1번 Thread.interrupted() 코드예시
fun main() {
val myInterruptible = MyInterruptible()
val thread = Thread(myInterruptible)
thread.start()
for (i in 1..5) {
printWithThread("... Main thread working: $i")
Thread.sleep(1000)
}
thread.interrupt()
}
class MyInterruptible : Runnable {
override fun run() {
var i = 0L
while (Thread.interrupted().not()) {
i++
}
printWithThread("interrupted ${Thread.currentThread().name}, loop count: $i")
}
}

- Thread.interrupted()로 확인한 인터럽트는 확인 후 바로 Thread의 getAndClearInterrupt에 의해 인터럽트 상태가 초기화 된다.
- 따라서 인터럽트 상태는 해제 되고 인터럽트 == false인 상태이다.
사용자 스레드 vs. 데몬 스레드
- 자바는 스레드를 크게 두가지 유형인 사용자 스레드와 데몬 스레드로 구분할 수 있다.
- 사용자 스레드는 사용자 스레드를 낳고, 데몬 스레드는 데몬 스레드를 낳는다. 즉, 부모 스레드의 상태를 상속 받는다.
- JVM 애플리케이션이 실행되면 사용자 스레드인 메인 스레드와 나머지 데몬 스레드를 동시에 생성하고 시작한다.
사용자 스레드 (User Thread)
- 사용자 스레드는 MAIN 스레드에서 직접 생성한 스레드를 의미한다.
- 사용자 스레드는 각각 독립적인 생명주기를 가지고 실행하게 된다.
- 메인 스레드를 포함해서 모든 사용자 스레드가 종료하게 되면 애플리케이션이 종료하게 된다.
- 사용자 스레드는 foreground 에서 실행되는 높은 우선순위를 가지며
JVM은 사용자 스레드가 스스로 종료될 때까지 어플리케이션을 강제로 종료하지 않고 기다린다. - 자바가 제공하는 스레드 풀인 ThreadPoolExecutor는 사용자 스레드를 생성한다.
데몬 스레드 (Daemon Thread)
- 데몬 스레드는 JVM에서 생성하거나 or 직접 데몬 스레드로 생성한 경우를 말한다.
new Thread()를 생성하고 `setDaemon(true)`로 데몬 스레드로 선언이 가능하다.
public final void setDaemon(boolean on) {
checkAccess();
if (isVirtual() && !on)
throw new IllegalArgumentException("'false' not legal for virtual threads");
if (isAlive())
throw new IllegalThreadStateException();
if (!isVirtual())
daemon(on);
}
- 모든 사용자 스레드가 작업을 완료하면 데몬 스레드의 실행 여부와 상관없이 JVM은 데몬 스레드를 강제 종료하고
애플리케이션도 종료한다. - 자바에서 제공하는 스레드 풀인 ForkJoinFool은 데몬 스레드를 생성한다.
이렇게 되면 FJP로 생성하여 처리중인 잡이 있어도 더이상 작업중인 사용자 스레드가 없다면 데몬 스레드인 FJP의 스레드는 전부 종료되어 중단되게 된다.
코드 예시
사용자 스레드 동작 및 종료
fun main() {
val myUserThread1 = Thread({
for (i in 1..3) {
printWithThread("사용자 스레드 작업 $i ...")
Thread.sleep(1000L)
}
})
val myUserThread2 = Thread({
for (i in 1..3) {
printWithThread("사용자 스레드 작업 $i ...")
Thread.sleep(1000L)
}
})
val myUserThread3 = Thread({
for (i in 1..3) {
printWithThread("사용자 스레드 작업 $i ...")
Thread.sleep(1000L)
}
})
myUserThread1.start()
myUserThread2.start()
myUserThread3.start()
printWithThread("메인 스레드 종료. 나머지 남은 사용자 스레드 작업은 이어서 진행됨.")
}

- 실제로 사용자 스레드의 경우 메인 스레드가 종료되어도 유지된채 끝가지 작업을 마무리하는것을 볼 수 있다.
데몬 스레드 동작 및 종료 코드예시
fun main() {
val myDaemonThread1 = Thread({
for (i in 1..3) {
printWithThread("데몬 스레드 작업 $i ...")
Thread.sleep(1000L)
}
})
val myDaemonThread2 = Thread({
for (i in 1..3) {
printWithThread("데몬 스레드 작업 $i ...")
Thread.sleep(1000L)
}
})
val myDaemonThread3 = Thread({
for (i in 1..3) {
printWithThread("데몬 스레드 작업 $i ...")
Thread.sleep(1000L)
}
})
myDaemonThread1.isDaemon = true
myDaemonThread2.isDaemon = true
myDaemonThread3.isDaemon = true
myDaemonThread1.start()
myDaemonThread2.start()
myDaemonThread3.start()
printWithThread("메인 스레드 종료. 나머지 남은 데몬 스레드 작업은 종료됨.")
}

- 데몬 스레드의 경우 작업중이여도 마지막 사용자 스레드가 종료되면 JVM에 의해 같이 함께 종료된다.
- 스레드 start() 전에 isDaemon으로 데몬화 시키면 `IllegalThreadStateException` 예외가 발생한다.
ThreadGroup
- 자바는 스레드 그룹이라는 객체를 통해서 여러 스레드를 그룹화하는 편리한 방법을 제공한다.
- ThreadGroup은 곧 스레드 집합으로, 스레드 그룹에 다른 스레드 그룹이 포함될수도 있다.
- 그룹 내의 모든 스레드는 한번에 종료하거나 중단할 수 있다.
- 스레드는 반드시 하나의 스레드 그룹에 포함되어야 한다. 명시적으로 그룹에 넣지 않는다면 자신을 생성한 스레드가 속해있는 그룹에 포함되어진다.
- 따라서 main 스레드에서 생성하는 모든 스레드는 기본적으로 main 스레드 그룹에 속하게 된다.
스레드 그룹 생성 순서
- JVM 이 실행되면서 최상위 스레드 그룹인 system 스레드 그룹이 생성된다.
- JVM 운영에 필요한 데몬 스레드들을 생성해서 system 스레드 그룹에 포함시킨다.
- system 스레드 그룹 하위 집합으로 main 스레드 그룹을 만들고 main 스레드를 해당 그룹에 넣는다.


- main을 실행할 때 dubug를 해보면 main 스레드가 main 그룹에 있음을 알 수 있다.
- 여타 스레드들도 main 그룹에 있는것과 그렇지 않은 데몬 스레드들을 볼 수 있다.
ThreadGroup 구조
// 기본 생성자는 가려져있다.
private ThreadGroup()
// 이름만 받는 생성자.
// 내부적으로 Thread.currentThread().getThreadGroup()를 통해
// 부모 스레드 그룹을 스레드 그룹으로 지정한다.
public ThreadGroup(String name)
// 스레드 그룹과 이름을 지정하는 생성자.
public ThreadGroup(ThreadGroup parent, String name)
- activeCount(): 현재 그룹 및 하위 그룹에서 실행중인 모든 스레드의 수
- activeGroupCount(): 현재 그룹에서 활성화된 모든 하위 그룹의 수를 반환
- get/setMaxPriority(): 현재 그룹에 포함된 스레드가 가질 수 있는 최대 우선순위 조회/설정
- 스레드는 그룹의 max priority를 초과할 수 없다. 초과해서 설정해도 그룹의 최대 우선순위로 적용됨.
- interrupt(): 현재 그룹에 포함된 모든 스레드를 인터럽트 한다.
메인 스레드 그룹 코드 예시
fun main() {
val mainGroup = Thread.currentThread().threadGroup
printWithThread("메인 스레드의 그룹은 ${mainGroup.name} 그룹이다.")
printWithThread("메인 스레드의 부모 그룹은 ${mainGroup.parent.name} 그룹이다.")
printWithThread("메인 스레드 그룹을 인터럽트하면 메인 스레드를 포함한 모든 스레드가 인터럽트 된다.")
mainGroup.interrupt()
printWithThread("메인 스레드는 인터럽트 되었나요? -> ${Thread.currentThread().isInterrupted}")
}

- 메인 스레드의 그룹과 부모 그룹을 확인할 수 있다.
- 그룹을 인터럽트하면 소속된 스레드도 인터럽트 상태가 되는걸 볼 수 있다.
스레드 그룹간 집합 관계 코드 예시
fun main() {
val topGroup = ThreadGroup("TopGroup")
val subGroup = ThreadGroup(topGroup, "SubGroup")
val subThread = Thread(subGroup, {
printWithThread("저는 SubGroup 소속의 스레드입니다.")
}, "서브 그룹 막내 스레드")
val topThread = Thread(topGroup, {
printWithThread("저는 TopGroup 소속의 스레드입니다.")
}, "탑 그룹 막내 스레드")
subThread.start()
topThread.start()
printWithThread("TopGroup 내 그룹 목록")
printWithThread(topGroup.list())
}

- 스레드 그룹에 대해 list()를 수행하면 하위에 어떤 스레드 그룹과 스레드가 존재하는지 확인할 수 있다.
'기술 학습 > JVM 스레드 딥다이브' 카테고리의 다른 글
| JVM 동시성 프로그래밍 딥다이브 3. 스레드 기본 API - Sleep, Join, Interrupt 등 (1) | 2026.01.04 |
|---|---|
| JVM 동시성 프로그래밍 딥다이브 2. 스레드 생명주기와 상태 (0) | 2025.12.21 |
| JVM 동시성 프로그래밍 딥다이브 1. 프로세스 & 스레드 (1) | 2025.12.07 |
- Total
- Today
- Yesterday