Clojure 자주 묻는 질문

Clojure 자주 묻는 질문 (FAQ)

Clojure를 쓰다 보면 마주치는 질문들을 한데 모아 답을 달아둔 문서예요. 메일링 리스트나 커뮤니티 포럼에서 실제로 오갔던 질문들을 바탕으로 만들어졌어요. 리더가 텍스트를 어떻게 읽는지 같은 언어 차원의 이야기부터, 컬렉션·시퀀스·트랜스듀서, 동시성, 네임스페이스, 자바와의 연동까지 — Clojure를 깊이 다루려면 꼭 짚고 넘어가야 할 지점들을 짧은 Q&A 형태로 풀어놨어요.

출처: Clojure 공식문서

본문

리더와 문법 (Reader and Syntax)

키워드를 문자열 대신 쓰면 어떤 점이 좋나요?

키워드(keyword)는 캐시되고 intern됩니다. 즉 프로그램 안 어디서든 같은 키워드는 재사용돼서 메모리를 아끼고, 같음(equality)을 비교하는 일이 곧바로 동일성(identity) 비교가 돼서 아주 빨라져요. 게다가 키워드는 맵에서 자기 자신을 찾아내도록 호출(invoke)할 수 있어요. 그래서 여러 맵으로 이루어진 컬렉션에서 특정 필드를 끄집어내는 흔한 패턴이 가능해져요.

:1 같은 건 유효한 키워드인가요?

리더 문서를 보면 키워드는 "심볼과 비슷하다"고 정의하고, 심볼은 "숫자로 시작하지 않는다"고 해요. 그래서 원래 의도로 보면 :1은 유효하지 않아야 맞아요. 실제로 이게 읽힐 수 있는 건 키워드 정규식에 있던 버그 덕분이에요. 이 버그는 1.6 알파에서 고쳐졌는데, 그런 키워드를 실제 프로젝트에서 이미 많이 쓰고 있다는 게 곧 드러났어요. 기존에 돌아가던 코드를 깨지 않기 위해 변경을 되돌렸고, 이 형태 계속 지원해요. (다만 이것을 코드나 문서에서 명확히 하려는 열린 이슈들이 아직 있어요.)

그리고 숫자로 시작하는 이름을 가진 네임스페이스 키워드, 이를테면 :foo/1 같은 건 애초에 읽히거나 유효한 적이 없어요. 반면 ::1처럼 자동 해석(auto-resolved)되는 키워드는 읽을 수는 있지만, print → read 왕복은 안 돼요.

일반적으로는 숫자로 시작하는 키워드는 쓰지 않는 게 좋아요. 딱 통제된 좁은 범위에서만 쓰는 걸 권해요.

리더가 읽을 수 없는 키워드를 만들 수 있는 이유가 뭔가요?

keyword 함수를 쓰면 사용자 데이터나 다른 입력을 바탕으로 키워드를 프로그래밍 방식으로 만들 수 있어요. 마찬가지로 namespacename 함수로 키워드를 다시 구성 요소로 쪼갤 수도 있어요. 프로그램이 이 기능으로 식별자나 맵 키로 쓸 키워드를 만들되, 그 데이터를 다시 print하고 읽을 일이 없게 쓰는 건 흔한 일이에요.

이런 용법 때문이기도 하고(일반적인 성능 때문이기도 하고) keyword(혹은 symbol) 입력에는 검증이 없어요. 그래서 공백이나 허용되지 않는 문자가 들어가서, 출력했을 때 다시 키워드로 읽히지 않는 키워드가 만들어질 수 있어요. 이게 중요하다면 키워드를 만들기 전에 입력을 직접 검증해줘야 해요.

Clojure에는 왜 사용자가 확장할 수 있는 리더 매크로가 없나요?

리더는 텍스트(Clojure 소스)를 받아 Clojure 데이터로 되돌려주고, 그 데이터가 컴파일·평가돼요. 리더 매크로는 일반적인 s-expression이 아닌 것을 어떻게 읽을지 리더에게 알려줘요 — 인용 '이나 익명 함수 #() 같은 게 예시예요. 리더 매크로로는 리더가 읽는 완전히 새로운 문법(JSON, XML 등 다른 형식)을 정의할 수도 있어요. 이건 나중에 컴파일 시점에 개입하는 일반 매크로보다 훨씬 강력한 문법 능력이에요.

그런데 Lisp과 달리 Clojure는 이 리더 매크로 집합을 사용자가 확장하게 두지 않아요. 그렇게 하면 다른 사람이 읽을 수 없는(적절한 리더 매크로가 없어서) 코드가 생길 수 있거든요. 그 힘의 일부를 Clojure는 태그 리터럴(tagged literal)로 돌려줘요. 일반적으로 읽을 수 있는 데이터 를 만들 수 있으면서도 여전히 확장 가능하도록요.

이에 대해 더 자세히는 History of Clojure 논문에서 "reader macros"를 찾아보는 것도 좋아요.

let 바인딩이나 매개변수에서 _는 무엇인가요?

Clojure에서 _는 심볼로서 특별한 의미가 없어요. 다만 쓰지 않을 바인딩을 나타내는 관례(앞에 _를 붙이거나 _ 그대로)로 쓰여요. 흔한 경우는 순차 디스트럭처링에서 필요 없는 값을 건너뛸 때예요.

(defn get-y [point]
  (let [[_ y] point]   ;; point의 x값은 안 쓰니까 _로 표시해요
    y))

함수 이름에서 ?!는 무엇을 뜻하나요?

?!는 함수 이름의 일부로서 Clojure에서 특별한 의미는 없어요. 다만 관례상 ?는 술어(predicate) 함수를, !는 부수 효과를 가진 함수를 나타내요. 좀 더 구체적으로, ?는 그 술어가 엄격하게 불리언 값(true/false)을 돌려준다는 뜻이에요. !는 원래 ref 트랜잭션(소프트웨어 트랜잭션 메모리 STM 의미에서) 안에서 쓰기에 안전하지 않은 부수 효과를 가진 함수를 뜻하려던 거였어요. 실제 현장에서는 !가 덜 일관되게 쓰여서, 부수 효과가 아무거나인 경우를 폭넓게 가리키는 데 쓰이기도 해요.

익명 함수 #([%1])로 왜 벡터를 만들 수 없나요?

#()는 항상 여러분이 준 표현식 주위에 괄호를 감싸도록 확장돼요. 그래서 이 경우 (fn [x] ([x]))가 되고, 벡터를 호출하려다 실패해요. 대신 벡터 함수 #(vector %)를 쓰거나, 아예 vector를 그대로 쓰면 돼요. 그게 바로 만들려던 함수거든요.

컬렉션, 시퀀스, 트랜스듀서

conj는 왜 리스트 앞에 붙고 벡터 뒤에 붙나요?

conj(conjoin)를 포함한 대부분의 Clojure 데이터 구조 연산은 사용자가 성능을 예측할 수 있게 설계됐어요. conj의 기대는 "그 연산이 효율적인 위치에 삽입한다"는 거예요. 리스트는 연결 리스트라 상수 시간 삽입이 오직 앞에서만 가능해요. 벡터(인덱스형)는 뒤에서 확장되도록 설계됐어요. 사용자인 여러분이 어떤 데이터 구조를 고를 때 이 점을 고려하면 돼요. Clojure에서는 벡터가 훨씬 더 자주 쓰여요.

굳이 "컬렉션 앞에 추가"가 목표라면 cons가 맞아요. cons는 항상 앞쪽에 붙여요. 다만 그 결과는 원래 컬렉션 타입 인스턴스가 아니라 시퀀스라는 점만 기억하세요.

벡터/셋에 시퀀스 함수를 적용하면 반환값이 더 이상 벡터나 셋이 아니라는 걸 자꾸 잊어요.

일반적으로 Clojure 코어 함수는 두 부류로 나눠볼 수 있어요.

  • 데이터 구조 함수 — 데이터 구조를 받아 그 데이터 구조의 수정된 버전을 돌려줘요 (conj, disj, assoc, dissoc 등). 이 함수들은 항상 데이터 구조를 첫 번째 인자로 받아요.
  • 시퀀스 함수 — "seqable"을 받아 seqable을 돌려줘요. (일반적으로 반환값이 실제 ISeq 인스턴스가 되도록 확정하진 않아요 — 그래야 어떤 경우 성능 최적화가 가능해요.) map, filter, remove 등이 예시예요. 이 함수들은 seqable을 마지막에 받아요.

지금 겪는 건 시퀀스 함수를 쓰면서 전자의 의미를 기대하는 상황이에요. 새 Clojure 사용자에게 흔한 일이죠! 시퀀스 함수를 쓰되 출력 데이터 구조를 더 통제하고 싶다면 여러 방법이 있어요.

  • 데이터 구조에 대응하는 버전 쓰기 — mapv, filterv 등. 아주 제한된 집합이지만 seqable 대신 데이터 구조를 돌려줘요. (mapv inc (filterv odd? [1 2 3]))
  • 시퀀스 변환 결과를 into로 다시 데이터 구조에 붓기: (into [] (map inc (filter odd? [1 2 3])))
  • 트랜스듀서 쓰기(보통 into와 함께) — 효과는 2번과 거의 같지만, 변환 조합을 시퀀스를 만들지 않고 더 효율적으로 적용해요. 최종 결과만 만들어져요: (into [] (comp (filter odd?) (map inc)) [1 2 3]). 시퀀스가 커지거나 변환이 많아질수록 이 차이는 성능에서 눈에 띄게 커져요.

이 세 가지는 모두 즉시(eager) 변환이에요 — 호출하는 순간 출력 벡터가 만들어져요. 원래 시퀀스 버전 (map inc (filter odd? [1 2 3]))는 lazy해서 필요할 때만 값을 만들고(성능을 위해 내부적으로 chunking도 해요). 어느 쪽이 옳고 그르다기보다, 서로 다른 상황에서 각각 유용해요.

코어 함수에서 인자 순서의 규칙은 뭔가요?

주요 컬렉션 피연산자가 먼저 와요. 그래야 ->와 같은 걸 자연스럽게 쓸 수 있고, 그 위치가 가변 인자(arity) 유무와 무관해져요. OO 언어와 Common Lisp(slot-value, aref, elt)에도 이런 전통이 있어요.

시퀀스는 왼쪽에서 읽히고 오른쪽에서 공급된다고 생각해볼 수 있어요.

<- [1 2 3 4]

대부분의 시퀀스 함수는 시퀀스를 소비하고 만들어내요. 그래서 체인처럼 그려볼 수 있어요.

map <- filter <- [1 2 3 4]

그리고 많은 seq 함수는 어떤 식으로든 매개변수화된다고 생각할 수 있어요.

(map f) <- (filter pred) <- [1 2 3 4]

즉 시퀀스 함수는 소스(source)를 마지막에, 다른 매개변수를 그 앞에 받아요. partial로 위처럼 직접 매개변수화하는 게 가능해요. 함수형 언어와 Lisp에도 이런 전통이 있어요.

이건 "주요 피연산자를 마지막에"와는 다른 얘기라는 점에 주의하세요. 어떤 시퀀스 함수는 소스가 둘 이상이에요(concat, interleave). 시퀀스 함수가 가변 인자일 때는 보통 소스 쪽이 그런 경우예요.

Rich Hickey의 코멘트에서 인용했어요.

트랜스듀서의 좋은 사용 사례는 뭔가요?

일련의 변환을 수행할 때, 시퀀스는 변환 사이마다 중간(캐시된) 시퀀스를 만들어요. 트랜스듀서는 단일 복합 변환을 만들어 입력을 한 번의 즉시 패스로 실행해요. 서로 다른 모델이고, 둘 다 유용해요.

트랜스듀서의 성능 이점:

  • 원본 컬렉션 순회 — reducible 입력(컬렉션 등)에 쓸 때 불필요한 입력 컬렉션 시퀀스 생성을 피해요. 메모리와 시간에 도움이 돼요.
  • 중간 시퀀스·캐시 값 — 변환이 한 번의 패스로 일어나니 중간 시퀀스와 캐시 값 생성을 모두 없애요. 역시 메모리·시간에 도움이 돼요. 앞 항목과 이 항목이 합쳐지면 입력 컬렉션 크기나 변환 수가 늘수록 큰 이득을 주기 시작해요. (다만 둘 다 작을 때는 chunked 시퀀스가 놀랍도록 빠를 수 있어서 경쟁해요.)

트랜스듀서의 설계/사용 이점:

  • 변환 합성 — 변환 합성과 변환 적용을 분리하면 더 깔끔해지는 경우가 있어요. 트랜스듀서가 이를 지원해요.
  • 즉시성 — 변환을 즉시 처리하는 게(그리고 중간에 오류를 만날 가능성까지) laziness보다 더 중요할 때 트랜스듀서가 좋아요.
  • 자원 통제 — 입력 컬렉션을 언제 순회할지 더 통제할 수 있으니, 처리 완료 시점도 알 수 있어요. 그래서 입력 자원을 언제 해제·정리할지 알기 때문에 놓기 쉬워요.

시퀀스의 성능 이점:

  • laziness — 출력의 일부만 필요할 때(예: 사용자가 몇 개 쓸지 정하는 경우) lazy 시퀀스가 처리를 미루는 데서 더 효율적일 수 있어요. 특히 시퀀스는 중간 결과에 대해 lazy할 수 있지만, 트랜스듀서는 pull 모델이라 모든 중간 값을 즉시 만들어요.
  • 무한 스트림 — 트랜스듀서는 보통 즉시 소비되므로 무한 값 스트림과는 잘 맞지 않아요.

시퀀스의 설계 이점:

  • 소비자 통제 — API에서 seq를 돌려주면 입력과 변환을 결합해서 소비자에게 통제권을 주는 무엇인가가 돼요. 트랜스듀서는 여기에 잘 맞지 않아요. (하지만 입력과 변환이 분리된 경우에는 트랜스듀서가 더 잘 맞아요.)

코어 함수 (Core functions)

defn-는 있는데 def-는 없나요?

한때는 메타데이터가 지금보다 쓰기 번거로웠어요(비공개 defn의 문법이 #^{:private true}였죠). 그래서 defn-을 "쉬운" 버전으로 만들 가치가 있어 보였어요. 그 뒤 메타데이터 지원이 개선되고 "쌓을 수 있게(stackable)" 돼서, 독립된 메타데이터를 더 쉽게 합성할 수 있게 됐어요. 모든 def 폼의 비공개 변형을 만들기보다는, def나 다른 def 폼이 필요할 때 ^:private 메타데이터를 쓰는 방식이 선호돼요.

partial은 var나 프로토콜의 갱신을 보지 못하나요?

partial(이나 comp, juxt 같은 다른 고차 함수 조합자)을 쓸 때, 참조된 var는 partial이 호출되기 전에 함수 객체로 평가돼요. 그래서 참조된 함수 var의 값을 캡처하지, var 그 자체를 캡처하지 않아요. 예를 들어 (partial my-fn 100)my-fn#'my-fn의 현재 함수 값으로 평가한 뒤 그것으로 partial을 호출해요. REPL에서 my-fn var가 다시 바인딩돼도 이전의 partial 함수는 그 변경을 "보지" 못해요. 함수만 쥐고 있고 var는 없으니까요.

인터랙티브 개발에서 이게 문제라면 간접 계층(indirection)을 넣으면 돼요. var 참조 #'myfn을 쓰거나, var 역참조를 다시 포함하도록 별도의 fn이나 defn을 쓰는 방법이 있어요. 아니면 partial 자리에 fn이나 익명 함수 리터럴을 쓰는 방법도 있어요.

일반적으로 실행 중인 앱에서는 문제가 안 돼요(var가 보통 다시 바인딩되지 않으니까요). 다만 인터랙티브 REPL 개발에서는 생길 수 있어요.

스펙 (Spec)

왜 spec은 알파인가요?

spec이 알파인 건 API가 여전히 바뀔 수 있기 때문이에요. spec은 Clojure 코어에서 분리돼서, 메인 Clojure 버전과 독립적으로 갱신될 수 있어요. 언젠가 spec API가 안정적이라고 여겨지면 그때 알파가 제거될 거예요. 다음 버전의 spec은 alpha.spec에서 개발되고 있어요.

스펙은 어디에 둬야 하나요?

정답 하나는 없어요. 데이터 스펙은 자신의 네임스페이스에 두는 게 유용한 경우가 많아요. 데이터 스펙에서 쓰는 qualifier와 네임스페이스가 일치하든 아니든요. qualifier를 네임스페이스에 맞추면 스펙 안과 다른 네임스페이스의 alias에서 자동 해석 키워드(auto-resolved keyword)를 쓸 수 있어요. 다만 둘을 얽어매는 셈이라 리팩터링이 더 복잡해져요.

함수 스펙은 보통 스펙이 적용되는 함수 바로 앞이나 뒤에 두거나, 필요할 때(테스트·검증) 선택적으로 require하는 별도 네임스페이스에 두는 사람이 많아요. 후자의 경우 Clojure 코어는 foo.bar의 함수에 대한 함수 스펙을 담는 데 foo.bar.specs라는 패턴을 따랐어요.

중첩된 regex 연산은 어떻게 동작하나요?

Regex 연산(cat, alt, *, +, ? 등)은 항상 순차 컬렉션 안의 요소들을 서술해요. 그 자체로는 스펙이 아니에요. 스펙 문맥에서 쓰이면 스펙으로 강제(코어스)돼요. 중첩된 regex 연산은 같은 순차 컬렉션 위에서 단일 regex 스펙으로 합쳐져요.

중첩 컬렉션을 검증하려면 s/spec으로 안쪽 regex를 감싸서, regex 연산 사이에 스펙 경계를 강제하면 돼요.

instrument는 반환값을 검사하지 않나요?

instrument는 함수가 자신의 args 스펙에 따라 호출되는지 확인하려는 거예요. 즉 함수가 올바르게 호출되고 있는가 하는 문제죠. 이 기능은 개발 중에 써야 해요.

함수가 올바르게 작동하는지 확인하는 건 테스트 시점의 일이에요. check 함수로 검사해야 해요. check는 생성된 인자로 함수를 실제로 호출하고, 매 호출마다 ret와 fn 스펙을 검증해요.

매크로 스펙 검사를 건너뛰는 방법이 있나요?

네. Java 시스템 속성 -Dclojure.spec.skip-macros=true를 설정하면 매크로 확장 중에 매크로 스펙을 검사하지 않아요.

특정 키만 허용하는 맵은 어떻게 쓰나요?

Spec의 일반 철학은 "열린" 스펙이에요. 맵에는 s/keys 스펙에 required/optional로 명시된 것보다 더 많은 키가 들어갈 수 있어요. 키 집합을 제한하는 한 방법은 추가 제약을 s/and로 붙이는 거예요.

(s/def ::auth
  (s/and
    (s/keys :req [::user ::password])
    #(every? #{::user ::password} (keys %))))

스펙에 문서나 메타데이터를 추가할 수 있나요?

현재는 아니에요. 이건 다음 spec 버전에서 고려 중이에요.

상태와 동시성 (State and Concurrency)

reducers, core.async, futures, pmap 사이의 트레이드오프는 뭔가요?

각각 정말 다른 사용 사례를 다뤄요.

  • reducers — 이미 메모리에 있는 데이터(맵이나 벡터)에 변환을 계산할 때 세밀한 데이터 병렬 처리에 가장 좋아요. 계산할 작은 데이터 항목이 수천 개 있고, 그 일을 할 코어가 많을 때 최고예요. "embarrassingly parallel"(쉽게 병렬화 가능한)하다고 하는 그런 작업이에요.
  • futures — 작업을 백그라운드 스레드로 미뤄두고 나중에 결과를 가져올 때(또는 병렬로 I/O 대기를 할 때) 가장 좋아요. 크고 뭉친 작업(백그라운드에서 데이터를 한 번에 잔뜩 가져오기)에 더 잘 맞아요.
  • core.async — 주로 애플리케이션의 하위 시스템이나 내부 구조를 정리하는 데 써요. 채널(큐)로 한 "서브프로세스"(go 블록)에서 다른 쪽으로 값을 전달해요. 그래서 프로그램을 어떻게 쪼갤지에서 동시성과 아키텍처 이점을 얻는 거예요. core.async에서만 정말 얻을 수 있는 킬러 기능은, 여러 채널의 I/O 이벤트를 기다리다가 어떤 채널이든 첫 응답이 오는 걸 기다리는 것(alt/alts!)이에요. Promise도 독립 스레드/서브프로세스 사이에 단일 값을 전달하는 데 쓸 수 있지만, 단 한 번만 전달할 수 있어요.
  • pmap 같은 도구, java.util 큐와 executor, claypoole 같은 라이브러리는 거친 수준의 "태스크" 동시성을 다뤄요. 트랜스듀서 친화적인 파이프라인 기능이 매우 유용한 core.async와 여기서 일부 겹쳐요.

프로그램이 끝날 때 Clojure가 왜 1분 동안 "멈추나요"?

이 질문은 future, pmap, agent-send나 그런 함수를 호출하는 프로그램에서 가장 흔해요. 그런 프로그램이 끝날 때 종료 전에 60초 멈춤이 있어요. 고치려면 프로그램 종료 시 shutdown-agents를 호출하면 돼요.

Clojure는 future와 agent 함수 실행을 처리하는 두 개의 내부 스레드 풀을 써요. 두 풀 모두 non-daemon 스레드를 쓰고, JVM은 non-daemon 스레드가 살아 있는 동안 종료되지 않아요. 특히 future와 agent send-off 호출을 처리하는 풀은 60초 타임아웃의 Executor cached 스레드 풀을 사용해요. 위 시나리오에서 프로그램은 백그라운드 스레드가 작업을 마치고 타임아웃으로 소멸할 때까지 기다린 뒤에야 종료돼요.

왜 Clojure STM은 스냅샷 격리는 보장하지만 직렬화 가능성(serializability)은 보장하지 않나요?

읽기가 기본적으로 포함되면 STM이 느려져요(더 많은 트랜잭션이 직렬화 가능성을 요구하게 되니까요). 그런데 많은 경우 읽기를 포함할 필요가 없어요. 그래서 사용자는 필요할 때 성능 불이익을 받아들이고, 그렇지 않을 때 더 빠른 성능을 얻도록 선택할 수 있어요. 자세한 내용은 Refs 페이지를 봐요.

네임스페이스 (Namespaces)

네임스페이스는 파일과 1:1로 대응하나요?

아니요. (그게 일반적이긴 하지만요.) 하나의 네임스페이스를 load로 보조 파일을 불러오고, 그 파일들에서 in-ns로 네임스페이스를 유지하면서 여러 파일에 걸쳐 쪼갤 수 있어요. (clojure.core가 이렇게 정의돼요.) 반대로 한 파일에 네임스페이스를 여러 개 선언하는 것도 가능해요. (다만 아주 드물죠.)

네임스페이스는 일반 함수처럼 동작하나요? 문법을 보면 ns가 네임스페이스를 만드는 함수를 돌려주는 것 같고, 파일 내용을 괄호로 감싸면 그게 일반 s-expression이 될 것 같아요. 파일에 여러 개를 넣을 수 있다는 뜻인가요?

ns는 여러 일을 하는 매크로예요.

  • 새 내부 Namespace 객체를 만듭니다(아직 없으면).
  • 그 네임스페이스를 새 현재 네임스페이스로 만듭니다(*ns*).
  • clojure.core의 모든 var를 자동 refer하고 java.lang의 모든 클래스를 import합니다.
  • 다른 네임스페이스와 var를 지정된 대로 require/refers합니다.
  • (그 외 선택적인 일들도요.)

ns는 말한 것처럼 함수나 호출 가능한 것을 돌려주지 않아요.

ns는 보통 clj 파일 맨 위에 두지만, 실제로는 평범한 매크로라 REPL에서 똑같이 호출할 수 있어요. 한 파일 안에서 두 번 이상 쓸 수도 있어요. (다만 대부분의 clj 프로그래머에게는 놀랄 일이고, AOT에서는 원하는 대로 동작하지 않을 가능성이 커요.)

컴파일러 (Compiler)

direct linking은 REPL 경험에 어떤 영향을 주나요?

direct linking된 것은 무엇이든 var의 재정의를 보지 못해요. 예를 들어 clojure.core에서 무언가를 재정의하면, 그 var를 쓰는 core의 다른 부분은 재정의를 보지 못해요. (다만 REPL에서 새로 컴파일하는 것은 볼 수 있어요.) 실제로는 보통 문제가 되지 않아요.

자신의 앱 일부라면, REPL 개발 중에는 쓰지 않고 프로덕션을 빌드·배포할 때만 direct linking을 켜고 싶을 수도 있어요. 아니면 항상 재정의를 허용하고 싶은 부분에는 ^:redef, 동적 var에는 ^:dynamic을 붙여야 할 수도 있어요.

자바와 연동 (Java and Interop)

중첩(inner) 클래스를 어떻게 참조하나요?

외부 클래스 이름과 내부 클래스 이름을 $로 구분하면 돼요. 예: java.util.Map$Entry는 Map 안의 Entry 내부 클래스예요.

프리미티브를 나타내는 클래스를 어떻게 참조하나요?

프리미티브 타입은 박싱된 클래스의 정적 TYPE 필드에서 찾을 수 있어요. 예: Integer/TYPE.

함수 반환 타입에 타입 힌트를 어떻게 다나요?

반환 타입 힌트는 함수 밖으로 Java 타입 정보를 흘려보내는 데 유용할 때가 있어요. 반환 타입 힌트를 주려면 함수 매개변수 벡터에 두면 돼요.

(defn new-file ^java.io.File [path] (java.io.File. path))

타입 힌트를 var 자체에 둘 수도 있지만, 매개변수 벡터에 두는 게 두 가지 이유로 선호돼요.

  1. var 타입 힌트는 평가되는데, ^long이나 ^longs 같은 특수 프리미티브·배열 타입 힌트는 우연히 함수 이름도 돼요. var 타입 힌트가 평가되면 함수 객체가 되는데, 그건 유효하지 않은 타입 힌트라 무시돼요.
  2. 다중 arity 함수는 arity별로 다른 반환 타입 힌트를 지정할 수 있어요. (흔치 않지만요.)

vararg 시그니처를 가진 Java 메서드를 어떻게 호출하나요?

Java는 끝의 varargs 매개변수를 배열로 취급하고, Clojure에서는 명시적 배열을 넘겨 호출할 수 있어요.

예시:

;; 정적 Arrays.asList(T... a) 호출
(java.util.Arrays/asList (object-array [0 1 2]))

;; 정적 String.format(String format, Object... args) 호출
(String/format "%s %s, %s" (object-array ["March" 1 2016]))

;; 프리미티브 vararg는 적절한 프리미티브 배열 생성자를 써요
;; put(int row, int col, double... data) 호출
(.put o 1 1 (double-array [2.0]))

;; vararg가 없으면 최소한 빈 배열을 넘겨야 해요
(.put o 1 1 (double-array []))

;; into-array로 빈 타입 배열을 만들 수 있어요
;; Class 인스턴스의 getMethod(String name, Class... parameterTypes) 호출
(.getMethod String "getBytes" (into-array Class []))

왜 illegal access 경고가 나오나요?

Java 9는 모듈 시스템을 추가했어요. 코드를 모듈로 나누고, 모듈이 export하지 않은 코드는 모듈 외부에서 호출할 수 없게 했죠. 이 변경의 영향권 중 하나가 리플렉션 접근이에요. Clojure는 대상 객체나 함수 인자에 대해 충분한 타입 정보 없이 Java interop 호출을 만나면 리플렉션을 사용해요. 예:

(def fac (javax.xml.stream.XMLInputFactory/newInstance))
(.createXMLStreamReader fac (java.io.StringReader. ""))

여기서 faccom.sun.xml.internal.stream.XMLInputFactoryImpl의 인스턴스인데, 이건 javax.xml.stream.XMLInputFactory의 확장이에요. java.xml 모듈에서 javax.xml.stream은 export된 패키지지만, XMLInputFactoryImpl은 그 패키지의 public 추상 클래스의 내부 구현이에요. 여기서 createXMLStreamReader 호출은 리플렉티브하게 되고, Reflector가 구현 클래스에 기반해 메서드를 호출하려다가, 그 클래스는 모듈 밖에서 접근할 수 없어서 다음이 나와요.

WARNING: An illegal reflective access operation has occurred
WARNING: Illegal reflective access by clojure.lang.Reflector (file:/.m2/repository/org/clojure/clojure/1.10.0/clojure-1.10.0.jar) to method com.sun.xml.internal.stream.XMLInputFactoryImpl.createXMLStreamReader(java.io.Reader)
WARNING: Please consider reporting this to the maintainers of clojure.lang.Reflector
WARNING: Use --illegal-access=warn to enable warnings of further illegal reflective access operations
WARNING: All illegal access operations will be denied in a future release

가장 먼저 짚을 점은 이게 경고(warning)라는 거예요. Java 9부터 현재 릴리스까지는 이 호출을 허용하고 코드는 계속 동작해요.

해결 방법은 여러 가지가 있어요.

  • 아마 최선은 export된 타입에 타입 힌트를 줘서 호출이 더 이상 리플렉티브하지 않게 하는 거예요.
    (.createXMLStreamReader ^javax.xml.stream.XMLInputFactory fac (java.io.StringReader. ""))
    
  • Clojure 1.10부터는 --illegal-access=deny로 illegal access를 끌 수 있어요. 그러면 Java 리플렉션 시스템이 Clojure에 필요한 피드백을 줘서, 접근 불가능한 클래스를 통한 호출이 선택지가 아님을 감지하게 해요. Clojure는 대신 public 호출 경로를 찾고 경고가 나오지 않아요.
  • JVM 모듈 시스템 플래그(--add-exports 등)로 내부 패키지를 강제로 export해서 경고를 피하는 방법도 있어요. 이건 권장하지 않아요.

경고만으로 리플렉션이 어디서 일어나는지 알기 어렵다면 플래그를 추가하면 도움이 될 수 있어요.

--illegal-access=debug

예를 들어 Clojure CLI에서는 -J 옵션으로(또는 deps.edn의 alias 아래 :jvm-opts의 일부로) 이렇게 써요.

clj -J--illegal-access=debug

설계와 사용 (Design and Use)

Clojure로 캡슐화는 어떻게 하나요?

불변 데이터에 초점을 맞추다 보니, 일반적으로 데이터 캡슐화에 높은 가치가 두어지지 않아요. 데이터가 불변이면 다른 사람이 값을 수정할까 걱정할 필요가 없어요. 마찬가지로 Clojure 데이터는 직접 조작하도록 설계됐기 때문에, 데이터를 API로 감싸기보다 직접 접근을 제공하는 데 큰 가치가 있어요.

Clojure의 모든 var는 전역적으로 사용 가능해서, 함수를 네임스페이스 안에 캡슐화할 여지도 별로 없어요. 다만 var를 비공개로 표시하는 능력(함수는 defn-, 값은 ^:private를 가진 def)은 개발자가 API의 어느 부분이 공개용이고 어느 부분이 구현부인지 표시하는 편의를 줘요.

Deps와 CLI

clj는 lein과 boot의 대체품인가요?

아니요. Clojure CLI는 a) classpath를 만들고 b) clojure 프로그램을 실행하는 데 초점을 맞춰요. 아티팩트를 만들거나 배포하지는 않아요(그리고 앞으로도 안 할 거예요). 다만 도구와 라이브러리를 통해 그런 작업을 돕게 할 수는 있어요.

tools.deps는 의존성 해석과 classpath 구성을 위한 프로그래밍식 구성 요소를 제공하려는 거예요. clj/clojure는 이걸 명령줄 형태로 감싸서 Clojure 프로그램을 실행하는 데 쓸 수 있게 해줘요. 이 조각들을 조합해 다른 많은 일을 할 수 있어요.

이 도구들로 실행 중인 REPL에 의존성을 동적으로 추가할 수 있나요?

네 (Clojure 1.12부터) — add-lib을 봐요.

단일 파일 Clojure 스크립트를, 이상적으로는 shebang 줄로 직접 실행 가능하게 만들려면 어떻게 하나요?

추가 의존성이 필요 없다면 첫 줄에 #!/usr/bin/env clojure만 넣으면 돼요. 주의할 점은 clojure-main 함수를 자동으로 호출하지 않는다는 거예요. 그래서 파일이 함수 정의만으로 끝나지 않게 해야 해요. 명령줄 인자는 *command-line-args*에서 찾을 수 있어요.

추가 의존성이 필요하다면 다음을 써보세요. Dominic Monroe의 방식이고, funcool/tubax 자리에 필요한 deps를 넣으면 돼요.

#!/bin/sh

"exec" "clojure" "-Sdeps" '{:deps {funcool/tubax {:mvn/version "0.2.0"}}}' "$0" "$@"

;; Clojure code goes here.

기여하기 (Contributing)

왜 Clojure는 기여자가 먼저 기여자 계약(CA)에 서명해야 하나요?

http://clojure.org/contributing을 봐요.

두 가지 이유로 요약돼요.

  1. 기업이 Clojure를 도입하는 것을 망설이게 할 미래의 법적 도전에서 Clojure를 보호하기 위해서요.
  2. 유리하다면 Clojure를 다른 오픈소스 라이선스로 재라이선스할 수 있게 하기 위해서요.

기여자 계약에 서명하면 Rich Hickey가 여러분의 기여에 대한 공동 소유권을 갖게 돼요. 그 대가로 Rich Hickey는 Clojure가 Free Software Foundation이나 Open Source Initiative가 승인한 오픈소스 라이선스 아래 항상 제공될 것을 보장해요.

CA 이메일 확인이 왜 "Clojure CA (between and Rich Hickey) is Signed and Filed!"라고 하나요?

이건 Adobe EchoSign의 특유한 동작으로, 이메일 계정이 이미 Adobe EchoSign 계정과 연결되어 있는 사용자에게 나타나요. 그런 경우 EchoSign은 서식에 서명된 개인 이름 대신 기존 프로필의 회사 이름을 제목 줄에 써요. 걱정하지 마세요! 아무 영향 없어요 — 계약은 서명되어 이메일에 첨부된 그대로예요.

GitHub에 호스팅된 다른 프로젝트는 풀 리퀘스트를 받는데, Clojure는 왜 안 받나요?

Rich Hickey는 JIRA 티켓에 첨부된 패치를 평가하는 것을 선호해요. 이건 기여자를 어렵게 하거나 법적 이유 때문이 아니라, 워크플로 선호 때문이에요. 자세한 내용은 개발 페이지를 봐요.

이 주제에 대한 Rich Hickey의 2012년 10월 Clojure 구글 그룹 메시지 링크예요.

미래 아이디어 (Future ideas)

미래에 네이티브 버전의 Clojure가 나올까요?

사람들이 "네이티브" 버전의 Clojure, 즉 JVM에 의존하지 않는 버전을 자주 요청해요. ClojureScript 셀프-호스팅이 한 가지 현재 경로지만, 아마 사용 사례의 일부에만 유용할 거예요. GraalVM 프로젝트로 독립 실행형 바이너리 실행 파일을 만들 수 있어요. Graal로 만든 네이티브 이미지는 시작이 아주 빠르지만, 전체 JVM보다 성능 최적화 여지가 더 적을 수 있어요.

하지만 둘 다 사람들이 "네이티브 버전의 Clojure"을 요청하며 상상하는 것과는 다를 가능성이 커요. 그들이 상상하는 건 JVM에 호스팅되지 않고, 아마 LLVM 같은 걸 통해 네이티브 실행 파일로 직접 컴파일되는 언어 버전이에요. Clojure는 JVM에서 엄청난 성능·이식성·기능을 얻고, 세계 수준의 가비지 컬렉터 같은 것에 크게 의존해요. "Clojure 네이티브"를 만드는 건 더 느리고(아마 훨씬 느리고), 덜 이식 가능하고, 기능이 크게 줄어든(Clojure 라이브러리가 JDK에 크게 의존하니까요) Clojure 버전을 만들기 위한 큰 작업이 필요해요. Clojure 코어 팀은 이 작업을 할 계획이 없어요. 다만 누구에게나 훌륭한 학습 프로젝트가 될 테니 도전해보길 권해요!

더 알아보기