하스켈 리스트와 고차함수

앞서 리스트 기초 장에서는 리스트 타입의 정의와 head, tail, length 등 기본적인 리스트 함수들을 살펴보았습니다. 리스트는 이런 기본적인 함수 외에도 map, filter, foldr처럼 함수를 인자로 받는 함수들을 제공합니다. 하스켈에서는 함수가 다른 함수를 인자로 받거나 함수를 리턴할 수 있는데 이런 특성을 가진 함수들을 고차 함수(higher-order function)라고 부릅니다.

map 함수

우선 대표적인 고차 함수의 예로 map을 살펴보겠습니다. map의 타입은(a -> b) -> [a] -> [b]입니다. 타입 a를 받아 b를 리턴하는 함수 하나와 a 타입의 리스트 하나를 인자로 받아 b 타입의 리스트를 리턴하는 함수입니다. map은 주어진 함수를 리스트 각각의 원소에 적용하여 새로운 리스트를 리턴합니다. 간단한 사용 예는 다음과 같습니다.

> map (\x -> x + 1) [1,2,3]
[2,3,4]

여기서 \x -> x + 1은 하스켈에서 람다 함수(lambda function)을 정의하는 방법입니다.\를 사용하는 이유는 아스키 코드 중에서 그리스 알파벳 람다(λ)를 가장 닮았기 때문입니다.

이 람다 함수는 인자 x를 받아서 x + 1을 리턴합니다. ->를 기준으로 왼쪽은 인자, 오른쪽은 함수 몸통(function body)입니다. 인자가 2개 이상인 경우는 공백 문자로 구분해 주면 됩니다. 따라서 인자 xy를 받아 합을 리턴하는 함수는 \x y -> x + y로 정의할 수 있습니다.

앞서 하스켈 함수에서 배운 섹션(section)을 사용하면 같은 함수를 좀 더 간결히 표현할 수 있습니다. \x -> x + 1은 2개의 인자를 받는 (+) 연산자에 인자 하나만 먼저 적용하여 새로운 함수를 만들었다고 볼 수 있기 때문에 (+1)과 같이 표현할 수 있습니다.

> map (+1) [1,2,3]
[2,3,4]

filter

filter 함수는 각 원소에 대해 Bool을 리턴하는 테스트 함수(predicate)를 인자로 받아 각 원소 중에서 테스트를 통과한 원소들만 모아 새로운 리스트로 리턴하는 함수입니다.filter의 타입은 (a -> Bool) -> [a] -> [a]인데 첫 번째 인자 타입 a -> Bool을 보면 테스트 함수는 리스트의 원소 타입 a를 인자로 받아 Bool을 리턴하는 것을 확인하실 수 있습니다.

> filter odd [1,2,3,4,5]
[1,3,5]

filter와 비슷한 함수로는 partition 함수가 있는데, filter와 마찬가지로 테스트 함수를 받아 리스트 각 원소에 대해 테스트를 실시하는데, filter는 테스트를 통과한 원소의 리스트를 리턴하는 반면 partition은 테스트를 통과한 원소와 통과하지 못한 원소를 따로 모아 각각을 리스트로 리턴합니다.

리턴값이 2개의 리스트이므로 튜플을 사용합니다. 튜플의 첫 번째 원소는 테스트를 통과한 원소들의 리스트이고, 튜플의 두 번째 원소는 테스트를 통과하지 못한 원소들의 리스트입니다. partition 함수는 filter와 달리 Prelude 모듈에 정의되어 있지 않으므로 사용하려면 먼저 Data.List 모듈을 임포트해주어야 합니다.

> import Data.List
> partition odd [1,2,3,4,5]
([1,3,5],[2,4])

foldr

마지막으로 살펴볼 함수는 foldr입니다. 리스트의 경우 foldr의 타입은(a -> b -> b) -> b -> [a] -> b입니다. 알서 살펴본 map이나 filter와는 달리 리턴 타입이 [a]가 아니라 b임을 볼 수 있습니다. map처럼 리스트 각 원소를 가지고 새로운 리스트를 만들거나, filter처럼 리스트의 각 원소 중 일부를 추려서 새로운 리스트를 리턴하는 함수는 아님을 짐작할 수 있습니다.

이해를 돕기 위해 먼저 사용 예를 살펴보겠습니다. 다음은 foldr을 이용해 리스트 원소들의 합을 구하는 코드입니다.

> foldr (+) 0 [1,2,3,4,5]
15

(+)는 인자 두 개를 받아 합을 리턴하는 함수이고, 0은 초기값입니다. 위 foldr 함수를 풀어써보면 다음과 같습니다.

(1 + (2 + (3 + (4 + (5 + 0 )))))

리스트 기초에서 살펴본 리스트 [1,2,3,4,5]의 정의와 비교해보면 모양이 상당히 유사하다는 사실을 알 수 있습니다.

(1 : (2 : (3 : (4 : (5 : [])))))

리스트 정의에서 (:)(+)로 바뀌었고, []0으로 바뀌었습니다. 즉, foldr f acc의 의미는 리스트를 펼쳐놓고 (:)f[]acc로 바꾼 것과 같다고 생각할 수 있습니다.

이번에는 각 리스트의 곱을 구해보겠습니다.

foldr (*) 1 [1,2,3,4,5]

마찬가지로 리스트를 정의에 따라 풀어 쓰고 (:)(*)[]1로 바꿔쓰면 됩니다.

(1 : (2 : (3 : (4 : (5 : [])))))
(1 * (2 * (3 * (4 * (5 * 1 )))))

foldr을 이용하면 리스트의 길이를 리턴하는 length 함수도 구현할 수 있습니다. 아래 리스트 정의에서 (:)[]를 어떻게 바꾸면 각 원소 대신에 리스트의 길이를 계산하게 될지를 생각해 봅시다.

(1 : (2 : (3 : (4 : (5 : [])))))

금방 답이 떠오르지 않으신가요? 이해를 돕기 위해 이번에는 (:) 연산자를 infix가 아닌 prefix로 표기해보겠습니다.

(:) 1 ((:) 2 ((:) 3 ((:) 4 ((:) 5 []))))

(:)를 보기 좋게 임의의 함수로 f로 대체하면 다음과 같습니다.

f 1 (f 2 (f 3 (f 4 (f 5 []))))

이제 함수 f를 어떻게 정의하면 리스트의 길이를 구할 수 있을까요? 길이를 구할 때 리스트의 원소가 무엇인지는 중요하지 않으므로 첫 번째 인자를 무시합니다. 그리고 f 함수가 매번 호출될 때마다 리스트의 길이가 1만큼 늘어난다는 사실에 착안하여 두 번째 인자로 넘어온 값에 1을 더해주면 어떨까요? 그렇게 f를 정의하면 \x y -> y + 1가 되는데, 실제로 테스트해보면 리스트의 길이를 리턴하는 것을 볼 수 있습니다.

> foldr (\x y -> y + 1) 0 [1,2,3,4,5]
5

foldr 함수는 맥가이버 칼로 불릴 만큼 굉장히 강력한 함수입니다. foldr 함수를 이용하면 map이나 reverse 함수도 구현할 수 있습니다.

숙제: foldrmapreverse를 구현해 봅시다.

하스켈 리스트 기초

리스트는 하스켈에서 가장 범용적으로 사용되는 데이터 타입입니다. 리스트의 타입은[a]로 표시하는데, a는 요소 타입을 뜻합니다. 예를 들어 Char의 리스트는 [Char],Int의 리스트는 [Int]와 같이 표시합니다.

리스트 [a]머리(head)꼬리(tail)로 구성되고 머리는 타입 a, 꼬리는 또 다른 리스트 [a] 타입을 가집니다. 구성 요소에 자기 자신의 타입을 반복되기 때문에 리스트와 같은 타입을 재귀 타입(recursive type)이라고 부릅니다.

머리와 꼬리를 이용하여 이용하여 리스트를 만드는 연산자는 (:)입니다. []는 아무 요소도 없는 빈 리스트를 의미합니다. 리스트를 새로 만들려면 일단 리스트가 하나 있어야 하기 때문에 맨 처음에는 빈 리스트를 이용합니다. 1[]을 합하면 리스트 [1]이 됩니다.

> 1 : []
[1]
> 1 : 2 : []
[1,2]
> 1 : 2 : 3: []
[1,2,3]

여기서 (:) 연산자는 우결합(right-assocative)하기 때문에 1:2:3:[]의 의미는(1:(2:(3:[])))과 같습니다. 먼저 3[]를 합하여 [3]을 만들고 다시 여기에 2를 합하여 [2,3]를 만들고 다시 여기에 1을 합하여 [1,2,3]을 만든다고 이해하시면 됩니다.

매번 이렇게 리스트를 생성하면 불편하기 때문에 조금 더 쉽게 리스트를 생성하는 방법이 있습니다. [] 안에 ,로 구분하여 요소들을 나열해주면 됩니다.

> [False, True, False]
[False,True,False]
> [1,2,3]
[1,2,3]
> []
[]

length 함수는 리스트의 요소 개수를 리턴합니다.

> length [1, 2, 3]
3
> length [1]
1
> length []
0

참고로 우리가 쓰고 있는 String 타입은 실제로는 Char 타입의 리스트인 [Char]로 정의되어 있습니다. 따라서 리스트를 인자로 받는 함수인 lengthString을 줘도 문자열의 길이를 리턴합니다.

> length "abc"
3
> length ""
0

head 함수는 리스트의 머리를 돌려줍니다. 빈 리스트를 인자로 받았을 경우에는 돌려줄 머리가 없으므로 에러가 납니다.

> head [1, 2, 3]
1
> head  []
*** Exception: Prelude.head: empty list

반대로 tail 함수는 리스트의 꼬리를 돌려줍니다. 마찬가지로 빈 리스트를 인자로 받았을 때는 돌려줄 꼬리가 없으므로 에러가 납니다.

> tail [1, 2, 3]
[2,3]
> tail []
*** Exception: Prelude.tail: empty list

(++) 연산자는 리스트 두 개를 합친 새로운 리스트를 리턴합니다.

> [1, 2, 3] ++ [4, 5, 6]
[1,2,3,4,5,6]
> [1, 2, 3] ++ []
[1,2,3]

reverse 함수는 리스트의 순서를 거꾸로 뒤짚어서 리턴합니다. 빈 리스트는 뒤짚을 요소가 없으므로 그대로 빈 리스트를 리턴합니다.

> reverse [1, 2, 3]
[3,2,1]
> reverse []
[]

sum이나 product 함수를 이용하면 리스트 요소들의 모두 더한 값이나 모두 곱한 값을 얻을 수도 있습니다.

> sum [1, 2, 3, 4, 5]
15
> product [1, 2, 3, 4, 5]
120

takedrop 함수를 이용하면 서브 리스트를 얻을 수 있습니다. take는 주어진 개수만큼의 요소를 새로운 리스트로 만들어 리턴합니다. drop은 주어진 개수만큼의 요소를 빼거 나머지 요소들을 새로운 리스트로 만들어 리턴합니다.

> take 3 [1, 2, 3, 4, 5]
[1,2,3]
> drop 3 [1, 2, 3, 4, 5]
[4,5]

이 외에도 Data.List 문서를 보시면 리스트에 정의된 수많은 함수들을 확인하실 수 있습니다. 리스트는 매우 자주 사용되는 데이타 타입이므로 리스트 함수들은 여러 번 공부하고 익혀서 숙지하고 계시면 좋습니다.

SKI 콤비네이터

때는 2030년, 2016년 바둑으로 이세돌을 이긴 알파고는 더욱 진화하여 인류를 지배하기 시작하였다. 저항군은 알파고를 파괴할 컴퓨터 바이러스 개발에 나선다. 알파고는 이를 막고자 개발자들이 사용할 수 있는 함수들을 파괴하기 시작했다. 오랜 싸움 끝에 이제 인류에게 남은 함수는 단 3개뿐, 과연 인류는 3개의 함수로 알파고를 물리칠 바이러스를 만들 수 있을까?

2030년 인류가 바이러스 개발에 사용할 수 있는 함수는 단 3개가 남았습니다. 남은 함수가 3개밖에 없다는 사실을 알고 이제 알파고를 이기는 것을 불가능하다며 많은 사람들이 절망하고 있습니다.

s x y z = x z (y z)
k x y = x
i x = x

각각 s, k, i라고 이름 붙여진 함수들은 보시는 바와 같이 아주 간단한 일을 하는 함수입니다.

  • i: 인자 x를 받아서 그대로 x를 리턴하는 함수입니다. 항등 함수를 뜻하는 영어 identity function의 i에서 따온 이름입니다.
  • k: 2개의 인자 xy를 받아서 항상 첫 번째 인자 x를 리턴하는 함수입니다. 상수 함수를 뜻하는 영어 constant function을 c에서 따온 이름인데, 개발자들은 c를 k라고 부르는 습성이 있어 k 함수가 되었습니다.
  • s: 3개의 인자 x, y, z를 받는 함수입니다. 먼저, z를 인자로 x를 호출합니다. 그리고 나서 리턴된 함수에 z를 인자로 y 함수를 호출한 결과를 다시 인자로 넘겨서 호출합니다. s라는 이름은 대체 연산자를 뜻하는 substitution operator의 s에서 왔습니다.

i 함수와 k 함수는 크게 쓸모가 없어 보입니다. 인자를 그대로 돌려주는 함수나 두 개의 인자 중 항상 첫 번째 인자만 리턴하는 함수 모두 그다지 유용한 일을 하는 것 같지는 않습니다.

그나마 s 함수는 뭔가 유용한 일을 하는 것 같습니다. s가 대체 연산자라는 이름이 붙은 이유는 함수 호출은 변수를 대체하는 것과 같기 때문입니다. 간단한 예로, 인자 x를 받아서 튜플 (x, x)를 리턴하는 함수 \x -> (x,x)가 있다고 하면 이 함수에 1을 인자로 호출한 (\x -> (x, x)) 1의 값은 변수 x를 1로 대체한 (1, 1)과 같기 때문입니다.

많은 사람들이 절망하고 있던 그 순간 그간 비주류 언어라며 천대 받던 하스켈 개발자가 나섭니다. 이 세 함수만 있으면 알파고를 무찌를 바이러스를 충분히 개발할 수 있다고 자신감 있게 주장합니다. 사람들은 동요하기 시작합니다. 누군가가 묻습니다. “하지만 우린TrueFalseif 문도 없는데 없다고! 조건문 없이 어떻게 프로그램을 만들 수 있단 말이야?”

하스켈 개발자가 답합니다. “가능합니다. Truek, Falsesk로 정의하면 됩니다.k 함수는 인자 x, y를 받아서 x를 리턴합니다. sk 함수는 반대로 인자 x, y를 받아서 y를 리턴합니다. True일 때는 x, False 일 때는 y를 리턴하는 값을 만들었으므로 if 문처럼 사용할 수 있습니다.”

k x y = x
s k x y = k y (x y) = y

또 다른 개발자가 외칩니다. “하지만 루프가 없다고! 루프 없이는 아무런 유용한 프로그램을 짤 수가 없다고. 튜링 머신이 아니란 말이야.” 하스켈 개발자가 단호한 표정으로 다시 답합니다. “우리에게 남은 함수는 세 개 밖에 없고, 말씀하신 것처럼 이제 인류는 더 이상 루프를 사용할 수가 없습니다. 하지만 우리에게 재귀 함수가 있습니다. 루프에 비해 느리고, 어렵다며 천대 받던 재귀 함수이지만 이제 우리에게 남은 유일한 희망입니다. 재귀 함수를 정의하는 원리는 간단합니다. 먼저 sii를 보시기 바랍니다.”

sii a = (i a) (i a) = a a

“이처럼 sii는 인자 a를 받아서 a를 인자로 자기 자신을 다시 호출합니다. 네. 맞습니다. 여러분이 사랑하고 동시에 증오하던 무한 루프입니다. 이런 방식을 이용하면 재귀 함수 y를 만들어낼 수 있습니다. y 함수는 다음과 같이 정의합니다.”

y = s(c(s(ks)k)(sii))(c(s(ks)k)(sii))

또 다른 개발자가 소리칩니다. “그럼 정수는? 루프나 if가 있다고 하도 정수 없이 무슨 코딩을 하란 말이야?” 하스켈 개발자는 동요하지 않고 여전히 자신감 있게 설명을 이어갑니다. 오랜 설명 끝에 결국 모든 개발자들이 s, k, i 단 3개의 함수만으로 알파고를 물리칠 바이러스 개발이 가능하다는 말을 납득합니다. 이후 더 이상 하스켈 개발자를 천대하지 않고 함께 열심히 바이러스를 개발하여 알파고를 무찌르고 행복하게 살았답니다.

여기서 설명한 s, k, i 함수는 컴퓨터 과학자, 논리학자에게는 이미 잘 알려진 SKI 콤비네이터입니다. 콤비네이터라는 용어 자체가 생소할 수 있는데, 콤비네이터는 free 변수가 없는 함수를 뜻합니다. 여기서 free 변수가 없다는 뜻은 함수에 주어진 인자만 사용하는 순수 함수를 말합니다. 다음은 콤비네이터의 예제입니다.

\a -> a
\a -> \b -> a
\f -> \a -> \b -> f b a

콤비네이터가 아닌 함수의 예제는 다음과 같습니다. x라는 변수가 함수의 인자로 정의되지 않았습니다. 이런 변수 x를 free 변수라고 부릅니다.

\a -> \b -> x

재미있는 사실은 계산가능성(computability)를 봤을 때 SKI 콤비네이터는 람다 대수(lambda calculus)와 동급이고, 람다 대수가 튜링 머신과 동급이기 때문에 결국 SKI 콤비네이터가 정의한 3개의 함수만 가지고 우리가 컴퓨터를 가지고 할 수 있는 모든 계산을 할 수 있습니다. 인류가 이 세 개의 함수만 가지고 알파고를 무찌를 바이러스를 만들 수 있었던 이유도 세 개의 함수가 튜링 머신과 동급이기 때문입니다.

사실 엄밀하게 이야기하면 sk 2개만 함수만 있어도 됩니다. i조차도 sk의 조합인 skk로 만들어 낼 수 있기 때문입니다.

skk a = k a (k a) = a

s, k, i 세 함수를 장황하게 소개한 이유는 각 함수가 하스켈 프로그래밍에도 실제로 아주 자주 사용되는 필수 함수이기 때문입니다. 이들 함수는 하스켈에서 각각((->) r) Applicative 인스턴스의 (<*>) 함수, const 함수, id 함수로 정의되어 있습니다. 앞으로 설명할 내용에도 자주 등장하는 함수들이 꼭 기억해 두시기 바랍니다.

타입 클래스 기초

하스켈 타입 클래스(type class)오버로딩을 지원하기 위한 방법입니다. 여기서 오버로딩은 하나의 심볼(함수 혹은 연산자)를 타입에 따라 여러 의미로 사용하는 것을 의미합니다.대표적인 예로 하스켈의 Show 타입 클래스를 살펴보겠습니다. Show 타입 클래스는 show라는 함수를 정의하고 있습니다. show 함수는 인자를 받아 그 인자를 String으로 변환한 결과를 리턴합니다. 아래와 같이 다양한 인자 타입에 대해서 잘 동작하는 것을 확인할 수 있습니다.

> show 1
"1"
> show False
"False"
> show "Hello World"
"\"Hello World\""

IntString으로 변환하는 방법과 BoolString으로 변환하는 방법은 다를 수 밖에 없는데, show는 어떻게 IntBool, String등 여러 타입의 인자를 모두 변환할 수 있는 것일까요? 궁금즘을 해결하기 위해 show의 타입을 살펴보겠습니다.

> :t show
show :: Show a => a -> String

타입을 보면 일반적인 함수와 달리 a -> String 함수 타입 왼쪽에 Show a =>라는 컨텍스트(context)가 추가되어 있는 것을 볼 수 있습니다. 여기서 => 앞에 나오는 Show aa라는 타입이 Show라는 타입 클래스를 구현한 타입이어야 한다는 뜻입니다.

Show 타입 클래스를 구현한다는 것이 어떤 의미인지를 알아보기 위해, Show 타입 클래스가 어떻게 정의되었는지 먼저 살펴보겠습니다. 다음은 Prelude 모듈에 정의된 Show의 정의입니다. (간단한 설명을 위해 showsPrec등 다른 함수들은 생략했습니다.)

class  Show a  where
   show      :: a   -> String

위 정의의 의미는 타입 aShow라는 타입 클래스의 인스턴스가 되기 위해서는a -> String이라는 타입을 가진 show 함수를 제공해야 한다는 뜻입니다. 여기서 Show타입 클래스는 Show 타입 클래스를 구현하는 타입들이 갖춰야 하는 일종의 인터페이스라고 이해할 수 있습니다.

우리가 Int, Bool, String 등의 인자에 대해 show 함수를 호출할 수 있는 이유는 각각의 타입에 대해 Show 타입 클래스 인스턴스가 미리 정의되어 있기 때문입니다. 인스턴스 정의는 다음과 같이 instance 키워드를 사용합니다.

instance Show Int where
    show = ...

instance Show Bool where
    show = ...

instance Show String where
    show = ...

이처럼 show라는 함수는 실제로는 각 타입별로 구현이 다르지만, 우리는 타입별로 구분할 필요 없이 show라는 하나의 심볼만 사용하면 하스켈이 알아서 인자 타입에 맞는 실제show 함수 구현을 찾아서 불러주는 것입니다.

앞서 설명한 것처럼 이렇게 하나의 심볼을 타입에 따라 서로 다른 의미로 사용하는 방법을오버로딩이라고 부르고, 하스켈의 타입 클래스는 오버로딩을 구현하기 위한 방법입니다.

이해를 돕기 위해 직접 타입 클래스를 하나 만들어 보겠습니다. 아래에 정의한 Mergeable타입 클래스는 두 개의 인자를 받아서 하나로 합치는 함수 merge를 정의하고 있습니다.

class Mergeable a where
  merge :: a -> a -> a

ghcimerge 함수의 타입을 확인해보면 아래와 같이 Mergeable a라는 컨텍스트가 함수 타입 왼쪽에 표시되는 것을 볼 수 있습니다.

> :t merge
merge :: Mergeable a => a -> a -> a

이제 merge 함수를 Int 타입과 List 타입에 대해 정의해 보겠습니다.

instance Mergeable Int where
  merge x y = x + y

instance Mergeable [a] where
  merge x y = x ++ y

Int 타입의 merge 함수는 두 인자의 합을 계산하도록 했고, List 타입의 merge 함수는 두 인자를 병합하도록 하였습니다. ghci를 이용해 테스트해보면 아래와 같이 인자 타입에 따라 서로 다른 merge 함수가 호출되는 것을 확인할 수 있습니다.

> merge (1 :: Int) (2 :: Int)
3
> merge [1,2,3] [4,5,6]
[1,2,3,4,5,6]

추가로 Bool 같이 우리가 인스턴스를 정의하지 않은 타입에 대해서는 에러가 나는 것도 볼 수 있습니다.

> merge False True

<interactive>:23:1:
    No instance for (Mergeable Bool) arising from a use of ‘merge’
    In the expression: merge False True
    In an equation for ‘it’: it = merge False True

하스켈의 PreludeShow 외에도 Eq, Ord, Read, Enum, Bounded, Num, Integral,Floating 등 다양한 타입 클래스를 정의하고 있습니다. 이에 대한 설명은 다음 글에서 이어가도록 하겠습니다.

왜 하스켈을 배워야 하는가?

하스켈은 배우기 어려운 언어로 정평이 나있습니다. 순수(pure) 함수지연 연산(lazy evaluation), 하스켈 타입 시스템(System F와 type class 등등), 병렬 처리 등은 그저 시작에 불과합니다.

하스켈을 제대로 쓰기 위해서는 카테고리 이론에서 빌려온 Functor, Applicative Functor, Monad 같은 개념들을 익혀야 합니다. IO뿐만 아니라 대부분의 하스켈 코드가 이런 개념들을 활용해서 작성되어 있기 때문에 하스켈을 제대로 쓰려면 중요 개념들을 제대로 이해하고 있어야 합니다.

Monad 몰라도 IO 코드는 충분히 작성할 수 있고 MaybeEither, List 타입들을 사용할 수 있습니다만, Monad를 모를 거면 굳이 왜 하스켈을 배워야 할까요? 이미 Clojure, F#, Scala 등 대중적인 함수 언어들이 나와 있고, Java 8이나 C#, Swift, ES6에도 함수 언어의 요소가 상당히 반영되어 있는 상황에서 굳이 더 어렵기만 한 하스켈을 배워야 할 이유가 없어 보입니다.

우리가 사용하는 대중적인 프로그래밍 언어들은 어차피 모두 튜링 컴플리트(Turing complete)하기 때문에 계산가능성(computability)에 있어서는 차이가 없습니다. 한 언어가 계산할 수 있는 것을 다른 언어가 계산하지 못하지 않는다는 뜻입니다. 하스켈이 강력하다고 하지만 본질적으로 C++이나 Java가 할 수 없는 일을 할 수 있지는 않습니다.

왜 하스켈을 배워야 하는지 설명하기에 앞서 프로그래밍의 본질이 무엇인지 질문을 던져볼 필요가 있습니다. 프로그래밍은 기본적으로 컴퓨터에 일을 시키는 것입니다. “메모리 x에 있는 값을 레지스터에 로드해서 1을 더하고 다시 메모리 x에 저장해라”도 일종의 프로그램입니다.

하지만 이게 끝은 아닙니다. 우리가 푸는 문제는 메모리에서 값을 꺼내 1을 더한 다음 다시 메모리에 저장하는 수준이 아니라 현실 세계에서 발생하는 훨씬 더 크고 복잡한 문제이기 때문입니다. 그래서 프로그래밍의 본질은 한 번에 풀 수 없는 크고 복잡한 문제를 작은 문제들로 나누어서 해결하고 그렇게 나온 결과물들을 조합하여 다른 문제를 해결하는 것을 말합니다.

여기서 좋은 프로그램의 가장 중요한 특성으로 조합성(composability)이 등장합니다. 우리는 계속해서 크고 복잡한 문제를 풀어야 하고, 또한 비슷하지만 조금은 다른 문제들을 풀어야 합니다. 앞서 만들어 놓은 산출물을 쉽게 조합하여 새로운 문제를 해결할 수 있다면 프로그래머의 생산성은 비약적으로 늘 수 있기 때문입니다.

프로그래밍의 패러다임 변화는 조합성을 끌어올리려는 노력의 연속입니다. goto 문을 사용하던 어셈블리에서 서브루틴, 블록 구조, for/while 루프를 강조하는 구조적 프로그래밍(structured programming)이 나온 이유는 풀어야 하는 문제의 복잡도가 증가하여 프로그램을 조합할 더 좋은 방법이 필요했기 때문입니다.

구조적 프로그래밍이 많은 문제점을 해결했음에도 불구하고 80-90년대 우리가 풀어야 할 문제의 복잡도가 또 다시 가파르게 상승하면서 새롭게 등장한 패러다임이 객체지향 프로그래밍(object-oriented programming)입니다. 클래스나 객체의 개념, 캡슐화(encapsulation), 정보 은닉(information hiding) 등이 나온 이유도 더 복잡해진 문제를 풀기 위해 더 좋은 조합 방법이 필요했기 때문입니다.

21세기에 들어와 함수 언어가 주목 받고 있는 이유는 우리가 풀어야 할 문제가 또 다시 더욱 복잡해지면서 클래스나 객체가 제공했던 수준 이상의 조합성이 필요해졌기 때문입니다. 함수 언어의 중요한 특징인 고차 함수(higher order function), 다형 함수(polymorphic function) 등이 궁극적으로 해결하고자 하는 문제도 결국 조합의 문제입니다.

하스켈을 배워야 하는 이유는 Functor, Bifunctor, Profunctor, Applicative functor, Monoid, Monad, Arrow, Lense, F-algebra, Adjunction 같은 수학적 개념들이 또 다시 복잡해지고 있는 문제를 풀 수 있는 새로운 조합 방법의 단서를 제공하기 때문입니다. 그리고 하스켈은 이런 측면에서 다른 함수 언어와도 비교하기 힘들 정도로 발전해 있습니다.

물론 하스켈을 통해 배우지 않아도 우리는 이미 많은 개념들을 알고 있고 실제로 사용하고 있습니다. ES6의 Promise, C#의 널 전파 연산자(null propagation operator), Python의 리스트 컴프리헨션(list comprehension) 등은 전혀 다른 기능처럼 보이지만, 내부적으로 Monad라는 같은 구조를 가지고 있습니다. 하스켈을 공부하면 이렇게 서로 상이해 보이는 개념들에 존재하는 공통 구조를 쉽게 발견할 수 있습니다.

정리하면, 프로그래밍의 본질은 조합을 얼마나 쉽게 할 수 있느냐에 있습니다. 그리고 하스켈은 프로그램을 조합에 사용할 수 있는 새로운 개념과 도구들을 제공합니다. 하스켈을 배워야 하는 이유는 이런 도구들을 습득하여 앞으로 닥칠 더 크고 복잡한 문제를 해결하는 프로그램을 작성하는 능력을 배양하는 데에 있습니다.

마지막으로 하스켈을 배워야 하는 또 다른 이유는 그 자체로 재미있기 때문입니다. 어렵기 때문에 재미가 없는 것이 아니라 퍼즐을 푸는 것처럼 새로운 것을 알아가고 깨닫는 재미가 있습니다. 특히, 이미 오랜 세월 개발을 해서 매너리즘에 빠지신 분들은 하스켈을 통해서 다시 프로그래밍의 재미를 느껴보시길 권합니다.

권력 중독

파워(power)는 우리가 일상에서 자주 사용하는 단어입니다. 우리말로 힘, 권력, 권한, 능력 등으로 번역되는 파워는 다양한 맥락에서 다양한 뜻으로 사용됩니다. 슈퍼맨이나 스파이더맨 같은 슈퍼 영웅들은 슈퍼 파워를 가지고 세상을 구하고, 개발 팀장은 개발 팀장에게 주어진 권한으로 프로젝트를 수행합니다.

개발 팀장에게 부여된 권한의 유일한 목적은 주어진 프로젝트를 훌륭하게 완수하는 것에 있습니다. 여러 사람이 모여서 같이 일을 하다 보면, 갈등이 생기기 마련이고 대화와 설득에도 불구하고 의견 조율에 실패한다면 팀장은 본인에게 부여된 권한을 이용하여 의사결정을 내리게 됩니다. 물론 팀원들의 의견을 무시하고 매번 팀장의 권한만을 내세우면 팀원들의 반발을 사고 신뢰를 잃게 되므로 이런 부작용을 잘 알고 불가피한 경우에만 선택적으로 사용해야 합니다.

하지만 반지의 제왕에서 절대반지를 얻은 골룸의 예처럼, 일단 작은 권력이라도 맛을 보게 되면 사람이 달라집니다. 팀장이 되면 주변 사람들의 반응이 달라집니다. 팀원 시절과 똑같은 의견을 이야기해도 팀원들이 좀 더 귀담아 듣고 지지해 주는 경우가 많습니다. “내가 팀장이니깐 시키는 대로 해”라는 말을 할 필요도 없습니다. 권위에 약하고 남들과 다른 의견을 제시하는 것을 껄끄러워 하는 우리 조직 문화에서는 알아서 팀장 말을 따라주기 때문입니다. 당연히 팀장은 점점 자기중심적이 됩니다.

일단 권력에 중독되고 나면 생기는 가장 큰 문제는 현실을 있는 그대로 바라보는 현실 감각을 잃는 데 있습니다. 프로젝트에서 발생하는 여러 문제들에 대해 객관적인 진단을 내리고 해결책을 찾는 대신, 본인 상상 속에서 상황을 진단하고 해결하려고 합니다. 물론 일부 팀원들이 문제를 제기하지만 듣기 싫어합니다. 이미 듣기 좋은 말을 듣는 데 익숙해졌기 때문입니다.

또한 일단 팀장이 권력에 의존하기 시작하면 모든 문제를 권력의 문제로 바라보게 됩니다. 팀원들이 지시를 잘 따르지 않으면 이를 본인 권력에 도전하는 것으로 여깁니다. 또한 모든 문제의 원인이 본인에게 주어진 권한이 부족한 탓으로 생각하고 더 많은 권한을 얻기 위해 위만 쳐다보게 됩니다. 이런 팀장은 더 이상 프로젝트 성공이 목적이 아니라 더 많은 권력을 얻는 것이 목적이 됩니다. 이른바 권력 중독입니다.

누구든 권력의 유혹에서 자유로울 수는 없지만, 특별히 더 권력지향적인 사람들이 있습니다. 이런 사람들은 두 가지 모습을 동시에 보입니다. 자신보다 지위가 높은 사람에게는 철저하게 순종하고, 지위가 낮은 사람들을 지배하려고 합니다. 본인의 무능함을 지적 당하면, 자신은 실력이 있는 사람인데 주어진 권한이 부족한 탓이라고 변명합니다. 개발 팀장이 이런 모습을 보이기 시작하면 더 이상 개발 팀장이 아니라 정치 팀장이 됩니다.

일반적으로 권력은 강함의 표현이라고 생각하지만, 인간 심리를 보면 사실 정반대입니다. 권력에 대한 집착은 강함이 아니라 약함에 있습니다. 홀로 세상을 마주할 강인한 자아가 없기 때문에 외부에 의존할 대상을 찾는 것인데 이런 욕구는 권력에 대한 집착으로 나타납니다. 개발 팀장 버전으로 바꿔서 풀어보면, 개발 실력과 프로젝트 관리 능력으로 팀을 이끌 자신이 없기 때문에 권력에 대한 집착이 나옵니다.

개발 팀장은 실력으로 승부하는 자리입니다. 팀원들은 지배해야 할 부하가 아니라 프로젝트를 같이 만들어 나가는 동료들입니다. 절대반지를 끼면 모습이 보이지 않게 되므로 그 힘으로 무엇을 하는지 알 수 없지만, 팀장이 휘두르는 권력은 모든 팀원들이 지켜보고 있습니다. 아주 작은 권력에 집착해 무엇보다 소중한 팀원들의 신뢰를 잃는 팀장이 되지 않기를 바랍니다.

프로세스 개선

권한을 가진 사람의 책무는 쓸모 없는 전통을 제거하고 새로운 전통을 만드는 것이다. 아무 것도 변하지 않는 조직은 일터가 아니라 살아있는 박물관이다.

The responsibility of people in power is to continually eliminate useless traditions and introduce valuable ones. An organization where nothing ever changes is not a workplace but a living museum.

스콧 버쿤(Scott Berkun), The Years Without Pants: WordPress.com and the Future of Work

소프트웨어 개발에는 프로세스가 있습니다. 새로 작성한 코드를 저장소에 반영하기 전에는 반드시 동료 리뷰를 받아야 한다든지, 버그 수정 시에는 반드시 테스트 케이스를 추가해야 한다든지 하는 예가 그렇습니다. 이런 프로세스는 합리적인 이유가 있습니다. 동료 리뷰를 통해 코드의 품질을 높일 수 있고, 테스트 케이스를 추가하여 회귀 버그를 줄일 수 있기 때문입니다.

어떤 프로세스는 이유도 모르고 그저 전통이기 때문에 반복하고 있습니다. 이슈 트래커에서 누구나 확인할 수 있는 버그 수정 진척도를 매일 엑셀 문서로 만들어서 보고해야 한다든지, 코드 리뷰 후에 아무도 읽지 않는 코드 리뷰서를 별도로 작성해서 저장소에 올리는 일 등이 있습니다. 제가 지어낸 낸 사례가 아니라 모 대기업의 실제 사례입니다.

이런 일을 왜 하느냐고 물어보면 돌아오는 대답은 한결 같습니다.

원래 이렇게 한다. 다른 회사들도 다 이렇게 한다. (그리고 위에서 시키는 거니깐 어쩔 수 없다.)

소위 말해 전통입니다. 이유는 잘 모르겠지만, 나는 그렇게 하라고 배웠고 지금까지 해오고 있으니 너도 하라는 겁니다. 하지만 기존에 하던 대로만 해서는 아무런 발전도 할 수 없습니다. 천공카드가 인류가 발명한 최고의 저장매체라고 생각하고 기술의 진보를 멈췄다면, 우리 모두는 종이에 구멍 뚫어가며 프로그래밍하고 디버깅하는 시대에 살고 있을 겁니다.

천공카드

우리가 당연하게 생각하는 전통도 처음 시작은 혁명적인 아이디어였습니다. 일례로, 우린 8시간이 적절한 노동 시간이라고 생각합니다. 그런데 이 8이라는 숫자는 대체 어디서 나온 걸까요? 왜 6시간도 10시간도 아닌 8시간이 되었을까요? 산업 혁명 이후 18세기 후반이 되면 기업들은 이윤을 극대화하기 위해 공장을 일주일 7일, 하루 24시간 풀가동하기 시작합니다. 당연히 노동자들도 이에 맞춰 일을 하게 되었고, 하루 10-16시간씩 일을 하는 게 일반적이었습니다.

당시 영국의 로버트 오웬(Robert Owen)은 이런 방식이 지속 가능하지 않다고 보고 업무 시간을 하루 8시간으로 제한하자는 캠페인을 벌입니다. 그는 “8시간 노동, 8시간 놀이, 8시간 휴식”이라는 슬로건을 내걸었습니다. 하지만 실제로 8시간 근무를 도입한 건 20세기초 포드입니다. 1914년 포드는 근무 시간을 8시간으로 줄이고, 임금을 2배로 높이는 획기적인 조취를 했는데, 놀랍게도 2년 만에 이익이 2배로 늘어납니다. 이 사례는 이후 다른 기업들도 8시간 근무를 도입하는 계기가 되었습니다.

8시간 노동, 8시간 놀이, 8시간 휴식

8시간 근무는 20세기초 당시 공장 노동자들의 생산성을 극대화하기 위해 나온 아이디어입니다. 그런데 100년이 지난 지금 공장 노동자도 아닌 소프트웨어 개발자가 여전히 8시간 근무를 강요 받고 있습니다. 야근과 주말근무로 점철된 국내에서야 8시간도 감지덕지라고 생각할 수 있곘지만, 공장 노동자의 생산성을 극대화하기 위한 노동 시간과 대표적인 지식 노동인 소프트웨어 개발이 같을 수 있을지 의문이 듭니다. 하지만 왜 8시간 일해야 하냐고 물으면 똑같은 답을 들으실 겁니다.

원래 이렇게 한다. 다른 회사들도 다 이렇게 한다.

어떤 전통이든 시작할 때는 혁명적인 아이디어였고, 시간이 흘러 상황과 맥락이 변하면서 더 이상 유효하지 않게 됩니다. 소프트웨어 개발에 사용하는 기술이나 프로세스는 말할 것도 없습니다. 소프트웨어 산업의 짧은 역사를 생각하면 우리가 가지고 있는 전통은 대부분이 길어야 30-40년, 짧게는 5-10년밖에 되지 않은 혁명적인 아이디어들입니다. 그리고 새로운 아이디어로 빠르게 교체되고 있습니다. 이런 역사를 생각해보면 길어야 경력 10년 남짓의 개발자들이 전통을 운운하는 것이야 말로 우스운 일이 아닐 수 없습니다.

프로세스에 정답은 없습니다. 상황과 맥락에 따라 똑같은 프로세스가 좋은 프로세스일 수도 있고, 나쁜 프로세스일 수도 있기 때문입니다. 하지만 변하지 않는 프로세스는 나쁜 프로세스입니다. 상황과 맥락은 항상 변하기 때문입니다. 여러분은 일터로 출근하시나요? 아니면 박물관으로 출근하시나요?