하스켈 GraphQL 서버 맛보기

Eunmin Kim·2022년 6월 5일
3

하스켈 일기

목록 보기
5/12

오늘은 하스켈로 GraphQL 서버를 가볍게 만들어보자. 이전에 Clojure나 Kotlin(Spring)으로 프로젝트할 때 GraphQL 서버를 만들어 본 적이 있고 각각 자주 사용하는 라이브러리가 있어 어떤 라이브러리를 쓸지 고민을 하지 않았다. 하지만 하스켈로 GraphQL 서버를 만들 수 있는 패키지는 몇 가지 있어 조금 고민을 했다. 우리 프로젝트는 클라이언트와 GraphQL로 통신하고 서버끼리는 gRPC로 통신하기 때문에 처음에 mu-haskell 패키지를 사용했다. 지금 배포된 mu-haskell도 gRPC와 GraphQL 서버를 만드는데 문제가 없지만 프로젝트 진행이 느린 것 같아 지금은 조금 더 많이 사용하고 있는 Morpheus GraphQL 패키지를 사용하고 있다. 특히 Morpheus GraphQL은 stackage resolver에 등록이 되어 있는데 mu-haskell은 등록되어 있지 않다. 물론 stack.yaml에 수동으로 추가해주면 문제가 없다.
Morpheus GraphQL는 많은 사람이 프로젝트에 기여하고 있지는 않지만 그래도 관리자가 열심히 업데이트하고 있다. 조금 익숙해지면 프로젝트에 기여를 해보는 것도 좋을 것 같다.
Morpheus GraphQL은 GraphQL 스키마를 기반으로 하스켈 코드를 생성하거나 아니면 직접 생성될 하스켈 코드를 작성해서 GraphQL 서버를 만들 수 있다. 물론 GraphQL Introspection을 지원하기 때문에 코드로 생성한 서버도 스키마를 얻을 수 있다. 하지만 GraphQL 스키마를 기반으로 작성하는 것이 편리하기 때문에 GraphQL 스키마를 기반으로 서버를 만드는 방식으로 작업하고 있다.
먼저 stack을 사용한다고 생각하고 package.yamlmorpheus-graphql 의존성을 추가한다. 글을 쓰는 시점 기준으로 morpheus-graphql 패키지의 최신 버전은 0.20.0이지만 우리가 쓰고 있는 stack 최신 resolver인 lts-18.28에는 morpheus-graphql0.17.0 버전을 사용하고 있다. 우리 프로젝트는 프로젝트 생성 당시 최신 버전인 0.18.0을 사용하고 있기 때문에 stack.yamlextra-deps0.18.0 버전을 다음과 같이 추가하고 개발하고 있다. 최신 버전에 몇 가지 하위 버전 지원하지 않는 업데이트가 있어 천천히 0.20.0으로 올려야겠다.

extra-deps:
  - morpheus-graphql-0.18.0
  - morpheus-graphql-core-0.18.0
  - morpheus-graphql-app-0.18.0
  - morpheus-graphql-code-gen-0.18.0

GraphQL 쿼리가 하나 있는 간단한 스키마를 만들어 서버를 만들어 보자.

type Droid {
  id: ID!
  name: String!
  primaryFunction: String
}

type Query {
  droid(id: String!): Droid!
}

Droid라는 간단한 타입과 droid라는 쿼리 하나가 있는 단순한 스키마이다. 이제 하스켈 코드에서 이 스키마를 읽어 하스켈 타입으로 만들자.

importGQLDocument "schema.graphql"

Data.Morpheus.Document 모듈에 importGQLDocument 함수는 파일명을 받는데 파일을 읽어 하스켈 템플릿으로 코드를 생성해준다. 생성되는 타입은 QueryDroid와 같은 타입이다. 그리고 생성된 코드에는 여러 가지 언어 확장을 사용하기 때문에 다음과 같이 언어 확장을 추가해준다.

{-# LANGUAGE DataKinds             #-}
{-# LANGUAGE DeriveGeneric         #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE TemplateHaskell       #-}
{-# LANGUAGE TypeFamilies          #-}

특히 생성된 타입의 필드명이 중복될 수 있기 때문에 DuplicateRecordFields를 추가하는 것을 잊지 말자. 다음은 쿼리 (또는 필드)에 대한 GraphQL resolver를 만들어 줘야 한다. 지금은 Query 밖에 없지만 Mutation과 Subscription resolver도 지원하기 때문에 Morpheus GraphQL은 RootResolver라고 하는 타입에 Query, Muation, Subscription resolver 함수를 지정해 준다. RootResolver 타입은 Query, Muation, Subscription에 대한 타입 변수를 받고 각 타입은 importGQLDocument가 생성해준다. 이 예제에서는 Query 타입이 생성되고 Query 타입은 droid라고 하는 resolver 함수를 필드로 갖는 데이터 타입이다.

rootResolver :: RootResolver IO () Query Undefined Undefined
rootResolver =
  RootResolver
    { queryResolver = Query { droid = resolveDroid }
    , mutationResolver = Undefined
    , subscriptionResolver = Undefined
    }

Muation, Subscription은 사용하지 않기 때문에 Morpheus가 제공하는 Undefined 타입과 데이터 생성자로 표시했다. 이제 resolveDroid라는 resolver 함수를 구현하면 된다. droid 쿼리에 대응하는 resolver 함수의 시그니처는 다음과 같다.

resolveDroid :: Arg "id" Text -> ResolverQ e IO Droid

함수의 인자 타입은 droid의 인자인 id에 해당하는 타입이다. Arg 타입을 사용해서 인자 이름과 같은 타입 리터럴 문자열을 주면 해당 인자에 순서와 상관없이 매칭 해서 쓸 수 있다. 리턴 타입은 ResolverQ인데 두 번째 타입 변수인 IO를 베이스 모나드로 하는 모나드 트랜스포머다. ResolverQResolver QUERY의 타입 동의어로 Muation resolver의 경우에는 ResolverM을 쓴다. 또는 풀어서 Resolver MUTATION e IO Droid이라고 해도 된다. 첫 번째 타입 변수는 이벤트라고 하는데 아직 쓰는 법을 찾아보지 않았다. 이제 ResolverQ에 맞춰 resovler를 구현해주면 된다. 그냥 정해진 값을 내려주는 형태로 구현했다.

resolveDroid :: Arg "id" Text -> ResolverQ e IO Droid
resolveDroid (Arg droidId) =
    pure Droid
            { id = pure $ ID droidId
            , name = pure "R2-D2"
            , primaryFunction = pure Nothing
            }

하스켈 템플릿으로 생성된 Droid 타입은 필드 타입 역시 Resolver 타입이다. 그래서 필드별로 따로 resolver를 만들 수 있다. 여기서는 pure(또는 return)를 써서 ResolverQ로 만들어 줬다. primaryFunction의 경우는 필수 값이 아니기 때문에(스키마에 !가 없다) Maybe 타입으로 생성된다.
이제 RootResolver을 GraphQL 요청과 응답으로 바꿔주는 함수를 사용하면 거의 완성된다. Data.Morpheus 모듈에 있는 interpreter의 인자로 RootResolver 타입을 넘겨주면 된다. 여기서는 ByteString 형식의 GraphQL 요청을 받아 ByteString 형식의 응답을 주도록 했다.

gqlApi :: ByteString -> IO ByteString
gqlApi = interpreter rootResolver

gqlApi 함수에 문자열로 GraphQL 요청을 넣어 보면 결과가 잘 나오는 것을 볼 수 있다.

-- >>> gqlApi "{\"query\": \"{ droid (id:\\\"UjItRDIK\\\") { name } } \"}"
-- "{\"data\":{\"droid\":{\"name\":\"R2-D2\"}}}"

이제 웹 인터페이스를 붙여보자. /graphql POST 요청에 응답하는 간단한 동작이기 때문에 간단한 scotty 패키지를 사용하자.

main :: IO ()
main = scotty 8080 $ do
    post "/graphql" $ raw =<< (liftIO . gqlApi =<< body)

scotty 모나드 컨텍스트에서 body 함수로 값을 가져와 gqlApi로 넘긴다. 그리고 raw 함수로 문자열 그대로 응답해주도록 연결했다. 여기까지 하면 POST /graphql로 GraphQL 요청을 받을 수 있다. 편하게 확인하기 위해 Graphql Playground UI를 붙여보자. 다음과 같이 graphql-playground.html 파일을 만들고 scotty 라우터에 추가하자.

<!DOCTYPE html>
<html>

<head>
  <meta charset=utf-8 />
  <meta name="viewport" content="user-scalable=no, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, minimal-ui">
  <title>GraphQL Playground</title>
  <link rel="stylesheet" href="//cdn.jsdelivr.net/npm/graphql-playground-react/build/static/css/index.css" />
  <link rel="shortcut icon" href="//cdn.jsdelivr.net/npm/graphql-playground-react/build/favicon.png" />
  <script src="//cdn.jsdelivr.net/npm/graphql-playground-react/build/static/js/middleware.js"></script>
</head>

<body>
  <div id="root">
    <style>
      body {
        background-color: rgb(23, 42, 58);
        font-family: Open Sans, sans-serif;
        height: 90vh;
      }

      #root {
        height: 100%;
        width: 100%;
        display: flex;
        align-items: center;
        justify-content: center;
      }

      .loading {
        font-size: 32px;
        font-weight: 200;
        color: rgba(255, 255, 255, .6);
        margin-left: 20px;
      }

      img {
        width: 78px;
        height: 78px;
      }

      .title {
        font-weight: 400;
      }
    </style>
    <img src='//cdn.jsdelivr.net/npm/graphql-playground-react/build/logo.png' alt=''>
    <div class="loading"> Loading
      <span class="title">GraphQL Playground</span>
    </div>
  </div>
  <script>window.addEventListener('load', function (event) {
      GraphQLPlayground.init(document.getElementById('root'), {
        // options as 'endpoint' belong here
        endpoint: "/graphql"
      })
    })</script>
</body>

</html>
startServer :: IO ()
startServer = scotty 8080 $ do
    post "/graphql" $ raw =<< (liftIO . gqlApi =<< body)

    get "/graphql-playground" $ do
        setHeader "Content-Type" "text/html; charset=utf-8"
        file "graphql-playground.html"

브라우저에서 localhost:8080/graphql-playground을 띄워 확인해 볼 수 있다.

Morpheus GraphQL의 간단한 기능만 살펴봤다. 나중에 조금 더 알게 되면 자세한 내용을 남겨보겠다. 전체 코드는 아래!

{-# LANGUAGE DataKinds             #-}
{-# LANGUAGE DeriveGeneric         #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE TemplateHaskell       #-}
{-# LANGUAGE TypeFamilies          #-}

module Main where

import           Control.Monad.Trans
import           Data.ByteString.Lazy.Char8 (ByteString)
import           Data.Morpheus
import           Data.Morpheus.Document
import           Data.Morpheus.Types
import           Data.Text
import           System.Environment         (getArgs)
import           Web.Scotty

importGQLDocument "schema.graphql"

resolveDroid :: Arg "id" Text -> ResolverQ e IO Droid
resolveDroid (Arg droidId) =
    pure Droid
            { id = pure $ ID droidId
            , name = pure "R2-D2"
            , primaryFunction = pure Nothing
            }

rootResolver :: RootResolver IO () Query Undefined Undefined
rootResolver =
  RootResolver
    { queryResolver = Query { droid = resolveDroid }
    , mutationResolver = Undefined
    , subscriptionResolver = Undefined
    }

gqlApi :: ByteString -> IO ByteString
gqlApi = interpreter rootResolver

main :: IO ()
main = scotty 8080 $ do
    post "/graphql" $ raw =<< (liftIO . gqlApi =<< body)

    get "/graphql-playground" $ do
        setHeader "Content-Type" "text/html; charset=utf-8"
        file "graphql-playground.html"

오늘도 끗!

profile
Functional Programmer @Constacts, Inc.

0개의 댓글