실시간 차트 서비스에서 가장 중요한 것은 무엇일까요 속도? 정확성?
이 질문에서 시작된 고민이 Dual-Path Architecture라는 결과물로 이어지기까지의 과정을 기록합니다.
안녕하세요 오늘은 CoinFlow 서비스를 구축하기 위해 Data Flow 를 어떻게 설계했는지 설명해드리고자 합니다.
그럼 바로 시작하겠습니다!
초기 설계
처음에는 데이터 정합성과 정확성을 최우선으로 생각하여, 모든 데이터를 DB에 먼저 저장하고, 그 다음에 보여주는 구조로 설계했습니다.
금융 데이터인 만큼 데이터의 유실이나 오차를 절대 허용해서는 안 된다는 생각에, RDBMS를 Single Source of Truth 로 삼고자 했습니다.

데이터 흐름은 매우 직관적이었습니다.
- Collector: 외부 거래소(Binance)에서 실시간 체결(Tick) 데이터를 수집 및 적재합니다.
- Consumer: 큐에서 데이터를 하나씩 꺼내 가공합니다.
- Database: 가공된 데이터를 RDBMS(PostgreSQL)에
INSERT합니다. - API/Socket: 클라이언트 요청 시 DB에서 데이터를 조회하여 응답합니다.
언뜻 보기에는 데이터 정합성을 보장하는 안전한 구조처럼 보입니다. 하지만...치명적인 문제가 존재했습니다.
초기 설계의 치명적인 문제점
설계를 하고 시나리오를 계속해서 생각해보니, 실시간 틱 처리를 위한 과정에서 한계점이 보였습니다.
1. RDBMS는 실시간 틱 데이터를 감당할 수 없다
틱(Tick) 데이터는 초당 수백 건씩 쏟아지는 Time-series Data입니다. 이를 RDBMS에 직접 저장하려고 했을 때, 다음과 같은 문제점이 있었습니다.
- IOPS 한계 (Disk I/O 병목):
- RDBMS는 데이터의 무결성(ACID)을 보장하기 위해 매 Insert마다 로그(WAL)를 쓰고 디스크에 동기화합니다.
- 초당 수백 번의 Insert가 발생하면 디스크 I/O가 포화 상태가 되어, 정작 중요한 조회 쿼리(Select)까지 덩달아 느려지는 리소스 잠식 현상이 발생합니다.
- 인덱싱 비용:
- 시계열 데이터는 시간 순서로 정렬해서 조회해야 하므로
event_time컬럼에 인덱스가 필수입니다. - 하지만 인덱스를 걸면 데이터 1개를 저장할 때 인덱스 트리(B-Tree)도 같이 재정렬해야 합니다.
- 데이터가 쌓일수록 인덱스 트리가 거대해지고, 재정렬 비용이 기하급수적으로 늘어나 Insert 속도가 점점 느려집니다.
- 시계열 데이터는 시간 순서로 정렬해서 조회해야 하므로
- Latency:
Insert->Transaction Start->Lock->Write WAL->Index Update->Commit- 이 모든 과정을 거치는 데 최소 수 십 ms가 소요됩니다.
- 이미 DB에 저장된 시점에는 이미 지나간 과거 가격이 되어버려, 사용자에게 즉각적인 반응성을 제공할 수 없습니다.
2. 근본적인 정책에 대한 고민
우리는 주식이나 코인 차트를 볼 때 무엇을 가장 중요하게 생각할까요?
바로 지금 이 순간 얼마에 체결되었는지입니다.
매수 버튼을 누르려는 순간의 가격이 가장 중요하지, 그 데이터가 DB에 안전하게 기록되었는지는 사용자의 관심사가 아닙니다.
현재가를 사용자에게 0.1초라도 빨리 보여주는 것. 이것이 차트 서비스의 본질이라고 판단했습니다.
그래서 WebSocket을 통해 Tick 데이터를 Stream 하는 방식이 필요하다고 생각했습니다.
해결책: Dual-Path Architecture (The Solution)
저는 이 문제를 해결하기 위해 시스템을 속도 담당(Fast-Path)과 정확성 담당(Slow-Path)으로 분리했습니다.
🏃♂️ Fast-Path (Speed Layer)
일단 보여준다. 저장은 나중에 하고.
- 목표: 0.1초 이내에 사용자에게 시세 전달 (Zero Latency).
- 기술: Redis Stream (
tick:raw) - 흐름:
Collector가 거래소 틱을 받자마자 Redis Stream에 꽂아넣습니다. (XADD)TickRawStreamConsumer가 대기하고 있다가 즉시 데이터를 소비합니다.- 복잡한 계산 없이 바로 WebSocket으로 전달합니다.
- DB 저장 과정 생략!
// TickRawStreamConsumer.java (Fast-Path)
@Override
public void onMessage(MapRecord<String, String, String> message) {
// 1. Redis Stream에서 틱 꺼냄
TickDto tick = mapToDto(message);
// 2. DB 저장 없이 바로 소켓으로 전송 (비동기)
sessionManager.getSubscribers(symbol).forEach(session -> {
session.send(Flux.just(session.textMessage(json)));
});
}
🐢 Slow-Path (Accuracy Layer & Correction)
늦더라도 확실한 정답을 알려준다.
- 목표: 데이터 정합성 보장 (Strong Consistency).
- 기술: Redis Pub/Sub (
candle:closed) - 흐름:
Aggregator앱이 1분 동안 들어온 틱을 메모리에서 열심히 계산(Aggregation)합니다.- 1분이 딱 끝나는 순간(00초), DB에 저장하고 확정된 캔들(CandleClosedEvent)을 발행합니다.
CandleClosedStreamConsumer가 이걸 받아서 WebSocket으로 쏩니다.- 프론트엔드는 이 데이터를 받아서 기존에 대충(?) 그려놨던 캔들을 정확한 값으로 덮어씌웁니다.
// CandleClosedStreamConsumer.java (Slow-Path)
@Override
public void onMessage(Message message, byte[] pattern) {
// 1. Redis Pub/Sub으로 확정된 1분 봉 데이터 수신
CandleClosedEvent event = deserialize(message);
// 2. 클라이언트에게 확정된 데이터 전송 -> 프론트엔드 차트 보정(Correction) 수행
broadcastToSubscribers(event);
}
추가적으로 고민했던 부분 : WebSocket Multiplexing
여기서 또 하나의 기술적 고민이 있었습니다.
Fast-Path용 소켓이랑 Slow-Path용 소켓을 따로 뚫어야 하나?
- No! 브라우저 연결 제한(Connection Limit)과 리소스 낭비를 막기 위해 Single Connection으로 처리했습니다.
- 하나의 WebSocket 세션으로
Tick데이터(실시간)와CandleClosed데이터(보정용)를 모두 보냅니다. - 프론트엔드는 메시지 타입(
type: tickvstype: candle_closed)만 보고 구분해서 처리하면 됩니다.
장애 대응 및 한계점
물론 이 설계가 완벽한 것은 아닙니다.
하지만 시스템에서 발생할 수 있는 주요 장애 시나리오에 대한 대응책을 어느 정도 생각해봤습니다.
장애 시나리오: WebSocket 서버가 죽는다면?
- 문제: 실시간 시세를 뿌려주던 WAS가 비정상 종료되거나 재배포를 위해 내려갑니다.
- 대응 (Replay Strategy):
- Redis Stream은 로그(Log)입니다: 컨슈머가 죽어도 데이터는 Redis에 그대로 남아있습니다.
- 재시작 시점 복구: 서버가 다시 살아나면, 마지막으로 읽었던 Offset(
Last Consumed ID)부터 다시 읽어들입니다. - 밀려있던 틱들이 순식간에 재처리되면서 클라이언트는 잠시 멈췄던 차트가 "빠르게 감기(Fast-forward)" 되듯이 최신 상태로 동기화됩니다.
개선되어야 할 부분
- Consumer 장애:
- Redis Stream에 데이터는 계속 쌓이는데 Consumer가 오랫동안 죽어있으면 Memory Overflow가 발생할 수 있습니다.
- -> DLQ(Dead Letter Queue) 도입 및 모니터링 알람이 필요합니다.
- External API(거래소) 장애:
- 바이낸스 서버 자체가 터져서 틱이 안 들어오는 경우는 우리가 손쓸 수 없습니다.
- -> Circuit Breaker를 도입하여, 외부 장애 시 "점검 중" 상태로 전환하거나 다른 거래소로 Failover 하는 전략이 필요합니다.
- 늦게 들어오는 틱 처리:
- 네트워크 지연으로 인해 먼저 발생한 틱(10:00:00)이 나중에 발생한 틱(10:00:05)보다 늦게 도착할 수 있습니다.
- 이때 두 가지 전략을 사용하는 방법을 생각해봤습니다.
- Fast-Path (실시간): 정렬 포기 (Zero-latency).
- 정렬을 위해 기다리면(Buffering) 실시간성이 떨어지므로, 순서 역전을 허용하거나 과거 데이터를 무시합니다.
- Slow-Path (정확성): 로직 보정 (Correctness).
- 집계 시점에는 Event Time을 기준으로
Open/Close가격을 재산정하여 최종 데이터의 정합성을 맞춥니다.
- 집계 시점에는 Event Time을 기준으로
- Fast-Path (실시간): 정렬 포기 (Zero-latency).
결론: 최종 데이터 흐름 (Data Flow)
최종적으로 만들어진 전체적인 데이터 흐름을 다이어그램으로 정리했습니다.

🎨 다이어그램 분석 (Color Code)
다이어그램의 각 컴포넌트는 역할에 따라 색상으로 구분되어 있습니다.
- 🔴 Execution Modules (Red): 실제 비즈니스 로직을 수행하는 핵심 모듈들입니다.
- 🟢 External API (Green): 바이낸스(Binance)와 같은 외부 데이터 소스입니다. 우리가 제어할 수 없는 영역입니다.
- 🔵 Client (Blue): 클라이언트 영역입니다. WebSocket을 통해 실시간 데이터를 수신합니다.
- 🟡 Database (Yellow): 영구적인 데이터 저장을 담당하는 PostgreSQL입니다.
- 🟣 Message Queue (Purple): 메시지 큐 입니다.
- Stream: Fast-Path, 대량 데이터(Tick)
- Pub/Sub: Slow-Path, 이벤트 전파(Closed Candle)
이 구조 덕분에 사용자는 빠른 반응 속도를 경험하고, 시스템은 데이터 정합성을 지키는 구조를 만들 수 있었습니다.
오늘은 사용자 경험을 향상시킴과 동시에 정합성을 보장할 수 있는 설계에 대해 고민해봤던 과정을 기록해봤습니다.
더욱 좋은 방법이 있거나 잘못된 부분이 있으면 댓글로 남겨주시면 감사하겠습니다!
'[Spring] - Study > Project - CoinFlow(비트코인 차트)' 카테고리의 다른 글
| [Data Flow] 실시간 차트 데이터 흐름도 재설계: 프론트엔드 연산 제거와 완벽한 정합성 보장하기 (0) | 2026.02.28 |
|---|---|
| 초당 수만 건의 틱 데이터, 거래량은 어떻게 집계해야 할까? (BigDecimal vs Long) (1) | 2026.02.24 |
| CoinFlow 프로젝트 시작 ~.~ (0) | 2026.02.13 |