# 클로저 응용프로그램 구조화 전략 (A Strategy on Structuring Clojure Applications)

(원제: Structuring Clojure Applications)

이 글에서는 제 프로젝트에서 유용하게 사용한 Clojure 애플리케이션 구조화 전략을 살펴보겠습니다.

순수한 기능 스타일로 애플리케이션을 작성한다는 아이디어는 매력적이지만, 실제로는 순수한 연산과 부작용을 분리하는 방법이 항상 명확하지는 않습니다. 이러한 목표를 달성하기 위한 방법으로 클린 아키텍처 (opens new window)의 변형된 접근 방식이 종종 제안됩니다. 이 방식은 애플리케이션의 순수 연산 코어를 감싸는 외부 레이어에서 IO를 처리해야 한다는 것입니다.

이 개념은 매력적이지만, 연산할 데이터의 총량을 미리 알 수 있는 경우에만 효과가 있습니다. 안타깝게도 대부분의 실제 애플리케이션에서 어떤 데이터가 필요한지 미리 파악하는 것은 불가능합니다. 대부분의 경우 입력 유형과 현재 처리 상태에 따라 조건부로 추가 데이터를 로드해야 하는 경우가 많습니다.

하지만 우리가 할 수 있는 일은 애플리케이션을 개별적으로 추론할 수 있는 작은 구성 요소로 분리하는 것입니다. 그런 다음 이러한 구성 요소를 함께 구성하여 더 복잡한 작업을 수행할 수 있습니다. 저는 이것을 소프트웨어 개발의 레고 모델이라고 생각하고 싶습니다. 각 구성 요소는 레고 블록으로 볼 수 있으며, 다양한 문제를 해결하면서 이 레고 블록을 여러 가지 방식으로 구성할 수 있습니다.

해결하려는 문제는 노드가 상태를 계산하고 가장자리가 상태 간의 전환을 나타내는 그래프로 표현되는 워크플로로 표현할 수 있습니다. 이 그래프의 노드에 들어갈 때마다 입력을 살펴보고, 필요한 추가 데이터를 결정하고, 계산을 실행하고, 다음 상태로 전환합니다. 그래프의 각 노드는 특정 작업을 수행하는 레고 블록입니다. 이러한 노드는 데이터 흐름을 관리하는 코드 계층으로 연결됩니다.

위의 아키텍처를 구현하는 한 가지 접근 방식은 맵을 사용하여 전체 상태를 설명한 다음, 각각 특정 유형의 상태에 대해 작동하는 다중 메서드를 통과하여 새로운 상태를 생성하는 것입니다. 각 멀티메서드는 상태 맵을 매개변수로 받아 몇 가지 연산을 수행한 다음 다음 멀티메서드에 전달되는 새 맵을 반환합니다. 이것이 상태 머신과 비슷하게 들린다고 생각하신다면 매우 정확합니다.

This post will take a look at a strategy for structuring Clojure applications that I've found useful in my projects.

While the idea of writing applications in a pure functional style is appealing, it's not always clear how to separate side effects from pure compuation in practice. Variations of Clean Architecture (opens new window) approach are often suggested as a way to accomplish this goal. This style dictates that IO should be handled in the outer layer that wraps pure computation core of the application.

While this notion is appealing, it only works in cases where the totality of the data that will be operated on is known up front. Unfortunately, it's impossible to know ahead of time what data will be needed in most real world applications. In many cases additional data needs to load conditionally based on the type of input and the current state of processing.

What we can do, however, is break up our application into small components that can be reasoned about in isolation. Such components can then be composed together to accomplish tasks of increased complexity. I like to think of this as a Lego model of software development. Each component can be viewed as a Lego block, and we can compose these Lego block in many different ways as we solve different problems.

The problem being solved can be expressed in terms of a workflow represented by a graph where the nodes compute the state, and the edges represent transitions between the states. Each time we enter a node in this graph, we look at the input, decide what additional data we may need, run the computation, and transition to the next state. Each node in the graph is a Lego block that accomplishes a particular task. These nodes are then connected by a layer of code governs the data flow.

One approach to implement the above architecture is to use a map to describe overall state, then pass it through multimethods that each operate on a particular type of state and produce a new one. Each multimethod takes the state map as a parameter, does some operations on it, and then returns a new map that gets passed to the next multimethod. If you're thinking that this sounds a like a state machine then you're very much correct.

# 구현 (Implemention)

실제로 어떤 모습인지 구체적인 예를 살펴보겠습니다. 한 사용자가 시스템을 사용하여 다른 사용자에게 이메일 송금을 보내려는 워크플로우가 있다고 가정해 보겠습니다. 여기서 처리할 수 있는 몇 가지 경우가 있습니다.

두 사용자가 모두 시스템에 있는 행복한 경로 시나리오가 있습니다. 이 경우 송금인 계좌에서 금액을 인출하여 수취인 계좌에 입금하기만 하면 됩니다.

또 다른 시나리오는 송금인이 거래를 처리하기에 충분한 자금이 없는 경우입니다. 이 경우 사용자가 자금을 더 추가할 때까지 트랜잭션을 일시 중단할 수 있습니다.

마지막으로, 송금을 받는 사용자가 시스템에 없을 수 있으며, 송금을 수락하기 전에 초대를 받아야 할 수도 있습니다.

외부 리소스와의 상호작용을 나타내는 몇 가지 도우미 함수를 정의하는 것으로 시작할 수 있습니다.

Let's take a look at a concrete example of what this looks like in practice. Say we have a workflow where one user would like to send an email money transfer to another user using our system. There are a few cases we might want to handle here.

There's the happy path scenario where both users are in the system. In this case we simply withdraw the amount from the payor account and deposit it into the payee account.

Another scenario could be that the payor does not have the sufficient funds to do the transaction. In this case we may want to suspend the transaction until the user adds more funds.

Finally, the user receiving the funds may not be in the system, and they need to be invited before they can accept the transfer.

We can start by defining a few helper functions that represent interactions with external resources.

(def store (atom {:workflows {"33a19b1f-c7d1-45d8-9864-0ea17e01a26d"
                              {:id "33a19b1f-c7d1-45d8-9864-0ea17e01a26d"
                                :from   {:email "bob@foo.bar"}
                                :to     {:email "alice@bar.baz"}
                                :amount 200
                                :action :transfer}}
                  :users {"bob@foo.bar" {:funds 100}
                          "alice@bar.baz" {:funds 10}}}))

(defn persist [store {:keys [id] :as state}]
  (swap! store assoc-in [:workflows id] state))

(defn query [store email]
  (get-in @store [:users email]))

(defn load-state [store workflow-id]
  (get-in @store [:workflows workflow-id]))

(defn send-invite [email]
  (println "sending invite to" email))

(defn notify-user [email message]
  (println "notifying" email message))

(defn send-transfer [store from to amount]
  (println "transfering from" from "to" to amount)
  (swap! store
          #(-> %
              (update-in [:users from :funds] - amount)
              (update-in [:users to :funds] + amount))))

다음으로 워크플로우의 초기 상태를 나타내는 맵을 만들어 보겠습니다.

Next, we'll create a map to represent the initial state of the workfow.

{:id "33a19b1f-c7d1-45d8-9864-0ea17e01a26d"
  :from   {:email "bob@foo.bar"}
  :to     {:email "alice@bar.baz"}
  :amount 200
  :action :transfer}

맵에는 고유 ID, 사용자 입력을 나타내는 초기 데이터, 워크플로우의 현재 상태에 어떤 작업을 적용해야 하는지를 나타내는 :action 키가 포함됩니다.

이제 :action 키의 값에 따라 적절한 액션 핸들러를 디스패치하는 다중 메서드를 정의해 보겠습니다. 다중 메서드는 리소스 맵을 첫 번째 인수로 받습니다. 리소스는 데이터베이스 연결과 같은 IO 부작용을 처리하는 모든 코드를 나타냅니다. 워크플로우의 상태를 나타내는 맵은 두 번째 인수로 전달됩니다.

The map will contain a unique id, some initial data that represents user input, and an :action key indicating what action should be applied to the current state of the workflow.

Let's define a multimethod that will dispatch the approprate action handler based on the value of the :action key. The multimethod will accept a map of resources as the first argument. The resources represent any code that deals with IO side effects such as database connections. The map representing the state of the workflow will be passed in as the second argument.

(defmulti handle-action (fn [_resources {:keys [action]}] action))

이제 :transfer 작업에 대한 핸들러를 정의할 수 있습니다. 이 다중 메서드는 데이터스토어에서 사용자에 대한 추가 데이터를 가져와 적절한 조치를 취한 후 워크플로의 다음 단계를 나타내는 업데이트된 :action 키가 포함된 새 상태를 반환합니다.

We can now define a handler for the :transfer operation. This multimethod will hydrate some additional data about the users from the datastore, take the appropriate action, and return a new state with the updated :action key to indicate the next step in the workflow.

(defmethod handle-action :transfer [{:keys [store]} {:keys [from to amount] :as state}]
    (let [from-info (query store (:email from))
          to-info   (query store (:email to))
          available-funds (:funds from-info)
          state     (-> state
                        (update :from merge from-info)
                        (update :to merge to-info))] 
      (cond
        (nil? to-info)
        (assoc state :action :invite) 
        (>= available-funds amount)
        (do
          (send-transfer store (:email from) (:email to) amount)
          (assoc state :action :done))
        (< available-funds amount)
        (assoc state :action :notify-missing-funds))))

:invite:notify-missing-funds 액션에 대한 핸들러를 추가해 보겠습니다.

Let's add the handlers for :invite and :notify-missing-funds actions.

(defmethod handle-action :notify-missing-funds [{:keys [store]} {:keys [from] :as state}] 
  (notify-user (:email from) "missing funds")
  (persist store (assoc state :action :transfer))
  {:action :await})

(defmethod handle-action :invite [{:keys [store]} {:keys [to] :as state}]
  (send-invite to)
  (persist store (assoc state :action :transfer))
  {:action :await})

:invite:notify-missing-funds  액션은 상태를 유지하고 완료되면 :await 액션을 반환한다는 점에 유의하세요. 이 동작을 사용하여 워크플로가 외부 액션에 의해 차단되어 일시 중단되어야 함을 나타냅니다.

마지막으로 상태 머신을 실행하는 함수를 추가하겠습니다. 이 함수는 워크플로우 ID와 함께 리소스가 포함된 맵을 받습니다. 이 함수는 현재 상태를 로드하고 위에서 정의한 다중 메서드를 디스패치하여 실행합니다.

Note that :invite and :notify-missing-funds actions persist the state and return the :await action when they complete. We'll use this behavior to indicate that the workflow is blocked on an external action and needs to be suspended.

Finally, we'll add a function that executes the state machine. This function will accept a map containing the resources along with a workflow id. It will load the current state and execute it by dispatching the multimethod defined above.

(defn run-workflow
  [{:keys [store] :as resources} workflow-id]
  (loop [state (load-state store workflow-id)] 
    (condp = (-> state :action)
      :done state
      :await :workflow-suspended
      (let [state (handle-action resources state)]
        (recur state)))))

간단하게 설명하기 위해 원자(atom) 하나를 모의 데이터 저장소로 사용하겠습니다.

For simplicity's sake let's use an atom as our mock data store.

(def store (atom {:workflows {"33a19b1f-c7d1-45d8-9864-0ea17e01a26d"
                              {:id "33a19b1f-c7d1-45d8-9864-0ea17e01a26d"
                                :from   {:email "bob@foo.bar"}
                                :to     {:email "alice@bar.baz"}
                                :amount 200
                                :action :transfer}}
                  :users {"bob@foo.bar" {:funds 100}
                          "alice@bar.baz" {:funds 10}}}))

이제 REPL에서 이 워크플로를 실행해 볼 수 있습니다. 초기 상태로 실행하면 송금할 자금이 부족하여 워크플로우가 일시 중단된 것을 볼 수 있습니다.

We can now try running this workflow in the REPL. If we run it with the initial state, then we should see that the workflow was suspended because there were insufficient funds to transfer.

=> (run-workflow {:store store} "33a19b1f-c7d1-45d8-9864-0ea17e01a26d")

notifying bob@foo.bar missing funds
:workflow-suspended

워크플로에서 누락된 자금과 반환을 사용자에게 알리려고 시도합니다. 송금하려는 계정에 자금을 더 추가해 보겠습니다.

The workflow tries to notify the user of the missing funds and returns. Let's add more funds to the account trying to send the transfer.

=> (swap! store assoc-in [:users "bob@foo.bar" :funds] 300)

워크플로가 중단된 지점부터 다시 시작되어 전송이 성공적으로 완료됩니다.

The workflow restarts where it left off and completes the transfer successfully.

=> (run-workflow {:store store} "33a19b1f-c7d1-45d8-9864-0ea17e01a26d")

transfering from bob@foo.bar to alice@bar.baz 200
{:id "33a19b1f-c7d1-45d8-9864-0ea17e01a26d",
  :from {:email "bob@foo.bar", :funds 300},
  :to {:email "alice@bar.baz", :funds 10},
  :amount 200,
  :action :done}

# 사이드이팩트 정형화하기 (Formalizing Side Effects)

프로토콜을 사용하여 리소스 제공자를 공식화함으로써 위의 구현을 한 단계 더 개선할 수 있습니다. 이렇게 하면 외부 의존성이 무엇인지 명확해지고 모킹이 쉬워집니다. 다음과 같이 NotifyDataStore 프로토콜을 만들어 봅시다.

We can make one futher improvement over the implementation above by formalizing resource providers using protocols. Doing so will make it clear what the external dependecies are and facilitate mocking. Let's create Notify and DataStore protocols that look as follows.

(defprotocol Notify
  (send-invite [email])
  (notify-user [email message]))

(defprotocol DataStore
  (persist [_ state])
  (query [_ email])
  (add-funds [_ email amount])
  (load-state [_ workflow-id])
  (send-transfer [_ from to amount]))

다음으로 이러한 프로토콜을 구현하는 몇 가지 레코드를 추가해 보겠습니다.

Next, let's add a couple of records that implement these protocols.

(defrecord MockNotify []
  Notify
  (send-invite [_ email]
    (println "sending invite to" email))
  (notify-user [_ email message]
    (println "notifying" email message)))

(defrecord AtomDataStore [store]
  DataStore
  (persist [_ {:keys [id] :as state}]
    (swap! store assoc-in [:workflows id] state))
  (query [_  email]
    (get-in @store [:users email]))
  (add-funds [_ email amount]
    (swap! store assoc-in [:users "bob@foo.bar" :funds] 300))
  (load-state [_ workflow-id]
    (println "hi")
    (get-in @store [:workflows workflow-id]))
  (send-transfer [_ from to amount]
    (println "transfering from" from "to" to amount)
    (swap! store
            #(-> %
                (update-in [:users from :funds] - amount)
                (update-in [:users to :funds] + amount)))))

또한 앞에서 정의한 함수를 단순히 호출하는 대신 'Notify' 프로토콜을 사용하도록 멀티 메서드를 수정해야 합니다.

We'll also need to modify our multimethods to use Notify protocol instead of simply calling the functions we defined earlier.

(defmethod handle-action :notify-missing-funds [{:keys [store notify]} {:keys [from] :as state}]
  (notify-user notify (:email from) "missing funds")
  (persist store (assoc state :action :transfer))
  {:action :await})

(defmethod handle-action :invite [{:keys [store notify]} {:keys [to] :as state}]
  (send-invite notify to)
  (persist store (assoc state :action :transfer))
  {:action :await})

마지막으로 레코드를 인스턴스화하여 '실행 워크플로' 함수에 전달합니다.

Finally, we'll instantiate the records and passing them to our run-workflow function.

(def store (->AtomDataStore (atom {:workflows {"33a19b1f-c7d1-45d8-9864-0ea17e01a26d"
                                                {:id "33a19b1f-c7d1-45d8-9864-0ea17e01a26d"
                                                :from   {:email "bob@foo.bar"}
                                                :to     {:email "alice@bar.baz"}
                                                :amount 200
                                                :action :transfer}}
                                    :users {"bob@foo.bar" {:funds 100}
                                            "alice@bar.baz" {:funds 10}}})))
(def notify (->MockNotify))

(run-workflow {:store store
                :notify notify} 
              "33a19b1f-c7d1-45d8-9864-0ea17e01a26d")

(add-funds store "bob@foo.bar" 100)

(run-workflow {:store store
                :notify notify} 
              "33a19b1f-c7d1-45d8-9864-0ea17e01a26d")

# 토론(Discussion)

위의 접근 방식에는 애플리케이션을 구축할 때 특히 유용하다고 생각되는 몇 가지 측면이 있습니다.

각각의 다중 메서드는 독립적으로 추론하고 테스트할 수 있는 작은 프로그램으로 취급할 수 있습니다. 이러한 다중 메서드는 클린 아키텍처 스타일을 사용하여 에지에 IO를 유지하면서 쉽게 구조화할 수 있습니다.

리소스를 명시적 파라미터로 전달하면 계산에서 IO를 분리할 수 있습니다. 이 설계는 데이터 저장소와 같은 리소스가 명시적으로 전달되므로 테스트에 적합합니다. 나머지 코드를 변경하지 않고도 테스트를 실행할 때 모의 리소스 맵을 전달할 수 있습니다. 실제로 모의 리소스를 대상으로 개발을 시작하고 데이터베이스나 다른 시스템에 연결하는 것에 대해 걱정할 필요 없이 워크플로 로직이 의도한 대로 작동하는지 확인할 수 있습니다.

맵을 사용하여 실행 상태를 추적하면 쉽게 검사할 수 있습니다. 이 맵을 기록하여 어떤 작업을 수행 중인지, 데이터가 어떻게 보이는지 등을 확인할 수 있습니다. 또한 상태를 쉽게 직렬화할 수 있으므로 필요에 따라 연산을 일시 중단했다가 다시 시작할 수 있습니다. 이는 앞서 살펴본 것처럼 외부 작업을 기다리는 동안 워크플로를 일시 중단해야 하는 경우에 특히 유용합니다.

이 설계는 스테이트풀 리소스를 포함하는 시스템 맵을 관리하는 데 사용할 수 있는 Integrant와도 잘 어울립니다.

가장 중요한 점은 이러한 유형의 아키텍처는 암시적 결합 없이 재사용 가능한 구성 요소를 생성한다는 것입니다. 각 멀티메소드는 다른 멀티메소드와 독립적으로 사용할 수 있으며, 서로 다른 워크플로로 구성할 수 있습니다. 이를 통해 더 큰 구조를 구축하는 데 사용할 수 있는 컴포저블(조합가능한) 레고 블록을 얻을 수 있습니다.

Integrant (opens new window): Integrant는 데이터 기반 아키텍처로 애플리케이션을 구축하기 위한 Clojure(및 ClojureScript) 마이크로 프레임워크입니다. 컴포넌트 또는 마운트의 대안으로 생각할 수 있으며, Arachne에서 영감을 받아 Duct 작업을 통해 만들어졌습니다. Integrant is a Clojure (and ClojureScript) micro-framework for building applications with data-driven architecture. It can be thought of as an alternative to Component or Mount, and was inspired by Arachne and through work on Duct.

There are several aspects of the above approach that I've found to be particularly useful when building applications.

Each multimethod can be treated as a small program that can be reasoned about and tested independently. These multimethods can easily be structured using Clean Architecture style keepng IO at the edges.

Passing resources in as an explicit parameter allows decoupling IO from computation. This design lends itself well to testing since resources, such as the data store, are passed in explicitly. We can pass in a map of mock resources when running tests without any changes to the rest of the code. In fact, we can start developing against mock resources and ensure that the workflow logic works as intended before having to worry about connecting to databases or other systems.

Using a map to track the state of the execution makes it easy to inspect it. We can log this map to see what operation we're doing, what the data looks like, and so on. The state can also be easily serialized, allowing us to suspend and resume computation as needed. This is particularly useful in cases when the workflow needs to be suspended pending some external action as we saw earlier.

This design also plays well with Integrant which can be used to manage the system map containing stateful resources.

Most importantly, this type of architecture creates reusable components without implicit coupling. Each multimethod can be used indepenently of the others, and composed into different workflows. This gives us composable Lego blocks that we can use to build larger structures.