Servant

Posted on June 19, 2020

생각 스트레칭

Proxy

Proxy가 타입 정보를 저장하는 테크닉을 보고나서 servant를 보면 도움이 됩니다.

data Proxy a = Proxy

코드 조립을 위해, 타입 체커에게 힌트를 주기 위한 장치입니다.

값에는 의미 있는 정보를 넣어둘 곳이 없습니다. 값은 오직 한 가지 Proxy만 있습니다. 하지만, =의 왼편 타입 생성자에는 a라는 타입 변수가 있습니다. 코드에서 Proxy란 값이 쓰이면, 이 값의 타입은 Proxy Int, Proxy Double, Proxy (Maybe Int) … 이 될 수 있다는 말입니다. 이런 타입 정보는 오직 타입 체커에서만 쓰이고, 런타임에는 사라지는 정보들입니다. 예로 컴파일 타임에 타입 체커가 클래스의 인스턴스를 고르는데 이 정보를 쓸 수 있습니다.

※ 이렇게 값에 쓰이지 않는 타입 변수를 특별히 phantom variable이라 부릅니다.

data Proxy a = Proxy
data Player
data Villain
data VillainLike

class HasName a where
    who :: Proxy a -> String

instance HasName Player where
    who _ = "Player"

instance HasName Villain where
    who _ = "Villain"

yaho :: HasName a => Proxy a -> IO () 
yaho x = putStrLn $ "Yaho. I am " ++ who x -- 어떤 who가 실행될지는 코드를 조립해봐야 압니다.

GHCi에서 테스트할 때, 아래처럼 타입 지정을 해줍니다.

> yaho (Proxy :: Proxy Player)
Yaho. I am Player
> yaho (Proxy :: Proxy Villain)
Yaho. I am Villain

Q. GHCi 예를 보면 :: Proxy Player로 타입을 지정해서 yaho에 넘기고 있습니다. 런타임에 타입은 모두 사라지는 것 아닌가요?
A. main이 있는 코드에서 yaho (Proxy :: Proxy Player)를 만났다면, 컴파일 타임에 타입 미스 매치가 되진 않는지 살펴 본 후, 최종 바이너리에서는 타입 정보는 빼버립니다. 여기서 Proxy의 역할은 yaho의 인스턴스를 고르는 것이니, 런타임에는 HasName의 인스턴스가 어느 하나로 고정되고 타입 정보는 사라집니다. (만일, 런타임에도 타입 정보가 필요하다면, Typeable을 이용해서 남겨 놓는 방법이 있습니다. 아마도 Typeable도 내부 구현은 타입 클래스와 인스턴스를 쓰고 있을 것으로 생각합니다.)

만일 타입 체커가 코드 조립 중 yaho 함수에 (Proxy :: Proxy VillainLike)가 넘어간다면 컴파일 타임에 잡아 낼 수 있습니다.

※ 여기서는 따로 번역 안하고 음차해서 타입 레벨로 쓰고 있는데, 번역어를 붙인 다면, 타입 수준 프로그래밍 보다는, 타입층 프로그램이 더 어울리지 않을까 개인 생각입니다만, 통용되는 번역어는 아닌 것 같습니다.

Servant

모나드 스택들에서 허우적대다, 쉴 겸 눈에 바로 결과물이 보이는 웹 관련 라이브러리들을 봐 둘까 하고 봤다가 Type Level Programming 을 공부하도록 이끈 라이브러리입니다. Yui, JQuery 쯤에서 웹 관련 작업이 끊겨서, React도 모르는 상황에서 보니, 예시 코드를 봐도 뭘 위한 것인지 알게 되는데 좀 시간이 걸렸습니다.

Servant가 하는 일은, http 리소스 요청과 하스켈 함수를 매칭 시켜주는 역할입니다. 매칭된 함수가 결과를 뱉으면, 이 결과를 요청자에게 보내는 일은 Servant backend로 쓰이고 있는 WAI 라이브러리가 담당합니다. URL과 헤더를 파싱할 때, 타입 레벨 프로그래밍 패턴을 이용해서 안정성을 높인 라이브러입니다.

Servant 동작을 이해하는데 많은 도움을 준 포스트입니다. 이 포스트에서 일부 코드를 조금 가져와 풀어 봤습니다. (실제 Servant 코드는 아니고, 이해를 돕기 위한 미니 서번트 코드입니다.)

Implementing a minimal version of haskell-servant - Andres Löh

data Get (a :: *)

data a :<|> b = a :<|> b
infixr 8 :<|>

data (a :: k) :> (b :: *)
infixr 9 :>

data Capture (a :: *)

값 생성자가 없는 타입이 어떤 의미가 있을까요? 값이 없다면 다른 값들과 어떤 연산도 못합니다. 타입만으로 영향을 줄 수 있는게 뭘까 생각해 봤습니다.

프로그램은 값이 지나가는 길을 만드는 겁니다. 간단한 연산을 위한게 아니라면, 중간 중간 갈래 길을 만들어 조건에 맞게 길을 선택(분기)할 수 있게 만드는 게 프로그램입니다. 값이 없는 타입들은 다른 값들과 어울릴 수 없으나, 갈래 길을 만드는 용도로 쓸 수 있습니다.

타입으로 갈래 길을 만드는 하스켈 요소는 바로 클래스와 인스턴스입니다. 메소드에서 받은 인자 타입에 따라 인스턴스를 고를 수 있습니다. 이 요소를 적극 활용하는게 Type Family1 입니다.

type famliy Server layout :: *
type instance Server (Get a) = IO a
type instance Server (a :<|> b) = (Server a, Server b)
type instance Server (a :<|> b) = Server a :<|> Server b
type instance Server ((s :: Symbol) :> r) = Server r
type instance Server (Capture a :> r) = a -> Server r

이렇게 패밀리를 선언하면, 코드 중에 Server layout 이라 쓰고, 구체 타입은 컴파일 타임에 코드 조합에 따라 결정되도록 할 수 있습니다. 아래 route 메소드는 코딩시 고정된 타입이 아니라, 컴파일 타임에 코드 조합에 따라 결정하겠다는 표현입니다.

class HasServer layout where
  route :: Proxy layout -> Server layout -> [String] -> Maybe (IO String)
instance ...
instance ...
...

serve :: HasServer layout
      => Proxy layout -> Server layout -> [String] -> IO String
serve p h xs = case route p h xs of
  Nothing -> ioError (userError "404")
  Just m  -> m

serve 함수를 콜할 때 들어온 layout 에 따라 Server layout 타입이 결정됩니다. Server (Get a) 가 들어왔다면 route 의 타입은

route :: Proxy (Get a) -> IO a -> [String] -> Maybe (IO String)

로 구체 타입이 결정되고, HasServer (Get a) 인스턴스에 있는 route 를 부릅니다.

2025.7.6 추가
가끔 이론들 공부하다 보면, 얼핏 아는 것 같은데도, 계속 나사가 하나 빠진듯이 느껴질 때가 있습니다. 내가 뭘 모르는지도 모르는 그런 느낌 말입니다. 타입 레벨 프로그래밍은 타입 레벨의 함수, 연산자도 정의하고 컴파일러에게 일을 떠 맡긴다는 알고 있는데도 계속 그랬습니다. 결국 함수를 “현실화realize”해서 “값” 레벨에 영향을 미쳐야 하는데, 타입 레벨에서만 놀고 있으면 현실화 되는 게 없습니다. 언젠가는 값 레벨에 영향을 미쳐야 합니다. 이 영향을 미치는 방법이 정리되지 않아, 나사가 빠진 느낌이었던 걸로 보입니다. 값 레벨에 영향을 미치는 함수를 골라내는데 타입 레벨 장치들을 씁니다. 이를 위해 하스켈이 제공하는 문법은 타입 클래스, 인스턴스입니다. 즉 타입 레벨에서 가지고 놀아서 나온 결과 타입(팬텀 타입)은, 언젠가는 어떤 타입 인스턴스를 선택하냐는 질문에 답으로 쓰여야 합니다.

* 카인드는 Type 카인드라 부르기도 합니다. Nat, Symbol, Type … 카인드가 있는데, Nat, Symbol 등이 값 레벨에 영향을 미치려면 언젠가는 타입 함수(family)를 통해 Type 과 매핑되어야 합니다. Type 카인드는 런타임에 값이 존재할 수 있는 타입들을 의미합니다. 이에 반해, 런타임에 값이 없는 Nat, Symbol 등은 phantom 타입 카인드라 부르고, GHC가 추론할 때만 씁니다.

Int :: Type -- 나중에 실제 존재하는 값이 들어갈 자리
5 :: Nat -- 타입 레벨에만 존재하고, 값이 없습니다.

모호하게 했던 것 또 하나가 있는데, 바로 GHC의 애드혹한 동작입니다. 보통 찾는 인스턴스가 없으면 오류를 낼텐데, Nat, Symbol 카인드의 경우, GHC가 특별한 동작을 합니다. 이들 카인드에 대해선 컴파일 타임에 필요한 인스턴스를 자동으로 생성합니다.

풀 코드

{-# LANGUAGE DataKinds, PolyKinds, TypeOperators #-}
{-# LANGUAGE TypeFamilies, FlexibleInstances, ScopedTypeVariables #-}
{-# LANGUAGE InstanceSigs #-}

module TinyServant where

import Control.Applicative
import GHC.TypeLits
import Text.Read
import Data.Time

-- API specification DSL
data Get (a :: *)

data a :<|> b = a :<|> b
infixr 8 :<|>
-- 타입 매개 변수로 타입 두 개를 받는 타입입니다.
-- A :<|> B 도 하나의 타입으로, A 와 (B :<|> C) 를 받아
-- A :<|> (B :<|> C) 이렇게 써 줄 수 있습니다.
-- 오른 쪽 결합이니 괄호를 생략할 수 있습니다.
-- A :<|> B :<|> C 

data (a :: k) :> (b :: *)
-- k는 카인드 변수입니다. b는 * 카인드만 받고, a는 카인드 제한이 없다는 말입니다.
-- b는 * 카인드니 Maybe Int 같은 * -> * 는 못받는 걸로 오해할 수 있습니다.
-- Maybe는 * -> * 카인드고, 여기에 인자로 Int를 넘긴 Maybe Int 는 * 카인드입니다.
infixr 9 :>

data Capture (a :: *)

-- Example API
type MyAPI = "date" :> Get Day
        :<|> "time" :> Capture TimeZone :> Get ZonedTime

data Proxy a = Proxy

-- The Server type family
type family Server layout :: *
type instance Server (Get a) = IO a
type instance Server (a :<|> b) = Server a :<|> Server b
type instance Server ((s :: Symbol) :> r) = Server r
type instance Server (Capture a :> r) = a -> Server r

-- Handler for the example API
handleDate :: IO Day
handleDate = utctDay <$> getCurrentTime

handleTime :: TimeZone -> IO ZonedTime
handleTime tz = utcToZonedTime tz <$> getCurrentTime

handleMyAPI :: Server MyAPI
handleMyAPI = handleDate :<|> handleTime

-- The HasServer class
class HasServer layout where
  route :: Proxy layout -> Server layout -> [String] -> Maybe (IO String)

------------여기서 부턴, (팬텀) 타입들에 따라 선택될 코드들입니다.--------------
-- HasServer류의 타입을 받아, 해당 타입에 맞는 (해당 타입의 인스턴스의) route를 씁니다.
serve :: HasServer layout
      => Proxy layout -> Server layout -> [String] -> IO String
serve p h xs = case route p h xs of
  Nothing -> ioError (userError "404")
  Just m  -> m

-- The HasServer instance
type instance Server (Get a) = IO a

-- 타입들을 분해해서, 조각난 타입 각각에 맞는 route를 가져다 씁니다.
instance Show a => HasServer (Get a) where
  route :: Proxy (Get a) -> IO a -> [String] -> Maybe (IO String)
  route _ handler [] = Just (show <$> handler)
  route _ _       _  = Nothing

instance (HasServer a, HasServer b) => HasServer (a :<|> b) where
  route :: Proxy (a :<|> b) -> (Server a :<|> Server b) -> [String] -> Maybe (IO String)
  route _ (handlera :<|> handlerb) xs =
        route (Proxy :: Proxy a) handlera xs
    <|> route (Proxy :: Proxy b) handlerb xs
  -- (A :<|> (B :<|> C)) 를 만나면, 
  -- A 타입을 위한 route를 부르고, 
  -- (B :<|> C) 타입을 위한 route를 부릅니다.  B :<|> C 도 하나의 타입입니다.

instance (KnownSymbol s, HasServer r) => HasServer ((s :: Symbol) :> r) where
  route :: Proxy (s :> r) -> Server r -> [String] -> Maybe (IO String)
  route _ handler (x : xs)
    | symbolVal (Proxy :: Proxy s) == x = route (Proxy :: Proxy r) handler xs
  route _ _       _                     = Nothing

instance (Read a, HasServer r) => HasServer (Capture a :> r) where
  route :: Proxy (Capture a :> r) -> (a -> Server r) -> [String] -> Maybe (IO String)
  route _ handler (x : xs) = do
    a <- readMaybe x
    route (Proxy :: Proxy r) (handler a) xs
  route _ _       _        = Nothing

다음으로 Singleton 패턴, Reflection 을 보면 좋을 것 같습니다.


  1. Type Family : 타입 레벨 함수
    타입끼리 연산과 갈래 길을 만드는 건 Type familiy를 통해 표현됩니다. 타입 연산은, 타입 매개 변수를 가지고 있는 타입에 어떤 타입을 넣어주냐에 따라 결과 타입이 결정되는 걸 말합니다.

    class Add a b where  
        type SumTy a b
        plus :: a -> b -> SumTy a b
    
    instance Add Integer Double where  
        type SumTy Integer Double = Double

    SumTy 타입은, 컴파일 타임에 코드 조합을 하다 Add Integer Double 인스턴스를 쓰게 되면 Double 타입이 됩니다. Type family를 쓰면, 인스턴스에 따라 메소드 시그니처를 다르게 할 수 있습니다. 인스턴스를 고를 때나, 위와 같이 메소드들의 타입을 결정을 지을 땐 값이 필요 없고, 타입만 있으면 됩니다. 값이 없는 타입이 보이면 코드 어딘가에서 이렇게 쓰겠구나 생각하면 됩니다.↩︎

Github 계정이 없는 분은 메일로 보내주세요. lionhairdino at gmail.com