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

윤성현·2024년 12월 16일

코틀린 코루틴

목록 보기
6/11
post-thumbnail

6장. 코루틴 빌더

코루틴 라이브러리가 제공하는 세 가지 필수적인 코루틴 빌더를 탐색해보자 🔍

서론

  • 중단 함수는 컨티뉴에이션 객체를 다른 중단 함수로 전달해야 함
    ⇒ 중단 함수가 일반 함수를 호출하는 것은 가능하지만, 일반 함수가 중단 함수를 호출하는 것은 불가능 ⚠️
  • 모든 중단 함수는 다른 중단 함수에 의해 호출되어야 함
  • 중단 함수를 연속으로 호출하면 시작되는 지점이 생김
  • “코루틴 빌더”가 일반 함수와 중단 가능한 세계를 연결시키는 다리 역할 🌉
  • 코틀린 라이브러리는 launch, runBlocking, async 세 가지의 코루틴 빌더를 제공함

🚀 launch 빌더

launch가 작동하는 방식은 thread 함수를 호출하여 새로운 스레드르 시작하는 것과 비슷함

// launch 사용 예제
fun main() {
	GloablScope.launch {
		delay(1000L)
		println("World!")
	}
	GloablScope.launch {
		delay(1000L)
		println("World!")
	}
	GloablScope.launch {
		delay(1000L)
		println("World!")
	}
	println("Hello,")
	Thread.sleep(2000L)
}
// Hello,
// (1초 후)
// World!
// World!
// World!
  • launch 함수는 CoroutineScope 인터페이스의 확장 함수
  • CoroutineScope 인터페이스는 부모 코루틴과 자식 코루틴 사이의 관계를 정립하기 위한 목적으로 사용되는 구조화된 동시성 (structured concurrency)의 핵심

❗실제 현업에서는 GlobalScope의 사용 지양

❗ main함수 마지막에 sleep이 없었다면 코루틴을 실행하자마자 끝나게 되며, 코루틴이 실행될 기회도 없음 ⏳

  • launch가 작동하는 방식은 데몬 스레드와 어느 정도 비슷하지만 훨씬 가벼움
  • 블로킹된 스레드를 유지하는 건 비용이 들지만 중단된 코루틴을 유지하는 건 공짜에 가까움
  • 둘 다 별개의 작업을 시작하며 작업을 하는 동안 프로그램이 끝나는 것을 막는 무언가가 필요하다는 점은 비슷함
// 데몬 스레드 사용 예제
fun main() {
	thread(isDaemon = true) {
		delay(1000L)
		println("World!")
	}
	thread(isDaemon = true) {
		delay(1000L)
		println("World!")
	}
	thread(isDaemon = true) {
		delay(1000L)
		println("World!")
	}
	println("Hello,")
	Thread.sleep(2000L)
}

🛑 runBlocking 빌더

  • 일반적으로 코루틴은 스레드를 블로킹하지 않고 중단시키지만, 블로킹이 필요한 상황이 존재
  • 예: 메인 함수에서 프로그램이 너무 빨리 끝나지 않도록 스레드를 블로킹할 필요가 있을 때
  • runBlocking은 코루틴이 중단될 때 현재 스레드를 중단시켜 Thread.sleep과 유사한 작동을 함
fun main() {
	runBlocking {
		delay(1000L)
		println("World!")
	}
	runBlocking {
		delay(1000L)
		println("World!")
	}
	runBlocking {
		delay(1000L)
		println("World!")
	}
	println("Hello,")
}

// (1초 후)
// World!
// (1초 후)
// World!
// (1초 후)
// World!
// Hello,

// 아래 함수와 결과가 동일
fun main() {
	Thread.sleep(1000L)
	println("World!")
	Thread.sleep(1000L)
	println("World!")
	Thread.sleep(1000L)
	println("World!")
	println("Hello,")
}

runBlocking이 사용되는 경우

  1. 프로그램이 끝나는 것을 방지하기 위해 스레드를 블로킹할 필요가 있는 메인 함수
  2. 같은 이유로 스레드를 블로킹할 필요가 있는 유닛 테스트

runBlocking의 현재

runBlocking은 `코루틴 빌더로 중요하게 사용되었지만 현재는 거의 사용되지 않음

유닛테스트에서는 코루틴을 가상 시간으로 실행시키는 runTest가 주로 사용됨

대신 suspend를 붙여 중단 함수로 만드는 방법을 주로 사용함

💭 async 빌더

  • asynclaunch와 비슷하지만 값을 생성하기 위한 코루틴 빌더
  • 값을 반환하기 위해 Deferred<T> 타입 반환 (여기서 T는 생성되는 값의 타입)
  • await()를 통해 값이 준비될 때까지 대기 가능
fun main() = runBlocking {
	val resultDeferred: Deferred<Int> = GlobalScope.async {
		delay(1000L)
		42
	}
	// 다른 작업 수행
	val result: Int = resultDeferred.await() // (1초 후)
	println(result)                          // 42
	// 간단하게 작성할 수도 있음
	println(resultDeferred.await()) // 42 
  • async 빌더는 호출되자마자 코루틴을 즉시 시작
  • 몇 개의 작업을 한번에 시작하고 모든 결과를 한꺼번에 기다릴 때 사용
  • 반환된 Deferred는 값이 생성되면 해당 값을 내부에 저장하기 때문에 await에서 값이 반환되는 즉시 값을 사용할 수 있음
  • 값이 생성되기 전에 await을 호출하면 값이 나올 때까지 기다리게 됨
fun main() = runBlocking {
	val res1 = GlobalScope.async {
		delay(1000L)
		"Text 1"
	}
	val res2 = GlobalScope.async {
		delay(3000L)
		"Text 2"
	}
	val res3 = GlobalScope.async {
		delay(2000L)
		"Text 3"
	}
	println(res1.await())
	println(res2.await())
	println(res3.await())
}
// (1초 후)
// Text 1
// (2초 후)
// Text 2
// Text 3

‼️ async 는 값을 생성할 때 사용되며, 값이 필요하지 않을 때는 launch를 써야 함

🏗️ 구조화된 동시성

  • GlobalScope를 사용하면 부모-자식 관계 없이 코루틴이 독립적으로 실행되어 프로그램 종료를 막을 수 없음

GlobalScope가 필요한 이유는 무엇일까?

  • launch와 async가 CoroutineScope의 확장 함수이기 때문

runBlocking는 block 파라미터가 리시버 타입이 CoroutineScope인 함수형 타입

fun <T> runBlocking(
	context: CoroutineContext = EmptyCoroutineContext,
	block: suspend CoroutineScope.() -> T
): T

fun CoroutineScope.launch(
	context: CoroutineContext = EmptyCoroutineContext,
	start: CoroutineStart = CoroutineStart.DEFAULT,
	block: suspend CoroutineScope.() -> Unit
): Job

fun <T> CoroutineScope.async(
	context: CoroutineContext = EmptyCoroutineContext,
	start: CoroutineStart = CoroutineStart.DEFAULT,
	block: suspend CoroutineScope.() -> Unit
): Deferred<T>
  • GlobalScope를 굳이 사용하지 않고 runBlocking이 제공하는 리시버를 통해 this.launch 또는 launch와 같이 launch를 호출해도 됨
  • launch는 runBlocking의 자식이 됨
    ⇒ runBlocking은 모든 자식이 작업을 끝마칠 때까지 중단됨
fun main() = runBlocking {
	this.launch {
		delay(1000L)
		println("World!")
	}
	launch {
		delay(2000L)
		println("World!")
	}
	println("Hello,")
}
// Hello,
// (1초 후)
// World!
// (1초 후)
// World!
  • 부모는 자식들을 위한 스코프를 제공하고 자식들을 해당 스코프 내에서 호출함
    구조화된 동시성이라는 관계가 성립됨

부모-자식 관계의 가장 중요한 특징

  • 자식은 부모로부터 컨텍스트를 상속받음 (7장에서 다룰 예정)
  • 부모는 모든 자식이 작업을 마칠 때까지 기다림 (8장에서 다룰 예정)
  • 부모 코루틴이 취소되면 자식 코루틴도 취소됨 (9장에서 다룰 예정)
  • 자식 코루틴에서 에러가 발생하면, 부모 코루틴 또한 에러로 소멸 (10장에서 다룰 예정)

runBlocking은 CoroutineScope의 확장 함수가 아니므로 자식이 될 수 없고, 루트 코루틴으로만 사용될 수 있음. 따라서 runBlocking은 다른 코루틴과 쓰임새가 다름

💼 현업에서의 코루틴 사용

  • 중단 함수는 다른 중단 함수에 의해 호출되어야 하며, 모든 중단 함수는 코루틴 빌더로 시작되어야 함
  • runBlocking을 제외한 모든 코루틴 빌더는 CoroutineScope에서 시작되어야 함

코루틴 적용 예시 (Android, Backend) 📱💻

// Android
class NetworkUserRepository(
	private val api: UserApi,
): UserRepository {
	suspend fun getUser(): User = api.getUser().toDomainUser()
}

class NetworkNewsRepository(
	private val api: NewsApi,
	private val settings: SettingsRepository,
): NewsRepository {
	suspend fun getNews(): List<News> = api.getNews()
		.map { it.toDomainNews() }
	
	suspend fun getNewsSummary(): List<News> {
		val type = settings.getNewsSummaryType()
		return api.getNewsSummary(type)
	}
}

class MainPresenter(
	private val view: MainView,
	private val userRepo: UserRepository,
	private val newsRepo: NewsRepository,
): BasePresenter {
	fun onCreate() {
		scope.launch {
			val user = userRepo.getUser()
			view.showUserData(user)
		}
		scope.launch {
			val news = async {
				newsRepo.getNews()
					.sortedByDescending { it.date }
			}
			val newsSummary = async {
				newsRepo.getNewsSummary()
			}
			view.showNews(newsSummary.await(), news.await())
		}
	}
}
// Backend (Spring WebFlux)
@Controller
class UserController(
	private val tokenService: TokenService,
	private val userService: UserService,
) {
	@GetMapping("/me")
	suspend fun findUser(
		@PathVariable userId: String,
		@RequestHeader("Authorization") authorization: String,
	): UserJson {
		val userId = tokenService.readUserId(authorization)
		val user = userService.findUserById(userId)
		return user.toJson()
	}
}

중단 함수에서는 스코프를 어떻게 처리할까?

  • 함수 내에는 스코프가 없음
  • 스코프를 인자로 넘기는 것은 좋은 방법이 아님
  • 코루틴 빌더가 사용할 스코프를 만들어 주는 중단 함수인 coroutineScope 함수를 사용하는 것이 바람직

🔧 coroutineScope 사용하기

예시

레포지토리 함수에서 비동기적으로 두 개의 자원(사용자 데이터와 글 목록)을 가지고 오는 상황
사용자가 볼 수 있는 글만 반환하고 싶다고 가정
async를 호출하려면 스코프가 필요하지만 함수에 스코프를 넘기고 싶지 않는 경우

  • 중단 함수 밖에서 스코프를 만들려면 coroutineScope 함수를 활용
suspend fun getArticleForUser(
	userToken: String?,
): List<ArticleJson> = coroutineScope {
	val articles = async { articleRepository.getArticles() }
	val user = userService.getUser(userToken)
	articles.await()
		.filter { canSeeOnList(user, it) }
		.map { toArticleJson(it) }
}
  • coroutineScope는 람다 표현식이 필요로 하는 스코프를 만들어 주는 중단 함수
  • 람다식이 반환하는 것이면 무엇이든 반환
suspend fun main(): Unit = coroutineScope {
	launch {
		delay(1000L)
		println("World!")
	}
	println("Hello,")
}
// Hello,
// (1초 후)
// World!

0개의 댓글