05 · 분기 B — pod가 건다: 링을 만들어야 하는 경우
- 먼저 “연결을 상시 유지해야 하나"부터 물어야 합니다. POS에 우리가 닿을 수 있다면 보낼 게 생겼을 때 아무 파드나
POST하면 됩니다. 상시 연결을 유지하지 않기로 하면 링도 lease도 통째로 필요 없어집니다 — B의 링 요구는 상당 부분 스스로 만든 문제입니다. - 링만으로는 중복 다이얼을 못 막습니다. Alloy 문서가 consistent hashing을 eventually consistent라고 명시하고 Thanos는 상호배제를 아예 운영자에게 떠넘깁니다. 링은 후보를 좁히고 소유는 lease가 확정합니다.
hash % membersCount(vmagent 방식)는 우리 요구와 맞지 않습니다. 그 방식은 멤버십을 모르는 대신 죽은 몫 인수를 포기합니다. “죽은 파드의 몫을 남은 파드가 주워간다"가 요구사항이면 처음부터 배제됩니다.- 참고 원형은 Loki가 아니라 Kafka consumer group입니다. 중복이 사고인 도메인에서 검증된 방식은 재배정 순간에 소유권 공백을 만들어 겹침을 원천 차단하는 쪽입니다. 잘 나누는 것만으로는 부족합니다.
- 단말별 lease는 비쌉니다. 샤드별 lease로 내려야 합니다. 5만 개 키를 5초마다 갱신하면 초당 1만 명령입니다. 소유 단위를 샤드(64개)로 올리면 키가 64개가 되고 갱신 부하가 세 자릿수 줄어듭니다.
- lease TTL이 곧 최대 무연결 시간입니다. TTL 15초면 파드가 죽고 최대 15초간 그 단말들은 끊겨 있습니다. 줄이면 갱신 부하가 오릅니다 — 이 트레이드오프가 B의 SLO를 정합니다.
- B에서도 StatefulSet은 근거가 약합니다. ordinal이 필요한 건
hash % N방식뿐인데 그건 이미 배제됐습니다. 멤버십을 Redis나 EndpointSlice에서 읽으면 Deployment로 충분합니다. - B를 택하면 SSE는 폐기됩니다. pod가 걸고 POS가 SSE를 서빙하면 데이터가 반대로 흐릅니다. 전송 프로토콜을 처음부터 다시 골라야 합니다.
왜 이 문서인가. 02가 분기 B로 판정됐을 때의 설계입니다. 그런데 이 문서의 절반은 B를 축소하는 데 씁니다 — 링과 lease와 멤버십 발견을 전부 만들기 전에 그게 정말 필요한지를 세 번 되묻습니다. 03의 판정 축 ②(죽은 몫 인수)와 ③(중복 소유가 사고인가)이 둘 다 “예"일 때만 이 문서 전체가 필요합니다.
1. 축소 질문 셋 — 이걸 통과해야 링을 만든다
1.1 연결을 상시 유지해야 하는가
B는 POS에 우리가 닿을 수 있다고 전제합니다. 그렇다면 보낼 게 생겼을 때 그때 연결해서 보내면 됩니다.
발신 서비스 → 아무 게이트웨이 파드 → POST https://pos-83f21.stores.internal/push이 모델에는 소유권이 없습니다. 어느 파드가 보내든 상관없고 파드가 죽어도 인수할 것이 없습니다. 중복은 발신 측 멱등키로 막습니다. 링·lease·멤버십 발견이 전부 사라집니다.
상시 연결을 고집할 이유는 아래 말고는 없습니다.
- 지연 예산: 매 푸시마다 TCP + TLS 핸드셰이크(수십~수백 ms)를 감당할 수 없습니다
- 수립 비용: 인증서 검증이나 매장 회선 특성상 연결 수립 자체가 비쌉니다
둘 다 아니면 §2 이후는 읽을 필요가 없습니다. 첫 번째는 커넥션 풀로 대부분 해결됩니다 — 파드가 자주 쓰는 단말과 맺은 연결을 재사용하되 소유권은 주장하지 않는 방식입니다. 두 파드가 같은 단말에 각자 연결을 유지해도 문제가 없다면 그건 소유권 문제와 무관한 리소스 문제입니다.
1.2 중복 연결이 정말 사고인가
두 파드가 같은 POS에 연결을 유지하는 것 자체는 대개 무해합니다. 사고가 되는 건 같은 명령이 두 번 전달될 때입니다.
발신 측이 이벤트에 멱등키를 붙이고 POS가 그 키로 중복을 걸러내면 중복 연결은 자원 낭비일 뿐 사고가 아니게 됩니다. 그러면 판정 축 ③이 “아니오"가 됩니다. 03 §2의 vmagent 방식(hash % N + replicationFactor)이 갑자기 쓸 수 있는 선택지가 됩니다 — 코디네이션 없이, 멤버십 발견 없이, 중복을 감수하는 대신 죽은 몫을 미리 덮는 방식입니다.
멱등성을 클라이언트에 넣을 수 있다면 그게 가장 싼 길입니다.
1.3 인수 지연을 얼마나 감당하나
파드가 죽고 그 몫을 다른 파드가 주울 때까지 그 단말들은 연결이 없습니다. 이 시간이 lease TTL + 감지 지연입니다. 30초를 감당할 수 있다면 설계가 헐거워지고 1초를 요구하면 갱신 부하가 폭증합니다.
이 값을 먼저 정하지 않으면 §4의 TTL을 고를 수 없습니다.
세 질문을 다 통과했다면 이제 링을 만듭니다.
2. 배정 알고리즘 — 셋 중 하나
| 이동량 (멤버 변화 시) | 멤버십을 알아야 하나 | 죽은 몫 인수 | 중복 소유 구간 | |
|---|---|---|---|---|
hash % N (vmagent) | 전량 재배정 | ❌ 상수 | ❌ 없음 | 없음 (뷰가 상수라 항상 일치) |
| consistent hashing (Alloy·Loki) | 1/N | ✅ 실시간 | ✅ | 있음 — eventually consistent |
| 코디네이터 배정 (Kafka) | 최적 | ✅ 중앙 조정자 | ✅ | 없음 — 공백 규약으로 차단 |
hash % N은 §1.2를 통과하지 못하면 배제됩니다. 죽은 몫을 아무도 인수하지 않으므로 파드 3번이 죽으면 그 단말 2,500대는 파드가 되살아날 때까지 영원히 연결이 없습니다. replicationFactor로 덮으려면 중복 연결을 허용해야 하고 그건 §1.2의 질문으로 되돌아갑니다.
consistent hashing이 현실적 기본값입니다. 이동량이 1/N이므로 파드 20→21 확장 시 5만 대 중 약 2,400대만 재연결합니다. hash % N이면 거의 5만 대 전량이 움직입니다. 차이가 20배입니다.
코디네이터 배정은 중복을 원천 차단하지만 코디네이터가 새 단일 장애점입니다. Kafka는 브로커 클러스터가 이미 있어서 성립하는 구조입니다. 우리가 그걸 새로 만들 이유는 없습니다. 대신 공백 규약이라는 아이디어만 가져옵니다(§4.3).
3. 멤버십을 어디서 아는가
consistent hashing을 택했으면 살아 있는 파드 목록이 실시간으로 필요합니다. 네 가지 방법 중 셋은 탈락합니다.
| 방법 | 평가 |
|---|---|
| headless service DNS | gw-0.gw-hl 같은 A 레코드를 조회. ready 파드만 나오므로 재시작 중인 파드와 죽은 파드를 구분 못 하고, DNS 캐시 TTL만큼 뷰가 늦다. 부트스트랩용으로는 쓸 만하지만 링 소스로는 부정확하다. |
| memberlist (gossip) | Mimir·Alloy가 쓰는 방식. Go 생태계(hashicorp/memberlist)에는 성숙한 구현이 있지만 Kotlin/JVM에는 등가물이 마땅치 않다(JGroups는 성격이 다르다). 직접 구현할 물건이 아니다. |
| Kubernetes EndpointSlice watch | 가장 정확하다. Service의 ready 엔드포인트 변화를 즉시 받는다. RBAC 하나와 fabric8/official client 의존성이 붙는다. k8s-native가 중요하면 이쪽. |
| Redis 자기등록 + TTL | 파드가 SET member:{id} <addr> EX 10을 5초마다 갱신하고, 링은 SCAN member:*로 구성. 이미 Redis가 있으므로 새 의존성이 없다. 뷰 갱신 지연이 TTL로 명시적으로 제어된다. |
권장은 Redis 자기등록입니다. EndpointSlice보다 정확도는 조금 떨어지지만(TTL만큼 늦습니다) lease와 같은 저장소를 쓰므로 멤버십 뷰와 소유권 판정이 같은 일관성 도메인 안에 들어옵니다. 서로 다른 두 소스(k8s API와 Redis)를 섞으면 “링은 나를 소유자로 계산하는데 lease는 남이 갖고 있다"는 상태를 디버깅하기 어려워집니다.
4. 중복 다이얼 — 링 위에 lease를 얹는다
4.1 왜 링만으로는 안 되는가
Alloy 문서가 consistent hashing을 *“eventually consistent (rather than fully consistent like hashmod sharding is)"*라고 쓴 그대로입니다. 멤버십 변화가 전파되는 동안 파드 A는 아직 옛 뷰로, 파드 B는 새 뷰로 계산합니다. 두 파드가 같은 단말을 자기 것이라 믿는 구간이 반드시 생깁니다.
메트릭 스크랩에서는 중복 샘플 몇 개입니다. 명령 푸시에서는 같은 명령이 두 번 전달됩니다.
4.2 lease
SET own:{shard} {podId} NX PX 15000NX가 상호배제를 만듭니다. 뷰가 달라져도 통과하는 쪽은 하나입니다.- 소유 파드는 TTL의 1/3 주기로 갱신합니다. 갱신은 반드시 소유자 확인 후여야 하므로 Lua로 묶습니다.
-- 갱신: 내가 소유자일 때만 TTL 연장
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('PEXPIRE', KEYS[1], ARGV[2])
end
return 0- 정상 종료 시에는 같은 방식으로
DEL합니다 — TTL을 기다리지 않고 즉시 인수되게 합니다.
도식 텍스트
- 멤버십 — EndpointSlice · Redis 등록
- 링 계산 — hash(deviceId) → 소유 파드
- lease 획득 — SET own:… NX PX 15s
- 다이얼 유지 — 연결 수립 · lease 갱신
- 포기 — 다른 파드가 소유 중
- POS
- 멤버 변화
- 내 것이면
- NX 성공
- 푸시
- NX 실패
4.3 소유권 공백 — Kafka에서 가져오는 규약
lease만으로도 중복은 막히지만 소유권이 넘어가는 순간의 순서가 남습니다. 옛 소유자가 아직 연결을 들고 있는데 lease가 만료돼 새 소유자가 다이얼하면 잠시 둘 다 연결돼 있습니다.
Kafka consumer group의 eager rebalance가 쓰는 규약을 그대로 가져옵니다.
- 멤버십 변화를 감지하면 자기 것이 아니게 된 샤드를 먼저 놓습니다 — 연결을 끊고 lease를
DEL합니다. - 그 다음에 새로 자기 것이 된 샤드의 lease를 시도합니다.
놓기가 잡기보다 먼저입니다. 이 순서 하나가 중복 구간을 없앱니다. 대가는 재배정 중 짧은 무연결 구간인데 §1.3에서 이미 예산을 잡아둔 값입니다.
4.4 lease 단위를 단말이 아니라 샤드로
단말별 lease를 쓰면 5만 개 키입니다. TTL 15초에 5초 주기 갱신이면:
5만 키 ÷ 5초 = 초당 10,000 갱신 명령파이프라인으로 묶어도 Redis에 상시 걸리는 부하입니다. 소유 단위를 샤드로 올리면 키가 64개가 됩니다.
own:0 … own:63 → 64키 ÷ 5초 = 초당 13 명령세 자릿수 차이입니다. 잃는 것은 세밀도 — 단말 하나만 다른 파드로 옮기는 건 못 하고 항상 샤드 단위(≈780대)로 움직입니다. 이 워크로드에서 단말 단위 이동이 필요한 시나리오가 없으므로 잃는 게 없습니다.
5. TTL이 SLO를 정한다
최대 무연결 시간 ≈ lease TTL + 멤버십 감지 지연 + 다이얼 시간| lease TTL | 갱신 주기 | Redis 부하 (샤드 64) | 최대 무연결 |
|---|---|---|---|
| 30초 | 10초 | 6.4 ops/s | ~35초 |
| 15초 | 5초 | 13 ops/s | ~20초 |
| 5초 | 1.5초 | 43 ops/s | ~8초 |
| 2초 | 0.6초 | 107 ops/s | ~4초 |
샤드 단위 lease에서는 어느 값을 써도 Redis 부하가 무시할 수준입니다. 그러니 부하는 TTL의 기준이 못 됩니다. 오탐 위험을 보고 정합니다 — TTL이 짧을수록 GC pause나 순간적인 네트워크 지연으로 살아 있는 파드가 소유권을 잃고 그때마다 780대가 재연결합니다.
JVM 워크로드에서 2초 TTL은 위험합니다. GC pause 하나가 곧 리밸런싱입니다. 15초가 현실적인 기본값입니다.
6. 리밸런싱 폭풍
파드 20 → 21로 확장하면 consistent hashing 기준 약 1/21, 즉 2,400대가 다른 파드로 옮겨갑니다. 각각 연결을 끊고 다시 맺습니다.
- 다이얼 측 속도 제한이 필요합니다. 새 소유자가 2,400개 연결을 동시에 열면 그 파드의 소켓·TLS 핸드셰이크·CPU가 한꺼번에 튑니다. 초당 상한을 걸고 jitter를 섞습니다.
hash % N이었다면 5만 대가 전량 움직입니다. 이 한 가지만으로도 consistent hashing을 고를 이유가 됩니다.- HPA를 붙이지 않는 편이 낫습니다. 스케일 이벤트마다 리밸런싱이므로 오토스케일링이 곧 상시 폭풍이 됩니다. B에서는 고정 replicas + 수동 조정이 정직한 선택입니다.
마지막 항목에서 A와 차이가 가장 큽니다. 분기 A에서는 파드를 늘려도 기존 연결이 전혀 움직이지 않습니다.
7. StatefulSet인가
B라면 StatefulSet이 필요할 것 같지만 근거를 따져보면 약합니다.
| StatefulSet이 주는 것 | 우리가 쓰는가 |
|---|---|
안정적 ordinal (gw-0, gw-1) | hash % N을 쓸 때만 필요. §2에서 배제됨 |
| 안정적 네트워크 ID | 멤버십을 Redis/EndpointSlice에서 읽으면 불필요 |
| PVC 고정 | 디스크에 남길 상태가 없다 |
| 순차 롤링 업데이트 | 오히려 불리 — 재배정이 N번 순차 발생 |
넷 다 해당되지 않습니다. 03 §1에서 본 Loki ingester가 StatefulSet인 이유는 WAL을 담을 PVC와 재시작 시 같은 링 토큰을 되찾기 위해서인데 우리는 디스크에 아무것도 안 씁니다.
분기 B에서도 Deployment가 맞습니다. 파드는 기동 시 자기 ID(UID 또는 hostname)를 Redis에 등록하고 링은 그 등록 목록으로 구성합니다. StatefulSet이 필요해지는 건 hash % N 정적 샤딩으로 회귀할 때뿐이고 그건 §1.2가 “중복이 사고가 아니다"로 답했을 때입니다.
8. 전송 프로토콜을 다시 골라야 한다
pod가 걸고 POS가 서빙하는 구조에서 SSE를 쓰면 데이터가 POS → pod로 흐릅니다. 원안이 폐기됩니다. 남는 선택지:
| 방식 | 평가 |
|---|---|
POS가 HTTP 서버, pod가 POST | 가장 단순. 그런데 §1.1대로 상시 연결이 필요 없어지므로 링 자체가 사라진다 |
| pod가 WebSocket 클라이언트 | 역할만 반전. POS가 WebSocket 서버를 띄워야 한다 |
| gRPC bidi stream (POS가 서버) | 타입 안전·흐름 제어가 좋다. POS에 gRPC 서버를 얹을 수 있어야 한다 |
| pod가 연 TCP 위 커스텀 프레이밍 | 만들지 마라. 재연결·재개·하트비트를 전부 새로 만들게 된다 |
첫 줄이 계속 §1.1로 되돌아온다는 데 주의해야 합니다. B에서 가장 단순한 전송을 고르면 링이 필요 없어지고 링이 필요한 전송을 고르면 이 문서 전체가 필요해집니다. 그 선택이 실제로 무엇을 사는지 명확히 하고 결정해야 합니다.
9. 실패 모드
| 상황 | 무슨 일이 | 대응 |
|---|---|---|
| 파드 사망 | 그 파드의 샤드들이 무주공산 | lease TTL 만료 → 링 재계산 → 다른 파드가 NX 획득 → 다이얼 (§5의 최대 무연결 시간) |
| 파드 GC pause > TTL | 살아 있는데 소유권 상실 | 깨어난 파드가 갱신 실패를 감지하고 즉시 연결을 끊어야 한다. 안 끊으면 진짜 중복이 된다 |
| Redis 장애 | lease 갱신·멤버십 등록 모두 실패 | 모든 파드가 동시에 소유권을 잃는 것을 막아야 한다. Redis 도달 불가는 “소유권 유지"로 해석(fail-open) — 전면 연결 해제보다 낫다 |
| 멤버십 뷰 분열 | 두 파드가 같은 샤드를 계산 | lease NX가 하나만 통과 (§4.2) |
| 단말 다이얼 실패 | 매장 오프라인·단말 전원 off | 백오프 재시도. 연속 실패를 소유권 포기로 해석하지 말 것 — 다른 파드가 시도해도 똑같이 실패한다 |
| 리밸런싱 중 | 2,400대 동시 이동 | 다이얼 속도 제한 + jitter (§6) |
세 번째 줄이 중요합니다. Redis가 잠깐 안 보인다고 전 파드가 연결을 끊으면 5만 대가 동시에 끊깁니다. lease는 중복을 막는 장치이지 연결 유지의 조건이 아니므로 Redis 장애 시에는 현 소유권을 그대로 들고 버티는 쪽이 안전합니다. 어차피 그 상황에서는 아무도 새로 획득하지 못하므로 중복도 생기지 않습니다.
10. B가 A보다 비싼 항목 정산
| 항목 | A | B |
|---|---|---|
| 커넥션 소유권 | 로드밸런서 | 링 + lease + 멤버십 등록 |
| 확장 시 연결 이동 | 0 | 1/N |
| 오토스케일링 | 자유 | 사실상 불가 |
| 주소 인벤토리 | 없음 | 5만 건 실시간 |
| TLS 인증서 | 1장 | 단말마다 |
| 방화벽 정책 | 아웃바운드 1건 | 매장마다 인바운드 |
| 장애 감지 | 단말이 즉시 알림 | 우리가 폴링 |
| 전송 | SSE (재개 내장) | 재선택 + 재개 직접 구현 |
B가 이기는 칸이 없습니다. 그래서 02의 판정이 “B가 가능한가"가 아니라 “B를 택할 이유가 있는가"여야 하고 대부분 답은 아닙니다. 네트워크가 B를 허용하더라도 A를 선택할 수 있습니다 — POS가 outbound로 걸게 하면 전용망 위에서도 A가 그대로 성립합니다.