Deps and CLI 가이드 정리

2022년 10월 14일

Clojure

# clojure# clj & clojure# deps.edn

📕 목차

개요

Clojure는 다음을 위해서 command line tools를 제공한다.

  • interactive REPL(Read-Eval-Print Loop)를 실행하기 위해서
  • 클로저 프로그램을 실행하기 위해서
  • 클로저 식을 평가하기 위해서

위의 모든 시나리오 상에서 우리는 다른 클로저 혹은 자바 라이브러리를 사용하고 싶을 것이다.

이러한 라이브러리는 직접 로컬에서 작성되었거나, 깃헙과 같은 곳에 있는 프로젝트거나 Maven Central이나 Clojars와 같은 레포지토리에서 사용가능한 라이브러리일 것이다.

이러한 모든 경우에서 우리는 다음과 같은 것을 포함하여 라이브러리를 사용한다.

  1. 어떠한 라이브러리를 사용할지 명시하고, 이것에 이름과 버전과 같은 것을 명시한다.
  2. 깃 혹은 Maven 레포지로부터 로컬 머신으로 가져온다 (설치)
  3. JVM classpath 상에서 사용할 수 있도록하여 REPL 혹은 실행중인 프로그램에서 클로저가 라이브러리를 찾을 수 있도록 한다.

우리가 매번 위와 같은 절차를 하는 것은 너무 번거롭고 복잡하기 때문에 클로저의 CLI 툴은 1번을 위해서 deps.edn 파일을 작성하기 위한 문법을 제공하고 이 파일을 기반으로 위의 2, 3번을 자동화 해준다.

즉, deps.edn에 사용하고자 하는 라이브러리와 버전을 명시한다면 자동으로 classpath를 설정해주고 로컬 머신에 라이브러리를 가져와서 설치해준다.

사전 준비

다음 공식문서를 참고하여 Clojure를 설치한다.

REPL을 실행하고 라이브러리 사용하기

위의 공식문서를 참고하여 Clojure를 설치했다면 clj를 사용해서 REPL을 실행할 수 있다.

$ clj

Clojure 1.11.1
user=>
  • REPL에서는 클로저 식을 타이핑하고 엔터를 눌러서 그 식을 평가할 수 있다.
  • Control-D를 누르면 REPL을 종료할 수 있다.
user=> (+ 2 3) # 다음 과 같은 식을 입력 후 엔터를 누르면 식을 평가할 수 있습니다.
5 # 식의 결과를 확인할 수 있다

user=> # Ctrl-D를 누르면 REPL을 종료할 수 있습니다.
$

REPL에서는 많은 클로저 혹은 자바 라이브러리들을 사용할 수 있습니다. 예를들어, 날짜와 시간과 함께 작업을 할 때 clojure.java-time 라이브러리를 사용하는 것을 고려할 수 있습니다.

To work with this library, you need to declare it as a dependency so the tool can ensure it has been downloaded and add it to the classpath. The readme in most projects shows the name and version to use. Create a deps.edn file to declare the dependency:

위에서 예시로 들은 라이브러리를 사용하기 위해서는, 라이브러리 관리 툴에 디펜던시를 선언해야 툴에서 이 라이브러리를 다운로드하고 classpath에 추가할 수 있습니다.

  1. deps.edn 파일을 생성합니다.
  2. 다음과 같이 라이브러리를 명시해줍니다.
{:deps
 {clojure.java-time/clojure.java-time {:mvn/version "1.1.0"}}}

만약 버전을 모른다면 find-versions 도구를 사용해서 사용가능한 모든 목록을 볼 수 있습니다.

$ clj -X:deps find-versions :lib clojure.java-time/clojure.java-time
Downloading: org/slf4j/slf4j-nop/1.7.25/slf4j-nop-1.7.25.jar from central
Downloading: clojure/java-time/clojure.java-time/maven-metadata.xml from clojars
{:mvn/version "0.1.0"}
{:mvn/version "0.2.0"}
{:mvn/version "0.2.1"}
{:mvn/version "0.2.2"}
{:mvn/version "0.3.0"}
{:mvn/version "0.3.1"}
{:mvn/version "0.3.2"}
{:mvn/version "0.3.3"}
{:mvn/version "1.0.0-SNAPSHOT"}
{:mvn/version "1.0.0"}
{:mvn/version "1.1.0"}

Restart the REPL with the clj tool:

$ clj
Downloading: clojure/java-time/clojure.java-time/1.1.0/clojure.java-time-1.1.0.pom from clojars
Downloading: clojure/java-time/clojure.java-time/1.1.0/clojure.java-time-1.1.0.jar from clojars
Clojure 1.11.1
user=> (require '[java-time.api :as t])
nil
user=> (str (t/instant))
"2022-10-07T16:06:50.067221Z"

clj 로 REPL을 실행해보면은 deps.edn 파일에 선언한 라이브러리가 이전에 사용한 적이 없다면 다운로드 되는 것을 확인하실 수 있습니다.

파일이 다운로드되고나면, 이 라이브러리는 다른 프로젝트 등에서 동일한 버전을 사용할 때 재사용되어집니다. 파일은 보통 ~/.m2 혹은 /.gitlibs 폴더에 설치되어 사용됩니다.

프로그램 작성하기

기본설정으로 clj 도구는 src 폴더 안에 있는 소스 파일을 찾아본다. 그러므로 src/hello.clj 라는 경로에 파일을 만들어보자.

(ns hello
  (:require [java-time.api :as t]))

(defn time-str
  "Returns a string representation of a datetime in the local time zone."
  [instant]
  (t/format
    (t/with-zone (t/formatter "hh:mm a") (t/zone-id))
    instant))

(defn run [opts]
  (println "Hello world, the time is" (time-str (t/instant))))

프로그램 실행하기

위에서 작성한 코드를 clj 도구를 사용해서 실행하기 위해서는 -X 옵션을 사용하면 된다. 옵션의 뒤에는 main으로 사용하고자 하는 함수의 namespace/func-name 과 같은 형식으로 src 폴더 하위에 있는 네임스페이스와 함수 이름을 지정해주면 된다.

$ clj -X hello/run
Hello world, the time is 12:19 PM

로컬 라이브러리 사용하기

다양한 곳에서 공통으로 사용되는 로직은 별도의 라이브러리로 분리하여 관리하고 싶을 때가 있다. clj 도구는 로컬 디스크에 있는 라이브러리 프로젝트를 사용하는 방법 또한 지원한다. 위에서 사용한 java-time 사용한 몇 가지 함수들을 별도의 라이브러리로 분리해보자.

  1. 먼저 별도의 프로젝트로 분리해보자. 아래와 같이 deps.edn이 두개가 있는 구조가 된다.
├── time-lib
│   ├── deps.edn
│   └── src
│       └── hello_time.clj
└── hello-world
    ├── deps.edn
    └── src
        └── hello.clj
  1. time-lib 폴더 안의 deps.edn에는 기존 hello-world의 deps.edn과 동일하게 의존성을 명시해준다.
{:deps
 {clojure.java-time/clojure.java-time {:mvn/version "1.1.0"}}}
  1. time-lib 안의 src/hello_time.clj 파일에 다음과 같이 옮겨준다.
(ns hello-time
  (:require [java-time.api :as t]))

(defn now
  "Returns the current datetime"
  []
  (t/instant))

(defn time-str
  "Returns a string representation of a datetime in the local time zone."
  [instant]
  (t/format
    (t/with-zone (t/formatter "hh:mm a") (t/zone-id))
    instant))
  1. 기존의 hello-world/src/hello.clj는 다음과 같이 변경한다.
(ns hello
  (:require [hello-time :as ht])) ;; 위의 namespace (hello-time)을 사용한다.

(defn run [opts]
  (println "Hello world, the time is" (ht/time-str (ht/now))))
  1. hello-world 폴더 하위의 deps.edn 파일의 의존성을 다음과 같이 변경한다.
{:deps
 {time-lib/time-lib {:local/root "../time-lib"}}}
  • 경로를 지정할 때는 프로젝트의 루트 디렉토리를 기준으로 지정한다. (deps.edn이 있는 경로)
  • 앞의 이름을 time-lib/time-lib로 붙이는 것은 아직 이해하지 못했다 🥲

깃 라이브러리 사용하기

사내 라이브러리를 만들어서 사용할 때 종종 깃에 별도의 레포지토리로 관리하는 경우가 있다. 여기서는 깃의 레포지토리에 별도의 프로젝트를 만들어서 사용하는 방법에 대해서 알아본다.

public 혹은 private git repository 둘다 사용이 가능하다.

위에서 사용했던 time-lib를 그대로 git library로 만드는 과정을 정리해보겠다. 위에서는 로컬의 경로로 참조해서 deps.edn에 명시했는데 time-lib 프로젝트를 그대로 깃으로 만들어서 hello-world/deps.edn의 의존성을 변경해보겠다.

  1. time-lib를 위한 git 프로젝트를 설정한다. 그리고 Github의 레포지토리에 push 를 한다.
cd ../time-lib

git init
git add deps.edn src
git commit -m "init

그리고 라이브러리에 버저닝을 하고싶다면 다음과 같이하면된다.

git tag -a 'v0.0.1' -m "initial release'
git push --tags
  1. 로컬 경로로 되어있던 부분을 깃으로부터 받아오도록 수정한다.
    • repository lib - Clojure CLI는 특정 컨벤션을 사용하는데 io.github.yourname/time-lib 와 같이 https://github.com/yourname/time-lib.git 주소를 대신해서 사용할 수 있다.
    • tag - v.0.0.1 (위에서 만든 태그)
    • sha - 태그의 sha (git rev-parse —short v0.0.1 명령을 통해서 찾을 수 있다)
{:deps
 {io.github.yourname/time-lib {:git/tag "v0.0.1" :git/sha "4c4a34d"}}}
  1. 프로그램을 다시 실행해보면 github으로부터 clone 후 checkout 하는 메세지를 확인할 수 있다.
$ clj -X hello/run
Cloning: https://github.com/yourname/time-lib
Checking out: https://github.com/yourname/time-lib at 4c4a34d
Hello world, the time is 02:10 PM

Other examples

다른 예시들은 공식문서를 참고하자.

테스트 소스 파일 포함시키기

일반적으로, 프로젝트의 classpath는 프로젝트 소스코드만 포함하고 테스트는 포함하지 않는다. extra-paths 를 사용하면 make-classpath 단계에서 classpath를 만드는 것을 수정할 수 있다. 그렇게 하기 위해서는 다음과 같이 작성을 하면 된다.

{:deps
  {org.clojure/core.async {:mvn/version "1.3.610"}}

 :aliases
 {:test {:extra-paths ["test"]}}}
  • alias를 만들고 이름을 test라고 만든다. 그리고 extra-paths를 사용해서 test 경로를 지정해준다.

전체 테스트를 테스트 러너를 사용해서 실행시키기

위에서 설정한 :test alias를 확장해서 clojure.test 의 모든 테스트를 cofnitect-labs의 test-runner 를 사용해서 실행할 수 있다.

{:deps
 {org.clojure/core.async {:mvn/version "1.3.610"}}

 :aliases
 {:test {:extra-paths ["test"]

         ;; test 시 추가적인 의존성을 명시해준다.
         :extra-deps {io.github.cognitect-labs/test-runner
                      {:git/url "https://github.com/cognitect-labs/test-runner.git"

                       ;; 공식문서에서 여긴왜 :git/sha라고 명시하지 않은 것이지?
                       :sha "9e35c979860c75555adaff7600070c60004a0f44"}}

         ;; 메인함수 실행 시 인자로 전달할 옵션을 적어준다.
         :main-opts ["-m" "cognitect.test-runner"]

         ;; clj -X:test 할 때 실행될 함수이다.
         :exec-fn cognitect.test-runner.api/test}}}

위와 같이 설정 후 다음을 실행하면 모든 테스트를 실행한다. (test 폴더 하위의 -test 네임스페이스의 모든 테스트)

clj -X:test

의존성 오버라이딩

별칭은 여러 개를 선택해서 사용이 가능한 데 이때 의존성을 오버라이딩 해서 사용이 가능하다. 예시를 통해서 알아보자.

{:deps
  {org.clojure/core.async {:mvn/version "0.3.465"}}

  :aliases
  {:old-async {:override-deps {org.clojure/core.async {:mvn/version "0.3.426"}}}}

위의 예시에서 core.async의 버전을 0.3.465 로 명시하였고 :old-async alias에서 :override-deps 를 통해서 버전을 0.3.426 (하위버전)으로 명시하였다.

이때, 별도의 별칭 없이 clj를 실행하면 core.async의 버전은 0.3.465버전을 사용할 것이고 clj M:old-async 별칭을 사용하면은 0.3.426 버전을 사용할 수 있다.

local jar 포함하기

Maven Repository에 있는 것이 아닌 로컬 디스크에 있는 jar 파일을 직접 참조해야할 필요가 있는 경우가 있다. 예를들면 database driver jar와 같이 드라이버 jar 파일을 참조하는 경우이다.

이러한 경우에는 폴더의 경로를 지정하는 대신에 직접 jar file를 명시할 수 있다.

{:deps
  {db/driver {:local/root "/path/to/db/driver.jar"}}}

Ahead-of-time (AOT) compilation

  1. gen-class 혹은 gen-interface 를 사용할 때 클로저 소스는 반드시 java class들을 생성하기 위해서 AOT compile이 되어야한다.

  2. 이러한 컴파일 작업은 compile 함수를 호출하면된다.

  3. 컴파일된 클래스 파일의 기본 경로는 classes/ 이다. 기본적으로 classes/ 경로는 classpath 경로에 추가되어있지 않으므로 deps.edn의 paths에 추가해준다.

{:paths ["src" "classes"]} ;; 생성된 클래스 파일을 classpath에 추가하기 위해서 classes 경로를 추가한다.

이제, AOT 컴파일에 의해 생성된 클래스들을 사용할 준비가 되었으므로, gen-class를 사용하는 예시를 살펴보자.

(ns my-class)

;; 이 파일은 java에서 MyClass라는 클래스를 만들고 안에 hello라는 함수를 만드는 것과 동일하다.
(gen-class
  :name my_class.MyClass
  :methods [[hello [] String]])

(defn -hello [this]
  "Hello, world!")

위의 코드는 AOT 컴파일에 의해 클래스 파일로 변환이 될 것이고 컴파일된 소스코드는 다음과 같이 :import 를 사용해서 사용한다.

(ns hello
  (:require [my-class])
  (:import (my_class MyClass)))

(defn -main [& args]
  (let [inst (MyClass.)] ;; MyClass. => (new MyClass)
  • :require와 :import를 둘 다 사용했다.
    • :require에 네임스페이스를 명시하면 자동으로 찾아서 컴파일을 한다.
    • 그리고 :import 를 통해서 사용한다.
  • (MyClass.) 은 java interop으로 (new MyClass)와 동일하다.
    • (Classname. args*)
    • (new Classname args*)

위와 같이 파일을 작성했다면 이제 REPL에서 compile을 호출해서 컴파일을 할 수 있다.

$ clj -M -e "(compile 'hello)"

그리고 메인함수가 있는 hello namespace를 실행해보자.

$ clj -M -m hello

참고자료

profile

박민기

단순하게 살아라. 현대인은 쓸데없는 절차와 일 때문에 얼마나 복잡한 삶을 살아가는가? - 이드리스 샤흐

© 2023, 미나리와 함께 만들었음