비동기 프로그래밍 기법

비동기 프로그래밍 기법

수십 년 동안 개발자들은 한 가지 문제와 씨름해 왔어요. 바로 애플리케이션이 블로킹되지 않게 하는 문제예요. 데스크톱, 모바일, 백엔드 어디에서라도 우리는 사용자가 기다리게 만들고 싶지 않아요. 더 나쁜 경우에는 애플리케이션 확장을 막는 병목(bottleneck)이 생기는 것도 피하고 싶고요.

이 문제를 해결하기 위해 수많은 접근 방식이 나왔어요.

코루틴이 뭔지 설명하기 전에, 다른 해법들을 먼저 가볍게 살펴볼게요.

출처: Asynchronous programming techniques

본문

스레드(Threading)

스레드는 애플리케이션 블로킹을 피하는 데 가장 널리 알려진 방법이에요.

fun postItem(item: Item) {
    val token = preparePost()
    val post = submitPost(token, item)
    processPost(post)
}

fun preparePost(): Token {
    // makes a request and consequently blocks the main thread
    return token
}

위 코드에서 preparePost가 오래 걸리는 작업이라고 가정해 볼게요. 그러면 자연스럽게 사용자 인터페이스를 블로킹하게 되죠. 우리가 할 수 있는 건 이 작업을 별도 스레드에서 실행하는 거예요. 그러면 UI가 블로킹되는 걸 피할 수 있어요. 아주 흔한 기법이지만 몇 가지 단점도 있어요.

  • 스레드는 값싸지 않아요. 스레드는 비용이 드는 컨텍스트 스위칭을 필요로 해요.
  • 스레드는 무한하지 않아요. 실행할 수 있는 스레드 수는 운영체제에 의해 제한돼요. 백엔드 애플리케이션에서는 이게 큰 병목이 될 수 있어요.
  • 스레드는 항상 쓸 수 있는 게 아니에요. JavaScript처럼 스레드를 지원하지 않는 플랫폼도 있어요.
  • 스레드는 쉽지 않아요. 스레드 디버깅과 레이스 컨디션 피하기는 멀티스레드 프로그래밍에서 우리가 늘 겪는 문제들이에요.

콜백(Callbacks)

콜백 방식의 핵심은 함수 하나를 다른 함수의 인자로 넘기고, 그 작업이 끝나면 그 함수를 호출하게 하는 거예요.

fun postItem(item: Item) {
    preparePostAsync { token -> 
        submitPostAsync(token, item) { post -> 
            processPost(post)
        }
    }
}

fun preparePostAsync(callback: (Token) -> Unit) {
    // make request and return immediately 
    // arrange callback to be invoked later
}

얼핏 보면 훨씬 우아한 해법처럼 느껴져요. 하지만 여기에도 몇 가지 문제가 있어요.

  • 중첩된 콜백의 어려움. 보통 콜백으로 쓰이는 함수는 결국 자기만의 콜백을 필요로 하기 쉬워요. 그러다 보면 콜백이 연달아 중첩되면서 도저히 읽을 수 없는 코드가 돼요. 이런 패턴을 흔히 콜백 지옥(callback hell) 이라고 부르고, 깊게 중첩된 들여쓰기가 삼각형 모양을 만들어서 피라미드 오브 둠(pyramid of doom) 이라고도 불러요.
  • 오류 처리가 복잡해져요. 중첩 구조 덕분에 오류 처리와 전파가 더 어려워져요.

콜백은 JavaScript 같은 이벤트 루프 아키텍처에서 꽤 흔해요. 하지만 그쪽에서도 보통은 프로미스나 리액티브 익스텐션 같은 다른 방식으로 옮겨 가고 있어요.

퓨처, 프로미스 등(Futures, promises, and others)

퓨처(future)나 프로미스(promise)의 아이디어는요(언어나 플랫폼에 따라 다른 용어를 쓰기도 해요), 호출을 하면 언젠가 그 호출이 Promise 객체를 돌려주겠다는 약속을 받고, 그 객체를 대상으로 작업을 이어간다는 거예요.

fun postItem(item: Item) {
    preparePostAsync() 
        .thenCompose { token -> 
            submitPostAsync(token, item)
        }
        .thenAccept { post -> 
            processPost(post)
        }
         
}

fun preparePostAsync(): Promise<Token> {
    // makes request and returns a promise that is completed later
    return promise 
}

이 방식은 프로그래밍하는 법 자체에 여러 변화를 요구해요. 특히요.

  • 다른 프로그래밍 모델. 콜백과 비슷하게, 위에서 아래로 내려가는 명령형 방식에서 벗어나 호출을 이어 붙이는 조합적(compositional) 모델로 옮겨 가요. 루프나 예외 처리 같은 전통적인 프로그램 구조는 보통 이 모델에서는 더 이상 유효하지 않아요.
  • 다른 API. 보통 thenComposethenAccept 같은 완전히 새로운 API를 배워야 해요. 게다가 플랫폼마다 그 API가 달라지기도 해요.
  • 특정한 반환 타입. 반환 타입이 실제로 필요한 데이터에서 벗어나, 내부를 살펴봐야 하는 새 타입 Promise가 돼요.
  • 오류 처리가 복잡할 수 있어요. 오류의 전파와 체이닝이 항상 단순하진 않아요.

리액티브 익스텐션(Reactive extensions)

리액티브 익스텐션(Reactive Extensions, Rx)은 에릭 마이어(Erik Meijer)가 C#에 처음 도입했어요. .NET 플랫폼에서 분명히 쓰였지만, 넷플릭스가 자바로 옮기면서 RxJava라고 이름 붙일 때까지는 주류가 되지 못했죠. 그 뒤로 JavaScript(RxJS)를 포함해 다양한 플랫폼용 포트가 쏟아져 나왔어요.

Rx의 핵심 아이디어는 관찰 가능한 스트림(observable streams) 개념으로 나아가는 거예요. 데이터를 (무한한 양의) 스트림으로 보고, 그 스트림을 관찰할 수 있게 되는 거죠. 실용적으로 보면 Rx는 데이터를 대상으로 여러 연산을 할 수 있게 해주는 확장 기능들이 붙은, 사실상 옵서버 패턴(Observer Pattern)이에요.

접근 방식은 퓨처와 꽤 비슷해요. 다만 퓨처가 이산적인(discrete) 요소 하나를 돌려준다면, Rx는 스트림을 돌려준다고 볼 수 있어요. 하지만 앞의 방식들과 마찬가지로 프로그래밍 모델을 완전히 새롭게 생각하게 만든다는 점이 유명하죠. 흔히 이렇게 표현돼요.

"everything is a stream, and it's observable"

이 말은 문제를 다른 방식으로 접근하게 한다는 뜻이고, 동기 코드를 쓸 때 익숙했던 것에서 꽤 큰 전환이에요. 퓨처에 비해 한 가지 장점은, 이식된 플랫폼이 많다 보니 C#, Java, JavaScript 등 Rx가 있는 어떤 언어를 쓰든 대체로 일관된 API 경험을 얻을 수 있다는 점이에요.

게다가 Rx는 오류 처리에서도 좀 더 나은 접근 방식을 제공해요.

코루틴(Coroutines)

코틀린이 비동기 코드를 다루는 방식은 코루틴을 쓰는 거예요. 핵심은 일시 중단 가능한 계산(suspendable computations) 이에요. 즉, 함수가 어떤 지점에서 실행을 일시 중단하고 나중에 다시 이어갈 수 있다는 아이디어예요.

코루틴의 장점 중 하나는 개발자 입장에서 논블로킹 코드를 쓰는 게 블로킹 코드를 쓰는 것과 사실상 똑같다는 점이에요. 프로그래밍 모델 자체는 크게 달라지지 않아요.

다음 코드를 볼게요.

fun postItem(item: Item) {
    launch {
        val token = preparePost()
        val post = submitPost(token, item)
        processPost(post)
    }
}

suspend fun preparePost(): Token {
    // makes a request and suspends the coroutine
    return suspendCoroutine { /* ... */ } 
}

이 코드는 메인 스레드를 블로킹하지 않고 오래 걸리는 작업을 실행해요. 여기서 preparePost일시 중단 함수(suspendable function) 라고 불러요. 그래서 앞에 suspend 키워드가 붙어 있죠. 앞서 말한 것처럼 이 함수는 실행하다가 잠시 멈추고, 나중에 어떤 시점에 다시 이어져요.

  • 함수 시그니처는 완전히 같아요. 달라진 건 suspend가 붙었다는 것뿐이에요. 반환 타입은 우리가 원하는 타입 그대로예요.
  • 코드는 여전히 동기 코드를 쓰듯이 작성돼요. 위에서 아래로 쓰고, 코루틴을 시작해 주는 launch라는 함수를 쓰는 것 외에 특별한 문법이 필요 없어요(launch는 다른 튜토리얼에서 다뤄요).
  • 프로그래밍 모델과 API는 그대로예요. 루프나 예외 처리 같은 걸 계속 쓸 수 있고, 완전히 새로운 API 집합을 배울 필요가 없어요.
  • 플랫폼 독립적이에요. 대상이 JVM이든 JavaScript든 다른 플랫폼이든, 우리가 쓰는 코드는 똑같아요. 내부적으로 컴파일러가 각 플랫폼에 맞게 조정해 줘요.

코루틴은 새 개념도 아니고, 코틀린이 발명한 것도 아니에요. 수십 년 동안 있었고 Go 같은 다른 프로그래밍 언어에서도 인기 있는 개념이에요. 다만 중요한 건, 코틀린에서 구현된 방식은 기능 대부분이 라이브러리에 위임돼 있다는 점이에요. 실제로 suspend 키워드 외에는 언어에 새로 추가된 키워드가 없어요. 이건 asyncawait가 문법의 일부인 C# 같은 언어와 꽤 다르죠. 코틀린에서 그런 것들은 그냥 라이브러리 함수일 뿐이에요.

더 알아보기