"가상 면접 사례로 배우는 대규모 시스템 설계 기초"를 읽고 정리한 글입니다 :)
채팅 시스템을 설계하기 위해 고려해야할 요구사항은 다음과 같다.
채팅 시스템 서버의 기본적인 기능은 다음과 같을 것이다.
이 기능을 지원하기 위해 채팅을 시작하려는 클라이언트는 서버에 네트워크 통신 프로토콜을 사용하여 요청을 보낼 것이다. 어떠한 통신 프로토콜을 사용하는지도 중요한 문제인데, 책에서는 널리 사용되는 HTTP 프로토콜을 사용하여 설계를 진행하였다.
채팅 서비스에서 HTTP 프로토콜을 사용할 때는 Keep-Alive 헤더를 사용하면 효율적이다. 클라이언트와 서버 사이의 연결을 끊지 않고 일정 기간 유지하기에, TCP 접속 과정에서 발생하는 핸드 셰이크 횟수를 줄일 수 있기 때문이다.
HTTP 프로토콜은 메시지 전송 용도로는 괜찮지만, 메시지 수신의 경우 이것보다 더 복잡하다. HTTP는 클라이언트가 연결을 만드는 프로토콜이기에, 서버에서 클라이언트에게 임의 시점에 메시지를 보내는 데는 제약이 있다. 그렇기에 서버가 연결을 만드는 것처럼 동작할 수 있도록 여러 기법들이 제안됐는데, 대표적으로 폴링(Polling), 롱 폴링(Long Polling), 웹소켓(WebSocket)이 그 예시이다.
폴링은 클라이언트가 주기적으로 서버에게 요청을 보내 상태를 동기화하는 기법이다. 당연히 주기적으로 요청을 보내니, 폴링을 자주하면 할수록 비용이 올라간다. 그렇다고 폴링의 주기를 길게할 경우, 실시간성에 크게 위배된다. 또한 동기화할 필요가 없는 경우 서버 자원이 불필요하게 낭비된다.
폴링이 여러가지 면에서 비효율적이라 고안된 방식이 롱 폴링이다. 롱 폴링의 경우 클라이언트는 새 메시지가 반환되거나 타임아웃될 때까지 연결을 유지한다. 클라이언트가 새 메시지를 받으면 기존 연결을 종료하고 서버에 새로운 요청을 보내어 모든 절차를 다시 시작한다.
이 방식에는 몇가지 단점이 있다.
웹 소켓은 서버가 클라이언트에게 비동기 메시지를 보낼 때 가장 널리 사용하는 기술이다.
웹 소켓 연결은 클라이언트가 시작하며, 처음에는 HTTP 연결로 시작하지만 특정 핸드셰이크 절차를 거친 뒤 웹소켓 연결로 업그레이드된다. 이렇게 한번 연결된 연결은 영구적이며, 양방향 통신이 가능하다. 또한 웹 소켓은 HTTP 프로토콜이 사용하는 기본 포트번호를 그대로 사용하기에, 방화벽이 있는 환경에서도 일반적으로 잘 동작한다.
웹 소켓의 경우 양방향 통신이 가능하기에, 메시지를 수신하는 데서만 사용하는 것이 아닌 메시지를 전송하는데서도 사용할 수 있다. 이는 메시지를 보낼 때나 받을 때나 같은 프로토콜을 사용할 수 있게 되므로, 구현이 단순해지고 직관적이게 된다.
앞에서 설명한 것처럼 웹 소켓은 메시지 전송과 수신에 있어 큰 장점을 가진다. 그렇기에, 클라이언트와 서버 사이의 주 통신 프로토콜로 웹 소켓을 사용하도록 설계하려 한다. 다만, 다른 대부분의 기능(회원가입, 로그인 등)에 대해서는 굳이 웹 소켓을 사용할 필요는 없기에 일반적인 HTTP 프로토콜을 사용해도 좋다.
책에서는 채팅 시스템을 무상태 서비스, 상태유지 서비스, 제 3자 서비스로 나누어 설계하였다.
무상태 서비스는 로그인, 회원가입 등을 처리하는 전통적인 요청/응답 서비스이다. 무상태 서비스가 제공하는 기능들은 많은 웹사이트와 앱이 보편적으로 제공하는 기능이다.
무상태 서비스는 로드밸런서 뒤에 위치하여, 로드밸런서에 의해 적절한 요청을 할당받는다. 이 책에서는 무상태 서비스들 중 '서비스 탐색 서비스(Service Discovery Service)'를 강조하였다. 서비스 탐색이란 서로 다른 서비스들의 IP와 포트에 대한 정보를 저장하고 관리, 제공하는 것을 의미한다. 이번 설계에서는 클라이언트가 접속할 채팅 서버의 주소를 클라이언트에게 알려주는 역할을 한다.
현재 설계에서는 유일하게 상태 유지가 필요한 서비스는 채팅 서비스이다. 각 클라이언트가 채팅 서버와 독립적인 네트워크 연결을 유지해야 하기 때문이다.
채팅 앱에서 가장 중요한 제 3자 서비스는 푸시 알림이다. 새 메시지를 받았다면, 설사 앱이 실행중이지 않더라도 알림을 받아야 한다. 따라서 푸시 알림 서비스와의 통합은 중요하며, 이에 대한 설명은 이전에 작성한 알림 시스템 설계를 참조하면 된다.
우리는 대규모 시스템을 설계하는 것이기 때문에 서버 한대로 모든 기능을 처리할 수 없다. 서버의 처리량 문제도 있고, SPOF(Single-Point-Of-Failure) 문제도 그 이유일 것이다. 그렇기에 설계를 할 때는 규모 확장성에 대해서도 고민을 해두어야 한다.
채팅 시스템을 설계할 때, 어떤 데이터베이스를 사용하느냐는 중요한 문제이다. 이때 따져보아야할 것은 데이터의 유형과 읽기/쓰기 연산의 패턴이다.
채팅 시스템이 다루는 데이터는 보통 두 가지이다. 첫 번째는 사용자 프로필, 설정, 친구 목록처럼 일반적인 데이터이다. 이러한 데이터들은 안정성을 보장하는 관계형 DB에 저장한다. 여기서 다중화(Replication)과 샤딩(Sharding)은 이런 데이터의 가용성과 규모확장성을 보증하기 위해 보편적으로 사용되는 기술이다.
두 번째 유형의 데이터는 채팅 시스템에 고유한 데이터로, 채팅 이력(Chat History)이다. 이 데이터를 어떻게 보관할지 결정하려면 읽기/쓰기 연산 패턴을 이해해야 한다.
이 모두를 지원할 DB를 고르는 것이 저장소 설계의 핵심이다. 책에서는 키-값 저장소를 추천하는데, 그 이유는 다음과 같다.
책에서는 서비스 탐색(Service Discovery), 메시지 전달 흐름, 사용자 접속 상태를 표시하는 방법 3가지에 대해 추가로 상세하게 설계를 하였다.
서비스 탐색(Service Discovery) 기능의 주된 역할은 클라이언트에게 가장 적합한 채팅 서버를 추천하는 것이다. 이때 사용되는 기준으로는 클라이언트의 위치(Geographicalocation), 서버의 용량(Capacity) 등이 있다. 서비스 탐색 기능을 구현하는데 널리 쓰이는 오픈소스 솔루션으로는 아파치 주키퍼가 있다.
사용가능한 모든 채팅 서버를 서비스 탐색 서비스에 등록시켜 두고, 클라이언트가 접속을 시도하면 사전에 정한 기준에 따라 최적의 채팅 서버를 골라준다. 클라이언트는 해당 채팅 서버와 웹소켓 연결을 맺고, 채팅을 시작할 수 있다.
채팅 시스템에 있어 종단 간(End-to-End) 메시지 흐름을 이해하는 것은 중요하다. 메시지 흐름은 여러가지 경우가 있지만, 책에서는 1대1 채팅 메시지의 처리 흐름과 여러 단말 간 메시지 동기화 과정, 그룹 채팅 메시지의 처리 흐름을 설명한다.
1대1 채팅 메시지 처리 흐름
사용자 A가 사용자 B에게 메시지를 보낸다고 가정해보자. 사용자 A가 채팅서버 1로 메시지를 전송하면, 채팅 서버는 해당 메시지를 데이터베이스에 저장함과 동시에 메시지 큐에 해당 메시지 정보를 전송한다. 이후, 사용자 B가 접속 중이라면 사용자 B가 접속 중인 채팅서버를 통해 웹소켓으로 메시지를 전송한다. 만약 사용자 B가 접속 중이 아니라면, 푸시 알림 서버를 통해 알림을 보낸다.
여러 단말 사이의 메시지 동기화
만약 한 사람이 여러 개의 단말을 사용할 경우, 단말들에 대해 메시지를 동기화해줄 필요가 있다. 동기화 방법에 대해서는 여러 방안이 있겠지만, 책에서는 cur_max_message_id
라는 변수를 통해 이를 해결한다. 해당 변수는 각 단말마다 별도로 가지고 있으며, 해당 단말에서 관측된 가장 최신 메시지의 ID를 추적하는 용도이다. 해당 변수값보다 이후에 만들어진 ID를 가진 메시지는 새 메시지로 간주하고 가져오면 된다.
소규모 그룹 채팅에서의 메시지 흐름
책에서 설계한 소규모 그룹 채팅에서의 메시지 흐름은 다음과 같다. 한 그룹 채팅방의 사용자 A, B, C 총 3명이 있다고 가정하자. 이 경우, 각 사용자가 자신의 메시지 동기화 큐(Message Sync Queue)를 가진다. 그리고 사용자 A가 메시지를 보낼 경우, 사용자 B와 사용자 C의 메시지 동기화 큐에 메시지가 들어가게 된다. 이 설계안의 경우 소규모 그룹 채팅에 적합하다. 그 이유는 다음과 같다.
위챗(WeChat)이 이런 접근법을 쓰고 있으며, 그룹의 크기는 500명으로 제한하고 있다. 하지만 이와 달리 많은 사용자를 지원해야 하는 경우라면, 이 방식이 적합하지 않을 것이다.
사용자의 접속 상태를 표시하는 채팅 서비스들이 많다. 일반적으로 접속 상태 표시를 구현하는 방법은 다음과 같다.
사용자가 로그인(접속)하게 되고 웹소켓 연결이 맺어지게 되면, 접속상태 서버가 사용자의 상태(ex. online)와 액티브된 타임스탬프를 디비에 저장한다. 이 과정이 이루어지면, 해당 사용자는 접속 중인 것으로 표시된다.
이후 로그아웃이 이루어지면(웹소켓 연결이 끊어지면), 디비에 저장된 사용자 상태를 offline으로 바꾼다. 이 절차가 끝나면 해당 사용자는 접속 중이지 않은 것으로 표시된다.
이상적인 상황이라면 위 플로우만 있으면 될 것이다. 하지만 네트워크 환경에서는 언제나 단절이 이루어질 수 있기에 그런 상황에 대응할 수 있어야 한다. 이런 장애에 대응하는 가장 간단한 방법은 웹소켓 연결이 끊어지면 사용자를 오프라인 상태로 표시하고, 연결이 복구되면 온라인 상태로 변경하는 것이다. 하지만 만약 자동차를 타고 터널을 지나가는 상황처럼 짧은 시간 연결이 끊어지는 경우에 매번 이런식으로 동작하면 UX 면에서 좋지 않다.
그래서 책에서는 Heartbeat 검사를 통해 이 문제를 해결한다. 이는 온라인 상태의 클라이언트로 하여금 주기적으로 박동 이벤트(Heartbeat Event)를 보내도록 하고, 마지막 이벤트를 받은지 x초 이내에 또 다른 박동 이벤트를 받으면 해당 사용자의 접속 상태를 계속 온라인으로 유지하는 것이다. 이 방법을 사용하면 짧은 시간의 네트워크 단절에서는 사용자의 상태가 오프라인으로 바뀌지 않도록 할 수 있다.
상태 정보의 전송
특정 사용자의 상태는 위에서 설명한 것처럼 설정할 수 있다. 하지만 더 중요한 것이 있는데, 특정 사용자의 상태 변경을 해당 사용자에게 관심이 있는 다른 사용자에게 알리는 것이다. 일반적으로 발행-구독 모델(Publish-Subscribe Model)을 사용하여 이 문제를 해결한다. 쉽게 이야기하면 사용자 A의 상태가 변경(발행)되면 사용자 A를 구독하고 있는 다른 사용자(구독)에게 이벤트를 전파하는 것이다.
해당 방식은 웹소켓을 사용하여 실시간으로 통신하는데, 다만 그룹의 크기가 클 경우 비용이 많이 들 수 있다. 이런 성능 문제를 해결하기 위해, 사용자가 그룹 채팅에 입장하는 순간에만 상태 정보를 업데이트하게 하는 방법이 있을 수 있다.
분산 환경에서 실시간 메시징 서비스를 구현하는 것은 생각보다 어려운 일이다. 위에서 설계한 내용 말고도, 사진이나 비디오, 파일과 같은 대용량의 데이터를 처리하는 방법과 종단간 암호화(End-to-End Encryption), 캐시, 로딩 속도 개선, 오류 처리 등 생각해야할 내용이 무척이나 많다. 한번에 설계하는 것은 당연히 힘들겠지만, 차근차근 개선해나가면 좋을 것이다.