티스토리 뷰
fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = Coroutinestart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job {
...
}
코루틴 빌더 함수의 시그니처 첫 번째 파라미터가 CoroutineContext라는 사실을 알 수 있다.
빌더 함수의 리시버뿐만 아니라 마지막 인자의 리시버도 CoroutineScope 타입이다.
interface CoroutineScope {
val coroutineContext: CoroutineContext
}
CoroutineScope는 CoroutineContext를 감싸는 래퍼처럼 보인다.
interface Continuation<in T> {
val context: CoroutineContext
fun resumeWith(result: Result<T>)
}
Continuation 또한 CoroutineContext를 포함하고 있다.
코루틴에서 가장 중요한 요소들이 CoroutineContext를 사용하고 있다.
CoroutineContext는 확실히 중요한 개념처럼 보이는데, 도대체 뭘까?
CoroutineContext 인터페이스
CoroutineContext는 원소나 원소들의 집합을 나타내는 인터페이스다.
Job, CoroutineName, CoroutineDispatcher와 같은 Element 객체들이 인덱싱된 집합이라는 점에서 Map이나 Set 같은 컬렉션과 비슷하다.
특이한 점은 각 Element도 CoroutineContext라는 점이다. 따라서 컬렉션 내의 모든 원소는 그 자체만으로 컬렉션이라 할 수 있다.
launch(CoroutineName("Name1")) { ... }
launch(CoroutineName("Name2") + Job()) { ... }
위 코드처럼 컨텍스트의 지정과 변경을 편리하게 하기 위해 CoroutineContext의 모든 원소가 CoroutineContext로 되어있다.
fun main() {
val name: CoroutineName = CoroutineName("A name")
val element: CoroutineContext.Element = name
val context: CoroutineContext = element
val job: Job = Job()
val jobElement: CoroutineContext.Element = job
val jobContext: CoroutineContext = jobElement
}
컨텍스트에서 모든 원소는 식별할 수 있는 유일한 Key를 가지고 있다. 각 키는 주소로 비교된다.
CoroutineName이나 Job은 CoroutineContext 인터페이스를 구현한 CoroutineContext.Element를 구현한다.
SupervisorJob, CoroutineExceptionHandler와 Dispatchers도 마찬가지다. 모두 중요한 코루틴 컨텍스트다.
CoroutineContext에서 원소 찾기
fun main() {
val ctx: CoroutineContext = CoroutineName("A name")
val coroutineName: CoroutineName? = ctx[CoroutineName] // ctx.get(CoroutineName)
println(coroutineName?.name) // A name
val job: Job? = ctx[Job] // ctx.get(Job)
println(job) // null
}
CoroutineContext는 get을 이용해 유일한 키를 가진 원소를 찾을 수 있다.
코틀린에서 get() 함수는 연산자이기 때문에, 대괄호를 사용해 원소를 찾을 수도 있다.
컨텍스트에 원소가 있으면 반환되고, 없으면 null이 반환된다는 점에서 Map과 비슷하다.
CoroutineName을 찾기 위해서는 CoroutineName을 사용하기만 하면 된다.
data class CoroutineName(
val name: String
) : AbstractCoroutineContextElement(CoroutineName) {
override fun toString(): String = "CoroutineName($name)"
companion object Key : CoroutineContext.Key<CoroutineName>
}
CoroutineName은 타입이나 클래스가 아닌 컴패니언 객체다.
클래스 이름을 사용하여 컴패니언 객체를 참조할 수 있기 때문에, ctx[CoroutineName]은 ctx[CoroutineName.Key]와 동일하다.
코루틴 라이브러리에서 컴패니언 객체를 키로 사용해 원소를 찾는 건 흔한 일이다. 이 방식은 기억하기 아주 편리하다.
interface Job : CoroutineContext.Element {
companion object Key : CoroutineContext.Key<Job>
// ...
}
Job과 SupervisorJob처럼 Key를 사용하는 클래스는 CoroutineContext.Key 인터페이스를 구현한다.
CoroutineContext의 키는 이 인터페이스를 가리킨다.
컨텍스트 더하기
fun main() {
val ctx1: CoroutineContext = CoroutineName("Name1")
println(ctx1[CoroutineName]?.name) // Name1
println(ctx1[Job]?.isActive) // null
val ctx2: CoroutineContext = Job()
println(ctx2[CoroutineName]?.name) // null
println(ctx2[Job]?.isActive) // 빌더를 통해 생성되는 잡의 기본 상태가 Actice 상태이므로 true
val ctx3 = ctx1 + ctx2
println(ctx3[CoroutineName]?.name) // Name1
println(ctx3[Job]?.isActive) // true
}
두 개의 CoroutineContext를 합쳐 하나의 CoroutineContext로 만들 수 있다.
다른 키를 가진 두 원소를 더해서 만들어진 컨텍스트는 두 가지 키를 모두 가진다.
fun main() {
val ctx1: CoroutineContext = CoroutineName("Name1")
println(ctx1[CoroutineName]?.name) // Name1
val ctx2: CoroutineContext = CoroutineName("Name2")
println(ctx2[CoroutineName]?.name) // Name2
val ctx3 = ctx1 + ctx2
println(ctx3[CoroutineName]?.name) // Name2
}
같은 키를 가진 또 다른 원소가 더해지면 Map처럼 새로운 원소가 기존 원소를 대체한다.
비어있는 코루틴 컨텍스트
fun main() {
val empty: CoroutineContext = EmptyCoroutineContext
println(empty[CoroutineName]) // null
println(empty[Job]) // null
val ctxName = empty + CoroutineName("Name1") + empty
println(ctxName[CoroutineName]) // CoroutineName(Name1)
}
CoroutineContext는 컬렉션이므로 빈 컨텍스트도 만들 수 있다.
빈 컨텍스트는 원소가 없기 때문에 다른 컨텍스트에 더해도 아무런 변화가 없다.
원소 제거
fun main() {
val ctx = CoroutineName("Name1") + Job()
println(ctx[CoroutineName]?.name) // Name1
println(ctx[Job]?.isActive) // true
val ctx2 = ctx.minusKey(CoroutineName)
println(ctx2[CoroutineName]?.name) // null
println(ctx2[Job]?.isActive) // true
val ctx3 = (ctx + CoroutineName("Name2"))
.minusKey(CoroutineName)
println(ctx3[CoroutineName]?.name) // null
println(ctx3[Job]?.isActive) // true
}
컨텍스트에서 원소를 제거하기 위해서는 minusKey 함수에 제거할 원소의 키를 인자로 전달하면 된다.
컨텍스트 폴딩
fun main() {
val ctx = CoroutineName("Name1") + Job()
ctx.fold("") { acc, element -> "$acc$element " }
.also(::println)
// CoroutineName(Name1) JobImpl{Active}@dbab622e
val empty = emptyList<CoroutineContext>()
ctx.fold(empty) { acc, element -> acc + element }
.joinToString()
.also(::println)
// CoroutineName(Name1), JobImpl{Active}@dbab622e
}
컨텍스트의 각 원소를 조작해야 하는 경우 다른 컬렉션의 fold와 유사한 fold 메서드를 사용한다.
다른 컬렉션의 fold와 동일하게 인자로 아래 항목을 전달한다.
- 누산기의 첫 번째 값
- 누산기의 현재 상태와 현재 실행되고 있는 원소로 누산기의 다음 상태를 계산할 연산
코루틴 컨텍스트와 빌더
fun CoroutineScope.log(msg: String) {
val name = coroutineContext[CoroutineName]?.name
println("[$name] $msg")
}
fun main() = runBlocking(CoroutineName("main")) {
log("Started") // [main] Started
val v1 = async {
delay(500)
log("Running async") // [main] Running async
42
}
launch {
delay(1000)
log("Running launch") // [main] Running launch
}
log("The answer is ${v1.await()}") // [main] The answer is 42
}
부모-자식 관계의 특징 중 하나는 부모의 컨텍스트를 자식에게 전달한다는 것이다.
즉, 자식은 부모로부터 컨텍스트를 상속받는다.
fun main() = runBlocking(CoroutineName("main")) {
log("Started") // [main] Started
val v1 = async(CoroutineName("c1")) {
delay(500)
log("Running async") // [c1] Running async
42
}
launch(CoroutineName("c2")) {
delay(1000)
log("Running launch") // [c2] Running launch
}
log("The answer is ${v1.await()}") // [main] The answer is 42
}
모든 자식은 빌더의 인자에 전달된 특정 컨텍스트를 가질 수 있다.
이 컨텍스트는 부모로부터 상속받은 컨텍스트를 대체한다.
defaultContext + parentContext + childContext
코루틴 컨텍스트를 계산하는 공식은 위와 같다.
코루틴 스코프 설정 → 부모 컨텍스트 상속 → 자식 코루틴의 세부 설정순으로 컨텍스트가 적용된다.
자식의 컨텍스트는 부모로부터 상속받은 컨텍스트 중 같은 키를 가진 원소를 대체한다.
디폴트 원소는 어디서도 키가 지정되지 않았을 때만 사용된다.
디폴트로 설정되는 원소는 ContinuationInterceptor가 설정되지 않았을 때 사용되는 Dispatchers.Default이다.
또한 애플리케이션이 디버그 모드일 때는 Coroutineld도 디폴트로 설정된다.
Job은 자식이 부모로부터 상속받지 않는다. 코루틴의 자식과 부모가 소통하기 위해 사용되는 특별한 컨텍스트다.
중단 함수에서 컨텍스트에 접근하기
suspend fun printName() {
println(coroutineContext[CoroutineName]?.name)
}
suspend fun main() = withContext(CoroutineName("Outer")) {
printName() // Outer
launch(CoroutineName("Inner")) {
printName() // Inner
}
delay(10)
printName() // Outer
}
CoroutineScope는 컨텍스트에 접근할 때 사용하는 coroutineContext 프로퍼티를 가지고 있다.
그럼 중단 함수에서는 어떻게 컨텍스트에 접근할 수 있을까?
중단 함수 사이에서 전달되는 Continuation 객체는 컨텍스트를 참조하고 있다.
따라서 중단 함수에서 부모의 컨텍스트에 접근하는 것이 가능하다.
coroutineContext 프로퍼티는 모든 중단 스코프에서 사용 가능하다. 이를 통해 컨텍스트에 접근할 수 있다.
컨텍스트 개별적으로 생성하기
class MyCustomContext : CoroutineContext.Element {
override val key: CoroutineContext.Key<*> = Key
companion object Key :
CoroutineContext.Key<MyCustomContext>
}
코루틴 컨텍스트를 커스텀하는 경우가 많지는 않지만 방법은 간단하다.
가장 쉬운 방법은 컨텍스트가 될 클래스를 만들고 Coroutinecontext.Element 인터페이스를 구현하는 것이다.
이러한 클래스는 Coroutinecontext.Key<*> 타입의 key 프로퍼티를 필요로 한다.
이 키는 컨텍스트를 식별하는 키로 사용된다.
커스텀한 컨텍스트는 아래와 같은 특징을 가진다는 점에서 CoroutineName과 아주 비슷하다.
- 부모에서 자식으로 전달된다.
- 자식은 같은 키를 가진 다른 컨텍스트로 대체할 수 있다.
실제로 그런지 보기 위해 연속된 숫자를 출력하는 컨텍스트를 보자.
class CounterContext(
private val name: String
) : CoroutineContext.Element {
override val key: CoroutineContext.Key<*> = Key
private var nextNumber = 0
fun printNext() {
println("$name: $nextNumber")
nextNumber++
}
companion object Key : CoroutineContext.Key<CounterContext>
}
suspend fun printNext() {
coroutineContext[CounterContext]?.printNext()
}
suspend fun main(): Unit =
withContext(CounterContext("Outer")) {
printNext() // Outer: 0
launch {
printNext() // Outer: 1
launch {
printNext() // Outer: 2
}
launch(CounterContext("Inner")) {
printNext()// Inner: 0
printNext() // Inner: 1
launch {
printNext() // Inner: 2
}
}
}
printNext() // Outer: 3
}
테스트 환경과 프로덕션 환경에서 서로 다른 값을 쉽게 주입하기 위해 컨텍스트를 커스텀하는 경우도 있다.
하지만 일반적으로 사용하는 방법은 아니다.
data class User(val id: String, val name: String)
abstract class UuidProviderContext :
CoroutineContext.Element {
abstract fun nextUuid(): String
override val key: CoroutineContext.Key<*> = Key
companion object Key :
CoroutineContext.Key<UuidProviderContext>
}
class RealUuidProviderContext : UuidProviderContext() {
override fun nextlluid(): String =
UUID.randomUUID().toString()
}
class FakeUuidProviderContext(
private val fakeUuid: String
) : UuidProviderContext() {
override fun nextUuid(): String = fakeUuid
}
suspend fun nextUuid(): String =
checkNotNull(coroutineContext[UuidProviderContext]) {
"UuidProviderContext not present"
}
.nextUuid()
// 테스트하려는 함수
suspend fun makeUser(name: String) = User(
id = nextUuid(),
name = name,
)
suspend fun main() {
// 프로덕션 환경일 때
withContext(RealUuidProviderContext()) {
println(makeUser("Michal"))
// 예를 들어 User(id=d260482a-..., name=Michal)
}
// 테스트 환경일 때
withContext(FakeUuidProviderContext("FAKE_UUID")) {
val user = makeUser("Michal")
println(user) // User(id= FAKE_UUID, name=Michal)
assertEquals(User("FAKE_UUID", "Michal"), user)
}
}
요약
- CoroutineContext는 Map이나 Set 같은 컬렉션과 개념이 비슷하다.
- CoroutineContext는 Element 인터페이스의 인덱싱된 집합이며, Element 또한 CoroutineContext다.
- CoroutineContext의 모든 원소는 유일한 Key를 가지고 있다. 이 키는 원소를 식별할 때 사용된다.
- CoroutineContext는 코루틴에 관련된 정보를 객체로 그룹화하고 전달하기 위해 사용된다.
- CoroutineContext는 코루틴에 저장된다. 저장된 CoroutineContext를 통해 코루틴의 작동 방식을 정할 수 있다. (코루틴의 상태가 어떤지 확인하고 어떤 스레드를 선택할지 결정하는 등)
출처
'app > kotlin' 카테고리의 다른 글
2.4 코틀린 코루틴 라이브러리 - 취소 (0) | 2025.01.12 |
---|---|
2.3 코틀린 코루틴 라이브러리 - 잡과 자식 코루틴 기다리기 (0) | 2025.01.12 |
2.1 코틀린 코루틴 라이브러리 - 코루틴 빌더 (0) | 2025.01.11 |
1.5 코틀린 코루틴 이해하기 - 언어 차원에서의 지원 vs 라이브러리 (0) | 2025.01.10 |
1.4 코틀린 코루틴 이해하기 - 코루틴의 실제 구현 (0) | 2025.01.10 |