AOT 컴파일과 클래스 생성

AOT 컴파일과 클래스 생성

Clojure는 로드하는 모든 코드를 그 자리에서 JVM 바이트코드로 컴파일해요. 그런데 때로는 미리 컴파일(ahead-of-time, AOT)해 두는 게 유리한 경우가 있어요. AOT 컴파일을 쓰는 이유는 이렇습니다.

  • 소스 없이 애플리케이션을 배포하고 싶을 때
  • 애플리케이션 시작 시간을 줄이고 싶을 때
  • Java에서 쓸 수 있는 이름 붙은 클래스를 생성하고 싶을 때
  • 런타임 바이트코드 생성이나 커스텀 클래스로더가 필요 없는 애플리케이션을 만들고 싶을 때

출처: Clojure 공식문서

본문

Clojure의 컴파일 모델은 Java의 코드 재로딩 제약에도 불구하고, Clojure의 동적(dynamic)인 성격을 최대한 보존합니다.

  • 소스와 클래스파일의 경로는 Java 클래스패스 규칙을 따릅니다.
  • 컴파일의 대상은 네임스페이스(namespace)입니다.
  • 각 파일, 각 fn, 그리고 각 gen-class는 각각 하나의 .class 파일을 만들어냅니다.
  • 각 파일은 같은 이름에 __init이 붙은 로더(loader) 클래스를 생성합니다.
  • 로더 클래스의 정적 초기화자는 그 소스 파일을 로드했을 때와 같은 효과를 냅니다.
    • 보통 이 클래스들을 직접 쓸 일은 없어요. use, require, load가 이 클래스와 더 최신 소스 사이에서 알아서 선택해 주거든요.
  • 로더 클래스는 네임스페이스를 컴파일할 때 참조되는 각 파일에 대해 생성되는데, 그 파일의 로더 .class 파일이 소스보다 오래된 경우에만 만들어집니다.
  • 독립된 gen-class 시설이 있어서 Java 클래스로 직접 쓰는 이름 붙은 클래스를 만들 수 있어요. 여기에는 이런 기능이 있습니다.
    • 생성할 클래스 이름 지정
    • 슈퍼클래스 선택
    • 구현할 인터페이스 지정
    • 생성자 시그니처 지정
    • 상태(state) 지정
    • 추가 메서드 선언
    • 정적 팩토리 메서드 생성
    • main 생성
    • 구현 네임스페이스로의 매핑 제어
    • 상속된 protected 멤버 노출
    • 한 파일에서 여러 이름 붙은 클래스 생성(구현은 하나 이상의 네임스페이스에)
  • ns 선언 안에 선택적인 :gen-class 지시자를 넣어서, 네임스페이스에 대응하는 이름 붙은 클래스를 생성할 수도 있어요. (:gen-class ...)를 넣으면 기본값으로 :name은 ns 이름, :main은 true, :impl-ns는 같은 ns, :init-impl-ns는 true가 됩니다. gen-class의 모든 옵션을 지원합니다.
  • 컴파일할 때가 아니면 gen-class와 :gen-class 지시자는 무시됩니다.
  • 독립된 gen-interface 시설도 있어서 Java 인터페이스로 직접 쓰는 이름 붙은 인터페이스 클래스를 만들 수 있어요. 여기에는 이런 기능이 있습니다.
    • 생성할 인터페이스 이름 지정
    • 슈퍼인터페이스 지정
    • 인터페이스 메서드의 시그니처 선언

컴파일하기

라이브러리를 컴파일하려면 compile 함수를 쓰고, 네임스페이스 이름을 심볼로 넘겨줘요. 클래스패스 안에 정의된 어떤 네임스페이스 my.domain.lib(파일은 my/domain/lib.clj)에 대해, 다음이 일어나야 합니다.

  • 로더 클래스파일이 my/domain/lib__init.class에 생성됩니다. 이 위치는 pass:[*compile-path*] 아래인데, 이 경로가 클래스패스에 있어야 해요.
  • 네임스페이스의 각 fn마다 하나씩, my/domain/lib$fnname__1234.class 같은 이름의 클래스파일 묶음이 생성됩니다.
  • 각 gen-class에 대해:
    • 지정된 이름의 스텁(stub) 클래스파일이 생성됩니다.

컴파일러 옵션

Clojure 컴파일러는 몇 가지 컴파일러 플래그로 제어할 수 있어요. 런타임에는 이 값들이 동적 변수 pass:[clojure.core/*compiler-options*]에 저장되는데, 다음과 같은 선택적 키워드 키를 가진 맵입니다.

  • :disable-locals-clearing (boolean)
  • :elide-meta (키워드 벡터)
  • :direct-linking (boolean)

이 컴파일러 옵션들은 compile 함수 호출을 감싸는 동적 바인딩(dynamic binding) 안에서 바꿔서 컴파일러 동작을 변경할 수 있어요.

또는 컴파일러 옵션을 시작 시점의 Java 시스템 프로퍼티로도 설정할 수 있습니다.

  • -Dclojure.compiler.disable-locals-clearing=true
  • "-Dclojure.compiler.elide-meta=[:doc :file :line :added]"
  • -Dclojure.compiler.direct-linking=true

각 옵션에 대한 자세한 내용은 아래에서 볼게요.

Locals clearing

기본적으로 Clojure 컴파일러는 로컬 바인딩에 대한 GC 참조를 적극적으로 비우는(clearing) 코드를 만들어내요. 그런데 디버거에서 로컬 변수를 살펴보면 null로 보여서 디버깅이 어려워지죠. disable-locals-clearing=true로 설정하면 locals clearing을 막을 수 있어요. 다만 프로덕션 컴파일에서는 locals clearing을 끄는 걸 권장하지 않습니다.

Elide meta

Var 메타(문서 문자열, 파일/라인 정보 등)는 컴파일된 클래스의 상수 풀에 문자열로 컴파일됩니다. 클래스 크기를 줄이고 클래스로딩을 빠르게 하려면 메타를 생략(elide)할 수 있어요. 이 옵션은 제거할 메타 키워드의 벡터를 받는데, 흔히 쓰는 것으로 :doc, :file, :line, :added가 있어요. 메타를 생략하면 어떤 기능이 동작하지 않을 수 있다는 점을 기억하세요(예를 들어 doc이 생략된 문서 문자열을 돌려주지 못하게 되죠).

Direct linking

보통 함수를 호출하면 var를 역참조(dereference)해서 그 함수 인스턴스를 찾은 다음에 호출해요. 이 var를 통한 간접(indirection)은 Clojure가 동적 런타임 환경을 제공하는 방식 중 하나예요. 그런데 프로덕션 환경에서 함수 호출의 대부분은 이런 식으로 재정의되지 않는다는 점이 오래전부터 관찰되어 왔어요. 즉 불필요한 재전송(redirection)이 발생하고 있었죠.

_direct linking_을 쓰면 이 간접 호출 대신 함수를 직접 정적으로 호출하도록 바꿀 수 있어요. 결과적으로 var 호출이 빨라집니다. 게다가 컴파일러가 클래스 초기화에서 안 쓰는 var를 제거할 수 있는데, direct linking을 쓰면 훨씬 더 많은 var가 안 쓰게 되어요. 보통 클래스 크기가 작아지고 시작 시간이 빨라집니다.

direct linking의 한 가지 결과는, var 재정의가 direct linking으로 컴파일된 코드에서는 보이지 않는다는 점이에요(var 역참조를 하지 않으니까요). ^:dynamic으로 표시된 var는 절대 direct link되지 않습니다. 재정의를 지원하는 var로 만들고 싶은데 dynamic은 아닐 때는 ^:redef로 표시해서 direct linking을 피할 수 있어요.

Clojure 1.8부터 Clojure 코어 라이브러리 자체가 direct linking으로 컴파일됩니다.

런타임

Clojure가 생성하는 클래스는 매우 동적입니다. 특히 gen-class에는 메서드 본문이나 다른 구현 세부 사항이 전혀 지정되지 않는다는 점을 기억해 두세요. gen-class는 시그니처만 지정할 뿐이고, 생성되는 클래스는 그저 스텁일 뿐이에요. 이 스텁 클래스는 모든 구현을 구현 네임스페이스에 정의된 함수에 위임합니다. 런타임에 생성된 클래스의 어떤 메서드 foo를 호출하면, implementing.namespace/prefixfoo라는 var의 현재 값을 찾아서 호출해요. 그 var가 바인딩되어 있지 않거나 nil이면 슈퍼클래스 메서드를 호출하고, 인터페이스 메서드라면 UnsupportedOperationException을 던집니다.

gen-class 예제

가장 단순한 경우는 빈 :gen-class를 넣는 거예요. 그러면 컴파일된 클래스는 main만 갖고, 네임스페이스에 -main을 정의해서 구현합니다. 이 파일은 src/clojure/examples/hello.clj에 저장해야 합니다.

(ns clojure.examples.hello
    (:gen-class))

(defn -main
  [greetee]
  (println (str "Hello " greetee "!")))

컴파일하려면 먼저 출력 디렉터리 classes가 존재하는지 확인해요.

mkdir classes

그리고 클래스패스를 설명하는 deps.edn 파일을 만듭니다.

{:paths ["src" "classes"]}

그다음 다음과 같이 컴파일해서 클래스들을 생성해요.

$ clj
Clojure 1.10.1
user=> (compile 'clojure.examples.hello)
clojure.examples.hello

그리고 다음과 같이 일반 Java 애플리케이션처럼 실행할 수 있어요(출력 클래스 디렉터리를 반드시 포함하세요).

java -cp `clj -Spath` clojure.examples.hello Fred
Hello Fred!

이번에는 더 복잡한 :gen-class와, 독립된 gen-class 및 gen-interface 호출을 모두 쓰는 예제를 볼게요. 여기서는 인스턴스를 만들려는 클래스들을 만드는 중이에요. clojure.examples.instance 클래스는 특히 까다로운 인터페이스인 java.util.Iterator를 구현합니다. 이 인터페이스는 상태를 유지해야 하는 구현을 요구하거든요. 이 클래스는 생성자에서 String을 받고, 그 문자열의 문자들을 순서대로 내어주는 방식으로 Iterator 인터페이스를 구현할 거예요. :init 절은 생성자 함수의 이름을 지정합니다. :constructors 절은 생성자 시그니처에서 슈퍼클래스 생성자 시그니처로 가는 맵이에요. 여기서 슈퍼클래스는 기본값인 Object이고, 그 생성자는 인자를 받지 않습니다. 이 객체는 state라는 이름의 상태를 가지며, 테스트할 수 있도록 main도 갖습니다.

:init 함수(여기서는 -init)는 조금 특이해요. 항상 벡터를 돌려주는데, 그 첫 요소가 슈퍼클래스 생성자에 넘길 인자들의 벡터예요. 우리 슈퍼클래스는 인자를 받지 않으므로 이 벡터는 비어 있어요. 벡터의 두 번째 요소는 인스턴스의 상태입니다. 상태를 바꿔야 하고(그리고 상태는 항상 final이기 때문에) 문자열과 현재 인덱스를 담은 맵에 대한 ref를 사용하겠습니다.

hasNextnext는 Iterator 인터페이스의 메서드 구현이에요. 언뜻 이 메서드들은 인자를 받지 않는 것처럼 보이지만, 인스턴스 메서드의 구현 함수는 항상 메서드가 호출된 객체에 해당하는 첫 인자를 하나 더 받아요. 여기서는 관례적으로 this라고 부르죠. 상태를 일반적인 Java 필드 접근으로 얻을 수 있다는 점도 눈여겨보세요.

gen-interface 호출은 clojure.examples.IBar라는 인터페이스를 만들고, 여기에는 메서드 하나 bar만 있습니다.

독립된 gen-class 호출은 또 하나의 이름 붙은 클래스 clojure.examples.impl를 생성하는데, 그 구현 네임스페이스는 기본적으로 현재 네임스페이스가 됩니다. 이 클래스는 clojure.examples.IBar를 구현해요. :prefix 옵션은 메서드 구현이 기본값인 "-" 대신 "impl-"로 시작하는 함수에 바인딩되게 합니다. :methods 옵션은 어떤 슈퍼클래스/인터페이스에도 없는 새 메서드 foo를 정의합니다.

main에서 이 클래스들의 인스턴스를 만들고 메서드를 호출하는 데 일반 Java 인터롭(interop)을 쓰고 있다는 점을 보세요. Java 쪽에서 쓰는 것도 마찬가지로 평범합니다.

(ns clojure.examples.instance
    (:gen-class
     :implements [java.util.Iterator]
     :init init
     :constructors {[String] []}
     :state state))

(defn -init [s]
  [[] (ref {:s s :index 0})])

(defn -hasNext [this]
  (let [{:keys [s index]} @(.state this)]
    (< index (count s))))

(defn -next [this]
  (let [{:keys [s index]} @(.state this)
        ch (.charAt s index)]
    (dosync (alter (.state this) assoc :index (inc index)))
    ch))

(gen-interface
 :name clojure.examples.IBar
 :methods [[bar [] String]])

(gen-class
 :name clojure.examples.impl
 :implements [clojure.examples.IBar]
 :prefix "impl-"
 :methods [[foo [] String]])

(defn impl-foo [this]
  (str (class this)))

(defn impl-bar [this]
  (str "I " (if (instance? clojure.examples.IBar this)
              "am"
              "am not")
       " an IBar"))

(defn -main [s]
  (let [x (new clojure.examples.instance s)
        y (new clojure.examples.impl)]
    (while (.hasNext x)
      (println (.next x)))
    (println (.foo y))
    (println (.bar y))))

위와 같이 컴파일합니다.

$ clj
Clojure 1.10.1
user=> (compile 'clojure.examples.instance)
clojure.examples.instance

그리고 일반 Java 애플리케이션처럼 실행해요.

java -cp `clj -Spath` clojure.examples.instance asdf
a
s
d
f
class clojure.examples.impl
I am an IBar

더 알아보기