이 글은 동시성과 분산 시스템에서 ‘퓨처’가 단순한 콜백의 설탕이나 스레드의 대체물이 아니라, 비동기 현실을 정확히 모델링하고 타입과 합성을 통해 의미를 드러내며 실패와 실행을 간결하게 구성하게 해 주는 추상화임을 설명한다. 트위터의 Finagle 사례를 바탕으로, 퓨처가 수행 메커니즘과 의미를 분리하고 조합 가능성을 제공함으로써 견고하고 모듈러한 소프트웨어 작성에 어떻게 기여하는지 논한다.
Marius Eriksen (marius@monkey.org)
02 Apr 2013 (원래 aboutwhichmorelater.tumblr.com에 게시됨.)
동시성 프로그래밍은 현대 분산 시스템을 구축하는 맥락에서 점점 더 중요한 주제가 되고 있다. 대부분의 이러한 시스템에는 상당한 정도의 내재적 동시성이 존재한다. 예를 들어: 대규모 말뭉치 크기를 감당하기 위해 검색 엔진은 인덱스를 많은 작은 조각으로 나눈다. 그리고 쿼리를 효율적으로 만족시키려면 이러한 샤드 각각에 대한 요청을 동시에 발행해야 한다.
스레드(또는 프로세스)는 동시 시스템을 프로그래밍할 때 흔히 쓰이는 추상화다: 여러 _실행 스레드_가 있고, 각 스레드는 자신만의 스택을 가진다. 시스템은 이러한 스레드가 물리 하드웨어에 어떻게 매핑되고, 어떻게 선점되고 인터리브(interleave)되는지 관리한다. 이는 운영체제가 스레드가 I/O 작업을 기다리는 동안 해당 스레드의 실행을 중단하고, 다른 스레드를 진행시키도록 해 준다. 전통적으로 스레드는 높은 고정 비용(스택은 페이지 크기 단위로 할당해야 함)과 높은 컨텍스트 스위칭 비용을 모두 갖고 있었다. 이벤트 기반 프로그래밍의 출현은 이 문제를 해결했다: 단 하나의 스레드만 있고, 런 루프(run-loop)가 있으며, I/O 이벤트는 명시적으로 디스패치된다(예: 읽을 데이터가 준비됨, 쓰기가 완료됨 등). 이는 스레드의 비용을 줄여 주지만(스택이 하나뿐이고, OS가 실행을 인터리브할 필요가 없음), 프로그래밍 모델은 어색하다: 프로그래머는 프로그램을 I/O 작업으로 경계가 정해진 여러 조각으로 쪼개야 한다. 특히 C에서는 각 조각마다 별도의 함수를 선언해야 한다. libevent 책의 도입부에 더 자세한 내용이 있다. 이것은 node.js의 모델이기도 하지만, 자바스크립트는 일급 클로저를 제공하므로 문제가 다소 완화된다: 콜백을 중첩함으로써 컨텍스트를 쉽게 공유할 수 있다. 이는 간결성은 개선하지만 모듈성은 아니다: 각 동작의 시퀀스가 고정되고, 오류는 매우 조심스럽게 처리해야 한다. 사소하지 않은 애플리케이션은 금세 이해하기 어렵게 된다.
이벤트 기반 프로그래밍이 전제하던 가정들은 상당 부분 쇠퇴했다: 컨텍스트 스위치는 이제 그다지 비싸지 않고, 메모리는 풍부해졌다. 그러나 우리 시스템이 요구하는 동시성의 정도는 더 커졌다. 수십만, 아니면 수백만 개의 작업을 동시에 처리하는 것이 드물지 않다. 이러한 애플리케이션의 전형적인 예는 소위 롱 폴링(long-polling) 웹 서버다.
Haskell과 Go 같은 언어는 런타임에서 경량 스레드를 구현하여, 수백만 개의 실행 스레드를 값싸게 유지하고, 런타임 자체가 I/O 작업을 다중화하도록 한다. Go는 이를 _분할 스택(segmented stacks)_으로 관리한다: 필요할 때 스택 공간을 할당한다. 이는 컴파일러의 협조와 다른 ABI를 필요로 하지만, 처음부터 다시 시작할 수 있다는 점이 바로 그 미덕이다.
따라서 분명히 “이벤트 vs. 스레드”는 잘못된 이분법이다; 이 명제는 별 의미도 없고, 두 개의 서로 다른 관심사를 혼동한다: 이는 두 가지 구체적 프로그래밍 모델을 가리키며, (1) 컨텍스트가 어디에 인코딩되는지(힙 vs. 스택), (2) 다중화가 어디서 이루어지는지(라이브러리, 또는 런타임/OS)에서 다르다.
Finagle과 다른 곳에서는 우리의 동시성 프로그래밍 모델의 기반으로 조합 가능한 퓨처들을 사용한다(이는 오래된 java.util.Future 및 그 유사품과는 상당히 다르다). 흔히들 말하길, 퓨처는 전통적 “이벤트 프로그래밍”의 결점을 보완해 준다고 한다: 콜백은 지루하고, 모듈성을 해치며, 이해하기 어려운 스파게티 같은 코드를 낳는다. 퓨처는 콜백을 관리 가능하게 만들어 주는 구성요소를 도입함으로써 이를 바로잡는다.
이는 요점을 비껴간다.
퓨처는 분산 시스템을 구축할 때 발생하는 유형의 관심사로 자연스럽게 번역되는 동시성 프로그래밍 모델을 제공한다. 예컨대 원격 프로시저 호출(RPC)을 보자; RPC는 본질적으로 _비동기적_이다. 이는 각 컴포넌트가 각자의 속도로 동작하고, 서로 독립적으로 실패한다는 뜻이다. 공유 시계도, 공유 버스도 없다. RPC는 보통 전형적인 로컬 연산과는 극적으로 다른 지연(latency) 특성을 갖는다. 사실 이는 RPC뿐 아니라 어떤 형태의 I/O에도 해당된다.
퓨처는 현실 세계를 정확히 모델링한다. Future[T]는 T 타입의 결과를 나타내며, 그 결과는 미래의 어떤 시점에 전달될 수도 있고, 아예 실패할 수도 있다. 현실의 비동기 작업처럼, 퓨처는 항상 세 가지 상태 중 하나에 있다: 미완료, 성공적으로 완료, 실패로 완료.
퓨처는 동기와 비동기 연산 사이에 방화벽을 제공한다. 퓨처가 일반 값과 다른 타입을 가지기 때문에(퓨처가 제공하는 값은 T가 아니라 Future[T] 타입), 어떤 연산이 비동기 의미론을 내포하는지 단번에 식별할 수 있다. 이는 코드의 런타임 의미론을 더 쉽게 추론하게 해 준다: 그러한 연산은 느릴 뿐 아니라, 실패 의미론도 크게 다르기 때문이다. 더 나아가, 퓨처는 “전염된다(tainting)”: 퓨처의 결과를 끌어다 쓰려면, 다른 값들을 그 안으로 “들어 올려(lift)”야 한다. 즉, 비동기가 필요한 어떤 것을 계산한다면 그 연산 자체가 비동기가 된다 — 타입이 Future[T]여야 한다. 결론적으로 의미론이 타입에 의해 드러난다; 우리는 타입 시스템을 사용해 동작을 모델링하고 강제할 수 있다. 달리 말해: 컴파일러로 특정 동작을 보장할 수 있다.
퓨처는 잘 합성된다. 물리적으로 비동기적인 연산처럼, 퓨처는 합성에 대해 닫혀 있다. 이는 중요하다: 두 비동기 연산의 합성은 다시 비동기 연산으로 기술될 수 있다. 퓨처도 마찬가지로 동작한다: 두 퓨처를 순차적으로 구성할 수도 있고(예: 데이터 의존성이 있을 때), 동시에 구성할 수도 있다(의존성이 없을 때). 둘 모두 새로운 퓨처를 만든다. 퓨처를 합성할 때 실패 처리는 내장되어 있다: 순차 구성에서는 첫 실패에서 시퀀스가 단락(중단)되고; 동시 구성에서는 하위 연산 중 하나라도 실패하면 합성된 연산도 실패한다.
퓨처는 영속적이다. Future[T]는 항상 같은 것을 의미하며, 나중에 어떻게 사용되든 동일한 기저 연산을 가리킨다. 연산들을(동시 또는 순차로) 고리처럼 잇기 위해서는 새로운 퓨처가 생성되며, 새로운 의미론을 갖는다. 이는 퓨처에 대한 추론을 매우 단순하게 만든다; 다른 곳에서 어떻게 사용되거나 수정될지를 걱정할 필요가 없다.
퓨처는 의미를 메커니즘으로부터 해방한다. 다양한 콤비네이터로 퓨처를 연결함으로써, 프로그래머는 연산의 _의미_를 표현하지 실행 메커니즘을 표현하는 것이 아니다. Finagle 사용자 가이드에서 가져온 이 예제에서, 우리는 웹 페이지의 모든 이미지를 가져온다:
val allImages: Future[Seq[Image]] =
fetchUrl(url) flatMap { bytes =>
val fetches = findImageUrls(bytes) map { url =>
fetchUrl(url)
}
Future.collect(fetches)
}
이것은 다음과 같이 읽을 수 있다:
allImages는 먼저url을 가져오고, 이어서 가져온 페이지 본문에서 찾은 각 이미지 URL에 대해, 그 모든 URL을 다시 이미지로 가져온 결과를 돌려주는 연산이다.
코드는 우리가 하고자 하는 것을 영어 설명과 꽤 가깝게 표현한다. 우리는 연산을 기술했지, 그 수행 방법을 기술하지 않았다. 설명 속에는 내재적 동시성이 있다: 이후의 이미지 URL들은 동시에 모두 가져올 수 있다; 이는 퓨처와 함께 쓰는 동시 콤비네이터(이 경우 Future.collect)로 자연스럽게 번역된다. 실패 처리는 설명에서 생략되어 있으며, 코드에서도 생략되어 있다. 이 경우 어떤 연산이든 실패하면 더 진행할 수 없다는 것이 분명하고, 그것이 합성 연산(allImages)의 의미론이기도 하다. 해결해야 하는 문제에 내재된 데이터 의존성은 모든 것을 드러낸다.
이 분리는 추론의 용이성을 높여 준다: 연산의 의미가 그것을 어떻게 실행할지에 대한 지시와 뒤엉켜 있지 않다. 새 스레드를 만들지도 않고, 오류를 명시적으로 처리하지도 않으며, 이미지 가져오기 결과를 모으기 위한 명시적 조정 메커니즘도 필요 없다. 대신 _데이터 의존성_만 있을 뿐이다 — 모든 이미지를 가져오려면 그들의 URL을 가져와야 하고; URL을 얻으려면 웹 페이지를 가져와야 한다.
이 속성은 또한 이러한 연산의 런타임 동작을 별도로 고찰할 수 있게 한다. 예컨대 퓨처 구현체는 동시에 수행되는 연산의 수를 제한할 수도 있고, 고유한 지역성을 활용할 수도 있다(예: 연결 풀에 편향을 주어 특정 연결이 항상 한 하위 스레드에서 처리되도록). 라이브러리는 트레이싱 데이터를 스레드처럼 함께 전달해 연산을 진단할 수도 있다. 이는 가정이 아니다: Finagle은 이를 모두 수행하며, 특별한 런타임 지원 없이 전적으로 라이브러리만으로 구현되어 있다.
퓨처는 그 자체로 매력적인 동시성 프로그래밍 모델을 제시한다. 퓨처를 스레드의 값싼 대체물로 보아서는 안 된다.
궁극적으로 이러한 추상화는 견고하고 안전하며 성능 좋고 모듈러한 소프트웨어를 작성하는 데 기여하기 위해 존재한다. 퓨처는 트위터에서 이 점에서 큰 성공을 거두었고, 동시성 시스템을 구성하기 위한 기본 추상화로 오랫동안 유효할 것이라 생각한다. 우리는 지금 동시성의 캄브리아기에 있으며, 나는 퓨처가 그 생존자 중 하나가 될 것이라 예측한다.
이는 다른 모델이 좋지 않다는 뜻은 아니다. 개인적으로 나는 Go/Haskell의 값싼 스레드 모델의 큰 팬이다: 언어와 런타임에 내장되어 있어 그 사용이 강제되기 때문이다. 아마 이것이 퓨처의 주된 걸림돌일 것이다. 퓨처는 서드파티 라이브러리로 구현되기 때문에 사용이 일관되지 않을 수 있고, 늘 작은 브리지나 어댑터를 작성하는 자신을 발견할지도 모른다. 트위터에서는 우리의 시스템이 공통된 기반 위에 구축되어 있어 이 문제가 덜하다. 그러나 이는 모델에 본질적인 제약이 아니다: C#과 F#은 이를 언어와 런타임에 내장했다. 스칼라의 SIP-14도 그 생태계를 통합할 것을 약속한다.