View

반응형

채팅 앱은 우리가 가장 많이 사용하고 있는 서비스 중 하나다.
채팅 앱에는 1:1 채팅이나 그룹 채팅같이 업무 혹은 개인 메시지에 특화된 채팅 시스템이나 게임 등에 특화된 음성 채팅 등이 존재하는데, 이번장에서는 카카오톡이나 페이스북 메신저와 같은 1:1 채팅과 그룹 채팅에 특화된 채팅 앱을 개략적으로 알아본다.

 

개략적인 요구사항

  • 응답 지연이 낮은 1:1 채팅 기능 제공
  • 최대 100명까지 참여할 수 있는 그룹 채팅 기능 제공
  • 사용자 접속 상태 표시
  • 하나의 계정으로 여러 단말에서 동시 접속 지원
  • 푸시 알림

 

채팅의 기본 기능과 프로토콜

채팅 서비스가 갖춰야 할 기본 기능은 아래와 같다.

  • 클라이언트들로부터 메시지 수신
  • 메시지 수신자 결정 및 전달
  • 수신자가 접속 상태가 아닌 경우 접속할 때까지 보관

채팅 서비스의 경우 어떤 통신 프로토콜을 사용할 것인지가 굉장히 중요한 요소이다.
위 그림에서는 클라이언트가 채팅 서비스에 HTTP 프로토콜로 연결한 다음 메시지를 보내어 수신자에게 해당 메시지를 전달하라고 알린다.

이때 keep-alive 헤더를 사용해 클라이언트-서버 사이의 연결을 끊지 않고 계속 유지하도록 하면 핸드셰이크 횟수를 줄일 수 있어 효율적이다. 하지만 메시지 수신 시나리오는 이것보다 복잡하고 서버에서 클라이언트로 임의 시점에 메시지를 보내는데 쉽게 쓰일 수 없는 문제가 존재한다. 따라서 이를 해결하기 위해 폴링, 롱 폴링, 웹소켓 등 다양한 기법들이 제안되어 왔다.

 

폴링

폴링 기법은 클라이언트가 주기적으로 서버에게 새 메시지가 있는지 확인하는 방법이다.
주기적으로 확인하는 방법이기 때문에 서버에서 답해줄 메시지가 없는 경우 서버 자원이 불필요하게 낭비된다.

롱 폴링

롱 폴링은 폴링의 비효율적인 부분을 개선한 기법이다.

롱 폴링은 주기적으로 새 메시지가 있는지 확인하는 대신 새 메시지가 반환되거나 타임 아웃될 때까지 연결을 유지한다. 새 메시지를 받으면 기존 열결을 종료하고 서버에 새로운 요청을 보내어 모든 절차를 다시 시작한다.

하지만 롱 폴링 또한 단점이 존재하는데, 다음과 같다.

  • HTTP 서버들은 일반적으로 무상태 서버이므로 메시지를 보내는 클라이언트와 수신하는 클라이언트가 같은 채팅 서버에 접속하지 않을 수 있다.
    • 로드밸런싱을 위해 라운드 로빈 알고리즘을 사용하는 경우, 메시지를 받은 서버는 해당 메시지를 수신할 클라이언트와의 롱 폴링 연결을 가지고 있지 않은 서버일 수도 있기 때문이다.
  • 서버 입장에서는 클라이언트가 연결을 해제했는지 아닌지 알 좋은 방법이 없다.
  • 메시지를 많이 받지 않는 클라이언트도 타임아웃이 일어날 때마다 서버에 접속하므로 비효율적이다.

 

웹소켓

웹소켓은 서버가 클라이언트에게 비동기 메시지를 보낼 때 가장 널리 사용하는 기술이다.

클라이언트가 웹소켓 연결을 시작하고, 한번 맺어진 연결은 양방향으로 이루어진다.
처음에는 HTTP 연결이지만 특정 핸드셰이크 절차를 거치면 웹소켓 연결로 변경된다.
또한, 웹소켓을 사용하면 메시지를 보낼 때나 받을 때 동일한 프로토콜을 사용할 수 있으므로 구현도 단순하고 직관적이다. 따라서 채팅 시스템을 설계할 때 굳이 HTTP 연결을 고집할 필요 없이 웹소켓을 사용하면 된다.

 

개략적인 설계안

웹 소켓을 프로토콜로 사용하기로 결정했으니 이제 무상태 서비스, 상태 유지 서비스, 제3자 서비스 연동으로 나누어 전체 시스템의 개략적인 설계를 해보자.

 

무상태 서비스

무상태 서비스는 로그인, 회원가입, 사용자 프로필 표시 등을 처리하는 전통적인 요청/응답 서비스로 로드밸런서 뒤에 위치한다.

무상태 서비스들 중 서비스 탐색 서비스는 클라이언트가 접속할 채팅 서버의 DNS 호스트명을 클라이언트에게 알려주는 역할을 한다. 즉, 클라이언트에게 가장 적합한 채팅 서버를 추천해주는 것이다.

 

상태 유지 서비스

채팅 서비스는 각 클라이언트가 채팅 서버와 독립적인 네트워크 연결을 유지해야 한다. 따라서 상태 유지 서비스가 필요하게 된다. 클라이언트는 일반적으로 서버가 살아 있는 한 다른 서버로 연결을 변경하지 않기 때문에 특정 서버에 부하가 몰리지 않도록 서비스 탐색 서비스와 긴밀한 협력을 하게 된다.

 

제3자 서비스 연동

채팅 서비스에서 새 메시지를 받은 경우 푸시 알림은 필수적으로 필요하다. 이는 앱이 실행중이 않더라도 알림을 받아야 하므로 채팅 서비스와 푸시 알림 서비스의 통합은 매우 중요하다. 이 부분이 궁금하다면 알림 시스템 설계를 참고하자.

 

규모 확장성

채팅 서비스가 커지면 대량의 트래픽 처리는 필수적이므로 규모 확장성이 중요한 요소로 작용한다.
계속해서 살펴봤던 규모 확장성의 개념을 도입하면 다음과 같은 구조를 개략적으로 설계할 수 있다.

실시간으로 메시지를 주고받기 위해 클라이언트는 채팅 서버와 웹소켓 연결을 끊지 않고 유지한다.

  • 채팅 서버는 클라이언트 사이에서 메시지를 중계하는 역할을 담당한다.
  • 접속 상태 서버는 사용자의 접속 여부를 관리한다.
  • API 서버는 로그인, 회원가입 등 나머지 전부를 처리한다.
  • 알림 서버는 푸시 알림을 담당한다.
  • 키-값 저장소는 채팅 이력을 보관하고 사용자에게 이전 채팅 이력 정보 제공을 담당한다.

 

저장소

채팅 시스템이 다루는 데이터는 보통 두 가지다.

  1. 사용자 프로필, 설정, 친구 목록과 같은 일반적인 데이터
  2. 채팅 이력처럼 채팅 시스템에 고유한 데이터

우리는 이 두 개의 데이터 유형과 읽기/쓰기 연산 패턴을 이해하고 저장소를 선택해야 한다.

1번의 경우 데이터 안정성을 보장하는 관계형 데이터베이스가 적합하고
2번은 키-값 저장소와 같은 NoSQL이 유리하다.

채팅 이력 데이터의 양은 엄청나게 많고 최근에 주고받은 메시지가 가장 빈번하게 사용되며, 검색 기능이나 멘션 같은 기능도 잘 지원해야 한다. 키-값 저장소는 수평적 규모 확장이 쉽고, 데이터 접근 지연시간이 낮기 때문에 채팅 이력을 보관하는 저장소로 사용하기에 적합하다.

 

데이터 모델

그렇다면 키-값 저장소에 저장될 메시지 데이터는 어떻게 보관해야 할까?
메시지의 키가 되는 ID, 메시지를 보내는 사람, 받는 사람, 메시지 컨텐츠, 메시지 생성 일자 등이 필요할 것이다.
여기서는 메시지의 ID가 중요하다.

메시지 ID는 고유해야 하며 정렬이 가능해야 한다. 즉, 새로운 ID는 이전 ID보다 큰 값을 가지고 있어야 한다.

가장 적합한 메시지 ID 생성 방법은 몇 가지가 있는데, 첫 번째 방법은 분산 시스템의 유일 ID 생성기 설계에서 살펴본 스노플레이크 같은 전역적 64-bit 순서 번호 생성기를 이용하는 것이고 두 번째 방법은 지역적 순서 번호 생성기를 이용하는 것이다.
여기서 지역적 순서 번호 생성기란 같은 그룹 안에서만 유일성을 보증하는 순서 번호 생성기를 말한다.

 

상세 설계

상세 설계에서는 채팅 시스템의 중요한 컴포넌트인 서비스 탐색, 메시지 전달 흐름, 사용자 접속 상태 표시하는 방법을 더 자세히 알아보자.

서비스 탐색

앞서 잠깐 언급했듯 서비스 탐색은 클라이언트에게 가장 적합한 채팅 서버를 추천해준다.
주키퍼로 구현한 서비스 탐색 기능의 동작 방식은 다음과 같다.

  1. 사용자가 시스템 로그인을 시도한다.
  2. 로드밸런서가 API 서버들 중 하나로 요청을 보낸다.
  3. API 서버가 인증 처리를 완료하면 서비스 탐색 기능이 동작하여 최적의 채팅 서버를 찾는다.
  4. 사용자는 최적의 채팅 서버로 선택된 채팅 서버 2와 웹소켓 연결을 맺는다.

메시지 전달 흐름

1:1 채팅 메시지 처리 흐름  

  1. 사용자 A가 채팅 서버 1로 메시지 전송
  2. 채팅 서버 1은 ID 생성기를 사용해 메시지 ID를 결정
  3. 해당 메시지를 메시지 동기화 큐로 전송
  4. 메시지가 키-값 저장소에 보관됨
  5. (a) 사용자 B가 접속 중인 경우 B가 사용 중인 채팅 서버로 메시지 전송 (b) 사용자 B가 접속 중이 아니라면 푸시 알림 서버로 푸시 알림 메시지 전송
  6. 사용자 B와 채팅 서버 사이에 연결된 웹소켓을 통해 메시지 전송

 

여러 단말 사이의 메시지 동기화

우리는 스마트폰뿐 아니라 여러 단말을 사용하고 있는 경우가 많다.
PC와 스마트폰 애플 워치, 아이패드 등 여러 단말 사이에 메시지 동기화는 어떻게 동작할까?

예를 들어, 사용자 A는 스마트폰과 랩톱 두 대의 단말을 사용하고 있다고 가정해보자.
이때, A의 스마트폰의 채팅 앱에 로그인 결과로 채팅 서버 1과 웹소켓 연결이 만들어져 있고 랩톱에서도 로그인하여 별도의 웹소켓이 채팅 서버 1에 연결되어 있다.

각 단말은 cur_max_message_id라는 변수를 유지하여 단말에서 관측된 최신 메시지의 ID를 추적한다.
수신자 ID가 현재 로그인 사용자 ID와 같고 키-값 저장소에 보관된 메시지 ID가 cur_max_message_id 보다 크다면 새로운 메시지로 간주한다. cur_max_message_id는 단말마다 별도로 관리하여 키-값 저장소에서 새 메시지를 가져오는 동기화 작업을 쉽게 구현할 수 있다.

 

그룹 채팅 메시지 흐름

소규모 그룹 채팅을 지원할 때는 다음과 같이 수신자별로 메시지 동기화 큐에 새 메시지를 복사하는 방식을 사용할 수 있다. 아래의 예시는 사용자 A가 B, C에게 새로운 메시지를 전송했을 때 메시지 흐름이다.

소규모 그룹 채팅인 경우 큐에 복사하는 작업 비용이 크지 않기 때문에 이러한 방식이 적합하다.
하지만 대규모의 사용자를 지원해야 하는 그룹 채팅이라면 같은 메시지를 모든 사용자의 큐에 복사하는 일은 바람직하지 않다.

반대로 그룹 채팅에서 메시지를 수신할 때는 모든 사용자의 메시지를 받을 수 있어야 한다.

접속상태 표시

사용자의 접속 상태 기능은 접속상태 서버를 통해 관리한다.
접속상태 서버는 클라이언트와 웹소켓으로 통신하는 실시간 서비스의 일부이다.
사용자와 실시간 서비스 사이에 연결이 이루어지면 접속상태 서버는 사용자의 상태last_active_at 타임스탬프를 키-값 저장소에 보관한다.

이 작업이 끝나면 사용자는 접속 중인 것으로 표시된다.

인터넷을 통한 연결이 항상 안정적일 수 없으니 우리는 접속 장애에 대한 설계도 함께 고려해야 한다.
일반적으로 사용하는 방법은 주기적으로 박동(heartbeat) 이벤트를 접속상태 서버로 전송하고, 마지막 이벤트를 받은 지 x초 이내에 또 다른 박동 이벤트를 받는다면 접속 상태를 유지시키는 방법이다.

이렇게 접속상태 변화를 관리하더라도 다른 사용자가 해당 상태 변화를 알 수 없다면 아무 쓸모없는 기능이 될 것이다. 그렇다면 다른 사용자는 해당 사용자의 상태 변화를 어떻게 알 수 있을까?

발행-구독 모델을 사용해 각 친구마다 채널을 하나씩 두어 상태 정보 변화를 통지받을 수 있도록 설계하면 쉽게 상태 정보 변화를 전달받을 수 있게 된다.

하지만 이 방법은 소규모 그룹 채팅에 적합하며 대규모 그룹 채팅에는 적합하지 않다.
대규모 그룹 채팅의 경우 너무 많은 이벤트가 발생하기 때문이다.
만약 대규모 그룹 채팅에서 상태 정보를 전달 기능이 필요하다면 입장 순간에만 상태 정보를 읽어가던가, 수동으로 갱신하도록 유도해야 한다.

 

마무리

부가 정보

  • 사진이나 비디오 등의 미디어를 지원하기 위해서는 압축 방식, 클라우드 저장소, 섬네일 생성 등을 고려해볼 수 있다.
  • 종단 간 암호화를 사용해 당사자 외에 아무도 메시지 내용을 볼 수 없게 하여 메시지 전송의 보안을 강화할 수 있다.
  • 읽은 메시지에 대해 캐시를 도입하면 서버와 주고받는 데이터 양을 줄 일 수 있다.
  • 사용자의 데이터, 채널 등을 지역적으로 분산하는 네트워크를 구축하면 로딩 속도를 개선할 수 있다.

 

핵심 정리

  • 클라이언트와 서버 사이의 실시간 통신을 위해 웹소켓을 사용한다.
  • 채팅 시스템 설계에 필요한 주요 컴포넌트에는 채팅 서버, 접속 상태 서버, 푸시 알림 서버, 채팅 이력 키-값 저장소, 나머지 기능을 구현하는 API 서버 등이 있다.

 

반응형
Share Link

인기 글

최신 글

전체 방문자

Today
Yesterday