본문으로 건너뛰기

2024 GTC 이벤트 실시간 랭킹: GraphQL Subscription 활용법

· 약 14분
Sujin Kim

래블업은 2024년 GTC 이벤트를 기념하여 특별한 이벤트를 개최했다. 참가자들은 래블업이 제공한 LLM 모델을 이용하여 주어진 이미지와 유사한 이미지를 생성했고, 높은 점수를 받은 참가자 중에서 추첨을 통해 무려 NVIDIA RTX 4090 그래픽 카드를 증정했다. 🫢
이번 포스트에서는 이벤트 페이지 중 참가자들의 점수를 실시간으로 확인할 수 있게 해주는 리더 보드 페이지에 사용된 GraphQL의 subscription 기능에 대해 알아보고자 한다.

2024 GTC 이벤트 페이지

Subscription 이란?

클라이언트가 서버 측 이벤트 스트림에 대한 응답으로 데이터를 쿼리할 수 있도록 하는 메커니즘이다. 데이터가 실시간으로 바뀌는 경우, 예를 들어 실시간 로그나 채팅 어플리케이션 등을 구현할 때, 서버에서 업데이트를 푸시해주면 바로 반영할 수 있다.

subscription은 필요한 정보가 서버에서 변경될 때만 데이터를 보내준다. 따라서 데이터 변경이 빈번하지 않은 경우, subscription은 데이터 트래픽을 줄이고, 이에 따른 비용 절감 효과도 있을 수 있다.

비슷한 개념으로 GraphQL의 Query의 fetchPolicy를 network-only로 설정해서 항상 최신의 결과를 얻을 수 있는데, subscription의 특성과는 좀 다르다. 이는 클라이언트가 데이터를 필요로 할 때마다 항상 서버에 요청하며 항상 최신 데이터를 보장하지만, 각 요청에 대한 네트워크 비용을 수반한다. 그래서 어떤 버튼을 클릭했을 때 항상 최신의 결과를 보여주도록 보장하기 위해 fetchPolicy를 network-only로 설정하는 것은 괜찮지만, 주식 거래 창과 같이 업데이트가 빈번한 데이터를 가져오기 위해 query를 사용한다면 네트워크 비용이 상당해진다.

결론적으로, 목표 응용 프로그램의 요구 사항, 사용자 수, 데이터의 업데이트 빈도 등에 따라 subscription을 사용할지, query를 사용할지 결정해야 한다.

사용 방법

subscription 정의하기

사용 방법은 query와 유사한데, 키워드만 subscription 을 사용해주면 된다.

  const leaderboardSubscriptions = graphql`
subscription Ranking_leaderboardSubscription {
leaderboard {
submissions {
id
name
score
imageUrl
}
lastUpdatedAt
}
}
`;

leaderboard 스트림에서 이벤트가 발생할 때마다 애플리케이션에 알림이 전송되고, 클라이언트에서는 업데이트된 결과를 얻을 수 있다.

그럼 다음과 같은 결과를 얻을 수 있다.

leaderboard: {
submissions: [
{
"id": "76293167-e369-4610-b7ac-4c0f6aa8f699",
"name": "test",
"score": 0.5910864472389221,
"imageUrl": "<IMAGE_URL>"
},
],
lastUpdatedAt: 1710176566.493705
}

subscribe

실시간 랭킹을 보여주기 위해 해당 페이지에 들어갈 때 subscribe를 호출하고, 다른 페이지로 넘어갈 경우, dispose 를 호출하여 unsubscribe 하기 위해 useEffect를 사용했다.

import { useEffect } from 'react';
import { requestSubscription } from 'react-relay';

useEffect(() => {
const subscriptionConfig = {
subscription: leaderboardSubscriptions,
variables: {},
onNext: (response: any) => {
setLeaderboard(response.leaderboard.submissions); // 미리 정의된 state
},
onError: (error: any) => {
console.error('Leaderboard subscription error', error);
},
};
const { dispose } = requestSubscription(
RelayEnvironment, // 아래 '설정 방법' 참고
subscriptionConfig,
);
return () => {
dispose();
};
}, []); // 빈 의존성 배열을 통해 컴포넌트가 마운트되거나 언마운트될 때만 이 부분이 실행되도록 함

requestSubscription

  • 메소드는 반환 값으로 Disposable 오브젝트를 제공한다.
  • Disposable 오브젝트에는 구독을 취소하는 dispose 메서드가 포함되어 있다.

onNext

  • subscription으로 데이터가 업데이트되면, 미리 정의해두었던 state를 업데이트하여 실시간 랭킹을 보여줄 수 있도록 하였다.
  • onNext, onError 외에도, subscription이 끝날 때 호출되는 onCompleted, 서버 응답을 기반으로 메모리 내 릴레이 저장소를 업데이트를 위한 updater 등 다양한 설정들이 있다. 자세한 설명은 이 링크를 참고하길 바란다.

dispose

  • useEffect hook 내에서 반환하는 cleanup 함수를 통해 컴포넌트가 언마운트될 때 dispose 메소드를 호출하여 구독을 종료하게 된다.

설정 방법 (+Relay)

Relay document 에 따르면, GraphQL subscriptions 은 WebSockets과 통신하며, graphql-ws를 사용해서 network를 설정하는 방법은 다음과 같다. (subscriptions-transport-ws를 사용하는 방법도 있지만 deprecated 되었으니 패스하기로 한다.)

import { ExecutionResult, Sink, createClient } from 'graphql-ws';
import {
Environment,
Network,
RecordSource,
Store,
SubscribeFunction,
RelayFeatureFlags,
FetchFunction,
Observable,
GraphQLResponse,
} from 'relay-runtime';
import { RelayObservable } from 'relay-runtime/lib/network/RelayObservable';
import { createClient } from 'graphql-ws';

const wsClient = createClient({
url: GRAPHQL_SUBSCRIPTION_ENDPOINT,
connectionParams: () => {
return {
mode: 'cors',
credentials: 'include',
};
},
});

const subscribeFn: SubscribeFunction = (operation, variables) => {
return Observable.create((sink: Sink<ExecutionResult<GraphQLResponse>>) => {
if (!operation.text) {
return sink.error(new Error('Operation text cannot be empty'));
}
return wsClient.subscribe(
{
operationName: operation.name,
query: operation.text,
variables,
},
sink,
);
}) as RelayObservable<GraphQLResponse>;
};

// Export a singleton instance of Relay Environment
// configured with our network function:
export const createRelayEnvironment = () => {
return new Environment({
network: Network.create(fetchFn, subscribeFn),
store: new Store(new RecordSource()),
});
};

export const RelayEnvironment = createRelayEnvironment();

wsClient

  • url 에는 GraphQL 서버의 웹소켓 URL을 입력한다.
  • credentials 설정은 connectionParams를 통해 가능하다.

subscribeFn

  • Observable의 구독 동작을 정의한다.
  • if (!operation.text) { ... } 에서 쿼리 문자열의 유효성을 확인하여 유효하지 않은 경우, 오류를 발생시키고 실행을 중단한다.
  • 마지막으로 return wsClient.subscribe( ... ) 코드는 웹소켓 클라이언트를 사용하여 실제로 subscription을 구독하고, GraphQL operation의 payload를 sink (즉, Observer) 에게 전달한다.
  • 간단히 말해, 이 함수는 GraphQL subscription 요청을 처리하고, subscription 이벤트가 발생할 때마다 해당 결과를 Observable 스트림에 push하는 역할을 한다고 볼 수 있다.

createRelayEnvironment

  • 새로운 Relay Environment 를 생성하고 반환한다.
  • Relay의 Environment는 다른 고수준 Relay 객체들과 네트워크 계층, 캐시등을 관리하는 컨테이너이다.
  • GraphQL query/mutation 요청을 처리하는 함수를 fetchFn, subscription 요청을 처리하는 함수를 subscribeFn 에 할당한 상태이다.
  • 캐시 데이터를 저장하고 관리하는 Relay Store를 생성하기 위해 RecordSource 저장소를 사용했다.

RelayEnvironment

  • createRelayEnvironment 함수를 호출함으로써 RelayEnvironment를 초기화하고, 이를 추후 다른 곳에서 임포트해 사용할 수 있게 내보내는 역할을 한다.
  • 이렇게 구성된 RelayEnvironment는 주로 QueryRenderer, useLazyLoadQuery, commitMutation 등에서 사용된다.

CORS 에러

처음에 GraphQL 서버의 웹소켓 URL을 설정하기 위해 서버측에서 사용하는 config.toml 파일을 읽어와서 주소를 설정했다. 그런데 자꾸 CORS 에러가 나면서 요청 보낼 때마다 Unauthorized 가 뜨는 것이다. 그래서 이것저것 삽질을 한 결과, 동료분의 도움으로 해결할 수 있었다. (정말 감사합니다 🥹🙏)

해결 방법은 바로 http-proxy-middleware 를 사용해 setupProxy 를 설정하는 것!

create-react-app manual에서도 알 수 있듯이, 일반적으로 프론트엔드와 백엔드가 분리된 개발 환경에서 CORS 이슈를 방지하기 위한 설정이나, 개발 서버에서 실제 서버의 특정 경로에 대한 요청을 프록시하기 위해 setupProxy 를 설정할 수 있다.

코드는 다음과 같다.

/src/setupProxy.js
const { createProxyMiddleware } = require('http-proxy-middleware');

module.exports = function (app) {
app.use(
createProxyMiddleware('/graphql', {
target: 'http://127.0.0.1:9220',
changeOrigin: true,
followRedirects: true,
ws: true,
}),
);
};

createProxyMiddleware('/graphql', { ... })

  • '/graphql'에서 발생하는 모든 HTTP 요청을 미들웨어가 처리하도록 설정한다.

target: 'http://127.0.0.1:9220'

  • 프록시 된 요청이 전달될 서버의 주소를 설정한다. 여기선 9220번 포트로 설정했다.

changeOrigin: true

  • 요청의 호스트 헤더를 target의 호스트로 변경한다. CORS 이슈를 해결하기 위해 사용한다.

followRedirects: true

  • 이 설정은 서버가 요청에 대해 리다이렉트 응답을 보냈을 때 그 리다이렉트를 프록시가 따르도록 한다.

ws: true

  • 이 설정은 웹소켓 프록시를 활성화한다. 클라이언트와 서버 간의 웹소켓 연결도 이 프록시를 통해 전달되며, subscribe를 위해 true로 설정하였다.

리더보드 페이지

기나긴 삽질 끝에 마침내 완성한 리더보드 페이지! 🎉 참여해 주신 모든 분들께 깊은 감사를 드립니다. 🙇🏻‍♀️

결론

GraphQL의 subscription을 사용하여 실시간 랭킹 같은 기능을 구현할 수 있었다. CORS 때문에 설정 방법에 애를 먹긴 했지만, 사용 방법은 query를 쓸 때와 크게 다르지 않아 어렵지 않았다.

subscription은 실시간 업데이트효율성이 가장 큰 장점이 아닐까 생각한다. 서버로부터 실시간으로 데이터를 수신하므로 사용자는 항상 최신 상태를 볼 수 있으며, 필요한 데이터가 변경될 때만 업데이트를 받기 때문에, 자주 변경되지 않은 데이터에 대해서는 서버 요청을 최소화할 수 있다.

하지만 웹소켓 또는 유사한 실시간 프로토콜을 구현해야 하며, 클라이언트와 서버 사이의 연결 상태를 관리하는 로직도 필요하기에 복잡하긴 하다. 이 글에서 다루진 않았지만, subscription을 위해 서버측에서 추가 작업이 필요하다. 그리고 실시간 연결을 필요로 하기 때문에 그에 따른 서버 자원과 클라이언트의 리소스 소모가 있을 수 있다.

따라서 어떠한 방법이 비용이나 성능 면에서 더 효율적인지는 애플리케이션의 특성, 데이터의 갱신 빈도, 사용자의 동시 접속자 수 등 여러 요소에 따라 달라질 수 있으니 적절히 판단하여 사용하길 바란다.

references