이 글에서는 TypeScript + Apollo GraphQL을 기준으로 한다. 이 글에서는 GraphQL의 핵심이라 생각되는것들을 위주로 본다. GraphQL Client 중 Apollo를 사용한다
최근에는 모두 RESTful API 방식으로 API를 작성한다. RESTful은 Representational State Transfer의 약자로, 리소스를 이름으로 구별하여 리소스 정보를 주고 받으며, HTTP method GET,POST,PUT(or PATCH),DELETE를 사용하여 자원에 대한 CRUD를 한다. 그리고 RESTful API만의 설계 규칙 또한 있다.(이 글에서는 생략)
ex) 예시
GET /member/profile/:id
POST /member/auth/
DELETE /member/post/:id
RESTful API는 이미 우리 일상속에 녹아들었다. 지금 이 글을 볼때 들어온 URI 또한 RESTful API가 쓰였을꺼라는 예상을 감히 해본다.
그렇다면 이 글에서 다루고자 하는 GraphQL은 무엇일까? 우선 GraphQL 공식 홈페이지를 들어가면 아래와 같이 나와있다.
말 그대로 GraphQL은 Query Language로, API를 만들때 사용할 수 있다.GraphQL 서버에서는 쿼리가 실행될 때 마다 타입 시스템에 기초해 쿼리가 유효한지 검사해야하며, GraphQL 서비스를 만들기 위해서는 GraphQL 스키마에서 사용할 타입을 정의해야한다.
GraphQL은 Declarative Data Fetch Language 라고 한다. 어떤 데이터가 필요한지에 대한 요구사항만 작성하면 되고, 어떻게 가져올 것인지는 신경쓰지 않아도 된다. 다양한 언어들에 대한 지원 또한 한다.
결국 GraphQL은 클라이언트와 서버간의 통신 명세라고 정리할 수 있다.
RESTful API 또한 설계하는 규칙이 있다.(참고) 동일하게, GraphQL 또한 설계 규칙이 있다.
GraphQL의 전반적인 흐름은 아래와 같이 생겼다. 개발자는 GraphQL Query 작성과 Resolver에 대한 작성을 해주면 된다. GraphQL Query는 .graphql
이라는 파일에 생성을 해주면 되며, Resolver는 GraphQL Query에 명시한, Query Logic을 처리해주는 부분이다.
사진에서 볼 수 있듯이, Resolver는 데이터 베이스에서 바로 CRUD를 진행할 수 도 있으며, RESTful API와의 연계를 통해 CRUD를 진행할 수 도 있다. 대부분의 경우에서는 RESTful API와의 연계를 통해 GraphQL을 설계한다고 한다.
RESTful API같은 경우, 각각의 endpoint에 대해서 따로따로 요청을 보내야 하지만, GraphQL은 하나의 endpoint에서 쿼리를 작성하여 원하는 정보에 대해 원하는 형태로 데이털르 가져올 수 있다.
예를 들어 특정 배우의 이름, 키, 몸무게만 필요하다고 가정하자 RESTful API는 해당 배우에 대한 정보를 들고오게 되면 아래와 같이 불필요한 Payload까지 반환하는것을 볼 수 있다.
반면에 GraphQL은 자신이 필요한 필드만 정의를 하여 불러오면 불필요한 필드에 대해서는 반환하지 않는다.
Payload의 용량 크기가 많이 줄어들게 된다. 이렇게 되면, Front-End에서의 후처리 과정이나, 네트워크 오버헤드 측면에서 이점을 가져올 수 있다.
만약 한 영화의 출연 배우들 각각의 기존 출연 영화를 보고싶다고 가정해보자. RESTful API에서는 우선 영화의 정보를 가져와 출연배우 목록을 들고와야한다.
위 사진처럼 api 링크가 나올 수 도 있고, 각각의 출연 배우의 아이디가 나올 수 도 있다. 문제는 각각의 배우에 대해서 일일히 요청을 다시 보내고, 위에서 봤던것처럼, 불필요한 데이터들이 포함된 Payload에서 내가 필요한 값에 대해 처리해야한다.
반면, GraphQL을 사용하면, 아래와 같이 한번에 영화 출연자들과, 출연영화를 한번에 알 수 있다.
GraphQL에는 Root Type이라는 특별한 타입이며, Root Type에는 Query, Mutation이 있다. 이 두 타입 안에는 쿼리 요청을 만들기 위해서 쿼리의 원형(Prototype)을 정의해 놓는다. 사실 이 두개는 내부적으로 다른점이 없다. 사용할때 차이를 두는데 주로 데이터를 Read
할때는 Query
를 사용하고, Create
,Update
,Delete
할때는 Mutation
을 사용한다.
일반적인 쿼리문은 위와 같은 형태로 작성할 수 있다. 각각이 의미하는것을 살펴보자
Query
, Mutation
, Subscription
이 세가지를 의미한다.위에서 보았던 Variable Definition
은, 쿼리에 필요한 매개변수를 의미한다. 예를 들어 아래와 같은 Mutation이 있다고 가정하자
type Mutation{
...
deleteProduct(name: String, price: Int, maintainers:[String!]!): Boolean
...
}
이런 경우, GraphQL Server에 요청을 보낼 때에 Request Body의 variables
필드로 Object 타입 명시를 해주면 된다.
await axios.post('(GraphQL Server)', {
query: '(GraphQL Query)',
variables: {
"name" : "",
"price" : 0,
"Maintainers" : ["",""...]
},
operationName: null,
})
만약 다른 Muataion에서도 동일한 Variable Definition을 갖는다면, 매개변수를 다시 작성해 줘야하는 수고스러움이 생긴다. 이러한것을 방지하여, Variable Definition을 한번에 해줄 수 있는 Input Type
이 있다.
위의 deleteProduct Mutation이 가지고 있는 세개의 Variable Definition을 하나의 Input Type으로 묶어보자
input CommonVariableDefinition{
name: String
price: Int
maintainers:[String!]!
}
위에서 정의한 input type을 가지고 Mutation 정의와, Request Body를 바꿔보자
# Muataion
type Mutation{
...
deleteProduct(input: CommonVariableDefinition): Boolean
...
}
// Request Script
await axios.post('(GraphQL Server)', {
query: '(GraphQL Query)',
variables: {
"input" : {
"name" : "",
"price" : 0,
"Maintainers" : ["",""...]
}
},
operationName: null,
})
Mutation의 Variable Definition에 정의한 매개변수를 받는 이름(여기서는 input)을 variables
필드의 Key로 지정하고, 해당 input 필드에 맞는 값들을 적어주면 된다. input타입 또한 당연히 여러개 받을 수 있다. 예시 GraphQL 쿼리와 요청 객체를 작성해 본다.
# GraphQL Queries
input CommonVariableDefinition{
name: String
price: Int
maintainers:[String!]!
}
input OtherDefinition{
ok: Boolean
value: String
}
input thirdDefinition{
changedPrice: Int
}
type Mutation{
...
someMutation(normalValue:Int,cd:CommonVariableDefinition,od:OtherDefinition,td:thirdDefinition):String
...
}
// Request Script
await axios.post('(GraphQL Server)', {
query: '(GraphQL Query)',
variables: {
"normalValue" : 10,
"cd": {
"name" : "",
"price" : 0,
"Maintainers" : ["",""...]
},
"od" : {
"ok" : true,
"value" : ""
},
"td" : {
"changedPrice" : 0
}
},
operationName: null,
})
Query, Mutation 각각 Resolver(뒤에서 본다)로 구현할 쿼리의 일종의 함수 원형(Prototype)을 작성해 둔다. 서버측에서 작성한 Query
타입은 클라이언트에서 query
Operation Type으로, Mutation
타입은 클라이언트에서 Mutation
Operation Type으로 작성한다. 각각 서버, 클라이언트 관점에서 Query, Mutation의 쿼리는 아래와 같이 작성할 수 있다.
# GraphQL Client
query GetProduct($getProductId: ID!) {
getProduct(id: $getProductId) {
description
id
name
}
}
# GraphQL Client : Request variable
{
"getProductId": "(id)"
}
# GraphQL Server
type Query{
getAllProduct:[Product!]!
getProduct(id: ID!): [Product!]!
}
# GraphQL Client
mutation Mutation($input: ProductInput) {
addProduct(input: $input)
}
# GraphQL Client : Request variable
{
"input": {
"description": null,
"name": null,
"price": null
}
}
# GraphQL Server
type Mutation{
addProduct(input: ProductInput): Boolean
updateProduct(input: UpdateProductInput): Boolean
deleteProduct(input: DeleteProductInput): Boolean
}
Schema는 데이터베이스에서의 Schema를 생각하면 된다. 스키마에는 타입 정의를 모아둔다. 스키마는 type
이라는 키워드를 통해 정의하며, 이는 GraphQL의 핵심 단위이다. GraphQL에서 타입은 커스텀 객체이며, 이를 보고 애플리케이션의 핵심 기능을 알 수 있다.
스키마 안에는 필드가 들어가며, 이 필드는 각 객체의 데이터와 관련 되어있다. 필드는 각 객체의 데이터와 관련이 있으며, 특정 데이터를 반환한다. 데이터를 반환할때 GraphQL의 기본 자료형(아래에서 본다)을 반환하거나, 커스텀 타입, 혹은 여러 타입을 리스트로 묶어 반환할 수 도 있다. 위에서 봤던 Query의 쿼리문들처럼, Variable Definition을 줄 수 있고, 그에 맞는 Resolver 또한 구현해줄 수 있다.
type ExampleType{
id: ID!
name: String!
getExamples(id:Int):[Boolean!]!
}
// Resolver Code
...
ExampleType: {
getExamples: (parent: any, args: queryInput) => {
//logics
}
}
...
GraphQL의 기본 자료형에는 두가지 타입이 있다. 하나는 Scalar Type, 또 하나는 Object Type이다. Object Type는 일반적인 JavaScript Object 타입과 동일하므로 따로 다루지 않겠다. GraphQL의 Scalar Type에는 총 다섯가지 스칼라 타입이 내장되어있다.
기본적으로 위 5가지 타입이 있지만, Scalar Type은 Custom Scalar Type을 만들 수 있다. scalar
이라는 키워드로 정의하고자 하는 커스텀 스칼라 이름을 정해준다.
# Custom Scalar Type
scalar Datetime
그 다음, 각자 GraphQL 쿼리를 작성하는 언어를 이용하여 Custom Scalar Type을 정의해준다. Scalarserialize
, parseValue
, parseLiteral
총 세가지 메소드에 대한 구현이 필요하다.(Document)
import { GraphQLScalarType, Kind } from 'graphql'
export const dateScalar = new GraphQLScalarType({
name: 'Date',
description: 'Date custom scalar type',
// 내부적인 표현을 외부적인 표현으로 바꾸는것
serialize(value) {
if (value instanceof Date) {
return value.getTime();
}
throw new Error('GraphQL Date Scalar serializer expect instance of Date')
},
/**
* parseValue, parseLiteral : 외부적인 표현을 내부적인 표현으로 변경
*/
// Query Variable에 선언된것을 value라고 함. Input Type을 사용하는 경우이다.
parseValue(value) {
if (typeof value === 'number') {
return new Date(value)
}
throw new Error('GraphQL Date Scalar Parser expect a \'number\'')
},
// 직접 literal 형태로 기재한 값을 의미한다. Input Type을 쓰지 않고 literal 형태로 기재하는 경우이다.
parseLiteral(ast) {
if (ast.kind === Kind.INT) {
return new Date(parseInt(ast.value, 10))
}
return null;
}
})
GraphQL은 데이터를 가져오고 가공하고 반환하는 과정을 직접 구현해야한다. 각 언어에서 지원하는 GraphQL에 대한 라이브러리들은, GraphQL 쿼리문을 파싱하고 처리하는 역할만 하게 된다.
Resolver를 구현하는데 있어 제약이 있지 않다. ORM을 사용하는등 데이터베이스에서 직접 가져올 수 도 있고, 파일에서 가져올 수 도 있고, RESTful API를 호출하여 처리할 수 도 있다. RESTful API를 이용하여 Legacy System들에 대한 변환을 줄 수 도 있다.
type Query{
getAllProduct:[Product!]!
getProduct(id: ID!): [Product!]!
}
export const resolver = {
...
Query: {
getAllProduct: (parent: any, args: queryInput) => {
return list
},
getProduct: (parent: any, args: queryInput) => {
const result = list.filter((product: Product) => {
return product.id === parseInt(args.id, 10)
})
return result
},
},
Mutation: {
...
}
}
그렇다면 GraphQL이 RESTful API 방식을 대체할 수 있을까? 개인적인 생각으로는 아직 이르다고 생각한다. GraphQL을 활용하면 아래와 같은 이점을 얻을 수 있다.
그렇다면 반면에 어떤 단점이 있을까?
query
필드를 통해 쿼리를 넘겨줘야한다. 하지만 이 과정에서. RESTful API에 비해 Request 크기가 더 커질 수 도 있다.그리고 개인적인 의견으로 GraphQL의 쿼리를 작성하는 과정도 그리 쉽지 않았다. 또한 추후 새로 추가될 스키마에 대해서도 연관관계를 생각하기 시작하면, 복잡해지기 시작한다.
개인적인 생각으로는 GraphQL과 RESTful API의 장점을 혼합하여 운용하는것이 현재로서는 바람직하다고 생각하였다. 예를들어 GraphQL을 Front-End와 RESTful API의 사이에 두고, API의 결과를 Wrapping하는 방식을 예로 들 수 있다.
만약 데이터 베이스와 직접 연결을 하여 구현을 한다면, 개인적으로 MongoDB가 가장 잘 어울린다고 생각을 하였다. GraphQL은 기본적으로 처리 및 반환형태를 JSON타입을 가지고 있으며, MongoDB Schema 또한 JSON과 거의 동일한 형태를 가지고 있기 때문에 GraphQL의 Resolver에서도 처리를 하기 편하며, MongoDB로부터 값을 불러와 반환하기에도 처리량이 비교적 적게 체감되었기 때문이다.
GraphQL을 가지고 간단한 CRUD 코드를 작성해 보았다.
GraphQL을 공부해보며, GraphQL을 활용한 간단한 아키텍쳐를 그려보며 정리를 마친다.