제품 간 잔액 동기화: 다른 제품의 실시간 잔액을 WebSocket으로 표시
제품 A가 사용자 잔액을 제품 B로 옮기고 A의 앱이 B의 잔액을 보여 줄 때, A의 백엔드를 거치는 폴링은 A의 처리 능력을 B의 지연에 묶어 둡니다. B가 변경마다 WebSocket으로 푸시해 해결하는 방법을 흐름도로 설명합니다.
두 제품 사이에서 흔한 구성입니다. 사용자가 잔액 일부를 제품 A에서 제품 B로 옮긴 뒤 B에서 씁니다. 사용자가 B에서 쓰거나 얻는 동안에도 A의 앱은 B에 얼마가 남았는지 실시간으로 보여 줘야 합니다. 첫 버전은 보통 HTTP로 물어봅니다. A의 앱이 A의 백엔드를 폴링하고, A의 백엔드가 B에 묻습니다. 폴링할 때마다 두 제품을 가로지르는 요청 사슬이 생기고, 트래픽이 늘면 몇 명의 사용자를 감당할 수 있는지를 정하는 것은 어느 제품도 아닌 이 사슬이 됩니다.
요청 사슬은 이렇게 쌓입니다
잔액 옮기기
A의 앱에 B의 잔액 표시
HTTP 요청은 기다리는 동안 무언가를 붙잡고 있습니다. worker 스레드, 클라이언트 커넥션 풀의 연결, 흔히 데이터베이스 연결까지요. A의 요청은 B가 응답해야 응답할 수 있으므로, 자기 작업 시간에 B가 걸리는 시간 전체를 더한 만큼 그것들을 붙잡습니다. 리틀의 법칙으로 숫자를 낼 수 있습니다. 처리 중인 요청 수 = 초당 요청 수 × 요청 하나에 걸리는 초입니다. 게다가 B는 페이지를 열어 둔 A의 사용자마다 몇 초에 한 번씩 요청을 받습니다. 잔액이 바뀌었든 아니든 말이죠.
worker가 얼마나 드는가
벤치마크가 아니라 계산 예시입니다(2026-11-09 작성). A의 사용자 5,000명의 화면에 B의 잔액이 떠 있고 A의 앱은 5초마다 폴링하므로, 초당 1,000개의 요청이 A를 거쳐 B로 갑니다.
| A의 백엔드를 거치는 폴링 | B가 변경마다 WebSocket으로 푸시 | |
|---|---|---|
| 초당 A와 B에 도착하는 잔액 요청 | 각각 1,000개, 대부분 변화 없음 | 없음, 앱이 연결할 때 한 번 읽음 |
| 요청 하나가 A의 worker를 붙잡는 시간 | 자체 작업 10 ms + B 호출 60 ms = 70 ms | A를 전혀 거치지 않음 |
| B의 잔액을 중계하느라 바쁜 A의 worker 수 | 1,000 × 0.07초 = 70 | 0 |
| B가 600 ms로 느려졌을 때 | 1,000 × 0.61초 = 610, 200개짜리 풀의 세 배 | 그대로 0: 다음 업데이트가 조금 늦게 올 뿐 |
아픈 곳은 마지막 줄입니다. 풀이 worker 200개라면 B가 잠깐 느려지는 것만으로 A의 풀이 바닥납니다. 로그인과 결제를 포함한 A 자신의 요청이 큐에서 기다리다 타임아웃되고, 클라이언트가 재시도하면서 요청이 더 늘어납니다. A의 동시 처리량은 하필 트래픽이 가장 많을 때 떨어지고, 그 원인은 A가 운영하지도 않는 제품입니다. 그동안 B도 대부분 "변화 없음"인 폴링에 자기 처리 능력을 씁니다.
변경 후: B가 변경마다 한 번만 발행
B는 변경을 기록할 때마다, 입금을 포함해 한 번 발행합니다. A의 앱은 WebSocket 연결 하나를 유지하고 네트워크 왕복 한 번 만에 새 잔액을 받습니다. B의 잔액은 더 이상 A의 백엔드를 거치지 않으므로 느리거나 바쁜 B가 A의 worker를 붙잡을 수 없고, B도 수천 건의 폴링에 응답할 필요가 없어집니다.
설정 방법
- B의 잔액은 B가 책임집니다. B에 얼마가 남았는지의 기준은 B의 원장입니다. 커밋할 때마다 B는 아무 Pusher 서버 라이브러리의 HTTP API로 새 잔액과 버전 번호를 담은
balance.updated를 발행합니다. - 사용자마다 비공개 채널 하나. 예를 들어
private-b-balance-42입니다. A와 B가 같은 앱을 쓴다면 A의 인증 엔드포인트가 로그인한 사용자가 42인지 확인한 뒤 구독에 서명합니다. B가 자기 앱을 운영한다면 A의 앱은 B의 앱 키로 연결하고 B의 인증 엔드포인트가 서명합니다. 중계 서버가 암호문만 보게 하려면private-encrypted-b-balance-42를 쓰세요. 다른 비공개 채널은 전송 중에는 TLS로 보호되고 저장 시 암호화됩니다. - 버전 번호를 쓰세요. 화면에 있는 것보다 낮은 버전의 이벤트는 무시합니다. A의 앱이 연결하거나 재연결하면 HTTP로 B의 잔액을 한 번 읽고 그다음 이벤트를 적용합니다.
- A의 백엔드도 받을 수 있습니다. 예를 들어 사용자가 돈을 되돌리기 전처럼 A가 서버에서 B의 잔액이 필요하다면, 매번 B를 호출하는 대신 Pusher 클라이언트 라이브러리로 같은 이벤트를 받아 사본을 최신으로 유지할 수 있습니다.
그대로인 것
A에서 B로 옮기는 것은 여전히 멱등 키가 붙은 HTTP 요청이므로 재시도해도 돈이 두 번 옮겨지지 않고, B도 기록할 때마다 직접 잔액을 확인합니다. WebSocket이 나르는 것은 B의 잔액이 바뀌었다는 소식이지 변경 자체가 아닙니다. 반대편 제품이 외부 API 제공업체라면 이체 자체도 큐를 넘치게 할 수 있습니다. API 제공업체와의 지갑 이체를 보세요.
자주 묻는 질문
폴링 간격을 늘리면 되지 않나요?
간격을 늘리면 요청은 줄지만 잔액은 오래된 값이 됩니다. B에서 얻은 금액이 A의 앱에 간격 하나만큼 늦게 나타나고, 매번의 폴링은 여전히 B를 기다립니다. 푸시는 네트워크 왕복 한 번이면 되고, 바뀐 것이 없으면 아무것도 보내지 않습니다.
연결이 끊긴 동안 A의 앱이 이벤트를 놓치면요?
pusher-js와 다른 클라이언트 라이브러리는 스스로 재연결합니다. 재연결 후 HTTP로 B의 잔액을 한 번 읽고 그보다 높은 버전의 이벤트만 적용하세요. 버전 번호가 있으면 중복되거나 늦게 온 이벤트도 문제가 되지 않습니다.
A가 B의 코드를 바꿔야 하나요?
B에 필요한 추가는 하나입니다. 잔액을 기록한 뒤 balance.updated를 발행하고, 자기 앱을 운영한다면 구독에 서명하는 것입니다. 이체 API는 그대로입니다.
Hibira에서는 이 이벤트를 어떻게 세나요?
발행 한 번이 메시지 하나입니다. 유료 플랜에서는 구독자에게 가는 전달을 세지 않으므로, 같은 잔액을 A와 B의 앱에 모두 보여 줘도 한쪽에만 보여 주는 것과 비용이 같습니다. 요금을 보세요.
제품명은 각 소유자의 상표입니다.