이미지 로딩 중...

WebRTC 보안 및 인증 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 11. 21. · 6 Views

WebRTC 보안 및 인증 완벽 가이드

실시간 영상 통화 서비스를 안전하게 구축하는 방법을 배워봅니다. HTTPS 설정부터 종단간 암호화까지, 실무에서 바로 적용할 수 있는 보안 기법을 초급 개발자 눈높이에서 친절하게 안내합니다.


목차

  1. HTTPS/WSS 필수 설정
  2. JWT 인증 시스템
  3. 회의실 비밀번호
  4. 대기실 기능
  5. 종단간 암호화 (E2EE)
  6. CORS 설정

1. HTTPS/WSS 필수 설정

시작하며

여러분이 WebRTC로 화상 통화 앱을 만들었는데, 갑자기 카메라와 마이크가 작동하지 않는다면? 브라우저 콘솔에 "getUserMedia는 보안 컨텍스트에서만 사용 가능합니다"라는 에러가 뜨는 상황을 경험해본 적 있나요?

이런 문제는 실제 개발 현장에서 가장 자주 발생하는 실수입니다. 웹 브라우저는 사용자의 개인정보를 보호하기 위해 카메라, 마이크, 위치 정보 같은 민감한 기능을 HTTPS 환경에서만 허용합니다.

HTTP로 접속하면 아예 차단되어버리죠. 바로 이럴 때 필요한 것이 HTTPS와 WSS(WebSocket Secure) 설정입니다.

마치 집에 현관문 잠금장치를 설치하는 것처럼, 여러분의 서비스에 보안 레이어를 추가하는 첫 번째 단계입니다.

개요

간단히 말해서, HTTPS는 웹사이트와 사용자 사이의 통신을 암호화하는 프로토콜입니다. 마치 편지를 봉투에 넣어 보내는 것처럼, 데이터를 암호화해서 중간에 누가 가로채도 읽을 수 없게 만듭니다.

WebRTC 서비스에서 HTTPS가 필수인 이유는 세 가지입니다. 첫째, getUserMedia 같은 미디어 API는 HTTPS에서만 작동합니다.

둘째, 사용자의 영상과 음성 데이터가 암호화되어 안전하게 전송됩니다. 셋째, 최신 브라우저들은 보안되지 않은 사이트에서 많은 기능을 차단합니다.

기존에는 HTTP로 개발하고 나중에 배포할 때만 HTTPS를 적용했다면, 이제는 개발 단계부터 HTTPS 환경을 구축하는 것이 표준입니다. 로컬 개발 환경에서도 mkcert 같은 도구로 인증서를 만들어 사용합니다.

WSS는 WebSocket의 보안 버전입니다. WebRTC 시그널링 서버와 통신할 때 사용하는데, HTTPS와 마찬가지로 데이터를 암호화합니다.

HTTPS 페이지에서는 반드시 WSS를 사용해야 하며, WS(일반 WebSocket)는 차단됩니다. 실무에서는 Nginx나 Caddy 같은 웹 서버를 리버스 프록시로 사용하여 SSL/TLS 인증서를 관리합니다.

Let's Encrypt를 사용하면 무료로 자동 갱신되는 인증서를 발급받을 수 있어서 매우 편리합니다.

코드 예제

// Node.js Express 서버에 HTTPS 적용하기
const https = require('https');
const fs = require('fs');
const express = require('express');

const app = express();

// SSL 인증서 파일 읽기
const options = {
  key: fs.readFileSync('server.key'),    // 개인키
  cert: fs.readFileSync('server.cert')   // 인증서
};

// HTTPS 서버 생성
const server = https.createServer(options, app);

// WebSocket Secure 설정
const WebSocket = require('ws');
const wss = new WebSocket.Server({ server, path: '/signal' });

wss.on('connection', (ws) => {
  console.log('새로운 보안 연결이 수립되었습니다');
  // 시그널링 로직...
});

server.listen(443, () => console.log('HTTPS 서버가 443 포트에서 실행 중'));

설명

이것이 하는 일: 위 코드는 Node.js 서버에 SSL/TLS 인증서를 적용하여 HTTPS와 WSS를 동시에 지원하는 보안 서버를 만듭니다. 첫 번째로, https 모듈과 fs 모듈을 사용하여 인증서 파일을 읽어옵니다.

server.key는 개인키이고 server.cert는 공인 인증서입니다. 이 두 파일은 마치 자물쇠와 열쇠처럼 한 쌍으로 작동하며, Let's Encrypt나 인증 기관에서 발급받을 수 있습니다.

그 다음으로, https.createServer()에 인증서 옵션과 Express 앱을 전달하여 HTTPS 서버를 생성합니다. 이렇게 하면 모든 HTTP 트래픽이 자동으로 암호화됩니다.

브라우저 주소창에 자물쇠 아이콘이 표시되고, 사용자는 안전한 연결임을 확인할 수 있습니다. 세 번째로, WebSocket.Server를 생성할 때 server 객체를 전달하면 자동으로 WSS로 업그레이드됩니다.

path 옵션으로 시그널링 엔드포인트를 지정하고, connection 이벤트에서 클라이언트와의 보안 통신을 처리합니다. 마지막으로, 443 포트(HTTPS 표준 포트)에서 서버를 실행합니다.

이제 클라이언트는 https://yourdomain.com으로 접속하고, wss://yourdomain.com/signal로 WebSocket 연결을 수립할 수 있습니다. 여러분이 이 코드를 사용하면 브라우저의 보안 정책을 통과하여 getUserMedia를 정상적으로 사용할 수 있습니다.

또한 모든 데이터가 암호화되어 중간자 공격으로부터 안전하고, SEO와 신뢰도도 향상됩니다.

실전 팁

💡 로컬 개발 환경에서는 mkcert 도구를 사용하여 신뢰할 수 있는 자체 서명 인증서를 만드세요. localhost와 127.0.0.1 모두 인증서에 포함시키면 편리합니다.

💡 프로덕션 환경에서는 Let's Encrypt와 Certbot을 사용하여 무료 SSL 인증서를 자동 발급하고 갱신하세요. 90일마다 자동 갱신되도록 cron job을 설정하는 것을 잊지 마세요.

💡 HTTP로 접속하는 사용자를 자동으로 HTTPS로 리다이렉트하는 미들웨어를 추가하세요. Express에서는 express-sslify 패키지가 유용합니다.

💡 HSTS(HTTP Strict Transport Security) 헤더를 설정하여 브라우저가 항상 HTTPS로만 접속하도록 강제하세요. helmet 미들웨어를 사용하면 쉽게 적용할 수 있습니다.

💡 인증서 만료일을 모니터링하는 시스템을 구축하세요. 인증서가 만료되면 서비스 전체가 중단되므로, 최소 30일 전에 알림을 받도록 설정하는 것이 안전합니다.


2. JWT 인증 시스템

시작하며

여러분이 화상 회의 서비스를 만들었는데, 누구나 아무 회의실에나 들어갈 수 있다면? 회사의 중요한 임원 회의에 낯선 사람이 갑자기 참여하는 상황을 상상해보세요.

이런 일이 실제로 발생하면 큰 보안 사고가 됩니다. 이런 문제는 인증(Authentication)과 인가(Authorization) 시스템이 없을 때 발생합니다.

사용자가 누구인지 확인하고, 특정 회의실에 접근할 권한이 있는지 검증해야 합니다. 세션 기반 인증도 있지만, WebRTC처럼 실시간 통신이 많은 서비스에서는 무상태(Stateless) 인증이 더 효율적입니다.

바로 이럴 때 필요한 것이 JWT(JSON Web Token) 인증 시스템입니다. 마치 영화관 입장권처럼, 한 번 발급받으면 서버에 계속 확인하지 않아도 유효성을 검증할 수 있는 토큰 방식입니다.

개요

간단히 말해서, JWT는 사용자 정보를 안전하게 담은 암호화된 문자열입니다. 헤더, 페이로드, 서명 세 부분으로 구성되며, 점(.)으로 연결되어 "xxx.yyy.zzz" 형태를 가집니다.

WebRTC 서비스에서 JWT가 유용한 이유는 여러 가지입니다. 첫째, 서버가 세션을 저장할 필요가 없어 확장성이 뛰어납니다.

여러 서버를 운영해도 토큰만 검증하면 되죠. 둘째, 토큰 안에 사용자 ID, 권한, 회의실 정보 등을 담을 수 있어 데이터베이스 조회가 줄어듭니다.

셋째, 만료 시간을 설정하여 보안을 강화할 수 있습니다. 기존에는 서버 세션과 쿠키를 사용했다면, 이제는 클라이언트가 토큰을 localStorage나 메모리에 저장하고 매 요청마다 Authorization 헤더에 포함시킵니다.

서버는 비밀키로 서명을 검증하여 위조 여부를 확인합니다. JWT의 핵심 특징은 세 가지입니다.

자체 포함성(Self-contained): 토큰 자체에 필요한 정보가 모두 들어있습니다. 무상태성(Stateless): 서버가 토큰을 저장하지 않아도 됩니다.

확장 가능성(Scalable): 마이크로서비스 아키텍처에 적합합니다. 이러한 특징들이 실시간 통신 서비스에서 매우 중요합니다.

실무에서는 로그인 시 Access Token과 Refresh Token을 함께 발급합니다. Access Token은 15분처럼 짧게, Refresh Token은 7일처럼 길게 설정하여 보안과 사용자 경험을 모두 챙깁니다.

코드 예제

// JWT 발급 및 검증 시스템
const jwt = require('jsonwebtoken');

const SECRET_KEY = process.env.JWT_SECRET || 'your-secret-key-change-this';

// 사용자 로그인 후 토큰 발급
function generateToken(userId, roomId, role = 'participant') {
  const payload = {
    userId: userId,
    roomId: roomId,
    role: role,                    // 참여자, 호스트 등 권한
    iat: Math.floor(Date.now() / 1000)  // 발급 시간
  };

  // 15분 후 만료되는 토큰 생성
  return jwt.sign(payload, SECRET_KEY, { expiresIn: '15m' });
}

// 미들웨어: 토큰 검증
function verifyToken(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1]; // "Bearer xxx"

  if (!token) {
    return res.status(401).json({ error: '토큰이 필요합니다' });
  }

  try {
    const decoded = jwt.verify(token, SECRET_KEY);
    req.user = decoded;  // 요청 객체에 사용자 정보 저장
    next();
  } catch (error) {
    return res.status(403).json({ error: '유효하지 않은 토큰입니다' });
  }
}

// 보호된 라우트 예시
app.post('/join-room', verifyToken, (req, res) => {
  const { userId, roomId, role } = req.user;
  // 사용자가 해당 회의실에 접근 권한이 있는지 확인됨
  res.json({ message: `${roomId} 입장 허용`, role });
});

설명

이것이 하는 일: 위 코드는 사용자 로그인 시 JWT를 발급하고, API 요청마다 토큰을 검증하여 인증된 사용자만 회의실에 접근할 수 있도록 합니다. 첫 번째로, generateToken 함수는 사용자 ID, 회의실 ID, 권한 정보를 페이로드에 담아 JWT를 생성합니다.

jwt.sign() 메서드가 SECRET_KEY로 서명하여 위조 방지 기능을 추가하고, expiresIn 옵션으로 15분 후 자동 만료되도록 설정합니다. 이렇게 짧은 만료 시간을 설정하면 토큰이 탈취되어도 피해를 최소화할 수 있습니다.

그 다음으로, verifyToken 미들웨어는 모든 보호된 API 요청을 가로채서 토큰을 검증합니다. Authorization 헤더에서 "Bearer" 접두사를 제거하고 토큰만 추출한 다음, jwt.verify()로 서명이 올바른지 확인합니다.

만료되었거나 위조된 토큰이면 에러가 발생하여 403 응답을 반환합니다. 세 번째로, 검증에 성공하면 디코딩된 페이로드를 req.user에 저장하여 다음 미들웨어나 라우트 핸들러에서 사용할 수 있게 합니다.

이렇게 하면 매번 데이터베이스를 조회하지 않아도 사용자 정보를 알 수 있어 성능이 크게 향상됩니다. 마지막으로, /join-room 같은 보호된 라우트에서는 verifyToken 미들웨어를 거친 후에만 실행됩니다.

이미 인증이 완료된 상태이므로 req.user에서 사용자 정보를 꺼내 회의실 입장 로직을 처리하면 됩니다. 호스트인지 참여자인지에 따라 다른 권한을 부여할 수도 있습니다.

여러분이 이 코드를 사용하면 안전하고 확장 가능한 인증 시스템을 구축할 수 있습니다. 서버를 여러 대로 늘려도 세션 동기화 문제가 없고, 데이터베이스 부하도 줄어들어 수천 명이 동시에 접속하는 서비스에도 대응할 수 있습니다.

실전 팁

💡 절대로 JWT 페이로드에 비밀번호나 민감한 개인정보를 넣지 마세요. JWT는 암호화가 아닌 인코딩이므로, Base64 디코딩하면 누구나 내용을 볼 수 있습니다. 사용자 ID 정도만 넣는 것이 안전합니다.

💡 SECRET_KEY는 환경 변수로 관리하고 절대 코드에 하드코딩하지 마세요. 최소 32자 이상의 무작위 문자열을 사용하고, 프로덕션과 개발 환경의 키를 다르게 설정하세요.

💡 Access Token은 짧게(15분), Refresh Token은 길게(7일) 설정하는 이중 토큰 전략을 사용하세요. Access Token이 만료되면 Refresh Token으로 새로 발급받아 사용자가 다시 로그인하지 않아도 됩니다.

💡 WebSocket 연결 시에도 초기 핸드셰이크에서 토큰을 검증하세요. 쿼리 파라미터나 커스텀 헤더로 토큰을 전달하고, 연결이 수립되기 전에 검증해야 안전합니다.

💡 토큰 블랙리스트를 Redis에 저장하여 로그아웃 기능을 구현하세요. JWT는 무상태이므로 강제로 무효화할 수 없지만, 블랙리스트를 확인하면 로그아웃된 토큰을 거부할 수 있습니다.


3. 회의실 비밀번호

시작하며

여러분이 팀 미팅을 하는데, 회의실 링크가 외부에 유출되어 전혀 모르는 사람들이 계속 들어온다면? Zoom이나 Google Meet처럼 대중적인 서비스에서도 이런 일이 실제로 발생해서 "Zoom 폭탄"이라는 용어까지 생겼습니다.

이런 문제는 회의실 URL만으로 접근을 제어할 때 발생합니다. URL은 쉽게 공유되고 유출될 수 있기 때문에, 추가적인 보안 장치가 필요합니다.

특히 민감한 내용을 다루는 회의나 유료 서비스라면 더욱 중요하죠. 바로 이럴 때 필요한 것이 회의실 비밀번호 기능입니다.

마치 은행 카드에 비밀번호가 있는 것처럼, 링크를 알아도 비밀번호를 모르면 입장할 수 없게 만드는 2차 보안 레이어입니다.

개요

간단히 말해서, 회의실 비밀번호는 특정 회의실에 접근하려면 올바른 비밀번호를 입력해야 하는 보안 기능입니다. URL 기반 접근 제어의 한계를 보완하는 필수 장치입니다.

회의실 비밀번호가 필요한 이유는 명확합니다. 첫째, URL이 SNS나 메신저로 유출되어도 비밀번호를 모르면 접근할 수 없습니다.

둘째, 회의 호스트가 비밀번호를 변경하여 기존 링크를 무효화할 수 있습니다. 셋째, 보안 감사나 컴플라이언스 요구사항을 충족시킬 수 있습니다.

기존에는 회의실 ID만으로 접근을 제어했다면, 이제는 회의실 ID + 비밀번호 조합으로 이중 인증합니다. 비밀번호는 단방향 해시 함수로 암호화하여 저장하고, 사용자가 입력한 값을 같은 방식으로 해시하여 비교합니다.

비밀번호 시스템의 핵심 특징은 다음과 같습니다. 선택적 적용: 호스트가 공개/비공개 회의를 선택할 수 있습니다.

임시 비밀번호: 일회용 비밀번호를 생성하여 보안을 강화할 수 있습니다. 실패 제한: 연속 실패 시 일시적으로 차단하여 무차별 대입 공격을 방지합니다.

실무에서는 bcrypt나 scrypt 같은 안전한 해시 알고리즘을 사용하고, 비밀번호 복잡도 정책을 적용합니다. 또한 회의가 종료되면 비밀번호도 함께 삭제하여 데이터를 최소화합니다.

코드 예제

// 회의실 비밀번호 설정 및 검증
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 10;

// 회의실 생성 시 비밀번호 설정
async function createRoom(roomId, password) {
  // 비밀번호를 bcrypt로 해시화하여 저장
  const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);

  // 데이터베이스에 저장
  await db.rooms.insert({
    id: roomId,
    password: hashedPassword,
    createdAt: new Date(),
    maxAttempts: 5  // 최대 시도 횟수
  });

  return { roomId, message: '회의실이 생성되었습니다' };
}

// 회의실 입장 시 비밀번호 검증
async function verifyRoomPassword(roomId, inputPassword, clientIp) {
  const room = await db.rooms.findOne({ id: roomId });

  if (!room) {
    throw new Error('존재하지 않는 회의실입니다');
  }

  // 실패 횟수 체크 (무차별 대입 공격 방지)
  const attempts = await getAttempts(clientIp, roomId);
  if (attempts >= room.maxAttempts) {
    throw new Error('시도 횟수 초과. 10분 후 다시 시도하세요');
  }

  // 비밀번호 비교
  const isValid = await bcrypt.compare(inputPassword, room.password);

  if (!isValid) {
    await incrementAttempts(clientIp, roomId);
    throw new Error('잘못된 비밀번호입니다');
  }

  // 검증 성공 시 실패 카운트 초기화
  await resetAttempts(clientIp, roomId);
  return { success: true, roomId };
}

// Express 라우트 예시
app.post('/verify-room', async (req, res) => {
  try {
    const { roomId, password } = req.body;
    const clientIp = req.ip;

    const result = await verifyRoomPassword(roomId, password, clientIp);
    res.json(result);
  } catch (error) {
    res.status(401).json({ error: error.message });
  }
});

설명

이것이 하는 일: 위 코드는 회의실 생성 시 비밀번호를 암호화하여 저장하고, 입장 시 입력된 비밀번호를 안전하게 검증하는 시스템입니다. 첫 번째로, createRoom 함수는 회의실을 생성할 때 bcrypt.hash()를 사용하여 비밀번호를 해시화합니다.

SALT_ROUNDS가 10이면 약 2^10번의 해시 연산을 수행하여 무지개 테이블 공격을 방지합니다. 원본 비밀번호는 절대 저장하지 않고, 해시값만 데이터베이스에 저장합니다.

그 다음으로, verifyRoomPassword 함수는 사용자가 입장하려고 할 때 실행됩니다. 먼저 해당 IP에서 몇 번 실패했는지 확인하여, 최대 시도 횟수를 초과하면 즉시 차단합니다.

이렇게 하면 자동화된 공격 프로그램이 수천 번 비밀번호를 시도하는 것을 막을 수 있습니다. 세 번째로, bcrypt.compare()로 사용자가 입력한 비밀번호와 저장된 해시를 비교합니다.

bcrypt는 자동으로 salt를 처리하므로, 같은 비밀번호라도 매번 다른 해시값이 생성되지만 비교는 정확하게 됩니다. 일치하지 않으면 실패 카운터를 증가시키고 에러를 반환합니다.

마지막으로, 비밀번호가 일치하면 해당 IP의 실패 카운터를 초기화하고 성공 응답을 반환합니다. 이제 프론트엔드는 이 응답을 받아 JWT를 발급받거나 WebSocket 연결을 수립하여 회의실에 입장할 수 있습니다.

여러분이 이 코드를 사용하면 안전하고 사용자 친화적인 회의실 보안 시스템을 구축할 수 있습니다. 링크가 유출되어도 비밀번호가 보호해주고, 무차별 대입 공격도 자동으로 차단되어 서버 리소스를 절약할 수 있습니다.

실전 팁

💡 비밀번호 복잡도 정책을 프론트엔드와 백엔드 양쪽에서 검증하세요. 최소 8자 이상, 숫자와 문자 조합을 권장하되, 너무 복잡하면 사용자 경험이 나빠질 수 있습니다.

💡 실패 횟수 카운터는 Redis에 TTL과 함께 저장하세요. 10분 후 자동으로 삭제되도록 설정하면 차단된 사용자가 시간이 지나면 다시 시도할 수 있습니다.

💡 비밀번호 없이도 입장 가능한 "공개 회의실" 옵션을 제공하세요. 호스트가 선택할 수 있게 하면 유연성이 높아집니다. 데이터베이스에 password 필드가 null이면 검증을 건너뛰도록 구현합니다.

💡 일회용 비밀번호(OTP) 기능을 추가하면 보안이 더욱 강화됩니다. 회의 시작 시 6자리 숫자 코드를 생성하고, 5분간만 유효하도록 설정하여 링크 유출 피해를 최소화합니다.

💡 회의가 종료되면 비밀번호 데이터를 즉시 삭제하는 정책을 구현하세요. GDPR 같은 개인정보 보호 규정을 준수하고, 데이터 최소화 원칙을 실천할 수 있습니다.


4. 대기실 기능

시작하며

여러분이 중요한 고객 미팅을 진행하는데, 준비도 안 된 상태에서 참가자들이 계속 들어와서 어수선해진다면? 또는 스팸 봇이나 잘못 들어온 사람을 회의 중간에 강제 퇴장시켜야 하는 불편한 상황이 생긴다면?

이런 문제는 실시간 입장 제어가 없을 때 발생합니다. 비밀번호만으로는 "누가" 들어오는지 확인할 수 없고, 한 번 입장하면 호스트가 통제하기 어렵습니다.

특히 웨비나나 온라인 수업처럼 호스트 주도적인 회의에서는 큰 문제가 됩니다. 바로 이럴 때 필요한 것이 대기실(Waiting Room) 기능입니다.

마치 병원 대기실처럼, 참가자들이 먼저 대기하고 호스트가 승인한 사람만 회의실로 들여보내는 시스템입니다.

개요

간단히 말해서, 대기실은 참가자가 회의실에 바로 입장하지 못하고 호스트의 승인을 기다리는 가상 공간입니다. 호스트는 대기 중인 참가자 목록을 보고 개별적으로 승인하거나 거부할 수 있습니다.

대기실 기능이 필요한 이유는 여러 가지입니다. 첫째, 호스트가 회의 시작 전 준비할 시간을 확보할 수 있습니다.

둘째, 참가자의 신원을 확인하고 부적절한 사람의 입장을 차단할 수 있습니다. 셋째, 회의 분위기를 조성하고 순서대로 입장시킬 수 있어 전문적인 인상을 줍니다.

기존에는 모든 참가자가 즉시 입장했다면, 이제는 대기실에서 호스트의 승인을 기다립니다. 실시간으로 대기 상태가 업데이트되고, 호스트는 모바일이나 웹에서 언제든지 승인/거부할 수 있습니다.

대기실 시스템의 핵심 특징은 다음과 같습니다. 실시간 알림: WebSocket으로 즉시 상태 변경이 전달됩니다.

일괄 승인: 여러 참가자를 한 번에 승인할 수 있습니다. 자동 정책: 호스트가 없을 때 자동 승인하거나 모든 참가자를 대기시키는 정책을 설정할 수 있습니다.

실무에서는 대기실 상태를 Redis에 저장하여 빠른 조회와 업데이트를 지원하고, 이벤트 기반 아키텍처로 여러 서버 간 상태를 동기화합니다.

코드 예제

// 대기실 관리 시스템
const waitingRooms = new Map(); // roomId -> Set of participants

// 참가자가 입장 요청 시
function joinWaitingRoom(roomId, participant) {
  if (!waitingRooms.has(roomId)) {
    waitingRooms.set(roomId, new Set());
  }

  const waiting = waitingRooms.get(roomId);
  waiting.add(participant);

  // 호스트에게 새 참가자 알림 전송
  notifyHost(roomId, {
    type: 'NEW_PARTICIPANT',
    participant: {
      id: participant.id,
      name: participant.name,
      joinedAt: new Date()
    }
  });

  // 참가자에게 대기 상태 전송
  notifyParticipant(participant.id, {
    type: 'WAITING',
    message: '호스트가 승인할 때까지 기다려주세요'
  });
}

// 호스트가 참가자 승인
function admitParticipant(roomId, participantId, hostId) {
  const waiting = waitingRooms.get(roomId);

  if (!waiting) {
    throw new Error('대기실이 존재하지 않습니다');
  }

  const participant = Array.from(waiting).find(p => p.id === participantId);

  if (!participant) {
    throw new Error('참가자를 찾을 수 없습니다');
  }

  // 대기실에서 제거
  waiting.delete(participant);

  // 참가자에게 입장 허용 알림
  notifyParticipant(participantId, {
    type: 'ADMITTED',
    roomId: roomId,
    message: '회의실에 입장합니다'
  });

  // 회의실 참가자 목록에 추가
  addToMainRoom(roomId, participant);

  return { success: true, participantId };
}

// 모든 대기 참가자 일괄 승인
function admitAll(roomId, hostId) {
  const waiting = waitingRooms.get(roomId);

  if (!waiting || waiting.size === 0) {
    return { success: true, count: 0 };
  }

  const participants = Array.from(waiting);

  participants.forEach(participant => {
    admitParticipant(roomId, participant.id, hostId);
  });

  return { success: true, count: participants.length };
}

// WebSocket 메시지 핸들러 예시
wss.on('connection', (ws, req) => {
  ws.on('message', (message) => {
    const data = JSON.parse(message);

    if (data.type === 'JOIN_WAITING') {
      joinWaitingRoom(data.roomId, data.participant);
    } else if (data.type === 'ADMIT') {
      admitParticipant(data.roomId, data.participantId, data.hostId);
    }
  });
});

설명

이것이 하는 일: 위 코드는 참가자가 회의실에 입장하려고 할 때 대기실에 먼저 배치하고, 호스트가 승인하면 본 회의실로 이동시키는 시스템입니다. 첫 번째로, joinWaitingRoom 함수는 참가자가 입장 요청을 보낼 때 실행됩니다.

Map과 Set 자료구조를 사용하여 각 회의실별로 대기 중인 참가자 목록을 관리합니다. 참가자를 대기실에 추가한 후, WebSocket을 통해 호스트에게 실시간 알림을 보내 새로운 참가자가 있음을 알립니다.

그 다음으로, admitParticipant 함수는 호스트가 특정 참가자의 승인 버튼을 클릭했을 때 실행됩니다. 대기실 목록에서 해당 참가자를 찾아 제거하고, 참가자에게 입장 허용 메시지를 전송합니다.

이때 WebRTC 연결을 수립하기 위한 시그널링 정보도 함께 전달합니다. 세 번째로, admitAll 함수는 여러 참가자를 한 번에 승인하는 편의 기능입니다.

대규모 웨비나에서 수십 명을 일일이 승인하기 번거로울 때 유용하며, Array.from()으로 Set을 배열로 변환하여 forEach로 순회하면서 모두 승인합니다. 마지막으로, WebSocket 메시지 핸들러에서 클라이언트의 요청을 받아 적절한 함수를 호출합니다.

JOIN_WAITING 이벤트는 참가자가 보내고, ADMIT 이벤트는 호스트가 보내며, 서버가 중간에서 이를 조율하여 실시간 양방향 통신을 구현합니다. 여러분이 이 코드를 사용하면 전문적인 화상 회의 서비스처럼 입장 흐름을 제어할 수 있습니다.

스팸 봇을 원천 차단하고, 회의 시작 전 호스트가 여유 있게 준비할 수 있으며, 참가자들도 안정적인 입장 경험을 얻게 됩니다.

실전 팁

💡 대기실 상태를 Redis에 저장하면 서버가 재시작되어도 대기 중인 참가자 정보가 보존됩니다. 또한 여러 서버를 운영할 때 Redis Pub/Sub으로 상태를 동기화할 수 있습니다.

💡 대기 시간이 5분을 초과하면 자동으로 연결을 끊고 재입장을 요구하세요. 무한정 대기하면 메모리 누수가 발생할 수 있고, 참가자도 혼란스러워합니다.

💡 호스트가 오프라인이거나 없을 때의 정책을 미리 설정하게 하세요. "자동 승인", "모두 대기", "부호스트에게 권한 위임" 등의 옵션을 제공하면 유연성이 높아집니다.

💡 대기실에서도 간단한 프로필 정보를 표시하세요. 이름, 이메일, 프로필 사진을 보여주면 호스트가 승인 여부를 쉽게 판단할 수 있습니다. 익명 참가자는 기본 아바타로 표시합니다.

💡 거부된 참가자에게는 재입장 제한을 두세요. 같은 참가자가 계속 입장 요청을 보내면 IP 기반으로 일시 차단하고, 호스트가 명시적으로 거부한 경우 해당 회의에는 영구 차단합니다.


5. 종단간 암호화 (E2EE)

시작하며

여러분이 의료 상담이나 법률 자문처럼 매우 민감한 내용을 화상으로 나누는데, 서버 관리자나 해커가 영상과 음성을 엿듣고 있다면? HTTPS로 전송 중에는 암호화되지만, 서버에 도착하면 복호화되어 서버에서는 평문으로 볼 수 있습니다.

이런 문제는 중앙 서버를 거치는 모든 통신 시스템의 근본적인 한계입니다. 서버가 암호화 키를 가지고 있으면 데이터를 읽을 수 있고, 서버가 해킹당하거나 내부자가 악의적이면 모든 통신이 노출됩니다.

금융 정보나 개인 의료 정보를 다룰 때는 치명적입니다. 바로 이럴 때 필요한 것이 종단간 암호화(E2EE, End-to-End Encryption)입니다.

마치 두 사람만 아는 암호로 대화하는 것처럼, 송신자와 수신자만 데이터를 복호화할 수 있고 중간의 서버는 암호화된 내용만 전달합니다.

개요

간단히 말해서, E2EE는 통신하는 양 끝단에서만 암호화와 복호화가 일어나고, 중간 서버는 암호화된 데이터만 중계하는 보안 기술입니다. 서버 관리자조차 내용을 볼 수 없습니다.

WebRTC에서 E2EE가 중요한 이유는 세 가지입니다. 첫째, 의료, 금융, 법률 분야에서는 규정상 E2EE가 필수입니다.

둘째, 서버 해킹이나 데이터 유출 사고가 발생해도 암호화된 데이터는 무용지물입니다. 셋째, 사용자 신뢰도가 크게 향상되어 민감한 정보를 다루는 서비스에서 경쟁 우위를 가집니다.

기존 WebRTC는 DTLS(Datagram Transport Layer Security)로 전송을 암호화하지만, SFU 같은 미디어 서버가 복호화할 수 있었습니다. E2EE를 추가하면 클라이언트에서 한 번 더 암호화하여, 미디어 서버는 암호화된 패킷만 중계하고 내용을 볼 수 없습니다.

E2EE의 핵심 특징은 다음과 같습니다. 제로 지식 증명: 서버는 암호화 키를 모릅니다.

Forward Secrecy: 과거 세션의 키가 노출되어도 다른 세션은 안전합니다. 클라이언트 측 암호화: 모든 암호화 연산이 브라우저에서 수행됩니다.

실무에서는 WebRTC Insertable Streams API를 사용하여 미디어 프레임을 암호화하고, 키 교환에는 ECDH(Elliptic Curve Diffie-Hellman)를 사용합니다. 여러 참가자가 있을 때는 각 연결마다 개별 키를 사용합니다.

코드 예제

// WebRTC Insertable Streams를 이용한 E2EE 구현
class E2EEManager {
  constructor() {
    this.keys = new Map(); // participantId -> CryptoKey
    this.cryptoKeyLength = 128; // AES-GCM 128비트
  }

  // 암호화 키 생성 (AES-GCM)
  async generateKey() {
    return await crypto.subtle.generateKey(
      { name: 'AES-GCM', length: this.cryptoKeyLength },
      true,  // extractable
      ['encrypt', 'decrypt']
    );
  }

  // 송신 스트림 암호화 변환
  createEncryptionTransform(participantId) {
    const key = this.keys.get(participantId);

    return new TransformStream({
      async transform(chunk, controller) {
        // 미디어 프레임에서 데이터 추출
        const view = new DataView(chunk.data);

        // IV(Initialization Vector) 생성
        const iv = crypto.getRandomValues(new Uint8Array(12));

        // AES-GCM으로 암호화
        const encrypted = await crypto.subtle.encrypt(
          { name: 'AES-GCM', iv: iv },
          key,
          chunk.data
        );

        // IV와 암호화된 데이터를 합쳐서 전송
        const newData = new ArrayBuffer(iv.length + encrypted.byteLength);
        const newView = new Uint8Array(newData);
        newView.set(iv, 0);
        newView.set(new Uint8Array(encrypted), iv.length);

        chunk.data = newData;
        controller.enqueue(chunk);
      }
    });
  }

  // 수신 스트림 복호화 변환
  createDecryptionTransform(participantId) {
    const key = this.keys.get(participantId);

    return new TransformStream({
      async transform(chunk, controller) {
        const data = new Uint8Array(chunk.data);

        // IV 추출 (첫 12바이트)
        const iv = data.slice(0, 12);

        // 암호화된 데이터 추출
        const encryptedData = data.slice(12);

        // 복호화
        const decrypted = await crypto.subtle.decrypt(
          { name: 'AES-GCM', iv: iv },
          key,
          encryptedData
        );

        chunk.data = decrypted;
        controller.enqueue(chunk);
      }
    });
  }

  // WebRTC sender에 암호화 적용
  async setupE2EE(sender, participantId) {
    const key = await this.generateKey();
    this.keys.set(participantId, key);

    const senderStreams = sender.createEncodedStreams();
    const encryptionTransform = this.createEncryptionTransform(participantId);

    senderStreams.readable
      .pipeThrough(encryptionTransform)
      .pipeTo(senderStreams.writable);
  }
}

// 사용 예시
const e2ee = new E2EEManager();
const sender = peerConnection.addTrack(localStream.getVideoTracks()[0]);
await e2ee.setupE2EE(sender, 'remote-participant-id');

설명

이것이 하는 일: 위 코드는 WebRTC 미디어 스트림에 실시간 암호화/복호화를 적용하여, 서버를 포함한 제3자가 영상과 음성을 볼 수 없게 만듭니다. 첫 번째로, E2EEManager 클래스는 참가자별 암호화 키를 관리합니다.

generateKey() 메서드가 Web Crypto API를 사용하여 AES-GCM 알고리즘의 128비트 키를 생성합니다. AES-GCM은 빠르고 안전한 대칭키 암호화 방식으로, 인증(무결성 검증)까지 제공하여 데이터 변조를 감지할 수 있습니다.

그 다음으로, createEncryptionTransform() 메서드는 송신 스트림에 적용할 변환 함수를 만듭니다. TransformStream API를 사용하여 각 미디어 프레임을 가로채고, 암호화한 후 다시 전송합니다.

IV(Initialization Vector)는 매번 무작위로 생성하여 같은 데이터도 다르게 암호화되도록 하며, 암호화된 데이터 앞에 붙여서 수신자가 복호화할 때 사용할 수 있게 합니다. 세 번째로, createDecryptionTransform() 메서드는 수신 스트림에서 역방향 작업을 수행합니다.

받은 데이터의 앞 12바이트에서 IV를 추출하고, 나머지 부분을 복호화합니다. crypto.subtle.decrypt()가 AES-GCM으로 복호화하며, 키가 일치하지 않거나 데이터가 변조되었다면 에러가 발생합니다.

마지막으로, setupE2EE() 메서드는 WebRTC의 RTCRtpSender에 암호화 변환을 적용합니다. createEncodedStreams()로 인코딩된 프레임 스트림에 접근하고, pipeThrough()로 암호화 변환을 파이프라인에 연결합니다.

이렇게 하면 네트워크로 전송되기 직전에 암호화되고, 수신자 측에서만 복호화됩니다. 여러분이 이 코드를 사용하면 의료, 금융, 법률 상담처럼 최고 수준의 보안이 필요한 서비스를 구축할 수 있습니다.

서버가 해킹당해도 암호화된 스트림만 얻을 수 있어 실제 내용은 안전하고, HIPAA, GDPR 같은 규정 준수에도 도움이 됩니다.

실전 팁

💡 E2EE는 CPU 부하가 크므로 성능 테스트를 꼭 하세요. 모바일 기기에서는 배터리 소모가 증가하고, 저사양 기기에서는 프레임 드롭이 발생할 수 있습니다. VP8보다 H.264를 사용하면 하드웨어 가속으로 부담을 줄일 수 있습니다.

💡 키 교환 방식으로 ECDH를 사용하여 안전하게 키를 공유하세요. 서버를 통하지 않고 클라이언트 간 직접 키를 교환하면 서버가 키를 절대 알 수 없습니다. 시그널링 채널로 공개키만 전달하고, 각자 개인키로 공통 비밀을 생성합니다.

💡 그룹 통화에서는 SFU(Selective Forwarding Unit) 모드에서도 E2EE를 적용하세요. 각 참가자와의 연결마다 개별 키를 사용하고, 서버는 암호화된 패킷만 전달합니다. 이를 "더블 래칭(Double Ratcheting)"이라고 부르며, Signal 프로토콜에서 사용하는 방식입니다.

💡 키 로테이션 정책을 구현하여 장시간 통화에서도 안전성을 유지하세요. 30분마다 새 키를 생성하고 Forward Secrecy를 보장하면, 과거 키가 노출되어도 이후 통신은 안전합니다.

💡 E2EE 활성화 여부를 UI에 명확히 표시하세요. 자물쇠 아이콘이나 "종단간 암호화 활성화" 배지를 보여주면 사용자가 안심하고 민감한 정보를 공유할 수 있습니다. 일부 참가자만 E2EE를 지원하지 않으면 경고를 표시합니다.


6. CORS 설정

시작하며

여러분이 WebRTC 서비스를 만들고 프론트엔드를 배포했는데, 브라우저 콘솔에 "CORS policy에 의해 차단되었습니다"라는 빨간 에러가 뜬다면? 시그널링 서버에 연결조차 되지 않아서 아무것도 작동하지 않는 상황을 경험해본 적 있나요?

이런 문제는 웹 보안의 기본 원칙인 동일 출처 정책(Same-Origin Policy) 때문에 발생합니다. 브라우저는 보안상의 이유로 다른 도메인으로의 요청을 기본적으로 차단하는데, 프론트엔드가 https://myapp.com이고 API 서버가 https://api.myapp.com이면 "다른 출처"로 간주됩니다.

바로 이럴 때 필요한 것이 CORS(Cross-Origin Resource Sharing) 설정입니다. 마치 아파트 출입증처럼, 신뢰할 수 있는 도메인에게만 리소스 접근 권한을 부여하는 서버 측 보안 설정입니다.

개요

간단히 말해서, CORS는 브라우저가 다른 도메인의 서버로 요청을 보낼 때, 서버가 "이 도메인은 허용합니다"라고 응답 헤더로 알려주는 메커니즘입니다. 서버가 허용하지 않으면 브라우저가 응답을 차단합니다.

WebRTC 서비스에서 CORS가 중요한 이유는 명확합니다. 첫째, 프론트엔드와 백엔드를 분리하여 개발하는 현대적인 아키텍처에서는 필수입니다.

둘째, 여러 도메인(모바일 앱, 웹, 임베디드 위젯)에서 같은 API 서버를 사용할 때 각각 허용해야 합니다. 셋째, Preflight 요청 처리를 잘못하면 WebSocket 연결조차 실패합니다.

기존에는 모든 도메인을 허용하는 "*" 설정을 사용했다면, 이제는 보안을 위해 명시적으로 신뢰하는 도메인만 허용합니다. 개발 환경에서는 localhost를, 프로덕션에서는 실제 도메인을 화이트리스트에 추가합니다.

CORS의 핵심 헤더는 다음과 같습니다. Access-Control-Allow-Origin: 허용할 도메인을 지정합니다.

Access-Control-Allow-Methods: 허용할 HTTP 메서드(GET, POST 등)를 나열합니다. Access-Control-Allow-Headers: 클라이언트가 보낼 수 있는 커스텀 헤더를 지정합니다.

Access-Control-Allow-Credentials: 쿠키나 인증 정보 포함 여부를 결정합니다. 실무에서는 Express의 cors 미들웨어를 사용하여 간편하게 설정하고, 환경 변수로 허용 도메인 목록을 관리합니다.

WebSocket의 경우 초기 HTTP 핸드셰이크에서 Origin 헤더를 검증합니다.

코드 예제

// Express 서버에 CORS 설정
const express = require('express');
const cors = require('cors');
const app = express();

// 허용할 도메인 화이트리스트 (환경 변수로 관리)
const allowedOrigins = process.env.ALLOWED_ORIGINS
  ? process.env.ALLOWED_ORIGINS.split(',')
  : ['http://localhost:3000', 'http://localhost:5173']; // 개발 환경 기본값

// CORS 옵션 설정
const corsOptions = {
  origin: function (origin, callback) {
    // origin이 undefined면 같은 출처 요청 (Postman, curl 등)
    if (!origin) return callback(null, true);

    if (allowedOrigins.indexOf(origin) !== -1) {
      // 허용된 도메인
      callback(null, true);
    } else {
      // 차단된 도메인
      callback(new Error('CORS policy에 의해 차단되었습니다'));
    }
  },
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], // 허용할 메서드
  allowedHeaders: ['Content-Type', 'Authorization'],    // 허용할 헤더
  credentials: true,  // 쿠키 포함 요청 허용
  maxAge: 86400       // Preflight 캐시 시간 (24시간)
};

// CORS 미들웨어 적용
app.use(cors(corsOptions));

// WebSocket 서버에 CORS 적용
const WebSocket = require('ws');
const wss = new WebSocket.Server({ noServer: true });

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

  // Origin 검증
  if (!origin || allowedOrigins.indexOf(origin) === -1) {
    socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
    socket.destroy();
    return;
  }

  // 허용된 Origin이면 WebSocket 연결 수립
  wss.handleUpgrade(request, socket, head, (ws) => {
    wss.emit('connection', ws, request);
  });
});

// 라우트 예시
app.post('/api/create-room', (req, res) => {
  // CORS가 이미 처리되었으므로 바로 로직 실행
  res.json({ roomId: 'abc123', message: '회의실이 생성되었습니다' });
});

server.listen(3000, () => console.log('CORS가 설정된 서버 실행 중'));

설명

이것이 하는 일: 위 코드는 신뢰할 수 있는 도메인에서만 API 서버와 WebSocket 서버에 접근할 수 있도록 CORS 정책을 설정합니다. 첫 번째로, allowedOrigins 배열에 허용할 도메인 목록을 정의합니다.

환경 변수 ALLOWED_ORIGINS에 쉼표로 구분된 도메인을 설정하면 프로덕션에서 유연하게 관리할 수 있고, 개발 환경에서는 localhost의 여러 포트를 기본값으로 제공합니다. 이렇게 하면 React 개발 서버(3000)와 Vite 개발 서버(5173)를 모두 지원할 수 있습니다.

그 다음으로, corsOptions 객체에서 origin 함수로 동적 검증을 수행합니다. 요청의 Origin 헤더를 확인하여 allowedOrigins에 포함되어 있으면 허용하고, 없으면 에러를 반환합니다.

credentials를 true로 설정하면 쿠키나 Authorization 헤더를 포함한 요청을 받을 수 있어 JWT 인증과 호환됩니다. 세 번째로, WebSocket 서버에도 CORS를 적용합니다.

HTTP 업그레이드 요청을 가로채서 Origin 헤더를 검증하고, 허용되지 않은 도메인이면 403 응답으로 소켓을 닫습니다. 이렇게 하면 WebSocket 핸드셰이크 단계에서 차단하여 불필요한 연결을 방지합니다.

마지막으로, maxAge 옵션으로 Preflight 요청의 캐시 시간을 24시간으로 설정합니다. 브라우저는 복잡한 요청(POST, 커스텀 헤더 포함 등) 전에 OPTIONS 메서드로 Preflight 요청을 먼저 보내는데, 매번 보내면 성능이 저하되므로 한 번 확인하면 하루 동안 캐시합니다.

여러분이 이 코드를 사용하면 프론트엔드와 백엔드를 안전하게 분리하여 개발할 수 있습니다. 악의적인 도메인의 요청은 차단되고, 신뢰하는 도메인만 API를 사용할 수 있어 CSRF(Cross-Site Request Forgery) 공격도 방지됩니다.

실전 팁

💡 절대로 프로덕션 환경에서 Access-Control-Allow-Origin을 "*"로 설정하지 마세요. 모든 도메인을 허용하면 CSRF 공격에 취약해지고, credentials 옵션과도 호환되지 않습니다. 반드시 명시적인 도메인 목록을 사용하세요.

💡 서브도메인을 동적으로 허용하려면 정규표현식을 사용하세요. 예를 들어 /^https://.*.myapp.com$/로 검증하면 user.myapp.com, admin.myapp.com 등 모든 서브도메인을 허용할 수 있습니다.

💡 개발 환경과 프로덕션 환경의 CORS 설정을 분리하세요. .env.development와 .env.production 파일로 관리하고, NODE_ENV에 따라 자동으로 로드되도록 하면 실수를 방지할 수 있습니다.

💡 Preflight 요청 실패는 디버깅하기 어려우므로 로그를 추가하세요. OPTIONS 요청을 로깅하여 어떤 Origin에서 어떤 헤더로 요청했는지 확인하면 문제를 빠르게 찾을 수 있습니다.

💡 CDN이나 리버스 프록시를 사용한다면 CORS 헤더가 중복되지 않도록 주의하세요. Nginx와 Express 양쪽에서 CORS 헤더를 설정하면 충돌이 발생할 수 있으므로, 한 곳에서만 설정하는 것이 안전합니다.


#WebRTC#HTTPS#JWT#E2EE#CORS#WebRTC,보안

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

함께 보면 좋은 카드 뉴스

Docker 배포와 CI/CD 완벽 가이드

Docker를 활용한 컨테이너 배포부터 GitHub Actions를 이용한 자동화 파이프라인까지, 초급 개발자도 쉽게 따라할 수 있는 실전 배포 가이드입니다. AWS EC2에 애플리케이션을 배포하고 SSL 인증서까지 적용하는 전 과정을 다룹니다.

보안 강화 및 테스트 완벽 가이드

웹 애플리케이션의 보안 취약점을 방어하고 안정적인 서비스를 제공하기 위한 실전 보안 기법과 테스트 전략을 다룹니다. XSS, CSRF부터 DDoS 방어, Rate Limiting까지 실무에서 바로 적용 가능한 보안 솔루션을 제공합니다.

Redis 캐싱과 Socket.io 클러스터링 완벽 가이드

실시간 채팅 서비스의 성능을 획기적으로 향상시키는 Redis 캐싱 전략과 Socket.io 클러스터링 방법을 배워봅니다. 다중 서버 환경에서도 안정적으로 작동하는 실시간 애플리케이션을 구축하는 방법을 단계별로 알아봅니다.

반응형 디자인 및 UX 최적화 완벽 가이드

모바일부터 데스크톱까지 완벽하게 대응하는 반응형 웹 디자인과 사용자 경험을 개선하는 실전 기법을 학습합니다. Tailwind CSS를 활용한 빠른 개발부터 다크모드, 무한 스크롤, 스켈레톤 로딩까지 최신 UX 패턴을 실무에 바로 적용할 수 있습니다.

React 채팅 UI 구현 완벽 가이드

실시간 채팅 애플리케이션의 UI를 React로 구현하는 방법을 다룹니다. Socket.io 연동부터 컴포넌트 설계, 상태 관리까지 실무에 바로 적용할 수 있는 내용을 담았습니다.