하스켈 GHC.Generics 탐험기

Eunmin Kim·2022년 6월 1일
4

하스켈 일기

목록 보기
1/12

역시 하스켈은 알아야 할 내용이 많은 것 같다. 어떤 발표에서 하스켈 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에 정의된 ToJSONFromJSON 타입 클래스의 인스턴스를 만들어 주면 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에 기본이 되는 fromto 함수를 사용해서 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으로 실제 값인 1Eunmin이 들어 있는 것을 볼 수 있었다. 그리고 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 S1GHC.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와 같은 타입 변수가 여럿 있는데 먼저 iS, 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.Textpack 함수를 사용했다.
이렇게 만든 버전은 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.Genericsto를 이용해 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를 자동으로 변환하는 코드를 서비스에 적용해서 코드를 많이 줄일 수 있었다.

profile
Functional Programmer @Constacts, Inc.

0개의 댓글