코틀린 코루틴 (7장 정리)

윤성현·2024년 12월 17일

코틀린 코루틴

목록 보기
7/11
post-thumbnail

7장. 코루틴 컨텍스트

💡 서론

모두 CoroutineContext를 사용하고 있는데, 도대체 무엇일까?

// launch의 구현
public fun CoroutineScope.launch(
	context: CoroutineContext = EmptyCoroutineContext,
	start: CoroutineStart = CoroutineStart.DEFAULT,
	block: suspend CoroutineScope.() -> Unit
): Job {
	...
}
// CoroutineScope의 정의
public interface CoroutineScope {
	public val coroutineContext: CoroutineContext
}
// Continuation의 정의
public interface Continuation<in T> {
	public val context: CoroutineContext
	public fun resumeWith(result: Result<T>)
}

🗝️ CoroutineContext 인터페이스

  • CoroutineContext는 원소나 원소들의 집합을 나타내는 인터페이스
  • Job, CoroutineName, CoroutineDispatcher와 같은 Element 객체들이 인덱싱된 집합
    • 맵(Map)이나 셋(Set)과 같은 컬렉션과 유사한 개념
    • Element도 CoroutineContext
  • 컨텍스트의 지정과 변경을 편리하게 하기 위해 CoroutineContext의 모든 원소는 CoroutineContext로 되어 있음
  • 컨텍스트에서 모든 원소는 식별할 수 있는 유일한 Key를 가지고 있음 🔑
  • 각 키는 주소로 비교 가능
// 다음과 같은 구조로 이루어져있음
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
}

🔍 CoroutineContext에서 원소 찾기

  • 컬렉션과 비슷해서 get또는 [](대괄호) 를 이용해 유일한 키를 가진 원소를 찾을 수 있음
  • 원소가 없으면 null이 반환됨
fun main() {
	val ctx: CoroutineContext = CoroutineName("A name")
	
	val coroutineName: CoroutineName? = ctx[CoroutineName]
	println(coroutineName?.name) // A name
	val job: Job? = ctx[Job]
	println(job) // null
}

➕ 컨텍스트 더하기

  • 두 개의 CoroutineContext를 합쳐 하나의 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)       // 'Active' 상태이므로 true
	// 빌더를 통해 생성되는 잡의 기본상태가 'Active' 상태이므로 true
	
	val ctx3 = ctx1 + ctx2
	println(ctx3[CoroutineName]?.name) // Name1
	println(ctx3[Job]?.isActive)       // true
}

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
}

🪹 비어 있는 코루틴 컨텍스트

  • CoroutineContext는 컬렉션이므로 빈 컨텍스트를 만들 수 있음
  • 빈 컨텍스트는 원소가 없으므로, 다른 컨텍스트에 더해도 아무런 변화가 없음
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)
}

✂️ 원소 제거

  • minusKey 함수에 키를 넣는 방식으로 원소를 컨텍스트에서 제거할 수 있음
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 ctx = (ctx + CoroutineName("Name")).minusKey(CoroutineName)
	println(ctx3[CoroutineName]?.name) // null
	println(ctx3[Job]?.isActive)       // true

🗂️ 컨텍스트 폴딩

  • 컨텍스트의 각 원소를 조작해야 하는 경우 다른 컬렉션의 fold와 유사한 fold 메서드를 사용할 수 있음

fold가 필요로 하는 것

  • 누산기의 첫 번째 값
  • 누산기의 현재 상태와 현재 실행되고 있는 원소로 누산기의 다음 상태를 계산할 연산
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
}

🏗️ 코루틴 컨텍스트와 빌더

  • CoroutineContext는 코루틴의 데이터를 저장하고 전달하는 방법
    • 부모는 기본적으로 컨텍스트를 자식에게 전달
    • 자식은 부모로부터 컨텍스트를 상속받음
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 가 사용됨
    • 애플리케이션이 디버그 모드일 때는 CoroutineId도 디폴트로 설정됨

✋ 중단 함수에서 컨텍스트에 접근하기

일반적인 중단 함수에서 어떻게 컨텍스트에 접근할 수 있을까?

  • 컨텍스트는 중단 함수 사이에 전달되는 컨티뉴에이션 객체가 참조하고 있음
  • 따라서 중단 함수에서 부모의 컨텍스트에 접근 가능
  • coroutineContext 프로퍼티는 모든 중단 스코프에서 사용 가능하며, 이를 통해 컨텍스트에 접근
suspend fun printName() {
	println(coroutineContext[CoroutineName]?.name)
}

suspend fun main() = withContext(CoroutineName("Outer")) {
	printName() // Outer
	launch(CoroutineName("Inner")) {
		printName() // Inner
	}
	delay(10)
	printName() // Outer
}

🧩 컨텍스트를 개별적으로 생성하기

코루틴 컨텍스트를 커스텀하게 만드는 방법

  1. CoroutineContext.Element 인터페이스를 구현하는 클래스 만들기
    • 이런 클래스는 CoroutineContext.Key<*> 타입의 key 프로퍼티를 필요로 함
    • 키는 컨텍스트를 식별하는 키로 사용됨
    • 가장 전형적인 방법은 클래스의 동반 객체를 키로 사용하는 것
class MyCustomContext : CoroutineContext.Element {
	override val key: CoroutineContext.Key<*> = Key
	
	companion object Key : CoroutineContext.Key<MyCustomContext>
}
  • 이런 식으로 커스텀 컨텍스트를 만든 뒤, 부모→자식 사이에서 값 주입 및 재정의 가능
// 예시 (연속된 숫자를 출력하도록 설계된 컨텍스트)
class CounterContext(
	private val name: String
): CorotineContext.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 nextUuid(): 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(): Unit {
	// 프로덕션 환경일 때
	withContext(RealUuidProviderContext()) {
		println(makeUser("Michael"))
		// 예를 들어 User(id=d260482a-..., name=Michael)
	}
	
	//테스트 환경일 때
	withContext(FakeUuidProviderContext("FAKE_UUID")) {
		val user = makeUser("Michael")
		println(user) // User(id=FAKE_UUID, name=Michael)
		assetEquals(User("FAKE_UUID", "Michael"), user)
	}
}

0개의 댓글