티스토리 뷰
코틀린 장점
- 멀티플랫폼에서 작동시킬 수 있기 때문에 코틀린을 사용하는 모든 플랫폼에서 사용할 수 있다.
- 기존 코드 구조를 광범위하게 뜯어고치지 않고 코루틴을 도입할 수 있다.
안드로이드에서의 코루틴 사용
흔한 애플리케이션 로직은 다음과 같다.
- 하나 이상의 소스(API, 뷰 구성요소, 데이터베이스, 설정, 다른 애플리케이션)로부터 데이터를 얻어온다.
- 데이터를 처리한다.
- 가공된 데이터로 무엇인가를 한다. (화면에 보여주기, 데이터베이스에 저장, API로 전송)
기존의 구현 방식과 코루틴을 사용한 예제를 비교하여 두 방법이 어떻게 다른지 알아보자.
예제 상황 - API로 뉴스를 가져와서 정렬한 후, 화면에 띄운다.
1) 가장 간단한 방법
fun onCreate() {
val news = getNewsFromApi()
val sortedNews = news
.sortedByDescending { it.publishedAt }
view.showNews(sortedNews)
}
하지만 안드로이드에서는 이렇게 간단하게 구현할 수 없다.
UI 스레드를 블로킹하면 안되기 때문이다.
onCreate()가 메인 스레드에서 실행된다면 getNewsFromApi() 함수가 메인 스레드를 블로킹할 것이다.
getNewsFromApi() 함수를 다른 스레드에서 실행하더라도 showNews()를 호출할 때 정보가 없으므로 크래시가 발생한다.
2) 스레드 전환
fun onCreate() {
thread {
val news = getNewsFromApi()
val sortedNews = news
.sortedByDescending { it.publishedAt }
runOnUiThread {
view.showNews(sortedNews)
}
}
}
1)을 해결하기 위한 가장 직관적인 방법이다.
블로킹이 가능한 스레드를 먼저 사용하고, 이후에 메인 스레드로 전환한다.
이 방법은 다음과 같은 문제가 있다.
- 스레드가 실행된 후 멈출 수 있는 방법이 없어 메모리 누수가 발생한다.
- 스레드를 많이 생성하면 비용이 많이 든다.
- 스레드를 자주 전환하면 복잡도가 높아지고 관리하기 어렵다.
- 코드가 길고 이해하기 어렵다.
뷰를 재빨리 닫았다고 생각해 보자. 뷰가 열려 있는 동안 데이터를 가져오는 스레드들이 생성된다.
생성된 스레드들을 제거하지 않으면, 스레드는 할일을 모두 수행한 후 존재하지 않는 뷰를 수정하려 할 것이다.
이는 불필요한 작업이며, 예상하기 어려운 결과가 발생할 수 있다.
3) 콜백
fun onCreate() {
getNewsFromApi { news ->
val sortedNews = news
.sortedByDescending { it.publishedAt }
view.showNews(sortedNews)
}
}
함수를 논블로킹으로 만들고, 함수의 작업이 끝났을 때 호출될 콜백 함수를 넘겨준다.
fun onCreate() {
startedCallbacks += getNewsFromApi { news ->
val sortedNews = news
.sortedByDescending { it.publishedAt }
view.showNews (sortedNews)
}
}
콜백은 중간에 작업을 취소할 수 없다.
취소할 수 있는 콜백 함수를 만들 수도 있지만 어렵다.
콜백 함수 각각에 대해 취소할 수 있도록 구현해야 하고, 취소하기 위해 모든 객체를 모아야 한다.
fun showNews() {
getConfigFromApi { config ->
getNewsFromApi(config) { news ->
getUserFromApi { user ->
view.showNews(user, news)
}
}
}
}
세 곳에서 데이터를 가져오는 위 예제의 문제점은 다음과 같다.
- 병렬 처리가 불가능하다.
- 들여쓰기가 많아질수록 읽기 어렵다. (-> 콜백 지옥)
fun onCreate() {
// 1
showProgressBar()
showNews()
hideProgressBar()
// 2
showProgressBar()
showNews {
hideProgressBar()
}
}
또한 콜백은 작업의 순서를 다루기 힘들어진다.
1번 방식을 사용하면 프로그레스 바는 뉴스를 보여주는 작업을 시작하고 곧바로 사라진다.
프로그레스 바가 제대로 작동하게 하려면 2번처럼 showNews() 에도 콜백 함수를 만들어야 한다.
4) RxJava
RxJava를 사용하면 데이터 스트림 내에서 일어나는 모든 연산을 시작, 처리, 관찰할 수 있다.
fun onCreate() {
disposables += getNewsFromApi()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.map { news ->
news.sortedByDescending { it.publishedAt }
}
.subscribe { sortedNews ->
view. showNews (sortedNews)
}
}
메모리 누수도 없고, 취소가 가능하며, 스레드를 적절하게 사용하고 있다.
하지만 구현이 아주 복잡하다. RxJava를 도입하려면 수많은 코드를 바꿔야 한다.
subscribeOn(), observeOn(), map(), subscribe()와 같은 함수들을 배워야 한다.
취소하는 작업도 명시적으로 표시해야 한다.
fun getNewsFromApi(): Single<List<News>>
객체를 반환하는 함수들은 Observable이나 Single 클래스로 래핑해야 한다.
5) 코틀린 코루틴
코루틴의 핵심 기능은 코루틴을 특정 지점에서 멈추고 이후에 재개할 수 있다는 것이다.
코루틴은 중단했다가 다시 실행할 수 있는 컴포넌트를 뜻한다.
- 코루틴을 사용하면 우리가 짠 코드를 메인 스레드 에서 실행하고 API에서 데이터를 얻어올 때 잠깐 중단시킬 수 있다.
- 코루틴을 중단시켰을 때 스레드는 블로킹되지 않으며, 뷰를 바꾸거나 다른 코루틴을 실행하는 등의 또 다른 작업이 가능하다.
- 데이터가 준비되면 코루틴은 메인 스레드에서 대기하고 있다가 메인 스레드가 준비되면 멈춘 지점에서 다시 작업을 수행한다.
위 사진은 updateNews()와 updateProfile() 함수가 메인 스레드 내에서 각각의 코루틴으로 실행되는 걸 보여준다.
두 함수가 한 스레드 내에서 넘나들며 실행될 수 있는 이유는 스레드가 블로킹되는 것이 아니라 코루틴이 중단되기 때문이다.
따라서 코루틴을 사용하면 아까의 예제를 다음과 같이 구현할 수 있다.
fun onCreate() {
vievModelScope.launch {
val news = getNewsFromApi()
val sortedNews = news
.sortedByDescending { it.publishedAt }
view.showNews(sortedNews)
}
}
시작할 때 봤던 코드와 거의 동일하다.
이 코드는 메인 스레드에서 실행되지만, 스레드를 블로킹하지는 않는다.
코루틴의 중단은 데이터가 오는 걸 기다릴 때 스레드를 블로킹하는 대신 코루틴을 잠시 멈춘다.
코루틴이 멈춰 있는 동안 메인 스레드는 프로그레스 바를 그리는 등의 다른 작업을 할 수 있다.
데이터가 준비되면 코루틴은 다시 메인 스레드를 할당받아 이전에 멈춘 지점부터 다시 시작된다.
세 곳에서 데이터를 가져올 때는 아래처럼 구현할 수 있다.
fun showNews() {
viewModelScope.launch {
val config = getConfigFromApi()
val news = getNewsFromApi(config)
val user = getUserFromApi()
view.showNews(user, news)
}
}
하지만 이 방식은 효율적이지 않다.
함수가 순차적으로 호출되기 때문에, 각 함수가 1초씩 걸린다면 총 3초가 걸린다.
만약 API를 병렬로 호출했다면 2초만에 작업을 끝낼 수 있다. (config 1초 / new, user 병렬로 1초)
이럴 때는 async를 사용한다.
async는 반환값이 있는 요청을 처리하기 위해 만들어진 코루틴 빌더 함수다.
await() 함수를 호출하여 결과를 기다린다.
fun showNews() {
viewModelScope.launch {
val config = async { getConfigFromApi() }
val news = async { getNewsFromApi(config.await()) }
val user = async { getUserFromApi() }
view.showNews(user.await(), news.await())
}
}
여전히 간단하고 읽기 쉽다. 효율적이고 메모리 누수가 일어나지 않는다.
코루틴은 코틀린의 다른 기능도 활용할 수 있다.
예를 들면 for문이나 컬렉션 함수를 블로킹 없이 구현할 수 있다.
모든 페이지를 병렬로 받아오는 예제와 한 페이지씩 받아오는 예제를 확인해 보자.
// 모든 페이지 병렬로 받아옴
fun showAllNews() {
vievModelScope.launch {
val allNews = (0 until getNumberOfPages())
.map { page -> async { getNewsFromApi(page) } }
.flatMap { it.await() }
view.showAllNews(allNews)
}
}
// 한 페이지씩 받아옴
fun showPagesFromFirst() {
viewModelScope.launch {
for (page in 0 until getNumberOfPages()) {
val news = getNewsFromApi(page)
view.showNextPage(news)
}
}
}
백엔드에서의 코루틴 사용
코루틴을 도입하기 위해 suspend 키워드를 추가하면 된다.
코루틴을 도입하면 동시성을 쉽게 구현할 수 있고, 동시성을 테스트할 수 있으며, 코루틴을 취소할 수도 있다.
코루틴을 사용하는 가장 중요한 이유는 스레드를 사용하는 비용이 크기 때문이다.
스레드는 명시적으로 생성하고 유지되어야 한다.또한 스레드를 위한 메모리가 할당되어야 한다.
수백만 명의 사용자들이 애플리케이션을 사용하고 있고 데이터베이스나 다른 서비스로부터 응답을 기다릴 때마다 스레드를 블로킹하고 있다면 메모리와 프로세서 사용에 엄청난 비용이 들 것이다. (스레드 생성, 유지, 동기화 모든 과정이 고비용)
// 1
fun main() {
repeat(100_000) {
thread {
Thread.sleep(1000L)
print(".")
}
}
}
// 2
fun main() = runBlocking {
repeat(100_000) {
launch {
delay(1000L)
print(".")
}
}
}
1번은 스레드 10만 개를 만들고 1초 동안 기다린다. (다른 서비스로부터 응답을 기다리는 상황)
이 프로그램을 실행시키면 모든 점을 찍는 데 상당한 시간이 걸린다.
또는 OutOfMemoryError 예외로 프로그램이 종료된다. 수많은 스레드를 실행하는 비용이 크기 때문이다.
2번은 스레드 대신 코루틴을 사용했고, 스레드를 재우는 대신 코루틴을 중단시킨다.
프로그램을 실행하면 1초 후 모든 점이 출력된다.
코루틴을 시작하는 비용은 스레드와 비교가 안 될 정도로 저렴하다.
출처
'kotlin > coroutines' 카테고리의 다른 글
2.1 코틀린 코루틴 라이브러리 - 코루틴 빌더 (0) | 2025.01.11 |
---|---|
1.5 코틀린 코루틴 이해하기 - 언어 차원에서의 지원 vs 라이브러리 (0) | 2025.01.10 |
1.4 코틀린 코루틴 이해하기 - 코루틴의 실제 구현 (0) | 2025.01.10 |
1.3 코틀린 코루틴 이해하기 - 중단은 어떻게 작동할까? (0) | 2025.01.09 |
1.2 코틀린 코루틴 이해하기 - 시퀀스 빌더 (0) | 2025.01.09 |