※ 가상 면접 사례로 배우는 대규모 시스템 설계 기초 2권 - 챕터 1을 읽고 정리한 글입니다.
들어가며
아키텍처에 정답은 없으나, 내 주변 맛집 찾기 서비스와 같이 정적인 위치에 대한 서비스를 설계할 때는 이 공간 데이터들을 DB에 적재해둔 다음 지오해시나 쿼드트리와 같은 공간 데이터 검색을 위한 인덱스를 활용하는 형태의 아키텍처를 구상해볼 수 있습니다. 그러나 내 친구들의 현재 위치와 같이 자주 바뀌는 동적인 위치에 대해 다룰 때는 조금 다른 형태의 아키텍처를 고려할 필요가 있습니다. 이번에는 페이스북처럼 인근에 있는 친구들의 목록을 보여주는 서비스를 위한 아키텍처를 설계해봅니다.
냅다 무대뽀로 설계하면 고려할 것이 많으므로.. 다음과 같은 기능적 요구사항을 가정하겠습니다.
- 본인과 친구들의 직선거리를 기준으로, 특정 거리 이하의 친구들의 목록을 볼 수 있어야 합니다.
- 목록의 각 항목엔 그 친구까지의 거리, 해당 정보가 갱신된 시각을 표기해야 합니다.
- 이 친구 목록은 친구들의 몇 초마다(=친구들의 위치가 바뀔 때마다로 해석) 갱신되어야 합니다.
- 10분 이상 비활성 상태인 친구들은 목록에 포함하지 않습니다.
- 유저들의 위치 히스토리도 별도로 기록해야 합니다.
다음과 같은 비기능적 요구사항도 가정하겠습니다.
- 각 사용자들의 위치 정보는 30초마다 갱신된다고 하겠습니다. 이때 이들의 위치 변화가 반영되는 데 오랜 시간이 걸리지 않아야 합니다. (책에선 낮은 지연성 = low latency로 표현됩니다)
- 일부 데이터가 유실돼도 괜찮습니다.
- 위치 데이터에 강한 일관성은 요구하지 않아도 됩니다. 이 말은 복제본을 사용할 경우 복제본과 원본 DB의 데이터가 순간 달라지는 현상이 발생해도 몇 초까지는 눈감아준다는 말입니다.
혼자 생각해보기 - HTTP 폴링 기반의 설계
우선 사용자들의 기본적인 정보와 사용자간 친구 관계를 가지는 사용자 DB를 두는 것을 생각해볼 수 있고, 위치 히스토리는 쓰기 위주의 요청을 많이 받게 될 것이므로 위치 히스토리 DB도 따로 두는 것을 생각해볼 수 있습니다. 사용자들의 현재 위치 정보만 갖고 있는 캐시 서버를 별도로 구축할 수 있고, TTL을 10분으로 설정하면 "해당 캐시 서버에 위치 정보가 있는 사용자 = 활성 상태 사용자"로 취급할 수 있으므로 해당 캐시 서버를 활성 상태인 주변 친구들을 검색하는 데 활용할 수 있습니다.
"사용자들의 위치 변화가 반영되는 데 오랜 시간이 걸리지 않아야 한다"라는 비기능적 요구사항으로 인해, 책에서는 웹소켓을 사용해 친구들의 위치 변화를 실시간에 가깝게 처리하는 아키텍처를 선보입니다. 만약 해당 요구사항이 없었다면 개인적으론 30초마다 HTTP 요청을 보내는 방식을 사용하는 아키텍처를 설계할 수 있다고 생각합니다. 이 경우 다음과 같은 개략적인 설계가 가능해보입니다. (사실 시스템 설계를 해본 경험이 없다시피 하다보니.. HTTP를 쓴다면 어떻게 할 수 있을지를 생각해볼 겸 설계해봤습니다.)
- 모바일 클라이언트가 30초마다 자신의 위치 정보를 담아 로드밸런서로 HTTP 요청을 보냅니다.
- 로드밸런서가 URL을 보고 인근 친구 검색 서버로 요청을 전달합니다.
- "유저별 현재 위치"를 담는 캐시 서버에 내 위치를 갱신하고, 위치 히스토리에 타임스탬프와 내 위치를 기록합니다. 이 작업들은 병렬로 수행합니다.
- 캐시 서버로부터 내 친구 목록을 조회합니다. 없으면 원본 DB에서 캐싱해옵니다.
- 내 친구 목록과 "유저별 현재 위치"를 담는 캐시 서버를 활용해 친구들의 현재 위치를 조회하고, 특정 거리 이하인 애들만 필터링하여 클라이언트에게 응답합니다.
책에서는 사용자들이 서버로 보내는 위치 정보 변경 전달에 대한 QPS가 334,000 정도의 상황임을 가정하고 있으니, 그에 따른 추가적인 설계가 덧붙여져야 할 것 같습니다. 암튼 이렇게 하면 30초마다 인근에 있는 내 친구들의 목록을 받을 수는 있지만, 그들의 위치 변화가 내 모바일 기기로 실시간으로 전송되는 것은 아닙니다. 책에서는 친구들의 위치 변화를 내 모바일 기기로 실시간에 가깝게 전송할 것을 요구하므로 그에 맞춘 설계안을 살펴보겠습니다.
설계
1) 개략적인 설계
친구들의 위치 변화를 내 기기로 실시간에 가깝게 받으려면 각 기기들을 P2P로 직접 통신하도록 이을 수 있으나, 모바일 기기 특성 상 통신 연결 상태가 좋지 않을 수 있고 일반적인 경우엔 가용한 전력이 한정되어 있음을 고려해야 합니다. 각 기기들이 직접 통신하게 하는 것보다는 중간에 공용 백엔드를 두고 해당 서버를 통해 각자의 위치 정보를 전달하는 방안을 고려할 수 있겠습니다.
즉, (1)특정 유저가 본인의 위치를 백엔드로 전달하면 (2)해당 서버가 다른 유저들에게 그 유저의 위치 변화를 전달해야 합니다. HTTP는 요청이 와야 응답을 주는 프로토콜이므로 (1)은 HTTP로 처리가 가능하나 (2)는 처리가 힘듭니다. 이때 전이중 통신이 가능한 웹소켓 프로토콜을 사용하도록 서버를 구성한다면 (1)과 (2)를 비교적 쉽게 처리할 수 있습니다.
이때 내 위치 변화를 내 친구들한테만 전달하면 되므로, 나와 친구들 사이에 pub / sub 기반의 메시징 기능을 사용하는 것을 고려할 수 있습니다. 대표적으로 Redis pub / sub을 다음과 같이 사용할 수 있습니다.
웹소켓 서버로수신된 사용자의 위치 정보에 대한 이벤트를 해당 사용자의 채널에 발행(publish)하면, 그 채널을 구독(subscribe)하고 있는 친구들에게 위치 정보 변경이 전달됩니다. 위치 정보 변경을 수신한 친구들이 활성 상태라면 거리를 다시 계산하고, 새로 계산된 거리가 유효 거리라면 웹소켓 연결을 통해 해당 친구의 모바일 기기 단말로 새 위치와 갱신 시각을 보내는 방식으로 설계할 수 있습니다.
또한 웹소켓 서버가 스케일 아웃되면 나랑 내 친구가 웹소켓 연결을 맺고 있는 서버가 달라지는 상황도 발생할 수 있는데요. Redis pub / sub 서버를 사용할 경우 내 위치 정보 변경이 내 친구와 웹소켓 연결을 맺고 있는 서버로도 전달될 수 있으므로 해당 상황에 대한 대응이 가능합니다.
결국 개략적으로 다음 형태의 아키텍처를 고려할 수 있겠습니다.
- 모바일 클라이언트가 30초마다 자신의 위치 정보를 담아 로드밸런서로 요청을 보냅니다.
- 로드밸런서는 해당 클라이언트가 연결을 맺고 있는 웹소켓 서버로 해당 요청을 전달합니다.
- 웹소켓 서버는 수신받은 위치 정보를 위치 히스토리 DB에 저장합니다.
- 웹소켓 서버는 수신받은 위치 정보를 유저별 현재 위치를 담는 캐시 서버에 갱신하고, 웹소켓 연결 핸들러 안의 변수에 해당 위치를 반영합니다.
- 웹소켓 서버는 수신받은 위치 정보를 Redis pub / sub 서버의 사용자 채널에 발행합니다. 3 ~ 5까지의 작업은 병렬 수행합니다.
- Redis pub / sub에 발행된 위치 변경 이벤트는 모든 구독자들에게 브로드캐스트됩니다.
- 6에서 발생된 위치 변경 이벤트를 받은 웹소켓 연결 핸들러가 있는 웹소켓 서버들은 해당 정보를 바탕으로 새 거리를 계산합니다.
- 7에서 계산한 거리가 유효한 거리라면 타임스탬프와 함께 해당 구독자의 모바일 기기로 웹소켓 프로토콜을 통해 전송합니다.
5 ~ 8까지의 과정을 도식화해서 살펴보면 다음과 같습니다.
2) 위치 히스토리 DB
위치 히스토리가 이 서비스에서 주요한 기능은 아니나, 어떤 DB에 저장할 지는 고려하는 것이 좋습니다. 우선 어떤 데이터를 저장할 지를 생각해보면 사용자 식별자와 위도 경도 정보, 타임스탬프값을 저장하면 될 것입니다. 책에서는 QPS가 334,000이므로, 막대한 쓰기 연산을 감당할 수 있어야 하며 대규모의 데이터 저장이 예상되는 만큼 수평적 확장이 가능해야 할 것입니다. 카산드라(Cassandra)는 데이터 쓰기 시 메모리에 먼저 데이터들을 저장하다가 한 번에 디스크로 flush하는 구조라 쓰기 성능이 좋고, 스케일 아웃이 용이하므로 이 요구사항에 적합합니다. 관계형 DB도 사용할 수는 있으나 대규모의 데이터가 예상되는만큼 이 경우는 샤딩을 고려해야겠습니다.
상세 설계
1) API 서버의 확장성
친구 추가, 사용자 정보 상세 조회 등을 담당하는 API 서버는 무상태 서버이므로, CPU 사용률 등에 따라 동적으로 서버 수를 늘리거나 줄이도록 설정할 수 있습니다.
2) 웹소켓 서버의 확장성
웹소켓 서버는 유상태 서버로, 특정 사용자와 연결을 맺으면 사용자와의 통신은 연결을 맺은 서버와만 이루어진다는 특징을 고려하여 확장/축소를 생각해야 합니다. 확장의 경우 API 서버와 마찬가지로 사용률 등에 따라 서버를 늘릴 수 있고, 로드밸런서에서 부하 분산 알고리즘으로 Least-Connections를 사용하면 각 웹소켓 서버들에 맺힌 연결의 개수를 어느 정도 균등히 유지할 수 있습니다. 다만 서버의 규모를 축소시킬 때는 해당 서버에 있던 연결들이 종료될 수 있도록 주의할 필요가 있습니다. 이때 해당 서버를 로드밸런서가 draining(연결 종료 중)으로 인식하도록 설정하면, 해당 서버로는 더 이상 웹소켓 연결이 맺어지지 않도록 할 수 있습니다. 참고로 이를 인플라이트 요청(현재 활성화된 요청)들만 처리하도록 설정한다고도 표현합니다. 암튼.. 그 상태로 충분한 시간이 흐른 뒤 연결들이 모두 종료되면 서버를 제거할 수 있습니다.
참고로 서버 제거 시 draining을 설정하는 것은 웹소켓 서버에만 국한된 얘기가 아니며, AWS에선 다음과 같이 300초를 디폴트로 draining이 설정되어 있습니다.
3) 클라이언트 초기화
모바일 기기에서 주변 친구 서비스를 최초 사용할 경우, 웹소켓 클러스터에 있는 서버 가운데 하나와 연결을 맺게 됩니다. 최초 연결 시 모바일 기기에서 사용자의 위치 정보를 송신하게 되면 웹소켓 서버는 구체적으로 다음과 같은 작업을 하도록 설계할 수 있습니다.
- 위치 정보 캐시에 해당 사용자의 위치 갱신하고 해당 위치를 웹소켓 연결 핸들러 내의 변수에 저장합니다
- 사용자 DB로부터 해당 사용자의 친구 목록을 가져옵니다.
- 위치 정보 캐시로부터 2번에서 가져온 친구들의 위치를 가져옵니다. 위치 정보 캐시에는 TTL을 10분으로 하여 위치 정보들이 저장되므로, 비활성화된 유저들의 위치 정보는 가져오지 않게 됩니다.
- 각각의 친구 위치들에 대해 거리를 계산하고, 유효한 거리라면 모바일 기기로 전달합니다.
- 2번에서 가져온 모든 친구들에 대해 Redis pub / sub 채널을 구독합니다. 물론 비활성화 친구에 대한 채널을 유지하는 것은 메모리가 필요하나, 극소량인 데다가 활성화 상태로 전환되기 전까진 CPU나 I/O를 이용하지 않으니 크게 고려하지 않아도 됩니다.
- 사용자의 현재 위치를 Redis pub / sub 채널에 발행합니다.
4) 위치 정보 캐시
각 사용자들의 현재 위치 정보를 TTL을 통해 일정 기간 만큼만 보관하므로, 아무리 많아도 "사용자 전체 수 X 위치 정보를 저장하는 데 필요한 공간"이 메모리 사용량의 최대 한도로 유지됩니다. 다만 QPS가 334,000으로 가정된 상황이므로 Redis 서버 한 대가 이를 모두 감당하는 것은 상당히 부담될 수 있습니다. 그러나 사용자별 위치 정보 데이터는 사용자 식별자를 기준으로 비교적 쉽게 샤딩할 수 있고, 가용성을 높이고 싶다면 각 샤드에 보관하는 위치 정보를 standby 노드에 복제하는 방식을 활용할 수 있습니다.
5) Redis pub / sub 서버
이 아키텍처에서 Redis pub / sub 서버는 사용자의 위치 정보 변경을 사용자의 친구들에게 보낼 때의 라우팅 계층으로서 활용되고 있습니다. 주변 친구 기능을 활용하는 모든 사용자에게 채널이 하나씩 부여되며, 단순한 설계를 위해 모바일 기기는 최초 연결 시 활성화 여부와는 상관없이 모든 친구의 채널을 구독합니다. 이 경우 메모리 사용량과 CPU 사용량을 다음과 같이 고려해 볼 수 있습니다.
(a) 메모리 사용량
Redis pub / sub은 메모리에 해시 테이블과 링크드 리스트를 통해 채널과 그 채널의 구독자들을 관리합니다. 구독자 한 명에 대해 20Byte의 용량을 사용하고, 주변 친구 기능의 사용자가 1억 명이고 모두에게 채널 하나씩을 할당한다고 한 뒤 각 사용자의 친구들 중 100명만 활성화 상태들이라고 가정하겠습니다. 그러면 1억 X 20Byte X 100명 = 약 200GB의 메모리를 사용하게 되는 것이며, 100GB의 메모리가 있는 서버를 사용할 경우 Redis pub / sub 서버는 2대 정도만 있으면 되겠습니다.
(b) CPU 사용량
책에서는 사용자들이 위치 정보 변경을 서버로 전달하는 QPS가 334,000인 상황으로, 각 사용자들이 400명 정도의 친구를 가지고 그들 중 10%인 40명이 활성화 상태라고 가정(즉 웹소켓 서버에 그 사용자의 웹소켓 연결 핸들러가 물려있는 상태)하면 Redis pub / sub 서버는 초당 1,400만 건의 위치 정보 변경 이벤트를 전달하게 됩니다. Redis pub / sub 서버 한 대로는 처리하기 힘든 양입니다.. 기가비트 네트워크 카드를 탑재했다고 해도 보수적인 관점에서 1초에 처리 가능한 구독자 수를 100,000 정도로 추정할 수 있다고 하는데(책에서 이렇게 말하는 명확한 근거는 모르겠습니다만..), 그렇다고 해도 1,400만 건 / 10만 = 140대의 서버가 필요합니다. 즉 Redis pub / sub 서버에서 병목이 생기면 메모리가 아닌 CPU 사용량에서 그 이유를 찾을 수 있고, 이에 대한 해결책으로 분산 Redis pub / sub 클러스터를 고려할 수 있습니다.
6) 분산 Redis pub / sub 클러스터
Redis pub / sub 클러스터를 구성하여 각 사용자들이 저마다 하나씩 가지는 pub / sub 채널들을 분산시킬 수 있고, 각 채널들은 서로 독립적이므로 사용자 식별자를 기준으로 어떤 서버에 배정될지 정할 수 있습니다. 이때 Redis pub / sub 클러스터의 규모를 확대 또는 축소시키는 경우도 고려를 해야 하는데, 그러기 위해서는 Redis pub / sub 서버의 성격이 "무상태"인지 아니면 "유상태"인지를 짚어봐야 합니다.
우선 pub / sub 채널에 전송되는 메시지는 구독자들에게 전송된 후 바로 삭제된다는 관점에서는 무상태라고 볼 수 있습니다. 그러나 각 pub / sub 서버들은 자신들이 가지는 채널에 대한 상태 정보(ex : 각 채널의 구독자 목록)을 보관하고 있다는 관점에서 보면 유상태라고 볼 수 있습니다. 그래서 특정 채널을 담당하던 서버가 없어질 경우 그 채널에 매달려있던 구독자 정보들이 없어질 수 있습니다.
즉 Redis pub / sub 클러스터는 유상태 서버 클러스터로 취급하여 관리할 필요가 있습니다. 현재 가용한 pub / sub 서버들의 목록을 유지하고 이 서버들에서 발행한 변경 내역들을 구독할 수 있는 기능을 가진 컴포넌트를 별도로 두는 것을 고려할 수 있고, 대표적으론 주키퍼라는 분산 코디네이션 서비스를 쓸 수 있습니다. 이때 가용한 pub / sub 서버들을 해시 링 형태로 보관하고, 메시지를 발행할 채널 또는 구독할 채널이 있는 pub / sub 서버를 정해야 할 때 이 링을 참조하도록 할 수 있습니다. (이 글의 제일 하단에서 다루겠습니다). 이를 통해 웹소켓 서버가 특정한 채널에 위치 정보 변경을 발행하는 과정을 다음과 같이 나타낼 수 있습니다.
- 웹소켓 서버는 해시 링을 참조해 메시지를 발행할 pub / sub 서버를 결정합니다. 이 과정에서 주키퍼를 활용하나, 성능을 높이고 싶다면 해시 링 사본을 웹소켓 서버 자체에 캐시하는 방법을 사용 가능합니다(즉 주키퍼를 참조하는 네트워크 i/o가 없어짐). 그러나 이 경우는 해시 링 원본에 구독 관계를 설정, 사본의 상태를 항상 원본과 동일하게 유지하도록 추가 설계가 필요합니다.
- 웹소켓 서버가 해당 pub / sub 서버가 관리하는 채널에 메시지를 발행합니다.
그럼에도.. Redis pub / sub 클러스터와 같은 유상태 서버 클러스터의 규모를 확대하거나 축소하는 것은 운영 부담과 위험이 큰 작업인 것은 여전합니다. 따라서.. 어지간하면 처음부터 큼지막하게 오버 프로비저닝을 하는 것이 보통입니다. 그러나 정말 어쩔 수 없이 규모 변경을 불가피하게 진행해야 할 경우 시스템 부하가 가장 낮은 때(ex : 새벽..)에 하는 것이 좋습니다.
※ Consistent Hashing와 Hash ring
위에서 pub / sub 채널들은 서로 독립적이므로 사용자 식별자 등을 기준으로 어떤 서버에 채널을 배정해야 할 지 정할 수 있다고 했습니다. 이와 같이 분산 시스템에서 특정한 값이 해시값에 따라 어느 노드로 갈지 정하는 경우 대표적으로 모듈러 연산을 활용 가능합니다.
ex) 3으로 나눈 나머지에 따라 노드를 배정한다고 하면..
- 1번 : 1번 노드에 배정
- 2번 : 2번 노드에 배정
- 3번 : 0번 노드에 배정
- 4번 : 1번 노드에 배정..
하지만 이 방법은 노드의 수가 변하면 특정 노드에 있던 데이터들이 여전히 그 노드에 남아있을지는 보장되지 않으므로 기존 데이터들을 재분배해야 하는 문제가 있습니다. 이때 안정 해시(Consistent Hashing)을 사용하면 노드 수가 변해도 재분배해야 하는 데이터를 적은 수로 가져갈 수 있으며, 대표적인 방법이 해시 링입니다.
해시 링은 이미지처럼 각 노드("키"로도 이해 가능하며 이미지에선 A, B, C)와 데이터(Jane, Kate 등)를 특정 해시값으로 변환해 링 위에 배치하고, 데이터들이 놓인 위치(해시값 범위)에 따라 어느 노드에 배정될지를 결정하는 방식입니다. 만약 위 이미지에서 C가 사라진다고 하면 C에 붙어있던 John과 Steve만 A로 붙여주면 되고, 특정 노드가 추가된다고 하면 해당 범위에 있는 애들만 다시 붙여주면 됩니다. 따라서 노드 수에 변화가 생겼을 때 모든 데이터를 재분배하지 않고 특정 범위에 해당하는 데이터들만 재분배해줄 수가 있게 됩니다.
다만 단점도 있는데요. 데이터를 균등히 저장하지 못할 수 있다는 단점(해시 특성상 어쩔 수 없다고 생각됩니다)과 노드가 삭제되는 순간에는 인접한 다른 노드로 삭제된 노드에 붙어있던 데이터들이 달라붙게 되어 그 노드에 대한 부하가 커질 수 있고, 최악의 경우 이게 연쇄적인 노드 죽이기(?)가 될 수 있다는 단점이 있습니다. 이는 실제 노드가 여러 개의 논리적인 virtual node들을 만들고, 얘네들을 링 위에 무작위하게 뿌리는 방식으로 어느 정도 보완 가능합니다.
'INFRA & DEVOPS' 카테고리의 다른 글
분산 메시지 큐 설계 (4) | 2024.11.30 |
---|---|
위치 기반 서비스를 위한 아키텍처 설계 - 구글 맵 (2) | 2024.11.21 |
CAP 이론이란 (0) | 2024.11.17 |
위치 기반 서비스를 위한 아키텍처 설계 - 근접성 서비스 (0) | 2024.11.07 |
docker-compose 훑어보기 (0) | 2024.02.07 |