[Coroutine] Dispatchers 별 Thread.sleep 를 사용하는 경우 코루틴의 동작

오규성·2025년 12월 22일
post-thumbnail

회사 면접을 보면서, 다소 헷갈렸던 부분이 있었어서 글을 정리하게 되었다.
지금 생각해보면 이걸 왜 헷갈렸나 싶지만 나중에 봤을때도 헷갈릴 수 있을 것이라 생각되어 정리해보려고 한다.


Coroutine 이란?

Coroutine 은 Thread 위에서 돌아가는 경량화된 실행 단위이자, 쓰레드가 직접 점유하지 않고 재개 및 중단을 가능하게 하는 비동기 실행 모델

즉, CoroutineThread 위에서 돌아가므로 Thread 에 종속된다고 생각하면 된다.

이러한 코루틴은 Dispatchers 를 통해 어느 스레드 및 스레드풀에서 처리할 지 전략을 설정할 수 있다. 또한 코루틴은, 상태 머신 (State Machine) 이라는 것에 의해 특정 지점에서의 중단 및 재개가 가능해지는데 그렇다면 메인 쓰레드 내에서 Thread.sleepdelay 를 사용하게 되면 어떻게 될까?

테스트 시작

이번에 확인하려는 것은 Thread.sleep + delay + cancelAndJoin() 를 사용했을 때의 결과를 확인해보려고 한다.

이를 테스트하기 위해 다음 코드를 기본적으로 사용하고, 코드를 변경할 부분은 for 문 내부이다.

runBlocking {
    val job = launch {
        for (i in 0 until 7) {
        	...
        }
    }

    delay(1300L)
    Log.d("테스트 ", "Cancel")
    job.cancelAndJoin()
}

여기서 runBlocking 의 Contex 를 지정해주지 않았기에 현재 스레드로 진행하므로 Main Thread 에서 진행되며, Start 는 DEFAULT 이기에 즉시 스케줄링 및 실행된다.

Job.cancelAndJoin() 의 경우 job 을 취소하고 완료될 때까지 대기하는 함수이다.

1. Dispatchers.Main 테스트

실제 UI 환경 Main Thread 에서 runBlocking 을 호출해서 테스트해보자.
CoroutineContextCoroutineStart 는 미지정한다.

1. delay + delay

runBlocking {
    val job = launch {
        for (i in 0 until 7) {
            Log.d("테스트 ", "실행 - $i")
            delay(500L)
        }
    }

    delay(1300L)
    Log.d("테스트 ", "Cancel")
    job.cancelAndJoin()
}

위의 코드는 Dispatchers.Main 을 중심으로 코루틴을 실행시킨다.
이 코드를 실행시킨다면 어떤 결과가 나올까?

작업은 위와 같이 나오며 실행 순서는 다음과 같다.

  1. launch { ... } 를 통해 job 을 등록한다.
  2. 다음 줄로 진행되어 delay(1300) 에서 재개 예약을 남기고 일시중단한다.
  3. 스케줄링된 job 을 실행하고 println 후 delay(500) 에서 반복적으로 일시중단(재개 예약) 한다.
  4. 스케줄러의 시간이 1300ms 만큼 진행되면 부모가 재개되어 "Cancel"을 출력하고 job.cancelAndJoin() 을 호출한다.
  5. 자식 job은 취소 상태가 되고, 다음 취소 체크 지점(delay) 에서 CancellationException 를 throw 하면서 취소 완료된다. 현재는 1.0초와 1.5초 사이의 일시중단 상태이기에 delay 가 취소되면서 즉시 Cancel 한다.
  6. cancelAndJoin() 은 자식 job이 완료(취소 완료 포함) 될 때까지 기다린 뒤 리턴한다.

2. ensureActive + Thread.sleep + delay

ensureActive() 는 현재 코루틴 스코프의 isActive 상태를 확인하고, isActive 가 아니라면 CancellationException 을 throw 하는 함수이다.
이를 활용해서 코드를 변경해보자.

runBlocking {
    val job = launch {
        for (i in 0 until 7) {
            ensureActive()
            Log.d("테스트 ", "실행 - $i")
            Thread.sleep(500L)
        }
    }

    delay(1300L)
    Log.d("테스트 ", "Cancel")
    job.cancelAndJoin()
}

자, 코드를 변경해보았다.

Thread.sleep 은 suspendCancellableCoroutine 이 아니고, ensureActive() 은 현재 Coroutine 의 isActive 에 따라 CancellationException 을 Throw 한다.
이제 결과를 확인해보기에 앞서 어떤 결과가 나올지 상상해보자.

.
.
.
결과는 다음과 같다.

작업 실행 순서는 다음과 같다.

  1. launch { ... } 를 통해 job 을 등록한다.
  2. 다음 줄로 진행되어 delay(1300) 에서 재개 예약을 남기고 일시중단한다.
  3. 스케줄링된 job 을 실행하고 println 후 Thread.sleep(500) 에서 반복적으로 스레드를 차단한다.
  4. delay(1300) 은 1.3초가 지나면 재개 작업을 메인 큐에 올리지만, 메인 스레드가 약 3.5초 동안 Thread.sleep으로 블로킹되어 큐를 처리하지 못해 재개가 지연된다.
  5. Thread.sleep 이 모두 끝나고 나서 delay 작업을 Thread 가 꺼내, 작업이 재개되면서 Cancel 을 프린트한다.
  6. job.cancelAndJoin() 을 실행하지만, 이미 Job 은 끝났기에 작업이 리턴된다.

-> 이 과정에서 ensureActive() 가 확인하는 당시에는 isActivetrue 이므로 취소 완료되지 않는다.

3. Thread.sleep + delay

runBlocking {
    val job = launch {
        for (i in 0 until 7) {
            Log.d("테스트 ", "실행 - $i")
            Thread.sleep(500L)
        }
    }

    delay(1300L)
    Log.d("테스트 ", "Cancel")
    job.cancelAndJoin()
}

이것 또한 2번과 같이 표시되며 실행과정도 비슷하다.

2. Dispatchers.IO 테스트

이제 Dispachers 미지정 -> IO 로 변경해보자

1. delay 방식

runBlocking(Dispatchers.IO) {
    val job = launch {
        for (i in 0 until 7) {
            Log.d("테스트 ", "실행 - $i")
            delay(500L)
        }
    }

    delay(1300L)
    Log.d("테스트 ", "Cancel")
    job.cancelAndJoin()
}

실행 순서는 기존 Dispatcher.Main 과 비슷하게 출력되었지만 Dispatchers.IO 는 스레드풀 기반이기에 부모/자식이 서로 다른 스레드에서 실행될 수 있으니 유의하자.

2. ensureActive + Thread.sleep 방식

runBlocking(Dispatchers.IO) {
    val job = launch {
        for (i in 0 until 7) {
            ensureActive()
            Log.d("테스트 ", "실행 - $i")
            Thread.sleep(500L)
        }
    }

    delay(1300L)
    Log.d("테스트 ", "Cancel")
    job.cancelAndJoin()
}

이번에는 Main Thread 에서 진행했을때와 결과가 다르게 나온다.
그 이유는 launch 내부에서 차단하는 Thread 와 runBlocking 의 Thread 가 서로 다르기 때문이다.

테스트를 위해 스레드를 확인해보면, 실제로 서로 다른 스레드를 사용하는 것을 확인할 수 있다.

실행 순서

  1. launch 를 Scheduling (이 때, launch 는 Dispatchers 는 미지정이므로 부모 Dispatcher 를 따라간다.)
  2. 다음 줄로 넘어가 delay(1300L) 실행 및 일시중단 (병렬이므로 2, 3 순서 변경 가능성 존재)
  3. launch 가 실행되면서 print 및 Thread 를 500ms 만큼 차단을 반복
  4. 1.3초가 지났을때 Coroutine Scheduler 에서 재개 작업을 꺼내어 실행 (가능한 이유는 launch 와 별도의 스레드이기 때문)
  5. job.cancelAndJoin() 을 호출, 현재 Job 에서의 sleep 이 끝나는 경우 다음 순회에서 ensureActive() 로 취소 완료된다.

3. Thread.sleep 방식

runBlocking(Dispatchers.IO) {
    val job = launch {
        for (i in 0 until 7) {
            Log.d("테스트 ", "실행 - $i")
            Thread.sleep(500L)
        }
    }

    delay(1300L)
    Log.d("테스트 ", "Cancel")
    job.cancelAndJoin()
}

실행 순서는 다음과 같다.

  1. launch 를 Scheduling (이 때, launch 는 Dispatchers 는 미지정이므로 부모 Dispatcher 를 따라간다.)
  2. 다음 줄로 넘어가 delay(1300L) 실행 및 일시중단 (병렬이므로 2, 3 순서 변경 가능성 존재)
  3. launch 가 실행되면서 print 및 Thread 를 500ms 만큼 차단을 반복
  4. 1.3초가 지났을때 Coroutine Scheduler 에서 재개 작업을 꺼내어 실행 (가능한 이유는 launch 와 별도의 스레드이기 때문)
  5. job.cancelAndJoin() 을 호출하지만 suspendCancellationCoroutine 이 내부에 없으므로 취소 가능한 상태가 아니기 때문에 끝까지 진행하고 완료 처리

3. Dispatchers.Default

이 경우는 Dispatchers.IO 와 다른 상황이 발생하지 않지만, Dispatchers.Default 의 경우 CPU 코어 개수와 같은 수의 스레드를 가진 스레드풀을 보유하기에, 코루틴을 그보다 많이 띄우는 경우 병목/지연 현상이 발생할 수 있다.


Dispatcher 별로 어느 스레드에서 돌아가고, 어느 상황에서 사용하는 것인지는 알고 있었으나 Thread.sleep 를 함께하는 케이스는 생각해본 적이 없던터라 좀 당황했었다.

이제라도 확실하게 알게되었으니 이를 계기로 다음에는 관련 문제를 틀리지 않기를 ...!!

profile
안드로이드 개발자 Gyu 의 개발 블로그 !

0개의 댓글