코틀린 코루틴 1부

벼리·2025년 7월 20일
0

코루틴

목록 보기
1/2

들어가며

해당 시리즈는 오로지 개인 기록용이며, 코틀린 코루틴의 책 내용을 저만의 방식대로 요약합니다.

시리즈 완료 후

https://marcinmoskala.com/CoroutinesRaceGuesser/

위의 사이트에서 100점 받는 것을 목표로 하고 있습니다(난이도 아직 모름)

코틀린 코루틴을 배워야 하는 이유

RxJava나 Reactor와 같은 JVM 계열 라이브러리가 있는데도 불구하고, 우리는 왜 코틀린 코루틴을 배워야할까요? 코틀린 코루틴 외에도 우리는 이미 비동기적 연산을 수행하기 위한 다양한 방법들을 알고 있습니다. 그리고 다른 대안들보다 왜 코루틴이 더 나은지 살펴보도록 하겠습니다.

스레드 전환

안드로이드에서는 단 하나의 메인 스레드가 뷰를 다룹니다. 메인 스레드는 앱에서 가장 중요한 스레드라 블로킹되면 안 되기 때문에, 다음과 같이 구현하면 ANR이 발생할 수 있습니다.

fun onCreate() {
	val news = getNewsFromApi()
	val sortedNews = news
			.sortedByDescending { it.publishedAt }
				
	view.showNews(sortedNews)
}

onCreate 함수가 메인 스레드에서 실행된다면 getNewsFromApi 함수가 스레드를 블로킹할 것이고, 애플리케이션에 ANR이 발생할 것입니다. getNewsFromApi 를 다른 스레드에서 실행하더라도 showNews를 호출할 때 정보가 없으므로 메인 스레드에서 마찬가지로 크래시가 발생합니다.

스레드 전환을 하면 위의 문제를 가장 직관적으로 해결할 수는 있습니다.

fun onCreate() {
	thread {
		val news = getNewsFromApi()
		val sortedNews = news
				.sortedByDescending { it.publishedAt }
		runOnUiThread {
				view.showNews(sortedNews)
		}
	}
}

하지만 위의 코드는 다음과 같은 문제가 발생할 수 있습니다.

  • 스레드가 실행되었을 때 멈출 수 있는 방법이 없어 메모리 누수로 이어질 수 있습니다.
  • 스레드를 많이 생성하면 비용이 많이 듭니다.
  • 스레드를 자주 전환하면 복잡도를 증가시키며 관리하기도 어렵습니다.
  • 코드가 쓸데없이 길어지고 이해하기 어려워집니다.

예를 들어서, 데이터 처리가 생성되어 작업을 수행하는 동안 뷰 객체가 더 이상 존재하지 않을 수 있습니다. 이때, 작업이 끝난 후 존재하지 않는 뷰 객체를 수정할려고 하면 예외가 발생합니다.

이렇기 때문에, 스레드를 전환하여 Android 작업을 수행하는 것은 많은 비용과 수고가 듭니다

콜백

콜백은 앞의 메인 스레드 블로킹 문제를 해결할 수 있는 수단 중 하나입니다.

시간이 오래 걸리는 작업을 시작한 후, 프로그램의 메인 스레드를 붙잡지 않고 콜백을 이용해 즉시 제어권을 돌려줄 수 있습니다.

fun onCreate() {
	getNewsFromApi { news -> 
		val sortedNews = news
			.sortedByDescending { it.publishedAt }
		view.showNews(sortedNews)
	}

}

그러나 콜백은 다음과 같은 단점이 있습니다.

  • 중도 취소 불가
    • 취소 가능하게 만들기 위해서는, 모든 객체를 분리해서 모아야 합니다.
  • 병렬 처리 불가
  • 가독성 저하

RxJava와 리액티브 스트림

자바 진영에서 많이 사용하는 비동기 연산으로는 RxJava나 그 뒤를 이은 Reactor와 같은 리액티브 스트림이 있습니다. 이를 이용하면 데이터 스트림 내에서 일어나는 모든 연산을 시작, 처리, 관찰할 수 있습니다.

리액티브 스트림은 스레드 전환을 통해 복잡한 연산을 효율적으로 동시 처리하는 데 강력한 기능을 제공합니다. 이를 통해 애플리케이션의 응답성을 높이고 자원을 효율적으로 사용할 수 있습니다.

fun onCreate() {
	disposables += getNewsFromApi()
		.subscribeOn(Schedulers.io())
		.observeOn(AndroidSchedulers.mainThread())
		.map { news ->
			news.sortedByDescending { it.publishedAt }
		}
		.subscribe { sortedNews ->
			view.showNews(sortedNews)
		}
}

RxJava는 콜백에 비해 다음과 같은 장점이 있습니다.

  • clear(), dispose() 등 별도의 작업을 통해 메모리 누수 방지 가능ㅂ

  • 작업 취소 가능

  • 스레드 적절히 활용

다만 RxJava는 이전 코드와는 달리 구현하기 복잡하다는 단점이 있습니다. 또한 객체를 반환하는 함수들을 Observable이나 Single 클래스로 래핑해야 합니다.

코틀린 코루틴의 사용

하지만 코틀린 코루틴을 사용하면 위의 문제들을 해결할 수 있습니다.

코루틴은 특정 지점에서 멈추고 이후 재개할 수 있습니다. 코루틴을 중단시켰을 때 스레드는 블로킹되지 않으며 뷰를 바꾸거나 다른 코루틴을 실행하는 등의 또 다른 작업이 가능합니다.

따라서 코틀린 코루틴을 사용하면 뉴스를 별도로 처리하는 작업을 다음과 같이 구현할 수 있습니다.

fun onCreate() {
	viewModelScope.launch {
		val news = getNewsFromApi()
		val sortedNews = news
			.sortedByDescending { it.publishedAt }
				
		view.showNews(sortedNews) 
	}

}

이렇게 메인 스레드에서 실행되면서, 스레드를 블로킹하지 않을 수 있습니다.

스레드 전환, 콜백, 자바 진영의 비동기 연산들의 단점들을 모두 해결하면서 작업을 수행할 수 있습니다.

시퀀스 빌더

코루틴의 중단에 대해 이해하기 위해, 시퀀스 빌더의 동작을 예시로 설명하겠습니다.

시퀀스는 List나 Set과 같은 컬렉션과 비슷한 개념이지만, 필요할 때마다 값을 하나씩 계산하는 지연 처리를 합니다. 즉, 연산을 지연 처리하여 중간 결과를 즉시 생성하지 않습니다. 최종 결과가 요청될 때에만 필요한 연산이 순차적으로 계산됩니다.

시퀀스의 특징은 다음과 같습니다.

  • 요구되는 연산을 최소한으로 수행
  • 무한정이 될 수 있음
  • 메모리 사용이 효율적

이제 시퀀스 예제에 대해 알아보겠습니다.

val seq = sequence {
	println("Generating First")
	yield(1)
	println("Generating Second")
	yield(2)
	println("Generating Third")
	yield(3)
	println("Done")
}
fun main {
	for (num in seq) {
		println("The next number is $num")
	}
}
// Generating first
// The next number is 1
// Generating second
// The next number is 2
// Generating third
// The next number is 3
// Done

예제 코드를 보면 숫자를 생성하기 전에 println이 출력됩니다. 숫자가 미리 생성되는 대신 필요할 때마다 생성되고 있습니다. 즉, yield(1) 을 출력한 후에 잠시 중단 되고, println을 마친 후에 중단된 지점에서 다시 실행됩니다.

코루틴을 사용하면 간단하게 작업을 중단할 수 있으며, 이 개념은 코루틴의 동작에 있어서 중요한 키워드입니다.

중단은 어떻게 작동할까?

중단 함수는 코틀린 코루틴의 핵심 개념입니다.

코루틴을 중단한다는 것은 실행을 중간에 멈추는 것을 의미합니다. 그렇다면 어떻게 실행을 멈추고, 다시 해당 지점을 이어서 재개할 수 있을까요? 이를 위해 Continuation 객체를 활용해야 합니다. Continuation 을 이용하면 중간 과정을 기억해서 다시 일을 재개할 수 있습니다. 이 점이 스레드와 가장 큰 차이점입니다. 중단만 가능하고 저장이 불가능한 스레드와는 다르게, 코루틴을 활용하면 태스크를 효율적으로 관리할 수 있습니다.

재개

중단 함수는 말 그대로 코루틴을 중단할 수 있는 함수를 의미합니다. 코루틴을 중단하는 함수이기 때문에 반드시 코루틴에 의해 호출되어야 합니다.

이제 예제를 살펴보도록 하겠습니다.

suspend fun main() {
	println("Before")
		
	suspendCoroutine<Unit> { continuation ->
			continuation.resume(Unit)
	}
	println("After")
}

// Before
// After

suspendCoroutine 은 main 메서드 내부를 중단시킵니다. 이때, 람다 함수는 Continuation 객체를 저장한 뒤 코루틴을 다시 실행할 시점을 결정하기 위해 사용됩니다. 원하는 시점에 resume() 을 호출하여, 중단된 코루틴을 재개할 수 있습니다. 그리고 resume() 이 호출되기 전, 잠시 정지되는 동안 다른 스레드를 실행할 수 있습니다.

suspend fun main() {
	println("Before")
		
	suspendCoroutine<Unit> { continuation ->
		thread {
			println("Suspended")
			Thread.sleep(1000)
			continuation.resume(Unit)
			println("Resumed")
		}
	}
		
	println("After")
}

// Before
// Suspended
// (1초 후)
// After
// Resumed

sleep에 의해 정지되어, 다른 스레드의 println("After") 를 수행하고 다시 재개하여 println("Resumed") 을 출력하는 것을 볼 수 있습니다. 이때, 람다 표현식이 Continuation 객체를 통제하여 코루틴을 정지하고 재개합니다.

값으로 재개하기

suspendCoroutine을 호출할 때 Continuation 객체로 반환될 값의 타입을 지정할 수 있습니다.

resume을 통해 반환되는 값은 반드시 지정된 타입과 같은 타입이어야 합니다.

suspend fun main() {
	val i: Int = suspendCoroutine<Int> { cont ->
		cont.resume(42)
	}
	println(i) // 42

	val str: String = suspendCoroutine<String> { cont ->
		cont.resume("Some text")
	}

	val b: Boolean = suspendCoroutine<Boolean> { cont ->
		cont.resume(true)
	}

	println(b) // true

}

안드로이드에서는 Api를 호출해 네트워크 응답을 기다리는 것처럼 특정 데이터를 기다리고 중단하는 상황이 자주 발생합니다. 스레드는 특정 데이터가 필요한 지점까지 비즈니스 로직을 수행합니다. 이후 네트워크 라이브러리를 통해 데이터를 요청합니다.

만약 코루틴이 없다면 스레드는 응답을 기다리고 있을 수밖에 없습니다. 그리고 스레드를 생성하는 비용이 많이 들기도 하며, 안드로이드의 메인 스레드처럼 중요하다면 스레드가 가만히 대기하는 것은 엄청난 낭비입니다. 하지만 코루틴의 중단 덕분에 데이터가 도착하면 스레드를 다시 중단 지점에서 다시 재개 하여 로직을 이어서 수행할 수 있습니다. 그리고 Continuation 의 반환이 코루틴 재개의 기준이자, 비즈니스 로직에 필요한 데이터를 나타냅니다.

그렇다면 코루틴에서 예외가 발생한다면, 어떻게 예외로 재개할 수 있을까요?

예외로 재개하기

resume이 호출될 때 suspendCoroutine은 인자로 들어온 데이터를 반환합니다. 그리고 resumeWithException이 호출되면 중단된 지점에서 인자로 넣어준 예외를 던집니다.

이를 나타내는 예제 코드는 다음과 같습니다.

suspend fun requestNews(): News {
	return suspendCancellableCoroutine<News> { cont ->
		reqeustNews(
				onSuccess = { news -> cont.resume(news) },
				onError = { e -> cont.resumeWithException(e) }
		)
	}

}

함수가 아닌 코루틴을 중단시킨다

여기서 강조해야할 점은, 함수가 아닌 코루틴을 중단시킨다는 점입니다. 중단 함수는 코루틴이 아니고, 단지 코루틴을 중단할 수 있는 함수입니다.

이를 설명하는 예제를 살펴보도록 하겠습니다.

var continuation: Continuation<Unit>? = null

suspend fun suspendAndSetContinuation() {
	suspendCoroutine<Unit> { cont ->
		continuation = cont
	}
}

suspend fun main() {
	println("Before")
		
	suspendAndSetContinuation()
	continuation?.resume(Unit)
		
	println("After")
}

// Before

위의 예제에서는 suspendCoroutine의 람다 내부에서 resume을 호출하고 있지 않기 때문에 “After”가 호출되지 않은 채 프로그램의 실행 상태가 유지됩니다.

var continuation: Continuation<Unit>? = null

suspend fun suspendAndSetContinuation() {
	suspendCoroutine<Unit> { cont ->
		continuation = cont
	}
}

suspend fun main() = coroutineScope {
	println("Before")
		
	launch {
		delay(1000)
		continuation?.resume(Unit)
	}
		
	suspendAndSetContinuation()
		
	println("After")
}
// Before
// (1초 후)
// After

하지만 다른 코루틴에 의해 재개되면 “After”을 출력하면서 프로그램 실행이 종료될 수 있습니다.

이는 suspend 함수가 아닌 코루틴에 중단과 재개의 제어권이 있음을 의미합니다.

profile
코딩일기

0개의 댓글