웹소켓 실전 적용하기
목차
WebSocket이 양방향 통신이라는 것은 알지만, 서비스에 적용하려니 인증이나 연결 관리부터 막막한 개발자를 위한 글입니다. 브라우저 클라이언트와 Django Channels 서버를 기준으로, 사용자를 확인하고 → 끊어진 연결을 복구하고 → 여러 서버 사이에서 메시지를 전달하는 과정을 살펴봅니다.
본문의 도식은 실제 서버에 접속하지 않는 시뮬레이션입니다. 버튼으로 조건과 단계를 바꿔 결과를 비교할 수 있습니다.
HTTP API에서는 흔히 Authorization 헤더에 토큰을 담습니다. WebSocket도 HTTP 핸드셰이크에 헤더를 사용하지만, 브라우저의 기본 WebSocket API는 임의의 Authorization 헤더를 설정할 수 없습니다. 서버용 클라이언트 라이브러리의 제약과는 구분해야 합니다.
브라우저에서 인증정보를 전달할 방법은 여러 가지입니다.
| 전달 방법 | 확인할 점 |
|---|---|
| 쿠키 | 기존 세션 인증을 활용할 수 있습니다. 쿠키의 적용 범위와 서버의 Origin 검증을 함께 설정합니다. |
| URL의 Query String | 핸드셰이크 단계에서 확인할 수 있지만, URL이 프록시·서버 로그에 남으면 토큰도 노출됩니다. 서버 간 통신에서도 같은 위험이 있습니다. |
| 연결 후 첫 메시지 | URL에 토큰을 넣지 않아도 됩니다. 대신 인증 전 접근 차단과 인증 대기 시간 제한이 필요합니다. |
여기서는 연결이 열린 뒤 첫 메시지로 토큰을 보내는 방식을 사용합니다. JWT는 토큰의 형식이고 Query String은 전달 방법입니다. JWT를 URL로 보낼 수도 있으므로 둘을 서로 다른 인증 방식처럼 비교하면 안 됩니다.
또한 토큰의 만료나 교체가 기존 WebSocket 연결을 자동으로 끊지는 않습니다. 만료 후 연결을 닫을지, 새 토큰으로 재인증할지 서버 정책을 정해야 합니다. 인증 전달 방식 참고
서버가 연결을 수락해도 사용자는 아직 인증되지 않았습니다. 아래에서 유효한 토큰과 만료된 토큰의 결과를 비교해 보세요.
연결 성공과 인증 성공은 다릅니다
토큰을 선택하고 첫 메시지가 서버에 전달되는 과정을 따라가 보세요.
token유효한 토큰- 연결
- 토큰 전송
- 검증
- 결과
WebSocket 연결을 수락하면 통신 경로가 열립니다. 서비스 메시지는 아직 처리하지 않습니다.
첫 메시지 인증을 보여주는 로컬 시뮬레이션입니다. 실제 서비스에 연결하지 않습니다.
클라이언트는 open 이벤트 이후 첫 메시지를 보냅니다. 서버의 authenticated 응답을 받은 뒤부터 서비스 메시지를 보냅니다. 아래 메시지 형식은 이 글에서 정한 애플리케이션 규약이며 WebSocket 표준 메시지가 아닙니다.
socket.addEventListener("open", () => {
socket.send(JSON.stringify({
type: "authenticate",
token: accessToken,
}));
});
이 교환은 개발자 도구에서도 확인할 수 있습니다. 다음 안내의 메뉴 이름은 Chrome의 영어 UI를 기준으로 합니다. Chrome 메시지 확인 문서
인증 메시지를 직접 확인하기
Chrome 개발자 도구에서 연결을 찾고 오가는 메시지를 읽어 보세요.
wss://example.com/ws/chat/101websocketWS 필터를 선택하면 WebSocket 연결만 표시됩니다.
Network 탭에서 WS 필터를 선택한 뒤, 개발자 도구를 연 상태로 페이지를 새로고침하거나 다시 연결합니다.
실제 개발자 도구를 삽입한 화면이 아닌 설명용 예시입니다. 메뉴 이름은 Chrome 버전과 언어에 따라 다를 수 있습니다.
Channels의 Consumer는 연결별 이벤트를 처리하며, scope에 연결 정보를 보관합니다. scope["user"]라는 키가 있다는 사실만으로 인증 성공을 판단하면 안 됩니다. 세션 인증 미들웨어가 미인증 사용자를 AnonymousUser로 넣을 수도 있기 때문입니다.
아래 예제는 첫 메시지 인증 상태를 별도로 관리합니다. 인증 메시지는 부모가 처리하고, 인증을 마친 서비스 메시지만 receive_authenticated()로 전달합니다. 자식 클래스가 부모의 receive()를 호출한 뒤 무조건 서비스 로직을 실행하는 구조를 피할 수 있습니다.
예제는 Django Channels의 비동기 Consumer와 PyJWT 2.x를 사용합니다. 프로젝트에 channels와 PyJWT를 설치하고 다음 조건을 맞춰야 합니다.
- Django settings의
WS_JWT_KEY,WS_JWT_ISSUER,WS_JWT_AUDIENCE를 토큰 발급 서버와 일치하도록 설정합니다. 서명 키는 서버 환경변수 등으로 관리합니다. - 이 예제는
HS256으로 발급된 토큰을 검증합니다.sub는 사용자 PK의 문자열이며exp,iss,aud가 필수입니다. 다른 발급 방식이라면 검증 설정과 사용자 조회를 맞춰 바꿉니다. - 사용자 모델에
is_active가 있다고 가정합니다.MyServiceConsumer.as_asgi()를 WebSocket 라우트에 연결하고, 라우팅을AllowedHostsOriginValidator또는 명시적인OriginValidator로 감쌉니다.
import asyncio
import json
import time
from contextlib import suppress
import jwt
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncWebsocketConsumer
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
@database_sync_to_async
def fetch_user_from_token(token):
claims = jwt.decode(
token,
settings.WS_JWT_KEY,
algorithms=["HS256"],
issuer=settings.WS_JWT_ISSUER,
audience=settings.WS_JWT_AUDIENCE,
options={"require": ["exp", "sub", "iss", "aud"]},
)
User = get_user_model()
user = User.objects.get(pk=claims["sub"], is_active=True)
return user, float(claims["exp"])
class AuthWebsocketConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.authenticated = False
self.closing = False
self.deadline = time.monotonic() + 5
await self.accept()
self.auth_timeout = asyncio.create_task(self.expire_authentication())
async def expire_authentication(self):
await asyncio.sleep(max(0, self.deadline - time.monotonic()))
if not self.authenticated:
await self.reject()
async def reject(self, code=1008):
self.closing = True
await self.close(code=code)
async def receive(self, text_data=None, bytes_data=None):
if self.closing:
return
if text_data is None:
await self.reject(code=1003)
return
try:
message = json.loads(text_data)
except json.JSONDecodeError:
await self.reject(code=1007)
return
if not isinstance(message, dict):
await self.reject(code=1007)
return
if not self.authenticated:
token = message.get("token")
if (time.monotonic() >= self.deadline
or message.get("type") != "authenticate"
or not isinstance(token, str)):
await self.reject()
return
try:
user, expires_at = await fetch_user_from_token(token)
except (jwt.InvalidTokenError, get_user_model().DoesNotExist,
ValueError, TypeError, ValidationError):
await self.reject()
return
# The timeout may have fired while the database query was running.
if self.closing or time.monotonic() >= self.deadline:
if not self.closing:
await self.reject()
return
if expires_at <= time.time():
await self.reject()
return
self.scope["user"] = user
self.expires_at = expires_at
self.authenticated = True
self.auth_timeout.cancel()
await self.send(text_data=json.dumps({"type": "authenticated"}))
return # Do not pass the authentication message to the service.
if time.time() >= self.expires_at:
await self.reject()
return
await self.receive_authenticated(message)
async def receive_authenticated(self, message):
raise NotImplementedError
async def disconnect(self, close_code):
self.closing = True
self.auth_timeout.cancel()
with suppress(asyncio.CancelledError):
await self.auth_timeout
class MyServiceConsumer(AuthWebsocketConsumer):
async def receive_authenticated(self, message):
# Validate each operation and its permissions here.
await self.send(text_data=json.dumps({"type": "received"}))
잘못된 JSON, 인증 메시지가 아닌 첫 메시지, 유효하지 않은 토큰은 연결을 종료하고 return합니다. close()만 호출하면 현재 Python 함수의 나머지 코드가 자동으로 중단되는 것은 아닙니다. 데이터베이스 조회 중 인증 제한 시간이 지나도 뒤늦게 인증을 승인하지 않도록 다시 확인합니다.
이 예제의 만료 정책은 서비스 메시지를 받을 때마다 exp를 확인하고, 만료되면 종료하는 것입니다. 아무 메시지도 오지 않는 연결을 만료 시각에 즉시 닫는 정책은 별도 타이머가 필요합니다. 인증 이후에도 방 참여나 메시지 전송 권한은 각 서비스 동작에서 검사해야 합니다.
Channels 인증 · Origin 검증 · PyJWT 검증 옵션
네트워크 전환, 일시적인 통신 장애, 서버 재시작으로 연결이 끊길 수 있습니다. 가장 단순하게는 close 이벤트에서 다시 연결하면 됩니다.
function connect() {
const socket = new WebSocket("wss://example.com/ws/chat/");
socket.addEventListener("close", connect);
}
하지만 장애가 계속되면 클라이언트가 쉬지 않고 재접속합니다. 여러 사용자의 연결이 한꺼번에 끊기면 복구 중인 서버로 요청이 몰립니다. 그래서 실패가 반복될수록 대기 상한을 늘리고, 실제 대기 시간에는 무작위성(jitter)을 더합니다.
다음 예제는 대기 상한을 1초에서 최대 16초까지 두 배씩 늘립니다. 실제 대기는 상한의 절반 이상, 상한 미만에서 고르므로 첫 재접속부터 시점이 분산됩니다. 연결 직후 다시 끊기는 상황에서도 지연이 유지되도록, 인증 후 30초 동안 연결이 유지된 경우에만 상한을 초기화합니다.
function createConnection(url, getToken, onMessage, onAuthRequired) {
const initialDelay = 1000;
const maxDelay = 16000;
let delayLimit = initialDelay;
let socket = null;
let retryTimer = null;
let stableTimer = null;
let stopped = false;
let authenticated = false;
function connect() {
if (stopped) return;
const current = new WebSocket(url);
socket = current;
current.addEventListener("open", () => {
if (stopped || socket !== current) return;
// getToken synchronously returns the current access token.
try {
current.send(JSON.stringify({
type: "authenticate", token: getToken(),
}));
} catch {
current.close(1008);
}
});
current.addEventListener("message", (event) => {
if (stopped || socket !== current) return;
let message;
try {
message = JSON.parse(event.data);
if (!message || typeof message !== "object") throw new Error();
} catch {
current.close(1007);
return;
}
if (message.type === "authenticated") {
if (authenticated) return;
authenticated = true;
// Reset only after 30 seconds of a stable, authenticated connection.
stableTimer = setTimeout(() => { delayLimit = initialDelay; }, 30000);
return;
}
if (authenticated) onMessage(message);
});
current.addEventListener("close", (event) => {
if (socket !== current) return;
clearTimeout(stableTimer);
authenticated = false;
socket = null;
if (stopped) return;
if ([1000, 1002, 1003, 1007, 1008, 1009].includes(event.code)) {
if (event.code === 1008) onAuthRequired();
return;
}
// Equal jitter: wait between half the limit and the full limit.
const delay = Math.floor(delayLimit / 2 + Math.random() * delayLimit / 2);
delayLimit = Math.min(maxDelay, delayLimit * 2);
retryTimer = setTimeout(connect, delay);
});
}
connect();
return {
send(message) {
if (!authenticated || socket?.readyState !== WebSocket.OPEN) return false;
socket.send(JSON.stringify(message));
return true;
},
stop() {
stopped = true;
authenticated = false;
clearTimeout(retryTimer);
clearTimeout(stableTimer);
socket?.close(1000);
},
};
}
getToken()은 현재 토큰을 동기적으로 반환하는 애플리케이션 함수입니다. 토큰 발급·갱신은 호출 전에 완료해야 합니다. onMessage에는 인증 후 수신한 서비스 메시지를 전달하고, onAuthRequired에서는 토큰 갱신이나 로그인 화면으로 이어지는 처리를 합니다. 새 연결을 만들기 전에는 기존 객체의 stop()을 호출합니다.
1000 정상 종료와 인증·프로토콜·메시지 형식 관련 종료는 자동 재시도하지 않습니다. 특히 1008은 일반적인 정책 위반 코드이므로 실제 서비스에서는 서버의 종료 규약에 맞춰 처리해야 합니다. 이 글의 서버는 인증 실패·만료에도 이 코드를 사용합니다. 사용자가 화면을 떠날 때 stop()을 호출하면 예약된 재접속도 취소됩니다.
WebSocket 종료 코드 · Backoff와 jitter
연결이 다시 열렸다고 끊긴 동안의 메시지가 돌아오는 것은 아닙니다. 새 연결에서 인증을 마치고 필요한 방을 다시 구독해야 합니다. 누락 메시지를 복구하려면 서버에 메시지를 저장하고, 마지막으로 처리한 메시지 ID나 커서를 기준으로 조회하는 절차가 필요합니다.
미전송 메시지를 다시 보낼 때는 같은 메시지가 두 번 처리되지 않도록 메시지 ID와 중복 처리 정책도 정합니다. 재연결은 통신 경로 복구이고, 이 과정은 서비스 데이터 복구입니다.
동시 연결이나 메시지 처리량이 늘면 서버의 자원이 부족해질 수 있습니다. 서버 한 대의 CPU·메모리를 늘리는 방법이 수직 확장, 애플리케이션 서버를 추가하는 방법이 수평 확장입니다.
한 대의 성능을 높일까, 여러 대로 늘릴까?
서버 한 대에서 출발해 확장 방식을 적용해 보세요.
두 방식 모두 같은 구성에서 시작합니다.
구조를 비교하는 예시이며 처리량 벤치마크가 아닙니다. 어느 방식도 무한한 처리 용량을 보장하지 않습니다.
수직 확장은 구성 관리가 비교적 단순하지만 한 대에 추가할 수 있는 자원에 한계가 있습니다. 수평 확장은 여러 인스턴스로 부하를 나눌 수 있지만, 데이터베이스·브로커의 병목과 서버 간 통신 비용을 고려해야 합니다. 두 방식 중 어느 것이 적합한지는 실제 병목과 운영 비용에 따라 달라집니다.
A방에는 철수와 영희, B방에는 철수와 민수가 있습니다. 철수와 영희는 서버 1에, 민수는 서버 2에 연결되어 있다고 가정해 봅시다.
방 정보를 각 서버의 메모리에만 저장하면 서버 1은 자신의 연결에만 메시지를 보낼 수 있습니다. 철수가 B방에 메시지를 보내도 서버 2의 민수에게 전달할 경로가 없습니다.
다른 서버의 사용자에게 전달할 수 있을까요?
철수와 민수는 같은 B방에 있지만 서로 다른 서버에 연결되어 있습니다.
메시지를 보낸 뒤 한 단계씩 진행하세요. 브로커 유무에 따른 결과를 비교할 수 있습니다.
로컬 시뮬레이션입니다. 브로커는 공유 channel layer를 나타내며, 메시지 보존과 재전송은 별도로 설계해야 합니다.
Pub/Sub는 발행한 이벤트를 해당 이벤트의 구독자에게 전달하는 패턴입니다. 이 사례에서 서버 간 이벤트를 받는 쪽은 다른 서버에서 실행되는 Consumer입니다. 브라우저는 자신의 WebSocket 서버와 계속 통신합니다.
Django Channels는 이를 위한 channel layer와 그룹 API를 제공합니다. 운영 환경에서는 Redis를 사용하는 channels_redis로 서버들이 공유하는 전달 계층을 구성할 수 있습니다. 프로세스별 메모리 계층만 사용하면 다른 프로세스로 메시지를 전달할 수 없습니다.
다른 서버의 사용자에게 전달할 수 있을까요?
철수와 민수는 같은 B방에 있지만 서로 다른 서버에 연결되어 있습니다.
메시지를 보낸 뒤 한 단계씩 진행하세요. 브로커 유무에 따른 결과를 비교할 수 있습니다.
로컬 시뮬레이션입니다. 브로커는 공유 channel layer를 나타내며, 메시지 보존과 재전송은 별도로 설계해야 합니다.
설정 예시는 다음과 같습니다. channels_redis를 설치하고 모든 애플리케이션 서버가 같은 Redis 계층을 사용하도록 연결 정보를 지정합니다. 아래 localhost 주소는 로컬 개발용입니다.
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {"hosts": [("127.0.0.1", 6379)]},
},
}
인증 이후 방 참여 권한을 확인한 Consumer는 자신의 channel_name을 그룹에 추가합니다. 발신자가 그 방에 보낼 권한도 확인한 뒤 group_send()를 호출하면, 그룹에 속한 Consumer들이 각자의 서버에서 이벤트를 처리합니다.
아래는 앞의 Consumer에 연결할 핵심 API 호출과 이벤트 핸들러 발췌입니다. group_add()는 방 참여 처리에, group_send()는 서비스 메시지 처리에 넣습니다. 방 선택·권한 조회 로직은 서비스에 맞게 구현해야 합니다.
# Inside an authenticated AsyncWebsocketConsumer.
# First check that scope["user"] is allowed to join the requested room.
await self.channel_layer.group_add("room.B", self.channel_name)
# After checking the sender's room membership and message shape:
await self.channel_layer.group_send(
"room.B",
{"type": "chat.message", "text": "Hello, room B!"},
)
# Each recipient Consumer handles the event on its own server.
async def chat_message(self, event):
if self.closing or not self.authenticated:
return
if time.time() >= self.expires_at:
await self.reject()
return
await self.send(text_data=json.dumps({
"type": "chat.message", "text": event["text"],
}))
# On disconnect, discard the group and run the parent's cleanup.
async def disconnect(self, close_code):
await self.channel_layer.group_discard("room.B", self.channel_name)
await super().disconnect(close_code)
type: "chat.message" 이벤트는 chat_message()로 전달됩니다. 그 핸들러가 self.send()를 호출하면 해당 Consumer에 연결된 브라우저가 메시지를 받습니다. 그래서 B방의 민수는 받고, A방에만 있는 영희는 받지 않습니다.
그룹 전달은 과거 메시지 저장소가 아닙니다. 공유 전달 계층을 구성해도 메시지 보존이나 재접속 후 재생까지 자동으로 해결되지는 않습니다. 앞 절의 커서 기반 복구가 필요하다면 저장소와 조회 절차를 함께 설계해야 합니다. Channels의 그룹과 channel layer
- 잘못된 토큰이나 인증 전 서비스 메시지가 핸들러에 도달하지 않는지 확인합니다.
- 서버 장애 중 재접속 간격이 분산되고, 사용자가 종료하면 재시도가 멈추는지 확인합니다.
- 서로 다른 서버에 연결된 같은 방의 사용자는 메시지를 받고, 다른 방의 사용자는 받지 않는지 확인합니다.
- 재연결 후 인증·방 구독·누락 메시지 복구가 필요한 순서로 수행되는지 확인합니다.
인증, 연결 관리, 서버 간 전달을 각각 확인하면 문제가 발생했을 때 어느 단계에서 끊겼는지도 찾기 쉬워집니다.