비동기와 동기 함수의 ‘색’ 문제를 Haskell의 모나딕 I/O와 대조하며, 조합성 이슈와 고차 함수의 변형, 탈출구, 그린 스레드와 async/await의 차이를 검토하고 비동기성을 코드에서 명시적으로 드러내는 선택의 타당성을 논한다.
현대의 고전으로 2015년 블로그 글 “What color is your function?”이 있다. 비동기에 관한 이야기이고, 요즘 계속해 온 주제이니 한 번 얘기해 보자.
함수의 “색”은 함수를 두 부류—비동기 함수와 일반 함수—로 나누는 비유다. 아이디어는 이렇다. 비동기 함수는 어떤 함수든 호출할 수 있지만, 일반 함수에서는 비동기 함수를 호출할 수 없다. 이 때문에 함수들 사이에 인위적인 경계가 생기고, 모든 것이… 복잡해진다.
이 주장에 공감하는 편이다. 왜냐하면 오래전에 Haskell을 처음 배울 때 비슷한 문제를 겪었기 때문이다. 나는 늘 “printf 디버깅” 성향이 강했기 때문에, 작성 중인 함수가 왜 오동작하는지 파악해야 할 때면 당연히 print를 넣고 싶었다.
하지만 print를 추가하면 그 함수는 I/O를 하게 된다. 그러면 이제 그 함수의 타입이 바뀐다. a 대신 IO a를 반환해야 하기 때문이다. 그리고 그 함수를 호출하는 모든 함수도 바꿔야 한다. 이제 그 함수들도 I/O를 하게 되었기 때문이다. 그리고 그들을 호출하는 함수들도, 계속해서 마찬가지다.
전염된다.
머릿속 어딘가에는 이런 데 대한 씁쓸함이 남아 있다. :와 ::의 의미를 바꿔 놓은 것 외에도, Standard ML과 Haskell은 printf 디버깅의 어려움도 서로 뒤바꿔 놓았다고 느꼈다. Haskell에서는 deriving Show 덕분에 타입을 출력하기 쉽다! ML에서는 원하는 곳에 실제로 print를 넣을 수 있어서 타입을 출력하기 쉽다! 그리고 각각 반대로는… 그냥 어렵다.
이런 방식의 프로그래밍에 회의적인 시선을 이해한다. 전염성은 정말로 문제다. 다만… 이 특정한 문제는 Haskell 초급자들이 겪는 “뉴비” 문제이기도 하다. 결국에는 문제가 사라진다. 적어도 내게는 그렇게 보였다.
왜 이 문제가 결국 큰 문제가 아니게 되었는지에 대한 내 이론은 다음과 같다.
나는 I/O를 나머지 프로그램과 분리하는 장점을 칭찬하긴 하지만, 그것에 맹신적이지는 않다. Haskell은 “어디까지 가면 너무 멀리 가는가”의 경계를 찾으려는 흥미로운 실험이라고 생각한다.
가장 짜증났던 계산과 I/O 분리 사례 하나를 얘기해 보겠다. 문제는 컴파일과 비슷했다. 초기 파일 하나로 시작하는데, 그 파일에 다른 의존성이 있을 수 있고, 그 의존성에도 또 다른 의존성이 있을 수 있고, 계속 이어진다. 명령형 코드로 쓰면 쉽다. 각 파일을 파싱하고, 의존성을 뽑아내고, 더 많은 파일을 파싱하러 루프를 돈다.
I/O를 분리하려고 하면, 지나치게 복잡한 설계를 발명하게 된다. 나는 결국 순환적인 프로그램을 만들었다. 파싱 결과의 스트림을 “순수” 함수에 넣어 주고, 그 함수가 필요한 다른 파일 이름을 스트림으로 내보내게 한다. 그러면 그 파일들을 파싱해서, 이미 함수의 입력으로 제공된 스트림에 밀어 넣는다. 출력에 의존하는 입력! 게으른 평가 만세!
하지만… 으.
그리고 비록 모나딕 I/O에서의 “색칠하기” 문제가 Haskell 프로그래머로 성숙해질수록 많이 줄어들긴 하지만, 구석구석에는 여전히 남아 있다. Haskell에는 map이 있는데, “map을 모나드적으로 수행”하려면 mapM이 또 필요하다. 이건 거의 모든 일반적으로 유용한 고차 함수에 반복된다. filterM, foldM, zipWithM에 인사하자… 그리고 이건 리스트에 대해서만 그렇다.
그러니 완벽하진 않다. 모나드는 Haskell의 정체성이지만, 이런 류의 문제는 순수 함수형 환경에서 효과를 다루는 다른 방법을 연구자들이 찾고 있는 중요한 이유이기도 하다.
조금 되감아 보자. 비동기 함수는 T 대신 Promise<T>를 반환하는 함수일 뿐이다. 그럼… 이건 그냥 다른 타입을 반환하는 함수 아닌가? String을 반환하는 함수와 Integer를 반환하는 함수를 구분하듯이, 모든 함수가 색을 가진 걸까?
음, 꼭 그렇진 않다. 여기서의 문제는 전적으로 “조합성”에 관한 것이다. 조합성 문제에는 몇 가지 다른 종류가 있다.
Promise<T>가 그냥 또 다른 타입일 수는 있지만, 거기서 원하는 게 T였다면 문제가 있다. await는 비동기 함수 안에서만 쓸 수 있다. Promise<T> -> T로 가는 완벽한 함수를 쓸 수는 없다. 이는 “그 함수가 단지 다른 타입을 반환한다”는 것보다 훨씬 큰 제약이다.filter가 Boolean을 반환하는 걸 원하지만, 당신이 가진 함수가 Promise<Boolean>을 반환한다면 문제가 생긴다.T 대 Promise<T>가 아니라 List<T> 대 List<Promise<T>>일 수도 있다. 어쩌면 Promise<List<T>>를 원했을지 모르지만, 역시 그렇게 풀리지 않았다.이 논거의 큰 부분은 비동기 함수를 비동기 컨텍스트가 아닌 곳에서 호출할 수 없다는 데 기대고 있다. 즉, 우리는 T에서 Promise<T>로 가는 건 할 수 있지만, 비동기 함수 안이 아니면 Promise<T>에서 T로 쉽게 갈 방법이 없다. 하지만 사실 그걸 하는 방법이 몇 가지 있다.
덜 흥미로운 버전은 “그냥 블록하는 선택지는 항상 있다”는 것이다. 해당 future에 대해 태스크 러너를 호출하고, 그 특정 future가 완료될 때까지 동기적으로 블록한다. 이건 최고의 접근은 아니다. 우리는 아마 블록하고 싶지 않을 테니까. 그래도 옵션이긴 하다.
또 다른 흥미로운 탈출구는 절대적 순수성이 없다는 점에서 온다. Haskell을 쓰지 않는 한, 우리는 아마 (스레드 풀 기반일 수도 있는) 태스크 러너 같은 전역 상태를 항상 조금은 가지고 있다. 만약 비동기 함수의 결과를 바탕으로 값을 “반환할” 필요가 없다면, 비동기적 작업을 나중에 완료하도록 전역 태스크 러너에 새 비동기 태스크를 기꺼이 넘길 수 있다. 따라서 비동기가 아닌 함수도 나중에 일어날 비동기 작업을 유발할 수 있다.
그리고 Go가 내부의 그린 스레딩 런타임에서 쓰는 요령도 있다. 블록하려 한다면, 먼저 태스크 풀 실행자에게 새로운 태스크 실행 스레드를 만들라고 신호하라. 왜냐하면 지금 쓰는 이 스레드는 한동안 일을 못 하게 될 테니까.
하지만 이 함수 색칠하기 논변에 대해 나를 괴롭히는 게 하나 더 있다. 아무 함수나 그냥 조합하는 데에는 작은 장애물이 있는 것처럼 보인다. 하지만 여기서의 트레이드오프는 “문제를 강제로 드러낼 것인가” vs “잠재적으로 조용히 잘못된 일을 할 것인가” 사이의 문제다.
2주 전에 우리는 두 개의 동시 쿼리를 수행하려는 짧은 함수를 살펴봤다:
async function example() {
async function branch1() {
return compute1(await query1());
}
async function branch2() {
return compute2(await query2());
}
var (x, y) = await futures_join(branch1(), branch2());
return compute2(x, y);
}
이걸 Go에서, 프라미스 대신 그린 스레드를 사용해 어떻게 쓸까? 우리가 가장 먼저 해야 할 일은 query1과 query2가 잠재적으로 블록한다는 것을 “기억”하는 것이다. 그것을 잊으면, 아무 생각 없이 코드를 쓸 때 쿼리가 동시가 아니라 조용히 순차적으로 실행되게 된다.
그리고 해당 쿼리가 돌아오자마자 compute1 같은 것을 공격적으로 호출하고 싶다면, 우리는 여전히 branch1 같은 함수를 만들어야 한다. 그런 다음 각 분기를 go로 실행하고, 각각의 결과를 기다린다. (여기서는 채널과 반환값의 추가적인 번거로움은 무시하겠다.) 그러니 이건 await와 꽤 비슷해 보인다.
결국 전체적으로 더 간단한 것을 말하는 건 아니다. 그린 스레드는 비동기 함수보다 “더 쉽지” 않다. 함수의 색이 사라지는 것도 아니다. 다만 그것들을 타입으로 드러내면 가끔 방해가 생길 뿐이다.
하지만 결국 함수 색칠하기 논거에는 피할 수 없는 타당성이 있다. 비동기 함수를 비비동기 함수에서 완벽하게 호출할 수는 없고, 이는 위에서 설명한 문제들을 만든다.
다음 질문은: 그 문제가 감수할 가치가 있는가? 어떤 접근에 단점이 있다고 해서, 다른 접근에 더 나쁜 문제가 없다는 뜻은 아니다.
지난주에 우리는 서로 다른 동시성 모델에 대해 이야기했다. 내 주장 중 하나는 단순히 이것이다. 세상은 비동기적이다. CPU가 디스크와 통신할 때, 그것은 비동기적이다. GPU나 NIC와 통신할 때도 비동기적이다. 다른 머신과 통신할 때도, 심지어 프로그램이 같은 로컬 머신의 다른 프로세스나 스레드와 통신할 때조차, 그 모든 것이 비동기적이다.
그런데 우리는 모든 것이 동기적인 것처럼 보이는 프로그래밍 모델을 갖고 있다.
그리고는 이런 논거들을 듣는다. 아, 안 돼, 함수 색칠하기 문제가 있으니, 계속 모든 것이 동기적인 척하자고.
나는 그 주장을 선뜻 받아들이지 못하겠다. 프로그래밍 모델에서 비동기성을 드러내면 두 종류의 함수 사이에 구분이 생긴다. 하지만 그 구분은 원래부터 존재했다. 다만 보이지 않았을 뿐이다. 언제나 우리 대신 내부에서 처리되었을 뿐이다.
OS 스레딩에서 그 처리는 블로킹이었다. 그린 스레딩에서는 진짜 블로킹 케이스가 어떻게든 숨어들지 않기를 바라는 것이다. 왜냐하면 이 모든 것이 내부에서 처리되기 때문이다.
프라미스와 async/await는 그것을 명시적으로 만든다. 아주 작은 런타임으로, 비교적 최소한의 “함수 색칠하기” 문제만을 안고서 말이다. (특히 I/O를 프로그램의 나머지 부분과 더 분리해야 한다는 주장에 동의한다면, 더욱 최소화된다.)
무언가를 명시적으로 만드는 것이 더 낫냐는 문제는 충분히 논쟁의 여지가 있다. 항상 일방적으로 우월한 것은 아니다. 하지만 그것을 암묵적으로 만드는 탓에 우리가 실제 세계와 전혀 맞지 않는 동기적 세계관으로 프로그램을 작성하게 된다면, 나는 암묵성이 더 낫다는 주장에 회의적이 된다.
아마 우리의 프로그램이 작동하는 방식에 대한 마음속 모델이 실제 세계가 작동하는 방식과 더 일치할수록 더 좋은 것일지도 모른다.