웹소켓 실전 적용하기

Feb 26, 2023

Practical WebSocket

    • 웹소켓이 양방향 연결이고 실시간 통신에 필요한 것을 알고 있으나, 적용하려고 하니 막막한 사람
    • 웹소켓을 실제 서비스에 적용할 때 고려야해되는 점을 알고 싶은 사람

    웹소켓을 이용한 서비스를 만들 때, 유저 정보를 어떻게 받아야할지 고민이 됩니다. HTTP에서는 Header에 Authorization 정보를 넣어서 보내면 되지만, 웹소켓은 Header가 없기 때문에 그런 방식으로 유저 정보를 받을 수 없습니다.

    웹소켓으로 인증정보를 처리하는 첫번째 방법은 URI에 Query String으로 넣어서 보내는 방법입니다.

    wss://blog.chavo.dev/?key=USER_API_KEY
    

    URI에 Query String으로 넣어서 보내는 방법은 간단하고, 구현하기도 쉽지만, API_KEY 정보가 URI에 노출되기 때문에 서버사이드에서만 사용하는 것이 좋습니다. API_KEY를 빠르게 만료시키는 방법으로 보안을 강화할 수는 있지만, API_KEY가 바뀔 때마다 연결이 끊어지기 때문에 실제 서비스에서 사용하기에는 유저 사용성을 해치기 때문에 좋은 방법이 아닙니다.

    두번째 방법은 Connection을 열 때 JWT로 인증정보를 보내는 방법입니다.

    JWT는 JSON Web Token의 약자로, HTTP에서 유저 정보를 처리할 때 가장 흔하게 쓰이는 인증 방식입니다. HTTP에서 JWT를 사용할 때는 Authorization Header에 넣어서 보내듯이, 웹소켓에서는 Connection을 열 때 JWT를 보내는 방식을 사용하여 유저 정보를 기억할 수 있습니다.

    JWT-based

    고객 메신저 SaaS 프로그램인 채널톡의 실제 인증 방식

    Tip 💡
    Google Chrome의 Developer Tool > Network > ws에서 해당 사이트의 WebSocket이 어떻게 이뤄지고 있는지 확인할 수 있습니다.

    Developer Tool

    위의 방법으로 Django Channels에서 JWT 인증을 처리해봅시다. Django Channels는 Django에서 WebSocket을 사용할 수 있게 해주는 라이브러리입니다.

    Channels는 Consumer라는 class를 제공하여 WebSocket에 필요한 Event를 처리할 수 있게 해주고, 별도의 클래스를 만들어서 Authentication이나 서비스에 필요한 추상화를 더해줄 수 있습니다.

    문서에서는 Scope에서 연결에 대한 정보들을 저장할 것을 권장하고 있습니다. Scope는 dict 형태로, scope['user']에 유저 정보를 저장할 수 있습니다.

    아래는 AuthWebsocketConsumer라는 class를 만들어서 Authentication을 처리하는 예시 코드입니다.

    abc/AuthWebsocketConsumer.py
    class AuthWebsocketConsumer(WebsocketConsumer):
        def receive(self, text_data=None, bytes_data=None):
            # Case: Already user authentication is finished
            if "user" in self.scope:
                pass
            else:
                data = json.loads(text_data)["data"]
                if "token" in data.keys():
                    # Get token from data
                    token = data["token"].encode("utf-8")
                    # Custom function to fetch user from token
                    user = fetch_user_from_token(token)
                    self.scope["user"] = user
    
            # Case: User Authentication is not working 
            if "user" not in self.scope:
                self.close()
    
    service/consumer.py
    class MyServiceConsumer(AuthWebsocketConsumer):
        ...
        def receive(self, text_data=None, bytes_data=None):
            super(MyServiceConsumer, self).receive(text_data, bytes_data)
        ...
    

    웹소켓 연결은 끊기기 쉽습니다. 서버가 죽는 케이스를 제외하고도 와이파이 연결이 끊기거나, 다른 와이파이를 연결하거나, 잠시 네트워크가 끊기는 상황에서도 WebSocket 연결이 끊어집니다.

    이런 케이스에 대비해서 연결이 끊어졌을 때 다시 연결을 시도하는 방법을 알아봅시다.

    가장 간단한 해결책으로는 아래와 같은 코드를 작성할 수 있습니다.

    function connect() {
        ws = new WebSocket("wss://your_server_uri/ws/abc/");
        ws.addEventListener("close", connect);
    }
    

    위 코드는 WebSocket이 닫힐 때마다 다시 연결을 시도합니다. 하지만 이 방법은 서버에 부하를 줄 수 있고, 서버가 아예 동작하지 않는 경우에는 연결과 끊어짐의 무한 루프에 빠질 수 있습니다.

    이런 문제를 해결하기 위해서는 다시 연결해주는 타이밍을 조절해야 합니다.

    const initialReconnectDelay = 1000;
    const currentReconnectDelay = initialReconnectDelay;
    const maxReconnectDelay = 16000; // (2 ** 4) * 1000
    
    function connect() {
        ws = new WebSocket("wss://your_server_uri/ws/abc/");
        ws.addEventListener("open", onWebsocketOpen);
        ws.addEventListener("close", onWebsocketClose);
    }
    
    function onWebsocketOpen() {
        currentReconnectDelay = initialReconnectDelay;
    }
    
    function onWebsocketClose() {
        ws = null;
        setTimeout(reconnectToWebsocket, currentReconnectDelay);
    }
    
    function reconnectToWebsocket() {
        if (currentReconnectDelay < maxReconnectDelay) {
            currentReconnectDelay *= 2;
        }
        connect();
    }
    

    실패 횟수에 따라 지수적으로 다시 연결하는 타이밍을 늘려주기 때문에 오래 연결되지 않는 상황에서 서버에 부하를 줄일 수 있습니다.

    실제 서비스를 운영할 때 서버가 죽게 되면 위의 코드에서는 모든 클라이언트가 같은 타이밍으로 요청이 오기 때문에 클라이언트 수가 많은 경우에는 서버에 큰 부담이 될 수 있습니다.

    다음과 같이 연결하는 타이밍을 무작위로 만들어주면 서버에 부하를 줄일 수 있습니다.

    ...
    function reconnectToWebsocket() {
        if (currentReconnectDelay < maxReconnectDelay) {
            currentReconnectDelay += Math.floor(Math.random() * currentReconnectDelay);
        }
    ...
    }
    

    실제 서비스에서 WebSocket 서버를 운영하는 경우 요청이 많아지면 서버를 더 추가해야 합니다. 서버를 늘리는(Scaling) 방법은 크게 두 가지가 있습니다.

    1. WebSocket이 올라가있는 서버의 성능을 올리는 Vertical Scaling
    2. WebSocket 서버를 여러대 두는 Horizontal Scaling
    horizontal-vs-vertical-scaling

    요청이 무한대로 증가하는 경우를 생각해보면 Vertical Scaling은 업그레이드 할 수 있는 서버의 RAM, CPU, Disk 등이 한계가 있지만 Horizontal Scaling은 서버를 무한대로 늘릴 수 있습니다. 그렇기 때문에 가능하다면 Horizontal Scaling을 선택하는 것이 좋습니다.

    하지만 다음과 같은 케이스를 생각해봅시다.

    카카오톡과 같은 채팅 시스템에서 단체 카톡방에서 메세지를 보내는 경우를 생각해봅시다. 단체 카톡방 A와 B가 있고, 철수와 영희는 A 카톡방에 있고, 철수와 민수는 B 카톡방에 있습니다.

    처음 서버가 하나 밖에 없을 때는 각각의 카톡방 정보를 서버에 기록해두고, 메세지를 보낼 때마다 해당 카톡방에 있는 모든 사용자에게 메세지를 보내면 됩니다. 하지만 서버가 하나 더 생기고, 같은 카톡방에 있는 사용자들이지만 다른 서버에 연결된 경우는 어떻게 해야할까요?

    horizontal-scaling-problem

    오른쪽 상황에서 철수가 B 카톡방에서 얘기하면 민수에게 메세지를 어떻게 전달해야할까?

    이 상황을 해결하기 위해 Pub/Sub(publish-subscriber) 패턴을 이용할 수 있습니다.

    pub-sub

    이미 다양한 웹소켓 서버 라이브러리에서 지원중인 Pub/Sub 패턴은 Publisher(일반적으로 서버)와 Subscriber(일반적으로 클라이언트)가 나뉘어져있고, Publisher가 메시지를 발행하면 Message Broker를 통해 해당 메세지를 받아야할 단일 혹은 다수의 Subscriber에게 전달합니다.

    비즈니스 로직은 Publisher와 Subscriber에서 처리하고 Message Broker는 메시지를 전달하는 역할만 합니다. 이렇게 하면 Publisher와 Subscriber는 서로에 대해 알 필요가 없고, Message Broker를 통해 통신하기 때문에 여러 대의 Publisher가 추가되어도 서비스 운영에 문제가 없습니다.

    다음과 같이 WebSocket을 실제 서비스에서 적용할 때 고려할만한 사항들에 대해서 알아보았습니다. 제가 놓친 것이 있거나 내용 중 잘못된 부분이 있다면 댓글로 알려주세요! :)

    읽어주셔서 감사합니다!