이 기사에서는 Clojure 프로그래밍 언어와 이 언어의 동시성에 대해 살펴볼 것이다. 본 기사에서 Clojure에 대한 개론을 설명하려는 것은 아니므로, Clojure에 대해 어느 정도 알고 있는 것으로 가정하고 서술한다. 예제를 실행하려면 Clojure 1.1이 필요하며, 따라서 Java 1.5 이상을 사용해야 한다. 이 기사를 작성하면서 사용한 버전은 Java 1.6.0_20이었다. 이런 도구에 대한 링크는 참고자료를 참조한다. 아래의 다운로드 표에서 이 기사에 사용되는 소스 코드를 다운로드할 수 있다.
소프트웨어 개발자들은 지난 수년에 걸쳐 어떻게 동시 프로그래밍이 사실상의 프로그래밍 방식이 되어갈 것인지 수도 없이 많이 들어왔다. 이렇게 바뀌게 되는 가장 큰 이유는 컴퓨터에 탑재되는 프로세서 수는 증가했지만 컴퓨터 프로세서의 속도는 비슷해졌기 때문이다. 사실, 이렇듯 칩당 프로세서 수의 증가 때문에 무어의 법칙은 계속 유효하게 적용되었다. 이 내용은 Wikipedia(링크는 참고자료 참조)에 다음과 같이 잘 요약되어 있다.
"무어의 법칙에서 허용되는 이득을 완벽히 활용하기 위해, 최근에는 병렬 계산이 필수가 되었다. 수년 동안 프로세서 메이커들이 클록 속도와 명령 레벨 병렬 처리 능력을 꾸준히 높인 결과, 아무런 수정 없이 단일 스레드 코드가 최신 프로세서에서 더 빨리 실행된다. 프로세서 메이커들은 CPU 전력 분산을 관리하기 위해 다중 코어 칩 디자인을 장려하고, 하드웨어를 최대한 활용하려면 소프트웨어를 다중 스레드 또는 다중 프로세스 방식으로 작성해야 한다."
위 단락의 내용을 보고 이 기사를 써야겠다는 생각이 많이 들었다. 그러나 이런 종류의 미사여구가 지금껏 수년간 횡행했지만, 아직도 많은 개발자들은 단일 스레드 코드를 이용해 순조롭게 프로그래밍하고 있다. 이에 대한 한 가지 큰 이유는 다름아닌 인터넷의 힘이다. 새 애플리케이션 중 상당수가 웹 애플리케이션이다. 서버 측 웹 애플리케이션 개발은 주로 단일 스레드 프로그래밍이다. 웹 서버는 서버의 많은 코어를 이용해 사용자로부터의 수많은 동시 요청을 처리할 수 있지만, 단일 스레드 코드로 이런 각각의 요청을 처리할 수 있는 경우가 많다. 이는 좋은 일이며, 웹 애플리케이션이 성공을 거두고 있는 많은 이유 중 하나다. 따라서 다수의 개발자에게는 사용자의 랩탑 및 데스크탑 컴퓨터에 나타나는 그런 코어가 전부 활동을 시작하는 것은 아니다.
웹의 성공이 동시 프로그래밍의 발생을 저해하는 유일한 이유는 아니다. 실은, 웹 애플리케이션 개발과 그 역사를 살펴보면 개발자 입장에서 웹 애플리케이션 개발이 얼마나 쉬워졌는지 바로 알 수 있다. PHP 및 JSP에서 Ruby on Rails에 이르기까지, 웹 개발이 점점 더 쉬워져 개발자가 웹에서 더 많은 일을 하고 더욱 놀라운 애플리케이션을 개발할 수 있었다. 이를 동시 프로그래밍과 대조해보자. 가장 인기 있는 프로그래밍 언어(예: C++ 및 Java)에서는 동시 프로그래밍의 구문(스레드, 잠금)이 수십 년간 크게 변하지 않았다. 동시 프로그래밍은 항상 어려웠고 앞으로도 계속 그럴 것이다. 따라서 개발자들이 동시 프로그래밍을 피하는 것이다. 어느덧 동시 프로그래밍이라는 것이 단순하지 않은 동시 프로그래밍 작업을 할 때면 회사 내에서 누구든 그 실력을 믿어 의심치 않는 실력자 한두 명쯤은 있어야 안심이 되는 상황이 되었다.
이런 사정 때문에 새롭고 더욱 세련된 프로그래밍 언어가 등장하게 된 것이며, 그 훌륭한 예가 바로 Clojure이다. Clojure는 하위 레벨에 동시성이 빌드되어 있다. 그래서 개발자가 스레드와 잠금을 직접 다루지 않아도 되므로, 사용하기에 더 간단하고 문제도 적은 모델을 얻게 된다. 따라서 애플리케이션 논리에 다시 집중할 수 있고 시스템을 갑자기 정지시키는 교착 상태가 발생하지 않을까 너무 걱정하지 않아도 된다. Clojure에 빌드된 동시성 구문을 살펴보자.
앞서 언급한 바와 같이, 가장 인기 있는 프로그래밍 언어에서는 스레드와 잠금이라는 매우 기본적인 동시성 기능을 제공한다. 예를 들어, Java 5 및 6에서는 동시성을 위해 다수의 새 유틸리티 API를 도입했지만, 이들 대부분은 스레드 풀 및 다양한 유형의 잠금과 같이 스레드와 잠금을 바탕으로 빌드된 유틸리티이거나 동시성/성능 특성이 더 나은 데이터 구조였다. 동시 프로그램의 디자인 방법에 대한 기초에는 아무런 변화가 없었다. 그래서 여전히 똑같은 문제를 해결해야 하고, 그에 대한 해결책도 똑같이 취약하다. 단지 상용구 코드를 덜 작성해도 될 뿐이다.
Clojure는 모든 면에서 기본적으로 다르다. Clojure에서는 일반적인 기본 기능인 스레드 및 잠금 기능을 제공하지 않는다. 그 대신, 스레드 또는 잠금에 대한 언급이 전혀 없는 완전히 다른 동시 프로그래밍 모델을 얻는다. 여기서 말하는 모델은 복수의 의미로 사용된다. Clojure에는 네 가지 다른 동시성 모델이 있다. 이들 각 모델은 스레드와 잠금 상위에 있는 추상화로 간주할 수 있다. 가장 간단한 var부터 시작해서 이들 각각의 동시성 모델을 살펴보자.
가장 간단한 유형의 Clojure 동시성 모델이 바로 var이다. Var는 변수와 변수 값의 선언일 뿐이다. 목록 1은 Clojure에서 var를 사용하는 간단한 예제를 나타낸 것이다.
목록 1. Clojure var
1:1 user=> (defstruct item :title :current-price)
#'user/item
1:2 user=> (defstruct bid :user :amount)
#'user/bid
1:3 user=> (def history ())
#'user/history
1:4 user=> (def droid (struct item "Droid X" 0))
#'user/droid
1:5 user=> (defn place-offer [offer]
(binding [history (cons offer history)
droid (assoc droid :current-price (get offer :amount))]
(println droid history)))
#'user/place-offer
1:9 user=> (place-offer {:user "Anthony" :amount 10})
{:title Droid X, :current-price 10} ({:user Anthony, :amount 10})
nil
1:17 user=> (println droid) ;there should be no change
{:title Droid X, :current-price 0}
nil
|
목록 1에서 첫 번째로 수행하는 작업은 데이터 구조의 쌍 item과 bid를 선언하는
것이다. 그 다음, 그냥 빈 목록인 history라는 var를 작성한 다음, 어떤 항목을 나타내는
droid라는 var를 작성한다. 그리고 place-offer라는 함수를 작성한다. 이 함수는
입찰 기회를 잡아 droid의 current-price를 변경하고 history에 해당 입찰을
추가한다. 이를 위해 바인딩 매크로를 사용했다. 이 매크로는 var의 스레드 로컬 값을 변경한다. 그래서 place-offer
함수의 실행 범위에서 droid와 history가 가리키는 값이 서로 다를 것이다. 하지만, 실행 범위를 벗어나서는
값이 변경되지 않는다. Clojure에서는 기본적으로 모든 것이 불변이다. var를 바인딩하면 스레드 로컬 범위에 있는 것들을 간단히 변경할 수 있다. 다른 스레드에서
이 값을 읽을 경우 아무런 변경도 관찰하지 못할 것이다. 분리된 태스크를 실행하는 과정의 일부로서 상태를 변화시킬 필요가 있는 상황에서는 이렇게 하는 것이
간단한 방법이다. 다른 스레드에서 상태를 보게 되는 방식으로 상태를 변경하려는 경우에는 Clojure의 아톰을 사용하고 싶을 수 있다.
아톰은 상태가 바뀔 수 있는 변수이다. 이 변수는 사용법이 매우 간단하고 완전히 동기적이다. 다시 말해, 아톰의 값을 바꾸는 함수를 호출하면 그 함수가 리턴할 때 모든 스레드에서 이 새 값을 보게 될 것임을 확신할 수 있다. 목록 2는 아톰을 사용한 예제를 나타낸 것이다.
목록 2. Clojure 아톰
1:21 user=> (def droid (atom (struct item "Droid X" 0)))
#'user/droid
1:22 user=> (def history (atom ()))
#'user/history
1:28 user=> (defn place-offer [offer]
(reset! droid (assoc @droid :current-price (get offer :amount))))
#'user/place-offer
1:33 user=> (place-offer {:user "Anthony" :amount 10})
{:title "Droid X", :current-price 10}
1:36 user=> (println @droid)
{:title Droid X, :current-price 10}
nil
|
이 코드는 목록 1에서 보여준 이전 예제를 바탕으로 한 것이다. 이번에는 atom 함수를 사용하여
droid와 history를 아톰으로 다시 정의한다. atom 함수를 사용하면
초기값 주위의 랩퍼인 atom 오브젝트를 얻는다. 새 place-offer 함수에서 reset!
함수를 사용하여 droid의 값을 변경한다. @ 기호와 함께 droid와
history를 제자리에 추가했음을 알 수 있다. 이렇게 하면 Clojure에서 포인터를 참조 해제하게 되어 사용자에게 실제 값을
알려준다. 그 다음, 새 place-offer 함수를 호출하고 그 후에 droid를 인쇄하여 그 값이 실제로 바뀌었음을 알 수 있다. place-offer에서는
하나의 atom, 즉 droid만 변경했다. history atom은
변경하지 않았다. 이 아톰에서도 reset!을 확실히 사용할 수 있었다. 하지만, 두 가지 변경 내용을 모두 볼 수 있을지에 대한 보장은
없다. 즉, 한 스레드가 droid의 값이 바뀌는 것을 보는 것은 가능하겠지만, history의 값이 바뀌는 것을
볼 수는 없다. 그런 종류의 일관성을 얻으려면 조정 작업이 필요하다. 또한, 트랜잭션과 ref도 필요하다.
Clojure의 ref는 가장 강력한 동시성을 제공한다. 이것은 Clojure의 STM(Software Transactional Memory) 구현이다. Ref는 atom과 비슷하다. Atom과 비교해볼 때, 코드 행을 하나만 더 추가하면 되는 경우가 많다. 그 주요 이점이 바로 조정이다. Ref를 사용하면 단일 트랜잭션에서 여러 오브젝트의 상태를 변경할 수 있다. 이 트랜잭션은 원자적이고 일관되며 격리될 것이다(ACID의 ACI - 이것은 전부 메모리에 있으므로 영속성이 없음). 격리된 특성은 어떤 관찰자든 트랜잭션의 모든 변경 내용을 보거나 전혀 아무 것도 보지 못할 것임을 암시한다. 그런데 atom에서는 그렇지 않다. 목록 3은 ref의 사용 예제를 나타낸 것이다.
목록 3. Clojure ref
1:90 user=> (def droid (ref (struct item "Droid X" 0)))
#'user/droid
1:91 user=> (def history (ref ()))
#'user/history
1:92 user=> (defn place-offer [offer]
(dosync
(ref-set droid (assoc @droid :current-price (get offer :amount)))
(ref-set history (cons offer @history))
))
1:97 user=> (place-offer {:user "Tony" :amount 22})
({:user "Tony", :amount 22})
1:99 user=> (println @droid @history)
{:title Droid X, :current-price 22} ({:user Tony, :amount 22})
nil
|
목록 3에 있는 코드는 목록 2에 있는 코드와 매우 유사하다. Ref는 atom에서 사용한 것과 같은 랩퍼 패턴을 따른다. place-offer
함수 구현은 dosync에 대한 호출로 시작된다.
이 함수는 트랜잭션을 랩핑한다. 그래서 필자가 앞서 언급한 조정 기능을 제공한다. 이 함수를 사용하면 droid와
history를 모두 변경할 수 있고, 데이터의 더티 읽기가 없을 것임을 알 수 있다. 아톰과 꼭 마찬가지로, 이 함수를 실행한 후
값을 참조 해제하고 인쇄하여 값이 바뀌었음을 확인할 수 있다.
여기서 STM이 정확히 어떻게 작동하는지 궁금할 것이다. 한 스레드가 22의 값으로 place-offer 함수를 호출하는
다른 스레드와 동시에 25의 값으로 이 함수를 호출하면 어떻게 될까?
Clojure는 트랜잭션 중간에 값이 바뀌지 않도록 할 것이다. 따라서 트랜잭션이 dosync 블록의 끝에 도달했을 때 STM에서 다른 트랜잭션이 현재
트랜잭션의 시작 이후에 완료했음을 확인하는 경우, 현재 트랜잭션이 롤백되고 다시 실행된다. 함수가 여러 번 실행될 수 있으므로, 이는 순수 함수, 즉 부작용이
없는 함수만 트랜잭션의 일부로 사용된다는 점을 매우 중요하게 만든다. Clojure는 매우 높은 성능의 지속적 데이터 구조를 사용하여 이런 종류의 트랜잭션/롤백을
효율적으로 수행한다.
새 오퍼의 양이 이전 오퍼보다 많은 경우에만 새 오퍼를 선택할 수 있는지 확인하려면 ref 선언에 유효성 검증 함수를 추가하기만 하면 된다. 이 경우, 트랜잭션 중에 변경 사항이 발견된 경우 트랜잭션이 롤백되고 다시 시작된다. 유효성 검증 확인에 실패하는 경우에는 트랜잭션이 중단된다.
Clojure의 STM을 사용하는 데 있어 관건은 dosync 함수 내부에 있는 것을 랩핑하는 것이다. 눈치 빠른 관찰자라면 이것이 동기화된 블록 내부 또는 잠금 획득/해제 플로우 내부의 랩핑 코드와 매우 유사하다는 점을 지적할 수도 있다. 물론, 그런 전통적인 동시성 메커니즘은 어렵기로 악명이 높다. Clojure는 더 간단하다. 상태를 변경하려는 경우에는 dosync를 사용해야 한다. dosync 외부에 있는 ref의 상태를 변경할 수는 없다. 더 나아가, Clojure의 트랜잭션을 작성할 수 있다. dosync 블록 내부에서 역시 dosync 블록을 가진 다른 함수를 호출할 수 있다. 함수들이 공유한 잠금이 어떤 종류인지 애써 알아낼 필요가 없다. 교착 상태를 걱정할 필요도 없다. Ref와 atom은 둘 다 동기 함수이다. 상태를 동기적으로 변경할 필요가 없다면, 에이전트가 몇 가지 이점을 제공할 수 있다.
상태를 변경해야 할 때가 많지만, 상태가 변경되기를 기다리거나 여러 스레드에서 변경 작업을 할 수 있는 경우 변경 순서에 대해 신경을 쓸 필요는 없다. 이는 공통 패턴으로서, Clojure는 이 문제를 해결하기 위한 프로그래밍 모델인 에이전트를 제공한다. 목록 4는 에이전트의 사용 예제를 나타낸 것이다.
목록 4. Clojure 에이전트
1:100 user=> (def history (agent ()))
#'user/history
1:101 user=> (def droid (agent (struct item "Droid X" 0)))
#'user/droid
nil
1:107 user=> (defn place-offer [offer]
(send droid #(assoc % :current-price (get offer :amount))))
1:110 user=> (place-offer {:user "Tony" :amount 33})
#<Agent@396477d9: {:title "Droid X", :current-price 0}>
1:111 user=> (await droid)
nil
1:112 user=> (println @droid)
{:title Droid X, :current-price 33}
nil
|
다시 한 번 agent 함수를 사용하여 droid와 history의
초기값을 랩핑하는 것부터 시작해보자. 그런 다음, place-offer의 새 버전을 정의한다.
이번에는 에이전트 뒤에서 값을 직접 변경할 수 없다.
그 대신, send 함수를 사용한다. 이 함수는 에이전트와 다른 함수를 매개변수로 취한다. 두 번째 함수는 에이전트의
값에 적용될 다른 함수이다. 결과 값은 에이전트의 값을 바꾸는 데 사용될 것이다.
목록 4에서는 익명 함수를 사용하여 send로 전달했다. Atom과 ref 모두 이런 종류의 시맨틱도 지원한다는 점에
유의해야 하며, 여기서 상태를 업데이트하기 위해 함수가 전달 및 사용된다. 그 다음, await 함수를 사용했다는 점에 주목하자. 이 함수는
에이전트가 자신에게 전송된 함수를 실행할 때까지 스레드를 차단하는 역할을 한다. 원하는 변경 내용이 실제로 적용되었는지 확인하는 것이 좋다.
그렇지 않으면, 에이전트의 비동기 특성으로 인해 전송된 함수가 적용되었는지 확신할 수 없을 것이다.
본 기사에서는 Clojure의 각 동시성 모델을 보여주었다. 매우 다양한 동시성 문제가 있지만, 그 중 다수는 Clojure의 모델 중 하나에 쉽사리 맵핑된다. 그런 경우, Clojure의 여러 가지 기능을 이용하면 문제를 훨씬 쉽게 해결할 수 있을 것이다. 문제점이 잘 맵핑되지 않을 때는 Clojure가 지닌 Java와의 상호 운용성을 활용하고 Java의 스레드와 잠금을 대신 사용할 수 있다. 따라서 동시성에 크게 의존할 태스크를 수행할 때면 항상 Clojure라는 언어가 있음을 염두에 두어야 한다.
| 설명 | 이름 | 크기 | 다운로드 방식 |
|---|---|---|---|
| Article source code | auctions.clj.zip | 1KB | HTTP |
교육
- Wikipedia에서 무어의 법칙(Moore's law)에 대해 알아보자.
-
Clojure 프로그래밍 언어(Michael Galpin,
developerWorks, 2009년 9월): 이 기사에서 Clojure에 대한 개론을 습득할 수 있다.
- Clojure 커뮤니티에서 작성하여 수많은 Clojure 프로젝트에서 사용되는 필수 라이브러리에 대해서는
clojure-contrib를 살펴보자. 이 라이브러리에는 기본적으로 Eclipse 플러그인이 포함되어 있다.
- Clojure 초보자에서 전문가로 발전하는 최선의 길은 Stuart Halloway의
Programming Clojure를 숙독하는 것이다.
- Beginning Haskell(David Mertz,
developerWorks, 2001년 12월): 다른 실용적 언어에 대한 소개는 이 튜토리얼을 확인해본다.
- developerWorks 웹 개발 사이트에서는 다양한 웹 기반
솔루션을 다루는 기사를 전문적으로 게시한다.
제품 및 기술 얻기
- Clojure 사이트를 방문하면 Clojure를 다운로드하고
튜토리얼을 읽고 참조 문서에 액세스할 수 있다.
- Java SDK를 얻을 수 있다.
이 기사에서는 JDK 1.6.0_17을 사용했다.
- IBM 제품 평가 버전을
다운로드하고 DB2®, Lotus®, Rational®, Tivoli® 및 WebSphere®에서 실습용 애플리케이션 개발 도구와 미들웨어 제품을 구할 수 있다.
토론
- My developerWorks
profile을 지금 바로 작성하고 Clojure에 대해 관심 목록을
설정하자.
My developerWorks에서 최신 정보를 자주 확인하자.
- 웹
개발에 관심이 있는 다른 developerWorks 구성원을 찾아보자.
- My developerWorks:
그룹에서 웹 개발자들과 서로의 웹 개발 경험과 지식을 공유할 수 있다.
- 지식 공유: 웹 주제를
중점적으로 다루는 developerWorks 그룹 중 하나에 참여하자.
- Roland Barcia는 자신의 블로그에서
Web 2.0 및 미들웨어에 대해 설명했다.
- developerWorks 멤버의 shared
bookmarks on web topics를 따라가 보자.
- 빠른 해답: Web 2.0 Apps forum을 방문한다.
