Clojure 디른타입(deftype, defrecord, reify): 추상화를 직접 빚어내기
Clojure 디른타입(deftype, defrecord, reify): 추상화를 직접 빚어내기
Clojure는 코드를 다룰 때 늘 "추상화"를 기준으로 생각해요. 시퀀스, 컬렉션, 호출 가능한 것(callability) 같은 추상화가 있고, 그 추상화를 실제로 구현해 둔 것들도 여럿 있죠. 그런데 잠깐, 추상화를 직접 만들려면 어떻게 해야 할까요? 공식문서는 이 질문에서 출발해서, 추상화를 정의하고 추상화의 구현을 만들어내는 강력하고 유연한 도구인 **디른타입(datatypes)**을 소개해요. 이름처럼 데이터의 모양(type)을 직접 빚어내는 기능이죠.
출처: Clojure 공식문서
본문
왜 디른타입이 필요한가요?
Clojure는 추상화 위에서 움직여요. 시퀀스, 컬렉션, 호출 가능성 같은 추상화와, 그 추상화를 채워주는 여러 구현들이 이미 마련되어 있죠. 여기서 추상화는 호스트 플랫폼의 인터페이스로, 구현은 호스트 플랫폼의 클래스로 정해져요.
이 구조는 언어를 부트스트래핑하는 데는 충분했지만, 막상 우리가 새로운 추상화나 저수준 구현을 만들려고 하면 마땅한 수단이 없었어요. 바로 이 빈자리를 채우는 것이 **프로토콜(protocol)**과 **디른타입(datatype)**이에요. 호스트 플랫폼이 주는 기능을 전혀 포기하지 않으면서, 추상화 정의와 데이터 구조 정의를 유연하게 할 수 있게 해 주죠.
기본 개념: deftype, defrecord, reify
디른타입 기능의 핵심은 세 가지 매크로예요.
- deftype — 추상화의 구현을 이름 있는 타입으로 정의
- defrecord — 추상화의 구현을 이름 있는 타입으로 정의하되, 맵처럼 동작
- reify — 추상화의 구현을 익명 타입으로 만들면서 인스턴스까지 생성
이들이 만들어내는 추상화 자체는 프로토콜이나 인터페이스로 정의돼요. 디른타입은 호스트 타입(deftype·defrecord는 이름이 있고, reify는 익명)에 구조를 담고(deftype·defrecord는 명시적 필드, reify는 암묵적 클로저), 추상화의 메서드를 타입 안에서 선택적으로 구현해요. 이를 통해 호스트 플랫폼이 자랑하는 최고 성능의 원시(primitive) 표현과 다형성(폴리모피즘) 메커니즘에, 비교적 깔끔한 방식으로 접근할 수 있어요.
한 가지 짚고 넘어갈 점은, 디른타입이 괄호로 감싼 단순한 호스트 문법이 아니라는 거예요. 호스트 시설의 일부 기능만 골라 지원하며, 때로는 호스트 자체보다 더 동적으로 움직여요. 의도는 분명해요. 인터롭(interop) 때문에 예외적으로 호스트 기능을 써야 하는 경우가 아니라면, Clojure를 떠나지 않고도 플랫폼에서 가장 빠른 데이터 구조를 만들 수 있게 하려는 것이죠.
deftype과 defrecord
deftype과 defrecord는 주어진 필드들과, 선택적으로 프로토콜·인터페이스의 메서드들로 구성된 이름 있는 클래스의 바이트코드를 동적으로 생성해요. 개발 과정에서 재평가도 가능하고, AOT 컴파일 없이도 인터랙티브하게 쓸 수 있죠.
이 둘은 데이터 구조를 만들되 이름 있는 필드를 가진다는 점에서 옛 defstruct와 비슷한데, 몇 가지가 달라요.
- 유일한 클래스를 만들며, 필드는 주어진 이름에 대응해요.
- 결과 클래스가 제대로 된 타입을 가져요. struct가 메타데이터로 타입을 표현하던 관례와 다르죠.
- 이름 있는 클래스니까 접근 가능한 생성자가 있어요.
- 필드에 타입 힌트를 달 수 있고, 원시 타입도 쓸 수 있어요.
- 여기서 주의할 점: non-primitive 타입의 힌트는 현재 필드 타입이나 생성자 인자를 제한하는 데 쓰이지 않고, 클래스 메서드 안에서의 사용을 최적화하는 데만 쓰여요. 필드 타입과 생성자 인자를 제한하는 것은 계획 중이에요.
- 하나 이상의 프로토콜·인터페이스를 구현할 수 있어요.
#my.thing[1 2 3]같은 특별한 리터러 문법으로 쓸 수 있는데, 이때는 조건이 있어요.- 벡터 안의 각 원소는 평가되지 않은 채 생성자로 전달돼요.
- 디른타입 이름은 **완전히 정규화(full qualified)**되어야 해요.
- Clojure 1.3 이후 버전에서만 지원해요.
deftype/defrecord로Foo를 정의하면, 인자를 그대로 생성자에 넘기는->Foo함수가 함께 생겨요. (1.3 이상에서만)
그럼 deftype과 defrecord는 뭐가 다를까요?
- deftype은 사용자가 지정한 것 외에는 아무 기능도 제공하지 않아요. 생성자 하나만 있을 뿐이죠.
- defrecord은 영속 맵(persistent map)의 완전한 구현을 함께 제공해요. 여기에는 다음이 포함돼요.
- 값 기반의
equals와hashCode - 메타데이터 지원
- 연관(associative) 지원
- 필드에 대한 키워드 접근자
- 확장 가능한 필드 (정의에 없는 키도
assoc가능) - 기타 등등
- 값 기반의
- deftype은 가변(mutable) 필드를 지원하지만, defrecord은 지원하지 않아요.
- defrecord는
#my.record{:a 1, :b 2}같은 맵을 받는 추가 리터러를 지원하는데, 다음 규칙을 따라요.- defrecord 이름은 완전히 정규화되어야 해요.
- 맵 안의 원소는 평가되지 않아요.
- 기존 defrecord 필드는 키에 해당하는 값으로 초기화돼요.
- 리터러 맵에 해당 키가 없는 defrecord 필드는
nil로 초기화돼요. - 추가 키-값은 허용되며 defrecord에 더해져요.
- Clojure 1.3 이후 버전에서만 지원해요.
defrecord로Bar를 정의하면, 맵을 받아 그 내용으로 새 레코드 인스턴스를 초기화하는map->Bar함수가 함께 생겨요. (1.3 이상에서만)
왜 deftype과 defrecord 둘 다 필요할까요?
대부분의 OO 프로그램에서 클래스는 두 종류로 나뉘어요. 하나는 구현·프로그래밍 영역의 산물, 즉 String이나 컬렉션 클래스, Clojure의 레퍼런스 타입 같은 것이고, 다른 하나는 애플리케이션 도메인 정보를 나타내는 클래스, 즉 Employee, PurchaseOrder 같은 것이죠.
여기서 문제가 생겨요. 도메인 정보를 클래스에 담으면 정보가 클래스마다 다른 비표준적인 미니언어 뒤에 숨겨져 버려요. 심지어 무해해 보이는 employee.getName()조차 사실은 데이터에 대한 독자적인 인터페이스예요. 모든 책이 각자 다른 언어로 쓰여 있다면 읽기가 어려워지듯이, 정보를 그런 클래스에 넣으면 정보를 일반적인 방식으로 처리할 수 없게 돼요. 쓸데없는 특수성이 늘어나고, 재사용은 줄어들죠.
그래서 Clojure는 예전부터 그런 정보를 **맵(map)**에 담으라고 권해 왔고, 디른타입이 등장해도 그 조언은 변하지 않아요. defrecord를 쓰면 일반적으로 다룰 수 있는 정보를 얻으면서, 타입 기반 다형성의 이점과 필드의 구조적 효율까지 함께 챙길 수 있어요. 반대로 vector 같은 컬렉션을 정의하는 디른타입이 기본 구현으로 map까지 들고 있을 필요는 없어요. 그런 프로그래밍 구조물을 정의하는 데는 deftype이 어울려요.
종합하면, 정보를 담는 모든 용도에서는 레코드가 structmap보다 낫기 때문에, 그런 structmap은 defrecord로 옮기는 게 좋아요. 프로그래밍 구조물에 structmap을 쓰던 코드는 드물었겠지만, 있다면 deftype이 훨씬 잘 맞을 거예요.
한편, AOT 컴파일된 deftype/defrecord는 gen-class의 일부 용도를 대체할 수 있어요. 제약이 문제가 되지 않는 경우라면, gen-class보다 성능이 더 좋죠.
디른타입과 프로토콜에는 입장이 있어요
디른타입과 프로토콜은 호스트 플랫폼의 구조와 잘 정의된 관계를 갖고, Clojure 기능을 Java 프로그램에 드러내는 훌륭한 방법이기도 해요. 하지만 이들은 주로 인터롭용 도구가 아니에요. 호스트의 OO 메커니즘을 완전히 흉내 내거나 적응시키려고 하지 않죠. 특히 다음과 같은 입장을 반영해요.
- 구체 클래스로부터의 파생은 나쁘다 — 디른타입은 인터페이스에서만 파생할 수 있고, 구체 클래스에서는 파생할 수 없어요.
- 항상 프로토콜이나 인터페이스에 맞춰 프로그래밍해야 한다 — 디른타입은 자기 프로토콜·인터페이스에 없는 메서드를 노출할 수 없어요.
- 불변성(immutability)이 기본이다 — 그리고 레코드에서는 유일한 선택지예요.
- 정보의 캡슐화는 어리석다 — 필드는 public이에요. 의존성을 피하려면 프로토콜·인터페이스를 써요.
- 다형성을 상속에 묶는 것은 나쁘다 — 프로토콜이 그 굴레에서 풀어줘요.
디른타입과 프로토콜을 쓰면 Java 소비자에게 깔끔하고 인터페이스 기반인 API를 내놓을 수 있어요. 반대로 깔끔한 인터페이스 기반 Java API를 만나면, 그것과 인터롭하고 확장하는 데 쓸 수 있죠. 하지만 "나쁜" Java API라면 gen-class를 써야 해요. 이렇게 해야만 Clojure 프로그램을 설계하고 구현하는 데 쓰는 프로그래밍 구조물들이 OO의 우발적 복잡성에서 자유로울 수 있어요.
reify: 익명 타입과 인스턴스를 한 번에
deftype과 defrecord가 이름 있는 타입을 정의한다면, reify는 익명 타입을 정의하면서 동시에 그 타입의 인스턴스를 하나 만들어요. 언제 쓰면 될까요? 한 번만 쓰고 말 일회성 구현이 필요하면서, 주변 로컬 컨텍스트를 활용하고 싶을 때예요. 용도로 보면 Java의 익명 내부 클래스나 proxy와 비슷해요.
reify의 메서드 본문은 어휘적 클로저라서 주변 로컬 스코프를 참조할 수 있어요. 그리고 reify는 proxy와 이런 점들이 달라요.
- 프로토콜이나 인터페이스만 지원하고, 구체 슈퍼클래스는 지원하지 않아요.
- 메서드 본문이 결과 클래스의 진짜 메서드지, 외부 함수가 아니에요.
- 인스턴스의 메서드 호출이 직접 이뤄져요. 맵 조회를 거치지 않죠.
- 메서드 맵을 동적으로 교체하는 기능은 지원하지 않아요.
그 결과, 생성과 호출 모두 proxy보다 성능이 더 좋아요. 제약이 문제가 되지 않는 모든 경우에 reify가 proxy보다 낫죠.
Java 애노테이션 지원
deftype, defrecord, 그리고 definterface로 만든 타입은 Java 인터롭을 위해 Java 애노테이션을 포함한 클래스를 만들 수 있어요. 애노테이션은 메타데이터로 붙여요.
- 타입 이름(deftype/record/interface) → 클래스 애노테이션
- 필드 이름(deftype/record) → 필드 애노테이션
- 메서드 이름(deftype/record) → 메서드 애노테이션
예시를 볼게요. 여기서 확인할 것은 애노테이션이 어디(타입·필드·메서드)에 어떻게 자리 잡는지예요.
(import [java.lang.annotation Retention RetentionPolicy Target ElementType]
[javax.xml.ws WebServiceRef WebServiceRefs])
(definterface Foo (foo []))
;; 타입 위의 애노테이션
(deftype ^{Deprecated true
Retention RetentionPolicy/RUNTIME
javax.annotation.processing.SupportedOptions ["foo" "bar" "baz"]
javax.xml.ws.soap.Addressing {:enabled false :required true}
WebServiceRefs [(WebServiceRef {:name "fred" :type String})
(WebServiceRef {:name "ethel" :mappedName "lucy"})]}
Bar [^int a
;; 필드 위의 애노테이션
^{:tag int
Deprecated true
Retention RetentionPolicy/RUNTIME
javax.annotation.processing.SupportedOptions ["foo" "bar" "baz"]
javax.xml.ws.soap.Addressing {:enabled false :required true}
WebServiceRefs [(WebServiceRef {:name "fred" :type String})
(WebServiceRef {:name "ethel" :mappedName "lucy"})]}
b]
;; 메서드 위의 애노테이션
Foo (^{Deprecated true
Retention RetentionPolicy/RUNTIME
javax.annotation.processing.SupportedOptions ["foo" "bar" "baz"]
javax.xml.ws.soap.Addressing {:enabled false :required true}
WebServiceRefs [(WebServiceRef {:name "fred" :type String})
(WebServiceRef {:name "ethel" :mappedName "lucy"})]}
foo [this] 42))
(seq (.getAnnotations Bar))
(seq (.getAnnotations (.getField Bar "b")))
(seq (.getAnnotations (.getMethod Bar "foo" nil)))
타입 위에서 ^{...} 메타데이터를, 필드 앞에서 ^{...} 메타데이터를, 메서드 앞에서 ^{...} 메타데이터를 각각 붙이는 구조예요. 마지막 줄들로 클래스·필드·메서드에서 애노테이션을 꺼내 확인할 수 있어요.