본문 바로가기
💻 개발 이야기/Java, Kotlin

[Kotlin] 코루틴 이해를 위한 배경 지식

by Jinseong Hwang 2023. 6. 24.

 
안녕하세요. 황진성입니다.
 
최근에 이직을 했고, 합류한 팀에서 비즈니스 로직 틈틈이 코루틴을 사용하는 것을 확인했습니다.
저는 비동기, 병렬 처리 등에 대해서 제대로 공부하거나 사용해 본 적이 없어서 코드를 명확하게 이해하는 것이 힘들었습니다.
 
단순히 공식 문서에서 예제 한두번 돌려보고 대충 이해하고 쓰기만 했습니다.
꼼꼼히 코드 리뷰를 해주실 때 잘못 사용된 부분을 알려 주시긴 하는데, 언제까지 이렇게 개발할 수 없었습니다.
이 참에 코루틴에 대해 어느 정도 자세히 알아보고자 합니다.
 
코루틴과 코루틴을 감싸고 있는 이야기를 모두 해볼까 하는데, 여러 개의 포스트로 나눠서 차근차근 정리해 보겠습니다.
1편에서는 "코루틴을 감싸고 있는 이야기"에 집중해서 이야기해보겠습니다. 
 
 

동시성(Concurrency)과 병렬성(Parallelism)


https://joearms.github.io/published/2013-04-05-concurrent-and-parallel-programming.html

컴퓨터가 세상에 나온 초창기에는 CPU에 단 하나의 코어만 들어갔습니다. 코어가 단 하나만 있기 때문에 "동시"에 단 하나의 작업만 수행할 수 있었습니다. 만약 CPU가 무언가를 다운로드 받는 작업을 수행 중이라면, 다운로드가 끝날 때까지 아무 작업도 수행할 수 없었습니다. 끝나기만을 기다려야 했고, 끝나기 전에 오류가 발생하면 처음부터 다시 시작해야 할 때도 있었습니다.
 
시분할 시스템(Time Sharing)이 나오면서 이 문제는 어느 정도 해결됐습니다. 하나의 CPU 코어를 여러 프로그램이 돌려가며 사용을 하기 시작합니다. 음악을 들으면서, 문서 작업을 할 수 있게 되었습니다. 하지만 이것도 완벽히 "동시"에 작업을 한다고 보기는 어렵습니다. 음악을 들으면서 문서 작업을 하는 것이 사용자 입장에서는 동시에 진행된다고 느끼겠지만, 정확히 말하면 하나씩 진행되는 것이고 작업 변경이 매우 빠르게 번갈아가며 발생하는 것이기 때문에 사용자는 느끼지 못하는 것입니다. 이런 방식으로 작업을 수행하는 것을 동시성이라고 합니다.
 
과거로 조금 가보겠습니다. 바야흐로 2005년, 인텔에서 최초의 듀얼 코어 CPU인 펜티엄D를 선보였습니다. 이때부터는 여러 개의 코어가 있기 때문에 진짜 "동시"에 작업하는 것이 가능해졌습니다. 이런 방식으로 작업을 수행하는 것을 병렬성이라고 합니다. 사람은 컴퓨터를 활용해서 더욱 빠르게 많은 작업을 처리할 수 있게 되었습니다. 하지만 그에 따라 소프트웨어 개발 난이도가 올라갔고, 문제들이 생겨나기 시작했습니다. 문제들에 대해 하나씩 알아봅시다.

최근에 애플에서 발표한 M2 Ultra에는 24개의 CPU 코어가 들어갑니다.

 
 

메모리 가시성(Memory Visibility) 문제

https://dataonair.or.kr/db-tech-reference/d-lounge/technical-data/?mod=document&uid=236971

Java를 사용해 보신 분이라면 `volatile` 키워드와 함께 언급된 것을 본 적이 있을 것입니다. 현대 CPU의 구조를 보면 L1, L2 캐시를 각각 코어별로 가지고 있습니다. L3 캐시(공유캐시) 혹은 메인 메모리에서 가져온 값에 대해 동기화가 되지 않을 수 있습니다.
 
만약 메인 메모리의 cnt=1 이라는 값이 저장되어 있다고 가정해 봅시다.

  1. 코어A, 코어B가 메인 메모리로부터 cnt=1 읽음.
  2. 코어A, 코어B가 동시에 cnt=cnt+1 연산을 수행함. -> (두 코어 모두 cnt=2 라는 결과를 도출함)
  3. 코어A, 코어B가 동시에 결과를 메모리에 쓰기 작업을 함. -> (최종적으로 cnt=2로 저장됨)

 
분명 메모리의 cnt에 1을 더하는 작업을 2번 수행했는데, 결과는 한 번만 수행된 것과 같습니다. 이러한 문제를 가시성 문제라고 합니다. 이 문제를 해결하기 위해 락을 걸어서 해결할 수 있습니다.

  1. 코어A에서 읽기 작업이 이뤄진 후, 메인 메모리의 cnt라는 변수에 락(Lock)을 걸어서 다른 코어에서 읽기 작업을 금지/대기시킵니다.
  2. 코어A에서 연산과 쓰기 작업이 끝나고 락을 해제하면 비로소 코어B가 읽어서 작업을 할 수 있게 됩니다.

 

캐시 라인 갱신 비효율 문제

CPU 캐시에 저장되어 있는 데이터는 메인 메모리에서 가져온 데이터입니다. 메인 메모리에서 데이터를 가져올 때 32, 64, 128 바이트 단위로 묶어서 가져오는데, 보통 64바이트입니다. 데이터 묶음을 한 방에 캐시로 옮긴다고 해서, 이 묶음을 캐시 라인이라고 합니다. (메모리 상 array 형태로 옮긴다고 생각하면 됩니다.)
 
캐시 라인 단위로 데이터를 옮기게 되면 메인 메모리에 접근하는 횟수가 줄어들어서 성능이 향상됩니다. 또한 캐시의 공간 지역성 특성 때문에 인접한 데이터를 함께 가져오는 것이 캐시 히트율을 높이는 데 도움이 됩니다. 즉 성능 향상을 위해 캐시 라인을 사용합니다.
 
하지만 코어가 여러 개인 멀티 프로세서 환경이라면 성능에 역효과를 미칠 수 있습니다. 다음 그림을 살펴봅시다.

https://jungwoong.tistory.com/42

메인 메모리에 1000번 주소부터 1063번 주소까지 8바이트 데이터가 8개 저장되어 있다고 가정해 봅시다.
1000번 주소부터 1063번 주소까지 총 64바이트로, 하나의 캐시 라인입니다.

1000 1008 1016 1024 1032 1040 1048 1056

 
[1] CPU1 이 1000번 주소의 데이터 읽기를 시도했습니다.

1000
(CPU1 읽음)
1008 1016 1024 1032 1040 1048 1056

 
[2] CPU2 도 1000번 주소의 데이터 읽기를 시도했습니다.

1000
(CPU1 읽음,
CPU2 읽음)
1008 1016 1024 1032 1040 1048 1056

 
[3] CPU1 이 1000번 주소의 데이터를 변경했습니다.

1000
(CPU1 수정,
CPU2 읽음)
1008 1016 1024 1032 1040 1048 1056

 
[4] CPU1 이 1000번 주소의 데이터를 변경함과 동시에 다른 CPU에 있던 1000~1063 캐시라인 전체를 무효화합니다. 캐시 라인 전체가 갱신됩니다.

1000 1008 1016 1024 1032 1040 1048 1056

 
[5] CPU2 에서 읽었던 데이터는 무효화되었고, 메인 메모리에서 갱신된 캐시 라인을 다시 가져옵니다.

1000
(CPU2 읽음)
1008 1016 1024 1032 1040 1048 1056

 
만약 3번 과정이 빠르게 진행된다면 큰 문제가 없겠지만, 오래 걸린다면 CPU2 가 오랜 시간 대기를 해야 합니다. 멀티 코어로 동작하는 것처럼 보이지만, 실제로는 단 하나의 코어만 작업을 수행합니다. 이 과정에서 성능 비효율이 발생합니다.
 
 

문제를 해결하기 위한 노력


위에서 메모리 가시성 문제, 캐시 라인 갱신 비효율 문제에 대해 알아봤습니다. 하지만 이외에도 더 많은 문제들이 있습니다. 자세히 다루지는 않았지만, CPU에서 최적화를 하기 위해 명령어 순서를 바꿔서 처리하곤 하는데, 그때 메모리 배리어(Memory Barrier)를 추가해서 연산 순서를 보장할 수도 있습니다.
 
비동기, 병렬 프로그래밍에는 위험성들이 많이 존재하는데, 이 문제를 해결하고 코드로 표현하기 위해 다양한 시도들이 있었습니다. 대표적인 방법으로 Callback 코드와 Rx(ReactiveX) 코드가 있습니다.
 

Callback

callback을 사용해서 코드를 작성하면 성공 callback과 실패 callback을 연달아 작성하게 되는데, 각 callback에 또 다른 callback을 붙이고... 그러다 보면 완벽한 콜백 지옥이 완성됩니다. 에러 처리가 비효율적이게 되고, 가독성 또한 매우 떨어져서 유지보수하기 힘든 코드가 됩니다.
 

RxKotlin

비동기 처리를 조금 더 깔끔하게 처리하기 위해 나온 라이브러리입니다. 조금 더 명확하게 얘기하자면 ReactiveX를 Kotlin로 구현한 라이브러리입니다. RxKotlin를 사용하면 관찰 가능한(Observable) 스트림을 사용해서 비동기 애플리케이션 구현이 가능합니다. 표현이 대체로 어려운데, 저도 직접 써본 적은 없어서 머릿속에 두리뭉실하게 남아 있네요.. RxJava/RxKotlin도 잘 쓰시는 분들은 너무 편하다고 하시는데, 반응형 프로그래밍이라는 것이 새로운 패러다임이기 때문에 러닝 커브가 높다고 합니다.
 

Coroutine

코루틴은 일종의 가벼운 스레드로, 비동기 병렬 프로그래밍을 할 때 간편하게 처리해 주는 역할을 합니다. 코루틴은 Co + Routine 인데, 이는 Co(협력한다) + Routine(루틴) 즉, 루틴이 협력해서 동작하는 것을 의미합니다. 여기서 말하는 루틴은 하나의 태스크, 함수라고 이해하면 편합니다. 이 부분에 관해서는 여기에 굉장히 잘 설명이 되어 있으니 처음 코루틴을 접하는 분들은 꼭 읽어 보시길 권장합니다.
 
 

그래서 코루틴 왜 쓰나요?


당연히 이점이 있으니 코루틴을 사용합니다.
2가지를 꼽아보자면, 퍼포먼스 향상을 기대할 수 있고 비동기 코드를 깔끔하게 작성할 수 있습니다.
 

퍼포먼스 향상

제가 위에서 코루틴을 설명할 때 "가벼운 스레드"라는 표현을 사용했습니다. 말 그대로 코루틴은 기존 Java Thread에 비해 CPU 자원을 더 효율적으로 사용합니다. 물론 코루틴도 Java Thread 기반으로 동작하지만 suspend 라는 개념의 구현으로 낭비되는 자원을 획기적으로 줄여줍니다.

https://amitshekhar.me/blog/suspend-function-in-kotlin-coroutines

위 이미지를 살펴봅시다. 스레드가 functionA를 실행하다가 다른 작업을 위해 suspend 될 수 있습니다. 같은 스레드가 다른 작업인 functionB를 실행하는데, 별도로 스레드를 block하지 않고 바로 실행 가능합니다. Context Switching 비용이 발생하지 않기 때문에 효율성과 성능을 향상시킬 수 있습니다.
 

비동기 코드를 깔끔하게 작성

Callback, RxKotlin, Coroutine 순으로 동일한 동작을 하는 코드를 살펴보겠습니다.
 
Callback 코드

class SequentialNetworkRequestsCallbacksViewModel(
    private val mockApi: CallbackMockApi = mockApi()
) : BaseViewModel<UiState>() {

    private var getAndroidVersionsCall: Call<List<AndroidVersion>>? = null
    private var getAndroidFeaturesCall: Call<VersionFeatures>? = null

    fun perform2SequentialNetworkRequest() {

        uiState.value = UiState.Loading

        getAndroidVersionsCall = mockApi.getRecentAndroidVersions()
        getAndroidVersionsCall!!.enqueue(object : Callback<List<AndroidVersion>> {
            override fun onFailure(call: Call<List<AndroidVersion>>, t: Throwable) {
                uiState.value = UiState.Error("Network Request failed")
            }

            override fun onResponse(
                call: Call<List<AndroidVersion>>,
                response: Response<List<AndroidVersion>>
            ) {
                if (response.isSuccessful) {
                    val mostRecentVersion = response.body()!!.last()
                    getAndroidFeaturesCall =
                        mockApi.getAndroidVersionFeatures(mostRecentVersion.apiVersion)
                    getAndroidFeaturesCall!!.enqueue(object : Callback<VersionFeatures> {
                        override fun onFailure(call: Call<VersionFeatures>, t: Throwable) {
                            uiState.value = UiState.Error("Network Request failed")
                        }

                        override fun onResponse(
                            call: Call<VersionFeatures>,
                            response: Response<VersionFeatures>
                        ) {
                            if (response.isSuccessful) {
                                val featuresOfMostRecentVersion = response.body()!!
                                uiState.value = UiState.Success(featuresOfMostRecentVersion)
                            } else {
                                uiState.value = UiState.Error("Network Request failed")
                            }
                        }
                    })
                } else {
                    uiState.value = UiState.Error("Network Request failed")
                }
            }
        })
    }

    override fun onCleared() {
        super.onCleared()

        getAndroidVersionsCall?.cancel()
        getAndroidFeaturesCall?.cancel()
    }
}

위 예시를 보면 구현이 상당히 장황한데, 들여 쓰기도 너무 깊어서 가독성이 떨어집니다. 오류가 발생하는 곳곳마다 처리를 해줘야 하고, `onCleared()`를 빠뜨리지 않고 호출해서 메모리 누수를 수동으로 막아야 합니다.
 
RxKotlin 코드

class SequentialNetworkRequestsRxViewModel(
    private val mockApi: RxMockApi = mockApi()
) : BaseViewModel<UiState>() {

    private val disposables = CompositeDisposable()

    fun perform2SequentialNetworkRequest() {
        uiState.value = UiState.Loading

        mockApi.getRecentAndroidVersions()
            .flatMap { androidVersions ->
                val recentVersion = androidVersions.last()
                mockApi.getAndroidVersionFeatures(recentVersion.apiVersion)
            }
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .subscribeBy(
                onSuccess = { featureVersions ->
                    uiState.value = UiState.Success(featureVersions)
                },
                onError = {
                    uiState.value = UiState.Error("Network Request failed.")
                }
            )
            .addTo(disposables)
    }

    override fun onCleared() {
        super.onCleared()
        disposables.clear()
    }
}

Callback에 비해서는 코드가 훨씬 간결해졌습니다. `subscribeXX()`, `addTo()` 등의 메서드를 사용해서 가독성이 좋아졌습니다. 다만, 리액티브 프로그래밍에 대한 이해도가 낮으면 이 코드가 어떻게 동작하는지 이해하기 힘들 수 있습니다.
 
Coroutine 코드

class Perform2SequentialNetworkRequestsViewModel(
    private val mockApi: MockApi = mockApi()
) : BaseViewModel<UiState>() {

    fun perform2SequentialNetworkRequest() {
        uiState.value = UiState.Loading
        viewModelScope.launch {
            try {
                val recentVersions = mockApi.getRecentAndroidVersions()
                val mostRecentVersion = recentVersions.last()

                val featuresOfMostRecentVersion =
                    mockApi.getAndroidVersionFeatures(mostRecentVersion.apiVersion)

                uiState.value = UiState.Success(featuresOfMostRecentVersion)
            } catch (exception: Exception) {
                uiState.value = UiState.Error("Network Request failed")
            }
        }
    }
}

Callback과 RxKotlin에 비해서 구현이 가장 간단합니다. `launch()`를 통해 코루틴 스코프를 지정하는 코드를 빼면, 순차적으로 작성되어 있기 때문에 누구나 이해하기 쉽습니다.
 
 

맺음말


코루틴이 만능은 아닙니다. 멀티 프로세서 시스템에 진정한 "동시" 작업이 이뤄지기 위해서는 스레드가 필요합니다. 코루틴을 사용하면 외부 API 호출이나 I/O 작업을 포함한 비동기 애플리케이션 코드를 더 읽기 쉽고 유지보수하기 쉽게 작성할 수 있게 도와줄 뿐입니다.
 
코루틴 자체보다는 배경 이야기를 더 많이 한 것 같은데, 다음 게시글부터는 코루틴 이야기를 본격적으로 시작해 보겠습니다.
 
감사합니다.
 
 

References