책을 읽고 중요한 부분을 기록하고, 이해가 부족했던 부분을 탐구합니다.
예상 독자는 같은 책의 같은 내용을 읽은 분들로, 편의상 상세 내용은 생략합니다.
계략적 설계안
10억 명의 사용자로 가정,
1억 DAU 로 가정,
동시 접속 사용자는 10% 로 가정, 1,000 만
사용자는 30초마다 자기 위치를 시스템에 전송한다.
위치 정보 갱신 QPS = 천만 / 30 =~ 334,000
QPS 계산은 늘 흥미롭습니다.
이제 여기서 인당 400 명의 친구를 갖는다고 가정하고, 그 가운데 10%가 인근에서 활성화 상태라고 가정하면,
334,000 * 400 * 10 % = 1,400만
즉, 초당 1,400 만 건의 위치 정보 갱신 요청이 처리되어야 하는 것입니다.
이와 같은 시스템 설계에 초경량 메시지 버스 레디스 펍/섭이 아주 주요한 역할을 합니다. 레디스 펍/섭에 새로운 채널을 생성하는 것은 아주 갑싼 연산이기 때문에, 기가바이트급 메모리를 갖춘 최신 레디스 서버에는 수맥만 개의 채널을 생성할 수 있습니다.
다음 이미지는 클라이언트가 주기적으로 위치를 갱신하고, 구독자들이 그 위치 변경 내역을 전송 받는 과정을 도식화 합니다.
한 사용자당 평균 400명의 친구가 있고, 그 가운데 10%가 인근에서 활성화 상태라고 가정하였으니 한 사용자의 위치가 바뀔 때마다 위치 정보 전송은 40건 정도 발생한다고 이해할 수 있을 것 같습니다.
(사용자 위치변경 -> 400명 구독 친구에게 전달 -> 그 중 활성화, 인근 거리인 친구 40명에게만 정보가 전송)
여기다가 QPS 인 334,000 을 곱하면 위에서 계산한 초당 위치 정보 갱신 요청량 1,400 만이 되는 것이겠죠.
데이터 모델
위치 정보 캐시에서 '주변 친구' 기능을 켠 활성 상태 친구의 가장 최근 위치를 보관합니다.
키: 사용자 ID / 값: {위도, 경도, 시각}
주변 친구 기능은 사용자의 현재 위치만 이용하기 때문에, 데이터베이스에 따로 저장할 필요가 없습니다. 물론 별도 서비스(광고 등)를 위해 로그를 저장하는 요구사항이 존재할 확률이 높겠지만, 병렬적으로 처리 가능한 부분이고, 위 서비스에서는 논외입니다. 다만, 로그 수집을 위해선 막대한 쓰기 연산을 감당할 수 있는 카산드라를 활용하는 방법이 있다 정도로 짚고 넘어가겠습니다.
로그 스키마 (예상): user_id, latitude, longitude, timestamp
상세 설계
웹 소켓 서버
양방향 통신을 위한 웹소켓 서버가 필요하고, 많은 요청을 처리하기 위해서 이를 클러스터로 구성하여 규모 확장성을 만족시킬 수 있습니다. 다만, 웹소켓 서버는 유상태 서버 (HTTP 처럼 요청이 각각 독립적이지 않은 경우)이므로 기존 서버를 제거할 때는 주의해야 합니다.
노드를 실제로 제거하기 전에 우선 기존 연결부터 종료될 수 있도록 해야 합니다. 이를 위해, 로드밸런서가 인식하는 노드 상태를 '연결 종료 중' 으로 변경해두고, 모든 연결이 종료되면 (혹은 충분한 시간이 흐른 뒤에) 서버를 제거하면 됩니다.
결국 유상태 서버 클러스터의 규모를 자동으로 확장하려면 좋은 로드밸런서가 있어야하고, 대부분의 클라우드 로드밸랜서는 이런 일을 잘 처리한다고 합니다.
근데, 여기서 잠깐, 노드를 제거할 일이 무엇이 있을까요? 단순합니다. 사용자가 몰리지 않는 시간대에 더이상 웹소켓 서버가 필요하지 않으니 이 수를 줄여야 할 필요가 있는 것이겠죠
클라이언트 초기화
처음 유저가 활성화가 될 때에 본인의 위치를 업데이트함과 동시에 다른 친구들에 위치 정보를 받아오기 시작해야 할 것 입니다. 이 단계를 간략하게 표현하면
본인 위치 갱신 -> DB 에서 내 친구들 정보 조회 -> 위치 정보 캐시에 일괄 (batch) 요청을 통해 활성화된 모든 친구 위치 조회 (페이스북 기준 최대 5,000명) -> 각 친구 위치에 대해 거리 계산-> 각 친구의 레디스 서버 펍/섭 채널 구독 (친구의 활성화/비활성화 여부 관계없이) -> 현재 내위치 펍/섭 전용 채널을 통해 Boradcasting
여기서 몇 가지 궁금한 점이 생겼습니다.
1. 사용자 DB 에서 해당 사용자의 모든 친구 정보를 가져오는 과정은 얼마나 걸릴까?
RDBMS 라고 생각했을 때, 10억명의 유저가 유저 테이블에 저장되고, 각각 최대 5,000 명의 연관관계가 연관관계 테이블에 존재할 것 같습니다. 이는 하나의 DB 서버로 가능할까요? 이 부분이 병목 지점이 될 수 있지 않을까 하는 의문이 들었습니다.
책에 이와 관련한 설명이 나와있지 않기에, 이 부분은 직접 적당한 가정을 통해 유추해 보겠습니다.
유저 테이블에 한 레코드에 이름, 주소, 생년월일, 프로필 이미지 url 등등 다양한 데이터가 포함될 수 있으며 이를 대충 400 byte 정도로 가정해 보겠습니다. 그러면 단순히 10억에 400byte 를 곱해보면, 400GB 가 됩니다. 이는 사실 DBMS 의 입장에서는 작은 사이즈에 불과할 것 같습니다. 실제로, DBMS 가 관리할 수 있는 데이터 크기에는 제한이 없고, PB 단위까지도 기능상의 문제는 없을 것입니다. 다만 하드웨어의 제약이 존재할 것이고, 다른 지점에서 병목이 발생할 수 있겠죠.
이 예시에서 1억 DAU와 천만명의 동시접속자를 가정하였으므로, 약 한시간 기준으로 천만명의 접속자가 빠져나가고, 천만명의 새로운 접속자가 생겨난다고 가정해 봅시다. 이렇게 되면, 한 사용자의 모든 친구 정보를 가져오는 요청이 천만 / 3600s =~ 약 2800 QPS 정도의 속도로 들어오게 들어옵니다. 평균적으로 친구가 400 명이 있다고 하였으니, 아이디 목록을 가져오는데에 2800 QPS * 400 명 * 20 byte =~ 22.4MB, 초당 약 22.4MB 의 네트워크 요청만 처리되면 됩니다.
1초에 22.4MB 면 얼마 되지 않아보이네요. 네트워크 처리량은 문제가 되지 않을 것 같습니다. 그 대신 그 많은 응답을 만들기 위한 DBMS 의 부하, 디스크 I/O 등의 처리량을 고려해야겠죠.
이제 한 쿼리를 실행하는데에 CPU time 을 계산하고, 이를 QPS 에 곱하고, CPU 처리량이 bottle neck 인지 알아보고 하는 등의 계산도 할 수 있을 것 같습니다. 다만, 이 부분은 포스트에서 더 이상 진행하지 않겠습니다. 실무에서 경험한 지식이 없기 때문에, 정확한 이해가 부족한 것 같습니다. 실제로는 모니터링 등과 병행되어야 하는 항목인 것 같습니다. 나중에 조금 더 공부해보고, 경험을 쌓은 뒤 내용을 추가 및 수정하겠습니다.
2. 활성화/비활성화 여부 관계없이 구독하는 이유
비활성화 상태 친구의 펍/섭 채널을 유지하기 위한 메모리가 필요한 것은 사실이지만 소량이며, 활성화 상태로 전환되기 전에는 CPU 나 I/O 를 전혀 이용하지 않기 때문에 부담이 크지 않고, 이 부담에 비해 설계가 단순해지기 때문에 얻을 수 있는 이점이 훨씬 큽니다.
활성화 상태로 바뀐 친구의 채널을 구독하거나 비활성 상태가 된 친구의 채널을 구독 중단하는 등의 작업을 할 필요가 없기 때문이죠.
즉, 처음부터 '주변 친구' 기능을 활용하는 모든 사용자에게 채널 하나씩을 부여 하고, 초기화 과정에서 모든 친구의 채널과 구독 관계를 설정합니다.
여기서 잠깐, 1억명에게 채널 하나씩을 할당해도 괜찮은 걸까요? 메모리 감당 가능한 수준일까요?
주변 친구 기능을 활용하는 사용자 수 1억명
-> 채널 개수 1억개
한 사용자의 활성화 상태 친구 100명 으로 가정
구독자 한 명 추적에 필요한 내부 해시 테이블과 연결 리스트의 크기 20 Byte
모든 채널을 저장하는 데는 200GB(1억 * 20Byte * 100명의 친구 / 10^9)
100GB 메모리를 설치할 수 있는 최신 서버를 사용하는 경우, 모든 채널을 보관하는 데 레디스 펍/섭 서버 단 두대면 될 것 같습니다.
그럼 서버 두대 땅땅땅, 결정 일까요?
아닙니다. CPU 사용량을 고려해 보아야 합니다. 가장 위에서 살펴 보았듯이, 위치 정보 갱신 요청 양은 초당 1400만 건에 해당합니다. 실제 벤치마크 없이 최신 레디스 서버 한대로 얼마나 많은 메시지를 전송할 수 있는지 정확히 알 수는 없지만, 저자는 서버 한대로 그 정도 규모의 전송량을 처리하기는 곤란하다고 보았습니다.
보수적으로 기가비트 네트워크 카드를 탑재한 현대적 아키텍처의 서버 한 대로 감당 가능한 구독자의 수는 100,000 이라고 가정해 보도록 합니다. 이 추정치에 따르면 필요한 레디스 서버의 수는 1400만 / 100,000 = 140대 입니다.
이러한 계산 결과를 통해 다음과 같은 결론을 내릴 수 있습니다.
- 레디스 펍/섭 서버의 병목은 메모리가 아닌 CPU 사용량이다.
- 이 설계안에서 풀어야 하는 문제의 규모를 감당하려면 분산 레디스 펍/섭 클러스터가 필요하다.
모든 채널은 독립적이기 때문에 메세지를 발행할 사용자 ID 를 기준으로 펍/섭 서버들을 샤딩하면 됩니다. 하지만 현실적으로는 수백 대의 펍/섭 서버가 관련된 문제이므로 그 동작 방식을 상세하게 짚어보아야 합니다. 이에 대한 내용은 밑에서 다시 다뤄보겠습니다.
3. 현재 내 위치를 전송할 수 있는 채널은 언제 만들어 지는거야? broadcasting 하려면 일단 내 채널이 있어야 하잖아.
2번 의문점을 답변하는 과정에서 해답이 나온 것 같습니다. '주변 친구' 기능을 활용하는 모든 사용자에게 채널을 미리 부여하고, 삭제하지 않기 때문에 이는 크게 문제되지 않습니다.
4. 위치 정보 캐시는 어떤식으로 설계할까?
활성화 상태 사용자의 위치 정보를 캐시하기 위해 레디스를 활용할 수 있습니다. TTL 을 설정하여 위치 정보가 갱신될 때마다 초기화 하기 때문에, 최대 메모리 사용량은 일정 한도 아래로 유지됩니다.
시스템이 가장 붐빌 때 천만 명의 사용자가 활성화 상태이며, 위치 정보 보관에 100바이트가 필요하다고 가정해도 약 1GB 정도로, 수 GB 이상의 메모리를 갖춘 최신 레디스 서버 한 대로 모든 위치 정보를 캐시할 수 있습니다.
다만, 천만 명의 활성 사용자가 30초마다 변경된 위치 정보를 전송한다고 가정하면, 레디스 서버가 감당해야 하는 갱신 연산 수는 초당 33만번 이상으로, 고사양 서버를 쓴다 해도 살짝 부담되는 수치라고 합니다.
다행스럽게도, 캐시할 데이터는 사용자 ID 를 기준으로 쉽게 샤딩할 수 있어, 적절한 샤딩이 필요할 것 같습니다.
가용성을 높이기 위해 각 샤드에 보관하는 위치 정보를 secondary 노드에 보관해 두고, primary 노드에 장애가 발생하면 대체하는 방식을 활용할 수 있습니다.
분산 레디스 펍/섭 서버 클러스터
이 서비스의 핵심요소인 분산 레디스 펍/섭 서버 클러스터에 대하여 짚고 넘어가겠습니다.
다행히 모든 채널은 서로 독립적이기 때문에 메세지를 발행할 사용자 ID 기준으로 펍/섭 서버를 샤딩하면 수백 대의 레디스 서버에 채널을 쉽게 분산할 수 있을 것입니다.
내부적으로 etcd 나 ZooKeeper 를 서비스 탐색 컴포넌트로 사용하여, 해시 링 값을 키-값 쌍으로 저장할 수 있습니다. 안정 해시 기법을 활용하여 채널을 균등하게 분산하면서도, 메세지를 발행할 채널이나 구독할 채널을 정해야 할 때에는 해시 링 값 참조를 통해 이에 해당하는 펍/섭 서버를 수월하게 찾을 수 있습니다.
서비스 탐색 컴포넌트에 보관되어 있는 해시 링의 사본을 웹소켓 서버에 캐시하는 방식으로 성능 효율을 높일 수도 있겠죠. 그 경우에는 웹소켓 서버가 링 원본을 구독하며 사본의 상태를 원본과 동일하게 유지해야 합니다.
레디스 펍/섭 서버 클러스터 규모 확장시 고려사항
이를 고려하기 위해서 먼저 이러한 클러스터가 무상태 서버인지, 유상태 서버인지 구분할 필요가 있습니다. 만약 무상태 서버라면 큰 부담 없이 규모를 늘리거나 줄일 수 있습니다. 하지만 유상태 서버라면 각 서버가 가지고 있던 데이터는 어떻게 처리할지 부터 고민이 시작 됩니다.
먼저 레디스 펍/섭 채널에 전송되는 메시지는 메모리나 디스크에 지속적으로 보관되지 않고, 모든 구독자에게 전송되고 나면 삭제됩니다. 이런 관점에서 보면 펍/섭 채널을 통해 처리되는 데이터는 무상태라고 볼 수 있을 것 같습니다.
하지만 펍/섭 서버는 채널에 대한 상태 정보를 보관합니다. 각 채널의 구독자 목록이 그 상태 정보의 핵심적 부분이죠. 특정한 채널을 담당하던 펍/섭 서버를 교체하거나 해시 링에서 제거하는 경우 채널은 다른 서버로 이동시켜야 하고, 해당 채널의 모든 구독자에게 그 사실을 알려야 합니다. 그래야 그 기존 채널에 대한 구독 관계를 해지하고 새 서버에 마련된 대체 채널을 다시 구독할 수 있기 때문입니다 이런 관점에서 보면 펍/섭은 유상태 서버입니다.
유상태 특징이 하나라도 있으면 이는 전체적으로 유상태 서버로 취급하는게 바람직합니다. 그리고, 유상태 서버 클러스터의 규모를 늘리거나 줄이는 것은 운영 부담과 위험이 큰 작업이므로, 처음부터 혼잡 시간대 트래픽을 무리 없이 감당하고 불필요한 크기 변화를 피할 수 있게 어느 정도 여유를 두고 오버 프로비저닝 하는 것이 보통입니다.
마무리
2장에서는 '주변 친구' 기능의 설계안을 살펴보았습니다. 개념적으로 보자면 어떤 사용자의 위치 정보 변경 내역을 그 친구에게 효율적으로 전달하는 시스템을 설계한 것입니다.
이 시스템에 핵심 컴포넌트는 웹소켓, 레디스, 레디스 펍/섭 이라고 볼 수 있을 것 같습니다.
'대규모 시스템 설계 기초 2' 카테고리의 다른 글
3장 - 구글 맵 (0) | 2024.05.11 |
---|