본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout

내비게이션

  • Tech
  • Projects
  • Books
  • About
  • Tags

카테고리

  • AI / ML
  • 웹 개발
  • 프로그래밍
  • 개발 도구

연결

  • GitHub
  • Email
  • RSS
© 2026 Kreath Archive. All rights reserved.Built with Next.js + MDX
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 6장: 실시간 구독(Subscriptions)
2026년 2월 16일·웹 개발·

6장: 실시간 구독(Subscriptions)

GraphQL Subscriptions의 프로토콜과 구현을 다룹니다. WebSocket 전송, PubSub 패턴, Redis를 활용한 확장, 필터링 전략, 그리고 Federation 환경에서의 한계와 SSE 등 대안 패턴을 살펴봅니다.

13분735자10개 섹션
graphqlapi-designfrontend
공유
graphql-architecture6 / 11
1234567891011
이전5장: Federation과 Supergraph다음7장: 인증과 인가

학습 목표

  • GraphQL Subscription의 동작 원리와 프로토콜을 이해한다
  • PubSub 패턴을 활용한 구독 리졸버를 구현할 수 있다
  • Redis PubSub을 통해 다중 인스턴스 환경으로 확장한다
  • Federation 환경에서의 구독 한계와 대안 패턴을 파악한다

Subscription의 개념

GraphQL의 세 번째 루트 타입인 Subscription은 서버에서 클라이언트로 실시간 데이터를 푸시하는 메커니즘입니다. Query와 Mutation이 요청-응답 모델인 반면, Subscription은 이벤트 기반 스트리밍 모델입니다.

subscription-schema.graphql
graphql
type Subscription {
  messageCreated(channelId: ID!): Message!
  orderStatusChanged(orderId: ID!): Order!
  notificationReceived: Notification!
}
 
type Message {
  id: ID!
  content: String!
  author: User!
  channel: Channel!
  createdAt: DateTime!
}

클라이언트는 구독을 시작하면 서버와의 지속적인 연결을 유지하고, 관련 이벤트가 발생할 때마다 데이터를 받습니다.

subscription-operation.graphql
graphql
subscription OnNewMessage {
  messageCreated(channelId: "general") {
    id
    content
    author {
      name
      avatar
    }
    createdAt
  }
}

전송 프로토콜: graphql-ws

GraphQL Subscription의 표준 전송 프로토콜은 graphql-ws입니다. WebSocket 위에서 동작하며, 구독의 생명주기를 관리합니다.

Info

레거시 프로토콜인 subscriptions-transport-ws는 더 이상 유지보수되지 않습니다. 새로운 프로젝트에서는 반드시 graphql-ws 라이브러리를 사용해야 합니다.

연결 흐름

서버 구현

ws-server.ts
typescript
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import { ApolloServer } from '@apollo/server';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
 
const httpServer = createServer(app);
const wsServer = new WebSocketServer({
  server: httpServer,
  path: '/graphql',
});
 
// WebSocket 서버에 graphql-ws 프로토콜 적용
const serverCleanup = useServer(
  {
    schema,
    context: async (ctx) => {
      // ConnectionInit의 payload에서 인증 처리
      const token = ctx.connectionParams?.token as string | undefined;
      const currentUser = token ? await verifyToken(token) : null;
      return { currentUser, loaders: createLoaders(prisma), prisma };
    },
    onConnect: async (ctx) => {
      const token = ctx.connectionParams?.token as string | undefined;
      if (!token) {
        return false; // 연결 거부
      }
      return true;
    },
    onDisconnect: () => {
      console.log('Client disconnected');
    },
  },
  wsServer,
);
 
const server = new ApolloServer({
  schema,
  plugins: [
    ApolloServerPluginDrainHttpServer({ httpServer }),
    {
      async serverWillStart() {
        return {
          async drainServer() {
            await serverCleanup.dispose();
          },
        };
      },
    },
  ],
});

PubSub 패턴

Subscription 리졸버는 PubSub(Publish-Subscribe) 패턴을 사용하여 이벤트를 구독하고 발행합니다.

인메모리 PubSub

개발 환경에서 사용하는 간단한 구현입니다.

pubsub-basic.ts
typescript
import { PubSub } from 'graphql-subscriptions';
 
const pubsub = new PubSub();
 
// 이벤트 채널 상수
const EVENTS = {
  MESSAGE_CREATED: 'MESSAGE_CREATED',
  ORDER_STATUS_CHANGED: 'ORDER_STATUS_CHANGED',
  NOTIFICATION_RECEIVED: 'NOTIFICATION_RECEIVED',
} as const;
 
const resolvers = {
  Subscription: {
    messageCreated: {
      subscribe: (_parent: unknown, args: { channelId: string }) => {
        return pubsub.asyncIterableIterator(
          `${EVENTS.MESSAGE_CREATED}.${args.channelId}`
        );
      },
    },
  },
  Mutation: {
    sendMessage: async (
      _parent: unknown,
      args: { input: SendMessageInput },
      context: Context,
    ) => {
      const message = await context.dataSources.messages.create({
        ...args.input,
        authorId: context.currentUser!.id,
      });
 
      // 이벤트 발행
      await pubsub.publish(
        `${EVENTS.MESSAGE_CREATED}.${message.channelId}`,
        { messageCreated: message },
      );
 
      return message;
    },
  },
};
Warning

인메모리 PubSub은 단일 프로세스에서만 동작합니다. 서버 인스턴스가 여러 개이면 한 인스턴스에서 발행한 이벤트가 다른 인스턴스의 구독자에게 전달되지 않습니다. 프로덕션에서는 반드시 외부 메시지 브로커를 사용해야 합니다.


Redis PubSub 확장

프로덕션 환경에서는 Redis PubSub을 사용하여 다중 인스턴스 간 이벤트를 전파합니다.

redis-pubsub.ts
typescript
import { RedisPubSub } from 'graphql-redis-subscriptions';
import Redis from 'ioredis';
 
const options = {
  host: process.env.REDIS_HOST ?? 'localhost',
  port: Number(process.env.REDIS_PORT ?? 6379),
  retryStrategy: (times: number) => Math.min(times * 50, 2000),
};
 
const pubsub = new RedisPubSub({
  publisher: new Redis(options),
  subscriber: new Redis(options),
});

Redis를 매개로 하면 어떤 인스턴스에서 이벤트가 발생하든 모든 인스턴스의 구독자에게 전달됩니다.


구독 필터링

모든 이벤트를 모든 구독자에게 보내는 것은 비효율적입니다. withFilter를 사용하여 구독자별로 이벤트를 필터링합니다.

subscription-filter.ts
typescript
import { withFilter } from 'graphql-subscriptions';
 
const resolvers = {
  Subscription: {
    messageCreated: {
      subscribe: withFilter(
        (_parent: unknown, args: { channelId: string }) => {
          return pubsub.asyncIterableIterator(EVENTS.MESSAGE_CREATED);
        },
        (
          payload: { messageCreated: Message },
          variables: { channelId: string },
        ) => {
          // 해당 채널의 메시지만 필터링
          return payload.messageCreated.channelId === variables.channelId;
        },
      ),
    },
    orderStatusChanged: {
      subscribe: withFilter(
        () => pubsub.asyncIterableIterator(EVENTS.ORDER_STATUS_CHANGED),
        (
          payload: { orderStatusChanged: Order },
          variables: { orderId: string },
          context: Context,
        ) => {
          const order = payload.orderStatusChanged;
          // 해당 주문이면서 권한이 있는 사용자만
          return (
            order.id === variables.orderId &&
            (order.userId === context.currentUser?.id ||
              context.currentUser?.role === 'ADMIN')
          );
        },
      ),
    },
  },
};

클라이언트 구현

Apollo Client에서 구독을 사용하는 방법입니다.

client-subscription.ts
typescript
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';
import { split, HttpLink, ApolloClient, InMemoryCache } from '@apollo/client';
import { getMainDefinition } from '@apollo/client/utilities';
 
const httpLink = new HttpLink({
  uri: 'https://api.example.com/graphql',
});
 
const wsLink = new GraphQLWsLink(
  createClient({
    url: 'wss://api.example.com/graphql',
    connectionParams: {
      token: getAuthToken(),
    },
  }),
);
 
// HTTP와 WebSocket을 쿼리 타입에 따라 분기
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(),
});
use-subscription.tsx
typescript
import { useSubscription, gql } from '@apollo/client';
 
const MESSAGE_SUBSCRIPTION = gql`
  subscription OnNewMessage($channelId: ID!) {
    messageCreated(channelId: $channelId) {
      id
      content
      author {
        name
        avatar
      }
      createdAt
    }
  }
`;
 
function ChatMessages({ channelId }: { channelId: string }) {
  const { data, loading, error } = useSubscription(MESSAGE_SUBSCRIPTION, {
    variables: { channelId },
  });
 
  if (loading) return <p>연결 중...</p>;
  if (error) return <p>구독 오류: {error.message}</p>;
 
  return (
    <div>
      <p>새 메시지: {data?.messageCreated.content}</p>
    </div>
  );
}

Federation에서의 구독 한계

Apollo Federation 환경에서 Subscription은 공식적으로 지원되지 않습니다. Apollo Router는 HTTP 기반이며 WebSocket 연결을 서브그래프로 라우팅하는 메커니즘이 없습니다.

대안 패턴

1. 전용 구독 서비스: Federation과 별도로 구독 전용 GraphQL 서버를 운영합니다.

2. Server-Sent Events(SSE): WebSocket 대신 HTTP 기반 SSE를 사용하면 기존 HTTP 인프라를 활용할 수 있습니다.

sse-subscription.ts
typescript
import { createYoga } from 'graphql-yoga';
 
// GraphQL Yoga는 SSE를 통한 구독을 기본 지원
const yoga = createYoga({
  schema,
  // SSE를 통해 구독 전달 (별도 WebSocket 서버 불필요)
});

3. 폴링(Polling): 실시간성 요구가 낮은 경우, 주기적인 Query 요청으로 대체합니다.

polling.tsx
typescript
import { useQuery, gql } from '@apollo/client';
 
const NOTIFICATIONS_QUERY = gql`
  query Notifications {
    notifications(unreadOnly: true) {
      id
      message
      createdAt
    }
  }
`;
 
function NotificationBadge() {
  const { data } = useQuery(NOTIFICATIONS_QUERY, {
    pollInterval: 30000, // 30초마다 재조회
  });
 
  const count = data?.notifications.length ?? 0;
  return count > 0 ? <span>{count}</span> : null;
}
Tip

실시간성의 수준을 정확히 정의하는 것이 중요합니다. "1초 이내 전달"이 필요하면 Subscription이나 SSE가 적합하고, "30초 이내"면 폴링으로 충분합니다. 불필요하게 복잡한 실시간 인프라를 구축하지 않는 것이 좋습니다.


구독 운영 시 고려사항

연결 관리

WebSocket 연결은 서버 리소스를 지속적으로 소비합니다. 적절한 관리가 필요합니다.

connection-management.ts
typescript
const wsServerConfig = {
  // 연결 초기화 타임아웃 (5초)
  connectionInitWaitTimeout: 5000,
  
  // 하트비트 간격
  keepAlive: 12000,
  
  onConnect: async (ctx: Context) => {
    // 동시 연결 수 제한
    const connectionCount = await getActiveConnectionCount(ctx.extra.socket);
    if (connectionCount > 100) {
      return false; // 연결 거부
    }
    return true;
  },
};

메모리 누수 방지

구독이 정상적으로 해제되지 않으면 메모리 누수가 발생할 수 있습니다. 클라이언트 측에서 컴포넌트 언마운트 시 구독을 해제하고, 서버 측에서는 비활성 연결을 정리해야 합니다.


정리

이번 장에서는 GraphQL Subscription의 프로토콜, 구현, 확장 방법을 학습했습니다. graphql-ws 프로토콜 위에서 PubSub 패턴으로 이벤트를 발행/구독하고, Redis PubSub으로 다중 인스턴스 환경을 지원하는 방법을 다루었습니다. Federation 환경에서의 구독 한계와 SSE, 폴링 등의 대안도 살펴보았습니다.

실시간 기능은 강력하지만 운영 복잡도를 크게 높입니다. 진정으로 실시간이 필요한 기능에만 Subscription을 적용하고, 나머지는 더 단순한 방법을 선택하는 것이 현명합니다.

다음 장 미리보기

7장에서는 GraphQL API의 인증과 인가를 다룹니다. JWT 기반 인증 미들웨어, 컨텍스트를 통한 사용자 주입, 리졸버 수준과 디렉티브 기반 인가, RBAC/ABAC 모델, 그리고 Authorization Facade 패턴을 살펴보겠습니다.

이 글이 도움이 되셨나요?

관련 주제 더 보기

#graphql#api-design#frontend

관련 글

웹 개발

7장: 인증과 인가

GraphQL API에서의 인증과 인가 전략을 다룹니다. JWT 인증 미들웨어, 컨텍스트 기반 사용자 주입, 리졸버 수준 인가, 디렉티브 기반 선언적 인가, RBAC/ABAC 모델을 학습합니다.

2026년 2월 18일·13분
웹 개발

5장: Federation과 Supergraph

Apollo Federation 2.0을 활용한 마이크로서비스 GraphQL 아키텍처를 다룹니다. 서브그래프와 슈퍼그래프 개념, 엔티티 참조 리졸버, 주요 디렉티브, 스키마 컴포지션 과정을 학습합니다.

2026년 2월 14일·15분
웹 개발

8장: 캐싱 전략

GraphQL API의 다양한 캐싱 전략을 다룹니다. HTTP 캐싱의 한계, Persisted Queries를 통한 CDN 캐싱, @cacheControl 디렉티브, Redis 기반 리졸버 캐싱, 캐시 무효화 패턴을 학습합니다.

2026년 2월 20일·13분
이전 글5장: Federation과 Supergraph
다음 글7장: 인증과 인가

댓글

목차

약 13분 남음
  • 학습 목표
  • Subscription의 개념
  • 전송 프로토콜: graphql-ws
    • 연결 흐름
    • 서버 구현
  • PubSub 패턴
    • 인메모리 PubSub
  • Redis PubSub 확장
  • 구독 필터링
  • 클라이언트 구현
  • Federation에서의 구독 한계
    • 대안 패턴
  • 구독 운영 시 고려사항
    • 연결 관리
    • 메모리 누수 방지
  • 정리
    • 다음 장 미리보기