함수에 ‘색’을 입힌 우화로 동기/비동기 분할의 폐해를 설명한다. 콜백, 프로미스, async/await가 왜 근본 문제를 풀지 못하는지, 그리고 스레드·고루틴·코루틴처럼 전환 가능한 독립 호출 스택이 어떻게 이 ‘색깔 문제’를 없애는지를 유머러스하게 탐구한다.
나만 그런지 모르겠지만, 아침에 나를 가장 기운나게 하는 건 그 옛날식 프로그래밍 언어 혀끝 비평이다. 평민들이 쓰는 그 “blub” 언어들 중 하나를 누가 후려칠 때 피가 끓는다. 그들은 StackOverflow를 수시로 훔쳐보며 그 언어로 하루를 겨우 보낸다.
(한편, 당신과 나는 가장 계몽된 언어만 쓴다. 우리 같은 전문가 장인의 다듬어진 손을 위해 설계된 끌처럼 날카로운 도구들만.)
물론, 그 비판문의 _저자_인 나로선 위험이 따른다. 내가 조롱하는 언어가 당신이 좋아하는 언어일 수도 있다! 나도 모르게 오합지졸을 내 블로그로 들여보내고, 그들이 횃불과 쇠스랑을 들고 달려와 내 어리석은 팸플릿에 분노를 쏟을 수도!
그 불길의 열기에서 나를 보호하고, 혹시 연약할 수 있는 당신의 감수성을 건드리지 않기 위해, 대신 내가 방금 만들어낸 언어에 대해 성토하겠다. 오로지 불태우기 위해 존재하는 허수아비다.
알고 있다. 이게 헛짓거리처럼 보인다는 걸. 믿어달라, 끝까지 가면 이 허수아비의 짚 머리에 누구(들)의 얼굴이 그려져 있는지 알게 될 것이다.
블로그 글 하나 쓰자고 완전히 새로운 (형편없는) 언어를 배우는 건 벅차니, 당신과 내가 이미 아는 언어와 대부분 비슷하다고 치자. 문법은 대충 JS 같다고 하자. 중괄호와 세미콜론. if, while 등. 프로그래밍 동굴의 공용어.
JS를 고르는 건 이 글의 주제라서가 아니다. 그냥 당신, 평균 독자의 통계적 표본으로서, 가장 쉽게 이해할 가능성이 높은 언어라서다. 보라:
function thisIsAFunction() {
return "It's awesome";
}
우리의 허수아비는 현대식 (형편없는) 언어이므로 일급 함수도 있다. 그래서 이런 걸 만들 수 있다:
// collection에서 predicate을 만족하는 모든 원소를 담은 리스트를 반환한다.
function filter(collection, predicate) {
var result = [];
for (var i = 0; i < collection.length; i++) {
if (predicate(collection[i])) result.push(collection[i]);
}
return result;
}
이건 일명 _고차 함수_로, 이름이 암시하듯 끝내주게 고급지고 무척 유용하다. 보통 컬렉션을 이리저리 만질 때 쓰는 걸로 익숙하겠지만, 이 개념이 몸에 배면 거의 어디든지 써먹게 된다.
예를 들면, 당신의 테스트 프레임워크에서:
describe("An apple", function() {
it("ain't no orange", function() {
expect("Apple").not.toBe("Orange");
});
});
혹은 데이터를 파싱해야 할 때:
tokens.match(Token.LEFT_BRACKET, function(token) {
// 리스트 리터럴을 파싱한다...
tokens.consume(Token.RIGHT_BRACKET);
});
그래서 당신은 신나게 달린다. 함수들을 이리저리 넘기고, 호출하고, 반환하는 멋진 재사용 라이브러리와 앱을 수도 없이 쓴다. Functapalooza.
그런데 잠깐. 여기서 우리 언어가 이상해진다. 묘한 기능이 하나 있다:
1. 모든 함수에는 색이 있다.
익명 콜백이든 일반 이름 붙은 함수든, 각 함수는 빨강이나 파랑 둘 중 하나다. function이라는 키워드 하나 대신, 두 개가 있다:
blue_function doSomethingAzure() {
// 이것은 파란 함수...
}
red_function doSomethingCarnelian() {
// 이것은 빨간 함수...
}
언어에는 색 없는 함수가 전혀 없다. 함수를 만들고 싶다면? 색을 고르라. 규칙이 그렇다. 사실, 따라야 할 규칙이 몇 가지 더 있다:
2. 함수 호출 방식은 그 함수의 색에 따라 다르다.
“파란 호출” 문법과 “빨간 호출” 문법이 있다고 상상해 보자. 예컨대:
doSomethingAzure()blue;
doSomethingCarnelian()red;
함수를 호출할 때는 함수의 색에 맞는 호출을 써야 한다. 반대로 하면—빨간 함수에 blue를 붙이거나 그 반대—끔찍한 일이 일어난다. 어린 시절의 잊고 싶은 악몽, 이를테면 침대 밑에 숨어 있던 팔이 뱀인 광대 같은 게 되살아난다. 모니터 밖으로 튀어나와 당신 눈의 유리체를 빨아먹는다.
성가신 규칙 아닌가? 아, 하나 더 있다:
3. 빨간 함수는 오직 다른 빨간 함수 안에서만 호출할 수 있다.
파란 함수는 빨간 함수 안에서 호출할 수 있다. 이건 괜찮다:
red_function doSomethingCarnelian() {
doSomethingAzure()blue;
}
하지만 반대는 안 된다. 만약 이렇게 하려고 하면:
blue_function doSomethingAzure() {
doSomethingCarnelian()red;
}
글쎄, 밤의 광대 스파이더마우스가 찾아올 것이다.
이 때문에 filter() 같은 고차 함수를 쓰기가 더 까다로워진다. _그것_의 색을 골라야 하고, 그에 따라 넘겨줄 수 있는 함수들의 색도 제한된다. 가장 그럴듯한 해법은 filter()를 빨강으로 만드는 것이다. 그러면 빨강이든 파랑이든 받아서 호출할 수 있다. 하지만 그러면 이 언어라는 털옷의 다음 가려운 지점에 부딪힌다:
4. 빨간 함수는 호출하기 더 고통스럽다.
지금은 “고통스럽다”의 정확한 정의는 하진 않겠다. 그냥 프로그래머가 빨간 함수를 호출할 때마다 귀찮은 고리를 몇 개 통과해야 한다고 상상해 보라. 아주 장황할 수도 있고, 특정 종류의 문 안에서는 못 쓸 수도 있다. 혹은 소수인 줄 번호에서만 호출할 수 있을지도.
중요한 건, 당신이 어떤 함수를 빨강으로 만들면, 당신의 API를 쓰는 모두가 당신 커피에 침을 뱉고(혹은 더 상상하기 싫은 체액을 보태고) 싶어할 거란 점이다.
그러니 분명한 해법은 절대 빨강을 쓰지 않는 것이다. 모든 걸 파랑으로 만들면, 모든 함수의 색이 같아지고, 그건 곧 색이 없는 것과 같고, 그건 곧 우리 언어가 완전히 멍청하지 않은 상태로 되돌아간다는 뜻이다.
아아, 사디스트 언어 디자이너들—우리는 모두 프로그래밍 언어 디자이너가 사디스트라는 걸 알고 있지 않은가?—은 마지막 가시 하나를 우리 옆구리에 또 찔러 넣었다:
5. 핵심 라이브러리의 일부 함수는 빨강뿐이다.
플랫폼에 내장된, 우리가 꼭 써야 하고, 우리가 직접 구현할 수 없으며, 빨강으로만 제공되는 함수들이 있다. 이쯤 되면, 제정신인 사람이라면 이 언어가 우릴 증오한다고 생각할 것이다.
문제는 우리가 고차 함수를 쓰려 하기 때문이라고 생각할 수도 있다. 그 모든 함수형 허세는 그만두고, 하나님이 의도하신 대로 평범한 파란 칼라 1차 함수만 쓰면 마음고생을 덜 것 같다고.
오직 파란 함수만 호출한다면 우리 함수도 파랑으로 만든다. 그렇지 않으면 빨강으로 만든다. 함수가 함수를 받는 일만 없으면, 우리는 함수의 “색에 다형적”이어야 한다(“다색적”?) 같은 헛소리를 걱정할 필요가 없다.
하지만, 유감스럽게도 고차 함수는 그저 한 예일 뿐이다. 이 문제는 프로그램을 재사용 가능한 별도 함수들로 쪼개고자 할 때면 어디에나 퍼져 있다.
예컨대, 사회 연결망에서 서로에게 얼마나 끌리는지를 나타내는 그래프 위에서 다익스트라 알고리즘을 구현한, 괜찮은 코드 덩어리가 있다고 하자. (그 결과가 뭘 의미하는지 결정하는 데 생각보다 오래 걸렸다. 전이적 비호감도?)
나중에, 이 같은 코드 덩어리를 다른 곳에서도 써야 한다. 자연스럽게 그것을 별도 함수로 뽑아 올린다. 원래 있던 곳과 새 코드에서 모두 호출한다. 그런데 무슨 색으로 만들어야 할까? 당연히 가능하면 파랑으로 하겠지만, 그 안에서 빨강 전용 핵심 함수 중 하나를 쓴다면?
새로 호출하려는 곳이 파랑이면 어떡하나? 그 함수를 빨강으로 바꿔야 한다. 그러면 그걸 호출하는 함수도 빨강으로 바꿔야 한다. 으. 어찌 됐든, 당신은 늘 색을 신경 써야 한다. 개발이라는 해변 휴가에서 수영복 속 모래가 될 것이다.
물론, 나는 실제로 색 이야기를 하는 게 아니다, 그렇지 않은가? 이건 우화, 문학적 장치다. 스니치 이야기는 배의 별이 아니라, 인종에 관한 이야기다. 이제쯤이면 색이 무엇을 의미하는지 감이 왔을 것이다. 아니라면, 대공개다:
빨간 함수는 비동기 함수다.
Node.js에서 JavaScript로 프로그래밍한다면, 값을 콜백 호출로 “반환”하는 함수를 정의할 때마다 당신은 방금 빨간 함수를 만든 것이다. 규칙 목록을 다시 보고 내 은유가 얼마나 들어맞는지 보라:
try/catch나 여러 제어 흐름문 안에서 쓸 수도 없다.___Sync() 버전들을 추가하며 수위를 좀 낮췄다.)사람들이 “콜백 지옥”을 말할 때, 그들은 언어 안에 빨간 함수가 있다는 사실이 얼마나 짜증나는지 말하는 것이다. 그들이 비동기 프로그래밍용 라이브러리를 4,089개나 만든다면, 그건 언어가 떠넘긴 문제를 라이브러리 레벨에서 감내하려는 시도다.
업데이트 2021/12/03: 오늘 기준 15,118개의 async 라이브러리.
Node 커뮤니티의 사람들은 오래전부터 콜백이 고통이라는 걸 알고 해법을 찾아왔다. 많은 사람을 들뜨게 한 기법 중 하나가 프로미스다. “퓨처”라는 래퍼 이름으로도 알 것이다.
이는 콜백과 에러 핸들러를 감싼, 일종의 강화 래퍼다. 함수에 콜백과 에러백을 넘기는 걸 _개념_으로 생각한다면, 프로미스는 그 아이디어의 _구체화_다. 비동기 연산을 나타내는 일급 객체다.
방금 단락에 멋들어진 PL 용어를 잔뜩 끼워 넣어 달콤하게 들릴지 모르겠지만, 기본적으로 뱀 기름이다. 프로미스는 확실히 비동기 코드를 약간 더 쉽게 쓴다. 합성도 좀 더 나아져서, 규칙 #4가 조금은 덜 부담스럽다.
하지만, 솔직히 말해, 배를 맞는 것과 중요 부위를 맞는 것의 차이다. 기술적으로 덜 아프긴 하겠지만, 그 가성비에 흥분할 사람은 없을 것이다.
예외 처리나 다른 제어 흐름과 함께 쓸 수 없는 건 여전하다. 동기 코드에서 퓨처를 반환하는 함수를 호출할 수도 없다. (글쎄, 할 순 있지만, 그러면 나중에 당신 코드를 유지보수할 사람이 시간 여행 기계를 발명해 그 순간으로 돌아와서 #2 연필로 당신 얼굴을 콕 찍을 것이다.)
당신은 여전히 세상을 비동기와 동기 두 조각으로 갈라놓았고, 그에 따른 온갖 비참함이 따른다. 그러니 당신의 언어가 프로미스나 퓨처를 제공하더라도, 그 얼굴은 내 허수아비와 놀랍도록 닮았다.
(그건 내가 일하는 Dart도 포함한다. 그래서 팀의 일부가 다른 동시성 모델을 실험하는 게 그렇게나 신난다.)
C# 프로그래머들은 지금 꽤 우쭐할지도 모른다(Hejlsberg와 일행이 언어에 달콤한 기능을 차곡차곡 얹을수록 그 유혹에 빠지는 듯하다). C#에서는 await 키워드로 비동기 함수를 호출할 수 있다.
이 덕분에 비동기 호출을 동기 호출만큼 쉽게 만들 수 있다. 작은 귀여운 키워드 하나를 덧붙이면 된다. await 호출을 식 안에 중첩하고, 예외 처리 코드에서도 쓰고, 제어 흐름 안에 마구 집어넣을 수 있다. 맘껏 쓰라. 새 랩 앨범 계약금처럼 await를 비처럼 뿌려라.
Async-await는 좋다. 그래서 우리도 Dart에 추가하고 있다. 비동기 코드를 _작성하기_가 훨씬 쉬워진다. 이제 “하지만”이 올 차례다. 맞다. 하지만… 여전히 세상을 둘로 갈랐다. 그 비동기 함수들은 쓰기 쉬워졌지만, 여전히 비동기 함수다.
여전히 두 가지 색이 있다. Async-await는 성가신 규칙 #4를 푼다. 빨간 함수를 파란 함수만큼이나 덜 성가시게 호출할 수 있다. 하지만 다른 규칙은 그대로다:
Task<T>(Dart에서는 Future<T>) 래퍼를 반환한다.await가 필요하다.T인데 래퍼 객체가 생긴다. 그걸 벗기려면 당신의 함수도 비동기로 만들고 await해야 한다. (아래 참조.)await를 넉넉히 뿌린 점을 제외하면, 적어도 이건 해결했다.확실히 더 낫다. 나는 언제든 콜백이나 퓨처보다 async-await를 택하겠다. 하지만 우리의 고통이 싹 사라졌다고 스스로 속이진 말자. 고차 함수를 쓰거나 코드를 재사용하려는 순간, 색은 여전히 거기 있고, 코드베이스 곳곳에 피를 흘린다.
JS, Dart, C#, Python은 이 문제를 갖고 있다. CoffeeScript와 대부분의 JS로 컴파일되는 언어들도 그렇다(그래서 Dart도 물려받았다). core.async로 열심히 밀어붙였음에도, ClojureScript조차도 이 문제를 겪는다고 나는 _생각_한다.
하나, 그렇지 않은 언어를 알고 싶은가? Java. 그렇다, 맞다. “그래, 이번엔 진짜 자바가 제대로 하고 있네.”라고 말할 기회가 얼마나 되겠는가? 하지만 이번엔 그렇다. 변명하자면, 그들은 퓨처와 비동기 IO로 옮겨가며 이 간극을 메우려 애쓰는 중이다. 바닥으로 달리는 경주 같다.
C#도 사실 이 문제를 피할 수 있다. 그들은 색깔 도입을 _선택_했다. async-await와 Task<T>를 넣기 전에는 그냥 일반 동기 API 호출을 썼다. 이 문제가 없는 다른 세 언어: Go, Lua, Ruby.
공통점이 무엇일까?
스레드. 더 정확히는: 전환 가능한 여러 독립 호출 스택(사이에서 전환될 수 있는). 운영체제 스레드일 필요는 없다. Go의 고루틴, Lua의 코루틴, Ruby의 파이버면 충분하다.
(C#이 작은 단서를 갖는 이유가 이것이다. C#에서는 스레드를 쓰면 async의 고통을 피할 수 있다.)
근본적인 문제는 “연산이 완료되면 어디에서 다시 시작할 것인가?”다. 당신은 커다란 호출 스택을 쌓아 올린 뒤 어떤 IO 연산을 호출한다. 성능을 위해, 그 연산은 OS의 비동기 API를 사용한다. 완료될 때까지 기다릴 수 없다. 기다려도 완료되지 않는다. 언어의 이벤트 루프로 끝까지 되돌아가 OS가 돌 시간을 줘야 한다.
연산이 끝나면, 하던 일을 재개해야 한다. 언어가 “어디까지 진행했는지”를 기억하는 보통의 방법은 _호출 스택_이다. 현재 호출 중인 모든 함수와 각 함수의 명령 포인터 위치를 추적한다.
하지만 비동기 IO를 하려면 전체 C 호출 스택을 풀어 헤쳐 버려야 한다. 일종의 진퇴양난. 엄청 빠른 IO는 가능하지만, 결과를 어떻게 쓸 도리가 없다! 코어에 비동기 IO를 가진 모든 언어—혹은 JS의 경우 브라우저의 이벤트 루프—는 이 문제를 각자 방식으로 감당한다.
Node는 끝없이 오른쪽으로 전진하는 콜백들에 모든 호출 프레임을 클로저 속에 집어넣는다. 이렇게 할 때:
function makeSundae(callback) {
scoopIceCream(function (iceCream) {
warmUpCaramel(function (caramel) {
callback(pourOnIceCream(iceCream, caramel));
});
});
}
각 함수 표현식은 자신을 둘러싼 모든 문맥을 _클로즈_한다. 즉, iceCream과 caramel 같은 매개변수를 호출 스택에서 힙으로 옮긴다. 바깥 함수가 반환되고 호출 스택이 날아가도 괜찮다. 그 데이터는 힙 어딘가에 여전히 떠다닌다.
문제는 이 모든 단계를 당신이 손수 구체화해야 한다는 점이다. 사실 이 변환에는 이름이 있다: 연속 전달 스타일(Continuation-Passing Style, CPS). 70년대 언어 해커들이 자기들 컴파일러 내부에서 쓸 중간 표현으로 고안했다. 다소 괴상한 코드 표현 방식인데, 우연히도 몇몇 컴파일러 최적화를 쉽게 해 준다.
그 어떤 이도, 단 1초도, 프로그래머가 실제 코드를 그렇게 쓸 거라고는 생각하지 않았다. 그러다 Node가 등장했고, 갑자기 우리는 컴파일러 백엔드인 척을 하고 있다. 어디서 길을 잘못 든 걸까?
프로미스와 퓨처도 사실 아무것도 더해 주지 않는다. 써 본 사람은 안다. 여전히 함수 리터럴의 거대한 무더기를 손수 만들어 낸다. 단지 그것들을 비동기 함수에 넘기는 대신 .then()에 넘길 뿐이다.
Async-await는 도움이 된다. 컴파일러 두개골을 열어 await 호출을 볼 수 있다면, 거기서 컴파일러가 실제로 CPS 변환을 수행하는 걸 보게 될 것이다. C#에서 await가 필요한 _이유_가 이것이다. 컴파일러에게 “여기서 함수를 둘로 쪼개라”는 힌트를 주는 것이다. await 이후의 모든 것은 컴파일러가 합성한 새로운 함수로 끌어올려진다.
그래서 async-await는 .NET 프레임워크에 런타임 지원이 필요 없었다. 컴파일러가 그것을 이미 처리할 수 있는 일련의 체이닝된 클로저로 컴파일해 버린다. (흥미롭게도, 클로저 자체도 런타임 지원이 필요 없다. _그것들_은 익명 클래스로 컴파일된다. C#에서 클로저는 정말로 가난한 자의 객체다.)
언제 생성자를 꺼내오나 궁금했을 것이다. 당신의 언어에 yield 키워드가 있는가? 그렇다면 매우 비슷한 일을 할 수 있다.
(사실, 생성자(generator)와 async-await는 동형이라는 _생각_이다. 내 하드 디스크 구석 어딘가에는 async-await만으로 생성자 스타일의 게임 루프를 구현한 코드가 떠다닌다.)
어디까지 했더라? 아, 맞다. 콜백, 프로미스, async-await, 생성자에서, 결국 당신은 비동기 함수를 힙에 존재하는 한 무더기의 클로저로 쭉 펴 바르게 된다.
당신의 함수는 가장 바깥 클로저를 런타임에 넘긴다. 이벤트 루프나 IO 연산이 끝나면 그 함수가 호출되고 당신은 하던 데서 다시 시작한다. 그러나 그건 당신 위에 있는 모든 것들도 전부 반환해야 함을 의미한다. 전체 스택을 여전히 풀어야 한다.
여기서 “빨간 함수는 빨간 함수만 호출할 수 있다”는 규칙이 나온다. main()이나 이벤트 핸들러까지 전체 호출 스택을 클로저화해야 한다.
하지만 스레드(그린 스레드든 OS 스레드든)가 있다면 그럴 필요가 없다. 전체 스레드를 그냥 중단(suspend)시키고, 그 모든 함수에서 반환하지 않고도 곧장 OS나 이벤트 루프로 돌아갈 수 있다.
내가 보기엔 Go가 이것을 가장 아름답게 한다. 어떤 IO 연산이든 하는 순간, 그 고루틴을 그냥 세워 두고 IO에 막히지 않은 다른 고루틴을 재개한다.
표준 라이브러리의 IO 연산을 보면, 동기처럼 보인다. 즉, 그냥 작업을 하고 완료되면 결과를 반환한다. 하지만 그건 JavaScript에서 의미하는 “동기”와는 다르다. 이런 연산이 대기 중인 동안에도 다른 Go 코드는 돌아간다. 요점은 Go가 동기와 비동기 코드의 구분을 지워버렸다는 것이다.
Go에서 동시성은 표준 라이브러리의 각 함수에 새겨진 색이 아니라, 당신이 프로그램을 어떻게 모델링하느냐의 한 측면이다. 이것은 내가 앞서 말한 다섯 규칙의 고통을 완전히, 그리고 철저히 제거한다.
그러니, 다음에 누가 새로운 핫한 언어를 들고 와서 비동기 API가 있으니 동시성이 끝내준다고 말하기 시작하면, 내가 왜 이를 갈기 시작하는지 이제는 알 것이다. 그건 당신이 다시 빨간 함수와 파란 함수로 돌아갔다는 뜻이니까.