제공 : 한빛 네트워크
저자 : Adam Turoff
역자 : 김현우
원문 : An Introduction to Haskell, Part 1: Why Haskell
확실한 말로 시작해 보자 : 만약 여러분이 프로페셔널 프로그래머라면 여러분의 미래에 Haskell이 있다.
1987년에는 이 문장이 Smalltalk에 해당하는 말이었다. 그러나 20년이 지난 현재에는 Smalltalk가 무엇인지는 모르더라도 객체지향 프로그래밍, 클래스 계층, 모델-뷰-컨트롤러 패턴 등과 같이 Smalltalk로부터 파생된 많은 기법들이 널리 퍼져있다.
요즘의 Haskell이 이와 비슷한 상황이다. Haskell은 현재 소수의 사람들만이 사용하고 있으며 정밀 작업을 수행하기 어려운 안정성에 신뢰도 역시 부족하다. 앞으로 여러분이 Haskell을 배우거나 실제 프로젝트에 쓰게 될지도 모르겠다. 그러나 실제로 사용하지는 않더라도 Haskell로부터 나온 개념들은 Java, C#, C++, Perl, Python, Ruby와 같은 여러 주류 언어에 서서히 적용되고 있다. 과거 20년 동안 Smalltalk가 그랬듯이 Haskell역시 향후 20년간은 프로그래밍과 새로운 언어 디자인에 강력한 영향을 끼칠 것이다.
Haskell 이란?
Haskell은 중요하지만 무엇이 Haskell을 특별하게 하는가? haskell.org에 보면 Haskell을 간단히 정의하고 있다 :
Haskell은 범용적인 순수한 함수형 프로그래밍 언어로 정적인 타입, 높은 함수 우선순위, 다형성, 타입클래스, 부작용(side effect) 없는 처리가 주요한 특징이다.
여러분이 학자나 언어 설계자면 이러한 말들이 꽤 의미 있게 들릴 것이다. 만약 프로페셔널 프로그래머라면 굳이 자세히 설명하지 않더라도 정적인 타입이나 다형성이란 말쯤은 알고 있을 것이다.
Haskell이 함수형 언어라는 것은 기본적으로 람다식에 기초하고 있음을 뜻한다. 이 말이 괴기스럽게 들릴 수도 있겠지만 람다식은 그리 어려운 것이 아니니(믿거나 말거나 지만) 겁먹을 필요는 없다.
람다식은 단순히 함수를 어떻게 평가할 것인가에 관한 규칙들을 모아놓은 것에 불과하다. 두 개의 동작 파트(함수정의와 함수평가)와 두 개의 소재 파트(함수와 값)가 있다. 여러분이 프로페셔널 프로그래머라면 람다식을 배우기 위한 소양은 이미 갖추고 있는 것이다. 이를테면 C와 같은 언어에서 아래 문장을 평가하기 위한 규칙정도는 당신 머리 속에 있을 것이다.
printf("%.03fn", sqrt(incr(1)));
먼저 함수의 매개변수를 평가하고 나서 다음으로 함수의 결과 값을 계산한다. 마지막 결과 값이 남을 때까지 이 과정이 재귀적으로 반복된다. 위의 예에서 가장 안쪽의 함수인 incr(1)을 계산해서 결과 값 2를 얻는다. 다음으로 sqrt(2) 를 계산해서 1.414.... 를 얻는다. 마지막으로 printf 는 두 개의 매개변수 "%.03fn" 과 1.414... 를 가지고 어떤 형식을 출력 시키게 된다.
람다식에는 몇 가지 특징이 더 있는데, 함수를 값으로 리턴하거나 매개변수로 함수를 받는 함수를 생성하는 능력이 그것이다. 이러한 특징은 작고 모듈화 된 함수를 정의할 수 있게 하며, 이들을 접합하여 전문적인 함수를 손쉽게 만들 수 있게 한다
예를 들면, filter는 이미 정의되 있는 테스트 함수와 값들의 리스트를 받고 테스트를 통과한 값으로 새로운 리스트를 만들어 리턴 하는 범용적인 목적의 함수이다. 함수 even은 입력된 값이 숫자 2로 나누어 질 수 있는지를 판단하는 테스트 목적의 함수이다. 여기서 리스트 중에 짝수를 추출하기 위해 Haskell에서 어떻게 두 함수를 사용하는지 보일 것 이다. 본 예에서는 Glorious Glasgow Haskell Compiler (ghc)에서 제공하는 인터프리터인 ghci를 사용했다.
Prelude> filter even [1..10] -- filter the even numbers out of a list
[2,4,6,8,10]
내가 값의 리스트를 얻어서 그 리스트의 짝수만을 리턴 하는 함수를 정의하고자 한다면 인터프리터에서 다음과 같이 아래와 같이 할 것이다.
Prelude> let evens = filter even -- define the function
Prelude> evens [1..10] -- filter numbers out of a list
[2,4,6,8,10]
이 예제가 시시해 보일 수도 있겠지만, 함수형 프로그래밍 언어에서 람다식을 사용할 때의 장점(단지 기존의 함수 몇 개를 조합하여 요구되는 함수를 만드는 능력)을 잘 보여주고 있다.
흥미롭게도 이 예제는 파이프라인이 어떻게 동작되어야 하는지를 정확히 보여주고 있다.
C 언어에서 동일한 동작을 하는 함수를 작성하는 것은 훨씬 복잡하다. 우선 결과 리스트가 할당되어야 하고 결과 값을 세어야 한다. 그 다음 그 값들을 새 리스트에 복사해야 한다. Haskell에서 간단히 구현했던 내용이 C에서는 다음과 같은 함수로 작성된다.
int *evens(int *in, int count) {
int matches = 0;
int i, *out, *ptr;
for (i = 0; i < count; i++) {
if ((in[i] % 2) == 0) {
matches++;
}
}
out = malloc(sizeof(int) * matches);
ptr = out;
for (i = 0; i < count; i++) {
if ((in[i] % 2) == 0) {
*ptr = in[i];
ptr++;
}
}
return out;
}
이 함수는 Haskell의 evens의 동작을 조합하여 정의한 것으로, Haskell의 filter even의 코드와 동등하다. 만약 C 라이브러리에 filter의 동작을 수행하는 함수가 있다면 코드를 좀 더 간단히 작성할 수 있을 것이다. 하지만 안타깝게도 그러한 방법은 C언어와 같은 언어에서의 해결책이다.
보다 현대적인 언어인 Java나 C++ , C# 같은 언어로 이런 함수를 만드는 것은 좀 더 낫다. 최소한 함수 처음 부분의 메모리 관리는 알아서 해주기 때문이다. "filter even [1..10]" 구문은 Perl, Python, Ruby 같은 동적인 언어의 표현과 비슷하다. 다음은 동적인 언어들의 표현을 나열한 것이다.
## Perl
grep {$_ % 2 == 0} (1..10)
## Python
filter(lambda n: n % 2 == 0, [1,2,3,4,5,6,7,8,9,10])
## Ruby
[1,2,3,4,5,6,7,8,9,10].delete_if {|i| i % 2 == 1}
왜 람다식 인가?
대부분의 주류 프로그래밍 언어는 머신 제어(실제 컴퓨터이거나 버추얼 머신이거나)에 초점을 맞춘다. 이것은 대다수 언어의 숙명으로 프로그래머는 머신에게 순간순간 마다 무엇을 해야 할 지를 알려줘야 한다. 프로그램을 이해한다는 것은 흔히 한번에 한 줄씩 코드를 읽고 나서 "다음엔 컴퓨터가 무엇을 할까 ?" 하고 묻는 것을 뜻한다.
함수형 프로그래밍 언어는 다른 접근을 위해 람다식을 사용한다. 머신에게 문제를 풀기위한 문장을 하나씩 주는 대신 프로그래머가 해결책이 무엇인지를 기술하여 컴퓨터가 문제를 해결하기 위해 어떻게 실행할 것인지를 찾도록 한다. 예를 들면, 원하는 값만을 선택하여 리스트를 만드는 filter와 같은 함수를 이용해서 C와 같은 언어 보다 쉽게 새 리스트를 만들 수 있다 (혹은 Ruby를 예로 들면, 불필요한 항목을 제거함). C에서 이러한 함수를 만들려면, 먼저 결과를 저장할 메모리를 할당하고 다음으로 선택된 값을 결과 리스트에 복사해야 한다.
물론 이러한 기법을 사용하기위해 반드시 함수형 언어를 써야 하는 것은 아니다. 함수형 언어 세계의 개념들이 주류 언어에서도 나타나고 있기 때문이다. Tom Christiansen의 말은 이 점을 잘 지적한다.
명령형, 함수형, 객체지향, 논리형 프로그래밍의 4가지 스타일을 모두 경험하지 못한 프로그래머는 하나 혹은 그 이상의 부분에 맹점을 가지고 있다. 그것은 흡사 끓이는 법은 알지만 튀기는 법은 모르는 것과 같다. 프로그래밍은 몇 번의 수업으로 쉽게 배울 수 있는 것이 아니다.
많은 프로그래밍 언어들이 혼합된 스타일을 지원한다. 대부분의 객체지향 언어의 핵심이 명령형으로 되어있으며, 클래스에서 객체와 메쏘드는 약간 버전업된 C보다 조금 나은 언어의 꾸밈 기능을 지원한다. 많은 프로그래밍 언어는 함수형, 명령형, 객체지향 스타일을 구분하기 힘들게 섞어놓았다.
반면 Haskell은 함수형 스타일의 언어에서도 순수한 함수형 언어이다. Haskell를 배우고 사용하는 것은 람다식과 함수형 언어의 위력과 장점을 이해하는 좋은 방법이다.
언어의 분리
Tom의 견해는 컴퓨터 과학의 네 토대(명령형, 객체지향, 함수형, 논리 기반의 프로그래밍)를 설명한 것이다. 이 네 개의 기법은 다시 두 가지 스타일, 기계적 언어(명령형과 객체지향)와 수학적 언어(함수형과 논리 기반)로 나눌 수 있다.
"기계적" 언어는 실행을 위한 연속된 단계 단계에 초점을 맞춘다. 객체지향 프로그래밍에서는 프로그램을 개발하는데 추가되는 귀찮은 작업을 컴파일러나 인터프리터가 하도록 하기 때문에 간단한 명령형 프로그래밍 보다 훨씬 강력하다.
"수학적" 언어들은 어떻게 문제를 풀 것인지를 이론적 모델과 구체적인 실행 모델로 시작한다. 이런 식으로 접근을 하는 네 가지 언어를 예로 들면 정규표현식, SQL, make 그리고 물론 Haskell이 있다.
정규표현식은 스트링 매칭을 위해 오토마타 이론을 사용한다. "^(NYC?|New York( City)?)$" 과 같은 정규표현식 보다는 4개의 스트링 "NY", "NYC", "New York", "New York City”로 쓰는 것이 훨씬 쉽다. 더구나 괜찮은 정규표현식 라이브러리는 최적화된 검색으로 항상 각 문자마다 오직 한번만 조사하는데 반해 손으로 작성한 최적의 스트링 검색은 반복해서 스트링을 매치시켜야 하기 때문에 훨씬 어렵다.
SQL은 데이터 베이스의 데이터를 검색하기 위해서 관계 대수학을 사용한다. SQL에서는 단 한 문장으로 많은 테이블과 많은 검색 조건 그리고 여러 계산된 표현식을 참조할 수 있다. 일반적인 관계형 데이터베이스 엔진은 색인된 데이터를 얻기 위해 쿼리 최적화기를 사용한다. 색인을 참조하면 쿼리에 대한 결과를 구할 때 일의 양을 줄일 수 있다. SQL과 관계형 데이터 베이스를 사용하면 디스크에 저장된 데이터 테이블의 입출력을 관리하는 여러 페이지 짜리 코드를 작성하는 것과 같다. 이러한 동작을 매우 간단한 SQL 문장으로 처리 할 수 있지만 처리 과정은 훨씬 느리다. 복잡한 SQL문장 역시 비교적 이해하고 작성하기 쉬운 반면 코드로 작성한다면 힘들게 여러 페이지 짜리 코드로 작성해야 한다. 뿐만 아니라 에러가 있을 수도 있고 최적화 하는 작업도 만만치 않으며 이해하기도 어렵다.
Make와 이와 유사한 ant, nant, rake 같은 툴 들은 소프트웨어 프로젝트를 생성하기 위해 일련의 명령파일을 활용한다. 내부적으로 make는 이 명령파일을 읽고 기초적인 그래프 이론을 활용하여 파일에 적힌 코드 간의 종속관계를 파악한다. Make는 make test, make docs, make all, make debug와 같은 목적에 맞는 구문이 포함되어 배포된다. 이들은 프로젝트 재생성을 위해 필요한 종속 그래프를 분석하고 생성 과정에서 확실히 필요한 부분만을 컴파일 한다. 이 과정은 수작업에 의해 작성된 스크립트로 처리하는 것보다 유지하기 쉽고 더 믿을 만하다. 추가적으로 make 는 멀티프로세싱 모드로 동작할 수도 있다. 생성 작업을 빠르게 하기 위해서 여러 독립적인 일을 동시에 처리할 수 있다. 이 점은 수작업으로 만든 Build 스크립트로는 거의 불가능한 일이다.
Haskell 역시 프로그래밍 언어 카테고리에 속하며, 람다식이 그 토대가 된다. make, SQL, 정규표현식은 모두 거대한 프로젝트 안에서 상황에 맞는 문제를 해결하기 위한 제한된 용도로 사용되는 언어이다. Haskell은 범용적인 언어이며 어플리케이션과 대형 시스템을 설계하고 구축하는 종류의 작업도 수행할 수 있다.
Haskell과 프로그래밍의 미래
오늘날 소프트웨어 개발은 변곡점에 와있다. 최근까지 대다수의 소프트웨어는 한 머신의 한 프로세서에서 동작하도록 작성되어왔다. 요즘의 소프트웨어는 점차 다수의 CPU 코어를 사용하거나 네트워크 안에서 다수의 노드를 포함하는 멀티프로세싱을 충족하도록 작성되어야 한다. 요즘 사용되는 많은 언어와 환경은 아직 한 머신과 한 프로세서에서만 잘 동작한다.
우리는 개발자로서 점차 강력해지는 컴퓨터의 힘을 이용하기 위해 어떻게 소프트웨어를 개발해야 하는지는 공통된 의문이다. 가장 흥미로운 세가지 아이디어를 ghc에서 찾아 볼 수 있으니 빨리 방문해 보기 바란다.
멀티프로세싱 프로그램의 고질적인 문제는 전역적으로 공유되는 상태를 관리하는 것이다. Haskell은 모든 값을 상수로 만들어서 이러한 문제를 피할 수 있다. 다른 말로 하면, Haskell의 변수들은 바뀌지 않는다. 함수가 결과를 리턴 할 때면 항상 새로운 값을 만들고 가베지 컬렉터가 메모리의 생성과 제거를 맡아 수행한다. 함수의 결과는 호출자에게 되돌려 주고 결과적으로 프로그램의 동작을 변화시키는 전역 상태 같은 것이 없다. 상태 동작이 요구되는 프로그램이 Haskell에도 있긴 하지만, 프로그램의 다른 부분과는 엄격히 분리하여 관리 할 수 있다.
Haskell 프로그램은 "shared-nothing" 스타일을 자연스럽게 사용한다. 이 방법은 멀티 CPU와 멀티 네트워크로 확장할 수 있는 웹 어플리케이션을 작성하는 가장 뛰어난 방법과 동일하다.
멀티 프로세싱의 또 다른 문제는 자원 경쟁이다. 두 쓰레드가 동일한 공유자원을 얻기 위해 서로 경쟁한다. 한가지 방법은 세마포어와 락을 사용하는 것이다. 그러나 안타깝게도 쓰레드와 락을 관리하는 것은 말썽의 소지가 많아 잘못되기 십상이다. 또 다른 접근은 아토믹 트랜잭션을 사용하는 것이다.
데이터 베이스의 엔진은 10명의 사용자가 동시에 업데이트 했을 때 정상적으로 동작하도록 트랜잭션을 사용한다. 현재까지도 이런 친숙한 개념들이 데이터 베이스 영역을 벗어나서 소프트웨어를 개발하고자 하는 개발자에게는 허용되지 않는 부분이었다. 이 모델은 소프트웨어 트랜잭션 메모리와 STM을 지원하는 STM 라이브러리로 이제 ghc에서 가능하다. STM은 다른 언어에도 서서히 적용되고 있다.
세 번째 문제는 한 머신에서 다수의 CPU를 효과적으로 사용하도록 다루는 것이다. 몇 가지 “황당한 병렬처리” 문제는 하나의 CPU에서 사용되는 순차적인 명령들을 통해서 쉽게 작성할 수 있다. 분할된 일을 하나의 머신에 있는 다수 CPU로 처리하도록 코드를 작성하는 것은 여러 가지로 숙고해야 하는 훨씬 어려운 일이다.
이러한 상황을 해결하기 위한 작업이 ghc에서 진행 중이다. Data Parallel Haskell extension은 병렬 계산의 형식을 Haskell 프로그램으로 통합한 것이다. 이 확장은 프로그래머가 한 머신의 여러 CPU를 병렬화된 연산으로 이용하는 장점을 얻게 한다. 이것은 프로그래머가 일상적인 Haskell 프로그램을 작성하게 하며, 어느 부분이 “황당한 병렬” 인지 파악 하게 하고 투명하게 병렬화 된다. Data Parallel Haskell은 현재 진행 중 이며 ghc 개발팀에 의해 활발하게 연구되고 있다.
분명히 Haskell은 중요한 언어다. 당신의 주의를 끌만한 자격이 있다.
이 시리즈의 다음 파트에서는 Haskell의 두 가지 독특한 특징인 function과 monad에 대해서 다루도록 하겠다.
저자 Adam Turoff 는 오픈 소스를 이용한 WEB backend 시스템 분야에 12년간의 경력이 있는 소프트웨어 개발자이다. 그는 프로젝트에 Perl, Ruby, Rails, Haskell과 기타의 툴들을 혼합해서 사용한다.
역자 김현우님은 알고리즘과 바이오인포메틱스 분야에 SCI 논문을 포함한 다수의 논문을 게재 하였으며, 정보과학회와 한국생명정보학회에서 우수논문발표상과 IBM Basic Research Award를 수상하였습니다. 현재는 LG전자에서 디지털 방송관련 애플리케이션을 개발하고 있습니다.