안윤호님이 2007년, 2008년 작성하신 문서. SICP에 대한 소개라고 볼 수 있다.
해커 문화의 뿌리를 찾아서
저자 안윤호
Part1: 리스프가 탄생하기까지
2007년 4월 3일
'마법사 책'이라 불린 SICP
SICP(Structure and Interpretation of Computer Programs)는 한때 미국 주요 대학들의 컴퓨터학과 필수 과정 중 하나였다. 책은 매우 어려운 편이고 책의 연습문제라는 것들에는 우수한 컴퓨터 프로그래머들이 고민하던 문제들이 포함되어 있다. 그리고 다른 책과는 그 구성이 너무 다르다. 끝부분에 가면 메타 서큘러 실행기를 거쳐 언어의 컴파일러인지, 하드웨어의 컴파일러인지 경계가 애매한 지경에까지 이른다. 학생들이 당혹해 할 것은 틀림없다. SICP는 컴퓨터 교육의 패러다임을 바꾸어 놓았다는 평을 듣기도 한다.
그림 1. 마법사 책이라고 하는 SICP의 표지.
그림에 나온 람다(lambda)와 apply-eval 개념은 책을 관통하는 주제다. 책은 http://mitpress.mit.edu/sicp/ 에 온라인 문서로 공개되어 있다.
그래서 SICP에 대해서는 많은 사람들의 의견이 반반으로 갈린다. 생각하고 배울 점이 많은 책이라는 의견과 배울 것도 없으면서 시간만 낭비하게 하는 괴상한 책이라는 견해다. 한때 이 책을 필수 학점의 교재로 사용하던 학교에서는 학생들이 과정을 따라가는 일 자체에 공포심을 느꼈으며 불평도 대단했다. 시종일관 생각하게 만든다는 것이 공포심을 갖게 하는 요인이었다. 필자 역시 처음에는 책이 너무나 이상하다고 생각해서, 보다가 두 번이나 집어 던진 기억이 있다. 필자는 강의를 들은 것이 아니라 혼자서 공부했기 때문에 책의 배경을 이해하지 못했다. 책에 대한 재평가는 SICP와 관련된 메모들과 문서들, 그리고 문서들의 역사적인 맥락을 조금이나마 알게 되면서부터다. 그리고 컴퓨터에 대해 다시 생각하게 되었다.
책에 대해 불평하던 사람들 가운데 나중에 이 책을 다시 보거나 재평가하는 사람들이 꽤 있다. 그 중에는 조엘 스폴스키(Joel Spolsky)도 있었다. 조엘은 조엘 온 소프트웨어(Joel On the Software)라는 블로그로 유명한 사람이다. 조엘의 글 중에 ""은 미국 대학의 컴퓨터 학과가 너무 쉬운 것들만 다루려고 해서 수준 저하가 올 것이라는 내용이었다.
조엘 자신이 나이를 먹었다는 확실한 징조가 "요즘 아이들이" 어려워 보이는 것들을 하려고도, 할 수 있다고도 생각하지 않는다는 현상이라고 했다. 컴퓨터 학과에서 자료구조와 함수형 언어 같은 것을 심도 있게 가르치지 않기 때문에 평이한 학생들만을 만들어 내며, 학생들은 교과 과정이 어렵지 않기 때문에 많은 생각을 하지 않고 졸업한다. 그래서 평범한 프로그램만 만들어 내는 평범한 프로그래머만이 나올 것 같다는 내용이다(예전에는 자료구조나 함수형 언어 같은 것이 너무 어려워 고교 시절까지 스스로 우수하다고 생각했던 학생들이 자신의 능력을 진지하게 생각하고 과를 바꾸는 경우가 있었다는 것이다. 과정 자체가 사람들을 솎아내는 기능이 있었다).
이렇게 어려운 교과 중 하나가 MIT 학부 과정의 SICP다. 앞서 말했듯이 과거에는 주요 대학들의 표준 교재였다. 조엘은 블로그에서 이렇게 말하고 있다
"코스의 난이도는 경이적이다. 처음 5번 정도의 강의에서 스킴(Scheme)의 대부분을 배우며 그것으로 다른 함수를 입력으로 받는 fixed-point 함수를 배울 준비가 끝난다. 펜실베니아 대학의 CSE121에서 나 자신이 이런 과정과 씨름할 때, 전부는 아니더라도 대부분은 강의를 따라갈 수 없었다. 교재는 너무 어려웠다. 교수님에게 이런 과정은 부당하다는 긴 하소연 편지를 썼다. 학교의 누군가가 내 말(아니면 다른 불평자)을 들은 것이 틀림없다. 왜냐하면 이 과정은 이제 자바로 진행되기 때문이다. … 차라리 이런 불평을 듣지 않았으면 좋았을 것이다. … 이제 전공과목에서 4.0을 받느라 머리를 쓸 필요가 없어진 것이다."
아마존의 서평은 책의 내용이 좋다('great'와 'excellent' 정도의 표현은 흔하다)는 평으로 도배되어 있다. 책의 내용이 지나치게 어렵다는 것을 불평하면서도 그렇다. 사람들이 지적하는 불만 사항은 현실적인 면에 관한 것이다. 이 책은 실전에는 바로 도움이 되지 않는다. 서평 목록 처음에 나오는 피터 노빅(구글의 검색엔진 품질 책임자로 norvig.com 에 가면 리스프에 대한 많은 글들을 볼 수 있다)이나 폴 그레이엄(비아웹의 창업자로 '해커와 화가'라는 책을 썼다. www.paulgraham.com 에 가면 역시 리스프에 대한 많은 글이 있다) 같은 사람들은 SICP에 대해 극찬 일색이다. 두 사람은 리스프의 중요한 옹호자이고 리스프 세계에서 SICP는 중요한 교과서다. 두 사람 모두 리스프에 대해 중요한 책들을 출판했지만 SICP는 아주 중요한 책으로 평가된다.
SICP의 문화 코드, 그리고 리스프
사람들이 이렇게 생각한다면 SICP는 나름대로 중요한 의미를 지닌다. 이유는 간단하다. 사람들이 중요하다고 생각하는 그 무엇이 있기 때문이다. 책에는 생각할 내용이 많은 것이다. 고전으로 평가 받는 이유는 책이 담고 있는 내용도 있지만 책을 주제로 담론을 전개할 수 있기 때문이기도 하다. 그렇다면 책이 어려워도 고생해가며 읽을 가치는 충분하다고 볼 수 있다. 물론 아닐 수도 있다.
SICP는 한글판으로도 출판된다. 필자도 부분적으로나마 번역 과정에 참여했다. 한글 번역의 문제점과 장점을 다 같이 갖고 있는 SICP의 한글판은 컴퓨터의 고전이 번역되는 것으로 볼 수 있고 중요한 의미를 갖는다. 아무래도 한글판이 나오면 한 명이라도 더 보게 되어 있다. 이를테면 예전에 Computer Organization and Design 같은 고전이 번역되자 사람들이 더 많이 읽고 더 많은 과정에서 교재로 도입했다.
그러나 SICP를 교재로 사용하고 있는 과정은 아무래도 줄고 있다. 조엘이 말한 것과 같은 맥락이다. 그러나 컴퓨터의 중요한 고전을 독학으로 공부하는 것도 가능하다. 이미 학부를 졸업한 사람들이나 관련이 없는 학과를 나온 경우에는 독학 이외에는 방법이 없다(필자 역시 독학 내지 흥미로 공부하고 이 글을 쓰고 있다).
SICP가 쉽다거나 어렵다거나 하는 문제는 교사와 학생의 문제일 수도 있으며(예상보다 높은 수준의 교사가 필요할 것이라는 것이 개인적 의견이다) 리스프나 스킴 자체의 문제일 수도 있다. 커리큘럼이 너무 어렵고 격렬하다고 하여 HTDP(How to Design Programs)와 같은 교과 과정도 있다. 그러나 분명히 이런 격렬함이 이 책을 다른 책과 다르게 만든 그 무엇이기도 하다. 이상한 주입 과정과 사고 과정을 거치면서 분명히 어떤 변화가 나타난다. 그러나 우수한 선생이나 대가에게 배울 수 없다면 우수한 학생 또는 대가학생이라는 방법도 가능하다. 학생이 목표를 선정하고 이리저리 공부하는 방법을 고안하면 된다. 실패율은 높지만 배우는 것도 많다. 아무튼 리스프나 SICP를 배우고 이해하거나 짜보고 싶은 프로그램들이 있었으므로 해봐야 할 과정이었다.
필자는 이 책을 독학하기 위해 여러 가지 자료를 살펴보아야 했고 약간의 시간을 낼 수 있어서 여러 가지를 구경할 수 있었다. 그래서 이번에 쓰는 글은 아마추어 해커의 관점에서 해커 사상의 원류라고 할 수 있는 리스프와 리스프의 중요한 파생 언어 중 하나인 스킴을 살펴보는 것이다.
필자는 컴퓨터로 먹고 살지 않는다. 진지하기는 하지만 생업은 아니다. 아마추어다. 덕분에 하고 싶은 것들을 하고 있어도 누가 뭐라고 하지 않는다. 적극적으로 보자면 컴퓨터는 필자에게 중요한 문화 탐험의 하나다. 그래서 도대체 SICP라는 책을 만들기 위한 문화코드와 재료가 무엇인지, 그리고 역사(history)와 사람들의 이야기(biography)가 어떤 것인지가 더 중요하다. 이런 날줄과 씨줄로 리스프의 문화 코드가 만들어진 것이기 때문이다. 그래서 필자의 이야기는 완전한 전문가의 이야기도 아니며 그저 진지한 아마추어의 이야기 정도로 파악해주면 좋을 것이다.
그래서 SICP라는 책을 해설하는 것이 아니라 SICP를 만든 자료들과 근거들이 어떠한 맥락에서 어떤 경로를 거쳐 발전해 왔는가를 보는 것이 필자의 접근법이다(이러한 접근법은 시간이 조금 더 든다).
SICP의 그 이전에 리스프라는 더 거대한 덩어리가 있었다. 여기에도 문서들이 보존되어 있다. 필자는 코드만 보고 '아하!' 하고 모든 것을 이해하는 천재가 아니기 때문에 이것저것을 살펴보아야 하고 문서가 있으면 다 이해하지는 못하더라도 읽어보려고 했다. 때로는 코드만 보는 것보다는 더 이해가 빠를 수도 있겠다. 자칫하면 재미가 없을 수도 있으므로 접근방법을 미리 설명하는 것이다. 그러니까 필자의 글은 어려운 SICP를 이해하기 위한 주변 자료들을 제시하고 나름대로 설명해 보고 싶은 것이다. 실제로 리스프나 스킴이 오랜 세월 동안 진화해 왔기 때문에 역사성을 무시하지 못한다. 그래서 역사적 자료와 문헌들을 사람들의 이야기와 몇 줄의 코드에 섞어서 이야기할 수밖에 없다. 교양과목들처럼 말이다.
거슬러 살펴본 리스프의 탄생
SICP는 개정 과정을 몇 차례 거쳤는데 SICP는 리스프가 아니라 리스프의 방언인 스킴으로 되어 있다. 그리고 스킴을 만든 사람은 가이 스틸(Guy Steele)과 제럴드 서스만(Gerald Sussman)이다. 그 전까지의 리스프와 스킴의 차이점이라면 스킴은 칼 휴이트(Carl Hewitt)의 액터 모델(actor model)을 구현하기 위해 만들었으며 그 이전의 다른 리스프 구현의 전통을 이어받았다는 점이다.
그런 노력의 와중에서 전통적인 리스프에 대한 심각한 의문 제기가 있었고 일련의 사고 과정은 람다 페이퍼(lambda paper)라는 이름으로 나타난다. 결국 여러 편의 논문들이 나오고 SICP라는 책으로 만들어지기까지는 10년의 세월이 필요했다. 1970년대 초반부터 작업이 시작되어 1980년대 중반에야 책으로 나온 것이다.
그림 2. 리스프를 발명(또는 발견)했다고 하는 존 매카시
이른바 이들이 발표하는 람다 페이퍼라는 것을 사람들은 좋아하기도 했고 싫어하기도 했다(글들은 http://library.readscheme.org/page1.html에 있다). 그 중 "The Art of the Interpreter of, the Modularity Complex(Parts Zero, One, and Two)"라는 유명한 문서가 많은 사람들에게 영향을 주었다. 그 외에도 "Lambda: The Ultimate Imperative"와 "Lambda: The Ultimate Declarative", 그리고 "Debunking the 'Expensive Procedure Call' Myth, or, Procedure Call Implementations Considered Harmful, or, Lambda: The Ultimate GOTO"라는 글들도 유명하지만 사람들은 이 글들을 별로 좋아하지 않았던 것 같다. "Lambda: The Ultimate X" and "X considered Harmful"이라고 풍자하는 글들도 있었다고 한다.
중요한 내용이라 언급하지 않을 수 없는 액터 모델에 대해서도 설명하면 좋겠지만 지면상 불가능하다. 일단 위키 백과의 소개 글 정도면 큰 그림을 이해하는 데는 충분할 것이다. 휴이트와 대화하면서 람다가 액터와 같다는 것을 확인한 서스만은 정말로 좋아했다고 한다. 나중에 앨런 케이(Alan Kay)와 휴이트가 만나면서 스몰토크(smalltalk) OOP(Object-Oriented Programming)의 메시지 모델과 액터 모델은 서로 많은 영향을 주고받았다.
1970년대 스킴이 람다 페이퍼를 중심으로 사람들의 관심과 비난을 한데 얻던 시절, 서스만과 함께 스킴에 대한 연구를 진행하던 가이 스틸은 당시를 아주 재미있던 시절이라고 나중에 회고했다. 스틸은 당시 대학원을 다녔다. 석사 과정에 다니는 학생이 젊은 교수와 함께 언어의 중요한 틀을 만든 것이다. 스틸은 나중에 스킴 컴파일러에 대한 논문을 쓰고 D. Hillis의 Thinking Machine으로 자리를 옮겼기 때문에 SICP가 출판될 때는 동료인 Harold Abelson이 공동 저자로 되어 있다(가이 스틸은 현재 썬(Sun)의 연구진으로 있다). 기존의 리스프에 대해 문제점을 제기한 가이 스틸은 나중에 CLTL(Common Lisp The Language)라는 리스프 드래프트의 작성자가 되었다. 드래프트를 만들며 리스프의 많은 구현들의 장단점을 취합했다. 그만큼 실력이 있었다(스틸은 C 표준안과 자바 표준 그리고 포트란의 표준안을 작성했거나 위원회의 주요 멤버이기도 했다).
스킴이 그전까지의 리스프와 중요한 차이를 보인 것은 람다에 대한 중요성을 부각시키고 람다의 행동에 대한 엄밀한 분석을 이룬 것이다. 테일 리커전(tail recursion)이나 렉시컬 스코프(lexical scope)와 같은 것도 중요한 차이점이다. 람다에 대해 생각한 것은 앞의 오리지널 람다 페이퍼라는 문서들이 바로 그 증거이며 SICP에는 이 문서들의 내용이 녹아 들어있다. 진지한 독자들이라면 호기심으로라도 람다 페이퍼들을 살펴볼 필요는 충분히 있겠다.
튜링 머신과 람다 계산법
컴퓨터 역사에서 리스프 초기 해커들은 1세대 해커에 속한다. 바로 스티븐 레비의 "해커"에 나오는 사람들이다. SICP의 저자인 제랄드 서스만 역시 1960년대 중반에 이 문화권 속에 들어와 있었다. 스티브 러셀(Steve Russell)이나 다른 해커들과 같이 구현된 지 얼마 되지 않은 리스프와 PDP 컴퓨터를 가지고 기계들과 하나가 되어 생활했다. 이들에게 있어서 리스프와 MIT의 AI 연구소는 하나의 도약대였다. 그리고 해커 문화를 탄생시켰다. AI 연구소는 자유롭게 프로그래밍을 할 수 있는 장비와 분위기를 제공한 최초의 장소였기 때문이다. 그래서 컴퓨터의 역사에서 리스프의 위치는 매우 중요하다("A Marriage of Convenience: The Founding of the MIT Artificial Intelligence Laboratory")라는 문서가 있다).
리스프가 하나의 중요한 언어가 될 수 있었던 것은 수학적인 아이디어의 표현에서 뛰어났기 때문이다. 그것은 우선적으로 람다와 리커전이었다. 시작은 조금 묘하며 컴퓨터의 시작에도 관련이 있다.
조금 더 역사를 거슬러 올라가면 리스프라는 언어를 만든 매카시가 알론조 처치(Alonzo Church)의 제자였다. 처치는 미국의 수학자이자 논리학자였다. 처치의 제자 중에는 뛰어난 사람이 많았다. 컴퓨터의 시작이라고도 하는 앨런 튜링(Alan Turing)도 처치의 제자였다. Stephen Cole Kleene이나 John George Kemeny도 처치의 제자다.
컴퓨터의 시작인 수학적인 문제를 기계적으로 푸는 문제는 튜링에 의해 튜링 기계로 알려져 있다. 이것은 1936년 튜링이 발표한 "On Computable Numbers, with an Application of Entscheidungs Problem"이라는 애매한 제목의 논문에서 표면으로 나오게 되었다. 튜링은 당시 중요한 수학적 문제였던 entscheidungs problem에 태클을 건 것이다. 이 문제는 수학자 데이비드 힐버트가 1928년 제시한 것이다. 적어도 이론상으로 주어진 수학적 주장이 증명 가능한 것인지 판단할 수 있는 명확한 프로시저가 있는지에 대한 문제다. 이런 문제는 튜링과 같은 사람을 위한 문제로 증명의 답은 "그렇지 않다"는 것이었다. 처치 역시 같은 결론에 도달한 논문을 1936년에 발표했다. 프로시저의 기계화에 대해 눈이 뜨인 것이다.
프로시저라는 의미를 더 명백하게 하기 위해 튜링은 LCM(Logical Computing Machine)이라고 부르는 추상적인 기계를 발명했다. LCM은(다른 사람들은 튜링 머신이라고 불렀다) 명령과 데이터를 담은 종이테이프를 갖고 있고 테이프를 따라 움직이는 헤드가 정해진 규칙에 따라 명령을 읽고 해석하며 테이프에 새로운 것을 기록할 수 있는 기계다. 이런 종류의 기계는 진술한 내용의 진위를 테스트하는 프로시저를 따라할 수 있다. 같은 시기 프린스턴 대학에는 폰노이만도 있었다. 문제를 푸는 기계에 대한 튜링의 생각은 자연스럽게 일반적인 컴퓨팅 기계를 만드는 쪽으로 흘렀다. 튜링은 "Proposal for Deveolpment in the Mathematical Division of an Automatic Computing Engine"이라는 제안서를 영국의 국립 물리학 연구소(NPL)에 제출했다. 그 후 에니악(ENIAC)이 나오고 다시 폰노이만에 의해 프로그램 가능한 전자식 디지털 계산기가 나왔다.
그러나 처치가 같은 문제에 동원한 수단은 람다 계산법(lambda calculus)이었다. 람다 계산법 자체가 계산 가능한 함수를 다루는 것이므로 그 자체를 가장 단순한 범용 컴퓨터라고 생각할 수 있고 람다 계산법이 나온 지 20여 년이 지난 후에 매카시는 람다 계산법을 새로운 컴퓨터 언어에 도입할 것을 고려했다. 튜링 머신이 컴퓨터라는 기계에 영향을 주었다면 람다 계산법은 컴퓨터 언어에 영향을 주었다고 볼 수도 있다.
매카시의 유명한 글 "Recursive Functions of Symbolic Expressions and Their Computation by Machine"은 1960년 4월에 작성되었다. 그 이전에는 몇 개의 메모들과 편지들이 남아있다. 이 글이 바로 리스프의 시작이라고 봐도 좋다.
매카시의 아이디어는 MIT의 AI 연구소에서 허망할 정도로 빨리 하나의 프로그래밍 언어로 만들어졌다. 처음에는 종이에 적던 핸드 컴파일 수준의 작업이 하나의 아이디어로 만들어지고 그 다음에는 스티브 러셀에 의해 실제로 컴퓨터 프로그램으로 만들어진 것이다. 리스프는 이렇게 갑자기 만들어진 것이다. 람다 함수를 이야기하면 독자들이 질릴 것 같아서 실제로 리스프가 어떻게 구현되었는가를 보여주는 것이 재미있을 것 같다. 실제로는 SICP 4장에 나오는 내용을 아주 간단하게 미리 설명해보자는 것이다.
그렇다면 매카시가 만들었다는 리스프라는 것이 도대체 무엇인가. 매카시가 사용했다는 IBM 704가 없으니 오늘날의 PC로 만들어 보는 수밖에 없다. 실제로 폴 그레이엄이 이런 실험을 했다. 폴 그레이엄이 책에 수록하지는 않았으나 인터넷에서 볼 수 있는 The Roots of Lisp라는 글은 매카시가 쓴 논문을 오늘날 우리가 사용하는 커먼 리스프(Common LISP)로 구현한 것이다. 그것은 바로 원시적인 인터프리터가 얼마나 쉽게 구현될 수 있는지를 보여주는 것이다. 인쇄하면 A4 한 쪽도 안 되는 소스코드로 리스프 인터프리터가 구현될 수 있다(물론 기계어로 구현하는 것은 더 복잡하지만 불가능할 것도 없다. 파이썬이나 자바로도 리스프 인터프리터를 작성할 수 있다).
원시적인 인터프리터를 만드는 것은 정말 쉽다. 여기서 조금씩 덧붙이면 SICP의 인터프리터가 된다. 메타 서큘러 인터프리터라고 하는 것으로 리스프가 수행되는 기계가 있다면 리스프를 돌려보는 인터프리터로 귀착된다. 그리고 이 코드는 빈약하기 그지없던 1960년대의 하드웨어로도 잘 수행되던 코드다. 이 간단한 코드가 코드를 만들고 그 코드가 코드를 또 만들어낸 것이다. 인공지능의 복잡한 코드들도 간단한 인터프리터에서 시작된 것이다. 이 인터프리터의 모든 식은 일곱 개의 간단한 원시 연산자로 해결할 수 있다. 리스프의 식을 리스트로 표현하면 가장 먼저 나오는 식은 연산자(operator)이며 다른 요소는 인수(arguments) 또는 피연산자(operands)라고 생각할 수 있다. 연산자로 quote
, atom
, eq
, car
, cdr
, cons
, cond
를 사용할 수 있으면 원리적으로 리스프 인터프리터를 만들 수 있다.
(quote x)
는x
를 되돌리며'x
와 같다.(atom x)
는x
가 아톰이라는 기본형의 원소이거나 빈 리스트이면t
를, 아니면()
를 되돌린다(t
는 참을 의미하고()
는 거짓을 의미하는 값이라고 하자).(eq x y)
는x
와y
의 값이 같으면t
를, 아니면()
를 되돌린다.(car x)
는 리스트x
의 첫 값을 되돌린다.(cdr x)
는 리스트x
의 첫 값을 제외한 나머지 값을 되돌린다.(cons x y)
는x
로 시작하고 리스트y
의 값들이 따라오는 리스트를 돌려준다.(cond (p1 e1) ... (pn en))
은p1
부터 시작하여p
로 시작하는 식이 참이 나올 때까지 계산한다.- 만약 참이 나오면 해당하는 식
e
를 전체cond
의 값으로 되돌려준다.
이게 다인가? 다는 아니지만 이 연산자들만으로 인터프리터를 만들 수 있다. 다음 회에는 앞의 식들에 간단한 설명을 붙이고 진짜 인터프리터를 만들어 수행을 시켜보겠다.
Part2: 원시 리스프의 재구성
2007년 5월 8일
들어가며
리스프(LISP) 개발 초기 역사에 대해 중요한 문서는 매카시 자신의 History of LISP이며 다른 하나는 H.Stoyan이 정리해 놓은 리스프의 역사로 둘 다 매카시의 홈페이지(http://www-formal.stanford.edu/jmc/history)에서 찾아볼 수 있다.
History of LISP에서 매카시는 너무 가벼운 마음으로 일을 진행한 것을 한탄하고 있다. 튜링머신보다 리스프가 더 좋으려면 만능 리스프 함수가 있어야 하고 이 함수는 튜링머신으로 적는 것보다 더 깔끔하고 이해하기 쉬워야 했다. 이것이 바로 리스프 함수 eval[e, a]
이었다. 여기서 e
는 리스프 식이며, a
는 변수에 적어 넣을 값을 가지고 있는 리스트다. 매카시는 eval
을 만들면서 리스프 함수를 데이터처럼 나타내는 방법을 만들어야 했고 이 표기법은 표기를 위한 목적으로 생각했을 뿐 실제로 리스프 프로그램을 위해 사용되리라고는 생각하지 못했다. 매카시가 함수 eval
을 구현하기 위해 그리고 새로운 언어를 위해 할 일은 많았다. 일의 중요한 진척의 하나로 함수가 함수의 인자로서 사용될 수 있는 표기법을 논리적으로 완결하는 일도 필요했다. 매카시가 만든 일들 가운데에는 원래의 람다(lamda) 표기법에 없었던 것들도 있었다. 이를테면 Label 표기법 같은 것들이다.
Stoyan의 문서는 매카시의 리스프 문헌을 중심으로 재구성한 것이다. The Influence of the Designer on the Design -- J.McCarthy and Lisp라는 제목으로 찾아볼 수 있다.
지난 회에 설명한 리스프의 기본적인 논문은 매카시가 쓴 Recursive Functions of Symbolic Expressions and their Computation by Machine의 Part 1이었다(Part 2는 끝내 나오지 않았다). 이 글은 지금 봐도 참신함을 잃지 않을 정도로 잘 정리된 글이다. 몇 년에 걸쳐 다듬고 매만진 글이기 때문인지도 모른다.
매카시는 이 글과 그 전의 아이디어를 조수에게 보여주었다. 글을 읽던 매카시의 대학원생이었던 스티프 러셀(Steve Russel)은 예상보다 총명했다. 러셀은 eval
함수가 리스프의 인터프리터로 사용될 수 있다는 것을 깨달았다. 결국 리스프를 IBM 704에서 구현했다(나중에 Space War라는 첫 번째 컴퓨터게임을 만들기도 한다).
컴퓨터로 프로그래밍과 디버깅을 거듭하자 무엇인가가 나왔다. 그리고 사람들은 인터프리터로 만든 언어를 갖게 되었다. 갑자기 리스프가 구현된 것이다. 인터프리터가 예상치 못하게 빠르게 나옴에 따라 언어의 형태가 갑작스럽게 초기 상태에서 고정되었고 원래의 논문 'Recursive Function...'에서 별생각 없이 만든 결정들이 언어의 요소가 되었다. 그중에는 나중에 별로 좋은 생각이 아니었다는 것으로 밝혀진 것들도 있었으나 대부분은 그대로 살아남았다. 그만큼 리스프는 빠르게 수용되었다. 그리고 매카시 자신이 리스프가 가능한 것이라고 믿었던 것이 아니기 때문에 리스프를 발명한 것이 아니라 발견했다고도 말한다.
매카시는 History of LISP에서 당시 자신이 람다함수에 대해 알기는 했지만 불완전하게 이해하고 있었다는 사실도 인정했다. 처음에 리스프는 람다함수의 불완전한 표기법을 채택하고 있었고 점차 완전한 것으로 변해갔다. 이미 리스프가 만들어진 상태에서 매카시는 몇 가지를 고쳐보려고 했지만 때는 늦었다. 리스프를 실제로 사용하는 사람들과 리스프를 만든 매카시의 생각의 차이가 커져갔고 사용자 그룹의 힘이 더 커져감에 따라 매카시는 언어에 대한 통제를 포기하고 자신의 주제로 돌아가 버렸다. 하지만 리스프를 만든 것은 분명 매카시였다.
리스프 인터프리터의 구현
이번 회의 주제는 'Recursive Functions ...'을 폴 그레이엄이 요즘의 리스프로 다시 구성한 것을 설명하는 것이다. 즉 리스프의 재구성이다. 50년이 다 되어가는 옛날 리스프를 다시 구현해 본다는 것이 이상하기는 하지만 인터프리터를 살펴보는 일이 리스프를 이해하는 가장 좋은 방법이며 전통적인 방법이다.
리스프 개발은 사람들이 원시적인 인터프리터를 만들고 그 위에 다른 리스프를 구현하며 발전했다. 사실 리스프의 역사에는 어떤 리스프 인터프리터 위에 다른 리스프가 올라가고 또 다른 것이 그 개량판 위에 올라간 역사가 있다. 얼마나 복잡한 층위를 만들었는지는 아무도 모른다. 이런 방식의 인터프리터를 메타서큘러 인터프리터라고 한다. 처음엔 해커들은 자신의 리스프를 만들었고(라이브러리보다 언어를 만드는 편이 더 빨랐다. 간단했기 때문이다) 이들 중에서 남는 것은 점차 줄어들었다. 아무래도 효과적인 구현들만이 남았기 때문이다. 결국에는 몇 개로 줄어들었다. 하지만 요즘도 리스프 교재에서 메타서큘러 인터프리터를 만드는 일은 중요한 단계로 남아있다. 언어 자체의 이해가 증가하기 때문이다. SICP는 4장 전체를 인터프리터에 바치고 있다.
맨 처음 나온 리스프 인터프리터는 스티브 러셀이 IBM 704에 만든 구현이다. 그 구현은 펀치카드 방식의 IBM 704를 손으로 어셈블하면서 만들어졌고 당시의 자료들 또한 남아있다. 러셀은 나중에 엄청나게 번거로운 작업이었다고 회고했다. 펀치카드로 작업하는 배치 방식 코딩과 디버깅은 쉬운 일이 아니다.
얼마 후 리스프는 DEC의 PDP-1 컴퓨터로 이식되었다. PDP-1의 인터랙티브한 환경에서 리스프가 돌아간다는 것이 알려지자 곧바로 컴퓨터에 빠져 사는 사람들이 나오게 되었다. 이들이 바로 해커들이다. 해커리즘의 근본적인 도구는 매카시의 논문을 스티브 러셀이 작업(해킹)하여 탄생했고 DEC 초기 기계들을 중심으로 MIT에서 해커들이 해킹하는(다듬는) 형태를 통해 발전했다. 스탠포드나 BBN 같은 회사들에서도 중요한 리스프 변종들이 나오기 시작했다.
매카시에게 기계적 지능 구현을 위해 가장 중요한 것은 포멀리즘(형식주의)이었다. 수학의 형식을 빌어 알고리즘으로 문제를 푸는 일은 문제를 기계가 풀 수 있는 형식(표기법)과 기능을 만드는 일이다. 리스프는 중요한 진보였다. 동일하지는 않지만 처치의 람다 계산법을 사용하여 범용의 튜링머신을 만들어 낸 것이기 때문이다. 포멀리즘은 기계가 다룰 수 있는 형태로 표현할 수 있다면 기계가 반복적인 계산을 되풀이하여 정확한 답을 줄 수 있기 때문에 형식과 형식의 체계는 매우 중요하다. 또 이 형식주의는 사람 손으로 문제를 푸는 것보다는 기계에 더 적합한 형태의 연산이라는 것이 매카시의 생각이었다. 정확한 형식으로 표현할 수 있다면 기계가 못 풀 문제도 없다는 것이다.
초기 인공지능은 이렇게 소박한 기반 위에서 출발했다. 물론 기계가 풀 수 있을 정도로 간단한 형식으로 바꾸는 것이 쉽지 않다는 것을 발견하는 과정이 곧 뒤따랐다. 매카시가 차용한 람다 계산법이라는 것은 일종의 일반화된 함수의 치환으로 생각할 수 있다. 그런데 이 간단한 치환으로 많은 것들을 할 수 있다. 기호와 기호 표기의 포멀리즘이 지배하는 곳에서는 더욱 그렇다. 기계적으로 기호를 치환하는 것으로 아주 많은 일을 할 수 있다. 인공지능도 그런 일들 가운데 하나다.
인공지능의 또 다른 개척자 마빈 민스키는 프로그래밍의 다른 측면을 보았다. 앞으로 필요한 인공지능과 관련하여 프로그램들의 개발이 많이 필요할 것이며 이 프로그램들은 컴퓨터에 빠져있는 해커들이 아니면 만들어낼 수 없다는 것을 본 것이다. 민스키는 학자들의 엘리트주의나 권위주의적인 기업의 기술 문화가 아닌 해커 문화의 일면을 보았다. 해커들의 지성의 다른 측면이었다.
민스키는 자유방임적인 놀이터의 주인 역할을 자처했다. MIT의 인공지능 연구소에 투입된 자금과 장비를 이용해 해커들을 고용하고 이들이 마음껏 프로그래밍을 할 수 있는 환경을 만들었다. 해커들은 대학원생 출신이거나 다른 곳에서 들어오기도 했다, 급료는 높지 않았다. 오로지 해킹이라는 일 자체가 목표였고 컴퓨터를 마음대로 쓰는 것으로 동기는 충분히 높았다고 전한다. 이런 것들을 좋아하는 사람들에게 장난감을 던져주고 그들이 원하는 것을 하게 내버려두는 것이 민스키의 아이디어였다. 당시의 인공지능 연구소에는 할 일이 많았다. 이 놀이터에서 해커들은 마법사로 볼 수 있고 착한 놀이터 주인인 민스키가 부탁을 하면 무엇이든지 만들어 주곤 했다.
다만 해커들의 놀이에는 스스로 정한 엄격한 문화와 기준이 있었다. 당시로서는 이런 놀이터는 인공지능 연구소가 유일했다. 이들의 개성과 배경은 모두 달랐다. 이윽고 특이한 문화가 탄생했다. 그 특징의 하나인 강한 개성과 자유, 그리고 이들과 양립하는 고도의 지성이 있었다. 스티븐 레비의 『해커』라는 책은 당시의 분위기를 전한다. 이런 분위기를 유지하는 것이 얼마나 어려운가를 상상하는 것은 오늘날에도 어렵지 않다. 1960년대에는 요즘보다 더 어려운 일이었지만 해커들의 놀이터는 실제로 여러 해 동안 존재했고 고도의 지적 기준과 심미안, 몰입과 창조의 와중에서 프로그램들과 문화가 태동했다. 스티븐 레비에 의하면 이런 일들은 결국은 해커들의 자기 표현이었다. 일종의 창조적 예술이라고 본 것이다.
말이 길어졌지만 그 때 이들이 진지하게 사용했던 언어는 리스프였다. 지금으로 보면 초라한 하드웨어를 가지고 해커들은 이 리스프로 인공지능 연구소에서 원하던 것들을 (거의) 무엇이든지 만들어 주는 마술을 부렸다. 인공지능의 유명한 프로그램들이 빈약한 기계에서 리스프로 만들어졌다. 당시에는 뛰어난 사람들이 리스프에 빠져 있었고 리스프를 바탕으로 만든 언어들도 많으며 리스프에서 많은 영감을 받기도 했다. 리스프는 처음부터 언어라기보다는 수학적 표현이나 알고리즘에 더 가까웠던 것이다.
구현하기 전에 고려할 것들
이번 회의 주제가 매카시의 'Recursive Function ...'을 이해하는 것이므로 다시 원래 주제로 돌아가 보자. 지난 글은 7개의 기본 연산자를 만드는 것으로 끝났다. 정말 이 7개의 식으로 인터프리터를 만들 수 있을까? 이것이 이번 주제다. 답은 미리 말했듯이 "만들 수 있다"이다.
문제는 리스프에 접할 기회가 적었기 때문에 관심이 있다고 해도 리스프를 전혀 모르면 설명이 애매하다고 느낄 수 있는 부분이 있어 여기에 대해 약간의 보충 설명이 필요할 수 있다. 보충 설명을 위해 『A Gentle Introduction to Symbolic Computation』 이라는 훌륭한 책이 있다. 책의 앞부분을 읽고 그림을 보고 있으면 보조 자료로 충분하다. 하지만 필자는 가급적 설명을 쉽게 하려고 애쓸 것이다. Peter Siebel의 『Practical Common LISP』도 쉽게 읽을 수 있는 책이다. 이 정도면 역시 충분할 것이다.
그리고 리스프를 실행할 수 있는 적당한 환경이 있어야 한다. 요즘은 LispWorks나 Franz Lisp 같은 곳에서 윈도우와 리눅스용 리스프를 다운로드할 수 있으므로 문제가 될 것이 없다. 그 외에도 많은 리스프 구현이 있으며 소스까지 공개된 것들도 있다. 하지만 이번 설명에서 반드시 리스프가 필요한 것은 아니다. 종이와 연필로도 풀어볼 수 있다.
리스프에서 식(expression)이 리스트일 때 첫 번째 요소가 연산자(operator)이면 나머지 요소들은 인자(argument)로 작용한다. 이를테면 2+3은 (+ 2 3)
으로 표시한다. 연산자는 +
이고 2
와 3
은 인자인 것이다. 먼저 지난번에 설명한 7개의 연산자를 다시 적어 보자.
(quote x)
는x
를 되돌리며'x
와 같다.(atom x)
는x
가 아톰이라는 기본형의 원소이거나 빈 리스트이면t
를, 아니면()
를 되돌린다(t
는 참을 의미하고()
는 거짓을 의미하는 값이라고 하자).(eq x y)
는x
와y
의 값이 같으면t
를, 아니면()
를 되돌린다.(car x)
는 리스트x
의 첫 값을 되돌린다.(cdr x)
는 리스트x
의 첫 값을 제외한 나머지 값을 되돌린다.(cons x y)
는x
로 시작하고 리스트y
의 값들이 따라오는 리스트를 돌려준다.(cond (p1 e1) ... (pn en))
은p1
부터 시작하여p
로 시작하는 식이 참이 나올 때까지 계산한다. 만약pi
에서 참이 나오면 해당하는 식ei
를 전체cond
의 값으로 되돌려준다. 끝까지 참이 나오지 않으면 빈 리스트를 되돌린다.
먼저 2번의 atom
이라는 연산자를 살펴보자. 리스프에서 어떤 식이 atom
이라는 것은 리스트가 아니라는 것을 의미한다. 기호 아톰(atomic symbol)은 어떤 기호가 atom
의 성질을 갖는다는 것을 의미한다. 또한 리스프의 S-식(S-Expression)을 다음과 같이 정의한다. 우선 S-식의 표현을 리스프에서 의미를 부여한 기호인 ( . )
를 사용하여 나타내기로 하자.
- 기호 아톰은 S-식이다.
- 만약
e1
과e2
가 S-식이라면(e1 . e2)
도 S-식이다.
정의는 A
, B
, AB
와 같은 기호는 당연히 S-식이다. 그러므로 정의 2에 의해 (A . B)
도 S-식이며 ((AB . C) . D)
도 기호식이다. 그러므로 리스프에서는 기호 아톰과 리스트 두 종류의 S-식 형태만이 존재한다. 따라서 (atom x)
가 참이면 x는 리스트가 아닌 S-식, 바로 기호 아톰이다.
이제는 4, 5, 6번을 조금 자세히 살펴보자. 이들은 리스트를 만들고 리스트를 조작하는 핵심적인 기능을 한다. 리스프가 LISt Processing이라는 것을 생각하면 핵심적인 조작이다. 모든 일들은 CONS 셀(Construct Cell)이라는 자료구조를 중심으로 일어난다.
그림 1. cons 셀
CONS 셀의 왼쪽은 CAR라고 부르며 오른쪽은 CDR이라고 부른다. 리스트 구조와 리스트로 나타내는 식의 의미는 매카시의 'Recursive Function...'를 보아야 할 것이나 여기서는 이것으로 충분하다(초기 IBM 704와 그 후속 기종은 36비트로 15비트씩을 CAR와 CDR에 할당했다. CAR와 CDR은 어셈블러의 매크로 함수 이름이었다고 한다).
가장 기본적인 식은 CONS다. CONS는 두 개의 인자를 취해 이들을 연결한다. 그래서 (cons 1 2)
는 (1 . 2)
를 리턴한다. 앞의 cons 셀에서 car는 1
이고 cdr은 2
이다. 따라서 다음과 같은 식이 가능하다.
리스트는 다음과 같이 표현된다. 이를테면 3개의 요소로 구성된 리스트 (1 2 3)
이 있다고 하자. 그러면 이 리스트의 실제 모양은 아래 그림과 같다.
>
그림 2. 리스트 (1 2 3)
(1 2 3)
을 만들고 조작하는 방법은 재귀적이다.
위 식의 car를 구하면 다음과 같다.
그림에서 상상할 수 있듯이 리스트 (1 2 3)
의 car는 1
이다. (1 2 3)
의 cdr을 구하면 다음과 같다.
첫 번째 박스의 CDR이 가리키는 포인터는 2와 3의 리스트인 것이다. 앞 식의 car를 다시 구한다면 다음과 같다.
이런 식으로 문제를 해결한다. 함수형 스타일(functional style)이다. 이보다 더 복잡한 것들도 재귀를 이용한 함수형 방식으로 처리할 수 있고 인터프리터가 만들어내는 복잡한 치환도 마찬가지다. 복사도 할 수 있고 리스트를 뒤집을 수도 있다. 리스트 안의 리스트와 같은 중첩된 표현도 가능하다. 위의 car와 cdr의 조합들은 많이 사용되는 것들이라 아래와 같은 표기법으로 사용되기도 한다.
Root of LISP에 나오는 리스트 예제들도 간단하다.
마찬가지로 다음과 같다.
하나 더 남아있다. list라는 연산자를 이용하는 것이다. (list e1 ... en)
은 결국 (cons e1 ... (cons en '()) ... )
과 같은 형식이다. 예를 들면 아래의 두 식은 같은 값을 되돌려준다.
아직까지는 특별히 이해에 어려울 것이 없는 것 같다. 7번의 cond
도 간단하다. (cond (p1 e1) ... (pn en))
은 p
라는 술어(predicate)를 차례로 계산하여 참이 나올 때까지 계산하고 참이 나오면 pi
에 대한 ei
를 계산하여 되돌린다. 만약 참이 나오지 않으면 '()
를 되돌린다(빈 리스트 '()
를 일단 false
로 생각하자). 매카시의 책에서는 (p1 -> e1, ... , pn -> en)
으로 표기했다. 예를 들면 (1 < 2 -> 4, 1 > 2 ->3)
은 4
를 되돌린다.
리스프에서도 다음과 같은 예제를 보면 별다른 것이 없다. 'a
와 'b
가 같지 않으므로 두 번째 술어부를 계산하고 'a
가 atom이므로 second
가 나온 것이다. 만약 'a
가 atom이 아니었으면 두 번째 술어부도 참이 아니므로 끝에 도달하여 '()
를 리턴하였을 것이다.
위의 연산자 중에서 quote
와 cond
를 제외하고 나머지는 먼저 연산자가 계산되고 나서 인자들이 계산된다. 이런 연산자를 함수(function)라고 부른다. 함수 표기법에 대해 매카시의 의견은 매우 간단했다(요즘은 당연히 여겨지는 것이 당시엔 나름대로 중요한 결정이었다).
사람들이 y2+x
와 같은 form을 함수와 구별 없이 사용하는 경향이 있는데 알론조 처치는 앞의 식을 form
이라고 불렀고 form
이 함수가 되려면 인자들의 값이 form
에서 어떤 값과 일치하는지 알 수 있어야 한다는 것이다. 처치가 고안한 표기법은 E가 form이라고 할 때 ((x1 ... xn), E)
로 표기하면 인수의 차례는 x1
에서 xn
까지 일치해야 한다는 것이다. 람다는 일단 이런 표기법이라고 할 수 있다.
리스프에서 함수는 (lambda (p1 ... pn ) e)
로 표시하며 p 1 ... pn
은 인자(parameters)이고 e
는 식이다. 함수 호출(function call)의 일반적 형태는 다음과 같다.
여기서 a1 ... an
의 식들을 모두 계산하고 난 후 식 e
를 계산한다. e
식을 계산할 때 pi
는 해당하는 계산된 ai
의 값과 일치한다. a1
부터 an
까지를 모두 계산하여 적용시키기 때문에 값을 전하는 것이다(call by value). 이름이나 포인터만을 전하는 것과 다르다(call by name). 이 문제는 나중에 다시 논의하게 되며 일단 CBV 방법만을 사용한다고 가정하자. 그러면 모두 계산을 해서 값만을 e
에 전달한다는 의미다. 간단한 예를 들면 다음과 같다.
여기서 y2+x
가 인자에 맞추어 계산되었다.
위 식에서 인자는 'a
이고 리스트 '(b)
와 함께 cons
가 적용되었다. 람다를 설명했으니 이제 label을 설명할 차례다.
(label f (lambda (p1 ... pn) e))
로 표기하는 것은 함수 (lambda (p1 ... pn) e)
로 표기하는 것에 대해 e
안에 f
가 나타나는 경우 f
는 label
이하의 식으로 계산된다. 이 방식은 N. Rochester가 고안하고 매카시가 채용한 것이다. 처치의 람다로 계산하는 것보다는 간단했다고 한다. 일반적으로 label
보다는 defun
으로 더 많이 사용한다. 그러니까 (defun f (p1 ... pn) e)
라고 쓰는 일이 더 많은 것이다. 람다 함수에 이름이 붙었다고 생각하면 된다.
이제 앞에 나온 식을 바탕으로 몇 개의 함수를 정의해 보자. 여기서 함수 뒤에 점(null
이 아니라 null.
처럼)을 붙인 것은 파생된 함수를 나타내기 위해서다. 기본적인 7개의 식은 이미 앞에서 설명했다.
1부터 6은 매카시의 글에 나오는 식들을 실제의 리스프로 변환한 것들이다.
리스프 인터프리터의 핵심, eval
이제 여기까지 왔으니 eval
의 소스를 구경할 차례다. 앞에서 말한 것처럼 eval
의 소스 코드는 a4 한 페이지 정도 분량에 지나지 않는다. 리스프 인터프리터에서 eval
은 핵심 그 자체로 식을 계산(evaluate)하여 결과를 돌려주는 일을 한다. 식에서 'eval.', 'evcon.', 'evlis.'처럼 점이 붙어있는 함수는 기본 연산자를 바탕으로 파생된 함수라는 것을 알려준다.
이 식을 돌려보기로 하자. eval
함수는 두 개의 인수를 갖는다. eval. (e a)
에서 계산하려는 식 e
와 함수 호출에서 atom
에 부여할 값을 부여하는 a
라는 리스트다. a
는 환경(environment)이라고도 부른다. 이 환경은 나중에 나오는 environment model과는 조금 다르다. 환경의 값은 pair
를 이용하여 쌍으로 만들어 내며 값을 찾기 위해서는 assoc
을 이용한다. eval
은 4개의 cond
항목으로 이루어져 있다.
- 우선 식
e
가atom
인 경우의eval.
의 동작을 보자. 식e
는 그냥x
이고 환경은'((x a) (y b))
다.cond
는assoc.
을 이용하여a
를 되돌린다.
- 두 번째는
e
가(a ...)
와 같은 형태의 식으로a
는atom
이다, 그리고 이 경우는 앞에서 설명한 7개의 기본 연산자를 모두 사용하는 경우이며 다시cond
로 각 연산자별로 분기한다.
quote
를 제외한 나머지는 모두 다시 eval
을 호출하여 인자의 값을 계산한다.
생각해보면 결국 인자를 모두 계산하여 6개의 기본 연산자에 대입하는 것으로 귀착된다. cond
는 조금 더 복잡하다. cond
를 계산하려면 evcon
이라는 다른 함수를 불러야 한다. 이 함수는 재귀적으로 식의 요소를 평가하여 첫 번째 t
가 나오면 술어 다음의 식을 계산한다. t
가 나오는 술어가 없다면 '()
를 리턴한다. evcon
함수는 cond
리스트의 처음부터 eval.
하여 참이 나오면 그 술어와 쌍이 되는 식을 eval.
하여 돌려준다.
그리고 두 번째 절의 마지막은 매개변수처럼 전달된 함수 호출을 다루는 것으로 아톰을 해당 값으로 치환하는 것이다. 이들은 lambda
나 label
을 이용하며 그 값이 다시 계산된다.
위의 식에서 f는 환경에서 발견되어 (lambda (x) (cons 'a x))
로 치환되었다.
- 그 다음은
label
이다.label
의 식은 매우 복잡하지만label
이 하는 일은 결국 환경a
에label
함수의 이름과 해당lambda
를 더하는 것이다. 그래서 다음과 같다.
결국 이 식은 환경 a
에 firstatom
의 람다 식을 추가한 것이다(너무 복잡하게 생각하면 안 된다). 결국 계산이 일어나면 a
를 리턴한다.
- 그 다음은
lambda
다.((lambda (p1 … pn ) e) a1 … an)
은evlis
를 불러서a1 ... an
의 인자들을 계산한다. 그 다음에 이 계산 값(v1 ... vn)
이a1 ... an
과 쌍을 만들게 되어(a1 v1) ... (an vn)
의 리스트가 환경의 앞에 추가되는 형태가 된다.
위의 식에서 x
와 y
의 값이 쌍으로 주어졌다. lambda
의 인자 리스트는 환경변수로 계산되어 바뀐 것이다(대단히 중요한 결론이다. 리스프 인터프리터는 인자 리스트를 계산하여 환경에 보관한다. 그리고 연산자만 남고 인자 리스트는 없어진다). 결국 계산은 (a c d)
를 되돌린다.
위의 eval.
은 매카시의 글에 나온 식을 그레이엄이 리스프로 번역한 것들이고 필자는 두 개의 문서를 놓고 비교했다. 그레이엄의 프로그램에서 apply
가 보이지 않기 때문에 리스프 인터프리터에 대해 배운 독자들은 이상하다고 생각할지 모른다. 그러나 최초의 리스프 인터프리터 구현은 요즘의 인터프리터와 apply
와 eval
의 순서가 반대다. 글에서 매카시는 apply
를 universal function으로 보았다. 매카시의 리스프에서 apply
는 각 인자에 대해 quote
를 붙이기 위해 사용되었다. 시작이 되고 나면 모두 eval
이 처리한다(일반적으로 apply
가 적용되는 곳이 위 식에서= evlis.= 가 적용되는 부분이다).
역사적인 이유로 매카시가 생각한 용법의 apply
도 적어본다.
끝으로
이게 다인가? 다는 아니지만 핵심이라고 말할 수 있다. 요즘의 리스프에서 몇 개 빠진 부분은 있으나 중요한 부분은 모두 망라한다.
메타서큘러 인터프리터는 상당히 중요하므로 매카시 본인이 만든 인터프리터도 있다. 매카시 자신이 만든 "A Micro-Manual for Lisp -- not the whole Truth"라는 글이 이런 내용으로 2페이지짜리 글을 인터넷에서 다운로드할 수 있다.
이번에 설명한 eval.
은 SICP의 강의 비디오 7a에서 서스만이 "모든 언어의 커널(The Kernel of Every Language)" 또는 "The Spirit in the computer"이라고 부르는 것이다. 몇 개의 식만 잘 정의하면 일단 돌아갈 수 있는 인터프리터가 나온다는 것, 이것이 바로 비밀이다. 나중에 이르기까지 인터프리터는 이것보다 조금 더 복잡해졌을 뿐이다. 앞의 프로그램에서 빠진 중요한 문제가 몇 개가 있으며 환경변수의 문제 같은 것이 있다. 이들은 SICP에서 모두 설명된다. The Art of Interpreter의 내용은 당연히 반영되었다.
이렇게 간신히 돌아가기 시작한 언어를 컴퓨터에 입력하고 종이에 식을 적은 후 검증을 하던 것이 1세대 해커들의 일이었다, 하지만 잘 돌아갔다.
리스프에서 모든 것은 리스트다. 프로그램도 데이터도 리스트이며 이것을 처리하는 인터프리터도 리스트이다. 만약 이런 것들을 일일이 손으로 계산한다면 고역이겠으나 다행히 컴퓨터가 있다.
정신없이 설명하다보니 독자들은 SICP의 4장을 미리 연습한 셈이 되고 말았다. 그리고 리스프가 구현되던 당시의 상황과 리스프라는 언어의 핵심을 한꺼번에 본 셈이다. 기왕 여기까지 왔으니 SICP나 다른 리스프 책을 보아도 좋을 것이다. 만약 리스프의 장점에 대해 조금 더 고무적인 글을 읽고 싶다면 폴 그레이엄의 『해커와 화가』와 같은 책이 있다. 책에서 리스프의 어떤 점이 중요하며 왜 좋은가에 대해 명쾌하게 설명하고 있다.
Part3: 해커리즘의 문화
2007년 6월 5일
들어가며
누가 무언가를 아주 좋아한다고 생각해 보자. 그리고 그 일이 컴퓨터나 프로그래밍에 관한 것이라고 범위를 더 좁혀보자. 예술 또는 다른 분야의 일이나 취미라도 상관은 없다. 그리고 그 일에 지속적으로 몰입하는 사람들이 몇 명 더 있다고 하자. 여기에서 공통적인 문화가 하나 탄생한다. 일종의 하위문화(subculture)라고 할 수도 있는 이 문화는 그것이 어떤 것이건 빠져있는 사람들에게는 아주 진지한 것이다. 보는 각도에 따라 진지한 놀이처럼, 때로는 진지한 일처럼 보일 수도 있다(일과 놀이의 심리적 요소가 아주 비슷하다는 사실은 예전부터 알려져 왔었다).
만약 이 문화가 주류 문화라면 우리가 매일 열심히 일하는 세상 일이 된다(일상적인 세상 일이 아주 재미있는 경우는 흔치 않지만). 주류 세계와는 조금 다른 일이 더 재미있을 수도 있다. 비주류나 반문화적인 요소가 있는 문화가 수용되는 일도 꽤 많이 존재했다.
현재 IT 문화의 일부 역시 기묘한 하위문화로부터 출발했다. 해커리즘도 그 중 하나다. 당연히 초창기에는 컴퓨터를 만지는 사람들이 이상하게 보이거나 이단적으로 보였다. 그것도 (대기업이나 연구소 직원으로서가 아니라) 정말로 컴퓨터가 좋아 컴퓨터에 빠져든 사람들은 사회의 일반적인 시각에서 보면 분명히 이단적인 요소가 있었다.
스티븐 레비의 책 『해커 그 광기와 비밀의 기록(원제는 Hackers: Heroes of the Computer Revolution)』은 이러한 이야기를 풀어나갔다. 초창기 MIT AI 연구소를 중심으로 한 1세대 해커와 컴퓨터를 사람들에게 보급시킨 2세대 해커들의 이야기가 책의 3분의 2를 채우고 있다. 1세대 해커가 리스프(LISP) 해커와 미니컴퓨터 해커라면 빌 게이츠나 스티브 잡스 같은 문화 아이콘들은 2세대 해커에 속한다.
『해커…』라는 책이 나온 지 이미 20년 정도가 지났고 컴퓨터가 하나의 주류 문화 정도가 아니라 문명의 근간이 되면서 이런 사람들의 이야기가 수용되기 시작했다. 세월이 더 지나면 이야기를 아름답게 적을 수 있는 동화작가의 손을 거쳐 미화되어야 할지 모른다. 사람들이 기대하는 문화코드는 보통 그런 것들이고 그런 책들이 서점에서 잘 팔린다. 나중에 아이들에게 이야기해줄 때도 껄끄럽지 않을 것이다. 그러나 새로운 문화나 하위문화가 항상 주류 문화에 대해 껄끄러웠다는 점은 인정해야 한다. 이들이 메인스트림으로 변한다 해도 초기의 그 이상한 에너지와 열정은 신비로운 것이며 다른 혁명이나 문화 활동에서도 공통적으로 나타났던 일들이다.
역사적으로도 비슷한 예가 있다. 산업혁명이 초기에 당시로서는 지극히 이단적이던 유니태리언파 사람들이나 프리메이슨, 아니면 그에 못지않게 비표준적이던 사람들에 의해 시작되었다는 것을 역사책에서는 잘 다루지 않는다. 교육 배경이나 사회적 지위가 아니라 스스로의 생각에 따라 움직일 수밖에 없던 사람들은 언제나 많은 것이다. 이들을 산업기계문명의 해커라고 불러도 하나도 이상한 일이라고 할 수 없을 것이다.
컴퓨터 문화의 전부는 아니더라도 중요한 요소들이 컴퓨터에 빠져있던 사람들의 정서를 반영한다. 세속적인 일이 아니라 컴퓨터에 빠져있던 이상한 사람들의 이야기는 방송으로도 나왔다. 예전에 미국 PBS에서 크린즐리의 'Triumph of the Nerds'라는 프로그램을 방송한 적이 있다. 이 프로는 상당한 인기를 모았다. 주식 시장에서 IT 주식이 마구 오르던 시절이어서 이 프로그램은 사람들의 반발을 사지도 않았다. 기묘한 괴짜인 너드들이 영웅시되기 시작했다. 결국 한때 괴짜 같던 컴퓨터 문화가 중요한 무엇으로 인정받았음을 의미한다. 컴퓨터 문화라는 것은 이상한 일도 아니었으며 예전과 달리 중요하지 않다고 생각하는 사람도 별로 없다.
산업혁명의 발전이 만들어낸 변화들이 모두 밝은 측면만을 가지는 것은 아니며 러다이트 운동이나 다른 노동운동 문화를 만들어낸 것도 사실이다. 컴퓨터에 대해서도 사람들이 반드시 밝은 측면만을 바라보는 것은 아니다. 그러나 초창기에 결정적인 변화를 만들어낸 사람들은 그들만의 강렬한 에너지가 있었다. 산업혁명이나 르네상스와 같은 시절에도 분명히 어떤 사람들의 강렬한 지적인 에너지가 있었다.
물론 해커들만 이상한 것은 아니다. 그들 주위의 일상도 정상적인 것은 아니었다. 1세대 해커들이 활동한 1960년대와 1970년대의 사회 분위기를 정상으로 보기도 어렵다. 그때가 좋은 시절이었는가를 생각해 보면 당시 사회의 광기어린 면들을 지적하지 않을 수 없다. 2차 대전이 끝나고 미국과 소련이 주도하는 냉전이 최고조에 달했던 시절이다. 군부 출신 대통령 아이젠하워가 걱정스러워 할 정도로 비대해진 군산복합체가 있었고 언제라도 핵전쟁이 터질지 모른다는 불안한 생각이 사회를 지배하던 시절이었다. 그리고 방위에도 과학기술 발전이 필수적이라는 것을 알게 되면서 이들의 돈이 컴퓨터를 연구하는 분야에도 투자되었다. 소련이 인공위성을 먼저 발사하면서 갑자기 엄청난 돈이 과학 연구에 투자되었다. 당시 사회에도 약간 미쳐 보이는 요소가 없는 것은 아니었다.
고가의 장비이던 컴퓨터를 만지는 사람들도 이 자금으로부터 무관하지는 않았다. 양심을 지키기 위해 최선을 다했다 해도 그렇다. 컴퓨터를 하려면 주류 문화의 설비를 이용하지 않을 수 없었다. 당시 지배층의 아이디어와 사회 통제에 대한 집착이 실제로는 누구의 생각과 이해관계를 반영하는가에 대해 생각하지 않을 수 없다. 그리고 악의에 찬 지배와 관리에 대한 집착이 아니더라도 그 집착에 이르게 한 판단의 근거들이 옳았는지를 생각하지 않을 수 없다. "옳다와 그르다"의 경계점은 항상 애매하기 때문에 근거에 대해 생각해보지 않을 수 없는 것이다. 사회 규정이나 규범에도 언제나 버그가 꽤 많이 존재한다.
요즘 세계화와 지적 자본주의 속에서 우리의 활동에 대해 후세에 어떤 비평을 들을지는 미지수다. 자유롭게 보이는 우리의 IT 산업과 문화는 복잡한 관계의 거미줄 속에서 자유롭지 않다. 회사와 기업에서 발생하는 새로운 통제는 얼마든지 우리를 얽매고 있을 수 있다. 실제로 기업들은 생존을 위해 여러 가지 일을 해왔던 것도 사실이다. '옳다와 그르다' 또는 '정상적이거나 묘해 보이는' 일들도 항상 새롭게 생각해 보지 않을 수 없다.
비판의식을 갖건 비판의식을 갖지 않건 하위문화는 강렬한 관심과 집중으로 만들어지는 작은 종교집단처럼 보인다고 한다. 사람들이 빠져있는 관심의 원은 그 자체가 하나의 작은 세계로 다른 세계와 구별된다. 완전히 빠져든 사람이 있다면 그것이 세상의 중심이다. 그리고 이런 중심은 도처에 있다. 영원하지는 않더라도 하위문화의 에너지는 쉽게 사라지지 않는다. 또한 이 에너지가 없다면 변화는 일어나지 않는다. 에너지 변화를 수반하는 몰입은 드문 일이 아니다. 사람들은 한때 문학에 열중한 적도 있었으며, 음악에 열광하거나 정치나 전쟁에 열광한 적도 있었다. 팝 문화나 TV나 영화에 빠져든 적도 있으며 모든 문화 영역은 이런 에너지로 넘친다. 글을 쓰다 보니 다른 문화와의 유사점만을 부각했지만 그것은 사실이다. 언제나 일어나는 현상인 것이다!
그 중에서 필자는 컴퓨터의 문화 원동력이었던 해커에 대해 이야기하고 있는 것이다. 우리가 과거의 해커들에 대해 어떤 식으로 규정하느냐는 바로 같은 업종에서 일하거나 관심을 갖는 우리의 일에 대한 새로운 해석을 필요로 한다(독자들은 이와 비슷한 말을 다른 역사책에서 이미 많이 들었을 것이다).
하드웨어와 교감하다
초창기 컴퓨터는 많은 개량이 필요한 것이었다. 제작회사들이 아무리 멋있게 포장을 해도 당시 기계들은 요즘의 작은 IC 하나만도 못한 능력을 가졌다. 1초에 10만 번 덧셈을 할 수 있는 정도였다. 컴퓨터 하드웨어는 별것이 없었다.
하드웨어의 예를 들기 위해 오디오 앰프의 예를 들어보자. 오래전에 오디오라는 것은 별것이 없었다. 진공관 몇 개로 만든 앰프를 가지고 오디오광들은 신비스러운 음악의 세계로 빠져들었다. 당시의 명기라는 설계들도 회로로 보자면 오늘날의 기준에서 초라하기 그지없다. 주요 음원인 LP 판에서 나올 수 있는 소리의 질도 제한적인 것이었다. 그러나 많은 사람들은 빈약한 하드웨어의 경계를 넘어 소리에 빠져들었다. LP 판에서 나오는 잡음도 큰 방해가 되지는 못했다. 판을 너무 열심히 듣다 보면 골이 닳아버려 LP 판의 물리적인 수명이 다하곤 했다.
>
그림 1. 하나의 진공관으로 만들어진 앰프. 사실상 트랜지스터 1개에 해당한다.
그러나 적어도 최소한도의 구현은 있어야 했다. 매우 초기에는 오디오를 듣는 사람과 만들고 개선하는 사람을 구분하기 힘들었다. 초기 오디오 설계와 구현은 많은 에너지의 집중을 필요로 했다. 하지만 최소한도의 세팅이 이루어지고 사람들이 이것을 좋아하기만 한다면 그 관심과 집중은 많은 변화를 만들어낼 수 있었다. 사람들은 수백 개의 음반을 듣고 수집하기도 하며 음반을 평가하고 관리하는 일도 큰 사업이라는 사실을 알게 된다. 레코드 시장도 커져갔다.
컴퓨터 역시 마찬가지였다. 일단 최소한도의 것들이 만들어지자 컴퓨터에 빠져드는 사람들이 늘기 시작했다. 당시 자료들을 검토하고 있자면 놀랄만한 일들이 한두 가지가 아니다. 1950년대는 말할 것도 없고 1960년대에 들어와 트랜지스터를 이용한 컴퓨터가 나오기 시작했을 때의 하드웨어도 빈약하기는 마찬가지였다. 예를 들면 플립플롭 회로 하나가 작은 책자 정도 크기였는데 레지스터의 1비트에 해당했다. 그러니까 책꽂이 하나가 1워드가 되는 셈이다. 커다란 컨트롤 패널이 보여주는 정보는 요즘의 디버거 한 줄의 정보에도 미치지 못할 때가 많았다. 프로세서 유닛의 명령은 다이오드와 배선으로 하드와이어 연결이 이루어져 있었다. 이 정도의 기계도 줄을 서야 사용할 수 있었다. 레지스터와 컨트롤 유닛, ALU 같은 것은 구조가 밖에서도 훤히 보였다.
>
그림 2. 초창기 PDP-1의 모듈의 일부. 이 모듈은 NOT의 기능을 수행하고 이런 모듈들을 모아 PDP-1이 만들어졌다.
>
그림 3. 몇 년이 지나자 TTL IC 한 개가 모듈의 기능을 대체할 수 있게 되었다.
교감하고 집중하는 것이 사실상 프로젝트의 성공과 실패를 좌우하는 일이다. 많은 프로젝트들은 사람들의 관심과 집중을 받지 못해 실패했다. 반대로 아주 빈약한 하드웨어라도 사람들의 힘과 정신력의 집중은 대단한 결과를 만들어낸다. 개발 프로젝트의 대상은 사람들과 교감한다. 개인적인 생각이지만 필자는 사람들의 머릿속 코딩이 기계의 코딩에 우선한다고 생각한다. 초창기에는 더 중요하다. 몇 년을 우회할 발전이 며칠 만에 해결되는 수도 있다. 그래서 우선은 사람들을 코딩되어야 한다.
어떤 일을 너무 좋아하는 사람들이 나타나면 일은 빠르게 진행된다. 컴퓨터가 이들에게는 세상의 중심이었다. 오늘날의 컴퓨터에 비하면 빈약한 하드웨어와 소프트웨어였으나 이것으로도 교감할 수 있었다. 하드웨어는 언제나 더 많은 개선을 필요로 했고 최고의 효율을 발휘해도 언제나 연산능력과 메모리는 부족했다.
초창기 컴퓨터라는 것은 비싼 장비였기 때문에 사용에 제약이 가해지기는 했으나 컴퓨터 제작회사의 엔지니어나 그것을 사용하는 해커들은 실험적으로 많은 해킹을 했다. 기술적으로 특별히 감출만한 것들도 없었다. 나중에 그 기계 사용자들이 회사에 입사해 새로운 컴퓨터들을 만들어 내기도 했다. 필자는 과연 당시 사용자들이 새로운 시도를 중지하고 제작회사에서 하라고 정한 일들만 하는 것이 옳았는지 아니면 왕성한 실험정신을 발휘한 것이 맞았는지에 대해 생각해 보아야 한다고 믿는다(물론 필자의 생각은 이단적일 수 있으며 생각하기에 따라 맞을 수도 틀릴 수도 있다).
이 정도의 하드웨어에서 지난번에 설명한 것과 같은 리스프 인터프리터가 구현되었다. 초기에는 리스프에 제한이 너무 많았다. 리스프가 상당히 개선된 것은 코톡이라는 해커가 설계한 컴퓨터에 그린블러트의 MacLISP가 구현되고부터다. 코톡은 나중에 DEC로 가서 PDP-6의 주 설계자가 되고 PDP-6은 나중에 PDP-10이 된다.
1970년대에 이미 36비트 컴퓨터가 존재했다. 다시 몇 년의 세월이 지나자 초기 해커들은 몇 명을 제외하고는 대부분 자리를 옮겼다. 컴퓨터가 세상의 중심이었던 사람들 역시 체력과 집중력이 약화되는 것을 몸으로 느꼈을 뿐만 아니라 사회적, 경제적인 제약으로부터 자유롭지 못했다. 초기 리스프 해커들의 전성기는 10년 정도 지속되었다.
>
그림 4. PDP-1(www.computerhistory.org 의 사진에서)
람다 계산법(Lambda Calculus)
열정과 집중이 중요하다는 이야기를 너무 길게 한 것 같다. 이제 다시 리스프 이야기로 돌아오자.
리스프가 람다 표기법을 채택한 것은 지난번에 설명했다. 리스프 프로그램에 대해 자세히 설명하지도 않고 리스프 인터프리터를 만드는 이야기를 진행하였으니 황당하기는 하지만 실제로 리스프는 그렇게 갑자기 세상에 나타난 것이다.
리스프는 함수를 람다 표기법으로 나타낸다. 람다 표기법은 특별히 수나 기호를 구분하지 않는다. 람다 표기법은 조금 생소한 것이라 설명이 필요하다. 람다 계산법은 치환을 다루는 계산법이다. 전반적인 내용이나 배경이 위키백과에 상당히 잘 정리되어 있다. 필자는 람다 계산법을 설명하기 위해 'An Introduction to Lambda Calculus and Scheme'에 나오는 예제를 그대로 몇 개 인용해 보았다.
함수는 입력을 받는 부분과 결과를 내는 부분이 있다. 이제 우리가 어떤 대상에 초콜릿을 씌우는 함수를 갖고 있다고 생각하고 다음과 같은 것을 생각해 보자.
이것을 람다 계산법을 사용하여 표현하면 다음과 같다.
여기서 L
은 람다(λ)를 나타낸다. 함수에 인자를 대입하는 것을 다음과 같이 표시한다.
람다 계산법에 따르면 람다식에 어떤 인자를 적용한 결과가 또 하나의 함수일 수도 있다. 초콜릿이 아니라 캐러멜을 포장할 수도 있는 것이다. 이를테면 아래와 같은 람다식을 만들 수 있다. 이 식은 y
로 싸인 x
를 만드는 것이다.
이제 캐러멜을 덮는 함수를 만들어낼 수 있다. 식은 y
인자로 캐러멜을 받았다.
그리고 이 함수는 다시 땅콩을 덮도록 만들 수 있다. x
인자로 peanuts
를 받았다.
함수의 인자가 반드시 숫자일 필요는 없다. 람다 계산법에서 함수는 다른 함수의 인자가 될 수도 있다. 지난번의 간단한 인터프리터에서도 함수를 다른 함수의 입력으로 사용할 수 있었다. 아래 식에서 f
인자는 함수다.
그래서 초콜릿을 포장하는 함수를 ant
에 적용할 수 있다. (apply-to-ants)
를 잘 살펴보면 단순한 치환이 이루어지는 것을 알 수 있다. ()
의 주변을 잘 살펴보라. f
는 Lx.chocolate-covered x
로 대체되었다. 그리고 x
에는 ants
가 적용되었다.
함수가 한 람다식을 적용한 결과로 만들어질 수 있다는 것은 대단한 일로 많은 가능성을 갖고 있다. 이런 것을 클로저(closure)라고 부르기도 하며 리스프나 스킴(scheme)에서 고차함수를 만드는 바탕이 되었다.
자세한 증명은 람다식을 다루는 문헌들을 찾아보기로 하고 람다 계산법이 왜 일반적인 컴퓨팅의 원리로 변할 수 있었는지를 생각해 보자. 사실 컴퓨터는 구현 이전부터 만들어질 수 있는 방법이 있었다. 그 중 하나는 람다 계산법을 통해서다. 전기 스위치를 사용하건 다른 기계적인 무엇을 사용하건 만들어질 수 있었다. 구현 논리는 이미 수학적으로 존재했다. 우선 조건식을 람다함수로 만들어낼 수 있다. 이를테면 참과 거짓을 다음과 같이 만들어낼 수 있다.
매우 생소하기는 하지만 논리식을 람다 함수로 표시해본 것이다. 몇 개의 치환을 거쳐 람다 함수는 조건식을 정확히 계산한다. 아래의 식은 if-then-else
가 참이면 apple
을, 거짓이면 banana
를 돌려주도록 되어 있다. 미리 false
를 적용한 것이라 banana
를 돌려준다.
간단한 정리 몇 개를 적용한 것치고는 많은 일을 할 수 있는 것 같다는 생각이 들지 않는가? 아무튼 앞서의 초콜릿과 캐러멜을 치환하는 식과 peanuts
, raisins
, ants
를 치환하는 방법을 그대로 적용한 것이다. 종이와 연필로 계산해볼 수 있다. 지난번의 cons
와 car
, cdr
도 람다식으로 표현할 수 있다.
이런 방법으로 생각하는 cons
, car
, cdr
의 정의가 나름대로 중요한 내용이라 실제로 SICP의 비디오 강의 5b에서는 중요한 개념으로 떠오른다. 강의에 나오는 스킴 식에서는 다음과 같이 정의했다.
이 식을 실제로 수행하면 다음과 같다.
별것 아닌 것처럼 보이는 내용이겠지만 위 정의를 조금 더 변형하면 사이드 이펙트(side-effect)를 만들어낼 수도 있고 함수형 언어에서 덮어쓰기(assignment)의 메커니즘을 만들어낼 수도 있다. 재귀(recursion) 역시 람다식을 이용해 만들어낼 수 있다. Y 컴비네이터라고 부르는 것인데 리스프에서는 label을 이용한 다른 방법으로 구현했다.
리스프라는 언어는 이런 람다 계산법을 적용하는 하나의 이론적인 기계 그 자체이며(지난번에 설명한 간단한 evaluator 그 자체가 A4 용지 한 페이지 정도의 식이다) 리스트로 되어있는 다른 리스프 식을 읽어 이들을 치환하여 계산을 하고 경우에 따라 수식이나 새로운 함수 자체를 답으로 되돌린다.
Paradigms of Artificial Intelligence Programming
초창기 해커들이 관여하던 몇 가지 문제들을 정리한 책이 있다. 리스프가 개발되고 문제의 표현을 위해 사용되던 곳이 인공지능(AI) 분야였기 때문에 당연히 리스프에는 좋은 예제가 많았다.
피터 노빅(Perer Norvig, http://norvig.com )이 쓴 책 중에 『Paradigms of Artificial Intelligence Programming: Case Studies in Common Lisp』라는 유명한 책이 있다. 첫 회에 소개한 SICP보다 더 어려운 수준의 책(하드코어에 속한다고 하는 사람도 있다)이지만 리스프로 어떻게 인공지능 문제들을 접근했는지에 대한 좋은 자료다. 책의 2부는 초기 AI 프로그램을 다루고 분석하고 있다. 이를테면 GPS(General Problem Solver), ELIZA, 수학적 기호처리 같은 것들을 다룬다.
책을 사볼 필요는 없겠지만 Paradigms of Artificial Intelligence Programming에서 'Excerpts from the preface, including why Lisp?' 같은 글들을 읽어 보기 바란다. 어려운 내용도 아니며 좋은 생각거리를 제공할 것이다. 그 외에도 좋은 글들이 꽤 많다.
Part4: 액터와 람다
2007년 7월 18일
들어가며
지난번에는 람다에 대해 간단히 적었다. 람다 계산법이 이상하게 보인다 해도 너무 심각하게 생각하지 않아도 된다. 당장은 이해할 필요도 없다. 이해에는 많은 시간이 걸릴지도 모르며 이해하지 않아도 별 문제는 없을지도 모른다. 컴퓨터를 잘 몰라도 IT 개발 업무에 종사하는 사람이 상당수 있는 것처럼 람다를 잘 모른다고 큰 문제가 생기는 것도 아니다. 실제 코드를 짜는 일에 람다 계산법을 적용하는 일도 별로 없다. 리스프는 이미 람다를 충실하게 구현하고 있다.
필자의 임무는 리스프 여행 가이드이므로 너무 어렵지 않게 이야기를 적어 나가야 한다는 것을 안다. 읽다가 호기심이 생기는 부분이 있으면 찾아볼 자료들은 얼마든지 있을 것이다. 이번 이야기는 람다가 중요한 화두로 등장하는 역사적 사실의 배경이다. 그 시작은 서스만과 스틸의 스킴(Scheme)이 람다 페이퍼들과 함께 나왔고 람다 페이퍼가 나온 이유는 이들이 액터 모델(actor model)을 이해하고 싶어 했기 때문이다.
액터 모델은 칼 휴이트(Carl Hewitt)라는 총명한 학자가 제시한 모델이다. 사실 이번 이야기는 액터 모델보다도 휴이트가 주인공이다.
휴이트와 액터
어느날 칼 휴이트가 람다와는 관련이 없어 보이는 액터 모델이라는 이론을 들고 나왔다(Carl Hewitt, Peter Bishop, Richard Steiger(1973). 「A Universal Modular Actor Formalism for Artificial Intelligence」). 람다가 하나의 계산 형태인 것처럼 액터 역시 형식이 중요하다. 이 모델에서 세상의 모든 것은 액터라는 개념을 채택했다. 마치 객체지향 프로그래밍에서 모든 것이 객체이며 리스프에서는 모든 것이 리스트인 것과 같다.
액터는 객체보다 더 동시적인 모델이다. 액터는 물리적인 세상을 추상화하려는 시도에서 나왔다(반면 람다는 논리적인 모형으로부터 나왔다). 물리적인 시스템에서 중력이나 전기장은 다른 요소들과 직접적, 동시적으로 영향을 받는다. 액터는 이러한 동시적 작용을 위한 메시지를 보내는 모델을 만들고자 했다(액터와 비슷한 것들은 여러 번 등장했다. 우선 정보이론 자체가 맥스웰의 도깨비에 대한 새넌과 위너의 고민으로부터 나온 것을 생각해 보면 된다. 더 거슬러 올라가면 라이프니츠의 모나드(monad) 이론부터 시작한다). 액터는 컴퓨터상의 존재로 메시지를 받으면 다음과 같은 일을 동시적으로 만들어낼 수 있다.
- 다른 액터에 한정된 개수의 메시지를 보낼 수 있다.
- 유한한 개수의 액터를 만들어낼 수 있다.
- 다른 액터가 받을 메시지에 수반될 행동(behavior)을 지정할 수 있다.
- 이런 일들이 동시적으로 진행되는 데 있어 미리 정해진 순서는 없다.
통신은 비동기적이며 메시지를 보내는 액터는 메시지가 다 수신되기를 기다리지 않는다. 메시지 수신자는 주소로 확인되며 우편주소라고 부른다. 그래서 액터는 주소를 갖고 있는 액터들에 한해 통신할 수 있다. 주소는 수신하면서 알아낼 수도 있고 자신이 만든 액터의 주소일 수도 있다. 요약하면 액터 모델은 액터들 사이의 동시적 계산, 액터의 동적인 생성, 메시지에 액터의 주소를 포함시킬 수 있는 것, 그리고 도착순서의 제한을 받지 않은 비동기적 메시지 전달을 통한 상호작용이 특징이다.
내용을 읽다 보면 액터 모델과 비슷한 것이 우리가 사용하는 전자우편이라는 생각이 들지 않는가? 실제로 그렇다. 액터 모델의 내용을 읽고 있으면 리턴 값을 전달하는 이야기가 없으며 실제로 계산 값은 메시지를 전달하면서 전해질 수 있다. 그래서 액터 방식으로 계산하는 방법은 함수나 서브루틴을 호출하여 2와 3을 더하여 5를 리턴 받는 것이 아니다. 이를테면 다른 액터에 3이라는 값을 보내면서 여기에 2를 더할 것을 요구하는 메시지와 함께 전송하는 것이다. 이상하게 보이는가?
이상하게 생각할 것은 없다. 이메일로 지시를 보내 일을 처리하는 방식을 확장하면 된다. 메시지를 여러 번 보내고 받으면 복잡한 일도 처리할 수 있다(어떤 사람들은 웹 서비스의 SOAP 모델도 액터 모델로 잘 표현할 수 있다고 생각한다).
휴이트의 액터는 람다 계산, 스몰토크(Smalltalk), 시뮬라의 영향을 받았다. 이들은 동시에 휴이트의 영향을 받았다.
우선 람다 계산부터 살펴보자. 위키백과 영어판의 액터 모델에서 람다 계산법을 이용한 메시지 전달은 다음과 같다.
얼핏 보면 단순한 치환(substitution)이다. 그러나 서스만과 스틸은 스킴을 만들면서 환경(environment)이라는 개념을 도입하면서 리스프를 다시 해석하게 되었다(액터를 이해하기 위해 서스만과 스틸이 만든 장난감 리스프 언어가 스킴이 되었다. 휴이트와 이야기를 나누던 서스만은 사소한 점 두 가지를 제외하고는 람다와 액터 모델이 거의 일치한다는 것을 알았다). 스틸은 이 내용을 「The History of Scheme」이라는 글에서 요약했다. 람다가 개별적으로 상태 변수를 갖고 서로 값을 주고받으면서 액터의 역할과 같은 일을 할 수 있다는 것을 알았다. 동시성 문제가 완전히 해결된 것은 아니지만 리스프 구조에 큰 변화가 왔고 그 와중에 지난번에 소개한 'the original lambda papers'라는 것들이 나왔다. 람다라는 것은 개념으로부터 하나의 완벽한 계산상의 실체가 되었다.
별다른 내용이 아닌 것 같지만 프로시저나 함수들, 액터, 객체 그리고 람다 객체들이 메시지를 주고받는 패턴으로 컴퓨터의 제어 구조를 결정한다는 간단하면서도 심오한 결론에 도달한다. 운영체제의 IPC가 얼마나 복잡해질 수 있는가를 이해할 수 있는 독자라면 같은 패턴이 언어에도 적용된다는 것을 알 수 있다. 객체가 사용하는 메시지 전달 같은 것은 그 다양한 종류의 하나일 뿐이다.
사실 조금만 파고들어도 복잡한 측면들이 나타난다. 일부 객체지향 언어에서 메시지는 객체를 통제하는 거의 유일한 수단이다. 만약 객체가 메시지에 반응한다면 그 객체는 메시지에 대한 메서드(method)를 갖고 있는 것이다. 객체지향 언어보다 먼저 나타난 구조적 프로그래밍에서 메시지를 보내는 방법은 함수 호출이다. 그 이전에는 포트란이나 베이직처럼 직접 goto(jump)하는 방법이 있었다. 프로그램이 제어를 전달하는 방법에서 jump는 나쁜 방법이 아니다. 많은 문헌에서 컨티뉴에이션(continuation)이라고 부르는 방법도 제어와 메시지를 전하는 방법이다. 스택을 쓰지 않아도 컨티뉴에이션으로 해결할 수 있고 파이썬이나 그 외 몇 가지 언어에서는 이미 시험대에 오른 문제이기도 하다. 차이가 있다면 일의 종류나 알고리즘에 따라 얼마만큼 우아하고 추상적으로 표현할 수 있느냐가 관건이다.
리스프에서 스킴을 만든 팀만 휴이트를 만난 것이 아니다. OOP 발전에 결정적 영향을 미친 알런 케이(Alan Kay)도 휴이트를 만났다. 먼저 알란 케이가 스몰토크를 만들면서 메시지 패싱의 사고방식에 영향을 받았다. 메시지 패싱은 최초의 객체지향 언어라고 하는 시뮬라에 처음 모습을 나타냈다. 휴이트의 경우 메시지 패싱은 패턴 지향적으로 플래너(Planner)를 불러내는 데 사용되었다. 플래너는 휴이트가 만든 인공지능 언어이며 여기서 메시지 패싱의 개념을 설명하고 있다. 이것이 스몰토그-71 개발에 영향을 주었다. 얼마 후 휴이트 역시 스몰토크-71에 의해 크게 자극 받았으나 구현 방법이 너무 복잡한 것이 문제였다.
1972년 케이는 스몰토크-72에 대한 아이디어와 세이모어 패퍼트(Seymour Papert)의 로고(Logo)에 나오는 'little person'의 개념을 토론하기 위해 MIT를 방문했다. 그러나 스몰토크-72의 메시지 패싱은 정말 복잡했다. 그래서 나중에는 메시지 패싱에 근거하는 동시 계산의 수학적 모델은 적어도 스몰토크-72보다는 간단해야 한다고도 했다. 스몰토크의 나중 버전들은 시뮬라의 모델을 따랐다. 휴이트의 메시지 패싱에 대한 방법론이 케이에게 영향을 준 것은 분명하지만 액터 모델도 케이의 영향을 받았다.
스몰토크가 GUI와 데스크톱 그래픽 환경에 결정적 영향을 준 것에 비해(PARC의 워크스테이션과 매킨토시 그리고 스퀵) 액터는 컴퓨터 이론과 공학에서 다루어졌다. 역사의 개괄은 간단하지만 둘은 서로 많은 영향을 주고받았다(우리가 알고 있는 객체의 개념이 세포의 영향을 받은 것이라든지, 북유럽에서 날아온 알골의 변형인 시뮬라의 영향 같은 것은 모두 튜링상을 받은 사람들의 가장 중요한 개념이었음에도 불구하고 별로 알려져 있지 않다. 신기하게도 세부적인 언어 구현의 요소들에 대해 고민하면서도 문제 자체를 문제로 고민할 기회는 상대적으로 적었다).
언어는 어느 정도까지 추상화나 세계의 모습을 제공해야 하는가? 그것은 잘 모른다. 하지만 언어에서 생물학적 모델, 물리학적 모델 같은 것을 다룰 때 메시지 패싱은 중요한 부분이다(물론 독자들은 메시지 패싱이 본질적이며 골치 아픈 내용이라는 것을 잘 알고 있을 것이다). 이 문제는 아무튼 30년이 넘게 중요한 화두로 작용했다.
휴이트가 던진 화두에 리스프 해커들은 람다를 골똘히 연구하는 것으로 문제를 해결했다. 람다 함수의 인스턴스는 제한이 없으며 서로 전부 다른 상태(state)를 만들 수 있기 때문에 수많은 객체가 있는 것과 마찬가지다. 여기서 람다들이 어떤 측면(aspect)을 바라보도록 설계하는지가 중요한 문제다.
SICP 3장은 이런 모델링의 중요한 모습을 드러낸다. 리스트를 기계의 부속을 연결하는 것처럼 사용하는 코드들이 나온다. 5장에 가면 재귀(recursion)와 컨티뉴에이션이 실제 언어 구현에서 어떻게 제어를 넘기는지 극명한 예들을 제시한다. 이런 것들을 학부 학생들에게 가르치겠다는 과감한 시도가 SICP에 사람들이 열광하는 이유다. 물론 싫어하는 이유이기도 하다.
책을 보며 필자가 느낀 기묘한 느낌은 오랫동안 머릿속에 남아있었다. 그런데 필자만 그런 것을 느낀 것은 아니었다. 필자는 예전에 김창준 님이 워드 커닝엄(Ward Cunningham)과 인터뷰한 글을 떠올렸다(워드 커닝엄과 켄트 벡은 패턴을 컴퓨터 프로그램에 적용하는 실험적 작업을 20년 전에 시도했다). 그 내용이 박스 기사에 소개되어 있다.
액터 모델은 오랫동안 많은 사람들에게 영감을 준 화두다. "핵심적 내용인 메시지 패싱에 대해 생각해 보면 좋을 것이다" 정도가 필자의 간단한 결론이 되겠다. 어떤 형식으로 요소들 간에 메시지를 주고받게 할 것이며 어떤 기구를 제공할 수 있는가 하는 문제는 답이 나지 않는 문제다. 그래서 계속 생각해 볼 화두로서 가치는 충분하다.
조금만 생각해 보는 것으로도 우리가 아는 언어에 대한 상식들에 대한 새로운 관점들을 얻을 것이다. 그리고 이 주제들은 SICP 5장에서 반복적으로 나온다.
>
그림 1. Viewing Control Structures as Patterns of Passing Messages
그림은 휴이트의 「Viewing Control Structures as Patterns of Passing Messages」에 나오는 그림이다. 재귀를 이용하여 팩토리얼을 계산하는 문제를 메시지 패싱으로 본 그림이다. 생각하기에 따라 메일로 메시지를 보내는 것처럼 문제를 해결할 수도 있다. 수신자 숫자를 늘리거나 더 복잡한 동작을 시킬 수도 있다. 이 그림은 「History of Scheme」에서 다른 방법으로 설명한다.
휴이트의 다른 작업 플래너
공교롭게도 칼 휴이트, 제럴드 제이 서스만, Terry Winograd는 모두 세이모어 패퍼트의 제자였다. 플래너는 휴이트가 만든 언어의 이름이다. 지식(knowledge)을 정의하면서 지식을 논리 수학의 형식으로 정리해야 한다는 매카시 같은 사람들의 생각과, 고수준의 프로시저 플랜의 형태로 지식을 표현할 수 있다는 사람들의 의견이 있었다. 플래너는 두 방법을 혼합한 형태였다. 인공지능 언어는 리스프 뿐만 아니라 여러 가지가 있었고 플래너는 그 중 하나였다. 플래너는 너무 방대하기 때문에 피코플래너나 마이크로플래너 같은 부분적 구현들도 있었다.
- 리스프(매카시 외, 1958)
- 플래너(휴이트, 1969)
- 마이크로플래너(서스만 외, 1971)
- Conniver(서스만 외, 1972)
- 플라즈마(휴이트 외, 1973)
- Schemer(서스만과 스틸, 1975)
이들은 1970년대 초반 마이크로플래너를 홍보하기 위해 에딘버그를 방문한다. 서스만과 Terry Winograd는 에딘버그 대학을 방문하여 새로운 버전인 마이크로플래너의 소식을 전하고 증명 방법의 절차 논리를 논의했다. 에딘버그에는 피코플래너라는 마이크로플래너의 축소판과 완전한 플래너가 구현되어 있었다. 에딘버그의 논리학자 코왈스키와 헤이스는 마이크로플래너의 많은 부분이 전통적인 수학적 논리에 의해 수행될 수 있다는 것을 알았다. 그래서 코왈스키는 마이크로플래너의 일부를 사용하여 프롤로그(Prolog: programming in logic)의 아이디어를 떠올리고 마르세이유 대학에서 프롤로그가 구현되었다. 그래서 프롤로그가 나오는데 이것은 미국의 연구팀에게는 충격이었다.
유럽 연구자들은 리스프를 잘 몰랐으나 플래너의 개념은 알고 있었고 미국 학자들은 우아하게 자신의 연구를 포장하지 못했다. 플래너의 개념이 명제 논리학과 만나자 프롤로그가 나왔다. Colomeur와 다른 연구자들에 의해 새로운 언어와 형식이 나온 것이다. 프롤로그는 플래너보다 많이 간단했고 큰 장점으로 작용했다. 간단히 배워 논리 프로그래밍을 배울 수 있었다. 프롤로그는 유럽에서 많이 사용되었다.
결국 기본적 도구를 만들어 준 셈인데 프롤로그는 또 다른 하나의 강력한 주류로 나타난다. 플래너에서 지식에 대해 '절차적 지식의 구현'의 개념이 조금 명확해졌다면 다른 형식을 빌려 선언형 지식(declarative knowledge)의 모습을 떠올릴 수 있었다.
그 이후 리스프의 고급 교과서에는 프롤로그를 만드는 예제가 나온다, SICP와 지난번에 소개한 PAIP(Paradigms of Artificial Intelligence Programming)에도 프롤로그 인터프리터를 만드는 예제가 많은 지면을 할애하여 나온다. SICP의 논리 프로그래밍 장에는 몇 가지 간단한 법칙과 프레임으로 프롤로그 인터프리터를 만드는 방법을 설명한다. 그러면서 "훌륭하기는 했으나 이해하기는 어려웠던 휴이트의 박사 논문을 가지고 씨름하던 MIT의 연구자들에 의해 논리 프로그래밍을 연구 중이었으나" 하는 문장으로 시작하는 주석문이 있다. 지식을 표현하는 더 우아한 표현법이 있다는 것을 인정해야 했다.
지면상 더 쓸 수는 없지만 어떤 지식들은 '선언(declare)'될 수 있었던 것이다. 논리적으로 선언된 지식들은 법칙에 의해 프로그램을 자동으로 '생성'할 수 있다. 이상해 보이기는 하지만 사실이다. PAIP는 프롤로그에 너무 많은 지면을 할애한다고 비난까지 받았지만 정말 중요한 부분이었다.
스킴 칩과 리스프 머신
SICP 5장은 컴파일러, 그리고 조금 비약하면 스킴 칩까지 구현하는 내용이다. 이 칩의 가장 충격적인 특징은 소프트웨어가 자신이 실행될 하드웨어를 디자인하는 것이었다. 설계의 원형은 「Design of LISP-based Processors, or SCHEME: A Dielectric LISP, or Finite Memories Considered Harmful, or LAMBDA: The Ultimate Opcode」(Guy Lewis Steele, Gerald Jay Sussman, AI Lab memo, AIM-514)에 나온다. 이 글을 쓸 때는 1979년이었다. 당시로서는 획기적인 개념이었다. 설계의 많은 부분은 반도체 팀이 아닌 소프트웨어 디자이너들이 쓴 것이고 이 글은 유명한 카버 미드(Carver Mead)의 책이 나오기 전의 초고를 빌려 읽으면서 썼다. 책의 내용은 거의 30년이 되어가는 지금 보아도 흥미진진하다. 람다의 원형이 점차 간단해져 칩으로 변한다. 한편 출판된 미드의 책은 이 글을 중요한 사례로 인용했다.
SICP 5장은 개념적인 레지스터 머신(register machine)을 만들면서 설명하고 컴파일러를 만든다. 컴파일러는 점차 기계로 스킴을 옮긴다. 리스프 컴파일러의 서브셋과 기계의 요소는 크게 다르지 않다. 이런 일이 아주 쉬운 내용은 아니지만 아주 어려운 내용도 아니다. 그러나 정말 생각할 것이 많은 내용이다.
리스프는 함수를 람다 표기법으로 나타낸다. 람다 표기법은 특별히 수나 기호를 구분하지 않는다. 람다 표기법은 조금 생소한 것이라 설명이 필요하다. 람다 계산법은 치환을 다루는 계산법이다. 전반적인 내용이나 배경이 위키백과에 상당히 잘 정리되어 있다. 필자는 람다 계산법을 설명하기 위해 'An Introduction to Lambda Calculus and Scheme'에 나오는 예제를 그대로 몇 개 인용해 보았다.
5장의 주요 내용으로 나오는 컴파일러인 래빗(Rabbit)이라는 최초의 스킴 컴파일러가 가이 스틸의 석사 학위 논문이었다. 커먼 리스프와 스킴을 만드는 데 핵심적인 역할을 한 스틸은 졸업 후 다니엘 힐리스의 Thinking Machine에 합류한다. 수많은 프로세서, 적어도 몇 만개를 묶어놓은 프로세서들의 알고리즘은 사실상 스킴 칩 설계의 영향을 받았다. 스틸과 힐리스는 모두 서스만이 지도한 학생이었다.
>
그림 2. 스킴 칩의 내부회로
박스 기사 다음에 인용한 글은 워드 커닝엄과 김창준의 인터뷰의 일부로 월간 마이크로소프트웨어에 실렸던 글이다. 오늘 소개한 내용과 연관이 있다.
창준: 당신의 경력에 가장 많은 영향을 준 책이나 논문 혹은 사람이 있다면요? 워드: 카버 미드(Carver Mead)와 린 콘웨이(Lynn Conway)의 『VLSI 설계(VLSI Design)』에서 아주 많은 영향을 받았습니다. 대학에서 전기 공학을 공부할 때는 하나의 집적 회로 위에 앤드(AND) 게이트 하나를 어떻게 만들 수 있는지 배웠습니다. 미드와 콘웨이의 책에서는 컴퓨터 프로세서 전체를 어떻게 만들 수 있는지 배웠지요. 카버 미드는 제 영웅입니다.
창준: 『VLSI 시스템 입문(Introduction to VLSI systems)』을 말하는 것이죠? 프로그래밍 책 중에서 가장 영향을 많이 받은 책을 고른다면요? 워드: 네. 사실 그 책은 시스템 설계자를 위해 쓰였지 전기 기술자를 위해 쓰인 것이 아니기 때문에 그 책을 컴퓨터 프로그래밍 서적으로 생각합니다. 아델 골드버그(Adele Goldberg)와 데이비드 롭슨(David Robson)의 『Smalltalk: The language and its implementation』 책의 초고에서 객체를 배웠습니다. 제가 스몰토크를 '딱'하고 깨친 것은 스몰토크를 리스프(Lisp)로 구현해 놓은 장을 읽었을 때였습니다. 애석하게도 이 장은 책이 출판될 때 빠져 버렸습니다 *. 그 장을 읽기 바로 전 주에 제럴드 서스만과 가이 스틸의 '인터프리터의 기술(Guy Lewis Steele, Jr. and Gerald Jay Sussman. "The Art of the Interpreter or, the Modularity Complex(Parts Zero, One, and Two)". MIT AI Lab. AI Lab Memo AIM-453. May 1978.)'이라는 메모를 읽었는데, 그 장은 이 메모에 대한 훌륭한 보완이 됐습니다.
∗ 인터뷰에서 책에 빠져 있다고 말하는 부분은 Blue Book에서 볼 수 있다.
Part5: Fixed Point 계산과 고차 함수
2007년 8월 7일
Fixed Point 계산
함수를 반복적으로 적용하는 경우에 대해 생각해 보자. 함수를 여러 차례 적용하는 방식에는 반복(iteration)과 재귀(recursion)가 있다(재귀를 순환이라고 부르는 사람도 있다. 번역하는 사람들마다 다른 용어를 사용하는데 재귀와 반복을 되풀이와 되돌이로 표현하는 용례도 있다).
SICP의 시작부에는 제곱근의 해를 구하는 예제가 나온다. 제곱근을 구할 때 뉴튼 계산법(Newton's Method)을 사용하는 것으로 방정식의 해를 구하는 방법이다. 방정식은 뻔하다. 초기값 x
로부터 시작하여 방정식의 해를 근사법으로 추론하는 방법이다. 해는 x
의 값과 추론한 근사값의 중간에 있다고 가정하자. 만약 해를 구하는 함수가 있다고 하면 이 함수를 여러 번 적용하면 해를 구할 수 있을지 모른다. 문제는 이 예제가 어렵다는 것이 아니라 책의 초반부에 나온다는 점이다.
우선 SICP 책에서 루트를 구하는 간단한 코드를 보자. 이 코드는 근사치를 구하여 이 근사치의 제곱이 원하는 해에 근접하는지를 조사하는 것이다. 핵심적인 코드는 근사치 guess
가 원하는 값에 충분히 가까우면 guess
를 리턴하고, 아닌 경우 improve
로 guess
값을 계산하여 다시 자기 자신을 호출하는 5줄짜리 sqrt-iter
함수다. improve
는 x
와 guess
로 새로운 guess
값을 만든다.
매우 간단하지만 상당한 정밀도까지 해를 구할 수 있다. SICP(http://mitpress.mit.edu/sicp/full-text/book/book.html) 1장에는 코드에 대한 자세한 설명이 나오고 뉴튼 계산법은 미적분학을 배운 사람들에게는 쉬운 문제다. 수치해석까지 공부한 사람들은 훨씬 많은 예들을 알고 있을 것이다.
>
그림 1. x=cos(x)의 그림
위의 식에서 중요한 것은 적당한 x에 대해 sqrt-iter를 여러 번 적용하면 해가 나온다는 것이다. 이런 예제들 중에 fixed point의 방법론을 적용하는 문제들이 있다. 그것은 f(x) = x
를 만족시키는 조건에서 초기값 x0
으로 시작한 문제들이 수렴하는 값 x
가 있다는 것이다. 그렇다면 반복적으로 함수를 적용할 때 다음은 어떤 값으로 수렴할 것이다.
>
경우에 따라 f는 아주 여러 번 적용될 수 있고 몇 번만 적용될 수도 있다. 때로는 무한정으로 적용될 수도 있을 것이다.
함수를 여러 번 적용하여 라디안으로 표현한 코사인 함수에서 x=cos(x)
를 만족하는 해를 찾는 것을 그림 1에서 보여주고 있다. 해는 중앙의 점으로 수렴한다(수렴은 리아푸노프 안정성이나 위상평면에서 해의 존재 유무나 궤적 문제로 복잡하게 풀어나갈 수도 있다). 어떤 시스템은 일정한 범위에서는 수렴한다는 것이 알려져 있다. 비슷한 예들은 매우 많으며 자연계에서도 관찰된다. 비선형 동력계 또는 카오스 패턴으로 나타난다. 로렌츠 어트랙터나 다른 여러 가지 스트레인지 어트랙터(strange attractor)들이 자연계에 존재한다는 것이 밝혀졌다. 해들은 특정한 값이나 일정한 범위에 있는 수많은 해를 갖는다.
수렴하는 조건이나 식들이 수식만이 아니라 기호나 집합일 수도 있다. 컴퓨터에서는 데이터들을 수렴하는 방법이 있을지도 모른다. 표현 방식은 여러 가지지만 단순한 반복문이나 재귀 코드나 다를 것이 없다. 여러 번 적용하는 것에서 일정한 패턴이 나오는 것을 기대할 수 있다. 만약 f가 수학의 함수가 아니라 리스프의 함수인 경우 재귀 형태나 반복 형태나 형식상의 큰 차이는 없다. 안에서 어떤 방법으로 구현되는지가 중요하다. f를 그냥 람다 함수라고 생각해도 마찬가지다. 또 어떤 함수를 fixed point로 만드는 변환 T를 만들고 새로이 변환된 함수가 수렴하기를 기대해 볼 수도 있다.
어떻게 보면 별다른 내용이 없는 수학 이야기 같지만 생각하기에 따라 생각할 거리를 던진다. 상징적으로 어떤 함수를 재귀적으로(또는 반복적으로) 사용하여 기호를 처리하거나 계산을 하는 것은 수렴 여부에 관계없이 다음과 같은 함수를 적용하는 하나의 패턴으로 생각해 볼 수 있다.
이런 방식으로 표현하면 평상시 사용하는 자바나 C++ 코드와는 다른 모습으로 생각해 볼 수 있다. 반복이나 재귀는 그저 함수를 여러 번 적용하는 것일 수 있다. 실제 코드에서 중간에 있는 f는 실제로 조건에 따라 내부의 함수 g나 h를 부를지도 모르며 리턴되는 값의 형태도 다양하다(fixed point의 성질과 적용에 대해 조금 더 생각해 보고 싶은 독자들은 서스만과 아벨슨의 강의 비디오 7a의 뒷부분을 보면 궁금증이 풀릴 것이다. Y 연산자의 문제나 fixed point가 수렴하는 문제들에 대한 통찰을 제공한다).
리스프 머신의 기본 아이디어
브라이언 하비(Brian Harvey)라는 해커가 있다(http://www.cs.berkeley.edu/~bh/). 1960년대 말 MIT 졸업 후 AI 연구소에서 일한 적이 있다. 현재는 버클리에서 강의를 하고 있다. 'Simply Scheme'이라는 책의 저자이기도 하다. 버클리 로고(LOGO) 개발자이기 때문에 로고에 관심이 있는 필자는 가끔 하비의 홈페이지를 들여다본다.
이번 글에 하비가 나온 이유는 SICP 강의를 하기 때문이다. 이 강의 동영상은 공개되어 있으며(이 글을 쓰는 현재 유튜브에서 볼 수 있다) SICP 저자인 서스만과 아벨슨의 강의와는 또 다른 느낌의 강의를 하고 있다. 서스만의 강의보다는 느슨하게 진행되지만 새로운 관점을 보여주고 있다. 하비의 강의를 보고 새로운 시각에서 스킴과 SICP를 바라보는 사람들도 있었다고 한다. 필자도 강의를 보면서 많은 것을 배웠다. 학부 저학년을 대상으로 하는 강의로 가벼운 마음으로 들을 수 있다.
강의 주제 'Interpreter'에서는 리스프의 인터프리터에 대해 설명한다. 한마디로 인터프리터는 만능 기계(universal machine)이라는 것이다. 필자의 첫 번째와 두 번째 글이 인터프리터를 만드는 것으로 시작했으므로 독자들은 람다 계산법을 실행하는 특이한 프로그램에 대해 이미 알고 있으며 함수 apply와 eval에 대해서도 이미 알고 있을 것이다.
그림 2에서 (lambda(x)(+ (* 2 x) 3))
을 인터프리터가 읽으면서 람다를 수행하는 기계처럼 변하고 7을 받아 결과 값이 나오는 모습을 보여주고 있다. 인터프리터는 주어진 식에 따라 변신을 한다. 식을 읽은 인터프리터는 마치 (2x +3)을 수행하는 기계처럼 변신하는 것이다. ((lambda(x)(\plus (* 2 x) 3)) 7)
은 17
을 리턴한다.
>
그림 2. 람다식 그림
그림 2에서 칠판의 오른쪽 그림은 인터프리터 기계에 람다식과 계산할 값을 제공하는 개념을, 왼쪽 그림은 람다식을 읽은 인터프리터가 2x+3
을 계산하는 기계로 변한 것을 설명하고 있다.
그림 3은 식 (lambda (x) (+ (* 2 x) 3))
을 cons cell 구조로 그려본 것이다. 당연히 식은 리스트 구조이지만 내부적으로는 이런 모습이라는 것을 보여주고 싶었다
>
그림 3. =(lambda (x) (+ ( 2 x) 3))= 리스트*
인터프리터 역시 지난번에 보았듯이 조금 커다란 리스트다. 리스트로 만들어진 리스프 인터프리터 기계는 리스트로 만들어진 식을 읽고 결과를 리턴한다. 이 시스템에서 모든 것은 리스트다. 자료구조 역시 간단한 아톰이 아니라면 리스트이며 결과 역시 아톰이 아니라면 리스트다. 변수표도 리스트이며 환경이라고 불리는 변수 값 찾아보기의 프레임들 역시 리스트로 표기한다(최적화를 위해 실제로는 리스트가 아니지만 리스트로 표기한다).
리스트를 읽고 리스트를 만들어내는 프로그램도 리스트이며 이런 일을 모두 주관하는 인터프리터마저 리스트다. 이미 만들어진 리스프의 인터프리터를 이용하여 다른 리스프를 만들어내는 메타서큘러 인터프리터 역시 새로운 리스트를 원래의 인터프리터가 읽고 만들어낸 또 하나의 리스트일 뿐이다. 그런데 그 리스트는 실제로 작업을 한다!
앞의 비유를 들자면 인터프리터 기계에 새로운 인터프리터 코드를 넣었더니 기계가 새로운 기계로 변신한 경우다. 그러니 만능 기계라는 말은 맞다. A4 1장으로 만든 개념적인 인터프리터 안에 내재된 특성치고는 놀라운 것이다. 그리고 람다 계산법의 특성에서 도출되는 결론이지만 코드와 프로그램은 잘 구분되지 않는다(연재 3회의 글 가운데 람다를 소개한 부분을 살펴보라).
고차 함수(Higher Order function)
SICP 책의 앞부분인 1.3에 나오는 글이면서도 상당히 어려운 부분이다. 우선 고차 함수라고 번역할 수 있는 이 함수는 하나 또는 그 이상의 함수를 인수로 취하거나 결과값으로 함수를 내어주어야 한다. 컴퓨터보다는 수학에서 더 적절한 비유를 찾을 수 있다. 미분연산자는 함수를 받아 다른 함수로 내어준다. 함수형 프로그래밍 언어에서 많이 쓰이는 map
함수 역시 고차 함수다. 이를테면 함수 f를 입력으로 받아 개별적인 요소들에 대해 계산한 결과 값을 돌려준다.
독자들도 알다시피 함수형 프로그래밍(functional programming)은 함수의 계산(evaluation)만으로 프로그래밍하며 상태(state)를 갖거나 데이터 값을 변경하지 않는 것이다. 간단히 말하면 함수형 프로그래밍에는 대입 연산이라는 것이 없다. 그러나 명령형 프로그래밍(imperative programming)은 상태 변화에 기반을 둔다. 리스프에는 원래 대입 연산이 없었다. 나중에 대입 연산이 구현되었으나 함수형 언어처럼 사용할 수 있다. 스킴 역시 마찬가지다.
고차 함수를 사용하면 함수 대입이나 변환에서 상당한 유연성을 제공하는 것이 분명하다. 여기에 든 예제들은 주로 수식 위주지만 기호와 리스트, 다른 함수가 제공하는 지연된 답들마저도 고차 함수를 이용하여 표현할 수 있다. 앞서 예를 든 반복문의 적용 패턴도 일종의 고차 함수처럼 바라볼 수 있다.
고차 함수의 정의와 무관하지 않은 몇 가지 패턴이 있다. 우선 프로시저가 다른 프로시저의 인자로 작용하는 경우를 생각할 수 있다. 다음 식은
>
아래와 같은 모습의 프로시저로 구성할 수 있다(공통된 패턴이 있다는 것은 유용한 추상화가 가능하다는 증거이기도 하다. 일단 양측을 잘 살펴볼 필요가 있다).
위 식에서 term
과 next
는 함수이면서 실제로 함수의 인자이자 이름처럼 넘겨졌다. C와 같은 언어라면 함수의 포인터를 넘기는 것으로 비슷한 일을 할 수 있지만 제약이 있을 것이다. 리스프 계열 언어에서는 용법의 사소한 차이는 있어도 함수 그 자체가 다른 함수의 인자가 된다. 밑의 식은 1부터 10까지의 정수를 그냥 더하는 것이다. 제곱이나 세제곱 아니면 다른 복잡한 함수도 쉽게 적용할 수 있다. identity
대신 square
나 cubic
이 붙은 함수를 정의하고 적용하면 되는 것이다. term
과 next
에 해당하는 함수를 바꾸어주는 것만으로도 많은 일을 할 수 있다.
그러면 (sum-integers 1 10)
은 55
를 리턴한다.
두 번째는 람다 함수 사용이다. sum
을 만드는 프로시저 코드 안에 직접 람다를 사용하여 좀 더 유연하게 프로시저를 만들 수 있다. pi-sum
은 a
에서 b
까지 계산마다 +4
씩 증가하고 이 값을 (lambda (x) (/ 1.0 (* x (+ x 2))))
에 적용하고 결과를 더해가는 프로시저다.
세 번째로는 프로시저를 일종의 메서드처럼 사용하는 방법이다. 프로시저가 다른 프로시저와 복합적인 방법으로 사용되는 것은 물론이고 다른 프로시저를 컨트롤하는 프레임워크가 되는 것이다.
네 번째는 프로시저의 결과 값 자체가 새로운 프로시저가 되는 것이다. 앞서 말한 것처럼 미분연산자를 통과한 함수가 전혀 다른 것이 되는 일 같이 어떤 프로시저는 입력으로 받은 프로시저 자체를 새로운 프로시저로 되돌린다. 지면상 이 방법의 예제를 적는 것은 생략하지만 SICP의 1.3.4에 간단한 예제가 있다. 만능 기계처럼 움직이는 인터프리터 예제도 이 범주에 속한다(사실 1.3을 한 번에 이해할 수 있다면 정말 지나치게 총명한 독자라고 할 수 있다. 이 장에는 보물찾기처럼 많은 것들이 숨어있다).
앞의 예들은 일반적 언어에서는 자주 사용되지 않는다. 강력한 권한 때문에 안전성이나 코드 효율 면에서 복잡한 문제들이 발생할 소지가 있다. 그래서 보통 프로그래밍 언어들은 조작되는 요소들에 제약을 걸고 있다. 가장 적게 제한 받는 요소들을 first-class의 요소들이라고 한다. first-class 요소들의 '책임과 권리'는 다음과 같다.
- 변수를 사용하여 이름을 부여할 수 있고
- 프로시저의 인자로 넘겨질 수 있으며
- 프로시저의 결과 값으로 되돌려질 수 있고
- 자료 구조 내에 포함될 수 있다.
독자들의 머리를 아프게 하기 때문에 좋은 예라고 볼 수는 없지만 SICP 1.3장은 뉴튼 계산법으로 제곱근을 계산하는 방법들을 여러 가지 방법으로 추상화하여 만들어낼 수 있음을 보여준다. 하나의 아이디어를 표현하는 여러 가지 우아한 방법이 있다는 것을 알려준다.
보충에 가까운 내용으로 피터 노빅의 글 'A Retrospective on Paradigms of AI Programming'에 있는 'PAIP로부터 배운 것들' 부분에서는 다음과 같은 내용이 일종의 교훈이라고 전한다. 모두 52개나 되지만 지금까지 이야기한 것과 관련이 있는 것은 몇 개 안 된다.
- 람다 함수를 사용하라(앞서 설명했다).
- 실행시에 새로운 함수(closure)를 만들어내라(closure는 다음 회에 설명할 강력한 프로그래밍 패러다임이다).
- 문제 해결에 가장 적절한 표기법을 사용한다.
- 여러 가지 프로그램에 같은 데이터를 사용한다.
- 구체적이어야 한다. 추상화를 사용하라. 간단해야 한다. 주어진 도구를 사용하라. 애매해지면 안 된다. 시종일관하라.
- 매크로를 사용하라(정말 필요한 경우에만).
- 20개 또는 30개 정도의 중요한 데이터 타입이 있는데 이들에 대해서는 잘 알고 있어야 한다,
위의 내용 중 클로저라는 것은 OOP에 나오는 개체의 인스턴스를 만드는 과정의 리스프 버전과 비슷하나 람다 함수의 특성을 이용한다. 지면상 다음 회에서 설명해야 한다.
>
다음 회에는 'GÖdel, Escher, Bach'라는 책에 대해 잠시 설명할 것이다. 상당히 어려운 책이지만 많은 컴퓨터 사람들의 영감을 자극했다(퓰리처상도 받았고 이미 고전이다). 책에는 여러 가지 테마가 섞여있지만 필자가 이야기하려는 것은 그 중의 일부만으로 내용 설명이 아니라 영감을 줄지 모르는 몇 가지 화두를 설명하기 위해서다.
>
그 다음은 해커들의 힘이라는 것이 결국 집단지능이라는 민스키의 이야기를 해야 할 것이다(사실 민스키와 매카시의 AI 연구소에 모여있던 프로그래머들을 해커라고 부르면서 해커리즘이 생겼다). 그리고 민스키를 소개하면서 그의 대표작인 'Society of Mind'를 이야기하지 않을 수도 없다. 이 책은 앞서 말한 'GÖdel, Escher, Bach'와는 또 완전히 다른 무엇이 들어있다.
Part6: 괴델, 에셔, 바흐 그리고 해커리즘의 쇠락
2007년 9월 18일
들어가며
지난번에는 람다에 대해 간단히 적었다. 람다 계산법이 이상하게 보인다 해도 너무 심각하게 생각하지 않아도 된다. 당장은 이해할 필요도 없다. 이해에는 많은 시간이 걸릴지도 모르며 이해하지 않아도 별 문제는 없을지도 모른다. 컴퓨터를 잘 몰라도 IT 개발 업무에 종사하는 사람이 상당수 있는 것처럼 람다를 잘 모른다고 큰 문제가 생기는 것도 아니다. 실제 코드를 짜는 일에 람다 계산법을 적용하는 일도 별로 없다. 리스프는 이미 람다를 충실하게 구현하고 있다.
필자의 임무는 리스프 여행 가이드이므로 너무 어렵지 않게 이야기를 적어 나가야 한다는 것을 안다. 읽다가 호기심이 생기는 부분이 있으면 찾아볼 자료들은 얼마든지 있을 것이다. 이번 이야기는 람다가 중요한 화두로 등장하는 역사적 사실의 배경이다. 그 시작은 서스만과 스틸의 스킴(Scheme)이 람다 페이퍼들과 함께 나왔고 람다 페이퍼가 나온 이유는 이들이 액터 모델(actor model)을 이해하고 싶어 했기 때문이다.
액터 모델은 칼 휴이트(Carl Hewitt)라는 총명한 학자가 제시한 모델이다. 사실 이번 이야기는 액터 모델보다도 휴이트가 주인공이다.
지난번에 람다와 프로세스에 대한 이야기를 했기 때문에 이번 주제는 복잡한 것들과 동시적인 것들에 대한 암시들이다. 다른 것들도 많겠지만 고전적인 것들을 꺼내 보았다. 필자의 책상 오른쪽 옆구리에는 오랫동안 놓여있는 책 몇 권이 있는데 가끔씩 꺼내 읽으면서 많은 생각을 한다. 실용적이지도 않으며 결론은 없지만 암시적인 임무는 충실히 하는 책들이다. 컴퓨터에 관련된 책 중에는 마빈 민스키의 『The Society of Mind』와 더글러스 호프스태터의 『Gödel, Escher, Bach』(이하 GEB)가 있다. 몽상가 기질이 있는 필자는 가끔 책에 나오는 구절들을 생각하며 묘한 상상을 하곤 한다. 책의 제목들은 화두라고 볼 수 있으며 상식적으로 생각하는 내용에 도전하기도 한다. 우리가 무엇을 안다는 것이 무엇인가에 도전하기도 하고 지능이라는 것이 무엇인가에 대한 화두도 던진다.
알 것 같기도, 모를 것 같기도 한 화두들은 전염성이 있으며 선승들이 몇 개의 화두를 붙잡고 고민하는 것처럼 머릿속에 오래 남아 오만 가지 생각을 불러일으키기거나 다른 아이디어를 싹 틔우기도 한다. 사람들은 화두를 갖고 생각과 논쟁을 거듭한다.
이상한 고리
출판된 지 거의 30년이 되었지만 이 책을 기억하는 사람들이 많고 아직도 팔린다. 이 책은 제목(Gödel, Escher, Bach) 그대로 세 사람의 생애와 작품들을 모아놓고 여러 가지를 보여준다. 형식적 규칙(formal rule)과 자기참조(self-reference)를 통해 의미가 없는 것들로부터 의미가 만들어지는 현상을 설명한다. 통신이라는 것과 지식을 전달하고 저장하는 방법들, 그리고 기호를 통한 표현의 한계를 설명한다. 이 책은 1999년 한국어판이 나왔다.
그림 1. GEB 표지
이렇게 어려운 주제를 다뤘지만 책이 인기를 얻은 것은 이들을 흥미롭게 설명했기 때문이다. 그래서 사람들은 이 책을 보고 많은 생각을 하게 되었다. 주제들은 우리의 생각과 주위에 흔한 것들이라 막상 생소한 것도 없었다. 생각을 글로 옮기고 철학적으로 생각하면 어려워진다. 말이라는 것이 원래 어려운 것이다. 책을 너무 심각하고 진지하게 생각하면 안 된다. 저자는 유희적 요소를 동원했다.
아킬레스와 거북(『이상한 나라의 앨리스』를 쓴 루이스 캐럴의 "거북은 아킬레스에게 무엇을 말했는가"에서 캐릭터를 따왔다) 그리고 게(바흐의 'crab canon'에서 따왔다)가 등장하고 이들이 나누는 대화가 책의 내용이다.
해커들이 오랫동안 프로그래밍의 요소를 놓고 고민했듯 철학자들은 자기참조라든지 형식적인 규칙들을 놓고 고민해왔다. 음악이나 미술에도 이런 요소들이 있다고 한다. 언어에도 있다. 나("I")는 자기참조적인 낱말이다. 자기참조라고 하니 철학적인 용어처럼 보이지만 프로그래밍에서는 언제나 접하는 요소다. 재귀(recursion)를 말한다.
그림 2. 자기참조를 상징하는 그림. Ouroboros라고 부른다. 자기 꼬리를 삼키는 뱀(또는 용)의 그림이라고 한다. (대체이미지)
몇 가지 요소를 잘 묶어 이들이 생기는 법칙을 부여하면 의미가 생긴다. 음표는 의미가 없지만 바흐가 이들을 묶어 악보로 만들어 놓으면 의미가 있다고 생각되는 음악이 나온다. 생성규칙 역시 지난 칼럼에서 이야기했다. 리스프의 리스트 표현에 앞서 설명한 s-expression이 생성규칙의 하나다.
GEB에 나오는 바흐의 카논과 푸가는 이것을 한층 더 발전시킨 것이다. 카논은 여러 개의 성부로 되어 있다. 이들은 마음대로 나오는 것이 아니라 주제는 제1성부에 있고 2, 3성부는 같은 주제를 반복한다. 카논은 주제를 스스로에게 다시 적용하는 것이라고 한다. 주제들이 화음을 이루는 법칙이 있기 때문에 음표는 하나의 선율의 일부이며 화성학적으로나 선율적으로 다른 방식으로 작용해야 한다. 듣는 사람은 이들의 연관관계를 파악하며 카논의 의미를 파악한다. 그러니까 정교한 프로그래밍과 다를 것이 별로 없다. 악보는 컴퓨터의 소스코드처럼 기록되며 이것을 연주하는 것은 컴퓨터가 소스코드를 실행하는 것과 같다.
의도적으로든, 의도하지 않든 사람들은 음표의 강약과 화음 속에서 음악에 푹 빠진다. 높은 집중도와 몰입 그리고 지적인 활동이 요구되는 활동인 것이다. 같은 일을 하는 여러 개의 쓰레드나 프로세스 활동처럼 각 성부의 악보는 빨라지기도 하고 느려지기도 한다. 바흐의 천재성은 6성부의 푸가도 만들어 낼 수 있었다(때로는 즉흥적으로 푸가나 카논을 만들기도 했다). 6성 푸가는 당시의 정신사에 깊이 관여한 프리드리히 대왕에게 헌정되었다. 고도의 인공물(artifact)인 음악은 사람들의 정신을 고양시키는 프로그래밍 활동으로 볼 수 있다. 카논이나 푸가를 들으면서 실제로 이런 일이 일어나는 경우도 있기 때문에 여러 개의 성부가 만드는 효과를 프로그래밍이라고 부르지 못할 것도 없다.
책의 다음 주제는 무한히 상승하는 것처럼 보이는 카논을 예로 든다. "무한히 상승하는" 카논은 C 단조에서 시작하여 D, E 단조로 조를 바꾸고 다시 정확히 C 단조로 돌아온다. 이 과정은 무한히 지속할 수 있을 것 같은 느낌을 주고 저자는 이상한 고리의 전조를 읽어냈다. 이상한 고리는 호프스태터가 최근에 쓴 책의 제목(『I am a Strange Loop』)이기도 하며 GEB를 관통하는 중요한 주제이기도 하다. 위계질서의 층위가 무한히 올라가거나 내려갈 것 같지만 예기치 못하게 위계질서의 층위는 처음으로 돌아오는 현상을 "이상한 고리(strange loop)"라고 불렀다. "헝클어진 위계질서(tangled hierarchy)"가 나타난다.
그래서 에셔가 나온다. 에셔의 그림들은 이율배반, 착시 또는 중의성에 기반을 두고 있다. 어떤 그림들은 때로 "이상한 고리"를 보여준다. 그림에 나오는 폭포는 단순한 고리이지만 몇 개의 층위가 나오는 그림들도 있다. 그 중 하나가 에셔의 손 그림이다. 그림에서 손은 다른 손을 그린다. 서스만과 아벨슨의 SICP 비디오 강의에서도 이 그림을 인용했다. 한쪽 손의 이름은 eval이고 다른 손에는 apply라고 적혀 있다. 어떤 것이 우선적인 층위인지는 애매하다. 리스프 인터프리터의 프로그래밍은 apply로 시작해도, eval로 시작해도 같은 결과에 도달했다(필자의 두 번째 칼럼에서 다루었다). 코드상으로는 eval이 더 높은 순위처럼 보이지만 아니다(더 많은 층위의 문제들도 있다. 그것은 책을 보는 수밖에 없다).
(대체 이미지)
이율배반 논리에 대한 "이상한 고리"도 존재한다. 그것은 괴델의 불완전성 논리에 의해서였다. 앞에 나온 폭포가 "이상한 고리"인 것과 같이 괴델에 의해 수학적 논증 그 자체가 도마에 오르게 되었다. 결국 수학적 논증에도 같은 주제가 적용된다는 것을 알게 되었다(크레타섬 사람이 "모든 크레타인은 거짓말쟁이다"라고 말하거나 "이 명제는 거짓이다"라고 하는 명제 그 자체가 문제가 되겠다).
>
(대체 이미지)
GEB의 이야기 거리들
책을 다 소개한다는 것은 바보짓에 가까우니 두세 가지 주제만을 생각해 보자. 그냥 생각해보기만 하는 것이다.
1부의 6장 "정보는 어디에 자리잡고 있는가"에서는 음반의 골에 음악 정보를 담은 LP 음반과 전축을 예로 든다. 각각 정보 저장자(information-bearer)와 정보 발현자에 해당된다. 음반은 음을 충실하게 복원한다. 그러나 경우에 따라 정보를 정보 저장자에서 "꺼내는" 일에는 노력이 필요하며 정보를 꺼내기 위한 노력에 투입되는 정보가 정보 저장자보다 많은 경우가 있다고 한다.
컴퓨터를 하드웨어 입장에서 본다면 프로그램 파일로부터 실제 프로그램을 실행하기 위해 엄청난 노력을 해야 한다. 운영체제를 수행해 자원을 관리하고 프로그램을 읽어 일일이 수행시켜야 한다. 어쩌면 간단한 프로그램보다 더 거대한 시스템이 준비되어 있다고 볼 수도 있다. 모든 준비가 끝나야 프로그램은 제대로 수행될 수 있다. 라이브러리가 하나만 잘못되어도 문제가 나타나며 시스템이 수행하는 것이 애플리케이션에 있는 모든 정보도 아니다. 이렇게 생각한다면 정보가 어디에 있는지는 예상보다 어려운 문제다.
책에서는 DNA의 이중나선에 있는 정보의 발현을 다룬다. DNA의 정보는 (ATGC의 염기서열로 되어 있는) 일종의 리스트 구조처럼 보이는 자료구조다. DNA의 정보는 적절한 시기에 발현(revelation)된다. 바흐의 카논 악보가 악기의 소리를 내는 것과 비슷하지만 이 리스트는 수없이 많은 단백질을 만들어내고 이들의 동시적인 활동이 생명 활동이다. 하나씩은 어떻게 해석하더라도 이 암호가 다른 것들과 어떻게 통신하는가에 대해서는 잘 모른다. 그냥 기능한다는 것만 알 뿐이다. 몇 가지는 그 사이클이 밝혀졌음에도 불구하고 이들의 상호작용은 아주 복잡한 악보의 연주 활동이라고 말할 수 있다. 수백 수천 개의 성부로 구성되어 있는 악보와 마찬가지다. 이 악보는 설계자가 누구인지도 모르며 주석조차 달려있지 않다. 이 리스트는 전체가 다 필요한 것이 아니라 특정 부분들이 해석되며 시작과 끝은 일정한 염기 서열로 표시되어 있다. 그러면 인터프리터를 닮은 리보솜이 DNA를 복사하여 작업을 시작한다.
저자는 정보의 발현을 주크박스에 비교했다. 주크박스의 버튼을 누르면 음악이 연주된다. 그렇다면 세포는 거대하고 복잡한 주크박스라고 볼 수 있다. 단추의 버튼으로 트리거 된 기나긴 체인이 발동할 수도 있다. 그리고 이 체인은 다른 주크박스의 스위치를 트리거 하기도 한다. 주크박스의 출력이 발라드가 아니라 더 복잡한 주크박스의 제작법을 다룬 가사처럼 보일 수도 있다. DNA의 일부가 RNA로 전사되어 이 RNA로 단백질을 만들면 다른 스위치가 트리거 된다. 그래서 표현형은 잠재적으로 DNA에 잠재되어 있던 정보의 발현이라고 본다(이를테면 특정 부위의 몇 개의 염기서열은 대머리나 곱슬머리의 표현형을 만들 수도 있으나 표현형의 발현은 주위 환경이나 다른 유전자의 영향과 무관하지 않은 경우가 많다).
머리가 아파지기 시작한다. 그러면 DNA에는 생명에 관계된 모든 정보가 전부 포함되어 있는가? 그렇다고 볼 수도 있지만 아닐 수도 있다. DNA의 정보를 꺼내는 모든 과정은 DNA에 있는 것이 아니라 DNA 자체에는 코드화되어 있지 않은 세포 속의 복잡한 활동에 의해 일어난다. 그런데 그 세포는 DNA를 복제해 물려받으며 만들어진 것이다. 정보는 DNA에 있는가 아니면 다른 어디에 있는 것인가? 이들 모두인가? 닭이 먼저인가 달걀이 먼저인가처럼 이상한 고리가 있는 것처럼 보이지 않는가? 그리고 이 메시지는 보편성이 있는 메시지인가? 메시지의 층위는?
만약 이것이 컴퓨터 내부에서 발생하는 사건이라면 어쩌면 비교적 간단한 몇 개의 IPC나 RPC가 만들어지고 자동으로 트리거 되는 몇 개의 프로세스가 수천 개의 반응을 조절하는 것인지도 모른다. 만드는 일에 필요한 정보는 분석하는 일의 정보보다 훨씬 작을 수도 있다.
다른 문제는 층위에 관한 것이다. 10장의 기술층위와 컴퓨터 체계에서는 컴퓨터의 층위를 설명한다. 같은 장의 앞과 뒤 부분에는 많이 인용되는 '전주곡(prelude)'과 '개미 푸가'가 소개된다. 이 장은 사회생물학으로 유명한 E. 윌슨이 직접 초고를 읽고 감수해 주었다고 한다. 나중에 유전자 알고리즘을 만든 존 홀랜드에 의해 여러 번 다시 인용되었다.
'전주곡' 부분에서는 전주곡이나 푸가의 한 성부 역시 하나의 곡이라고 할 수 있다. 등장인물들은 이들을 개별적으로 따라가면서 전체를 듣는 것이 가능하지도 않으며 반대로 전체를 들으며 개별적인 성부를 듣는 것이 가능하지도 않다는 것을 이야기한다. 그럼에도 음악을 감상할 수 있는 것은 두 가지를 무의식적으로 또는 자연스럽게 오가기 때문이라고 이야기한다. 분명히 하나의 성부와 다른 성부들을 같이 듣는 것은 다른 일이다.
층위가 저절로 생기는 것은 아니다. 무엇인가에 의해 만들어진다. 예를 들어 운영체제의 층위는 프로세서 안에 있는 개개의 트랜지스터보다는 훨씬 위에 있다. 컴퓨터는 사람들이 거의 모든 것을 다 알고 있다고 생각해도 모르는 부분이 많아진다. 밖에서 보면 하나의 생물처럼 보이는 개미 군집은 작은 로봇과 같은 개미로 만들어져 있다. 개미들은 미약하고 지능이 없지만 개미 군집은 훨씬 더 많은 일을 할 수 있다.
개미핥기, 게, 아킬레스 그리고 거북이가 나온다. 이들의 대화가 개미들의 푸가와 오버랩되며 이야기가 진행된다. 이들은 처음에 전일주의(holism)와 환원주의를 놓고 입씨름을 벌인다. 전일주의는 전체가 그 부분들의 합보다 크다는 생각이며 환원주의란 각 부분과 그 '합'의 본성을 알아야만 전체를 알 수 있다는 생각이다. 개미는 컴퓨터의 트랜지스터처럼 생각해 볼 수 있다. 비교적 동작을 잘 알고 있기 때문에 개미를 잘 안다는 생각이 들고 이들이 하는 일 역시 잘 알려져 있지만 전체로서의 개미 군집은 이들의 모습과 다르다(가끔 물방울과 파도는 다르다는 식의 표현을 쓰기도 한다).
개미핥기는 환원주의자로 등장한다. 그러면서 자기는 개미 군락에 대한 신경외과 의사로 자처한다. 개체인 개미들은 개미핥기가 먹어 치우지만 개미 군락의 이상한 신경증상이 좋아진다고 주장한다. 더군다나 개미 군락에 하나의 인격체처럼 이름을 붙이기도 한다. 개미핥기는 개미를 마구 잡아먹는다는 다른 대화자들에게 한 개미 군락을 핥아먹었던 일을 힐러리 아줌마와 재미있게 대화했다고 이야기했다. 정말 대화는 즐거웠다는 것이다.
모두 경악하는 가운데 개미핥기는 자신의 기술이 오랜 훈련과 관찰 끝에 얻어진 숙련된 기술이라고 말한다. 개미 한 마리에게는 무서운 일이겠지만 자신은 개미의 카스트(개미들끼리의 신호를 통해 만들어진 카스트)에 새로운 질서를 부여했다고 말한다. 개미 군락의 업그레이드이며 "하나의 지식조각"을 만들어 주었다고도 말한다. 필자는 하나의 악보의 음표와 같은 개미와 성부와 같은 카스트 그리고 전체 악보와 같은 군락의 모습을 떠올린다. 이들은 모두 의미가 있다. 카논이나 푸가와 마찬가지로 책을 읽다 보면 떠오르는 이상한 마음속의 그림에 독자들이 흥미를 느낄지도 모른다(서점에 있는 호프스태터의 『이런 이게 바로 나야!』라는 책에 '전주곡'으로 나와 있는 부분만 읽어도 될 것이다. 읽어보면 많은 생각을 할 수 있다).
우리가 작업하는 층위는 컴퓨터에서 어느 정도 위치가 될 것인가를 생각해 볼 수 있다. 컴퓨터는 네트워크로 물려 컴퓨터 군락의 일부가 된다. 어느 정도의 카스트에 살고 있는지 한번 생각해 보는 것도 재미있을 것이다.
>
The Society of Mind
GEB 서문에는 책에 깊은 영향을 준 사람의 하나로 마빈 민스키가 나온다. 존 매카시와 테리 위노그라드는 책의 조언자로 나온다. 민스키의 대중적인 책을 이야기할 때도 되었다. 민스키의 대중적인 책은 『The Society of Mind』였다. 민스키는 마음이 없는 에이전트(agent)들이 모여 마음이 만들어진다고 보았다. "복잡한 모든 것은 서로 통하지 않는 부분들로 해체되어야 한다", "걷는 것을 통제하는 뇌의 부분은 집으로 걸어가는 것인지 일하러 가는 것인지 알고 싶어하지 않는다"와 같은 아리송한 이야기들을 말해왔다.
1970년대 초기에 민스키와 페퍼트는 『The Society of Mind』의 이론을 형식화하기 시작했다. 어린이 심리(developmental child psychology)에서의 통찰력과 경험을 AI 연구와 결합하려는 것이었다. 『The Society of Mind』에서는 지능(intelligence)이라는 것이 하나의 메커니즘의 산물이 아니라 다양한 종류의 에이전트들간의 상호작용에 의해 이루어진다고 제안한다. 다른 작업들은 근본적으로 다른 메커니즘을 요구하기 때문에 다양성이 필요하다고 주장하였다.
과학에서는 환원주의자가 우세하지만 현실에서는 여러 가지 이론과 법칙 같은 것을 만들어내는 소설가가 이겼는데 그 이유는 현실에서 작용하는 법칙이 과학 법칙보다 많기 때문이라고 했다. 우리가 지능이라고 생각하는 것의 정의가 항상 변하는 것이고 지능이라고 부르는 것을 만들어 내려면 법칙이 과학에서 생각하는 것보다 더 많다는 것이다. 과학자나 엔지니어는 가급적 간단한 법칙으로 설명하기를 좋아한다. 현실은 그렇지 않다. 얽혀있어서 원인과 결과와 법칙은 애매하다. 지능은 이런 곳에서도 돌아가야 한다.
1986년에 민스키는 『The Society of Mind』를 출간하였는데, 그 책에서는 하나의 페이지에 완결된 에세이가 270쪽에 걸쳐 서로 연결되어 있어서 민스키의 아이디어 구조를 반영하였다. 각 페이지는 어떤 심리적 현상을 설명하기 위해 하나의 메커니즘을 제안하거나 다른 페이지에서 제안한 해결책 도입에 필요한 문제를 제기한다. 책은 정식으로 출판되기 전에 초고들을 넘겨주었기 때문에 출판되기도 전에 여러 곳에 인용되었다. 민스키는 단편들로 이루어진 책을 사람들이 싫어한다고 말했다. "사람들은 줄거리가 없다는 이유로 그 책을 싫어합니다", "줄거리가 없다는 것이 줄거리입니다" 그것은 마음이나 정신처럼 독립적인 능력이 모여있는 것이라고 설명했다. 그러니 필자처럼 이 책이 재미있다고 생각하는 사람에게는 문제될 것이 없다. 그럼에도 불구하고 생각할 것은 많은 책이다. 독자를 놀라게 만드는 글귀들은 도처에 널려있지만 다음과 같이 말한 적도 있다.
"지능을 만드는 마술과 같은 트릭은 무엇인가? 그 트릭은 바로 아무런 트릭도 없다는 데 있다. 지적인 힘은 방대한 다양성이 그 바탕이며 어떤 완전한 단일 원리가 바탕이 아니다."
그렇다면 인공지능 책들이 점차 두꺼워지는 것도 이해가 간다. 인공지능이라고 부르는 것들 역시 지능처럼 필요에 따라 여러 가지로 정의할 수 있다.
그 다양성을 뒷받침하는 것은 자연에서는 쉽게 복제되어 엄청난 수를 자랑하는 생물들이다. 이들은 어떤 방법으로든 서로 연결되어 있는 긴 영향력의 팔을 갖고 있다. 서로 연결되어 클러스터처럼 일정한 일을 하는 조직을 만들어내기도 하고 생각하는 조직을 만들어 내기도 하며 가르쳐 준 것도 아닌데 생각을 하기 시작한다. 아마 프로그래밍으로 이런 것들을 만들어 낼 수 있다면 재미있을 것이다.
민스키의 어록은 끝이 없겠지만 1980년대에 앨런 케이와 비바리움 프로젝트를 진행할 때 이런 말을 한 적도 있다고 한다. 비바리움은 가상의 세계에서 생물을 시뮬레이트하는 프로젝트였다. "나는 엉성한 수정 프로그램에 찬성합니다. 버그를 발견하면 그것을 고치지 마십시오. 또 다른 코드를 써보고 버그가 발생하는가를 보고 그를 단념하십시오. ... 코드를 생물학적으로 소거하는 것은 비겁한 방법으로, 그것이 죽음이라는 것을 여러분은 알고 있습니다. ... 죽지 않는 것의 문제는 무한한 버그를 만든다는 것입니다."
재미있다고 생각할 수 있다(실제로 생물들은 끊임없이 이런 일을 반복하고 있다). 아무튼 해커리즘의 전성기는 이런 재미있는 사람들이 많던 시절이었다. 민스키는 초기 해커들이 있던 연구실의 책임자였다.
다시 해커리즘으로 돌아와: 집단 해커리즘과 리스프
스티븐 레비가 쓴 『해커』의 1부는 다음과 같이 시작한다.
그들은 9층의 테크스퀘어 시절을 해킹의 황금시대라 불렀다. 대부분의 시간을 우중충한 기계실과 근처의 사무실에서 보냈다. 그들은 터미널 근처에 바짝 모여 앉아 터미널에 줄지어 나타나는 초록색 코드의 문자열을 바라보면서 셔츠 주머니에 꽂혀 있던 연필을 꺼내 들고 프린트되는 출력용지에 표시를 하면서 자기들만의 은어로 무한루프나 잘못된 서브루틴에 대해 떠들어댔다.
방을 가득 메운 이 기술의 수도승 무리들은 그 어느 때보다도 낙원에 가까이 다가가 있었다. 이들의 이타적이고 무정부주의적인 태도야말로 생산성과 PDP-6에 대한 열정을 높일 수 있는 힘이었다. 예술과 과학 그리고 놀이가 한데 어우러져 프로그래밍이라는 마술적 행위로 녹아 들어갔다. 이 과정에서 해커들은 컴퓨터 안에서의 정보흐름에 대해서는 전지전능한 달인의 경지에 이르렀다. 이것은 다른 의미에서 (프로그래밍뿐만 아니라) 자신들의 삶 속에 포함된 모든 오류까지도 자랑스럽게 수정해내는 과정이기도 했다.
그러나 해커들이 '현실세계'라는 감상으로 뒤덮여 있는 시스템으로부터 아무런 간섭도 받지 않고 그들의 꿈을 삶을 살아가려는 한 그들의 꿈은 실현될 수 없었다. 그린블러트와 나이트가 자신들의 비호환성 시분할 운영체제(ITS)가 우월하다는 것을 외부인들에게 알리는 데 실패한 예는 작은 한 그룹의 사람들이 해커 주위에 몰입된다 해서 모든 해커들이 기대하고 있는 거대한 규모의 변화를 이루어낼 수 없다는 것을 말해주는 좋은 보기였다. 사람들은 컴퓨터를 해커들과 같은 열정으로 바라보지도 않았다. ...
또 해커들의 의도를 바람직하거나 이상적인 것으로 보지도 않았다.
민스키와 함께 AI 연구소를 이끌던 세이무어 페이퍼트는 나중에 『미디어랩』의 저자 스튜어트 브랜드에게 다음과 같이 말했다.
"해커들은 컴퓨터 과학의 전선을 창조해내고 있었습니다. 설계 내역도 없이 그들은 빠르고 지저분하게 프로그램 작성을 시도합니다. 최초의 컴퓨터 그래픽과 최초의 워드프로세서, 최초의 컴퓨터 게임 그리고 최초의 시분할 방식을 만들어냈습니다. 그들에게 무엇을 해보라고 해야 먹혀 들지 않았습니다. 하지만 관심을 끌 수는 있지요."
이들은 컴퓨터에 헌신한 집단으로 볼 수 있다. 특별한 보상이 주어지지 않았음에도 불구하고 초기 해커들은 컴퓨터를 사용하면서 황홀해했다. 자신이 하고 싶은 일에 충실한 해커들을 민스키는 "영웅"이라고 불렀다.
그러나 초기의 집단정신이 점차 퇴조하기 시작했다. 1980년대가 되면서부터 무엇인가를 개발하면 돈이 된다는 것을 안 해커들이 연구소를 떠나기 시작했다. 자신이 하고 싶은 것을 계속하는 해커들은 줄어들었다. 해커들의 황금시대는 PDP-6과 그 PDP-10과 같은 컴퓨터에서 일어났다. 세월은 바뀌고 있었다. 1세대 해커들이 컴퓨터에 빠져있던 기간에도 세상은 빠르게 변했다. 많은 알고리즘과 인공지능 프로그램이 나왔으나 그것은 시작이었다. 해커들이 만든 프로그램은 '빠르고 지저분하게' 세상에 태어날 수 있었으나 그 뒤에 숨어있는 프로그래밍의 요소들은 역시 나름대로 어렵고 복잡한 문제들이 숨어있었다. 컴퓨터 과학이 빠르게 발전함에 따라 열정적으로 추구했던 프로그램 만들기가 결국 하나의 프로그래밍 도구를 만드는 수준이라는 것을 깨닫고 만 해커들도 있었다. 이들의 역할은 군사작전의 교두보 확보와 같은 것이었는지도 모른다.
1980년대가 되자 몇 명 남아있지 않은 해커들마저 빠져 나갔다. 리스프 머신 상용화를 놓고 벌인 싸움 때문에 그나마 AI 연구소에 남아있던 해커들이 양분되었기 때문이다. 당시 리스프 머신은 적당한 가격에 비교적 좋은 성능의 워크스테이션을 제공할 수 있었기 때문에 주문이 밀릴 정도로 인기가 좋았다. 해커들이 리스프가 가장 효과적으로 수행될 수 있도록 설계한 기계인 리스프 머신이 잘 팔리자 해커들의 창조성을 상업화하여 창조성을 살리면서도 돈도 잘 벌 수 있는 회사를 만들면 어떨까 하는 아이디어가 나오면서 해커들이 양분되었다. 그 후 리스프 머신도 인기가 떨어지자 사람들은 뿔뿔이 흩어졌다. 두 회사 어디에도 속하지 않은 사람은 민스키와 스톨만 정도였다. 집단적인 정신도 흩어졌다.
리스프 머신의 운영체제 ITS의 작성언어인 리스프의 운명도 달려있었다. 리스프 머신의 인기가 시들해지자 리스프는 시스템 언어가 아니라 시스템에서 돌아가는 언어의 하나가 되었기 때문이다. 폴 그레이엄은 언어의 인기에 대해 말했다(http://www.paulgraham.com/popular.html). 프로그래밍 언어의 인기에 영향을 미치는 외부 요인 하나를 인정하면서 시작해 보자. 인기를 얻기 위해 프로그래밍 언어는 인기 있는 시스템의 스크립트 언어(scripting language)가 되어야 한다. 포트란과 코볼은 초창기 IBM 메인프레임의 스크립트 언어였다. C는 유닉스의 스크립트 언어였다.
리스프는 크게 인기 있는 언어가 아니다. 그것이 크게 인기 있는 시스템의 스크립트 언어가 아니기 때문이다. 지금 인기가 남아 있다면 그 역사는 그것이 MIT의 스크립트 언어였던 1960년대와 1970년대로 거슬러 올라간다. ...
프로그래밍 언어는 고립하여 존재하지 않는다. '해킹하다'는 타동사이다. -- 해커는 보통 무엇인가를 해킹한다. -- 실제로 언어들은 무엇을 해킹하는 데 쓰이는가에 따라 판가름 난다. 그래서 인기 있는 언어를 고안하고 싶다면, 언어 외에 다른 것들도 공급하거나, 그 언어가 기존 시스템의 스크립트 언어를 대체하도록 고안해야 한다.
리스프는 한때 중요한 도구였고 많은 사람들이 리스프로 꿈꾸고 리스프로 숨 쉰 적이 있었다. 그만한 도구가 없었기 때문이다. 지금은 리스프의 힘은 상대적으로 줄어들었다. 리스프 커뮤니티는 예전의 힘을 찾지 못하고 있다. 결정적인 이유는 아직 사람들을 다시 끌어 모을 특별한 계기가 없기 때문이다. 사람들은 프로젝트의 성공이 장비의 성능이나 대수가 아니라 참여한 사람들의 열정과 관심에 좌우된다는 것을 알고 있었다. 결국은 사람이 먼저 프로그래밍되어야 한다고 생각할 수 있다. 개미의 푸가처럼 말하면 많은 악보가 활발하게 연주되는 상황이 되어야 집단지능의 힘이 나온다.
리스프가 요즘은 별로 인기가 없음에도 불구하고 리스프를 소개한 이유는 아직도 SICP처럼 훌륭한 책들이 교과서로도 쓰이며, 리스프에 관심을 가진 사람들이 있다고 생각하기 때문이다(민스키는 다양성이 지능의 근본이라고 말했지 않은가?). 리스프만의 강력한 표현력과 상상력이 필요한 부분도 있고 리스프를 통해 구현된 도구도 많이 남아있다. 직접 사용하지는 않더라도 리스프를 이해하면서 얻을 수 있는 것이 많다고 생각했기 때문이다.
필자가 독자들에게 바라는 것은 호기심 또는 관심이다.
컴퓨팅 기술의 원형 탐험
저자 안윤호
Part1: 간단한 레지스터 머신에서 시작해 보기
2008년 3월 18일
들어가며
지난해 썼던 「해커 문화의 뿌리를 찾아서」에서는 해커와 리스프(LISP) 구현 이야기를 하다가 『괴델, 에셔, 바흐』와 『Society of Mind』라는 책을 소개하는 것으로 매듭지으면서 그 이야기들이 하나의 화두 역할을 하기를 바라는 소망적인 생각을 전했다.
「해커…」의 주제가 된 SICP는 『컴퓨터 프로그램의 구조와 해석』이라는 제목으로 책이 번역, 출판되었으니 관심 있는 독자들은 원서와 같이 읽어보아도 좋을 것이다. 필자는 때로 번역이 참으로 편리함을 주는 수단이라는 생각을 한다. 일정 수준 이상의 번역은 아무래도 술술 읽히는 신비한 능력이 있다. 그래서 번역이 중요하며 책의 번역이 일어나면 읽는 사람도 늘어나며 동시에 관심이 있는 사람도 늘어난다. 책의 내용을 떠나 책 자체가 논의나 토론의 대상이 되기도 한다. 그래서 필자는 SICP 번역이 중요한 의미가 있다고 믿는다.
지난해 연재에서는 시작하면서 「The Root of LISP」라는 글을 재료 삼아 리스프 인터프리터를 만드는 방법을 설명했는데 이는 역사적으로도 리스프 언어의 시작이었다. 매카시는 포트란에서 자극을 받아 람다(lambda) 계산법을 실행하는 방법을 생각하기 시작했고 그 생각이 실제로 구현되었던 것이다.
이 주제가 SICP 4장을 중심으로 한 메타서큘러 계산기(metacircular evaluator)의 실제 골격이다. SICP를 학교에서 가르칠 때 1장에서 3장만 가르치는 관행으로 볼 때 약간 파격적인 구상이나, 반대로 독자들이 다른 접근법을 통해 통찰력을 갖고 리스프를 바라볼 수 있다고 생각해 적어 보았던 것이다. 리스프에 관심이 있는 독자들은 지난 연재 1회와 2회 내용을 (다시) 읽어 보기를 바란다.
이번 연재는 비슷한 주제의 반복이다. 그러나 조금 현실적인 목표를 잡아 잘 다루지 않지만 중요하다고 판단되는 주제들을 생각해 보려 한다. 그 주제들은 레지스터 머신, 상태(state), 컨티뉴에이션(continuation), 클로저(closure) 같은 것들이다. 어려운 것들이라 깊이 들어가지 않고 중요한 주제의 화두와 그림을 제시하고 간단한 설명을 더하는 수준이 될 것이다. 이해가 잘 되지 않아도 너무 걱정할 필요는 없다. 원래 쉬운 주제가 아니기 때문이다. 그러나 이 용어들이 무엇인지 알아둘 필요는 있으며 생각할 거리로만 남겨두어도 된다(교양 과목처럼 생각해도 된다. 교양 과목은 중요한 주제를 다루지만 이를 아주 심각하게 받아들이는 순진한 사람들은 별로 없기 때문이다). SICP 책을 이해하기 위해 필요하기도 하지만 두고두고 생각할 주제가 되기에 충분한 중요성이 있다.
레지스터 머신 단순하게 보기
SICP 5장 제목은 '레지스터 기계로 계산하기'다. 책의 지은이들은 이 기계를 설계한 이유가 일종의 신비(mystery)를 풀기 위한 것이라고 말한다. 신비라는 것은 잘 알 수 없는 일들이 일어나는 것이다. 원인과 결과의 인과 관계가 복잡해 보일 때가 그렇다.
이때는 문제들을 단순화해 바라볼 필요가 있다. 지난번의 리스프 계산기(evaluator)는 그 자체가 다른 리스프 위에서 구현된 것으로 자세한 부분은 눈에 보이지 않는다. 그래서 신통하게도 A4 한 장도 안 되는 소스 코드로 매카시의 코드를 구현했다. 그러나 정작 최초의 구현을 맡은 프로그래머는 엄청난 양의 어셈블리 작업에 매달렸다. 하부를 이루는 apply의 마지막 부분들을 모두 손으로 만들어야 했던 것이다. 그러니 마법은 없었다. 신비스럽게 보이는 재귀(recursion)나 apply, eval 모두 마지막에는 기계어로 만든 낮은 레벨의 함수에서 실행되어야 했던 것이다.
신비로운 부분을 없애려면 어셈블리나 기계어로 바꾸는 과정으로 마지막 부분을 설명할 수 있을 것이다. 기계마다 하드웨어가 다르나 본질적인 요소는 실제로 간단하다. 그래서 등장하는 레지스터 기계는 이 계산기를 더 낮은 수준으로 구현하기 위해 만든 가상의 기계다. 몇 가지 기계 요소의 동작을 정의하는 것이 레지스터 기계 설계의 시작이다. 추상적이지만 중요한 부분을 모두 보여주는 편리한 교재이기도 하다.
당연한 것이지만 신기하게 문제를 푸는 정교한 알고리즘도 끝에 가면 수없이 많은 단순한 기계적인 조작의 연속이자 합으로 되어 있다. 독자들은 레지스터 기계를 살펴보는 것으로 반복(iteration)과 재귀의 차이를 알 수 있고 continue 레지스터와 스택(stack)을 이용하는 방법의 차이도 알 수 있다. 기계어를 따로 배우거나 기계어 코드에 정통하지 않아도 핵심 부분을 배우는 데는 지장이 없다. 따라서 레지스터 머신은 좋은 비유이자 모델이다. 조금 더 비유를 하자면 하노이의 탑(tower of hanoi)과 같은 수학적 예제가 있다. 알고리즘을 배우는 시간에 재귀 문제의 모델로 곧잘 등장한다. 복잡하게 보이는 고리 옮기기는 끝에 가서는 단순한 기계적 옮기기로 변한다. 정교한 알고리즘이나 복잡한 프로그램도 끝에 가서는 레지스터나 스택에서 순수하게 기계적인 조작을 하는 단순한 조작으로 변한다. 그렇지 않으면 연산이 일어나지 않는다. 그러니 끝까지 도달하면 신비는 없어지는 셈이다.
SICP 5장은 레지스터 머신을 정의해 밑바닥에서 일어나는 일들을 정의하는 작업과 리스프 언어 구현을 통합하는 작업을 그려보는 큰 그림을 그리고 있다. 어셈블러도 만들고 컴파일러도 만들며 인터프리터와 분석기(analyzer)도 다룬다. 그리고 결국은 언어를 실행하는 하나의 완결된 주제를 다루게 된다. 이른바 대통합인 셈이며 커다란 도전이기도 하다.
5장 시작은 매우 간단한 기계를 생각하면서 시작한다. 레지스터 머신이다. 간단하게 접근할 수 있는 몇 개의 저장소(register)를 사용하는 기본적인 방법을 그려보고 있다. 그림 1은 책의 5.1장에 나오는 GCD(최대공약수)를 구하는 기계의 그림이다. 알고리즘은 a를 b로 나눈 나머지가 0이 될 때까지 같은 작업을 되풀이(반복: iteration)하는 알고리즘을 기계로 구현해 본 것이다(책의 원문은 http://mitpress.mit.edu/sicp/full-text/book/book.html 에서 읽어 볼 수 있다).
>
그림 1. GCD를 구하기 위한 데이터패스의 그림
GCD 기계는 별다른 것이 없다. 기계는 같은 동작을 되풀이한다. a를 b로 나눈 나머지를 t에 저장하고 이 임시 결과의 값은 b로 옮긴다. b는 a로 이동한다. 반복 동작으로 나머지가 0이 되도록 기계적으로 계산할 따름이다. 나머지가 0이 되면 a가 최대공약수가 되는 알고리즘이다.
>
그림 2. GCD 기계의 컨트롤러 그림
기계는 크게 두 가지 요소로 나눌 수 있다. 일종의 설비나 부품과 같은 레지스터 a, b, t와 중간의 데이터 흐름을 제어하는 밸브 역할을 하는 a←b, b←t, t←r 같은 것들로 이들을 데이터패스(data path)라 부른다. 그리고 어디엔가 이들을 제어하는 제어기(controller)가 있다. 그림 2가 제어기의 그림이다. 간단한 플로 차트로 되어 있다. 그림 3은 서스먼과 아벨슨의 강의 동영상 9a의 화면으로 데이터패스와 컨트롤러의 관계를 보여주고 있다. 이 추상적인 기계의 요소들은 모두 정의가 가능하다. 정의된 소스 코드는 쉬운 내용으로 책의 5.1에 나온다. 필자는 이 부분을 다룬 지은이들의 동영상 9a를 한번 보기를 권장한다(동영상을 받을 수 있는 곳은 http://swiss.csail.mit.edu/classes/6.001/abelson-sussman-lectures/ 다).
>
그림 3. GCD 기계의 데이터패스와 컨트롤러의 관계를 설명하는 서스만
그림 4는 팩토리얼을 구하기 위한 코드를 구현하고 있다. 팩토리얼 코드는 다음과 같다.
>
그림 4. 팩토리얼을 구하는 기계의 데이터패스. 스택과 continue 레지스터와 관련된 데이터패스가 추가되었다.
이 코드를 구현하는 그림에서 앞의 GCD 기계와의 중요한 차이는 스택을 구현한다는 점이다. 스택이 있으면 스택에 데이터 값을 저장할 수도 있으며 저장할 수 있는 값 중에는 컨트롤러가 되돌아올 장소를 저장할 수 있다. 그래서 continue라는 레지스터가 도입되는데 이 레지스터는 goto 명령을 수행하기 위한 장소를 지정하고 저장된 몇 개의 label 사이를 오간다.
GCD와 팩토리얼의 코드는 차이가 거의 없다. 그러나 팩토리얼의 코드는 중간 계산값과 n을 저장해야 하는 문제가 있다. 그래서 데이터패스는 그림 4처럼 조금 복잡하게 변했다. 복잡하게 변하는 이유를 책의 구절을 인용하면 다음과 같다.
팩토리얼의 경우 팩토리얼을 구하는 문제의 일부분으로 안에 있는 팩토리얼 값을 구해도 이 값은 원래 구하려 한 팩토리얼 값이 아니다. n!을 구하려면, (n-1)!에 n을 곱해야 한다. GCD 설계를 흉내 내서 n 레지스터 값을 하나씩 줄여 팩토리얼 기계를 되풀이해서 돌리는 방법으로 풀려고 한다면, 다음 단계에서 n은 이미 바뀌어 버렸기 때문에, 결국 결과를 구할 때 곱해야 하는 예전 n 값을 쓰지 못한다. 따라서 안에 있는 문제를 푸는 팩토리얼 기계를 따로 만들어야 한다. 이렇게 따로 만든 팩토리얼은 또 다시 그 안에 있는 팩토리얼 문제를 풀려고 또 다른 팩토리얼 기계를 만들어야 한다. 이런 방법으로 계속해서 각각의 팩토리얼 기계는 안에서 다른 팩토리얼 기계를 만든다.
장황한 설명처럼 들리지만 기계를 여러 벌 만들면 해결되는 문제다. 그런데 필요한 기계를 여러 벌 만드는 것은 항상 어렵거나 불가능했다. 과거에는 어려운 문제였지만 튜링머신의 아이디어가 나온 이후에는 간단한 기억장치를 이용하는 것으로 문제들을 해결할 수 있다는 것을 알게 되었다.
보통 이런 문제는 스택을 사용하여 해결한다. 스택에 데이터를 저장하고 정확히 되돌릴 수만 있으면 한 벌의 설비(데이터패스)로 많은 계산을 할 수 있기 때문이다. 스택이 무엇인지 모르는 사람은 별로 없겠지만 스택 사용 방법이 쉬운 것은 아니다. 반드시 자료구조로서 스택으로 한정할 것도 아니다. 하지만 필요한 데이터는 어떠한 형태로든 저장되어야 한다(스택은 컴퓨터가 나오고 10년 정도 지난 후 발명되었다).
예제의 팩토리얼 계산은 서브루틴을 풀듯이 해결한다. 이 방법은 제어기가 안에 있는 문제를 풀고 난 다음, 원래 문제를 이어서 풀려고 할 때, 알맞은 명령 위치로 되돌아갈 목적으로 continue 레지스터를 사용한다(continue는 C 언어의 label과 goto라고 생각하면 된다). continue 레지스터에 저장된 엔트리 포인트로 돌아가는 팩토리얼 서브루틴을 만들 수 있다. 서브루틴이 호출될 때마다 n 레지스터와 continue를 저장(아래 소스 코드의 save 명령으로 push와 같다고 생각하면 된다)하고 나중에 값을 되돌릴(소스 코드의 restore로 pop과 같다) 수 있다. 계산의 '단계'마다 continue 레지스터를 사용한다. 팩토리얼 서브루틴은 낮은 레벨의 문제를 불러낼 때 그 위치(안에 있는 문제를 막 풀기 시작한 곳)를 새로운 값으로 하여 continue 레지스터에 넣는다. 또한 자신을 호출한 곳으로 돌아가려면 다시 예전 값이 필요하다. 그 예전 값은 스택에 저장되어 있다.
갑자기 기계어 같은 컨트롤러 코드가 나와서 황당하지만 별다른 것이 아니다. 처음에는 모두 기계어를 썼으니 이런 코드들을 손으로 만들어 보는 것이 당연했을 것이다.
구현 전략은 간단하다. 제어기 시퀀스는 n과 continue에 되도는 계산(recursive call)을 하기 전 값을 저장하고 베이스 케이스인 n=1이 될 때까지 fact-loop 루프를 진행한다. continue와 n 레지스터는 스택에 쌓인다. n=1이 되면 base case에서 val 레지스터에 1을 지정하고 스택에 지정된 continue 위치로 돌아간다(계산이 끝날 때까지는 after-fact로 지정되어 있다). after-fact에서는 계산을 끝내고 난 다음 되돌아가 넣어둔 값을 다시 꺼내도록 한다. 스택을 모두 소비하면 마지막 fact-done으로 가는 구조다(물론 책에 나오는 내용과 다른 구현 방법도 있다). 복잡하게 보이는 내용은 9a의 동영상에 포함되어 있다. 서스만이 칠판에 그리는 내용을 보면 독자들은(영어로 설명하는 장벽을 넘어) 무슨 말을 하려는지 알 수 있을 것이다. 그러니 동영상을 보라!
평범했던 레지스터 기계에 스택 연산을 추가한 팩토리얼 기계를 만들면서 재귀적 계산 방법의 일반적 전략을 살펴볼 수 있다. 책에 나오는 피보나치 수열을 포함하여 더 복잡한 예제들도 비슷한 전략으로 풀어낼 수 있다. 반복이나 재귀나 구현의 차이는 근소하고 스택과 continue 레지스터 사용 전략이 바뀔 뿐이다. 완전히 기계적인 내용이다.
결론적으로 스택이나 레지스터를 잘 조작할 수 있으면 아주 복잡한 문제들도 풀어낼 수 있다. 하부 레벨에서는 단순한 기계적 작업이 더 많아지는 것뿐이다. 기계적으로는 분명히 그렇다. 이 아이디어를 조금 더 확장해 보자. 스택과 continue 레지스터와 비슷한 설비를 갖는 조금 더 복잡한 기계를 만들면 원하는 연산을 수행할 수 있는 범용 처리기가 될 것이다. 우리는 물론 매일 이 기계를 사용하고 있다. 바로 컴퓨터의 프로세서다.
손으로 그려 돌려본 레지스터 머신을 이해했다면 그 다음 행보는 기계의 동작을 수행해보는 것이다. 간단한 레지스터 머신을 시뮬레이트하는 코드는 비교적 쉽게 만들 수 있으며 기계 내부 동작은 투명하게 보인다.
우선 모형 기계(시뮬레이션하려는 기계 부품에 해당하는 데이터 구조)를 만들기 위해 레지스터 기계의 설명(specification)을 사용하는 프로시저가 필요하다. 이 기계를 만들려면 레지스터의 이름을 정의하고 조작들을 정의하며 제어기도 필요하다. 그 형식은 다음과 같다.
레지스터, 연산, 제어기를 받아 모형 기계를 내놓는다. 그리고 모형 기계를 조작해 실제 기계를 시뮬레이트하는 프로시저들이 필요하다.
기계의 시뮬레이트된 레지스터에 값을 넣는다.
기계의 시뮬레이트된 레지스터에서 값을 꺼낸다.
기계의 제어 시퀀스 시작에서 출발해 시퀀스 끝에 이르면 멈춘다. 정말 단순하다. 나중에 얼마나 복잡해질지 모르게 될 코드의 시작은 이렇게 단순하다. 코끼리를 냉장고에 집어넣는 방법(냉장고 문을 연다 -> 코끼리를 냉장고에 넣는다 -> 냉장고 문을 닫는다)처럼 단순한 느낌이 드는 이 간단한 식을 구현하는 일은 SICP 1장부터 4장까지 설명한 모든 내용의 총체다. 기계를 만드는 make-machine의 와 가 긴 코드로 바뀌면서도 깔끔한 구조를 유지하며 독자들에게 포기하지 않고 생각을 계속할 수 있도록 설명을 이어나가는 것이 SICP의 매력이다.
간단한 프로세서 만들어 보기
글을 쓰면서 느낀 것은 책에는 빠진 과정이 하나 있다는 점이다. 교과 과정이나 지면상 어쩔 수 없겠지만 바로 프로세서 구현이다. 프로세서가 레지스터 머신이라는 사실이 이해돼도 코드로만 적어보면 실감이 나지 않는다. 실제로 간단한 프로세서를 만들어 보는 것이 이해가 더 빠를 것이다. 실감이 나지 않으면 이해의 성취감도 줄어든다.
그래서 궁리 끝에 정말 간단한 프로세서 구현을 설명하는 것이 좋겠다는 생각을 하게 되었다. 일반적으로는 디지털 회로로 구성하는 것을 하드웨어 예제와 코드로 간단히 구현하는 것이 좋을 것으로 생각하고 있다. 간신히 돌아갈 수 있는 프로세서를 C의 유사 코드와 그림으로 설명하려는 시도다. 아마도 긴 시도가 될 것 같다.
>
그림 5. 아주 단순한 구조의 프로세서. 이런 프로세서를 실제로 만들어 보려 한다.
레지스터 머신을 간단하게 설명했지만 수학적인 추상적 기계의 계보는 매우 복잡하다. 관심이 있는 독자는 위키백과의 레지스터 머신을 읽어보는 것도 좋을 것이다.
width="80%" >
그림 6. http://www.computerhistory.org/ 에 나오는 사진. 1946년 무어의 연구실에서 시퀀셜 제어기를 만들고 있다. 이런 종류의 제어기는 가장 간단한 레지스터 머신에 바탕을 두고 있다.
Part2: 작은 아이디어가 만들어낸 큰 차이
2008년 4월 15일
들어가며
영화 터미네이터를 보면 사이버다인이라는 회사에서 만든 최초의 터미네이터는 미래에서 타임머신을 타고 온 터미네이터의 부서진 부품에서 수거된 스카이넷 칩을 사용해 만들었다. 영화의 2부는 이 칩을 파기하려고 미래에서 다시 터미네이터가 온다는 줄거리다. 필자는 메타서큘러 계산기를 볼 때마다 터미네이터를 떠올리곤 한다. 배우는 입장에서 리스프(Lisp)를 만들어 보기 위해 리스프가 필요하다. 다만 최초의 리스프는 어셈블러로 만들어졌다.
전혀 실감나지 않는 이야기지만 컴퓨터를 만드는 일에는 컴퓨터가 필요했다. 칩을 시뮬레이트하기 위해서도 컴퓨터가 필요하고, 칩에 코드를 적용하고 테스트하기 위한 개발 도구에도 필요하며, 프로그래밍 언어를 실행하는 데도 컴퓨터가 필요하다. 과거에는 컴퓨터가 없었거나 개발 환경이 없어서 손으로 컴파일했다(주관적인 의견이지만 컴퓨터를 발전시키는 일에는 발전된 모습의 컴퓨터가 필요하다. 상상력과 비전이 언제나 미래의 컴퓨터가 된다. 요즘은 앨런 케이가 말한 것 같은 상상력 증폭기가 시들한 모습이다).
과거 8비트 시절에도 희소한 자원이었던 컴퓨터가 필요했다. 8비트 컴퓨터를 만들기 위해 더 좋은 컴퓨터가 필요했다. 수행되는 컴퓨터의 모델이 필요했다. 이를테면 빌 게이츠의 베이직 인터프리터는 미니컴퓨터를 이용해서 8비트의 8080을 모의 실험해서 나왔다. 그 다음은 실제로 구현하기 위해 MITS라고 하는 기업이 있는 앨버커퀴라는 마을로 갔고 인터프리터와 컴파일러를 파는 MS라는 회사가 나왔다. MS-DOS의 기본 모형에 가까운 CP/M 역시 DEC(Digital Equipment Corporation)의 미니컴퓨터가 개발의 원형을 제공했다. 마이크로프로세서는 인텔의 테드호프 팀에서 만들었는데 이들의 머릿속에는 이미 PDP-8이라는 컴퓨터가 있었다.
그러니 필자가 말한 터미네이터 이야기가 아주 동떨어진 것은 아니다. 어디엔가 원형이 숨쉬고 있고 필자의 취미는 원형을 포함해 그 뒷이야기를 추적하는 것이다. 생물학 배경을 갖고 있는 필자의 적성은 비교해부학(Comparative Anatomy)이다. 일의 윤곽은 과거를 잘 헤집어보는 것으로 쉽게 파악된다. 큰 영양가는 없겠지만 어떤 사물을 생각하는 데에는 시절과 인연을 잘 살피는 일이 중요하다고 한 선사(禪師)의 말이 생각난다. 이 선사는 식어가는 재에서 불씨를 찾지 못하겠다고 하는 제자를 깨우친 후 이 말을 했다. 잘 살펴보니 불씨는 남아있었던 것이다.
아무튼 컴퓨터를 만들려면 컴퓨터가 있는 편이 편하다(위조지폐범이 돈을 만들려면 실제 화폐가 있어야 한다). 다행히 컴퓨터는 도처에 널려있다. 컴퓨터보다는 사람들의 시간이 희소한 자원이다. 이번 글은 32비트나 64비트 슈퍼컴퓨터(굴러다니는 PC)를 가지고 '원시적 컴퓨터' 기계를 만드는 방법을 생각해보는 리버스 엔지니어링이다. 레트로라고 보아도 좋겠다. 그 의도는 예상보다 컴퓨터라는 것이 별것 아니며 누구나 만들 수 있는 것이라는 생각을 갖도록 해보자는 것이다. 그러면 SICP 5장이 조금 더 쉽게 보일 것이다.
C나 파이썬으로 만드는 프로세서(오토마타 만들기)
레지스터 머신이라는 이름이 기억에 필요한 장치인 레지스터의 존재를 떠올리게 한다. 단순한 AND OR NOT으로 만들어진 장치와 절차적인 작업을 실행할 수 있는 장치의 차이는 기억장치의 존재다. 기억장치는 상태(state)를 기억한다. 디지털 기계의 상태라는 것은 별다른 것이 아니다. 1010111… 같은 값을 저장하고 기억하는 것이다. AND OR NOT의 단순조합으로으로 만들 수 있는 장치는 입력이 들어오면 바로 출력으로 변한다. 이를테면 다음과 같은 유사코드가 되겠다.
이런 정도로도 많은 일을 할 수 있겠지만(2진수의 가감산이나 논리연산 같은 것) 아주 복잡한 일은 시킬 수 없다. 입력을 하면 곧바로 출력을 내지만 연속적인 동작이나 몇 가지 정해진 일을 하는 것 같은 일은 할 수 없다. 일종의 오토마타가 필요했다.
논리회로나 전자장비가 발전하자 시퀀셜 머신이 중요한 주제로 등장했다. 예전에는 자동인형이나 오토마타로 불리던 것을 전기적으로 구현한 것이다. 수학적 이론은 그 이전부터 발전했다. 오토마타는 무엇으로 만들건 할 수 있는 일이 많았다. 기계식 계산기를 컴퓨터의 조상(오토마타)으로 보고 교과서에 예제로 올리는 역사적인 이유는 이들이 알고리즘을 구현하고 있었기 때문이다. 기계보다 전기가 오토마타를 만들기에 적합했다.
전기식 장치인 릴레이가 발전하자 기계식보다 할 수 있는 일이 훨씬 많아졌다. 어떤 릴레이는 상태를 기억할 수 있고 외부 신호에 따라 일정한 반복 작업을 수행할 수 있도록 조합할 수 있었다. 기계의 상태는 무한하지 않고 유한하며(FSM: finite sate machine) 따라서 고장이 나지 않는 한 간단한 조작들을 실행할 수 있었다. 시간이 지나자 릴레이들이 숫자를 더하거나 빼거나 곱셈, 나눗셈을 할 수도 있었다. 숫자를 세는 카운터를 만들 수도 있었고 펀치카드와 비슷한 것을 읽을 수도 있게 되었다(릴레이가 나오기 훨씬 전에 배비지의 기계는 기계적으로 비슷한 아이디어를 구현했다). 계산기를 만들 수도 있었다. 릴레이로 만든 시퀀스 제어기는 1970년대까지 중요한 산업용 제어기였다.
유한 상태 기계의 핵심은 몇 개의 상태라는 값을 가지고 이들을 오가는 방법이 정해져 있는 것이다. 상태는 특정한 값을 갖는 (반드시 이진수가 아니더라도) 보관장소가 있어야 한다. 기계의 핀조합이건 펀치카드건 릴레이건 일종의 기억장치와 같다. 릴레이 다음에는 진공관이 나와 더 편해졌지만 릴레이는 1 또는 0에 해당하는 전기적인 값을 갖는다. 진공관도 마찬가지였다.
상태를 기억시키는 방법만 확립되면 어떤 상태에서 다른 상태로 이동시키는 것은 비교적 간단하다. 2차대전이 시작될 무렵이 되자 알고리즘을 오토마타에 적용하는 방법이 여러 가지가 나오게 되었다. 간단한 시퀀스 조작을 적용하면 암호도 풀어 낼 수 있었다. 암호를 푸는 방법은 수학적인 것이지만 절차는 기계적인 적용이다.
튜링머신의 고안
오토마타를 잘 조합하면 되는 것인데 이 과정에 튜링의 고안인 튜링머신이 적용되었다. 당시에 상태를 기억하는 방법은 기다란 강철 테이프에 자기적으로 기록하는 장치를 사용하는 것이었다. 튜링의 기계는 독일의 에니그마 암호를 풀었다. 고속으로 돌아가는 강철 테이프가 메모리였으며 메모리에 기록과 판독을 할 수만 있으면 이론적으로는 어떤 계산이든지 못할 것이 없다. 튜링머신은 이런 식으로 생각할 수 있었다. 기계는 절차적인 연산을 되풀이하여 해를 구한다.
전자식 오토마타와 시퀀셜 제어기의 구조는 종이 한 장 차이였다. 그리고 그 다음에 나오는 컴퓨터와도 미세한 차이 밖에 없었다. 그러나 이 작은 차이들이 아주 큰 차이를 만들어냈다고 볼 수 있다. 컴퓨터 문명이라는 것이 모두 기계의 상태 변화를 이용한 계산에 의존하고 있다.
그러면 첫 단계로 상태를 갖는 기계를 생각하기 위해 다음과 같은 유사코드 프로그램을 생각해 보자. 먼저 상태 테이블을 만들고 다음과 같이 정의되어 있다고 가정하자. 논리회로 시간에는 모두 2진수로 배우지만 그것은 기계의 관점이고 프로그램에서는 제한이 없다.
그리고 다음과 같은 코드가 돌아간다고 생각하자.
기계는 state[0]에서 그 다음에 갈 번지수인 1을, stae[1]에서 번지수 2를 얻어낸다. 그 다음에는 state[2]에서 99를, state[99]에서는 100을 얻는다. 그리고 state[100]에서는 0을 얻는다. 그러면 수많은 배열 중에서 몇 가지 상태만을 확실하게 오간다. 이런 오토마타는 배열을 채워 넣기에 따라 카운터처럼 움직이고 몇 가지 상태를 오가면서 일을 할 수 있다.
초기의 컴퓨터나 시퀀스 제어기가 이런 식으로 계산한다고 하면 오토마타의 상태를 직접 코딩하는 일이 필요했고 하드웨어를 직접 만지는 것으로 코딩이 변하는 것이었다. 프로그래밍이란 하드웨어 프로그래밍을 의미한다. 상태변수를 정하기에 따라 아주 복잡한 계산도 가능하다. 컴퓨터에서 글자 하나를 바꾸는 것이 실제 기계에서는 회로를 바꾸는 번거로운 일로 변한다. 그래도 획기적인 변화였다.
잇따르는 진화
여기서 몇 가지 진화 내지는 변형의 가능성이 더 있다. 하나는 상태에 더해 출력신호를 덧붙이는 것이다. 조금 더 기계적인 느낌을 주기 위해 16진수를 사용해 출력을 표시해보자.
그리고 다음과 같은 코드가 돌아간다고 생각하자.
그러면 상태가 바뀔 때마다 outp[xx]로 신호와 비슷한 것을 출력할 수 있다. outp[xx]에 신호선이 물려있다면 변하는 신호를 볼 수 있을 것이다. 다른 방법도 생각해 볼 수 있다. 앞의 코드를 조금 바꾼다.
조금 더 확실한 코드가 필요하겠지만(이를테면 inp의 값을 인지하는 것이 address가 0이 되는 경우와 같은 조금 명확한 기준이 필요하며 잘못된 시퀀스에 있을 때에는 에러를 내거나 다시 0으로 돌아가는 것 같은 동작도 필요하지만 이해하는 데에는 이 정도로 충분하다) 위의 코드는 state1과 out1의 값을 적당히 채워 넣으면 inp의 조건에 따라 각기 다른 오토마타의 경로를 만들 수 있다. 출력도 바뀐다. state와 out의 배열을 추가하거나 더 정교한 루틴을 만들 수도 있다. 더 근본적인 해결책은 inp와 address에 대한 계산식을 만들어 그 필요한 state의 값을 구할 수 있다. out이나 outp의 값도 같이 계산할 수도 있지만 복잡하고 예측이 어려운 계산으로 흘러간다.
지금까지 설명으로 상태를 정의하고 적당한 입력과 출력을 정의한다면 복잡한 오토마타를 만들 수 있다는 것이 확실하다. 프로그래밍은 분명히 state, out, inp를 정의하는 것이다(아직까지 기계어나 어셈블리어 같은 것은 나오지도 않았다). 실제로 물리적인 구현으로는 하드웨어를 프로그래밍하는 것이다. 상태를 변경하면 이 복잡한 프로그램은 에니그마의 암호를 풀지도 모른다(실제로 암호를 풀었다).
그러면 추상적인 프로그램으로 숫자 바꾸기 게임을 하는 것은 그렇다고 치고 실제로 하드웨어로 만드는 방법을 생각해보자. 우선 간단한 기계라 상태가 256개 밖에 없다고 하자. 그러면 상태를 표시하는 데에는 8비트면(2^8=256) 가능하다. 그러면 inp와 address를 합친 데이터비트는 모두 8의 비트로 표시할 수 있어야 한다. 하드웨어를 만들어 본 적이 있으면 알겠지만 이 정도도 사실 쉽지 않은 과제다.
상태를 바꾸는 표를 하드웨어적으로 만들려면 논리회로 수업에 나온 카르노 테이블 같은 것을 만들어야 한다. 만약 가로축을 현재 상태(addrss와 inp의 상태), 세로축을 다음에 분기가 일어날 상태로 정한다면 8*8의 테이블을 만들어 이것을 AND OR NOT의 조합으로 만들어야 한다. 종이와 연필로 만든다면 대단한 도전이다. 하루 종일 걸리겠지만 8*8의 테이블을 채워야 한다(출력만을 만드는 outp를 별개로 친다고 해도 그렇다. 만약 out이 어떤 방법으로든 다시 입력에 반영된다면 outp의 비트만큼 테이블은 커진다). 4*4나 그 이상만 되어도 테이블에서 논리소자로 바꾸는 일은 노동이다. 이렇게 만든 논리회로의 값을 일종의 상태저장기인 플립플롭에 입력하는 것이 일반적인 제어기의 설계다. 예전의 디지털 전자 설계는 이런 회로들을 그리는 것으로 시작되고 끝났다.
그림 1은 무어머신의 구조다. 이 구조의 기계를 만든 무어(Alfred Moore)의 연구실은 나중에 에니악(ENIAC: Electronic Numerical Integrator and Calculator)을 만든 곳으로 디지털 로직 초창기에는 중요한 연구 장소였다. 그림에서 Compute Next state는 논리회로이며 Present State Memory는 단순한 몇 비트의 D-플립플롭으로 만든 것이다(그림 2). 앞의 프로그램의 Address 변수값은 Next State 신호선의 값이며 state[address]는 Present State라고 볼 수 있다. 아무튼 등가물을 만드는 것은 어려운 일이 아니다.
>
그림 1. 간단한 무어머신
>
그림 2. D-플립플롭
우리가 컴퓨터 프로그램으로 만드는 것은 간단한 일이었지만 실제 구현은 까다로운 편이다. 아예 표준적인 부속품조차 없던 1940년대와 50년대에는 논리소자 하나하나를 조립하는 일이 큰 작업이었다. 지난번 기사의 제어기 사진은 아마도 아주 간단한 제어밖에 할 수 없는 단순한 기계였을 것이다. 1970년대에 일반적인 TTL 소자의 부품이 쉽게 입수되던 시절에도 복잡한 논리회로를 만드는 것은 일종의 도전이었다. 같은 일을 릴레이로 만드는 일은 쉽게 실감이 나지 않을 정도다. 아주 단순한 회로도 어렵다.
이 일을 쉽게 만들게 된 것은 롬(ROM)을 사용하면서부터다. 복잡한 게이트를 일일이 만드는 것이 아니라 롬의 테이블에 값을 저장하면 되었던 것이다. 롬을 만드는 방법은 원시적인 방법으로는 배선을 직접 연결하거나 다이오드와 저항으로 매트릭스를 만들거나 전기적으로 퓨즈를 태우는 방식도 있고 바이오스(BIOS)를 만드는 플래시롬에 직접 저장할 수도 있다. 속도 차이는 있겠으나 실제로 독자들이 만들어볼 수도 있다. 요즘은 VHDL 같은 것으로 만들어낼 수도 있다. 아무튼 롬을 사용하면 설계는 그렇게 무서운 일이 아니다. 앞서 만든 프로그램의 상태를 롬에 집어 넣으면 롬은 그림 1의 Compute Next State에 해당하고 플립플롭은 롬의 출력에 그대로 물려진다. 그림 2의 D-플립플롭은 D의 입력이 clock에 들어오면 그대로 출력되는 소자로 클럭이 들어오면 Q는 그대로 롬의 번지수가 된다. 가장 간단한 회로다. 배열에 값을 지정하여 의미있는 동작을 만드는 것은 어려운 일이며 이 정도로도 충분히 프로그래밍이라고 할 수 있는 작업이다. 지금이나 그 당시나 대표적인 오토마타인 무어머신은 이런 식으로 움직였다.
지금까지 설명한 내용 가운데 특별히 어려운 것은 없다. 중요한 내용은 필요한 일이 있을 때마다 오토마타를 재구성해야 한다는 것을 강조하고 싶다. 일이 바뀌면 핸드컴파일과 핸드 와이어링을 반복해야 한다. 하드와이어링이 아무나 할 수 없는 일이라는 것을 실감하기 위해 PDP-10 회로판의 뒷면을 보여주고 싶다. 이런 작업은 우선 성격이 좋아야 하고 배선 실수도 없어야 한다(그림 3과 4의 출처는 위키백과다). 핸드와이어링은 롬이 나오면서 크게 줄어들었다.
>
그림 3. 1970년대까지도 플립플롭은 모듈인 경우가 많았다. 이런 모듈이 수천 개 필요한 경우도 많았다.
>
그림 4. 래핑 도구를 이용해 배선을 하드와이어링한 회로의 백패널 사진
이런 일을 반복하다 보니 아주 머리가 좋은 사람들은 곧바로 일종의 메타프로그래밍이 가능하다는 것을 알게 되었다. 기계를 항상 재조직하는 것이 아니라 미리 정한 명령을 읽고 쓰는 기계를 만들면 되는 것이다.
다음 회에는 오늘날 컴퓨터의 원형인 폰 노이만 아키텍처와 프로세서와 관련된 고전에 등장하는 몇 가지 원시 형태의 프로세서에 대해 살펴보겠다.
Part3: 폰 노이만과 프로그램 내장식 컴퓨터
2008년 5월 20일
시퀀셜 머신에서 컴퓨터로
1945년에 작성된 'First Draft of a Report on the EDVAC'이라는 유명한 글이 있다. 이른바 폰 노이만(John von Neumann) 아키텍처라고 부르는, 오늘날의 컴퓨터의 기본적 동작을 정의한 글로 처음에 손으로 쓴 글을 다시 타이핑한 것이다(글은 인터넷에서 PDF 형태로 읽어 볼 수 있다).
노이만은 원자폭탄, 컴퓨터, 미사일 개발에서 핵심 두뇌 중의 두뇌였다. 핵 개발이 끝나자 노이만은 ENIAC(Electronic Numerical Integrator and Calculator, 이하 에니악)을 만든 펜실베이니아 대학에 눌러 앉았다. 에니악은 1946년 펜실베이니아 대학에서 대중에게 알려졌는데 개발은 더 일찌감치 이루어졌다. 첫 번째 계산은 대포의 사정거리표가 아니라 로스알라모스의 핵폭탄 개발의 수학적 모델링을 위해 사용되었다. 이 계산은 폰 노이만이 이끌어냈다고 전한다. 혹자는 폰 노이만을 컴퓨터에 관심을 갖게 만든 것이 아마 에니악의 가장 중요한 귀결일 것이라고도 말한다. 노이만은 핵무기와 컴퓨터, 나중에는 장거리 미사일 개발에 관여한다. 대륙간 탄도탄 위원회는 폰 노이만 위원회라고도 불렀다.
수학 신동이었으며 한때 처치나 튜링과 같이 있던 적도 있던 폰 노이만은 정부에도 영향력이 강한 조언자였다. 노이만은 대포의 사정거리표 같은 것보다 훨씬 더 많은 것을 에니악이 계산할 수 있음을 실감하고 있었다. 에니악은 다소 둔한 최초의 프로그램 가능한 전자식 디지털 계산기였다. 무게는 가볍게(?) 30톤이 넘고 최고의 속도를 얻기 위해 배선반에서 프로그램 되었으며 프로그램 재작성에는 며칠이 걸렸다. 프로그램 가능하다고 보는 것은 관대한 편이다. 그러나 이 컴퓨터의 경험으로 노이만은 1945년 육군 조달부에 EDVAC(Electronic Discrete Variable Computer, 이하 에드박) 제안서를 냈으며 1946년 아이디어를 더 발전시킨 메모를 적었다. 'Preliminary Discussion of the Logic Design of an Electronic Computing Instrument'라는 제목이었다.
1945년의 'First Draft…'에서 시퀀셜 머신의 제어기는 변화를 암시하기 시작한다. 이 글이 사람들에게 회람되자 펜실베이니아 대학의 디자인팀은 반발했다. 대중에게 알려지면 나중에 특허를 내는 데 지장이 생길 뿐 아니라 프로그램 내장식 컴퓨터는 노이만 혼자의 아이디어가 아니라 학교의 무어(Alfred Moore) 연구소의 토론에서 도출됐다는 이유에서였다. 무어 연구소는 디지털 회로와 컴퓨터의 중요한 혁신이 일어난 장소다. 디지털 제어기와 시퀀스 로직의 중요한 연구 성과가 이미 이루어진 상태에서 노이만이 컨설턴트 자격으로 눌러앉아 토론에 참여했다. 이곳에서 에니악과 에드박이 나왔고 이 연구소의 개발자였던 모클리(John Mauchly)와 에커트(J. Presper Eckert)가 최초의 상업적 컴퓨터 회사 UNIVAC을 만들었다.
프로그램 내장식으로 가는 진화의 첫 단계는 제어기를 만드는 일에서 출발한다. 단순한 논리 회로에서 프로세서로 이행하는 진화의 결정적인 요소는 제어 유닛(Control Unit) 도입이었다. 그림 1은 1970년대 프로세서 설계에 대해 적은 책의 일부다(이 그림은 1971년 발행된 Gordon Bell과 Alan Newell의 'Computer Structure: Reading and Examples'에서 원용된 것으로 추정된다).
>
그림 1. 제어 유닛은 기존 데이터패스 설비인 A, N2, B, N1의 데이터 이동을 제어한다.
간단한 프로그램이 제어 유닛에 내장되어 있었고 제어 유닛 그 자체는 위에서 설명한 간단한 카운터와 같은 구조다. N1과 N2는 레지스터가 없는 단순한 논리 회로 그 자체다(AND OR NOT의 조합으로 만들 수 있다). 그리고 제어 유닛은 미리 정한 신호 s1, s2, s3를 N1과 N2로 보낸다.
N1에서는 s1 신호가 들어오면 그림의 입력 X 값이 B로 들어간다(B <- X). N2에서는 s1에서는 아무 일도 하지 않으나 s2 신호가 들어오면 A<-B, C<-[0]의 조작을 하게 된다. s3에서는 A <- A+B, C <- A와 B의 오버플로우를 출력하도록 설정한다. 제어 유닛은 클럭이 들어가면 s1, s2, s3의 제어 신호를 내고 종료한다. 시스템에서 s1과 s2, s3의 의미는 아래와 같다.
결과는 문제없이 동작하는 범용의 덧셈 장치라고 할 수 있다. 제어 유닛의 동작은 아주 간단하다.
N2가 계산 장치인 ALU(Arithmetic Logic Unit) 역할을, 레지스터 A가 accumulator 비슷한 역할을 하는 것인데 CU와 N1, N2를 다른 세트로 교체한다면 뺄셈을 할 수도 있고 곱셈을 할 수도 있으며 시프트 연산이나 다른 일들도 할 수 있다. 실제로 DEC(Digital Equipment Corporation)는 제어 유닛과 ALU 같은 것들을 IC가 아니라 트랜지스터로 직접 제작하고 팔기도 했다.
동작을 정의하고 보니 일종의 프로세서라는 것이 별로 대단한 것이 아님을 알 수 있다. 당시 무어의 연구소에서는 이런 작업들을 연구하고 있었다. 요즘 학생들이 디지털 실습 시간에 하는 실험들은 당시 무어 연구소의 심각한 도전이었던 셈이다. 그리고 위의 제어기는 지난번에 적었던 간단한 프로그램, 즉 롬의 테이블을 그대로 프로그램으로 옮긴 것 같은 프로그램의 조직론이 무어 연구소의 중요한 과제였다.
그러나 몇 단계가 더 남아 있었다. 그 다음의 결정적인 진화는 명령어 처리기를 가지고 제어 유닛을 마음대로 제어하는 이른바 프로그램 내장식 컴퓨터로 진화하는 것이다. 별것이 아니다. 그림 2에서 보면 I라고 적힌 인스트럭션 레지스터(Instruction Register)가 있다. 이 I 값에 따라 더하기, 빼기, 곱하기, 시프트 연산을 하도록 제어 유닛을 조금 더 복잡하게 만든다. 그러니까 커다란 변화라는 것은 제어기를 제어하는 제어기의 추가라고 생각하면 된다. 몇 개의 제어기를 만들고 이것들을 제어하는 제어기를 만드는 것이다. 효율을 너무 따지지 않으면 머릿속에서 만들고 연필로 그린 다음 프로그램으로 옮겨서 테스트해볼 수 있다.
>
그림 2. 앞서 설명한 구조에 I 레지스터가 추가됐다.
제어 유닛에 들어 있는 레지스터를 제외하면 밖에 나와 있는 레지스터는 A, B, I 세 개뿐이다. 나중에 오버플로우를 표시하는 1비트의 레지스터는 상태 레지스터로 진화할지도 모르니 네 개라고 치자. 지나칠 만큼 간단하지만 초창기 컴퓨터는 복잡할 수가 없었다. 복잡하게 설계할 여력도 지식도 없었던 것이다.
그렇다면 명령어 레지스터 I의 값에 따라 제어 유닛의 기능이 변하는 논리 회로가 바로 컴퓨터의 전신이다. 명령어 몇 개로 중요한 처리를 다 할 수 있는 간단한 프로세서의 원형이 나온 것이다. 여기다 몇 개의 데이터패스만 더 추가하면 컴퓨터가 된다. 이제 명령(instruction)은 회로 안에 있는 것이 아니라 I 레지스터의 값에 좌우된다. 프로세서는 I 값에 정해진 동작만을 수행한다.
이 정도가 되면 휴대용 계산기 수준이다. 실제로는 작은 휴대용 계산기의 내부 역시 아주 간단하지는 않다. 휴대용 계산기의 I 값은 * + - / 버튼 가운데 하나가 될 것이다. 이 버튼을 누르는 것이 바로 명령이다! 실제로 간단한 전자 계산기의 초기 회로는 미니컴퓨터와 많이 닮았고 잘 나가던 일본의 계산기 회사 비지콤이 인텔에 주문한 칩이 최초의 마이크로프로세서가 되었다.
앞에서 설명한 유사 코드와 시퀀스 로직을 합치면 아마 독자들은 이 계산기를 돌릴 수 있는 코드를 어떻게든 만들어 낼 수 있을 것이다. 종이와 연필로 신호선을 그리고 코드로 옮기면 된다. 중요한 것은 아무리 엉성해도 조금만 생각하면 돌아갈 수 있는 프로세서 비슷한 것을 만들 수 있다는 사실이다. 효율은 나중에 고민할 일이다.
그 다음에 다시 진화가 일어났다. 제어 유닛은 명령어를 외부에서 가져올 수 있도록 새로운 제어 루프를 만든다. 제어 유닛은 리셋이 걸리고 난 초기 상태에서 시작할 명령이라는 것을 가지고 와야 한다. 그리고 명령에는 특정한 값이 필요할 것이다. 데이터패스도 추가되어야 한다. 적어도 명령을 읽어 들이는 일에는 외부 기억장치가 필요하고 보통 이 기억장치 역시 메모리에 들어있으므로 메모리 위치인 어드레스와 데이터 값을 읽기 위한 신호선이 필요하다. 이 신호를 앞의 프로그램에 추가한다. 제어 유닛은 명령어를 밖에서 읽어 들인 후 이 명령어를 수행한다. 그리고 필요한 데이터와 어드레스를 입출력한다. 명령을 수행하고 나면 그 다음 불러올 번지를 계산하여 어드레스 신호를 내야 한다. 그러려면 PC(Program Counter)와 같은 구조가 필요하게 된다. 이런 식으로 몇 가지를 추가하면 자체 완결적인 프로세서가 되는 것이다. 계산을 하거나 하지 않는 것도 필요하지만 중요한 사실은 외부의 명령어를 읽어 그것을 수행하는 하나의 완결적인 제어 메커니즘을 갖게 되었다는 점이다.
이런 방식이 일단 발명되자 다음은 모두 비슷한 패턴으로 만들어졌으니 컴퓨터는 폰 노이만 방식 비슷하게 부팅했다고 볼 수 있다. 사실은 사람들의 아이디어의 부팅까지가 어려운 과정이었다. 어마어마한 양의 지적인 에너지와 당시로서는 많은 금액이 투자되었다. 에니악 초기 개발에는 작은 발전소의 전기를 모두 소모할 정도였다는 일화가 있다. 진공관 1만 8000개를 구동하려면 전력 소모가 발전소까지 가지는 않더라도 변전소 하나는 있어야 할 정도였다. 더군다나 당시는 전쟁을 하던 시절이었다.
일단 만들어지고 나니 명령어를 만들고 어셈블러도 만들었으며 컴파일러와 여러 가지 도구가 만들어지는 것은 시간 문제였다. 어셈블러를 만들 때의 재미있는 일화가 하나 있다. 폰 노이만이 반대를 한 것이다. 이런 쓸데 없는 작업으로 컴퓨터의 연산능력을 낭비하면 안 된다는 것이었다. 노이만은 기계어를 더 좋아했다. 그리고 기계어만으로 충분히 무슨 일이나 할 수 있을 정도로 머리가 좋았다. 그러나 사람들은 그렇지 않았기 때문에 컴퓨터 언어를 쓰는 편이 더 좋았다. 그 다음에 노이만은 최초의 고급 언어라고 할 수 있는 포트란에 대해서도 쓸데 없는 일을 한다고 비평했다. 그러나 포트란은 컴퓨터 사용을 대폭 확장하는 길을 열었다. 노이만이 탁월하기는 했으나 항상 그의 의견이 옳았다고 할 수는 없었다.
일단 무엇인가가 만들어지자 만든 사람들과 사용하는 사람들은 다른 길을 갈 수 밖에 없었다. 컴퓨터는 아무것도 없는 상태에서 많은 에너지 투입을 거쳐 만들어진 후 점차 사람들의 손으로 넘겨졌다. 에드박 정도가 나오자 여기저기서 자신들의 버전을 만드는 사람이 늘어났다. 가장 어려운 일은 아무것도 없던 상태에서 I 레지스터의 제어 구조를 만드는 일까지로 교과서에서는 프로그램 내장식 컴퓨터라고 한 줄로 줄여 말한다. 그러나 그전에 유한 상태 기계와 시퀀셜 제어기 시절을 거쳤고 그 중에서 나온 하나의 발전, 조금 특별한 시퀀셜 제어기라는 사실을 잊어서는 안 된다.
I 레지스터에 들어가 컴퓨터를 제어할 데이터이자 프로그램의 요소인 명령어 구조는 컴퓨터의 구조와 뗄 수 없는 구조를 가지고 있으니 가장 간단한 프로세서를 들여다보는 편이 이해가 빠를 것 같다.
비교적 간단한 프로세서 MU0 맛보기
필자의 원래 의도는 마이크로프로세서의 모태가 되었던 PDP-8의 구조를 도해하려는 것이었으나 워낙 오래된 프로세서라 독자들이 실감하지 못할지도 모른다는 생각이 들었다. 그 대신 S. Furber의 ARM 교과서에 잠깐 소개되는 MU0에 대해 살펴보려 한다.
앞에서 설명한 프로그램을 그대로 플립플롭과 플래시 롬 같은 것으로 대체한다고 해도 실제로 동작은 가능하다. 프로그램을 그대로 롬 테이블에 집어 넣는 것으로 복잡한 게이트 설계를 대체할 수 있기 때문이다. 이런 롬과 몇 개의 플립플롭 레지스터나 램이 준비되고 미리 만들어진 ALU 유닛 같은 것을 이용하면 사실상 원리적인 프로세서는 곧바로 만들 수 있다. 레지스터와 연산 유닛 세트와 데이터가 전달되는 신호선 같은 것을 데이터패스라고 부르며 이것들을 제어하는 로직은 제어 로직(앞서 설명한 제어 유닛과 같다)이라고 부를 수 있다.
한국어판으로도 번역된 Furber의 책에 나오는 간단한 프로세서 MU0는 매우 간단한 마이크로프로세서다. 그리고 아마도 프로세서를 가장 간단히 설계한다고 해도 비슷한 모양이 될 것이다. 그림 3은 간단한 명령어 구조를 보여준다. 16비트 중 4비트를 연산 구분에 사용한다. 그림 4는 데이터패스의 구조다.
>
그림 3. MU0의 명령어 구조
>
그림 4. 데이터패스 구조
옵코드 명령어 동작은 다음과 같다.
이 프로세서의 데이터패스들이 하는 일은 우선 명령어를 메모리에서 가져오는 일이다. 그 다음은 메모리에서 명령어가 필요로 하는 데이터를 끄집어내오는 일이다. 이런 일에 필요한 데이터패스를 만들고 절차를 적어놓고 그때마다 필요한 제어 신호를 정한 다음 종이와 연필로 계산하거나 컴퓨터를 이용해 시뮬레이션한다. 종이와 연필로 신호선과 동작을 정하였다면 어려운 일이 없다. 컴퓨터로 시뮬레이션까지 마쳤다면 실제로도 문제가 없을 것이다. 이것이 바로 제어 로직(Control Logic)이 하는 일이다.
Furber의 책 ARM system-on-chip architecture(그림 5)는 이제 고전이 되었다. 책에 나오는 그림 1.6과 표 1.2가 이런 일을 하는 방법을 모두 요약한 것이다. 경우에 따라서 이 표만 보고 나서도 CPU 설계가 무엇을 의미하는지 깨닫는 독자들도 있을 것이다. 실제로 여기서 한 발짝 더 복잡한 것이 실제 프로세서다. 간단해 보이지만 많은 것을 배울 수 있는 예다. 책에는 무척 간략하게 나온다. 지나치기도 쉽다. 그러나 자세히 들여다보면 많은 것을 배울 수 있는 내용이 숨어 있다. 만약 관심이 있는 독자라면 Furber 책의 연습문제까지 풀어보기 바란다.
>
그림 5. Furber의 책. 한국어판으로도 번역됐다. ARM에 관심 있는 사람들에게는 필독서다.
필자가 지금까지 말한 내용과 Furber 책의 데이터패스와 제어 로직이 바로 튜링 시절부터 발전을 거듭해 온 프로세서의 구조다.
필자의 설명은 너무 이야기 같고 Furber의 책은 너무 단순하니 일종의 고전인 패터슨과 헤네시의 COD(Computer Organization and Design)를 읽어보는 것도 좋을 것이다. COD는 데이터패스와 제어 유닛의 동작 설명을 조금 더 자세하게 RISC에 맞춰 설명한다. COD가 좋은 교과서이긴 하지만 제어 로직의 복잡성이나 실제 구현을 보여주는 일에는 미흡하다. 그러나 이만한 교과서도 별로 없을 것이다. COD 저자의 홈페이지에는 보충하는 내용이 많이 (그리고 조금은 두서없이) 널려있다. 필자는 그 중간 정도의 교재 같은 것이 하나 있으면 좋겠다고 생각할 때가 있다. 입문용으로는 정말 요긴하게 쓰일 것으로 생각한다(필자에게는 T. Booth의 Digital Networks and Computer Systems(2/e)라는 책이 하나의 화두였는데 이미 30년 전 책이긴 하지만 정말 재미있게 읽었던 기억이 있다. 적어도 아마추어에게 구체적 통찰력을 주기에는 충분했다). COD는 실제 MIPS 칩의 데이터패스와 제어 유닛을 보여주므로 조금 어려우며 MU0는 실제성이 부족하다. 따라서 MU0와의 연관성을 생각하면서 COD의 해당되는 장을 읽고 있으면 무언가 떠오르는 것이 있을지도 모른다.
MU0가 훨씬 쉬우므로 논의의 대상으로 삼기로 하자. 책의 표 1.2에 나오는 내용은 롬으로 단번에 구현할 수 있다. 표를 그냥 옮기면 될 정도다. 이것들을 PLA(Programmable Logic Array)로 옮기는 것도 어렵지 않다.
MU0에 조금씩 명령어를 보태고 데이터패스의 요소들을 첨가하고 제어 로직을 조금씩 복잡하게 만들면 최소한도의 실용적인 복잡성에 도달한다고 할 수 있겠다. 여기까지의 작업은 1980년대의 작은 컴퓨터로도 충분히 검증해 볼 수 있는 내용이었고 실제로 ARM의 오리지널 설계팀은 처음에 8비트 컴퓨터에서 베이직으로 만든 도구로 검증 작업을 했다. 물론 ARM의 초기 형태라 해도 MU0보다는 훨씬 더 복잡했다(요즘 PC로 자바나 파이썬 같은 언어로 검증과 설계를 해보는 것은 당시의 슈퍼컴퓨터보다 좋은 장비를 쓰는 것이나 마찬가지다).
컴퓨터 개발의 일화를 두서없이 적어 본 내용을 생각하며 COD를 읽으면 조금 생각이 달라질지도 모른다. COD 다음에 하드웨어에 관심이 많은 개발자가 할 일이라는 것은 VHDL 같은 것으로 칩을 직접 만들어 보는 것이니 역사적인 맥락을 모두 잊어버리고 몇 개의 주어진 예제에 집착하게 된다. 그러나 이런 것들을 한번 종이와 연필로 적어 보거나 그려보는 일 역시 나름대로 중요하다. 통찰력이라는 것을 얻을 수 있는 경우가 많으니까.
아무튼 CPU나 프로세서 설계 방법과 명령어 체계를 알게 되자 프로세서는 전자회사들이 쉽게 만들 수 있는 대상으로 변했다. 컴퓨터와 논리 회로의 구분이 애매하던 시절에 DEC는 PDP라는 상품명으로 컴퓨터를 내놓았다. PDP는 Personalized Data Processors의 약자였는데 컴퓨터라는 이름을 붙이지 않은 것은 회사가 살아남기에는 시장이 너무 작아 보였기 때문이라고 한다.
PDP 시리즈가 공전의 히트작이 되고 시간이 흐르자 이런 종류의 컴퓨터를 미니컴퓨터라고 부르게 되었다. 그 다음에는 허니웰, GE 같은 회사들과 일본 회사들이 자신들의 컴퓨터를 출시했다. 명령어는 비슷한 것이 중복되게 정의되거나 불필요한 것도 많았으며 기계어 프로그램을 짜는 사람들과 시스템 엔지니어들도 잘 모르는 명령이 허다했다. 데이터패스를 설계해 놓은 것만 있으면 명령어는 제어 유닛을 바꾸어 놓기만 하면 되는 시절이 있었기 때문에 제어 유닛에 흔히 쓰이던 PROM만 바꾸면 다른 회사의 기계어를 그대로 사용해도 되던 시절도 있었다. 정말로 튜링 머신들(프로세서)들은 다 비슷했던 것이다. 한 컴퓨터가 하던 일들을 다른 컴퓨터가 할 수 있었다. 대형 또는 미니컴퓨터 시절에는 이런 일들이 흔했다. 요즘은 VM들이 이런 일을 한다.
마이크로프로세서가 나오기 전까지는 이런 관행이 계속되었다. 프로세서는 여러 가지 스타일로 만들 수 있었다. 제어 유닛과 데이터패스를 정의할 수 있다면 명령어를 만드는 것은 누구나 할 수 있는 일이다. SICP의 5장 레지스터 머신에 나오는 스킴칩은 아예 ALU를 사용하지도 않았다. 리스프 머신의 프로세서는 명령어를 리스프가 더 효율적으로 구현되게 CONS 연산 같은 것들이 편리하게 수행하도록 명령어 체계를 만들기도 했다.
다양성을 자랑하던 칩들이 서로 경쟁하다가 시장을 평정하는 마이크로프로세서들이 나타나자 사람들이 명령어를 정의하는 일은 점차 없어지고 프로세서의 이해라는 것이 인텔이나 모토로라의 프로세서 매뉴얼을 읽어야 하는 시절로 변해갔다. 요즘 칩들은 이해조차 어려운 부분이 많지만 그 시작은 의외로 단순하고 기계적인 내용이었다. 그 시작 뒤에는 아주 똑똑한 사람들의 통찰력이 숨어있다. 그것이 필자의 논점이기도 하다. 독자들이 똑똑해지지 않을 이유가 없다.
필자가 곧잘 예로 드는 사례로 TTL 계열의 IC를 이용해 프로세서를 만들어보았던 "A Minimal TTL Processor for Architecture Exploration"이라는 글이 있다. 구글에서 검색하면 문서들을 찾을 수 있을 것이다. 이 프로젝트는 간단한 TTL들을 이용하는 것으로도 프로세서를 만드는 일이 가능하다는 것을 보여주는 좋은 예이기도 하다. 회로까지 공개한 프로젝트라서 사용된 TTL들을 플래시 롬이나 PLD 같은 것으로 대체하면 더 간단한 프로젝트로 만들 수 있다는 것도 독자들은 이미 알고 있을 것이다. 아니면 비슷한 것을 더 원초적으로 만들어 볼 수도 있을 것이다(필자는 예전에 바이오스 칩에 쓰는 28f010 몇 개와 PLD로 며칠 동안 아주 느리고 간단한 CPU를 만든 적이 있다).
프로세서라는 것은 이처럼 간단하다. 여기에 가지를 치고 설비를 더한 것들이 요즘의 프로세서로 변한 것뿐이다. 그 진화의 경로도 결코 긴 시간에 걸쳐 일어난 것도 아니다. 그런데 요즘의 프로세서 매뉴얼을 보고 있으면 거의 이해를 포기시키려는 것처럼 보인다.
Part4: 제어 흐름을 다루는 또 다른 방법, 컨티뉴에이션
2008년 6월 17일
제어의 흐름
이번 회에 적어볼 내용은 제어의 흐름에 관한 것이다. 바로 제어의 전달(control transfer)에 관한 내용으로 독자들은 이미 프로그래밍을 하면서 익숙한 부분이다. 그 중 하나가 '계속'(continuation, 이하 컨티뉴에이션)이라는 것인데 제어의 전달에 대해 생각할 화두를 제공하기에는 충분하다. 그래서 독자들의 머리를 크게 아프게 하지 않을 정도의 범위에서 스킴(Scheme)이나 리스프(Lisp) 방식으로 생각거리를 제공하려는 것이다.
우선 다른 곳에서 컨티뉴에이션이 사용되는 것을 살펴보자. 요즘 운영체제는 커널 스레드를 사용한다. 운영체제 커널이 몇 개의 스레드로 운영되면 단일 스레드로 운영될 때에 비해 장점이 있다. 단일 스레드의 커널이 시스템 콜을 호출하다 블록(이를테면 어떤 내부 자원이 이용 가능할 때까지 잠들거나 인터럽트를 기다리며 대기 상태에 빠져 있을 수 있다)이 일어나면 운영체제 전체가 멈추지만 커널 스레드를 몇 개 갖고 있으면 다른 스레드들은 일을 할 수 있다. 커널 스레드는 매우 편리하지만 하나의 스레드마다 많은 자원을 점유해 너무 많은 블록이 일어나면 효율이 떨어진다.
운영체제 교과서들을 보면, 특히 초기 유닉스의 경우 스택에 모든 정보를 저장하고 그대로 잠든다. 이런 스레드가 깨어나면 스택 정보를 바탕으로 다른 일을 모두 처리할 수 있지만 때로는 너무 많은 정보를 스택에 과도하게 저장하는 일도 있다. 어떤 운영체제는 모든 시스템 콜과 예외들을 처리할 때에도 이것들을 하나의 인터럽트처럼 처리한다. 그래서 필요한 정보만을 저장하고 그 다음 작업으로 나아간다. 말이 조금 어려워진 것 같은데 코드를 사용하는 편이 빠를 것 같다. 예전에 Mach에서 유닉스를 위한 절충을 낸 적이 있다. 우선 Mach의 스레드 봉쇄를 위한 thread_block 함수의 구문은 다음과 같다.
여기서 contfn()은 스레드가 다음에 수행될 때 호출될 컨티뉴에이션의 함수다. 만약 contfn()이 NULL이면 전형적인 블록이 일어난다.
컨티뉴에이션을 사용하는 예제는 블록이 풀리면 그 다음 문장을 수행하지 않는다. 바로 f2를 수행한다.
시스템 사용자는 두 개의 시스템 콜을 구분할 수 없다. 둘은 같은 일을 하기 때문이다. syscall1을 부르는 방법마저 같다. f2가 할 일에 대한 정보를 제공할 수만 있으면 둘은 같은 일을 한다. 싱거워 보이긴 하지만 운영체제로 보면 중요한 차이가 있다. 운영체제 관점에서 이런 방식으로 일을 하면 커널 스택이 가벼워진다. 커널 스택에 많은 정보를 저장할 필요가 없으며 제어의 전이는 간단하게 f2로 옮겨지고 할 일을 마치면 시스템 호출로부터 돌아온다.
위의 코드를 보고 독자 중에는 jump나 goto 문을 떠올리는 사람도 있을 것이다. goto만을 사용해도 필요한 정보를 저장할 수만 있다면 같은 일을 할 수 있다. 위의 코드를 조건문과 goto f2로 바꿀 수도 있다. 컨티뉴에이션과 관련된 글들 역시 goto와 관련이 있다고 말한다. 함수(functional) 버전의 goto라고들 말한다. 제어는 컨티뉴에이션을 전달하면서 옮겨진다.
이것은 적당한 시점에 필요한 정보를 메모지에 적어 다른 사람에게 일을 맡기는 일상 생활의 업무들과 같다. 필요한 것은 메모지를 받을 사람과 메모지에 계속할 일을 적는 작업이다.
다시 보는 액터
예전 '해커 문화의 뿌리를 찾아서 Part 4: 액터와 람다'에서 필자는 액터 모델을 이야기했다. 어느 날 칼 휴이트가 람다와는 관련이 없어 보이는 액터 모델이라는 이론을 들고 나왔다(Carl Hewitt, Peter Bishop, Richard Steiger(1973). 「A Universal Modular Actor Formalism for Artificial Intelligence」). 람다가 하나의 계산 형태인 것처럼 액터 역시 형식이며 모델이다. 이 모델에서는 '세상의 모든 것은 액터'라는 개념을 채택했다. 마치 객체지향 프로그래밍에서 모든 것이 객체이며 리스프에서는 모든 것이 리스트인 것과 같다.
액터는 객체보다 더 동시적인 모델이었다. 액터는 물리적인 세상을 추상화하려는 시도에서 나왔다(반면 람다는 논리적인 모형으로부터 나왔다). 물리적인 시스템에서 중력이나 전기장은 다른 요소들과 직접적, 동시적으로 영향을 받는다. 액터는 이러한 동시적 작용을 위한 메시지를 보내는 모델을 만들고자 했다. 액터는 컴퓨터상의 존재로 메시지를 받으면 다음과 같은 일을 동시적으로 만들어낼 수 있다.
- 다른 액터에 한정된 개수의 메시지를 보낼 수 있다.
- 유한한 개수의 액터를 만들어낼 수 있다.
- 다른 액터가 받을 메시지에 수반될 행동(behavior)을 지정할 수 있다.
- 이런 일들이 동시적으로 진행되는 데 있어 미리 정해진 순서는 없다.
통신은 비동기적이며 메시지를 보내는 액터는 메시지가 다 수신되기를 기다리지 않는다. 메시지 수신자는 주소로 확인되며 우편주소라고 부른다. 그래서 액터는 주소를 갖고 있는 액터들에 한해 통신할 수 있다. 주소는 수신하면서 알아낼 수도 있고 자신이 만든 액터의 주소일 수도 있다. 요약하면 액터 모델은 액터들 사이의 동시적 계산, 액터의 동적인 생성, 메시지에 액터의 주소를 포함시킬 수 있는 것, 그리고 도착 순서의 제한을 받지 않은 비동기적 메시지 전달을 통한 상호작용이 특징이다.
내용을 읽다 보면 액터 모델은 실제 전자우편과 닮았다. 액터 모델은 리턴 값을 전달하는 이야기가 없으며 실제로 계산 값은 메시지를 전달하면서 전해질 수 있다. 그래서 액터 방식으로 계산하는 방법은 함수나 서브루틴을 호출하여 2와 3을 더하여 5를 리턴 받는 것이 아니다. 이를테면 다른 액터에 3이라는 값을 보내면서 여기에 2를 더할 것을 요구하는 메시지와 함께 전송한다. 그러면 더하기를 지시 받은 다른 액터는 자신이 계산을 하든가, 다른 액터에게 더 필요한 작업을 요구할 수 있다. 이메일로 지시사항과 필요한 자료를 보내 일을 처리하는 방식을 확장하는 것과 마찬가지다. 메시지를 여러 번 보내고 받다 보면 복잡한 일도 처리할 수 있다.
휴이트의 액터는 람다 계산, 스몰토크(Smalltalk), 시뮬라(Simula)의 영향을 받았다. 이들은 동시에 휴이트의 영향을 받았다. 모두 액터가 중요한 모델이라고 인정했다. 얼핏 보기에는 별다른 것이 없어 보이지만 튜링상을 받을 정도의 인물들이 인정할 정도면 어쩌면 중요한 일일지도 모른다.
액터를 이해하기 위해 서스만과 스틸이 만든 장난감 리스프 언어가 스킴이 되었다. 휴이트와 이야기를 나누던 서스만은 사소한 점 두 가지를 제외하고는 람다와 액터 모델이 거의 일치한다는 것을 알았다. 스틸은 이 내용을 「The History of Scheme」이라는 글에서 요약했다. 람다가 개별적으로 상태 변수를 갖고 서로 값을 주고받으면서 액터의 역할과 같은 일을 할 수 있다는 것을 알았다. 동시성 문제가 완전히 해결된 것은 아니지만 리스프 구조에 큰 변화가 왔고 그 와중에 지난번에 소개한 'original lambda papers'라는 것들이 나왔다. 그래서 람다라는 것은 개념으로부터 계산상의 실체가 되었다.
별다른 내용이 아닌 것 같지만 프로시저나 함수들, 액터, 객체 그리고 람다 객체들이 메시지를 주고받는 패턴으로 컴퓨터의 제어 구조를 결정한다는 간단하면서도 심오한 결론에 도달한다. 사실 조금만 파고들어도 복잡한 측면들이 나타난다. 일부 객체지향 언어에서 메시지는 객체를 통제하는 거의 유일한 수단이다. 만약 객체가 메시지에 반응한다면 그 객체는 메시지에 대한 메서드(method)를 갖고 있는 것이다. 객체지향 언어보다 먼저 나타난 구조적 프로그래밍에서 메시지를 보내는 방법은 함수 호출이다. 그 이전에는 포트란이나 베이직처럼 직접 goto(jump)하는 방법이 있었다. 프로그램이 제어를 전달하는 방법에서 jump는 나쁜 방법이 아니다. 많은 문헌에서 컨티뉴에이션(continuation)이라고 부르는 방법도 제어와 메시지를 전하는 방법이다. 스택을 쓰지 않아도 컨티뉴에이션으로 해결할 수 있고 파이썬(stackless python이 도전했던 문제다)이나 그 외 몇 가지 언어에서는 이미 시험대에 오른 문제이기도 하다. 차이가 있다면 일의 종류나 알고리즘에 따라 얼마만큼 우아하고 추상적으로 표현할 수 있느냐가 관건이다. 중요한 문제이기 때문에 한번 리스프나 스킴 방식으로 생각해 볼 때가 되었다.
제3의 방법, 컨티뉴에이션
SICP 책에는 나와 있지 않으나 original lambda paper에 나오는 컨티뉴에이션 예제가 있다. 독자들은 Call with Current Continuation을 생각하겠지만 원래 예제는 중간 계산값을 저장하거나 꺼내는 스택을 이용하지 않고 어떤 람다 함수에 계산 값을 보내어 일을 시킨다는 의미였다. 서스만과 스틸이 스킴을 이용하여 풀어낸 원래 문제는 팩토리얼을 구하는 문제였다. 팩토리얼이라고 하면 독자들은 다음에 나오는 식을 기억할 것이다. 책이건 필자의 설명이건 몇 번이나 보아온 선형 재귀(linear recursion) 예제다.
다음 그림은 (factorial 6)을 구하는 경우의 중간 계산을 보여준다.
>
그림 1. factorial6
그리고 독자들은 다음 식도 기억할 것이다. 이터레이션(iteration) 문제다.
이 식을 풀면 다음과 같은 중간 계산을 보여준다.
>
그림 2. count
그런데 제3의 방법도 있다. 컨티뉴에이션을 이용하는 방법이다. 서스만과 스틸의 장난감 언어에서 구현한 예제다. 식은 매우 간단하다(출처는 Scheme: An Interpreter for Extended Lambda Calculus의 앞부분이다. 이 문서는 'inspired by actors'라는 문구로 시작한다). Hewitt이 발견한 방법으로 적용한 것이다.
얼핏 보기에는 이터레이션과 같다. 그러나 이 식은 컨티뉴에이션을 사용한다. 저장되는 것은 프로그램 수행에 필요한 정보이며 계산의 중간 값이 아니다.
(fact 3 answer)라는 식을 입력하였을 때 위의 식은 컨티뉴에이션인 answer에 결과를 적용한다. 이 프로그램을 스킴에서 실행하면 다음과 같이 된다. 컨티뉴에이션 패싱 스타일(continuation passing style)의 원시적인 모습이다. 계산의 중간 과정들을 옮겨본다.
fact3 answer
이제야 계산이 끝났다. 새로운 방법의 팩토리얼 계산법은 이렇게 끝난 것이다. 괄호가 한두 개 빠지거나 더해졌을지는 모르지만 컨티뉴에이션을 전달하여 문제를 푸는 방식의 기본적인 개념은 어떤 람다에게 값을 전해주는 것뿐이다. fact가 한 일은 아무것도 없다. fact는 이터레이션처럼 동작했고 마지막에는 (fact n c)의 컨티뉴에이션 c에게 n=0인 경우에 1을 적용(apply)했을 뿐이다. 위에 적은 식이 팩토리얼의 재귀나 이터레이션보다 복잡한 것도 없다. 결국 모든 치환과 적용이 일어나면 answer에게 값이 전달된다.
실제의 answer는 어떤 함수일까? 결과가 적용되는 가장 간단한 함수 answer는 자기가 받은 값을 되돌리는 (lambda (x) x)다. 그렇다면 (fact 3 (lambda (x) x))는 6을 되돌린다.
조금 싱겁겠지만 실행 문맥 자체를 적용하는 새로운 계산법이 등장한 것이다. 복잡하고 중요한 문맥을 되돌릴 수도 있으며 아주 복잡한 계산도 할 수 있다. 실제로 컴파일러 내부에서는 이런 방법을 적용할 수 있다. 문맥을 전달하면서 복잡한 제어 구조를 명시적인 식으로 만들어 낼 수도 있다. 계산식을 만들어 내는 것은 소스코드를 새로 쓰는 것과 다르지 않다. 컨티뉴에이션에 대해서는 생각할 거리가 많은 것이다.
다음 이야기인 CPS(continuation passing style)와 call/cc(call with current continuation)는 이보다 조금 더 복잡하기는 해도 컨티뉴에이션에 대한 가장 기본적인 부분은 이번 글에서 다룬 셈이다.
Part5: 제어 흐름을 다루는 또 다른 방법, 컨티뉴에이션(2)
2008년 7월 15일
컨티뉴에이션 패싱 스타일
지난번에는 컨티뉴에이션(continuation)을 설명했다. 컨티뉴에이션 패싱 스타일(continuation passing style)의 재귀(recursion)라고 부르는 방법이다. 중요한 개념이기 때문에 약간의 설명을 덧붙이겠다. 이 방법이 스택을 사용하는 방법과 다른 점은 스택을 사용하는 방법이 중간 계산값을 저장하는 반면 컨티뉴에이션은 함수와 계산값을 같이 건넨다는 점이 다르다. 예제를 들지는 않겠지만 더 정교한 계산이 가능하다.
지난 회 예제에서 함수 fact는 컨티뉴에이션 함수인 answer에 값을 전해주는 역할을 한다. 결국 나중에 일을 처리할 함수는 answer다. 지난 회 예제의 긴 식에서 answer는 람다 함수 (lambda(x) x)다. 그러니까 받은 값이 무엇이든 그 값을 그대로 되돌리는 함수이고 이 answer에 적용할 값을 건네준 것이다. answer는 끝에서 계산된 값을 그대로 되돌린다. 이제 원래 예제를 생각해보고 설명을 덧붙이자.
마지막 식의 a는 결국 길어진 식을 인자로 받는다. 이 간단한 식에서 컨티뉴에이션 c는 변하지 않으며 n은 계속 감소한다. 자꾸 길어지는 식은 결국 앞으로 계산할 식이 늘어나는 것이니 늘어나는 식을 받는 람다 함수는 n을 a에 곱하고 함수 c에 적용한다. (c (* n a)) 부분이 계속 늘어난다. 별것 아니지만 지금까지의 방법과 다른 것은 사실이다. 실행 문맥이 전해진 것이다.
다음과 같이 간략히 설명할 수 있다. 앞의 fact에서 n이 0이면 c에 1을 적용한다.
n이 0이 아니면 fact는 (fact (- n 1) (lambda (a) (c (* n a))))를 계산한다. 그러니까 n에서 1을 뺀 값과 새로운 람다 함수를 부른다. 그러면 함수의 정의에서 fact (lambda (n c)...)의 n에는 (- n 1)이, c에는 (lambda (a) (c (* n a)))가 주어진다. 물론 (lambda(a) (…로 시작하는 c는 계속 길어진다. 그러다가 (lambda (a) (…에 적용할 값이 주어지면 계산이 일어나면서 줄어든다. 위 예제에서는 c에 1을 적용하는 경우다. 그러면 이번 예제의 c는 (lambda (x) x)로 정했으므로 1을 되돌린다. 그리고 기다란 람다식이 계산을 일으키며 줄어든다. 스택이 늘어난 대신 람다식의 중첩(nesting)이 일어났다.
이 방법이 컨티뉴에이션을 전달하는 재귀의 예제다. 조금 생소하지만 강력한 패러다임이다. 함수형 언어의 표기법과 람다 함수의 생소함이 독자에게 낯선 느낌을 줄 뿐이다. 아무튼 독자들이 재귀와 스택으로 만들어진 예제들로만 생각할 필요는 없다는 것이 중요하다. 중요한 내용은 계산의 컨트롤을 전달하는 일과 접근 방식이다. 컨트롤은 다른 함수를 부르는 것으로 전해진다. Hewitt이 말한 액터(actor)의 역할과 같은 것이다. 이것이 가장 중요한 아이디어라고 스틸과 서스만은 말하고 있다.
다시 한번 소스코드를 열심히 들여다본 독자들이 모든 것을 이해하지는 않았더라도 컨티뉴에이션이 어떤 것인지는 대략 알게 되었다고 가정한다면 이제 다시 다음과 같은 내용을 생각할 수 있겠다.
일단 fact의 내부에서는 어떤 요소도 리턴값을 되돌리지 않았다. 리턴값을 되돌린 것은 컨티뉴에이션 answer다. 이전 예제에서 마지막 문장 (answer 6)이 값을 되돌린다. answer는 (lambda (x) x) 말고도 여러 가지가 가능하다. 이를테면 (fact 3 (lambda (x) print (x)))가 될 수도 있다. (fact 3 (lambda (x) sqrt (x)))를 (sqrt ((fact 3 (lambda (x) x))) 대신 사용할 수 있다. 두 사용법의 차이는 컨트롤을 전달하는 접근 방식이다.
CPS(Continuation Passing Style)에서 생각한다면 스택과 함수의 리턴값에 익숙한 관행을 바꾸어 생각하는 접근이 몇 가지 더 있다.
우선 앞의 예제에서 , -, *는 값을 되돌리는 프리미티브를 사용했다. 그러나 이것은 프리미티브의 특성이다. 컨티뉴에이션을 사용하는 새로운 프리미티브를 만들면 프로그램의 스타일을 바꿀 수 있다. =, -, *의 새로운 구현을 =
, --, **처럼 표기하자. 이것들의 마지막 요소는 컨티뉴에이션이다. ==는 두 개의 컨티뉴에이션을 받아 조건이 참이면 앞의 컨티뉴에이션을, 나머지 경우는 두 번째 컨티뉴에이션을 부른다.
이런 프리미티브가 구현된다면 앞의 fact를 아래와 같이 표기할 수 있다. 아주 이해하기 쉽고 명료한 코드다(단 프리미티브가 구현되어 있어야 한다).
앞서의 길어지는 람다와 위에서처럼 CPS로 작성한 코드는 같다! 프리미티브를 구현한다면 앞의 예제와 같은 작용을 한다. 설명을 덧붙이면 이 예제에서는 팩토리얼 계산에서 값을 되돌리는 함수 적용이 없다. 모두 다른 함수를 호출할 뿐이다. 의도한 스타일이 더 명확해 보인다!(프리미티브 구현은 Lambda: The Ultimate Declarative(text 버전)의 뒷부분에 적고 있다)
아마추어인 이유로 인터넷이 스승인 필자는 처음에 위키백과에 나오는 CPS와 컨티뉴에이션을 잘 이해하지 못했다. 사실 두 개의 글이 잘 요약되기는 했으나 좀처럼 이해할 수가 없었다. 약간의 이해라도 제공한 문헌은 사실 오리지널 람다 페이퍼였다. 그나마 반나절 동안 fact 코드를 들여다 보고서야 간신히 이해했다. 앞의 CPS 버전의 fact를 위키백과에 나오는 예제들과 비교해 보면 독자들의 이해가 조금 쉬워질 것으로 보인다.
CPS를 사용하면 프로그래머의 프로그램 컨트롤에 대한 통제력이 증가하지만 반면에 확실하게 돌아가도록 신경을 써야 한다는 전제 조건이 붙는다. 여러 가지 고려해야 할 조건이 있기 때문이며 프로그래머는 이를 모두 고려해야 한다.
우선 컨티뉴에이션을 사용하기는 하지만 명료하지 않은 방식으로 사용하는 경우를 예로 들어보자. 이 예제는 Kent Dybvig의 책에 있는 예제다(R. Kent Dybvig의 The Scheme Programming Language 2판 3.4절에 나온다.)
코드에서 h를 계산하면 g가 계산되고 g는 어쩔 수 없이 f를 계산해야 한다. 결국 위 식은 다음과 같이 계산된다.
위의 식을 CPS 스타일로 바꾸면 다음과 같다.
프로그램에 대한 통제가 증가하며 같은 결과가 나온다. 위의 식에서 k는 컨티뉴에이션이다.
CPS는 사실상 goto와 마찬가지 역할을 하며 함수형 언어의 goto처럼 보인다. 함수에 전달할 값들을 잘 지정할 수 있으면 어셈블리어나 포트란으로도 같은 일을 할 수 있다. 필요한 것은 (계산할 함수의 번지, 계산에 필요한 값)이다. 그러니 jump와 본질적으로 다를 것이 없다. 실제로 goto와 람다는 모두 컨트롤을 불러 일으키는 것뿐이다. 표기법이 다를 뿐이다. 필자가 서스만과 스틸의 오리지널 람다 페이퍼를 보고 경탄한 부분은 컨트롤의 전이에 대해 명쾌히 설명한 부분이다. 많은 생각을 불러일으키는 부분으로 우리가 알고 있는 프로그램의 요소들과 람다를 비교하여 설명했다.
위키백과의 CPS에는 자바의 스윙 UI 사용과 비교했으며 필자는 유닉스 커널에 있는 컨티뉴에이션을 예로 들었다. 전통적인 과거의 유닉스 커널은 중첩된 시스템 콜을 가지고 스택을 중심으로 프로시저 호출을 하듯 작업을 처리했으며 스택은 종종 한없이 커지곤 했다. MS-DOS의 경우에는 인터럽트처럼 사용하는 시스템 콜로 운영체계를 돌렸다. 어떤 부분은 중첩된 스택이 불리한 경우도 있다. Richard P. Draves는 Control Transfer in Operating System Kernels라는 글에서 이것들의 장단점과 실제 사례를 분석했다. 널리 인용되는 글이니 한번 읽어 보아도 좋을 것이다.
실제로 컨티뉴에이션은 신기한 개념이 아니다. 어떤 언어를 쓰건 계산할 식과 계산할 값을 주고 이것을 처리할 함수나 다른 처리 장치를 지정하는 것이다. 어셈블리나 C 프로그램에도 컨티뉴에이션과 비슷한 일들을 하는 코드들이 있다. 컴파일러 구현에도 사용된다.
Call/CC(Call-With-Current-Continuation)
CPS를 통해 컨티뉴에이션을 설명했다. 별다른 내용은 아니었다. 이제 컨티뉴에이션에 대해 어느 정도 이해했으니 call/cc를 설명할 때가 되었다. 상당히 중요하지만 별로 다루지 않는 내용이었다. 필자 역시 정확한 문헌 부족으로 몇 개를 이어 모아서 이해할 수밖에 없었다.
우선 SICP에 나오는 컨티뉴에이션 예제를 생각해보자. SICP에서는 4.3.3의 amb 실행기 구현에서 나온다. amb는 비결정적인 실행기의 구성요소다. 이 실행기를 수행하다 보면 답이 나오는 경우도 있고 계산을 하지 못하는 경우도 있다. 실행기는 계산의 결과값이 나오면 그 값을 가지고 success continuation을, 실패하면 failure continuation을 부른다(번역판 SICP는 컨티뉴에이션을 '다음 할 일'이나 '할 일'이라고 번역했는데 필자도 이후로는 이 용어를 사용하겠다). 값을 얻는 데 성공한 다음 할 일은 이 값으로 계산을 이어나가는 것이고 계산 때에는 실패 시 할 일도 넘겨준다. 뒤에 이어지는 계산 과정이 막다른 길에 이르면 실패 시 할 일의 프로시저를 불러온다. 실패한 할 일의 역할은 다른 길을 찾아 계산이 이어지도록 하는 것이다. 결국 amb의 계산 과정 자체가 하나의 트리 구조를 이룬다고 볼 수 있는데 여러 대안 중 먼저 찾아낸 값으로 계산을 이어나가는 구조다.
설명은 장황하지만 앞에서 말한 = 같은 함수와 구조가 비슷하다고 생각하면 된다. 이를테면 (=
(answer_exist?) success-continuation failure-continuation)처럼 생각할 수 있다.
amb는 한 예이고 여러 인공지능 문제는 답을 얻어내기 위한 선택의 트리 구조를 가지고 있다. 선택의 트리를 조작하기 위한 여러 가지 기법이 전통적인 인공지능의 문제라고 해도 과언이 아니다.
선택의 가지를 고르면서 어느 경로가 더 이상 답이 없다는 사실을 알았을 때 이런 프로그램들이 할 일은 다른 대안을 찾기 위해 바로 앞의 갈림길로 돌아가는 것이다. 만약 여기서도 찾지 못하면 그 앞의 갈림길로 돌아간다. 이때 아래의 선택 가지에서 변수값 지정이나 여타 상태 변화가 일어난다면 계산은 완전히 다르게 된다. 따라서 선택의 갈림길에서 할 일은 올바른 계산을 위해 원래 상태까지도 되돌려 놓아야 한다. 프롤로그처럼 backtrack이 하나의 프로그램 구성요소인 언어에서는 더 중요한 문제다(여담이지만 Paradigms of Artificial Intelligence Programming은 책의 1/3 정도를 리스프로 프롤로그를 만드는 데 할애했다는 비평이 있을 정도로 중요한 문제다. 저자인 Peter Norvig은 아주 중요하다고 생각했다).
쉬운 일처럼 보이지만 실제로 어떤 루프에서 빠져 나오는 일은 쉬운 것이 아니다. 리스프뿐만 아니라 C나 자바로 짠 프로그램에서도 쉬운 것이 아니다. 여러 개의 중첩된 do 루프에서 goto를 사용하지 않고 빠져 나오기도 쉬운 일이 아니다. The C Programming Language에는 우아하게 goto를 사용하는 예제가 있다. 알골도 예외가 아니었다. 길게 중첩된 재귀 트리에서 쉽게 빠져 나오는 뾰족한 방법은 오랜 기간 프로그래머들을 괴롭혔다.
오래된 리스프에서는 throw와 catch를 이용해 프로그램이 루프를 빠져 나왔다. 커먼 리스프에는 goto와 그보다 정교한 매크로들이 이런 역할을 했다. 그러나 매크로 역시 나름대로 어려운 요소가 많았다. 컨티뉴에이션이 하나의 답을 제시했다.
스킴에서는 call-with-current continuation이라는 연산자를 만들어 이런 문제를 해결했다. call/cc를 중요한 제어 구성요소로 사용한 최초의 언어이기도 하다. 위키백과에 나오는 예제는 쉽다기보다는 실제로 가장 간단하게 call/cc의 용례를 보여주는 예다. 코드는 아주 단순하다.
중간의 문장 (call/cc (lambda (k) (set! theContinuation k)))가 핵심으로 k는 컨티뉴에이션이다. 이 변수를 theContinuation에 지정한다. 그 다음에 다음과 같은 문장을 입력해 보자.
중간의 (define anotherContinuation theContinuation)은 theContinuation을anotherContinuation 변수를 만들고 지정한다. 그러면 두 변수는 서로 다른 상태를 갖게 된다. 이것들을 실행(evaluate)하며 서로 다른 상태의 프로그램이 돌아가는 것을 보고 있는 것이 바로 위 예제다.
그렇다면 call/cc는 상태를 저장하는 포인터와 같은 것인가? 현재로서는 그렇다라고 대답할 수밖에 없다. 그렇다면 별것이 아니지 않는가라고 물을 수도 있다. 부분적으로는 그렇다. 그러나 call/cc는 많은 프로그래머들이 이해하느라고 고생한 부분이다. 그리고 다음번 주제다(람다와 컨티뉴에이션의 중요한 주제들을 같이 비교하며 살펴볼 문제다).
call/cc는 어떤 일을 하는 것일까? call/cc는 현재 하는 일(current continuation)을 얻어서 하나의 인자를 갖는 프로시저 p로 건넨다. 위의 예제 (call/cc (lambda (k) (set! theContinuation k)))에서 하나의 인자를 갖는 프로시저 (lambda (k) (set! theContinuation k))로 컨티뉴에이션 k를 건네는 것이다. 프로시저는 이 값을 변수에 지정했다.
그 다음에 프로시저가 k에 대해 어떤 값을 적용하는지에 따라 call/cc는 다르게 반응한다. k에 어떤 값이 적용되면 call/cc가 적용된 컨티뉴에이션은 이 값을 되돌려 받는다. 가장 중요한 내용이다. 프로시저가 k에 값을 적용하지 않으면(결국 k를 사용하지 않으면) 그냥 프로시저가 되돌린 값이 call/cc를 적용한 결과가 된다. 아마 말보다는 예제가 더 쉬울 것이다. Dyvbig의 책에 나오는 예제를 그대로 인용하겠다.
쉽지 않은가? 다음번에는 조금 더 어려운 문제들이 기다리고 있다.
Part6: 컨티뉴에이션과 클로저
2008년 8월 19일
들어가며
필자는 지난 2회에 걸쳐 컨티뉴에이션(continuation)을 설명했다. 한번은 CPS(continuation passing style)를 다룬 「Original Lambda Papers」의 내용을, 그 다음은 call/cc(Call with Current Continuation)를 간략하게 소개했다.
프로그램의 진행을 의미하는 '다음 할 일(continuation)'은 여러 방법으로 생각할 수 있다. 많은 프로그래머와 학자 들의 머리를 싸매게 했고 그 의미는 여러 번 재발견되었다고 한다. 프로그램의 가장 본질적인 요소는 제어의 흐름(control flow)이고 제어는 어디에선가 계속되어야 한다. 그러니 제어 흐름에 대해 여러 가지로 생각할 수 있고 사람들이 새로운 생각을 갖게 되면 그때마다 재발견했다고 할 수 있다. 이것은 필자의 생각이 아니라 컴퓨터가 나오면서부터 존재한 오래된 현상이다. 아마 앞으로도 재발견과 발견을 되풀이할 것이다.
필자는 이 부분을 찾아보다가 Hayo Thielecke(컨티뉴에이션을 분류하여 학위를 땄다)라는 사람의 글을 읽게 되었다. 글은 「Continuation, functions and jumps」(http://www.cs.bham.ac.uk/~hxt/research/Logiccolumn8.pdf )라는 제목으로 공개되어 있다. 복잡하긴 하지만 직관적인 요약본으로 전부를 이해하기는 어려워도 어느 정도 전반적인 윤곽을 제공한다. 이 글에서 Thielecke는 C 언어의 예를 들어 컨티뉴에이션의 의미를 설명하고 있다. 아무래도 C의 예문들이 제시하는 의미가 친숙하기 때문에 좋은 예들이다.
기계어를 다루어 본 경험이 있다면 리턴하지 않는 함수(non- returning function)와 인수를 갖는 점프(jumps with arguments)는 구별하기 어려우며 이것들이 '계속 할 일'과 관련이 있다는 것을 알 수 있다. 함수에서 return하는 일조차 때로는 jump나 goto로 이루어진다. 스택을 사용하지 않고 몇 개의 필요한 변수만 옮겨 다니는 경우도 많은 것이다(프로세서에 스택 포인터가 없는 경우도 있으니 스택보다는 goto나 jump가 더 보편적인 방법이다). C 언어의 return이 호출되는 경우도 컴파일러는 스택이나 문맥의 정보를 정리하고 goto로 처리하는 경우가 많다.
함수형 언어의 함수 호출 역시 호출 후 아무것도 되돌리지 않고 또 되돌아오지 않는다면 goto와 다를 바 없다. 되돌린다고 해도 goto와 다를 바가 없다. '다음 할 일'은 이런 일을 생각해보는 하나의 화두다. 스킴의 CPS는 이렇게 생각해보면 그다지 신기한 것이 아니다. 함수가 언제나 리턴하는 것이 아니라는 생각을 한다면 CPS는 별다른 것이 아니다.
Call with Current Continuation
스킴의 식을 계산하면서 두 가지 요소가 꼭 필요하다. 하나는 무엇을 계산할 것인가와 계산 값으로 무엇을 할 것인가이다.
(if (null? x) (quote ()) (cdr x))의 식에서 (null? x)를 계산하는 경우를 생각해보자. '무엇을 계산할 것인가'는 (null? x)이며 이 값에 따라 quote ()나 (cdr x)를 계산한다. '계산 값으로 다음에 무엇을 할 것인가'가 다음에 할 일, 바로 컨티뉴에이션이다. 컨티뉴에이션이 계산 결과를 기다린다고 볼 수도 있겠다.
그림 1. Kent Dybvig의 책
\:좋은 설명이 있고 http://www.scheme.com/tspl3/ 에서 내용을 읽어 볼 수 있다. Chez 스킴의 구현자이기도 한 저자의 설명은 상당히 명확하다.
Dybvig의 책에는 x가 (a b c)의 값을 갖는 경우, 그러니까 (cdr x)를 기다리는 경우를 적고 있다. 이때 (if (null? x) (quote ()) (cdr x))를 평가하면서 값을 기다리는 컨티뉴에이션을 여섯 개로 분류했다. 이것들이 기다리는 값은 다음과 같다.
- (if (null? x) (quote ()) (cdr x))의 값
- (null? x)의 값
- null?의 값
- x의 값
- cdr의 값
- 다시 x의 값
별다른 것이 아니다. 계산이 일어나면 그 '계속 할 일'이 컨티뉴에이션이다.
스킴은 어떤 식(expression)의 '계속'이라도 call/cc를 사용하여 얻어낼 수 있도록 허용한다. call/cc는 강력한 제어 흐름 조절 수단이기도 하다. 어떤 사람들은 지나칠 정도로 강력하며 혼란을 일으킬 소지가 있다고 비평한다.
지난번의 설명과 예제들을 참조하면서 다음과 같은 예제를 한번 생각해 보자.
우선 retry를 #f로(false) 정의한다. 그 다음은 일반적인 factorial의 식과 같다. 다만 call/cc가 있다는 사실과 call/cc가 건네준 컨티뉴에이션 k를 retry에 저장한다는 것만 다르다.
(factorial 4)를 입력하면 24가 나온다. 그 과정에서 retry는 현재의 컨티뉴에이션으로 지정된다. 하지만 k에 아무 값도 적용하지 않았으므로 지난번의 예처럼 x가 0인 경우 (= x 0) 1이 되돌려진다. 그러나 현재의 컨티뉴에이션을 저장하는 retry를 얻었다. retry는 모든 문맥을 갖고 있다. 설명은 어렵지만 실제로는 어렵지 않다. 우선 factorial 4를 구해보자.
이제 factorial 4의 실행 문맥을 그대로 저장하고 있는 retry에 2를 지정하자. retry는 결국 factorial 4를 계산하다가 평상시 같으면 1을 되돌릴 경우의 컨티뉴에이션을 저장하고 있는 것이다. 그러면 retry에 다음과 같이 해 보자.
(retry 2)를 입력하면 사실상의 점프가 일어나 원래 실행하던 곳으로 돌아간다. 그러면 원래 실행이 일어나던 컨티뉴에이션에서 k에 2가 적용되며 (call/cc (lambda (k) (set! retry k) 1))은 2를 되돌린다. 2뿐만 아니라 retry에 적용한 어떤 값도 되돌릴 수 있다. 2를 되돌리면 factorial의 식은 x=0의 케이스에서 2를 되돌린다. 그러면 식은 전체 문맥을 복원하며 48이 된다(엉뚱한 값을 적용하면 심각한 에러를 만날 것이다. 그러나 분명히 적용할 수 있다).
이 식을 몇 번만 돌려보면 독자들은 무엇인가를 깨달을 것이고 call/cc의 중요한 측면을 이해한 것이다(Escape Operator). 아주 복잡한 식이라도 retry와 같은 엔트리 포인트를 갖고 있으면 여기에서 문맥을 되돌리며 다시 시작할 수 있다. 재미있기는 하지만 언어라기보다는 운영체제의 문맥 교환이나 디버거에 가깝다. 일반적인 언어에서 보여주는 제한이나 표현 능력과는 커다란 차이가 있다. 변수나 함수 하나조차 실행 도중에 정의할 수 없는 언어와 리스프나 스킴처럼 람다나 컨티뉴에이션이 자유롭게 만들어지는 언어의 표현 능력은 큰 차이가 있다. 다만 프로그래머의 능력과 상상력이 필요하다.
이런 능력은 call/cc를 하나의 강력한 디버거처럼 사용할 수 있게 한다. 위의 retry는 여러 번 호출할 수 있다. retry의 위치도 정해진 것이 아니며 적용하는 값도 자유롭다. 복잡한 식을 적용할 수도 있다. 되돌리는 것이 새로 실행할 람다식이 될 수도 있는 것이다. 비슷한 예를 하나 더 적어보자.
이 식은 처음에는 2를 되돌린다. call/cc 내부의 식이 정상으로 종료되어 1이 되돌려져 다시 1에 더해지기 때문이다. 하지만 전의 예제와 마찬가지로 return이 현재의 컨티뉴에이션을 갖고 있다. 그래서 여기에 값을 적용하면 원래 식이 다시 평가된다. 적용된 값에 1을 더한다. 그래서 (return 12) ==> 13처럼 된다.
이 일의 의미는 외부에서 내부의 계산에 다시 들어간 것이라고 말할 수 있다(re-entered the computation from outside). 그래서 계산은 여러 가지로 다시 평가해 볼 수 있다. 혼란을 일으킬 수 있지만 강력한 통제 수단이다.
아주 게으른 예제도 있다.
필자가 만들어본 예인데 이 예제는 아무 일도 하지 않고 return만 정의하고 지정한다. 그러면 (return (* 3 4)) => 12처럼 사용할 수 있고 일종의 인터프리터나 마찬가지다. 실제로 일어난 일은 람다식을 저장된 컨티뉴에이션에 적용하며 되돌린 것뿐이다.
지난번의 예제들이 call/cc는 무엇이든지 되돌린다는 사실에 중점을 두었다면 이번 예제는 컨티뉴에이션을 저장하여 다시 제어를 되돌린다는 점이 다르다.
독자들은 이제 스킴의 R5RS에 나오는 call/cc를 봐도 별로 어렵지 않게 느껴질 것이다. 복잡한 예제가 많지만 사실은 간단한 요소들로 구성된 것이다. 그래서 스킴을 조금만 사용해본 독자들이라면 위키백과에 나오는 간단한 예제(http://en.wikipedia.org/wiki/Call-with-current-continuation 에 나오는 generate digit 문제)를 풀어보는 것으로 조금 더 이해를 넓힐 수 있겠다. 쉽기도 하고 어렵기도 한 예제가 널려 있다. 풀어보고 이해하는 것은 관심 있는 사람들의 몫이다.
call/cc가 다른 함수형 언어에 많이 도입된 요즘은 필자의 설명이 별다를 것은 없으며 구현마다 난이도와 중요성이 다르지만, 설명에 열을 올린 의미를 찾는다면 컨티뉴에이션의 역사적인 맥락과 도입 과정을 설명한 것에서 찾고 싶다. 관심 있는 독자들은 스킴의 R5RS나 다른 문헌을 본다면 방향 감각 형성에 도움이 될 것으로 기대한다.
Closure
SICP의 한국어판이 나오면서 책의 연습문제 풀이와 질문이 인터넷에 많이 올라오고 있다. 이해와 관심이 늘어나고 있다. 책을 열심히 읽는 것도 좋지만 필자는 통찰력을 강화하기 위해 다른 자료들을 읽어보는 것도 좋다고 생각한다. 그 자료 중 SICP보다 먼저 나온 「Original Lambda Papers」도 있다. 제럴드 서스만(Gerald Sussman)이 가이 스틸(Guy Lewis Steele, Jr.)과 함께 작성한 글 묶음이다. 스킴이 나오게 된 이유와 저변에 깔린 미니멀리즘을 이해하는 데 필요하다(특히 「Scheme: An Interpreter for Extended Lambda Calculus」, 「Lambda: The Ultimate Imperative」, 「Lambda: The Ultimate Declarative」).
스킴은 그 이전의 리스프보다 람다의 해석과 적용을 명확하게 하고 또 확장한 것이다. 언어의 구성요소가 단순해진 대신 람다로 과거의 리스프 변종들의 구성요소를 대체할 수 있다는 것을 보여 주었다. 그 중 하나가 리스프에서 람다식의 표현 방법이다. 우선 데이터와 함수의 구별이 없다. 표현 능력이나 특성이라고 해도 좋을 것 같다. 데이터의 표현과 제어의 흐름도 별로 다르지 않다. 그러니 어디에서 출발해도 좋을 것이다. 예전에 리스프에 대한 글을 처음 쓰기 시작하면서 모든 것이 리스트라고 했으니 리스트의 구조부터 출발하자.
1, 2, 3을 원소로 갖는 리스트는 (1 2 3)이라고 하며 cons 셀로 구성된다. 이 리스트를 만드는 방법은 cons 연산을 이용하는 것이다. cons 연산은 두 개의 원소를 받아 리스트를 리턴한다. 우선 cons (3, ())으로 리스트를 만들고 이 리스트와 2를 cons 연산하고 다시 이 결과를 1과 cons 연산한다.
결과적으로 (cons 1 (cons 2 (cons 3 nil)))을 계산하여 (1 . (2 . (3 . nil)))을 만든다. 큰 리스트는 작은 리스트로부터 만들어진다. 이렇게 보면 리스트는 데이터다. 그런데 예전에 액터(actor) 모델을 들고 나왔던 Hewitt가 cons에 대한 코드를 만든 적이 있다(오리지널 예제를 스킴으로 옮긴 것이다).
이렇게 정의한 cons_new는 일종의 객체와 비슷한 것을 만들어낸다. 값 2, 3을 갖고 있는 함수를 만들어낸다. 그리고 car와 같은 역할을 하는 first, cdr과 같은 역할을 하는 rest 함수를 정의한다. 객체의 메서드와 비슷한 역할을 한다. 예를 들면 다음과 같이 입력해 보자.
화면에 closure라는 용어가 나타났다.
끝의 두 개의 예는 우리가 알고 있는 리스트와 조금 다르지만 코드를 보면 동작을 이해할 수 있다. 몇 가지 근소한 차이점을 빼면 람다로 구현한 함수로 구성한 리스트와 일반적인 데이터로서의 리스트는 구별하기 힘들다.
액터 모델은 이미 이전 컬럼에서 소개한 적이 있다. 스킴의 「Original Lambda Papers」는 그 자체가 액터 모델에서 출발한다('Inspired by ACTORS...'). 액터는 script와 set of acquaintances로 구성된다. 전자는 수행할 코드이고 후자는 다른 액터들을 알고 있는 것이다.
람다가 어떤 환경에서 계산(evaluate)되면 하나의 클로저(closure)가 된다. 위의 예에서 (cons_new 2 3)을 계산하면 새로운 클로저가 생긴다. 클로저 자체는 하나의 함수다. 여기에 메시지를 보내는 것이 'rest? 'first? 'list? 같은 인자를 덧붙이는 것이다. 메시지를 보내는(message passing) 방법은 리스프에서는 오랜 역사가 있다. 하지만 클로저를 온전히 구현한 것은 스킴이 처음이었다. 스킴은 지역 변수의 처리가 되어 있지 않던 리스프에 렉시컬 스코프(lexical scope)를 도입했다. 지역 변수는 상태(state)라고도 부른다. SICP의 3장은 이 부분의 설명에 많은 부분을 할애했다. 2장은 메시지 패싱과 데이터 요약에 관한 부분이다. 이미 1장부터 프로시저의 요약 부분에 나타나기 시작한다. 1장 뒷부분에 일등급 프로시저(first class procedure)의 조건이라는 권리와 특권을 다음과 같이 적고 있다.
프로시저는:
- 변수의 값이 될 수 있다.
- 프로시저 인자로 사용 가능하다.
- 프로시저의 결과가 될 수 있다.
- 데이터 구조 속에 집어넣을 수 있다.
람다는 무엇이든지 될 수 있다. 데이터가 되는 것은 자연스러운 일이다. 그래서 앞의 리스트의 cons와 같은 자연스러운 처리를 할 수 있다.
SICP에 나오는 예제 중 미분을 푸는 문제가 있다. 함수 f와 dx를 받아 하나의 클로저를 만들게 된다. 내부의 상태 변수가 만들어진다, 그리고 x를 받아 계산한다.
우선 함수 f와 dx를 입력하여 내부 변수를 갖는 미분 함수를 정의해보자. 내부 변수를 갖는 프로시저가 만들어진다. 제곱근을 dx = 0.001로 차분하는 프로시저다.
이 클로저는 x를 기다린다. 이를테면 7에 대한 계산 값은 다음과 같다.
한 줄짜리 코드로 이 정도 일을 할 수 있다는 것은 놀랍다. 지역 변수에 해당하는 상태는 없어지지 않는다. 이를테면 mysqrt 프로시저를 정의해 보자(SICP의 문제라면 이런 예제들이 1장부터 쏟아져 나온다는 것이다. 책의 매력이기도 하다).
다른 언어들이 이런 능력에 영감을 받지 않을 이유가 없다. 다음 번에 다룰 주제다.
Part7: 여러 가지 얼굴의 클로저
2008년 9월 23일
들어가며
지난번에는 클로저를 간단히 소개했다. 스킴이나 리스프에서 클로저는 상태(state)를 갖고 있는 람다 함수다. 다른 언어들의 디자인에서 언어의 융통성과 표현 능력을 증가시킬 수 있는 방안으로 클로저를 도입하려는 시도가 있다. 자바스크립트나 루비는 클로저를 상당한 수준으로 지원하고 있으며 올해부터는 C++ 표준에서도 람다와 클로저를 지원하려고 몇 개의 중요한 프로토타입이 검토되고 있다. 자바도 어느 정도 확정된 모습이 나오고 있다(http://www.javac.info/).
클로저는 객체(object)와 닮은 점이 있다. 객체가 C 언어로 번역되면 구조체와 비슷한 모양이 되겠지만 객체보다는 덜 비정형적인 클로저의 번역된 코드 원형은 아래와 비슷한 모양이 될 것이다(출처: http://www.jetcafe.org/jim/highlowc.html).
이런 코드는 기계어로 구현해도 비슷한 모양이 된다. 인다이렉트 어드레싱을 이용하는 모습이니 포인터를 사용하는 C 코드와 비슷하다. 누가 프로그래밍을 해도 거의 닮은 모양을 만들어낼 것이다. 요점은 간단하다. 포인터로 함수와 데이터를 전하고 다시 함수와 데이터의 포인터를 돌려받는다. 원문을 읽고 실제로 C로 클로저 비슷한 것을 만들어 보면 재미있을 것 같다.
Jim Larson은 위의 글 말고도 'An Introduction to Lambda Clculus and Scheme'이라는 제목의 간단한 강의록을 작성했다. 꽤 많이 알려진 글이며 앞부분에는 함수에 대한 예제가 나온다. 상당히 좋은 예제이기 때문에 글의 앞부분을 인용해 설명해 보자(클로저를 설명하기 위해 조금 변형해 보았다).
함수형 언어의 함수 호출 역시 호출 후 아무것도 되돌리지 않고 또 되돌아오지 않는다면 goto와 다를 바 없다. 되돌린다고 해도 goto와 다를 바가 없다. '다음 할 일'은 이런 일을 생각해보는 하나의 화두다. 스킴의 CPS는 이렇게 생각해보면 그다지 신기한 것이 아니다. 함수가 언제나 리턴하는 것이 아니라는 생각을 한다면 CPS는 별다른 것이 아니다.
클로저가 만들어지는 과정
어떤 물건을 초콜릿으로 덮는(chocolate-covering) 함수를 생각해 보자. 함수는 입력에 대해 아래에 보이는 것처럼 출력한다.
이런 일을 하는 함수를 람다로 다음과 같이 표시할 수 있다.
L은 람다식을 나타내고 인자 x를 갖는다고 하자. 이제 인자 x에 peanuts를 대입하면 다음과 같다.
초콜릿으로 덮인 땅콩이 나온 것이다. 그런데 함수는 다른 람다식을 적용한 결과로도 만들어낼 수 있다. 이제 '덮는 함수를 만들어 내는'(covering function maker) 람다식을 생각해보자.
이제는 초콜릿뿐만 아니라 캐러멜을 덮는 함수를 만들 수 있다.
첫 번째 식에서 y는 캐러멜로 치환되었다. 그러면서 캐러멜로 덮는 람다식 Lx.caramel-covered x가 나타났다. 이 식에 peanuts를 적용하면 캐러멜로 덮인 땅콩이 나온다. Lx.caramel-covered x에서 caramel은 일종의 상태라고 볼 수 있다. '덮는 함수를 만드는' 식에 caramel을 적용한 결과 만들어진 상태를 갖고 있다. 물론 초콜릿이나 다른 재료를 적용하는 것도 상태를 만드는 작업이다.
상태를 갖고 있는 람다 함수를 클로저라고 한다면 독자들은 지금 클로저가 만들어지는 과정을 본 것이다. 클로저는 계산(evaluate)되어 caramel과 같은 bound variable을 갖는 함수를 말한다. 계산된 bound variable이 상태(state)인 셈이다.
람다 함수는 다른 함수의 입력으로도 사용될 수 있다. 이를테면 "apply-to-ants" 같은 함수를 생각할 수 있다.
이제 초콜릿으로 덮는 함수를 "apply-to-ants"에 적용해 보자:
인자 f가 Lx.chocolate-covered x로 치환되었고 이 함수에 ants가 재차 적용되었다. 그러면 chocolate-covered ants가 된다. 여기까지 이해했다면 람다의 많은 것을 이해한 셈이다. 반드시 초콜릿으로 덮을 이유도 없어진다. 앞에서 (Ly.Lx.y-covered x)caramel ->Lx.caramel-covered x였으니 (Lf.(f)ants) ((Ly.Lx.y-covered x)caramel)은 caramel-covered ants가 될 수 있다.
특별히 어렵게 생각할 것이 없다. 람다식은 인자를 치환하는 기능을 수행하고 클로저는 한번 계산되어 치환이 일어난(상태를 갖는) 람다식이다. 기계적인 과정인 것이다.
클로저의 예
지난번에 설명한 스킴 클로저의 예 가운데 다음과 같은 함수가 있었다.
이 식은 다음과 같다. derivative는 함수의 이름을 정의한 것이다.
이 식은 앞서 설명한 패턴인 Ly.Lx.y-covered x와 같은 패턴이다. 앞서 (Ly.Lx.y-covered x)caramel -> Lx.caramel-covered x에서 캐러멜을 덮는 함수를 만든 것처럼 람다식에 f dx가 주어지고 그 다음 람다식에 x를 적용하는 순서가 남아있다. 이제 f에 sqrt를, dx에 0.001을 적용하여 클로저를 만들 수 있다.
위의 식은 클로저이며 클로저에 이름을 붙일 수 있다.
이제 drv1은 중간값을 가진 함수다. 값들을 적용해 볼 수 있다.
자바스크립트에서는 x를 인자로 하는 함수를 되돌린다.
위의 식을 다음과 같이 적을 수도 있다.
자바스크립트는 f와 dx를 중간값으로 갖는 함수를 리턴하고 drv1은 f와 dx의 값을 계속 간직한다. 변수 f와 dx는 derivative가 수행된 다음에도 drv1에 살아남아 있다. 그 다음 함수는 drv1을 다시 정의할 필요가 없다.
위키백과에는 책이 얼마 이상 팔리면 베스트셀러로 분류하는 자바스크립트 함수 예제가 있다. filter를 사용했다.
이런 접근 방식은 필요한 함수를 동적으로 만들 수 있어 편리하다. 함수를 몇 차례 적용하는 것으로 훨씬 복잡한 함수를 만들 수 있으며 편리하기도 하지만 경우에 따라 메모리를 많이 차지하는 문제가 발생할 수 있다. 내부의 상태변수가 계속 남아있기 때문이다.
함수를 만드는 함수
이제 클로저의 용도를 생각해 볼 수 있겠다. 우선 함수를 만들어내는 용도에 알맞다. 앞에서 설명한 '초콜릿으로 덮는' 함수와 비슷한 것들을 생각해 볼 수 있다. 이 과정을 몇 번 거듭하면 매우 복잡한 함수를 동적으로 쉽게 만들 수 있다. 고차(higher order) 함수를 만드는 방법이기도 하다(SICP 1장부터 나온다).
그 다음은 일종의 OOP 같은 프로그래밍을 생각해 볼 수 있다. 먼저 SICP 3장의 예를 보자. 예제는 같지만 설명을 클로저의 관점에서 해보기로 한다.
은행 계좌를 표현하는 람다식이다. 앞의 '초콜릿으로 덮는' 함수와 비슷한 모양이지만 set!이라는 새로운 함수가 나타났다. 여기서는 변수의 값을 지정하는 역할을 한다. 은행의 잔고(balance)는 잔고에서 일정액(amount)을 뺀 값으로 새롭게 지정(assign)된다. 그 앞의 begin은 (begin ... )처럼 몇 개의 식을 차례로 계산할 때 사용한다. 위의 make-withdraw에서는 은행 잔고를 계산한 후 이 값을 리턴한다.
앞에서 본 것처럼 make-withdraw는 일종의 클로저다. 그래서 (make-withdraw 100)을 계산하면 상태변수를 갖는 클로저가 나타나고 이 클로저를 w1과 w2로 정의한다. 그러면 두 개의 w1, w2 클로저는 다른 상태를 갖는다.
이제 make-account를 조금 더 확장해 보자. 위의 예에서는 돈을 인출(with-draw)하는 함수만 있는데 돈을 적립(deposit)하는 함수도 만들어 보자. 다시 말하지만 define은 람다식이다. 이를테면 (define (withdraw amount) (...))는 (define withdraw (lambda (amount) (...))와 같다. 그러니까 아래 식은 보기보다 많은 람다로 이루어졌다.
이제 acc라는 새로운 객체 비슷한 것을 만들어보자. acc는 상태를 갖는 클로저다. 이 클로저로 인스턴스 만들기에 메서드 호출을 합친 것과 비슷한 일을 할 수 있다.
acc에 메시지 'withdraw나 'deposit을 지정하여 내부의 withdraw와 deposit을 불러냈다. 이 일은 dispatch 프로시저에서 정한다. 바로 앞의 예보다는 정교하게 변한 것이다. 그리고 acct2라는 새로운 클로저를 만들 수 있다. 내부의 상태 변수는 서로 독립적이다.
여기에 앞에서 한 것과 같은 조작을 독립적으로 할 수 있다.
초콜릿으로 덮는 함수와 관련하여 설명하면 한 가지만 더 설명하면 될 것 같다. dispatch 프로시저다. SICP에서 메시지 패싱(message passing) 방식이라는 것인데 사실 별다른 것이 없다. 특수한 함수가 아니다. 일종의 코딩 방법이다(지금 바로 이해가 필요한 것은 아니지만 이해하려는 독자들을 위해 덧붙인다. 클로저 이해에는 지장이 없다).
위 코드는 사실상 다음과 같다.
단순한 람다식으로 만약 입력이 ((acc 'withdraw) 50)이라면 그 다음의 50이라는 값을 전해주기 위한 방법이다. 람다식의 간결한 계산법에 예외가 생긴 것이 아니다. 자세한 내용은 책과 비교해 보기 바란다.
특별한 것이 없다고 생각하는 독자들이 많을 것이다. 정말로 클로저는 특별한 게 없다. 그래도 많은 내용을 적어 보았으니 Larson이 적어 놓은 클로저의 응용 예제를 한번 살펴보는 것도 좋겠다.
앞의 은행 계좌 예제와 비슷한 확장판이다. 차이가 있다면 위의 코드에서 메시지를 받은 프로시저는 객체처럼 그 메시지를 처리할 프로시저를 내놓다는 점이다(물론 처리하는 함수도 생각할 수 있다). 각각의 프로시저는 메시지를 받아 계산(evaluate)을 일으키면서 만들어질 당시의 상태변수를 갖고 있다. make-object 함수를 여러 번 부르는 것으로 인스턴스 비슷한 프로시저가 여러 개 만들어지고 클래스 구조와 상속 같은 것도 메시지 전달을 통해 만들 수 있다. 고차 함수를 사용함으로써 객체 지향 코드를 자연스러운 방법으로 만들 수 있다.
후기
리스프와 OOP의 관계 설명은 Peter Norvig의 PAIP(『Paradigms of Artificial Intelligence Programming: Case Studies in Common Lisp』) 13장에 잘 요약되어 있다. 클로저에 대해서도, 또한 CLOS(Common LISP Object System)에 대해서도 설명하고 있다. 13장의 앞부분은 은행 계좌 예제와 비슷한 리스프 코드를 OOP와 비교하면서 시작한다.
오리지널 람다 페이퍼 중 하나인 「Lambda: The Ultimate Declarative」에서는 리스프로 객체 지향 프로그래밍을 하는 방법을 다루고 있다. 글의 결론은 '클로저는 액터와 같다(Closure=Actor)'이다.
Part8: 제어 흐름의 패턴
2008년 10월 28일
들어가며
필자는 가끔씩 명료한 알고리즘을 수많은 괄호로 바꾸어 놓는 리스프의 식에 아연실색하곤 한다. 명료한 알고리즘도 괄호에 둘러싸이면 (특히 머리가 잘 돌아가지 않을 때에는) 어려운 문제로 둔갑한다. 이런 때는 인터프리터 기계가 아니라는 사실 정도로 위안을 삼을 수밖에 없다. 그러나 가끔 이런 괄호의 벽을 구성하는 제어 흐름을 생각해야 할 필요가 있다. 나무가 아닌 숲을 생각해 보는 것과 비슷하다.
지난번 다룬 call/cc와 컨티뉴에이션은 알고리즘이 괄호의 벽을 빠져나가고 들어오는 중요한 방법이라고 할 수 있다. SICP에서는 자세히 다루지 않았지만 스킴에서는 중요한 주제다. 책의 저자 서스만과 그의 학생이자 동료나 다름 없던 가이 스틸은 오리지널 람다 페이퍼, 특히 「Lambda the Ultimate Imperative」에서 제어 흐름을 집중적으로 다뤘다(많은 통찰을 얻을 수 있으니 읽기를 권한다). 생각해볼 주제가 많은 글이다.
프로그램의 제어를 전달하는 요소는 몇 가지로 나누어 살펴 볼 수 있다.
- 시퀀셜 블록
- goto
- if ~ then ~ else
- while ~ do 같은 루프
- exit
시퀀셜 블록
이런 요소들에 대한 명령형 언어의 코드와 람다 식을 비교해 보자. 먼저 명령 여러 개를 처리하는 시퀀셜 블록부터 시작한다. 가장 흔한 코드를 예로 들어 보자. 여기서 s1 등은 문(statement)이다.
아니면 C 언어의 {s1 ; s2 ; s3;} 같이 표현할 수도 있겠다. 코드 블록으로 {s1 ; {s2 ; {s3}}}}처럼 적을 수도 있다. 문장을 이렇게 늘어놓는 것은 일종의 사이드 이펙트를 바라는 것이다. 계산이 일어나거나 대입이 일어나기를, 그것도 순차적으로 일어나기를 원한다. 너무나 일상적인 코딩이어서 전혀 생소하지 않다. 그러나 스킴 같은 함수형 언어에서는 대입이 일어나는 것을 항상 기대하지는 않는다(a=b ; c=b처럼 대입을 일으키는 일, 프린터나 화면에 출력을 하는 것도 일종의 사이드 이펙트다. 계산이 끝나면 항상 무엇인가 변화가 있다).
s1, s2 같은 것은 스킴이나 리스프에서는 람다 식을 계산하는 작업에 해당한다. 그러니 s1; s2를 순차적으로 계산하는 방법은 다음처럼 생각해 볼 수 있다.
인자의 계산이 먼저 일어나므로 s1을 먼저 계산한다. 그리고 이것을 람다 식에 적용하는 것이다. 람다 식은 변수 dummy를 받은 후 s2를 계산한다. 결과적으로 s1을 먼저 계산하고 s2를 계산한다. 이 식을 (block S1 S2)처럼 만들면 (block S1 S2 … Sn)은 (block S1 (block S2 (... (block Sn-1 block Sn) ...))처럼 적을 수 있겠다. 실제 스킴 코드는 (begin s1 s2 s3 ...)처럼 적는다. 별다른 것은 아니지만 한번 생각해볼 필요가 있다.
블록을 일반화한 (lambda (dummy) (lambda (dummy)(…)s3) s2) s1) 같은 패턴의 적용과 계산을 생각할 수 있다. 적용은 순차적으로 일어나며 무엇인가 사이드 이펙트를 기대한다. 마지막 식 Sn을 계산하면 이 값이 리턴값이며 그 앞의 식들은 사이드 이펙트를 제외한다면 결과에 영향이 없다. 그저 계산만 한 것이다. 그래서 예전의 어떤 순수한 함수형 리스프에서는 순차식을 인정하지 않았다. 모든 람다 함수는 조건 식을 제외하면 하나의 람다 식으로 이루어져야 했다. 람다의 인자도 lambda (a)처럼 하나만 받을 수 있도록 제한하려 했다.
시퀀셜 블록을 설명했으니 조금 도약해서 (define bar (lambda (x y) (f x y))) 같은 식을 생각해 보자. 이 식에 인수를 적용하므로 이를테면 (bar foo etc)는 foo와 etc를 앞서 정의한 bar에 적용한다. 이때 foo와 etc가 람다 식이라면 먼저 foo와 etc를 계산한다. foo를 먼저 etc를 나중에 계산한다. 결과적으로 내부의 (f x y)에 다시 대입되기 이전에 이미 한번 계산이 일어난다. 명시하지는 않았지만 foo와 etc를 미리 계산하는 것은 s1과 s2를 계산하는 패턴과 다르지 않다. 원래는 foo의 계산된 값이 x이고 etc의 계산된 값을 y에 적용하기 위한 계산이었으나 이것들이 함수 호출을 일으키면 일종의 시퀀셜 블록처럼 작용하는 것을 알 수 있다. 사이드 이펙트가 있었다면 당연히 무엇인가 변할 것이다.
이런 혼동을 피하기 위해 람다 식의 인자를 하나로, 내부의 식도 한 줄로 생각하는 것이 더 명확하게 람다 함수를 표현하는 방법일 수도 있다. 실제로 알론조 처치가 생각한 람다 식은 이런 형태였다고 한다. 하지만 수없이 많은 람다를 만들어내며 프로그래밍하기를 좋아하는 프로그래머는 없었다.
시퀀셜 블록을 이제 하나의 순차적 컨티뉴에이션처럼 생각할 수도 있다. s1의 컨티뉴에이션은 s2이며 s2의 컨티뉴에이션은 s3 ... 이런 식으로 생각할 수 있겠다. 람다 계산법을 발명한 처치 역시 컨티뉴에이션에 대해 잘 알았는데(이름을 짓지는 않았지만) 처치는 두 종류의 컨티뉴에이션을 생각했다. 하나는 지금 예로 든 것과 같은 순차적인 것이고 다른 하나는 조건에 따라 분기하는 컨티뉴에이션이다. 예전에 컨티뉴에이션 패싱 스타일(Continuation Passing Style)을 다루면서 든 예들과 비슷하다.
어디로 갈까, goto
시퀀셜 블록 다음으로는 GOTO 문을 대체하는 패턴이다. C 언어에서는 label과 goto를 자주 사용하지는 않지만 가끔씩 쓴다. 먼저 알골의 예를 살펴 보자.
스킴에서는 letrec을 사용하여 비슷한 일을 할 수 있다. letrec은 람다에 이름을 부여한다는 점에서 define과 비슷하다(letrec에 대해서는 r5rs 문서나 「learning scheme in fixnum days」 같은 문서의 예를 보기 바란다).
이 예제는 L1부터 시작한다. 식의 끝부분에서 (L1)이 초기값으로 지정되었다. 많이 사용하는 letrec을 사용하여 예제를 보여주었지만 예전에 다룬 CPS 예제들과 본질적으로 같은 요소들을 갖고 있다. 공통적인 패턴은 쉽게 파악할 수 있다. 함수 호출은 본질적으로 goto다.
간단한 변수 대입(assignment) 역시 람다로 구현할 수 있다. 2차 방정식의 해를 구하는 예제다. 알골에서는 다음과 같다.
이 식을 람다로 만들어볼 수 있다.
특별한 트릭은 아니다. A2와 SQRTDIC을 인자로 받은 다음 ROOT1과 ROOT2에 해당하는 값을 계산하도록 람다 함수에 적용한 것이다. set!이나 다른 사이드 이펙트를 사용하지 않고 람다 식 내부에서 변수 대입처럼 처리했다. 이보다 조금 더 복잡한 예제도 있다. 음이 아닌 정수의 패리티를 구하는 예제다.
이 식을 스킴으로 표현하면 다음과 같다.
사이드 이펙트를 일으키는 것이 아니라 A와 PARITY처럼 변경이 예상되는 변수를 함수의 인자로 전달하는 트릭을 사용했다. 그리고 letrec을 사용했다. 참고로 그림은 이 계산의 흐름도다. 함수는 일종의 GOTO처럼, 다시 말하면 인자를 갖는 GOTO처럼 사용되었다.
>
그림 1. 계산 흐름도
글에 나오는 나머지 문제들, 루프나 복합식 예제도 위에서 소개한 예제들을 이용하여 쉽게 바꾸어 볼 수 있다. 예들은 그다지 많지도 복잡하지도 않으니 독자들이 한번 읽어보는 것으로 쉽게 이해할 수 있을 것이다. 글에 나오는 이스케이프 연산자(escape operator)라는 것은 나중에 call/cc로 이름을 바꾸어 발전했고 이 주제 역시 지난 글에서 다루었다.
함수 불러내기(Function Invocation: Ultimate Imperative)
스킴은 처음에는 액터 모델에 집착한 작은 장난감 언어로 시작했다. 액터 모델과의 유사성 내지는 동일성을 따져보고 구현하는 일에 많은 집착을 보였다. 클로저와 액터의 유사성이나 함수 호출에서 메시지 패싱, 제어의 전달이라는 측면을 부각했다. 그전까지는 막연하게 생각하던 것들에 집착하여 파고든 것이다. 필자는 이 내용을 몇 번에 걸쳐 소개했다. 저자들의 주장을 일관하는 내용은 '함수 불러내기(invocation) = goto + 메시지 전달'이라는 점이었다. 컨티뉴에이션을 연구하는 사람 중에는 이 관점을 가장 중요한 관찰이었다고 평하는 사람이 많다. 그런데 그 내용은 특별하거나 어려운 것이 아니다. 일반적으로 함수 호출(function call) 시나리오는 다음과 같다.
- 인자를 미리 계산하여 함수가 필요로 할 것으로 예상되는 장소에 저장한다.
- 함수를 부르고 돌아올 위치를 저장한다.
- 함수는 값을 계산하고 이 값을 호출자가 가져갈 수 있는 위치에 저장한다.
- 함수는 저장된 주소로 돌아가고 이 주소값을 버린다.
이 정도는 독자들도 다 아는 내용이다.
함수 호출을 하나의 goto로 생각한다면 함수가 되돌아갈 위치를 아는 방법은 무엇인지를 생각해 보아야 한다. 저자들은 다음과 같은 함수의 동작을 생각해 보았다고 한다.
bar는 x y를 인자로 받아 x를 g에 적용하여 계산하고 y를 h에 대해 계산한 다음 이 계산값들을 다시 f에 적용하는 함수로 정의했다.
리스프에서 bar를 계산할 때 인자를 미리 계산하고 돌아갈 위치(보통 스택에 존재한다)를 알 필요가 있다. 그 다음에는 f g h를 불러내야 한다. g와 h를 계산할 때에는 돌아올 주소를 알려주어야 한다. 계산은 진행되어야 하니까. f를 계산할 때에는 돌아올 주소를 알려줄 필요가 없으며 단지 GOTO만으로도 가능하다. 이 값은 이미 bar에 의해 물려받은 것이다. 컨티뉴에이션 관점에서 보았을 때 f의 계산이 끝나면 바로 다음의 식으로 진행해야 한다!
꼬리 전달과 꼬리 재귀
람다 페이퍼의 3부작 글에서 이 주제는 모습을 바꾸어 여러 번 반복된다. 끝의 f에서 일어나는 제어의 전달은 무조건적인 것이며 휴이트의 액터에서도 거론되었던 문제다. 서스만은 이 함수의 끝부분을 꼬리 전달(Tail Transfer)이라 불렀다. 이 전달은 재귀에 있어서도 마찬가지라 꼬리 재귀(Tail Recursion)의 배경이 된다. 스택에 중간의 계산값들이 저장되는 일은 있어도 끝에 가면 아무것도 남지 않는다. 그냥 점프만이 일어난다. 이 주제의 설명은 별다르지는 않지만 결론은 특별하다.
저자들은 PDP-10의 기계어로 동작을 설명한다. 두 종류의 명령을 사용했다. 하나는 PUSHJ foo라는 명령으로 현재 주소를 스택에 PUSH하고 주소 foo로 Jump한다. 다른 하나는 POPJ로 스택에 저장된 주소를 POP하고 이 주소로 Jump한다. 그러면 BAR는 다음과 같이 구성된다. 함수 F G H도 같이 나타냈다. bar를 컴파일하면 다음과 비슷한 코드가 나온다.
맨 처음 BAR를 불렀을 때 스택의 모습은 다음과 같다. BAR를 부른 함수는 돌아올 주소를 저장한다.
BAR는 G를 먼저 계산해야 하니 BAR는 G를 푸시한다(PUSH G). 그러나 그 전에 돌아올 위치가 주어진다. PUSHJ G 바로 뒤의 BAR1이다. 그러면 스택은 다음과 같다.
G에서 무엇인가를 한참 계산한 다음 POPJ를 실행하면 BAR1으로 돌아오고 스택은 처음과 같이 변한다.
이것은 BAR1에서 H로 갈 때에도 마찬가지이며 BAR2에서 어디엔가 저장된 G와 H의 계산 결과를 가지고 F를 계산할 때에도 마찬가지다.
BAR2에서 F에 진입하였을 때 스택의 모습은 다음과 같다(BAR3은 BAR 함수의 끝부분이다).
원래는 PUSHJ F, F를 계산하고 POPJ를 수행한다. 그러면 BAR3로 돌아오고 이때 스택의 모습은 다음과 같다.
이제 BAR3의 POPJ를 수행하면 BAR를 호출한 곳으로 돌아가며 스택은 처음과 같게 된다.
BAR2에서 PUSHJ F를 GOTO F로 대체하면 어떻게 될까? 다음과 같이 된다. BAR3가 저장되지 않는다. 다음은 F에 진입하였을 때 스택의 모습이다.
F가 POPJ를 수행하면 원래 BAR의 복귀할 주소로 돌아간다. 둘은 같은 결과를 낸다. 결과적으로는 PUSHJ F ; POPJ 하나가 생략된 스택 사용의 간단한 최적화처럼 보인다. 그러나 중간의 함수 계산과 끝부분의 함수 계산은 균등하지 않다. 둘은 다르게 컴파일되어야 한다.
다른 리스프 컴파일러의 결과를 기계어로 대조해 보아도 비슷한 결과를 만들어 냈다(이 내용은 「Lambda: The Ultimate Declarative」에 자세히 나오니 생략한다). 저자들은 몇 개의 기계에서 나온 결과들을 분석했다. 모두 비슷한 모습을 보였다.
중간 계산값들을 스택이 아니라 다른 레지스터에 저장한다고 하면 다음과 같이 적을 수 있다. 이번에는 PUSHJ를 사용하지 않고 PUSH를 사용했다.
스킴 인터프리터를 구현하면서 선택한 설명은 F와 G를 다른 것으로 보는 것이다. G는 다른 함수의 인자가 되는 함수이고 이때는 돌아올 주소를 저장해 주어야 한다(function call이다). 그러나 F는 아니다(function invocation이다). F를 보았을 때 처음부터 아무것도 저장되지 않았고 끝에서는 점프가 일어난다. 이제 일반적인 형태를 다시 들여다 보자.
중간의 값들이 어떤 형태를 갖건 F는 앞에서 적은 것처럼 처음부터 스택에 아무것도 갖지 않은 채로 출발했고 끝에서는 GOTO로 끝나며 아무것도 남지 않는다. 서스만은 꼬리 재귀가 아니라 꼬리 전달이 더 좋은 용어일 것이라고 했다.
Part9: 게으른'계산이 필요한 순간
2008년 11월 18일
들어가며
필자는 종종 역사를 강조했다. 거기에는 이유가 있다. 현재 복잡하고 과도한 최적화가 일어난 중요한 아이디어 가운데는 처음엔 간단하고 소박한 이유에서 출발한 것이 많기 때문이다. 몇 번의 중요한 변형을 거쳐 포장을 마치고 교과서에 나타난 아이디어들은 일종의 학습물처럼 변한다. 아니면 교과서의 예제로 변하고 만다.
이런 일이 좋은 경우도, 좋지 않은 경우도 있다. 아무튼 필자에게는 그 시작이 그리 어렵지 않다는 확신과 어떤 논리적인 근거로 오늘날의 것처럼 변했는지를 아는 일이 중요했다(이해한 느낌이라도 난다). 그래서 필자는 역사적 맥락을 반드시 살펴보기를 권했다. 맥락을 놓고 보면 도중의 몇 번의 도약과 변화도 사실상 환히 보이며 개념을 만드는 등장인물들도 대략 정해져 있음을 알게 된다. 대략 어떤 문제로 고민하다 어떤 답을 들고 나오는지가 중요하다.
아주 깊은 이해는 아니라도 흐름을 이해할 정도면 문제는 쉽게 이해된다. 시간만 약간 더 들인다면 공부한 결과의 깊이와 폭은 그렇지 않은 경우와는 비교가 안 된다. 그 이후의 이해는 더 빨리 그리고 깊이 늘어날 가능성이 있다. 대신 에너지 소모는 다소 크다.
그 반대의 경우도 있는데 하나의 새로운 주제에 집착하여 이것을 발전시켜 나가는 경우다. 이 경우에도 많은 에너지가 소모되나 결국 나중에 비슷한 주제를 모두 훑어보아야 하기 때문에 비슷한 경로를 밟는다. 개념이라는 것이 어디서 툭 떨어지는 것은 아니기 때문이다. 대부분 집요한 사고의 산물이다.
중요한 글들의 가치가 있다면 근본적인 문제를 들고 나왔기 때문이며 이들은 답이 아니라 문제 그 자체인 경우가 더 많다. 답은 문제에서 나오지만 본질적인 문제 제기들은 답이 나와도 남아있다. 이런 글들은 읽어 보는 것이 좋다. 모든 논문을 읽을 수는 없으나 어떤 것들은 (반드시) 읽어야 한다. 개인의 선택이다
'게으른'계산
이번 회에는 게으른(lazy) 계산법에 관련된 주제를 다룰 것이다. SICP에서는 스트림이라는 제목으로 다루는데 다소 어려운 주제라고 생각하는 사람이 많다. 필자의 생각으로는 정작 스트림보다 더 중요한 주제는 지연된 계산(delayed evaluation) 문제다. 책의 첫 번째 절은 스트림을 지연된 리스트(Streams Are Delayed Lists)로 본다. 스트림은 피터 란딘(Peter Landin)이 고안했고 이 아이디어를 다니엘 프리드만(Daniel Friedman)이 LISP에서 지연된 리스트로 구현한 것을 SICP 저자들이 다시 세련된 모습으로 바꾼 것이다.
책의 예제는 별다른 것이 없다. a와 b 사이의 소수의 합을 구하는 함수부터 출발한다. 코드의 상세한 부분을 몰라도 동작을 이해하는 건 어렵지 않아 보인다. 첫 번째 접근법은 반복(iteration)을 이용하는 예제다. 소수를 고르는 루프를 돌리고 해당하는 소수의 합을 구한다.
다른 방법은 enumerate-interval이라는 함수에서 a와 b 사이의 정수 리스트를 만들고 조건식(predicate)에 해당하는 부분만을 걸러주는 filter 함수에 적용하여 만든 새로운 리스트를 accumulate 함수에 적용하여 더(+)한다. filter는 predicate에 해당하는 부분만을 cons하여 새로운 리스트를 만든다.
필터는 새로울 것이 없다. predicate에 해당하는 부분만 리스트에 더하면 된다.
리스트를 만들고 필터를 적용하여 걸러낸 리스트를 계속 더해가는 접근법으로 우아한 형식을 갖고 있다. 쉽게 읽을 수 있으며 모듈성이 좋다.
이 방법은 작은 범위에서는 잘 작용하지만 이를테면 10,000에서 1,000,000까지의 정수에서 소수를 구하는 경우에는 시간이 정말 오래 걸린다. 거의 100만 개 정도의 정수 리스트를 만들고 이것을 일일이 필터링하여 몇 개 안 되는 소수를 만들어 낸다. 명백히 비효율적이다. 사실 다 계산할 필요가 없다. 식이 비효율적인 이유는 모든 것을 다 계산하기 때문이다. 그렇다고 enumerate-interval과 filter 그리고 accumulate를 모두 뒤섞은, 처음과 같은 식을 만드는 것은 모듈성을 크게 떨어뜨리고 만다.
이 같은 스트림을 계산하면서 중요한 것은 계산을 다 할 필요가 없다는 사실이다. 스트림에서 중요한 것은 맨 앞의 요소이고, 그 다음의 값들은 나중에 필요하다. 스트림은 데이터를 계속 내어줄 것이라는 일종의 약속이다. 그러니 스트림을 리스트처럼 생각한다면 다음과 같은 식을 만들어 볼 수 있겠다.
이것은 cons의 구조와 같아 보인다. 그리고 (cons-stream <a> <b>)는 (cons <a> (delay <b>)) 같이 정의한다. 앞의 인자는 cons의 경우와 같지만 뒤의 인자는 계산하지 않는다. 계산하지 않고 미루는 것이다. 그래서 실제 식은 다시 다음과 같이 생각할 수 있다.
직관적으로 생각하면 이렇다. 스트림 내부가 어떤 식으로 되어 있는지는 잘 모른다. 하지만 stream-car에 적용하면 첫 번째 요소가 바로 나오고 stream-cdr에 적용하면 그 다음 요소를 계산하도록 강제한다. 스트림은 데이터를 내어주는 일종의 식(expression)인 것이다.
그러니 일반적인 사용법은 식에 계산을 일으키는 것으로 충분하다. 스트림이 데이터를 내어주는 것만 확실하다면 일반적인 car, cdr 연산과 다르지 않다. 단순하게 생각한다면 첫 번째, 두 번째, 세 번째 식으로 스트림의 요소를 끄집어 내는 방법은 다음과 같다.
그렇다면 먼저 stream의 요소인 delay의 구조를 살펴보자.
delay는 식 앞에 람다로 둘러싸서 계산을 지연한 것이다. force는 지연된 형태의 람다식을 계산하도록 만드는 것이다.
실제로 plt 스킴을 돌려 실행해 보면 다음과 같이 나타난다(plt 스킴에서 r5rs 언어 세트를 사용했다. r5rs에서 delay와 force는 별개의 제어 구조로 정의된다).
dealy와 force가 표준이 되기 이전의 원래 형태는 람다를 이용하는 것이었다. lambda()로 둘러싸 평가를 지연하는 것이다.
위의 식에서 5를 (+ 1 2) 같은 식으로 대체해도 똑같은 결과를 얻을 것이다.
SICP에 나오는 스트림 버전의 예제는 앞의 filter와 enumerate-interval을 스트림 버전으로 바꾼 것이다. 스트림 버전에서는 예전보다 빠르게 값을 만들어 낸다. 불필요한 부분을 cons하지 않도록 바꾸어 놓았기 때문이다.
여기서 바꾸어 놓은 버전은 아래와 같다.
자세한 동작은 SICP의 3.5.1을 보자. 이 식의 동작은 stream-filter와 stream-enumerate-interval이 상호작용하는 것이다. stream-filter는 지연된 계산을 트리거한다. stream-enumerate-interval이 모든 정수를 다 cons하지 않도록 한다. 사실상 stream-filter는 첫 번째 정수를 찾고 그 다음 정수가 나올 때까지 stream-enumerate-interval을 돌려 찾아낸다. iteration 루프가 돌아가는 것과 같다. 이런 형태로 스트림을 적용하는 방식은 모듈성을 떨어뜨리지 않고 우아한 계산을 가능하게 한다.
함수를 반드시 바로 계산할 필요는 없다는 사실, 때로는 계산을 하지 않은 편이 낫다는 사실은 매우 중요하다. SICP는 조금 까다로운 예제를 들어 어렵게 설명하고 있다(스트림의 유용성에 대한 논란도 분분한 편이다).
계산을 미루는 것을 게으른 계산(lazy evaluation)이라 부른다(혹시 게으른 계산의 실용적인 사용에 대해 궁금한 독자들은 developerWorks 기사를 참고하라). SICP는 3장에서 스트림을 설명하고 나서 곧바로 4.2절의 Lazy Evaluator로 넘어간다. 이때도 자료가 부족한 편인데 프리드만이 쓴 "Scheme and the Art of Programming"의 15장은 이 내용을 보강한다. 관심이 있는 독자들에게는 도움이 되겠다.
인자를 이름으로 넘기기(Call by Name)
이제 더 중요한 과제인 지연된 람다를 다른 예제를 들어 설명하려고 한다. 이 경우에는 람다가 계산을 일으키지 않는 트릭에 대해 스트림 이전의 형태를 아는 것이 도움이 되겠다. 근본적이고 통찰력이 있는 예제가 바로 스킴에서 call-by-name 문제다. 이 예제는 스틸과 서스만의 Lambda the ultimate imperative에 나온다(더 이전의 중요한 자료는 프리드만의 CONS should not evaluate its argument라는 글이다). 당시 저자들은 call-by-name 같은 접근법이 코루틴이나 제너레이터(generator) 같은 것을 만드는 데 도움을 줄 것으로 보았다.
스킴에서 일반적인 인자 넘기기(parameter passing)는 call-by-value다. 인자의 값은 함수로 넘기기 전에 이미 계산이 이루어진다. 대부분의 다른 언어 역시 call-by-value나 call-by-reference로 넘어간다. 그러나 알골(ALGOL)-60 같은 경우는 call-by-name으로 인자를 넘긴다. 인자는 계산되지 않은 상태의 매크로처럼 넘어간다(실제로는 함수를 다루는 핸들만이 넘어간다). 알골에서는 이런 메커니즘을 썽크(Thunk)라고 불렀다. 이를테면 다음과 같은 항을 만들어내는 함수를 생각해 보자. 함수의 분모는 n의 제곱이다.
이런 함수 terms가 있을 때 terms는 직관적으로 다음과 같은 코드로 생각할 수 있다. 아래의 함수는 n 번째 항부터 리스트를 만들어 낸다.
이 함수를 돌리면 리스트를 만들다 모든 자원을 소모하고 스킴 인터프리터는 정지하고 만다. 메모리 부족은 금방 일어난다. 종료 조건이 없는 재귀라 무한 수열을 만들어내며 곧 발산한다. 계산이 멈추지 않으니 계산이 끝나지 않는다. 무한한 리스트를 만드는 enumerate-interval과 같다. 그러니 열심히 계산(eager evaluation)하지 않으면 문제는 쉬워진다.
이런 함수 terms가 제대로 작용할 때 terms가 만들어내는 리스트에 대해 계산을 할 수 있다. 이를테면 car(cdr(cdr(terms(3))))은 1/25이다. 리스트의 세 번째 항부터 계산을 시작하고 그 리스트를 두 번 cdr한 항은 25다.
앞서 말한 것처럼 terms에 종료 조건이 없다고 해도 모든 계산을 다 일으키지 않는다면 별다른 문제가 아니다(다시 말하지만 함수가 반드시 미리 계산될 이유는 없다). 앞서 소개한 예처럼 많은 프로그램이 문제를 미리 풀어서 문제를 일으키기도 한다. 그러니 문제를 푸는 것을 지연시키는 것도 나쁘지 않은 것이다. 계산을 일으키지 않는 쉬운 방법은 람다를 만들되 불필요한 것은 미리 계산하지 않는 것이다.
다음은 위 예제를 요즘의 plt 스킴으로 구현해본 것이다. 책의 코드는 오래 되었지만 매우 본질적인 내용이다. 우선 cbn-car와 cbn-cdr을 정의했다. cbn은 call by name의 약자다. car는 s에 true를, cdr은 flase를 적용한다.
cbn-cons는 두 인자 x y를 받아 참인 경우 (x)를, 거짓이면 (y)를 내어 놓는다. ()로 둘러싼 것은 x와 y를 계산(evaluate)하는 것을 의미한다.
그리고 terms를 정의한다. cbn-cons에 들어가는 함수를 lambda()(...)로 둘러싼 것을 알 수 있다. 계산은 지연된다.
그러면 이제 함수를 돌려 볼 수 있다. 가장 본질적인 내용은 terms가 람다 함수를 되돌린다는 내용이다.
(terms 3)은 cbn-cons로 만든 앞서의 스트림 예제와 비슷한 것을 되돌린다. 그러면 여기에 car와 cdr을 적용한다. cbn-car는 s에 참값(#t)을 적용한다. (s #t)
cbn-car가 제대로 나왔다. 책의 예제는 여기까지다. 이제 지연된 계산을 이용해 문제를 더 풀어보자. cbn-cdr은 이미 지연된 계산을 내포하고 있다.
계산을 시키면 다음과 같다(스트림 예제의 force나 마찬가지다).
이것은 다음 식과 마찬가지다.
다시 한번 계산을 시켜 보자.
다음 식과 같은 작용이다.
결국 이들은 문제를 풀려고 클로저를 만들어내고 적용하는 과정이다. 별 것이 아니다. 클로저를 만들고 필요할 때마다 계산한다. 마치 스트림의 원시형처럼 보인다(사실이기도 하다).
아주 날카로운 독자들은 스트림을 바로 이해할 수 있을지도 모르나 필자의 경우는 이렇게 생각하는 편이 이해가 빨랐다. 그래서 call-by-name과 같이 쉬우면서도 어려운 개념은 이런 식으로 설명할 수 있게 되었다. 원 글에 같이 나오는 call-by-need도 비슷한 예제다. call-by-need는 불필요한 재계산을 피한다.
call-by-name의 인자에 filter나 enumerate-interval 같은 함수를 포함시켜 코드를 짜도 접근 방식은 변하지 않는다. 상태를 가진 함수를 부르는 것뿐이다. 이제 비슷한 식들이 상당히 복잡해져도 독자들은 혼란스럽지 않을 것이다.
스트림과 비슷한 문제들은 지연된 람다 함수를 한번 계산하고 나머지 계산 과정을 뒤로 미루는 접근법이다.
다른 방법론들
비슷한 문제에 대한 대안으로 스트림이 전부는 아니다. call/cc나 코루틴도 있으며 스트림으로도 가능하다.
필자의 머리에 떠오르는 것으로는 유명한 same fringe 문제가 있는데 이것은 트리의 마지막 노드, 즉 이파리(leaf)들이 동일한지를 체크하는 문제다. 가지는 임의로 복잡해질 수 있지만 관심이 있는 것은 마지막 노드들이다. 이 문제는 앞서 설명한 스트림처럼 만들어 풀거나 더 고전적인 방법으로 트리를 일차원적인 리스트로 바꾸어 푸는 방법도 있다, 이 예제는 많은 스킴 문헌에 나온다. call/cc나 코루틴을 동원하여 푸는 방법도 있고 CPS(continuation passing style)로 푸는 방법도 있다. 이들은 모두 흐름 제어의 중요한 요소라는 것을 아는 일이 중요하다. 설명은 어렵지만 내용은 별다른 것이 없다. 다음과 같은 예제다.
여러 가지 버전의 same fringe 문제는 지면상 다음회의 내용이다.
Part10: 같은 잔가지(same fringe) 문제
2008년 12월 23일
들어가며
지난번 글에서 스트림과 '인자를 이름으로 넘기기(call-by-name)'를 설명했다. 이 간단한 예제가 중요한 이유 중 하나는 필요한 계산만 하는 일이 중요하기 때문이다. 불필요한 계산을 끝까지 뒤로 미루는 것도 나쁘지 않다. 불필요한 계산을 하지 않고 넘어가는 것은 중요한 최적화다. 지난번의 인자를 이름으로 넘기기는 람다로 둘러싸서 계산을 지연시키는 것만으로도 일을 간단히 처리할 수 있다는 사실을 보여준다. 그 다음 예제는 call-by-need 같은 것으로 일종의 간단한 최적화다. 관심이 있는 독자들은 더 읽어보면 좋을 것 같다.
이번에 다룰 같은 잔가지(same fringe) 문제는 여러 가지를 생각하게 하는 예제다. 문제를 해결하는 방법이 많기 때문이다(프로그래밍 스타일에 대한 작은 화두들을 던진다).
왼쪽에서 오른쪽처럼 정해진 순위가 있는 트리 구조에서 내부 구조는 달라도 이파리(leaf)들이 같은 순서로 나타나면 둘은 같은 잔가지다. 아래 예에서 트리 1과 트리 2는 같은 잔가지다. 트리 3은 아니다.
>
그림 1. 트리
쉽게 생각할 수 있는 직관적인 방법은 두 개의 트리를 모두 일차원 리스트로 치환해 생각하는 것이다. 고전적인 소스도 많지만 필자는 Dorai Sitaram의 『Teach Yourself Scheme in Fixnum Days』에서 인용했다(SICP를 읽다가 질리는 독자들에게는 무척 좋은 스킴 교재이기도 하며 call/cc 부분에 대한 13장의 설명은 아주 명쾌하다). 지난번에 소개했듯이 리스트 두 개를 비교한다.
이 문제에 대한 직관적인 소스 코드는 간단하다.
소스 코드는 자명하기 때문에 설명이 필요 없을 정도다. 앞의 let loop는 다음과 같이 사용하는 일종의 변형된 letrec이다. define 형태로 바로 바꿀 수 있다.
flatten 프로시저는 cdr 재귀로 리스트를 밋밋한 1차원 리스트로 치환한다.
same-fringe? 프로시저에서 (let loop ((ftree1 (flatten tree1)) (ftree2 (flatten tree2)))로 tree1을 밋밋하게 만든 ftree1을 받아 초기화한다. ftree2도 마찬가지다. 리스트 끝에 도달할 때까지 이파리를 비교한다. 이른바 함수형 스타일(Functional Style) 예제다. 리스프(LISP)를 발견한 존 매카시가 작성한 예제는 위의 예제보다 조금 더 우아한 함수형 스타일을 보이지만 접근방법은 마찬가지다.
지난번의 스트림이나 인자를 이름으로 넘기기에서 제기한 문제점들과 마찬가지로 이 루틴은 원래의 트리를 끝까지 읽어 처리한다(스트림에서는 큰 범위의 소수값을 얻기 위해 엄청나게 기다리거나 메모리 부족을 기다려야 했다). 결과를 내려면 이것들을 모두 CONSing해야 하는데 두 벌의 복사본이 필요하며 처리 도중에 만들어지는 중간 결과값도 무시 못 할 정도다. 아무리 최적화를 진행해도 적어도 트리의 원소 수에 해당하는 CONS 작업이 필요하다. 트리가 커지면 계산은 한없이 느려지고 중간 값들도 늘어난다. 중간 값들을 저장하지 못할 수도 있다.
하지만 같은 잔가지 문제를 푸는 방법의 감을 잡았으니 독자들도 독자적인 해법을 하나 둘씩 생각할 수 있을 것이다. 방법은 정말 다양하다. 그 중 하나는 제네레이터를 사용하는 것인데 아래 예제인 tree->generator는 한번 호출이 일어날 때마다 다음 값을 내어준다(C 언어의 random() 같은 것을 랜덤 제네레이터라고 부른다). 이 예제 역시 『Teach Yourself Scheme in Fixnum Days』에 나오는 예제다(유명한 예제로 비슷한 예들은 넘치도록 많다). 이 예제에서는 CONS를 사용하지 않고 마지막 값을 되돌리며 그 다음 호출이 일어나면 새로운 계산을 한다.
이번 예제의 same-fringe? 프로시저는 ((gen1 (tree->generator tree1)) (gen2 (tree->generator tree2)))로 tree1을 밋밋하게 만든 gen1으로 초기화한다. gen2도 마찬가지다. 리스트의 끝에 도달할 때까지 이파리를 비교한다. 앞의 예제와 다를 것은 없다.
리스프 계열 언어들의 장점은 모듈별로 상당한 수준까지 상향식(bottom up) 접근이 되는 것일 것이다. 우선 call/cc로 만든 트리 제네레이터를 돌려보자:
call/cc는 직관적으로 설명하면 Sitram의 글에서는 현재 컨티뉴에이션(current continuation)을 프로그램의 나머지 부분(rest of the program)으로 본다. 다음 코드를 보자.
위 코드는 call/cc의 관점에서는 다음과 같이 본다는 의미다.
[]은 정말 하나의 작은 구멍처럼 본다. 무엇이 나타날지는 알 수 없다.
call with의 의미는 []에 무엇을 넣는가이다. call/cc의 인자 k는 프로그램의 나머지 부분을 대표한다. 여기에 (lambda (k) (+ 2 (k 3)))처럼 k에 3을 적용하면 []는 3으로 변한다. 앞의 \+ 2 계산은 의미가 없어진다. 현재의 컨티뉴에이션에 3을 적용하는 것이 전부이자 마지막인 것이다. 프로그램이 앞으로 더 할 일은 여기서 끝난다. (+2 []) 계산에서 빠져 나오는 것이다. 컨티뉴에이션이 3이다. 그래서 []는 3으로 변한다. 결국 (+ 1 [])은 (+ 1 3)이다. 이런 방법을 이스케이프 컨티뉴에이션(escape continuation)이라고 부른다.
그러나 컨티뉴에이션은 다른 방법으로도 사용된다. 어보티브 컨티뉴에이션(abortive continuation)이라고 부르는 것은 이전에 버려졌던 문맥을 되살리는 데 사용된다. 프로그램의 나머지 부분, 그러니까 []을 저장하면 몇 번이건 그 부분을 되살릴 수 있다.
앞의 예제와 차이점은 글로벌 변수 r에 k를 저장한 것이다. 따라서 r은 그 이전까지의 모든 것이다.
r에서 본다면 (+ 1 [])까지 무엇을 하다가 만 것이다. 그러므로 (r 5)는 k에 5를 적용한 것과 마찬가지다.
그리고 r이 계산 중간에 나타나면 그 이전에 하던 일들을 모두 버린다(abort).
앞에 하던 계산은 다 필요가 없어지고 그냥 r에 5를 적용하던 앞의 문맥이 허공에서 나타나듯 계산이 일어난다.
tree->generator의 가장 중요한 부분은 두 군데다.
밑 부분의 lambda()는 일종의 프로시저 본체로 tree->generator가 호출되면 맨 먼저 실행되는 부분이다. (set! caller k)는 caller에 현재 문맥을 저장하고 generate-leaves를 부른다.
generate-leaves 역시 동작은 정해져 있다. loop (tree tree)는 트리의 값을 car, cdr을 이용해 이파리를 찾아가는 루틴이다. 이파리에 해당하는 부분에 오면 일종의 []가 기다리고 있다.
generate-leaves는 (lambda () (rest-of-tree 'resume))의 값으로 변한다. caller tree가 나무의 이파리 값을 caller에 적용하면 call/cc가 받아 이를 되돌린다. 다음에 generate-leaves를 부르면 함수의 처음부터 시작하는 것이 아니라 이스케이프 컨티뉴에이션을 일으킨 부분에서 다시 시작한다. 그러니 어보티브 컨티뉴에이션인 셈이다.
따라서 tree->generator를 부르면 (set! caller k)로 현재 위치를 저장하고 generate-leaves를 부른다. generate-leaves는 (caller tree)로 트리의 리프 노드를 적용한다. 이 작업은 빈 리스트가 될 때까지 계속된다(이보다 조금 더 간단하지만 구조는 같은 예제가 위키백과의 Call-with-current-continuation에 있다).
이런 형태의 제네레이터는 스트림과는 또 다른 모습이다. 상당히 편리하며 사용하기도 깔끔하다. 물론 스트림으로 구현한 예제도 있다(패턴으로 이름이 알려진 워드 커닝엄의 사이트에 정리되어 있다. SameFringeProblem 에 보면 여러 가지 언어로 구현한 예제가 나온다). 지난번의 스트림 버전의 소수(prime) 찾기 문제를 제네레이터의 우아한 형식으로 만들 수 있다. 소수가 발견될 때마다 값을 되돌리면 된다. 이해하기도 더 쉬울 것이고 필터와 지연된 연산으로 머리를 싸맬 이유도 없다. 스트림은 복잡해지면 지연된 연산의 제어가 어렵다.
call/cc로 만든 이번 예제에는 발전형이 더 있다. call/cc의 중요한 사용법의 하나인 코루틴(coroutine)이다. 코루틴을 컨티뉴에이션으로 구현한 사람은 스트림을 지연된 리스프로 구현한 다니엘 프리드만(Daniel Friedman)이다. 코루틴은 서브루틴의 일반화된 형태다. 필요한 시점이 되면 계산한 값을 다른 프로시저나 함수에 이양하고(yield) 다시 진입할 때에는 이양이 끝난 다음 지점으로 들어온다. 코루틴을 사용한 예제가 많으나 Sitram의 call/cc 바로 뒤에는 설명을 곁들인 코루틴 예제가 나온다(책의 예제는 스킴의 매크로를 이용하기는 하지만 매크로를 사용하지 않고도 풀 수 있다). 설명까지 같이 있으니 소스 코드만 이해하는 것보다 훨씬 쉽다고 볼 수 있다.
코루틴은 다른 언어들에도 사용된다. 파이썬(Python)이나 루비(Ruby)는 yield를 사용하며 자바에도 사용하려는 움직임이 있다. 이는 제네레이터(generator)나 이터레이터(iterator)라는 이름으로 사용이 늘어나고 있다. 함수를 일종의 독립된 모듈처럼 그리고 모든 계산을 다 하지 않는 형태의 이점이 크기 때문이다. yield하면 제어는 원래의 호출자에 돌아간다. 코루틴은 아주 단순하므로 오류를 일으킬 여지도 적다. 아무튼 이것들은 모두 상태를 갖는 함수를 전제로 하며 클로저라고 볼 수 있다.
위키백과의 코루틴 예제(다른 자료도 많지만)는 일반형으로 다음과 같은 모습이다. 독자들이 운영체제를 배우면서 한번은 보았을 생산자-소비자 문제다.
생산자(produce)는 아이템을 재고가 꽉 찰 때까지 만들어낸다. 그 다음에는 yield 명령으로 제어를 포기하는데 제어는 소비자(consume)로 간다. 소비자는 재고를 다 소진하면 yield 명령으로 제어를 포기하고 생산자에게 제어를 돌린다. 위에 적은 간단한 루틴에서는 세마포어나 다른 잠금 설비가 필요 없이 생산과 소비의 문제를 해결한다. 너무 단순하다는 것 빼고는 별다른 문제가 없다.
코루틴은 사실상 goto다. 값을 되돌리는 call보다는 goto에 가깝다. 그래서 어셈블리어로 보여주는 편이 빠르며 인터넷에 예제도 많다. 많이 인용되는 예제 중 하나는 David Mertz가 Randall Hyde's The Art of Assembly에서 인용하여 사용한 그림이다(Charming Python #b5라는 글로 상당히 정리가 잘된 글이다. 파이선의 2.5 이전의 버전이지만 근본적인 내용을 잘 설명하고 있다. 관심 있는 독자들은 읽어 보면 좋을 것이다. IBM developerWorks에 소개된 기사도 있는데 저자는 이것들을 제어 흐름(control flow)의 주제로 분류했다. 그래서 필자의 이전 글들과 비교해 보면 Metz의 주장이 더 쉽게 이해될지도 모른다). 그림에서 프로세스 #1과 #2의 동작은 원래 상태를 기억하며 제어의 주고받기를 계속한다. 그림에서는 yield 대신 cocall을 사용했다.
>
그림 2. Cocall Sequence Between Two Processes
그림을 보고 독자들은 결국 이 그림은 두 개의 call/cc를 사용한 스킴 프로그램과 같은 것이 아닌가 하고 되물을 것이다. 사실이다. 코루틴은 중요한 패턴을 정리하여 일반화한 것이다.
코루틴은 복잡한 상태 기계(state machine)를 비교적 간단하게 만들 수 있다는 장점을 갖고 있다. 변수의 문맥을 잘 유지할 능력과 설비만 있으면 패턴화된 goto의 일반적이고 유연한 표현 능력은 매우 뛰어나다(일반적인 예제와 설명은 Charming Python #b5를 읽는 편이 빠를 것이다. David Mertz의 글은 매우 좋은 설명을 담고 있다). 워드 커닝엄이 만든 C2 위키의 예제들도 좋은 설명과 예제를 적고 있다.
어떤 함수를 호출하고 리턴값을 기다리는 일반적인 패턴을 잊어버리면 유연한 패턴을 기대할 수 있다. 이를테면 프로시저마다 빠져나오면서 저장한 call/cc의 값을 갖는다고 하자. c1, c2, c3 ... 같은 식으로 정할 수 있겠다. 그러면 c1은 현재 상태에서 c2나 c3, ... cn 어떤 프로시저로도 제어를 넘길 수 있고 이것들은 서브루틴과 비슷하기도 하지만 진입점은 마지막으로 빠져 나온 식이나 문장의 그 다음 지점이 된다. 비슷한 프로시저를 순수한 함수형 언어나 구조적 언어로 작성하려고 하면 상당한 어려움이 있을 것이다. 본질적으로 goto에 해당하는 요소를 도입하는 편이 빠르다. 상태 기계로 보는 것도 좋다.
생산자-소비자 또는 같은 잔가지 문제는 '상태를 갖는 goto'의 유연성의 일부를 드러낸다고 볼 수 있다.
사족이긴 하지만 예전에 Edsger Dijkstra의 「Go To Statement Considered Harmful」이라는 글이 있었다. 이 글은 goto는 원시적이며 표현의 자유도가 너무 높아 관리하기 어렵다고 못을 박았다. 그 후 구조적 프로그래밍의 붐이 일어났고 옹호자 중에는 Dijkstra보다 더 심하게 goto를 반대하는 사람들도 나타났다. 커다란 논란이 일어났다. 구조적 프로그래밍이 대세를 잡자 goto는 기피 대상이 되었다. 그런 연유로 Dijkstra의 글이 'gotophobia'를 일으켰다고 말한다(예전에 잡지 마이크로소프트웨어에서 김창준 님이 필자와는 다른 각도로 다룬 적이 있다. 반응이 좋아 당시 독자들은 아직도 김창준 님의 글을 기억하고 있을 것으로 안다). 아무튼 40년 전에 쓴 Dijkstra의 글은 매우 유명한 글임에는 분명하다. 필자는 오랜만에 다시 한번 읽어 보았다(원래 글은 ACM에 있으나 http://www.cs.utexas.edu/users/EWD/ewd02xx/EWD215.PDF 에 있는 글을 읽었다). 정확한 이유는 알 수 없지만 글의 끝부분에는 영향을 받지 않을 수 없었으며 영향을 받은 것을 후회하지 않노라고 적은 두 사람이 Peter Landin과 Christoper Strachery였다. 둘은 컨티뉴에이션(continuation) 개념의 창시자다. 어떤 영향인지는 개인적으로 정말 궁금한 사항이다.
goto는 없어지지 않았으며 함수형 언어에서조차 사라지지 않았다. 오히려 중요한 구성요소로 사용하는 언어가 더 많다.
끝으로 초기 형태를 살펴보기 위해 「Scheme: An Interpreter for Extended Lambda Calculus」에 나오는 소스를 살펴보자. 예전의 CPS factorial 바로 다음 부분이다. 아직 call/cc가 나오기 전이지만 이들은 칼 휴이트의 같은 잔가지를 스킴으로 옮겼다. 문맥을 옮긴 것이 아니라 첫 번째 Fringe와 다음번의 Fringe를 컨티뉴에이션 함수에 건네는 것이다. 함수에 리모콘처럼 First와 Next 값을 전달한다.
>
그림 3. Fringe 전달