2장. 주변 친구
"주변친구" 기능은 본인 위치 정보 접근 권한을 허락한 사용자에 한해 인근의 친구 목록을 보여주는 시스템이다.
근접성 서비스의 경우 사업장 주소는 정적이지만, 주변 친구 위치는 자주 바뀔 수 있다.
1단계: 설계 범위 확정
기능 요구 사항
- 모바일 앱에서 주변 친구를 확인할 수 있어야 한다.
- 주변 친구 목록에 보이는 각 항목에는 해당 친구까지의 거리, 해당 정보가 마지막으로 갱신된 시각(timestamp)이 함께 표시
- 친구 목록은 몇 초마다 한 번씩 갱신되어야 한다.
비기능 요구 사항
- 낮은 지연 시간(low latency): 주변 친구의 위치 변화가 반영되는데 오랜 시간이 걸리면 안된다.
- 안정성: 때로 몇 개 데이터가 유실되는 것 정도는 용인할 수 있다.
- 결과적 일관성(eventual consistency):강한 일관성을 지원하는 데이터 저장소를 사용할 필요는 없으며, 복제본 데이터가 원본과 동일하게 변경되기까지 몇 초 정도 걸리는 것은 용인할 수 있다.
개략적 규모 요청
- 5마일(8km) 반경 이내 친구로 정의
- 친구 위치 정보는 30초 주기로 갱신
- 기능을 활용하는 사용자는 1억명
- 천만 명이 동시에 시스템을 이용
- 평균적으로 한 사용자는 400명의 친구를 갖는다.
- 페이지당 20명의 주변 친구를 표시하고 요청이 있으면 더 보여준다.
2단계 개략적 설계안
위치 정보를 모든 친구에게 전송해야 한다는 요구사항 때문에 클라이언트와 서버 사이의 통신 프로토콜로 단순한 HTTP 프로토콜을 사용하지 못할 수 있는 부분을 감안.
계략적 설계
개념적으로 보면 사용자는 근방의 모든 활성 상태 친구의 새 위치 정보를 수신하고자 한다.
설계 방안
- 순수한 p2p 방식으로도 설계
- 실용적인 아이디어는 아니지만, 일반적으로 추구해야 할 설계 방향에 대한 통찰은 얻을 수 있다.
- 조금 더 실용적인 설계안은 공용 백엔드를 사용하는 것.
- 모든 활성 상태 사용자의 위치 변화 내역을 수신해야 한다.
- 사용자의 위치 변경 내역을 수신할 때마다 해당 사용자의 모든 활성 상태 친구를 찾아서 단말로 변경 내역을 전달해야 한다.
- 두 사용자 사이의 거리가 특정 임계치보다 먼 경우에는 변경 내영을 전송하지 않는다.
공용 백엔드가 명쾌한 설명 같지만, 큰 규모에서 적용하기가 쉽지않다.
천 만명이 30초마다 갱신하고, 사용자 한 명이 친구 400이 있으면 1400만(334,000 * 400 * 10%)건의 위치 정보 갱신 요청을 처리해야 한다.
설계안
소규모 백엔드를 위한 개략적 설계안을 만들어 보자.
로드 밴런서
RESTful API 서버 및 웹소켓 서버 앞에 위치한다.
- RESTful API 서버
- 무상태 서버로 요청/응답 트래픽을 처리한다.
- 친구를 추가/삭제하거나 사용자 정보 갱신 등의 부가적 작업을 처리
- 웹소켓 서버
- 친구 위치 정보 변경을 실시간에 가깝게 처리하는 서버 클러스터
- 반경 내 친구 위치가 변경되면 해당 내역은 이 연결을 통해 클라이언트로 전송
레디스 위치 정보 캐시
- 활성화 상태 사용자의 가장 최근 위치 정보를 캐시하는데 사용
- TTL(Time-To-Live) 필드로 기간이 지나면 비활성 상태로 바뀌고 캐시에서 삭제된다.
사용자 데이터베이스
- 관계형 데이터베이스나 NoSQL 어느 쪽이든 사용 가능하다.
위치 이동 이력 데이터베이스
- 사용자의 위치 변동 이력을 보관
레디스 펍/섭 서버
- 초경량 메세지 버스이다.
- 웹소켓 서버를 통해 수신한 특정 사용자의 위치 정보 변경 이벤트는 해당 사용자에게 배정된 펍/섭 채널에 발생한다.
- 특정 사용자의 위치가 바뀌면 모든 친구의 웹소켓 연결 핸들러가 호출되고, 각 핸들러는 다시 거리를 계산한다.
주기적 위치 갱신
- 모바일 클라이언트가 위치 변경된 사실을 로드밸런서로 전송
- 로드밴런서는 웹소켓 서버로 보낸다.
- 웹소켓 서버는 해당 이벤트를 위치 이동 이력 데이터베이스에 저장
- 웹소켓 서버는 새 위치를 위치 정보 캐시에 보관, TTL도 새롭게 갱신하고 핸들러 안의 변수에 해당 위치를 반영.
- 웹소켓 서버는 레디스 펍/섭 서버의 해당 사용자 채널에 새 위치를 발행, 3~5는 병렬로 처리
- 새로운 위치 변경 이벤트는 모든 구독자에게 브로드캐스트된다.
- 새 위치를 보낸 사용자와 메세지를 받은 사용 사이의 거리를 새로 계산
- 계산한 거리가 반경을 넘지 않다면, 구독자의 클라이언트 애븡로 전송
API 설계
- [서버 API] 주기적인 위치 정보 갱신
요청: 클라이언트는 위도, 경도, 시각 정보를 전송
응답: 없음 - [클라이언트 API] 클라이언트가 갱신된 친구 위치를 수신하는데 사용할 API
전송되는 데이터: 친구 위치 데이터와 변경된 시각을 나타내는 타임스탬프 - [서버 API] 웹소켓 초기화 API
요청: 웹소켓 서버는 친구 ID 전송
응답: 클라이언트는 자기 친구들의 위치 데이터를 수신 - [클라이언트 API] 새 친구 구독 API
요청: 웹소컷 서버는 친구 ID 전송
응답: 가장 최근의 위도, 경도, 시각 정보 전송 - [클라이언트 API] 구독 해지 API
요청: 웹 소켓 서버는 친구 ID 전송
응답: 없음
데이터 모델
위치 정보 캐시
'주변 친구' 기능을 켠 활성 상태 친구의 가장 최근 위치를 보관한다.
레디스를 사용해 해당 캐시를 구현한다.
키 | 값 |
사용자 ID | {위도, 경도, 시각} |
Q. 위치 정보 저장에 데이터베이스를 사용하지 않는 이유는?
- 사용자의 현재 위치만을 이용한다. 즉, 사용자 위치는 하나만 보관하면 충분하다.
- TTL을 지원하므로 비활성화 상태인 사용자 정보를 자동으로 제거할 수 있다.
- 영속성을 보장할 필요가 없다.
위치 이동 이력 데이터베이스
필요로 하는 것은 막대한 쓰기 연산 부하를 감당할 수 있고, 수평적 규모 확장이 가능한 데이터베이스.
카산드라(Cassandra)는 이런 요구에 잘 부합한다.
관계형 데이터베이스도 사용할 수 있으나 서버 한 대에 보관하기에는 너무 많을 수 있으므로 샤딩이 필요하다.
3단계: 상세 설계
중요 구성요소별 규모 확장성
API 서버
- 무상태 서버로, 클러스터의 규모를 CPU 사용률이나 부하, I/O 상태에 따라 자동으로 늘리는 방법은 다양하다
웹소켓 서버
- 웹소켓 클러스터도 사용률에 따라 규모를 늘리는 것은 어렵지 않다.
- 유상태 서버라 기존 서버를 제거할 때 주의해야 한다.
- 기존 연결부터 종료될 수 있도록 하고, 이를 위해 로드밸런서가 인식하는 노드 상태를 '연결 종료 중'으로 변경하면 해당 서버는 새로운 웹소켓 연결이 만들어지지 않는다.
클라이언트 초기화
- 모바일 클라이언트는 기동되면 웹소켓 클러스터 내의 서버 가운데 하나와 지속성 웹소켓 연결을 맺는다.
- 현대적 프로그래밍 언어는 이런 연결 유지에 많은 메모리를 필요로 하지 않는다.
- 단말은 이용 중인 사용자의 위치 정보를 전송하고, 그 정보를 받은 웹소켓 연결 핸들러는 다음과 같은 수행을 한다.
- 위치 정보 캐시에 보관된 사용자의 위치를 갱신
- 해당 위치 정보는 연결 핸들러 내의 변수에 저장
- 사용자 데이터베이스를 뒤져 사용자의 모든 친구 정보를 가져온다.
- 위치 정보 캐시에 일괄 요청을 보내어 모든 친구의 위치를 한 번에 가져온다.
- 친구 위치 각각에 대해 웹 소켓 서버는 거리를 계산하고, 검색 반경 이내이면 웹 소켓 연결을 통해 클라이언트에게 반환
- 웹소켓 서버는 친구의 레디스 서버 펍/섭 채널을 구독한다.
- 사용자의 현재 위치를 레디스 펍/섭 서버의 전용 채널을 통해 모든 친구에게 전송
사용자 데이터 베이스
두 가지 종류의 데이터가 보관된다.
- 사용자 상세 정보 데이터
- 친구 관계 데이터
- 실제로 운영하려면 데이터베이스가 아닌 데이터를 관리하는 팀의 API를 호출하여 가져와야 한다.
한 대의 서버로 감당이 안되면 사용자 ID를 기준으로 샤딩하면 관계형 데이터베이스는 수평적 규모 확장이 가능하다.
위치 정보 캐시
사용자의 위치 정보를 캐시하기 위해 레디스를 사용했다.
- TTL을 설정하여 사용자의 위치 정보가 갱신될 때마다 초기화 되기 때문에 최대 메모리 사용량은 일정 한도 아래로 유지된다.
- 만약 최고 고사양 서버를 사용해도 부담되면, 캐시 데이터는 쉽게 샤딩할 수 있다.
- 사용자의 위치정보는 서로 독립적인 데이터로, 사용자 ID를 기준으로 여러 서버를 샤딩하면 된다.
- 가용성을 높이려면 위치 정보를 대기 노드에 복제하면된다.
레디스 펍/섭 서버
- 펍/섭 서버를 모든 온라인 친구에게 보내는 위치 변경 내역 메시지의 라우팅 계층으로 활용한다.
- 채널을 만드는 비용이 아주 저렴하다.
- 채널 하나를 유지하기 위해서는 구독자 관계를 추적하기 위한 해시 테이블과 연결 리스트가 필요한데 아주 소량의 메모리만을 사용한다.
- 오프라인 사용자라 어떤 변경도 없는 채널의 경우에는 생성된 이후에 CPU 자원은 전혀 사용하지 않는다.
=> 본 설계는 이 점을 이용해서 아래와 같이 활용한다.
- '주변 친구' 기능을 사용하는 모든 사용자에 채널 하나씩 부여한다. 앱 초기화 시에 모든 친구의 채널과 구독 관계를 설정한다.
- 더 많은 메모리를 사용하겠지만, 메모리가 병목이 될 가능성이 낮다. 아키텍처를 단순하게 만들 수 있다면 더 많은 메모리를 투입한다.
얼마나 많은 레디스 펍/섭 서버가 필요한가?
- 메모리 사용량
- 모든 사용자에게 채널 하나씩 할당한다고 하면, 200GB(1억명 * 20바이트 * 100명의 친구 / 10%)
- CPU 사용량
- 기가비트 네트워크 카드를 탑재한 현대적 아키텍처의 서버 한대로 감당 가능한 구독자의 수는 100,000이라고 가정
- 추정치에 따라 필요한 레디스 서버의 수는 1400만/100,000 = 140대
- 레디스 펍/섭 서버의 병목은 메모리가 아니라 CPU사용량이다
- 풀어야 하는 문제의 규모를 감당하려면 분산 레디스 펍/섭 클러스터가 필요
분산 레디스 펍/섭 서버 클러스터
1) 해시링
- 레디스 채널은 서로 독립적이라 메세지를 발행할 사용자 ID를 기준으로 펍/섭 서버를 샤딩하면 된다.
- 본 설계안에서는 서비스 탐색 컴포넌트(etcd, 주키퍼 등)를 도입하여 문제를 푼다.
=> 서비스 탐색 이용은 아래 두 가지 기능만을 사용한다.
- 가용한 서버 목록을 유지하는 기능 및 해당 목록을 갱신하는데 필요한 UI나 API
- 즉, 서비스 탐색 소프트웨어는 설정 데이터를 보관하기 위한 소규모의 키-값 저장소(해시 링)라고 보면 된다.
- 키: /config/pub_sub_ring
- 값: ["p_1", "p_2", "p_3", "p_4"]
- 즉, 서비스 탐색 소프트웨어는 설정 데이터를 보관하기 위한 소규모의 키-값 저장소(해시 링)라고 보면 된다.
- 클라이언트(웹소켓 서버)로 하여금 '값'에 명시된 레디스 펍/섭 서베에서 발생한 변경 내역을 구독할 수 있도록 한다.
- 가용한 서버 목록을 유지하는 기능 및 해당 목록을 갱신하는데 필요한 UI나 API
- '키'에 매달린 '값'에는 활성 상태의 모든 레디스 펍/섭 서버로 구성된 해시 링을 보관한다.
2) 웹소켓 서버가 특정 사용자 채널에 위치 정보 변경 내역을 발행하는 과정
=> 구독할 채널이 존재하는지 찾는 과정도 동일.
- 해시 링을 참조하여 메세지를 발행한 레디스 펍/섭 서버를 선정
- 해당 서버(웹소켓 서버)가 관리하는 사용자 채널에 위치 정보 변경 내역을 발행
레디스 펍/섭 서버 클러스터의 규모 확장 고려사항
- 레디스 펍/섭 서버 클러스터는 유상태 서버 클러스터로 취급하는 것이 바람직하다.
- 펍/섭 채널에 전송되는 메세지는 메모리나 디스크에 지속적으로 보관되지 않는다.
=> 채널의 구독자에게 전송되고 나면 바로 삭제되기 때문에 펍/섭 채널을 통해 처리되는 데이터는 무상태라고 볼 수 있다. - 펍/섭 서버를 교체하거나 해시링에서 제거하는 경우, 해당 채널의 모든 구독자에게 사실을 알려야한다.
=> 이런 관점에서 보면 펍/섭은 유상태 서버이다.
- 펍/섭 채널에 전송되는 메세지는 메모리나 디스크에 지속적으로 보관되지 않는다.
- 유상태 서버는 혼잡 시간대 트래픽을 무리 없이 감당하고 불필요한 크기 변화를 피할 수 있도록 어느 정도 여유를 두고 오버 프로비저닝하는 것이 보통이다.
- 클러스터 크기 조정 절차
- 새로운 링 크기를 계산하고 늘어나는 경우에 새 서버를 준비
- 해시 링의 키에 매달린 값을 새로운 내용으로 갱신
- 대시보드 모니터링
운영 고려사항
- 기존 레디스 펍/섭 서버를 새 서버로 교체할 때 운영 문제가 발생할 가능성은 클러스터 크기를 조정할 때보다 훨씬 낮다.
- 운영 순서
- 담당자는 서비스 탐색 컴포넌트의 해시 링 키에 매달린 값을 갱신하여 장애가 발생한 노드를 대기 중인 노드와 교체한다.
- 교체 사실은 모든 웹 소켓 서버에 통지하고, 각 웹소켓 서버는 실행 중인 연결 핸들러에게 새 펍/섭 서버의 채널을 다시 구독하도로록 알린다.
- 연결 핸들러는 해시 링과 대조하여 새 서버로 구독 관계를 다시 설정해야 하는지 검토
친구 추가/삭제
처리 순서
- 새 친구가 추가/삭제 되면 콜백이 호출되면 웹소켓 서버로 새 친구의 펍/섭 구독(또는 취소)를 하라고 메세지를 보낸다.
- 구독인 경우, 웹소켓 서버는 해당 친구가 활성화 상태이면 가장 최근 위치 및 시각 정보를 응답 메시지에 담아 보낸다.
친구가 많은 사용자
- 친구 관계는 양방향이며, 팔로어 모델처럼 단방향의 관계는 배제하기 떄문에 유명 인사 시스템은 존재할 수 없다.
- 펍/섭 구독 관계는 클러스터 내의 많은 웹소켓 서버에 분산되어 있다.
=> 부하는 각 웹소켓 서버가 나누어 처리하므로 핫스팟 문제는 발생하지 않는다.
주변 임의 사용자
Q. 정보 공유에 동의한 주변 사용자를 무작위로 보여줄 수 있도록 하는 기능을 추가해보자
A. 지오해시에 따라 구축된 펍/섭 채널 풀을 두는 것이다.
- 처리 순서
- 사용자 위치가 변경되면 웹소켓 연결 핸들러는 해당 사용자의 지오해시 ID를 계산
- 지오해시 ID를 담당하는 채널에 새위치를 전송
- 근방에 있는 상요자 가운데 해당 채널을 구독하고 있는 사용자는 특정 사용자의 위치가 변경되었다는 메세지를 수신
- 격자 경계 부근에 있는 사용자를 잘 처리하기 위해 지오해시뿐만 아니라 주변 지오해시 격자를 담당하는 채널도 구독한다.
레디스 펍/섭 외의 대안
- 라우팅 계층으로 펍/섭을 사용했다.
- 얼랭(Erlang)은 지금 문제에 유용한 해결책이 될 수 있다.
- 고도로 분산된 병렬 애플리케이션을 위해 고안된 프로그래밍 언어이자 런타임 환경이다.
- 얼랭 생태계는 언어 컴포넌트뿐만 아니라 실행 환경 및 라이브러리를 아우른다.
- BEAM VM에서 실행되는 개체로 생성 비용이 리눅스 프로세스 생성 비용에 비해 엄청 저렴하다.
- 웹 소켓 서비스를 얼랭을 구현하고, 레디스 펍/섭 클러스터는 분산 얼랭 애플리케이션으로 대체할 수 있다.
- 각 사용자는 얼랭 프로세스로 표현
- 프로세스는 클라이언트가 전송하는 갱신된 사용자 위치를 웹소켓 서버를 통해 수신
- 친구 관계에 있는 사용자의 얼랭 프로세스와 구독 관계를 설정하고 위치 변경 내역을 수신
4단계: 마무리
개념적으로 살펴보면 어떤 사용자의 위치 정보 변경 내역을 그 친구에게 효율적으로 전달하는 시스템을 설계
- 웹소켓: 클라이언트와 서버 사이의 실시간 통신을 지원
- 레디스: 위치 데이터의 빠른 읽기/쓰기를 지원
- 레디스 펍/섭: 사용자의 위치 정보 변경 내역을 모든 온라인 친구에게 전달하는 라우팅 계층
규모를 늘리는 구성요소
- RESTful API 서버
- 웹소켓 서버
- 데이터 계층
- 레디스 펍/섭 서버 클러스터
- 레디스 펍/섭 서버의 대안