프론트엔드 개발/GraphQL

Apollo Subscription 으로 GraphQL 서버와 WebSocket 통신하기

snowman95 2023. 4. 17. 17:54
728x90
반응형

구독

백엔드 데이터의 변경 사항에 대해 클라이언트에 실시간으로 알리는데 유용합니다.

Query,Mutation 과 달리 지속적인 연결을 유지해야 하기 때문에 graphql-ws 라이브러리를 통해 WebSocket으로 통신합니다.

 

구독은 이럴 때 사용합니다.

이 경우가 아니라면 쿼리를 사용하여 간헐적 폴링을 하거나 필요한 순간에만 쿼리 하세요.

또한 구독은 외부 데이터의 변경 사항을 구독하는 것입니다. 로컬 클라이언트 캐시의 변경사항 구독에 사용할 수 없습니다!

  • 큰 개체에 대한 작은 증분 변경
    • 큰 덩어리 객체에서 필요한 개별 필드의 업데이트가 발생했을때 가져오는 경우를 뜻합니다.
  • 대기 시간이 짧은 실시간 업데이트
    • 예를 들면 채팅 앱에서 실시간 메시지를 주고 받는 경우

 

Apollo Client 에서 지원하는 라이브러리

  • graphql-ws (권장)
  • subscriptions-transport-ws (구 방식)

 

구독 정의

서버, 클라이언트 측 모두에서 구독을 정의해야 합니다.

 

서버 측

- GraphQL 스키마에서 사용 가능한 구독을 유형의 필드로 정의합니다.

type Subscription {
  commentAdded(postID: ID!): Comment
}

 

클라이언트 측

- Apollo 클라이언트가 실행할 각 구독의 모양을 정의합니다.

const COMMENTS_SUBSCRIPTION = gql`
  subscription OnCommentAdded($postID: ID!) {
    commentAdded(postID: $postID) {
      id
      content
    }
  }
`;

 

일반적인 쿼리와는 다릅니다. 서버에서 즉시 응답을 반환할 거란 기대 없습니다.

특정 이벤트가 발생했을 때에만 응답을 받습니다.

 

 

Setup

WebSocket 사용을 위한 라이브러리를 설치합니다.

npm install graphql-ws

이떄 Apollo Link 라는 것을 사용해야 합니다.

 

 

Apollo Link 란?

Apollo 클라이언트의 네트워크 통신을 사용자 지정하는 데 도움이 되는 라이브러리 입니다.

일반적으로 알고있는 middleware 와 같은 역할을 합니다. axios 로 따지면 axios intercepter 같은 녀석입니다.

import { ApolloLink } from '@apollo/client';

const timeStartLink = new ApolloLink((operation, forward) => {
  operation.setContext({ start: new Date() }); // 무언가 수행
  return forward(operation); // 다음으로 링크를 호출
});

그리고 이러한 링크를 연결 (체인)

import { ApolloLink } from '@apollo/client';

const roundTripLink = new ApolloLink((operation, forward) => {
  // Called before operation is sent to server
  operation.setContext({ start: new Date() });

  return forward(operation).map((data) => {
    // Called after server responds
    const time = new Date() - operation.getContext().start;
    console.log(`Operation ${operation.operationName} took ${time} to complete`);
    return data;
  });
});

Apollo Client 인스턴스에 link 추가하면 완료

import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client';
import { RetryLink } from '@apollo/client/link/retry';

const directionalLink = new RetryLink().split(
  (operation) => operation.getContext().version === 1,
  new HttpLink({ uri: "http://localhost:4000/v1/graphql" }),
  new HttpLink({ uri: "http://localhost:4000/v2/graphql" })
);

const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: directionalLink
});

 

Apollo Link 를 사용하여 네트워크 통신 사이에 WebSocket 을 끼워넣는다는 의미로 이해할 수 있겠네요.

아주 간단하게 GraphQLWsLink 링크 체인에 추가만 해주면 됩니다.

import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';

const wsLink = new GraphQLWsLink(createClient({
  url: 'ws://localhost:4000/subscriptions',
}));

 

그런데 의문인 것은 이렇게 하면 모든 네트워크 통신들이 WebSocket 으로 되는게 아닌가 싶습니다.

기존 Query/Mutation 은 HTTP 를 그대로 사용해야 하는데요.

 

그래서 boolean 검사 결과에 따라 HttpLink 또는 GraphQLWsLink 를 사용하도록 분할 기능을 제공합니다.

분할 기능을 이용하여 실행 중인 작업 유형에 알맞은 링크를 결합합니다.

import { split, HttpLink } from '@apollo/client';
import { getMainDefinition } from '@apollo/client/utilities';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';

const httpLink = new HttpLink({
  uri: 'http://localhost:4000/graphql'
});

const wsLink = new GraphQLWsLink(createClient({
  url: 'ws://localhost:4000/subscriptions',
  connectionParams: {
    authToken: user.authToken, // 통신에 인증이 필요한 경우 이렇게 추가해야 합니다.
  },
}));

// The split function takes three parameters:
//
// * A function that's called for each operation to execute
// * The Link to use for an operation if the function returns a "truthy" value
// * The Link to use for an operation if the function returns a "falsy" value
const splitLink = split(
  ({ query }) => {
    const definition = getMainDefinition(query);
    return (
      definition.kind === 'OperationDefinition' &&
      definition.operation === 'subscription'
    );
  },
  wsLink,
  httpLink,
);

const client = new ApolloClient({
  link: splitLink, // 링크 연결
  cache: new InMemoryCache()
});

 

 

구독 실행

리액트에서 useSubscription hook 을 사용하여 구독합니다.

useQuery hook과 거의 동일하게 동작하므로 어렵지 않습니다. 매우 간단합니다.

useSubscription hook 의 응답값의 data 에는 항상 최신 데이터가 들어가게 됩니다.

 

const COMMENTS_SUBSCRIPTION = gql`
  subscription OnCommentAdded($postID: ID!) {
    commentAdded(postID: $postID) {
      id
      content
    }
  }
`;

function LatestComment({ postID }) {
  const { data, loading } = useSubscription(
    COMMENTS_SUBSCRIPTION,
    { variables: { postID } }
  );
  return <h4>New comment: {!loading && data.commentAdded.content}</h4>;
}

 

블로그의 게시물에 대한 기존 댓글을 모두 가져오는 쿼리인 COMMENTS_QUERY 가 있고

새 댓글 추가 COMMENTS_SUBSCRIPTION 에 대한 구독을 하고 싶은 경우

useQuery 도 쓰고 useSubscription 도 써야 할까요?

 

이럴땐 useQuery 응답값 중 하나인 subscribeToMore 함수를 사용하면 됩니다.

이 함수는 무한스크롤 또는 페이지네이션 작업 시 사용되는 fetchMore 과 유사합니다.

  • document : 실행할 구독을 나타냅니다.
  • variables : 구독을 실행할 때 포함할 변수를 나타냅니다.
  • updateQuery : 이전 쿼리의 현재 캐시된 결과와 subscriptionData를 GraphQL 서버에서 푸시한 결과와 결합하는 방법을 Apollo 클라이언트에 알려주는 기능입니다. 이 함수의 반환 값은 쿼리에 대해 현재 캐시된 결과를 완전히 대체합니다 .
function CommentsPageWithData({ params }) {
  const { subscribeToMore, ...result } = useQuery(
    COMMENTS_QUERY,
    { variables: { postID: params.postID } }
  );

  return (
    <CommentsPage
      {...result}
      subscribeToNewComments={() =>
        subscribeToMore({
          document: COMMENTS_SUBSCRIPTION,
          variables: { postID: params.postID },
          updateQuery: (prev, { subscriptionData }) => {
            if (!subscriptionData.data) return prev;
            const newFeedItem = subscriptionData.data.commentAdded;

            return Object.assign({}, prev, {
              post: {
                comments: [newFeedItem, ...prev.post.comments]
              }
            });
          }
        })
      }
    />
  );
}

아래와 같이 컴포넌트 마운트시 한번 수행시켜놓으면 구독 됩니다.

즉, 새로운 댓글이 추가될 때마다 업데이트 받아볼 수 있게 됩니다.

export function CommentsPage({subscribeToNewComments}) {
  useEffect(() => subscribeToNewComments(), []);
  return <>...</>
}

 

적절하게 연계해서 사용하는게 쉽지는 않아 보이네요.

계속 사용해 보면서 사용법에 익숙해져야 될 것 같습니다.

 

 

반응형