Kotlin Coroutine: suspend 함수를 Effective 하게 설계하자!

Murjune·2024년 6월 24일
2

Coroutine

목록 보기
1/8
post-thumbnail
  • 해당 포스팅에서 언급하는 코루틴은 kotlin-coroutine 입니다.
  • 코루틴에 대한 기본 지식이 없다면 해당 포스팅을 이해하기 어려울 수 있습니다.
  • 내부적으로 suspend function 이 어떻게 중단/재개되는지 다루지 않습니다.
  • 이번 포스팅과 suspend function 공식문서 을 함께 읽는 것을 추천해요 ⭐️

Intro

간단한 예제를 통해 기본적인 suspend function 활용법에 대해 알아보자

// super 개발자의 삶..🫢
fun main() = runBlocking {
    println("아침에 일어난다")
    println("밥을 먹는다.")
    delay(100)
    println("코딩하기")
    println("밥을 먹는다.")
    delay(100)
    println("코딩하기")
    delay(100)
    println("밥을 먹는다.")
    delay(100)
    println("코딩하기")
    delay(100)
    println("잠을 잔다.")
}

현재 "코딩하기"와 "밥을 먹는다." 코드가 중복해서 사용되고 있다. 이런 경우, 함수화를 통해 코드구조를 개선시키고 싶을 것이다.

일반 함수의 경우 일시 중단을 지원하지 않기 때문에 코루틴을 일시 중단하는 delay() 를 호출 할 수 없다.(참고로 delay() 함수도 suspned 함수이다.)

이럴 때, 일시 중단가능한 suspend function 을 사용해 함수화를 해주면 된다.

suspend fun coding() {
    println("코딩하기")
    delay(100)
}

suspend fun eat() {
    println("밥을 먹는다.")
    delay(100)
}

fun main() = runBlocking {
    println("아침에 일어난다")
    eat()
    coding()
    eat()
    coding()
    eat()
    coding()
    println("잠을 잔다.")
}

이처럼 suspend function 은 코루틴을 활용한 복잡한 비동기 코드를 구조화하여 재사용성과 가독성을 위해 사용된다.

이제 suspend function 여러 테스트 케이스를 통해 suspend function 의 여러가지 사용법에 대해 알아보자 😁

만약, 코루틴 테스트를 처음 접하는 독자가 있다면 다음 포스팅을 먼저 읽고 오길 추천한다
코루틴 테스트 쌩기초 탈출하기 💪

1) Suspend 함수는 코루틴이 아니다.

suspend fun suspendFuncA() {
    delay(300)
	println("Hello")
}

suspend fun suspendFuncB() {
	delay(200)
	println("Odooong")
}

간단한 suspend 함수 suspendFuncA , suspendFuncA 가 있다. 각각, 300ms, 200ms delay를 주었다.

@Test
fun `Suspend 함수는 코루틴이 아니다`() = runTest {
    val job = launch {
        suspendFuncA()
        suspendFuncB()
    }
    advanceUntilIdle() // 현재 testScope 내부 코루틴 작업이 모두 끝날 때까지 대기
    currentTime shouldBe 500
    // output: Hello Odooong
}

해당 테스트는 몇 초 뒤에 종료될까? 🤔 300ms? 500ms?
한 번 생각해보시죠 ㅎ ㅎ
.
.
.
.
suspendFuncA()suspendFuncB() 가 서로 독립적인 코루틴이라 생각하고, suspend 함수들이 비동기적으로 실행되어 300ms 만큼 시간이 걸릴 것이라 예상한 독자들도 있을 것이다.

그러나, 해당 코드는 위 그림처럼 실행된다.
suspend function 이 종료될 때까지 호출부 코루틴(runBlocking)은 blocking 되기에 해당 테스트는 총 500ms 만큼 시간이 걸린다.

위 테스트 코드는 아래 코드와 완전히 동일하다.

@Test
fun `suspend function 은 코드 블록에 불과하다 - 위 테스트 함수와 완전히 동일`() = runTest {
    val job = launch {
    	delay(300)
		println("Hello")
		delay(200)
		println("Odooong")
    }
    advanceUntilIdle() 
    currentTime shouldBe 500
    // output: Hello Odooong
}

Suspend function 은 코루틴이 아니다. 호출부 코루틴 내에서 돌아가는 중단 가능한 코드 블럭에 불과하는 점을 잊지 말자

Intro 에서 다룬 예제와 비슷한데 자주 오해할 수 있는 내용이라 한 번 더 강조하기 위해 다뤘다.

2) suspend function 에서 병렬 처리할 때, CoroutineScope를 사용하지 말자

이번에는 suspendFuncA() 외 suspendFuncB() 를 병렬처리 해보자

@Test
fun `비동기 처리 - 동시성`() = runTest {
    val job = launch {
		val childA = launch {
			suspendFuncA()
        }
        val childB = launch {
            suspendFuncB()
        }
    }
    advanceUntilIdle()
    currentTime shouldBe 300
    // output: Odooong Hello
}

빌드 시 suspendFuncA()와 suspendFuncB() 를 동시에 실행시키기 때문에 해당 테스트는 300ms 만큼 걸린다.

이때, 인덴트 깊이가 늘어나는 것이 가독성을 해친다고 생각하여 다음과 같이 launch{} 을 suspend 함수 내로 분리하고 싶은 욕구가 들 수도 있다.

그러나, launch() 함수는 CoroutineScope의 확장 함수이기에 suspend function에서 바로 호출이 불가능하다. launch() 함수를 사용하기 위해서는 새로운 CoroutineScope 를 통해 열어줘야 한다.

여기서, 많은 사람들이 CoroutineScope 를 사용하는 실수를 한다.

CoroutineScope 를 사용하면서 구조화된 동시성을 유지해주기 위해선 CoroutineContext 를 넣어줘야한다. 이때, 다음과 같이 Dispatcher.IO 혹은 EmptyCoroutineContext 를 넣어주곤 한다.

해당 코드는 문제가 없을까?

❌ 아니다. 해당 코드에는 2가지 문제점이 있다.

  • 1) 호출자의 코루틴과 구조화된 동시성이 깨진다
  • 2) suspend function 이 종료되어도 코루틴은 동작한다.(즉, 비동기적으로 코드가 동작한다)

CoroutineScope()은 매개변수에 호출자의 Job or Job을 포함하는 CoroutineContext를 넘겨주지 않으면, 호출부 코루틴과 독립적인 코루틴 환경을 구축한다.

따라서, 위 그림처럼 호출부의 코루틴과의 구조화된 동시성이 깨지게 된다.

// suspend 키워드도 빼도 된다
fun suspendFunc() {
    val independantJob = CoroutineScope(Dispatchers.IO).launch {
        delay(200)
        println("Hello Odooong")
    }
}

@Test
fun `다른 코루틴 디스패처를 사용하면 구조화된 동시성이 깨져, 독립된 코루틴이 된다`() = runTest {
	// runTest 과 아래 suspendFunc() 에서 생성된 코루틴은 별개이다.
	suspendFunc()
    advanceUntilIdle()
    currentTime shouldBe 0
    // Hello Odooong 이 호출되지 않음
}

부모 코루틴은 자식 코루틴이 모두 종료될때까지 대기하는 특성을 가지고 있다. 그러나, CoroutineScope를 사용했기에 runTest 코루틴(부모 코루틴)과의 구조를 깨버렸기에 runTest 코루틴은 independantJob이 끝날 때까지 대기하지 않는다.

테스트를 실행시켜보면 advanceUntilIdle() 를 호출했음에도 runTest 가 바로 종료되는 것을 볼 수 있다.

  • coroutineContext 활용
suspend fun suspendFuncAWithCoroutineScope() {
    CoroutineScope(coroutineContext + CoroutineName("ChildA")).launch {
        delay(300)
        print(" Hello ")
    }
}

suspend fun suspendFuncBWithCoroutineScope() {
    CoroutineScope(coroutineContext + CoroutineName("ChildB")).launch {
        delay(200)
        print(" Odooong ")
    }
}

coroutineContext property 를 활용하면 현재 실행되고 있는 코루틴의 context를 가져올 수 있다. CoroutineScope 에 coroutineContext를 넣어주면 runTest 코루틴과 구조화된 동시성을 유지할 수 있다.

그럼 테스트 코드를 다음과 같이 깔끔하게 나타낼 수 있다.

@Test
fun `suspend 함수 내에 코루틴 스코프를 열어 자식 코루틴 생성 - 동시성`() = runTest {
	suspendFuncAWithCoroutineScope()
	suspendFuncBWithCoroutineScope()
    advanceUntilIdle()
    currentTime shouldBe 300
    // Output: Odooong Hello
}

휴! 시간도 300ms 로 단축했고, 가독성도 챙겼으니 해당 코드는 좋은 코드일까? 🤔

❌ 언듯 보기에는 좋아보일 수 있으나, 잘못 설계한 것이다.
동료개발자는 suspend 함수가 종료되는 시점에 내부 작업들이 끝났을 것이라 예상할 것이다.

그러나, 실상은 그림과 같이 suspend 함수는 자식 코루틴을 만들고 바로 종료되고, 자식 코루틴들은 비동기적으로 실행되고 있다.

이는 심각한 버그의 원인이 될 수 있으며, 어디서 발생한 버그인지 찾기도 매우 힘들다.🥲

위 코드와 비슷한 형태로 설계된 코드가 때문에 버그가 발생하여 쌩고생한 경험이 있다 🥲

  • suspend function 에서 CoroutineScope 를 사용하지 말자
  • suspend function 실행이 종료되었을 때, 내부 코드의 실행이 완료되도록 설계하자!

3) coroutineScope or withContext 함수를 활용하자!⭐️

suspend function 내부 동작을 병렬 처리하고 싶다면, coroutineScope 를 활용하자!(대문자 CoroutineScope 말고 소문자 coroutineScope 라는 함수가 있다)

@Test
fun `suspend 함수 분리 - 동시성`() = runTest {
    mergedSuspendFunc()
    currentTime shouldBe 300
    // Output: Odooong Hello
}

suspend fun mergedSuspendFunc() = coroutineScope {
    launch(CoroutineName("ChildA")) {
        suspendFuncA()
    }
    launch(CoroutineName("ChildB")) {
        suspendFuncB()
    }
}

coroutineScope를 사용하면 runTest 코루틴mergedSuspendFunc() 가 종료될 때까지 대기하고, mergedSuspendFunc() 내부에서는 병렬적으로 코드를 실행하도록 할 수 있다.

코루틴 관계도는 다음과 같다

코드 구조와 시간적 효율성 2마리 토끼를 모두 잡을 수 있게 되었다 😁

coroutineScope 의 특성

  • 호출부의 coroutineContext 를 상속받는 Job을 생성하기에 코루틴의 구조화된 동시성을 깨지 않는다.
  • 자식 코루틴이 모두 끝날때까지 호출부의 코루틴을 일시 중단시킨다는 특징이 있다.(suspend function과 찰떡궁합이다.)
  • 코드 블럭의 마지막 값을 return 한다.

만약, 다른 디스패처를 활용하고 싶다면 withContext를 활용해주면 된다. 디스패터를 지정해줄 수 있다는 점을 제외하고 coroutineScope와 동일한 기능을 한다.

suspend fun mergedSuspendFunc() = withContext(Dispatcher.IO) {
    launch {
        suspendFuncA()
    }
    launch {
        suspendFuncB()
    }
}

suspend 함수 내부에서 병렬 처리를 하고 결과값을 반환해야 할 경우가 종종 생긴다. 이런 경우 coroutineScope 와 async 를 함께 사용하면 된다.(Like 분할정복)

우테코 선릉 캠퍼스 크루들을 fetch 해오는 예시를 통해 알아보자!

suspend fun fetchWootecoAndroidCrews(): List<String> {
    delay(300)
    return listOf("오둥이", "꼬상", "하디", "팡태", "악어", "케이엠")
}

suspend fun fetchWootecoFrontendCrews(): List<String> {
    delay(200)
    return listOf("토다리", "제이드")
}

안드 크루와 프론트 크루를 불러오는 api가 있다
두 함수의 실행 결과는 서로 독립적이기 때문에 병렬 처리하기에 매우 적합하다 😁

suspend fun fetchWootecoCrews(): List<String> = coroutineScope {
        val androidJob = async { fetchWootecoAndroidCrews() }
        val frontJob = async { fetchWootecoFrontendCrews() }
        // 결과값 반환
        androidJob.await() + frontJob.await()
}

따라서, async{}로 각 함수가 서로 다른 코루틴에서 실행되도록 묶어준 후, 결과값을 반환해주는 부분에 await()를 호출해준다.

@Test
fun `우테코 선릉 캠퍼스 크루들 불러오기`() = runTest {
    val crews = fetchWootecoCrews()
    currentTime shouldBe  300
    crews shouldContainExactlyInAnyOrder listOf("오둥이", "꼬상", "하디", "팡태", "악어", "케이엠", "토다리", "제이드")
}

이처럼 어떤 값을 반환하고 내부적으로 병렬처리를 하는 suspend function 을 설계할 때 coroutineScope + async를 활용해보자 😁

필자는 우테코 쇼핑 주문하기 미션에서 적용가능한 쿠폰을 불러오는 UseCase 에서 coroutineScope + async 를 활용하여 병렬처리를 적용해본 적이 있다.

해당 코드

4) supervisorScope 사용을 고려해보자(심화)

suspend function 내에서 coroutineScopeasync/launch 를 활용하여 여러 api 들을 병렬 처리할 때 한가지 제약이 있다. 자식 코루틴이 한개라도 exception이 터진다면 예외가 전파되어 모든 코루틴이 cancel된다는 것이다.

만약, 기획단에서 통신에 성공한 데이터라도 보여달라고 요청한다면 어떻게 해야될까?

이번엔 우테코 코치님들을 불러오는 예시를 통해 알아보자!

suspend fun fetchAndroidCoaches(): List<String> {
    delay(50)
    return listOf("제이슨", "레아", "제임스")
}

suspend fun fetchFrontCoaches(): List<String> {
    delay(150)
    return listOf("준", "크론")
}

suspend fun fetchBackCoaches(): List<String> {
    delay(70)
    throw NoSuchElementException("제가 백엔드 코치님들은 모릅니다..")
}

현재 fetchBackCoaches() 함수에서 error를 발생시키고 있다.

@Test
fun `하나라도 예외가 발생하면, 모든 작업이 취소된다`() = runTest {
    shouldThrow<NoSuchElementException> {
        fetchErrorWootecoCoaches()
    }
    currentTime shouldBe 70
}

suspend fun fetchWootecoCoaches() = coroutineScope {
    val androidJob = async { fetchAndroidCoaches() }
    val frontJob = async { fetchFrontCoaches() }
    val backendJob = async { fetchBackCoaches() }
    androidJob.await() + frontJob.await() + backendJob.await()
}

fetchWootecoCoaches() 를 불러올 때 예외가 fetchBackCoaches -> coroutineScope -> runTest 로 예외가 전파되며, 모든 작업들은 취소가 된다.

이때, 기획에서는 통신에 성공한 코치님 데이터라도 불러와 화면에 보여달라 요청을 했다!

이럴 때 에러 전파를 방지하기 위해 supervisorScope{} 를 활용할 수 있다.

supervisorScope 는 coroutineScope 와 거의 똑같지만, 자식 코루틴의 예외 전파를 차단시킨다는 특성이 있다.

이를 활용하여 다음과 같이 개선할 수 있다.

suspend fun fetchWootecoCoaches() = supervisorScope { 
    val androidJob = async { fetchAndroidCoaches() }
    val frontJob = async { fetchFrontCoaches() }
    val backendJob = async { fetchBackCoaches() }
    // result
    val backendResult = runCatching { backendJob.await() }
    androidJob.await() + frontJob.await() + backendResult.getOrDefault(emptyList())
}

기존 coroutineScope가 supervisorScope로 교체된 것 외에도, await() 하는 부분에서 runCatching으로 예외 처리를 해주는 것을 볼 수 있다.

async 으로 인해 만들어진 코루틴에서 Exception이 발생하면 1) 전파되는 예외 + 2) await() 에서 발생하는 예외가 모두 처리 해줘야하기 때문이다.

@Test
fun `supervisorScope 를 활용하여 Error 전파 방지`() = runTest {
    val coaches = fetchWootecoCoaches()
    currentTime shouldBe 150
    coaches shouldContainExactlyInAnyOrder listOf("제이슨", "레아", "제임스", "준", "크론")
}

여러 코루틴 성공한 값들만 불러와 반환하도록 개선해주었다!

정리

  • suspend function 코루틴이 아니다.
  • suspend function 은 호출부 코루틴의 코드블럭이다.
  • suspend function 이 종료될 때, 내부 실행 코드도 종료되도록 설계하자
  • suspend function 내부에서 병렬 처리를 할 떄, 구조화된 동시성을 보장해주기 위해 coroutineScope/withContext 를 사용하자
  • 자식 코루틴의 예외 전파를 방지하고 싶다면, coroutineScope 대신 supervisorScope를 사용하자

긴 글 읽어주셔서 감사합니다.
이해가 안되거나 피드백 주실 부분이 있다면 편하게 댓글로 부탁드려요!! 🙇‍♂️

profile
열심히 하겠슴니다:D

2개의 댓글

comment-user-thumbnail
2024년 7월 2일

잘보고 갑니다 ㅎㅎ

1개의 답글

관련 채용 정보