WebSocket 을 이해하며 채팅서비스를 구현해보아요 | MySQL, MongoDB

Jake·2024년 5월 9일
0


안녕하세요 여러분!

이번시간에는 채팅서비스를 구현하는 과정 중 제가 했던 생각과 고민들을 정리해보는 시간을 가져볼게요.

아래의 순서대로 진행되어요.


  1. 채팅 서비스 요구사항 정의
  2. 프로토콜 정하기
  3. 구현시 마주한 고민

채팅서비스의 요구사항은 ?


출처 : sendbird


먼저 저는 서비스 요구사항들을 아래와 같이 정의하였어요.

기능적인 관점에서는

  • 1 대 1 채팅을 지원하고
  • 실시간 통신이 되어야 하며
  • 유저 검증이 되어야 한다

서비스적인 관점에서는

  1. 팀 매칭을 하기 위해 사용자들이 서로 편하게 소통할 수 있는 창구를 만들기 위해
  2. 팀 매칭을 하기 전이므로 그룹 채팅은 필요하지 않다고 생각
  3. 채팅이므로 실시간 통신이 필요
  4. 인증된 유저들에게만 채팅이 가능하도록 유저 검증

와 같이 정의해보았어요.


어떤 프로토콜을 사용할까 ?


요구사항을 위와 같이 정하였고, 이제 이 요구사항들을 구현하기 위해 어떤 방법(프로토콜)을 사용할지 고민을 하였고 아래 4가지 방법 중 고민해보았어요.


실시간성 보장을 초점에 두었습니다.


HTTP

가장 먼저 HTTP를 고려하였어요.

이유는 현재 사용중인 기술이었으며 바로 적용시킬 수 있었기 때문이에요.

HTTP 통신은 요청 (Request) 과 응답 (Response) 방식으로 통신이 진행되요. 여기서 요청은 클라이언트가 하고 응답은 서버가 전송해요. 기존에 이 방식은 어떠한 정보들이 필요할때 클라이언트가 서버에 요청하여 해당 정보를 받고 사용자에게 전달해주기 위해 이러한 방식을 사용하였어요.


다만, 기능적으로 구현은 가능하지만 제가 생각했던 요구사항과는 거리가 멀었어요.

애초에 HTTP의 경우 클라이언트가 요청한 정보들을 받아 사용자에게 전달하는 서비스에 적합하게 설계되었어요.

예를 들어 SNS의 피드 와 같이 주기적으로 업데이트가 필요한 상황 같은 경우가 적합하다고 생각해요. 이는 클라이언트가 요청한 정보들을 요청하고 서버는 그 요청내용을 파악한 뒤 클라이언트에게 응답하면 클라이언트는 다시 그 전달받은 정보들을 사용자에게 보여주는 형태처럼 말이죠.


하지만 실시간성을 보장해야하는 요구사항을 충족하기에는 클라이언트가 요청하기 전까지 서버는 어떠한 응답을 줄 수 없으므로 실시간성을 보장하기란 어려워요.


HTTP로 실시간성 보장을 위한 노력들


Polling



HTTP는 클라이언트의 요청이 없다면 채팅 상태를 업데이트 할 수가 없겠죠?

저희의 목표는 채팅서비스를 구현하는 것이며 실시간성 보장 에 집중을 해야해요.

그래서 Polling 방식에서는 데이터가 실시간으로 변할 수 있도록 클라이언트가 지속적으로 서버에게 요청을 하는 것이에요.


만약 채팅 상태가 변하지 않더라도 클라이언트는 서버에게 정보를 받기 전까지는 그 사실을 알 방법이 없기 때문에 실시간성을 보장하려면 지속적으로 요청을 할 수 밖에 없습니다.

이는 자연적으로 클라이언트와 서버에게 부담이 될 수 있어요.

그리고 클라이언트가 Request를 요청한다는 것 안에는 Http Request Connection 에 대한 연결을 맺고 끊는 내용이 포함되어 있어요. 그래서 실시간 정도의 빠른 응답 또한 기대하기가 어렵습니다.


만약 일정하게 갱신되는 서버의 데이터 (대시보드와 같은 서비스) 는 유용하게 사용할 수 있습니다.


Long Polling



사람들은 Polling에서 클라이언트의 너무 많은 요청들이 발생하니 이 부담을 어떻게 줄일까 고민을 하였어요. 그 결과 나오게된 방법이 Long Polling 입니다. 즉 요청에 대한 응답까지의 시간을 Long 하게 가져가는 것이에요.

Polling에서 왜 어디에서 부담이 발생할까? 를 생각해보면 서버의 변경과 관계없이 요청을 지속적으로 하기 때문이에요.

그래서 Long Polling 에서는 이 응답시간을 늦추어 요청 빈도수를 많이 줄여 부하를 줄이는 방식으로 해결하였어요.

이렇게 되면 클라이언트는 서버로 요청을 하고 응답을 받은 뒤 바로 다시 요청하여 다음 이벤트를 지속적으로 기다리게 되며 클라이언트의 요청의 빈도수도 그만큼 줄어들게 되니 요청에 대한 부담은 줄게 될거에요.

하지만 이벤트들의 시간 간격이 좁다면 Polling과 별 차이게 없게 되고 이 또한 실시간성에 대한 요구사항을 충족시키지는 못하므로 다른 대안이 필요하였어요.


Streaming (Server Side Event, SSE)



클라이언트의 많은 리소스를 요구하는 지속적인 요청(HTTP Request) 때문에 부담이 되니, 클라이언트는 최초 1번만 요청을 하고 이후 변경점이 생기면 서버로 부터 계속 응답을 받으면 되지 않을까?

와 같은 방법으로 해결한 것이 Streaming 이에요.

유튜브 채널을 구독하고 채널에서 특정 이벤트(영상을 올림, 게시글을 올림)가 발생하였을때 알림을 받는 것과 유사하게 생각해볼 수 있어요.


클라이언트는 로그인 이후 채팅방에 입장시 서버에게 1번만 요청을 하고 이후 채팅방에 변경이 생기면 그에 대해 응답을 받게 되어 실시간성을 보장받을 수 있게 되죠.

하지만 클라이언트가 서버로 요청을 보낼때에는 동일하게 HTTP Request 요청을 해야하므로, SSE를 사용하게 된다면 이 방법도 클라이언트와 서버에 많은 부하를 발생시키게 되어요.


뭐가 문제야 ?


앞서 언급한 Polling, Long Polling, Streaming 방식은 모두 HTTP 방식을 사용하고 있어요.

요청이 빈번한 상황에서 HTTP를 사용하게 되면 HTTP Overhead 가 발생할 수 있어요.

HTTP Overhead는 정보의 신뢰성 판단을 위한, 보내지는 헤더 같은 정보 때문에 오히려 데이터량이나 처리시간이 증가하는걸 말해요.


예를 들어 A라는 처리를 실행한다면 3초가 걸린다고 했는데, 안전성을 고려하여 추가로 B라는 처리를 추가한 결과 처리시간이 10초가 걸렸다면 이 때 발생하는 오버헤드는 7초입니다.

반대로 7초가 걸리는 B를 개선하여 A+B를 처리한 처리시간이 5초가 되었다면, 오버헤드가 5초 단축되었다고도 해요.


WebSocket



출처 : sendbird


드디어 웹소켓입니다!

먼저 위와 같은 HTTP 프로토콜을 사용하면 HTTP Overhead 가 발생하니, 다른 프로토콜을 사용해야겠다고 생각했어요. 그리고 실시간을 보장해야하는 통신이므로 비연결성이 아닌 연결이 된 상태에서 정보를 전달하는게 좋지 않을까 생각하였습니다.


웹소켓은 실시간 보장을 어떻게 해결할 수 있었을까요 ?

WebSocket 방식은 아래의 순서로 통신을 진행해요.

  1. 클라이언트와 서버가 서로 연결을 위해 HTTP로 WebSocket HandShake 진행
    • 클라이언트 WebSocket HandShake Request
    • 서버 WebSocket HandShake Response
  2. 연결을 맺은 이후 양방향 통신 진행
    • 클라이언트측에서 서버로 메세지 전달
    • 서버는 전달받은 메세지를 다시 연결된 클라이언트에게 전송
  3. 클라이언트 혹은 서버에서 상대방에게 연결 해제 요청 연결 해제 응답으로 연결 해제 진행

WebSocket을 사용하면 클라이언트가 요청을 보낸 내용을 서버측에서 WebSocket 포트에 연결된 Session을 통해 접속한 다른 클라이언트에게 이벤트 방식으로 응답이 바로 가능해요. 여기에서는 HTTP Overhead가 발생하지 않아요. 클라이언트와 서버가 연결을 맺고 있으므로 인증을 위한 추가 정보가 필요하지 않기 때문이에요. 그래서 연결된 상태에서 정보를 주고받으며, 연결되어있으므로 서버 또한 클라이언트에게 정보를 전달하기가 수월해요.

기존의 HTTP 의 경우 비 연결성인 특징을 가지다 보니 그만큼 인증처리에 대한 정보가 필요했고 많은 요청들에 대해 부담이 갔어요. 하지만 WebSocket의 경우 클라이언트와 서버를 서로 연결시켜 쉽고 빠르게 정보를 주고 받도록 설계를 하였다고 생각합니다.

그래서 기존의 문제들인 부하, 실시간성을 모두 해결하였다고 생각해요.


그리고 최초 접속이 HTTP Request로 이루어지므로 추가 방화벽을 열지 않고도 양방향 통신이 가능한 점과 HTTP 규격인 CORS 적용이나 인증등의 과정을 기존과 동일하게 사용할 수 있는 점도 큰 장점입니다.


HTTP | WebSocket

HTTP는 매우 훌륭한 프로토콜이라고 생각해요.

실제 아래와 같이 현재 굉장히 많은 서비스들에 사용중입니다.

출처 : sendbird

다만, 채팅서비스의 요구사항을 충족시키기에는 아쉬운점이 많았고 그 대안으로 WebSocket을 사용하였습니다.


구현시 마주한 고민사항들


그래서 websocket 어떻게 사용하는거야 ?


소스코드는 아래에 있습니다.

Synergy_be - Chat


먼저 Spring Boot 에서 웹소켓을 사용하기 위해 WebSocketHandler 를 사용해야했어요.

이를 위해 websocket 의존성을 gradle에 추가해주었어요.

implementation 'org.springframework.boot:spring-boot-starter-websocket'

저는 최종적으로WebSocketHandler 인터페이스를 구현한 TextWebSocketHandler 클래스를 상속하여 ChatHandler 를 구현하였어요.

각 관계는

ChatHandler -> TextWebSocketHandler -> AbstractWebSocketHandler -> WebSocketHandler

이며 왼쪽에 있는 클래스들 ChatHandler, TextWebSocketHandler 가 각 오른쪽에 있는 클래스를 상속하였고 AbstractWebSocketHandler 클래스는 WebSocketHandler 인터페이스를 구현하였어요.


WebSocketHandler 인터페이스는 5개의 메서드를 가지고 있었어요.


이 중 저는 3개를 @Override 하여 재정의하여 사용하였습니다.

  • afterConnectionEstablished
  • handleMessage
  • afterConnectionClosed

간략하게 설명을 하자면

  • afterConnectionEstablished 는 클라이언트에서 연결 완료 후 실행되는 메서드
  • handleMessage 는 클라이언트가 서버로 메세지를 전송시 실행되는 메서드
  • afterConnectionClosed 는 클라이언트 혹은 서버가 연결 해제 후 실행되는 메서드

이에요.


각 메서드의 이름에서도 유추할 수 있듯이 쓰임세는 어느정도 정해져있었어요.

제가 집중한 곳은 handleMessage 였습니다.


클라이언트로부터 전달받은 Message 를 어떻게 데이터베이스에 저장하고 다시 접속해있는 클라이언트에게 보낼 수 있을까 고민하였어요.


WebSocketSessionMap


먼저 저는 채팅방 별로 WebSocketSession 리스트가 존재하고 그 리스트에 현재 접속하고 있는 세션을 연결시켜주어야 한다고 생각했어요.


그래서 Key를 Long 타입의 chatRoomId 를 가지고 Value로 WebSocketSession 리스트를 가지는 HashMap 컬렉션을WebSocketSessionMap 클래스 내에 정의하였어요.

HashMap으로 컬렉션을 정의한 이유는 chatRoomId를 통해 WebSocketSession 리스트를 시간복잡도 O(1) 내에 찾기 위함이에요.



HashMap의 Value의 값인 WebSocketSessionList 는 다시 아래와 같이 정의되어 있어요.



이렇게 클래스 객체로 정의한 이유는 일급 컬렉션으로 구현하기 위함이에요.

일급컬렉션과 관련된 내용은 아래를 참고하였습니다.

일급 컬렉션 (First Class Collection)의 소개와 써야할 이유 by 향로 (기억보단 기록을)


그래서 정리해보면 각 클래스간의 관계와 속성은

  • WebSocketSessionMap 은 현재 클라이언트로부터 접속된 유저들의 세션을 관리하는 클래스 객체이며 속성으로 WebSocketSessionList를 Value로 하는 HashMap을 가지고 있으며

  • WebSocketSessionList 는 속성으로 WebSocketSession 을 List로 가지는 구조입니다.


추가 개선점으로는 ConcurrentHashMap을 이해하여 Hashmap -> ConcurrentHashMap 으로 변경하는 것입니다.


handleTextMessage


그럼 위에서 정의한 WebSocketSessionMaphandleTextMessage() 에서는 어떻게 사용하였을까요 ?

먼저 실행 로직은 아래와 같아요.


    /**
     * 전체 실행 로직
     * - TextMessage에서 payLoad를 가져옴
     * - payLoad 값을 통해 objectMapper를 사용하여 ChatMessageRequest Dto 로 변환
     * - payLoad에 담긴 userId를 통해 user 검증
     * - payLoad에 담긴 chatRoomId를 통해 websocketList 탐색
     *   - 만약 없으면 생성
     * - chatType이 ENTER일 경우 session 에 add
     * - chatType이 TEXT일 경우 sendMessage(), saveMessage() 호출
     * - websocket connection을 close할 경우 해당 session을 websocket List에서 remove
     * @param WebSocketSession session
     * @param TextMessage message
     * @throws Exception
     */

위의 4번째 줄인 chatRoomId를 통해 webSocketList 탐색 부분에서 최초로 webSocketSessionMap 이 사용됩니다.

이 과정은 현재 채팅방의 세션으로 접속하기 위한 것이에요.


그리고 채팅방 세션의 존재 여부에 따라 분기처리 해주었어요.

  • 채팅방이 존재하지 않는다면 ? -> 생성

그리고 ChatType에 따라 분기처리 해주었어요.

  • ChatType이 ENTER라면 ? -> 세션에 추가
  • ChatType이 TEXT라면 ? -> 채팅전송, 채팅내용 저장

이렇게 분기처리를 한 이유는 Type에 따라 유연하게 구현하고싶었기 때문이에요. 이후 Image, Video에 대해서도 다루기 위해서 입니다.


방금 채팅내용을 저장한다고 했어요.

저장은 어떻게 진행하였는지 알아볼게요.


저장은 어떤 디비로 ?



채팅방을 나간 이후 다시 재접속을 하였을때 이전 대화내용을 불러오기 위해서는 채팅 내용을 저장해야해요.

채팅 내용을 저장하는 방법은 크게 2가지로 나뉜다고 생각해요.

  • RDB
  • NOSQL

저는 채팅방 정보(ChatRoom)는 RDB(MySQL)에 채팅 메세지(ChatMessage)는 NOSQL(MongoDB) 에 저장하였어요.


이유는

채팅방의 경우

  • 채팅에 비해 자주 생성되지 않으며
  • 사용자 엔티티와 관계를 맺고있다고 생각

그러므로 관계형 데이터베이스인 MySQL에 저장하였어요.


채팅메세지의 경우

  • 생성되는 빈도수가 높으며
  • 다른 엔티티와 여러 관계를 맺지 않으며
  • SELECT 요청시 빠른 응답속도가 필요

그러므로 NOSQL을 사용하기로 하였어요.


유저 검증은 어떻게 ?


채팅 정보까지 잘 저장이 되었어요.

그렇다면 이제 유저에 대한 검증이 필요하겠죠 ?

왜냐하면 아무 유저에 대해서 채팅을 하게 할 순 없으니 말이에요.

여기서 저는 2가지 방법을 생각해보았어요.

  • Json Web Token (JWT)
  • userId

JWT


현재 저희 서비스는 JWT를 통해 사용자 인증, 인가를 진행하고 있어요.

따라서 처음 웹소켓 연결 요청시 HTTP로 요청을 시도하니 이때 클라이언트에서 Header에 Auth 정보를 담아 보내어 인증을 진행하면 되지 않을까? 생각하고 프런트 팀원과 함께 논의를 해보았어요.

하지만 WebSocket HandShake의 Request는 제가 생각했던 일반적인 HTTP 요청과는 달랐고, 여기에 Custom Header 를 추가하기란 어려웠습니다.


관련 내용 Github Issue

Support for custom headers for handshake

위 내용은 Authorization header를 달 수 있게 해 달라는 주장vs 본인이 크롬 웹소켓 개발에 기여했다는 어떤 사람의 쿼리스트링으로 날려도 괜찮다는 주장 으로 나뉘게 되었고

header를 달 수 있게 해달라는 사람의 주장은, 결국 토큰 인증을 하려면 개발자들이 각자 socket connection 뒤에 알아서 작은 인증 시스템을 만들어 사용해야 하는데 이미 authorization header, cookie기반 토큰인증 등 정형화된 형식이 많이 있는데 왜 또 바퀴를 다시 만들어야만 하냐는 것이며

header를 달기 싫어하는 쪽은 CORS preflight을 이용해 origin을 한 번 더 체크하는 것(웹소켓에 해더를 달려면 필요한 과정)에 대한 브라우저의 비용이 connection 완료 후 메시지로 한 번 더 체크하는 것보다 크다는 입장이었어요.


secondary socket token 추가하기


슬랙 에서는 짧은 만료시간을 가진 토큰을 만들어 query param으로 인증한다고 해요.

소켓을 열 일이 있다면 완전히 또다른 두번째 토큰을 유저에게 발급하고 저장해두어요. 이 토큰은 query param으로 들어갈 것이므로 더 짧고 빨리 만료되어야 해요. STOMP를 사용하면 url path를 통해 message를 보낼텐데, 이 url path에 이 짧은 토큰을 넣는 것이에요.

다시 말해, 소켓 연결을 하기 전에 미리 기존에 사용하던 JWT로 유저 인증을 먼저 한 뒤 추가적인 30초 짜리 토큰을 만들어 소켓 연결 후 이 토큰을 통해 사용자 인증을 진행한다는 것이에요. 이렇게 되면 좋은 점은 기존에 사용하던 Login Refresh Jwt 방식을 그대로 사용할 수 있고 소켓 인증 토큰과 로그인 인증 토큰은 다르므로 외부로 노출이 되더라도 빠르게 만료가 되어 안전할 수 있어요.

인증처리 과정은

  1. Login JWT 인증받기
  2. socket 연결 요청하기
  3. url path 의 socket token 인증받기

로 이루어 지게 됩니다.


아래는 카카오 뉴스 댓글창의 예시에요.


출처 : tlatldms

userId 로 검증


채팅 메세지를 전송하기 위해 유저에 대한 검증이 필요하다고 생각이 되어 어떻게 검증할 수 있는지에 대해 위와 같이 자료조사를 해보았습니다.

하지만 WebSocket에 Custom Header를 설정하기에는 어려움이 있었고, 현재로서는 userId로도 검증이 가능하여 최종적으로 userId로 검증을 진행하였습니다.


0개의 댓글

관련 채용 정보