최근 개발을 하던도중 코루틴동작 방식을 잘못 이해하고 사용해서 문제가 발생했었는데요.
까먹지 않고자 정리를 해보았습니다.
문제상황
비동기보다는 멀티스레드를 편하게 사용하려고 코루틴을 사용하고 있는데요.
이때, 아래 예시 코드와 같이 첫번째 async가 실패하면 두번째 async로 fallback 하는 로직이 있었습니다.
runBlocking {
val async1 = async {
...
}
val async2 = async {
...
}
val result = runCatching {
async1.await()
}.onSuccess {
// log
}.recoverCatching {
async2.await()
}.getOrElse {
...
}
}
위는 예시 코드인데요.
코드를 보시면 처음에 runBlocking으로 코루틴 컨텍스트를 만들고 내부에서 async1, async2를 만들어 줍니다.
마지막에 async1.await() 으로 로직을 처리하다가 실패 할 경우 recoverCatching 구문으로 들어가 async2.await를 해줍니다.
async1이 실패했을때 과연 예상한대로 async2의 결과를 잘 받아올까요?
실제로 코드를 수행해보면 예상과는 약간 다르게 동작하는데요. 결과는 아래와 같이 나옵니다.
결과 | async1 | async2 |
성공 | 성공 | 성공 |
실패 | 성공 | 실패 |
실패 | 실패 | 성공 |
결론적으로 위 코드는 async1, async2 둘중 하나만 실패해도 전체가 실패하는 코드가 되었습니다.
결과가 이렇게 나오는 이유를 알려면 coroutine의 job을 이해해야 하는데요.
Coroutine job과 실패전이
코루틴은 내부적으로 Job을 들고 있고, 이 Job은 코루틴의 상태를 컨트롤 해줍니다.
아래 그림은 코루틴 job과 job별 상태변환도 인데요. 공식 문서에 상태별의미등이 더 자세히 나와있으니 참고해보셔도 좋을거 같아요.
wait children
+-----+ start +--------+ complete +-------------+ finish +-----------+
| New | -----> | Active | ---------> | Completing | -------> | Completed |
+-----+ +--------+ +-------------+ +-----------+
| cancel / fail |
| +----------------+
| |
V V
+------------+ finish +-----------+
| Cancelling | --------------------------------> | Cancelled |
+------------+ +-----------+
코루틴은 하나의 자식이 실패했을때 전체가 실패하는 특징이 있고, 이를 위해서 부모 자식간의 양방향 참조를 하고 있게됩니다.
아래는 실제 코루틴 Job의 필드 코드인데요. 내부 프로퍼티로 children, parent가 있는것을 확인할 수 있습니다.
public interface Job : CoroutineContext.Element {
...
@ExperimentalCoroutinesApi
public val parent: Job?
...
public val children: Sequence<Job>
}
따라서 처음에 작성되어 있는 코드는 아래와 같은 Job 트리를 갖게되며, 자식의 실패가 부모에 전파되기 때문에 async1, ascyn2중 하나가 실패했을때 전체가 실패하게 되었던 것 입니다.
코루틴의 취소 전파 시점
만약 코드를 아래와 같이 살짝 바꿔서 실패하는 async1에 대해서는 await하지 않도록 만들면 어떻게 될까요?
runBlocking {
val async1 = async {
throw IllegalArgumentException()
}
val async2 = async {
...
}
val result = runCatching {
async2.await() // async2에 await
}.onSuccess {
// log
}.recoverCatching {
async2.await()
}.getOrElse {
...
}
}
async1에 await하지 않았으므로 성공적으로 끝날거라 예상한 코드가 실제로는 async1의 로직에 의해서 부모 코루틴이 실패상태로 변하게 되고 실패하게 됩니다.
코루틴은 (async를 사용하는경우) await 시점에 로직을 수행하기 전에 자식 코루틴의 취소 상태를 부모와 동기화 합니다. 누구에게 거느냐에 상관없이 자식 coroutine들에 대해서 동기화를 수행하므로, async2에 await를 걸었지만 async1의 실패상태가 부모 코루틴에 전파되었고 이 때문에 실패하게 된 것 입니다.
또한, Context와 Job은 다르기 때문에 의식적으로 부모 코루틴 아래에서 다른 Context로 async를 시작해도 Job은 부모와 연결되어있게 됩니다.
즉, Context가 다르더라도 Job이 연결되어 있으면 실패가 전이됩니다.
해결
해결방법은 여러가지가 있는데요.
첫번째는 Job을 아예 분리시키는 것 입니다.
예를들어, 코드를 아래와 같이 설계하면 두 async는 서로 영향을 받지 않게되고 async1이 실패했을때 async2를 성공적으로 호출합니다.
val async1 = async {
...
}
val async2 = async {
...
}
runBlocking {
val result = runCatching {
async1.await()
}.onSuccess {
// log
}.recoverCatching {
async2.await()
}.getOrElse {
...
}
}
하지만 이 방식은 구조화된 동시성을 깨므로 아래와 같은 문제가 발생할 수 있습니다.
- 고아 코루틴 문제: 부모 작업이 종료된 후에도 자식 코루틴이 계속 실행될 수 있음.
- 리소스 누수: 실행 중인 코루틴이 제대로 정리되지 않아 메모리 누수나 비정상 동작 발생.
- 디버깅 어려움: 작업이 어디서 생성되고 어떻게 실행되는지 추적하기 어려움.
코루틴에서는 구조화된 동시성을 유지하면서 자식간의 실패전이가 발생하지 않도록 기능을 제공해주는데요. 바로 supervisorJob 입니다.
supervisorJob의 경우 바로 사용하면 마찬가지로 부모 Job과 연결이 끊기므로 부모 Job을 명시적으로 지정 해주어야 하며, 매번 작성하는게 번거로워서 supervisorScope를 선호합니다.
사용법은 아래 코드와 같은데요.
runBlocking {
supervisorScope {
val async1 = async {
...
}
val async2 = async {
...
}
val result = runCatching {
async1.await()
}.onSuccess {
// log
}.recoverCatching {
async2.await()
}.getOrElse {
...
}
}
}
이렇게 구현하면, supervisorScope가 내부적으로 async1, async2의 부모가 되면서 실패전이를 막아줍니다.
그림으로는 아래와 같은 구조가 되게 됩니다.
다만 코루틴은 supervisorScope가 모두 종료될때까지 대기하므로 아래와 같은 코드를 작성하면 코루틴이 끝날때까지 총 30초가 걸리게 됩니다.
runBlocking {
supervisorScope {
launch {
delay(10s)
}
}
supervisorScope {
launch {
delay(10s)
}
}
supervisorScope {
launch {
delay(10s)
}
}
}
'끄적끄적' 카테고리의 다른 글
[끄적끄적] supervisorScope를 주의해서 사용하자 (0) | 2025.01.19 |
---|---|
[끄적끄적] Saga에서 Exception을 Json으로 변환하다가 발생한 에러와 해결 (2) | 2024.11.10 |
[끄적끄적] 44만 SAGA 를 처리하며 얻은 인사이트 (1) | 2024.10.27 |
[끄적끄적] 효율적인 키 분배 및 리밸런싱 방식 (1) | 2024.06.16 |
[끄적끄적] 주문 Saga Isolation 부족 해결하기 (1) | 2024.05.02 |