3. 우물에서 벗어나 넓은 세상으로 Haskell

 
김재우 kizoo@bluette.com
블루엣 인터내셔널의 기술 이사로 재직중이며 개발 환경과 온라인 교육 시스템을 결합한 소프트웨어를 설계하고 있다. 소프트웨어 공학 기술이나 관련 이론을 실천하도록 만드는 것이 개발자로서의 목표. 현재 동명 정보기술원과 함께 분야별 표준 교육 과정. 전문 개발자 양성 및 인증을 위한 교육 시스템‘Theory Into Practice’를 설계하고 있으며, 인도 Vinayaka 대학 IT Parks 소프트웨어 개발팀과 함께 기업형 솔루션 교육과정 및 소프트웨 어 개발 기술을 연구하고 있다.
 
 
Haskell은 지금까지 프로그래밍 분야에서 연구된 여러 가지 성과를 충실하게 반영하고 있다. Haskell로 프로그래밍을 배우면 ‘잘하는 프로그래밍이 무엇인지 알게 되는 것’은 물론이고, 시중에서 유행하는 언어에서는 찾아보기 힘든 여러 개념과 기법을 미리 맛볼 수 있다.
 
벌써 Haskell을 소개한 지 3개월이 지났다. 바쁜 와중에 글을 쓰다 보니 뒤돌아볼 여가도 없이 여기까지 와버렸다. 필자의 바람은 더 많은 사람들이 올바르게 프로그래밍하는 가치와 즐거움을 함께 느꼈으면 하는 것이다. 하지만 이 글이 얼마나 많은 사람들에게 감흥을 주었는지 알 길이 없다 보니 이런 저런 걱정이 앞선다. 혹시나 이 글이 제 가치를 제대로 알려주지 못하고, 잘못된 선입관이나 섣부른 지식만 소개한 것은 아닌지 아주 조마조마하다. 그런 사태(?)를 미리 막기 위해 꼭 하고 싶은 말이 있다. 만에 하나 이 글이 프로그래밍의 재미를 떨어지게 만들었거나‘역시 이런 언어는 내가 배울 게 못돼’하는 느낌을 갖게 만들었다면 그 모든 잘못은 오로지 이 글이 못난 탓이다. 더 재미있고 바르게 소개할 방법이 얼마든지 있는데, 시간과 재주가 모자라 이 정도 글 밖에 나오지 못했기 때문이다. 그러므로 이 글만 보고 이 기법의 가치를 섣부르게 짐작하지 말았으면 좋겠다. 온전한 재미를 한껏 느끼고 싶다면 더 잘 쓴 책을 가져다가 진지하게 공부하는 게 좋다.
많은 사람들이 잘 만든 언어로 프로그래밍을 배우거나 소프트웨어를 만드는 일을‘정말 재미있다’고 말한다. 물론 쉬워서라기보다 분명히‘틀을 갖추어 제대로’한다는 데서 오는 즐거움일 것이다. 그러나 올바로 프로그래밍하는 재미를 알기가 그렇게 쉽지 않다는 걸 먼저 깨달아야 한다. 당연한 얘기지만 뭐든지 제대로 하려면 쉬운 일이 없다. 특히 단 한번도 이런 식으로 프로그래밍을 해본 경험이 없다면‘황당’하다는 생각을 떨쳐버리기 쉽지 않을 것이다. 오랫동안 올바르게 해보려고 노력하지 않았다면 참된 가치를 제 것으로 만들기 힘들다. 이런 황당함을 위로하기 위해 한 마디 하자면, 필자 역시 처음에는 마찬가지였다. 필자의 경우는 아주 최악의 상황이었다. 너무 일찍 프로그래밍 언어를 공부한 죄로 온 몸에 잘못된 습관이 가득 배어 있었는데 다시 바탕을 뒤엎고 바로 잡자니 무척 힘이 들었다. 그러니까 아주 똑똑하거나 너무 익숙해서 금방 이런 프로그래밍 기법을 익히게 된 경우는 결코 아닌 것이다.
이번에 새로 Haskell를 익히고자 하는 독자를 위해 이번 호에는 이런 기법을 배우기까지 지나간 필자의 고생담을 풀어 놓기로 했다.물론 이 글의 목적은 지난 호에서 소개하지 못했던 Haskell만의 독특한 기능을 소개하는 것이다. 지난 호에서 ‘함수를 써서 프로그래밍’하는 일반 상식을 다루었기 때문에 이번에는 Haskell과 같은 함수형 언어가 보여주는 재미있는 기능을 다룰 것이고, 자연스런 이해를 돕기 위해 이야기 형식으로 풀어 쓴 것이라고 보면 된다. 특히 지난 호에 강규영 씨가 쓴‘자바에 대한 몇 가 지 오해에 대한 변론 ’을 읽고 어떻게든 필자 의견을 더해야겠다고 생각했는데, 이 연재의 의도를 벗어날 수는 없기 때문에 중간 중간 충분히 답이 될 만한 얘기 거리를 풀어 놓았으니 뜻하는 바를 비교하며 읽어봐도 좋을 것이다.

우물 안에 갇혔다는 것을 깨닫기 까지

 
지금부터 언어가 프로그래밍을 배워야 하는 누군가의 사고력에 어떤 영향을 주는지 필자의 경험을 빌어 말하고자 한다. 프로그래밍 언어마다 제각기 풀어내는 문제의 성격이 다르기 때문에 어떤 언어를 열심히 공부하면 그 언어의 사고에 젖게 된다는 사실을 말하고 싶다. 물론 이미 자기 나름대로 ‘프로그래밍하는 방법’을 어떻게든 갖게 됐다면 새로운 언어를 어떤 식으로 배우든 큰 차이는 없다. 다만 그 새로운 언어가 정말 새로운 것인지 자기보다 더 나은 사람으로부터 바른 조언을 듣는 것이 좋다. 아니라면 그저 비슷한 언어를 갖고 매번 같은 문제를 문법만 바꿔가며 옮겨 쓰는 효과밖에 없다.
 

우물 안에 있던 시기

지난 1980년대 초, 그러니까 필자가 중학교 시절 애플에서 처음 프로그래밍을 공부할 때 배운 언어는 베이직(
BASICBASIC
BASIC
)이었다. 지금과 같이 편리한 운영체제라는 것이 없던 때였지만 별로 불편하다는 생각을 하지 못할 만큼 환경이 단순했기 때문에 프로그래밍에만 집중할 수 있어서 정말 재미있었다. 그 당시에 컴퓨터를 배우는 어린 학생들은 누구나 게임을 만들고 싶어 했고, 필자도 유행하던 만화 주인공으로 게임을 만들고 있었다. 열심히 모눈 종이에 캐릭터를 그리고 비트맵 데이터로 표현한 다음에 이리 저리 움직이는 프로그램을 짜느라 날 새는지를 몰랐다.
다음에 고등학교 시절, 학원에서 익힌 포트란과 코볼(
COBOLCOBOL
COBOL
)이다. 포트란은 문법만 다를 뿐 베이직과 문제를 풀어나가는 방법이 크게 다르지 않았기 때문에 쉽게 익힐 수 있었다. 베이직 인터프리터를 쓸 때처럼 간편하게 프로그래밍할 수는 없었지만 그럭저럭 배우는 재미가 있었다. 그러나 포트란은 베이직과 문제의 성격이 달랐다. 베이직을 쓸 때도 수치 계산을 하지만 게임 속의 그래픽 객체를 움직이거나 벽돌을 쓰러뜨리기 위해 만들던 것과는 목적이 달라 보였다. 포트란 교재에 수록된 문제는 어떤 제3의 목적이 있다기보다 오로지 그 문제 자체의 답을 더 정확하고 빠르게 계산하는 방법에 초점을 맞추었고, 또 그렇게 하는 것이 잘하는 프로그래밍이라는 쪽으로 이끌어 가고 있었다. 어쨌거나 분명한 것은 그 당시에도 포트란으로 게임을 만드는 예제는 찾아볼 수 없었으며, 필자 역시 포트란으로 게임 프로그램을 만들 생각은 애초에 하지도 않았다.
한 해가 지난 다음 이번에는 코볼을 배우게 됐다. 이 언어는 앞서 배운 언어처럼 간결하지 않았다(다른 사람들과 달리 필자에게는 솔직히 좀 지저분해 보였다). 실제 문제를 풀어내는 코드는 얼마 되지 않았고 포트란처럼 문제를 푸는 논리가 복잡하게 전개되는 것도 아닌데 프로그램을 완성하려면 어쩔 수 없이 이런 저런 이해하지 못할 구문을 외워야 했다. 포트란으로 프로그래밍을 배울 때까지만 해도 나름대로 재미가 있었는데 코볼은 정말 지루한 언어였다. 실제로 언어보다 푸는 문제가 더 지겨웠다고 해야 맞다. 어쨌든 수업 시간에 졸지 않고 버티기가 힘들 정도로 따분했다. 당장에 문제를 푸는 데 별 필요도 없어 보이는 여러 기능(각 Division마다 괴상망측한 구문과 여러 종류의 파일 시스템)에다 문장처럼 풀어쓴 식을 배우다 보니 ‘프로그래밍이 이렇게 재미없는 일이었나’하는 회의마저 들었다. 나중에 포트란으로 풀던 문제가 수치해석 알고리즘 공부고, 코볼의 이런 저런 기능은 메인프레임에서 경영 정보를 대량으로 처리하는 데 꼭 필요한 기능이라는 걸 깨닫게 됐지만 두 언어로 프로그래밍하면서 베이직을 배울 때만큼 재미를 느끼지 못한 것은 사실이다.
나중에 대학에 들어가서는
CC
C
로 시작해 파스칼(
Pascal Pascal
Pascal
),
C++C++
C++
, 스몰토크(
SmalltalkSmalltalk
Smalltalk
) , Scheme, CAML, Gofer 등 정말 많은 언어를 동시에 접하게 됐다.
HaskellHaskell
Haskell
은 졸업 후에 Gofer를 익히다 자연스럽게 알게 된 언어다. 그리고 그 학습의 순서를 패러다임의 이동으로 보면 명령형(Imperative) 언어, 객체지향(Object-oriented) 언어, 함수형(Funcitonal) 언어를 순서대로 익힌 셈이다. 그리고 그 변화에는 나름대로의 이유가 있었다.
 

우물 밖에서 들려오는 강물 소리

앞에서 말한 언어를 배워나가다 Haskell 식으로 하는 프로그래밍을 접하게 된 것은 대학교 3학년에 이르러서다. 그 전까지 쓰던 언어들(베이직∙포트란∙코볼∙C∙파스칼∙스몰토크∙Ada∙C++ 등은 보통 ‘명령형 패러다임’이라고 잘못 번역해서 부르고 있는 부류의 언어다)은 서로 다른 언어이기는 하지만 문제를 풀어내는 기법을 보면 그다지 크게 다른 방식을 요구하지 않았기 때문에 프로그래밍에 대한 이해를 한쪽으로 굳혀버리기에 충분했다. 제각기 기능의 양과 추상화를 지원하는 수준에 차이가 있을 뿐이었다.
특히 필자는 2학년 겨울 방학이 시작될 쯤에 공부하기 시작한 C++가 정말 마음에 들었다. Ada만큼 체계 있는 예외처리를 지원하고, 객체지향성도 지원하는 등 그 당시에 필자가 알고 있던 언어 중에서는 가장 많은 표현력을 제공했기에 C++는 지상 최고의 프로그래밍 언어처럼 여겨졌다. 그 당시 빠듯한 형편에 방학 중 부업으로 번 돈을 모두 C++ 프로그래밍 서적을 사느라 다 써버렸을 정도다. 지금 자바(
Java Java
Java
) ∙
C#C#
C#
DelphiDelphi
Delphi
∙ 비주얼 베이직을 좋아하는 사람들이 그러는 것처럼 그 때 필자도 C++에 대한 짧은 지식을 가는 곳마다 웅변하고 다녔다. 세월이 그렇게만 흘러갔다면 프로그래밍에 대한 필자의 지식은 지금도 별반 차이가 없었겠지만 참 다행스럽게도 생각의 관성을 완전히 뒤집어 놓는 경험을 하게 됐다.
3학년 첫 학기 Samuel Kamin이라는 학자가 쓴 『Programming Languages : An Interpreter-based Approach』라는 책으로 ‘프로그래밍 언어’과목을 배우기 시작했다. 이 책은 언어를 소개하는 데 있어 아주 독특한 접근 방법을 사용하고 있었다. 우선 약식으로 만든 인터프리터를 연습 환경으로 사용하는데, 이 소스는 1000줄 정도 되는 파스칼 코드로 작성돼 있었다. 2000여 줄의 코드도 당시의 필자에게는 큰 감동이었지만 새로운 언어를 소개할 때마다 인터프리터 소스를 조금씩 고쳐가며 여러 언어의 핵심 기능을 차례로 보여주는 데는 놀라지 않을 수 없었다. 게다가 그 언어의 특징을 잘 사용해 프로그래밍 연습을 할 수 있도록 좋은 예제를 충분히 싣고 있었기 때문에 서로 의미와 기법이 다른 여러 언어의 강점을 잘 이해할 수 있도록 꾸며져 있었다. 이때 필자를 가장 황당하게 했던 언어는 SASL이다. Haskell로 표현하자면 다음과 같은 표현을 직접 쓸 수 있는(복잡하게 흉내내지 않아도 되는) 유일한 언어였다.
 
fib = 1:1:[ a+b |(a,b) <- zip fib (tail fib)] -- 피보나치 수 열은 끝없이 열거되어 리스트의 원소가 된다.
 
다음의 zip은 Standard Prelude에 있는 함수다. HUGS의 Prelude에는 zipWith라는 함수를 빌어 정의되지만 이해를 돕기 위해 기본 기능만 써서 다음과 같이 옮겨 보았다. 다음 정의에서 패턴 _가 의미하는 바는‘어떤 꼴의 값이 들어온다고 하더라도 모두 이 패턴에 해당된다’는 뜻이다.
 
zip (x:xs) (y:ys) = (x,y) : zip xs ys zip _ _ = []
 
도대체 어떻게 컴퓨터와 같은 유한한 계산기로 무한한 값을 표현할 수 있으며, 심지어는 리스트와 같은 자료 구조에 그 ‘무한한 값의 연속’을 보관해둘 수 있다는 말인가? 이 간단한 표현은 지금까지 필자가 알고 있던‘프로그래밍 언어로 표현할 수 있는 문제’에 대한 사고를 완전히 뒤집어 놓았기 때문에 ‘어떻게 문제를 풀 것인가’에 대한 기본 바탕 마저 흔들어 놓았다. 이런 표현은 계산된 값이 아니라, 값을 계산해내는 데 필요한 식 자체를 저장하고 있어야 가능하다. 물론 지난 호에서 상세하게 소개한 바와 같이 이 언어는 미루어 계산하는 방식(Lazy Evaluation)을 기본으로 하고 있기 때문에 이런 표현이 가능하다. 그러나 그 당시에는 설명을 여러 번 듣고도 신기하기만 했다. 지금에 와서는 다른 언어로도 저 같은 표현을 얼마든지 흉내낼 수 있고 실제 어떤 언어로 작업을 해도 여기서 배운 기법을 필요할 때마다 쓰게 됐지만, 그것은 순전히 저런 언어를 한 번이라도 보았기 때문에 흉내라도 낼 수 있었던 것이다. 그리고 이런 언어와 시중에서 흔히 쓰는 언어에는 바탕의 차이에서 오는 표현 수준의 차이가 있다.
 
이런 언어에서 식(함수를 포함해서)은 값의 개념에 자연스럽게 포함된다. 예를 들어 '25 + 3'과 '(\x -> x + 3) 25'와 '28'은 정말 같은 값이다. 사실 식과 값이 다를 바 없어야 정석인데,시 중의 언어는 이런 식으로 생각하는 것을 정면으로 부정하게 만들거나 최소한 부자연스럽게 느끼도록 한다. 당연히 그에 따라 표현력은 많이 줄어들고(앞에서 예로 든 fib를 같은 기법을 써서 다른 언어로 표현해 보라), 자연스러워야 할 개념이 괴상한 기능으로 느껴지게끔 사고의 범위를 묶어둔다. 그러므로 어쩔 수 없이 유행하는 언어를 쓰더라도 지금 쓰는 대부분의 언어가 이전부터 잘못 내려받은 관습을 그대로 따르고 있다는 것 정도는 알아두는 게 좋겠다.
 

프로그래밍 언어에 대한 몇 가지 단상

자연어가 그렇듯이 프로그래밍 언어도 사고하는 방법에 영향을 주며, 그 강도가 오히려 자연어보다 높다. 앞서 나온 프로그래밍 언어나 기법은 사라지기보다 서로 맞물려 돌아가며 서로의 장단점을 포용한다. 어떤 시기에 공존하는 언어는 서로 장단점을 주고받으면 변화하다가 결국 비슷해지는데, 그렇다고 해도 제각기 자리 잡은 응용 분야가 다르고 문제를 푸는 방법도 달라서 만들고자 하는 소프트웨어에 따라 여러 형태로 조립된다. 그러므로 어떤 경우라도 하나로 맞물려 돌아가는 복잡한 소프트웨어 시스템은 지금의 다국적 환경과 같이 여러 언어로 개발된 부품이 서로 공존하며 맞물려 돌아가고, 이런 현상은 앞으로 어떤 언어나 기술이 나오더라도 바뀌지 않는다. 특히 한 언어를 아주 잘 쓰는 사람들 중에는 모든 언어가 ‘다 거기서 거기’라고 섣불리 판단하고, 적은 경험에서 오는 잘못된 자신감을 배우는 이들에게 쉽사리 건네주는 경우가 있다. 그러나 스스로의 경험과 학습의 테두리를 벗어나는 생각들이 이 분야에는 셀 수 없이 많다. 생계를 꾸리기 위해 많이 쓰는 기술을 익혀야 하는 것은 당연하지만 그게 전부라고 생각하면 곤란하다. 이 글에서 소개하는 Haskell만 해도 90년대 와서 프로그래밍의 바탕을 뒤흔들만한 여러 변화를 받아들였다. 그런 변화들 중에는 놀랄 만한 것들이 얼마든지 있다. 그러므로 우리는 처 음으로 프로그래밍을 배우는 이들에게 가능한 다양한 사고방식과 응용 분야를 고루 맞볼 수 있도록 해야 한다.
프로그래밍 언어를 공부하는 것과 그 언어로 만든 라이브러리를 익히는 것이 아주 다르다는 사실을 잘 이해하고 있어야 한다. 이전에도 말했듯이 한 응용 분야를 위해 설계된 라이브러리는 그 모 언어와 완전히 다른 새로운 언어로 보는 게 옳다. 같은 문법을 쓴다는 것은 처음 입문시에 친숙함을 줄 뿐이고, 시간이 지나면 별다른 도움이 안 된다. 길게 보면 차라리 그 응용 분야에 아주 적합한 언어를 배우는 것보다 못한 경우가 더 많다. 그러므로 같은 문제를 풀더라도 어느 하나를 강요하기보다 필요에 따라 서로 다른 것을 마음껏 선택할 수 있도록 자유로운 환경을 마련해 주는 것이 더 바람직하며, 어떤 응용 분야를 위한 라이브러리는 완전히 새로운 언어처럼 취급해서 그 영역만의 단어와 숙어, 문제 푸는 기법을 익히는 게 이치에 맞다. 그게 부담스럽다면 아예 한 분야에 전문가가 되고, 다른 분야의 사람과 공동 작업을 하는 데 무리가 없도록 그 분야의 바탕 지식을 잘 이해하는 수준에서 머무르는 게 현실성 있는 대안이다.
한 언어의 중요한 요소를 이루는 기능은 그 의도를 정확하게 설명해야 한다. 잘 설계된 언어라서 학습 진도에 따라 자연스럽게 표현력을 키워가도록 가르칠 수 있다면 다행이지만, 시중에 나온 대부분의 언어는 그렇지 않다. 처음부터 작은 문제를 푸는 데 필요도 없는 기능을 쉽게 노출하기 때문에 그 언어의 기능을 가려가며 설명해봐야 교육 효과에 차이가 나지 않는다. 지난 특집에서 자바가 어렵다고 설명한 이유가 이 때문이다.자바를 써야 잘 풀 수 있는 문제로 프로그래밍 연습을 할 수가 없다면, 즉 한동안 프로시저만으로 기본을 연습해야 할 수준이라면 자바를 쓰는 게 현명한 처사가 아니라는 지적한 것이다. 예를 들어 프로시저 추상화를 가르친다면 지난 호에서 다루는 문제 정도는 가르쳐야 한다
 
 

흘러가는 물에 몸을 맡기고

이후에 프로그래밍을 제대로 공부하기 시작하면서 필자가 처음 선택한 언어는 프랑스 inria 연구소(www.inria.fr)에서 개발한 CAML Light이다. 이 언어는 이미 널리 쓰이고 있는 ML의 방언이다. 처음에는 SML/NJ를 익히려고 했지만 모듈을 처리하는 기능이 강한 반면 너무 복잡해서 그나마 부담이 적었기 때문에 친절해 보이는 CAML Light를 골랐다. 오랜만에 설레는 마음으로 뚜껑을 열었다. 이 언어는 정말 특이했다. 그래서 처음 생각과는 달리 C++ 류의 언어에 젖어있던 사고방식을 바꾸는 데 꽤 오랜 시간이 걸렸다. 3학년 프로그래밍 언어 시간에 맛보기로 만져보던 것과는 무게가 달랐다. CAML은 이전까지 그럭저럭 써오던 Scheme과도 많은 차이가 있어 이 언어가 자랑하는 기능을 제대로 쓰자니 한 동안 프로젝트 과제를 마음에 드는 수준으로 제출할 수가 없었다(전에는 Scheme과 C++를 주로 써서 과제를 제출했다). (CA)ML에서 당장 눈에 띄는 기능은 데이터 패턴이었다. 본래 패턴을 프로그래밍에 사용하던 언어는 관계(relation)를 이용하는 Prolog 류의 논리형 언어인데, 함수를 쓰는 언어와도 잘 어울려서 재미있는 기법을 쓰고 있었다.
 

데이터 패턴

ML 류의 언어가 데이터 패턴을 어떻게 프로그래밍에 사용하는지 살펴보자. 예를 들어 우리가 자연수라는 데이터를 표현하고자 한다면 우선 다음과 같이 쓸 수 있다(물론 여기서는 일부러 Haskell을 쓴다).
 
-- Haskell data Natural = ...
// Java interface Natural
 
이제 Natural 데이터를 만들어 낼 수 있는 함수(constructor)를 적어 정의를 완성해야 한다. 일단 알 수 있는 사실은 모든 (범)자연수가 어떻게든 0부터 시작한다는 점이다.
 
-- Haskell data Natural = Zero...
 
여기서 ZeroNatural 형의 값을 만드는 특수한 함수로 생성 함수(Constructor function)라 부른다. 생성함수는 첫 글자를 반드시 대문자로 써야 한다.
 
// Java interface Natual class Zero implements Natural
 
생성 함수를 만들어 쓰지 않고, subtyping을 바탕으로 하나의 독립된 형 Zero로 표현했다. 자바에서는 이렇게 표현하는 것이 가장 본래 의미에 가깝다. 물론 자연수는 0으로만 이뤄지지 않는 무한한 데이터다. 이를 굳이 코드로 표현하자면 다음과 같다.
 
-- Haskell data Natural = Zero | One | Two | Three ....
 
여기서 ‘|’는 OR의 의미를 갖는 문법이다. 풀어서 읽어보면 ‘자연수는 0 아니면 1 아니면 2 아니면...’이다. 물론 무한한 모든 값의 패턴을 열거할 수는 없으니까 이와 같은 열거형 표현을 쓰는 것은 불가능하고 나머지 자연수를 모두 만들어내는 생성 함수를 기술하는 걸로 대신한다.
 
data Natural = Zero | Succ Natural
 
Zero와 마찬가지로 Succ도 함수다. Zero는 인자가 없는 함수지만 SuccNatural 형의 인자를 받는다. 생성함수 ZeroSucc의 type을 알아보면 쉽게 확인할 수 있다.
 
🗣
Main> :t Zero Zero :: Natural Main> :t Succ Succ :: Natural -> Natural
 
// Java class Zero implements Natural class Succ implements Natural { public Succ(Natural n) no = n private Natural no;
 
3이란 수는 생성함수의 조합으로 다음과 같이 표현할 수 있다.
 
-- Haskell Succ (Succ (Succ Zero))
// Java new Succ( new Succ( new Succ( new Zero() )))
 
 
이제 앞에서 다룬 내용을 되짚어 보자. 먼저 Haskell에서 말하는 ctor 함수는 그 기능 면에서 객체지향 언어의 생ctor와 목적이 같다. 말하자면 그 형을 구성하는 모든 원소를 만들어 내는 데 사용하는 데이터 생성 인터페이스다. 그러나 보통 자바와 같은 언어에서 말하는 생성자와 그 느낌이 사뭇 다르다. 그 차이점을 살펴보기 위해 스택 데이터를 표현해보자.
 
-- Haskell data IntStack = EmptyStack | Push Int IntStack
// Java class IntStack { public IntStack(); public Stack push(int item) { .... return this; // just for message-cascading style expressions ...
 
 
앞의 코드를 보면 자바에서 pushIntStack과 같은 ctor라기보다 그저 상태 값을 바꾸는 일반 메쏘드(modifier)와 다를 바 없다. 그래서 Haskell(ML)에서의 Push 함수처럼 그 역할을 겉으로 알아보기가 힘들지만, 그 의미를 살펴보면 push가 스택 데이터를 표현하는 데 있어 절대로 빠질 수 없는 기능이라는 것을 알 수 있다. 즉 모든 스택 데이터는 EmptyStack(new Instack())Push(s.push(i)) 함수의 조합으로 표현할(생성할) 수 있고, 또 그 방법밖에 없도록 만들어야 옳다. 다시 말해 두 기능 중 하나만 빠지더라도 스택 정의는 미완성이 된다. 그런 의미에서 스택에 속하는 모든 데이터를 표현하는 완전한 연산의 집합이라는 의미에서 두 함수를 생성함수(생성자)라고 부르는 것이다. 이런 개념은 사용하는 언어에 상관없이 우리가 새로 만들고자 하는 데이터를 설계하는 데 있어 기본이 된다. 자바에서는 비록 push를 일반 메쏘드로 표현했지만 그렇다고 원칙이 변하지는 않는다. 메쏘드로 표현할 수밖에 없는 것은 언어의 특성상 그런 것이고, 본래의 의미는 IntStack 클래스를 구성하는 필수 연산이기 때문에 IntStack과 함께 스택 데이터를 만들어 내는 생성자 역할을 맡게 된다.
이미 알고 있는 독자도 있겠지만 이와 같은 데이터 설계 방법은 수학의 귀납형 정의(Inductive defintion)에 바탕을 두고 있다. 한 집합을‘수학적 귀납법의 원칙(The Principle Of Mathematical Induction)’에 따라‘원소 생성’의 관점에서 정의내리는 기법으로 보통 전산 과학에서는 이를 재귀형 정의(recursive definition)라고 부른다(모든 재귀형 구조가 귀납형은 아니기 때문에 엄밀히 따지자면 정확한 용어라고 할 수는 없다). 앞에서 예로 든 자연수와 스택은 각각의 귀납형 정의를 충실하게 코드로 옮겨 쓴 것일 뿐이다(자바 경우도 마찬가지다). 예를 들어 IntStack의 정의에서 EmptyStackPush 함수는 각각 귀납형 정의를 완성하는 바탕 단계(Base step)와 귀납 단계(Induction step)에 해당한다. 그리고 마지막 단계(Final Step)는 모든 귀납형 정의에 공통으로 들어가는 문장으로 앞의 두 단계를 거쳐 만들 수 있는 값만이 이 집합의 원소가 된다는 규정이다. 이 마지막 규정은 언뜻 불필요해 보이지만 실은 생성 함수(생성자)가 무엇인지에 대한 정확한 의미가 담겨 있다. 예를 들어 Natural 형의 생성함수는 ZeroSucc 밖에 없기 때문에 다른 함수로는 자연수 데이터를 만들 수 없도록 되어 있다. 객체지향 언어에서 객체를 만들 때 반드시 생성자를 써서 객체를 만들도록 문법과 의미를 제약해 둔 이유도 바로 이 때문이다.
일단 데이터 형을 잘 정의하고 그에 따라 생성자를 선언하고 나면, 그 데이터 형에 속하는 값의 구조(패턴)가 결정된다. IntStack이라면 그 재귀형 정의에 따라 다음 두 가지 구조의 데이터 밖에는 없다고 확신할 수 있다.
 
EmptyStack Push n s
 
여기서 nInt의 원소이고 sIntStack의 원소다. 그러므로 Stack을 처리하는 데 필요한 나머지 연산은 값의 두 가지 패턴에 대응하는 방식으로 정의할 수 있다.
 
pop :: IntStack -> IntStack pop EmptyStack = error "Stack is Empty(POP)" pop (Push n s) = s
 
정말 깔끔하고 군더더기 없다. 이번에는 리스트를 이용해 많은 데이터를 한꺼번에 스택으로 묶어버리는 코드를 작성해보자.
 
list2stack :: [Int] -> IntStack list2stack [] = EmptyStack list2stack (x:xs) = Push x (list2stack xs)
 
앞의 코드는 pop의 경우와 마찬가지로 list 형 데이터의 패턴 [], x:xs에 대응하는 방식을 따르고 있다. 한편 스택의 원소를 리스트로 묶어버리는 함수는 스택의 패턴에 맞춰 코드가 작성된다.
 
stack2list :: IntStack -> [Int] stack2list EmptyStack = [] stack2list (Push n s) = n : stack2list s
 
두 함수를 다음과 같이 간단히 실험해보자.
 
(stack2list . list2stack) [1,2,3] == [1,2,3]
🗣
True
 
물론 생성 함수와 패턴을 반드시 재귀 구조의 자료를 정의할 때만 쓸 수 있는 것은 아니다. C++의 enum과 같은 방법으로도 데이터를 선언할 수 있다. 예를 들면 Haskell의 Standard Prelude에는 Bool 형이 다음과 같이 선언돼 있다.
 
data Bool = False | True
 
Bool 데이터 형으로 함수 and를 정의한다면 다음과 같다.
 
and :: Bool -> Bool -> Bool and False _ = False and True b = b
 
마찬가지로 각 요일을 나타내는 데이터 형을 정의하고자 한다면 다음과 같이 표현할 수 있다.
 
 
data Days = Mon | Tue | Wed | Thu | Fri | Sat | Sun
 
그리고다음과같이사용할수있다.
 
nextDay :: Days -> Days nextDay Mon = Tue nextDay Tue = Wed ... nextDay Sun = Mon dayAfter :: Int -> Days dayAfter 0 day = day dayAfter (n+1) day = next (dayAfter n day)
 
지금까지 볼 수 있는 바와 같이 값의 패턴을 쓰게 되면 if와 같은 제어식을 언어 환경에 넘겨버릴 수 있기 때문에 보다 선언에 가까운 표현(declarative expression)을 쓸 수 있어 데이터 형을 처리할 때 체계 있고 이해하기 쉬운 코드를 작성할 수 있다는 장점이 있다. 시중에서 보는 언어에서는 이런 표현 기법을 쉽게 접할 수 없기 때문에 처음에는 어색하게 느껴질 수 있지만, 자꾸 쓰다 보면 코드가 데이터의 구조에 따라 아주 체계있게 정리된다는 느낌을 가질 수 있을 것이다. 그리고 점차 패턴이 가져다 주는 제어 추상화로 문제를 풀어내는 데 반드시 필요한 코드에만 집중할 수 있다는 것이 얼마나 프로그래밍의 생산성을 높여주는지 실감할 수 있을 것이다. 물론 데이터 패턴 말고도 다양한 패턴이 있어 프로그래밍을 재밌게 만든다.
이번 호에서는 중복을 피하기 위해 일부러 빠뜨렸지만, 지난 호에서부터 줄곧 리스트를 처리하기 위해 써 온 멋진 패턴식(List Comprehension)을 기억할 것이다.
 
[dayAfter 3 d | d <- [Mon, Tue, Wed]]
 
필자가 처음 이 표현을 ML에서 봤을 때(지금 이 글을 보고 있는 상당수의 독자들과 마찬가지로) 이래도 프로그램이 된다는 것 자체가 신기했다. 어떤 이들은 이런 표현이 사치스럽지 않냐고 하겠지만 언어의 표현력이 문제 해결 능력과 생산성, 그리고 문제 해결 기법에 까지 아주 큰 영향을 준다는 점을 미처 깨닫지 못했을 때나 하는 소리다. 가령 처음으로 프로그래밍을 공부하러 오는 이들에게 C++/자바를 던져 주고 주어진 다음과 같은 문제를 풀어오라고 했다고 하자.
 
  • 문제 : 주어진 두 정수의 집합에서 모든 수의 조합 중에 두 수를 더한 값이 -5와 10 사이에 있는 경우만 골라 그 순서쌍을 출력하라.
 
Haskell에서라면 다음과 같이 표현할 수 있다. 값을 만들어내는 과정을 기술하기 보다 얻어야 할 값의 성질(관계)를 기술하는 수준에 가깝다.
 
[(x,y) | x <- [-5..10], y <- [-3..7], x + y > -3, x + y <10]
 
다른 언어라면 얼마나 배워야 이와 같은 문제를 풀어낼 수 있을까(이 문제를 풀어야 할 사람들이 고등학교 시절까지 수학 기초를 배운 것 말고는 다른 경험이 없다는 점을 반드시 염두에 두고 생각해야 한다)? 다시 말하자면 표현력은 문제를 풀어내는 사고력 및 생산성과 아주 밀접한 관계가 있다. 그렇다면 우리가 처음에 어떤 언어로 프로그래밍을 배워야 정해진 시간에 더 많은 문제를 재미있게 풀어보도록 가르칠 수 있을까? 그것도 아주 제대로 틀을 갖추는 방법까지 포함해서 말이다.
 

형 변수

대학 4학년 시절 CAML로 프로그래밍을 하면서 그 전에 다른 언어로는 생각할 수도 없었던 많은 개념을 배우게 됐다. 그 중에는 쉽게 받아들일 수 있는 기능도 많았지만 적응하는 데 오랜 시간이 걸리는 괴상한(?) 기법들도 있었다. 어쨌거나 ML 덕분에 나는 깊은 우물 안에서 힘들지만 한 걸음 한 걸음씩 빠져나오고 있었던 것이다. 이런 저런 언어를 배우느라 너무 잡스럽게 공부하고 있지는 않나 걱정이 되기도 했는데 수년 후에 그런 걱정을 말끔히 씻어줄 만한 경험을 하게 됐다.
일단 졸업 후에 실무에서 다시 C++를 연습하게 됐을 때 이는 전에 열심히 보던 그 C++가 아니었다. 새롭게 익힌 개념들은 C++ 프로그래밍의 표현력을 끌어올리는 데 오히려 큰 도움이 됐고, 생각하는 방식이 다른 언어를 쓰면서 익힌 여러 기법들이 C++ 프로그래밍에서도 고스란히 되살아났다. 가장 큰 이득을 본 예를 들자면 1990년대 C++의 가장 큰 변화였던 STL이 나왔을 때였다. 다른 동료들은 이 새로운 개념의 라이브러리를 공부하는 데 많은 어려움을 겪었다. C++가 그 동안 추구해왔던 객체지향성과는 다른 프로그래밍 기법을 쓰고 있었기 때문이다. 주변 동료들과 달리 필자는 별다른 어려움 없이 나오자마자 곧바로 사용할 수 있었다. 그리고 STL을 설계한 사람이 ML이나 Scheme과 같은 언어에서 오랫동안 써오던 프로그래밍 기법을 C++에 맞게 적용했을 뿐이라는 것도 금방 눈치 챌 수 있었다. 물론 인터페이스는 함수를 자연스럽게 지원하는 언어처럼 말끔하지 않았고, C++의 표현력을 한계까지 몰아붙여 응용해야 할 만큼 여러 가지 구현 기술이 총동원돼 눈을 어지럽혔지만 왜 그럴 수밖에 없었는지는 책을 뒤져보지 않아도 저절로 알 수 있었다. 대부분은 C++의 여러 특성 때문에 그 한계를 어떻게든 극복하기 위한 구현 기술일 뿐이다.
왜 그렇게 쉽게 적응할 수 있었을까? ML이나 Haskell 류의 언어는 STL과 같은 프로그래밍 방법을 가장 잘 쓸 수 있는 언어다. 이미 그런 식의 프로그래밍 언어를 줄곧 써오던 터여서 STL의 이런 저런 현란한 구현 기술이 애처롭게 보일 뿐, 새롭게 적응해야 할 사고의 장벽은 없었다. 이제부터 그 이유를 자세히 설명하기로 하겠다.
다른 언어에서와 마찬가지로 ML 류의 언어 역시 다형성으로 재사용성을 높일 수 있는 기능을 제공한다. 이 언어에서 쓰는 방법은 이른바‘함수식 다형성(Parametric Polymorphism)’이라고 불리는 것인데, 자주 쓰는 식을 함수로 만들어 필요할 때마다 다시 쓸 수 있듯이 이 기법은 형을 인수처럼 취급해서 같은 목적을 달성한다. 예를 들어 지난 호에서 소개한 map은 어떤 형일까? 인터프리터에서 map의 형을 알아보면 다음과 같은 결과를 볼 수 있다.
 
🗣
:t map map :: (a -> b) -> [a] -> [b]
 
앞에서 map의 type expression에는 a, b니 하는 특정 형을 지칭하지 않는 이름이 들어있다. 이번에는 다음과 같은 함수를 첫번째 인자로 보낸 후 그 이름들이 어떻게 변하는지 살펴보자.
 
increment :: Int -> Int increment x = x + 1
 
앞의 함수를 첫 번째로 인자로 넣어준 식(map increment)의 형을 알아보면 다음과 같다.
 
🗣
Main> :t (map increment) map increment :: [Int] -> [Int]
 
처음 map의 type expression에서 (a -> b) 자리에는 increment가 들어갔기 때문에 결과는 [a] -> [b] 꼴로 나와야 한다. 그런데 출력된 결과는 [Int] -> [Int]로 정수 리스트를 받아 정수 리스트를 내놓는 정확한 형이다. 틀은 같지만 a와 같은 이름이 사라지고 대신에 Int(작은 범위의 정수)가 나왔다. 어떻게 이런 결과가 나오는 것일까? map의 형이 계산 중에 바뀐 것일까?
함수에서 인수를 받아 값을 계산하는 것처럼 a, b도 변수의 일종이며 형을 값으로 받는다는 게 다르다. 그러므로 map의 type expression은 마치 다음의 함수식과 같은 의미를 가진다. 즉 식의 형을 계산하는 것조차도 함수 계산을 쓰고 있는 것이다.
 
TypeOfMap a b = (a -> b) -> [a] -> [b]
 
map의 type expression은 이런 함수를 정의한 것과 같다. 앞서 말한 바와 같이 이때 ab는 보통 함수의 인자와 크게 다를 바가 없다. 단지 형을 값으로 취한다는 의미에서 형 변수(type variable)라 부른다. 이 식의 의미는 다음과 같다.
 
a, b는 형을 값으로 취하는 변수다. mapa 형 값을 받아 b 형 값을 내놓는 함수, 즉 정의구역과 공변역이 서로 다를 수 있는 함수를 첫 번째 인자로 취한다(a -> b). 두 번째로 리스트 값을 받을 수 있는데 그 리스트의 원소는 a형, 즉 첫 번째 인자로 받은 함수의 정의구역과 같아야 한다([a]). 세 번째로 받는 리스트의 원소는 이 함수의 공변역과 같은 형이어야 한다([b])”
 
만일 mapincrement를 적용하면 형 변수 a,`b`의 값이 결정된다.
 
(a -> b) == (Int -> Int)
 
그러므로 aInt 형으로 bInt 형으로 결정된다. map의 정의에 따라 나머지 인자들의 형도 결정할 수 있다. 즉 [a] -> [b][Int] -> [Int]가 된다.
이번에는 좀 다른 경우를 보자. 다음에 정의한 함수 idmap처럼 실제 형 값을 일부러 정하지 않은 함수다.
 
id :: a -> a id x = x -- 받은 걸 그대로 내놓는 함수
// C++ template<class a> a id(a, x) { return a; }
 
다만 함수의 정의에 따라 정의구역과 공변역의 형이 동일하다는 사실만 알 수 있다. 그러므로 이 함수를 map의 첫번째 인자로 취하면 다음과 같은 결과를 얻게 된다.
🗣
Prelude> :t map id map id :: [a] -> [a]
 
increment의 경우와는 달리 id는 형 값이 정해지지 않은 함수다. 그러므로 정의구역과 공변역이 같아야 한다는 제약 조건을 제외하고는 mapa b에 형 값을 제공해 주지 못한다. 결국 (map id)는 입력 리스트와 출력 리스트가 같은 형이어야 하는 함수다.
앞의 설명에서 map, idincrement와 달리 정확한 형 값이 없고, 들어오는 인자에 따라 여러 가지 다른 형으로 해석될 수 있었다. 이 때 map, id와 같이 그 type expression에 하나 이상의 형 변수가 있으면 이를 polytype이라고 하고, 함수 형이 polytype이면 그 함수를 다형 함수(Polymorphic function)라 한다. 이와 같이 ML이나 Haskell에서는 형 변수 개념을 사용해 어떤 식의 형을 최소 일반형(the least general type)으로 유추한 다음 식이 줄어가는 과정에서 점차 단형(monomorphic)으로 범위를 좁혀가는 방식을 사용한다. Haskell과 ML에서 사용하는 형 체계는 Hindley-Milner type system에 바탕을 두고 있는데, 이 체계에서는 어떤 식이라도 단 하나의 기본형을 가진다는 특징이 있다. 대부분의 정적 형 검사를 지원하는 함수 언어는 이 체계를 사용한다.
또한 함수식 다형성은 서브타이핑과 달리 모든 형 검사가 컴파일 시점에 끝난다는 장점이 있다. 동적으로 형을 검사하는 기능이 필요 없기 때문에 컬렉션 형의 자료구조, 즉 리스트, 배열, 스택, 해시 테이블 등의 자료구조를 만들 때 특히 유리하다. C++가 template와 같은 다형성을 추가한 이유도 이런 이점을 중요하게 생각했기 때문이다. 앞에서 보았던 IntStack과 같은 자료 구조는 type variable을 이용해 다음과 같이 다형구조로 만들 수 있다.
 
data Stack a = EmptyStack | Push a (Stack a)
 
앞에서 a는 특정 형을 가리키지 않기 때문에 문맥에 따라 다른 형으로 해석된다. 여기서 형의 이름 Stack은 마치 생성함수처럼 어떤 형 a를 받아 새로운 형 Stack a를 만들어 낸다. 그래서 Stack을 형 생성함수(type constructor)라 부르며 Push 등을 데이터 생성함수(data constructor)로 구분하는 게 원칙이다.
이제 다형 데이터가 된 Stack 정의에 맞추어 앞에서 각 함수의 형 값(Type signature)을 고치면 다음과 같다. 물론 코드는 전혀 손댈 필요가 없다.
 
pop :: Stack a -> Stack a ... list2stack :: [a] -> Stack a ... stack2list :: Stack a -> [a] ...
 
보통 이와 같은 형 체계를 쓰는 언어는 다른 언어보다 형 검사가 엄격하다. 그래서 정적으로 형을 검사하는 다른 언어를 쓰던 사람도ML과 같은 언어를 쓰다보면 얼마나 불안한 형검사 기능을 써왔는지 새삼 느낄 수 있으며, 버그 없는 코드를 작성하는 데 얼마나 큰 도움이 되는 지를 실감할 수 있다.
 
 

마치면서

이번 호에는 Haskell의 몇 가지 간단한 특징을 이야기 형식으로 풀어보았다. 기능을 그저 열거하기보다 필자 스스로가 왜 그런 기능이 필요해서 이런 저런 언어를 배우게 되었는가, 그리고 Haskell이란 언어가 이런 저런 요구를 어떤 멋진 기능으로 충족시켜줬는가를 말하고 싶었다. 그러나 이런 방식이 Haskell를 전달하는 데 얼마나 도움이 됐는지 모르겠다. 어쨌든 이번 호는 여러 사정으로생각한것만큼 글을 다 쓰지 못했다. 이야기를 마무리 짓지 못하고 다음으로 미룰 수밖에 없는 것에 대해 양해를 구한다.
 
정리 : 강경수 elegy@sbmedia.co.kr
 

참고 문헌

 
  • Herold Abelson, Gerald Jay Sussman with Juile Sussman, “Structure and Interpretation of Computer Programs,” 2nd Ed. MIT Press, 1996
  • Richard Bird, “Introduction to Functional Programming using Haskell,” 2nd Ed. Prentice Hall, 1998
  • Richard Bird, Philip Wadler, “Introduction To Functional Programming,” Prentice Hall, 1988
  • Anthony J. Field, Peter G.Harrison“Functional Programming,” Addison Wesley, 1988
  • Timothy Budd , ”Multiparadigm Programming in Leda,” Addison Wesley, 1995
  • Paul Hudak John Peterson,Joseph Fasel, ”The Gentle Introduction to Haskell”, http://www.haskell.org/tutorial/, 2000
  • Simon Peyton Jones, About Haskell, http://www.haskell.org/about Haskell.html, 2001
  • Andrew Hunt, David Thomas, “The Pragmatic Programmer : From Jorneyman to Master”, Addison-Wesley 1999
  • Why Functional Programming Matters by John Hughes, The Computer Journal, Vol. 32, No. 2, 1989, pp. 98 - 107. Also in: David A. Turner (ed.): Research Topics in Functional Programming, Addison- Wesley, 1990, pp. 17 - 42.
 
Copyright (c) 2002 김재우. All rights reserved.