트랜지언트(Transients) — 불변성 안에서 빠르게 데이터 구조를 다듬는 법

트랜지언트(Transients) — 불변성 안에서 빠르게 데이터 구조를 다듬는 법

Clojure의 데이터 구조는 불변이고 영속적(persistent)이라서 안전하지만, 큰 구조를 여러 단계로 수정하면서 만들려면 순수 함수·불변 데이터로만은 속도가 따라주지 않아요. 그래서 Clojure는 트랜지언트(transients) 라는 최적화 도구를 마련했어요. 내부적으로는 가변(mutable) 상태를 잠깐 썼다가, 결과를 밖으로 내보낼 때는 다시 불변으로 돌려주는 방식이죠. 이 글에서는 트랜지언트가 왜 필요한지, 어떻게 동작하는지, 그리고 안전하게 쓰려면 어떤 규칙을 지켜야 하는지 살펴볼게요.

출처: Clojure 공식문서

본문

왜 트랜지언트가 필요할까

숲속에서 나무가 쓰러지면 소리가 날까? 그리고 이렇게도 물어볼 수 있을 거예요. 순수 함수가 불변 값을 만들어내기 위해 로컬 데이터를 조금 변형하는 건 괜찮을까? 흥미로운 질문이에요. 사실 Clojure 데이터 구조는 여러분이 assoc 같은 걸 호출할 때마다 내부적으로 뮤테이션(변형)을 사용해요. 배열을 하나 이상 만들고 그것을 바꾼 뒤, 그 이후로는 불변으로 쓸 수 있게 돌려주죠. 그 이유는 성능이에요. 순수 함수와 불변 데이터만으로는 속도를 따라잡을 수가 없거든요. 다만 일단 만들어져 공유된 뒤에는 불변이고 영속적이라는 점이 견고한 프로그램에 필수예요. Clojure가 내부적으로 뮤테이션하는 것은 데이터 구조의 내부 노드를 이루는, 새로 할당된 작은 배열들뿐이에요. 그 배열들을 밖에서 보는 사람은 아무도 없죠.

여러분은 조금 더 높은 수준에서 비슷한 상황을 마주쳐요. 크고 영속적인 데이터 구조를 여러 단계를 거쳐 초기화하거나 변형하고 싶은데, 그 단계들은 구조를 만들거나 변형하는 코드 외에는 어느 것에도 보이지 않는 그런 상황이죠. 여기서 어려운 점은 변형의 출발점이 기존의 영속적 데이터 구조이고, 함수의 결과는 공유될 것이라는 사실이에요. 전통적인 가변 데이터 구조로 복사했다가 다시 돌아오는 방식은 O(n) 복사를 요구하고, 내부 코드도 나머지 Clojure 코드와 전혀 어울리지 않는 명령형(imperative) 지저분한 상태가 돼요. 게다가 불행히도 가변 구조를 실수로 공유하거나 별칭(alias)으로 만들 위험을 막아줄 장치도 없어요. 특히 헬퍼 함수를 불러서 그 일을 시켜야 하는 경우라면요. 요컨대, 이런 코드를 빠르게 만들기 위해 Clojure의 모델을 벗어나야 한다면 참 아까운 일이에요. 트랜지언트 데이터 구조는 바로 이 최적화 문제에 대한 해법이에요. Clojure 모델과 잘 어울리고, Clojure에서 기대하는 것과 같은 스레드 안전성 보장도 제공하죠.

트랜지언트는 어떻게 동작할까

트랜지언트 데이터 구조는 언제나 기존의 영속적 Clojure 데이터 구조로부터 만들어져요. Clojure 1.1.0 기준으로 벡터, 해시맵, 해시셋이 지원돼요. 모든 Clojure 데이터 구조가 이 기능을 지원하는 건 아니지만, 대부분은 지원할 거예요. 리스트는 지원하지 않는데, 얻을 이득이 없기 때문이에요.

transient를 호출하면 데이터 구조의 트랜지언트 '복사본'을 얻어요. 이것은 출처의 복사본이면서 같은 성능 특성을 지닌 새로운 트랜지언트 데이터 구조를 만들어내죠. 사실 대부분 _그 출처 데이터 구조 그 자체_라고 할 수 있어요. 여기서 트랜지언트의 첫 번째 특징이 드러나요. 만드는 비용이 O(1) 이라는 것. 영속적 복사본이 그렇듯 트랜지언트도 출처와 구조를 공유해요.

트랜지언트의 두 번째 특징은, 트랜지언트를 만들어도 출처가 수정되지 않고, 트랜지언트를 통해서도 출처를 수정할 수 없다는 점이에요. 원본 데이터는 언제나처럼 불변이고 영속적으로 남아 있죠.

트랜지언트는 출처의 읽기 전용 인터페이스를 지원해요. 즉 트랜지언트 벡터에 nth, get, count를 호출하거나 함수로 호출할 수 있어요. 영속적 벡터처럼요.

하지만 트랜지언트는 출처 데이터 구조의 영속적 인터페이스를 지원하지 않아요. assoc, conj 같은 함수들은 전부 예외를 던져요. 트랜지언트는 영속적이지 않으니까요. 그래서 영속적이길 요구하는 문맥에 트랜지언트가 실수로 새어 나가는 일은 없어요.

대신 트랜지언트는 *!*가 붙은 비슷한 이름의 '변경' 연산들을 병렬로 지원해요. assoc!, conj! 같은 것들이죠. 이것들은 영속적 버전과 같은 일을 하는데, 반환값 역시 트랜지언트라는 점만 달라요. 여기서 특히 주의할 점은, 트랜지언트가 그 자리에서(in-place) 때려 넣도록(문자 그대로 'bashed') 설계되지 않았다는 거예요. 반드시 반환값을 받아서 다음 호출에서 써야 해요. 이렇게 함으로써 트랜지언트는 대체하는 함수형 영속 코드와 같은 코드 구조를 지원해요. 아래 예시에서 보여주듯, 이 덕분에 코드 구조를 바꾸지 않고도 어떤 코드의 성능을 쉽게 끌어올릴 수 있어요.

결과를 다 쌓았으면 트랜지언트에 *persistent!*를 호출해서 영속적 데이터 구조를 만들어낼 수 있어요. 이 연산도 O(1)이에요. *persistent!*를 호출한 이후에는 트랜지언트를 쓰면 안 되고, 모든 연산이 예외를 던져요. 여러분이 만들었던 별칭(alias)들에도 마찬가지로 적용돼요.

예시: 성능이 같은 구조로 빠르게

아주 전형적인 예시를 볼게요. 반환할 벡터를 쌓는 코드인데, 모든 '변경'이 함수 안에 국한돼 있어요. 트랜지언트를 쓰는 버전의 구조가 완전히 같다는 점에 주목해 주세요. 차이는 딱 이 세 가지뿐이에요.

  • 출처 벡터에 transient 호출
  • conj 대신 conj! 사용
  • 반환 시 persistent! 호출
(defn vrange [n]
  (loop [i 0 v []]
    (if (< i n)
      (recur (inc i) (conj v i))
      v)))

(defn vrange2 [n]
  (loop [i 0 v (transient [])]
    (if (< i n)
      (recur (inc i) (conj! v i))
      (persistent! v))))

;; 벤치마크 (Java 25, Clojure 1.12)
(def v (vrange 1000000))    ;; 8.4 ms
(def v2 (vrange2 1000000))  ;; 5.5 ms

이렇게, 트랜지언트는 빠르죠!

동시 사용(Concurrent use)

트랜지언트를 쓰는 법은 이게 전부예요. 하지만 중요한 제약이 하나 더 있어요. 트랜지언트는 스레드 격리(thread isolation)를 요구해요. 트랜지언트 연산의 결과는 이전 결과와 (가변) 구조를 공유하기 때문에, 두 개 이상의 스레드가 하나의 트랜지언트를 동시에 다루는 것은 오류예요. 특정 트랜지언트 인스턴스의 사용은 단일 스레드 범위 안에서 하거나, 이 제약을 강제하는 프레임워크 안에서 제어해야 해요.

Clojure 1.6 이하에서는 트랜지언트를 만든 스레드가 아닌 다른 스레드가 (읽기든 쓰기든) 사용하면 예외를 던졌어요. 이 검사는 1.7에서 제거됐는데, core.async의 go 블록처럼 다른 방식으로 단일 스레드 제약을 강제하는 프레임워크에서 더 유연하게 쓰일 수 있도록 하기 위해서였죠.

요약

트랜지언트는 함수형 데이터 구조 빌딩 코드의 고성능 최적화를 제공하면서도, Clojure의 데이터 구조와 잘 맞물리고 중요한 안전성 보장까지 갖추고 있어요.

  • 단일 경로(single-path) 사용
  • 영속적 데이터 구조에서 O(1)로 생성
  • 영속적 출처와 구조 공유
  • 편집 세션이 끝나면 영속적 데이터 구조를 O(1)로 생성
  • 함수형 버전과 같은 코드 구조
    • 반환값을 받아 다음 호출에 사용
    • 제자리에서 때려 넣지 않기
    • 영속적이 아니므로 중간 값을 붙잡아 두거나 별칭을 만들 수 없음
  • 영속적 버전을 반환한 뒤에는 사용 불가
  • 빠름

트랜지언트 영속 벡터, 해시맵, 해시셋은 Clojure 1.1에서 추가되었어요.

더 알아보기