Threading 매크로(Threading Macros) 가이드

Threading 매크로(Threading Macros) 가이드

Clojure에서 함수 호출이 중첩되면 (update (assoc person :hair-color :gray) :age inc)처럼 안쪽에서 바깥쪽으로 읽어야 해서 흐름을 따라가기가 어려워요. Threading 매크로(또는 화살표 매크로, arrow macros)는 이렇게 중첩된 함수 호출을 함수 호출의 선형적인 흐름으로 바꿔서 읽기 좋게 만들어 주는 도구예요. 이 문서에서는 ->, ->>, as->, some->, cond-> 같은 매크로들을 하나씩 살펴볼게요.

출처: Clojure 공식문서

본문

thread-first 매크로 (->)

Clojure에서는 관용적으로 순수 함수가 불변 데이터 구조를 원하는 출력 형태로 변환해요. 맵에 두 가지 변환을 적용하는 함수를 하나 볼게요.

(defn transform [person]
   (update (assoc person :hair-color :gray) :age inc))

(transform {:name "Socrates", :age 39})
;; => {:name "Socrates", :age 40, :hair-color :gray}

transform은 아주 흔한 패턴의 예시예요. 값을 하나 받아서 여러 단계의 변환을 적용하는데, 파이프라인의 각 단계가 이전 단계의 결과를 입력으로 받아요. 이런 형태의 코드는 thread-first 매크로 ->를 쓰면 더 좋게 다듬을 수 있어요.

(defn transform* [person]
   (-> person
      (assoc :hair-color :gray)
      (update :age inc)))

->는 초기 값을 첫 번째 인자로 받아서, 그 값을 하나 이상의 표현식에 차례로 넣어요.

참고: 여기서 "스레드(thread)"라는 단어(값을 함수 파이프라인에 통과시킨다는 뜻)는 동시성의 스레드(concurrent threads of execution) 개념과는 아무 관련이 없어요.

두 번째 폼부터 매크로가 첫 번째 값을 그 폼의 첫 번째 인자로 넣어요. 이 과정이 각 단계마다 반복되는데, 이전 계산의 결과가 다음 폼의 첫 번째 인자로 들어가지요. 그래서 인자 두 개짜리 함수 호출처럼 보이는 게 사실은 인자 세 개짜리 호출이 돼요. 스레드되는 값이 함수 이름 바로 뒤에 끼워지거든요. 삽입 지점을 콤마 세 개로 표시해 보면 이해하기 쉬워요.

(defn transform* [person]
   (-> person
      (assoc ,,, :hair-color :gray)
      (update ,,, :age inc)))

실무에서 자주 쓰는 표기는 아니지만, 이 시각적 표시는 유효한 Clojure 문법이에요. Clojure에서는 콤마가 공백(whitespace) 으로 취급되거든요.

의미적으로 transform*transform과 동등해요. 화살표 매크로는 컴파일 시점에 원래 코드로 확장되니까요. 두 경우 모두 함수의 반환값은 마지막 계산인 update 호출의 결과예요. 다시 쓴 함수는 변환 과정을 읽는 것처럼 읽혀요 — "사람을 하나 받아서, 머리를 회색으로 바꾸고, 나이를 늘리고, 그 결과를 돌려준다"처럼요. 물론 불변 값의 맥락에서는 실제 변경(mutation)은 일어나지 않아요. 대신 함수는 갱신된 속성을 가진 새 값을 돌려줄 뿐이에요.

문법적으로도 Threading 매크로를 쓰면 함수를 안쪽 표현식부터 읽는 대신, 적용 순서대로 왼쪽에서 오른쪽으로 읽을 수 있게 돼요.

thread-last (->>) 와 thread-as (as->) 매크로

-> 매크로는 순수한 문법 변환 규칙을 따라요 — 각 표현식마다 스레드되는 값을 함수 이름과 첫 번째 인자 사이에 넣는 거예요. 여기서 스레딩되는 표현식들은 (f arg1 arg2 ...) 형태의 함수 호출이라는 점에 주목할게요. 괄호가 없는 단순한 심볼이나 키워드는 인자 하나짜리 단순한 함수 호출로 해석돼요. 그 덕분에 단항 함수(인자를 하나만 받는 함수)를 간결하게 이어 붙일 수 있어요.

(-> person :hair-color name clojure.string/upper-case)

;; 위와 동등하다

(-> person (:hair-color) (name) (clojure.string/upper-case))

하지만 ->가 언제나 쓸 수 있는 건 아니에요. 항상 스레드 인자를 첫 번째 자리에 넣고 싶은 건 아니니까요. 10보다 작은 모든 홀수 양의 정수의 제곱의 합을 계산하는 함수를 볼게요.

(defn calculate []
   (reduce + (map #(* % %) (filter odd? (range 10)))))

transform과 마찬가지로 calculate도 변환의 파이프라인이에요. 하지만 앞의 예와 달리 스레드되는 값이 각 함수 호출에서 인자 목록의 마지막 자리에 나타나요. 그래서 thread-first 대신 thread-last 매크로 ->>를 써야 해요.

(defn calculate* []
   (->> (range 10)
        (filter odd? ,,,)
        (map #(* % %) ,,,)
        (reduce + ,,,)))

여기서도 보통은 생략하지만, 콤마 세 개가 인자가 삽입될 자리를 표시해 줘요. 보다시피 ->>로 스레딩되는 폼에서는 스레드 값이 인자 목록의 처음이 아니라 끝에 들어가요.

thread-first와 thread-last는 쓰이는 상황이 달라요. 어떤 게 적절한지는 변환 함수의 시그니처(서명) 에 달려 있어요. 결국은 사용하는 함수의 문서를 확인해야 하지만, 몇 가지 경험 법칙이 있어요.

  • 관례적으로 시퀀스(sequence)를 다루는 core 함수들은 그 시퀀스를 마지막 인자로 받아요. 그래서 map, filter, remove, reduce, into 등이 들어간 파이프라인은 보통 ->> 매크로를 써요.
  • 반면 데이터 구조를 다루는 core 함수들은 작업할 값을 첫 번째 인자로 받아요. 여기에는 assoc, update, dissoc, get 그리고 이들의 -in 변형들이 포함돼요. 이 함수들로 맵을 변환하는 파이프라인은 보통 -> 매크로를 필요로 해요.
  • Java interop을 통해 메서드를 호출할 때는 Java 객체가 첫 번째 인자로 전달돼요. 그럴 때 ->가 유용한데요, 예를 들어 문자열의 접두사를 확인하는 경우를 볼게요.
(-> a-string clojure.string/lower-case (.startsWith "prefix"))

또한 더 특화된 interop 매크로인 ..doto도 참고할게요.

마지막으로, ->->>도 적용할 수 없는 경우가 있어요. 파이프라인이 삽입 지점이 제각각인 함수 호출들로 이루어져 있을 수 있거든요. 그럴 때는 더 유연한 대안인 as->를 쓰면 돼요. as->고정 인자 두 개가변 개수의 표현식을 받아요. ->와 마찬가지로 첫 번째 인자는 이후 폼들을 통해 스레딩될 값이고, 두 번째 인자는 바인딩(이름) 이에요. 이후 각 폼에서는 이전 표현식의 결과를 그 바인딩 이름으로 참조할 수 있어요. 그래서 값이 첫 번째나 마지막이 아니라 어느 인자 자리든 들어갈 수 있어요.

(as-> [:foo :bar] v
  (map name v)
  (first v)
  (.substring v 1))

;; => "oo"

some->, some->>, cond-> 매크로

Clojure의 더 특화된 Threading 매크로 두 개, some->some->>Java 메서드와 연동할 때 가장 흔히 쓰여요. some->->처럼 값을 여러 표현식에 스레딩한다는 점에서 비슷해요. 하지만 체인 중 어느 지점에서든 표현식이 nil로 평가되면 실행을 단락(short-circuit)시킨다는 차이가 있어요. Java interop 맥락에서 화살표 매크로의 흔한 문제 하나는, Java 메서드가 nil(null)을 받을 것이라고 기대하지 않는다는 점이에요. 이런 경우 NullPointerException을 피하는 한 가지 방법은 명시적인 가드를 추가하는 거예요.

(when-let [counter (:counter a-map)]
  (inc (Long/parseLong counter)))

some->는 같은 효과를 더 간결하게 달성해요.

(some-> a-map :counter Long/parseLong inc)

a-map:counter 키가 없다면, 예외를 던지는 대신 전체 표현식이 nil로 평가돼요. 사실 이 동작이 너무 유용해서, 스레딩이 필요 없을 때도 some->를 쓰는 걸 흔히 볼 수 있어요.

(some-> (compute) Long/parseLong)

;; 위와 동등하다

(when-let [a-str (compute)]
  (Long/parseLong a-str))

->와 마찬가지로 cond->도 초기 값을 받지만, 차이가 있어요. 인자 목록을 test, expr 쌍들의 연속으로 해석한다는 점이 다르죠. cond->는 값이 표현식들을 통해 스레딩되면서도 테스트가 실패한 표현식은 건너뛰어요. 각 쌍마다 test가 평가되는데, 결과가 참(truthy)이면 스레드된 값을 첫 번째 인자로 넣어 그 표현식을 평가하고, 그렇지 않으면 다음 test, expr 쌍으로 진행해요. 여기서 주의할 점 — 비슷한 some->cond와 달리 cond->테스트가 falsenil로 평가되더라도 절대 평가를 단락시키지 않아요.

(defn describe-number [n]
  (cond-> []
    (odd? n) (conj "odd")
    (even? n) (conj "even")
    (zero? n) (conj "zero")
    (pos? n) (conj "positive")))

(describe-number 3) ;; => ["odd" "positive"]
(describe-number 4) ;; => ["even" "positive"]

cond->>는 스레드된 값을 각 폼의 마지막 인자로 넣는다는 점만 다르고, 나머지는 동일하게 동작해요.

더 알아보기