이미지 로딩 중...

WebRTC 실시간 채팅 구현 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 11. 20. · 2 Views

WebRTC 실시간 채팅 구현 완벽 가이드

WebRTC의 RTCDataChannel을 활용하여 서버 없이 브라우저 간 실시간 채팅을 구현하는 방법을 배웁니다. 메시지 전송부터 파일 공유, 읽음 표시까지 실무에서 바로 사용할 수 있는 완벽한 채팅 시스템을 만들어봅니다.


목차

  1. RTCDataChannel 생성
  2. 메시지 전송/수신
  3. 채팅 UI 구현
  4. 이모지 및 파일 전송
  5. 채팅 히스토리 저장
  6. 읽음 표시

1. RTCDataChannel 생성

시작하며

여러분이 실시간 채팅 서비스를 만들려고 할 때 이런 고민을 해본 적 있나요? "서버 비용이 너무 많이 드는데, 사용자들이 직접 연결할 수는 없을까?" 특히 화상 회의 중에 간단한 메시지를 주고받거나, 게임 중에 채팅을 하는 경우라면 더욱 그렇습니다.

전통적인 방식으로는 모든 메시지가 서버를 거쳐야 합니다. 사용자가 늘어날수록 서버 부하가 커지고, 비용도 증가합니다.

게다가 메시지가 서버를 거치면서 약간의 지연이 발생하죠. 바로 이럴 때 필요한 것이 RTCDataChannel입니다.

이것은 두 사용자의 브라우저를 직접 연결해주는 통로를 만들어, 서버 없이도 데이터를 주고받을 수 있게 해줍니다.

개요

간단히 말해서, RTCDataChannel은 두 브라우저 사이에 직접적인 데이터 통신 통로를 만들어주는 기술입니다. 마치 두 사람이 전화 통화를 하듯이, 브라우저끼리 직접 대화할 수 있게 만들어줍니다.

왜 이것이 필요할까요? 일반적인 채팅은 "나 → 서버 → 상대방"의 경로를 거칩니다.

하지만 RTCDataChannel을 사용하면 "나 → 상대방"으로 바로 전달됩니다. 예를 들어, 화상 회의 중에 파일을 공유하거나 게임 데이터를 실시간으로 동기화할 때 매우 유용합니다.

기존에는 Socket.io나 WebSocket으로 서버를 통해 메시지를 전달했다면, 이제는 브라우저끼리 직접 연결하여 더 빠르고 효율적으로 통신할 수 있습니다. RTCDataChannel의 핵심 특징은 첫째, 낮은 지연 시간(서버를 거치지 않으므로), 둘째, 양방향 통신(서로 동시에 데이터 전송 가능), 셋째, 다양한 데이터 타입 지원(텍스트, 바이너리 등)입니다.

이러한 특징들이 실시간 협업 도구나 게임에서 매우 중요합니다.

코드 예제

// WebRTC 연결 객체 생성 (STUN 서버로 공인 IP 찾기)
const peerConnection = new RTCPeerConnection({
  iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});

// 데이터 채널 생성 (채팅용 통로 만들기)
const dataChannel = peerConnection.createDataChannel('chat', {
  ordered: true,        // 메시지 순서 보장
  maxRetransmits: 3    // 최대 3번 재전송 시도
});

// 채널이 열렸을 때 실행
dataChannel.onopen = () => {
  console.log('채팅 채널이 열렸습니다!');
  dataChannel.send('안녕하세요!'); // 첫 메시지 전송
};

// 에러 처리
dataChannel.onerror = (error) => {
  console.error('채널 오류:', error);
};

설명

이것이 하는 일: 위 코드는 두 사용자의 브라우저를 직접 연결하는 통로를 만듭니다. 마치 두 집 사이에 비밀 터널을 뚫는 것과 같습니다.

첫 번째로, RTCPeerConnection 객체를 만듭니다. 이것은 두 브라우저를 연결하는 기본 틀입니다.

iceServers는 각 브라우저의 공인 IP 주소를 찾아주는 서버입니다. 왜냐하면 대부분의 컴퓨터는 공유기 뒤에 숨어있어서, 상대방이 나를 찾으려면 공인 IP를 알아야 하기 때문입니다.

그 다음으로, createDataChannel을 호출하여 실제 데이터가 오갈 통로를 만듭니다. 'chat'은 이 채널의 이름이고, ordered:true는 메시지가 보낸 순서대로 도착하도록 보장합니다.

maxRetransmits는 메시지가 전달 실패하면 몇 번까지 다시 보낼지 결정합니다. 이는 불안정한 네트워크 환경에서 중요합니다.

마지막으로, onopen 이벤트 핸들러로 채널이 성공적으로 열렸을 때의 동작을 정의합니다. 채널이 열리면 콘솔에 메시지를 출력하고, 첫 인사말을 보냅니다.

onerror는 문제가 생겼을 때를 대비한 안전장치입니다. 여러분이 이 코드를 사용하면 서버 비용 없이 실시간 채팅을 구현할 수 있습니다.

또한 서버를 거치지 않아 메시지 전달 속도가 매우 빠르고, 서버가 다운되어도 이미 연결된 사용자들은 계속 채팅할 수 있습니다.

실전 팁

💡 STUN 서버는 구글의 무료 서버를 사용할 수 있지만, 프로덕션 환경에서는 자체 STUN/TURN 서버를 구축하는 것이 안정적입니다. TURN 서버는 방화벽 때문에 직접 연결이 안 될 때 중계 역할을 합니다.

💡 ordered를 false로 설정하면 메시지가 순서 없이 도착하지만 더 빠릅니다. 게임의 실시간 위치 정보처럼 최신 데이터만 중요한 경우에 유용합니다.

💡 데이터 채널은 최대 16KB까지 한 번에 전송할 수 있습니다. 큰 파일을 보낼 때는 청크(chunk)로 나눠서 보내야 합니다.

💡 연결이 끊어졌을 때를 대비해 onclose 이벤트도 반드시 처리하세요. 사용자에게 재연결을 시도하거나 알림을 보낼 수 있습니다.

💡 여러 개의 데이터 채널을 만들 수 있습니다. 예를 들어 채팅용, 파일 전송용, 게임 데이터용으로 분리하면 관리가 쉬워집니다.


2. 메시지 전송/수신

시작하며

여러분이 채팅 앱을 만들다 보면 이런 문제를 겪게 됩니다. "메시지는 어떻게 보내지?

받은 메시지는 어떻게 처리하지? JSON으로 보내야 하나, 텍스트로 보내야 하나?" 특히 타임스탬프나 사용자 정보를 함께 보내야 할 때 더욱 복잡해집니다.

실제로 많은 초보 개발자들이 메시지를 단순 문자열로만 보내다가, 나중에 기능을 추가하려고 하면 전체 구조를 다시 짜야 하는 상황에 빠집니다. 또한 메시지가 제대로 전달되지 않았을 때의 처리도 고민해야 합니다.

바로 이럴 때 필요한 것이 구조화된 메시지 전송/수신 시스템입니다. 처음부터 확장 가능한 구조로 만들어두면 나중에 기능을 추가하기가 훨씬 쉬워집니다.

개요

간단히 말해서, 메시지 전송/수신은 데이터 채널을 통해 구조화된 정보를 주고받는 과정입니다. 단순히 "안녕"이라는 텍스트만 보내는 게 아니라, 누가, 언제, 어떤 내용을 보냈는지 모든 정보를 포함합니다.

왜 이것이 필요한가요? 실무에서는 메시지만 보내는 게 아니라 타입(일반 메시지, 시스템 알림, 파일 등), 발신자 정보, 타임스탬프, 메시지 ID 등 다양한 정보가 필요합니다.

예를 들어, "철수님이 오후 3시 25분에 '회의 시작합니다'라고 보냈고, 메시지 ID는 msg_123"처럼 상세한 정보가 있어야 읽음 표시나 답장 기능을 구현할 수 있습니다. 기존에는 단순히 send("안녕")처럼 텍스트만 보냈다면, 이제는 JSON 객체로 모든 메타데이터를 포함하여 보냅니다.

이렇게 하면 나중에 새로운 필드를 추가하기도 쉽고, 메시지를 분류하고 처리하기도 편합니다. 핵심 특징은 첫째, JSON 형식으로 구조화된 데이터 전송, 둘째, 타임스탬프와 고유 ID로 메시지 추적 가능, 셋째, 타입별로 다른 처리 로직 적용 가능입니다.

이러한 구조는 채팅 앱의 확장성을 크게 높여줍니다.

코드 예제

// 메시지 전송 함수
function sendMessage(text, type = 'text') {
  const message = {
    id: `msg_${Date.now()}_${Math.random()}`,  // 고유 ID 생성
    type: type,                                 // 메시지 타입
    content: text,                              // 실제 내용
    sender: currentUser.name,                   // 발신자
    timestamp: new Date().toISOString(),        // 전송 시간
    status: 'sent'                              // 전송 상태
  };

  dataChannel.send(JSON.stringify(message));    // JSON으로 변환하여 전송
  displayMessage(message, 'sent');              // 내 화면에 표시
}

// 메시지 수신 처리
dataChannel.onmessage = (event) => {
  const message = JSON.parse(event.data);       // JSON 파싱

  // 메시지 타입에 따라 다르게 처리
  if (message.type === 'text') {
    displayMessage(message, 'received');        // 채팅 메시지
  } else if (message.type === 'file') {
    handleFileMessage(message);                 // 파일 메시지
  }

  sendReadReceipt(message.id);                  // 읽음 확인 전송
};

설명

이것이 하는 일: 위 코드는 메시지를 체계적으로 보내고 받는 시스템을 만듭니다. 마치 편지를 쓸 때 봉투에 발신자, 수신자, 날짜를 적는 것처럼, 모든 필요한 정보를 담아서 전송합니다.

첫 번째로, sendMessage 함수는 메시지 객체를 생성합니다. 여기서 id는 메시지를 고유하게 식별하는 번호입니다.

Date.now()로 현재 시간을, Math.random()으로 무작위 숫자를 결합하여 절대 겹치지 않는 ID를 만듭니다. type 필드는 나중에 일반 텍스트, 이모지, 파일 등을 구분할 때 사용합니다.

timestamp는 ISO 형식의 시간으로, 전 세계 어디서나 동일하게 해석됩니다. 그 다음으로, JSON.stringify로 객체를 문자열로 변환하여 전송합니다.

데이터 채널은 문자열이나 바이너리만 전송할 수 있기 때문입니다. displayMessage는 내가 보낸 메시지를 내 화면에 바로 표시하는 함수입니다.

이렇게 하면 전송 즉시 사용자에게 피드백을 줄 수 있습니다. 마지막으로, onmessage 핸들러가 상대방의 메시지를 받습니다.

JSON.parse로 다시 객체로 변환한 후, type 필드를 확인하여 메시지 종류에 따라 다른 처리를 합니다. 예를 들어 일반 텍스트는 채팅창에 표시하고, 파일은 다운로드 링크를 만듭니다.

sendReadReceipt는 메시지를 읽었다는 확인을 상대방에게 보냅니다. 여러분이 이 코드를 사용하면 메시지 히스토리 관리, 읽음 표시, 답장 기능 등을 쉽게 구현할 수 있습니다.

ID로 특정 메시지를 추적할 수 있고, timestamp로 시간순 정렬이 가능하며, type으로 다양한 메시지 형식을 지원할 수 있습니다.

실전 팁

💡 메시지 ID는 데이터베이스에 저장할 계획이라면 UUID 라이브러리를 사용하는 것이 더 안전합니다. Date.now()와 Math.random()은 간단하지만 완벽한 고유성을 보장하지는 않습니다.

💡 JSON.stringify가 실패할 수 있으므로 try-catch로 감싸세요. 특히 순환 참조가 있는 객체는 에러가 발생합니다.

💡 메시지가 너무 길면 전송이 실패할 수 있습니다. 16KB 이상의 메시지는 여러 청크로 나눠서 보내고, 수신 측에서 조립하는 로직이 필요합니다.

💡 네트워크 지연을 고려해 timestamp는 항상 발신자의 시간을 사용하세요. 수신 시간과 발신 시간 둘 다 저장하면 지연 시간 분석도 가능합니다.

💡 status 필드를 활용해 'sent(전송됨)', 'delivered(전달됨)', 'read(읽음)' 상태를 추적하면 카카오톡처럼 체크 표시를 구현할 수 있습니다.


3. 채팅 UI 구현

시작하며

여러분이 채팅 기능을 만들었다면 이제 이것을 화면에 예쁘게 보여줘야 합니다. "내가 보낸 메시지는 오른쪽, 받은 메시지는 왼쪽에 표시하려면?" "새 메시지가 오면 자동으로 스크롤되게 하려면?" "시간 표시는 어떻게 하지?" 같은 고민을 하게 됩니다.

실제로 UI가 불편하면 아무리 기술적으로 완벽한 채팅이라도 사용자들이 외면합니다. 메시지가 뒤죽박죽 섞이거나, 누가 보낸 건지 구분이 안 되거나, 스크롤이 이상하게 동작하면 사용자 경험이 나빠집니다.

바로 이럴 때 필요한 것이 직관적이고 사용하기 편한 채팅 UI입니다. 카카오톡이나 슬랙 같은 유명한 채팅 앱들의 UI 패턴을 참고하여 만들면 사용자들이 익숙하게 느낍니다.

개요

간단히 말해서, 채팅 UI는 메시지를 화면에 보기 좋게 배치하고, 사용자가 쉽게 입력하고 확인할 수 있도록 만드는 인터페이스입니다. 마치 대화하듯이 자연스럽게 메시지가 오가는 느낌을 주어야 합니다.

왜 좋은 UI가 중요할까요? 사용자는 기술을 보는 게 아니라 화면을 봅니다.

내 메시지와 상대방 메시지가 명확히 구분되어야 하고, 시간 정보가 보여야 하며, 읽지 않은 메시지는 강조되어야 합니다. 예를 들어, 고객 상담 채팅에서 메시지 구분이 안 되면 누가 뭐라고 했는지 헷갈려서 큰 문제가 될 수 있습니다.

기존에는 단순히 <div>에 텍스트만 추가했다면, 이제는 발신자에 따라 다른 스타일을 적용하고, 타임스탬프를 포맷하고, 스크롤을 자동으로 관리합니다. 핵심 특징은 첫째, 발신자에 따른 좌우 정렬(내 메시지는 오른쪽, 상대방은 왼쪽), 둘째, 자동 스크롤로 항상 최신 메시지 표시, 셋째, 시간 포맷팅과 날짜 구분선입니다.

이러한 요소들이 모여 편안한 채팅 경험을 만듭니다.

코드 예제

// 메시지를 화면에 표시하는 함수
function displayMessage(message, direction) {
  const chatContainer = document.getElementById('chat-messages');

  // 메시지 컨테이너 생성 (내 메시지면 오른쪽 정렬)
  const messageDiv = document.createElement('div');
  messageDiv.className = `message ${direction}`;
  messageDiv.setAttribute('data-message-id', message.id);

  // 시간 포맷팅 (오후 3:25 형식으로)
  const time = new Date(message.timestamp).toLocaleTimeString('ko-KR', {
    hour: '2-digit',
    minute: '2-digit'
  });

  // HTML 구조 생성
  messageDiv.innerHTML = `
    <div class="message-content">${escapeHtml(message.content)}</div>
    <div class="message-time">${time}</div>
    <div class="message-status">${getStatusIcon(message.status)}</div>
  `;

  chatContainer.appendChild(messageDiv);
  scrollToBottom();  // 자동 스크롤
}

// 맨 아래로 스크롤 (부드럽게)
function scrollToBottom() {
  const container = document.getElementById('chat-messages');
  container.scrollTo({
    top: container.scrollHeight,
    behavior: 'smooth'
  });
}

// XSS 공격 방지를 위한 HTML 이스케이프
function escapeHtml(text) {
  const div = document.createElement('div');
  div.textContent = text;
  return div.innerHTML;
}

설명

이것이 하는 일: 위 코드는 메시지를 화면에 카카오톡처럼 예쁘게 표시합니다. 내가 보낸 메시지는 오른쪽, 상대방 메시지는 왼쪽에 배치되어 대화의 흐름이 자연스럽습니다.

첫 번째로, displayMessage 함수는 새로운 div 요소를 만들어 메시지를 담습니다. direction 파라미터가 'sent'면 내 메시지이므로 오른쪽 정렬 스타일이 적용되고, 'received'면 왼쪽 정렬됩니다.

data-message-id 속성은 나중에 이 메시지를 찾거나 업데이트할 때 사용합니다. 예를 들어 읽음 표시를 추가하거나 메시지를 삭제할 때 필요합니다.

그 다음으로, toLocaleTimeString으로 타임스탬프를 "오후 3:25" 같은 친숙한 형식으로 변환합니다. 'ko-KR'을 사용하면 한국 시간 형식으로, 'en-US'를 사용하면 영어 형식으로 표시됩니다.

escapeHtml 함수는 메시지 내용에 <script> 같은 위험한 코드가 포함되어 있어도 안전하게 텍스트로만 표시합니다. 이것은 보안에 매우 중요합니다.

마지막으로, appendChild로 메시지를 채팅창에 추가하고 scrollToBottom을 호출합니다. scrollTo의 behavior: 'smooth'는 스크롤이 뚝뚝 끊기지 않고 부드럽게 움직이게 만듭니다.

scrollHeight는 전체 콘텐츠의 높이이므로, 이 값으로 스크롤하면 맨 아래로 이동합니다. 여러분이 이 코드를 사용하면 전문적인 채팅 UI를 쉽게 만들 수 있습니다.

사용자들은 익숙한 인터페이스 덕분에 별도의 학습 없이 바로 사용할 수 있고, XSS 방지 덕분에 보안도 걱정 없습니다. 또한 data-message-id로 각 메시지를 추적할 수 있어 읽음 표시나 수정 기능을 추가하기도 쉽습니다.

실전 팁

💡 스크롤이 최하단이 아닐 때 새 메시지가 오면 "새 메시지 1개" 같은 알림을 표시하세요. 사용자가 위쪽 메시지를 읽고 있을 때 자동 스크롤되면 불편합니다.

💡 같은 사람이 연속으로 메시지를 보내면 프로필 사진과 이름은 첫 메시지에만 표시하는 것이 깔끔합니다. 시간도 분 단위로 묶어서 한 번만 보여줄 수 있습니다.

💡 긴 메시지는 최대 높이를 지정하고 "더 보기" 버튼을 추가하세요. 화면을 너무 많이 차지하면 다른 메시지를 보기 어렵습니다.

💡 링크는 자동으로 감지해서 클릭 가능하게 만드세요. URL 정규식으로 찾아서 <a> 태그로 변환하면 됩니다. 단, target="_blank"로 새 창에서 열리게 하세요.

💡 가상 스크롤링(Virtual Scrolling)을 구입하면 수천 개의 메시지가 있어도 성능이 좋습니다. 화면에 보이는 메시지만 DOM에 렌더링하는 기법입니다.


4. 이모지 및 파일 전송

시작하며

여러분이 채팅 앱을 만들다 보면 사용자들이 이런 요청을 합니다. "이모지 좀 보낼 수 없나요?" "파일 첨부 기능이 있었으면 좋겠어요." 텍스트만 주고받는 것은 2000년대 초반 스타일이고, 요즘은 다양한 콘텐츠를 공유하는 게 기본입니다.

하지만 파일 전송은 생각보다 복잡합니다. 작은 이모지는 괜찮지만, 수십 MB짜리 파일을 어떻게 보낼까요?

한 번에 보내면 채널이 막히고, 진행률은 어떻게 표시하고, 실패하면 어떻게 할까요? 많은 개발자가 여기서 막힙니다.

바로 이럴 때 필요한 것이 청크(chunk) 기반의 파일 전송 시스템입니다. 파일을 작은 조각으로 나눠서 보내고, 수신자가 다시 조립하는 방식으로 안전하고 효율적으로 전송할 수 있습니다.

개요

간단히 말해서, 이모지와 파일 전송은 텍스트가 아닌 다른 형태의 데이터를 채팅으로 주고받는 기능입니다. 이모지는 유니코드 문자이므로 간단하지만, 파일은 바이너리 데이터를 청크로 나눠서 전송해야 합니다.

왜 이것이 중요할까요? 현대의 커뮤니케이션은 텍스트만으로 부족합니다.

이모지로 감정을 표현하고, 문서나 이미지를 공유하고, 때로는 동영상까지 보냅니다. 예를 들어, 원격 근무 중에 디자인 파일을 공유하거나, 계약서를 전송하는 등 실무에서 파일 공유는 필수입니다.

기존에는 파일을 보내려면 별도의 파일 서버에 업로드하고 링크를 공유했다면, WebRTC로는 P2P로 직접 전송할 수 있습니다. 서버 비용도 절약되고, 프라이버시도 더 보호됩니다.

핵심 특징은 첫째, 이모지는 일반 텍스트 메시지처럼 전송, 둘째, 파일은 청크로 분할하여 순차 전송, 셋째, 진행률 표시와 에러 처리입니다. 이러한 기능들이 사용자 경험을 크게 향상시킵니다.

코드 예제

// 이모지 전송 (일반 메시지와 동일하게)
function sendEmoji(emoji) {
  sendMessage(emoji, 'emoji');  // 타입만 'emoji'로 구분
}

// 파일 선택 핸들러
document.getElementById('file-input').addEventListener('change', (e) => {
  const file = e.target.files[0];
  if (file) sendFile(file);
});

// 파일 전송 함수
async function sendFile(file) {
  const CHUNK_SIZE = 16384;  // 16KB씩 나눠서 전송
  const chunks = Math.ceil(file.size / CHUNK_SIZE);
  const fileId = `file_${Date.now()}`;

  // 파일 메타데이터 먼저 전송
  sendMessage(JSON.stringify({
    fileId: fileId,
    name: file.name,
    size: file.size,
    type: file.type,
    chunks: chunks
  }), 'file-meta');

  // 파일을 청크로 나눠서 전송
  for (let i = 0; i < chunks; i++) {
    const start = i * CHUNK_SIZE;
    const end = Math.min(start + CHUNK_SIZE, file.size);
    const chunk = file.slice(start, end);
    const buffer = await chunk.arrayBuffer();

    // 청크 전송 (바이너리 데이터)
    dataChannel.send(new Uint8Array(buffer));
    updateProgress(fileId, (i + 1) / chunks * 100);  // 진행률 업데이트
  }
}

// 파일 수신 처리
const fileBuffers = new Map();  // 파일별 청크 저장

function handleFileChunk(fileId, chunkData, chunkIndex) {
  if (!fileBuffers.has(fileId)) {
    fileBuffers.set(fileId, []);
  }
  fileBuffers.get(fileId)[chunkIndex] = chunkData;

  // 모든 청크 수신 완료 시
  if (isFileComplete(fileId)) {
    const blob = new Blob(fileBuffers.get(fileId));
    createDownloadLink(blob, fileMetadata.get(fileId));
  }
}

설명

이것이 하는 일: 위 코드는 큰 파일을 안전하게 전송할 수 있게 작은 조각으로 나누고, 받는 쪽에서 다시 합치는 시스템을 만듭니다. 마치 큰 피자를 한 조각씩 배달하는 것과 같습니다.

첫 번째로, 이모지 전송은 매우 간단합니다. sendMessage 함수를 그대로 사용하되 type만 'emoji'로 설정합니다.

이모지는 유니코드 문자이므로 일반 텍스트와 동일하게 처리됩니다. 다만 UI에서 이모지를 더 크게 표시하는 등 다르게 처리할 수 있도록 타입을 구분합니다.

그 다음으로, sendFile 함수가 파일을 처리합니다. CHUNK_SIZE를 16KB(16384바이트)로 설정한 이유는 RTCDataChannel의 전송 한계 때문입니다.

먼저 파일의 메타데이터(이름, 크기, 타입, 총 청크 수)를 전송하여 수신자가 준비하도록 합니다. 그 다음 file.slice()로 파일을 청크로 잘라내고, arrayBuffer()로 바이너리 데이터로 변환한 후, Uint8Array로 전송합니다.

마지막으로, handleFileChunk 함수가 수신한 청크들을 Map에 저장합니다. fileBuffers는 파일ID를 키로, 청크 배열을 값으로 가집니다.

모든 청크가 도착하면 Blob으로 합쳐서 실제 파일로 만들고, createDownloadLink로 다운로드 링크를 화면에 표시합니다. 여러분이 이 코드를 사용하면 서버 없이도 대용량 파일을 안전하게 전송할 수 있습니다.

청크 단위로 보내므로 네트워크가 불안정해도 전송이 끊기지 않고, 진행률을 실시간으로 표시할 수 있습니다. 또한 바이너리 데이터를 직접 다루므로 어떤 종류의 파일이든 전송 가능합니다.

실전 팁

💡 파일 크기 제한을 설정하세요. 예를 들어 100MB 이상은 전송을 막거나 경고를 표시합니다. WebRTC는 P2P이므로 업로드 속도가 느린 사용자는 큰 파일 전송 시 문제가 생깁니다.

💡 청크 전송 중 에러가 발생하면 해당 청크만 재전송하는 로직을 추가하세요. 전체 파일을 다시 보내는 것보다 효율적입니다.

💡 이미지 파일은 썸네일을 먼저 전송하여 미리보기를 제공하세요. 사용자가 전체 파일을 다운로드할지 결정할 수 있습니다.

💡 파일 타입을 검증하세요. 실행 파일(.exe, .sh)은 보안 위험이 있으므로 전송을 차단하거나 경고를 표시합니다.

💡 청크 전송 속도를 조절하는 백프레셔(backpressure) 메커니즘을 구현하세요. dataChannel.bufferedAmount를 확인하여 버퍼가 가득 차면 잠시 대기합니다.


5. 채팅 히스토리 저장

시작하며

여러분이 채팅 앱을 사용하다가 브라우저를 닫았다가 다시 열었는데 모든 대화가 사라졌다면 어떨까요? 아마 다시는 그 앱을 사용하지 않을 겁니다.

사용자들은 당연히 이전 대화를 다시 볼 수 있어야 한다고 생각합니다. 하지만 WebRTC는 P2P 연결이므로 서버에 자동으로 저장되지 않습니다.

연결이 끊어지면 모든 메시지가 사라집니다. 그렇다고 서버를 사용하면 WebRTC의 장점이 사라지죠.

어떻게 해야 할까요? 바로 이럴 때 필요한 것이 로컬 스토리지 기반의 채팅 히스토리 저장입니다.

브라우저의 IndexedDB를 사용하면 수천 개의 메시지를 효율적으로 저장하고 검색할 수 있습니다.

개요

간단히 말해서, 채팅 히스토리 저장은 주고받은 모든 메시지를 브라우저에 영구적으로 보관하는 기능입니다. 마치 컴퓨터에 일기를 저장하듯이, 대화 내용을 로컬에 저장합니다.

왜 이것이 필요할까요? 첫째, 사용자 경험입니다.

이전 대화를 찾아보고, 약속 시간을 확인하고, 공유받은 링크를 다시 열어볼 수 있어야 합니다. 둘째, 오프라인 접근입니다.

인터넷이 끊겨도 과거 메시지는 볼 수 있습니다. 예를 들어, 고객 상담 채팅에서 이전 상담 내역을 확인하는 것은 매우 중요합니다.

기존에는 서버 데이터베이스에 저장했다면, 이제는 각 사용자의 브라우저에 저장합니다. 프라이버시가 더 보호되고, 서버 비용도 들지 않습니다.

핵심 특징은 첫째, IndexedDB로 대용량 데이터 저장, 둘째, 채팅방별로 메시지 분리 저장, 셋째, 빠른 검색을 위한 인덱싱입니다. 이러한 구조는 수만 개의 메시지도 빠르게 처리할 수 있게 합니다.

코드 예제

// IndexedDB 초기화
let db;
const DB_NAME = 'ChatHistory';
const STORE_NAME = 'messages';

async function initDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, 1);

    // DB 스키마 생성 (처음 실행 시)
    request.onupgradeneeded = (event) => {
      db = event.target.result;
      const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' });
      store.createIndex('roomId', 'roomId', { unique: false });  // 채팅방별 검색
      store.createIndex('timestamp', 'timestamp', { unique: false });  // 시간순 정렬
    };

    request.onsuccess = (event) => {
      db = event.target.result;
      resolve(db);
    };
    request.onerror = () => reject(request.error);
  });
}

// 메시지 저장
async function saveMessage(message) {
  const transaction = db.transaction([STORE_NAME], 'readwrite');
  const store = transaction.objectStore(STORE_NAME);
  await store.add(message);
}

// 채팅방의 메시지 불러오기
async function loadMessages(roomId, limit = 50) {
  const transaction = db.transaction([STORE_NAME], 'readonly');
  const store = transaction.objectStore(STORE_NAME);
  const index = store.index('roomId');

  const messages = [];
  const request = index.openCursor(IDBKeyRange.only(roomId), 'prev');  // 최신순

  return new Promise((resolve) => {
    request.onsuccess = (event) => {
      const cursor = event.target.result;
      if (cursor && messages.length < limit) {
        messages.push(cursor.value);
        cursor.continue();
      } else {
        resolve(messages.reverse());  // 시간순으로 뒤집기
      }
    };
  });
}

설명

이것이 하는 일: 위 코드는 브라우저에 내장된 데이터베이스인 IndexedDB를 사용하여 채팅 메시지를 영구적으로 저장합니다. 마치 책을 도서관에 정리하듯이, 메시지를 체계적으로 보관합니다.

첫 번째로, initDB 함수가 데이터베이스를 초기화합니다. indexedDB.open()으로 'ChatHistory'라는 이름의 DB를 엽니다.

버전 번호 1은 스키마가 바뀔 때마다 증가시킵니다. onupgradeneeded는 DB를 처음 만들거나 버전이 올라갈 때 실행됩니다.

여기서 'messages'라는 객체 스토어(테이블 같은 것)를 만들고, 메시지 ID를 키로 지정합니다. 그 다음으로, createIndex로 두 개의 인덱스를 만듭니다.

'roomId' 인덱스는 특정 채팅방의 메시지만 빠르게 찾을 수 있게 하고, 'timestamp' 인덱스는 시간순 정렬에 사용됩니다. 인덱스가 없으면 전체 메시지를 하나씩 확인해야 하므로 느립니다.

마지막으로, saveMessage는 메시지를 저장하고, loadMessages는 특정 채팅방의 최근 메시지 50개를 불러옵니다. openCursor의 'prev'는 최신 메시지부터 읽겠다는 의미입니다.

IDBKeyRange.only(roomId)로 해당 채팅방 메시지만 필터링합니다. 커서는 데이터를 하나씩 순회하는 포인터입니다.

여러분이 이 코드를 사용하면 브라우저를 닫았다 열어도 대화 내용이 그대로 보입니다. 검색 기능을 추가하거나, 특정 날짜의 메시지를 찾거나, 통계를 내는 것도 쉽습니다.

IndexedDB는 수 GB의 데이터도 저장할 수 있으므로 용량 걱정도 없습니다.

실전 팁

💡 주기적으로 오래된 메시지를 삭제하는 로직을 추가하세요. 예를 들어 6개월 이상 된 메시지는 자동 삭제하면 저장 공간을 절약할 수 있습니다.

💡 메시지 내용에 대한 전문 검색(Full-text search)을 구현하려면 별도의 라이브러리(예: Lunr.js)를 사용하세요. IndexedDB는 부분 문자열 검색을 지원하지 않습니다.

💡 민감한 정보는 암호화하여 저장하세요. 브라우저의 Web Crypto API로 메시지를 암호화할 수 있습니다. 특히 공용 컴퓨터에서 사용할 경우 중요합니다.

💡 에러 처리를 꼼꼼히 하세요. 사용자의 저장 공간이 가득 차면 QuotaExceededError가 발생하므로, 이 경우 사용자에게 알리고 오래된 데이터 삭제를 제안합니다.

💡 여러 탭에서 동시에 채팅을 사용할 경우를 대비해 BroadcastChannel로 탭 간 동기화를 구현하세요. 한 탭에서 보낸 메시지가 다른 탭에도 표시되어야 합니다.


6. 읽음 표시

시작하며

여러분이 메시지를 보낸 후 이런 궁금증을 가져본 적 있나요? "상대방이 내 메시지를 읽었을까?" 카카오톡의 숫자 1이 사라지는 그 순간의 만족감, 또는 읽고도 답장이 없을 때의 그 느낌을 모두 알 것입니다.

읽음 표시는 단순한 기능처럼 보이지만, 사용자 경험에 큰 영향을 줍니다. 특히 업무용 채팅에서는 "메시지가 전달되었는지", "상대방이 확인했는지"를 아는 것이 중요합니다.

하지만 구현은 생각보다 복잡합니다. 메시지 상태를 추적하고, 여러 수신자를 처리하고, UI를 업데이트해야 합니다.

바로 이럴 때 필요한 것이 체계적인 읽음 표시 시스템입니다. 메시지 ID를 기반으로 상태를 추적하고, 실시간으로 UI를 업데이트하여 사용자에게 명확한 피드백을 제공합니다.

개요

간단히 말해서, 읽음 표시는 메시지가 '전송됨 → 전달됨 → 읽음'의 단계를 거치는 것을 시각적으로 보여주는 기능입니다. 카카오톡의 숫자, 텔레그램의 체크마크가 모두 읽음 표시입니다.

왜 이것이 중요할까요? 첫째, 커뮤니케이션의 투명성입니다.

상대방이 메시지를 봤는지 알면 다음 행동을 결정할 수 있습니다. 둘째, 신뢰성 확인입니다.

중요한 메시지가 제대로 전달되었는지 확인할 수 있습니다. 예를 들어, 긴급 업무 지시를 보낸 후 상대방이 확인했는지 바로 알 수 있습니다.

기존에는 서버가 메시지 상태를 관리했다면, P2P 채팅에서는 각 피어가 읽음 확인(read receipt)을 직접 전송합니다. 핵심 특징은 첫째, 메시지 ID 기반의 상태 추적, 둘째, 읽음 확인 메시지 자동 전송, 셋째, 실시간 UI 업데이트입니다.

이러한 요소들이 모여 신뢰할 수 있는 메시징 경험을 만듭니다.

코드 예제

// 메시지 상태 관리
const messageStatus = new Map();  // messageId -> status 매핑

// 읽음 확인 전송
function sendReadReceipt(messageId) {
  const receipt = {
    type: 'read-receipt',
    messageId: messageId,
    readAt: new Date().toISOString(),
    readBy: currentUser.id
  };
  dataChannel.send(JSON.stringify(receipt));
}

// 읽음 확인 수신 처리
function handleReadReceipt(receipt) {
  // 메시지 상태 업데이트
  messageStatus.set(receipt.messageId, 'read');

  // UI 업데이트 (체크마크 변경)
  const messageElement = document.querySelector(
    `[data-message-id="${receipt.messageId}"]`
  );

  if (messageElement) {
    const statusIcon = messageElement.querySelector('.message-status');
    statusIcon.innerHTML = '✓✓';  // 더블 체크 (읽음)
    statusIcon.classList.add('read');  // 파란색으로 변경
  }

  // IndexedDB에도 상태 저장
  updateMessageStatus(receipt.messageId, 'read');
}

// 상태 아이콘 반환
function getStatusIcon(status) {
  switch(status) {
    case 'sent':
      return '✓';       // 단일 체크 (전송됨)
    case 'delivered':
      return '✓✓';      // 더블 체크 (전달됨)
    case 'read':
      return '✓✓';      // 더블 체크 파란색 (읽음)
    default:
      return '🕐';      // 시계 (전송 중)
  }
}

// 메시지가 화면에 보이면 자동으로 읽음 처리
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const messageId = entry.target.getAttribute('data-message-id');
      if (!messageStatus.has(messageId)) {
        sendReadReceipt(messageId);
        messageStatus.set(messageId, 'read');
      }
    }
  });
}, { threshold: 0.5 });  // 메시지가 50% 이상 보일 때

// 새 메시지에 옵저버 등록
function observeMessage(messageElement) {
  observer.observe(messageElement);
}

설명

이것이 하는 일: 위 코드는 메시지가 읽혔는지 추적하고, 실시간으로 발신자에게 알려주는 시스템을 만듭니다. 마치 우편물에 수령 확인 도장을 받는 것과 같습니다.

첫 번째로, sendReadReceipt 함수가 읽음 확인을 보냅니다. 이것은 일반 메시지가 아닌 특수한 제어 메시지로, type이 'read-receipt'입니다.

어떤 메시지를 읽었는지(messageId), 언제 읽었는지(readAt), 누가 읽었는지(readBy) 정보를 포함합니다. 이 정보는 나중에 읽음 시간을 표시하거나, 그룹 채팅에서 누가 읽었는지 보여줄 때 유용합니다.

그 다음으로, handleReadReceipt가 상대방이 보낸 읽음 확인을 처리합니다. messageStatus Map에 상태를 저장하고, querySelector로 해당 메시지의 DOM 요소를 찾아 아이콘을 업데이트합니다.

classList.add('read')로 CSS 클래스를 추가하면 체크마크가 파란색으로 변합니다. 또한 updateMessageStatus로 IndexedDB에도 저장하여 페이지를 새로고침해도 상태가 유지됩니다.

마지막으로, IntersectionObserver가 핵심 기능을 담당합니다. 이것은 메시지가 화면에 보이는지 자동으로 감지합니다.

threshold: 0.5는 메시지의 50% 이상이 보일 때 읽음으로 처리하겠다는 의미입니다. isIntersecting이 true면 메시지가 화면에 보이는 것이므로, 자동으로 읽음 확인을 전송합니다.

이렇게 하면 사용자가 스크롤해서 메시지를 볼 때마다 자동으로 읽음 처리됩니다. 여러분이 이 코드를 사용하면 카카오톡처럼 전문적인 읽음 표시 기능을 구현할 수 있습니다.

사용자는 메시지 전달 상태를 한눈에 확인할 수 있고, 자동 읽음 처리 덕분에 번거롭게 버튼을 누를 필요도 없습니다. 또한 IntersectionObserver는 성능이 뛰어나 수백 개의 메시지를 동시에 관찰해도 브라우저가 느려지지 않습니다.

실전 팁

💡 그룹 채팅에서는 모든 사람이 읽었을 때만 '읽음'으로 표시하고, 일부만 읽었으면 숫자로 표시하세요. 예를 들어 "3명 중 2명 읽음"처럼 보여줍니다.

💡 사용자가 읽음 표시를 끌 수 있는 옵션을 제공하세요. 프라이버시를 중요하게 생각하는 사용자를 위한 배려입니다. 단, 이 경우 상대방의 읽음도 볼 수 없게 하는 것이 공평합니다.

💡 메시지가 너무 많으면 성능 문제가 생길 수 있으므로, 최근 100개 메시지만 옵저버에 등록하세요. 오래된 메시지는 unobserve()로 해제합니다.

💡 오프라인일 때는 읽음 확인을 로컬에 저장해두었다가, 다시 연결되면 일괄 전송하세요. 네트워크 상태를 확인하는 navigator.onLine을 활용합니다.

💡 읽음 표시가 너무 빠르게 변하면 혼란스러우므로, 약간의 디바운싱(debouncing)을 적용하세요. 0.5초 정도 화면에 보인 후에 읽음으로 처리하면 자연스럽습니다.


#WebRTC#RTCDataChannel#실시간채팅#P2P통신#데이터채널#WebRTC,채팅

댓글 (0)

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

함께 보면 좋은 카드 뉴스

회의실 관리 시스템 완벽 가이드

WebRTC를 활용한 실시간 회의실 관리 시스템 구축 방법을 초급 개발자도 쉽게 이해할 수 있도록 설명합니다. 회의실 생성부터 참여자 관리, 호스트 권한까지 실무에 필요한 모든 기능을 다룹니다.

WebRTC 오디오/비디오 제어 완벽 가이드

실시간 화상회의나 영상통화 앱을 개발할 때 필수적인 WebRTC의 미디어 제어 기능들을 마스터해보세요. 음소거, 카메라 전환, 볼륨 조절 등 실무에서 바로 활용할 수 있는 모든 제어 방법을 상세한 예제와 함께 배워봅니다.

WebRTC 화면 공유 완벽 가이드

WebRTC의 화면 공유 기능을 처음부터 끝까지 배워봅니다. getDisplayMedia API 사용법부터 화면과 카메라 동시 표시, 공유 중단 감지, 해상도 설정까지 실무에서 바로 활용할 수 있는 모든 것을 다룹니다.

WebRTC Mesh 방식 다자간 화상회의 완벽 가이드

다자간 화상회의를 구현하는 Mesh, SFU, MCU 방식의 차이부터 실제 구현까지. PeerConnection 관리, 참여자 추가/제거, 대역폭 최적화까지 실무에 바로 적용할 수 있는 완벽한 가이드입니다.

1:1 화상 통화 구현 완벽 가이드

WebRTC를 활용한 1:1 화상 통화 시스템을 처음부터 끝까지 구현하는 방법을 배워봅니다. 통화 시작부터 종료까지, 음소거와 카메라 제어, 연결 품질 모니터링까지 실무에 바로 적용할 수 있는 모든 과정을 다룹니다.