[해커 최후의 언어, 커먼 Lisp] ③ ORDB 인터페이스를 만들자

일반입력 :2008/09/12 10:23

최종원 (ITSEC 대표)

커먼 Lisp(Common Lisp, 이하 CL) 프로그래밍 마지막 호에서는 CL로 ORDB 인터페이스를 제작하는 과정을 살펴보기로 한다. 특히 CLOS와 MOP을 이용한 프로그래밍에 주의를 기울이기 바란다.CL에서 RDB를 사용하기 위해서는 Postgresql을 비롯하여 여러 가지 RDB를 지원하는 CLSQL 라이브러리를 사용하는 것이 한 방법이다. CL에서는 CLSQL에서 제공하는 FDML(Functional Data Manipulation Language)과 ODML(Object Oriented Data Manipulation Language) 등을 이용하여 RDB에 대한 각종 질의(query)를 수행할 수 있다. 또 FDDL(Functional Data Definition Language)와 OODDL(Object Oriented Data Definition Language) 등을 통해 CL에서 RDB에 테이블을 만드는 일 등을 할 수 있다.CLSQL을 이용한 프로그래밍CLSQL은 여러 DB에 대해 ODBC를 통한 연결뿐 아니라 네이티브(native) 라이브러리를 통한 연결을 지원한다. 필자 팀은 네이티브 라이브러리를 통해서 Postgresql과 연결하기로 했다. Postgresql과 CLSQL을 설치하고 데이터베이스를 생성한 후 SLIME 리스너에서 다음과 같은 함수호출로 DB에 연결할 수 있다.

(asdf:operate 'asdf:load-op 'clsql)(clsql::connect '("host" "db-name" "user" "password") :database-type :postgresql)
보통의 경우 CL에서 클래스를 정의할 때는 CLOS의 defclass 등을 사용하지만, CLSQL을 사용할 때는 def-view-class를 사용한다. CLOS의 defclass와 다른 점은 DB에 관련된 키워드들이 있다는 점이다. 다음과 같은 정의를 파일로 저장하고 CL로 로드하기 바란다.
;; ORDB 확장을 위한 새로운 네임 스페이스를 만든다.(defpackage "OBJECT-STORE"   ;; OBJECT-STORE 네임 스페이스에서 사용할 다른 네임 스페이스들   (:use "COMMON-LISP" "CL-USER" #+sbcl "SB-PCL" #+lispworks "CLOS"          "CLSQL-SYS" "CLSQL"))(in-package "OBJECT-STORE")(def-view-class persistent-object ()  ((poid :initarg :poid          :accessor poid          :db-kind :key           :column #:poid          :type integer)   (ptype :initarg :ptype    :db-kind :key    :column #:ptype    :type text    :accessor ptype)(inserted-object-info-list :initform ()                 :accessor inserted-object-info-list                 :db-kind :virtual)(deleted-object-info-list :initform ()                :accessor deleted-object-info-list                :db-kind :virtual))(:BASE-TABLE "PERSISTENT_OBJECT"))
이렇게 기존의 프로그래밍 언어에 새로운 문법을 추가하는 방법을 ‘문법의 추상화(syntactic abstraction)’라고 부르며, 이를 통해서 주어진 문제를 보다 적절히 해결해 나가는 스타일은 가장 흔한 리습 프로그래밍 방법이다. 이와 같이 클래스가 정의되고 나면 CLOS에서 객체를 만드는 방법과 동일하게 make-instance를 사용하여 CL 세계에 객체를 만들어낼 수 있다. 리스너에서 다음을 입력하고 엔터를 치면 된다.
(setq foo (make-instance ‘persistent-object :poid 1 :ptype "none"))
‘foo’에 바인드된 객체는 CL 세계에만 존재하고 DB에는 아직 존재하지 않는 객체이다. DB에 객체를 저장하려면 다시 리스너에서 다음을 입력한다.
(update-records-from-instance foo)
이제 CL을 종료하고 다시 시작한 후, persistent-object가 정의된 파일을 로드하고 DB로부터 객체를 불러내보자. 함수 select는 클래스의 이름을 인자로 필요로 하며, :flatp, :refresh 등의 키워드를 선택적으로 받을 수 있다.
(select 'persistent-object :flatp t)=> (#)(setq new-foo (car *))
KMP 시스템 DB 프로그래밍 계획일반적으로 DB를 사용하는 시스템들과는 달리 KMP 시스템은 DB를 객체들의 저장소로 이용한다. 물론, DB 본연의 목적인 질의에 사용하기도 하지만 주로 객체 저장소로 사용한다. DB를 이렇게 객체의 저장소로 사용하려면 프로그래밍 언어의 적절한 지원이 필요하다. CLSQL은 대부분 필요한 것들을 제공해 주지만 객체를 다루기 더 쉽게 하기 위해서는 CLSQL과 CL에 다음과 같은 확장을 추가해야 한다.
▲  객체 ID(object ID)를 인자로 받는 함수 find-persistent-object 호출로 객체를 DB로부터 불러낼 수 있어야 한다.▲  (select 'person)과 같은 질의로 person 클래스뿐 아니라 그 서브 클래스(이를테면 student, programmer 등)에 존재하는 모든 객체들을 클래스 정보가 틀리지 않게 돌려줄 수 있어야 한다.▲  CLSQL에서 지원이 취약한 다 대 다(many to many) 관계를 자동적으로 처리해 줄 수 있어야 한다.▲  객체의 변화뿐 아니라 소유하고 있거나 참조하고 있는 다른 객체의 변화도 계속 DB와 일치해야 한다.
한편 DB를 사용하는 시스템에서 흔히 볼 수 있는 불필요한 DB 액세스를 줄이기 위해 층을 두어 캐시를 할 필요가 있다. 특히 객체 위주의 시스템에서는 매번 DB에 접근해서 필요한 정보를 가져올 것이 아니라 객체로부터 정보를 얻어내는 것이 자연스럽다.CLSQL은 이미 질의문에 대한 캐시를 제공한다. 즉 동일한 질의에 대해 DB로부터 가져오라는 키워드(:refresh)가 없는 한 이미 캐시에 존재하는 정보가 재사용된다. 여기에 객체층을 하나 더 두어 객체 레벨에서 캐시를 하는데, 객체 레벨에서는 그 객체가 언제 DB로부터 다시 가져올 필요가 있는지 판단하기 쉽기 때문이다.객체들이 어떻게 사용되는가에 대해 추가적인 층들을 더 둘 수 있다. 예를 들면 KMP 시스템에서는 UI에서 여러 페이지에 걸쳐 내용을 보여줘야 할 경우에 대해 추가적으로 캐시를 한다. 화면 밑에 일련의 페이지 번호들을 보여주어야 하는 이런 화면들은 모든 사용자에 대해 동일하게 자주 보여주게 된다.이런 화면을 만들기 위해서는 매번 계산해야 하고, 그 결과 얻은 객체들은 한번 사용하고 나면 쓰레기가 되어서 가비지 컬렉터(Garbage Collector)에 의해 수집당할 운명에 처하는 것이 보통이다. 이것들에 대해 캐시를 할 수 있다면 매번 계산에 필요한 시간도 절약하고 빈번히 가비지 컬렉터가 동작하는 것도 막을 수 있다.이런 수법은 일반적으로 가비지 컬렉터가 있는 프로그래밍 언어에서 쓸 수 있다. 요약하면 다음과 같은 캐시 층들이 적절하게 유지되어 불필요한 DB 질의를 최대한 막는다.
▲ CL 세계에 있는 모든 객체는 DB로부터 다시 가져올 이유가 없는 한 계속 캐시에 유지하며, 언제 캐시를 버리고 DB로부터 객체를 다시 꺼내올지 결정한다.▲ 리스트 보기와 검색같이 밑에 페이지 일련번호가 필요한 UI 화면들에 대해 프로그래머가 쉽게 이해하고 사용할 수 있는 인터페이스를 갖는 캐시를 둔다.
ORDB 확장CLSQL에서 지원하지 않는 부분에 대해 확장하기로 하자. 앞에서 정의한 persistent-object 클래스가 DB에 저장될 수 있는 객체들의 슈퍼 클래스가 될 것이다. CLSQL에서 지원하지 않는 부분에 대한 확장하려면 어쩔 수 없이 CLSQL을 조금 수정해야 할 필요가 있다. 오픈소스 소프트웨어를 사용하다보면 이렇듯 자신의 상황에 맞게 수정할 때가 종종 있는데, 이런 경우 수정해야 할 부분을 최소로 유지하는 것이 중요하다.원래의 오픈소스 소프트웨어의 새 버전이 발표되면 기존에 사용하던 코드를 업데이트해야 하는데, 바뀐 부분이 많을수록 쉽지 않고 문제가 생길 가능성이 있기 때문이다. 물론 CVS와 같은 적절한 버전관리 툴을 사용함으로써 간소화할 수 있다.객체의 변신앞에서 정의한 persistent-object 클래스가 DB에 저장될 수 있는 객체들의 슈퍼 클래스가 된다. 클래스의 정의를 살펴보면, poid와 ptype 슬롯이 DB에 저장될 슬롯임을 알 수 있다. 모든 객체는 유일한 ID를 갖게 되는데, 그것이 poid 슬롯의 값이 되며 ptype은 객체의 클래스 이름이 된다.왜 객체마다 그 객체가 어떤 클래스에 속하는가 하는 ptype 정보를 갖고 있을까? 그것은 그 객체가 여러 클래스로부터 상속 받은 클래스일 수 있기 때문이다. 즉 ORDB 인터페이스를 통해서 ‘person 클래스에 속한 모든 객체들을 달라’는 질의를 했을 때, person으로 부터 상속받은 student나  programmer에 속하는 객체들은 본래 자신이 속한 클래스가 돼야지 DB 질의문에서 지정한 person 클래스가 되면 안된다.ptype을 바탕으로 객체를 필요에 따라 다른 객체로 바꿔주는 transmogrify 함수를 작성해 보자.
(locally-enable-sql-reader-syntax)(defun transmogrify (object)(let ((ptype (read-from-string (ptype object)))       (poid (poid object)))   (if (eq ptype (type-of object))        object        (car (select ptype :where [= [slot-value ptype 'poid]  poid] :flatp t :refresh t)))))(locally-disable-sql-reader-syntax)
‘locally-*’ 함수들은 CLSQL에서 ‘[’와 ‘]’를 필요로 하는 확장된 문법을 사용하기 위해 필요한 매크로 폼으로, 매크로 익스팬드하면 컴파일러 등에서 사용하는 평가기(evaluator)를 위한 명령이 들어있음을 알 수 있다(첫째 줄과 마지막 줄). 이미 정의했던 persistent-object 클래스의 슬롯정의를 살펴보면 ptype의 DB 타입은 Postgresql의 텍스트 타입으로, CL에서는 문자열로 맵핑되기 때문에 심볼로 변환해야 한다(셋째 줄).함수 transmogrify는 객체로부터 ptype과 poid를 알아낸 후, 실제 객체의 클래스와 ptype을 비교하여 같으면 객체를 리턴하고(여섯째 줄) 다르면 추가 정보를 갖고 DB로부터 다시 객체를 찾는다(일곱째 줄과 여덟째 줄).이제 매번 DB로 객체에 대한 질의를 할 때 앞에서 작성한 transmogrify를 호출할 적당한 곳을 CLSQL의 라이브러리에서 찾아서 끼워넣을 차례이다. 이를 위해서는 CLSQL의 소스코드를 읽어서 적당한 부분을 찾아내야 하는데, ‘oodml.lisp’ 파일의 ‘fault-join-target-slot’ 함수에서 그 부분을 찾아냈다. 바로 fault-join-target-slot 함수에서 find-all 부분에 transmogrify를 추가적으로 사용해야 한다.간단히 설명하면 find-all은 ptype 정보 없이 요청된 클래스를 만들어내기 때문에 ‘person’ 클래스로 질의하면 programmer나 student 객체가 아닌 person 객체만 리턴된다. find-all 위치에 다음과 같은 함수가 호출되면 원하는 결과를 얻을 수 있게 된다.
(defun find-all-with-specific-type (view-classes &rest args)  (flet ((find-object-using-cache-or-transmogrify (object)     (if (typep object 'persistent-object)          (let ((return-object (try-to-get-cached-persistent-object (poid object))))     (unless return-object        (setq return-object (transmogrify object))        (add-to-persistent-cache return-object))     return-object)          object)))     (tree-traverse (apply #'clsql-sys::find-all view-classes args)        #'find-object-using-cache-or-transmogrify)))
이 정의에서 find-all-with-specific-type은 본래 CLSQL에 있던 find-all의 인자들을 그대로 사용할 수 있게 했고 내부적으로 find-all을 호출한다. 함수 find-all 호출 결과는 트리 형태가 되기 때문에 tree-traverse를 이용하여 말단의 원소(element)에 대해서 find-object-using-cache-or-transmogrify를 호출한 후 그 결과들을 원래 트리 형태로 리턴한다.로컬 함수인 find-object-using-cache-or-transmogrify는 객체가 persistent-object 클래스 타입인 경우 먼저 캐시(try-to-get-cached-persistent-object)에서 객체 oid를 갖고 있는 것을 찾아 리턴하고, 그것이 실패했을 경우 객체에 대해 transmogrify를 적용하여 새로운 객체를 캐시에 넣고 그 객체를 리턴한다.유틸리티 함수인 tree-traverse는 tree를 탐색하면서 원하는 일을 수행하고 원래 트리와 동일한 구조에 그 결과들을 리턴하는 함수이다. 이런 종류의 함수는 학교에서 Scheme과 같은 프로그래밍 언어로 프로그래밍을 배울 때 한번 쯤 만들어보게 되는 함수이다. CL로는 재귀호출 가능한 로컬 함수를 정의하는 ‘labels’로 다음과 같이 작성했다.
(defun tree-traverse (tree traverser)   (labels ((traverse (tree)      (if (atom tree)           (funcall traverser tree)           (funcall #'cons (traverse (car tree))               (if (cdr tree)            (traverse (cdr tree)))))))            (traverse tree)))
객체의 캐시앞에서 이미 try-to-get-cached-persistent-object와 add-to-persistent-cache를 이용해서 객체층에서의 캐시를 사용했다. 객체 충에서의 캐시는 약한 해시 테이블(weak hash table)로 구현했다.
(defvar *persistent-objects-cache* (make-hash-table #+lispworks :weak-kind #+lispworks :value))
#+lispworks는 C의 #ifdef와 유사한 것으로, :lispworks가 전역변수 *features-list*에 있으면 다음에 나오는 폼을 평가하라는 지시이다. 약한 참조는 대부분의 상용 CL에서 지원하지만 오픈소스 CL은 그렇지 않은 경우가 많다. 결국 조건적으로 LispWorks인 경우에만 사용하도록 했다. KMP 시스템의 모든 객체들은 *persistent-objects-cache*에 존재하다가 가비지 컬렉터가 일어났을 때 어디에서도 객체를 참조하는 곳이 없는 경우에 메모리에서 사라지게 된다.예를 들어 답변이 하나도 없는 질문만 모아둔 *open-questions* 리스트가 있다고 하자. 새로운 질문이 들어오면 개체를 만들어서 캐시와 *open-questions*에 추가한다. 캐시에 새로 추가된 객체는 *open-questions*에서 참조하기 때문에 가비지 컬렉터가 처리할 대상에서 제외된다.시간이 흘러서 누군가가 질문에 답을 다는 순간 답이 달린 객체를 *open-questions*에서 빼내면 그 객체는 참조하는 곳이 없어지게 되며, 가비지 컬렉터가 수행되는 순간 그 객체는 참조하는 곳이 없어지게 되며, 가비지 컬렉터가 수행되는 순간 그 객체는 수집되어 캐시와 메모리에서 사라지게 된다. 캐시는 다음과 같은 인터페이스를 통해 접근할 수 있다.
(defun clear-persistent-objects-cache ()   "캐시에서 모든 객체를 없앤다"   (clrhash *persistent-objects-cache*))(defun add-to-persistent-cache (object)   "객체를 캐시에 추가한다"   (setf (gethash (poid object) *persistent-objects-cache*) object))(defun delete-from-persistent-cache (object)   "객체를 캐시에서 삭제한다"   (remhash (poid object) *persistent-objects-cache*))(defun try-to-get-cached-persistent-object (poid)   "캐시에서 poid를 갖는 객체를 찾는다"   (gethash poid *persistent-objects-cache*))
인터페이스를 통해 적절한 추상화를 유지함으로써 후에 해시 테이블이 아닌 다른 구현으로 바꾸더라도 다른 부분은 수정할 필요가 없도록 했다. 약한 해시 테이블을 이용해서 객체 캐시 층을 두는 간단한 테크닉은 쓸데없는 DB 질의를 줄일 수 있는 가장 쉬운 테크닉일 것이다.CLSQL의 함수 바꿔치기이제 CLSQL oodml.lisp 파일에 다음을 추가하고 find-all을 호출하는 부분을 모두 find-all-with-specific-type으로 바꾼다. 몇 군데(apply #'find-all ...)의 폼이 있는데, 이것들은 ‘(apply 'find-all-with-specific-type ...)’으로 바꿔서 런타임에 find-all-with-specific-type의 정의를 바꿀 수 있도록 하자. 함수를 칭할 때 “#'”와 “'”의 사용상 차이는 이미 다루었으므로, 이전 연재를 참고하기 바란다.
(defvar *find-all-with-specific-type-fn*         (lambda (&rest args)    (declare (ignore args))    (error "Unexpected find-all-with-specific-type")))(defun find-all-with-specific-type (view-classes &rest args)  (apply *find-all-with-specific-type-fn* view-classes args))
변수 *find-all-with-specific-type-fn*에 의미없는 람다 함수를 바인딩하고, find-all-with-specific-type에서는 그 함수를 호출하게 했다. 그 이유는 다음과 같다.
▲ CL의 네임스페이스인 패키지 관련 문제들의 발생을 최소화한다.▲ 후에 find-all-with-specific-type 함수를 다시 정의할 경우 프로그램 재실행 없이 함수만 평가하여 시스템을 원하는 대로 바꿀 수 있다.▲ 써드 파티 소프트웨어인 CLSQL에 대한 수정을 최소화하고 새 버전 업데이트시 버전관리상 충돌(conflict)이 일어나는 것을 최소화한다.▲ 의미 없는 함수를 이용하여, 컴파일러가 기대 이상으로 최적화해서 런타임에 함수를 바꿔치기 하는 것이 불가능해지지 않도록 한다.
마지막으로 *find-all-with-specific-type-fn* 함수를 바꿔치면 원하는 기능이 마무리된다.
(setf clsql-sys::*find-all-with-specific-type-fn* 'find-all-with-specific-type)
여기서 *find-all-with-specific-type-fn* 앞에 붙은 ‘clsql-sys::’는 CLSQL이 패키지 중 외부로 노출되지 않은 변수를 액세스하기 위해 붙인 패키지 정보이다.CL 패키지패키지는 CL의 네임스페이스로 모듈 사이의 이름 충돌을 막기 위해 쓰인다. CLSQL를 사용하려면 CLSQL 패키지를 사용한다고 미리 선언해야 한다. 다음은 사용자가 프로그램을 작성하기 위해 정의하는 패키지 예이다.
(defpackage "KMP"  (:use "COMMON-LISP" "CLSQL-SYS" "CLSQL")  (:export "UNKNOWN-USER-CONDITION"))
키워드 “:use” 다음에는 현재 패키지에서 사용할 다른 패키지들의 이름 목록이 오고, 키워드 “:export” 다음에는 현재 패키지에 정의된 것 중 외부에서 임포트(import)하여 사용할 수 있는 심볼 이름 목록이다. 임포트하는 방법 중 하나는, 앞의 예처럼 패키지를 정의하면서 패키지를 사용(use)하겠다고 선언하는 것으로 나열된 패키지들에서 문자열로 엑스포트(export)된 심볼들을 사용할 수 있다. 이때 심벌은 변수, 함수, 클래스, 구조체 등의 이름이 될 수 있다.CL을 처음 시작하면 대개의 경우 “CL-USER” 패키지에서 프로그래밍하게 된다. 지금까지 예들은 모두 CL-USER 패키지에 심볼이 만들어지면서 필요한 변수나 함수 등이 정의되었을 것이다. 리스너에서 ‘(use-package “CLSQL”)’을 평가하면 CLSQL에서 엑스포트된 것들을 사용할 수 있다. 애플리케이션을 개발할 경우 규모가 작은 것은 CL-USER 패키지만을 이용해서 개발해도 되겠지만, 대개의 경우 별도의 패키지를 정의해서 사용하는 것이 바람직하다.다 대 다 관계 지원다 대 다(many to many) 관계를 RDB에서 표현하려면 세 개의 테이블이 필요하다. 예를 들어, 자식과 부모의 관계를 보면 어떤 자식은 어머니와 아버지 두 사람에 대해 관계를 갖고 있으며, 어떤 아버지나 어머니는 하나 이상의 자식들에 대해 관계를 갖고 있다.결국 이런 관계를 표현하자면 부모 테이블, 자식 테이블, 부모 자식간의 관계를 알려주는 참조 테이블이 있어야 자연스럽게 표현할 수 있다. 반면 일 대 다 혹은 다 대 일의 관계는 두개의 테이블로 표현이 가능하며, CLSQL에서 자연스럽게 지원해 준다. 슬롯의 정의만 따로 떼어 살펴보자.
(content :reader content :db-kind :join           :db-info (:join-class content               :home-key content-id               :foreign-key poid))
‘content’라는 슬롯은 DB에서는 조인이고(:db-kind :join), 조인할 때 사용할 키는 현재 클래스에 정의된 content-id(:home-key content-id)이고, 조인 클래스 contetnt(:join-class contetnt)이며, 조인할 때 사용할 content 클래스에 정의된 외부 키는 poid(:foreign-key poid)임을 나타낸다.이렇게 되면 앞의 슬롯 정의를 갖는 클래스의 객체에서는 슬롯 content가 액세스되는 순간 ‘select poid from content where content.poid = content-id’와 개념적으로 동일한 질의가 DB로 보내진다(CLSQL에서 별도의 정의가 없는 한 DB의 테이블 이름은 클래스 이름과 같게 설정된다). 다 대 다 관계를 표현하기 위해 슬롯의 정의를 다음과 같이 확장해보자.
(parent :accessor parent         :db-kind :join         :db-info (:join-class parent-child ; DB에는 parent_child 테이블             :peer-class child             :home-key   parent-id   ; DB에는 parent_id             :foreign-key child-id   ; DB에는 child_id         ))
앞의 일 대 다 관계와 다른 점을 보면 첫째, 조인 클래스(:join-class)가 CL 클래스 없이 DB에 존재해야 하는 테이블 이름으로 쓰인다는 점 둘째, 궁극적으로 조인될 클래스는 :peer-class에 있는 child 클래스라는 점 셋째, 홈키(:home-key)와 외부 키(:foreign-key)는 모두 중간에 존재하는 참조 테이블 parent-child에 있는 컬럼 이름이라는 점이다.확장된 슬롯의 정의는 클래스가 정의되는 순간 시스템에 존재하는 클래스 초기화 루틴과 충돌을 일으키지 않게 저장했다가 나중에 필요한 경우 사용할 수 있어야 한다. CLOS의 MOP(MetaObject Protocol)을 사용하면 CLSQL의 다른 부분을 수정하지 않고 다음과 같이 할 수 있다.
(in-package :clsql-sys)(defmethod compute-effective-slot-definition :around ((class standard-db-class)                           #+kmr-normal-cesd                           slot-name                           direct-slots)   #+kmr-normal-cesd (declare (ignore slot-name))   (let ((dsd (find slot-name direct-slots :key #'slot-definition-name))           many-to-many-peer-class db-info)     (when (slot-boundp dsd 'db-info)       (setq db-info (view-class-slot-db-info dsd))       (unless (null db-info)         (let ((peer-class (getf db-info :peer-class)))    (when peer-class       (setf many-to-many-peer-class peer-class)       (remf (view-class-slot-db-info dsd) :peer-class)))))     (let ((esd (call-next-method)))       (when many-to-many-peer-class         (setf (getf (view-class-slot-db-info dsd) :peer-class) many-to-many-peer-class)         (setf (gethash :peer-class (view-class-slot-db-info esd)) many-to-many-peer-class))       esd)))
#+kmr-normal-cesd 부분은 무시해도 좋다. CLOS는 CL의 OO 확장인데, MOP을 지원한다. 대개의 경우 OO 언어에서 클래스가 정의되면 그 내부 정의는 프로그래머로부터 숨겨진 채로 초기화된다. CLOS는 MOP을 통해 그런 과정을 열어두었기 때문에 프로그래머가 원한다면 언제든 자신의 목적에 맞게 하위레벨 객체 생성 과정에 대해 원하는 일들을 할 수 있다. 앞에서 정의한 메쏘드는 슬롯 정의에서 :peer-class에 대한 정의가 있는지 확인하여, 있으면 잠시 그 정의 부분을 삭제한 후 원래 클래스 초기화 과정을 진행시킨다.함수 호출 call-next-method가 바로 본래의 초기화를 계속 진행하게 하는 부분으로 그 결과는 변수 ‘esd’에 잠시 보관한 후, ‘(when many-to-many ...’ 폼에서 강제로 :peer-class에 대한 정보를 CLSQL 몰래 저장해둔다. 마지막 리턴은 보관해 둔 esd를 리턴한다. 키워드 :around가 메쏘드 이름 바로 다음에 나오는데, CLOS에서 메쏘드의 동작을 제어하는 ‘메쏘드 조합(method combination)’을 표시하는 것으로, 키워드 :before, :after와 함께 ‘표준 메쏘드 조합(standard method combination)’ 메쏘드를 정의할 때 사용된다.그 외에도 여러 메쏘드 조합이 가능하며, MOP를 이용해 자신만의 메쏘드 조합을 정의할 수도 있다. 이런 메쏘드 조합은 실행되는 메쏘드의 순서나 그 호출 결과를 프로그래머 구미에 맞게 바꿀 수 있게 해준다. 메쏘드 조합은 한번쯤 들어봄직한 Aspect Oriented Programming의 슈퍼 셋이라고 할 수 있다(실제로 AOP의 Gregor Kiczales가 MOP의 주설계자이므로 AOP와 MOP가 전혀 무관한 것은 아니다).CLOS에서 메쏘드는 클래스가 아닌 동일한 이름의 “제네릭 펑션(generic function)”에 보관된다. 즉 메쏘드를 정의하게 되면 제네릭 펑션이 있으면 그 제네릭 펑션에 메쏘드를 추가하고 아니면 제네릭 펑션을 만든 후 메쏘드를 그곳에 추가한다. 프로그래밍 중에는 거의 99%가 표준 메쏘드 조합을 사용하는데 메쏘드의 실행은 다음과 같은 순서로 진행된다.
▲ 시작 단계 - 호출된 메쏘드 이름의 제네릭 펑션에서 인자로 전달된 객체들의 클래스를 바탕으로 모든 적용 가능한 메쏘드들을 뽑는다. 메쏘드들은 다음에서 사용된다.▲ 어라운드(around) 단계 - 키워드 :around로 정의된 메쏘드가 있는 경우 인자로 전달된 객체의 클래스 정보에 기반해서 가장 특정한(most specific) 어라운드 메쏘드를 실행한다. 내부에서 call-next-method를 호출하면 계속 다음으로 특정한 어라운드 메쏘드를 실행한다. 내부에서 call-next-method를 호출하지 않으면 5번 마지막 단계로 간다. 마지막으로 리턴 값은 호출한 함수로 리턴하고 다음 단계로 이동한다.▲ 비포(before) 단계 - 키워드 :before로 정의된 메쏘드가 있는 경우 가장 특정한 비포 메쏘드를 호출한다. 더 이상 비포 메쏘드가 없을 때까지 다음으로 특정한 비포 메쏘드를 호출한 후 다음 단계로 이동한다.▲ 프라이머리(primary) 단계 - 아무 키워드가 붙지 않은 ‘주된’ 메쏘드로, 가장 특정한 프라이머리 메쏘드가 호출된다. 내부에서 call-next-method를 호출하면 계속 다음으로 특정한 메쏘드를 호출한다. 마지막으로 리턴 값을 호출한 함수로 리턴한 후, 다음 단계로 이동한다.▲ 애프터(after) 단계 - 키워드 :after로 정의된 메쏘드가 있는 경우 가장 덜 특정한(least specific) 애프터 메쏘드를 호출한다. 더 이상 애프터 메쏘드가 없을 때까지 다음으로 덜 특정한 애프터 메쏘드를 호출한 후 다음 단계로 이동한다.▲ 마지막 단계 - 호출한 함수로 리턴값을 넘기고 마친다.
이 순서를 기억한 후 다시 한번 compute-effective-slot-definition의 정의를 살펴보자. 다음은 필자의 LispWorks 리스너에서 얻은 내용이다.
CL-USER> (use-package #+lispworks "CLOS" #+sbcl "SB-PCL")TCL-USER> #'compute-effective-slot-definition#CL-USER> (generic-function-methods #'compute-effective-slot-definition)(# # #)CL-USER> (pprint *)(# # #)
첫 번째 폼으로 CLOS 패키지를 사용하게 되었고, 두 번째 폼에서 compute-effective-slot-definition이 제네릭 펑션임을 확인했으며, 세 번째 폼에서 그 제네릭 펑션의 메쏘드들을 알아냈고, 마지막으로 그 결과를 예쁘게 출력하였다(pprint - pretty print). 현재 세 개의 메쏘드가 제네릭 펑션에 존재하는데, 인자들을 눈여겨 봐야 한다. 마지막 것이 시스템에 기본적으로 존재했던 프라이머리 메쏘드이고, 중간에 있는 메쏘드가 CLSQL에서 정의한 프라이머리 메쏘드이다.CLSQL에서 정의한 메쏘드를 정의를 들여다보지 않더라도 그 내부에서 call-next-method로 시스템에 기본적으로 존재했던 메쏘드를 호출한다는 것을 짐작할 수 있다. 맨 처음에 있는 메쏘드가 바로 앞에서 정의했던 메쏘드로 두 번째 메쏘드와 인자들은 동일하지만 앞에 :around가 붙어있기 때문에 실제 호출 순서는 가장 먼저이다.클래스 정의이제 앞에서 정의한 것들이 효력을 발휘하도록 하기 위해 간단한 클래스를 작성해 보기로 하자.
(def-view-class company (persistent-object)  ((name :column "name"          :initarg :name          :db-kind :key          :accessor name          :type    text)   (registered-id :column "registered_id"             :db-kind :base             :initarg :registered-id             :accessor registered-id             :type    (varchar 10)             :db-constraints "unique not null"))  (:base-table "company"))
클래스가 정의되는 순간 앞에서 정의했던 compute-effective-slot-definition가 호출되어 다 대 다 관계에 필요한 정보를 기억해둔다.다 대 다 관계의 사용클래스에 정의된 슬롯에 대한 정보를 갖고 있는 그 무엇을 ‘슬롯 정의 메타오브젝트(slot definition metaobject)’라고 부른다. 우리는 앞에서 그 슬롯 정의 메타오브젝트에 정보를 저장해 놓은 것이다. 이제 객체에서 그 정보를 사용할 일만 남았다. 역시 MOP 프로그래밍을 해야 하는데, 객체의 슬롯을 액세스하기 위해 정해진 프로토콜을 사용하면 된다. 메쏘드 slot-value-using-class가 바로 그것이다.이 메쏘드는 객체의 슬롯을 액세스하는 모든 메쏘드들이 최하위 층에서 궁극적으로 호출하게 되는 메쏘드이다. 보통의 다른 프로그래밍 언어들에서는 오픈되어 있지 않기 때문에 프로그래머로서는 액세스할 수 없는 층에 존재하는 코드이다. 패키지 관련해서 문제를 일으키지 않기 위해 잠시 CLSQL-SYS 패키지로 이동하여 정의하자.
(in-package "CLSQL-SYS")(locally-disable-sql-reader-syntax)(defmethod slot-value-using-class :before ((class standard-db-class)                    (instance kmp::persistent-object)                    slot-def)  (declare (optimize (speed 3)))  (let* ((slot-name (%svuc-slot-name slot-def))          (slot-def (kmp::kmp-find-slot-definition slot-name class)))    (unless (or (eq :virtual (clsql-sys::view-class-slot-db-kind slot-def))          (slot-boundp instance slot-name))      (unless (or *db-deserializing*            (null (view-database instance))            (not (gethash :peer-class (clsql-sys::view-class-slot-db-info slot-def))))    (setf (slot-value instance slot-name)           (kmp::build-many-to-many-join-objects (class-name class) slot-name instance))))))(defun %svuc-slot-name (slot)  #+lispworks slot  #-lispworks (slot-definition-name slot))
이미 존재하고 있는 메쏘드들을 수정하지 않고 비포 메쏘드로 추가했다. 인자 리스트에서 ‘kmp::persistent-object’를 사용한 것과 좀처럼 사용하지 않는 ‘declare’를 사용한 것이 보인다. CL은 다이나믹 프로그래밍 언어기 때문에 형(type)지정 없이 프로그래밍하지만 필요할 때는 declare를 이용해서 type을 지정하거나 앞에서처럼 컴파일러에게 어떤 것이 중요한지 알려주면서 최적화할 수 있다. 앞의 정의에서는 다른 무엇보다 메쏘드의 수행 속도를 빨리한다고 했다.정의 내부를 살펴보면 다른 것들은 모두 부수적인 조건들이고 :peer-class, 즉 다 대 다 관계에 대한 정보가 슬롯 정의 메타오브젝트에서 발견되면 kmp::build-many-to-many-join-objects를 호출해서 슬롯값을 계산해서 셋팅한다. 실제 질의는 build-many-to-many-join-objects에서 일어나며 필요한 정보(테이블 이름, 조인할 컬럼 이름들)를 모두 알기 때문에 그리 어려운 함수가 아니지만, 이미 머리 속이 복잡한 독자를 더욱 혼란스럽게 할 가능성이 많기 때문에 이 함수와 다른 부수적인 함수의 설명은 생략하기로 한다.객체의 저장객체를 저장할 때는 store-object 메쏘드를 사용한다.
(in-package "KMP")(defmethod store-object ((persistent-object PERSISTENT-OBJECT))  (unless (slot-boundp persistent-object 'poid)    (let ((poid (get-kmp-unique-poid)))       (setf (poid persistent-object) poid)))    (update-records-from-instance persistent-object)))(defun get-kmp-unique-poid ()  (clsql-sys::sequence-next "persistent_sequence"))
이때 주의해야 할 점은 객체가 처음 DB에 저장될 때이다. 이때는 poid가 없으므로 저장 직전에 DB의 시퀀스로부터 유일한 숫자를 받아와서 poid 슬롯에 셋팅한 후 CLSQL의 update-records-from-instance를 호출해야 한다. 슬롯이 다대다 관계의 조인 슬롯이고 새로운 객체가 추가되거나 삭제된 경우가 있을 수 있는데, 그럴 때에는 참조 테이블에서 해당하는 데이터를 업데이트하거나 삭제해야 한다.이런 일들은 store-object의 프라이머리가 하는 일과는 개념적으로 크게 동떨어진 일이므로 애프터 메쏘드로 다음과 같이 정의할 수 있다.
(defmethod store-object :after ((owner PERSISTENT-OBJECT))  (let ((owner-poid (poid owner))         (owner-class (class-of owner)))    (let ((inserted-object-info-list (inserted-object-info-list owner))           (deleted-object-info-list (deleted-object-info-list owner)))       (when (or inserted-object-info-list deleted-object-info-list)         (update-many-to-many-records     owner-poid     (build-args-for-many-to-many owner-class inserted-object-info-list)     (build-args-for-many-to-many owner-class deleted-object-info-list))))     (add-to-persistent-cache owner)))
코드의 내용은 persistent-object 클래스에서 DB에는 존재하지 않고 CL에서만 필요한 가상슬롯(virtual-slot)인 inserted-object-info-list와 deleted-object-info-list가 객체를 갖고 있는 경우 DB에 반영하는 것이다. 실제 SQL 질의는 수행 시간을 고려하여 Postgresql의 스토어드 프로시져(stored procedure)로 작성되었다.한번의 DB 히트로 여러 개의 레코드를 업데이트하거나 삭제하는 것으로, Postgresql 스토어드 프로시져 프로그래밍을 하는 사람이라면 흥미 있을 만한 코드일 것이다. format과 스토어드 프로시져 등에서 보여주는 구체적인 구현에 현혹되어 포기하지 말고 함수의 이름 위주로 그 뜻을 음미해 보는 것이 좋을 것이다.
(defun update-many-to-many-records (owner-poid insert-list delete-list)  (let ((insert-tables (first insert-list))         (insert-columns (second insert-list))         (insert-values (third insert-list))         (delete-tables (first delete-list))         (delete-columns (second delete-list))         (delete-values (third delete-list)))    (run-stored-procedure (format nil ; Format hackers only ;-)                 "update_many_to_many(~D,~                 '{~{"~A"~^,~}}'::text[],~                 '{~:{{"~A","~A"}~:^,~}}'::text[][],~                 '{~{{~{~D~^,~}}~^,~}}'::integer[][],~                 '{~{"~A"~^,~}}'::text[],~                 '{~:{{"~A","~A"}~:^,~}}'::text[][],~                 '{~{{~{~D~^,~}}~^,~}}'::integer[][])"                 owner-poid                 insert-tables                 insert-columns                 insert-values                 delete-tables                 delete-columns                 delete-values))))CREATE OR REPLACE FUNCTION update_many_to_many(INT,    TEXT[], TEXT[][], INT[][], TEXT[], TEXT[][], INT[][]) RETURNS INTAS '    DECLARE        owner_poid ALIAS FOR $1;        insert_tables ALIAS FOR $2;        insert_columns ALIAS FOR $3;        insert_values ALIAS FOR $4;        delete_tables ALIAS FOR $5;        delete_columns ALIAS FOR $6;        delete_values ALIAS FOR $7;        array_start INTEGER;        array_end   INTEGER;        inner_array_start INTEGER;        inner_array_end INTEGER;    BEGIN        array_start := array_lower(insert_values, 1);        array_end := array_upper(insert_values, 1);        IF array_start IS NOT NULL THENFOR i IN array_start..array_end LOOP        inner_array_start := array_lower(insert_values, 2);        inner_array_end := array_upper(insert_values, 2);         FOR j IN inner_array_start..inner_array_end LOOP         EXECUTE ''insert into ''