사전(AOT) 컴파일과 클래스 생성

사전(AOT) 컴파일과 클래스 생성 (Ahead-of-time Compilation and Class Generation)

Clojure는 로드하는 모든 코드를 즉시(on-the-fly) JVM 바이트코드로 컴파일해요. 하지만 사전(AOT) 컴파일이 유리할 때가 있어요. AOT 컴파일을 사용할 이유는 다음과 같아요:

  • 소스 없이 애플리케이션을 배포하려 할 때
  • 애플리케이션 시작을 빠르게 하려 할 때
  • Java가 사용할 이름 붙은 클래스들(named classes)을 생성하려 할 때
  • 런타임 바이트코드 생성과 사용자 지정 클래스로더가 필요 없는 애플리케이션을 만들려 할 때

Clojure 컴파일 모델은 Java의 코드 재로딩 제약에도 불구하고, Clojure의 동적 성격을 최대한 보존해요.

  • 소스와 클래스파일 경로는 Java 클래스패스 규약을 따르지 않아요.
  • 컴파일의 대상은 네임스페이스예요
  • 각 파일, fn, gen-class는 .class 파일을 생성해요
  • 각 파일은 같은 이름에 "__init"가 붙은 로더 클래스(loader class)를 생성해요
  • 로더 클래스의 정적 초기화자(static initializer)는 소스 파일을 로드할 때와 같은 효과를 만들어내요 ** 보통 이 클래스들을 직접 사용할 필요는 없어요. use, require, load가 그것들과 더 최신 소스 사이에서 선택해 주니까요
  • 네임스페이스가 컴파일될 때 참조된 각 파일에 대해, 그 로더 .class 파일이 소스보다 오래됐으면 로더 클래스가 생성돼요.
  • Java 클래스로 직접 사용하기 위한 이름 붙은 클래스들을 만들기 위해 독립형(stand-alone) gen-class 기능이 제공되며, 다음 기능을 지원해요: ** 생성된 클래스 이름짓기 ** 슈퍼클래스 선택 ** 구현할 인터페이스 지정 ** 생성자 시그니처 지정 ** 상태(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 지시어는 컴파일하지 않을 때는 무시돼요.
  • Java 인터페이스로 직접 사용하기 위한 이름 붙은 인터페이스 클래스 생성을 위해 독립형 gen-interface 기능이 제공되며, 다음 기능을 지원해요: ** 생성된 인터페이스 이름짓기 ** 슈퍼인터페이스 지정 ** 인터페이스 메서드의 시그니처 선언

출처: Clojure 공식 문서 - Ahead-of-time Compilation and Class Generation

본문

컴파일하기 (Compiling)

lib을 컴파일하려면 compile 함수를 사용하고, 네임스페이스 이름을 심볼로 제공해요. 클래스패스에 있는 my/domain/lib.clj에 정의된 어떤 네임스페이스 my.domain.lib에 대해, 다음이 일어나야 해요:

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

컴파일러 옵션 (Compiler options)

Clojure 컴파일러는 여러 컴파일러 플래그를 사용해 제어할 수 있어요. 런타임에 이것들은 동적 var pass:[clojure.core/*compiler-options*]에 저장되는데, 이것은 다음의 선택적 키워드 키를 가진 맵이에요:

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

이 컴파일러 옵션들은 compile 함수 호출 주위의 동적 바인딩으로 변경되어 컴파일러 동작을 바꿀 수 있어요.

또는 컴파일러 옵션을 시작 시 Java 시스템 속성으로 설정할 수도 있어요:

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

각 옵션에 대한 자세한 내용은 아래를 참고하세요.

지역 변수 정리 (Locals clearing)

기본적으로 Clojure 컴파일러는 지역 바인딩에 대한 GC 참조를 eagerly 정리하는 코드를 생성해요. 하지만 디버거를 사용하면 지역 변수가 null로 보여 디버깅이 어려워져요. disable-locals-clearing=true로 설정하면 지역 변수 정리를 막아요. 프로덕션 컴파일에서 지역 변수 정리를 끄는 것은 권장되지 않아요.

메타 제거 (Elide meta)

Var 메타(docstring, 파일·줄 정보 등)는 컴파일된 클래스의 상수 풀(constant pool)의 문자열로 컴파일돼요. 클래스 크기를 줄이고 클래스로딩을 빠르게 하려면 메타를 제거(elide)할 수 있어요. 이 옵션은 제거해야 할 메타 키워드 벡터를 받아요 — 몇 가지 흔한 것은 :doc, :file, :line, :added예요. 메타를 제거하면 특정 기능이 작동하지 않을 수 있다는 점에 주의하세요(예: doc는 제거됐다면 docstring을 반환할 수 없어요).

직접 연결 (Direct linking)

보통 함수를 호출하면 구현하는 함수 인스턴스를 찾기 위해 var가 역참조(dereferenced)된 다음 그 함수를 호출해요. 이 var를 통한 간접(indirection)은 Clojure가 동적 런타임 환경을 제공하는 방법 중 하나예요. 그러나 프로덕션 환경에서 함수 호출의 대부분은 이런 식으로 재정의(redefine)되지 않아 불필요한 리다이렉션을 유발한다는 것이 오래전부터 관찰됐어요.

직접 연결(direct linking) 을 사용하면 이 간접을 함수의 직접적인 정적 호출로 대체할 수 있어요. 이렇게 하면 var 호출이 더 빨라져요. 또한 컴파일러는 클래스 초기화에서 사용되지 않는 var를 제거할 수 있고, 직접 연결은 훨씬 더 많은 var를 사용되지 않게 만들 거예요. 전형적으로 이는 더 작은 클래스 크기와 더 빠른 시작 시간을 가져와요.

직접 연결의 한 결과는, var 재정의가 직접 연결로 컴파일된 코드에서는 보이지 않는다는 점이에요(직접 연결은 var 역참조를 피하니까요). ^:dynamic으로 표시된 var는 절대 직접 연결되지 않아요. 재정의를 지원하도록 var를 표시하고 싶다면(동적이 아닌 채로), ^:redef로 표시해 직접 연결을 피하세요.

Clojure 1.8부터 Clojure 코어 라이브러리 자체가 직접 연결로 컴파일돼요.

런타임 (Runtime)

Clojure가 생성한 클래스는 매우 동적이에요. 특히 gen-class에는 메서드 본문이나 다른 구현 세부 사항이 지정되지 않는다는 점에 주목하세요 — gen-class는 시그니처만 지정하고, 생성하는 클래스는 스텁뿐이에요. 이 스텁 클래스는 모든 구현을 구현 네임스페이스에 정의된 함수로 미뤄요. 런타임에 생성된 클래스의 어떤 메서드 foo에 대한 호출은 구현하는 네임스페이스/prefixfoo var의 현재 값을 찾아 호출해요. var가 바인딩되지 않았거나 nil이면 슈퍼클래스 메서드를 호출하고, 인터페이스 메서드라면 UnsupportedOperationException을 생성해요.

gen-class 예제 (gen-class Examples)

가장 단순한 경우로, 빈 :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 앱처럼 실행할 수 있어요(출력 classes 디렉터리를 포함해야 합니다):

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를 사용할 거예요.

hasNext와 next는 Iterator 인터페이스 메서드의 구현이에요. 메서드는 인자를 받지 않지만, 인스턴스 메서드의 구현 함수는 항상 메서드가 호출된 객체에 대응하는 추가 첫 인자(여기서는 관례상 'this'라 부름)를 받아요. 상태를 보통의 Java 필드 접근으로 얻을 수 있다는 점에 주목하세요.

gen-interface 호출은 clojure.examples.IBar라는 이름의 인터페이스를 만들 것이며, 단일 메서드 bar를 가져요.

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

main에서 보통의 Java 상호운용을 사용해 클래스의 인스턴스를 만들고 메서드를 호출할 수 있다는 점에 주목하세요. 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

더 알아보기