04 · 분기 A — POS가 건다: 라우팅을 없애는 설계
- 커넥션 레지스트리를 만들지 않습니다. push를 pull로 뒤집으면 “어느 파드가 이 단말을 들고 있나"라는 질문이 사라집니다. 발신 서비스는 스트림에
XADD만 합니다. 파드는 자기 커넥션에 해당하는 것만 골라 흘립니다. - Redis Stream ID를 SSE
id:에 그대로 싣습니다. 그러면Last-Event-ID가 곧XREAD의 시작점이 되고 재개 로직이 따로 필요하지 않습니다. ID 체계를 어떻게 잡느냐로 푸는 문제입니다. - 샤딩은 파드가 받는 양을 줄이지 않습니다. 파드가 랜덤 분산이면 모든 샤드를 구독하므로 파드당 수신량은 총 이벤트량 그대로입니다. 샤딩이 줄이는 건 Redis 노드당 송신량입니다. 둘을 섞어 말하면 용량 산정이 틀립니다.
- 탈출 임계는
R × S × P ≤ 예산하나입니다. 파드를 늘리면 Redis 송신량도 선형으로 같이 오릅니다 — 스케일아웃이 백엔드 부하를 키우는 구조이므로 용량 계획에 반영해야 합니다. - 재개 창은 명시적 계약입니다.
XADD ... MINID ~로 스트림을 시간 기준으로 자르고 창을 벗어난 재접속은 정상 경로로event: resync를 받아 전체 재동기화합니다. 장시간 오프라인 매장은 상시 발생합니다. - 브로드캐스트를 별도 스트림에 두지 않습니다. ID 공간이 둘로 나뉘어
Last-Event-ID계약이 깨집니다. 발신 측에서 샤드마다XADD를 반복하는 편이 훨씬 쌉니다. - at-least-once는 클라이언트 규약으로 만듭니다. POS가 처리를 끝낸 뒤에 last event ID를 갱신하면 중간에 끊겨도 그 이벤트가 재개 시 다시 옵니다.
02가 분기 A로 판정되면 이 문서가 설계 전문입니다. 주장은 하나입니다 — 재개 보장을 요구한 대가로 얻은 “이벤트가 파드 밖에 있다"는 성질을 끝까지 활용하면 API Gateway가 대신 해주던 커넥션 레지스트리를 만들 필요가 아예 없어집니다. 03의 판정 축 ①(파드 메모리의 휘발성 상태)이 “없음"이기 때문에 성립하는 설계입니다.
1. push를 pull로 뒤집는다
API Gateway 모델을 그대로 옮기면 이렇게 됩니다.
발신 서비스 → "deviceId=A는 어느 파드?" → 레지스트리 조회 → 그 파드에 POST → SSE write새로 붙는 컴포넌트는 레지스트리, 그 갱신 경로, 파드 간 직접 호출입니다. 재개 보장에 쓸 이벤트 로그는 이것과 별개로 또 필요합니다.
pull로 뒤집으면 이렇게 됩니다.
발신 서비스 → XADD pos-ev:{shard} → (파드가 알아서 tail) → SSE write레지스트리가 사라졌습니다. 이벤트 로그가 라우팅 매체를 겸하기 때문입니다. 재개 보장 때문에 어차피 만들어야 했던 것 하나가 라우팅까지 떠맡습니다.
도식 텍스트
- Deployment · 무상태
- 주문 서비스
- 설정 서비스
- Redis Stream — pos-ev:0 … pos-ev:63
- 게이트웨이 A — XREAD BLOCK · 로컬 필터
- 게이트웨이 B — XREAD BLOCK · 로컬 필터
- POS — SSE 수신
- POS — SSE 수신
- XADD
- XADD
- 전량 tail
- 전량 tail
- id: 1712-0
- id: 1712-0
2. 데이터 모델
2.1 스트림 키
pos-ev:{ hash(deviceId) % 64 }샤드 수는 파드 수와 무관하게 고정합니다(64 권장). 이유는 §4에서 다룹니다.
같은 deviceId는 항상 같은 샤드로 갑니다. Redis가 XADD를 직렬화하므로 단말별 순서가 자동으로 보장됩니다. 프로듀서가 여럿이어도 마찬가지입니다. 따로 설계해서 만든 성질이 아닙니다. 자료구조에서 공짜로 나옵니다.
2.2 엔트리
XADD pos-ev:17 MINID ~ <15분전ms>-0 * \
dev A83F21 \
type order.push \
payload '{"orderId":...}'dev 필드가 파드의 로컬 필터 키입니다. 파드는 엔트리를 받아 dev를 보고 자기 커넥션 맵에 있으면 write, 없으면 버립니다.
2.3 SSE 프레임
id: 1754321098765-0
event: order.push
data: {"orderId":"...","...":...}id:가 Redis Stream ID 그대로입니다. 이 설계의 축입니다.
3. 재개 — 코드가 아니라 ID 체계로 푼다
WHATWG 명세가 보장하는 것은 이게 전부입니다.
id:필드가 last event ID 버퍼를 설정합니다- 재연결 시 그 값이
Last-Event-ID헤더로 자동 송신됩니다
Stream ID를 id:에 실었으므로 재접속 요청은 이렇게 옵니다.
GET /events
Last-Event-ID: 1754321098765-0파드가 할 일은 그 값을 XREAD의 시작점으로 넘기는 것뿐입니다.
XREAD BLOCK 30000 STREAMS pos-ev:17 1754321098765-0이전 파드가 무엇을 어디까지 보냈는지 알 필요가 없습니다. 인수인계 자체가 없습니다. 03 §1에서 본 Loki ingester와는 여기서 달라집니다 — 저쪽은 미flush 데이터가 특정 파드 메모리에 있어서 그 파드를 찾아가야 했고 우리는 그 상태를 애초에 만들지 않았습니다.
도식 텍스트
- POS
- 게이트웨이 B
- Redis Stream
- 게이트웨이 A 종료 · SSE 스트림 끊김
- 1. 재접속 · Last-Event-ID 1712-0
- 2. XREAD pos-ev:17 1712-0
- 분기 — 1712-0 이 재개 창 안
- 3. 1713-0 … 1719-0
- 4. 밀린 이벤트 재생
- 5. 라이브 tail
- 창 밖 (트리밍됨)
- 3'. 시작점 없음
- 4'. event: resync
- POS 가 REST 로 · 전체 상태 재조회
3.1 클라이언트 규약 — 언제 ID를 갱신하는가
브라우저 EventSource는 프레임을 파싱하는 즉시 버퍼를 갱신합니다. POS 클라이언트는 우리가 만들므로 이걸 늦출 수 있습니다.
- 수신 즉시 갱신 → at-most-once. 처리 중 죽으면 그 이벤트는 사라집니다.
- 처리 완료 후 갱신 → at-least-once. 처리 중 죽으면 재개 시 다시 옵니다. 대신 이벤트 핸들러가 멱등해야 합니다.
주문·정산처럼 유실이 돈이 되는 이벤트가 섞여 있다면 후자입니다. 이 선택은 서버 코드를 한 줄도 바꾸지 않습니다 — 클라이언트 규약이 전달 보장 등급을 정합니다.
3.2 재개 창 계약
스트림을 무한히 쌓을 수는 없으므로 자릅니다. MAXLEN보다 MINID가 낫습니다 — 시간 기준이라 “재개 창 15분"이라는 계약과 직결되고 이벤트량이 튀어도 창 길이가 흔들리지 않습니다.
XADD pos-ev:17 MINID ~ 1754320198765-0 * ...~는 근사 트리밍이라 매크로 노드 경계에서만 자릅니다 — 정확한 절단보다 훨씬 싸고 창이 계약보다 조금 긴 건 문제가 아닙니다.
창을 벗어난 재접속도 정상 경로로 다룹니다. 매장 폐점 후 재개점, 단말 교체, 네트워크 장기 단절이 상시 발생합니다. 이때 파드는 요청받은 ID가 스트림에 없음을 감지하고 이렇게 응답합니다.
event: resync
data: {"reason":"window-expired"}POS는 이걸 받으면 REST로 전체 상태를 다시 가져옵니다. 이 폴백이 없으면 아무 에러도 없이 이벤트를 건너뛴 채로 계속 돌아갑니다 — 재개 보장 설계에서 가장 위험한 실패 모드입니다.
4. 읽기 증폭 — 이 설계의 유일한 구조적 대가
4.1 샤딩이 무엇을 줄이고 무엇을 못 줄이는가
로드밸런서가 커넥션을 랜덤으로 흩으면 파드 20개에 2,500개씩 붙은 상태에서 각 파드의 커넥션이 64개 샤드 전부에 걸칩니다. 그래서 모든 파드가 모든 샤드를 구독합니다.
| 지표 | 샤딩으로 줄어드는가 | 값 |
|---|---|---|
| 파드당 수신량 | ❌ 안 줄어든다 | R × S (총 이벤트량 그대로) |
| Redis 노드당 송신량 | ✅ 줄어든다 | (R / N_nodes) × S × P |
샤드를 Redis Cluster의 여러 노드에 흩으면 송신 부하가 노드 수만큼 나뉩니다. 샤딩의 값은 거기까지입니다. 파드가 읽는 양은 그대로입니다. 이 둘을 섞어서 “샤딩하면 팬아웃이 줄어든다"고 쓰면 용량 산정이 틀어집니다.
노드 분산에는 구현상의 대가가 있습니다. Cluster에서 다중 키 XREAD는 모든 키가 같은 슬롯에 있어야 하므로 샤드를 흩는 순간 “커넥션 하나로 64개 스트림을 tail한다"가 성립하지 않습니다. 해시 태그로 슬롯 그룹을 만들거나 Cluster 대신 독립 인스턴스로 나누는 방법이 07 §3.3에 있습니다.
4.2 임계 공식
Redis 총 송신량 = R × S × P
R = 초당 총 이벤트 수
S = 이벤트 평균 크기
P = 게이트웨이 파드 수| R | S | P | 총 송신량 | 판정 |
|---|---|---|---|---|
| 500 | 1 KB | 20 | 10 MB/s | 여유 |
| 2,000 | 1 KB | 20 | 40 MB/s | 정상 |
| 2,000 | 4 KB | 20 | 160 MB/s | Cluster 분산 필요 |
| 10,000 | 1 KB | 20 | 200 MB/s | Cluster 분산 필요 |
| 10,000 | 4 KB | 40 | 1.6 GB/s | 이 설계의 한계 밖 |
P가 곱에 들어갑니다. 커넥션이 늘어 파드를 늘리면 Redis 부하도 같이 옵니다. 일반적인 스케일아웃 직관 — 파드를 늘리면 파드당 부하가 줍니다 — 과 반대 방향이라 용량 계획서에 이 항을 명시해두지 않으면 나중에 반드시 걸립니다.
파드당 수신량(R × S)은 대개 문제가 아닙니다. R=10,000, S=1KB여도 파드당 10MB/s이고 그중 대부분을 즉시 버립니다. 병목은 항상 Redis 송신 쪽에서 먼저 옵니다.
4.3 샤드 수를 64로 고정하는 이유
파드가 전 샤드를 구독하므로 샤드 수는 팬아웃에 영향을 주지 않습니다. 그래도 미리 쪼개두는 이유가 있습니다.
- Redis Cluster 슬롯 분산 — 단일 키에 모든 쓰기가 몰리면 노드 하나가 핫스팟이 됩니다.
- §6의 전환을 데이터 모델 변경 없이 하려고 — 나중에 커넥션을 샤드에 정렬시키기로 하면 스트림 구조는 그대로 두고 구독 범위만 좁히면 됩니다.
- 트리밍 비용 분산 —
MINID트리밍이 64개 키에 나뉩니다.
64는 파드 수(수십)보다 충분히 크고 구독 커넥션 수(P × 64 = 1,280)가 Redis에 부담이 되지 않는 선입니다.
5. 브로드캐스트를 별도 스트림에 두지 않는다
“전 매장 공지” 같은 이벤트를 pos-ev:broadcast 스트림 하나에 두고 모든 파드가 추가로 구독하게 하는 설계가 자연스러워 보입니다. 하지만 그러면 Last-Event-ID 계약이 깨집니다.
클라이언트가 들고 오는 last event ID는 하나인데 스트림이 둘이면 재개 시작점을 두 개 복원해야 합니다. 복합 ID("1712-0|993-0")로 만들면 클라이언트·서버 양쪽에 파싱 규칙이 생깁니다. 두 스트림 사이의 상대 순서도 여전히 정의되지 않습니다.
발신 측에서 흡수하면 됩니다. 브로드캐스트는 64개 샤드에 각각 XADD합니다.
// 브로드캐스트: 샤드마다 한 번씩
(0 until SHARDS).forEach { shard ->
redis.xadd("pos-ev:$shard", mapOf("dev" to "*", "type" to type, "payload" to json))
}XADD 64회는 파이프라인으로 묶으면 왕복 한 번입니다. ID 공간이 하나로 유지되는 값에 비하면 무시할 수 있는 비용입니다. dev: "*"는 파드가 로컬 필터에서 “전 커넥션 대상"으로 해석합니다.
6. 언제 이 설계를 벗어나는가
§4.2의 곱이 예산을 넘으면 B나 C로 나갑니다.
B — 레지스트리 + 직접 push. deviceId → podIP를 Redis에 TTL로 등록하고 발신 측이 조회해 해당 파드에 직접 gRPC push합니다. 송신량이 R × S로 떨어집니다(P가 사라집니다). 대신 얻는 문제:
- 이중 경로. 재개 보장은 여전히 이벤트 로그가 필요하므로 재접속 시 “로그 catch-up → 라이브 push 전환"의 경계가 생깁니다. 그 경계에서 중복·역전을 막으려면 시퀀스 비교가 들어갑니다.
- stale 레지스트리. 파드가 갑자기 죽으면 TTL 만료 전까지 없는 파드로 push가 갑니다. 실패를 감지하고 재시도하는 경로가 필요합니다.
- headless service. 파드 IP로 직접 붙어야 하므로 여기서 처음으로 필요해집니다.
C — 커넥션을 샤드에 정렬. L7 라우터가 deviceId 해시로 파드를 고르게 하면 파드는 자기 샤드만 구독하면 되고 증폭이 사라집니다. 대신 라우팅 계층과 리밸런싱마다 벌어지는 재접속 폭풍이 붙습니다. 03 §3에서 본 eventual consistency 구간도 생깁니다.
권장 순서는 A → B입니다. C는 증폭을 가장 깨끗하게 없애지만 링과 라우팅 계층을 한꺼번에 들여오면서 얻는 것이 “Redis 대역폭"뿐입니다. 대역폭은 Redis Cluster 노드를 늘려서도 삽니다. C로 가는 진짜 이유는 대역폭이 아닙니다. 파드 메모리에 per-key 상태를 두고 싶어질 때인데 우리는 그러지 않기로 했습니다.
7. 실패 모드
| 상황 | 무슨 일이 일어나나 | 설계된 대응 |
|---|---|---|
| 게이트웨이 파드 사망 | 그 파드의 SSE 스트림 전부 끊김 | POS가 retry: 후 재접속 → LB가 생존 파드로 분산 → Last-Event-ID로 재개 |
| 배포 (롤링) | 파드마다 순차적으로 위와 동일 | preStop에서 jitter 섞은 retry: 배포 — 06 |
| Redis 장애 | 신규 이벤트 유실 + tail 중단 | 발신 측 XADD 실패를 상위로 전파. 게이트웨이는 재연결 후 마지막 ID부터 재개 — 커넥션은 끊지 않는다 |
| 재개 창 초과 | 요청 ID가 트리밍됨 | event: resync → POS가 REST 전체 재동기화 |
| POS가 안 읽음 (느린 소비자) | 파드의 커넥션별 버퍼가 참 | 커넥션당 큐 상한 → 초과 시 연결 종료, 재접속 후 resync |
| 파드가 이벤트 읽고 write 실패 | POS는 못 받음 | last event ID가 안 올라갔으므로 재개 시 다시 옴 (§3.1 규약 전제) |
발신 서비스 중복 XADD | 같은 이벤트가 두 엔트리 | 막지 않는다. 이벤트 핸들러 멱등성이 계약 |
여섯째 줄이 이 설계의 정직한 한계입니다. XREAD 위치 재개는 “보낸 것"이 아니라 “읽은 것"을 기준으로 하므로 서버가 보냈는지 POS가 받았는지는 서버가 모릅니다. 그 간극은 §3.1의 클라이언트 규약이 메웁니다. 그 규약 없이는 재개 보장이 at-most-once로 내려앉습니다.
8. 이 설계가 만들지 않은 것
- 커넥션 레지스트리
- 파드 간 직접 통신
- headless service
- 링 · consistent hashing · 멤버십 발견
- StatefulSet · PVC · 안정적 파드 ID
- 리더 선출
전부 03의 판정 축 ①이 “없음"이기 때문에 필요가 없어진 것들입니다. 만약 나중에 파드 메모리에 per-key 상태를 두어야 할 이유가 생기면 — 예를 들어 단말별 rate limit 상태나 세션 스코프 집계 — 이 목록이 한꺼번에 되살아납니다. 그때는 05를 다시 읽어야 합니다.