Tcl이 왜 장난감 언어가 아니라 강력한 언어인지, 핵심 개념(명령, 치환, 문자열·리스트, eval/uplevel 등)부터 이벤트 기반 I/O, 다중 패러다임, DSL 제작까지 예시로 설명하며 흔한 오해를 반박한다.
Salvatore antirez Sanfilippo, 2006년 3월 6일
왜 Tcl은 장난감 언어가 아니라 매우 강력한가 최근 reddit에 링크된 Tour de Babel이라는 글에서(다른 온갖 허튼소리 사이에) 이렇게 읽을 수 있다: 이런, 사람들은 여전히 내장 인터프리터로 Tcl을 쓰고 있다. 파이썬이 생각할 수 있는 모든 면에서 Tcl보다 훨씬 우수한데도 말이다 — 물론, 그건 ‘frost thing’을 제외하면 그렇다는 얘기다.
좋다, 글 전체가... 그다지 타당하지는 않다. 하지만 불행히도 많은 오해는 아는 독자에게 금방 들통나는데, Tcl을 깎아내리는 이 한마디만큼은 대개 곧이곧대로 믿어진다. 이 글이 Tcl이 그렇게 나쁘지 않다는 걸 납득시키길 바란다.
내 프로그래밍 인생에서 여러 종류의 애플리케이션을 만들며 많은 언어를 사용했다. C로 만든 다수의 자유/유료 프로그램, Scheme으로 만든 웹 CMS, Tcl로 만든 여러 네트워킹/웹 애플리케이션, Python으로 만든 상점 관리 시스템 등등. 또 Smalltalk, Self, FORTH, Ruby, Joy 같은 다른 언어들도 많이 가지고 놀았다. 그런데도 난 확신한다. 프로그래밍 커뮤니티에서 Tcl만큼 오해받는 언어는 없다고.
Tcl이 결점이 없는 건 아니다. 하지만 그 한계의 다수는 언어 설계에 하드코딩된 것이 아니라, 몇 해 전에 Tcl이 자신의 “아버지”(John Ousterhout)를 잃었고, 그와 함께 강력한 결단을 내리는 일사불란한 리더십도 잃은 탓이다. 올바른 변화가 있다면 Tcl의 대부분 한계는 극복할 수 있으며, 동시에 언어의 힘도 지킬 수 있다. Tcl이 놀라울 정도로 강력하다는 걸 믿지 못하겠다면 우선 이 글을 시간 내서 읽어보라. 그 뒤에도 마음에 들지 않을 수는 있지만, 최소한 존중은 하게 될 것이고, _Tcl은 장난감 언어_라는, _Lisp에는 괄호가 너무 많다_보다도 더 유치한 오해에 대해 분명한 반박 근거를 갖게 될 것이다.
본격적으로 시작하기 전에, Tcl이 어떻게 동작하는지부터 설명하겠다. 최고의 언어들이 그렇듯, Tcl에도 몇 가지 개념이 있고, 이것들이 서로 결합되어 프로그래밍의 자유와 표현력을 제공한다.
이 짧은 Tcl 소개 이후에는, Tcl에서(루비의 블록보다 훨씬 강력한 방식으로) 일반 프로시저만으로 리스프 매크로와 매우 유사한 일을 어떻게 해내는지, 언어의 거의 모든 부분을 어떻게 재정의할 수 있는지, 그리고 프로그래밍할 때 대체로 타입을 신경 쓰지 않아도 되는지를 볼 것이다. Tcl 커뮤니티는 순수 Tcl로 수많은 OOP 시스템, 급진적인 언어 변형, 매크로 시스템 등 흥미로운 것들을 개발했다. 프로그래밍 가능한 프로그래밍 언어를 좋아한다면, 최소한 관심 있게 보게 될 거라 장담한다.
Tcl의 첫 번째 아이디어는 명령이다. 프로그램은 명령들의 연속이다. 예를 들어 변수 ‘a’에 5를 대입하고 값을 출력하려면 두 개의 명령을 쓴다:
set a 5 puts $a
명령은 공백으로 구분된 단어들의 모음이다. 명령은 줄바꿈이나 ; 문자로 끝난다. Tcl에서는 모든 것이 명령이다 — 보듯이 대입 연산자가 없다. 변수를 설정하려면 첫 번째 인자로 변수 이름, 두 번째 인자로 값을 받는 set이라는 명령을 사용해야 한다.
거의 모든 Tcl 명령은 값을 반환한다. 예를 들어 set 명령은 변수에 대입된 값을 반환한다. set을 인자 하나(변수 이름)로만 호출하면, 해당 변수의 현재 값이 반환된다.
두 번째 아이디어는 명령 치환이다. 한 명령 내에서 일부 인자가 [ 와 ] 괄호 사이에 나타날 수 있다. 이 경우 그 인자는 괄호 안 코드의 반환값으로 치환된다. 예를 들어:
set a 5 puts [set a]
두 번째 명령의 첫 번째 인자 [set a]는 “set a”의 반환값(즉 5)으로 치환된다. 치환 단계 이후 명령은 다음에서:
puts [set a]
to
puts 5
로 바뀐다. 그리고 그 시점에 실행된다.
변수 치환을 매번 set으로만 한다면 너무 장황하므로, 꼭 필요하지는 않더라도 Tcl 초기 개발 중 어느 시점에 변수 치환이 도입되었다. 변수 이름 앞에 $ 문자가 붙으면 그 변수의 값으로 치환된다. 따라서 다음 대신에:
puts [set a]
이렇게 쓸 수 있다:
puts $a
명령이 공백으로 구분된 단어들이라면, 공백을 포함할 수 있는 인자는 어떻게 할까? 예를 들어:
puts Hello World
는 잘못된 프로그램이다. Hello와 World가 서로 다른 인자이기 때문이다. 이 문제는 그룹화로 해결한다. "" 안의 텍스트는 하나의 인자로 간주되므로, 올바른 프로그램은 다음과 같다:
puts "Hello World"
이런 종류의 그룹화 안에서는 명령과 변수 치환이 동작한다. 예를 들어 이렇게 쓸 수 있다:
set a 5 set b foobar puts "Hello $a World [string length $b]"
결과는 "Hello 5 World 6"가 된다. 또한 \t, \n 같은 이스케이프도 예상대로 동작한다. 한편, 모든 특수 문자를 그대로(아무 치환도 없이) 취급하는 또 다른 종류의 그룹화가 있다. { 와 } 사이의 모든 것은 치환이 수행되지 않는 단일 인자로 간주된다. 따라서:
set a 5 puts {Hello $a World}
는 _Hello $a World_를 출력한다.
개념 1은 프로그램은 명령으로 구성된다는 것이었다. 사실 이건 생각하는 것보다 훨씬 더 철저히 적용된다. 예를 들어 다음 프로그램에서:
set a 5 if $a { puts Hello! }
if는 두 개의 인자를 가진 명령이다. 첫 번째는 치환된 변수 a의 값이고, 두 번째는 문자열 { ... puts Hello! ... }이다. if 명령은 곧 살펴볼 특수한 버전의 eval을 사용해 두 번째 인자로 전달된 스크립트를 실행하고 결과를 반환한다. 물론 원한다면 if의 당신만의 버전, 또는 다른 어떤 제어 구조라도 작성할 수 있다. 심지어 if 자체를 재정의하여 기능을 추가할 수도 있다!
다음 프로그램은 작동하며 예상한 대로 동작한다:
set a pu set b ts $a$b "Hello World"
그렇다, Tcl에서는 모든 일이 런타임에 일어나며 전부 동적이다. 궁극의 레이트 바인딩 언어이며, 타입이 없다. 명령 이름은 특별한 타입이 아니라 단지 문자열이다. 숫자도 문자열이고, Tcl 코드도 문자열이다(기억하라, 우리는 if 명령의 두 번째 인자로 문자열을 전달했다). 문자열이 무엇을 의미하는지는 그것을 다루는 명령에 달려 있다. 문자열 "5"는 "string length 5"에서는 문자들의 문자열로 보이고, "if $a ..."에서는 불리언 값으로 보인다. 물론 명령들은 값의 형식이 적합한지 검사한다. "foo"에 "bar"를 더하려 하면, Tcl은 "foo"와 "bar"를 숫자로 파싱할 수 없기 때문에 예외를 발생시킨다. 이런 검사는 Tcl에서 매우 엄격해서, PHP식의 말도 안 되는 묵시적 타입 변환이 조용히 일어나는 현상은 없다. 타입 변환은 오직 그 문자열이 해당 명령이 인자로 필요로 하는 것으로서 의미가 있을 때에만 일어난다.
그렇다면 Tcl은 이렇게나 동적인데, 성능은 어떨까? 놀랍게도 현재의 루비 구현과 비슷한 수준으로 빠르다. Tcl 구현에는 요령이 하나 있는데, 객체(여기서 OOP 의미의 객체가 아니라 Tcl 값을 표현하는 C 구조체)가 주어진 문자열의 마지막 사용 형태에 대한 네이티브 값을 캐싱한다. 어떤 Tcl 값이 항상 숫자로 사용된다면, 그것을 나타내는 C 구조체 내부에는 정수가 저장되고, 이후의 명령들이 계속해서 이를 정수로 사용하기만 하면 객체의 문자열 표현은 건드리지 않는다. 실제로는 이보다 더 복잡하지만, 결과적으로 프로그래머는 타입을 신경 쓸 필요가 없고, 그럼에도 프로그램은 타입이 더 명시적인 다른 동적 언어들만큼 빠르게 동작한다.
Tcl이 사용하는 더 흥미로운 타입 중 하나(정확히는 문자열 포맷)는 리스트다. 리스트는 Tcl 프로그램의 중심 구조다. Tcl 리스트는 항상 유효한 Tcl 명령이다! (그리고 결국 둘 다 문자열이다). 가장 단순한 형태에서 리스트는 명령과 같다: 공백으로 구분된 단어들. 예를 들어 문자열 "a b foo bar"는 네 개의 원소를 가진 리스트다. 리스트에서 일부분을 떼어내거나, 원소를 추가하는 등의 명령들이 있다. 물론 리스트의 원소는 공백을 포함할 수 있으므로, 올바른 리스트를 만들기 위해 list 명령을 사용한다. 예:
set l [list a b foo "hello world"] puts [llength $l]
llength는 리스트의 길이를 반환하므로, 위 프로그램은 4를 출력한다. lindex는 지정한 위치의 원소를 반환하므로, "lindex $l 2"는 "foo"를 반환하는 식이다. 리스프와 마찬가지로, Tcl 프로그래머의 다수는 프로그램에서 가능한 많은 개념을 리스트 타입으로 모델링한다.
대부분의 리스프 해커는 이미 Tcl이 전위 표기 언어라는 점을 눈치챘을 것이다. 그래서 리스프처럼 수학도 연산자를 명령으로 사용해 puts [+ 1 2]처럼 하는 줄 알 수 있다. 하지만 Tcl에서는 다르게 동작한다. 더 친근하게 만들기 위해, 중위 표기 수식 하나를 인자로 받아 평가하는 expr라는 명령이 있다. Tcl에서 수학은 다음처럼 동작한다:
set a 10 set b 20 puts [expr $a+$b]
if와 while 같은 명령은 내부적으로 expr을 사용해 표현식을 평가한다. 예를 들어:
while {$a < $b} { puts Hello }
여기서 while 명령은 두 개의 인자를 받는다 — 첫 번째 문자열은 각 반복마다 참인지 검사하기 위해 exprression으로 평가되고, 두 번째는 매번 자체가 evaluate된다. 수학 연산자가 내장 명령이 아닌 건 설계 실수라고 생각한다. expr은 복잡한 수학을 해야 할 때 유용한 멋진 도구로 보이지만, 단지 두 수를 더하려고 [+ $a $b]처럼 쓰는 편이 더 편리하다. 이를 언어에 대한 변경으로 공식 제안한 바 있다는 점도 언급할 만하다.
물론, 수학 연산자를 명령으로 쓰기 위해 Tcl 프로그래머가 프로시저(사용자 정의 명령)를 작성하는 걸 막을 이유는 없다. 다음처럼 말이다:
proc + {a b} { expr {$a+$b} }
proc 명령은 프로시저를 만든다. 첫 번째 인자는 프로시저 이름, 두 번째는 프로시저가 입력으로 받는 인자 리스트, 마지막 인자는 프로시저의 본문이다. 두 번째 인자, 인자 리스트가 Tcl 리스트라는 점에 주목하라. 보듯이 프로시저에서 마지막 명령의 반환값은(명시적으로 return을 쓰지 않는 한) 프로시저의 반환값이 된다. 그런데 잠깐... Tcl에서는 모든 것이 명령이라 했지? 그렇다면 +, -, *, ...에 대한 프로시저를 각각 네 개 따로 쓰기보다는 더 간단한 방식으로 만들 수 있다:
set operators [list + - * /] foreach o $operators { proc $o {a b} [list expr "$a $o $b"] }
이후에는 [+ 1 2], [/ 10 2] 같은 식으로 쓸 수 있다. 물론 Scheme의 프로시저처럼 가변 인자를 쓰는 편이 더 영리하다. Tcl에서는 프로시저가 내장 명령과 같은 이름을 가질 수 있으므로, Tcl 자체를 재정의할 수 있다. 예컨대 Tcl용 매크로 시스템을 만들기 위해 나는 proc을 재정의했다. proc을 재정의하는 것은 프로파일러를 작성할 때도 유용하다(Tcl 프로파일러는 대개 Tcl 자체로 개발된다). 내장 명령을 재정의하더라도, 덮어쓰기 전에 rename으로 다른 이름으로 바꿔두면 여전히 호출할 수 있다.
이 글을 읽는다면 이미 eval이 무엇인지 알고 있을 것이다. 명령 eval {puts hello} 는 많은 다른 언어와 마찬가지로, 인자로 전달된 코드를 평가한다. Tcl에는 또 다른 녀석이 있는데, uplevel이라는 명령으로, 호출한 프로시저의 컨텍스트나, 그 호출자의 호출자(또는 바로 최상위 컨텍스트)의 컨텍스트에서 코드를 평가할 수 있다. 이것이 의미하는 바는, 리스프에서 매크로가 하는 일을 Tcl에서는 그냥 단순한 프로시저로 할 수 있다는 것이다. 예: Tcl에는 다음처럼 사용할 수 있는 repeat라는 “내장” 명령이 없다:
repeat 5 { puts "Hello five times" }
하지만 작성하는 것은 사소하다.
proc repeat {n body} { set res "" while {$n} { incr n -1 set res [uplevel $body] } return $res }
마지막 평가 결과를 저장해 두었다는 점에 주목하라. 그래서 우리의 repeat는(대부분의 Tcl 명령처럼) 마지막으로 평가된 결과를 반환한다. 사용 예:
set a 10 repeat 5 {incr a} ;# Repeat는 15를 반환한다
짐작했겠지만, incr 명령은 두 번째 인자를 생략하면 정수 변수를 1 증가시키는 데 사용된다. "incr a"는 호출한 프로시저의 컨텍스트(즉, 이전 스택 프레임)에서 실행된다.
축하한다, 이제 Tcl 개념의 90% 이상을 알게 되었다!
모든 Tcl 기능을 다 보여줄 생각은 없지만, Tcl로 고급 프로그래밍 과제가 얼마나 멋지게 풀리는지 감을 주고자 한다. 다시 강조하지만 Tcl에는 여러 결점이 있다. 그러나 그 대부분은 언어의 핵심 아이디어에 있지 않다. 오늘날 웹 프로그래밍, 네트워크 프로그래밍, GUI 개발, DSL, 스크립팅 같은 흥미로운 영역에서 루비, 리스프, 파이썬과 경쟁할 수 있는 Tcl 파생 언어의 여지가 있다고 생각한다.
Tcl 문법은 너무나 단순해서, Tcl 자체로도 몇 줄 만에 Tcl 파서를 작성할 수 있다. 앞서 언급했듯 Tcl용 매크로 시스템을 Tcl로 직접 작성했는데, 소스 수준 변환으로 테일 콜 최적화까지 가능할 정도로 복잡한 작업을 해낸다. 동시에 Tcl 문법은 프로그래밍 스타일에 따라 더 Algol스러운 모습으로도 스케일한다.
타입이 없고 변환을 수행할 필요도 없지만, 문자열 형식에 대한 검사가 매우 엄격하므로 버그를 심기 쉬운 편도 아니다. 더 좋은 점은 직렬화가 필요 없다는 것이다. 큰 복합 Tcl 리스트를 TCP 소켓으로 보내고 싶은가? 그냥 이렇게 쓰면 된다: puts $socket $mylist. 반대쪽에서는 **set mylist [read $socket]**로 읽으면 끝이다.
Tcl에는 I/O 라이브러리와 통합된 이벤트 기반 프로그래밍이 내장되어 있다. 코어 언어가 제공하는 것만으로도 복잡한 네트워킹 프로그램을 쓰는 일이 우스울 만큼 쉽다. 예를 들어, 아래 프로그램은 모든 클라이언트에 현재 시간을 출력하는 동시성(내부적으로 select(2) 기반) TCP 서버다.
socket -server handler 9999 proc handler {fd clientaddr clientport} { set t [clock format [clock seconds]] puts $fd "Hello $clientaddr:$clientport, current date is $t" close $fd } vwait forever
논블로킹 I/O와 이벤트 처리는 정말 잘 되어 있어, 더 이상 소켓 출력 버퍼가 없는 소켓에 쓰려 해도 Tcl이 사용자 공간에서 자동으로 버퍼링하고, 소켓의 출력 버퍼에 다시 공간이 생기면 백그라운드로 전송해 준다.
파이썬 사용자들도 좋은 아이디어는 알아본다 — 파이썬의 "Twisted" 프레임워크는 Tcl이 수년간 네이티브로 가져온 동일한 select 기반 IO 개념을 활용한다.
Tcl에서는 대략 커먼 리스프와 비슷하게 객체지향, 함수형, 명령형 코드를 뒤섞어 작성할 수 있다. 과거에 많은 OOP 시스템과 함수형 프로그래밍 프리미티브가 구현되었다. 프로토타입 기반 OOP부터 Smalltalk류의 것까지 다양하며, 많은 것들이 Tcl 자체로 구현되었다(혹은 최소한 개념 증명으로는). 게다가 Tcl에서는 코드가 일급 시민이기 때문에, 언어의 논리와 잘 어울리는 함수형 프리미티브를 작성하는 일이 아주 쉽다. 예로 lmap이 있다:
lmap i {1 2 3 4 5} { expr $i*$i }
이는 제곱 리스트, 1 4 9 12 25를 반환한다. lamba의 한 변종(이 역시 Tcl로 개발되었다)을 기반으로 map 같은 함수를 작성할 수 있지만, Tcl에는 이미 Lisp식 방식보다(리스프에는 잘 맞지만 다른 데에는 아닐 수도 있는) 더 자연스러운 함수형 프로그래밍을 가능하게 해 주는 것들이 갖춰져 있다. 너무 딱딱한 언어에 함수형 프로그래밍을 끼워 넣으려 하면 어떤 일이 벌어지는지 보라: 파이썬과 그 함수형 프리미티브를 둘러싼 끝없는 논쟁 말이다.
리스프 프로그래머라면 프로그램 전반에 유연한 데이터 구조인 리스트를 둘 수 있다는 것이 얼마나 아름다운지 잘 알 것이다. 특히 리터럴이 대부분의 경우 "foo bar 3 4 5 6"처럼 단순할 때는 더더욱.
eval, uplevel, upvar 및 매우 강력한 내부 조사(introspection) 기능을 통해 언어를 재정의하고 문제를 푸는 새로운 방식을 발명할 수 있다. 예를 들어, 다음 흥미로운 명령은 함수의 첫 번째 명령으로 호출되기만 하면 그 함수를 자동으로 메모이즈 버전으로 만들어 준다:
proc memoize {} { set cmd [info level -1] if {[info level] > 2 && [lindex [info level -2] 0] eq "memoize"} return if {![info exists ::Memo($cmd)]} {set ::Memo($cmd) [eval $cmd]} return -code return $::Memo($cmd) }
그리고 프로시저를 작성할 때 이렇게만 쓰면 된다:
proc myMemoizingProcedure { ... } { memoize ... the rest of the code ... }
Tcl은 아마도 국제화 지원이 가장 뛰어난 언어일 것이다. 모든 문자열은 내부적으로 UTF-8로 인코딩되고, 정규식 엔진을 포함한 모든 문자열 연산이 유니코드 안전하다. 기본적으로 Tcl 프로그램에서 인코딩은 문제가 아니다 — 그냥 잘 동작한다.
unknown이라는 프로시저를 정의하면, Tcl이 어떤 명령을 실행하려다(명령 이름이 정의되지 않아) 실패할 때마다 그 명령의 인자들을 나타내는 Tcl 리스트와 함께 이 프로시저가 호출된다. 그 안에서 무엇이든 할 수 있으며, 값을 반환하거나 에러를 던질 수도 있다. 값을 그냥 반환하면, 해당 명령이 Tcl에 알려지지 않았더라도 마치 동작하는 것처럼 보이게 되고, unknown이 반환한 값이 정의되지 않은 그 명령의 반환값으로 사용된다. 여기에 uplevel과 upvar, 그리고 거의 문법이 없는 언어 자체를 더하면, 도메인 특화 언어(DSL) 개발에 인상적인 환경이 된다. Tcl은 리스프나 FORTH처럼 거의 문법이 없다. 하지만 문법이 없다는 데에도 여러 방식이 있다. Tcl은 기본적으로 설정 파일처럼 보인다:
disable ssl validUsers jim barbara carmelo hostname foobar { allow from 2:00 to 8:00 }
위는 사용된 명령 disable, validUsers, hostname만 정의하면 유효한 Tcl 프로그램이다.
아쉽게도 수많은 흥미로운 기능을 다 보여줄 지면은 없다. 대부분의 Tcl 명령은 하나의 일을 잘하며, 기억하기 쉬운 이름을 갖는다. 문자열 연산, 내부 조사 등은 단일 명령의 서브커맨드로 구현된다. 예를 들어 string length, string range 등이 그렇다. 인덱스를 인자로 받는 언어의 모든 부분은 end-num 표기를 지원하므로, 예를 들어 리스트에서 첫 번째와 마지막을 제외한 모든 원소를 가져오려면 이렇게만 쓰면 된다:
lrange $mylist 1 end-1
그리고 일반적으로 흔한 경우에 대한 훌륭한 설계와 최적화가 많이 들어 있다. 또한 Tcl 소스 코드는 여러분이 볼 수 있는 C 프로그램 중 가장 잘 쓰인 축에 속하며, 인터프리터의 품질은 놀랍다. 상업용이라는 말의 최선의 의미 그대로다. 구현 면에서도 흥미로운 점은, 윈도우, 유닉스, 맥 OS X 등 다양한 환경에서 정확히 동일하게 동작한다는 것이다. 운영체제 간 품질 차이는 없다(그래, Tk — Tcl의 주요 GUI 라이브러리 — 조차도 포함해서 말이다).
모든 사람이 Tcl을 좋아해야 한다고 주장하는 건 아니다. 내가 주장하는 바는, Tcl은 강력한 언어이지 장난감이 아니며, Tcl의 대부분 한계는 제거하고 그 힘은 온전히 지닌 새로운 Tcl 계열 언어를 만드는 것이 가능하다는 점이다. 나도 직접 시도했고, 그 결과가 Jim 인터프리터다. 코드는 존재하고 동작하며 대부분의 Tcl 프로그램을 실행할 수 있다. 하지만 무료로 언어 개발을 계속할 시간이 더는 없어 프로젝트는 지금 대체로 중단된 상태다. 또 다른 Tcl 계열 언어 시도인 Hecl은 현재 진행 중이며, 자바 애플리케이션을 위한 스크립팅 언어로 개발되고 있다. 저자(David Welton)는 Tcl 코어 구현이 작고, 명령 기반 설계가 두 언어 사이의 글루로 쓰기 쉽다는 사실을 활용한다(이건 현대 동적 언어에는 흔치 않은 일인데, 같은 생각이 Scheme에도 적용된다는 점도 사실이다). 이 글을 읽고 난 뒤에는 더 이상 Tcl을 장난감으로 보지 않게 된다면 정말 기쁘겠다. 고맙다. Salvatore.
p.s. Tcl을 더 배우고 싶은가? Tclers Wiki를 방문하라.