코틀린 코루틴 (11장 정리 - 코루틴 스코프 함수)

윤성현·2024년 12월 28일

코틀린 코루틴

목록 보기
11/11
post-thumbnail

11장. 코루틴 스코프 함수

여러 개의 엔드포인트에서 데이터를 동시에 얻어야 하는 중단 함수를 떠올려보자.
가장 바람직한 방법을 보기 전에 차선책부터 살펴보자! 🎯

코루틴 스코프 함수가 소개되기 전에 사용한 방법들

중단함수에서 중단함수를 호출하는 방법

  • 문제 : 작업이 동시에 진행되지 않음
// 데이터를 동시에 가져오지 않고, 순차적으로 가져옴
suspend fun getUserProfile(): UserProfileData {
	val user = getUserData() // (1초 후)
	val notifications = getNotifications() // (1초 후)
	return UserProfileData(
		user = user,
		notifications = notifications,
	)
}

두 개의 중단 함수를 각각 async로 래핑해서 동시에 실행

  • GlobalScope 사용
// 이렇게 구현하면 안 됩니다! (GlobalScope 사용은 바람직하지 않음)
suspend fun getUserProfile(): UserProfileData {
	val user = GlobalScope.async { getUserData() }
	val notifications = GlobalScope.async { getNotifications() }
	return UserProfileData(
		user = user.await(), // (1초 후)
		notifications = notifications.await(),
	)
}

GlobalScope에서 async를 호출하면 부모 코루틴과 아무런 관계가 없음. 이때 async 코루틴은

  • 취소될 수 없음
    • 부모가 취소되어도 async 내부의 함수가 실행 중인 상 태가 되므로 작업이 끝날 때까지 자원이 낭비됨
  • 부모로부터 스코프를 상속받지 않음
    • 항상 기본 디스패처에서 실행되며, 부모의 컨텍스트를 전혀 신경 쓰지 않음
  • 메모리 누수가 발생할 수 있으며 쓸데없이 CPU를 낭비함
  • 코루틴을 단위 테스트하는 도구가 작동하지 않아 함수를 테스트하기 아주 어려움

스코프를 인자로 넘기는 방법

// 이렇게 구현하면 안 됩니다!
suspend fun getUserProfile(
	scope: CoroutineScope
): UserProfileData {
	val user = scope.async { getUserData() }
	val notifications = scope.async { getNotifications() }
	return UserProfileData(
		user = user.await(), // (1초 후)
		notifications = notifications.await(),
	)
}

// 또는

// 이렇게 구현하면 안 됩니다!
suspend fun CoroutineScope.getUserProfile(): UserProfileData { 
	val user = async { getUserData() }
	val notifications = async { getNotifications() }
	return UserProfileData(
		user = user.await(), // (1초 후)
		notifications = notifications.await()
	)
}
  • 취소 가능, 적절한 단위 테스트 추가 가능
  • 문제 : 스코프가 함수에서 함수로 전달됨
    • 스코프가 함수로 전달되면 스코프에서 예상치 못한 부작용이 발생할 수 있음
    • ex) async에서 예외가 발생하면 모든 스코프가 닫히게 됨
    • ex) 스코프에 접근하는 함수가 cancel 메서드를 사용해 스코프를 취소하는 등 스코프를 조작할 수 있음

coroutineScope

coroutineScope는 스코프를 시작하는 중단 함수이며, 인자로 들어온 함수가 생성한 값을 반환

suspend fun <R> coroutineScope(
	block: suspend CoroutineScope.() -> R
): R

coroutineScope함수는 새로운 코루틴을 생성하지만 새로운 코루틴이 끝날 때까지 호출한 코루틴을 중단하기 때문에 호출한 코루틴이 작업을 동시에 시작하지는 않음

fun main() = runBlocking {
	val a = coroutineScope {
		delay(1000)
		10
	}
	println("a is calculated")
	val b = coroutineScope {
		delay(1000)
		20
	}
	println(a) // 10
	println(b) // 20
}

// (1초 후)
// a is calculated
// (1초 후)
// 10
// 20

생성된 스코프는 컨텍스트의 Job을 오버라이딩하여 생성된 스코프는 부모가 해야할 책임을 이어받음

  • 부모로부터 컨텍스트를 상속받음
  • 자신의 작업을 끝내기 전까지 모든 자식을 기다림
  • 부모가 취소되면 자식들 모두를 취소함
// CoroutineName이 부모에서 자식으로 전달됨
suspend fun longTask() = coroutinecScope { 
	launch {
		delay(1000)
		val name = coroutineContext[CoroutineName]?.name
		printIn("[$name] Finished task 1")
	}
	launch {
		delay(2000)
		val name = coroutineContext[CoroutineName]?.name
		println("[$name] Finished task 2")
	}
}

fun main() = runBlocking(CoroutineName("Parent")) {
	println("Before")
	longTask()
	println("After")
}
// Before
// (1초 후)
// [Parent] Finished task 1
// (1초 후)
// [Parent] Finished task 2
// After
// 부모가 취소되면 아직 끝나지 않은 자식 코루틴이 전부 취소됨
suspend fun longTask() = coroutinecScope { 
	launch {
		delay(1000)
		val name = coroutineContext[CoroutineName]?.name
		printIn("[$name] Finished task 1")
	}
	launch {
		delay(2000)
		val name = coroutineContext[CoroutineName]?.name
		println("[$name] Finished task 2")
	}
}

fun main() = runBlocking {
	val job = launch(CoroutineName("Parent")) {
		longTask()
	}
	delay(1500)
	job.cancel()
}
// [Parent] Finished task 1
  • 코루틴 빌더와 달리 coroutineScope나 스코프에 속한 자식에서 예외가 발생하면 다른 모든 자식이 취소되고 예외가 다시 던져짐
data class Details(val name: String, val followers: Int)
data class Tweet(val text: String)
class ApiException(
	val code: Int,
	message: String
) : Throwable(message)

fun getFollowersNumber(): Int =
	throw ApiException(500, "Service unavailable")

suspend fun getUserName(): String {
	delay(500)
	return "marcinmoskala"
}

suspend fun getTweets(): List<Tweet> {
	return listOf(Tweet("Hello, world"))
}

suspend fun getUserDetails(): Details = coroutinescope { 
	val userName = async { getUserName() }
	val followersNumber = async { getFollowersNumber() }
	Details(userName.await(), followersNumber.await())
}

fun main() = runBlocking<Unit> {
	val details = try {
		getUserDetails()
	} catch (e: ApiException) {
		null
	}
	val tweets = async { getTweets() }
	println("User: $details")
	println("Tweets: ${tweets.await()}")
}
// User: null
// Tweets: [Tweet(text=Hello, world)]

중단함수에서 병렬로 작업을 수행할 경우, coroutineScope를 사용하는 것이 좋음

suspend fun getUserProfile(): UserProfileData =
	coroutinescope {
		val user = async { getUserData() }
		val notifications = async { getNotifications() }
		UserProfileData(
			user = user.await(),
			notifications = notifications.await(),
		)
	}

coroutineScope는 중단 메인 함수 본체를 래핑할 때 주로 사용됨

suspend fun main(): Unit = coroutinescope {
	launch {
		delay(1000)
		println("World")
	}
	println("Hello, ")
}
// Hello
// (1초 후)
// World

coroutineScope는 기존의 중단 컨텍스트에서 벗어난 새로운 스코프를 만듦
부모로부터 스코프를 상속받고 구조화된 동시성을 지원

코루틴 스코프 함수

코루틴 빌더 (runBlocking 제외)코루틴 스코프 함수
launch, async, producecoroutineScope, supervisorScope,withContext, withTimeout
CoroutineScope의 확장함수중단 함수
CoroutineScope 리시버의 코루틴 컨텍스트로 사용중단 함수의 컨티뉴에이션 객체가 가진 코루틴 컨텍스트를 사용
예외는 Job을 통해 부모로 전파됨일반 함수와 같은 방식으로 예외를 던짐
비동기인 코루틴을 시작함코루틴 빌더가 호출된 곳에서 코루틴을 시작함

withContext

  • coroutineScope와 비슷하지만 스코프의 컨텍스트를 변경할 수 있다는 점에서 다름
  • withContext의 인자로 컨텍스트를 제공하면 부모 스코프의 컨텍스트를 대체
    withContext(EmptyCoroutineContext) == coroutineScope()
fun CoroutineScope.log(text: String) {
	val name = this.coroutineContext[CoroutineName]?.name
	println("[$name$text")
}

fun main() = runBlocking(CoroutineName("Parent")) { 
	log("Before")
	
	withContext(CoroutineName("Child 1")) { 
		delay(1000)
		log("Hello 1")
	}
	
	withContext(CoroutineName("Child 2")) { 
		delay(1000)
		log("Hello 2")
	}
	log ("After")
}
// [Parent] Before
// (1초 후)
// [Child 1] Hello 1
// (1초 후)
// [Child 2] Hello 2
// [Parent] After

supervisorScope

  • 호출한 스코프로부터 상속받은 CoroutineScope를 만들고 지정된 중단 함수를 호출한다는 점에서 coroutineScope와 비슷
  • 차이점) JobSupervisorJob으로 오버라이딩 해서 자식 코루틴이 예외를 던지더라도 취소되지 않음
  • 서로 독립적인 작업을 시작하는 함수에서 주로 사용됨
fun main() = runBlocking {
	println("Before")
	supervisorScope { 
		launch {
			delay(1000)
			throw Error()
		}
		launch { 
			delay(2000)
			println("Done")
		}
	}
	println("After") 
}
// Before
// (1초 후)
// 예외가 발생합니다...
// (1초 후)
// Done
// After

supervisorScope 대신 withContext(SupervisorJob())을 사용할 수 있는가?

  • 없다! withContext(SupervisorJob())을 사용하면 withContext는 여전히 기존에 가지고 있던 Job을 사용하며 SupervisorJob()이 해당 잡의 부모가 됨
  • 따라서 하나의 자식 코루틴이 예외를 던진다면 다른 자식들도 취소됨
  • withContext 또한 예외를 던지기 때문에 SupervisorJob()은 사실상 쓸모 없게 됨

withTimeout

  • 인자로 들어온 람다식을 실행할 때 시간 제한을 준다는 점에서 coroutineScope와 다름
  • 시간이 너무 오래 걸리면 람다식은 취소되고 TimeoutCancellationException을 던짐
suspend fun test(): Int = withTimeout(1500) {
	delay(1000)
	println("Still thinking")
	delay(1000)
	println("Done!")
	42
}

suspend fun main(): Unit = coroutineScope { 
	try {
		test()
	} catch (e: TimeoutCancellationException) {
		println("Cancelled") 
	}
	delay(1000)
	// 'test' 함수가 취소되었기 때문에,
	// 타임아웃 시간을 늘려도 아무런 도움이 되지 않습니다.
}
// (1초 후)
// Still thinking
// (0.5초 후)
// Cancelled

withTimeout은 테스트할 때 특히 유용함

  • 특정 함수가 시간이 많게 혹은 적게 걸리는지 확인하는 테스트 용도로 사용됨
  • runTest 내부에서 사용된다면 withTimeout은 가상 시간으로 작동함
class Test { 
	@Test
	fun testTime2() = runTest { 
		withTimeout(1000) {
			// 1000ms보다 적게 걸리는 작업
			delay(900) // 가상 시간
		}
	}
	@Test(expected = TimeoutCanceUationException::class)
	fun testTime1() = runTest {
		withTimeout(1000) {
			// 1000ms보다 오래 걸리는 작업
			delay(1100) // 가상 시간
		}
	}
	@Test
	fun testTime3() = runBlocking {
		withTimeout(1000) {
			// 그다지 오래 걸리지 않는 일반적인 테스트
			delay(900) // 실제로 900ms만큼 기다립니다.
		}
	}
}

코루틴 빌더 내부에서 TimeoutCancellationException을 던지면 해당 코루틴만 취소가 되고 부모에게는 영향을 주지 않음

suspend fun main(): Unit = coroutineScope { 
	launch { // 1
		launch { // 2, 부모에 의해 취소됩니다.
			delay(2000)
			println("Will not be printed")
		}
		withTimeout(1000) { // 이 코루틴이 launch를 취소
			delay(1500)
		}
	}
	launch { // 3
		delay(2000)
		println("Done")
	}
}
// (2초 후)
// Done
  • withTimeout의 완화된 형태로 withTimeoutOrNull이 있으며, 이 함수는 예외를 던지지 않음.
  • 타임아웃을 초과하면 람다식이 취소되고 null이 반환됨
  • 네트워크 연산 등 시간이 너무 오래 걸리는 잘못된 상황을 알리는 용도로 사용

코루틴 스코프 함수 연결하기

  • 서로 다른 코루틴 스코프 함수의 두가지 기능이 모두 필요하다면 코루틴 스코프 함수에서 다른 기능을 가지는 코루틴 스코프 함수를 호출해야 함
suspend fun calculateAnswerOrNull(): User? = 
	withContext(Dispatchers.Default) {
		withTimeoutOrNull(1000) { 
			calculateAnswer()
		}
	}

추가적인 연산

  • 작업을 수행하는 도중 추가적인 연산을 수행하는 경우, 예를 들어 사용자 프로필을 보여 준 다음, 분석을 위한 목적으로 요청을 보내는 경우, 동일한 스코프에서 launch를 호출하는 방법이 자주 사용됨
class ShowUserDataUseCase(
	private val repo: UserDataRepository,
	private val view: UserDataView,
) {
	suspend fun showUserData() = coroutineScope {
		val name = async { repo.getName() }
		val friends = async { repo.getFriends() }
		val profile = async { repo.getProfile() }
		val user = User(
			name = name.await(),
			friends = friends.await(),
			profile = profile.await()
		)
		view.show(user)
		launch { repo.notifyProfileShown() }
	}
}

(문제점 1) coroutineScope가 사용자 데이터를 보여 준 뒤 launch로 시작된 코루틴이 끝나기를 기다려야하므로 launch에서 함수의 목적과 유의미한 작업을 한다고 보기 어려움

(문제점 2) 분석을 위한 호출이 실패하면 전체 과정이 취소될 수 있음.
ex)getProfile에서 예외가 발생하면 getNamegetFriends 또한 응답이 쓸모 없어짐

// 핵심 동작에 영향을 주지 않는 추가적인 연산은 다른 스코프에서 시작하는 것이 나음
val analyticsScope = CoroutineScope(SupervisorJob())

class ShowUserDataUseCase(
	private val repo: UserDataRepository,
	private val view: UserDataView,
	private val analyticsScope: Coroutinescope,
) {
	suspend fun showUserData() = coroutinescope { 
		val name = async { repo.getName() }
		val friends = async { repo.getFriends() }
		val profile = async { repo.getProfile() }
		val user = User(
			name = name.await(),
			friends = friends.await(),
			profile = profile.await()
		)
		view.show(user)
		analyticsScope.launch { repo.notifyProfileShown() } 
	}
}
  • 주입된 스코프에서 추가적인 연산을 시작
  • 독립적인 작업을 실행한다는 것을 명확하게 알 수 있음

0개의 댓글