WebSocket 과 실시간 통신
한 번 연결을 맺고 유지하면서 양쪽이 먼저 메시지를 보낼 수 있게 하는 프로토콜
왜 필요한가
HTTP 는 클라이언트가 물어야 서버가 답합니다. 서버가 먼저 알려줄 방법이 없습니다.
| 방식 | 동작 | 문제 |
|---|
| 폴링 | 3초마다 "새 메시지 있어?" 요청 | 없어도 요청한다. 최대 3초 지연 |
| 롱 폴링 | 요청을 보내고 응답을 미뤄 둔다 | 메시지마다 연결을 다시 맺는다 |
| SSE | 서버가 한 방향으로 계속 보낸다 | 클라이언트에서 서버로는 못 보낸다 |
| WebSocket | 연결을 유지하고 양방향 | 연결 유지 비용이 든다 |
폴링의 낭비를 숫자로 보면 이렇습니다. 사용자 1만 명이 3초마다 물으면 초당 3,300 요청이고, 그중 대부분은 "새 것 없음"입니다. 헤더와 연결 수립 비용이 실제 데이터보다 큽니다.
연결 수립
WebSocket 은 HTTP 로 시작해 프로토콜을 바꿉니다.
| 단계 | 내용 |
|---|
| 1 | 클라이언트가 HTTP 요청에 Upgrade 헤더를 담아 보낸다 |
| 2 | 서버가 101 응답으로 승인한다 |
| 3 | 같은 TCP 연결에서 이후 WebSocket 프레임을 주고받는다 |
HTTP 로 시작하는 이유는 기존 인프라(포트 80과 443, 프록시)를 그대로 쓰기 위해서입니다.
무엇을 고를지
| 요구 | 선택 |
|---|
| 서버가 보내기만 하면 된다 | SSE. 구현이 단순하고 자동 재연결이 표준에 있다 |
| 양방향이 필요하다 | WebSocket |
| 갱신이 드물다 | 폴링으로 충분하다. 30초에 한 번이면 굳이 연결을 유지할 이유가 없다 |
| 지연이 100밀리초 이내여야 한다 | WebSocket |
연결을 오래 열어두는 비용
| 항목 | 내용 |
|---|
| 메모리 | 연결마다 버퍼와 상태가 남는다 |
| 파일 디스크립터 | 연결이 곧 열린 소켓이다. 상한에 걸린다 |
| 서버 재배포 | 배포할 때 모든 연결이 끊긴다 |
| 로드밸런서 | 연결이 한 서버에 고정되어 부하가 고르지 않다 |
| 중간 장비 | 프록시나 NAT 가 유휴 연결을 끊는다 |
운영에서 챙길 것
| 항목 | 내용 |
|---|
| 하트비트 | 주기적으로 핑을 보내 중간 장비가 끊는 것을 막고 죽은 연결을 감지한다 |
| 재연결 | 끊기면 간격을 늘려가며 다시 붙는다. 지터를 더해 몰림을 막는다 |
| 놓친 메시지 | 끊긴 동안의 메시지를 어떻게 줄지 정한다. 마지막 수신 지점을 보내 이어받는다 |
| 서버 간 전달 | 서버가 여러 대면 다른 서버에 붙은 사용자에게 어떻게 보낼지 정한다 |
| 상한 | 서버당 연결 수에 상한을 두고 넘으면 거절한다 |
네 번째가 규모가 커질 때 반드시 만나는 문제입니다. A 서버에 붙은 사용자가 B 서버에 붙은 사용자에게 메시지를 보내려면, 메시지 브로커를 두고 서버들이 구독하는 구조가 필요합니다.
Q.WebSocket 이 폴링보다 나은 점과 그 대가는 무엇인가요?
| 항목 | 폴링 | WebSocket |
|---|
| 지연 | 폴링 간격만큼 | 즉시 |
| 빈 요청 | 갱신이 없어도 계속 요청한다 | 없다 |
| 헤더 비용 | 요청마다 수백 바이트 | 프레임 헤더 몇 바이트 |
| 서버가 먼저 보내기 | 불가 | 가능 |
| 연결 유지 비용 | 없다 | 연결마다 메모리와 소켓 |
| 배포 영향 | 없다 | 재배포 시 전부 끊긴다 |
| 구현 복잡도 | 낮다 | 재연결, 하트비트, 서버 간 전달 |
대가가 아래 세 줄입니다. 특히 마지막이 실무 비용의 대부분입니다. 폴링은 실패해도 다음 요청이 알아서 복구하지만, WebSocket 은 끊김을 감지하고 다시 붙고 놓친 메시지를 이어받는 것을 직접 만들어야 합니다.
흔한 실수: 실시간이 필요하다는 이유만으로 WebSocket 을 고르는 것. 갱신이 30초에 한 번이면 폴링이 더 단순하고 값쌉니다. 초 단위 이하의 지연이 실제로 필요한지가 기준입니다.
Q.WebSocket 연결을 오래 유지하면 운영에서 어떤 문제가 생기나요?
| 문제 | 내용 |
|---|
| 소켓 상한 | 연결이 곧 파일 디스크립터다. 기본 상한에 걸려 새 연결이 거부된다 |
| 메모리 누적 | 연결마다 버퍼와 상태가 남아 연결 수에 비례해 늘어난다 |
| 배포 시 전체 끊김 | 서버를 내리면 붙어 있던 모든 연결이 끊긴다 |
| 부하 불균형 | 로드밸런서가 연결 시점에만 분배하므로 오래된 서버에 연결이 쌓인다 |
| 유휴 연결 절단 | 프록시나 NAT 가 일정 시간 조용한 연결을 끊는다 |
| 좀비 연결 | 클라이언트가 사라졌는데 서버는 모르고 자원을 쥐고 있다 |
세 번째와 네 번째가 함께 나쁘게 작용합니다. 배포로 서버를 하나씩 교체하면, 남은 서버에 연결이 몰리고 새 서버는 비어 있습니다.
| 대응 | 내용 |
|---|
| 소켓 상한 상향 | 운영체제 설정을 올린다 |
| 하트비트 | 죽은 연결을 감지해 정리하고 중간 장비의 절단을 막는다 |
| 점진 종료 | 배포 시 재연결 안내를 보내고 시간을 두어 흩어지게 한다 |
| 최대 연결 수명 | 일정 시간이 지나면 스스로 끊어 재분배를 유도한다 |
흔한 실수: 하트비트를 클라이언트만 보내게 하는 것. 서버도 응답이 없는 연결을 정리해야 좀비 연결이 쌓이지 않습니다.
Q.서버가 여러 대일 때 WebSocket 메시지를 어떻게 전달하나요?
연결은 특정 서버에 붙어 있으므로, 서버끼리 메시지를 나눠 갖는 통로가 필요합니다.
| 방법 | 내용 |
|---|
| 발행 구독 | 모든 서버가 브로커를 구독한다. 누가 보내도 전체가 받는다 |
| 라우팅 표 | 어느 사용자가 어느 서버에 있는지 저장해 그 서버로만 보낸다 |
| 전용 게이트웨이 | 연결만 담당하는 계층을 두고 뒤에서 붙인다 |
첫 번째가 가장 흔합니다. Redis 의 발행 구독이나 메시지 브로커를 두고, 서버는 받은 메시지 중 자기에게 붙은 사용자 몫만 내보냅니다. 구현이 단순한 대신 모든 서버가 모든 메시지를 받습니다.
두 번째는 규모가 커질 때 씁니다. 서버가 50대면 첫 번째 방식에서 각 서버가 전체 메시지를 다 받게 되어 낭비가 큽니다.
| 함께 정할 것 | 내용 |
|---|
| 접속 위치 저장 | 사용자와 서버의 매핑. 연결이 끊기면 지운다 |
| 정리 | 서버가 죽으면 그 서버의 매핑이 남는다. 만료를 둔다 |
| 순서 | 같은 대화의 메시지가 순서대로 가게 한다 |
흔한 실수: 로드밸런서의 고정 세션으로 해결하려는 것. 같은 사용자가 같은 서버로 가는 것은 되지만, 다른 사용자에게 보내는 문제는 그대로 남습니다.
Q.끊긴 동안 놓친 메시지를 어떻게 이어받나요?
클라이언트가 어디까지 받았는지를 알려주고 서버가 그 뒤부터 보냅니다.
| 방법 | 내용 |
|---|
| 마지막 수신 식별자 | 재연결 시 마지막 메시지 id 를 보내고 그 이후를 받는다 |
| 시각 기준 | 마지막 수신 시각 이후를 받는다. 시계 차이에 주의 |
| 전체 재조회 | 재연결 후 목록을 다시 불러온다. 단순하지만 무겁다 |
| 보관 기간 | 서버가 최근 메시지를 얼마간 들고 있어야 한다 |
첫 번째가 정확합니다. 순번이나 단조 증가하는 식별자를 쓰면 중복도 누락도 없습니다. SSE 는 이것이 표준에 있어서 마지막 수신 id 를 자동으로 다시 보냅니다.
네 번째를 함께 정해야 합니다. 서버가 최근 메시지를 보관하지 않으면 이어받을 대상이 없습니다. 보관 기간을 넘겨 끊겼던 클라이언트는 전체 재조회로 넘겨야 합니다.
| 주의 | 내용 |
|---|
| 중복 수신 | 경계에서 겹칠 수 있어 클라이언트가 id 로 걸러낸다 |
| 대량 전송 | 오래 끊겼다가 붙으면 수천 건이 몰린다. 개수에 상한을 둔다 |
| 순서 | 이어받은 것과 실시간으로 오는 것의 순서를 맞춘다 |
흔한 실수: 재연결만 구현하고 이어받기를 빼는 것. 연결은 복구되지만 그동안의 메시지가 사라져 사용자는 대화가 비어 있는 것을 봅니다.
먼저 스스로 답해보고 아래 답변과 견줘보세요. 막히는 부분은 문제로 확인할 수 있어요.
읽었으면 문제로 확인해보세요
네트워크 문제를 풀면 틀린 문제가 자동으로 노트에 쌓입니다. 가입 없이 5문제를 먼저 풀어볼 수도 있어요.