2017년 9월 20일 (수)

클로저스트립트에서 매크로 작성시 주의점


클로저스크립트 컴파일러는 크로스 컴파일러다

클로저스크립트(ClojureScript) 컴파일러는 클로저스크립트 코드를 컴파일하여 브라우저나 Node.js에서 실행할 자바스크립트 코드를 만듭니다. 하지만 클로저스크립트 컴파일러 자체는 JVM상에서 실행되는 자바 프로그램이며, cljs.jar 파일로 배포됩니다. 즉 클로저스크립트 컴파일러는 컴파일러가 실행되는 환경은 JVM인데, 컴파일러의 실행 결과인 목적 코드가 실행되는 환경은 JS VM(브라우져나 Node.js처럼 자바스크립트를 실행시키는 VM)인 것입니다. 이처럼 컴파일러가 실행되는 환경과 그 컴파일러의 목적 코드가 실행되는 환경이 다른 컴파일러를 크로스 컴파일러(Cross Compiler)라고 합니다. 클로저스크립트 컴파일러는 크로스 컴파일러인 셈이죠. 이 때문에 발생하는 문제들이 있는데, 이 글에서는 특히 매크로와 관련된 문제들을 살펴보겠습니다.

클로저스크립트 매크로는 클로저 코드이다

모든 리습 언어의 매크로가 그렇듯이 클로저스크립트의 매크로도 컴파일시에 확장이 됩니다. 매크로는 컴파일러 시에 수행되는 일종의 함수입니다. 즉 매크로는 코드를 입력받아 코드를 리턴하는, 사용자가 작성하고, 사용자가 사용하지만, 그 호출은 컴파일러에 의해 수행되는 함수입니다. 컴파일러는 매크로 호출을 만나면 입력받은 코드를 인수로 해서, 정의된 매크로(함수)를 수행하고, 그 수행 결과로 나온 코드를 원래의 매크로 호출 코드와 교체합니다. 이처럼 매크로 호출 코드를 매크로 출력 코드로 바꾸는 것을 매크로 확장이라고 합니다. 그런데 이 매크로 확장이 컴파일시에 되어야 하기 때문에 클로저스크립트의 매크로 확장은 목적 코드의 실행 환경인 JS VM이 아닌 컴파일러의 실행 환경인 JVM 상에서 이루어집니다. 그래서 클로저스크립트의 매크로는 JVM 상에서 실행될 수 있는 클로저 코드로 작성되어야 합니다. 이러한 이유로 클로저스크립트 매크로는 클로저스크립트 파일(.cljs)이 아닌 클로저 파일(.clj)로 작성됩니다.

크로스 컴파일러라는 클로저스크립트 컴파일러의 이러한 특성으로 인해 일반 코드와 매크로 코드가 분리되어서 작성되어야 한다는 것은 사실 조금 번거롭기는 하지만 그리 큰 문제는 아닙니다. 어짜피 매크로 코드는 다른 게 아니라 우리가 많이 익숙한 클로저 코드니까요. 문제는 매크로 도우미(Helper) 함수들입니다. 특히 .cljc 파일이 도입되면서는 숙련된 클로저 프로그래머조차 이와 관련해서 가끔 실수하기도 합니다. 사실 이러한 점은 원리를 알면 당연한 것이 되어서 쉽게 피할 수 있는 문제이기 때문에 이 글에서 차근차근 알아보도록 하겠습니다.

Note
이 글에서는 컴파일러의 특성을 알아보는 것이 목적이므로 Leiningen보다는 clojure.jar와 cljs.jar를 통해서 Clojure와 ClojureScript 컴파일러를 직접 사용합니다.

클로저의 매크로 작성 방식

보통 클로저(Clojure)에서는 다음과 같이 매크로를 작성합니다.

macro.clj
(ns macro)

(defmacro log [x]
  `(println "[log: ]" ~x))
core.clj
(ns core
  (:require [macro :refer [log]]))

(log "hello, world")

macro.clj에서 log 매크로를 정의합니다. 간단하게 로그를 프린트하는 형식(form)을 리턴합니다. 그리고 core.clj에서 macro.clj를 로드(:require)하고 log 매크로를 사용하여 "hello world" 를 프린트합니다.

코드를 실행하기 위해 다음과 같이 test 폴더 아래에 macro.clj와 core.clj를 만들고, 위의 소스와 같은 내용으로 각각 코드를 작성합니다.

$ mkdir test; cd test
$ touch macro.clj core.clj

다음 명령으로 clojure.jar를 다운로드합니다.

$ curl -o clojure.jar http://central.maven.org/maven2/org/clojure/clojure/1.8.0/clojure-1.8.0.jar

다음 명령으로 직접 소스를 컴파일하고 실행해 봅니다.

$ java -cp clojure.jar:. clojure.main core.clj
[log: ] hello, world

java 를 실행하면서 클래스패스로 clojure.jar 와 현재 디렉토리(' . ')를 주었습니다. clojure.main 함수를 실행하면서 core.clj 을 인자로 넘겼습니다. core.clj 파일이 컴파일되고 실행되어 명령창에 로그가 프린트되는 것을 확인할 수 있습니다.

여기서 눈여겨 볼 것은 사실 당연한 것이지만 clojure.jar의 컴파일러도 JVM에서 실행되었고, 컴파일된 core.clj도 같은 JVM 상에서 실행되고 있다는 사실입니다.

매크로 도우미 함수

보통 매크로를 작성하다 보면 매크로에서 자체적으로 사용하는 도우미(Helper) 함수들을 만들어 사용하게 됩니다.

위에서 작성한 log 매크로에 로그의 시각을 프린트하는 기능을 넣기 위해 now 도우미 함수를 추가합니다.

macro.clj 파일을 다음과 같이 수정합니다.

macro.clj
(ns macro)

(defn now [] ; <== 도우미 함수
  (System/currentTimeMillis))

(defmacro log [x]
  `(println "[log:" (now) "]" ~x))

위에서 했던 것과 같이 컴파일하고 실행해보고 확인합니다.

$ java -cp clojure.jar:. clojure.main core.clj
[log: 1504281655322 ]  hello, world

로그에 시각이 프린트되는 것을 확인할 수 있습니다.

now 함수는 정확하게 log 매크로가 실행되는 런타임에 실행되어 해당 시각을 나타내주고 있습니다.

클로저스크립트의 매크로 작성 방식

클로저스크립트에서 매크로 작성하는 방식도 클로저와 비슷합니다. 일단 매크로는 .cljs가 아닌 .clj파일에서 정의되어야 한다는 점에서.

그렇다면 위에서 작성한 매크로는 .clj로 작성했으니 당연히 ClojureScript에서도 사용할 수 있겠죠?

다음과 같이 core.cljs 파일을 작성합니다.

core.cljs
(ns core
  (:require-macros [macro :refer [log]]))

(enable-console-print!)

(log "hello, world")

ClojureScript에서는 매크로 파일(.clj)을 로딩하기 위해서는 :require-macros 를 사용합니다. :enable-console-print!:println 함수가 JS-VM상의 console에 프린트할 수 있도록 합니다.

다음과 같이 core.cljs 파일을 만들고, 위의 소스와 같은 내용으로 코드를 작성합니다.

$ touch core.cljs

클로저스크립트 코드 컴파일하기

ClojureScript 파일을 컴파일하기 위해서는 컴파일러의 build-api를 사용해야 합니다. 다음은 build-api를 사용하는 build.clj 파일입니다.

build.clj
(require 'cljs.build.api)

(cljs.build.api/build "."
  {:main 'core
   :output-to "out/main.js"})

cljs.build.api 를 로딩하고, build 함수를 사용합니다. build 함수는 2개의 인자를 받습니다. 첫번째 인자는 컴파일할 소스 디렉토리이고, 두 번째 인자는 컴파일러 옵션입니다. 옵션은 출력 파일을 가리키는 :output-to 와 주 이름 공간을 가리키는 :main 만 주어졌습니다. :output-to 는 컴파일한 결과인 JS 코드를 out/main.js 출력 파일에 쓰라는 설정입니다.

다음과 같이 cljs.jar를 다운로드받습니다. 이 파일은 클로저스크립트를 컴파일하기 위한 build-api를 담고 있습니다.

$ curl -LOk https://github.com/clojure/clojurescript/releases/download/r1.9.908/cljs.jar

다음과 같이 cljs.jar를 이용하여 build.clj를 실행시켜 core.cljs를 컴파일합니다.

$ java -cp cljs.jar:. clojure.main build.clj
WARNING: core is a single segment namespace at line 1 ./core.cljs
WARNING: Use of undeclared Var macro/now at line 6 ./core.cljs

클로저 컴파일러를 실행하던 것과 다른 것은 클패스 패스에 clojure.jar 대신 cljs.jar를 주고, 컴파일을 구동하기 위해 build.clj 파일을 주었다는 것입니다. 그리고 컴파일만 한다는 것입니다. 실행할 플랫폼은 JVM이 아니라 JS-VM입니다.

클로저스크립트의 매크로 도우미 함수

그런데 경고가 2개 나옵니다.

첫번째 경고는 core.cljs의 이름 공간이 단일 이름 공간이라는 경고입니다. 예제 자체를 최대한 단순하게 하려고 일부러 단일 이름 공간을 사용한 것이니 현재로서는 무시해도 상관없습니다. (이후로 이 글에서는 이 경고는 표시하지 않겠습니다. 여러분들이 콘솔창에서 테스트할 때는 이 경고는 계속 보일 것이지만 그냥 무시하셔도 됩니다)

두번째 경고는 macro/now 가 선언되어 있지 않다는 경고입니다. 당연합니다. macro/now 함수는 .clj에서 정의된 함수이므로 JVM상에서 실행되어야 하는 클로저 코드입니다. 하지만 현재 macro.clj에서 nowlog 매크로 확장시가 아니라 리턴값으로 반환하는 결과 코드에서 사용되고 있습니다. 그리고 이 결과 코드는 JS-VM에서 실행되는 자바스크립트 코드여야 합니다. 하지만 클로저스크립트 컴파일러는 클로저스크립트 코드로 정의된 now 함수를 찾을 수가 없습니다. 정의된 적이 없으니까요.

해결책은 2가지입니다.

첫번째 방법은 클로저 코드 now 함수를 매크로 확장 시에 호출되도록 하는 것이고, 두 번째 방법은 now 함수를 클로저스크립트 코드로 정의해서 사용하는 것입니다.

우선 첫 번째 방법을 해보겠습니다.

다음과 같이 macro.clj 파일을 수정합니다.

macro.clj
(ns macro)

(defn now []
  (System/currentTimeMillis))

(defmacro log [x]
  (let [t# (now)] ; <== now는 매크로 확장시에 호출된다.
  `(println "[log:" ~t# "]" ~x)))

위 예제에서는 now 함수가 매크로 안에서 직접 호출되고, 그 결과값을 매크로의 결과에 포함합니다.

다시 컴파일하면 정상적으로 성공합니다.

$ java -cp cljs.jar:. clojure.main build.clj
$ ls -al
total 57184
drwxr-xr-x  10 guruma  staff       340  9  2 02:25 .
drwxr-xr-x   6 guruma  staff       204  9  2 00:37 ..
-rw-r--r--   1 guruma  staff        99  9  2 01:43 build.clj
-rw-r--r--@  1 guruma  staff  25629199  9  2 01:24 cljs.jar
-rw-r--r--   1 guruma  staff   3622815  9  2 00:36 clojure.jar
-rw-r--r--   1 guruma  staff        86  9  2 00:46 core.clj
-rw-r--r--   1 guruma  staff        98  9  2 01:37 core.cljs
-rw-r--r--   1 guruma  staff       137  9  2 02:25 macro.clj
drwxr-xr-x  11 guruma  staff       374  9  2 02:25 out

out 폴더가 생성되는 것을 확인할 수 있습니다. out 폴더에 main.js 파일이 생성되는데, 이 파일이 컴파일된 결과 파일입니다.

클로저스립트립트 코드 실행하기

main.js를 브라우저에서 실행해 보기 위해 index.html 파일을 작성합니다.

<html>
    <body>
         <script type="text/javascript" src="out/main.js"></script>
    </body>
</html>

다음과 같이 index.html 파일을 브라우저로 실행합니다.

$ open index.html

브라우저의 콘솔창을 열어 확인해 보면 로그가 다음과 같이 프린트되는 것을 확인할 수 있습니다.

[log: 1504286749399 ]  hello, world
Caution

혹시 코드를 수정하게 되었는데, 수정한 코드가 브라우저에서 제대로 동작하지 않을 시에는 out 폴더를 삭제하신 후 다시 컴파일해 주세요.

$ rm -rf out
$ java -cp cljs.jar:. clojure.main build.clj

공통 도우미 함수

그런데 여기에 문제가 있습니다. now 함수는 log 매크로가 컴파일 시에 매크로 확장할 때 실행된다는 점입니다. 원래 의도는 log 매크로를 사용하는 코드가 런타임 시에 실행될 때 now 함수가 실행되어야 합니다. 이것이 사실 위에서 두 번째 해결책이었는데, 더 알맞는 것이었던 거죠.

이를 위해서는 now 함수는 .clj가 아닌 .cljs 파일에서 클로저스크립트 함수로 정의되어야 합니다.

다음과 같이 코드가 수정되어야 합니다.

util.cljs
(ns util)

(defn now []
  (str "js:" (js/Date.now)))
macro.clj
(ns macro)

(defn now []
  (System/currentTimeMillis))

(defmacro log [x]
  `(println "[log:" (util/now) "]" ~x))
core.cljs
(ns core
  (:require [util :refer [now]])
  (:require-macros [macro :refer [log]]))

(enable-console-print!)

(log "hello, world")

새로운 파일 util.cljs를 만들어 클로저스크립트 코드로 now 함수를 정의하였습니다. 현재 시각을 가져오기 위해서 이번엔 자바스크립트의 Date 오브젝트를 이용했습니다. 자바 코드인 (System/currentTimeMillis) 사용할 수 없기 때문입니다. 또한 콘솔 출력 시에 macro.clj의 now 함수와 구분할 수 있도록 "js:" 문자열을 앞에 덧붙였습니다. log 매크로에서는 (util/now) 형식(form)으로 수정했습니다. util 이라는 이름 공간을 지정해 주어서 매크로 확장 후 정확히 util 이름 공간의 now 를 호출할 수 있도록 한 것입니다. core.cljs에서는 :require 를 이용해서 util.cljs를 로딩해 줍니다.

컴파일하고 실행해봅니다.

$ java -cp cljs.jar:. clojure.main build.clj
$ open index.html

브라우저의 콘솔창에서 로그가 프린트되는 것을 확인합니다.

[log: js:1504286749399 ]  hello, world

"js:" 문자열이 나오는 것을 보고 확실히 이제 now 함수가 브라우저상에서 호출되어 프린트되는 것을 확인할 수 있습니다.

조건부 컴파일

일단 now 함수의 실행 시간 문제는 해결되었지만, 다른 문제가 있습니다. 같은 이름과 기능을 하는 2개가 함수가 하나는 macro.clj에 다른 하나는 util.cljs 파일에 각각 따로 정의되어 있다는 문제입니다. 이렇게 되면 관리가 어려워지게 됩니다.

사실 매크로를 작성하다 보면 대부분은 공통으로 사용될 수 있지만, 일부만 플랫폼에 따라 달라져야 하는 코드들이 분명 있습니다. 극히 일부 플랫폼 의존 코드만 다르고 대부분 코드들은 같은, 매크로나 매크로 도우미 함수를 각각 서로 다른 파일에서 관리해야 한다는 것는 클로저스크립트 매크로 작성 시에 큰 문제였습니다.

만약 이러한 코드들을 한 파일에서 정의하고 단지 일부 플랫폼 의존 코드만 따로 지정해서 조건부로 플랫폼에 따라 해당 플랫폼 코드만 컴파일할 수 있다면 정말 좋을 것입니다.

이러한 목적을 위해 처음에는 .cljx를 사용했습니다. 이것은 라이닝언(Leiningen)이라는 빌드툴에서 사용되는 것이었는데, 플랫폼 의존 코드를 지정하면 라이닝언의 cljx 플러그인이 해당 플랫폼 코드만의 파일들로, 즉 .clj와 .cljs 파일로 나누어서 자동으로 생성해 주었습니다. 그 후 클로저스크립트 컴파일러로 이후 컴파일 과정이 이어지는 방식이었습니다.

하지만 이것은 cljx라는 특정 도구를 사용해야 한다는 점에서 모두가 만족할 만한 해결책은 아니었습니다. 왜냐면 라이닝언 등의 해당 툴을 사용하지 못하는 경우도 있기 때문이었습니다. 결국, 조건부 컴파일은 컴파일러 자체 내에 포함되어야 했고, 실제로 Clojure 1.7과 ClojureScript 0.0-3196부터 .cljc 파일에 대해 Reader Conditionals라는 기능으로 추가되었습니다.

.cljc와 Reader Conditionals

이제 조건부 컴파일이 가능한 매크로를 .cljc 파일에 아래와 같이 작성합니다.

macro.cljc
(ns macro)

(defn now []
  #?(:clj (System/currentTimeMillis)
     :cljs (js/Date.now)))

(defmacro log [x]
  `(println "[log:" (now) " " ~x))

#? 이 Reader Conditionals라는 리더 매크로입니다. 컴파일러가 .clj, .cljs, .cljc 파일을 읽어들이면 제일 먼저 여러 가지 리더 매크로의 처리를 하는데, .cljc인 경우에는 추가로 Reader Conditionals 처리가 더해집니다. 리더 매크로의 이러한 처리들로 만들어진 form들을 컴파일러가 그 이후 단계로 처리하게 되는 것입니다.

#? 리더 매크로 안에서 :clj로 지정된 형식(form)은 클로저 컴파일러가, :cljs로 지정된 형식(form)은 클로저스크립트 컴파일러가 사용할 수 있도록 처리됩니다. (이외에 :clr와 :default 등도 있습니다. 이들을 플랫폼 tag라고 합니다.)

이제 core.cljs에서 util.cljs을 로딩할 필요가 없습니다. core.cljs파일을 다음과 같이 수정합니다.

core.cljs
(ns core
  (:require-macros [macro :refer [log]]))

(enable-console-print!)

(log "hello, world")

다음과 같이 macro.cljc를 만들고, 위의 소스 코드대로 작성합니다. 그리고 이제 기존 macro.clj과 util.cljs는 은 필요 없으니 삭제합니다.

$ touch macro.cljc
$ rm macro.clj util.cljs

컴파일합니다.

$ java -cp cljs.jar:. clojure.main build.clj

컴파일은 성공합니다. 아무 경고도 없습니다.
(물론 'single segment namespace' 경고는 계속 나옵니다만, 위에서 말한 것처럼 이 경고는 무시합니다.)

브라우저 콘솔창에서 확인합니다.

$ open index.html

다음과 같은 에러가 발생합니다.

Uncaught ReferenceError: macro is not defined

에러 메시지를 보면 macro 라는 심볼이 정의되어 있지 않아서 발생한 에러입니다. 무슨 의미일까요?

컴파일러는 log 매크로를 확장한 후, 그 결과 코드인 (println "[log:" (now) "] " "hello, world") 를 자바스크립트 코드로 변환하게 됩니다. 이를 위해서는 일단 printlnnow 심볼을 resolve 해야 합니다. println 은 cljs.core 이름 공간에서 찾을 수 있습니다. now 는 macro 이름 공간에서 찾습니다. 그래서 macro/now 함수가 됩니다.[1]

그런데 문제는 core.cljs 에서입니다. log 매크로가 확장된 후 macro/now 를 참조하게 되는데, core.cljs는 macro 이름 공간을 로딩한 적이 없기 때문에 macro 라는 심볼 자체를 참조할 수 없게 된 것입니다.

이러한 이유때문에 컴파일러가 macro/now 라는 심볼이 정의되지 않았다는 에러(Uncaught ReferenceError: macro is not defined)를 내는 것입니다.

macro 이름 공간을 참조할 수 있도록 core.cljs를 다음과 같이 수정합니다.

core.cljs
(ns core
  (:require [macro]) ; <== macro 참조
  (:require-macros [macro :refer [log]]))

(enable-console-print!)

(log "hello, world")

:require 로 macro 이름 공간을 참조합니다. 이렇게 하면 macro 이름 공간에서 정의된 심볼을 참조할 수 있게 되어서 macro/now 함수 호출이 가능하게 됩니다.

컴파일하고 브라우저를 실행합니다.

$ java -cp cljs.jar:. clojure.main build.clj
$ open index.html

브라우저 콘솔창에 다음과 같이 정상적으로 로그가 나오는 것을 확인할 수 있습니다.

[log: 1506263117138 ]  hello, world

사실 위의 예제는 설명을 하다 보니 좀 복잡해 진거지만,
실제로는 아래와 같이 더 간단하게 :refer-macros 를 사용합니다.

core.cljs
(ns core
  (:require [macro :refer-macros [log]]))

(enable-console-print!)

(log "hello, world")

.cljc 파일에서 매크로를 참조할 때

지금까지 .clj와 .cljs 파일에서 공통으로 사용할 매크로를 .cljc 파일에 정의해서 사용하는 내용이었습니다. 반대로 .cljc 파일에서 매크로를 사용할 수도 있습니다. 당연히 .cljc 파일로 클로저와 클로저스크립트 코드를 같이 작성할 수 있으니, 매크로를 사용하는 것도 가능한 거죠.

현재 core.clj와 core.cljs 파일이 있는데, 사실 같은 기능을 합니다. log를 찍는거죠. 다만 core.clj는 JVM상에서, core.cljs는 JS-VM상에서.

기능이 같다면 .cljc로 다음과 같이 작성하는 것이 가능합니다.

core.cljc
(ns core
  (:require #?(:clj  [macro :refer [log]]
               :cljs [macro :refer-macros [log]])))

#?(:cljs (enable-console-print!))

(log "hello, world")

이제 core.clj 와 core.cljs는 필요없으니 삭제합니다.

$ rm core.clj core.cljs

다음과 같이 core.cljc 파일을 클로저로 컴파일하고 실행해 봅니다.

$ java -cp clojure.jar:. clojure.main core.cljc
[log: 1506263238473] hello, world

컴파일되는 파일인 core.cljc 의 확장자가 .clj 가 아닌 것을 눈여겨 보십시요.

이번엔 다음과 같이 core.cljc 파일을 클로저스크립트로 컴파일하고, index.html로 브라우저를 실행합니다.

$ java -cp cljs.jar:. clojure.main build.clj
$ open index.html

브라우저 콘솔창을 열고 로그를 확인합니다.

[log: 1506266364171 ]  hello, world

같은 효과를 갖지만 #?@(Reader Conditional Splicing)을 이용하면 macro 심볼을 한 번만 사용할 수 있다.

core.cljc
(ns core
  (:require [macro #?@(:clj  [:refer [log]]
                       :cljs [:refer-macros [log]])] ))

#?(:cljs (enable-console-print!))

(log "hello, world")

faaf


1. 지금 설명을 이렇게 하고 있지만 사실 저도 여기서 이상한 점이 있습니다. 제 생각에는 브라우저 콘솔창에서 에러가 나기 전에 컴파일할 때 경고가 나는 것이 맞지 않을까 해서입니다. 왜냐하면 core.cljs에서 :require-macros로 macro.cljc를 로딩하면 클로저 코드로 로딩할 것이므로 now 함수는 클로저 함수일 것이고, 그렇다면 매크로 확장 결과에서의 now 심볼은 클로저스크립트일 것이므로, 컴파일러는 now 심볼을 resolve 하는데 실패하여야 하기 때문입니다. 실제로 우리가 위에서 macro.clj 를 core.cljs에서 처음 로딩할 때 정확히 그랬습니다. 그런데 macro.cljc를 로딩할 때는 경고가 나지 않는 이유는 결국 클로저스크립트 컴파일러가 macro.cljc를 클로저스크립트로도 로딩했다는 이야기가 됩니다. 실제로 이런 질문을 클로저스크립트 구글 그룹스에 올렸는데 아직 답을 얻지 못하고 있네요. 답을 알게 되면 알려드리도록 하겠습니다.(혹시 답을 아시는 분이 계시다면 알려주시면 감사하겠습니다)
Tags: compiler ClojureScript macro