본문 바로가기
공부/코틀린코루틴:딥다이브(마르친 모스카와)

30장 - 모범 사례

by 띵커베르 2024. 3. 16.
728x90
  • async 코루틴 빌더 뒤에 await 를 호출하지 마세요
    • async 로 비동기 작업을 정의한 뒤, 아무것도 하지 않은 채 연산이 완료되는 걸 기다리는건 아무 의미가 없습니다.
// 이렇게 구현하지 마세요.
suspend fun getUser(): User = coroutineScope { 
    val user = async { repo.getUser() }.await()
    user.toUser()
}


// 이렇게 구현하세요.
suspend fun getUser(): User  {
    val user = repo.getUser()
    return user.toUser()
}

 

 

  • withContext(EmptyCoroutineContext) 대신 coroutineScope 를 사용하세요
    • withContext 와 coroutineScope 의 차이는 withContext 가 컨텍스트를 재정의 할 수 있다는 것밖에 없으므로,
      withContext(EmptyCoroutineContext) 대신 coroutineScope 를 사용하세요
    • 더보기
      withContextwithContext는 코루틴의 컨텍스트를 임시적으로 변경하고자 할 때 사용됩니다. 이 함수는 코루틴의 실행 컨텍스트(예: 디스패처)를 변경하면서 코드 블록을 실행하고, 코드 블록이 완료되면 원래 컨텍스트로 돌아옵니다.withContext는 결과 값을 반환할 수 있으며, 호출된 스코프의 코루틴이 중단될 때까지 대기합니다. 따라서 비동기 작업의 결과를 기다리고 그 결과를 반환하고자 할 때 유용합니다.예: withContext(Dispatchers.IO) { ... }는 I/O 작업을 위해 디스패처를 임시로 변경합니다.coroutineScopecoroutineScope는 새로운 코루틴 스코프를 생성하지 않습니다. 대신 현재 코루틴의 스코프 내에서 새로운 코루틴을 시작할 수 있는 블록을 제공합니다. 이 블록 내에서 시작된 모든 코루틴이 완료될 때까지 coroutineScope는 중단됩니다.coroutineScope는 자식 코루틴들이 모두 완료될 때까지 현재 코루틴의 실행을 중단시키므로, 여러 비동기 작업을 병렬로 실행하고 모두 완료될 때까지 기다리고자 할 때 사용됩니다.coroutineScope는 현재 컨텍스트를 변경하지 않습니다.주요 차이점컨텍스트 변경: withContext는 코루틴의 실행 컨텍스트를 임시적으로 변경할 수 있지만, coroutineScope는 현재 컨텍스트를 유지합니다.용도: withContext는 주로 컨텍스트를 변경하면서 결과 값을 반환하고 싶을 때 사용되고, coroutineScope는 현재 스코프 내에서 여러 비동기 작업을 관리하고 싶을 때 사용됩니다.withContext(EmptyCoroutineContext) 대신 coroutineScope 사용 권장withContext(EmptyCoroutineContext)를 사용하면 컨텍스트를 변경하지 않는 효과가 있으므로, 이 경우 coroutineScope를 사용하는 것이 더 적절합니다. withContext(EmptyCoroutineContext)는 컨텍스트 변경의 의도가 없을 때 혼란을 줄 수 있으며, coroutineScope는 이런 경우에 의도를 더 명확하게 표현합니다. coroutineScope는 현재 컨텍스트를 유지하면서 새로운 코루틴을 시작할 수 있으므로, 컨텍스트 변경 없이 여러 비동기 작업을 동시에 실행하고 싶을 때 사용하기에 적합합니다.

 

  • awaitAll 을 사용하세요
    • awaitAll 함수는 비동기 작업 중 하나가 예외를 던졌을 경우에 곧바로 멈추며, map {it.await()} 는 실패하는 작업에 맞닥뜨릴 때까지 하나씩 기다리므로 map {it.await()} 보다 awaitAll 을 사용해야 합니다.
      • awaitAll 함수는 여러 비동기 작업(Deferred 객체)을 동시에 기다리는 데 사용됩니다. 이 함수는 모든 작업이 성공적으로 완료될 때까지 기다리고, 결과를 모아 반환합니다. 하지만, 이 작업 중 하나라도 예외를 던지면, awaitAll은 즉시 실패하며, 나머지 작업의 완료를 기다리지 않습니다. 이 접근 방식은 모든 작업이 동시에 실행되며, 첫 번째 예외가 발생하는 즉시 처리가 중단된다는 장점이 있습니다.
      • 반면에, map { it.await() }를 사용하면, Deferred 객체의 리스트를 순회하며 각각을 차례로 기다립니다. 이 방식은 각 작업이 순차적으로 완료될 때까지 기다리므로, 만약 어느 하나의 작업에서 예외가 발생하면, 그 시점까지 완료된 작업들의 결과만 사용할 수 있습니다. 이는 awaitAll보다 비효율적일 수 있으며, 모든 작업이 병렬로 실행되더라도 실패한 작업에 도달할 때까지 기다린다는 단점이 있습니다.
fun main30_1() = runBlocking {
    val deferredList = listOf(
            async { delay(1000); println("Task 1 completed"); 1 },
            async { delay(500); throw RuntimeException("Error in Task 2"); 2 },
            async { delay(100); println("Task 3 completed"); 3 }
    )

    try {
        val results = deferredList.awaitAll() // 모든 작업을 동시에 기다림
        println("Results: $results")
    } catch (e: Exception) {
        println("Caught an exception: ${e.message}")
    }

    val individualResults = mutableListOf<Int>()
    try {
        deferredList.map {
            try {
                individualResults.add(it.await()) // 각 작업을 차례로 기다림
            } catch (e: Exception) {
                println("Caught an exception in map: ${e.message}")
            }
        }
        println("Individual Results: $individualResults")
    } catch (e: Exception) {
        println("Caught an exception: ${e.message}")
    }
}

 

 

  • 중단 함수는 어떤 스레드에서 호출되어도 안전해야 합니다.
    • 중단 함수를 호출할 때, 지금 사용하고 있는 스레드가 블로킹될까 봐 걱정하면 안 됩니다.
    • 중단 함수가 블로킹 함수를 호출할 때는 Dispatchers.IO 나 블로킹에 사용하기로 설계된 커스텀 디스패처를 사용해야 합니다.
    • CPU 집약적인 디스패처로는 Dispatchers.Defaul 나 병렬 처리 수가 제한된 Dispatchers.Defaul 를 사용 해야 합니다.
    • 함수를 호출할 때 디스패처를 설정할 필요가 없도록 withContext 로 디스패처를 설정해야 합니다.
suspend fun fetchDataFromNetwork(): String {
    // 네트워크 호출을 가정한 IO 작업
    return withContext(Dispatchers.IO) {
        // 네트워크 요청을 여기서 수행
        "데이터"
    }
}

suspend fun processData(data: String): String {
    // 데이터 처리를 가정한 CPU 집약적 작업
    return withContext(Dispatchers.Default) {
        // 데이터 처리 로직
        data.reversed() // 예시로, 데이터를 뒤집는 작업을 수행
    }
}

fun main30_2() = runBlocking {
    val data = fetchDataFromNetwork()
    val processedData = processData(data)
    println(processedData)
}

 

 

  • Dispatchers.Main 대신 Dispatchers.Main.immediate 를 사용하세요
    • Dispatchers.Main.immediate 는 Dispatchers.Main 이 최적화된 것으로 필요한 경우에만 코루틴을 재분배 합니다.
    • 보통은 Dispatchers.Main.immediate 를 사용 합니다

 

  • 무거운 함수에서는 yield 를 사용하는 것을 기억하세요
    • 중단 가능하지 않으면서 CPU 집약적인 또는 시간 집약적인 연산들이 중단 함수에 있다면, 각 연산들 사이에 yield 를 사용하는 것이 좋습니다
suspend fun performLongRunningTask() {
    for (i in 1..100) {
        // 시간이 많이 소요되는 연산 수행
        heavyCalculation(i)

        // 다른 코루틴에게 실행을 양보
        yield()
    }
}

suspend fun performLongRunningTask2() {
    for (i in 1..100) {
        // 시간이 많이 소요되는 연산 수행
        heavyCalculation(i)

        // 다른 코루틴에게 실행을 양보
        yield()
    }
}

suspend fun heavyCalculation(i: Int) {
    // 복잡한 계산 수행 (예시)
    println("i: $i")
    Thread.sleep(10) // 대체로, 실제 애플리케이션에서는 Thread.sleep 대신 실제 계산을 수행
}

fun main30_3() = runBlocking {
    launch {
        performLongRunningTask()
    }
    launch {
        performLongRunningTask2()
    }
    // 다른 코루틴 작업을 여기에 추가할 수 있습니다.
}


i: 1
i: 1
i: 2
i: 2
i: 3
i: 3
i: 4
i: 4
i: 5
i: 5
i: 6
i: 6
i: 7
....

 

 

 

  • 중단 함수는 자식 코루틴이 완료되는 걸 기다립니다.
    • 부모 코루틴은 자식 코루틴이 끝나기 전에 완료될 수 없으며, coroutineScope 나 withContext 같은 코루틴 스코프 함수는 스코프 내의 코루틴이 완료될 때까지 부모 코루틴을 중단시킵니다.
    • 그 결과, 부모 코루틴은 부모 코루틴이 시작한 모든 코루틴을 기다리게 됩니다.

suspend fun longTask() = coroutineScope {
    launch {
        delay(1000)
        println("Done 1")
    }
    launch {
        delay(2000)
        println("Done 2")
    }
}

fun main30_4() = runBlocking {
    println("Before")
    longTask()
    println("After")
}

Before
-- 1초 후
Done 1
-- 2초 후
Done 2
After
  • 코루틴 스코프 함수의 마지막 코드에 launch 를 사용하면 launch 를 제거해도 똑같기 때문에 굳이 launch 를 사용할 필요가 없습니다.
  • 중단 함수는 함수 내에서 시작한 코루틴이 완료되는 걸 기다리는 것이 기본 동작입니다. 외부 스코프를 사용하면 이 원칙을 위배할 수 있으며 합당한 이유가 없다면 이런 방식은 피해야 합니다.

 

 

  • Job 은 상속되지 않으며, 부모 관계를 위해 사용됩니다.
    • Job 은 유일하게 상속되지 않는 컨텍스트입니다.
    • 코루틴은 각자의 잡을 가지고 있으며, 잡을 자식 코루틴으로 전달하고, 전달된 잡이 자식 코루틴 잡의 부모가 됩니다.

// 이렇게 구현하면 안 됩니다.
fun main30_5() = runBlocking(SupervisorJob()) {
    launch {
        delay(1000)
        throw Error()
    }
    launch {
        delay(1000)
        println("Done")
    }
    launch {
        delay(3000)
        println("Done")
    }
}

 

 

 

  • 구조화된 동시성을 깨뜨리지 마세요.
    • Job 을 명시적인 코루틴 컨텍스트로 설정했을 경우, 코루틴의 부모 관계가 깨질 수 있습니다.
    • Job 을 코루틴의 인자로 사용할 경우 Job 이 코루틴의 부모로 설정되는 문제가 발생합니다.

 

  • CoroutineScope 를 만들 때는 SupervisorJob 을 사용하세요
    • 스코프를 만들 때, 보통 스코프에서 시작한 코루틴에서 예외가 발생하면 다른 모든 코루틴을 취소하지 않을 거라고 생각합니다.
    • 예외가 전파되지 않는 스코프를 만들려면 디폴트로 설정된 Job 대신에 SupervisorJob 을 사용해야 합니다.
    • Job: 기본적으로, 코루틴 스코프는 Job 객체를 사용하여 스코프 내의 코루틴들을 관리합니다. 스코프 내의 한 코루틴에서 예외가 발생하면, 해당 예외는 스코프 내의 모든 코루틴을 취소하는데, 이는 모든 자식 코루틴들이 함께 실패하도록 보장합니다. 이는 코루틴 간의 강한 결합과 오류 전파를 의미합니다.
    • SupervisorJob: 반면, SupervisorJob을 사용하면 코루틴 스코프 내에서 예외의 전파 방식이 달라집니다. 스코프 내의 한 코루틴에서 예외가 발생해도, SupervisorJob은 이 예외가 다른 코루틴으로 전파되어 나머지 코루틴들을 취소하지 않습니다. 즉, 하나의 코루틴 실패가 전체 스코프의 다른 코루틴들에 영향을 미치지 않도록 합니다.
// Job 으로..
fun main30_6() = runBlocking {
    val scopeWithJob = CoroutineScope(Job() + Dispatchers.IO)

    scopeWithJob.launch {
        println("Task 1 is starting")
        throw Exception("Error in Task 1")
    }

    scopeWithJob.launch {
        delay(100) // Task 1에서 예외가 발생하면 이 작업은 취소됩니다.
        println("Task 2 is running") // 이 메시지는 출력되지 않습니다.
    }

    delay(500) // 충분한 실행 시간을 주기
}

Task 1 is starting
Exception in thread "DefaultDispatcher-worker-1 @coroutine#2" ...
.....

 

// SupervisorJob 으로..
fun main30_7() = runBlocking {
    val scopeWithSupervisorJob = CoroutineScope(SupervisorJob() + Dispatchers.IO)

    scopeWithSupervisorJob.launch {
        println("Task 1 is starting")
        throw Exception("Error in Task 1") // 이 예외는 이 코루틴에만 영향을 미칩니다.
    }

    scopeWithSupervisorJob.launch {
        delay(100) // Task 1에서 예외가 발생해도, 이 작업은 계속 실행됩니다.
        println("Task 2 is running") // 이 메시지는 정상적으로 출력됩니다.
    }

    delay(500) // 충분한 실행 시간을 주기
}
Task 1 is starting
Exception in thread "DefaultDispatcher-worker-1 @coroutine#2" java.lang.Exception: Error in Task 1
...
Task 2 is running

 

 

 

  • 스코프의 자식은 취소할 수 있습니다.
    • 스코프가 취소되고 나면, 취소된 스코프를 다시 사용할 수 없습니다.
    • 스코프에서 시작한 모든 작업을 취소하지만 스코프를 액티브 상태로 유지하고 싶은 경우에는, 스코프의 자식을 취소하면 됩니다.
    • 스코프를 유지하는 건 아무런 비용이 들지 않습니다.
fun main30_8() = runBlocking {
    val scope = CoroutineScope(Dispatchers.Default)

    // 자식 코루틴 1
    val childJob1 = scope.launch {
        delay(1000)
        println("Child coroutine 1 completed.")
    }

    // 자식 코루틴 2
    val childJob2 = scope.launch {
        delay(2000)
        println("Child coroutine 2 completed.")
    }

    // 특정 조건에서 첫 번째 자식 코루틴만 취소
    println("Cancelling child coroutine 1...")
    childJob1.cancel() // 첫 번째 자식 코루틴 취소

    // 나머지 작업이 완료되기를 기다림
    childJob2.join() // 두 번째 코루틴의 완료를 기다림
    println("Main coroutine is completed.")
}

Cancelling child coroutine 1...
Child coroutine 2 completed.
Main coroutine is completed.

 

 

  • GlobalScope 를 사용하지 마세요
    • 사용하기는 쉽지만 SupervisorJob 컨텍스트로 아주 간단한 스코프를 만드는게 낫다.
    • GlobalScope 는 관계가 없으며, 취소도 할 수 없고, 테스트를 위해 오버라이딩 하는 것도 힘듭니다.
    • GlobalScope 가 당장 필요하더라도, 의미 있는 스코프를 만드는 것이 나중에 도움이 될 것임.

 

  • 스코프를 만들 때를 제외하고, Job 빌더를 사용하지 마세요.
    • Job 함수를 사용해 잡을 생성하면, 자식의 상태와는 상관없이 액티브 상태로 생성된다.
    • 자식 코루틴 일부가 완료되더라도, 부모 또한 완료되는 것은 아닙니다.
// 이렇게 하지 마세요
fun main30_9(): Unit = runBlocking {
    val job = Job()
    launch(job) {
        delay(1000)
        println("Text 1")
    }

    launch(job) {
        delay(2000)
        println("Text 2")
    }

    job.join() // 여기서 영원히 대기하게 된다.
    println("Will not be prointed")
}

Text 1
Text 2
-- 영원히 대기


// 이렇게 해라
-------------------------------------------------

fun main30_10() = runBlocking {
    val job1 = launch {
        delay(1000)
        println("Text 1")
    }

    val job2 = launch {
        delay(2000)
        println("Text 2")
    }

    // 각 코루틴의 완료를 기다립니다.
    job1.join()
    job2.join()
    println("Both coroutines have completed")
}

Text 1
Text 2
Both coroutines have completed
  • Job 이 완료되려면, complete 메서드가 먼저 호출되고 'Active' 상태에서 자식 코루틴이 종료될 때까지 기다리는 'Completing' 상태로 바뀌어야 합니다.
  • 하지만 완료 중이거나 완료된 잡에서 새로운 코루틴을 시작할 수 없습니다.
  • 잡의 자식 참조를 사용해 자식 코루틴을 기다리는 것이 좀더 실용적입니다.
728x90

댓글