Reader Conditionals 가이드

Reader Conditionals 가이드

리더 컨디셔널(Reader Conditionals)은 여러 Clojure 계열 플랫폼이 겹치는 코드를 공유하면서, 플랫폼마다 조금씩 다른 부분을 깔끔하게 처리할 수 있게 해주는 기능이에요. 같은 소스 하나로 Clojure와 ClojureScript에서 각자 알맞은 코드를 읽어 들이도록 만들 수 있죠. 여기서는 그 문법과 실제로 어떤 상황에서 쓰이는지 차근차근 살펴볼게요.

출처: Clojure 공식문서

본문

도입

Reader conditionals는 Clojure 1.7에서 추가됐어요. 서로 다른 Clojure 방언들이 대부분 플랫폼 독립적이면서 일부는 플랫폼 의존적인 공통 코드를 공유할 수 있도록 설계된 기능이에요. 만약 여러 플랫폼에서 쓸 코드가 대부분 독립적이라면, 그냥 .clj 파일과 .cljs 파일을 분리해서 두는 편이 낫습니다.

Reader conditionals는 Clojure 리더(reader)에 통합되어 있기 때문에 별도 도구가 필요 없어요. 사용하려면 파일 확장자를 .cljc로 해주기만 하면 돼요. Reader conditionals는 하나의 표현식이며, 일반적인 Clojure 표현식처럼 다룰 수 있어요. 더 기술적인 내용은 리더 참조 문서를 확인해 보세요.

Reader conditionals에는 표준(standard)스플라이싱(splicing) 두 종류가 있어요. 표준 reader conditional은 전통적인 cond와 비슷하게 동작하죠. 문법은 #?이고, 생김새는 다음과 같아요.

#?(:clj  (Clojure expression)
   :cljs (ClojureScript expression)
   :cljr (ClojureCLR expression)
   :default (fallthrough expression))

:clj 같은 플랫폼 태그는 각 플랫폼에 하드코딩된 고정된 태그 집합이에요. :default 태그는 플랫폼 태그가 하나도 매칭되지 않을 때 그 표현식을 잡아내기 위한 잘 알려진 태그죠. 태그가 매칭되는 게 없는데 :default도 없다면, reader conditional은 아무것도 읽지 않아요. 이때 '없음(nil)'이 아니라, 스트림에서 아무것도 읽지 않은 것처럼 처리된다는 점이 중요해요.

스플라이싱 문법

스플라이싱 reader conditional의 문법은 #?@이에요. 리스트를 감싼 폼(form) 안으로 끼워 넣는 데 사용하죠. 그래서 Clojure 리더는 아래 코드를,

(defn build-list []
  (list #?@(:clj  [5 6 7 8]
            :cljs [1 2 3 4])))

이렇게 읽어요.

(defn build-list []
  (list 5 6 7 8))

한 가지 꼭 짚고 넘어갈 점이 있어요. Clojure에서 스플라이싱 컨디셔널 리더는 여러 개의 최상위 폼을 스플라이스할 수 없어요. 구체적으로 말하면, 이렇게 하면 안 된다는 뜻이에요.

;; Don't do this!, will throw an error
#?@(:clj
    [(defn clj-fn1 [] :abc)
     (defn clj-fn2 [] :cde)])
;; CompilerException java.lang.RuntimeException: Reader conditional splicing not allowed at the top level.

대신 각 함수를 개별적으로 감싸거나,

#?(:clj (defn clj-fn1 [] :abc))
#?(:clj (defn clj-fn2 [] :cde))

do로 모든 최상위 함수를 한 번에 감싸는 방법을 쓰면 돼요.

#?(:clj
    (do (defn clj-fn1 [] :abc)
        (defn clj-fn2 [] :cde)))

그럼 이제 이런 새 reader conditional을 실제로 어디에 쓰면 좋을지 예시를 몇 가지 살펴볼게요.

호스트 상호운용 (Host interop)

호스트 상호운용은 reader conditional이 풀어주는 가장 큰 고민거리 중 하나예요. 거의 순수한 Clojure로만 된 파일인데, 함수 하나 때문에 호스트 환경을 호출해야 하는 경우가 있죠. 이 예시가 전형적인 사례예요.

(defn str->int [s]
  #?(:clj  (java.lang.Integer/parseInt s)
     :cljs (js/parseInt s)))

네임스페이스 (Namespaces)

네임스페이스는 Clojure와 ClojureScript 사이에 코드를 공유할 때 생기는 또 하나의 큰 고민거리예요. ClojureScript는 매크로를 require하는 문법이 Clojure와 달라요. .cljc 파일에서 Clojure와 ClojureScript 양쪽에서 동작하는 매크로를 쓰려면, 네임스페이스 선언에 reader conditional이 필요합니다.

다음은 route-ccrs에 있는 테스트에서 가져온 예시예요.

(ns route-ccrs.schema.ids.part-no-test
  (:require #?(:clj  [clojure.test :refer :all]
               :cljs [cljs.test :refer-macros [is]])
            #?(:cljs [cljs.test.check :refer [quick-check]])
            #?(:clj  [clojure.test.check.properties :as prop]
               :cljs [cljs.test.check.properties :as prop
                       :include-macros true])
            [schema.core :as schema :refer [check]]))

또 다른 예시로, rethinkdb.query 네임스페이스를 Clojure와 ClojureScript에서 모두 쓰고 싶은 경우를 볼게요. 그런데 ClojureScript에서는 필수로 요구되는 rethinkdb.net을 로드할 수 없어요. 이 네임스페이스가 Java 소켓으로 데이터베이스와 통신하기 때문이죠. 그래서 reader conditional을 사용해, Clojure 프로그램이 읽을 때만 네임스페이스가 require되도록 처리했어요.

(ns rethinkdb.query
  (:require [clojure.walk :refer [postwalk postwalk-replace]]
            #?(:clj [rethinkdb.net :as net])))

;; snip...

#?(:clj (defn run [query conn]
      (let [token (get-token conn)]
        (net/send-start-query conn token (replace-vars query)))))

예외 처리 (Exception handling)

예외 처리도 reader conditional이 빛을 발하는 영역이에요. ClojureScript는 모든 것을 잡는 (catch :default)를 지원하지만, 그래도 호스트별 예외를 따로 처리하고 싶을 때가 자주 있죠. 다음은 그 예시예요.

(defn message-container-test [f]
  (fn [mc]
      (passed?
        (let [failed* (failed mc)]
          (try
            (let [x (:data mc)]
              (if (f x) mc failed*))
            (catch #?(:clj Exception :cljs js/Object) _ failed*))))))

스플라이싱 (Splicing)

스플라이싱 reader conditional은 표준 reader conditional만큼 널리 쓰이지는 않아요. 사용 예시를 보려면 ClojureCLR 리더에 있는 reader conditional 테스트를 살펴보면 좋아요. 언뜻 잘 보이지 않을 수 있는데, 스플라이싱 reader conditional 안에 있는 벡터들이 주변 벡터에 감싸져 있다는 점이 핵심이에요.

(deftest reader-conditionals
     ;; snip
     (testing "splicing"
              (is (= [] [#?@(:clj [])]))
              (is (= [:a] [#?@(:clj [:a])]))
              (is (= [:a :b] [#?@(:clj [:a :b])]))
              (is (= [:a :b :c] [#?@(:clj [:a :b :c])]))
              (is (= [:a :b :c] [#?@(:clj [:a :b :c])]))))

파일 구성 (File organisation)

.cljc 파일을 어디에 둘지에 대해 아직 커뮤니티 합의가 명확히 정해지진 않았어요. .clj, .cljs, .cljc 파일이 함께 있는 단일 src 디렉터리를 쓰는 방법과, src/clj, src/cljc, src/cljs처럼 디렉터리를 분리하는 방법, 두 가지가 있어요.

cljx

reader conditional이 도입되기 전에는, 플랫폼 간 코드 공유라는 같은 목표를 Leiningen 플러그인인 cljx가 해결했어요. cljx는 .cljx 확장자의 파일을 처리해서, 생성된 소스 디렉터리로 플랫폼별 파일 여러 개를 출력했죠. 그러면 이 파일들을 일반적인 Clojure 또는 ClojureScript 파일로서 Clojure 리더가 읽었어요. 잘 동작하긴 했지만, 실행할 도구가 하나 더 필요하다는 단점이 있었어요. cljx는 reader conditional을 대신하고자 2015년 6월 13일에 더 이상 쓰지 않기로(deprecated) 정해졌어요.

Sente도 이전에는 Clojure와 ClojureScript 사이에 코드를 공유할 때 cljx를 사용했었어요. 그런데 이 메인 네임스페이스를 reader conditional을 쓰도록 다시 작성했어요. 부모 :require 안으로 벡터를 스플라이스하는 데 스플라이싱 reader conditional을 사용한 점을 눈여겨보세요. 또 :clj:cljs 사이에 일부 require가 중복되어 있다는 점도 확인할 수 있어요.

(ns taoensso.sente
  (:require
    #?@(:clj  [[clojure.string :as str]
               [clojure.core.async :as async]
               [taoensso.encore :as enc]
               [taoensso.timbre :as timbre]
               [taoensso.sente.interfaces :as interfaces]]
        :cljs [[clojure.string :as str]
               [cljs.core.async :as async]
               [taoensso.encore :as enc]
               [taoensso.sente.interfaces :as interfaces]]))
  #?(:cljs (:require-macros
             [cljs.core.async.macros :as asyncm :refer (go go-loop)]
             [taoensso.encore :as enc :refer (have? have have-in)])))

비교를 위해, 이전에 cljx로 작성했던 원래 형태도 확인해 볼게요.

(ns taoensso.sente
  #+clj
  (:require
   [clojure.string     :as str]
   [clojure.core.async :as async])
   [taoensso.encore    :as enc]
   [taoensso.timbre    :as timbre]
   [taoensso.sente.interfaces :as interfaces])

  #+cljs
  (:require
   [clojure.string  :as str]
   [cljs.core.async :as async]
   [taoensso.encore :as enc]
   [taoensso.sente.interfaces :as interfaces])

  #+cljs
  (:require-macros
   [cljs.core.async.macros :as asyncm :refer (go go-loop)]
   [taoensso.encore        :as enc    :refer (have? have have-in)]))

더 알아보기