스레딩 매크로 (Threading Macros)
스레딩 매크로 (Threading Macros)
스레딩 매크로(화살표 매크로)는 중첩된 함수 호출을 직선 흐름의 함수 호출로 바꿔 가독성을 높여요. Clojure의 관용적 코드 스타일을 만드는 핵심 도구예요. 공식 Guides 기준으로 ->, ->>, as->, some->, cond->를 정리할게요.
본문
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"라는 단어(값을 함수 파이프라인에 통과시킨다는 뜻)는 실행 중인 동시성 스레드 개념과 무관해요.
두 번째 폼부터 매크로는 첫 값을 첫 인자로 삽입해요. 이 과정이 각 단계에서 반복되고, 이전 계산의 결과가 다음 폼의 첫 인자로 삽입돼요. 두 인자로 보이는 함수 호출은 사실 세 인자 호출이에요 — 스레드된 값이 함수 이름 바로 뒤에 삽입되기 때문이에요. 삽입 지점을 세 개의 쉼표로 표시하면 이해하기 쉬워요.
(defn transform* [person]
(-> person
(assoc ,,, :hair-color :gray)
(update ,,, :age inc)))
실무에서 잘 안 쓰이지만, 이 시각적 보조는 유효한 Clojure 문법이에요 — Clojure에서 쉼표는 공백이기 때문이에요.
의미상 transform*는 transform과 동일해요. 화살표 매크로는 컴파일 타임에 원래 코드로 펼쳐지고, 함수의 반환값은 마지막 계산(그 경우 update 호출)의 결과예요. 다시 쓴 함수는 변형을 설명하는 문장처럼 읽혀요: "사람을 받아 회색 머리를 주고, 나이를 올리고, 결과를 돌려줘." 물론 불변 값의 맥락에서는 실제 변경이 일어나지 않아요. 함수는 갱신된 속성을 가진 새 값을 돌려줄 뿐이에요.
문법적으로 스레딩 매크로는 리더가 가장 안쪽 표현식부터 읽는 대신 함수를 왼쪽에서 오른쪽 적용 순서로 읽게 해줘요.
thread-last (->>)와 thread-as (as->) 매크로
-> 매크로는 순전히 문법적 변형 규칙을 따라요. 각 표현식마다 함수 이름과 첫 인자 사이에 스레드 값을 삽입해요. 괄호 없는 베어 심볼이나 키워드는 단일 인자 함수 호출로 해석돼, 단항 함수 체인을 간결하게 쓸 수 있어요.
(-> person :hair-color name clojure.string/upper-case)
;; equivalent to
(-> person (:hair-color) (name) (clojure.string/upper-case))
하지만 항상 첫 위치에 삽입하고 싶지는 않을 수 있어서 ->는 보편적으로 적용되진 않아요. 10 미만의 모든 양의 홀수 정수의 제곱 합을 계산하는 함수를 생각해볼게요.
(defn calculate []
(reduce + (map #(* % %) (filter odd? (range 10)))))
calculate도 변형의 파이프라인이지만, 스레드 값은 각 함수 호출에서 마지막 인자 위치에 나타나요. 이때는 thread-first 대신 thread-last 매크로 ->>를 써야 해요.
(defn calculate* []
(->> (range 10)
(filter odd? ,,,)
(map #(* % %) ,,,)
(reduce + ,,,)))
보통 생략하지만, 세 개의 쉼표가 인자가 삽입될 자리를 표시해요. ->>로 스레드된 폼에서는 스레드 값이 인자 목록 끝에 삽입돼요.
어느 것을 쓸지는 변형 함수의 시그니처에 달려 있어요. 규칙 몇 가지가 있는데:
- 관례상 시퀀스를 다루는 코어 함수는 시퀀스를 마지막 인자로 기대해요. 그래서
map,filter,remove,reduce,into등을 포함한 파이프라인은 보통->>가 어울려요. - 반면 데이터 구조를 다루는 코어 함수는 다루는 값을 첫 인자로 기대해요.
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의 더 전문적인 스레딩 매크로 중 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)
;; equivalent to
(when-let [a-str (compute)]
(Long/parseLong a-str))
->처럼 cond-> 매크로도 초기 값을 받지만, ->와 달리 인자 목록을 test, expr 쌍의 연속으로 해석해요. cond->는 값(을)을 표현식들로 스레드하되 테스트가 실패한 것은 건너뛰어요. 각 쌍에서 test가 평가되고, 결과가 참이면 스레드 값이 첫 인자로 삽입된 상태로 expr이 평가돼요. 그렇지 않으면 다음 test, expr 쌍으로 진행해요. some->나 cond와 달리 cond->는 테스트가 false나 nil로 평가돼도 평가를 절대 단락시키지 않아요.
(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->>는 각 폼의 마지막 인자로 스레드 값을 삽입하지만 그 외엔 유사하게 동작해요.
원저자: Paulus Esterhazy
더 알아보기
- Clojure Reference - Other Useful Functions and Macros — 함수 조합 도구
- Clojure Guides - Higher Order Functions — 함수를 값처럼 다루기
- Clojure Reference - Data Structures — 스레드되는 불변 데이터 구조