본문 바로가기

실시간|알림

WebSocket 인증과 보안 완벽 가이드 - 실시간 통신 안전하게 구축하는 법

{ } </> 실시간/알림 WebSocket 인증과 보안

WebSocket 인증과 보안 완벽 가이드 - 실시간 통신 안전하게 구축하는 법

실시간 채팅, 알림, 협업 도구를 만들다 보면 WebSocket은 필수죠. 하지만 HTTP와 달리 양방향 연결이라는 특성 때문에 보안이 더 까다로워요. 제대로 된 인증 없이 WebSocket을 열어두면 누구나 접속할 수 있고, 토큰을 URL에 노출하면 로그에 그대로 남아버립니다. 이 글에서는 5년간 실시간 서비스를 운영하며 겪은 시행착오를 바탕으로, WebSocket 인증 구현부터 XSS/CSRF 방어, 재연결 처리까지 실무에서 바로 적용할 수 있는 보안 전략을 공유할게요. 코드 한 줄 한 줄에 "왜"가 담겨있으니, 복사-붙여넣기만 해도 안전한 서비스를 만들 수 있을 거예요.

WebSocket 인증이 HTTP와 다른 이유

HTTP는 매 요청마다 헤더에 인증 토큰을 담아 보낼 수 있어요. 하지만 WebSocket은 초기 핸드셰이크 이후 계속 연결이 유지되기 때문에, 연결 수립 시점에만 인증할 수 있죠. 더 큰 문제는 WebSocket API가 커스텀 헤더를 지원하지 않는다는 거예요.

// ❌ 이렇게 할 수 없어요 (브라우저 WebSocket API는 헤더 설정 불가)
const ws = new WebSocket('wss://api.example.com/chat', {
  headers: {
    'Authorization': 'Bearer ' + token  // 작동하지 않음!
  }
});

이 때문에 많은 개발자들이 다음과 같은 실수를 하게 돼요:

// ❌ 잘못된 예시 - 토큰을 URL에 노출
const ws = new WebSocket('wss://api.example.com/chat?token=' + token);
// 문제점: 서버 로그, 프록시, 브라우저 히스토리에 토큰이 남음

URL에 토큰을 넣으면 모든 로그 시스템에 토큰이 평문으로 기록되고, 리버스 프록시나 CDN을 거치면서 제3자에게 노출될 수 있어요. 실제로 저희 팀도 초기에 이 방식을 썼다가, 로그 분석 도구에서 모든 사용자의 액세스 토큰이 보이는 사고가 있었죠.

안전한 WebSocket 인증 구현 방법

가장 안전한 방법은 핸드셰이크 후 첫 메시지로 인증하기예요. 연결은 누구나 열 수 있지만, 인증되지 않으면 아무 기능도 사용할 수 없게 만드는 거죠.

클라이언트 구현

class SecureWebSocket {
  constructor(url, token) {
    this.url = url;
    this.token = token;
    this.authenticated = false;
    this.messageQueue = []; // 인증 전 메시지 큐
    this.reconnectAttempts = 0;
    this.maxReconnectAttempts = 5;

    this.connect();
  }

  connect() {
    // 토큰 없이 연결만 수립
    this.ws = new WebSocket(this.url);

    this.ws.onopen = () => {
      console.log('WebSocket 연결됨, 인증 시작...');

      // 연결 즉시 인증 메시지 전송
      this.ws.send(JSON.stringify({
        type: 'auth',
        token: this.token
      }));

      // 인증 타임아웃 설정 (5초 이내 인증 실패 시 연결 종료)
      this.authTimeout = setTimeout(() => {
        if (!this.authenticated) {
          console.error('인증 타임아웃');
          this.ws.close(4001, 'Authentication timeout');
        }
      }, 5000);
    };

    this.ws.onmessage = (event) => {
      const data = JSON.parse(event.data);

      // 인증 응답 처리
      if (data.type === 'auth_response') {
        clearTimeout(this.authTimeout);

        if (data.success) {
          this.authenticated = true;
          this.reconnectAttempts = 0;
          console.log('인증 성공');

          // 인증 성공 후 큐에 쌓인 메시지 전송
          this.messageQueue.forEach(msg => this.ws.send(msg));
          this.messageQueue = [];

          // 인증 성공 콜백
          if (this.onAuthenticated) this.onAuthenticated();
        } else {
          console.error('인증 실패:', data.error);
          this.ws.close(4003, 'Authentication failed');
        }
        return;
      }

      // 인증된 상태에서만 다른 메시지 처리
      if (this.authenticated && this.onMessage) {
        this.onMessage(data);
      }
    };

    this.ws.onerror = (error) => {
      console.error('WebSocket 에러:', error);
    };

    this.ws.onclose = (event) => {
      this.authenticated = false;
      console.log('WebSocket 종료:', event.code, event.reason);

      // 비정상 종료 시 재연결 시도
      if (event.code !== 1000 && this.reconnectAttempts < this.maxReconnectAttempts) {
        const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
        console.log(`${delay}ms 후 재연결 시도 (${this.reconnectAttempts + 1}/${this.maxReconnectAttempts})`);

        setTimeout(() => {
          this.reconnectAttempts++;
          this.connect();
        }, delay);
      }
    };
  }

  send(data) {
    // 인증 전에는 메시지를 큐에 저장
    if (!this.authenticated) {
      this.messageQueue.push(JSON.stringify(data));
      return;
    }

    this.ws.send(JSON.stringify(data));
  }

  close() {
    this.reconnectAttempts = this.maxReconnectAttempts; // 재연결 방지
    this.ws.close(1000, 'Client closed');
  }
}

// 사용 예시
const token = localStorage.getItem('accessToken');
const ws = new SecureWebSocket('wss://api.example.com/chat', token);

ws.onAuthenticated = () => {
  console.log('채팅방 입장 준비 완료');
  ws.send({ type: 'join_room', roomId: 'room123' });
};

ws.onMessage = (data) => {
  console.log('메시지 수신:', data);
};

이 구현의 핵심은 인증 전에는 아무 기능도 사용할 수 없다는 거예요. 메시지를 보내려 해도 큐에 쌓였다가, 인증 성공 후에야 전송되죠. 또한 재연결 로직도 포함되어 있어서, 네트워크 끊김이나 서버 재시작 상황에서도 자동으로 복구돼요.

서버 구현 (Node.js + ws 라이브러리)

const WebSocket = require('ws');
const jwt = require('jsonwebtoken');

const wss = new WebSocket.Server({ port: 8080 });

// 인증된 클라이언트 관리
const authenticatedClients = new Map();

wss.on('connection', (ws, req) => {
  const clientId = generateClientId(); // UUID 생성 함수
  let isAuthenticated = false;
  let userId = null;

  console.log(`새 연결: ${clientId}, IP: ${req.socket.remoteAddress}`);

  // 인증 타임아웃 설정 (클라이언트와 동일하게 5초)
  const authTimeout = setTimeout(() => {
    if (!isAuthenticated) {
      console.log(`인증 타임아웃: ${clientId}`);
      ws.close(4001, 'Authentication timeout');
    }
  }, 5000);

  ws.on('message', async (message) => {
    try {
      const data = JSON.parse(message);

      // 첫 메시지는 반드시 인증이어야 함
      if (!isAuthenticated) {
        if (data.type !== 'auth') {
          ws.send(JSON.stringify({
            type: 'error',
            message: 'Authentication required'
          }));
          ws.close(4003, 'Authentication required');
          return;
        }

        // JWT 토큰 검증
        try {
          const decoded = jwt.verify(data.token, process.env.JWT_SECRET);
          userId = decoded.userId;

          // 토큰 만료 시간 확인 (추가 검증)
          if (decoded.exp * 1000 < Date.now()) {
            throw new Error('Token expired');
          }

          clearTimeout(authTimeout);
          isAuthenticated = true;

          // 인증된 클라이언트 등록 (같은 유저의 중복 연결 관리)
          if (!authenticatedClients.has(userId)) {
            authenticatedClients.set(userId, new Set());
          }
          authenticatedClients.get(userId).add(ws);

          console.log(`인증 성공: userId=${userId}, clientId=${clientId}`);

          ws.send(JSON.stringify({
            type: 'auth_response',
            success: true,
            userId: userId
          }));

        } catch (error) {
          console.error(`인증 실패: ${clientId}`, error.message);

          ws.send(JSON.stringify({
            type: 'auth_response',
            success: false,
            error: 'Invalid or expired token'
          }));

          ws.close(4003, 'Authentication failed');
        }
        return;
      }

      // 인증 후 메시지 처리
      handleAuthenticatedMessage(ws, userId, data);

    } catch (error) {
      console.error('메시지 처리 에러:', error);
      ws.send(JSON.stringify({
        type: 'error',
        message: 'Invalid message format'
      }));
    }
  });

  ws.on('close', () => {
    clearTimeout(authTimeout);

    if (isAuthenticated && userId) {
      const userConnections = authenticatedClients.get(userId);
      if (userConnections) {
        userConnections.delete(ws);
        if (userConnections.size === 0) {
          authenticatedClients.delete(userId);
        }
      }
    }

    console.log(`연결 종료: userId=${userId}, clientId=${clientId}`);
  });

  ws.on('error', (error) => {
    console.error(`WebSocket 에러: clientId=${clientId}`, error);
  });
});

function handleAuthenticatedMessage(ws, userId, data) {
  // 실제 비즈니스 로직 처리
  switch (data.type) {
    case 'send_message':
      // 메시지 전송 로직
      broadcastToRoom(data.roomId, {
        type: 'new_message',
        from: userId,
        content: data.content,
        timestamp: Date.now()
      });
      break;

    case 'join_room':
      // 채팅방 입장 로직
      ws.send(JSON.stringify({
        type: 'room_joined',
        roomId: data.roomId
      }));
      break;

    default:
      ws.send(JSON.stringify({
        type: 'error',
        message: 'Unknown message type'
      }));
  }
}

function generateClientId() {
  return require('crypto').randomUUID();
}

console.log('WebSocket 서버 시작: ws://localhost:8080');

서버 코드의 핵심 포인트는 모든 연결은 의심하고, 인증 전에는 아무것도 하지 않는다는 원칙이에요. 첫 메시지가 인증이 아니면 즉시 연결을 끊고, 인증 타임아웃도 설정해서 무한정 기다리지 않게 했죠.

CORS와 Origin 검증

WebSocket도 Same-Origin Policy의 영향을 받아요. 하지만 HTTP와 달리 브라우저가 preflight 요청을 보내지 않기 때문에, 서버에서 명시적으로 Origin을 검증해야 해요.

const WebSocket = require('ws');

// 허용할 Origin 목록
const ALLOWED_ORIGINS = [
  'https://myapp.com',
  'https://www.myapp.com',
  'https://admin.myapp.com'
];

// 개발 환경에서는 localhost도 허용
if (process.env.NODE_ENV === 'development') {
  ALLOWED_ORIGINS.push('http://localhost:3000', 'http://localhost:5173');
}

const wss = new WebSocket.Server({ 
  noServer: true  // HTTP 서버와 통합하여 Origin 검증
});

const server = require('http').createServer();

server.on('upgrade', (request, socket, head) => {
  const origin = request.headers.origin;

  // Origin 헤더 검증
  if (!origin || !ALLOWED_ORIGINS.includes(origin)) {
    console.warn(`차단된 Origin: ${origin}, IP: ${socket.remoteAddress}`);
    socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
    socket.destroy();
    return;
  }

  // 추가 보안: User-Agent 검증 (봇 필터링)
  const userAgent = request.headers['user-agent'];
  if (!userAgent || userAgent.includes('bot')) {
    console.warn(`의심스러운 User-Agent: ${userAgent}`);
    socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
    socket.destroy();
    return;
  }

  // 검증 통과 시 WebSocket으로 업그레이드
  wss.handleUpgrade(request, socket, head, (ws) => {
    wss.emit('connection', ws, request);
  });
});

server.listen(8080, () => {
  console.log('WebSocket 서버 시작 (Origin 검증 활성화)');
});

왜 이렇게 하냐면, WebSocket 연결은 쿠키를 자동으로 포함하기 때문에 CSRF 공격에 취약해요. 악의적인 사이트에서 new WebSocket('wss://victim.com/chat')를 실행하면, 사용자 브라우저의 쿠키가 함께 전송돼버리죠. Origin 검증으로 이를 막을 수 있어요.

XSS 방어와 토큰 관리

WebSocket 보안에서 가장 많이 간과되는 부분이 토큰 저장 방식이에요. localStorage에 저장하면 XSS 공격으로 쉽게 탈취당해요.

안전한 토큰 저장 전략

// ❌ 잘못된 예시 - localStorage는 XSS에 취약
localStorage.setItem('accessToken', token);
const ws = new WebSocket('wss://api.example.com');
ws.send(JSON.stringify({
  type: 'auth',
  token: localStorage.getItem('accessToken')
}));

// ✅ 올바른 예시 1 - HttpOnly 쿠키 사용
// 서버에서 Set-Cookie: access_token=xxx; HttpOnly; Secure; SameSite=Strict
// 클라이언트는 토큰에 직접 접근 불가, XSS로부터 안전

// 하지만 WebSocket은 쿠키를 직접 사용할 수 없으므로
// 별도의 단기 토큰 발급 엔드포인트 필요
async function getWebSocketToken() {
  // HttpOnly 쿠키가 자동으로 포함됨
  const response = await fetch('/api/ws-token', {
    method: 'POST',
    credentials: 'include'  // 쿠키 포함
  });

  const { wsToken } = await response.json();

  // 단기 토큰 (1분 유효)을 메모리에만 보관
  return wsToken;
}

// ✅ 올바른 예시 2 - 메모리에만 토큰 보관
class TokenManager {
  constructor() {
    this.accessToken = null;
    this.refreshToken = null;
    this.tokenExpiry = null;
  }

  setTokens(access, refresh, expiresIn) {
    this.accessToken = access;
    this.refreshToken = refresh;
    this.tokenExpiry = Date.now() + (expiresIn * 1000);

    // 만료 5분 전 자동 갱신
    const refreshTime = (expiresIn - 300) * 1000;
    setTimeout(() => this.refreshAccessToken(), refreshTime);
  }

  async refreshAccessToken() {
    try {
      const response = await fetch('/api/refresh', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ refreshToken: this.refreshToken })
      });

      const { accessToken, expiresIn } = await response.json();
      this.setTokens(accessToken, this.refreshToken, expiresIn);

      // WebSocket 재인증 필요 시 처리
      if (window.wsConnection) {
        window.wsConnection.reconnect(accessToken);
      }
    } catch (error) {
      console.error('토큰 갱신 실패:', error);
      // 로그아웃 처리
      this.clearTokens();
      window.location.href = '/login';
    }
  }

  getAccessToken() {
    if (Date.now() > this.tokenExpiry) {
      console.warn('토큰 만료됨');
      return null;
    }
    return this.accessToken;
  }

  clearTokens() {
    this.accessToken = null;
    this.refreshToken = null;
    this.tokenExpiry = null;
  }
}

// 전역 인스턴스 (페이지 새로고침 시 사라짐)
const tokenManager = new TokenManager();

// 로그인 후 토큰 저장
async function login(username, password) {
  const response = await fetch('/api/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username, password })
  });

  const { accessToken, refreshToken, expiresIn } = await response.json();
  tokenManager.setTokens(accessToken, refreshToken, expiresIn);

  // WebSocket 연결
  const ws = new SecureWebSocket(
    'wss://api.example.com/chat',
    tokenManager.getAccessToken()
  );
}

메모리에만 토큰을 보관하면 XSS 공격으로부터 안전하지만, 페이지를 새로고침하면 토큰이 사라져요. 이를 해결하려면 refresh token을 사용한 자동 재로그인을 구현하거나, HttpOnly 쿠키 방식을 혼용해야 해요.

Rate Limiting과 DoS 방어

WebSocket은 연결 상태가 유지되기 때문에 메시지 폭탄 공격에 취약해요. 클라이언트당 메시지 전송 속도를 제한해야 해요.

const WebSocket = require('ws');

class RateLimiter {
  constructor(maxMessages, windowMs) {
    this.maxMessages = maxMessages;  // 최대 메시지 수
    this.windowMs = windowMs;        // 시간 윈도우 (밀리초)
    this.clients = new Map();        // clientId -> [timestamps]
  }

  check(clientId) {
    const now = Date.now();

    if (!this.clients.has(clientId)) {
      this.clients.set(clientId, []);
    }

    const timestamps = this.clients.get(clientId);

    // 시간 윈도우 밖의 타임스탬프 제거
    const validTimestamps = timestamps.filter(
      ts => now - ts < this.windowMs
    );

    this.clients.set(clientId, validTimestamps);

    // 제한 초과 확인
    if (validTimestamps.length >= this.maxMessages) {
      return false;  // 차단
    }

    // 현재 타임스탬프 추가
    validTimestamps.push(now);
    return true;  // 허용
  }

  reset(clientId) {
    this.clients.delete(clientId);
  }
}

// 1분에 최대 60개 메시지
const rateLimiter = new RateLimiter(60, 60000);

// 연결당 최대 메시지 크기 제한
const MAX_MESSAGE_SIZE = 10 * 1024; // 10KB

wss.on('connection', (ws, req) => {
  const clientId = generateClientId();
  let messageCount = 0;
  let isAuthenticated = false;

  ws.on('message', (message) => {
    // 메시지 크기 검증
    if (message.length > MAX_MESSAGE_SIZE) {
      console.warn(`너무 큰 메시지: ${clientId}, size=${message.length}`);
      ws.send(JSON.stringify({
        type: 'error',
        message: 'Message too large'
      }));
      ws.close(4009, 'Message too large');
      return;
    }

    // Rate limiting 검증
    if (!rateLimiter.check(clientId)) {
      console.warn(`Rate limit 초과: ${clientId}`);
      ws.send(JSON.stringify({
        type: 'error',
        message: 'Rate limit exceeded'
      }));
      ws.close(4008, 'Rate limit exceeded');
      return;
    }

    // 메시지 처리...
    messageCount++;

    // 통계 로깅 (1000개마다)
    if (messageCount % 1000 === 0) {
      console.log(`${clientId}: ${messageCount} messages processed`);
    }
  });

  ws.on('close', () => {
    rateLimiter.reset(clientId);
  });
});

// IP별 동시 연결 수 제한
const connectionsByIP = new Map();
const MAX_CONNECTIONS_PER_IP = 10;

wss.on('connection', (ws, req) => {
  const ip = req.socket.remoteAddress;

  if (!connectionsByIP.has(ip)) {
    connectionsByIP.set(ip, 0);
  }

  const currentConnections = connectionsByIP.get(ip);

  if (currentConnections >= MAX_CONNECTIONS_PER_IP) {
    console.warn(`IP당 연결 수 초과: ${ip}`);
    ws.close(4007, 'Too many connections from this IP');
    return;
  }

  connectionsByIP.set(ip, currentConnections + 1);

  ws.on('close', () => {
    const count = connectionsByIP.get(ip) - 1;
    if (count <= 0) {
      connectionsByIP.delete(ip);
    } else {
      connectionsByIP.set(ip, count);
    }
  });
});

Rate limiting은 단순히 DoS 공격만 막는 게 아니라, 비정상적인 클라이언트 동작을 조기에 탐지하는 역할도 해요. 정상적인 사용자라면 1분에 60개 메시지를 보낼 일이 거의 없거든요.

토큰 갱신과 재연결 전략

Access token은 보통 짧은 유효기간(15분~1시간)을 가져요. WebSocket 연결이 그보다 오래 유지되면 토큰 갱신 로직이 필요해요.

class AutoRefreshWebSocket extends SecureWebSocket {
  constructor(url, getTokenFn, refreshIntervalMs = 840000) { // 14분
    const initialToken = getTokenFn();
    super(url, initialToken);

    this.getTokenFn = getTokenFn;
    this.refreshIntervalMs = refreshIntervalMs;

    // 주기적으로 토큰 갱신
    this.startTokenRefresh();
  }

  startTokenRefresh() {
    this.tokenRefreshInterval = setInterval(async () => {
      if (!this.authenticated) return;

      console.log('토큰 갱신 시작...');

      try {
        const newToken = await this.getTokenFn();

        // 서버에 재인증 요청
        this.ws.send(JSON.stringify({
          type: 'reauth',
          token: newToken
        }));

        // 서버 응답 대기
        const reAuthPromise = new Promise((resolve, reject) => {
          const handler = (event) => {
            const data = JSON.parse(event.data);
            if (data.type === 'reauth_response') {
              this.ws.removeEventListener('message', handler);

              if (data.success) {
                console.log('재인증 성공');
                resolve();
              } else {
                console.error('재인증 실패');
                reject(new Error('Reauth failed'));
              }
            }
          };

          this.ws.addEventListener('message', handler);

          // 5초 타임아웃
          setTimeout(() => {
            this.ws.removeEventListener('message', handler);
            reject(new Error('Reauth timeout'));
          }, 5000);
        });

        await reAuthPromise;

      } catch (error) {
        console.error('토큰 갱신 실패:', error);
        // 재인증 실패 시 재연결
        this.reconnect();
      }
    }, this.refreshIntervalMs);
  }

  reconnect() {
    console.log('재연결 시작...');
    this.authenticated = false;
    this.ws.close(1000, 'Reconnecting');
    this.token = this.getTokenFn();
    this.connect();
  }

  close() {
    clearInterval(this.tokenRefreshInterval);
    super.close();
  }
}

// 사용 예시
const ws = new AutoRefreshWebSocket(
  'wss://api.example.com/chat',
  () => tokenManager.getAccessToken()  // 최신 토큰을 가져오는 함수
);

서버 측에서도 재인증을 지원해야 해요:

ws.on('message', async (message) => {
  const data = JSON.parse(message);

  if (data.type === 'reauth') {
    try {
      const decoded = jwt.verify(data.token, process.env.JWT_SECRET);

      // 기존 userId와 일치하는지 확인
      if (decoded.userId !== userId) {
        throw new Error('User ID mismatch');
      }

      console.log(`재인증 성공: userId=${userId}`);

      ws.send(JSON.stringify({
        type: 'reauth_response',
        success: true
      }));

    } catch (error) {
      console.error('재인증 실패:', error);

      ws.send(JSON.stringify({
        type: 'reauth_response',
        success: false,
        error: error.message
      }));

      ws.close(4003, 'Reauthentication failed');
    }
    return;
  }

  // 나머지 메시지 처리...
});

흔한 보안 실수와 해결책

실수 1: 에러 메시지에 민감한 정보 노출

// ❌ 잘못된 예시
ws.on('message', async (message) => {
  try {
    const data = JSON.parse(message);
    const user = await db.query('SELECT * FROM users WHERE id = ?', [data.userId]);

    if (!user) {
      // 데이터베이스 구조 노출!
      ws.send(JSON.stringify({
        error: 'User not found in users table'
      }));
    }
  } catch (error) {
    // 스택 트레이스 노출!
    ws.send(JSON.stringify({
      error: error.stack
    }));
  }
});

// ✅ 올바른 예시
ws.on('message', async (message) => {
  try {
    const data = JSON.parse(message);
    const user = await db.query('SELECT * FROM users WHERE id = ?', [data.userId]);

    if (!user) {
      // 일반적인 에러 메시지만
      ws.send(JSON.stringify({
        type: 'error',
        code: 'USER_NOT_FOUND',
        message: 'Invalid request'
      }));

      // 상세한 로그는 서버에만
      console.error(`User not found: userId=${data.userId}, clientId=${clientId}`);
    }
  } catch (error) {
    // 클라이언트에는 일반 메시지만
    ws.send(JSON.stringify({
      type: 'error',
      code: 'INTERNAL_ERROR',
      message: 'An error occurred'
    }));

    // 상세한 에러는 서버 로그에만
    console.error('Database error:', error);
  }
});

실수 2: 사용자 입력 검증 누락

// ❌ 잘못된 예시 - SQL Injection 취약
ws.on('message', async (message) => {
  const data = JSON.parse(message);
  const result = await db.query(
    `SELECT * FROM messages WHERE room_id = ${data.roomId}`
  );
});

// ✅ 올바른 예시 - Prepared statement 사용
const { body } = require('express-validator');

ws.on('message', async (message) => {
  const data = JSON.parse(message);

  // 입력 검증
  if (!data.roomId || typeof data.roomId !== 'string') {
    ws.send(JSON.stringify({
      type: 'error',
      message: 'Invalid room ID'
    }));
    return;
  }

  // 길이 제한
  if (data.content && data.content.length > 1000) {
    ws.send(JSON.stringify({
      type: 'error',
      message: 'Message too long'
    }));
    return;
  }

  // HTML 태그 제거 (XSS 방어)
  const sanitizedContent = data.content
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;');

  // Prepared statement로 안전하게 쿼리
  const result = await db.query(
    'SELECT * FROM messages WHERE room_id = ?',
    [data.roomId]
  );
});

실수 3: 권한 검증 누락

// ❌ 잘못된 예시 - 권한 확인 없이 메시지 삭제
ws.on('message', async (message) => {
  const data = JSON.parse(message);

  if (data.type === 'delete_message') {
    await db.query('DELETE FROM messages WHERE id = ?', [data.messageId]);
    ws.send(JSON.stringify({ success: true }));
  }
});

// ✅ 올바른 예시 - 권한 확인 후 삭제
ws.on('message', async (message) => {
  const data = JSON.parse(message);

  if (data.type === 'delete_message') {
    // 메시지 소유자 확인
    const msg = await db.query(
      'SELECT author_id FROM messages WHERE id = ?',
      [data.messageId]
    );

    if (!msg) {
      ws.send(JSON.stringify({
        type: 'error',
        message: 'Message not found'
      }));
      return;
    }

    // 본인 메시지인지 또는 관리자인지 확인
    if (msg.author_id !== userId && !isAdmin(userId)) {
      console.warn(`권한 없는 삭제 시도: userId=${userId}, messageId=${data.messageId}`);

      ws.send(JSON.stringify({
        type: 'error',
        message: 'Permission denied'
      }));
      return;
    }

    // 권한 확인 후 삭제
    await db.query('DELETE FROM messages WHERE id = ?', [data.messageId]);
    ws.send(JSON.stringify({ 
      type: 'message_deleted',
      messageId: data.messageId
    }));
  }
});

프로덕션 환경 보안 체크리스트

실제 서비스를 배포하기 전에 꼭 확인해야 할 보안 항목이에요:

// 프로덕션 보안 설정 예시
const wss = new WebSocket.Server({
  // 1. TLS 필수 (wss://)
  server: httpsServer,  // HTTP가 아닌 HTTPS 서버 사용

  // 2. 헤더 크기 제한
  maxHeaderSize: 4096,

  // 3. 연결당 메시지 버퍼 제한
  perMessageDeflate: {
    zlibDeflateOptions: {
      chunkSize: 1024,
      memLevel: 7,
      level: 3
    },
    zlibInflateOptions: {
      chunkSize: 10 * 1024
    },
    threshold: 1024
  },

  // 4. 클라이언트 추적 비활성화 (메모리 절약)
  clientTracking: false,

  // 5. 최대 동시 연결 수 제한
  maxClients: 10000
});

// 6. Heartbeat으로 좀비 연결 정리
function heartbeat() {
  this.isAlive = true;
}

wss.on('connection', (ws) => {
  ws.isAlive = true;
  ws.on('pong', heartbeat);
});

const heartbeatInterval = setInterval(() => {
  wss.clients.forEach((ws) => {
    if (ws.isAlive === false) {
      console.log('좀비 연결 종료');
      return ws.terminate();
    }

    ws.isAlive = false;
    ws.ping();
  });
}, 30000);  // 30초마다 체크

wss.on('close', () => {
  clearInterval(heartbeatInterval);
});

// 7. 보안 헤더 설정 (reverse proxy에서)
// Nginx 설정 예시:
/*
location /ws {
    proxy_pass http://localhost:8080;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    # 보안 헤더
    proxy_set_header X-Frame-Options DENY;
    proxy_set_header X-Content-Type-Options nosniff;
    proxy_set_header Strict-Transport-Security "max-age=31536000";

    # 타임아웃 설정
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
}
*/

// 8. 환경변수로 민감한 정보 관리
require('dotenv').config();

const JWT_SECRET = process.env.JWT_SECRET;
const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS.split(',');

if (!JWT_SECRET || JWT_SECRET.length < 32) {
  throw new Error('JWT_SECRET must be at least 32 characters');
}

// 9. 로깅과 모니터링
const winston = require('winston');

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [
    new winston.transports.File({ filename: 'error.log', level: 'error' }),
    new winston.transports.File({ filename: 'combined.log' })
  ]
});

ws.on('connection', (ws, req) => {
  logger.info('New connection', {
    ip: req.socket.remoteAddress,
    userAgent: req.headers['user-agent'],
    timestamp: new Date().toISOString()
  });
});

// 10. 비정상 패턴 탐지
const suspiciousActivity = new Map();

function detectSuspiciousActivity(clientId, activity) {
  if (!suspiciousActivity.has(clientId)) {
    suspiciousActivity.set(clientId, []);
  }

  const activities = suspiciousActivity.get(clientId);
  activities.push({ activity, timestamp: Date.now() });

  // 5분 내 10회 이상 의심 활동 시 차단
  const recent = activities.filter(a => Date.now() - a.timestamp < 300000);

  if (recent.length >= 10) {
    logger.warn('Suspicious activity detected', { clientId, activities: recent });
    return true;  // 차단
  }

  return false;
}

결론

WebSocket 보안은 "한 번 설정하면 끝"이 아니라 지속적으로 관리해야 하는 영역이에요. 제가 5년간 실시간 서비스를 운영하며 배운 핵심 교훈을 정리하면:

  1. 인증은 연결 후 첫 메시지로 - 토큰을 URL에 절대 넣지 마세요
  2. Origin과 Rate Limiting은 필수 - DoS 공격과 CSRF를 동시에 막아요
  3. 메모리 토큰 관리 - XSS 공격으로부터 가장 안전한 방법이에요
  4. 에러 처리와 로깅 분리 - 클라이언트에는 최소한의 정보만, 상세 로그는 서버에만
  5. Heartbeat으로 좀비 연결 정리 - 메모리 누수를 막고 서버 안정성을 높여요

실무에서 바로 적용할 수 있게 모든 코드에 주석을 달았으니, 여러분의 프로젝트에 맞게 커스터마이징해서 사용하세요. 특히 Rate Limiting 수치는 서비스 특성에 맞게 조정이 필요해요.

다음에 읽으면 좋은 글:

  • Socket.io vs 네이티브 WebSocket - 어떤 걸 선택해야 할까?
  • Redis Pub/Sub으로 WebSocket 수평 확장하기 - 다중 서버 환경에서의 메시지 브로드캐스팅
  • WebSocket 성능 최적화 실전 가이드 - 수만 명 동시 접속 처리하기

궁금한 점이나 실무에서 겪은 보안 이슈가 있다면 댓글로 공유해주세요. 함께 더 안전한 실시간 서비스를 만들어가요! 🚀