시작하며
안녕하세요. 개발자 stark입니다!
저는 shoot이라는 채팅 서비스를 사이드 프로젝트로 개발하고 있습니다. 제가 이 프로젝트를 만든 이유는 실무에서 short polling 채팅 서비스를 개발하고 나서 "웹소켓을 사용하면 더 좋지 않을까?"라는 궁금증이 생겼기 때문입니다. 이렇게 서비스를 구축하다 보니 다양한 궁금증이 생겼습니다. "카카오톡은 어떻게 동작할까? 라인은? Slack은? Discord는?" 정말 단순한 궁금증 때문에 시작한 이 프로젝트가 25년 1월부터 시작해서 지금까지 작업하게 될 줄을 상상도 못 했습니다. 참고로 서비스 이름을 shoot으로 지은 이유는 메시지를 보내는 것을 '쏘다'라는 개념에 대입해서 독특한 기능을 제공하고 싶었기 때문입니다.
제가 채팅 서비스를 만들겠다고 다짐했을 때는 무조건 WebSocket을 사용하기로 마음먹은 상태였습니다. 그래서 저는 어떤 방식으로 채팅을 구현할지 크게 고민하지 않고 마음 편하게 설계를 했었습니다. 다만 저는 웹소켓에 대한 이해도가 많이 부족했기에 웹소켓에 대한 공부를 먼저 시작했습니다. 이번 포스팅은 시작인만큼 제가 이해한 웹소켓에 대해서 정리해 보도록 하겠습니다.
이번 포스팅을 시작으로 지금까지 실시간 채팅을 만들면서 그 과정 속에서 제가 궁금했던 것들은 무엇이고 어떻게 해결해서 구현해 왔는지 천천히 정리해 보고자 합니다. 다들 재미있게 봐주세요!
GitHub - wlsdks/shoot: 실시간 채팅 서버 "shoot" (코프링에서 웹소켓과 Kafka, Redis를 활용한 구성)
실시간 채팅 서버 "shoot" (코프링에서 웹소켓과 Kafka, Redis를 활용한 구성) - wlsdks/shoot
github.com
참고로 제 github에 연계된 front도 있으니 필요하신 분들은 같이 확인해 주세요! (코드 사용은 자유롭게 하셔도 괜찮습니다)
웹소켓? 정체가 뭐냐?
웹소켓은 HTTP 업그레이드(Handshake)를 한 뒤 TCP 연결을 계속 유지해, 클라이언트 ↔ 서버가 양방향·실시간(Full-Duplex)으로 자유롭게 메시지를 주고받을 수 있게 해 주는 표준 프로토콜입니다. 한번 연결이 성사되면, 추가적인 HTTP 헤더 교환 없이 아주 가벼운 프레임만 주고받기 때문에 지연과 네트워크 오버헤드가 크게 줄어든다는 장점을 가지고 있습니다.
웹소켓이 어떻게 연결되는지 간단히 살펴봅시다.
- 먼저 클라이언트는 아래와 같이 일반 HTTP(S)로 서버에 “WebSocket 업그레이드” 요청을 보냅니다.
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: xxxxxxxxxxxxxxxx==
Sec-WebSocket-Version: 13
만약 요청을 받은 서버가 웹소켓을 지원하면 아래처럼 응답하여 프로토콜을 WebSocket으로 “업그레이드”합니다.
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: yyyyyyyyyyyyyyyy==
- 참고로 여기서 Sec-WebSocket-Accept 헤더의 값은 서버가 클라이언트의 요청을 올바르게 이해했음을 증명하는 '암표'와 같습니다. 이 값은 클라이언트가 보낸 Sec-WebSocket-Key 값에 웹소켓 표준에 정의된 특정 문자열(GUID, 258EAFA5-E914-47DA-95CA-C5AB0DC85B11)을 이어 붙인 뒤, SHA-1으로 해시하고 Base64로 인코딩하여 생성됩니다. 이 '챌린지-응답' 과정을 통해, 클라이언트는 상대방이 진짜 웹소켓 서버임을 확신하고 연결을 맺게 됩니다.
연결이 성립된 이후부터는 HTTP가 아니라 WebSocket 프레임 단위로 통신 (메시지, ping/pong 등)하게 됩니다. 또한 클라이언트나 서버가 동시에 아무 때나 메시지 보낼 수 있습니다.
Client Server
| |
|---(HTTP GET+Upgrade)--->|
| |
|<----(101 Switching)-----|
| |
|--- WebSocket Frame ---->|
| |
|<--- WebSocket Frame ----|
| |
웹소켓은 프레임 구조 (Frame Structure)를 사용합니다
조금 더 깊이 들어가 봅시다. 웹소켓은 프레임 구조(Frame Structure)를 사용합니다. 웹소켓에서 주고받는 데이터는 프레임(Frame)이라는 작은 단위로 나뉘어 전송됩니다. 프레임은 메시지를 효과적으로 처리하고 제어할 수 있도록 설계된 웹소켓 프로토콜의 기본 데이터 단위입니다.
웹소켓 프레임은 다음과 같은 주요 필드로 구성되어 있습니다.
- 이 그림은 웹소켓의 공식 기술 명세서(RFC 6455)에 나오는 표준적인 표현 방식입니다.
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| |(if payload len==126 or 127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+---------------------------------------------------------------+
프레임 구조를 쉽게 이해하기 위해 웹소켓 통신을 '택배 소포'에 비유해 봅시다. 우리가 보내려는 실제 데이터(메시지)가 '물건'이라면, 프레임은 이 물건을 안전하고 정확하게 전달하기 위한 '포장 상자'와 같습니다. 이 상자에는 여러 정보가 적힌 '운송장'이 붙어있습니다.
1. 프레임의 기본 정보 (운송장의 핵심 내용) : 프레임의 첫 2바이트(16비트)는 이 '소포'가 어떤 종류이고 어떻게 다뤄야 하는지에 대한 핵심 정보를 담고 있습니다.
- FIN (마지막 소포인가?):
- 1: 이번이 마지막 소포입니다. (보낼 물건이 하나로 끝남)
- 0: 뒤에 소포가 더 있습니다. (보낼 물건이 너무 커서 여러 상자에 나눠 담음)
- 예시: 1MB짜리 사진을 보낼 때, 한 번에 보내기 부담스러우니 100KB씩 10개로 쪼개 보낼 수 있습니다. 이때 앞의 9개 프레임은 FIN=0, 마지막 10번째 프레임은 FIN=1이 됩니다.
- Opcode (소포의 내용물 종류는?):
- 0x1: 글자가 들어있어요 (텍스트 데이터).
- 0x2: 사진이나 영상 같은 파일이에요 (바이너리 데이터).
- 0x8: 이제 그만 보내고 싶어요 (연결 종료).
- 0x9: 잘 지내고 있나요? (핑 퐁, 연결 확인용 신호).
- MASK (보낸 사람이 누구?):
- 1: 클라이언트(사용자 컴퓨터)가 서버로 보낼 때 사용합니다. 보안을 위해 내용물을 암호화했다는 표시입니다.
- 0: 서버가 클라이언트로 보낼 때는 0으로 설정됩니다. 웹소켓 표준(RFC 6455)에서는 "서버는 클라이언트로 전송하는 프레임을 마스킹해서는 안 된다(MUST NOT mask)"라고 강력하게 금지하고 있습니다. 서버가 마스킹된 프레임을 보내면 클라이언트는 연결을 즉시 끊어버립니다.
2. 데이터의 크기 정보 (소포의 크기)
- Payload length (내용물의 크기는?):
- 0~125: 내용물이 125바이트 이하일 때, 실제 길이를 그대로 적습니다.
- 126: 내용물이 126바이트 ~ 65,535바이트(약 64KB) 사이일 때 사용합니다. "실제 크기는 바로 뒤 2바이트에 적혀있으니 참고하세요"라는 의미입니다.
- 127: 내용물이 65,535바이트를 초과하는 거대한 데이터일 때 사용합니다. "실제 크기는 바로 뒤 8바이트에 적혀있으니 참고하세요"라는 의미입니다.
3. 보안을 위한 암호화 (내용물 섞어놓기)
- Masking-key (암호 푸는 열쇠):
- 클라이언트가 서버로 데이터를 보낼 때는 중간에 누가 훔쳐보거나 변조하는 것을 막기 위해 데이터를 마스킹(일종의 암호화)합니다. 이때 사용한 4바이트(32비트) 짜리 비밀 키가 바로 마스킹 키입니다.
- 이 키는 매 프레임마다 새로 생성되어 보안성을 높입니다.
- Payload Data (실제 내용물):
- 우리가 진짜로 보내고 싶었던 텍스트나 바이너리 데이터입니다.
- 클라이언트가 보낼 때는 마스킹 키와 XOR 연산을 통해 뒤섞인 상태로 보내집니다. 서버는 이 데이터를 받으면 마스킹 키를 이용해 원래 데이터로 복원합니다.
이로써 하나의 웹소켓 프레임, 즉 '택배 상자'의 모든 구성 요소를 살펴보았습니다. 그런데 이 구성 요소 중 '마스킹'에 관련된 부분은 왜 클라이언트가 보낼 때만 사용되는 비대칭적인 규칙을 가질까요? 이는 웹소켓의 보안을 지키는 매우 중요한 설계적 결정에 그 이유가 있습니다.
왜 클라이언트만 마스킹을 해야 할까?
웹소켓은 보안상의 이유로 클라이언트에서 서버로 보내는 데이터에 대해 마스킹을 강제합니다. 마스킹이 없으면 프록시 서버나 중간 시스템이 악의적인 목적을 가진 패킷을 주입하거나 조작할 위험이 높아지기 때문입니다. 마스킹 키는 클라이언트마다 매 프레임마다 새롭게 생성되며, 이를 통해 예측 가능한 패킷 공격을 방지합니다.
조금 설명하자면 서버는 통제된 환경에 있지만, 클라이언트(사용자 PC)는 다양한 네트워크 환경(예: 카페 Wi-Fi, 회사 네트워크)에 노출됩니다. 이 중간에 있는 프록시 서버나 방화벽이 웹소켓 통신을 일반적인 HTTP 통신으로 착각하고 데이터를 멋대로 캐싱(저장)하거나 변조할 위험이 있습니다. 만약 악의적인 사용자가 이를 악용해 특정 패턴의 데이터를 보내 프록시 서버를 오염시키면, 다른 사용자들에게까지 잘못된 데이터가 전달될 수 있습니다 (이를 '프록시 캐시 포이즈닝' 공격이라고 합니다).
클라이언트가 매번 다른 마스킹 키로 데이터를 무작위처럼 보이게 만들면, 중간의 프록시 서버는 데이터의 패턴을 예측할 수 없게 됩니다. 따라서 데이터를 캐싱하거나 변조하려는 시도를 막을 수 있어 보안이 훨씬 강화됩니다. 이것이 바로 웹소켓이 클라이언트 측에 마스킹을 강제하는 가장 중요한 이유입니다.
애플리케이션 계층의 약속: 서브 프로토콜 (Sub-protocols)
이제 우리는 웹소켓이 클라이언트와 서버 사이에 훌륭한 '양방향 고속도로'를 열어준다는 것을 알았습니다. 하지만 그 도로 위를 달리는 자동차들이 어떤 규칙(언어)으로 대화할지는 아직 정하지 않았습니다.
웹소켓이라는 도로 위로는 JSON, XML, Protobuf 등 어떤 데이터든 실어 보낼 수 있습니다. 하지만 보내는 쪽과 받는 쪽이 서로 다른 언어를 사용한다면 대화가 통하지 않을 것입니다. 서브 프로토콜은 바로 이 고속도로에 진입하기 전, 톨게이트에서 "앞으로 우리는 'JSON'으로만 대화합시다!"라고 약속하는 '통행 규칙'과 같습니다.
1. 클라이언트의 제안
- 클라이언트는 최초 HTTP Upgrade 요청 시, 자신이 지원하는 서브 프로토콜 목록을 Sec-WebSocket-Protocol 헤더에 담아 보냅니다. (쉼표( ,)로 구분한 후보 목록을 보냅니다.)
// 요청
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: niZHVkZsbHV3bDM4NjEzMg==
Sec-WebSocket-Version: 13
Sec-WebSocket-Protocol: json-rpc, soap
2. 서버의 선택 및 확정
- 서버는 클라이언트가 제안한 목록을 확인하고, 그중 자신이 지원할 수 있는 단 하나의 프로토콜을 선택하여 Sec-WebSocket-Protocol 응답 헤더로 돌려준다.
// 응답 – 서버가 soap 선택
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: NzdDZ0pZUkpMeHJqUDJhQ2pGdzQ=
Sec-WebSocket-Protocol: soap
3. 합의 완료
- 이제 양측은 앞으로 'soap' 형식으로만 메시지를 주고받기로 약속한 것입니다. 만약 서버가 클라이언트가 제안한 프로토콜을 하나도 지원하지 않는다면, 위 마지막 줄(Sec-WebSocket-Protocol)을 생략할 것입니다. 그러면 WebSocket.protocol === ""(빈 문자열)이고, 애플리케이션은 별도의 형식 합의를 따로 마련해야 합니다. (예: 연결 후 첫 번째 메시지로 사용할 데이터 형식을 알리는 방식 등)
서브 프로토콜이 왜 중요한가?
- 의미 불일치 방지 : 데이터 형식·버전을 명시적으로 합의하므로 파싱 오류, 보안 취약점을 줄입니다.
- 엔드포인트 재활용 : 동일한 /chat 경로에서 JSON-RPC·SOAP·Protobuf 등 다양한 형식을 수용할 수 있습니다.
- 엄격한 오류 검출 : 합의되지 않은 메시지가 오면 즉시 프로토콜 차단 또는 연결 종료로 방어 가능합니다.
데이터 폭주를 막는 법: 흐름 제어와 역압(Backpressure)
우리가 웹소켓으로 구현된 채팅 서비스에서 친구에게 채팅을 하던 중에 만약 서버가 클라이언트가 처리할 수 있는 속도보다 훨씬 빠르게 데이터를 쏟아낸다면 어떻게 될까요? 클라이언트의 메모리 버퍼가 가득 차고, 결국 애플리케이션이 멈추거나 충돌할 수 있습니다. 이 문제를 다루는 개념이 바로 역압(Backpressure)입니다.
예를 들어 사용자가 1년 만에 단체 대화방에 접속했습니다. 서버는 그동안 쌓인 수천 개의 메시지를 클라이언트에게 전달해줘야 합니다. 이때 가장 단순한 접근 방식은 서버가 DB에서 읽은 모든 메시지를 반복문으로 클라이언트에게 send() 하는 것입니다. 마치 수도꼭지를 최대로 틀어버리는 것과 같은 모습입니다. 하지만 클라이언트가 구형 모바일 기기라면 어떨까요? 클라이언트는 메시지 하나를 받을 때마다 아래와 같이 결코 가볍지 않은 작업들을 수행해야 합니다.
- JSON 데이터를 객체로 변환
- 프로필, 말풍선 등 HTML 엘리먼트 생성
- 화면(DOM)에 렌더링 하고 애니메이션 효과 적용
서버는 초당 500개의 메시지를 보내는데, 클라이언트는 고작 50개밖에 처리하지 못하는 '처리 속도의 불균형'이 발생합니다. 이러한 불균형은 결국 다음과 같은 재앙으로 이어집니다.
- 메모리 사용량 증가: 처리되지 못한 메시지들은 클라이언트의 메모리 버퍼에 계속 쌓입니다.
- UI 응답 없음 (Freezing): 브라우저의 메인 스레드는 밀려드는 데이터를 처리하느라 다른 사용자 인터랙션(스크롤, 클릭 등)에 반응하지 못하게 됩니다.
- 애플리케이션 불안정성: 결국 메모리 한계를 초과하면 브라우저 탭이 비정상적으로 종료될 위험이 있습니다.
이 문제를 해결하기 위해서는 수신 측의 처리 속도에 맞춰 송신 측이 데이터 전송량을 조절하는 '역압(Backpressure)' 메커니즘이 필요합니다. 가장 직관적인 방법은 수신-확인(ACK, Acknowledgement) 기반의 흐름 제어입니다. 이는 클라이언트가 처리 가능한 만큼만 받고, 처리가 끝나면 서버에 다음 데이터를 요청하는 '협력' 방식입니다.
- 서버의 분할 전송: 서버는 데이터를 한 번에 보내지 않고 일정한 묶음(예: 50개)으로 나누어 첫 묶음만 보낸 뒤, 클라이언트의 신호를 기다립니다.
- 클라이언트의 처리: 클라이언트는 50개의 메시지를 받아 화면에 모두 렌더링 하는 작업을 수행합니다.
- 클라이언트의 신호 (ACK): 모든 작업이 끝나면, 서버에게 "준비 완료! 다음 묶음 보내줘"라는 ACK 메시지를 보냅니다.
- 서버의 전송 재개: 서버는 ACK 신호를 받아야만 비로소 다음 50개 묶음을 보내줍니다.
이 과정을 반복하면 서버는 클라이언트의 처리 속도에 맞춰 전송 속도를 자동으로 조절하게 됩니다. 클라이언트는 절대 스스로 감당하지 못할 데이터의 홍수에 휩쓸릴 일이 없어집니다.
여기서 한 가지 기억해야 할 중요한 점은, 방금 설명한 ACK 기반의 역압 메커니즘은 웹소켓 프로토콜 자체에 내장된 기능이 아닙니다. 이것은 웹소켓이라는 통신 채널 위에서 개발자가 직접 구현해야 하는 '애플리케이션 레벨의 로직'입니다. 따라서 웹소켓을 사용한다고 해서 데이터 폭주 문제가 자동으로 해결되는 것이 아니며, 서비스의 특성에 맞게 흐름 제어 로직을 신중하게 설계해야 합니다.
웹소켓과 다른 실시간 통신 방식 비교
지금까지 웹소켓의 강력함을 확인했지만, 모든 상황에 웹소켓을 사용하는 것이 정답은 아닐 것입니다. 우리가 마주할 문제에 가장 적합한 도구를 선택하기 위해, 다른 실시간 통신 방식들과 웹소켓을 객관적으로 비교해 보겠습니다.
항목 | HTTP 단순 요청/응답 | Short-Polling | Long-Polling | SSE (Server-Sent Events) | WebSocket |
연결 유지 | X | X | 연결 요청 중 대기 | 단일 HTTP 스트림 유지 | TCP 소켓 유지 |
통신 방향 | 단방향 (서버 → 응답) | 단방향 | 단방향 | 서버 → 클라이언트 | 양방향 |
지연 | 요청 간격에 의존 | 주기마다 (수백 ms–수초) | 거의 실시간 | 실시간 | 실시간 |
오버헤드 | 높음 (헤더 반복) | 높음 | 중간 | 낮음 | 가장 낮음 |
브라우저 지원 | 모든 곳 | 모든 곳 | 모든 곳 | 대부분 (IE 제외) | 모든 현대 브라우저 |
장점 | 단순, 캐싱 용이 | 구현 쉬움 | 푸시 비슷, 폴링 횟수 ↓ | 구현 간단, HTTP 친화 | 최저 지연, 양방향 |
단점 | 지연 큼 | 트래픽·CPU↑ | 타임아웃 관리 | 단방향 / HTTP2에 한계 | 프록시·방화벽 설정 필요 |
- "C: Client, S: Server"를 의미합니다.
1) Short-Polling
C ─▶ S : GET /data
S ─▶ C : 200 OK (data)
... n초 후 ...
C ─▶ S : GET /data
S ─▶ C : 200 OK (data)
2) Long-Polling
C ─▶ S : GET /updates (대기)
────────────────┐
(새 데이터 생김) │ 서버는 응답 보낼 때까지 홀딩
◀───────────────┘
S ─▶ C : 200 OK (data)
C ─▶ S : GET /updates (재연결)
3) Server-Sent Events (SSE)
C ─▶ S : GET /stream (Accept: text/event-stream)
S ─▶ C : HTTP 200 OK
data: {json}\n\n (← 여러 번 푸시)
data: {json}\n\n
... (단방향 스트림)
4) WebSocket
C ─▶ S : GET /chat (Upgrade: websocket)
S ─▶ C : 101 Switching Protocols
───────────────────────────────
C ⇄ S : [msg1] (양방향, 프레임 단위)
C ⇄ S : [msg2]
어떤 상황에 어떤 통신 방식을 선택하는 게 좋을지도 살펴봅시다.
상황 | 추천 방식 | 이유 |
1분에 한두 번 데이터 갱신 | Short-Polling | 가장 간단, 인프라 그대로 사용 |
“새 댓글이 달리면 바로 알림” 정도 | Long-Polling / SSE | 서버가 주도해 푸시, 구현 난이도 보통, 프록시 친화 |
채팅·게임·주식 호가 같이 쌍방향・수 ms 지연 허용 불가 | WebSocket | 헤더 오버헤드↓, RTT↓, 동시에 서버 → 클라 푸시 & 클라 → 서버 명령 가능 |
브라우저만 대상으로 일방향 실시간 피드 (알림, 트윗 스트림 등) | SSE | HTTP 기반이라 배포·모니터링 편함, 자동 재접속 기능 |
마치며: 첫걸음을 떼다
이번 포스팅에서는 제 사이드 프로젝트 'shoot'의 시작점이 된 웹소켓에 대한 개념을 간단히 알아보았습니다. 웹소켓은 단순히 빠른 기술을 넘어, 실시간 양방향 통신을 가능하게 해주는 대단한 도구입니다. 핸드셰이크부터 내부 프레임 구조, 그리고 실전에서 마주할 수 있는 데이터 폭주 문제까지, 이러한 원리를 이해하는 과정을 거쳐야 안정적인 채팅 서비스를 만들 수 있습니다.
물론 이 글에서 제가 다룬 내용이 웹소켓의 전부는 아닙니다. 다음 포스팅부터는 이 웹소켓을 기반으로 "수많은 채팅방을 어떻게 관리할지?", "서버가 여러 대가 되면 메시지 전달은 어떻게 보장할지?", "Kafka와 Redis는 왜 도입하게 되었는지" 등, 'shoot' 프로젝트를 개발하며 마주했던 더 현실적인 고민과 해결 과정을 하나씩 풀어가 보려 합니다.
앞으로 실시간 채팅 서비스 개발에 도전하는 분들에게 제 경험이 작은 도움이 되기를 바랍니다.