역시 하스켈은 알아야 할 내용이 많은 것 같다. 어떤 발표에서 하스켈 4단계인가 5단계인가로 되어 있는 피라미드를 보여주고 대부분의 하스켈 개발자들은 4단계에 있고 그 정도에 있으면 실무에서 하스켈 사용하는데 무리가 없다고 했던 말에 위로를 받아본다. 국내에 하스켈에 관한 실용적인 내용이 많이 없어 하스켈을 실무에서 사용하면서 알게 된 내용을 그냥 일기 형식으로 남겨보려고 한다.
처음 남길 주제는 최근 작업하면서 살짝 맛봤던 GHC.Generics
모듈에 관한 내용이다.
우리 서비스는 mongoDB를 사용한다. 하스켈도 잘 만들어진 mongoDB 패키지가 있다. 그리고 mongoDB에서 사용하는 BSON 타입을 다루는 bson 패키지도 있다. 하지만 조금 아쉬운 점이 있다면 내가 만든 하스켈 데이터 타입의 값을 BSON 값으로 변환하거나 반대로 BSON 값을 내가 만든 하스켈 값으로 바꾸는 것이 번거롭다는 점이다. 예를 들어 아래 User
라고 하는 타입이 있고 이 타입의 값을 BSON의 Document 타입으로 바꾸거나 반대로 바꾸려면 다음과 같이 코드를 작성할 수 있다.
data User = User
{ userId :: Int
, userName :: String
} deriving (Eq, Show)
toDoc :: User -> Document
toDoc User {..} =
[ "_id" =: userId
, "name" =: userName
]
fromDoc :: Document -> Maybe User
fromDoc doc = User
<$> Mongo.lookup "_id" doc
<*> Mongo.lookup "name" doc
-- >>> fromDoc $ toDoc $ User 1 "Eumin Kim"
-- Just (User {userId = 1, userName = "Eumin Kim"})
이렇게 모든 타입에 대해 하나하나 변환 코드를 만들어 주는 것은 번거로운 일이다. 다른 언어들과 비슷하게 하스켈의 JSON 패키지인 aeson에는 레코드 데이터의 필드명을 기반으로 JSON을 자동으로 생성해주는 기능이 있다.
data User = User
{ userId :: Int
, userName :: String
} deriving (Eq, Show, Generic)
instance ToJSON User
instance FromJSON User
-- >>> encode $ User 1 "Eumin Kim"
-- "{\"userName\":\"Eumin Kim\",\"userId\":1}"
Data.Aeson
에 정의된 ToJSON
과 FromJSON
타입 클래스의 인스턴스를 만들어 주면 encode
또는 decode
로 JSON 변환을 쉽게 할 수 있다. 여기에 사용되는 기술이 Generic
이다. User
타입에 보면 Generic
을 파생(Generic
을 파생하려면 DeriveGeneric
언어 확장이 필요하다) 한 것을 볼 수 있다.
그럼 BSON은 어떨까? 아쉽게도 bson 패키지에는 그런 기능이 없다. 유사한 패키지로 bson-mapping이나 bson-generic이 있지만 버전 문제로 최신 bson 패키지 의존성을 사용할 수 없거나 오픈 소스 관리가 되고 있지 않다. 오픈 소스를 목적으로 하고 더 일반적인 타입을 대응하려면 조금 더 고민해야 할 것이 많겠지만 우리 프로젝트에서만 사용한다면 구현이 크게 어렵지 않을 것 같아서 구현해서 사용해보기로 했다.
GHC.Generics
를 사용해야 하기 때문에 하스켈 Generic
에 대한 간단한 이해가 필요할 것 같아서 자료를 찾아봤다. 요즘 가장 많이 참고하고 있는 책인 Haskell in Depth 12장에 보면 Data-type-generic programming이라는 내용이 나온다. 그리고 하스켈 base 패키지에 들어있는 GHC.Generics
모듈은 다른 패키지가 그렇듯 Hackage에 설명이 잘 되어 있다.
먼저 GHC.Genercis
에 기본이 되는 from
과 to
함수를 사용해서 User
타입의 값을 넣어보자.
import GHC.Generic
data User = User
{ userId :: Int
, userName :: String
} deriving (Eq, Show, Generic)
-- >>> from $ User 1 "Eunmin"
-- M1 {unM1 = M1 {unM1 = M1 {unM1 = K1 {unK1 = 1}} :*: M1 {unM1 = K1 {unK1 = "Eunmin"}}}}
값을 보면 M1
데이터 생성자로 여러 번 감싸져 있고 마지막에 K1
으로 실제 값인 1
과 Eunmin
이 들어 있는 것을 볼 수 있었다. 그리고 K1
을 감싸고 있는 M1
값은 :*:
라고 하는 중위 데이터 생성자로 연결되어 있다.
이 값은 무엇인가? 이 값은 그저 User
타입의 값을 JSON이나 BSON과 같이 조금 더 일반적인 데이터 구조로 만들어 준 것뿐이다. from
함수의 중요한 부분은 이 값이 가지고 있는 타입이다. 타입을 살펴보자.
-- >>> :type from $ User 1 "Eunmin"
-- from $ User 1 "Eunmin"
-- :: D1
-- ('MetaData "User" "Main" "main" 'False)
-- (C1
-- ('MetaCons "User" 'PrefixI 'True)
-- (S1
-- ('MetaSel
-- ('Just "userId")
-- 'NoSourceUnpackedness
-- 'NoSourceStrictness
-- 'DecidedLazy)
-- (Rec0 Int)
-- :*: S1
-- ('MetaSel
-- ('Just "userName")
-- 'NoSourceUnpackedness
-- 'NoSourceStrictness
-- 'DecidedLazy)
-- (Rec0 String)))
-- x
실제 Generic
에서 사용하려는 필드명이나 데이터 생성자 이름은 타입에 타입 레벨 리터럴 형식으로 들어있다. 다행히 하스켈 타입 레벨에 대해 이해는 하고 있는 정도라 당황하지 않고 타입을 읽을 수 있었다. 먼저 D1
, C1
, S1
타입과 타입 오퍼레이터인 :*:
가 있다. (값에 있는 :*:
는 데이터 생성자다) D1
, C1
S1
은 GHC.Generics
에 다음과 같이 타입 동의어로 정의되어 있다.
type S1 = M1 S
type C1 = M1 C
type D1 = M1 D
그리고 M1
은 다음과 같이 정의되어 있다.
newtype M1 i c f p = M1 { unM1 :: f p }
이렇게 되어 있기 때문에 D1
, C1
, S1
은 모두 M1
이라는 데이터 생성자로 생성된 값의 타입들이다. 그리고 여기에는 i
, c
, f
, p
와 같은 타입 변수가 여럿 있는데 먼저 i
는 S
, C
, D
가 들어가기 때문에 타입의 종류를 나타내는 것이라고 볼 수 있고 c
에는 ('MetaData "...
로 시작하는 타입이 있는 것을 봐서 메타 데이터 정보 같은 것이 들어가는 것이고 f
에는 C1
, S1
같은 타입이 오는 것으로 봐 다른 M1
타입이 올 수 있도록 정의되어(아마 D1
은 데이터 타입을 나타내는 것이라 D1
아래 다시 D1
이 올 수는 없을 것 같다) 있을 것이다. 그리고 메타 데이터 정보를 보고 알파벳이 의미하는 것을 유추할 수 있는데 D1
은 데이터 타입, C1
은 데이터 생성자, S1
은 데이터 셀렉터(필드명 같은 것)를 의미하는 것 같다.
메타 데이터에 여러 가지가 있지만 가장 필요한 내용은 셀렉터에 있는 필드명이기 때문에 ('Just "userId")
처럼 메타 데이터에 있는 문자열(Symbol Kind이다) 타입 레벨 리터럴을 값으로 가져올 수 있으면 된다. 하스켈 타입 레벨 프로그래밍에서 타입 레벨 리터럴을 값으로 가져오는 방법은 GHC.TypeLits
에 있는 symbolVal
함수를 사용하면 된다. symbolVal
을 직접 사용해서 가져올 수도 있지만 번거롭기 때문에 GHC.Generics
에서 셀렉터(S1
)의 이름을 가져올 수 있는 헬퍼 함수인 selName
을 제공한다. 따라서 셀렉터 타입의 값을 selName
에 넘기면 메타 데이터에 첫 번째 위치에 있는 필드명을 가져 올 수 있다. 필드명이 Maybe
타입인 이유는 data User = User Int String
처럼 필드명이 없는 데이터 생성자도 있기 때문이다.
-- >>> let (sUserId :*: _) = unM1 . unM1 . from $ User 1 "Eunmin"
-- >>> selName sUserId
셀렉터 값은 데이터 타입 (M1 :: D1 ...
) 아래 데이터 생성자 (M1 :: C1 ...
) 아래 :*:
데이터 생성자로 연결되어 있기 때문에 unM1
으로 감싸있는 값을 두 번 가져와 디스트럭처링을 해서 sUserId
에 셀렉터 하나를 바인딩했다.
이제 필드명을 가져오는 방법을 알았기 때문에 다음과 같이 D1
, C1
, S1
, :*:
타입에 따라 해야 할 일을 타입 클래스로 만들어 볼 수 있다.
class ToDoc f where
toDoc :: f a -> Document
instance (ToDoc a) => ToDoc (D1 c a) where
toDoc (M1 x) = toDoc x
instance (ToDoc a) => ToDoc (C1 c a) where
toDoc (M1 x) = toDoc x
instance (ToDoc a, ToDoc b) => ToDoc (a :*: b) where
toDoc (a :*: b) = toDoc a ++ toDoc b
instance (Selector s, Val a) => ToDoc (S1 s (K1 i a)) where
toDoc s@(M1 (K1 x)) = [ T.pack (selName s) =: val x ]
-- >>> toDoc . from $ User 1 "Eunmin"
-- [ userId: 1, userName: "Eunmin"]
여기서 :*:
타입인 경우 BSON Document 두 개를 ++
연결한 이유는 사실 BSON Document는 [Field]
타입 동의어로 리스트이기 때문에 두 개를 더 했다. 그리고 S1
타입에 대한 구현에서 T.pack
은 BSON 라벨이 Text
타입이기 때문에 String -> Text
하기 위해서 Data.Text
에 pack
함수를 사용했다.
이렇게 만든 버전은 aeson 라이브러리처럼 특정 타입 클래스의 인스턴스를 구현해야 사용할 수 있고 from
을 직접 사용하지 않아도 되게 하려면 ToDoc
타입 클래스를 타입 클래스의 default method로 감싸는 것이 편리하다.
class ToDoc a where
toDoc :: a -> Document
default toDoc :: (Generic a, ToDoc' (Rep a)) => a -> Document
toDoc a = toDoc' (from a)
class ToDoc' f where
toDoc' :: f a -> Document
instance (ToDoc' a) => ToDoc' (D1 c a) where
toDoc' (M1 x) = toDoc' x
instance (ToDoc' a) => ToDoc' (C1 c a) where
toDoc' (M1 x) = toDoc' x
instance (ToDoc' a, ToDoc' b) => ToDoc' (a :*: b) where
toDoc' (a :*: b) = toDoc' a ++ toDoc' b
instance (Selector s, Val a) => ToDoc' (S1 s (K1 i a)) where
toDoc' s@(M1 (K1 x)) = [ T.pack (selName s) =: val x ]
instance ToDoc User
-- >>> toDoc $ User 1 "Eunmin"
-- [ userId: 1, userName: "Eunmin"]
비슷한 방법으로 GHC.Generics
의 to
를 이용해 FromDoc
도 다음과 같이 만들 수 있다.
class FromDoc a where
fromDoc :: Document -> Maybe a
default fromDoc :: (Generic a, FromDoc' (Rep a)) => Document -> Maybe a
fromDoc a = (Just . to) =<< fromDoc' a
class FromDoc' f where
fromDoc' :: Document -> Maybe (f a)
instance (FromDoc' a) => FromDoc' (D1 c a) where
fromDoc' doc = M1 <$> fromDoc' doc
instance (FromDoc' a) => FromDoc' (C1 c a) where
fromDoc' doc = M1 <$> fromDoc' doc
instance (FromDoc' a, FromDoc' b) => FromDoc' (a :*: b) where
fromDoc' doc = (:*:) <$> fromDoc' doc <*> fromDoc' doc
instance (Selector s, Val a) => FromDoc' (S1 s (K1 i a)) where
fromDoc' doc = M1 . K1 <$> Mongo.lookup label doc
where
label = T.pack (selName (undefined :: S1 s (K1 i a) r))
instance FromDoc User
-- >>> (fromDoc . toDoc $ User 1 "Eunmin" :: Maybe User)
-- Just (User {userId = 1, userName = "Eunmin"})
몇 가지 포인트는 타입에서 값을 가져오는 selName
에 넘기는 값은 사실 값이 중요하지 않다는 점이다. 따라서 하스켈에서 예외가 발생하는 undefined
를 사용했다. undefined
를 사용해도 selName
에서 인자 값을 사용하지 않기 때문에 Lazy 평가를 하는 하스켈에서는 문제가 되지 않는다. 전체적으로 M1
을 이용해 제너릭 값을 만들고 to
를 이용해서 실제 타입 값으로 바꿔주는 일을 하는 코드이다.
GHC.Generics
에는 다양한 기능이 많고 아직 모르는 내용도 많지만 간단하게 필드명을 이용해서 BSON Document를 자동으로 변환하는 코드를 서비스에 적용해서 코드를 많이 줄일 수 있었다.