fe.resolver.ts
fe.resolver.ts

Powered by Notion & Next.js

Navigate

  • 개인정보처리방침

Connect

  • GitHub

© 2026 Hanul Lee. All rights reserved.

Powered by Notion & Next.js

목록으로
Development2025년 2월 6일

GraphQL subscription 컨벤션 이해하기

역시 소켓은 훨씬 싸다

#GraphQL

개요

graphQL은 어렵고 재밌다.

아직 ‘새로운 기술’ 이라는 뉴비 버프 때문인진 모르겠지만, REST API 와의 차별점이 참 많다.

그중에서 얼마전에 실제로 피부로 깨닫게된 subscription 이라는것에 대해 리마인드해보려 한다.

Subscription

graphQL의 삼대장을 꼽으라면 Query, Mutation, Subscription 이 있을 것이다.

그 중 Query(읽기)와 Mutation(쓰기/수정)은 REST의 GET, POST와 크게 다르지 않다.

하지만 Subscription은 태생부터 다르다.

얘는 HTTP가 아니라 WebSocket 위에서 논다.

만약 REST API 에서 비슷한 기능을 구현하려면 Polling 같은 방식을 써야 했겠지만, WebSocket 을 쓴다면 3-way handshake 등이 없으므로 비용적으로도, 속도면에서도 빠르다.

물론 HTTP 가 아닌 WebSocket 이라는 말은 즉 클라이언트에서도 서버와 연결하는 통로를 두 개로 만들어야 한다는 것이다.

Apollo Client 에서는 split 과 wsLink 설정으로 HttpLInk 외에도 Subscription 통로를 설정한다.

Split Link

REST API 시절엔 axios 인스턴스 하나면 충분했지만, 여기선 두 개의 링크가 필요했다.

  1. HttpLink: 기존의 Query, Mutation을 처리 (HTTP 통신)
  2. GraphQLWsLink: 실시간 Subscription을 처리 (WebSocket 통신)

대략적인 코드 흐름은 다음과 같다.

typescript
// http-link.ts
export const httpLink = new HttpLink({
  uri: ORIGINAL_SERVER_URI,
});


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

const wsClient = createClient({
  url: process.env.WS_URL,
  // 기본값은 true(구독자가 생길 때 연결)이지만, 앱 실행과 동시에 소켓을 맺어두기 위해 false로 설정
  lazy: false,
  retryAttempts: Infinity,
  shouldRetry: () => true,
  // 재연결 시도를 1초, 2초, 4초... 점진적으로 늦춰 서버 부하를 줄이기
  retryWait: async (retries) => {
    const delay = Math.min(1000 * 2 ** retries, 30000); // 최대 30초 대기
    await new Promise((resolve) => setTimeout(resolve, delay));
  },
  
  // 4. Heartbeat (Ping/Pong)
  // 25초마다 핑을 보내 연결이 살아있는지 확인
  keepAlive: 25_000,
});

export const wsLink = new GraphQLWsLink(wsClient);

typescript
const isSubscription = (operation: ApolloLink.Request) => {
  const definition = getMainDefinition(operation.query);
  return (
    definition.kind === 'OperationDefinition' &&
    definition.operation === OperationTypeNode.SUBSCRIPTION
  );
};

// main.ts 일부

이제 이 두 개의 링크(httpLink, wsLink)를 split으로 묶고, 최종적으로 Apollo Client에 주입해야 한다.

이때 중요한 건 미들웨어(Middleware)의 순서다. 헤더에 토큰을 심어주는 authLink나 에러를 전역으로 잡는 errorLink 같은 친구들도 함께 엮어야 하기 때문이다.

typescript
export const client = new ApolloClient({
  link: ApolloLink.from([
    // 인증
    authLink,
    // 전역 에러
    errorLink,
    // persistedQuery
    persistedQueriesLink,
    // httpLink, wsLink 분기
    splitLink,
  ]),
  cache: cacheConfig.cache,
});

내가 수정한건 받지 않기 (x-client-id)

subscription 이 걸린 후, 수정 및 삭제 등의 트리거가 연결된 모든 클라이언트에게 이벤트가 전파된다.

보통 Subscription 이벤트를 수신하면 클라이언트에서 가장 확실한 처리는 refetch다. 하지만 수정을 요청한 장본인(클라이언트 A) 입장에서는 Mutation의 응답(result model)으로 이미 최신 데이터를 알고 있는 경우가 많고, 이를 통해 Cache를 업데이트할 수 있다.

이때 불필요한 네트워크 요청과 중복 처리를 줄이기 위한 방법이 바로 x-client-id 컨벤션이다.

클라이언트에서 필터링하는 게 아니라, 아예 메시지 수신 대상에서 제외시키는 방식이다.

  1. Subscription 연결 시: 클라이언트에서 생성한 uuid를 헤더에 x-client-id 로 실어 등록한다.
  2. Mutation 요청 시: 똑같은 x-client-id를 헤더에 포함해 보낸다.
  3. 서버 처리: 이벤트를 브로드캐스트할 때, "이 ID를 가진 연결(Connection)은 제외하고" 나머지에게만 쏜다.

typescript
useSubscription(SOME_SUBSCRIPTION, {
  variables: { ... },
  context: { headers: { 'x-client-id': MY_CLIENT_ID } }
});

typescript
const [execute, result] = useMutation(SOME_MUTATION);


// 뮤테이션 실행시
execute({
  variables: { ... },
  context: {
    headers: {
      'x-client-id': MY_CLIENT_ID,
    },
  },

이 패턴 덕분에 클라이언트는 별도의 필터링 로직 없이 ‘이벤트가 오면 무조건 남이 수정한 것’ 이라고 간주하고 refetch 를 수행하면 된다.

  • 개요
  • Subscription
  • Split Link
  • 내가 수정한건 받지 않기 (x-client-id)