가상 면접 사례로 배우는 대규모 시스템 설계 기초 2 · 2장
주변 친구
1장의 근접성 서비스는 사업장이 거의 안 움직였지만, 이번엔 친구가 계속 움직인다. 그래서 “검색”이 아니라 “실시간 전달” 문제가 되고, 그 중심에 웹소켓과 레디스 펍/섭이 있다.
레디스 펍/섭유상태 서버
요구사항과 숫자
- 반경 5마일(약 8km) 안의 친구를 보여준다. 거리는 직선거리.
- 위치는 30초마다 갱신. 사람이 걷는 속도로는 30초 동안 크게 안 움직인다.
- 10분 넘게 갱신이 없는 친구는 목록에서 사라진다.
- 위치 이력은 따로 저장(머신러닝 등에 활용).
| 항목 | 값 (책의 가정) |
| 기능 사용자(DAU) | 1억 명 |
| 동시 접속자 | DAU의 10% = 1,000만 명 |
| 평균 친구 수 | 400명 |
| 위치 갱신 QPS | 1,000만 ÷ 30초 ≈ 약 33만 4천 |
감 잡기초당 33만 건의 위치 갱신이 들어오고, 각 갱신은 그 사람의 “온라인 친구들”에게 퍼져야 한다. 이 팬아웃이 이 장의 진짜 부하다.
전체 구조
| 컴포넌트 | 역할 |
| API 서버 무상태 | 친구 추가/삭제, 프로필 수정 같은 일반 요청. 오토스케일 쉬움. |
| 웹소켓 서버 유상태 | 클라이언트와 연결을 유지하며 위치 갱신을 주고받는다. |
| 위치 캐시 (레디스) | 활성 사용자의 가장 최근 위치만 보관. TTL로 비활성 사용자 자동 제거. |
| 사용자 DB | 프로필, 친구 관계. |
| 위치 이력 DB (카산드라 등) | 쓰기가 매우 많으므로 쓰기에 강한 DB. 사용자 ID로 샤딩. |
| 레디스 펍/섭 | 가벼운 메시지 버스. 사용자 1명 = 채널 1개. 친구들이 그 채널을 구독. |
펍/섭을 한 문장으로
민지가 움직이면 민지 채널에 새 위치를 발행한다. 민지 채널을 구독하고 있는 건 민지 친구들의 웹소켓 연결 핸들러. 레디스는 받은 메시지를 구독자에게 뿌리고 끝 — 메시지를 저장하지 않는다.
위치 정보 캐시
key: user:1024
value: { lat: 37.4979, lng: 127.0276, timestamp: 1758000000 }
TTL: 10분 (갱신될 때마다 연장)
DB 대신 레디스를 쓰는 이유
- 필요한 건 사용자당 현재 위치 하나뿐. 이력은 위치 이력 DB가 따로 맡는다.
- 읽기·쓰기가 매우 빠르다 — 초당 33만 건 갱신을 받아야 한다.
- TTL 지원 — 10분간 갱신 없으면 키가 사라지고, 그게 곧 “비활성” 판정이 된다.
영속성이 필요 없다
레디스 인스턴스가 죽으면 빈 인스턴스로 바꿔 끼우면 된다. 30초마다 갱신이 들어오니 캐시는 금방 다시 채워진다. 그 사이 갱신 주기 한두 번 동안 친구 위치 변경을 놓칠 수 있지만, 수용 가능한 트레이드오프다.
캐시 규모
동시 접속 1,000만 명 × 사용자당 약 100바이트면 메모리는 서버 한 대로 충분하다. 문제는 초당 33만 건 쓰기라 서버 한 대로는 버겁다는 것. 그래서 사용자 ID 기준으로 샤딩하고, 샤드마다 대기(standby) 복제본을 둔다.
위치 갱신 흐름
- 클라이언트가 위치를 보내면 로드밸런서가 이미 연결된 웹소켓 서버로 넘긴다.
- 웹소켓 서버가 위치 이력 DB에 저장한다.
- 위치 캐시를 갱신하고(TTL 연장), 연결 핸들러 변수에도 내 위치를 보관한다. 나중에 거리 계산에 쓴다.
- 내 채널에 새 위치를 발행한다.
- 레디스 펍/섭이 내 채널 구독자 전원(= 온라인 친구들의 연결 핸들러)에게 전달한다.
- 각 구독자 핸들러가 “새 위치 ↔ 자기가 보관한 내 친구 위치” 거리를 계산한다.
- 반경 안이면 친구 클라이언트로 전송, 밖이면 버린다.
위치 갱신 따라가 보기
사람을 누르면 그 사람이 위치를 발행한다. 끌어서 옮기면 옮긴 위치로 다시 발행한다. 빈 동그라미는 오프라인 — 웹소켓 연결이 없으니 구독자도 아니다. 거리는 대칭이라 발행자 기준 원으로 표시했다.
앱을 켰을 때 (클라이언트 초기화)
웹소켓 연결을 맺고 첫 위치를 보내면 서버가 이 일을 한다.
- 위치 캐시에 내 위치 저장, 연결 핸들러 변수에도 저장.
- 사용자 DB에서 내 친구 목록(평균 400명)을 가져온다.
- 위치 캐시에 친구들 위치를 한 번에(batch) 요청한다. TTL이 지난 비활성 친구는 결과에 없다.
- 받은 위치마다 거리를 계산해 반경 안인 친구만 클라이언트에 보낸다.
- 친구 전원의 채널을 구독한다 — 온라인이든 아니든. 채널은 싸고, 발행이 없는 채널은 CPU를 쓰지 않는다.
- 내 채널에 현재 위치를 발행한다.
친구 추가/삭제 시에는 클라이언트가 웹소켓 서버에 알리고, 서버가 그 친구 채널을 구독하거나 해지한다.
웹소켓 서버 확장
유상태 서버라 제거할 때 조심서버를 그냥 내리면 붙어 있던 연결이 한꺼번에 끊긴다.
- 로드밸런서에서 해당 서버를 드레인(draining) 상태로 표시 — 새 연결을 보내지 않는다.
- 기존 연결이 모두 닫힐 때까지(또는 충분히 오래) 기다린다.
- 그 뒤에 서버를 제거한다. 새 버전 배포도 같은 방식.
비유
문 닫을 식당이 “신규 손님 안 받음” 팻말을 걸고, 이미 앉은 손님이 다 먹고 나갈 때까지 기다리는 것.
레디스 펍/섭은 몇 대가 필요한가
메모리
채널 1억 개 × 채널당 구독자 약 100명 × 구독자당 포인터 약 20바이트
≈ 200GB → 서버 2대
CPU
초당 갱신 33.4만 × 온라인 친구 40명(400명의 10%) = 초당 1,400만 건 전달. 서버 1대가 초당 약 10만 건 처리한다고 보면
≈ 140대
결론병목은 메모리가 아니라 CPU다. 서버 한두 대로는 안 되고, 분산 클러스터가 필요하다.
분산 레디스 펍/섭 클러스터
채널을 여러 서버에 나눈다. 기준은 발행자의 사용자 ID. 어느 채널이 어느 서버에 있는지는 안정 해시(consistent hashing)로 정한다.
서비스 탐색 — 주키퍼(ZooKeeper), etcd
“지금 살아있는 펍/섭 서버 목록”과 해시 링 설정을 한곳에 두고, 바뀌면 구독자(웹소켓 서버)에게 알려주는 컴포넌트.
/config/pub_sub_ring → ["p_1", "p_2", "p_3", "p_4"]
- 웹소켓 서버들은 이 해시 링을 구독하고 메모리에 캐시한다.
- 민지가 위치를 발행하려 하면, 해시 링에서
민지 채널 담당 서버(예: p_3)를 찾는다.
p_3에 발행한다. 구독할 때도 같은 방식으로 서버를 찾는다.
해시 링에 서버를 넣고 빼 보기
바깥 점 = 채널(사용자 240명), 네모 = 펍/섭 서버. 채널은 시계 방향으로 처음 만나는 서버에 속한다. 테두리가 굵은 점이 방금 서버가 바뀐 채널이다.
펍/섭 클러스터 규모 확장
펍/섭은 메시지를 저장하지 않으니 무상태처럼 보이지만, “누가 어떤 채널을 구독 중인가”라는 상태를 들고 있다. 그래서 유상태 클러스터로 취급한다.
서버 수를 바꾸면 벌어지는 일해시 링이 바뀌어 일부 채널이 다른 서버로 옮겨진다 → 그 채널의 구독자 전원이 새 서버로 재구독해야 한다 → 재구독 요청이 한꺼번에 몰리고, 그동안 일부 위치 갱신을 놓친다.
- 처음부터 여유 있게 서버를 잡아 둔다(과잉 프로비저닝). 크기 조정 자체를 드물게.
- 해야 한다면 시스템 부하가 가장 낮은 시간에, 계획을 세워서.
- 진행 중에는 웹소켓 클러스터 CPU 급증 등을 대시보드로 지켜본다.
장애 서버 교체는 상대적으로 덜 위험
죽은 서버 자리를 대기 서버로 바꾸면 링 구조는 그대로라, 그 서버의 채널 구독자만 재구독하면 된다. 링 크기를 바꾸는 것보다 영향 범위가 훨씬 작다.
친구가 아주 많은 사용자
친구 수에 상한이 있고(예: 페이스북 5,000명) 친구 관계는 양방향이라, 팔로워 수백만인 셀럽 문제와는 다르다. 그런 사용자의 구독자들은 여러 웹소켓 서버에 흩어져 있어 부하도 분산된다.
추가 — 친구가 아닌 주변 사용자도 보여주기
친구 채널 대신 지오해시 격자별 채널을 만든다. 1장의 지오해시가 여기서 다시 나온다.
- 사용자는 자기 위치의 지오해시 채널을 구독한다.
- 격자 경계 문제 때문에 주변 8격자 채널까지, 총 9개를 구독한다.
- 위치가 바뀌면 자기가 속한 지오해시 채널에 발행 → 그 격자와 이웃 격자에 있는 사람들이 받는다.
예시 — 강남역에 있는 사용자가 구독하는 채널
geo:wydm67geo:wydm6egeo:wydm6g
geo:wydm66geo:wydm6dgeo:wydm6f
geo:wydm63geo:wydm69geo:wydm6c
역삼역 쪽(wydm6f)으로 걸어가 격자를 넘으면, 구독 목록도 wydm6f 중심의 9개로 바꿔야 한다.
대안 — 얼랭(Erlang)
레디스 펍/섭 대신 얼랭/엘릭서로 구현할 수도 있다. 얼랭 프로세스는 수백 바이트 수준으로 가벼워서, 사용자 한 명을 프로세스 하나로 두고 서버 한 대에 수백만 개를 띄울 수 있다. 다만 얼랭 개발자를 구하기 어렵다는 현실적 제약이 있다.