읽은 책/[책] 가상 면접 사례로 배우는 대규모 시스템 설계 기초 2권

2장. 주변 친구

코드몬스터 2024. 7. 28. 14:15
728x90

 

"주변친구" 기능은 본인 위치 정보 접근 권한을 허락한 사용자에 한해 인근의 친구 목록을 보여주는 시스템이다.

근접성 서비스의 경우 사업장 주소는 정적이지만, 주변 친구 위치는 자주 바뀔 수 있다.

1단계: 설계 범위 확정

기능 요구 사항

  • 모바일 앱에서 주변 친구를 확인할 수 있어야 한다.
  • 주변 친구 목록에 보이는 각 항목에는 해당 친구까지의 거리, 해당 정보가 마지막으로 갱신된 시각(timestamp)이 함께 표시
  • 친구 목록은 몇 초마다 한 번씩 갱신되어야 한다.

비기능 요구 사항

  • 낮은 지연 시간(low latency): 주변 친구의 위치 변화가 반영되는데 오랜 시간이 걸리면 안된다.
  • 안정성: 때로 몇 개 데이터가 유실되는 것 정도는 용인할 수 있다.
  • 결과적 일관성(eventual consistency):강한 일관성을 지원하는 데이터 저장소를 사용할 필요는 없으며, 복제본 데이터가 원본과 동일하게 변경되기까지 몇 초 정도 걸리는 것은 용인할 수 있다.

개략적 규모 요청

  • 5마일(8km) 반경 이내 친구로 정의
  • 친구 위치 정보는 30초 주기로 갱신
  • 기능을 활용하는 사용자는 1억명
  • 천만 명이 동시에 시스템을 이용
  • 평균적으로 한 사용자는 400명의 친구를 갖는다.
  • 페이지당 20명의 주변 친구를 표시하고 요청이 있으면 더 보여준다.

 

2단계 개략적 설계안

위치 정보를 모든 친구에게 전송해야 한다는 요구사항 때문에 클라이언트와 서버 사이의 통신 프로토콜로 단순한 HTTP 프로토콜을 사용하지 못할 수 있는 부분을 감안.

계략적 설계

개념적으로 보면 사용자는 근방의 모든 활성 상태 친구의 새 위치 정보를 수신하고자 한다.

 

설계 방안

  1. 순수한 p2p 방식으로도 설계
    • 실용적인 아이디어는 아니지만, 일반적으로 추구해야 할 설계 방향에 대한 통찰은 얻을 수 있다.
  2. 조금 더 실용적인 설계안은 공용 백엔드를 사용하는 것.
    • 모든 활성 상태 사용자의 위치 변화 내역을 수신해야 한다.
    • 사용자의 위치 변경 내역을 수신할 때마다 해당 사용자의 모든 활성 상태 친구를 찾아서 단말로 변경 내역을 전달해야 한다.
    • 두 사용자 사이의 거리가 특정 임계치보다 먼 경우에는 변경 내영을 전송하지 않는다.

공용 백엔드가 명쾌한 설명 같지만, 큰 규모에서 적용하기가 쉽지않다.

천 만명이 30초마다 갱신하고, 사용자 한 명이 친구 400이 있으면 1400만(334,000 * 400 * 10%)건의 위치 정보 갱신 요청을 처리해야 한다.

설계안

소규모 백엔드를 위한 개략적 설계안을 만들어 보자.

 

로드 밴런서

RESTful API 서버 및 웹소켓 서버 앞에 위치한다.

  • RESTful API 서버
    • 무상태 서버로 요청/응답 트래픽을 처리한다.
    • 친구를 추가/삭제하거나 사용자 정보 갱신 등의 부가적 작업을 처리
  • 웹소켓 서버
    • 친구 위치 정보 변경을 실시간에 가깝게 처리하는 서버 클러스터
    • 반경 내 친구 위치가 변경되면 해당 내역은 이 연결을 통해 클라이언트로 전송

레디스 위치 정보 캐시

  • 활성화 상태 사용자의 가장 최근 위치 정보를 캐시하는데 사용
  • TTL(Time-To-Live) 필드로 기간이 지나면 비활성 상태로 바뀌고 캐시에서 삭제된다.

사용자 데이터베이스

  • 관계형 데이터베이스나 NoSQL 어느 쪽이든 사용 가능하다.

위치 이동 이력 데이터베이스

  • 사용자의 위치 변동 이력을 보관

레디스 펍/섭 서버

  • 초경량 메세지 버스이다.
  • 웹소켓 서버를 통해 수신한 특정 사용자의 위치 정보 변경 이벤트는 해당 사용자에게 배정된 펍/섭 채널에 발생한다.
  • 특정 사용자의 위치가 바뀌면 모든 친구의 웹소켓 연결 핸들러가 호출되고, 각 핸들러는 다시 거리를 계산한다.

주기적 위치 갱신

  1. 모바일 클라이언트가 위치 변경된 사실을 로드밸런서로 전송
  2. 로드밴런서는 웹소켓 서버로 보낸다.
  3. 웹소켓 서버는 해당 이벤트를 위치 이동 이력 데이터베이스에 저장
  4. 웹소켓 서버는 새 위치를 위치 정보 캐시에 보관, TTL도 새롭게 갱신하고 핸들러 안의 변수에 해당 위치를 반영.
  5. 웹소켓 서버는 레디스 펍/섭 서버의 해당 사용자 채널에 새 위치를 발행, 3~5는 병렬로 처리
  6. 새로운 위치 변경 이벤트는 모든 구독자에게 브로드캐스트된다.
  7. 새 위치를 보낸 사용자와 메세지를 받은 사용 사이의 거리를 새로 계산
  8. 계산한 거리가 반경을 넘지 않다면, 구독자의 클라이언트 애븡로 전송

API 설계

  1. [서버 API] 주기적인 위치 정보 갱신
    요청: 클라이언트는 위도, 경도, 시각 정보를 전송
    응답: 없음
  2. [클라이언트 API] 클라이언트가 갱신된 친구 위치를 수신하는데 사용할 API
    전송되는 데이터: 친구 위치 데이터와 변경된 시각을 나타내는 타임스탬프
  3. [서버 API] 웹소켓 초기화 API
    요청: 웹소켓 서버는 친구 ID 전송
    응답: 클라이언트는 자기 친구들의 위치 데이터를 수신
  4. [클라이언트 API] 새 친구 구독 API
    요청: 웹소컷 서버는 친구 ID 전송
    응답: 가장 최근의 위도, 경도, 시각 정보 전송
  5. [클라이언트 API] 구독 해지 API
    요청: 웹 소켓 서버는 친구 ID 전송
    응답: 없음

데이터 모델

위치 정보 캐시

'주변 친구' 기능을 켠 활성 상태 친구의 가장 최근 위치를 보관한다.

레디스를 사용해 해당 캐시를 구현한다.

사용자 ID {위도, 경도, 시각}

 

Q. 위치 정보 저장에 데이터베이스를 사용하지 않는 이유는?

  • 사용자의 현재 위치만을 이용한다. 즉, 사용자 위치는 하나만 보관하면 충분하다.
  • TTL을 지원하므로 비활성화 상태인 사용자 정보를 자동으로 제거할 수 있다.
  • 영속성을 보장할 필요가 없다.

위치 이동 이력 데이터베이스

필요로 하는 것은 막대한 쓰기 연산 부하를 감당할 수 있고, 수평적 규모 확장이 가능한 데이터베이스.

카산드라(Cassandra)는 이런 요구에 잘 부합한다.

관계형 데이터베이스도 사용할 수 있으나 서버 한 대에 보관하기에는 너무 많을 수 있으므로 샤딩이 필요하다.

3단계: 상세 설계

중요 구성요소별 규모 확장성

API 서버

  • 무상태 서버로, 클러스터의 규모를 CPU 사용률이나 부하, I/O 상태에 따라 자동으로 늘리는 방법은 다양하다

웹소켓 서버

  • 웹소켓 클러스터도 사용률에 따라 규모를 늘리는 것은 어렵지 않다.
  • 유상태 서버라 기존 서버를 제거할 때 주의해야 한다.
  • 기존 연결부터 종료될 수 있도록 하고, 이를 위해 로드밸런서가 인식하는 노드 상태를 '연결 종료 중'으로 변경하면 해당 서버는 새로운 웹소켓 연결이 만들어지지 않는다.

클라이언트 초기화

  • 모바일 클라이언트는 기동되면 웹소켓 클러스터 내의 서버 가운데 하나와 지속성 웹소켓 연결을 맺는다.
  • 현대적 프로그래밍 언어는 이런 연결 유지에 많은 메모리를 필요로 하지 않는다.
  • 단말은 이용 중인 사용자의 위치 정보를 전송하고, 그 정보를 받은 웹소켓 연결 핸들러는 다음과 같은 수행을 한다.
    1. 위치 정보 캐시에 보관된 사용자의 위치를 갱신
    2. 해당 위치 정보는 연결 핸들러 내의 변수에 저장
    3. 사용자 데이터베이스를 뒤져 사용자의 모든 친구 정보를 가져온다.
    4. 위치 정보 캐시에 일괄 요청을 보내어 모든 친구의 위치를 한 번에 가져온다.
    5. 친구 위치 각각에 대해 웹 소켓 서버는 거리를 계산하고, 검색 반경 이내이면 웹 소켓 연결을 통해 클라이언트에게 반환
    6. 웹소켓 서버는 친구의 레디스 서버 펍/섭 채널을 구독한다.
    7. 사용자의 현재 위치를 레디스 펍/섭 서버의 전용 채널을 통해 모든 친구에게 전송

사용자 데이터 베이스

두 가지 종류의 데이터가 보관된다.

  1. 사용자 상세 정보 데이터
  2. 친구 관계 데이터
    • 실제로 운영하려면 데이터베이스가 아닌 데이터를 관리하는 팀의 API를 호출하여 가져와야 한다.

한 대의 서버로 감당이 안되면 사용자 ID를 기준으로 샤딩하면 관계형 데이터베이스는 수평적 규모 확장이 가능하다.

 

위치 정보 캐시

사용자의 위치 정보를 캐시하기 위해 레디스를 사용했다.

  •  TTL을 설정하여 사용자의 위치 정보가 갱신될 때마다 초기화 되기 때문에 최대 메모리 사용량은 일정 한도 아래로 유지된다.
  • 만약 최고 고사양 서버를 사용해도 부담되면, 캐시 데이터는 쉽게 샤딩할 수 있다.
  • 사용자의 위치정보는 서로 독립적인 데이터로, 사용자 ID를 기준으로 여러 서버를 샤딩하면 된다.
  • 가용성을 높이려면 위치 정보를 대기 노드에 복제하면된다.

레디스 펍/섭 서버

  • 펍/섭 서버를 모든 온라인 친구에게 보내는 위치 변경 내역 메시지의 라우팅 계층으로 활용한다.
  • 채널을 만드는 비용이 아주 저렴하다.
  • 채널 하나를 유지하기 위해서는 구독자 관계를 추적하기 위한 해시 테이블과 연결 리스트가 필요한데 아주 소량의 메모리만을 사용한다.
  • 오프라인 사용자라 어떤 변경도 없는 채널의 경우에는 생성된 이후에 CPU 자원은 전혀 사용하지 않는다.
    => 본 설계는 이 점을 이용해서 아래와 같이 활용한다.
    1. '주변 친구' 기능을 사용하는 모든 사용자에 채널 하나씩 부여한다. 앱 초기화 시에 모든 친구의 채널과 구독 관계를 설정한다.
    2. 더 많은 메모리를 사용하겠지만, 메모리가 병목이 될 가능성이 낮다. 아키텍처를 단순하게 만들 수 있다면 더 많은 메모리를 투입한다.

얼마나 많은 레디스 펍/섭 서버가 필요한가? 

  • 메모리 사용량
    • 모든 사용자에게 채널 하나씩 할당한다고 하면, 200GB(1억명 * 20바이트 * 100명의 친구 / 10%)
  • CPU 사용량
    • 기가비트 네트워크 카드를 탑재한 현대적 아키텍처의 서버 한대로 감당 가능한 구독자의 수는 100,000이라고 가정
    • 추정치에 따라 필요한 레디스 서버의 수는 1400만/100,000 = 140대
    • 레디스 펍/섭 서버의 병목은 메모리가 아니라 CPU사용량이다
    • 풀어야 하는 문제의 규모를 감당하려면 분산 레디스 펍/섭 클러스터가 필요

분산 레디스 펍/섭 서버 클러스터

1) 해시링

  • 레디스 채널은 서로 독립적이라 메세지를 발행할 사용자 ID를 기준으로 펍/섭 서버를 샤딩하면 된다.
  • 본 설계안에서는 서비스 탐색 컴포넌트(etcd, 주키퍼 등)를 도입하여 문제를 푼다.
    => 서비스 탐색 이용은 아래 두 가지 기능만을 사용한다.
    1. 가용한 서버 목록을 유지하는 기능 및 해당 목록을 갱신하는데 필요한 UI나 API
      • 즉, 서비스 탐색 소프트웨어는 설정 데이터를 보관하기 위한 소규모의 키-값 저장소(해시 링)라고 보면 된다.
        • 키: /config/pub_sub_ring
        • 값: ["p_1", "p_2", "p_3", "p_4"]
    2. 클라이언트(웹소켓 서버)로 하여금 '값'에 명시된 레디스 펍/섭 서베에서 발생한 변경 내역을 구독할 수 있도록 한다.
  • '키'에 매달린 '값'에는 활성 상태의 모든 레디스 펍/섭 서버로 구성된 해시 링을 보관한다.

2) 웹소켓 서버가 특정 사용자 채널에 위치 정보 변경 내역을 발행하는 과정
=> 구독할 채널이 존재하는지 찾는 과정도 동일.

  1. 해시 링을 참조하여 메세지를 발행한 레디스 펍/섭 서버를 선정
  2. 해당 서버(웹소켓 서버)가 관리하는 사용자 채널에 위치 정보 변경 내역을 발행

레디스 펍/섭 서버 클러스터의 규모 확장 고려사항

  • 레디스 펍/섭 서버 클러스터는 유상태 서버 클러스터로 취급하는 것이 바람직하다.
    • 펍/섭 채널에 전송되는 메세지는 메모리나 디스크에 지속적으로 보관되지 않는다.
      => 채널의 구독자에게 전송되고 나면 바로 삭제되기 때문에 펍/섭 채널을 통해 처리되는 데이터는 무상태라고 볼 수 있다.
    • 펍/섭 서버를 교체하거나 해시링에서 제거하는 경우, 해당 채널의 모든 구독자에게 사실을 알려야한다.
      => 이런 관점에서 보면 펍/섭은 유상태 서버이다.
  • 유상태 서버는 혼잡 시간대 트래픽을 무리 없이 감당하고 불필요한 크기 변화를 피할 수 있도록 어느 정도 여유를 두고 오버 프로비저닝하는 것이 보통이다.
  • 클러스터 크기 조정 절차
    1. 새로운 링 크기를 계산하고 늘어나는 경우에 새 서버를 준비
    2. 해시 링의 키에 매달린 값을 새로운 내용으로 갱신
    3. 대시보드 모니터링

운영 고려사항

  • 기존 레디스 펍/섭 서버를 새 서버로 교체할 때 운영 문제가 발생할 가능성은 클러스터 크기를 조정할 때보다 훨씬 낮다.
  • 운영 순서 
    1. 담당자는 서비스 탐색 컴포넌트의 해시 링 키에 매달린 값을 갱신하여 장애가 발생한 노드를 대기 중인 노드와 교체한다.
    2. 교체 사실은 모든 웹 소켓 서버에 통지하고, 각 웹소켓 서버는 실행 중인 연결 핸들러에게 새 펍/섭 서버의 채널을 다시 구독하도로록 알린다.
    3. 연결 핸들러는 해시 링과 대조하여 새 서버로 구독 관계를 다시 설정해야 하는지 검토

친구 추가/삭제

처리 순서

  1. 새 친구가 추가/삭제 되면 콜백이 호출되면 웹소켓 서버로 새 친구의 펍/섭 구독(또는 취소)를 하라고 메세지를 보낸다.
  2. 구독인 경우, 웹소켓 서버는 해당 친구가 활성화 상태이면 가장 최근 위치 및 시각 정보를 응답 메시지에 담아 보낸다.

친구가 많은 사용자

  • 친구 관계는 양방향이며, 팔로어 모델처럼 단방향의 관계는 배제하기 떄문에 유명 인사 시스템은 존재할 수 없다.
  • 펍/섭 구독 관계는 클러스터 내의 많은 웹소켓 서버에 분산되어 있다.
    => 부하는 각 웹소켓 서버가 나누어 처리하므로 핫스팟 문제는 발생하지 않는다.

주변 임의 사용자

Q. 정보 공유에 동의한 주변 사용자를 무작위로 보여줄 수 있도록 하는 기능을 추가해보자

A. 지오해시에 따라 구축된 펍/섭 채널 풀을 두는 것이다.

  • 처리 순서
    1. 사용자 위치가 변경되면 웹소켓 연결 핸들러는 해당 사용자의 지오해시 ID를 계산
    2. 지오해시 ID를 담당하는 채널에 새위치를 전송
    3. 근방에 있는 상요자 가운데 해당 채널을 구독하고 있는 사용자는 특정 사용자의 위치가 변경되었다는 메세지를 수신
  • 격자 경계 부근에 있는 사용자를 잘 처리하기 위해 지오해시뿐만 아니라 주변 지오해시 격자를 담당하는 채널도 구독한다.

레디스 펍/섭 외의 대안

  • 라우팅 계층으로 펍/섭을 사용했다.
  • 얼랭(Erlang)은 지금 문제에 유용한 해결책이 될 수 있다.
    • 고도로 분산된 병렬 애플리케이션을 위해 고안된 프로그래밍 언어이자 런타임 환경이다.
    • 얼랭 생태계는 언어 컴포넌트뿐만 아니라 실행 환경 및 라이브러리를 아우른다.
    • BEAM VM에서 실행되는 개체로 생성 비용이 리눅스 프로세스 생성 비용에 비해 엄청 저렴하다. 
  • 웹 소켓 서비스를 얼랭을 구현하고, 레디스 펍/섭 클러스터는 분산 얼랭 애플리케이션으로 대체할 수 있다.
    • 각 사용자는 얼랭 프로세스로 표현
    • 프로세스는 클라이언트가 전송하는 갱신된 사용자 위치를 웹소켓 서버를 통해 수신
    • 친구 관계에 있는 사용자의 얼랭 프로세스와 구독 관계를 설정하고 위치 변경 내역을 수신

4단계: 마무리

개념적으로 살펴보면 어떤 사용자의 위치 정보 변경 내역을 그 친구에게 효율적으로 전달하는 시스템을 설계

  • 웹소켓: 클라이언트와 서버 사이의 실시간 통신을 지원
  • 레디스: 위치 데이터의 빠른 읽기/쓰기를 지원
  • 레디스 펍/섭: 사용자의 위치 정보 변경 내역을 모든 온라인 친구에게 전달하는 라우팅 계층

규모를 늘리는 구성요소

  • RESTful API 서버
  • 웹소켓 서버
  • 데이터 계층
  • 레디스 펍/섭 서버 클러스터
  • 레디스 펍/섭 서버의 대안