Design Robinhood - 0.01초의 시세 차이와 수백만 건의 주문을 처리하는 법
금융 데이터의 정합성과 실시간성의 트레이드오프, 그리고 트래픽 폭주 상황에서의 대기열 설계를 배워봅니다.
로빈후드(Robinhood) 같은 주식 및 암호화폐 거래 플랫폼을 설계하는 문제를 다룹니다. 로빈후드는 사용자가 실시간으로 주식 시세를 확인하고, 매수/매도 주문을 넣으며, 자신의 포트폴리오 자산 변화를 추적하는 서비스입니다. 겉으로 보면 “주식 선택 -> 수량 입력 -> 주문”의 단순한 구조 같지만, 시스템 디자인 관점에서는 실시간 시세 스트리밍, 주문 체결 엔진(Matching Engine), 자산 원장(Ledger)의 무결성, 그리고 GameStop 사태와 같은 폭발적 트래픽 상황에서의 가용성을 모두 해결해야 하는 고난도 시스템입니다.
단순히 “주문을 DB에 저장한다”는 수준이 아닙니다. “초당 수백만 번 변하는 시세를 어떻게 지연 없이 모든 사용자에게 전달할까?”, “사용자의 잔액이 10달러인데 100달러어치 주식을 사려고 하면 어떻게 막을까?”, “주식 시장이 열리는 9시 정각에 몰리는 수천만 건의 주문을 어떻게 순서대로 처리할까?”, “외부 거래소(Market Maker)와의 통신 장애가 발생하면 사용자 주문은 어떻게 처리해야 할까?”, “내 자산 내역은 단 1원의 오차도 없이 기록되어야 하는데, 분산 시스템에서 이를 어떻게 보장할까?”가 중요한 시스템 입니다.
AI가 코드를 짜주는 시대에 개발자에게 필요한 역량은 “금융권 수준의 정합성(Strong Consistency)을 유지하면서도 인터넷 서비스의 확장성(Scalability)을 어떻게 동시에 확보할 것인가”를 결정하는 능력입니다.
이 글을 끝까지 읽으면 다음 질문에 답할 수 있게 됩니다.
Robinhood 같은 주식 거래 플랫폼의 요구사항을 어떻게 정리할까?
실시간 시세 데이터(Market Data)와 주문/결제 시스템(Order/Ledger)은 왜 완전히 분리해야 할까?
수백만 명에게 실시간 주식 호가를 지연 없이 전달하기 위해 WebSocket과 Pub/Sub을 어떻게 설계할까?
밈 주식(Meme Stock) 사태로 한 종목에 트래픽이 폭주할 때 어디서 병목이 생기며 어떻게 대응할까?
주문 처리 시 네트워크 장애가 발생해도 이중 결제를 막는 멱등성(Idempotency)은 어떻게 구현할까?
외부 거래소(Exchange)의 응답이 지연될 때 시스템이 마비되지 않도록 비동기 파이프라인을 어떻게 구성할까?
빅테크 인터뷰에서 면접관의 금융 도메인 follow-up 질문에 어떻게 답해야 할까?
Functional Requirements - 기능적 요구사항 (Robinhood)
사용자는 계정을 생성하고 은행 계좌를 연동할 수 있어야 합니다.
사용자는 현금을 입금하거나 출금할 수 있어야 합니다 (Funding).
사용자는 특정 주식(티커)의 실시간 가격과 히스토리 차트를 조회할 수 있어야 합니다.
사용자는 주식(지정가, 시장가 등)을 매수하거나 매도하는 주문을 넣을 수 있어야 합니다.
사용자는 주문의 진행 상태(대기, 체결, 취소, 실패)를 조회할 수 있어야 합니다.
사용자는 자신의 포트폴리오(현금 잔고, 보유 주식 수량, 실시간 수익률)를 볼 수 있어야 합니다.
사용자는 특정 가격 도달이나 주문 체결 시 알림을 받을 수 있어야 합니다.
관리자 시스템은 이상 거래, 마진 콜, 규제 위반 사항을 모니터링할 수 있어야 합니다.
Non-Functional Requirements - 비기능적 요구사항
Robinhood 같은 트레이딩 서비스는 소셜 서비스와 달리 데이터의 정확성이 생명입니다. 잔고가 1원이라도 틀리거나 체결되지 않은 주문이 체결된 것으로 보이면 회사의 존망이 흔들릴 수 있는 법적 규제와 맞닿아 있습니다.
따라서, 비기능 요구사항은 강한 정합성(Strong Consistency), 초저지연(Ultra-low Latency), 고가용성(High Availability), 멱등성, 보안, 그리고 예측 불가능한 트래픽 스파이크 대응입니다.
Consistency » Availability (트레이딩/결제 영역)
Strict Consistency over Eventual Consistency
가용성보다 데이터의 강한 정합성과 무결성을 절대적으로 우선한다.
소셜 피드에서는 좋아요 수가 1~2초 늦게 반영되어도 되지만, 트레이딩 시스템에서는 잔고가 100달러인데 150달러짜리 주식을 매수하게 내버려두면 안 됩니다. 주문 접수, 체결, 현금/주식 잔고 업데이트는 은행 계좌 잔액처럼 ACID 트랜잭션이 반드시 보장되어야 합니다.
Availability » Consistency (시장 데이터 영역)
반면 실시간 주식 호가(Market Data)는 초당 수만 번씩 변합니다. 100밀리초 전의 정확한 가격보다, 현재의 가격 추이를 서비스 중단 없이 보여주는 가용성과 저지연이 훨씬 중요합니다. 따라서 도메인에 따라 Consistency의 기준이 완전히 달라집니다.
Low-latency (주문 체결 & 실시간 시세)
사용자가 매수 버튼을 누른 시점의 가격과 실제 체결 가격의 차이를 최소화하기 위해, 주문 라우팅 파이프라인의 지연 시간은 수 밀리초(ms) 단위여야 합니다.
주식 가격이 변동될 때 모바일 클라이언트까지 도달하는 시간은 눈에 띄는 지연이 없어야 합니다.
Idempotency (멱등성)
모바일 네트워크 불안정으로 사용자가 “매수” 버튼을 두 번 누르거나, 클라이언트가 Timeout 후 자동 재시도를 할 수 있습니다. 서버는 동일한 주문 요청을 절대 두 번 처리해서는 안 됩니다.
Handling Extreme Spikes (Hot Key 대응)
특정 이벤트(예: 시장 개장 직후, 특정 기업 실적 발표, GME 사태) 시 특정 티커(Ticker)에 트래픽이 100배 이상 폭증할 수 있습니다.
시스템은 전체 다운타임 없이 이 스파이크를 견뎌야 하며, 필요하다면 해당 종목의 처리 큐만 격리(Isolation)하는 구조가 필요합니다.
Scalability & Separation of Concerns
시장 데이터(Read-heavy, Streaming), 주문(Write-heavy, transactional), 포트폴리오 집계(Compute-heavy)는 완전히 다른 트래픽 패턴을 가집니다. 이를 하나의 DB나 모놀리식 아키텍처에 넣으면 무조건 장애가 납니다. Microservices와 Domain-Driven Design을 통해 철저히 분리해야 합니다.
Scope 정리
In Scope
사용자 계정 및 포트폴리오 관리
실시간 시세(Market Data) 수집 및 스트리밍
시장가/지정가 주문 접수 및 체결
잔고(현금/주식) 차감 및 원장(Ledger) 기록
주문 체결 및 가격 알림
Out of Scope
옵션/마진 거래 상세 로직
세금 계산 및 리포팅
은행 ACH 이체 실제 연동망 구현
ML 기반 종목 추천
고객센터/백오피스 어드민 UI
가정하는 규모
DAU: 1,000만 명
Concurrent Users (Market Open): 200만 명
Peak QPS:
Market Data Broadcasts: 수백만 messages/sec
Order Placement: 수만 QPS
Portfolio View: 수십만 QPS
Latency:
Market Data Streaming P99 < 50ms
Order Placement (Internal Ack) P99 < 100ms
Data Volume:
초당 수십만 건의 틱 데이터(Tick Data)를 시계열 DB에 저장.
Core Entities
Robinhood 플랫폼을 설계할 때 중요한 건 “데이터베이스 기술의 분리”입니다. 모든 것을 RDB에 넣으면 트랜잭션은 안전하겠지만 시세 데이터를 감당할 수 없고, 모든 것을 NoSQL에 넣으면 트랜잭션 관리가 불가능해집니다.
왜 RDB vs TSDB vs Memory/Cache로 나뉘는지?
orders,ledgers(주문, 원장)절대적인 무결성(Consistency)과 ACID 트랜잭션 필요.
PostgreSQL, CockroachDB, Spanner 같은 RDB 또는 Distributed SQL 사용.
market_data_ticks,historical_prices(시세 데이터)엄청난 쓰기 속도와 시간 단위의 Range 조회(1일, 1주일 차트)가 필수.
InfluxDB, TimescaleDB, kdb+ 같은 Time-Series DB(TSDB) 사용.
user_sessions,order_idempotency_keys(상태 및 멱등성 캐시)초고속 읽기/쓰기가 필요. Redis 사용.
users사용자의 기본 정보, 인증 정보, KYC(본인인증) 상태를 저장합니다.
accounts사용자의 금융 계좌 정보입니다. 한 사용자가 일반 투자 계좌, 마진 계좌, 은퇴 계좌 등을 가질 수 있으므로 1:N 관계입니다.
positions(포트폴리오)사용자가 현재 보유하고 있는 특정 주식의 수량과 평균 매수 단가(Average Cost)를 저장합니다. 포트폴리오 화면의 기반 데이터가 됩니다.
balances사용자의 현금 잔고입니다. 매수 가능 금액(Buying Power)과 인출 가능 금액을 추적합니다.
orders사용자의 매수/매도 주문 원본 데이터입니다. 종목(Ticker), 수량, 주문 유형(Market/Limit), 상태(Pending, Executed, Canceled) 등을 관리합니다.
executions
하나의 주문이 여러 번에 걸쳐 부분 체결(Partial Fill)될 수 있습니다. 실제 외부 거래소나 Market Maker에서 발생한 물리적 체결 내역을 기록합니다.
ledger_entries(원장)회계 원장입니다. 현금의 증감, 주식의 증감을 복식부기(Double-entry bookkeeping) 형태로 기록하여 절대 데이터가 틀어지지 않도록 교차 검증하는 용도입니다.
instruments/tickers거래 가능한 주식, ETF, 암호화폐 등의 메타데이터(상장 거래소, 틱 사이즈, 거래 가능 시간 등)를 저장합니다.
historical_prices차트를 그리기 위한 과거 가격 데이터(Open, High, Low, Close - OHLC)를 저장합니다. 주로 TSDB에 저장됩니다.
watchlists사용자가 관심 있게 지켜보는 주식 목록입니다.
idempotency_keys중복 주문 접수를 막기 위한 키 테이블입니다. Redis와 RDB에 이중으로 관리하여 네트워크 지연 시의 중복 결제를 원천 차단합니다.
API Signatures
1. Order APIs (주문 및 체결 - 가장 중요한 Write Path)
주문 API는 멱등성(Idempotency) 보장과 비동기 처리(Asynchronous Processing)가 생명입니다. 클라이언트의 요청이 오면 유효성만 검사하고 빠르게 응답한 뒤, 실제 체결은 Kafka 기반의 백그라운드 엔진으로 넘깁니다.
Place Order (주문 접수) POST /v1/orders
Headers:
Authorization: Bearer <JWT_TOKEN>Idempotency-Key: req-550e8400-e29b-41d4-a716-446655440000(필수: 이중 결제 방지)
Request Body
{
"account_id": "acc_12345",
"ticker": "AAPL",
"side": "buy", // buy 또는 sell
"type": "market", // market(시장가) 또는 limit(지정가)
"quantity": 10, // 소수점 거래(Fractional shares) 지원 시 float 가능
"limit_price": null, // 지정가일 경우에만 값 존재
"time_in_force": "gfd" // gfd(Good For Day), gtc(Good Till Canceled)
}
Response (201 Created)
{
"order_id": "ord_999888",
"status": "pending", // 'executed'가 아님에 주의! 비동기 체결 대기 상태
"created_at": "2026-05-05T09:30:00Z"
}
이 API는 외부 거래소(Market Maker)의 응답을 기다리지 않습니다. DB에
pending상태로 기록하고 사용자 잔고를 예약(Hold)한 뒤 즉시 응답합니다.
Idempotency-Key는 Redis와 DB Unique Index에 이중으로 체크되어, 모바일 네트워크 재시도 시에도 절대 주문이 두 번 들어가지 않도록 막습니다.
1. 멱등성(Idempotency) 보장, “결제 버튼을 두 번 눌러도 한 번만 매수 되도록”
모바일 환경에서는 사용자가 터널을 지나가거나 엘리베이터를 탈 때 네트워크가 끊기는 일이 흔합니다. 사용자가 애플 주식 매수 버튼을 눌렀는데 응답을 받지 못해 불안한 마음에 버튼을 한 번 더 눌렀다고 가정해 보겠습니다. 시스템이 이를 두 개의 독립된 주문으로 인식하면, 사용자의 계좌에서 돈이 두 번 빠져나가는 대형 사고가 발생합니다.
이를 막는 기술이 멱등성(Idempotency)입니다. 동일한 요청을 여러 번 보내도 결과는 한 번 보낸 것과 같아야 한다는 뜻입니다.
Idempotency-Key 생성
클라이언트(모바일 앱)는 주문 요청을 보낼 때 무작위 고유 식별자(UUID)를 생성하여 HTTP 헤더(
Idempotency-Key)에 담아 보냅니다.
1차 방어선 (Redis)
Order Service는 요청을 받자마자 Redis에 이 키가 존재하는지 확인합니다. 만약 없다면 원자적 연산(
SETNX)을 통해 키를 저장하고 주문 로직을 진행합니다.
2차 방어선 (RDB)
데이터베이스의
orders테이블이나 별도의idempotency_keys테이블에도 이 키를 Unique Index로 설정해 둡니다. Redis에 장애가 나더라도 DB 단에서 중복 저장을 원천 차단합니다.
중복 요청 처리
만약 사용자가 버튼을 두 번 눌러 같은 키가 다시 들어오면, 서버는 에러를 뱉는 대신 “이미 정상적으로 접수된 첫 번째 주문의 상태(Pending)”를 그대로 반환합니다. 사용자는 아무 문제 없이 하나의 주문만 들어간 것을 확인하게 됩니다.
2. 유효성 검사와 빠른 응답 (Synchronous Validation), “가계상(Hold) 처리”
비동기 처리를 한다고 해서 무작정 주문을 Kafka로 던지면 안 됩니다. 사용자의 잔고가 10달러뿐인데 100달러짜리 주식 주문이 백그라운드 엔진으로 넘어가서 체결되면 안 되기 때문입니다. 따라서 클라이언트에게 응답(HTTP 201 Created)을 주기 전, 반드시 동기적(Synchronously)으로 끝나야 하는 최소한의 작업이 있습니다.
잔고 확인
사용자의
balances테이블을 조회하여 매수 가능 금액(Buying Power)이 충분한지 확인합니다.
잔고 예약 (Hold)
잔고가 충분하다면, 매수 금액만큼을 ‘출금 가능 금액’에서 차감하여 ‘예약(Hold)’ 상태로 묶어둡니다. 이 돈은 아직 완전히 빠져나간 것은 아니지만, 다른 주식을 사거나 외부로 송금할 수 없게 격리됩니다.
주문 상태 저장
orders테이블에 해당 주문을PENDING(체결 대기) 상태로INSERT합니다.
빠른 응답
이 DB 트랜잭션이 성공하면, 즉시 클라이언트에게 “주문이 정상 접수되었습니다”라고 응답합니다. 이 시점까지 외부 거래소(Market Maker)와의 통신은 단 한 번도 일어나지 않았습니다.
3. 비동기 처리 (Asynchronous Processing), “외부 장애로부터의 완벽한 격리”
클라이언트와 API 서버의 연결은 이미 종료되었지만, 진짜 주식 체결은 이제부터 시작됩니다.
만약 API 서버가 동기적으로(Synchronous) 외부 거래소에 주문을 넣고 체결될 때까지 기다렸다가 사용자에게 응답한다면 어떻게 될까요?
평소에는 문제가 없지만, 시장 개장 직후나 GME(게임스탑) 사태처럼 특정 종목에 트래픽이 몰려 외부 거래소의 처리가 5초씩 지연된다면 심각한 문제가 발생합니다. Robinhood의 API 서버 스레드(Thread) 수십만 개가 응답을 기다리며 멈춰버리고, 결국 전체 앱이 다운되는 연쇄 장애(Cascading Failure)로 이어집니다.
이를 막기 위해 Kafka 기반의 비동기 파이프라인이 필요합니다.
메시지 발행
Order Service는 DB에
PENDING상태를 기록한 직후, 주문 상세 정보(주문 번호, 티커, 수량 등)를 Kafka의orders-topic에 발행(Publish)합니다. API 서버의 역할은 여기서 끝납니다.
체결 엔진 (Execution Engine)
백그라운드에서 돌아가는 체결 워커들이 Kafka에서 메시지를 자신의 처리 속도에 맞춰 꺼내갑니다. 그리고 외부 거래소(Exchange / Market Maker)와 통신하여 실제 주식 매수 요청을 보냅니다.
충격 흡수 (Shock Absorber)
외부 거래소가 지연되더라도, 주문은 Kafka 큐에 안전하게 쌓여 있을 뿐 Robinhood의 API 서버(사용자가 잔고를 보거나 앱을 탐색하는 기능)는 전혀 영향을 받지 않습니다. Kafka가 엄청난 트래픽 스파이크를 흡수하는 댐 역할을 하는 것입니다.
체결 피드백과 원장 정산
외부에서 “체결 완료(Filled)” 응답이 오면, 체결 엔진은 이를 다시 Kafka의
executions-topic에 넣습니다. Ledger Service가 이를 꺼내어 앞서 ‘예약(Hold)’ 해두었던 현금을 최종 차감하고, 사용자의 주식 보유 수량(Positions)을 늘려줍니다.
사용자 알림
마지막으로 Notification Service가 이 결과를 받아 사용자의 스마트폰에 “AAPL 10주 매수 체결 완료”라는 푸시 알림을 보냅니다.
요약하자면, 이 설계는 “사용자의 돈과 관련된 유효성 검사(동기)는 내부 데이터베이스의 ACID 트랜잭션으로 강력하게 보호하고, 통제할 수 없는 외부 시스템과의 통신(비동기)은 Kafka를 통해 완전히 격리한다”는 대규모 트레이딩 시스템의 필수적인 Trade-off 원칙을 따르고 있습니다.
Get Order Status (주문 상태 조회) GET /v1/orders/{order_id}
Response (200 OK):
{
"order_id": "ord_999888",
"ticker": "AAPL",
"status": "executed",
"filled_quantity": 10,
"average_fill_price": 175.50,
"created_at": "2026-05-05T09:30:00Z",
"updated_at": "2026-05-05T09:30:02Z"
}
2. Market Data APIs (실시간 시세 및 차트 - 고가용성 Read Path)
시세 데이터는 REST API 폴링(Polling)으로 처리하면 서버가 견딜 수 없습니다. WebSocket을 통한 실시간 스트리밍과, TSDB를 조회하는 과거 데이터 REST API로 나뉩니다.
Connect to Market Data Stream (실시간 호가 스트리밍) Upgrade: websocket GET /v1/market-data/stream
Client -> Server (구독 요청):
JSON
{
"action": "subscribe",
"tickers": ["AAPL", "TSLA", "GME"]
}
Server -> Client (Conflation이 적용된 틱 데이터 푸시):
JSON
{
"type": "quote",
"ticker": "AAPL",
"price": 175.55,
"bid": 175.50,
"ask": 175.60,
"timestamp": "2026-05-05T09:30:01.123Z"
}
서버는 GME 사태처럼 초당 1만 번 주가가 바뀌더라도 이를 모두 클라이언트에 보내지 않습니다. 100ms 윈도우 단위로 마지막 가격(또는 요약 데이터)만 보내는 Conflation(병합) 기법을 적용해 네트워크 대역폭을 방어합니다.
실시간 시세 스트리밍(Market Data Streaming)은 트레이딩 플랫폼의 “얼굴”이자, 시스템 엔지니어링 관점에서 흥미로운 Read/Stream-Heavy 문제입니다.
사용자는 앱을 켜자마자 가격이 번쩍이며 변하는 것을 기대합니다. 만약 시세가 1초라도 지연되면 사용자는 잘못된 가격에 주문을 넣게 되고, 이는 곧 금전적 손실과 회사에 대한 소송으로 이어질 수 있습니다.
이 중요한 파이프라인이 내부적으로 어떻게 수백만 명의 트래픽을 견디며 동작하는지 3가지로 나누어 깊이 알아 보겠습니다.
1. 왜 REST API 폴링(Polling)은 불가능한가? (WebSocket의 필요성)
만약 200만 명의 동시 접속자가 애플(AAPL) 주가를 보기 위해 1초에 한 번씩 REST API로 GET /v1/market-data/AAPL을 호출한다고 가정해 봅시다.
초당 200만 QPS의 HTTP 요청이 발생합니다.
HTTP는 매 요청마다 헤더(Header)를 주고받으며, TCP 핸드쉐이크(Handshake)와 TLS 인증 과정을 거쳐야 할 수도 있습니다.
데이터(주가) 자체는 몇 바이트에 불과한데, 포장지(HTTP 헤더)가 훨씬 무거운 배보다 배꼽이 더 큰 상황이 벌어집니다.
Solution
WebSocket (상태 유지형 양방향 통신) 클라이언트가 앱을 열 때 서버와 단 한 번 연결(Handshake)을 맺고 파이프를 열어둡니다(
Upgrade: websocket). 이후 서버는 주가가 변할 때마다 무거운 HTTP 헤더 없이, JSON(또는 더 가벼운 Protobuf, Binary 포맷) 형태의 순수 데이터만 클라이언트로 밀어냅니다(Push). 이를 통해 서버의 CPU 및 네트워크 오버헤드를 극적으로 줄이면서 초저지연(Ultra-low latency)을 달성할 수 있습니다.
2. 수백만 명에게 동시에 뿌리기, Pub/Sub 아키텍처와 Connection Manager
WebSocket을 열어두는 것만으로는 부족합니다. 나스닥 거래소에서 “AAPL 가격이 175.55달러로 변했다”는 신호(Tick)가 단 1번 들어왔을 때, 서버는 애플 주식을 보고 있는 100만 명의 사용자를 어떻게 찾아내서 동시에 데이터를 보낼까요?
이때 Pub/Sub (발행/구독) 패턴이 필수적으로 사용됩니다.
구독 (Subscribe)
사용자가 앱에서 애플(AAPL)과 테슬라(TSLA) 차트를 열면, 클라이언트는 WebSocket을 통해
{"action": "subscribe", "tickers": ["AAPL", "TSLA"]}메시지를 보냅니다.
연결 관리자 (WebSocket Gateway)
클라이언트와 물리적인 연결을 맺고 있는 서버(Gateway)는 메모리 상에 “이 유저는 AAPL과 TSLA를 보고 있다”는 구독 지도(Subscription Map)를 유지합니다.
Redis Pub/Sub 또는 내부 버스
Gateway 서버 수십 대는 내부적으로 Redis의 특정 채널(예:
channel:quote:AAPL)을 구독(Listen)하고 있습니다.
발행 (Publish)
Market Data Ingestor가 거래소에서 데이터를 받아
channel:quote:AAPL에 단 한 번 175.55달러를 던집니다.
브로드캐스트 (Broadcast)
이 채널을 듣고 있던 모든 Gateway 서버가 메시지를 수신한 뒤, 자신의 메모리 지도를 뒤져 AAPL을 구독 중인 클라이언트들에게만 병렬로 데이터를 뿌립니다.
이 구조를 통해 데이터베이스(DB)를 전혀 조회하지 않고, 오직 인메모리(In-Memory)와 네트워크 라우팅만으로 수 밀리초(ms) 안에 데이터를 전달할 수 있습니다.
3. Conflation (데이터 병합), GME 사태와 같은 트래픽 폭주 방어
사용자님이 짚어주신 가장 중요한 설계 포인트입니다. 게임스탑(GME) 사태나 주요 지표 발표 시점에는 호가가 1초에 1만 번씩 바뀔 수 있습니다.
서버가 1초에 1만 번의 가격 변화를 모두 모바일 클라이언트로 푸시하면 어떻게 될까요?
네트워크 병목
서버의 아웃바운드 대역폭(Bandwidth)이 터져버립니다. 모바일 기기 역시 요금제 데이터가 순식간에 소진됩니다.
디바이스 마비
모바일 폰의 CPU가 1초에 1만 번 UI(화면)를 렌더링(Redraw)하려고 시도하다가 발열이 심해지고 앱이 멈춥니다(Crash).
인간의 인지 한계
어차피 사람의 눈은 1초에 수십 번 이상 변하는 숫자를 읽지 못합니다.
Solution
시간 기반 데이터 병합 (Time-based Conflation / Throttling) 모든 틱을 실시간으로 보내지 않고, 매우 짧은 시간 창(Time Window, 예: 100ms ~ 250ms)을 둡니다.
동작 방식
100ms 동안 GME 주가가 1,000번 변했다면, 서버의 Gateway 계층은 이를 모아둡니다. 그리고 100ms가 끝나는 시점에 가장 최신 가격(Last Traded Price) 또는 그 100ms 동안의 요약 데이터(고가/저가) 단 1번만 클라이언트로 전송합니다.
Trade-off (장단점)
이 방식을 사용하면 중간에 발생한 미세한 가격 변동 내역은 모바일 클라이언트에 전달되지 않아 ‘완벽한 최신성(Absolute real-time)’은 일부 희생됩니다. 하지만, 서버 생존율 보장, 네트워크 대역폭 절약, 모바일 앱의 쾌적한 렌더링이라는 압도적인 가용성(Availability) 이득을 얻게 됩니다.
실제 월스트리트의 HFT(고빈도 매매) 기관이 아닌 일반 개인 투자자를 위한 모바일 트레이딩 앱에서는 이 Conflation 기법이 시스템을 지탱하는 가장 강력한 방어막입니다.
Get Historical Prices (차트 데이터 조회)
GET /v1/market-data/historical?ticker=AAPL&resolution=5m&start_time=1714867200&end_time=1714953600
Response (200 OK)
{
"ticker": "AAPL",
"resolution": "5m",
"candles": [
{
"timestamp": "2026-05-05T09:00:00Z",
"open": 174.00,
"high": 175.50,
"low": 173.80,
"close": 175.20,
"volume": 1500000
}
// ...
]
}
이 API는 RDBMS가 아닌 InfluxDB 같은 Time-Series DB(TSDB)를 향합니다. 대량의 시계열 데이터를 빠르게 집계(Aggregation)하여 차트 렌더링에 필요한 OHLC(Open, High, Low, Close) 데이터를 제공합니다.
트레이딩 앱에서 사용자가 가장 긴 시간 동안 쳐다보는 화면이 바로 차트(Chart)입니다.
사용자가 애플(AAPL) 주식의 ‘5분봉 차트’를 열었을 때, 시스템은 지정된 기간 동안의 OHLCV (Open: 시가, High: 고가, Low: 저가, Close: 종가, Volume: 거래량) 데이터를 반환해야 합니다. 겉보기엔 단순한 SELECT 쿼리 같지만, 초당 수십만 건의 원본 거래 데이터(Tick)가 쏟아지는 환경에서 이를 RDBMS로 처리하려다가는 서비스가 곧바로 멈춰버리게 됩니다.
이 API가 왜 일반적인 관계형 데이터베이스(RDBMS)를 피하고 시계열 데이터베이스(TSDB, Time-Series Database)를 선택해야만 하는지, 그 이면에 숨겨진 시스템 설계를 자세히 알아보겠습니다.
1. RDBMS의 한계, “쓰기 폭주(Write Storm)와 B-Tree의 한계”
미국 주식 시장 전체에서 하루에 발생하는 틱(Tick: 체결 하나하나의 기록) 데이터는 수십억 건에 달합니다.
RDBMS(PostgreSQL, MySQL)에 저장한다면?
수십억 건의 데이터를
INSERT할 때마다 RDBMS의 기본 구조인 B-Tree 인덱스가 계속해서 업데이트되며 트리를 재정렬(Rebalancing)해야 합니다. 디스크 I/O가 폭발하고, 쓰기 속도가 급격히 느려지며, 데이터베이스의 용량은 감당할 수 없을 만큼 부풀어 오릅니다.
조회 시의 문제
사용자가 “최근 1주일간의 5분봉 차트”를 요청하면, DB는 1주일 치의 원본 틱 데이터 수백만 건을 읽어와서
GROUP BY 5 minutes를 한 뒤, 각각의MAX(price),MIN(price)를 계산해야 합니다. 이 무거운 쿼리를 100만 명의 사용자가 동시에 날리면 DB의 CPU는 100%를 치고 뻗어버립니다.
2. TSDB(Time-Series Database)의 강점, 시계열에 최적화된 설계
InfluxDB, TimescaleDB, kdb+, QuestDB와 같은 TSDB는 오직 ‘시간의 흐름에 따라 순차적으로 쌓이는 데이터’를 다루기 위해 태어났습니다.
초고속 쓰기 (Append-Only)
과거의 데이터를 수정(UPDATE)할 일이 거의 없습니다. TSDB는 데이터를 시간 순서대로 디스크에 순차 기록(Sequential Write)하는 구조(LSM Tree 등)를 사용하여 RDBMS와 비교할 수 없는 엄청난 쓰기 속도(Write Throughput)를 자랑합니다.
시간 기반 파티셔닝 (Time-based Partitioning)
데이터를 ‘2026년 5월 5일 데이터’, ‘5월 6일 데이터’처럼 시간 단위로 쪼개어 저장(Chunking)합니다. 따라서 “최근 1주일”을 검색할 때 디스크 전체를 뒤질 필요 없이 해당 기간의 파티션만 빠르게 스캔합니다.
열 지향 압축 (Columnar Compression)
주식 데이터는 동일한 티커(AAPL)에 시간과 가격만 조금씩 바뀝니다. TSDB는 유사한 데이터를 세로(Column)로 묶어 압축하므로, 스토리지 비용을 RDBMS 대비 10분의 1 수준으로 줄여줍니다.
3. 연속 집계 (Continuous Aggregation / Rollups)
TSDB => Time-Series Database
API 요청 파라미터를 보면 resolution=5m(5분봉)이 있습니다. TSDB가 아무리 빠르다 해도, API 요청이 들어올 때마다 수백만 건의 틱 데이터를 실시간으로 묶어서(Aggregation) 시가/고가/저가/종가를 계산하는 것(On-the-fly)은 비효율적입니다.
다운샘플링(Downsampling)과 사전 계산(Pre-compute) TSDB는 백그라운드 워커를 통해 끊임없이 들어오는 원본 틱 데이터를 실시간으로 요약(Rollup)해 둡니다.
틱 데이터(Raw Ticks)가 TSDB에 저장됩니다.
TSDB 내부의 Continuous Query (또는 Materialized View) 기능이 1분마다 원본 데이터를 읽어 1분봉(1m) 테이블을 만듭니다.
동일한 방식으로 5분봉(5m), 1시간봉(1h), 1일봉(1d) 테이블(View)을 백그라운드에서 미리 만들어 둡니다.
결과적으로 API 서버의 동작은 매우 가벼워집니다. 클라이언트가 GET /v1/market-data/historical?ticker=AAPL&resolution=5m...을 호출하면, API 서버는 무거운 연산을 하지 않습니다. 이미 잘 예쁘게 계산되어 저장된 “5분봉 사전 계산 테이블”에서 딱 필요한 시간대(start_time ~ end_time)의 행(Row)만 가볍게 읽어서 응답(JSON)으로 내려줍니다.
트레이드 오프
원본 데이터 외에 1m, 5m, 1h, 1d 등 요약 데이터를 추가로 저장해야 하므로 스토리지 비용은 일부 증가합니다.
하지만, 수백만 사용자가 동시에 차트를 열 때 발생하는 막대한 CPU 연산 비용(Compute Cost)을 스토리지 비용(Storage Cost)으로 치환하여 시스템의 극단적인 가용성과 저지연(Low Latency)을 확보하는 훌륭한 시스템 설계 전략입니다.
인터뷰에서 이 부분을 설명할 때, “단순히 DB에서 쿼리해서 줍니다”라고 말하는 것과, “TSDB의 Continuous Aggregation을 활용해 Read 시점의 Compute 비용을 Write 시점의 Storage 비용으로 Trade-off 했습니다”라고 대답하는 것을 권장드립니다.
3. Portfolio & Account APIs (자산 조회 - Client-side Hydration)
포트폴리오 API는 사용자의 “정적인 보유 수량”만 내려줍니다. 총 자산의 “실시간 가치(Valuation)”는 서버가 계산하지 않고 모바일 기기로 연산을 오프로딩합니다.
Get Portfolio / Balances (포트폴리오 조회) GET /v1/accounts/{account_id}/portfolio
Headers:
Authorization: Bearer <JWT_TOKEN>Response (200 OK):
JSON
{
"account_id": "acc_12345",
"cash": {
"total_balance": 5000.00,
"buying_power": 3500.00, // 1500.00은 현재 pending 상태인 주문으로 Hold 됨
"withdrawable": 5000.00
},
"positions": [
{
"ticker": "AAPL",
"quantity": 20,
"average_cost": 150.00
},
{
"ticker": "TSLA",
"quantity": 5,
"average_cost": 200.00
}
]
}
설계 포인트
응답값에
current_price나 실시간total_equity(총 평가금액)가 없습니다. 서버에서 수천만 명의 포트폴리오를 매초 계산하면 Compute-heavy 병목이 생깁니다.동작 방식
클라이언트는 이 API로
quantity(20주)를 받아오고, 앞서 연결한 WebSocket 스트림을 통해 AAPL의 실시간price(175.55)를 받습니다. 그리고 모바일 앱(클라이언트)에서 실시간으로 20 * 175.55를 곱하여 화면의 총 자산 금액을 초 단위로 갱신합니다.
포트폴리오 화면은 사용자가 Robinhood 앱을 열 때 가장 먼저, 그리고 하루에도 수십 번씩 가장 자주 확인하는 화면입니다.
사용자 눈에는 “내 총자산이 1초마다 번쩍거리며 오르내리는” 화면이지만, 이를 백엔드 서버에서 그대로 구현하려고 하면 가장 끔찍한 Compute-Heavy(연산 집약적) 병목에 직면하게 됩니다.
이 API 설계가 왜 수천만 대의 모바일 기기를 ‘분산 컴퓨팅 클러스터’처럼 활용하는 천재적인 Trade-off인지, 그 이면의 3가지 설계 원리를 파헤쳐 보겠습니다.
1. 안티 패턴(Anti-Pattern), 서버 측 실시간 계산 (Server-side Valuation)
보통 주니어 개발자들이 처음 시스템을 설계할 때 가장 많이 하는 실수가 “클라이언트가 요구하는 완성된 형태의 데이터를 서버가 모두 만들어서 내려준다”는 접근입니다.
만약 서버가 포트폴리오의 실시간 가치를 계산해서 내려준다면 어떤 일이 벌어질까요?
애플(AAPL) 주가가 1초에 10번 바뀝니다.
서버는 애플 주식을 보유한 사용자 500만 명의 DB(Positions)를 뒤집니다.
(보유 수량 * 새로운 현재가) + 현금 잔고연산을 500만 번 수행합니다.계산된 500만 개의 새로운 포트폴리오 총액 데이터를 클라이언트에게 푸시(Push)합니다.
이 짓을 AAPL 하나뿐만 아니라 TSLA, GME 등 사용자가 보유한 모든 종목에 대해, 시장이 열려 있는 6시간 반 동안 매초 수행해야 합니다. 서버의 CPU는 폭발하고, 연산을 위해 DB를 읽는 I/O 대역폭은 터져버리며, 네트워크 트래픽은 감당할 수 없게 됩니다.
2. 클라이언트 오프로딩 (Client-side Hydration / Offloading)
Robinhood의 아키텍트는 이 문제를 해결하기 위해 데이터의 성격을 ‘정적(Static)’인 것과 ‘동적(Dynamic)’인 것으로 완벽히 분리했습니다.


