📚 학습 목차 — 이 가이드에서 배울 내용
🌐 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을 선택해야 할까?
- 실시간 채팅, 그룹 메시지
- 멀티플레이어 게임
- 실시간 협업 도구 (Google Docs 등)
- 주식/코인 실시간 시세
- 라이브 커서 공유, 화이트보드
- 서버 → 클라이언트 단방향만 필요
- 뉴스피드, 소셜 미디어 피드
- 파일 업로드/다운로드 진행률
- 서버 로그 스트리밍
- 단순 알림 (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, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.slice(0, 500); // 최대 500자 제한
this.server.to(data.room).emit('receiveMessage', { ...data, message: safeMessage });
}
🔒 WebSocket 보안 체크리스트
handshake.auth.token 확인
허용된 origin만 연결 가능
Redis로 초당 메시지 수 제한
메시지 이스케이프 처리
maxHttpBufferSize 설정
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 진짜 질문들
💬 자주 나오는 면접 질문 & 모범 답변
HTTP: 단방향, 요청-응답 후 연결 종료, 서버가 먼저 데이터를 보낼 수 없음.
WebSocket: 양방향, 한 번 연결로 지속 통신, 서버가 언제든 데이터를 push 가능.
WebSocket을 선택해야 할 때: 실시간 채팅, 멀티플레이어 게임, 협업 도구, 실시간 시세처럼 서버에서 클라이언트로 자주 데이터를 보내야 할 때.
HTTP로 충분한 때: 사용자가 버튼 클릭 시에만 데이터가 필요한 경우 (일반 API)
문제: 서버 A에 연결된 사용자와 서버 B에 연결된 사용자는 같은 방에 있어도 메시지를 받지 못함. 각 서버가 자신에게 연결된 소켓만 알기 때문.
해결책: Redis Pub/Sub Adapter. 서버 A가 메시지를 Redis에 publish하면 → 서버 B가 subscribe해서 자신의 소켓으로 전달.
구현: @socket.io/redis-adapter 패키지 사용, main.ts에서 RedisIoAdapter 적용.
클라이언트 측: Socket.IO의 자동 재연결 기능 활용 (reconnection: true, reconnectionAttempts, reconnectionDelay). connect_error 이벤트 처리.
서버 측: handleDisconnect에서 방 정리, 사용자 상태 업데이트, 필요시 오프라인 알림 전송.
메시지 유실 방지: 재연결 시 lastEventId로 놓친 메시지 재전송 (SSE의 경우), 또는 재연결 시 최근 N개 메시지 재전송 처리.
SSE를 선택: 서버→클라이언트 단방향 전송만 필요할 때. 알림, 뉴스피드, 진행률 표시. 구현이 훨씬 간단하고 HTTP/2와 잘 맞음.
WebSocket을 선택: 양방향 실시간 통신이 필요할 때. 채팅, 게임, 협업 도구.
실제 현업: 알림은 SSE, 채팅/게임은 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주)
NestJS Gateway 기초
기본 채팅 앱 구현
Room 시스템 구현
SSE 구현
Rate Limiting
메시지 영속성(DB)
다중 서버 테스트
DM(1:1) 구현
프론트엔드 연동
실시간 협업 기능
모니터링 설정
부하 테스트