GraphQL을 오해하다

이번엔 GraphQL을 처음 접한 순간부터, 토이 프로젝트(서버)를 만드는 동안 내가 겪었던 착오와 오해, 햇갈렸던 개념들을 복습해보고자 한다. GraphQL이 대체 뭔가 하는 질문에 대해서는 다른 글을 보길 바란다. GraphQL을 전혀 모르는 상태에서 이 글을 본 다면 큰 도움이 되진 않을 것 같다.

Specification

GraphQL은 라이브러리다?

GraphQL이 무엇인지 알게 되는데에도 한참이 걸렸다. 나의 첫 오해는 GraphQL이 무언가 기능을 하는 라이브러리 같은 도구일 것이라는 착각이었다. 알고보니 GraphQL은 REST같은 API 디자인에 관한 새로운 관점의 스펙이었다. 어떤 언어를 사용하건 GraphQL의 구현체 — 마치 Flux에 여러가지 구현체가 존재하듯 — 를 사용하여 GraphQL 스펙에 맞는 코드를 짜야 한다. 가장 기본적으로 사용할 수 있는 구현체로는 Reference Implementation이라는 graphql-js 가 있었다. 하나의 언어(특히 자바스크립트)에 여러 가지 구현체가 존재하기도 한다. 찾아보니 꽤 많은 언어에 GraphQL 구현체가 이미 존재했다.

기존 엔드포인트에 GraphQL을 적용할 수 있다?

기존 REST로 구현된 API는 GET /contents, POST /contents, DELETE /contents/:contentsId, GET /posts, DELETE /posts/:postId 같은 방식으로 개발되어 있었고, 이 구조를 비슷하게 유지하면서도 GraphQL로 바꿀 수 있을 거라고 생각했다. 물론 그렇게 하는 것이 불가능한 것은 아니다. 하지만 GraphQL은 단일 엔드포인트를 권장하고 있었다. 모든 요청을 /graphql 한 곳에서 처리하는 것이다. REST가 URL과 Resource를 매칭시키는 개념적 모델을 사용했기 때문에 수많은 엔드포인트를 사용했다면, GraphQL의 개념적 모델은 모든 리소스가 그래프처럼 서로 연결되어있기 때문에 URL을 분리할 필요가 없다. 서버는 리소스를 가져오는 명령어(Query), 혹은 어떤 리소스를 변경하기 위한 명령어(Mutation)만 제공하면 된다. GraphQL에서 필요로 하는 엔드포인트는 Query Language 입력을 받기 위한 하나의 창구로 충분하다. GraphQL의 핵심은, 리소스를 url이 아니라 Query를 통해 표현하는 것이다.

그렇다면 다른 관점에서, 기존에 사용하던 REST와 GraphQL을 같이 사용할 수는 없을까? 아주 간단하다. 기존 REST API는 그대로 놔두고, /graphql 엔드포인트만 새로 만들면 된다.

Implementation

GraphQL은 MySQL과 어울리지 않는다?

사실 GraphQL과 MySQL같은 RDB는 꽤 잘 맞는다. DB에서의 테이블과 같은 표현이 결국 GraphQL의 엔티티로 그대로 매칭될 수 있기 때문이다. 다만 MySQL과 같은 DB에서 성능의 중요한 키가 되는 JOIN명령어를 GraphQL에서는 거의 사용하지 않고 다른 방식으로 풀어낸다는 점에서, JOIN의 성능상의 이득을 얻을 수 없기 때문에 어울리지 않는다는 생각이 조금 들었다. 차라리 MongoDB같은 DB가 GraphQL과 더 어울리는게 아닐까?

GraphQL의 장점은 클라이언트의 요청에 따라 데이터를 더 가져오거나 가져오지 않거나가 결정된다는 점이다. 서버 개발자가 결정하는 것은 엔티티 사이의 관계, 그리고 그 관계에 따라 데이터를 어떻게 가져올 지 뿐이다(같은 댓글 목록을 가져오더라도, post.comments와 user.comments, comments.comments일 때를 각각 구현한다). 만약 REST API에서 같은 리소스를 표현하면서도 성능상의 문제로 JOIN을 많이 하는 API와 덜 하는 API를 나눠서 구현했었다면, GraphQL을 사용한 경우 이런 고민을 서버에서는 더이상 하지 않게 된다. 클라이언트가 각자 자신의 상황에 맞게 (데스크톱/모바일 등) 적절한 Depth로 요청을 끊어서 처리할 수 있게 되었기 때문이다.

성능에 대한 고려가 깊어질 정도의 요청은 GraphQL로 따지자면 요청하는 데이터의 Depth가 깊다는 것이 될 것이다. 이러한 요청을 피하기 위해서 대개 쿼리의 maxDepth를 설정하여 요청을 제한하는 방식을 주로 구현하여 적용하는 듯 하다.

물론, GraphQL을 사용하면서도 JOIN을 사용하고자 하는 여러 사람들이 있었고, 여기에 대한 해결책으로 join-monster 와 같은 라이브러리가 등장하기 시작했다. 이에 대해서는 아직 제대로 찾아보진 못했지만, 비슷한 고민을 했다면 한 번 들여다보기 바란다.

첨언하자면, GraphQL의 설계에는 서로 다른 종류/위치의 데이터 소스에 접근해야 하는 경우에 대한 고민도 들어있다. MySQL, MongoDB, redis를 동시에 사용하는 경우에도 GraphQL은 요청에 따라 필요한 데이터 소스에만 접근하도록 동작한다.

Schema를 정의하는 두 가지 방법

GraphQL 사이트를 보면, schema 정의, 혹은 타입 정의 같은 것들이 나온다.

type Character {
name: String!
appearsIn: [Episode]!
}
type Query {
hero(episode: Episode): Character
droid(id: ID!): Droid
}

GraphQL이 단순한 스펙에서 벗어나 API 서버의 인터페이스로 동작하기 위해서는 이러한 타입 정의 뿐만이 아니라, 이러한 타입의 데이터를 어떻게 가져올 지가 동시에 정의되어야 한다. 때문에 graphql-js에서는 다음과 같은 방식으로 스키마를 정의한다.

var schema = new GraphQLSchema({
query: new GraphQLObjectType({
name: 'Character',
fields: {
name: {
type: GraphQLString,
resolve() {
return 'name';
}
},
appearsIn: {
type: GraphQLList(Episode),
resolve() {
return [];
}
}
}
})
})

GraphQL의 원형 스펙과는 많은 부분 달라 보이지만, 각 필드에 대한 정보를 한 눈에 볼 수 있고, 어떤 언어로 표현하더라도 일정한 형태를 보일 수 있다. graphql-js가 레퍼런스 코드이기 때문인지, 대다수 타 언어 GraphQL 라이브러리들이 이런 형식으로 구현되어 있다.

graphql-tools는 기존의 깔끔한 타입 선언을 포기하지 않고 코딩할 수 있도록 도와준다.

// define type by raw string
const Comment = `
type Comment {
id: Int!
message: String
author: String
}
`;
// Somehow get data from DB
const CommentResolver = () => { }
export const schema = makeExecutableSchema({
Comment,
CommentResolver,
});

개인적으로는 GraphQL의 원본 스키마가 살아있는 나중에 소개한 방식을 더 선호한다. 그러나 타입과 상호 타입간의 연결 및 의존성이 증가해 타입이나 스키마를 단순하게 정의하기가 복잡해진 경우, 여러 종류의 type과 resolver를 schema로 조합하는 과정 역시 복잡해진다. 나의 경우, 이 부분에서 꽤 고민을 했었다.

DataLoader의 역할

DataLoader는 GraphQL을 사용하다 보면 일어나기 쉬운 1+N 문제를 1+1으로 변환시켜주는 자바스크립트 라이브러리다. 자바스크립트에서 이벤트 루프가 돌아가는 한 사이클동안 들어온 id 기반 요청을 모아 배치로 처리한 후 값을 되돌려주는 방식으로 문제를 해결한다. GraphQL의 성능을 위해서는 필수적으로 사용해야 하는 라이브러리이다.

캐싱 기능 역시 내장하고 있는데, 여기서의 캐싱은 하나의 클라이언트 요청이 처리되는 동안 일어나는 여러번의 DB 요청 사이의 중복을 줄이는 방식의 캐싱이다. 그러니까, 깊이가 깊은 리소스의 Graph를 요청한 경우, 동일한 노드를 여러번 참조하게 될 수도 있다. 예를 들자면 포스트 — 코멘트 — 유저 — 포스트 — 코멘트 — 유저 와 같은 recursive한 6depth짜리 요청이 들어온 경우, 세 번째 depth의 유저 노드들과 여섯 번째 depth의 유저 노드들 사이에 중복이 있을 가능성이 농후하다. 이런 경우, 단일 요청이 처리되는 동안만 캐싱을 하여 Node의 중복 방문에 대한 캐싱 기능을 한다. DataLoader의 Readme.md 에서도 Redis나 Memcache와는 전혀 다른 용도라는 점을 강조하고 있다.

물론, 이 정도로 깊은 요청이 필요하다는 것 부터가 좋은 설계가 아니다.

Mutation

GraphQL의 mutation은 REST의 GET이 아닌 모든 변화를 일으키는 요청을 포함한다. Mutation을 구현할 때엔 두 가지 어려움을 겪었었다.

input type !== (output) type

만약 새로운 게시물을 등록한다고 하면, 이 포스트를 등록하기 위해서는 저자가 누구인지, 어떤 게시판에 올리는 지와 같은 정보를 함께 전달해야 한다. 하지만 이때의 정보는 저자에 대한 모든 정보가 아닌 저자의 id만 전달해도 충분하다. 쿼리(GET)할 때 사용되는 포스트라는 타입에서 저자 노드에 대한 엣지가 존재하는 것과 비교하면 같은 Post여도 같은 타입이 아니란 걸 알 수 있다. 따라서 GraphQL에는 input 타입이 별도로 존재하며, input 타입을 output으로 사용할 수 없도록 제약이 걸려 있다.

Content-Type: “application/graphql” ?

GraphQL은 기본적으로 Content-Type: “application/json” 을 사용한다. 문서에 의하면 Content-Type: “application/graphql” 역시 사용 가능하다. 하지만 두 요청 사이에는 큰 차이점이 있다는 점을 미처 읽지 못하고 사용했다가 낭패를 본 경험이 있다.

Content-Type: “application/json” 의 경우, request body는 다음 형식을 따라야 한다.

{
"query": "...",
"operationName": "...",
"variables": { "myVariable": "someValue", ... }
}

Content-Type: “application/graphql” 은, Mutation이 아닌 Query 요청을 위한 축약형 body를 사용할 수 있게 해 준다. application/json을 사용했다면, query 필드 내부에 존재했어야 할 텍스트를 그대로 사용하는 셈이다.

{
human(id: "1000") {
name
height(unit: FOOT)
}
}

이 차이점 때문에, 대부분의 (variables를 사용하게 되는) Mutation 요청에서 Content-Type: “application/graphql” 을 사용하는 것은 귀찮은 일이 된다. variable을 사용하면 GraphQL이 할 일을, 직접 string template을 사용하여 mutation query를 만들어내야 하기 때문이다. 때문에 많은 개발자들이 요청에 따라 각각 Content-Type을 설정하는 작업을 하기 보다는, Content-Type을 application/json으로 통일하여 사용하게 될 것이라고 생각한다.

이상으로, GraphQL을 사용하면서 겪었던 수많은 문제들 중 인상깊게 기억에 남은 몇몇 단편적인 기억들을 정리하는 것을 마친다. 사실은, GraphQL에 대한 소개글을 적고 싶었으나, 아 이런 이야기도 하고 싶은데, 흐름을 해치는 것 같아 라는 생각때문에 자꾸만 글이 막혔었다. 이제 그런 잡생각들을 모두 쏟아냈으니, GraphQL을 소개하는 글을 다시 적을 수 있을까 싶으면서도, 글 서두에 링크로 걸어둔 글이 너무 잘 되어있어 굳이 내가 더 부족한 글을 생산해내는 것이 의미가 있을까 하는 고민도 든다.

이 글이 GraphQL을 이제 막 써보고 있는 사람들에게 도움이 되었으면 좋겠다.

Show your support

Clapping shows how much you appreciated HyeonSeok Yang’s story.