하스켈에서 기본값 지연 평가를 둘러싼 불안, 메모리 누수와 성능 문제, 그리고 지연 평가가 제공하는 합성·표현력상의 이점을 다루는 글
프로그래밍 언어로서 하스켈을 결정적으로 규정하는 특징은 바로 지연(lazy) 평가다. 고차 함수, Hindley–Milner 타입 시스템, 람다 계산 기반이라는 점 등은 다른 언어에도 있다. 사실 하스켈은 80년대 말에 난립하던 비엄격(non-strict) 언어들의 “우후죽순” 상황을 정리하고자, 여러 위원회가 힘을 모아 하나의 언어로 수렴시키기 위해 만들어졌다.
HN(https://news.ycombinator.com/item?id=26281884), 스택 오버플로(https://stackoverflow.com/questions/7768536/space-leaks-in-haskell), 레딧(https://www.reddit.com/r/haskell/comments/pvosen/how_can_haskell_programmers_tolerate_space_leaks/) 같은 곳에서 하스켈을 쓰지 않는 프로그래머들이 지연 평가에 대해 어떤 감정을 갖고 있는지 시간을 들여 훑어보면, 한 단어로 요약할 수 있다: 불안. 지연 평가 때문에 발생하는 공간 누수(space leak)는 피해 갈 수 없고, 하스켈 프로그래머들은 그 사실을 감수하기로 선택한 것처럼 보인다. 이를 잡아내는 방법은 컴파일된 프로그램에 대한 동적 분석에 기반해 있다. 이를 피하는 일반적인 조언 같은 건 거의 없고, 경험이 쌓여야만 한다. 하지만 그조차 충분하지 않아서, 숙련된 하스켈 프로그래머도 가끔씩은 공간 누수에 걸려 넘어지곤 한다. 그렇다면 새로운 하스켈 프로그래머는 도대체 무슨 희망이 있을까?
내가 보기엔 이 불안은 결국, 웹 여기저기에서 반복되는 두 가지 일반적인 주장으로 요약할 수 있다.
함수형 언어에서의 지연/비엄격 평가(lazy/non-strict evaluation)는 필연적으로 공간 누수를 만들어 내며, 그 결과 일반적인 성능 저하를 초래한다. 이런 시스템은 프로덕션에서 믿고 쓸 수 없다. 엄격한(strict) 함수형 언어는 이런 걱정에서 자유로우므로 그쪽을 선호해야 한다.
기본값이 지연인 것은 실수다. 하지만 Rust나 Python처럼 명시적인 이터레이터(iterator) 개념을 도입하면 지연 평가의 장점은 회수할 수 있다. 이쪽은 놀라운 공간 누수도 없다.
위 두 문장은 실제로 웹에서 보이는 주장들의 약간은 과장된 버전이라고 생각한다. 내 목적은 허수아비를 세워 놓고 두들겨 패는 식의 변론이 아니다. 다만 이 두 가지를 논의하기 전에, 먼저 **가치 체계(value system)**에 대해 이야기할 필요가 있다.
몇 년 전 어떤 PL(프로그래밍 언어) 컨퍼런스 발표에서 이런 얘기가 나왔다.
프로그래밍 언어와 그 커뮤니티를 실제로 구분 짓는 건, 서로 다른 ‘가치 체계의 우선순위’다.
그 발표 영상을 유튜브나 구글에서 찾지 못했다(구글 검색이 예전보다 나빠진 걸까?). 혹시 아는 사람은 알려 주길 바란다. (수정: 역시나 Bryan Cantrill의 발표였다.)
예를 들어, C++ 커뮤니티를 다른 언어 커뮤니티와 갈라놓는 건 무엇일까? 바로 성능에 대한 극단적인 강조다. 그래서 “제로 비용 추상화(zero-cost abstraction)”에 그토록 집착하는 것이다. Lisp 커뮤니티는 성능보다는 문법적 확장성(syntactic extensibility)에 더 관심이 있다. 이런 식이다.
하스켈도 마음만 먹으면 충분히 빠르게 만들 수 있다. 하지만 그런 하스켈 프로그램은 우리가 흔히 보는 하스켈 코드와는 모양이 확연히 다르다. 언어 대결(benchmarks game)에서 하스켈 항목을 보라. 거기 나오는 코드는 거의 전부 가변 배열과 노골적인 재귀 호출을 사용한다. 대부분의 경우, C 스타일 코드로 옮겨 가는 과정이 눈앞에 그려질 정도다.
하스켈 커뮤니티 입장에서, “일반적인 코드”에서 단일 코어에서의 궁극적인 성능을 끌어 올리는 일은 우선순위가 아니다. 우리는 빠른 코드보다 합성 가능하고(compositional) 정확하고/신뢰할 수 있는(correct/reliable) 코드를 더 선호한다. 이 관점을 염두에 두고, 앞에서 언급한 질문들을 다시 보자.
멀리서 보면 그리 보일 수 있다. 하지만 실제로 하스켈을 써 보고, 공간 누수를 직접 겪어 보면 이런 사실을 알게 된다.
당신이 만들어 내는 공간 누수의 90%는, 함수의 메모리 사용량을 아주 조금 늘리는 데 그친다. 대부분은 눈에 띄지도 않는다. 예를 들어 (1 + 2) 같은 간단한 thunk는 최적화 시 수요 분석(demand analysis)의 대상이 된다.
그중 1–2% 정도만이 진지하게 문제 되는 수준이고, 이런 경우에는 cost centre를 박아서 코드를 프로파일링할 필요가 있다.
공간 누수가 점유하는 메모리 양은 멱법칙(power law)에 가깝게 분포한다. 그래서 대부분의 공간 누수는 정확성/신뢰성보다는 성능 문제로 여겨질 정도에 그친다.
공간 누수의 강도가 이렇게 분포되는 건 자연 법칙이 아니다. GHC 개발자들이 수많은 시간 동안 프로파일링과 최적화를 반복해 온 결과다. GHC의 수요 분석기와 fusion 최적화 덕분에 (b) 부류의 공간 누수는 점점 더 드물어졌다. 다만, 여전히 남아 있는 (b) 부류의 공간 누수에 대해서는, 프로파일링을 배우고 cost centre를 넣을 줄 아는 능력이 필요하다. 이 점은 어쩔 수 없다.
정리하면 이렇다. 공간 누수는 대부분 성능 문제이며, 드물게 그렇지 않은 경우에는 프로파일링을 배워서 다뤄야 한다. 하스켈 커뮤니티가 이런 상황을 받아들이는 이유는, 관심의 초점이 성능 손실이 아니라 지연 평가가 주는 합성 측면의 이점에 있기 때문이다.
엄격한 함수형 언어와 지연 언어에서는 프로그래밍 스타일이 달라진다. 엄격한 함수형 언어에서는, 의도를 잘 드러내는 고차 함수보다 (꼬리 호출일 거라 기대되는) 명시적 재귀 함수를 더 자주 쓰게 된다. 고차 함수를 쓰더라도, 그 함수가 꼬리 재귀인지 아닌지 신경 써야 한다. 그렇지 않으면 스택 안전성 문제가 생길 수 있다.
지연 언어에서는, 의도를 표현할 때 고차 함수가 훨씬 더 잘 맞는다. guarded (co)recursion과 지연 평가 덕분에 스택 문제를 걱정하지 않아도 되기 때문이다. 이는 “정확성”이라는 목표에 분명히 도움을 준다.
하지만 그보다 더 중요한 건, 합성 측면의 이점이다. 지연 언어에서 함수 합성(연산자 (.)를 쓰든, 그냥 함수 호출을 이어 붙이든)은 멋진 성질을 갖는다. 스트릭트 언어와 달리, 파이프라인의 소비자(consumer)가 평가를 주도(drive)한다는 점이다. 순수한 언어에서 귀납적(inductive) 자료형을 쓰다 보면, 수많은 작은 파이프라인들을 다루게 된다. 간단한 예제를 통해 이게 어떤 이득을 주는지 보자.
haskelllet y = (f . g . h . i) x
엄격한 언어에서는, 이 표현식이 평가되는 방식은 대략 이렇다. 먼저 i x의 결과를 완전히 평가해서 어떤 귀납적 자료 구조로 만든 다음, 그 “완성된” 구조 전체를 h에 넘긴다. 그 결과 또한 완전히 평가해서 g로 넘기고, 이런 식으로 f에 도달할 때까지 반복한다. 설령 f가 특정 예외 상황에서 일찍 중단(short-circuit)할 수 있다 하더라도, 거기 도달하려면 중간에 생긴 값들을 전부 소비해야 한다.
지연 언어에서는 평가가 **수요(demand)와 데이터 의존성(data dependency)**에 따라 진행된다. 우리가 y가 필요하다면, 파이프라인을 줄여 나가야 하고, 그러려면 f가 어떤 결과를 내도록 해야 한다. 그러면 보통 g의 결과에 대한 수요가 생기겠지만, 반드시 그런 건 아니다. f가 귀납적 자료형의 일부만 가지고 만족할 수도 있다.
이런 차이는 여러 흥미로운 결과를 낳는다.
Alternative 타입클래스 같은 것은 지연 평가 없이는 성립하지 않는다.head . sort는 전체를 평가하는 대신, O(n) 시간에 동작한다.“그러지 말고, 애초에 쓸데없는 부분을 만들지 않으면 되지 않나?”라고 말할 수도 있다. 하지만 다른 사람이 쓴 함수의 결과를 이리저리 가공해서 쓰려면, 그 결과를 어떻게 다룰지가 큰 문제다. 데이터 의존성을 자동으로 존중해 주는 시스템이 있다면 말 그대로 “신의 선물”이다.
정리하자면 이렇다. 지연 평가는 흔한 함수 합성을 “터보차저”처럼 강화해서, 하스켈에서는 함수 합성이 시스템을 조직하는 기본 방식이 되게 한다. 함수 합성의 힘이 더 약했다면, 명령형이나 객체지향 패턴이 시스템 구조화의 대안으로 훨씬 더 자주 고려됐을 것이다. 게다가, 고차 함수를 쓸 때 꼬리 재귀 여부 같은 걸 신경 쓰지 않아도 된다는 점까지 합쳐져서, 하스켈러들은 지연 평가를 선호하게 된다.
이런 이터레이터는 next() 함수를 가진, 명시적인 상태 머신이다. 꽤 멋진 개념이다. 다만 프로그래밍 개념 측면에서 보면, 이건 새로 배워야 하는 또 다른 개념이다. 지연 평가에서는 모든 귀납적 자료 구조가, “자료 구조”이자 동시에 “제어 구조” 역할을 겸한다.
이는 곧, 라이브러리 작성자는 각 생성자(constructor)마다 암묵적으로 “yield 지점”을 제공하고, 라이브러리의 사용자는 그걸 마음대로 해체해서 쓸 수 있다는 뜻이다. 이터레이터를 쓰면 .iter()나 .into_iter() 메서드에서 이 과정이 명시적으로 드러난다.
문(statement)이 중심인 명령형 언어에서는 이런 방식이 꽤 좋다. 하지만 오직 표현식과 순수 함수만 있는 함수형 언어에서는, 나는 귀납적 (코)자료형이 역할을 합쳐 버리는 쪽을 더 선호한다. 그 덕분에 생각해야 할 차원이 하나 줄어든다.
물론 한 가지 사소하지만 중요한 지적을 덧붙이자면, 효과(effects)가 있는 상황에서는 함수형 언어에서도 스트리밍 라이브러리가 필요하다. 이건 또 다른 문제다.
내가 작성하는 하스켈 코드는, 표면적으로 (.)를 잔뜩 쓰지 않더라도 결국 대부분이 파이프라인 꼴로 귀결된다. 이런 코드에서, 나는 심각한 (b) 유형의 공간 누수를 피하는 데 도움이 되는 패턴을 하나 발견했다. 나는 이것을 **“좋은 소비자 되기(being a good consumer)”**라고 부른다.
다음 글에서 이 개념을 자세히 설명하고, 이 아이디어로 제안한 수정 사항이 실제로 적용된 몇몇 PR을 링크할 생각이다.