SNUXI 개발일지 - 1 [WebSocket을 이용한 실시간 채팅 기능 구현과 이슈들]
최근 동아리 프로젝트로 학내 택시팟 서비스를 제공하는 서버를 만들고 있다.
나의 역할은
- 로그인 이후 room 생성, 삭제, 출입 시 전체적인 CRUD 설계 및 동시성 이슈 제어
- WebSocket 기반 실시간 채팅 기능 구현
이었는데, 이 과정에서 해당 기술을 사용했던 이유, 신경써야했던 이슈들, 그리고 대처법에 대해 정리해보고 관련 배경지식까지 정리해보려고 한다.
왜 WebSocket 인가? WebSocket vs HTTP
일단 공통점은 둘다 socket 통신을 기반으로 한다는 것이다. 둘다 TCP 위에서 socket을 열고 응답을 송수신한다.
그러나 HTTP의 경우, 기본 골자가 ‘요청하면 응답’하는 단방향적인 성격이 강하다. HTTP Request message를 통해 method, header, body 등을 보내고, HTTP Response message를 받는다. 그게 끝이다.
반면 WebSocket은 HTTP와는 다른 프로토콜을 사용하며, ‘서버-클라이언트 간 실시간’ 응답 교환에 특화되어있다(우리 서비스에서는 STOMP 사용). 연결 성사를 위한 첫 handshake 이후 연결이 계속 유지되며, 매번 응답 송수신마다 연결을 해제하고 다시 맺을 필요가 없다.
추가로, HTTP 또한 연결을 유지할 수 있다. HTTP 1.0은 기본적으로 Non-persistent 즉 한번 http request를 보내면 TCP 연결을 닫아버리며, http request를 다시 보내려면 다시 TCP 연결을 수행해야한다. 이를 보완하기 위해 HTTP 1.1은 Persistent 즉 한번의 TCP 연결 위에서 지속적으로 http request와 response를 주고받을 수 있다.
또한 HTTP에서도 다음과 같은 방법으로 실시간 연결을 흉내낼 수 있긴 하다.
1) Polling : 클라이언트가 서버에게 일정 주기마다 요청을 보내며 데이터의 변경을 확인한다. 장시간 데이터 변경이 없어도 계속 요청을 전송하므로 서버 자원과 대역폭 낭비가 굉장히 심하다.
2) Long Polling : Polling 방식에서, 비효율 개선을 위해, 서버가 요청을 받고 이벤트가 발생 시 응답을 그때서야 전송하는 방식이다. 변경이 발생하면 서버로부터 즉시 응답을 받아 실시간성에 좀 더 가까워지지만, 변경이 빈번할 경우 일반 Polling과 별 차이가 없으며 이 경우 마찬가지로 자원/대역폭 낭비가 심하다.
3) Streaming(SSE, Server-Sent Events) : 변경(이벤트) 발생 마다 서버가 데이터를 클라이언트에 전송하는 방식이다. 서버 -> 클라이언트 단방향 실시간성을 지원한다.
그렇지만 실시간성 관점에서는 WebSocket이 HTTP보다 훨씬 유리하다. 왜냐하면
1) WebSocket이 보내는 데이터 양이 HTTP에 비해 훨씬 적다 : HTTP는 기본적으로 Stateless이므로, 어떤 클라이언트가 어떤 정보를 가지고 요청했는지 저장하지 않는다. 따라서, HTTP 요청시 ‘나 아까 ~~한 사람인데..’를 증명하기 위해 header에 JWT 토큰과 같은 정보들을 넣어 매 요청마다 보내게 된다. 따라서, 한번의 요청 시 보내야 하는 데이터 양이 많다. 그러나 WebSocket은 한번 연결되면 서로 연결되어있다는 것을 알기 때문에(Stateful), 이런 정보들을 헤더에 담을 필요가 없어 오버헤드가 크게 감소한다.
2) 양방향 실시간성은 WebSocket이 훨씬 간편하다 : SSE 방식으로는 서버 -> 클라이언트 실시간성만 가능하지만, 현재 우리 서비스의 경우 실시간 채팅 기능 즉 양방향 실시간성이 필요하다. HTTP로 이를 흉내내긴 할 수 있지만(Streaming으로 서버->클라 방향 확보 + 클라->서버 방향은 HTTP request 사용 등), 1번과 같은 문제 + 많은 양의 데이터 전송 시 지연 발생 가능성으로 오버헤드가 적은 WebSocket을 사용한다.
실시간 채팅 서비스 구현에서의 이슈
단순히 실시간성 구현을 위해 websocket을 붙이기만 해서는 해결되지 않는다.
현재 우리 서버에서는 DB에 메시지들을 저장하여 보여주는 구조이다. 즉 실시간으로 여러 사용자가 메시지를 전송하는 요청을 한다면, 그것들을 차곡차곡 우리 서버에 저장해야 한다는 것이다.
클라이언트에게 이 책임을 맡겨버리면, 클라이언트 간 서로가 가지는 메시지들이 모두 다른 불일치 문제가 발생하기 때문에, 결국 동시성 문제는 필연적으로 서버가 해결해야한다.
그렇다면 우리 서버에서는 어떤 역할을 해야하는가?
- WebSocket message 유효성 검증 및 올바른 구독 채널에 broadcast
- 채팅방 정원 준수
- DB를 항상 올바르게 유지
정도로 나눌 수 있을 것이다.
즉 정리하면,
- 동시성 이슈 : 여러 사용자가 정원이 얼마 없는 방에 동시에 참가할 경우
- WebSocket 구독 이슈 : 채팅방에 소속되어있지 않은 사용자가(= 구독하지 않은 사용자가) 해당 방의 채팅을 읽거나 보내는 악의적인 경우
크게 2가지의 경우를 handle해야 한다고 판단했다.
1. 동시성 이슈
채팅방의 정원이 1명이고, 여기에 3명이 동시에 참가 버튼을 눌렀다고 하자. 그렇다면 성공 인원은 반드시 1명이어야 한다. 따라서, 이를 구현하기 위해 비관적 락/낙관적 락/atomic update 방식 중 하나를 선택해야 했는데, 나는 atomic update 방식을 선택했다.
-
비관적 락을 선택하지 않은 이유 : 비관적 락은 사용자가 자원을 잡은 순간부터 쭉 락을 잡아야 한다. 그러므로 DB와의 연결 유지 비용이 클 것이라 판단했고, 락을 계속 잡고 있는 상황에서 다른 행동을 한다면 deadlock 문제또한 발생할 수 있다고 판단했다. 즉 우리 서비스에서는 채팅방에 긴 시간 머물러야 하므로 적합하지 않다고 판단했다.
-
낙관적 락을 선택하지 않은 이유 : 아까와 같은 문제들은 발생하지 않을 것이지만, 이런 상황을 생각해보자. 만약 남은 정원이 5명이고 1000명이 입장 시도를 동시에 하게 되면, 1명만 성공하고 나머지 999명은 정원이 남아있는데도 실패 응답을 받는다. 여기서 retry 로직을 만들면 해결되지만, 이는 충돌이 많은 상황에서는 서버의 자원을 낭비할 수 있다.
이러한 이유로 atomic update를 사용했다. 특히 낙관적 락에서 예시로 들었던 시나리오에서, atomic update 방식은 retry 로직 없이 정합성을 보장할 수 있기 때문에 성능적으로도 atomic update를 이용하지 않을 이유가 없다고 판단했다. Repository에 @Query를 통해 채팅방을 떠나는/참여하는 경우에 이를 도입하여 동시성 이슈를 해결했다.
2. WebSocket 구독 이슈
기본적으로, 클라이언트는 특정 채널을 구독할 수 있고, 서버는 특정 채널을 구독한 모든 클라이언트에게 메시지를 broadcast할 수 있다.
그러나 한번의 handshake 이후 연결이 성공하면, 메시지 단위에서의 검증은 이루어지지 않아서, 인증되지 않은 사용자가 채널을 구독하거나 메시지를 보내거나 읽는 행위를 할 수 있기 때문에 이를 막아야 한다.
이를 막으려면 user id, pot id 등의 정보들을 대조하는 로직이 필요하다. 일반적인 api의 경우 이러한 단계를 service 로직 단계에서 했다. 그러나 지금과 같은 경우에, 메시지를 보낼 때마다 service에서 검증 과정을 거친다면 악의적인 사용자가 연결을 맺은 상태로 계속 서버의 자원을 사용하게 된다.
또한 service 로직에서는 메시지 송수신과 같은 비즈니스 로직에 집중해야 한다고 판단, websocket의 인증 로직과 service 로직을 분리해야 한다고 판단했다.
WebSocket의 경우, ChannelInterceptor라는 interface가 존재하여, 해당 interface를 상속받은 후 interceptor를 구현한 후 내부의 preSend 함수를 override 하면 메시지가 채널로 전송되기 전 override한 preSend 함수의 로직을 수행하여 검사하게 된다.
이러한 방법이 설계/운영/성능 관점에서 훨씬 적합하기 때문에 이러한 방법을 채택했다.
또한 구독(SUBSCRIBE)과 전송(SEND)만 인증 로직을 거치면 된다고 판단했다. 이 둘을 제외하면, 핵심적으로 발생할 수 있는 이벤트 종류는 구독취소와 같은 취소 이벤트, 연결 시도 이벤트가 있을 것이다.
구독을 취소하는 등의 연결 삭제 로직은 채널에 메시지를 뿌리거나 그렇지 않으므로 따로 방어할 필요가 없다고 판단했고, 연결 시도 이벤트의 경우 아직 어떠한 채널도 구독하지 않았고 어떠한 데이터도 보내거나 볼 수 없는 상태이므로 보안적으로 피해를 줄 수 없다고 판단했다.
구독 취소(여기에 더해 ACK 등 응답 확인의 경우) 등의 이벤트의 경우 무엇을 어떻게 검사해야하는지 명확하지 않고, 모든 종류의 요청마다 DB 조회를 통해 보안 검사를 수행하게 되면 성능이 저하될 수도 있다.
따라서 반드시 필요한 부분인 구독/전송 부분에 한해서만 인증 로직을 구현함을 통해 보안 이슈를 사전에 방지하였다.
이러한 이슈들을 처리해보며 꺠달은 점은, 실시간성 구현은 어떤 기술을 사용하느냐의 문제가 아니라 어떻게 서버를 설계하느냐에 따라 달렸다는 것이다. 단순히 websocket을 사용한다고 해서 모든 문제가 만사 ok로 해결되는 것은 아니다.
또한, 채팅 서비스 이슈를 handle하며 서버의 성능과 정합성 사이의 tradeoff를 정의한 후, 서버의 책임을 명확히 분리하는 것이 서비스 설계에 핵심적인 부분이라고 느꼈다. 즉 서버 쪽에서 상태를 검증하고 일관성을 유지해주어야 한다는 것을 체감했다.
개념적으로 공부할때보다 직접 구현에 뛰어들어보니 얻는게 확실히 많은 것 같다. 앞으로 이러한 탐구 자세(?)로 적극적으로, 효율적이며 신뢰성있는 서비스 만들기에 힘써야겠다.