Guider/Backend/BackendDevGuide0009
Backend#09

BackendDevGuide0009

실시간 통신과 WebSocket 완전 정복

 
 
BackendDevGuide Series · Episode 9
BackendDevGuide0009
실시간 통신과 WebSocket 완전 정복 A-Z
💬 채팅, 알림, 라이브 대시보드를 만드는 모든 기술
⏱ 예상 학습: 3~4주 🔌 WebSocket 완전정복 🚀 Socket.IO 실전 📡 SSE + GraphQL Subscriptions
💬
0ms
실시간 메시지 지연
🔌
10만+
동시 접속자 처리
🏢
90%
앱에서 실시간 기능 필요
HTTP x3
WS 오버헤드 절감

📚 학습 목차 — 이 가이드에서 배울 내용

1 실시간 통신의 세계 — HTTP vs WebSocket
2 WebSocket 원리 완전 이해 + 핸드쉐이크
3 NestJS WebSocket Gateway 완전 구현
4 Socket.IO 실전 — 채팅 앱 완전 구현
5 Redis Pub/Sub으로 스케일 아웃
6 SSE(Server-Sent Events) — 단방향 실시간
7 인증 & 보안 — JWT로 WebSocket 보호
8 Room & Namespace — 채널 분리 관리
9 GraphQL Subscriptions — 실시간 쿼리
10 WebSocket 모니터링 & 성능 최적화
11 실전 프로젝트 — 라이브 협업 도구 구현
12 면접 Q&A + 실전 체크리스트 + 로드맵

🌐 1장. 실시간 통신의 세계 — HTTP vs WebSocket

왜 HTTP만으로는 부족한가? 실시간의 본질 이해

📞 전화 vs 편지 비유로 이해하는 실시간 통신

HTTP (편지): 클라이언트가 편지를 보내면(요청) → 서버가 답장(응답). 답장이 오면 연결 종료. 서버가 먼저 연락할 수 없음.
WebSocket (전화): 한 번 연결하면 → 양방향으로 언제든지 대화 가능. 서버도 클라이언트에게 먼저 메시지를 보낼 수 있음!

실시간이 필요한 기능: 채팅, 주식 시세, 실시간 협업(Google Docs), 멀티플레이어 게임, 배달 추적, 라이브 알림

🔄 실시간 통신 4가지 방식 비교

방식 방향 연결 유지 서버→클라 push 오버헤드 적합한 용도
HTTP 폴링 단방향 ❌ 매번 재연결 ❌ 불가 매우 높음 단순 주기 갱신
Long Polling 단방향 ⚠️ 응답 시 재연결 ⚠️ 제한적 높음 WebSocket 폴백
SSE 서버→클라 ✅ 유지 ✅ 가능 낮음 알림, 뉴스피드, 진행상황
WebSocket 양방향 ✅ 유지 ✅ 가능 매우 낮음 채팅, 게임, 협업

🤝 WebSocket 핸드쉐이크 원리

// WebSocket 연결 수립 과정 (HTTP Upgrade)

// 1단계: 클라이언트 → 서버 (HTTP Upgrade 요청)
GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket              <-- WebSocket으로 업그레이드 요청
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZQ==  <-- 랜덤 키 (보안)
Sec-WebSocket-Version: 13

// 2단계: 서버 → 클라이언트 (101 Switching Protocols)
HTTP/1.1 101 Switching Protocols   <-- 연결 수락!
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

// 3단계: 이후 WebSocket 프레임으로 양방향 통신 시작!
// HTTP 오버헤드 없이 바이너리 프레임으로 초고속 통신

// HTTP vs WebSocket 헤더 크기 비교
HTTP 요청: ~700 bytes (헤더 포함)
WebSocket 프레임: ~2~6 bytes (헤더만!)
→ 메시지당 오버헤드 350배 차이!

💡 언제 WebSocket을 선택해야 할까?

✅ WebSocket이 적합한 경우
  • 실시간 채팅, 그룹 메시지
  • 멀티플레이어 게임
  • 실시간 협업 도구 (Google Docs 등)
  • 주식/코인 실시간 시세
  • 라이브 커서 공유, 화이트보드
📡 SSE가 더 적합한 경우
  • 서버 → 클라이언트 단방향만 필요
  • 뉴스피드, 소셜 미디어 피드
  • 파일 업로드/다운로드 진행률
  • 서버 로그 스트리밍
  • 단순 알림 (Like, Comment 수)

🔌 2장. NestJS WebSocket Gateway 완전 구현

설치부터 이벤트 처리, 에러 핸들링까지

⚙️ 설치 및 기본 설정

# WebSocket 패키지 설치
npm install @nestjs/websockets @nestjs/platform-socket.io
npm install socket.io
npm install @types/socket.io -D

# 클라이언트 (프론트엔드)
npm install socket.io-client

🏗️ WebSocket Gateway 완전 구현

// chat.gateway.ts — 완전한 채팅 게이트웨이
import {
  WebSocketGateway, WebSocketServer,
  SubscribeMessage, MessageBody,
  ConnectedSocket, OnGatewayInit,
  OnGatewayConnection, OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { Logger } from '@nestjs/common';

@WebSocketGateway({
  cors: {
    origin: process.env.CLIENT_URL || 'http://localhost:3000',
    credentials: true,
  },
  namespace: '/chat',         // 네임스페이스 분리
  transports: ['websocket'],   // WebSocket만 사용 (polling 폴백 비활성화)
})
export class ChatGateway
  implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {

  @WebSocketServer()
  server: Server;

  private logger = new Logger('ChatGateway');

  // 게이트웨이 초기화 완료
  afterInit(server: Server) {
    this.logger.log('✅ ChatGateway initialized');
  }

  // 클라이언트 연결
  handleConnection(client: Socket) {
    this.logger.log(`🟢 Client connected: ${client.id}`);
    // 연결된 모든 클라이언트 수 브로드캐스트
    this.server.emit('userCount', { count: this.server.sockets.sockets.size });
  }

  // 클라이언트 연결 해제
  handleDisconnect(client: Socket) {
    this.logger.log(`🔴 Client disconnected: ${client.id}`);
    this.server.emit('userCount', { count: this.server.sockets.sockets.size });
  }

  // 채팅방 입장
  @SubscribeMessage('joinRoom')
  handleJoinRoom(
    @ConnectedSocket() client: Socket,
    @MessageBody() data: { room: string; username: string },
  ) {
    client.join(data.room);
    client.data.username = data.username;
    client.data.room = data.room;
    
    // 같은 방 사람들에게 입장 알림
    this.server.to(data.room).emit('userJoined', {
      username: data.username,
      message: `${data.username}님이 입장했습니다.`,
      timestamp: new Date().toISOString(),
    });
    this.logger.log(`${data.username} joined room: ${data.room}`);
  }

  // 메시지 전송
  @SubscribeMessage('sendMessage')
  async handleMessage(
    @ConnectedSocket() client: Socket,
    @MessageBody() data: { message: string; room: string },
  ) {
    const messageData = {
      id: crypto.randomUUID(),
      username: client.data.username,
      message: data.message,
      room: data.room,
      timestamp: new Date().toISOString(),
    };

    // DB에 저장 (옵션)
    await this.chatService.saveMessage(messageData);
    
    // 같은 방 모든 클라이언트에게 전송
    this.server.to(data.room).emit('receiveMessage', messageData);
    
    // ACK (수신 확인) 반환
    return { status: 'delivered', messageId: messageData.id };
  }

  // 타이핑 중 표시
  @SubscribeMessage('typing')
  handleTyping(
    @ConnectedSocket() client: Socket,
    @MessageBody() data: { room: string; isTyping: boolean },
  ) {
    // 자신 제외 같은 방에 전달
    client.to(data.room).emit('userTyping', {
      username: client.data.username,
      isTyping: data.isTyping,
    });
  }

  // 채팅방 퇴장
  @SubscribeMessage('leaveRoom')
  handleLeaveRoom(
    @ConnectedSocket() client: Socket,
    @MessageBody() data: { room: string },
  ) {
    client.leave(data.room);
    this.server.to(data.room).emit('userLeft', {
      username: client.data.username,
      message: `${client.data.username}님이 퇴장했습니다.`,
    });
  }
}

// chat.module.ts — 모듈 등록
@Module({
  providers: [ChatGateway, ChatService],
})
export class ChatModule {}

📡 Socket.IO 이벤트 방식 정리

메서드 대상 예시
server.emit() 연결된 모든 클라이언트 전체 공지, 서버 상태
client.emit() 해당 클라이언트만 개인 알림, DM
server.to(room).emit() 특정 방 전체 채팅방, 그룹 알림
client.to(room).emit() 나 빼고 같은 방 타이핑 표시
client.broadcast.emit() 나 빼고 전체 입장/퇴장 알림

💬 3장. Socket.IO 실전 — 완전한 채팅 앱 구현

프론트엔드 연동부터 메시지 영속성까지

🖥️ 프론트엔드 Socket.IO 클라이언트 (React)

// hooks/useSocket.ts — Socket.IO 커스텀 훅
import { useEffect, useRef, useState, useCallback } from 'react';
import { io, Socket } from 'socket.io-client';

interface Message {
  id: string;
  username: string;
  message: string;
  timestamp: string;
}

export function useChat(room: string, username: string, token: string) {
  const socketRef = useRef<Socket | null>(null);
  const [messages, setMessages] = useState<Message[]>([]);
  const [onlineUsers, setOnlineUsers] = useState<number>(0);
  const [typingUsers, setTypingUsers] = useState<string[]>([]);
  const [isConnected, setIsConnected] = useState(false);

  useEffect(() => {
    // Socket.IO 연결 생성
    const socket = io('http://localhost:3001/chat', {
      auth: { token },             // JWT 토큰으로 인증
      transports: ['websocket'],   // polling 폴백 없이 WebSocket만
      reconnection: true,          // 자동 재연결
      reconnectionAttempts: 5,
      reconnectionDelay: 1000,
    });

    // 연결 이벤트
    socket.on('connect', () => {
      setIsConnected(true);
      socket.emit('joinRoom', { room, username });
    });

    socket.on('disconnect', () => setIsConnected(false));

    // 메시지 수신
    socket.on('receiveMessage', (msg: Message) => {
      setMessages(prev => [...prev, msg]);
    });

    // 온라인 사용자 수
    socket.on('userCount', ({ count }) => setOnlineUsers(count));

    // 타이핑 표시
    socket.on('userTyping', ({ username: user, isTyping }) => {
      setTypingUsers(prev =>
        isTyping ? [...prev.filter(u => u !== user), user]
                 : prev.filter(u => u !== user)
      );
    });

    // 재연결 이벤트 처리
    socket.on('connect_error', (err) => {
      console.error(`Connection error: ${err.message}`);
    });

    socketRef.current = socket;
    return () => { socket.disconnect(); };
  }, [room, username, token]);

  // 메시지 전송
  const sendMessage = useCallback((message: string) => {
    socketRef.current?.emit('sendMessage', { message, room },
      (ack: { status: string }) => {
        console.log(`Message ${ack.status}`);  // ACK 확인
      }
    );
  }, [room]);

  // 타이핑 알림
  const sendTyping = useCallback((isTyping: boolean) => {
    socketRef.current?.emit('typing', { room, isTyping });
  }, [room]);

  return { messages, onlineUsers, typingUsers, isConnected, sendMessage, sendTyping };
}

// ChatRoom.tsx — 채팅 UI 컴포넌트
export function ChatRoom({ room, username, token }) {
  const { messages, onlineUsers, typingUsers, isConnected, sendMessage, sendTyping }
    = useChat(room, username, token);
  const [input, setInput] = useState('');
  const typingTimer = useRef<any>();

  const handleInput = (e) => {
    setInput(e.target.value);
    sendTyping(true);
    clearTimeout(typingTimer.current);
    typingTimer.current = setTimeout(() => sendTyping(false), 2000);
  };

  const handleSend = () => {
    if (!input.trim()) return;
    sendMessage(input);
    sendTyping(false);
    setInput('');
  };

  return (
    <div>
      <div>🟢 {isConnected ? '연결됨' : '연결 중...'} | 온라인: {onlineUsers}명</div>
      <div>{messages.map(m => <div key={m.id}>[{m.username}] {m.message}</div>)}</div>
      {typingUsers.length > 0 && <div>{typingUsers.join(', ')}이(가) 입력 중...</div>}
      <input value={input} onChange={handleInput} onKeyPress={e => e.key === 'Enter' && handleSend()} />
      <button onClick={handleSend}>전송</button>
    </div>
  );
}

🔐 4장. 인증 & 보안 — JWT로 WebSocket 보호

WebSocket 연결을 안전하게 지키는 실전 방법

⚠️ WebSocket 보안의 함정

HTTP 요청과 달리 WebSocket은 한 번 연결되면 계속 유지됩니다.
→ 인증 없이 연결을 허용하면 누구나 서버에 메시지를 보낼 수 있음!
연결 시점에 JWT 토큰을 검증하고, 이후 모든 메시지에서 사용자 정보를 활용해야 합니다.

🛡️ WebSocket Guard + JWT 인증 완전 구현

// ws-jwt.guard.ts — WebSocket JWT 검증 가드
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { WsException } from '@nestjs/websockets';

@Injectable()
export class WsJwtGuard implements CanActivate {
  constructor(private jwtService: JwtService) {}

  canActivate(context: ExecutionContext): boolean {
    const client = context.switchToWs().getClient<Socket>();
    try {
      // handshake.auth.token 또는 쿼리 파라미터에서 토큰 추출
      const token =
        client.handshake.auth?.token ||
        client.handshake.query?.token as string;
      if (!token) throw new WsException('토큰이 없습니다.');
      
      const payload = this.jwtService.verify(token);
      client.data.user = payload;  // 소켓에 사용자 정보 저장
      return true;
    } catch {
      throw new WsException('유효하지 않은 토큰입니다.');
    }
  }
}

// chat.gateway.ts — 가드 적용
@UseGuards(WsJwtGuard)
@WebSocketGateway({ namespace: '/chat' })
export class ChatGateway {
  @SubscribeMessage('sendMessage')
  handleMessage(@ConnectedSocket() client: Socket, @MessageBody() data) {
    // client.data.user에서 인증된 사용자 정보 사용
    const { userId, username, role } = client.data.user;
    
    // 관리자만 broadcast 가능 예시
    if (data.type === 'announcement' && role !== 'admin') {
      throw new WsException('권한이 없습니다.');
    }
    this.server.emit('message', { ...data, username });
  }
}

// ✅ 연결 단계에서 인증 미들웨어로도 처리 가능
export class ChatGateway implements OnGatewayInit {
  afterInit(server: Server) {
    // Socket.IO 미들웨어 — 연결 전에 실행
    server.use((socket, next) => {
      const token = socket.handshake.auth?.token;
      if (!token) return next(new Error('인증 필요'));
      try {
        socket.data.user = jwtService.verify(token);
        next();
      } catch {
        next(new Error('유효하지 않은 토큰'));
      }
    });
  }
}

// ✅ Rate Limiting — 메시지 스팸 방지
import { Inject } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';

@SubscribeMessage('sendMessage')
async handleMessage(@ConnectedSocket() client: Socket, @MessageBody() data) {
  const userId = client.data.user.userId;
  const rateKey = `ws:rate:${userId}`;
  
  const count = await this.cache.get<number>(rateKey) || 0;
  if (count >= 10) {  // 10초에 10개 메시지 제한
    throw new WsException('메시지 전송 속도 제한을 초과했습니다.');
  }
  await this.cache.set(rateKey, count + 1, 10);  // 10초 TTL
  
  // XSS 방지 — 메시지 이스케이프
  const safeMessage = data.message
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .slice(0, 500);  // 최대 500자 제한
  
  this.server.to(data.room).emit('receiveMessage', { ...data, message: safeMessage });
}

🔒 WebSocket 보안 체크리스트

✅ 연결 시 JWT 검증
handshake.auth.token 확인
✅ CORS 설정
허용된 origin만 연결 가능
✅ Rate Limiting
Redis로 초당 메시지 수 제한
✅ XSS 방지
메시지 이스케이프 처리
✅ 메시지 크기 제한
maxHttpBufferSize 설정
✅ WSS(TLS) 사용
wss:// (HTTPS처럼 암호화)

🚀 5장. Redis Pub/Sub으로 WebSocket 스케일 아웃

여러 서버 인스턴스에서 실시간 메시지를 공유하는 방법

🏢 스케일 아웃 문제 — 멀티 서버의 함정

서버가 1대일 때는 문제 없지만, 2대 이상으로 늘어나면?
사용자 A가 서버1에 연결, 사용자 B가 서버2에 연결 → A가 B에게 메시지 보내면 서버1만 알고 서버2는 모름!
해결책: Redis Pub/Sub으로 모든 서버가 메시지를 공유하게 만들기 → Socket.IO Redis Adapter

# Socket.IO Redis Adapter 설치
npm install @socket.io/redis-adapter ioredis

// main.ts — Redis Adapter 적용
import { createClient } from 'redis';
import { createAdapter } from '@socket.io/redis-adapter';
import { IoAdapter } from '@nestjs/platform-socket.io';

class RedisIoAdapter extends IoAdapter {
  private adapterConstructor: ReturnType<typeof createAdapter>;

  async connectToRedis() {
    const pubClient = createClient({ url: process.env.REDIS_URL });
    const subClient = pubClient.duplicate();
    await Promise.all([pubClient.connect(), subClient.connect()]);
    this.adapterConstructor = createAdapter(pubClient, subClient);
    console.log('✅ Redis Adapter 연결 완료!');
  }

  createIOServer(port: number, options?: ServerOptions): any {
    const server = super.createIOServer(port, options);
    server.adapter(this.adapterConstructor);
    return server;
  }
}

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const redisIoAdapter = new RedisIoAdapter(app);
  await redisIoAdapter.connectToRedis();
  app.useWebSocketAdapter(redisIoAdapter);
  await app.listen(3001);
}

// 이제 서버 여러 대가 동작해도 Redis를 통해 메시지 공유!
// 서버1의 user A → Redis Pub → 서버2의 user B도 수신 ✅

// ✅ 특정 사용자에게만 메시지 보내기 (멀티 서버에서도 동작)
@Injectable()
export class NotificationService {
  constructor(@InjectModel(Socket.name) private server: Server) {}
  
  // userId → socketId 매핑 (Redis에 저장)
  async sendToUser(userId: number, event: string, data: any) {
    const socketId = await this.cache.get<string>(`socket:user:${userId}`);
    if (socketId) {
      this.server.to(socketId).emit(event, data);
    }
  }
  
  // 연결 시 userId → socketId 저장
  async handleConnection(client: Socket) {
    const userId = client.data.user?.userId;
    if (userId) {
      await this.cache.set(
        `socket:user:${userId}`,
        client.id,
        24 * 60 * 60  // 24시간
      );
    }
  }
}

📊 Socket.IO Adapter 비교

Adapter 스케일 아웃 영속성 추천 상황
기본 (인메모리) ❌ 단일 서버만 개발/테스트
Redis Adapter ✅ 수평 확장 ⚠️ 제한적 현업 표준 (권장)
MongoDB Adapter ✅ 수평 확장 ✅ 영구 저장 메시지 이력 필요

📡 6장. SSE(Server-Sent Events) 완전 정복

단방향 실시간 스트리밍 — 알림, 진행률, 뉴스피드에 최적

📺 SSE란? — TV 방송 비유

TV 방송국(서버)은 방송을 내보내고, 시청자(클라이언트)는 받기만 합니다.
시청자가 리모컨을 누르면(HTTP 요청) → 방송국이 반응하는 일반 HTTP가 아니라
방송국이 지속적으로 데이터를 흘려보내는 것이 SSE입니다.
WebSocket보다 간단하고, HTTP/2와 완벽 호환, 자동 재연결 내장!

💻 NestJS SSE 완전 구현

// notifications.controller.ts — SSE 엔드포인트
import { Controller, Get, Sse, MessageEvent, Param, Req } from '@nestjs/common';
import { Observable, Subject, fromEvent, merge, interval } from 'rxjs';
import { map, takeUntil } from 'rxjs/operators';

@Controller('notifications')
export class NotificationsController {
  constructor(private notifService: NotificationsService) {}

  // ✅ 기본 SSE 엔드포인트
  @Sse('stream')
  @UseGuards(JwtAuthGuard)
  sse(@CurrentUser() user: User, @Req() req): Observable<MessageEvent> {
    const disconnect$ = new Subject<void>();
    
    // 클라이언트 연결 종료 감지
    req.on('close', () => {
      console.log(`SSE disconnected: user ${user.id}`);
      disconnect$.next();
      disconnect$.complete();
    });
    
    // 사용자별 알림 Observable
    return this.notifService.getUserNotifications$(user.id).pipe(
      takeUntil(disconnect$),
      map(notification => ({
        data: JSON.stringify(notification),
        type: notification.type,   // 이벤트 타입 (클라이언트에서 구분)
        id: notification.id,       // 재연결 시 마지막 이벤트 ID
        retry: 3000,              // 재연결 간격 (3초)
      })),
    );
  }

  // ✅ 파일 업로드 진행률 SSE
  @Sse('upload-progress/:jobId')
  uploadProgress(@Param('jobId') jobId: string): Observable<MessageEvent> {
    return interval(500).pipe(
      map(() => this.uploadService.getProgress(jobId)),
      map(progress => ({
        data: JSON.stringify({ progress, status: progress < 100 ? 'processing' : 'done' }),
      })),
    );
  }
}

// notifications.service.ts — Subject로 이벤트 발행
@Injectable()
export class NotificationsService {
  private subjects = new Map<number, Subject<any>>();

  getUserNotifications$(userId: number): Observable<any> {
    if (!this.subjects.has(userId)) {
      this.subjects.set(userId, new Subject());
    }
    return this.subjects.get(userId)!.asObservable();
  }

  // 알림 발송 (다른 서비스에서 호출)
  sendNotification(userId: number, notification: any) {
    this.subjects.get(userId)?.next(notification);
  }
}

// 프론트엔드 SSE 연결 (vanilla JS)
const evtSource = new EventSource(`/api/notifications/stream`, {
  withCredentials: true,  // 쿠키 포함 (인증)
});

// 기본 메시지
evtSource.onmessage = (e) => console.log(JSON.parse(e.data));

// 타입별 이벤트 처리
evtSource.addEventListener('like', (e) => showLikeNotification(JSON.parse(e.data)));
evtSource.addEventListener('comment', (e) => showCommentNotification(JSON.parse(e.data)));
evtSource.addEventListener('follow', (e) => showFollowNotification(JSON.parse(e.data)));

// 연결 오류 및 자동 재연결
evtSource.onerror = () => console.log('SSE 재연결 중...');
// EventSource는 자동으로 재연결! (별도 처리 불필요)

🏠 7장. Room & Namespace — 채널 분리 관리

효율적인 그룹 관리와 독립적인 네임스페이스 설계

🏢 Namespace vs Room — 아파트 비유

Namespace = 아파트 단지 (완전히 분리된 공간, /chat, /game, /notification)
Room = 아파트 각 호수 (같은 단지 내에서 그룹 분리, "방 1호", "방 2호")

예: /chat 네임스페이스 안에 "room:123", "room:456" 같은 방이 존재.
/game 네임스페이스는 채팅과 완전히 독립적으로 동작.

// 다중 네임스페이스 — 기능별 분리

// chat.gateway.ts
@WebSocketGateway({ namespace: '/chat' })
export class ChatGateway { ... }

// game.gateway.ts
@WebSocketGateway({ namespace: '/game' })
export class GameGateway { ... }

// notification.gateway.ts
@WebSocketGateway({ namespace: '/notifications' })
export class NotificationGateway { ... }

// ✅ 동적 Room 관리 — 채팅방 시스템
@Injectable()
export class RoomService {
  private rooms = new Map<string, Set<string>>();  // roomId → socketIds

  join(roomId: string, socketId: string, socket: Socket) {
    socket.join(roomId);
    if (!this.rooms.has(roomId)) this.rooms.set(roomId, new Set());
    this.rooms.get(roomId)!.add(socketId);
  }

  leave(roomId: string, socketId: string, socket: Socket) {
    socket.leave(roomId);
    this.rooms.get(roomId)?.delete(socketId);
    if (this.rooms.get(roomId)?.size === 0) {
      this.rooms.delete(roomId);  // 빈 방 정리
    }
  }

  getMembersCount(roomId: string): number {
    return this.rooms.get(roomId)?.size || 0;
  }

  getRoomList(): { roomId: string; memberCount: number }[] {
    return [...this.rooms.entries()].map(([roomId, members]) => ({
      roomId, memberCount: members.size,
    }));
  }
}

// chat.gateway.ts — Room 기반 채팅
@WebSocketGateway({ namespace: '/chat' })
export class ChatGateway {
  constructor(private roomService: RoomService) {}

  @SubscribeMessage('createRoom')
  async createRoom(@ConnectedSocket() client: Socket, @MessageBody() data: { name: string }) {
    const roomId = `room:${crypto.randomUUID()}`;
    const room = await this.chatService.createRoom({ name: data.name, ownerId: client.data.user.userId });
    
    this.roomService.join(roomId, client.id, client);
    client.emit('roomCreated', { roomId, name: data.name });
    return { roomId };
  }

  @SubscribeMessage('getRoomList')
  getRoomList() {
    return this.roomService.getRoomList();
  }

  // 연결 해제 시 모든 방에서 제거
  handleDisconnect(client: Socket) {
    const rooms = [...client.rooms];
    rooms.forEach(roomId => {
      this.roomService.leave(roomId, client.id, client);
      this.server.to(roomId).emit('userLeft', {
        username: client.data.username,
        membersCount: this.roomService.getMembersCount(roomId),
      });
    });
  }
}

// ✅ 1:1 DM 구현 — 사용자 소켓 ID로 직접 전송
@SubscribeMessage('directMessage')
async sendDM(
  @ConnectedSocket() client: Socket,
  @MessageBody() data: { targetUserId: number; message: string }
) {
  // 수신자 소켓 ID를 Redis에서 조회
  const targetSocketId = await this.cache.get<string>(
    `socket:user:${data.targetUserId}`
  );

  if (targetSocketId) {
    // 온라인 → 소켓으로 바로 전송
    this.server.to(targetSocketId).emit('dm', {
      from: client.data.user.username,
      message: data.message,
      timestamp: new Date().toISOString(),
    });
  } else {
    // 오프라인 → 푸시 알림 큐에 추가
    await this.notificationQueue.add('push', {
      userId: data.targetUserId, type: 'dm', data
    });
  }
  // DB에 메시지 저장 (온/오프라인 모두)
  await this.chatService.saveDM({ ...data, fromUserId: client.data.user.userId });
}

🔮 8장. GraphQL Subscriptions — 실시간 쿼리

GraphQL + WebSocket으로 선언적 실시간 데이터

🎯 GraphQL Subscriptions이란?

GraphQL에는 Query(조회), Mutation(변경), Subscription(실시간 구독) 3가지가 있습니다.
Subscription = "이 데이터가 바뀌면 자동으로 알려줘" → WebSocket으로 구현됩니다.
REST + Socket.IO 방식보다 더 선언적이고 타입 안전하게 실시간 기능 구현 가능!

# 설치
npm install @nestjs/graphql @nestjs/apollo graphql apollo-server-express
npm install graphql-subscriptions

// app.module.ts — GraphQL with Subscriptions 설정
@Module({
  imports: [
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      autoSchemaFile: true,
      subscriptions: {
        'graphql-ws': true,          // 최신 WebSocket 프로토콜
        'subscriptions-transport-ws': true,  // 레거시 지원
      },
    }),
  ],
})
export class AppModule {}

// messages.resolver.ts — Subscription 구현
import { Resolver, Mutation, Subscription, Args, Int } from '@nestjs/graphql';
import { PubSub } from 'graphql-subscriptions';

const pubSub = new PubSub();

@Resolver(() => Message)
export class MessagesResolver {
  // 메시지 생성 Mutation
  @Mutation(() => Message)
  async sendMessage(
    @Args('roomId', { type: () => Int }) roomId: number,
    @Args('content') content: string,
  ) {
    const message = await this.messagesService.create({ roomId, content });
    
    // 구독자에게 이벤트 발행!
    pubSub.publish(`messageAdded.${roomId}`, { messageAdded: message });
    return message;
  }

  // 실시간 메시지 구독
  @Subscription(() => Message, {
    // 특정 방만 필터링
    filter: (payload, variables) =>
      payload.messageAdded.roomId === variables.roomId,
    // 응답 데이터 변환
    resolve: (payload) => payload.messageAdded,
  })
  messageAdded(@Args('roomId', { type: () => Int }) roomId: number) {
    return pubSub.asyncIterator<Message>(`messageAdded.${roomId}`);
  }

  // 온라인 상태 변경 구독
  @Subscription(() => UserStatus)
  userStatusChanged() {
    return pubSub.asyncIterator('userStatusChanged');
  }
}

// GraphQL 쿼리 (프론트엔드 - Apollo Client)
const MESSAGE_SUBSCRIPTION = gql`
  subscription OnMessageAdded($roomId: Int!) {
    messageAdded(roomId: $roomId) {
      id
      content
      author { id username avatar }
      createdAt
    }
  }
`;

// React + Apollo Client 사용
function ChatRoom({ roomId }) {
  const { data, loading } = useSubscription(MESSAGE_SUBSCRIPTION, {
    variables: { roomId },
  });

  return (
    <div>
      {loading ? '연결 중...' : '실시간 연결됨'}
      {data?.messageAdded && <MessageBubble message={data.messageAdded} />}
    </div>
  );
}

// ✅ Redis PubSub (다중 서버용)
import { RedisPubSub } from 'graphql-redis-subscriptions';
const pubSub = new RedisPubSub({
  connection: { host: process.env.REDIS_HOST, port: +process.env.REDIS_PORT }
});

📊 REST+Socket.IO vs GraphQL Subscriptions

항목 REST + Socket.IO GraphQL Subscriptions
타입 안전성 ⚠️ 수동 관리 ✅ 자동 생성
데이터 선택 ❌ 서버가 결정 ✅ 클라이언트가 선택
학습 곡선 ✅ 간단 ⚠️ GraphQL 학습 필요
성능 ✅ 빠름 ⚠️ 오버헤드 있음
추천 상황 게임, 고성능 채팅 복잡한 데이터 요구사항

📈 9장. WebSocket 모니터링 & 성능 최적화

실시간 연결 모니터링과 대용량 처리 전략

📊 연결 상태 모니터링

// socket-monitor.service.ts — 연결 모니터링
@Injectable()
export class SocketMonitorService {
  private stats = {
    totalConnections: 0,
    activeConnections: 0,
    messagesSent: 0,
    messagesReceived: 0,
    errors: 0,
  };

  onConnect(clientId: string) {
    this.stats.totalConnections++;
    this.stats.activeConnections++;
    // Prometheus 메트릭 업데이트
    this.gauge.set(this.stats.activeConnections);
  }

  onDisconnect() { this.stats.activeConnections--; }
  onMessageSent() { this.stats.messagesSent++; }
  onError() { this.stats.errors++; }

  getStats() { return this.stats; }
}

// ✅ Socket.IO Admin UI — 브라우저에서 실시간 모니터링
import { instrument } from '@socket.io/admin-ui';
npm install @socket.io/admin-ui

export class ChatGateway implements OnGatewayInit {
  afterInit(server: Server) {
    if (process.env.NODE_ENV !== 'production') {
      instrument(server, {
        auth: {
          type: 'basic',
          username: 'admin',
          password: 'password',
        },
      });
      console.log('🔍 Admin UI: https://admin.socket.io');
    }
  }
}

// ✅ Heartbeat (핑-퐁) — 연결 상태 확인
export class ChatGateway {
  afterInit(server: Server) {
    // Socket.IO 기본 핑-퐁 설정
    // pingInterval: 10초마다 서버→클라이언트 ping
    // pingTimeout: 5초 안에 응답 없으면 연결 끊음
  }

  // 수동 헬스체크 이벤트
  @SubscribeMessage('ping')
  handlePing(@ConnectedSocket() client: Socket) {
    client.emit('pong', { timestamp: Date.now() });
  }
}

// ✅ 메시지 배치 처리 — 고빈도 이벤트 최적화 (throttling)
import { throttleTime, bufferTime } from 'rxjs/operators';
import { Subject } from 'rxjs';

export class GameGateway {
  private positionUpdates$ = new Subject<any>();

  constructor() {
    // 100ms마다 배치로 위치 업데이트 전송 (게임 최적화)
    this.positionUpdates$.pipe(
      bufferTime(100),   // 100ms 동안 쌓인 업데이트 모아서
    ).subscribe(updates => {
      if (updates.length > 0) {
        this.server.emit('positionBatch', updates);  // 한 번에 전송!
      }
    });
  }

  @SubscribeMessage('position')
  handlePosition(@MessageBody() data) {
    this.positionUpdates$.next(data);
  }
}

// ✅ 연결 수 제한 — DDoS 방지
export class ChatGateway implements OnGatewayConnection {
  private readonly MAX_CONNECTIONS = 10000;
  
  handleConnection(client: Socket) {
    if (this.server.sockets.sockets.size > this.MAX_CONNECTIONS) {
      client.emit('error', { message: '서버 최대 용량 초과. 잠시 후 다시 시도하세요.' });
      client.disconnect(true);
      return;
    }
  }
}

🎯 WebSocket 성능 최적화 팁

최적화 기법 방법 효과
메시지 압축 perMessageDeflate: true 전송 데이터 60~80% 감소
이진 데이터 JSON 대신 Buffer/ArrayBuffer 파싱 오버헤드 제거
배치 처리 bufferTime()으로 묶어서 전송 메시지 수 90% 감소
Namespace 분리 기능별 네임스페이스 불필요한 이벤트 수신 제거
Redis Adapter 다중 서버 수평 확장 무한 스케일 아웃 가능

🛠️ 10장. 실전 프로젝트 — 라이브 협업 도구 핵심 구현

실시간 문서 편집 + 커서 공유 + 채팅 통합

🎯 실전 프로젝트 구성

Notion/Google Docs처럼 여러 사람이 동시에 편집하는 문서 도구의 핵심 기능을 구현합니다.
• 실시간 텍스트 변경 동기화
• 다른 사람의 커서 위치 공유
• 문서별 채팅 + 사용자 현황

// collab.gateway.ts — 실시간 협업 게이트웨이
@WebSocketGateway({ namespace: '/collab' })
@UseGuards(WsJwtGuard)
export class CollabGateway implements OnGatewayConnection, OnGatewayDisconnect {
  @WebSocketServer() server: Server;
  
  // 문서별 편집자 목록 (docId → [{userId, username, cursor}])
  private docEditors = new Map<string, Map<string, EditorInfo>>();

  // 문서 열기
  @SubscribeMessage('openDoc')
  async handleOpenDoc(@ConnectedSocket() client: Socket, @MessageBody() data: { docId: string }) {
    const { userId, username } = client.data.user;
    
    // 문서 방에 입장
    client.join(`doc:${data.docId}`);
    
    // 편집자 목록에 추가
    if (!this.docEditors.has(data.docId)) {
      this.docEditors.set(data.docId, new Map());
    }
    this.docEditors.get(data.docId)!.set(client.id, {
      userId, username, color: this.getRandomColor(userId),
    });
    
    // 현재 문서 내용 + 편집자 목록 전달
    const doc = await this.docService.findOne(data.docId);
    client.emit('docData', {
      content: doc.content,
      editors: [...this.docEditors.get(data.docId)!.values()],
    });
    
    // 같은 문서 편집자들에게 새 참여자 알림
    client.to(`doc:${data.docId}`).emit('editorJoined', { userId, username });
  }

  // 텍스트 변경 동기화 (OT - Operational Transform 기초)
  @SubscribeMessage('textChange')
  async handleTextChange(
    @ConnectedSocket() client: Socket,
    @MessageBody() op: { docId: string; delta: any; version: number }
  ) {
    // 버전 충돌 확인
    const currentVersion = await this.docService.getVersion(op.docId);
    if (op.version !== currentVersion) {
      // 충돌! 클라이언트에게 현재 버전 전송
      client.emit('conflict', { latestVersion: currentVersion });
      return;
    }
    
    // DB 업데이트
    await this.docService.applyChange(op.docId, op.delta);
    
    // 나 빼고 같은 문서 편집자에게 변경사항 전파
    client.to(`doc:${op.docId}`).emit('textUpdate', {
      delta: op.delta,
      userId: client.data.user.userId,
      version: currentVersion + 1,
    });
  }

  // 커서 위치 공유 (타이핑 중 커서 표시)
  @SubscribeMessage('cursorMove')
  handleCursorMove(
    @ConnectedSocket() client: Socket,
    @MessageBody() data: { docId: string; position: { line: number; ch: number } }
  ) {
    // 커서 정보 저장
    const editorInfo = this.docEditors.get(data.docId)?.get(client.id);
    if (editorInfo) editorInfo.cursor = data.position;
    
    // 나 빼고 전파 (고빈도 이벤트이므로 throttle 권장)
    client.to(`doc:${data.docId}`).emit('cursorUpdate', {
      userId: client.data.user.userId,
      username: client.data.user.username,
      color: editorInfo?.color,
      position: data.position,
    });
  }

  // 연결 해제 시 편집자 목록에서 제거
  handleDisconnect(client: Socket) {
    this.docEditors.forEach((editors, docId) => {
      if (editors.has(client.id)) {
        const { userId } = editors.get(client.id)!;
        editors.delete(client.id);
        this.server.to(`doc:${docId}`).emit('editorLeft', { userId });
      }
    });
  }

  private getRandomColor(userId: number): string {
    const colors = ['#e94560', '#00d4ff', '#27ae60', '#f5a623', '#9b59b6'];
    return colors[userId % colors.length];
  }
}

🎯 11장. 면접 Q&A + 실전 체크리스트 + 로드맵

현업 개발자가 묻는 WebSocket 진짜 질문들

💬 자주 나오는 면접 질문 & 모범 답변

Q1. HTTP와 WebSocket의 차이점은? 언제 WebSocket을 써야 하나요?

HTTP: 단방향, 요청-응답 후 연결 종료, 서버가 먼저 데이터를 보낼 수 없음.
WebSocket: 양방향, 한 번 연결로 지속 통신, 서버가 언제든 데이터를 push 가능.

WebSocket을 선택해야 할 때: 실시간 채팅, 멀티플레이어 게임, 협업 도구, 실시간 시세처럼 서버에서 클라이언트로 자주 데이터를 보내야 할 때.
HTTP로 충분한 때: 사용자가 버튼 클릭 시에만 데이터가 필요한 경우 (일반 API)

Q2. WebSocket 서버를 여러 대로 스케일 아웃하면 어떤 문제가 생기나요?

문제: 서버 A에 연결된 사용자와 서버 B에 연결된 사용자는 같은 방에 있어도 메시지를 받지 못함. 각 서버가 자신에게 연결된 소켓만 알기 때문.

해결책: Redis Pub/Sub Adapter. 서버 A가 메시지를 Redis에 publish하면 → 서버 B가 subscribe해서 자신의 소켓으로 전달.
구현: @socket.io/redis-adapter 패키지 사용, main.ts에서 RedisIoAdapter 적용.

Q3. WebSocket 연결이 갑자기 끊어지면 어떻게 처리하나요?

클라이언트 측: Socket.IO의 자동 재연결 기능 활용 (reconnection: true, reconnectionAttempts, reconnectionDelay). connect_error 이벤트 처리.
서버 측: handleDisconnect에서 방 정리, 사용자 상태 업데이트, 필요시 오프라인 알림 전송.
메시지 유실 방지: 재연결 시 lastEventId로 놓친 메시지 재전송 (SSE의 경우), 또는 재연결 시 최근 N개 메시지 재전송 처리.

Q4. WebSocket과 SSE 중 어떤 것을 선택해야 하나요?

SSE를 선택: 서버→클라이언트 단방향 전송만 필요할 때. 알림, 뉴스피드, 진행률 표시. 구현이 훨씬 간단하고 HTTP/2와 잘 맞음.
WebSocket을 선택: 양방향 실시간 통신이 필요할 때. 채팅, 게임, 협업 도구.
실제 현업: 알림은 SSE, 채팅/게임은 WebSocket으로 혼용하는 경우가 많음.

Q5. WebSocket 보안에서 주의해야 할 점은 무엇인가요?

연결 시 JWT 검증: handshake.auth.token 필수 검증
CORS 설정: 허용된 origin만 연결 가능하도록
Rate Limiting: Redis로 초당/분당 메시지 수 제한 (스팸 방지)
입력 검증 + XSS 방지: 메시지 이스케이프, 길이 제한
WSS 사용: TLS/SSL로 암호화 (HTTPS처럼 wss:// 사용)
재연결 시 재인증: 토큰 만료 처리

✅ 레벨별 WebSocket 체크리스트

🌱 초급
  • WebSocket vs HTTP 차이 설명
  • NestJS Gateway 기본 구현
  • emit/on 이벤트 처리
  • Room 입장/퇴장 구현
  • 클라이언트 연결/해제 처리
🚀 중급
  • JWT 인증 + WsJwtGuard
  • SSE 엔드포인트 구현
  • Redis Pub/Sub Adapter
  • Rate Limiting 구현
  • 타이핑 표시, DM 구현
🏆 고급
  • GraphQL Subscriptions
  • 실시간 협업 OT 구현
  • 배치 처리로 성능 최적화
  • 연결 모니터링 대시보드
  • 10만 동시접속 아키텍처

🗺️ 실시간 통신 학습 로드맵 (4주)

📅 1주차
WebSocket vs HTTP 이해
NestJS Gateway 기초
기본 채팅 앱 구현
Room 시스템 구현
📅 2주차
JWT 인증 연동
SSE 구현
Rate Limiting
메시지 영속성(DB)
📅 3주차
Redis Adapter 적용
다중 서버 테스트
DM(1:1) 구현
프론트엔드 연동
📅 4주차
GraphQL Subscriptions
실시간 협업 기능
모니터링 설정
부하 테스트
💬
BackendDevGuide0009 완료!
WebSocket부터 GraphQL Subscriptions까지 — 이제 실시간 기능을 자유롭게 구현할 수 있습니다!
다음: BackendDevGuide0010 - 마이크로서비스 아키텍처 완전 정복 A-Z
🔌 WebSocket 완전정복 🚀 Socket.IO 실전 📡 SSE 실시간 🔮 GraphQL Sub
반응형