1단계 문제 이해 및 설계 범위 확정
위처럼 시장에서 널리 사용되는 채팅 앱은 위챗이나 왓츠앱처럼 1:1 채팅에 집중하는 앱이나 슬랙과 같이 그룹 채팅에 중점을 두는 앱, 디스코드처럼 낮은 응답지연과 그룹 소통, 음성 채팅에 집중하는 앱 등 그 종류가 다양하다. 따라서 면접에서 질문을 통해 어떤 채팅 앱을 설계하려하는지 명확히 해두는 것이 가장 중요하다.
지원자: 어떤 앱을 설계해야 하나요? 1:1 채팅 앱인가요 아니면 그룹 채팅 앱일까요?
면접관: 둘 다 지원할 수 있어야 합니다.
지원자: 모바일 앱인가요 아니면 웹 앱인가요?
면접관: 둘 다 입니다.
지원자: 처리해야 하는 트래픽 규모는 어느 정도인가요?
면접관: 일병 능동 사용자수(DAU) 기준으로 5천만 명을 처리할 수 있어야 합니다.
지원자: 그룹 채팅의 경우에 인원 제한이 있습니까?
면접관: 최대 100명까지 참가할 수 있습니다.
지원자: 중요 기능으로는 어떤 것이 있을까요? 가령, 첨부파일도 지원할 수 있어야 할까요?
면접관: 1:1 채팅, 그룹 채팅, 사용자 접속상태 표시를 지원해야 합니다. 텍스트 메세지만 주고받을 수 있습니다.
지원자: 메세지 길이에 제한이 있나요?
면접관: 네. 100,000자 이하여야 합니다.
지원자: 종단 간 암호화(end-to-end encryption)를 지원해야 하나요?
면접관: 현재로서는 필요 없습니다만 시간이 허락하면 논의해볼 수 있겠습니다.
지원자: 채팅 이력은 얼마나 오래 보관해야 할까요?
면접관: 영원히요.
Plain Text
복사
위 질문을 통해 우리가 설계하는 앱은 다음과 같은 기능을 갖는다.
•
응답 지연이 낮은 일대일 채팅 기능
•
최대 100명까지 참여할 수 있는 그룹 채팅 기능
•
사용자 접속상태 표시 기능
•
다양한 단말 지원, 하나의 계정으로 여러 단말에 동시 접속 지원
•
푸시 알림
2단계 개략적 설계안 제시 및 동의 구하기
이 문제에 앞서 클라이언트와 서버의 통신 방법에 대해 자세히 알아보자. 채팅 시스템의 경우 클라이언트는 모바일이나 웹 애플리케이션이다. 클라이언트끼리는 직접 통신하지 않고, 각 클라이언트는 위 모든 기능을 지원하는 채팅 서비스와 통신한다.
따라서 채팅 서비스는 다음과 같은 기능을 지원해야 한다.
•
클라이언트들로부터 메세지 수신
•
메세지 수신자(recipient) 결정 및 전달
•
수신자가 접속(online) 상태가 아닌 경우에는 접속할 때까지 해당 메세지 보관
채팅을 시작하려는 클라이언트는 네트워크 통신 프로토콜을 사용해 서비스에 접속한다. 따라서 어떤 통신 프로토콜을 사용할 것인과도 중요한 문제이므로 면접관과 상의하자.
대부분 클라이언트/서버 애플리케이션에서 요청을 보내는 것은 클라이언트인데, 채팅 시스템에서도 마찬가지이다. 메세지 송신 클라이언트(sender)가 그 역할을 하고, 수신 클라이언트에 전달할 메세지를 채팅 서비스에 보낸다. 그 과정에서 HTTP 프로토콜을 사용하는데, keep-alive 헤더를 사용하여 연결을 끊지 않고 유지하여 메세지를 전송하는 것이 효율적이다. 페이스북과 같은 대중적인 채팅 프로그램이 초기에 HTTP를 사용했다.
그러나 메세지 수신 시나리오는 이보다 복잡하다. HTTP는 클라이언트가 연결을 만드는 프로토콜로, 서버에서 클라이언트로 임의 시점에 메세지를 보내는데는 쉽게 사용될 수 없다. 서버가 연결을 만드는 것처럼 보이기 위해서 많은 기법들이 제안되어 왔는데, 폴링(polling), 롱 폴링(long polling), 웹소켓 등이 있다.
폴링
폴링은 클라이언트가 주시적으로 서버에 새 메세지가 있냐고 물어보는 방법이다. 폴링 주기를 짧게하여 자주 폴링 할수록 폴링 비용이 증가한다. 메세지가 없는 경우에도 서버 자원이 불필요하게 낭비된다는 문제도 있다.
롱 폴링
롱 폴링은 폴링의 비효율적이라는 단점 때문에 나온 기법으로, 클라이언트가 새 메세지가 반환되거나 타임아웃 될 때까지 연결을 유지한다. 클라이언트는 새 메세지를 받으면 기존 연결을 종료하고 서버에 새로운 요청을 보내 모든 절차를 다시 시작한다.
하지만 이 방법에는 메세지를 보내는 클라이언트와 수신하는 클라이언트가 같은 채팅 서버에 접속하게 되지 않을 수 있다는 단점이 있다. HTTP 서버는 일반적으로 무상태(stateless)로 유지하는데, 로드밸런싱에 따라 메세지를 받은 서버가 메세지를 수신할 클라이언트와 롱 폴링 연결을 가지고 있지 않은 서버일 수 있다. 또한 서버 입장에서는 연결을 해제했는지 아닌지 알 방법이 없고, 클라이언트들이 타임아웃이 날때마다 주기적으로 다시 접속하니 여전히 비효율적이다.
웹소켓
웹소켓은 서버가 클라이언트에 비동기로 메세지를 보낼 때 가장 널리 사용하는 기술이다. 클라이언트에서 연결을 시작하여, 한 번 연결되면 항구적으로 양방향으로 통신할 수 있다. 첫 연결 시 HTTP 연결을 통해 수행하지만, 특정 핸드셰이크 절차를 거쳐 웹소켓 연결로 업그레이드 된다. HTTP 프로토콜이 사용하는 80이나 443 포트를 그대로 사용하기 때문에 방화벽이 있는 환경에서도 일반적으로 잘 동작한다.
웹소켓은 양방향 메세지 전송이 가능하기 때문에, 채팅 시스템에서 웹소켓 대신 HTTP를 고집할 이유는 없다.
웹소켓을 이용하면 메세지를 보내거나 받을 때 동일한 프로토콜을 사용할 수 있어 설계와 구현이 단순하고 직관적이다. 유의할 점은 웹소켓 연결은 항구적으로 유지되어야하기 때문에 서버 측에서 연결 관리를 효율적으로 수행해야한다는 것이다.
개략적 설계안
우리는 채팅 서비스에서 통신 프로토콜로 웹소켓을 사용하기로 결정했지만, 채팅이 아닌 회원가입이나 로그인, 사용자 프로필 등 다른 기능에서는 굳이 웹소켓을 사용할 필요는 없다. 이를 통한 개략적인 설계안으로는 채팅 시스템을 무상태 서비스, 상태유지 서비스, 제3자 서비스 연동의 세 부분으로 나누어 볼 수 있을 것이다.
무상태 서비스
이 설계안의 무상태 서비스는 로그인, 회원가입, 사용자 프로필 표시 등을 처리하는 전통적인 요청/응답 서비스이다.
무상태 서비스는 로드밸런서 뒤에 위치하며, 모놀리식(monolithic) 서비스일수도 있고 마이크로서비스일수도 있다. 이러한 서비스들 상당수는 시장에 완제품으로 나와있기 때문에 직접 구현하지 않아도 쉽게 쓸 수 있다.
상태 유지 서비스
위 설계안에서 채팅 서비스는 각 클라이언트가 채팅 서버와 독립적인 네트워크 연결을 유지해야하기 때문에 유일하게 상태 유지가 필요한 서비스이다. 서버가 살아있는 한, 클라이언트는 보통 다른 서버로 연결을 변경하지 않는다. 여기서 우리는 서비스 탐색(Service Discovery) 서비스를 활용하여 특정 서버에 부하가 몰리는 것을 방지할 수 있다.
제3자 서비스 연동
새 메세지를 받는다면 앱이 실행 중이지 않더라도 알림을 받아야하기 때문에, 채팅 앱에서 가장 중요한 제3자 서비스는 푸시 알림이다. 따라서 푸시 알림 서비스와의 통합은 아주 중요하다.
규모 확장성
우리가 설계해야하는 시스템은 대규모 트래픽을 처리해야하니, 서버 한 대로 얼마나 많은 접속을 동시에 허용할 수 있는지 따져봐야한다. 이번 시스템 설계에서는 동시 접속자를 1M으로 가정할건데, 접속당 10KB의 서버 메모리가 필요하다고 본다면, 10GB 메모리가 있을 때 모든 연결을 다 처리할 수 있을 것이다.
하지만 SPOF의 이유도 있고 이 정도 규모의 트래픽을 서버 한 대로 처리하는 것을 바람직하지 않다. 이것을 그냥 시작일 뿐을 면접관에게 고지하고, 서버 한 대로 처리하는 설계안에서 출발하여 점차 다듬어 나가는 것은 괜찮다.
위에서 언급한 내용들을 통해 개략적인 설계안을 정리하면 이와 같다.
•
채팅 서버는 클라이언트 사이에서 메세지를 중계하는 역할
•
접속 상태 서버(presence server)는 사용자의 접속 여부 관리
•
API 서버는 로그인, 회원가입, 프로필 변경 등 그 외 나머지를 처리
•
알림 서버는 푸시 알림을 전송
•
키-값 저장소를 통해 채팅 이력을 보관
저장소
서버 준비와 제3자 서비스 연동이 끝났다고 하면, 데이터 계층을 올바르게 만드는데 노력을 들여야한다. 데이터의 유형과 읽기/쓰기 연산의 패턴을 따져, RDBMS와 NoSQL 중 어떤 것을 채택할지 결정할 필요가 있다.
채팅 시스템이 다루는 데이터는 두 가지 유형으로 나뉜다.
첫 번째는 사용자 프로필, 설정, 친구 목록처럼 일반적인 데이터로, 안정성을 보장하는 RDBMS에 저장하고 가용성과 규모확장성을 보장하기 위해 다중화나 샤딩을 적용하기도 한다.
두 번째 데이터 유형은 채팅 이력이다. 채팅 이력을 어떻게 저장할지 결정하려면 읽기/쓰기 연산의 패턴을 이해할 필요가 있다.
•
채팅 이력의 데이터는 엄청난 양을 저장해야한다. 페이스북이나 왓츠앱은 매일 600억 개의 메세지를 처리한다.
•
이 데이터 중 빈번하게 사용되는 것은 주로 최근에 주고받은 메세지이다. 대부분 사용자는 오래된 메세지는 잘 확인하지 않는다.
•
사용자는 대체로 최근에 주고받은 데이터를 보지만, 검색 기능이나 특정 사용자가 언급(mention)한 메세지를 보거나, 특정 메세지로 점프하는 등 무작위적인 데이터 접근이 이뤄지기도 한다.
•
1:1 채팅 앱의 경우 읽기:쓰기 비율은 대략 1:1 정도이다.
이러한 특성들을 고려해 데이터베이스를 고르는 것은 중요한 일로, 이번 설계에서는 다음과 같은 이유로 키-값 저장소를 사용할 것이다.
•
키-값 저장소는 수평적 규모확장이 쉽다.
•
키-값 저장소는 데이터 접근 지연시간(latency)이 낮다.
•
관계형 데이터베이스는 데이터의 롱 테일(long tail)에 해당하는 부분을 잘 처리하지 못하는 경향이 있다. 인덱스가 커지면 무작위 접근의 처리 비용이 높아진다.
•
HBase를 사용하는 페이스북이나 카산드라를 사용하는 디스코드처럼, 많은 안정적인 채팅 시스템이 키-값 저장소를 채택하고 있다.
롱 테일(long tail)
파레토 법칙(80/20 법칙)에서 나오는 개념으로, 접근 빈도가 낮지만 전체 데이터의 대부분을 차지하는 데이터
예시) 온라인 스토어
•
20%의 핫 아이템 - 전체 조회의 80% 차지
•
80%의 틈새 아이템 - 전체 조회의 20% 차지 ← 롱테일
RDBMS가 롱테일을 잘 처리하지 못하는 이유
RDBMS는 데이터를 저장하는 구조와 옵티마이저의 특성 때문에 롱 테일을 잘 처리하지 못한다.
•
핫 데이터와 콜드 데이터가 디스크의 동일한 페이지에 섞여 저장됨
•
인덱스 설정 시 롱 테일 데이터도 같이 포함되어 비효율적
•
옵티마이저가 쿼리 최적화 시 통계 정보를 기반으로 하기 때문에, 롱 테일 분포에 대해 과대/과소 평가가 자주 발생
•
롱 테일 데이터는 버퍼 캐시에 올라와도 LRU 정책에 의해 금방 제거되어 매번 데이터를 새로 꺼낼 확률이 높음
데이터 모델
1:1 채팅을 위한 메세지 테이블
1:1 채팅을 위한 메세지 테이블의 경우, 위처럼 bigint 타입의 message_id를 PK로 사용하여 메세지 순서를 쉽게 정할 수 있도록 하는 역할도 담당한다. 서로 다른 메세지가 동시에 만들어지는 경우도 있기 때문에, created_at을 사용해 순서를 정할 수 없는 경우도 있기 때문이다.
그룹 채팅을 위한 메세지 테이블
그룹 채팅의 경우 (channel_id, message_id)의 복합 키(composite key)를 PK로 사용한다. 여기서 채널(channel)은 채팅 그룹과 동일하다.
그룹 채팅에서의 검색 기능은 특정 채널을 대상으로 하기 때문에, channel_id는 파티션 키(partition key)로도 사용한다.
메세지 ID
message_id는 메세지들의 순서를 표현할 수 있어야한다. 그러기 위해서는 다음과 같은 속성을 만족해야 할 것이다.
•
message_id의 값은 고유해야한다.
•
ID 값은 정렬 가능해야하며, 시간 순서와 일치해야한다. 다시말해, 새로운 ID는 이전 ID보다 큰 값이어야 한다.
첫 번째 방법으로 RDBMS는 auto_increment로 위 조건을 만족할 수 있지만, NoSQL은 보통 해당 기능을 제공하지 않는다.
두 번째 방법은 스노플레이크 같은 전역적 64-bit 순서번호 생성기를 이용하는 것이다.
마지막 방법은 지역적 순서 번호 생성기를 이용하는 것이다. 지역적이라 하는 이유는 ID의 유일성을 같은 그룹 안에서만 보증하는 것이다. 메세지 순서는 같은 채팅 세션에서만 유지되면 되기 때문에, 전역적 ID 생성기에 비해 구현하기 쉽기 때문에 좋은 접근 방법이다.
3단계 상세 설계
채팅 시스템의 서비스 탐색(service discovery), 메세지 전달 흐름, 사용자 접속 상태 표시하는 방법에 대해 조금 더 상세하계 설계해보자.
서비스 탐색
서비스 탐색의 주된 역할은 클라이언트에게 가장 적합한 채팅 서버를 추천하는 것인데, 그 기준으로 클라이언트의 지리적 위치, 서버 용량 등이 있다. 서비스 탐색은 사용 가능한 모든 채팅 서버를 등록 시켜두고, 클라이언트에서 접속을 시도하면 정해진 기준에 따라 최적의 채팅 서버를 골라주는 것이다.
이러한 서비스 탐색 기능은 오픈 소스 솔루션인 아파치 주키퍼(Apache Zookeeper)가 널리 사용되고 있다.
주키퍼로 구현한 서비스 탐색 기능의 동작 흐름을 살펴보자.
1.
사용자 A가 시스템에 로그인 시도
2.
로드밸런서가 로그인 요청을 API 서버들 중 하나로 전달
3.
API 서버가 사용자 인증 처리 후 서비스 탐색 기능 호출
4.
서비스 탐색 기능을 통해 최적의 채팅 서버 찾기(채팅 서버2)
5.
채팅 서버2와 웹소켓 연결
메세지 흐름
1:1 채팅 메세지의 처리 흐름과 여러 단말 간의 메세지 동기화 과정을 살펴보고, 그룹 채팅 메세지의 처리 흐름도 알아보자.
1:1 채팅 메세지 처리 흐름
위 그림은 1:1 채팅에서 사용자 A가 사용자 B에게 보낸 메세지가 어떤 경로로 처리되는 지를 나타낸다.
1.
사용자 A가 채팅 서버 1로 메세지 전송
2.
채팅 서버 1에서 ID 생성기를 사용해 메세지 ID 결정
3.
메세지 동기화 큐에 해당 메세지를 전송
4.
키-값 저장소에 메세지 보관
5.
메세지 전송
a.
사용자 B가 접속 중이라면, 사용자 B가 접속 중인 채팅 서버 2로 메세지 전송
b.
사용자 B가 비접속 중이라면, 푸시 알림 서버로 푸시 알림 메세지를 보냄
여러 단말 사이의 메세지 동기화
여러 대의 단말을 사용하는 사람이 많은데, 이러한 여러 단말 사이의 메세지 동기화는 어떻게 처리되는지 알아보자.
사용자 A는 전화기와 랩톱 두 대의 단말을 사용 중이고, 전화기에서 채팅 앱에 로그인하여 채팅 서버 1과 웹소켓이 연결되어 있는 상황이다. 또한 랩톱에서도 로그인하여 채팅 1에 연결되어있다.
각 단말은 cur_max_message_id라는 변수를 사용하여 해당 단말에서 관측된 가장 최신의 메세지 ID를 추적한다. 이를 통해 아래 두 조건을 만족하는 메세지를 새 메세지로 간주하여 처리한다.
•
수신자 ID가 현재 로그인한 사용자 ID와 같다.
•
키-값 저장소에 보관된 메세지의 그 메세지 ID가 cur_max_message_id보다 크다.
소규모 그룹 채팅에서의 메세지 흐름
그룹 채팅의 메세지 흐름은 1:1 채팅보다 더 복잡하다.
위 그림은 사용자 A가 사용자 A, B, C가 참여 중인 그룹 채팅 방에서 메세지를 보냈을 때의 처리 흐름이다. 우선 사용자 A가 보낸 메세지가 사용자 B와 C의 메세지 동기화 큐에 복사한다. 이 큐는 각 사용자별로 할당된 메세지 수신함과 비슷하게 처리되는데, 이런 설계는 다음과 같은 이유로 소규모 채팅 그룹에 적합하다.
•
새로운 메세지가 있는지 확인하기 위해서 자기 메세지 큐만 확인하면 되니 메세지 동기화 플로우가 단순하다.
•
그룹이 크지 않으면 메세지를 수신자별로 복사해서 큐에 넣는 작업 비용이 문제되지 않는다.
위챗이 위 설계 방식을 사용하고 있으며, 그룹의 크기는 500명으로 제한하고 있다. 하지만 많은 사용자를 지원해야한다면 이런 방식은 적합하지 않을 수 있다.
수신자 관점에서 메세지는 위처럼 여러 사용자로부터 오는 메세지를 수신할 수 있어야한다.
접속상태 표시
채팅 애플리케이션을 사용하다 보면 프로필 이미지나 대화명 옆에 녹색 불이 붙어있는데, 이렇게 사용자가 접속 중인 상태를 표시하는 것은 채팅 애플리케이션의 핵심 기능이다.
이런 접속상태를 표시하기 위해서 개략적 설계안에서는 접속상태 서버(presense server)를 두어 관리하도록 하였다. 접속 상태 서버는 클라이언트와 웹소켓으로 통신하는 실시간 서비스의 일부인데, 아래의 사용자의 상태가 바뀌는 시나리오에 따라 동작한다.
사용자 로그인
클라이언트와 실시간 서비스 사이에 웹소켓 연결이 맺어지고 나면, 접속상태 서버는 A의 상태와 last_active_at 타임스탬프를 키-값 저장소에 보관하고, 이후 해당 사용자는 접속 중으로 표시될 것이다.
로그아웃
로그아웃 시 키-값 저장소에 보관된 사용자 상태가 online에서 offline으로 바뀌는데, 이 절차가 끝나고 나면 해당 사용자는 UI상으로 접속 중이 아닌 것으로 표시될 것이다.
접속 장애
인터넷 연결이 안정적이지 못한 환경에서 접속하는 경우를 대비한 설계가 필요하다. 사용자의 인터넷 연결이 끊어지면, 클라이언트와 서버 사이의 웹소켓 연결도 끊어진다. 이런 경우 사용자 상태를 오프라인으로 바꾸었다가, 연결이 복구되면 온라인으로 표시하면 간단하게 해결될 것처럼 보인다. 하지만 짧은 시간 인터넷 연결이 끊어졌다가 복구되는 일은 흔하다. 이런 상황이 발생할 때마다 접속 상태가 바뀐다면 사용자 경험 측면에서도 바람직하지 않다.
박동(heartbeat) 검사를 통해 이 문제를 해결할 수 있다. 온라인 상태의 클라이언트에 주기적으로 박동 이벤트(heartbeat event)를 접속상태 서버로 보내도록하고, 마지막 이벤트를 받은지 x초 이내에 또 다른 박동 이벤트 메세지를 받으면 접속상태를 온라인으로 유지하는 것이다. x초 동안 이벤트가 없다면 오프라인으로 변경하면 된다.
상태 정보의 전송
위 그림은 사용자 A와 친구관계에 있는 사용자들에게 해당 사용자의 상태 변화를 어떻게 보여주는지를 나타낸다. 상태정보 서버는 발행-구독 모델(publish-subscibe model)을 사용하는데, 각각의 친구관계마다 채널을 하나씩 둔다. 각각의 채널을 통해 사용자 A의 상태 변화를 통지하여 처리하는 것이다.
이 방안은 그룹 크기가 작을 때는 효과적이지만, 그룹 크기가 커지는 경우 비용이나 시간이 많이 소모되어 좋지 않다. 이를 해소하는 한 가지 방법은 사용자가 해당 그룹 채팅에 입장하는 순간에만 상태 정보를 읽게 하거나, 친구 리스트에 있는 사용자의 접속상태의 갱신을 수동(manual)으로 하도록 유도하는 것이다.
4단계 마무리
채팅 시스템 아키텍처를 통해, 웹소켓을 사용해 실시간 통신을 구현하고, 채팅 서버, 접속 상태 서버, 푸시 알림 서버, 채팅 이력을 저장하는 키-값 저장소 등 다양한 컴포넌트를 설계해보았다. 면접에 시간이 남는다면 다음과 같은 내용을 추가적으로 논의해보아도 좋을 것이다.
•
사진이나 비디오 등의 미디어 파일 지원
◦
미디어 파일은 텍스트에 비해 용량이 크기 때문에, 압축 방식이나 클라우드 저장소, 썸네일 생성 등을 논의 해볼 수 있다.
•
종단 간 암호화
◦
메세지 발신인과 수신자 이외에는 아무도 메세지 내용을 볼 수 없게 암호화 해볼 수 있다.
•
클라이언트 캐시
◦
클라이언트에 이미 읽은 메세지를 캐시해두어 서버와 주고받는 데이터 양을 줄일 수 있다.
•
로딩 속도 개선
◦
사용자의 데이터, 채널 등을 지역적으로 분산하는 네트워크를 구축하여 앱 로딩 속도를 개선할 수 있다.
•
채팅 서버 오류
◦
채팅 서버 하나에 수십만 사용자가 접속해 있다면, 서버 하나가 죽었을 때 서비스 탐색 기능이 동작하여 클라이언트에 새로운 서버를 배정하고 다시 접속할 수 있어야 한다.
•
메세지 재전송
◦
재시도나 큐는 메세지의 안정적 전송을 보장하기 좋은 기법이다.



















