클로저로 웹 서버 애플리케이션 개발을 시작하는 사람들을 위한 Ring 소개서

목차

Ring 이란

Ring은 파이썬의 WSGI(Web Server Gateway Interface)에 영감을 받은 클로저 웹 애플리케이션 라이브러리입니다.

WSGI는 웹 서버와 파이썬 애플리케이션 사이의 표준 인터페이스로서, 웹 서버에 들어온 요청을 애플리케이션으로 전달하는 역할을 합니다. 클로저에서는 Ring이 이 일을 담당합니다. Ring은 저수준 인터페이스(handler, middleware, request, response 등)만을 제공합니다. 고수준 인터페이스(routing 등)를 사용하려면 Ring을 기반으로 하는 다른 라이브러리들을 사용해야 합니다.

이 튜토리얼에서는 Ring의 저수준 인터페이스들을 이용하여 간단한 웹 애플리케이션을 만들어보겠습니다.

필요한 프로그램 목록

이름버전
Clojure1.10.3
deps클로저에 포함
IntelliJ (Cursive)2021.2

간단한 요청 - 응답

인텔리제이에서 사용할 프로젝트를 만듭니다. deps를 패키지 매니저로 사용할 것이므로 deps를 지정해주고 적절한 이름으로 만듭니다.

프로젝트 최상위 경로에 있는 deps.edn 을 다음과 같이 변경합니다. deps.edn은 클로저에서 사용하는 패키지 매니저인 deps 의 설정 파일입니다. 이 글에서는 자세히 소개하지 않습니다. 자세한 내용은 공식문서를 참고하세요.

1{:paths ["src"]
2 :deps {ring/ring {:mvn/version "1.9.3"}}}

deps.edn

deps.edn 파일을 만든 뒤 IDE 프로그램인 인텔리제이에서 'Add as Clojure Deps project'를 클릭하여 프로젝트에 deps.edn 파일을 설정 파일로 등록하여 IDE와 연동합니다.

로컬호스트에 웹서버 띄우기

src 디렉터리를 생성하고, 'mark directory as source folder'를 클릭하여 소스 폴더로 설정합니다. 소스 폴더 안에는 hello_world 라는 이름으로 앱 디렉터리를 지정합니다.

  • 폴더 이름에 하이픈(-) 이 들어있으면 "Namespace name does not correspond to filesystem hierarchy" 라는 오류 메시지를 보실 수 있습니다. 하이픈 대신 언더바(_)를 사용해야 합니다.

이제 웹 서버에 들어온 요청을 넘겨받아 적절한 처리를 하고 응답을 넘겨줄 핸들러를 정의할 차례입니다. 핸들러를 정의할 클로저 namespace 파일을 생성합시다. 저는 core.clj 라고 이름 지었습니다.

1(ns hello-world.core)
2
3(defn handler [request]
4 {:status 200
5 :headers {"Content-Type" "text/html"}
6 :body "Hello World"})

namespace(ns) 의 폴더명에 섞인 언더바가 하이픈으로 변경된 것에 주의

이제 인텔리제이에서 REPL을 실행할 수 있게 설정합시다. 특별히 수정할 것 없이 이름만 정해주면 됩니다.

이제 run 메뉴에서 REPL을 실행할 수 있습니다.

REPL에서 아래의 코드를 입력하여 로컬 환경에서 웹 서버 애플리케이션을 띄워봅시다.

1(use 'ring.adapter.jetty)
2(use 'hello-world.core)
3(def server (run-jetty handler {:port 3000
4 :join? false}))
5
6(.stop server)
7(.start server)

REPL

잘 되었다면 def server ... 를 REPL 에서 실행하는 순간 웹서버가 시작되는 것을 로그에서 확인할 수 있습니다. 웹 브라우저를 이용해 웹 서버의 URL에 접속하면, 핸들러에 정의해 둔 메시지가 응답되는 것을 확인할 수 있습니다.

  • Ring 의 응답 데이터 구조
1{:ssl-client-cert nil,
2 :protocol "HTTP/1.1",
3 :remote-addr "127.0.0.1",
4 :headers {"sec-fetch-site" "none",
5 "host" "localhost:3000",
6 "user-agent" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:90.0) Gecko/20100101 Firefox/90.0",
7 "cookie" "...",
8 "sec-fetch-user" "?1",
9 "connection" "keep-alive",
10 "upgrade-insecure-requests" "1",
11 "accept" "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
12 "accept-language" "ko-KR,ko;q=0.8,en-US;q=0.5,en;q=0.3",
13 "sec-fetch-dest" "document",
14 "accept-encoding" "gzip, deflate",
15 "sec-fetch-mode" "navigate"},
16 :server-port 3000,
17 :content-length nil,
18 :content-type nil,
19 :character-encoding nil,
20 :uri "/",
21 :server-name "localhost",
22 :query-string nil,
23 :body #object[org.eclipse.jetty.server.HttpInputOverHTTP 0x6a5c0707 "HttpInputOverHTTP@6a5c0707[c=0,q=0,[0]=null,s=STREAM]"],
24 :scheme :http,
25 :request-method :get}

이제 Ring 이 제공하는 response 함수를 활용해봅시다. ring.util.response 는 아래와 같이 구현되어 있습니다.

1(defn response
2 "Returns a skeletal Ring response with the given body, status of 200, and no
3 headers."
4 [body]
5 {:status 200
6 :headers {}
7 :body body})

ring.utils.response/response 구현체

빈 헤더와 HTTP 상태 코드 200, 그리고 사용자가 넘겨준 body를 반환하도록 정의되어 있습니다. 이를 이용해 우리가 앞서 만든 코드를 아래와 같이 수정 할 수 있습니다.

1(ns hello-world.core
2 (:require [ring.util.response :refer [response]]))
3
4(defn handler [request]
5 (response "{\"test\": \"asd\"}"))

이 외에도 redirect, created, bad-request 등 HTTP 응답 상태 코드에 대응하는 여러가지 응답 함수가 있으므로 상황에 따라 사용할 수 있습니다. HTTP 응답 헤더도 설정할 수 있습니다. 만약 Content-Type 헤더를 application/json 으로 설정하여 응답을 주고 싶다면 아래와 같이 핸들러를 정의합니다.

1(ns hello-world.core
2 (:require [ring.util.response :refer [response header]]))
3
4(defn handler [request]
5 (header (response "{\"test\": \"asd\"}") "Content-Type" "application/json")

헤더를 설정하는 또 다른 방법으로, 아래와 같이 content-type 함수를 사용하는 것도 가능합니다.

1(ns hello-world.core
2 (:require [ring.util.response :refer [response content-type]]))
3
4(defn handler [request]
5 (content-type (response "{\"test\": \"asd\"}") "application/json")

미들웨어 설정해보기

모든 핸들러가 Content-Type 헤더를 application/json 으로 설정하려면 어떻게 해야 할까요? 각 핸들러마다 헤더를 명시적으로 설정해줘도 되지만, 미들웨어를 이용하면 일괄로 처리할 수 있습니다. Content-type 헤더 설정을 미들웨어에서 처리해봅시다.

1(ns hello-world.core)
2
3(defn handler [request]
4 {:status 200
5 :headers {"Content-Type" "text/html"}
6 :body "{\"test\": \"asd\"}"})
7
8(defn wrap-content-type [handler content-type]
9 (fn [request]
10 (let [response (handler request)]
11 (assoc-in response [:headers "Content-Type"] content-type))))
12
13(def app
14 (-> handler
15 (wrap-content-type "application/json")))

미들웨어를 추가한 core.clj

애플리케이션이 실행될 때 wrap-content-typehandler 함수를 감싸므로 handler 함수 안에서 헤더 설정을 명시하지 않아도 됩니다. 애플리케이션에 미들웨어를 등록한 걸 반영하기 위해서는, run-jetty 의 실행 대상을 handler 대신 app 으로 바꿔야 합니다. 서버를 실행시켜 둔 상태라면 서버도 다시 로드해야 합니다.

1(.stop server)
2(use 'hello-world.core :reload)
3(def server (run-jetty app {:port 3000
4 :join? false}))
5(.start server)

미들웨어를 적용하기 위해 (run-jetty handler ...) 에서 (run-jetty app ...) 으로 변경한 것에 주목

웹 브라우저에서 웹 서버에 다시 접속해보면, 응답 헤더가 올바르게 전달되어 브라우저에서 데이터를 JSON으로 인식하는 걸 확인할 수 있습니다.

미들웨어를 통해 자동 리로딩 기능을 설정할 수 있습니다. REPL을 이용하여 서버를 띄웠을 때는 변경을 반영하기 위해 서버를 재시작 해야했습니다. 자동 리로딩을 이용하면 변경을 파일에 저장하면 서버를 재시작 하지 않아도 변경이 반영됩니다. 개발 편의성을 위해 설정해 봅시다.

1(ns hello-world.core
2 (:require [ring.adapter.jetty :refer [run-jetty]]
3 [ring.middleware.reload :refer [wrap-reload]]
4 [ring.util.response :refer [response]]))
5
6(defn handler [request]
7 {:status 200
8 :headers {"Content-Type" "text/html"}
9 :body "{\"test\": \"asd\"}"})
10
11(def reloadable-app
12 (-> handler
13 wrap-reload))
14
15(defn -main []
16 (run-jetty #'reloadable-app {:port 3000
17 :join? false}))

자동 리로딩을 적용한 core.clj

자동 리로딩을 설정하려면 ring.middlewrae.reload/wrap-reload 를 불러와야 하고, (defn -main [] ())를 선언하여 프로그램 실행 지점을 만들어주어야 합니다.

만약 기존에 REPL로 웹 서버 애플리케이션을 띄워둔 게 있다면 꺼주어야 합니다. 그리고 프로그램 실행 지점을 통해 실행합니다.

이제 자동 리로딩이 잘 동작하는지 확인하기 위해 핸들러 :body 를 바꾸겠습니다.

1...
2
3(defn handler [request]
4 {:status 200
5 :headers {"Content-Type" "text/html"}
6 :body "{\"test\": \"foobar\"}"})
7
8...

파일을 저장하고 웹 브라우저에서 새로고침을 두어번하면 변경한 응답이 반영되어 있습니다.

정적 자원을 서빙 해보기

이제 핸들러를 이용해 정적 자원을 응답하도록 설정해 봅시다. 여기서 소개하는 것과 똑같은 구조가 아니어도 되지만, 편의상 프로젝트 최상위 경로를 기준으로 아래와 같은 구조가 되도록 resources/public 폴더를 만들겠습니다.

1/app
2 /resources
3 /public
4 /src
5 /hello_world
6 core.clj
7 deps.edn

폴더 구조

resources/public 에 서빙할 정적 자원을 넣어두시면 됩니다. 아래와 같이 간단한 index.html 파일을 만들어 넣습니다.

1<!DOCTYPE html>
2<html lang="ko">
3 <head>
4 <meta charset="UTF-8" />
5 <title>index</title>
6 </head>
7 <body>
8 Clojure time!
9 </body>
10</html>

resources/public/index.html

지금은 라우팅 없이 하나의 핸들러 모든 요청을 처리하고 있습니다. 핸들러를 아래와 같이 수정합니다.

1(ns hello-world.core
2 (:require [ring.adapter.jetty :refer [run-jetty]]
3 [ring.middleware.resource :refer [wrap-resource]]
4 [ring.util.response :refer [response]]))
5
6(defn handler [request]
7 (response (slurp "resources/public/index.html")))
8
9(def reloadable-app
10 (-> handler
11 (wrap-resource "public")))
12
13(defn -main []
14 (run-jetty #'reloadable-app {:port 3000
15 :join? false}))

core.clj

위의 코드에서 보듯, 정적 자원을 서빙 하기 위해서는 wrap-resource 가 필요합니다. wrap-resource 는 파라미터로 핸들러와 경로를 받습니다. root-pathpublic 으로 지정합니다. 소스 코드를 저장하여 웹 서버가 리로드 되도록 하고, 웹 브라우저에서 http://localhost:3000/index.html 에 접속해 봅시다.

index.html 파일의 내용이 잘 출력되는 것을 확인할 수 있습니다.

파라미터들 다루기

다음으로 URL의 쿼리 파라미터와 폼 파라미터(application/x-www-form-urlencoded)를 읽어 처리하는 방법을 알아봅시다. 먼저 URL의 쿼리 스트링을 파싱하는 미들웨어인 ring.middleware.params/wrap-params 를 추가합니다.

1(ns hello-world.core
2 (:require [ring.adapter.jetty :refer [run-jetty]]
3 [ring.middleware.session :refer [wrap-session]]
4 [ring.middleware.params :refer [wrap-params]]
5 [ring.util.response :refer [response]]))
6
7(defn handler [{:keys [query-params form-params params]}]
8 (response (str {:query-params query-params
9 :form-params form-params
10 :params params})))
11
12(def reloadable-app
13 (-> handler
14 wrap-params
15 wrap-session))
16
17(defn -main []
18 (run-jetty #'reloadable-app {:port 3000
19 :join? false}))

wrap-params 는 요청에 :query-params:form-params, :params 를 각각 키와 값으로 추가해줍니다. 이름에서 알 수 있다시피 query-params 는 URL 쿼리 스트링을, form-params 은 폼 데이터를 그리고 params 는 모든 파라미터를 파싱하여 담고 있습니다. 웹 서버를 리로드하고, 웹 브라우저에서 URL에 쿼리 스트링을 입력하여 접속하면 query-paramsparams 에 의도한 값인 foo: 1 이 들어감을 볼 수 있습니다.

마찬가지로 x-www-form-urlencoded 데이터를 전달하면 form-params 에 요청 내용이 입력되는 것을 확인할 수 있습니다.

하지만 wrap-paramsx-www-form-urlencoded 형식의 데이터만을 파싱할 수 있습니다. ring.middleware.params/wrap-params 의 주석에 자세한 내용이 적혀 있습니다.

Middleware to parse urlencoded parameters from the query string and form body (if the request is a url-encoded form).

multipart/form-data 형식의 데이터를 처리하려면 ring.middleware.multipart_params/wrap-multipart-params 를 사용해야 합니다. 그러면 wrap-multipart-params 를 이용해 파일 업로드를 처리해 봅시다. 파일을 올릴 폴더를 준비합니다.

/app
  /resources
    /public
  /src
    /hello_world
      core.clj
  deps.edn

여기서는 이전에 만들어둔 public 폴더를 그대로 사용하도록 하겠습니다. wrap-resource 를 미들웨어에 추가하고 자원을 저장할 경로로 public 폴더를 지정합시다. 파일 업로드를 처리할 준비가 끝났습니다. 이제 업로드 요청을 받았을 때 파일이 어떤 식으로 request-map 에 실리는지 살펴봅시다.

1(ns hello-world.core
2 (:require [ring.adapter.jetty :refer [run-jetty]]
3 [ring.middleware.resource :refer [wrap-resource]]
4 [ring.middleware.params :refer [wrap-params]]
5 [ring.middleware.multipart-params :refer [wrap-multipart-params]]
6 [ring.util.response :refer [response]]))
7
8(defn handler [{params :params}]
9 (prn params)
10 (response ""))
11
12(def reloadable-app
13 (-> handler
14 wrap-params
15 wrap-multipart-params
16 (wrap-resource "public")))
17
18(defn -main []
19 (run-jetty #'reloadable-app {:port 3000
20 :join? false}))

1{"test" {:filename "20210805_145756.jpg",
2 :content-type "image/jpeg",
3 :tempfile #object[java.io.File 0x805f6d6 "/var/folders/2d/t4tt59310811tbf48s8xbnpc0000gn/T/ring-multipart-15013718029732306064.tmp"],
4 :size 133949}}

prn 결과

request-map 안에, 키(test)에 업로드된 파일의 정보가 들어옵니다. :tempfile 의 값에 있는 객체를 이용해 파일 내용을 읽거나 저장 할 수 있습니다.

1...
2 (:require [clojure.core :refer [bean]]
3 [clojure.java.io :refer [copy file]]
4 ...)
5
6(defn handler [{params :params}]
7 (let [save-path "/tmp/"
8 tmp-file (-> (get-in params ["test" :tempfile])
9 bean
10 :path)]
11 (copy (file tmp-file) (file (str save-path (get-in params ["test" :filename])))))
12 (response ""))

업로드한 파일과 동일 이름으로 서버의 save-path에 저장

:tempfile의 값은 java.io.File 객체입니다. 이 객체를 사용하려면 자바빈 객체의 속성들을 클로저의 맵으로 바꿔주는 clojure.core/bean 이 필요합니다.

1(def handler [{params :params}]
2 (prn (bean (get-in params ["test" :tempfile]))))

변경된 내용을 확인해봅시다.

1{:path "/var/folders/2d/t4tt59310811tbf48s8xbnpc0000gn/T/ring-multipart-8223890071443078384.tmp",
2 :freeSpace 398616518656,
3 :parent "/var/folders/2d/t4tt59310811tbf48s8xbnpc0000gn/T",
4 :directory false,
5 :parentFile #object[java.io.File 0x660a21ea "/var/folders/2d/t4tt59310811tbf48s8xbnpc0000gn/T"],
6 :name "ring-multipart-8223890071443078384.tmp",
7 :file true,
8 ...}

bean 에 의해 클로저 맵으로 변경된 java.io.File 객체

올린 임시 파일이 저장된 경로와 파일명을 알아냈으므로, clojure.java.io/copy, file 을 통해 파일 객체로 변환하여 복사할 수 있습니다. 웹 브라우저로 파일 업로드를 테스트해보면 파일이 잘 올라가는 것을 확인할 수 있습니다.

세션과 쿠키

이번에는 세션과 쿠키를 다뤄 보겠습니다. ring.middleware.session/wrap-sessionring.middleware.session/wrap-cookies 가 필요합니다.

1(ns hello-world.core
2 (:require [ring.adapter.jetty :refer [run-jetty]]
3 [ring.middleware.session :refer [wrap-session]]
4 [ring.middleware.cookies :refer [wrap-cookies]]
5 [ring.util.response :refer [response]]))
6
7(defn handler [{session :session}]
8 (let [count (:count session 0)
9 session (assoc session :count (inc count))]
10 (-> (response (str (:count session)))
11 (assoc :session session)
12 (assoc :cookies {:cnt (:count session)}))))
13
14(def reloadable-app
15 (-> handler
16 wrap-session
17 wrap-cookies))
18
19(defn -main []
20 (run-jetty #'reloadable-app {:port 3000
21 :join? false}))

먼저 웹 서버를 실행시켜 결과를 확인해 보겠습니다.

count 는 세션이 유지되는동안 페이지를 새로고침할 때마다 1씩 증가합니다. 이는 세션에 담긴 값을 보여주는 응답에서 확인할 수 있습니다. 마찬가지로 응답 쿠키에 cnt 도 잘 들어와 있고요.

세션의 경우 요청에서 받아온 정보를 상태로 보관하고 있다가 응답으로 넘겨줄 수 있도록 구성되어 있습니다. 세션의 저장 방식으로는 Ring에서 지원하는 ring.middleware.session.memory/memory-storering.middleware.session.cookie/cookie-store 가 있습니다. 또한 사용자가 직접 정의한 저장 방식을 지정하는 것도 가능합니다.

Ring의 기본 저장 방식은 memory-store 입니다. 저장 방식을 변경하려면 미들웨어에서 :store 값을 변경하면 됩니다.

1(use 'ring.middleware.session.cookie)
2
3(def app
4 (wrap-session handler {:store (cookie-store {:key "a 16-byte secret"})})

https://github.com/ring-clojure/ring/wiki/Sessions#session-stores

쿠키의 경우 아래와 같은 옵션이 지원됩니다.

:domain - restrict the cookie to a specific domain

:path - restrict the cookie to a specific path

:secure - restrict the cookie to HTTPS URLs if true

:http-only - restrict the cookie to HTTP if true (not accessible via e.g. JavaScript)

:max-age - the number of seconds until the cookie expires

:expires - a specific date and time the cookie expires

:same-site - Specify :strict,:lax, or :none to determine whether cookies should be sent with cross-site requests

다음은 쿠키 설정의 한 예입니다.

1...
2
3(defn handler [{session :session}]
4 (let [session (update session :count (fnil inc 0))]
5 (-> (response (str (:count session)))
6 (assoc :session session)
7 (assoc :cookies {:cnt (:count session)
8 :secret {:value "foobar", :secure true, :max-age 3600}}))))
9...

core.clj/handler

위에서 설정한대로, secret 키에 foobar 라는 값이 들어있고, Secure: truemax-age 가 최근 접속 후 1시간까지로 설정된 것을 확인하실 수 있습니다.

마치며

이것으로 클로저의 저수준 웹 서버 애플리케이션 개발을 해보았습니다.

이 튜토리얼에서 소개된 내용의 대부분 ring wiki 를 참고하고 정리한 것입니다. 더 자세한 내용이 필요하다면 참고하시기 바랍니다.

감사합니다.





Gravatar for dw.kim@greenlabs.co.kr
김동욱백엔드 개발자
2021. 09. 17.

추천 콘텐츠