본문 바로가기

Architecture for Software/Lisp

새로운 웹 프로그래밍의 세계: Hunchentoot로 웹 사이트 제작하기

 Lisp을 좋아하는 사람들의 그룹(http://groups.google.com/group/lisp-korea)에서 Common Lisp을 하면서 Common Lisp을 이용한 라이브러리들에 대하여 스터디하고 있습니다. 현재까지 C프로그램과의 링킹부터 Socket 프로그래밍 및 Database 프로그래밍까지 진행하였습니다.
마지막으로 제가 웹 프로그래밍에 대한 내용을 발표하기로 하였습니다. ^^~

사실 이번에 웹 프로그래밍 발표를 위하여 여러가지 자료를 찾으면서 역시 Common Lisp은 긴 역사만큼 강력하다는 것을 느꼈습니다.
실제 활용을 하면서 Common Lisp을 만지다보니 그동안 잘 이해되지 않았던 부분도 자연스럽게 많이 이해되었습니다. ^^

Hunchentoot도 정말 쓸만하구나 하는 생각이 들었습니다.
참고로 http://www.adampetersen.se/articles/lispweb.htm 의 내용을 바탕으로 발표 자료를 준비하였습니다.
아울러 Ubuntu 9.10 + SBCL + Slime(2009-06-15) + Emace 22.2.1 환경 하에서 작성하였습니다.

사실 처음에 Windows 7 + Cygwin + SBCL + Slime으로 하다가 ASDF 관련한 GPG 에러가 있어서 Ubuntu로 갈아탔습니다. 이거 때문에 고생점 했었습니다.



1. Hunchentoot에 대하여 


Hunchentoot는 Common Lisp으로 작성된 웹 서버입이며, 다이나믹한 웹사이트를 구성할 수 있도록 도와주는 툴킷입니다. 단독실행(Stand-alone)될 수 있으며, HTTP/1.1 스펙을 지원하고, DBMS와 연결 및 SSL 등을 지원합니다. 아울러 Hunchentoot는 Session을 처리할 수 있으며, Cookie 기반의 Session 처리와 Cookie를 사용하지 않는 Session 처리도 가능합니다. 또한 Logging을 하거나 사용자 정의 에러 처리를 할 수 있습니다.


1.1 Hunchentoot 설치

Hunchentoot는 당근 ASDF를 기반으로 쉽게 설치가 가능합니다. ASDF에 대한 자세한 사항은 나중에 한번 정리해서 올리겠습니다.
설치 절차는 다음과 같습니다.

CL-USER> (require 'asdf)
NIL
CL-USER> (require 'asdf-install)
("ASDF-INSTALL")
CL-USER> (asdf-install:install :hunchentoot)
Install where?
1) System-wide install:
   System in /usr/lib/sbcl/site-systems/
   Files in /usr/lib/sbcl/site/
2) Personal installation:
   System in /root/.sbcl/systems/
   Files in /root/.sbcl/site/
 --> 1
Downloading 140293 bytes from http://weitz.de/files/hunchentoot.tar.gz ...
Installing /root/HUNCHENTOOT.asdf-install-tmp in /usr/lib/sbcl/site/,/usr/lib/sbcl/site-systems/
hunchentoot-1.1.0/
hunchentoot-1.1.0/acceptor.lisp
hunchentoot-1.1.0/CHANGELOG
hunchentoot-1.1.0/CHANGELOG_TBNL
hunchentoot-1.1.0/compat.lisp
hunchentoot-1.1.0/conditions.lisp
hunchentoot-1.1.0/cookie.lisp
.....

참고로 저의 경우 /usr/share/common-lisp/ 에 Common Lisp 관련된 자료를 몰아넣는 것을 좋아합니다. Slime도 여기에 설치했습니다.  아울러 ASDF-INSTALL 시 System-wide install을 하여 다른 계정으로도 쉽게 접근할 수 있도록 구성하는 것을 좋아합니다. Hunchentoot를 설치하다보면 계속 GPG 관련한 확인을 해주어야 합니다. 간단하게 ASDF-INSTALL 전에 다음과 같이 설정하시면 GPG 관련 항목을 더 이상 체크하지 않습니다.

CL-USER> (setq asdf-install::*verify-gpg-signatures* nil)


1.2 Hunchentoot 실행

ASDF를 이용하여 Hunchentoot가 정상적으로 설치했다면 다음과 같이 실행하면 됩니다. 8080포트를 사용합니다.

KLISPER> (hunchentoot:start (make-instance 'hunchentoot:acceptor :port 8080))
#<ACCEPTOR (host *, port 8080)>
간단하게 실행됩니다.

1.3 Hunchentoot 종료
간단하게 종료됩니다. 사실 종료는 아직~ 해보지 않았습니다. :-)

KLISPER> (hunchentoot:stop *hunchentoot-server*)



 2. CL-WHO에 대하여

사실 처음 CL-WHO를 들었을때 WHO(국제보건기구)에서 만들었나~ 하는 생각을 하였습니다. ㅎㅎㅎ CL은 당은 Common Lisp의 약자구요~ WHO는 With-Html-Output의 약자라는군요~ 참 했갈리게 하면서도 확실히 기억되는 이름입니다.

CL-WHO는 Lisp의 마크업 언어(Markup Language)입니다. Lisp의 특징인 S-expression을 기반으로 HTML, XHTML, XML 등의 마크업을 작성할 수 있도록 도와주는 넘입니다. 참고로 이와 비슷한 넘이 HTML-TEMPLATE란 넘이 있습니다.

아래의 Lisp 코드에서 (:XXX) 가 바로 CL-WHO 의 예 입니다.

KLISPER> (defmacro standard-page ((&key title) &body body)
  `(with-html-output-to-string (*standard-output* nil :prologue t :indent t)
     (:html :xmlns "http://www.w3.org/1999/xhtml"  :xml\:lang "en" :lang "en"
       (:head
         (:meta :http-equiv "Content-Type" :content "text/html;charset=utf-8")
     (:title ,title)
     (:link :type "text/css" :rel "stylesheet" :href "/retro.css"))
       (:body
         (:div :id "header" ; Start all pages with our header.
           (:img :src "/logo.jpg" :alt "Commodore 64" :class "logo")
           (:span :class "strapline" "Vote on your favourite Retro Game"))
         ,@body))))

2.1 CL-WHO 설치

당근 ASDF 를 이용하여 다음과 같이 설치합니다.

CL-USER> (asdf-install:install :cl-who)
Install where?
1) System-wide install:
   System in /usr/lib/sbcl/site-systems/
   Files in /usr/lib/sbcl/site/
2) Personal installation:
   System in /root/.sbcl/systems/
   Files in /root/.sbcl/site/
 --> 1
Downloading 19817 bytes from http://weitz.de/files/cl-who.tar.gz ...
Installing /root/CL-WHO.asdf-install-tmp in /usr/lib/sbcl/site/,/usr/lib/sbcl/site-systems/
cl-who-0.11.1/
cl-who-0.11.1/CHANGELOG
cl-who-0.11.1/cl-who.asd
.....




3. Parenscript 에 대하여

이름과 같이 Common Lisp 하에서 JavaScript를 CL-WHO와 같이 S-expression으로 표현할 수 있도록 도와주는 넘이 Parenscript 입니다. 이번 예제에서는 많이 쓰지 않았습니다. 하지만 재미 있는 기능이 많은 것 같습니다. :-)

이렇게 사용된다고 하네요~

(lambda (x)
  (case x
    (1 (loop repeat 3 do (alert "foo")))
    (:bar (alert "bar"))
    (otherwise 4)))

상기 Lisp 코드가 변환되면 이런 JavaScript 코드가 나온다고 합니다.

    function (x) {
        switch (x) {
        case 1:
            for (var _js1 = 0; _js1 < 3; _js1 += 1) {
                alert('foo');
            };
            return null;
        case 'bar':
            return alert('bar');
        default:
            return 4;
        };
    };




4. K-Lisper 들을 위한 Lisp 책 투표 사이트 구축하기

자 이제 Common Lisp에서 웹 프로그래밍을 하기 위한 환경들을 살펴보았으니 본격적으로 웹 프로그래밍을 시작해보겠습니다.
예제로 만들어볼 웹 사이트를 간단하게 설명드리면, K-Lisper들이 자신이 선호하는 책에 투표를 하면 투표 결과가 바로 나오며, 만약 선호하는 책이 없으면 등록할 수 있는 웹 사이트입니다.

구조는 간단하지만, 간단한 투표 시스템을 만들려고 해도 사실 어려운 것이 사실입니다. 하지만 Common Lisp을 사용하였을 때 얼마나 효과적으로 웹 프로그래밍이 가능한지 한번 확인해보시기 바랍니다. :-)


4.1 패키지 설정

K-Lisper 책 투표 사이트를 위한 패키지를 새로 만듭니다.

CL-USER> (defpackage :klisper (:use :cl :cl-who :hunchentoot :parenscript))
#<PACKAGE "KLISPER">

자~ 만든 패키지로 이동합시다.

CL-USER> (in-package :klisper)
#<PACKAGE "KLISPER">


4.2 책(Book) 클래스 정의 및 로직 구성

Lisp에도 클래스가 있어라고 이야기하시는 분이 있다면, 김영태님이 발표하신 CLOS에 관한 내용을 확인해보시기 바랍니다. 제 블로그에 Lisp의 객체지향은 그 무엇보다 강력했다. 란 제목으로도 올려놨습니다.

우선 책 투표 사이트이므로 책(Book) 클래스를 만들겠습니다.

KLISPER> (defclass book()
       ((name :initarg :name)
        (votes :initform 0)
        )
       )
#<STANDARD-CLASS BOOK>

상당히 간단한 책 클래스입니다. 필수적으로 입력받아야 하는 책 명(name)과 얼마나 투표했는지 나타내는 votes가 존재합니다.

그럼 새로운 책을 하나 만들어 볼까요~ :-)
예제 삼아  Lisp을 좋아하는 사람들의 그룹(http://groups.google.com/group/lisp-korea)에서 처음 공부하였던 책 제목을 넣어 봤습니다.

KLISPER> (setf klispers-books (make-instance 'book :name "Common Lisp: A Gentle Introduction to Symbolic Computation"))
; in: LAMBDA NIL
;     (SETF KLISPER::KLISPERS-BOOKS
;             (MAKE-INSTANCE 'KLISPER::BOOK :NAME
;                            "Common Lisp: A Gentle Introduction to Symbolic Computation"))
; ==>
;   (SETQ KLISPER::KLISPERS-BOOKS
;           (MAKE-INSTANCE 'KLISPER::BOOK :NAME
;                          "Common Lisp: A Gentle Introduction to Symbolic Computation"))
;
; caught WARNING:
;   undefined variable: KLISPERS-BOOKS
;
; compilation unit finished
;   Undefined variable:
;     KLISPERS-BOOKS
;   caught 1 WARNING condition
#<BOOK {D321AE9}>

생성된 Common Lisp 책 객체가 정상적으로 동작하는지 확인하기 위하여 책 명을 확인해보겠습니다.

KLISPER> (name klispers-books)
"Common Lisp: A Gentle Introduction to Symbolic Computation"

잘 들어가 있군요~ :-) 투표 건수도 확인해보겠습니다.

KLISPER> (votes klispers-books)
0

네~ 아직 투표는 하지 않았으니 0건이 맞습니다. 한번 투표해보겠습니다.

KLISPER> (incf (votes klispers-books))

;     (LET* ((#:TMP1390 KLISPER::KLISPERS-BOOKS)
;            (#:G1391 1)
;            (#:NEW1389 (+ (KLISPER::VOTES #:TMP1390) #:G1391)))
;       (FUNCALL #'(SETF KLISPER::VOTES) #:NEW1389 #:TMP1390))
;
; caught WARNING:
;   undefined variable: KLISPERS-BOOKS
;
; compilation unit finished
;   Undefined variable:
;     KLISPERS-BOOKS
;   caught 1 WARNING condition
1
KLISPER> (votes klispers-books)
1

incf 는 1씩 증가시켜주는 function 입니다. 제 Emacs는 WARNING되는 내용을 확인할 수 있도록 설정되어 있어 약간씩 내용이 틀릴 수 있습니다. 참고하세요~

책(Book) 클래스를 더 확장하겠습니다.

KLISPER> (defclass book()
       ((name :reader name
          :initarg :name)
        (votes :accessor votes
           :initform 0)
        )
       )
#<STANDARD-CLASS BOOK>

사용자가 선택한 책을 찾아서 투표해주는 메소드를 만들겠습니다.

KLISPER> (defmethod vote-for (user-selected-book)
       (incf (votes user-selected-book))
       )
STYLE-WARNING: Implicitly creating new generic function VOTE-FOR.
#<STANDARD-METHOD VOTE-FOR (T) {C847BF1}>

자~ 새로운 메소드를 통하여 한번 투표를 해보겠습니다.

KLISPER> (votes klispers-books) ; 현재 1건 투표됨
1
KLISPER> (vote-for klispers-books) ; 투표함
2
KLISPER> (votes klispers-books) ; 2건 투표됨.
2

책들을 담을 전역 변수를 만들겠습니다. 앞으로 이곳에 책들이 저장될 것입니다.

KLISPER> (defvar *books* '())
*BOOKS*

자~ 전역 변수를 기준으로 책 명으로 책을 찾아주는 메소드를 만들어 보겠습니다.

KLISPER> (defun book-from-name(name)
       (find name *books* :test #'string-equal :key #'name))
STYLE-WARNING: redefining BOOK-FROM-NAME in DEFUN
BOOK-FROM-NAME

그리고 책이 존재하는지 여부를 확인하는 function을 만들어 보겠습니다.

KLISPER> (defun book-stored? (book-name)
       (book-from-name book-name)
       )
BOOK-STORED?

아울러 책을 투표된 건수를 바탕으로 정렬해서 출력하는 function도 만들겠습니다. 나중에 화면에 출력할때 투표건에 따라 정렬할 때 사용할 예정입니다.

KLISPER> (defun books()
       (sort (copy-list *books*) #'> :key #'votes)
       )
BOOKS

마지막으로 책을 추가하는 function을 만들어 보겠습니다.

KLISPER> (defun add-book(name)
       (unless (book-stored? name)
         (push (make-instance 'book :name name)
           *books*)
         )
       )
ADD-BOOK

자~ 새로 만든 function들이 정상적으로 동작하는지 확인해보겠습니다.

KLISPER> (books) ; 현재 한권의 책도 없음.
NIL
KLISPER> (add-book "Common Lisp") ; 새로운 책 추가함.
(#<BOOK {C656311}>)
KLISPER> (book-from-name "Common Lisp") ; 책명으로 책을 찾음
#<BOOK {C656311}>
KLISPER> (add-book "Common Lisp") ; 같은 명칭의 책을 추가하였지만, 추가되지 않음. 즉 기능이 정상임.
NIL
KLISPER> (mapcar #'name (books)) ; 책 목록을 출력함.
("Common Lisp")

이로서 책 투표 사이트 구축을 위한 핵심 로직 구성이 끝났습니다. 이제 본격적으로 웹 페이지를 구성해 봅시다~


4.3 웹 사이트 구성

CL-WHO를 본격적으로 이용해볼 시간이 왔습니다. 간단하게 한번 CL-WHO를 테스트 해봅시다.

KLISPER> (with-html-output (*standard-output* nil :indent t)
       (:html
        (:head
         (:title "K-Lisper's Books")
         )
        (:body
         (:p "K-Lisper's is best!")
         )
        )
       )

<html>
  <head>
    <title>
      K-Lisper's Books
    </title>
  </head>
  <body>
    <p>
      K-Lisper's is best!
    </p>
  </body>
</html>
"
<html>
  <head>
    <title>
      K-Lisper's Books
    </title>
  </head>
  <body>
    <p>
      K-Lisper's is best!
    </p>
  </body>
</html>"

표준적으로 사용할 HTML 페이지의 구조를 잡는 Macro를 작성하려고 합니다. 즉, XHTML의 공통적인 요소를 담고 있는 Macro 입니다. 이 Macro를 통하여 불필요한 요소를 없애고 변화되는 내용만 반영하게 만들것입니다.

KLISPER> (defmacro standard-page ((&key title) &body body)
  `(with-html-output-to-string (*standard-output* nil :prologue t :indent t)
     (:html :xmlns "http://www.w3.org/1999/xhtml"  :xml\:lang "en" :lang "en"
       (:head
         (:meta :http-equiv "Content-Type" :content "text/html;charset=utf-8")
     (:title ,title)
     (:link :type "text/css" :rel "stylesheet" :href "/retro.css"))
       (:body
         (:div :id "header" ; K-Lisper's Books Header
           (:img :src "/logo.jpg" :alt "K-Lisper" :class "logo")
           (:span :class "strapline"  "Vote on your favorite Lisp Book"))
         ,@body))))

STANDARD-PAGE

주의하셔야 할 점은  ,title,@body, 는 마침표(.)가 아니라 쉼표(,)라는 것입니다.


자~ 첫 페이지인 index.htm을 한번 만들어 봅시다. Macro로 이미 정의한 standard-page의 위력이 발휘되는 순간입니다.

KLISPER> (defun index-page()
       (standard-page
           (:title "Klisper's Books")
         (:h1 "Top KLisper's Books")
         (:p "We'll wirite the code later..."
         )
         )
       )
INDEX-PAGE

이제 만든 페이지를 index.htm으로 걸어 봅시다.

KLISPER> (push (create-prefix-dispatcher "/index.htm" 'index-page) *dispatch-table*)
(#<CLOSURE (LAMBDA #) {C70DCED}> DISPATCH-EASY-HANDLERS DEFAULT-DISPATCHER)

웹 브라우져에서 http://localhost:8080/index.htm 이 정상적으로 출력되는지 한번 확인해보세요~

이렇게 등록하는 절차를 더욱 쉽게 하도록 Macro를 사용하겠습니다.

KLISPER> (defmacro define-url-fn ((name) &body body)
       `(progn
          (defun ,name()
        ,@body)
          (push (create-prefix-dispatcher ,(format nil "/~(~a~).htm" name) ',name)
            *dispatch-table*)
          )
       )
DEFINE-URL-FN

그리고 다시 index 페이지를 작성해보겠습니다.

KLISPER> (define-url-fn (index)
       (standard-page (:title "K-Lisper's Book Site")
         (:h1 "Top Books!")
         (:p "How about it?")
         )
       )
(#<CLOSURE (LAMBDA #) {BEE6D7D}> #<CLOSURE (LAMBDA #) {BB85A8D}>
 DISPATCH-EASY-HANDLERS DEFAULT-DISPATCHER)

웹 브라우져에서 다시 http://localhost:8080/index.htm 을 입력하고 정상적으로 변경되었는지 확인해보세요~ 두번에 해야할 절차를 한번에 간단하게 끝냈습니다. 와우~ 매크로여~ :-)



본격적으로 index.htm 페이지에 투표를 할 수 있는 기능을 추가해보겠습니다.

KLISPER> (define-url-fn (index)
       (standard-page (:title "K-Lisper's Top Books")
         (:h1 "Vote on your all time favorite Lisp Books!")
         (:p "Missing a book? Make it available for votes " (:a :href "new-book.htm" "here"))
         (:h2 "Current stand")
         (:div :id "chart" ; For CSS Style of Links
           (:ol
            (dolist (book (books))
              (htm
               (:li
            (:a :href (format nil "vote.html?name=~a" (name book)) "Vote!")
            (fmt "~A with ~d votes" (name book) (votes book))
            )
               )
              )
            )
           )
         )
       )
      
STYLE-WARNING: redefining INDEX in DEFUN
(#<CLOSURE (LAMBDA #) {C9B40F5}> #<CLOSURE (LAMBDA #) {D94667D}>
 #<CLOSURE (LAMBDA #) {AAB237D}> #<CLOSURE (LAMBDA #) {BB85A8D}>
 DISPATCH-EASY-HANDLERS DEFAULT-DISPATCHER)

길어 보이긴 합니다만, 기종의 HTML 작업의 경우 JSP나 PHP 또는 ASP 파일을 각각 만들고 각각 서버에 올려서 다시 컴파일 되는 과정이나 인터프리팅 되는 과정을 거쳐서 결과가 나오지만, Common Lisp의 웹 프로그래밍은 전혀 파일이 필요없습니다.

따라서 언제라도 index.htm 을 바로 변경할 수 있으며 만약 미리 만든 lisp 파일을 load한다고 하여도 파일속의 내용이 현저하게 적습니다. 웹 브라우져로 HTML 소스를 확인해보신다면 아마 팍팍 느끼실 것입니다.


이제 투표 결과를 받는 페이지입니다.

KLISPER> (define-url-fn (vote)
       (let ((book (book-from-name (parameter "name"))))
         (if book
         (vote-for book))
         (redirect "/index.htm")
         )
       )
(#<CLOSURE (LAMBDA #) {D21BE9D}> #<CLOSURE (LAMBDA #) {D9A7965}>
 #<CLOSURE (LAMBDA #) {D9B3E6D}> #<CLOSURE (LAMBDA #) {AAB237D}>
 #<CLOSURE (LAMBDA #) {BB85A8D}> DISPATCH-EASY-HANDLERS DEFAULT-DISPATCHER)


index.htm에서 Vote! 를 클릭하면 vote.htm으로 왔다가 바로 redirect되어 /index.htm으로 돌아가게 되어있습니다. 여기서 주목할 점은 이전에 만들었던 function인 vote-for를 로직으로 바로 활용했다는 점입니다. 그리고 "name" parameter 역시 간단하게 바로 받아서 처리합니다.

얼마나 멋집니까~ 하하하~ ;-)


이제~ 새로운 책을 추가하는 페이지를 만들겠습니다. 투표할 목록에 원하는 책이 없는 경우 간단하게 책 명을 입력하는 페이지입니다.
KLISPER> (define-url-fn (new-book)
       (standard-page (:title "Add a new book!")
         (:h1 "Add a new book to the chart")
         (:form :action "/book-add.htm" :method "post"
           :onsubmit (ps-inline ; Client-side validation.
               (when (= name.value "")
             (alert "Please enter a name.")
             (return false)))
            (:p "What is the name of the book?" (:br)
            (:input :type "text" :name "name" :class "txt"))
            (:p (:input :type "submit" :value "Add" :class "btn"))
            )
         ))
           
(#<CLOSURE (LAMBDA #) {BC8CD65}> #<CLOSURE (LAMBDA #) {DA52B0D}>
 #<CLOSURE (LAMBDA #) {D9A7965}> #<CLOSURE (LAMBDA #) {D9B3E6D}>
 #<CLOSURE (LAMBDA #) {AAB237D}> #<CLOSURE (LAMBDA #) {BB85A8D}>
 DISPATCH-EASY-HANDLERS DEFAULT-DISPATCHER)

이 페이지에서 드디어 Parenscript 를 사용합니다.  중간에 ps-inline 부터 사용하며, 만약 내용이 없는 경우 다시 입력을 받을 수 있도록 JavaScript로 입력값 검증을 처리하는 부분입니다.


자 새로운 책명을 입력받았다면, 책을 추가하는 페이지입니다. JavaScript에서 이미 걸렸겠지만, 다시 한번 빈 내용이 들어오는지 확인합니다. 이런 처리까지 해주어야 정말 좋은 웹 프로그래밍이라고 생각합니다. 참고로 로직 상으로도 이미 book-stored? function을 통하여 중복 방지 처리가 되어있습니다. 
KLISPER> (define-url-fn (book-add)
       (let ((name (parameter "name")))
         (unless (or (null name)(zerop (length name)))
           (add-book name))
         (redirect "/index.htm"))
       )
(#<CLOSURE (LAMBDA #) {D274E35}> #<CLOSURE (LAMBDA #) {D9999F5}>
 #<CLOSURE (LAMBDA #) {DA52B0D}> #<CLOSURE (LAMBDA #) {D9A7965}>
 #<CLOSURE (LAMBDA #) {D9B3E6D}> #<CLOSURE (LAMBDA #) {AAB237D}>
 #<CLOSURE (LAMBDA #) {BB85A8D}> DISPATCH-EASY-HANDLERS DEFAULT-DISPATCHER)


자~ 이제 index.htm에 있는 here 를 눌러서  새로운 책을 한번 추가해보세요~ :-)




여기까지가 Common Lisp의 새로운 웹 프로그래밍의 세계입니다. 다른 언어도 많은 장점을 제공하지만, Common Lisp이 당근 훌륭한 기능을 제공한다고 생각되네요~

아마 웹 브라우져의 내용이 궁금하신 분들이 많으시리라 생각됩니다. 한번씩 해보세요~ 따라하시기 편하도록 구성하였습니다. ;-)