왜 하스켈인가?

일러두기

이 글은 하스켈에 대해 잘 모르는 프로그래머를 대상으로 되도록 쉽게 썼습니다. 복잡한 개념을 거칠게 단순화할 수도 있고, 이론적으로 엄밀하지 않을 수도 있습니다.

개요

하스켈을 배우면 뭐가 좋을까요? 하스켈이라는 도구의 장점으로 꼽히는 것들을 훑어보겠습니다. 이런 것들이 있습니다.

성능

하스켈은 일반적으로 동적 언어들과 비슷한 양의 코드로 같은 일을 할 수 있으면서도 결과물의 성능이 훨씬 뛰어납니다. 이것은 하스켈이 네이티브 코드 컴파일을 하기 때문이기도 하고, 하스켈 대표 컴파일러인 GHC에 오랜 세월 동안 각종 최적화 기법이 누적 적용되어 있기 때문이기도 하고, 언어에 깔려 있는 근본적인 가정을 활용할 수 있는 덕분이기도 합니다.

예를 들어 동적 언어인 파이선에서

object.a = 1
print(object.a)

위의 코드를

print(1)

로 치환하는 것이 가능할까요?

세부적인 차이는 있지만 대체로 불가능합니다. object.a = 1object라는 객체의 a라는 애트리뷰트가 1이라는 뜻인 것 같지만, 실제로는 objectsetattr 함수에 "a"1이라는 인자를 넘기는 것이고, setattr은 오버라이드 되어 있을 수 있기 때문에 무슨 일이 일어날지는 정확히 알 수 없습니다. print(object.a) 또한 objectgetattr 함수에 "a"라는 인자를 넘기는 것입니다. 물론 대부분의 경우에는 메모리의 지정된 장소에 1을 읽고 쓰는 것뿐이기 때문에 JIT 컴파일러를 이용해 오버헤드를 크게 줄일 수 있지만, 항상 가능한 일은 아닙니다.

반면 하스켈에서는

data Object =
    Object { a :: Int }
myObj = Object { a = 1 }
main = print (a myObj)

위의 코드를

main = print 1

로 컴파일러가 치환하는 것이 매우 쉽습니다. 언어 설계상으로 그런 보장이 있기 때문입니다.

하스켈은 일반적인 컴파일러 최적화 기법인 상수 접기(constant folding)를 매우 광범위하게 적용할 수 있습니다. 또 자료의 타입이 언제나 확실하고, 자료가 기본적으로 불변이며 자료의 기본적인 구조가 컴파일 시간에 고정되기 때문에 컴파일러가 적극적으로 언박스(unbox)를 할 수 있습니다. 언박스는 컴파일 시점에 코드를 분석하여 포인터 연산이 필요 없는 값을 발견하면 포인터로 가리키던 (간접) 부분을 실제 값으로 치환하여 (직접) 컴파일하는 것으로서, 데이터가 버스를 오가느라 생기는 오버헤드와 캐시 미스를 대폭 줄입니다.

하스켈의 기본 자료형인 느긋한 리스트(lazy list)는 다른 언어에 나중에 확산된 이터레이터와 비슷한 개념으로, 같은 자료형이 반복되는 구조를 효과적으로 처리할 수 있습니다. 또 오늘날의 하스켈 컴파일러는 바이트열과 같이 일정한 규격의 데이터가 지속적으로 입력되고 출력되는 스트림(stream) 계열의 자료형에 대해서, 스트림을 가공하는 여러 종류의 함수를 이어붙일 경우 이것을 하나의 함수로 컴파일하여 불필요한 메모리 복사 등의 오버헤드를 줄이고 성능을 향상시키는 스트림 퓨전(stream fusion) 최적화를 내장하고 있습니다.

동시성, 병렬 처리

하스켈의 동시성은 어처구니가 없습니다.

동시성이 시대의 대세가 되었다는 데에는 이견이 없을 것입니다. 프로세서의 클럭 스피드는 한계에 달했고, 컴퓨터는 코어를 늘리는 방향으로 발달하기 시작했고, GPU를 GPGPU로 활용하고, 머신을 늘려 클러스터를 만듭니다.

하스켈은 병렬 입출력 관리자를 기반으로 언어적 특성을 십분 활용하여 놀라운 동시성과 병렬성을 보여줍니다. 하스켈의 동시성은 세 가지 층위로 나누어서 볼 수 있습니다.

스파크

스파크는 처리할 수 있는 작업의 최소 단위로 간주할 수 있습니다. 다음과 같은 하스켈 코드에서

main = do
    x <- action1 ...
    y <- action2 ...
    v <- h (f x + g y)
    ...

f xg y를 봅시다. fg는 순함수입니다. 이것은 하스켈의 타입 시스템에 의해 보장됩니다. f xg y+의 인자로 쓰이고 있기 때문입니다.

순함수는 인자만 같으면 반환값도 언제나 같은 함수입니다. 다시 말해서 f xg y 중 어느 것을 먼저 계산하든 f x + g y의 값은 똑같을 것입니다. 그러면 계산 순서도 상관 없고 아무 때나 계산해도 되는데 어쨌든 계산을 하기는 해야 하는 두 개의 작업이 있는 것입니다. 이것들이 바로 스파크가 될 수 있는 작업들입니다.

이 프로그램은 단순 무식하게 하나의 코어에서 f x를 먼저 계산하고 그 후에 g x를 계산하고 그 후에 두 값을 더하는 방식으로 동작하지 않아도 될 것입니다. 이론상으로는 f xg y를 각각 다른 프로세서 코어에 할당해서 계산을 시키고 결과를 더하는 방식으로 동작하면 병렬 처리로 훨씬 더 빠르게 작업이 끝나겠죠. 하지만 이런 최적화는

  1. 일반적으로 계산을 하기 위한 별도의 스레드를 명시적으로 생성하고, 그 스레드의 종결을 기다려서 값을 얻기 위한 여러 줄의 코드와 절차를 요합니다.
  2. 게다가 이미 여러 스레드를 운용하고 있는 소프트웨어라면 계산을 위한 스레드를 따로 생성하는 것이 오히려 코드를 더 느리게 만들 수도 있습니다.
  3. 소프트웨어가 실행될 환경의 코어 수가 고정되어 있지 않다면 최적 코드도 결정할 수 없습니다.
  4. 이런 난국을 회피하기 위해 애플리케이션 수준에서 스레드 풀(thread pool)을 만들어 쓰기도 하지만, 프로젝트에 그런 게 없었다면 새로 도입하는 것도 상당한 비용이고, 처리량을 극대화할 수 있는 올바른 스레드 풀 운용도 쉽지 않은 과제입니다.
  5. 코드 실행 시점의 실제 상황에 따라, 별도의 스레드를 생성하는 오버헤드가 병렬화로 얻는 성능 이득보다 더 커서 안 하느니만 못할 수도 있습니다.

등의 문제를 안고 있기 때문에 현실적으로 잘 활용되지 못합니다. 반면 하스켈은 순함수와 액션이 타입 시스템으로 확고하게 분리되어 있는 언어이기 때문에, 간단한 코드 수정만으로도 순함수 계산을 효율적으로 병렬화할 수 있습니다.

import Control.Parallel

main = do
    x <- action1 ...
    y <- action2 ...
    v <- f x `par` g y `pseq` h (f x + g y)
    ...

par 함수는 좌측의 계산을 병렬화해도 된다는 “힌트”를 컴파일러에게 줍니다. pseq 함수는 좌측의 계산이 우측의 계산보다 먼저 일어나야 한다는 조건을 명시합니다. 컴파일된 코드는 f x가 실행될 시점에서 실제로 어떻게 실행할 것인지를

등을 종합적으로 고려해 판단합니다. 또 몇 개의 코어를 활용할 것인지를 프로그램 실행 시점에 결정할 수 있습니다.

$ ghc -o main Main.hs -threaded
$ ./main
$ ./main +RTS -N4

하스켈 코드를 컴파일할 때 -threaded 옵션을 주면, 컴파일된 바이너리의 RTS(runtime system) 옵션으로 -N을 줄 수 있게 됩니다. 이제 프로그램에 +RTS -N4라는 옵션을 주면 이 프로그램은 4코어를 활용하는 프로그램으로 병렬화됩니다. f x를 별도의 스레드에서 계산할 것인지 등은 이러한 맥락을 바탕으로 최적 성능을 달성하는 방향으로 결정됩니다.

보다 엄밀하고 자세한 설명은 Simon Marlow의 2012년 발표 슬라이드 참조.

스레드

스파크보다 상위의 개념은 하스켈 스레드입니다. 하스켈 스레드는 일반적인 액션을 forkIO 함수에 넣어서 실행하면 생겨납니다.

main = do
    forkIO f
    ...

하스켈은 요즘 다른 언어에서도 각광 받고 있는 “그린 스레드” 개념을 이미 오래 전부터 구현해 언어에 내장해 놓았습니다. 스레드 생성과 소멸의 오버헤드가 매우 적고, 모든 스레드 안에서 블러킹 시스템 콜을 쓸 수 있고, 이렇게 만든 블러킹 스레드들은 자동으로 epoll이나 kqueue 등으로 번역되어 고성능으로 동작합니다. node.js가 초기에 넌블러킹 동시성을 내세우며 급부상했는데, 하스켈 스레드는 똑같은 개념을 콜백 지옥 없이 일반적인 코드를 그대로 유지하며 쓰게 해줍니다. RTS에 여러 개의 코어가 주어지면 각 스레드가 코어를 적절히 점유하거나 밀려나는 과정도 당연히 자동으로 됩니다.

(한편 forkIO 대신 forkOS를 쓰면 하스켈 스레드가 아니라 OS 스레드가 생겨나고, 하스켈 스레드와 똑같이 사용할 수 있지만, 하스켈 스레드만 써도 멀티코어 활용을 알아서 해 주기 때문에 대체로 굳이 쓸 이유가 없습니다.)

여러 스레드 간의 통신도 쉽고 간편합니다. MVar는 “값이 들어 있거나 혹은 비어 있는 상자”와 비슷한 개념으로, 락(lock)과 값을 합쳐 놓은 것으로 볼 수 있습니다. Chan을 사용하면 프로세스와 프로세스가 파이프를 통해 표준입출력을 주고 받는 것보다도 더 쉽게 스레드에서 스레드로 자료를 넘길 수 있습니다. 그리고 그 끝판왕은…

소프트웨어 트랜잭셔널 메모리

데이터베이스는 트랜잭션(쪼갤 수 없고 재시도 가능한 작업 단위)의 개념을 가지고 있기 때문에 여러 클라이언트가 접속해서 읽고 쓰고 난리를 쳐도 됩니다. 메모리에는 트랜잭션의 개념이 없기 때문에 여러 스레드가 동시에 메모리의 같은 영역에 접근하여 읽고 쓰기를 하게 내버려 두면 데이터가 반드시 패망하게 되고, 패망을 막기 위해 뮤텍스니 세마포어니 쓰다가 실수하면 프로그램은 수렁에 빠지고 프로그래머의 뇌가 패망하게 됩니다.

그러므로 메모리에도 트랜잭션의 개념을 갖자는 것이 하스켈의 소프트웨어 트랜잭셔널 메모리입니다.

main = do
    shared1 <- atomically $ newTVar (0 :: Word64)
    shared2 <- atomically $ newTVar (0 :: Word64)
    forkIO $ task1 shared1 shared2
    task2 shared1 shared2

task1 tvar1 tvar2 = do
    threadDelay 5000  -- 5 miliseconds
    atomically $ do
        s1 <- readTVar tvar1
        s2 <- readTVar tvar2
        writeTVar s2 (s1 + s2)
    task1 tvar1 tvar2

...

하스켈은 타입 시스템 덕분에 트랜잭션과 원자성을 정확하게 기술할 수 있습니다. STM의 atomically 함수는 STM 액션을 받아서 그것을 IO 액션으로 바꿔주는 함수입니다. 그러면 하나 이상의 STM 액션이 하나의 트랜잭션이 되어 원자적으로(atomically, 즉 쪼갤 수 없게) 실행됩니다. STM 타입이 따로 있으므로 트랜잭션 안에 IO 액션을 집어넣을 수 없으며, 반대로 실행 순서와 횟수에 영향을 받지 않는 순함수들은 자유롭게 트랜잭션에 포함할 수 있게 됩니다. 또 트랜잭션이기 때문에 실패했을 경우의 조치 역시 데이터베이스 트랜잭션과 마찬가지로 정확하게 기술할 수 있습니다.

STM은 데이터베이스만큼 안전하고, 데이터베이스만큼 편하고, 데이터베이스보다 훨씬 빠르고, 프로그래밍 가능한 기억 공간을 프로그램 내부에 가지게 해줍니다. 동시성 프로그래밍에서 데드락과 레이스 컨디션 등의 골칫거리를 해결하는 데에 가장 효과적이고 고도화된 방법이라고 할 수 있습니다. 트랜잭셔널 메모리가 어찌나 좋은지 최근 인텔 칩에는 하드웨어 트랜잭셔널 메모리(HTM) 기능이 추가되었으며 동시성 데이터 핸들링의 미래로 각광을 받고 있습니다.

정확성

소프트웨어의 정확성(correctness)을 기할 수 있게 해 주는 하스켈의 특징으로 세 가지를 꼽을 수 있습니다.

타입 시스템

하스켈의 타입 시스템은 버그를 피하며 프로그래머의 의도대로 정확하게 동작하는 소프트웨어를 작성할 수 있도록 상당히 높은 수준의 보장을 제공합니다. 예를 들어, 다음과 같은 파이선 코드가 있으면,

def f(a, b, c):
    if a.startswith(b):
        c.append(a)

f라는 함수가 있고 이 함수는 인자 세 개를 받는데, 인자 이름이 각각 a, b, c입니다. ab로 시작하면, 즉 a의 앞부분이 b와 일치하면, c에다가 a를 추가합니다. a는 아마도 문자열일 겁니다. 왜냐면 a.startswith를 호출하고 있으니까. b도 문자열일 겁니다. ab로 시작하는지를 묻고 있으므로. c는 리스트일 것 같습니다. append 메소드를 보니. 그리고 이 리스트에 문자열을 추가한다는 점으로 보아 아마도 문자열의 리스트일 겁니다. 보통 사람을 리스트에 추가한다고 하면 그 리스트는 사람의 리스트죠.

비슷한 하스켈 코드를 보겠습니다.

import Data.ByteString (isPrefixOf)

f a b c = if b `isPrefixOf` a
    then a : c
    else c

마찬가지로 f는 인자 세 개를 받는 함수입니다. ba의 접두사이면, 즉 a의 앞부분이 b와 일치하면 c에다가 a를 추가해서 반환하고 그렇지 않으면 그냥 c를 반환합니다. 여기서 우리가 알 수 있는 것은

예를 들어 여러분이 저 f라는 함수의 작성자라고 해봅시다. 그러면 누군가 파이선에서 f(1, 2, 3) 같은 코드를 써놓았다면 여러분은 즉시 그것이 잘못된 코드라는 것을 알 수 있을 것입니다. 문자열과 문자열과 문자열의 리스트를 받아야 될 함수가 숫자를 인자로 받고 있다니? 하지만 파이선 인터프리터는 그 f(1, 2, 3)이라는 코드가 실제로 실행되기 전까지는 뭐가 잘못되었는지 알지 못합니다. 정확히 말하면 a.startswith(b) 부분이 실행되기 전까지는 오류도 발생하지 않고 아무것도 알 수 없습니다. 그러면 “사람은 이 코드가 잘못되었다는 것을 보자마자 알 수 있는데, 기계는 그것을 알 수 없는” 상황이 됩니다. 이것은 영 바람직하지 못합니다. 사람이 미처 잡지 못하는 오류라도 컴파일러/인터프리터가 잡아줄 수 있어야 합니다. 그런데 거꾸로죠.

(실은 a가 문자열이 아니어도 이 파이선 코드는 오류를 일으키지 않을 수도 있습니다. 파이선은 덕 타이핑(duck typing)을 쓰기 때문에, a가 객체고 startswith라는 애트리뷰트를 가지고 있으며 그 애트리뷰트가 __call__을 구현하고 있다면 a가 문자열이 아닌데도 오류 없이 지나갈 수도 있을 것입니다. 그리고 바로 이것 때문에 구현은 a.startswith(b)만 가지고는 ab의 정체에 대해서 확신을 가질 수 없는 것이죠.)

반면 하스켈에서는 f 1 2 3 같은 코드를 보자마자 이것이 잘못된 코드임을 감지할 수 있습니다. 왜냐하면 a는 하늘이 두쪽나도 반드시 무조건 기필코 바이트열이기 때문입니다. a `isPrefixOf` b라는 코드가 있으니까요. isPrefixOf는 바이트열 두 개를 받는 함수이기 때문에, 그것의 인자로 사용되었다면 반드시 바이트열인 것입니다. 마찬가지로 c의 타입도 정확하게 알 수 있는데, a : c를 보면 리스트 연산자(:)가 사용되었죠. 이 연산자는 원소와 리스트를 인자로 받아서 원소를 리스트 앞에 추가한 리스트를 반환하는 연산자입니다. 따라서 c는 리스트이고, 이 리스트의 새 원소가 될 a가 바이트열이니 바이트열의 리스트겠죠. f 1 2 3은 컴파일 오류입니다.

컴파일 시간에 오류를 잡아낸다는 것은 실무적으로 매우 중요한 이점인데, 버그가 발견되지 않은 채로 코드가 프로덕션까지 나가버린 뒤에 버그가 터져서 서비스가 중단되거나 오작동하는 대재앙을 줄여주기 때문입니다.

다른 함수형 프로그래밍 언어 가운데에도 엄격한 타입 시스템을 적용하는 경우는 많지만 이로 인해 현실적 불편을 겪습니다. 예를 들면 서로 다른 정수형·실수형의 사칙연산에도 타입 구분을 위해 모두 별도의 연산자를 할당해야 하거나 직렬화(serialization)를 하기 위해 모든 타입에 대해 별도의 함수를 만들 수밖에 없습니다. 이런 현실적 불편이 코드를 쪼개고 재사용하는 과정을 방해하며, 다른 언어를 쓰다 온 프로그래머들은 답답함을 이기지 못하고 동적 타이핑의 세계로 돌아가고 싶다고 울부짖게 됩니다. 하스켈은 타입클래스를 이용해 정확성과 유연성 양쪽을 해결했으며 공통점이 있는 여러 타입에 대한 공통의 인터페이스를 가질 수 있게 했습니다. 타입클래스 개념은 하스켈의 대표적인 성공으로 꼽힙니다.

기본적 불변성과 통제된 가변성

일반적인 프로그래밍 언어에서는 변수를 씁니다. 변수는 이름 그대로 변하는 값이므로 시시각각 변합니다. 모든 변수가 변하고 변수의 변화는 프로그램의 실행 결과에 큰 영향을 줍니다. 프로그램이 문제를 일으키거나 예상치 못한 동작을 하면 그 상황을 재현하고 원인을 파악하는 데에 가장 핵심적인 관건은 변수입니다. 하스켈의 자료는 기본적으로 불변입니다. (immutability by default) 이것만으로도 이미 많은 문제가 사라집니다.

값이 변화해야 하는 경우는 어떻게 기술할까요? 가변 자료를 아예 만들 수 없다면 심각한 문제가 있을 것입니다. 어떤 자료 구조는 애초에 구현이 불가능해질 것이고, 어떤 자료 구조는 구현하더라도 성능 손실을 피할 수 없겠죠. 하스켈에서 가변 자료를 쓰는 대표적인 방법은 ST 액션 타입을 사용하는 것입니다. 모든 ST 액션은 STRefSTUArray 등의 가변 자료형을 복사 없이 (in-place) 변화시킬 수 있습니다. ST 액션은 다른 종류의 액션과 함부로 섞어 쓸 수 없으며 아무 함수에서나 실행할 수도 없으므로 다른 자료의 불변성을 해치지 않습니다. IORef, STRef, MVar, TVar 등의 모든 가변 자료형은 모두 특정한 액션 타입 안에서 정확한 타입을 사용해서만 읽고 쓸 수 있게 되어 있어서 안전성이 보장됩니다.

알고리즘과 입출력의 분리

일반적으로 프로그래밍에서는 눈에 핏발을 세우고 코드를 한 줄씩 추적해야 하는 일이 꽤 자주 생깁니다. 다음 코드에서

print(a.b(1))
print(a.b(1))
print(a.b(1))
print(a.b(1))
print(a.b(1))

print한 결과는 매번 다를 수도 있습니다. 따라서 프로그램이 문제를 일으키거나 예상치 못한 동작을 해도 그 상황을 재현하고 원인을 파악하기가 어렵습니다. 많은 프로그래머들이 문제가 되는 것처럼 보이는 모든 줄에 print를 삽입하거나, 디버거로 한 줄씩 실행하면서 로컬 변수를 하나씩 뜯어보며 버그와 씨름하는 고통의 시간을 보냅니다.

반면 하스켈에는

함수의 인자가 같으면 반환값도 같다

라는 성질이 있어서 이런 고뇌를 하지 않아도 됩니다. 이것이 참조 투명성입니다. 인자가 같으면 반환값도 같다는 성질이 보장되는 함수를 참조 투명한 함수 또는 순함수(pure function)라고 부르며, 그래서 하스켈을 순함수형(purely functional) 언어라고 부르기도 합니다.

참조 투명성이 언어 차원에서 강제된다는 것은 소프트웨어 개발이 복잡해질수록 엄청나게 큰 이득이 됩니다. 예를 들어,

def distance((x1, y1), (x2, y2)):
    return math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)

위 코드는 2차원 좌표 두 개를 받아서 좌표 사이의 거리를 반환하는 평범한 함수입니다. 이 코드는 인자가 같으면 반환값도 같을 것이며 프로그램의 그 어디에서 사용하든 문제가 없습니다. 앞서 말한 참조 투명한 함수 또는 순함수입니다. 문제는 순함수에

def distance((x1, y1), (x2, y2)):
    launchNuclearMissiles()
    return math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)

이렇게 코드를 추가해도 아무런 오류가 발생하지 않는다는 것입니다. 분명히 두 값을 받아서 값 하나를 반환하는 것 외에는 아무것도 하지 않는 순함수였는데, 누군가 거기에 코드를 추가하거나 변경해서 참조 투명성이 깨지는 일이 일어나도 다른 사람들은 알 수가 없고 심지어 그 변경을 가한 프로그래머 본인도 모르고 넘어갈 수도 있습니다. 순함수가 불순함수로 바뀌는 일이 암시적(implicit)으로 일어나는 것입니다. 이제 누군가 아무 생각 없이 두 좌표 사이의 거리를 구하기 위해 distance 함수를 호출했다가 핵미사일이 발사되고 문명이 초토화되겠죠.

반면 하스켈에서는 순함수와 불순함수의 구분이 명시적(explicit)이기 때문에 이런 걱정이 없습니다. 문자를 출력하거나, 파일을 읽거나, 데이터베이스에서 행을 가져오거나, 인터넷에 접속하는 등 외부 세계와 소통하는 코드는 실행할 때마다 결과가 같을 수 없습니다. 그런 코드를 부작용(side-effect)이 있는 코드라고 부릅니다. 다른 프로그래밍 언어에서는 부작용을 일으키는 코드도 함수라고 부르고 또 함수 안에서 호출해서 처리하지만, 하스켈은 부작용을 안전하게 다루기 위해 별도의 ‘액션’ 타입을 만듭니다. IO 타입이 대표적인 액션 타입이고, 그밖에도 가변 자료에 변경을 일으키는 액션을 다루는 ST 타입이나 소프트웨어 트랜잭셔널 메모리를 다루는 STM 타입 등이 있습니다.

중요한 것은, 예를 들어 하스켈에서 두 좌표 간의 거리를 구하기 위해서는

distance (x1, y1) (x2, y2) = sqrt $ (x2 - x1) ** 2 + (y2 - y1) ** 2

와 같은 코드를 작성하게 되겠죠. 이때 distance 함수의 타입은 distance :: (Double, Double) -> (Double, Double) -> Double와 같이 될 것입니다. 그런데 코드를 “두 좌표를 받아서 좌표 간의 거리를 구하는 함수”가 아니라 “두 좌표를 받아서 일단 문명을 초토화시킨 뒤 좌표 간의 거리를 구하는 함수”로 바꾸고 싶다면

distance (x1, y1) (x2, y2) = do
    launchNuclearMissiles
    return $ sqrt $ (x2 - x1) ** 2 + (y2 - y1) ** 2

처럼 쓰게 되는데, 이때 distance의 타입은 distance :: (Double, Double) -> (Double, Double) -> IO Double이 됩니다. 원래 반환값의 타입이 Double이었는데 지금은 IO Double이죠. 액션이 된 것입니다.

이렇듯 하스켈에서는 순함수와 액션이 타입으로 구분되기 때문에 아무렇게나 섞어서 쓸 수가 없고 섞으면 컴파일러가 타입 오류를 냅니다. 이것은 알고리즘과 입출력의 분리가 제대로 되어 있는지를 컴파일 시간에 확인할 수 있게 해줍니다.

간혹 이것 때문에 “하고 싶은 대로 마음대로 할 수가 없다”라며 하스켈에 불만을 표하는 분들이 계신데 흑마법에 너무 오래 몸을 담고 계셔서 그렇게 되고 만 것입니다. 정작 그렇게 말씀하시는 분들 역시 “잘 짠 코드”에서는 알고리즘과 입출력의 분리를 실천하고 계십니다. 왜냐하면 이것이 언어를 막론하고 권장되는 프로그래밍 스타일이며 유지·보수 가능한 코드를 짜기 위해서는 필연적으로 취하게 되는 전략이기 때문입니다. 대표적으로 그 유명한 MVC(Model-View-Controller)가 여기에 해당됩니다. 모델과 뷰는 부작용 없는 방식, 입력 값에 의해서만 출력 값이 결정되는 구조로 작성할 것이 권장되며 실제 입출력은 컨트롤러가 관장하죠.

프로그램을 예측 가능하게 만들려면 알고리즘과 입출력이 섞여 있는 코드보다는 알고리즘 코드와 입출력 코드의 분리가 압도적으로 유리합니다. 그런데 현실에서는 실수로 혹은 귀찮아서 부작용 있는 코드와 없는 코드를 섞어 만든 날림 코드로 시작한 뒤 점점 날림 코드의 규모가 커져서 기술 부채가 되어 발목을 잡혀 고통받는 프로젝트가 많이 있습니다. 이들이 무능한 프로그래머라서 그런 것일까요? 아닙니다. 동적 언어에서는 금지된 것이 없어서 프로그래머 본인이 강철같은 불굴의 뚝심으로 정신을 바짝 차리고 숭고하고 순결한 코드 설계와 비타협적 코딩 원칙을 관철하고 심지어 여기에 충성하지 않는 모든 반동적인 동료들을 숙청해야만 알고리즘과 입출력을 완벽하게 분리할 수 있습니다. 반면 하스켈에서는 의지력 소모도 인간관계의 파탄도 없이 그냥 컴파일러의 말을 듣다가 문득 정신이 들고 보니 참조 투명성과 알고리즘/입출력 분리가 달성되어 있는 것입니다.

조합성

하스켈은 여러 코드를 조합해서 쓰기 좋게 되어 있습니다. 다른 프로그래밍 언어에서는 각 코드의 세부 구현을 모른 채 마구 조합하면 망하는 경우(예를 들어 사용자 입력을 받아오는 함수의 출력부를 SQL 쿼리 보내는 함수의 입력부와 조합하는 경우)를 피하기 위해 코드를 알아야 합니다. 즉 조합 단위들을 블랙박스로 취급할 수 없습니다. 반면 하스켈은 타입 시스템을 통해 이런 사고의 상당수를 방지할 수 있습니다. 라이브러리 제작자의 입장에서는 “라이브러리 사용자가 타입을 맞추다 보니 올바른 코드가 되도록” 라이브러리를 설계하는 것이 가능해집니다.

조합 단위 중 하나의 구현이 바뀌어서 다른 코드에도 변경이 불가피해진 경우 컴파일러가 타입 오류를 통해 어디를 고쳐야 하는지를 정확하게 알려줍니다. 따라서 라이브러리 작성자와 애플리케이션 작성자가 다른 협업 프로젝트에서도 유리합니다. 또 타입클래스라는 인터페이스가 있기 때문에 조합 가능한 범위를 정확하게 지정하고 통제할 수 있습니다. 스탠다드 차타드는 하스켈의 이러한 강점을 이용해 대형 시스템을 만들고 유지하는 데에 크게 득을 보고 있다고 합니다.

하스켈의 놀라운 조합성을 잘 보여주는 동시에 결정적인 장점으로 꼽히는 것이 파서 컴비네이터(parser combinator)입니다. 파서 컴비네이터는 간단한 문자열을 받아 간단한 해석 결과를 내놓은 작은 파서들을 조합하여 더 복잡하고 큰 파서를 만들어낼 수 있는 파서 라이브러리로서 정규식의 간편함과 BNF의 기능성을 동시에 갖추었습니다. 파서 컴비네이터로 만들어지는 파서는 작성 속도와 실행 속도가 모두 빠르며, “일주일만에 만들어진 펄 6 구현”으로 유명한 퍽스(Pugs)에 사용되기도 했습니다.

디버깅, 테스팅, 프로파일링

하스켈은 실행 파일의 성능을 위해서 컴파일할 수도 있는 반면, runhaskell과 같은 명령으로 인터프리트 실행할 수도 있고, 대화형 인터프리터(GHCi)에서 코드를 불러와서 조사하거나 부분적으로 실행할 수도 있습니다. 부작용이 격리수용되므로 부작용 없는 함수들에 대해서 인터프리터에서 코드를 테스트해보기가 쉽습니다.

소스 코드 에디터와 GHCi를 동시에 띄워 두고 코드를 고친 뒤 GHCi에서 :reload 혹은 :r 명령으로 리로드하는 패턴으로 매우 편리하고 빠르게 개발할 수 있습니다. 코드에서 Debug.Trace 모듈을 반입해 선택적으로 값을 조회할 수도 있고, GHCi가 디버거를 내장하고 있기 때문에 코드에 브레이크포인트를 걸고 스택을 들여다보는 일반적인 디버깅 방식도 활용할 수 있습니다.

하스켈은 소프트웨어 테스팅의 새로운 영역을 개척한 언어로 유명합니다. 퀵첵은 하스켈의 타입 시스템을 활용하여 코드가 만족해야 할 조건을 논리적으로 지정해 주면 자동으로 입력 데이터를 생성해서 코드가 실제로 그 조건을 만족하는지를 확인해 주는 테스팅 라이브러리입니다. 이러한 자동화 테스트 또는 ‘속성 테스트’는 단위 테스트에 비해 간편하면서 강력하고 하스켈의 이점을 십분 살리는 방식으로서 칭송을 받고 있습니다. 퀵첵은 파서 컴비네이터와 마찬가지로 테스트를 조합해서 더 큰 테스트를 만들어낼 수 있습니다.

자동화 테스트 이외에도 HSpec 등 다른 언어에서 많이 쓰이는 인터페이스와 비슷하게 만들어진 테스트 라이브러리와, 이들을 모두 통합하여 운용할 수 있는 Tasty와 같은 테스팅 프레임워크가 있습니다.

하스켈은 고도의 프로파일링 기술을 제공합니다. GHC에 내장된 프로파일러를 이용해서 코드를 실행해 보고 코드의 어느 부분에서 프로세서 시간메모리 공간이 얼마나 쓰이는지 분석하는 것은 물론, 런타임 통계를 산출할 수 있고 컴파일러가 최적화한 중간 언어를 직접 분석할 수 있으며 GC를 시각화해서 튜닝할 수 있습니다. ThreadScopeghc-events-analyze와 같은 추가적인 도구를 이용해 하스켈 코드의 병렬 실행을 분석할 수 있고, criterion을 이용해 함수 단위로 실행 소요 시간을 벤치마크 비교하여 최적 코드를 찾을 수 있습니다.

커뮤니티와 생태계

좋은 하스켈 교재공개되어 있으며, 하스켈 subreddit, Stack Overflow의 하스켈 문답하스켈 위키도 훌륭한 학습자료입니다. 하스켈 IRC 채널도 하스켈을 배우는 초심자들의 질문에 잘 답해줍니다.

해키지는 하스켈 패키지 저장소인 동시에, 수많은 하스켈 프로젝트의 문서화 자료를 한 자리에서 둘러볼 수 있는 문서고의 역할도 합니다. 고수준 언어 중에는 모듈 반입에도 부작용이 있을 수 있고 네임스페이스 구성에도 부작용이 있을 수 있으며 아무 객체나 무엇이든 될 수 있어서 코드로부터 API 문서를 자동으로 생성하는 것이 어려운 경우가 있습니다. 하스켈은 좋은 타입 시스템과 합리적인 모듈 시스템을 가지고 있기 때문에 모듈과 값과 함수를 정적으로 검사하고 많은 유의미한 정보를 자동으로 추출할 수 있습니다. 따라서 API 문서화가 훨씬 쉽고 사람의 노력이 덜 들고 단일한 진실의 원천 원칙을 지키면서 문서화를 만들어냅니다.

해키지에 등록된 하스켈 라이브러리의 수는 2010년에 4천 개를 돌파했고, 지금은 8천 개가 넘습니다. 웹, 데이터베이스, 그래픽스, 사운드, 네트워크, OS, 유저 인터페이스, 수학 등 범용 프로그래밍 언어를 사용할 만한 모든 분야에 다양한 라이브러리가 있습니다. 실용적으로 하스켈을 쓰는 데에 필요한 기능을 제공하는 라이브러리가 없어서 난감한 경우는 거의 없습니다. 또 하스켈을 프론트엔드 언어로도 사용할 수 있습니다. 하스켈을 자바스크립트로 컴파일하기 위해 GHCJS, Haste, Fay, PureScript 등 다양한 방법이 있습니다.