📝

GraphQL subscription 도입

Created
2024/04/12 09:33
Tags
안녕하세요. Bold9 서버개발팀 박연호 입니다.
이번에 저희 서비스에 subscription을 적용하면서 공부했던 내용을 공유하려고 합니다.
Graphql 스펙에서는 3개의 operations 정의하고 있습니다. 주로 사용하는 query, mutation 이외에 subscription이 존재합니다. 문서에서는 데이터를 가져오는 장기 요청 이라고 설명하고 있습니다.
query, mutation은 서버에서 응답을 받고 연결이 종료되지만, subscription은 서버와/클라이언트의 연결이 계속 유지되면서 데이터를 서버로부터 지속적으로 받아올 수 있으며 WebSocket을 사용하여 구현할 수 있습니다.
이 글에서는 저희 회사 스펙인 apollo server와 nexus를 사용하여 subscription을 구현해 보도록 하겠습니다.

Subscription 구현

게시글이 생성되었을 때, 화면에서 생성된 업데이트를 표시해 주는 방법은 모든 게시글을 조회하는 query를 호출하면 됩니다. 하지만 query를 호출하는 대신, 프론트에서 게시글이 생성되었다는 이벤트를 subscription으로 받도록 코드를 작성해 보겠습니다.
import { RedisPubSub } from "graphql-redis-subscriptions"; const pubsub = new RedisPubSub({ connection: "redis url", }); const mutation = extendType({ type: "Mutation", definition(t) { t.field("createPost", { type: "Post", args: { content: stringArg(), }, resolve: async (parent, args, ctx, info) => { /** * DB 게시글 데이터 생성 */ await pubsub.publish("createPost", { --- (4) data: { content: args.content, }, }); return { author: args.author, content: args.content, }; }, }); }, }); const subscription = subscriptionType({ definition(t) { t.field("createPost", { type: "Post", subscribe: () => { return pubsub.asyncIterator("createPost"); --- (2) }, async resolve(eventPromise) { --- (5) const event: any = await eventPromise; return event.data; }, }); }, });
JavaScript
복사
mutation{ createPost( --- (3) content:"content", ) { content } } subscription{ --- (1) createPost { --- (6) content } }
JavaScript
복사
전체적인 핵심 코드는 위와 같습니다. 서버에서 createPost subscription이 추가되었고, 프론트에서는 createPost를 호출하고 있습니다. 그리고 서버 코드에 pubsub이라는 보이는데, pubsub은 이벤트의 중간 매개체 역할을 하게 됩니다.
먼저 프론트에서 createPost subscription을 호출하게 되면(1), subscribe 메서드의 pubsub.asyncIterator("createPost")가 실행됩니다(2). 여기서 일어나는 일은 redis의 createPost채널을 subscribe하게 됩니다. 이 부분이 핵심입니다.
즉 프론트에서 subscription를 실행한다는 것은 redis의 특정 채널을 subscribe하고 있다는 것이며, 이후에 mutation에서 publish하게 될 때, 실제로 redis의 특정 채널에 publish가 되어 해당 데이터를 읽어오는 것입니다.
1712830775.288263 [0 192.168.65.1:58736] "subscribe" "createPost"
JavaScript
복사
이후 프론트에서 게시글 생성 요청(createPost mutation)을 호출하면(3) DB에 게시글 생성 후, 데이터와 함께 특정 채널(”createPost”)로 publish하게 됩니다(4). 이때 redis에 위의 채널로 데이터와 함께 publish가 됩니다.
1712830775.288263 [0 192.168.65.1:58736] "subscribe" "createPost" 1712830865.673923 [0 192.168.65.1:58737] "publish" "createPost" "{\"data\":{\"content\":\"content\"}}"
JavaScript
복사
앞서 프론트가 createPost subscription을 호출하면서, (2)번에서 redis의 createPost 채널에 팔로우하고 있었습니다. 이제 해당 채널에 데이터가 들어왔으니, 가져와야겠죠 ?
해당 데이터는 (5)의 resolve 함수의 전달 인자로 담아져 오게 됩니다. 이후 해당 데이터를 가공 후 return하게 되면 프론트의 (6)번에서 받게 됩니다.
크롬 개발자도구 → Network → WS 부분을 보면, 서버에서 createPost mutation을 호출할 때마다 데이터를 계속 받아오는 것을 확인할 수 있습니다.
이후 프론트에서 subscription 연결을 끊게 되면 마찬가지로 redis 채널의 subscribe가 해제됩니다.
1712832277.080132 [0 192.168.65.1:60464] "unsubscribe" "createPost" 1712832277.080324 [0 192.168.65.1:60464] "punsubscribe" "createPost"
JavaScript
복사

특정 프론트에게만 publish 하기

사실 서비스를 구현하다 보면, 모든 프론트에게 publish하는 것보다는 조건에 맞는 프론트에게만 publish하는 경우가 더 많을 것입니다. socket.io에서 room과 같은 기능인데요, graphql에서는 graphql-subscriptions의 withFilter를 사용할 수 있습니다.
import { withFilter } from "graphql-subscriptions"; const subscription = subscriptionType({ definition(t) { t.field("createPost", { type: "Post", args: { userId: "String", }, subscribe: withFilter( () => { return pubsub.asyncIterator("createPost"); }, (payload, variables) => { return true; } ), async resolve(eventPromise) { const event: any = await eventPromise; return event.data; }, }); }, });
JavaScript
복사
mutation{ createPost( --- (3) content:"content", ) { content } } subscription{ --- (1) createPost(userId:'123') { --- (6) content } }
JavaScript
복사
기존의 코드와 달라진 점은 아래와 같습니다.
프론트에서 createPost subscription 호출 시, 필터 조건(userId)을 전달
서버의 createPost subscription 인자로 userId 추가
subscriptionType의 subscribe 함수에 withFilter 적용
여기서 핵심은 withFilter 호출 시, 인자로 전달하는 2개의 함수입니다.
첫 번째 함수 : 프론트에서 createPost subscription호출 시, redis의 createPost채널 subscribe(기존과 동일)
두 번째 함수 : 프론트에게 publish 할지 여부로 true는 프론트로 데이터 publish, false는 publish 하지 않음 여기서 payload는 createPost mutation에서 publish 메서드 호출 시 두 번째 값이며, variables는 프론트에서 호출한 createPost subscription의 전달 인자(userId) 입니다. 이 두 가지 값을 사용하여 비지니스 로직에 맞게 특정 프론트에게만 데이터를 전달할 수 있습니다.

PubSub의 종류

apollo server 공식 문서에는 예제 코드에 아래의 모듈을 사용하고 있지만, 실제 운영 환경에서 사용하는 것을 지양하고 있습니다. 왜냐하면 PubSub 모듈은 in-memory event publish system을 사용하고 있기 때문에 클러스터 모드나 여러 환경에서 서버를 올려 사용하는 경우 이벤트 간에 동기화가 되지 않습니다.
import { PubSub } from 'graphql-subscriptions'; const pubsub = new PubSub();
JavaScript
복사
즉 실제로 모든 또는 특정 프론트에게 이벤트가 publish되야 하지만, 특정 서버와 연결된 프론트에게만 이벤트가 갈 수 있습니다.
따라서 실제 운영 환경에서는 메세지/이벤트 브로커를 사용하여 여러 서버 간의 이벤트를 동기화할 필요가 있으며, 개발 환경에 맞게 라이브러리를 선택할 수 있습니다.

마치며

예전부터 미뤄왔던 subscription에 대해 여러 자료를 보면서 공부하고 테스트 해보면서 개념을 잡고 서비스에 녹여내는 과정이 재미있었습니다. subscription은 “특정 프론트에게만 필터링”을 어떻게 처리 하는지 궁금했는데, 동작방식을 알 수 있는 좋은 기회였습니다.
다만 withFilter 메서드의 콜백 함수의 전달 인자 타입이 모두 any로 되어있어서 이 부분이 아쉬웠고, 나중에 기회가 되면 pubsub으로 redis 말고 이벤트/메시지 브로커를 사용해 보고 싶습니다.
subscription은 저희 시스템에 적용해 볼 수 있는 부분이 많기 때문에 앞으로 더 많은 부분에 적용해 볼 생각입니다.