모의 주식 트레이딩 서비스 — ERD

1주차 MVP 기준 · 26.08.20 ~ 08.25 · 인증은 1주차 미구현 — 시드 사용자 1명(user_id = 1)으로 개발

PostgreSQL 15+12 tables append-only ledger회차 기반 초기화

전체 관계도

파란 테이블이 계정계(사용자의 돈), 흰 테이블이 시세·마스터입니다. 돈이 움직이는 경로는 account → trade_order → ledger_entry → holding 하나뿐이고, 시세 쪽과 만나는 접점은 trade_orderholding 둘뿐입니다.

계정계 시세 · 마스터 PK기본키 FK외래키 UK유니크 분류① ② 태그 — 종목 유형 판정 컬럼 TOSS토스증권 API 로 채우는 테이블 TimescaleDB 하이퍼테이블 데이터 흐름 (FK 아님)
close_price
→ prev_close
1:N
1:N
1:N
1:N
1:N
1:N
1:N
1:N
1:1
1:N
1:N
1:N

users 회원

  • PKuser_id
  • UKemail
  • password_hash
  • nickname
  • status

account 모의 계좌

  • PKaccount_id
  • FKuser_id
  • round_no
  • status
  • initial_cash
  • cash_balance
  • locked_cash  동결
  • version

daily_account_snapshot 2주차

  • PKaccount_id
  • PKsnapshot_date
  • cash_balance
  • stock_value
  • total_asset
  • unrealized_pnl

trade_order 주문+체결일부 TOSS

  • PKorder_id
  • FKaccount_id
  • FKstock_id
  • UKclient_order_id
  • side
  • quantity
  • status
  • reject_reason
  • executed_price
  • quote_at
  • exchange_rate
  • gross_amount
  • fee / tax
  • net_amount
  • ordered_at

ledger_entry 거래 원장 · append only

  • PKentry_id
  • FKaccount_id
  • FKorder_id
  • entry_type
  • amount
  • balance_after
  • exchange_rate
  • memo
  • occurred_at

holding 보유 종목

  • PKholding_id
  • FKaccount_id
  • FKstock_id
  • quantity
  • locked_quantity  동결
  • avg_buy_price
  • avg_exchange_rate

stock 종목 마스터TOSS /stocks

  • PKstock_id
  • UKsymbol + market_country
  • name / english_name
  • isin_code
  • market / currency
  • security_type
  • stock_category  분류①
  • leverage_factor  분류②
  • is_dividend  태그
  • dividend_yield
  • is_common_share
  • shares_outstanding
  • is_suspended
  • is_liquidation
  • is_warned
  • is_ranked / rank_no
  • trading_amount

stock_external_id 소스별 심볼

  • PKstock_id
  • PKsource
  • external_id

quote_snapshotTOSS /prices

  • PKstock_id
  • last_price
  • prev_close
  • upper_limit
  • lower_limit
  • quote_at
  • collected_at

daily_candle TOSS /candles

  • PKstock_id
  • PKtrade_date
  • open_price
  • high / low_price
  • close_price
  • volume

minute_candle TOSS /candles

  • PKstock_id
  • PKcandle_at
  • open_price
  • high_price
  • low_price
  • close_price
  • volume
1주차는 온디맨드 적재
하이퍼테이블 전환은 2주차

exchange_rateTOSS /exchange-rate

  • PKexchange_rate_id
  • UKbase+quote+rate_at
  • base_currency
  • quote_currency
  • rate
  • mid_rate
  • rate_at
  • collected_at
일반 테이블 — FK 관계 없음
연 6,000행이라 청크 불필요

MVP 동작 매트릭스 확정

조회는 언제나 전 종목 가능하고, 거래는 "해당 시장 정규장 + 상위 100" 에만 허용됩니다. 판정 기준은 보는 사람의 시각이 아니라 그 종목이 속한 시장이 열려 있는가입니다 — 한국 낮에 엔비디아를 열면 미국장이 닫혀 있으므로 전일 종가가 나갑니다.

시간대 (KST)국내 상위 100 미국 상위 100그 외 전 종목
09:00 ~ 15:30 5초 실시간 · 거래 O
차트 1분봉(온디맨드)
전일 종가 · 거래 X
차트 마지막 장 분봉
전일 종가 · 거래 X
차트 마지막 장 분봉
22:30 ~ 05:00 * 전일 종가 · 거래 X
차트 마지막 장 분봉
5초 실시간 · 거래 O
차트 1분봉(온디맨드)
전일 종가 · 거래 X
차트 마지막 장 분봉
그 외 시간 전일 종가 · 거래 X 전일 종가 · 거래 X 전일 종가 · 거래 X
차트는 어느 칸에서든 그려집니다. 정규장이면 5초 시세와 함께 1분봉이 이어지고, 장외이거나 다른 나라 종목이면 마지막 장의 분봉이 그대로 보입니다. 빈 차트는 사용자에게 "고장난 화면"으로 읽히므로 거래 불가와 조회 불가를 반드시 분리하세요.

분봉은 사용자가 상세 페이지를 열 때 토스에 요청합니다(온디맨드). 받은 봉은 minute_candle 에 넣어두고 60초 안에 다시 열면 DB 에서 바로 줍니다 — 테이블이 저장소이자 캐시 역할을 겸합니다. 스케줄러로 상시 적재하는 것은 2주차 과제로 둡니다.
상위 100 밖 종목의 전일 종가는 어떻게 채우나요? 스케줄러가 도는 대상은 상위 100 뿐이라 나머지 8,300여 종목은 quote_snapshot 이 비어 있습니다. 상세 페이지를 여는 순간 /prices/candles 를 함께 호출해 채우고, 그 결과를 quote_snapshot 에 UPSERT 해두세요. 한 번 조회된 종목은 다음부터 DB 에서 나갑니다.

검색 결과 목록에 가격을 같이 보여주고 싶다면, 20건이면 /prices 배치 1콜로 한 번에 받아옵니다 — 종목마다 따로 부르면 20콜이 되어 rate limit 에 걸립니다.
* 미국 정규장 시각은 서머타임에 따라 1시간 이동합니다.
서머타임(3월 둘째 일요일 ~ 11월 첫째 일요일) 22:30 ~ 05:00 ← 지금(8월)
표준시(11월 첫째 일요일 ~ 3월 둘째 일요일) 23:30 ~ 06:00

절대 하드코딩하지 마세요. /market-calendar/USregularMarket 세션 시각을 그대로 쓰면 됩니다 — 응답이 KST 기준으로 오므로 변환도 필요 없습니다. 하드코딩하면 11월 첫째 주에 장 시작 후 한 시간 동안 거래가 막힙니다.
종가 데이터 파이프라인
토스 캔들 API 의 종가가 어떻게 화면의 등락률이 되는지
GET /api/v1/candles?symbol=005930&interval=1d&count=200
└ 국내 15:40 / 미국 05:10 · 시장별 100콜 · 약 20초
closePrice 를 저장
daily_candle.close_price 그날 종가 확정
다음 장 시작 직전 복사 (국내 08:50 / 미국 22:00)
quote_snapshot.prev_close
5초마다 갱신되는 last_price 와 함께
등락률 = (last_price − prev_close) / prev_close

랭킹 목록 · 상세 페이지 · 마이페이지에 표시
토스 현재가 응답에는 등락률이 없습니다. symbol · timestamp · lastPrice · currency 네 필드뿐이라 전일 종가를 따로 확보해서 직접 계산해야 합니다. 그 전일 종가의 원천이 daily_candle 이고, 조인 없이 쓰려고 quote_snapshot 에 복사해둡니다.

복사 시점이 "마감 직후"가 아니라 "다음 장 시작 직전"인 점을 주의하세요. 마감 직후에 복사하면 prev_close = last_price 가 되어 장외 시간 내내 등락률이 0% 로 표시됩니다.
수집 범위 — 거래대금 상위 국내 100 + 해외 100 = 총 200종목. 랭킹 API 의 count 최대값이 100 이라 시장별 1콜로 완결됩니다. 선정 기준은 duration=1w, 갱신은 매주 월요일 국내 08:00 · 미국 21:00(각 시장 장 시작 직전).

메모리 캐시로만 처리하는 것market_calendar(장 운영 시간), 현재 환율(1분 TTL). 이력을 쌓을 이유가 없기 때문입니다.

이후 기능 추가 시wiki_term(금융 용어 위키), index_candle(지수 일봉 — 수익률 벤치마크 비교용).

토스증권 API 매핑

어느 엔드포인트가 어느 컬럼을 채우는지, 얼마나 자주 호출하는지 정리했습니다. 이 표가 곧 수집기 구현 스펙입니다. 토스의 한도는 API 그룹별로 따로 걸리므로 그룹이 다르면 서로 영향을 주지 않습니다.

TOSS 토스증권 API 외부 다른 소스 필요 자체 우리가 생성

엔드포인트별 호출 계획 2026-08 문서 기준

엔드포인트그룹 / 한도 호출 주기채우는 대상
POST /oauth2/tokenAUTH · 5 TPS 만료 직전 1회 DB 저장 없음. 토큰은 메모리 캐싱 필수 — 매 요청마다 발급하면 그것만으로 차단됩니다.
GET /api/v1/stocks/allSTOCK_ALL · 1 TPS 매주 월요일 07:00 마켓별 전체 종목 목록. 페이지네이션 없이 한 번에 반환합니다 (NASDAQ 약 2,800건, gzip 30KB). market 7개(KOSPI·KOSDAQ·NYSE·NASDAQ·AMEX·KR_ETC·US_ETC)를 각각 부르면 7콜로 전 종목 심볼이 확보됩니다.
필터가 우리 설계와 맞습니다 — commonShare=true(우선주 제외), status=ACTIVE(상장폐지 제외), securityType(STOCK·ETF·ETN·REIT…).
GET /api/v1/stocksSTOCK · 5 TPS 매주 월요일 07:00 stock 상세 — 종목명·통화·ISIN· security_type·is_common_share·leverage_factor· 상장주식수·상장일, 그리고 koreanMarketDetail 의 거래정지·정리매매 플래그.
/stocks/all 에서 받은 심볼을 200개씩 배치로 넘깁니다 — 8,500종목이면 43콜, 약 9초.
GET /api/v1/rankingsRANKING · 5 TPS 유니버스: 월요일
KR 08:00 · US 21:00
화면 랭킹: 30초 TTL
stock.is_ranked, stock.rank_no, stock.trading_amount, 신규 편입 종목의 prev_close(price.basePrice).
시장별 100개씩이라 KR·US 각 1콜로 완결됩니다. type=MARKET_TRADING_AMOUNT, duration=1w, excludeInvestmentCaution=true.
주말에는 집계가 없을 수 있으니 빈 배열이면 지난주 유니버스를 유지하세요.
GET /api/v1/pricesMARKET_DATA · 15 TPS 5초
정규장 시간에만
quote_snapshot.last_price, quote_at, currency. 시장별 100종목이 배치 1콜 — 5초 주기여도 한도의 1.3% 입니다. 국내장과 미국장이 겹치지 않아 동시 부하도 없습니다.
장이 닫히면 스케줄러를 멈춥니다. 그러면 마지막 값(=종가)이 남아 자연히 "전일 종가"가 조회됩니다.
가격이 문자열로 오므로 BigDecimal 로 파싱하세요.
GET /api/v1/price-limitsMARKET_DATA · 15 TPS 장 시작 전 1회 quote_snapshot.upper_limit, lower_limit. 전일 종가 기준으로 정해져 하루 동안 안 바뀌므로 실시간 폴링이 필요 없습니다. 단건 조회라 국내 100종목이면 100콜, 약 7초. 미국 종목은 가격제한이 없어 NULL 입니다.
GET /api/v1/candles
interval=1d
MARKET_DATA_CHART · 20 TPS 국내 15:40 / 미국 05:10 daily_candle. 한도가 20 TPS 로 올라 상위 100종목이면 5초, 전 종목 8,500개를 받아도 약 7분이면 끝납니다.
여기서 확정된 close_price 가 다음 장 시작 전 quote_snapshot.prev_close 로 복사되어 등락률의 기준이 됩니다.
timestamp 는 시각이므로 KST 기준 날짜로 변환하세요.
GET /api/v1/candles
interval=1m
MARKET_DATA_CHART · 20 TPS 1주차: 온디맨드
상세 페이지 진입 시
minute_candle. 사용자가 종목 상세를 열 때 호출하고, 받은 봉을 저장해 60초 캐시로 재사용합니다. 장외이거나 다른 나라 종목이면 마지막 장의 분봉이 그대로 옵니다.
동시 시청 30명 기준 0.5 req/s(2.5%) — 상시 적재보다 오히려 쌉니다. 아무도 안 보는 종목까지 1분마다 긁을 이유가 없기 때문입니다.
스케줄러 상시 적재는 2주차(지정가 체결 판정 · 5m·10m 집계)에 시작합니다.
GET /api/v1/stocks/
{symbol}/warnings
STOCK · 5 TPS 1주차 미사용
필요 시 08:00 배치
stock.is_warned. 정리매매·단기과열·투자경고/위험·VI 발동을 알려줍니다. 단건 조회라 100종목이면 100콜, 약 20초.
확정 스케줄에는 넣지 않았습니다 — 랭킹 API 의 excludeInvestmentCaution=true 로 이미 대부분 걸러지기 때문입니다.
GET /api/v1/exchange-rateMARKET_INFO · 3 TPS 이력 적재: 매시 정각
현재 환율: 1분 TTL 캐시
두 경로가 다릅니다. 그래프용 이력은 매시 정각에 exchange_rate 로 적재하고(하루 24콜), 체결에 쓰는 현재 환율은 1분 TTL 메모리 캐시에서 가져옵니다.
응답의 validFromrate_at 으로 쓰고 ON CONFLICT DO NOTHING 으로 적재하면 주말 중복이 자동으로 걸러집니다.
GET /api/v1/
market-calendar/KR·US
MARKET_INFO · 3 TPS 앱 기동 시 + 매일 1회 메모리 캐시로 충분합니다 (schema.sqlmarket_calendar 테이블이 선택 항목으로 들어 있으니, 이력을 남기고 싶으면 그쪽을 쓰세요). 세 곳에 쓰입니다 — ① 주문 가능 시간 판정, ② 시세 수집 스케줄러 on/off, ③ 화면의 "실시간 / 종가" 표시 분기.
서머타임·수능일·임시휴장 때문에 절대 하드코딩하면 안 됩니다.
wss://openapi-ws
/ws/v1
구독 100건 / 연결 2개 2주차 개선 과제 실시간 체결·호가 웹소켓. 연결당 구독 100건, 계정당 연결 2개라 국내 100 + 미국 100 = 정확히 200종목이 들어맞습니다. 도입하면 폴링이 사라지고 진짜 실시간이 됩니다.
대신 재연결·재구독, 60초 PING, full-replace 구독 관리가 필요하고 시세는 LOSSY 보장이라 프레임 유실을 감안해야 합니다. 1주차에는 폴링으로 갑니다.
POST /api/v1/orders 등 사용 안 함 주문 API 는 절대 호출하지 않습니다 — 실제 계좌에 실주문이 나갑니다. QuoteClient 에서 호출 가능 경로를 화이트리스트로 고정하세요.

테이블별 데이터 출처

테이블출처비고
stockTOSS 대부분 /stocks + /warnings + /rankings. 다만 stock_category자체 판정, dividend_yield외부 (국내 DART 배당 정보 / 해외 Alpha Vantage) 가 필요합니다.
quote_snapshotTOSS collected_at자체 기록. 나머지는 /prices, /price-limits, /rankings.
daily_candleTOSS 전부 /candles?interval=1d. 장 마감 직후 시장별 100콜. 수정주가 적용 여부(adjusted)를 팀에서 정하고 고정하세요 — 중간에 바꾸면 이미 저장된 과거 데이터와 어긋납니다.
minute_candleTOSS 전부 /candles?interval=1m. 1주차에는 온디맨드로 채웁니다 — 사용자가 상세를 열 때 받아서 저장하고 60초간 캐시로 씁니다. 스케줄러 상시 적재는 2주차부터입니다.
exchange_rateTOSS 전부 /exchange-rate. collected_at자체 기록합니다.
trade_order자체 + TOSS 주문 내용은 자체 생성이지만 executed_price·quote_atquote_snapshot 에서 복사(원천은 /prices), exchange_rate/exchange-rate 에서 옵니다. 토스에 주문을 보내지는 않습니다 — 체결은 우리 DB 안에서만 일어납니다.
holding자체 원장에서 파생. avg_exchange_rate 만 토스 환율에서 유래합니다.
ledger_entry자체 전부 자체 생성입니다. exchange_ratetrade_order.exchange_rate 를 그대로 복사해 넣습니다 (원천은 토스 /exchange-rate).
원장은 append-only 라 주문 테이블이 나중에 어떻게 바뀌든 이 기록은 그대로 남습니다.
users
account
daily_account_snapshot
stock_external_id
자체 외부 API 와 무관합니다. 계정계는 전적으로 우리가 소유하며, 이것이 이 프로젝트가 채널계가 아니라 계정계인 이유입니다.

배치 일정 확정

시각 (KST)주기하는 일
월요일 07:00주 1회 ① 전체 종목 마스터 갱신
/stocks/all × 마켓 → 전 종목 심볼, /stocks 배치 200개씩 → 상세
신규 상장·상장폐지가 여기서 반영됩니다. 약 50콜, 15초.
월요일 08:00주 1회 ② 국내 거래대금 상위 100 선정 — 토스 1콜
/rankings?market=KR&duration=1w&count=100is_ranked, rank_no, trading_amount 갱신
신규 편입 종목의 prev_close·일봉 백필
빈 배열이면 지난주 유니버스를 유지합니다.
월요일 21:00주 1회 ③ 미국 거래대금 상위 100 선정 — 토스 1콜
국내와 동일한 처리. 미국장 시작(22:30) 1시간 30분 전이라 새 유니버스로 첫 시세 수집을 시작할 수 있습니다.
08:50일 1회 국내 prev_close ← 전일 daily_candle.close_price
장 시작 10분 전. 상하한가도 이때 함께 받습니다.
확정 목록에는 없지만 등락률 계산에 필요합니다 (아래 설명)
09:00 ~ 15:305초 국내 상위 100 현재가 수집/prices 배치 1콜, 한도의 1.3%
이 시간에만 국내 종목 거래가 열립니다.
15:40일 1회 국내 일봉 적재 — 마감 10분 후. /candles?interval=1d 100콜, 약 5초
22:00 *일 1회 미국 prev_close 갱신 — 정규장 시작 30분 전
서머타임이면 23:00
22:30 ~ 05:00 * 5초 미국 상위 100 현재가 수집 — 배치 1콜
표준시(겨울)에는 23:30 ~ 06:00 으로 1시간 이동합니다.
05:10 *일 1회 미국 일봉 적재 — 마감 10분 후. 표준시에는 06:10
매시 정각1시간 환율 적재 — 하루 24콜. 주말·휴장에도 그냥 돌립니다 (중복은 UNIQUE 로 자동 차단).
그 외 시간 시세 수집 정지. 조회는 되지만 전일 종가가 표시되고 주문은 거부됩니다.
정규장 중에는 5초마다 배치 1콜이 전부입니다. 한도의 1.3% 만 씁니다. 200종목을 한 번에 조회하는 배치 API 덕분에 사용자 수와 외부 API 호출량이 완전히 분리됩니다 — 이 프로젝트에서 처음 풀어야 했던 문제가 이 한 줄로 해결됩니다.

국내장과 미국장은 시간대가 겹치지 않습니다. 09:00~15:30 과 22:30~05:00 이라 같은 순간에 도는 수집기는 언제나 하나입니다. 합산 부하를 걱정할 필요가 없습니다.
prev_close 갱신을 목록에 넣은 이유
토스 현재가 응답에는 등락률이 없습니다(symbol·timestamp· lastPrice·currency 네 필드뿐). 전일 종가를 직접 확보해 (last_price − prev_close) / prev_close 로 계산해야 합니다.

복사 시점이 "마감 직후"가 아니라 "다음 장 시작 직전"이어야 합니다. 마감 직후에 복사하면 prev_close = last_price 가 되어 장외 시간 내내 등락률이 0% 로 표시됩니다. 일봉 적재(15:40)와 prev_close 복사(다음날 08:50)를 반드시 분리하세요.
* 미국 시각은 서머타임에 따라 1시간 이동합니다. 하드코딩하지 말고 /market-calendar/USregularMarket 세션 시각을 그대로 쓰세요 — 응답이 KST 기준으로 오므로 변환도 필요 없습니다. 하드코딩하면 11월 첫째 주에 장 시작 후 한 시간 동안 거래가 막힙니다.
스케줄러에 넣지 않은 것 — 온디맨드로 처리합니다
· 분봉 — 사용자가 상세를 열 때 /candles?interval=1m 호출 + 60초 캐시. 상시 적재는 2주차(지정가 체결 판정)부터.
· 상위 100 밖 종목의 시세 — 상세 진입 시 /prices·/candles 를 불러 quote_snapshot 에 UPSERT. 8,500종목을 매일 도는 배치는 만들지 않습니다.
· 매수 유의사항(/warnings) — 단건 조회라 100종목이면 20초. 필요해지면 08:00 배치에 붙이세요.
수집기가 토스와 대화하는 유일한 지점입니다. 화면(채널계)과 원장(계정계)은 토스를 직접 호출하지 않고 우리 DB 만 봅니다. 그래서 나중에 시세 공급자를 교체해도 QuotePort 구현체 하나만 갈아끼우면 되고, 원장·주문 코드는 손댈 일이 없습니다.

컬럼 사전

테이블별 전체 컬럼과 의도입니다. 특히 왜 이 컬럼이 필요한가에 초점을 맞췄습니다. 나중에 추가할 수 있는 컬럼과, 지금 안 넣으면 영원히 복구 불가능한 컬럼이 섞여 있습니다.

계정계 — 사용자의 돈

users 회원계정계

1주차에는 인증을 구현하지 않습니다. 회원가입·로그인은 화면 UX 만 만들고, 서버는 시드 사용자 1명(user_id = 1)으로 고정해 동작합니다 (.envAUTH_ENABLED=false · DEV_FIXED_USER_ID=1).

테이블은 지금 만들어 두세요. account.user_id 가 이걸 참조하기 때문에 나중에 붙이려면 FK 와 데이터를 함께 손봐야 합니다. password_hash1주차에 비워두거나 더미 값을 넣고, 2주차에 로그인을 붙일 때 채웁니다. 소셜 로그인·OAuth 도 그때 검토합니다.
컬럼타입설명
user_idBIGINT PK 내부 식별자. IDENTITY 로 자동 채번합니다.
emailVARCHAR(255) UK 로그인 아이디를 겸합니다. 대소문자 구분 문제가 있으니 저장 전 소문자로 정규화하세요.
password_hashVARCHAR(255) 평문 저장 금지. BCrypt 로 해시합니다. Spring Security 의 BCryptPasswordEncoder 기본값이면 충분합니다.
nicknameVARCHAR(50) 화면에 노출되는 이름. 이메일 노출을 피하기 위해 둡니다.
statusVARCHAR(20) ACTIVE / DORMANT / WITHDRAWN. 탈퇴를 물리 삭제로 처리하면 거래 원장의 FK 가 깨지므로 상태 전환으로만 다룹니다.
created_at
updated_at
TIMESTAMPTZ 감사용 공통 컬럼. 모든 테이블에 두는 것을 권장합니다.

account 모의 투자 계좌계정계

포트폴리오 초기화의 단위입니다. 초기화 시 이 행을 지우지 않고 회차를 올린 새 행을 만듭니다.

컬럼타입설명
account_idBIGINT PK 주문·원장·보유종목이 전부 이 값에 매달립니다. 회차가 바뀌면 이 값도 바뀌므로 화면에서는 자동으로 새 회차만 보입니다.
user_idBIGINT FK 소유자. 한 회원이 여러 회차 계좌를 가질 수 있습니다.
round_noINT 포트폴리오 초기화 회차. 1부터 시작해 초기화할 때마다 +1. "3번째 도전 중" 같은 표시나 회차별 성적 비교로 확장할 수 있습니다.
statusVARCHAR(20) ACTIVE / CLOSED. 부분 유니크 인덱스로 회원당 ACTIVE 계좌가 하나만 존재하도록 강제합니다 (WHERE status='ACTIVE').
initial_cashNUMERIC(19,4) 지급액(5,000만원). 수익률 계산의 분모입니다. 나중에 지급액 정책이 바뀌어도 과거 회차의 기준이 보존되도록 계좌마다 저장합니다.
cash_balanceNUMERIC(19,4) 전체 예수금. 매수 트랜잭션에서 FOR UPDATE 로 잠그는 대상입니다. CHECK (cash_balance >= 0) 으로 음수를 DB 레벨에서 차단합니다.
locked_cashNUMERIC(19,4) 미체결 주문에 묶인 금액. 주문 접수 시 더하고 체결·취소 시 뺍니다.
주문가능금액은 저장하지 않고 cash_balance − locked_cash 로 계산합니다 — 파생값을 저장하면 한쪽만 갱신되는 버그가 조용히 지속되기 때문입니다.
동결액에는 수수료·세금을 포함한 net_amount 를 씁니다. gross_amount 만 묶으면 체결 시점에 수수료만큼 부족해집니다.
CHECK (locked_cash <= cash_balance) 로 과다 동결을 DB 가 막습니다.
versionBIGINT JPA 낙관적 락(@Version)용. 비관적 락(FOR UPDATE)을 주 전략으로 쓰더라도 이중 안전장치로 둡니다.
opened_at
closed_at
TIMESTAMPTZ 회차의 시작·종료 시각. 회차별 운용 기간 계산에 쓰입니다.

trade_order 주문 + 체결계정계

MVP는 시장가 즉시 체결이라 주문과 체결이 한 행입니다. 지정가를 도입하면 체결 부분을 trade_execution 으로 분리하게 됩니다. 거절된 주문도 남깁니다 — 사용자에게 "왜 안 됐는지" 설명하려면 필요합니다.

컬럼타입설명
order_idBIGINT PK 주문 번호. 체결 내역 화면의 정렬 키로도 씁니다.
account_idBIGINT FK 어느 회차 계좌의 주문인지. 초기화 후에는 새 계좌 것만 조회됩니다.
stock_idBIGINT FK 거래 대상 종목. 심볼이 아니라 내부 ID 를 씁니다 (심볼은 바뀔 수 있고 시장마다 중복될 수 있음).
client_order_idUUID UK 멱등성 키. 프론트가 주문 화면 진입 시 생성해 함께 보냅니다. 사용자가 버튼을 두 번 누르거나 네트워크 재시도가 발생해도 유니크 제약에 걸려 중복 체결이 막힙니다. 토스 API 도 같은 방식을 씁니다.
sideVARCHAR(4) BUY / SELL.
order_typeVARCHAR(10) MVP는 MARKET 고정. 컬럼을 미리 두면 나중에 LIMIT 추가 시 스키마 변경이 필요 없습니다.
quantityNUMERIC(19,6) 주문 수량. 국내는 정수지만 미국은 소수점 주식이 가능해 NUMERIC 으로 여유를 둡니다.
statusVARCHAR(12) 주문의 생애주기입니다.
1주차 시장가는 PENDING 을 거치지 않습니다. 접수와 체결이 한 트랜잭션 안에서 연속 실행되므로 FILLED 또는 REJECTED직행합니다. PENDING 이 실제로 저장되는 건 지정가를 붙이는 2주차부터입니다.
PENDING 접수 완료, 자금·수량 동결됨
FILLED 체결 완료, 동결 해제 + 출금 확정
REJECTED 검증 단계 거절 (동결하지 않음)
CANCELED 사용자 취소 · EXPIRED 타임아웃 자동 해제
상태 전이는 조건부 UPDATE 로 처리하세요 — WHERE order_id=? AND status='PENDING' 의 영향 행이 0 이면 이미 취소됐거나 다른 워커가 가져간 것입니다.
reject_reasonVARCHAR(40) 거절 사유 코드. MARKET_CLOSED · SUSPENDED · INSUFFICIENT_CASH · INSUFFICIENT_QUANTITY · STALE_QUOTE. 화면에 이유를 문구로 보여주기 위한 근거입니다.
executed_priceNUMERIC(19,4) 체결 단가. 종목 통화 기준입니다(미국 종목이면 달러). 원화 환산은 gross_amount 에 별도로 저장합니다.
quote_atTIMESTAMPTZ 체결에 사용한 시세의 기준 시각. "왜 이 가격에 체결됐는가"를 설명하는 유일한 근거이고, 금융 도메인에서 가장 중요한 감사 항목입니다. quote_snapshot.quote_at 을 그대로 복사해 넣습니다.
exchange_rateNUMERIC(19,6) 체결 시점 환율. 원화 종목은 1 입니다. 지금 안 남기면 나중에 환차손익(주가 손익 vs 환율 손익)을 영원히 분리할 수 없습니다.
gross_amountNUMERIC(19,4) 체결 금액(원화 환산). executed_price × quantity × exchange_rate.
feeNUMERIC(19,4) 거래 수수료 — gross_amount × FEE_RATE (0.01%, 매수·매도 공통, 국내·미국 공통). 요율이 아니라 적용된 금액을 저장합니다 — 나중에 정책이 바뀌어도 과거 거래의 기록이 그대로 남아야 합니다.
taxNUMERIC(19,4) 매도 시에만 발생하고, 시장에 따라 계산식이 다릅니다. 매수는 두 시장 모두 0 입니다.
시장계산식정체
국내
k_tax
gross × K_TAX_RATE
= 0.002
증권거래세 0.2%. 실제(0.15%)와 의도적으로 다르게 잡아 모의투자임을 분명히 했습니다
미국
a_tax
max(gross × A_TAX_RATE,
    A_TAX_MIN_USD × 환율)

= 0.0000206, 최소 $0.01
SEC Fee(Section 31). 미국엔 증권거래세가 없고 SEC 가 매도에만 부과합니다. $20.60 per million, FY2026 기준
k_tax·a_tax 로 컬럼을 나누지 않습니다. 한 주문은 국내 아니면 미국이라 둘 중 하나만 값이 생깁니다 — 나누면 항상 한쪽이 0 인 빈 칸이 절반이고, 합계를 낼 때마다 두 컬럼을 더해야 합니다. 어느 세금인지는 stock.market_country 로 알 수 있습니다.
net_amountNUMERIC(19,4) 실제 예수금 증감액. 매수: gross + fee (차감) / 매도: gross − fee − tax (입금). 이 등식이 항상 성립하는지 검증하는 테스트를 두세요.
ordered_atTIMESTAMPTZ 주문 접수 시각. quote_at 과의 차이가 곧 시세 지연입니다.

ledger_entry 거래 원장계정계

예수금이 움직인 모든 사건을 기록합니다. UPDATE 와 DELETE 를 하지 않는 것이 이 테이블의 존재 이유입니다. 잘못 기록했으면 수정하지 말고 반대 부호 항목을 넣어 상쇄합니다.

항목은 세 가지뿐입니다 — INITIAL_DEPOSIT · BUY · SELL. 수수료와 세금을 별도 줄로 쪼개지 않고 매수·매도 금액에 포함합니다. 원장 한 줄이 곧 trade_order.net_amount 하나에 대응하므로 목록이 절반으로 짧아지고 커서 처리도 단순해집니다.

대신 "지금까지 수수료로 얼마 썼나"는 원장만으로 뽑을 수 없습니다. 그건 SUM(trade_order.fee) 로 언제든 구할 수 있으니 정보가 사라지지는 않습니다 — 원장을 쪼개지 않아도 되는 이유가 여기 있습니다.
컬럼타입설명
entry_idBIGINT PK 원장 번호. 시간순으로 증가합니다.
account_idBIGINT FK 어느 계좌의 원장인지.
order_idBIGINT FK, NULL 원인이 된 주문. 최초 지급과 초기화는 주문이 없으므로 NULL 입니다.
entry_typeVARCHAR(20) 세 가지뿐입니다.
INITIAL_DEPOSIT 모의투자금 5천만원 지급 (+)
BUY 매수 — gross + fee 차감 (−)
SELL 매도 — gross − fee − tax 입금 (+)
RESET 항목은 두지 않습니다. 초기화는 새 계좌를 만드는 일이고, 새 계좌의 INITIAL_DEPOSIT 한 줄이 그 역할을 대신합니다. 이전 회차의 마감 시각은 account.closed_at 에 남습니다.
amountNUMERIC(19,4) 부호 있는 예수금 증감액. 수수료·세금이 포함된 값이라 언제나 trade_order.net_amount 와 절대값이 같습니다. 매수는 음수, 매도는 양수입니다.
이 컬럼의 누적 합이 account.cash_balance 와 일치해야 합니다 — 항목이 세 가지뿐이라 이 검증식이 아주 단순해집니다.
balance_afterNUMERIC(19,4) 이 항목 반영 직후의 잔액. 엄밀히는 파생값이지만, 정합성이 깨진 지점을 즉시 찾아내는 용도로 매우 유용합니다.
exchange_rateNUMERIC(19,6) 체결 시점 환율. 원화 종목은 1, 미국 종목은 그때의 USD/KRW 입니다.
amount 는 이미 원화로 환산된 값이라 계산에는 쓰이지 않습니다. "이 거래를 얼마짜리 환율로 했는가"를 원장만 보고 알 수 있게 하는 감사 항목입니다.
trade_order.exchange_rate 와 같은 값이지만, 원장은 append-only 라 주문 테이블이 나중에 어떻게 바뀌든 이 기록은 그대로 남습니다. 1주차에는 화면에 안 써도 됩니다 — 다만 지금 안 남기면 과거 값은 복원할 수 없습니다.
memoVARCHAR(200) 사람이 읽을 설명. 수수료를 별도 줄로 쪼개지 않으므로 "삼성전자 10주 @ 241,500 (수수료 포함)" 처럼 내역을 여기에 담습니다. 디버깅과 CS 대응이 쉬워집니다.
occurred_atTIMESTAMPTZ 발생 시각. (account_id, occurred_at) 인덱스로 기간별 조회를 처리합니다.

holding 보유 종목계정계

원장에서 파생되는 집계입니다. 이론적으로는 원장을 재생하면 복원할 수 있지만, 조회 성능을 위해 별도로 유지합니다. 계좌+종목당 한 행입니다.

컬럼타입설명
holding_idBIGINT PK 대리키. (account_id, stock_id) 에 유니크 제약을 겁니다.
account_idBIGINT FK 회차 계좌. 초기화하면 새 계좌라 보유 종목이 자동으로 비워집니다.
stock_idBIGINT FK 보유 종목.
quantityNUMERIC(19,6) 보유 수량. 0 이 되면 행을 지울지 남길지 정하세요 — 남기는 쪽을 권합니다 (재매수 시 평균단가 이력이 이어집니다).
locked_quantityNUMERIC(19,6) 미체결 매도 주문에 묶인 수량. 예수금 동결과 정확히 같은 원리입니다 — 10주를 갖고 5주 매도 주문을 세 번 걸면 15주를 팔게 되니까요.
매도 가능 수량 = quantity − locked_quantity 로 계산합니다. CHECK (locked_quantity <= quantity) 로 과다 동결을 막습니다.
avg_buy_priceNUMERIC(19,4) 이동평균 매입단가(종목 통화 기준). 매수 시 (기존수량×기존단가 + 신규수량×체결가) ÷ 총수량 으로 재계산합니다. 매도 시에는 바꾸지 않습니다 — 평가손익의 기준이기 때문입니다.
avg_exchange_rateNUMERIC(19,6) 매수 시점 평균 환율. 원화 종목은 1. 이 값이 있어야 "수익 중 얼마가 주가 상승이고 얼마가 환율 상승인지"를 나중에 분리할 수 있습니다. 지금 안 넣으면 복구 불가입니다.
updated_atTIMESTAMPTZ 마지막 변동 시각.

daily_account_snapshot 일별 자산 스냅샷계정계

1주차에는 화면에 쓰지 않지만 배치는 지금 넣으세요. 매일 장 마감 후 한 줄씩 쌓는 단순한 작업인데, 이게 없으면 2주차에 자산 추이 그래프를 그릴 과거 데이터가 아예 없습니다. 거래 내역으로 역산하려면 과거 시점의 모든 시세가 필요해 현실적이지 않습니다.

컬럼타입설명
account_id
snapshot_date
복합 PK 계좌 × 날짜로 하루 한 행.
cash_balanceNUMERIC(19,4) 그날 마감 시점 예수금.
stock_valueNUMERIC(19,4) 보유 종목 평가금액(종가 × 수량, 원화 환산).
total_assetNUMERIC(19,4) 예수금 + 평가금액. 자산 추이 그래프의 Y축입니다.
unrealized_pnlNUMERIC(19,4) 평가손익. 실현손익은 원장에서 집계하므로 별도 컬럼을 두지 않습니다.
시세 · 마스터

stock 종목 마스터

내부 stock_id 를 정규 식별자로 삼고, 외부 심볼은 매핑 테이블로 분리합니다. 하루 1회 배치로 갱신합니다.

컬럼타입설명
stock_idBIGINT PK 내부 식별자. 도메인 코드는 이 값만 알면 됩니다. 소스가 바뀌어도 이 값은 유지됩니다.
symbolVARCHAR(20) 005930 / AAPL. market_country 와 함께 유니크. 점이 포함된 티커(BRK.B)가 있으므로 Spring URL 경로 변수로 쓸 때 주의가 필요합니다.
market_countryVARCHAR(2) KR / US. 랭킹 화면의 국내·해외 탭과 장 운영 시간 판정에 쓰입니다.
marketVARCHAR(20) KOSPI / KOSDAQ / NASDAQ / NYSE. 화면 뱃지 표시용.
name
english_name
VARCHAR(200) 종목명. 검색 대상입니다. 한글 부분일치를 위해 pg_trgm GIN 인덱스를 겁니다.
isin_codeVARCHAR(12) 국제 표준 종목 식별자(KR7005930003). 종목코드는 바뀔 수 있지만 ISIN 은 상대적으로 안정적이라 실제 증권사도 둘 다 관리합니다.
currencyVARCHAR(3) KRW / USD. 환율 환산 필요 여부를 결정합니다.
security_typeVARCHAR(20) 토스가 준 원본 값(STOCK / ETF / ETN …). 가공하지 않고 그대로 보관합니다. 우리 분류 규칙이 바뀌어도 원본이 남아 있어야 재계산할 수 있습니다.
stock_categoryVARCHAR(20) 상품 유형 — 배타적, 프론트 분기의 1차 키. INDIVIDUAL(개별주) / PREFERRED(우선주) / ETF / ETN. security_typeis_common_share 로 배치에서 자동 판정합니다.
leverage_factorNUMERIC(4,1) ETF·ETN 의 레버리지 배수 — 프론트 분기의 2차 키. null=일반주식, 1.0=일반 ETF, 2.0·3.0=레버리지, -1.0·-2.0=인버스. 레버리지·인버스 종목에 변동성 경고 배너를 띄우는 근거입니다.
is_dividendBOOLEAN 배당 속성 — 태그. 유형과 독립이라 개별주에도 ETF 에도 붙을 수 있습니다. KB금융은 배당주이면서 개별주이므로 한 컬럼에 유형과 섞으면 표현이 불가능해집니다.
dividend_yieldNUMERIC(6,4) 연 배당수익률. is_dividend 판정의 근거값이며 임계값(예: 0.03)은 팀에서 정합니다. 국내는 DART "배당에 관한 사항", 해외는 Alpha Vantage DividendYield 가 후보입니다. 1주차에는 비워두고 뱃지를 끄셔도 됩니다.
is_common_shareBOOLEAN 토스 원본. false 면 우선주(삼성전자우 등)입니다. 거래대금 상위에 우선주가 섞여 들어오므로 stock_category = PREFERRED 판정에 씁니다.
shares_outstandingNUMERIC(20,0) 상장주식수. 시가총액 = 현재가 × 이 값 으로 계산하므로 시총 컬럼을 따로 두지 않습니다.
list_date
delist_date
DATE 상장일 / 상장폐지일. 종목 소개 팩트 시트에 씁니다.
is_suspendedBOOLEAN 거래정지. 주문 검증에서 즉시 거절 사유가 됩니다.
is_liquidationBOOLEAN 정리매매 중. 상장폐지 직전 단계라 위험 안내가 필요합니다.
is_warnedBOOLEAN 투자경고·투자위험 지정. 주문을 막지는 않고 화면에 경고 배너를 띄웁니다.
is_rankedBOOLEAN 거래대금 상위 100 포함 여부. 시세 수집 대상 판정에 씁니다.
rank_noINT 랭킹 순위(1~100). 화면 표시 전용입니다. 커서로 쓰면 안 됩니다 — 배치가 통째로 다시 쓰는 값이라 갱신 직후 같은 번호가 다른 종목을 가리킵니다. 상위 100 밖이면 NULL.
trading_amountNUMERIC(24,0) 최근 1주 누적 거래대금(duration=1w). 랭킹 정렬 기준이자 커서의 1차 키입니다. 선정 기준을 그대로 화면에 보여주므로 사용자가 "왜 이 순서인지"를 이해할 수 있습니다.
커서는 (trading_amount, stock_id) 튜플로 잡습니다 — 거래대금이 같은 종목이 있으면 stock_id 가 순서를 유일하게 결정합니다. 인덱스도 (market_country, trading_amount DESC, stock_id DESC) 로 같은 순서·같은 방향이어야 추가 정렬 없이 훑습니다.

quote_snapshot 현재가 스냅샷

전 종목을 1행씩 담습니다 — 약 8,500행 고정. 이력을 쌓지 않고 계속 UPDATE 하므로 시계열 테이블이 아닙니다.

대상갱신quote_at
상위 200종목
(국내 100 + 미국 100)
해당 시장 정규장 중 5초마다 방금 전 → 화면에 "12:36:59 기준 · 실시간"
나머지 약 8,300종목 일 1회 전일 종가 전일 마감 시각 → "8월 11일 종가 · 거래 불가"

이렇게 하면 화면 로직이 하나로 통일됩니다. 상세 페이지는 종목이 상위 100 이든 아니든 항상 이 테이블만 조회하고, quote_at 을 보고 문구만 바꿉니다. "이 종목이 상위 100 인가?"를 화면이 알 필요가 없어집니다.
거래 가능 판정은 별도입니다 — stock.is_ranked AND 해당 시장 정규장 중 AND 거래정지 아님.

컬럼타입설명
stock_idBIGINT PK/FK 종목당 한 행이므로 PK 가 곧 FK 입니다 (1:1 관계).
last_priceNUMERIC(19,4) 현재가(종목 통화). 토스가 문자열로 주므로 반드시 BigDecimal 로 파싱하세요. double 로 받으면 잔고가 어긋납니다.
prev_closeNUMERIC(19,4) 전일 종가. 토스 현재가 응답에 등락률이 없어서 (last_price − prev_close) / prev_close 로 직접 계산해야 합니다.
전일 daily_candle.close_price 를 복사해 채웁니다 (국내 08:50 / 미국 22:00, 다음 장 시작 직전). 일봉 수집이 실패했다면 last_price 복사로 폴백하고 (마감 시점 값이 곧 종가이므로), 신규 편입 종목은 랭킹의 price.basePrice 로 초기화합니다.
갱신 시점이 "마감 직후"가 아니라 "다음 장 시작 직전"인 이유 — 마감 직후에 갱신하면 prev_close = last_price 가 되어 장외 시간 내내 등락률이 0%로 표시됩니다.
upper_limit
lower_limit
NUMERIC(19,4) 상한가 / 하한가. 전일 종가 기준으로 정해져 하루 동안 바뀌지 않으므로 장 시작 전 1회만 조회하면 됩니다. 주문 가격 검증에 씁니다.
currencyVARCHAR(3) 가격의 통화. stock 과 중복이지만 조인 없이 시세만 조회할 때 편합니다.
quote_atTIMESTAMPTZ 토스가 알려준 시세 기준 시각. 두 곳에 쓰입니다 — 화면의 "12:36:59 기준" 표시, 그리고 주문 시 유효시간 검증 (15초 넘게 오래됐으면 STALE_QUOTE 로 거절).
collected_atTIMESTAMPTZ 우리가 수집한 시각. quote_at 과의 차이로 수집 파이프라인의 지연을 모니터링할 수 있습니다.

daily_candle 일봉TimescaleDB 하이퍼테이블

용도가 둘입니다 — 일봉 차트prev_close 의 원천. 장 마감 직후 수집해 그날 봉을 확정하고, 그 close_price 가 다음 장 시작 전 quote_snapshot.prev_close 로 복사되어 등락률의 기준이 됩니다.
100종목 × 250거래일 = 연 2.5만 행. 10년을 쌓아도 25만 행입니다.

이 테이블을 하이퍼테이블로 지정합니다. 파티션 키는 trade_date, 청크는 1년 단위입니다. PK 가 이미 (stock_id, trade_date) 라 "하이퍼테이블은 PK 에 파티션 키를 포함해야 한다"는 요건을 그대로 만족합니다.

솔직히 이 규모에서 성능 이득은 거의 없습니다. 일반 테이블 + 인덱스로도 25만 행은 순식간에 훑습니다. 지정하는 실질적 명분은 연속 집계입니다 — 아래를 보세요.
컬럼타입설명
stock_id
trade_date
복합 PK 종목 × 거래일로 하루 한 행. 응답의 timestamp 는 시각이므로 KST 기준 날짜로 변환해야 합니다. UTC 로 자르면 미국 종목이 하루씩 밀립니다.
open_priceNUMERIC(19,4) 시가.
high_price
low_price
NUMERIC(19,4) 고가 / 저가. 캔들 차트의 꼬리를 그립니다.
close_priceNUMERIC(19,4) 종가. 다음 거래일의 prev_close 로 복사되어 등락률 계산의 분모가 됩니다.
volumeNUMERIC(20,0) 거래량. 차트 하단 막대로 표시합니다.
수정주가 적용 여부를 지금 정하세요. 액면분할·무상증자가 일어나면 과거 주가가 소급 조정됩니다. 토스 캔들 API 의 adjusted 파라미터를 팀에서 합의하고 고정하세요 — 중간에 바꾸면 이미 저장된 과거 데이터와 어긋납니다.

장기 차트는 페이지네이션이 필요합니다. count 최대가 200봉이라 일봉 200개 ≈ 10개월입니다. 3년 차트를 그리려면 before 로 네 번 호출해야 하는데, 저장해두면 그 부담이 사라집니다.

주봉·월봉은 API 가 주지 않습니다. interval1m·1d 둘뿐이라, 주봉이 필요하면 이 테이블을 집계해서 만들어야 합니다.
명분은 주봉입니다. 토스 API 는 interval1m1d 만 줍니다. 주봉은 우리가 만들어야 하는데, 연속 집계를 걸면 별도 테이블도 배치도 없이 뷰 하나로 끝납니다. 일봉이 새로 들어올 때마다 정책이 알아서 갱신합니다.
CREATE MATERIALIZED VIEW candle_1w WITH (timescaledb.continuous) AS
SELECT stock_id,
       time_bucket(INTERVAL '1 week', trade_date) AS bucket,
       first(open_price, trade_date)              AS open_price,
       max(high_price) AS high_price, min(low_price) AS low_price,
       last(close_price, trade_date)              AS close_price,
       sum(volume)                                AS volume
  FROM daily_candle GROUP BY stock_id, bucket;
성능이 아니라 "파생 데이터를 코드로 관리하지 않는다" 쪽이 이유입니다.
액면분할이 일어나면 연속 집계를 수동으로 새로고침해야 합니다. 분할 시 과거 일봉이 통째로 소급 조정되는데, 연속 집계는 원본이 UPDATE 된 것을 자동으로 따라가지 않습니다. 주봉만 옛 가격으로 남아 차트가 어긋납니다.
CALL refresh_continuous_aggregate('candle_1w', NULL, NULL);
분할은 드물게 일어나므로 그때만 수동 실행하면 됩니다. 일봉 자체는 계속 API 의 adjusted 값을 그대로 씁니다 — 분봉에서 일봉을 파생하지는 마세요.
세 가지 제약을 알고 쓰세요.
하이퍼테이블은 다른 테이블에서 FK 로 참조받을 수 없습니다. daily_candle 을 참조하는 테이블은 없으므로 문제되지 않습니다 (반대 방향, 즉 stock 을 참조하는 것은 허용).
CREATE EXTENSION timescaledb트랜잭션 블록 안에서 실행할 수 없습니다. schema.sqlBEGIN…COMMIT 으로 감싸여 있어서 timescale.sql 로 파일을 분리했습니다.
압축·연속집계는 Apache 판이 아닌 TSL 라이선스 기능이고, 관리형 DB(AWS RDS 등)는 대체로 미지원이라 배포 방식에 영향이 있습니다. postgres:18-alpine 에도 안 들어 있어 이미지를 timescale/timescaledb 로 바꿔야 합니다.

안 쓰기로 해도 괜찮습니다. timescale.sql 을 건너뛰면 일반 테이블로 남을 뿐이고, PK 구조가 이미 호환되므로 나중에 언제든 전환할 수 있습니다.

minute_candle 분봉하이퍼테이블 — 2주차

1주차에는 온디맨드로 채웁니다. 사용자가 종목 상세를 열 때 /candles?interval=1m 을 호출하고, 받은 봉을 이 테이블에 넣은 뒤 60초 동안은 DB 에서 바로 내려줍니다. 테이블이 저장소이자 캐시 역할을 겸합니다.
비용은 동시 시청 30명 기준 0.5 req/sMARKET_DATA_CHART 20 TPS 의 2.5% 입니다. 상시 적재(1.67 req/s)보다 오히려 싼데, 아무도 안 보는 종목까지 1분마다 긁을 이유가 없기 때문입니다.

장외 시간에도 차트는 그려집니다. 장이 닫힌 종목에 /candles 를 부르면 마지막 장의 분봉이 그대로 옵니다. 한국 낮에 엔비디아를 열어도 마찬가지입니다 — 전일 종가와 함께 지난 미국장의 분봉 차트가 보입니다.

화면은 quote_at 과 장 운영 캘린더를 보고 "실시간 / 종가" 문구만 바꾸면 되고, 차트 자체는 분기가 필요 없습니다.
한 번에 받을 수 있는 봉은 200개입니다. 국내 정규장은 09:00~15:30 = 330분이라 하루치를 다 받으려면 before2회 호출해야 합니다. 1주차 차트가 "최근 200분"이면 1콜로 끝나니, 기본은 1콜로 두고 전체 보기를 누를 때만 2콜을 쓰는 편이 단순합니다.

실측 필요before 가 inclusive 인지, 그리고 마감 동시호가(15:30) 봉이 존재하는지. 15:30 봉이 없으면 330개가 아니라 329개라서, 개수로 검증하는 로직이 여기서 깨집니다.
2주차에 스케줄러 상시 적재로 전환합니다. 체결 엔진은 사용자가 차트를 안 봐도 과거 봉을 조회해야 하기 때문입니다 — "그 1분 안에 지정가에 닿았는가"를 low <= 지정가 로 판정합니다. 상위 100종목 × 60초 = 1.67 req/s(8.3%), 연 977만 행 규모입니다. 테이블 구조는 그대로 두고 채우는 방식만 바뀝니다.
컬럼타입설명
stock_id
candle_at
복합 PK candle_at봉 시작 시각(응답의 timestamp). TimescaleDB 하이퍼테이블은 PK 에 파티션 키를 반드시 포함해야 하는데, 이 구조가 그 요건을 이미 만족합니다.
open_priceNUMERIC(19,4) 그 1분의 시가.
high_price
low_price
NUMERIC(19,4) 고가 / 저가. 차트 꼬리를 그리는 값이면서, 나중에 지정가 주문의 체결 판정 근거가 됩니다 — "그 1분 안에 지정가에 닿았는가"를 low <= 지정가(매수) 로 판정할 수 있습니다.
close_priceNUMERIC(19,4) 종가.
volumeNUMERIC(20,0) 거래량.
2주차에 상시 적재로 전환하면 이 테이블도 하이퍼테이블로 만듭니다.977만 행이 되므로 그때는 청크 분할·압축이 실질적으로 필요합니다. 청크는 1일 단위 — 일봉(1년)과 다른 이유는 행 수가 200배 차이 나기 때문입니다. 같은 1년 청크를 쓰면 청크 하나가 1,000만 행이 되어 의미가 없습니다.

timescale.sql 에 주석으로 준비해뒀습니다 — 압축(7일 경과분, 90% 축소), 보존 정책(1년), 그리고 5분봉 연속 집계까지 함께 들어 있습니다. 1분봉만 쌓으면 5m·10m 는 뷰로 해결됩니다.

일봉까지 여기서 파생하지는 마세요. 액면분할 시 과거 일봉이 소급 조정되는데 연속 집계는 그걸 반영하지 못합니다. 일봉은 API 의 adjusted 값을 그대로 쓰는 게 맞습니다.

exchange_rate 환율일반 테이블

시세는 quote_snapshot 에 UPDATE 하므로 이력이 없지만, 환율은 그래프를 그려야 해서 시점별로 쌓습니다. 다른 테이블과 FK 로 연결되지 않습니다 — 원장에 필요한 환율은 "그때 그 값"이지 참조가 아니어야 하기 때문입니다. 나중에 환율 데이터를 정정해도 과거 체결 기록은 흔들리면 안 됩니다.

append-only 로 쌓이지만 하이퍼테이블로 만들지 않습니다.
매시 정각 적재라 통화쌍 하나당 연 6,000행입니다. 10년을 모아도 6만 행이라 청크로 자를 이유가 전혀 없습니다.
파생할 것도 없습니다. 일간·주간 그래프가 필요하면 그냥 GROUP BY 하면 됩니다 — 연속 집계를 걸 만큼 무거운 쿼리가 아닙니다.
대리키(exchange_rate_id)를 그대로 쓸 수 있습니다. 하이퍼테이블이면 PK 에 파티션 키를 넣어야 해서 (exchange_rate_id, rate_at) 같은 어색한 복합 PK가 됩니다.

"시계열이면 시계열 DB"가 아니라 "행이 얼마나 빨리 늘어나는가"가 기준입니다. 같은 append-only 라도 분봉은 연 977만 행, 환율은 연 6,000행 — 1,600배 차이입니다.
컬럼타입설명
exchange_rate_idBIGINT PK 대리키. 실제 식별은 (base, quote, rate_at) 유니크로 합니다.
base_currency
quote_currency
VARCHAR(3) 통화쌍. MVP 에서는 USD → KRW 하나뿐이지만, 컬럼으로 두면 나중에 통화가 늘어도 스키마를 안 고쳐도 됩니다.
rateNUMERIC(19,6) 매수 환율. 실제로 달러를 살 때 적용되는 값입니다. mid_rate 와의 차이가 환전 스프레드이고, 이것도 거래 비용의 일종이라 수수료·세금과 같은 맥락의 교육 소재가 됩니다.
mid_rateNUMERIC(19,6) 매매기준율(은행간 mid rate). 일반적으로 "환율"이라고 하면 이 값입니다. 그래프 표시와 평가금액 환산에 씁니다.
rate_atTIMESTAMPTZ 환율 시점 — 응답의 validFrom 을 그대로 씁니다. 토스는 1분 단위로 갱신하며 validFrom~validUntil 유효 윈도를 알려줍니다. 10:03:27 에 조회했어도 그 환율의 시점은 10:03:00 이므로, 그래프의 X축은 이 값이어야 정확합니다.
collected_atTIMESTAMPTZ 우리가 받은 시각. rate_at 과의 차이로 수집 지연을 볼 수 있습니다.
수집 주기: 매시 정각 (확정). 환율은 하루에 0.3~0.5% 정도만 움직여서 분 단위로 쌓으면 노이즈만 늘고 그래프는 같아 보입니다. 시간별로 쌓아두면 일간·주간·월간 그래프를 전부 여기서 집계해 그릴 수 있습니다. 반대로 일별로만 집계해두면 나중에 "시간별로 보고 싶다"가 나왔을 때 데이터가 없습니다.

주말·휴장에도 그냥 돌리세요. 외환시장이 쉬는 동안에는 토스가 같은 validFrom 을 계속 반환하므로, UNIQUE (base, quote, rate_at) 에 걸려 자동으로 걸러집니다. ON CONFLICT DO NOTHING 으로 적재하면 스케줄러에 주말 조건을 넣을 필요가 없습니다. 그래서 실제 적재량은 평일 위주로 연 6,000행 남짓입니다.

한 시간 건너뛰어도 괜찮습니다. 그래프에 점 하나가 빠질 뿐이니 실패 시 재시도하지 말고 다음 정각에 맡기세요. 연속 실패만 알림으로 잡으면 됩니다.

stock_external_id 소스별 심볼 매핑

같은 삼성전자를 토스는 005930, DART 는 00126380 으로 부릅니다. 지금 만들어두면 나중에 소스를 추가하거나 교체해도 도메인 코드를 고칠 일이 없습니다. 비용은 거의 0 인데 나중에 넣으려면 이미 짠 코드를 전부 손대야 합니다.

컬럼타입설명
stock_id
source
복합 PK 종목 × 소스. sourceTOSS / DART / FINNHUB 등입니다.
external_idVARCHAR(50) 해당 소스에서 쓰는 식별자. (source, external_id) 에도 유니크 제약을 걸어 한 외부 ID 가 두 종목에 매핑되는 사고를 막습니다.

종목 분류 모델

레버리지·인버스·우선주를 숨기지 않고 전부 노출하되, 유형에 따라 다른 안내를 보여줍니다. 모르는 상품을 가려두면 사용자는 실전에서 처음 만나게 됩니다. 손실이 0원인 환경에서 설명하는 편이 "거래를 이해시키는 도구"라는 목적에 맞습니다.

유형(배타적) × 속성(태그) 조합

배당주는 유형이 아니라 속성입니다. KB금융은 배당주이면서 개별주이므로 한 컬럼에 넣으면 표현할 수 없습니다. 그래서 유형과 태그를 분리했습니다.

조합화면 뱃지안내 문구 (프론트 정적 콘텐츠)
INDIVIDUAL 개별주 특정 기업 한 곳의 지분을 사는 것입니다. 그 회사가 잘되면 오르고 어려워지면 내립니다. 한 종목에 자산을 몰아넣지 않는 것이 중요합니다.
INDIVIDUAL
+ is_dividend
개별주 · 배당주 이익의 일부를 주주에게 정기적으로 나눠주는 기업입니다. 주가 상승이 크지 않아도 배당으로 수익이 발생할 수 있습니다.
PREFERRED 우선주 의결권이 없는 대신 배당을 우선적으로 받는 주식입니다. 같은 회사의 보통주와 가격이 다르게 움직이며 거래량이 적은 편입니다.
ETF
leverage = 1.0
ETF 여러 종목을 묶어 담은 상품입니다. 한 기업이 흔들려도 충격이 분산되어 개별주보다 변동이 작습니다.
ETF
leverage ≥ 2.0
레버리지 ETF
⚠ 경고 배너
지수가 1% 오르면 약 2% 오르고, 1% 내리면 약 2% 내립니다. 또한 매일 수익률을 재계산하는 구조라, 장기 보유하면 지수가 제자리로 돌아와도 손실이 남을 수 있습니다.
ETF
leverage < 0
인버스 ETF
⚠ 경고 배너
지수가 내릴 때 오르는 상품입니다. 방향을 반대로 베팅하는 것이라 시장이 오르면 손실이 납니다. 레버리지와 마찬가지로 장기 보유에 불리합니다.
레버리지의 "일일 재계산"을 꼭 설명하세요. 초보자가 가장 크게 손해 보는 지점입니다. 지수가 +10% 후 −9.09% 해서 제자리로 돌아와도, 2배 레버리지는 원금을 회복하지 못합니다. 이 개념을 안전한 환경에서 배우게 하는 것이 이 서비스의 존재 이유에 가깝습니다.
배치에서 실행할 분류 판정
stock_category = ETN이면 ETN, ETF면 ETF, is_common_share = false 면 PREFERRED, 나머지는 INDIVIDUAL.
is_dividend = dividend_yield >= 임계값. 임계값은 팀에서 결정하며, 1주차에는 전부 false 로 두고 뱃지를 꺼도 됩니다.

매수 처리 — 2단계 모델

MVP(시장가 즉시 체결)는 두 단계를 한 트랜잭션 안에서 연속 실행합니다. 지정가를 도입하면 Phase 1 을 커밋하고 체결 엔진이 나중에 Phase 2 를 돌립니다. 스키마를 미리 이렇게 잡아두면 그때 로직만 쪼개면 됩니다.

1주차에는 PENDING 이 DB 에 남지 않습니다. 한 트랜잭션이라 Phase 1 의 locked_cash += net_amount 와 Phase 2 의 locked_cash −= net_amount 가 커밋 전에 상쇄되고, statusFILLED 로 커밋됩니다.

그래도 두 단계를 그대로 코드에 남겨두세요. 지금 지우면 2주차에 다시 쓰게 되고, 무엇보다 동결 로직을 처음부터 검증하게 됩니다locked_cash 계산이 틀려 있으면 지정가를 붙이는 날 한꺼번에 터집니다.

Phase 1 주문 접수동결

단계동작설명
SELECT … FOR UPDATE 계좌 행 잠금. 검증보다 먼저 잠가야 그 사이에 값이 안 바뀝니다.
검증 장 시간 · is_ranked · 거래정지 · 시세 유효시간(15초) · 주문가능금액 = cash_balance − locked_cashnet_amount
locked_cash += net_amount 자금 동결. 수수료·세금을 포함한 net_amount 를 묶습니다 — gross_amount 만 묶으면 체결 시점에 수수료만큼 부족해집니다.
INSERT trade_order (PENDING) client_order_id 유니크 위반이면 중복 클릭이므로 기존 주문 결과를 반환합니다.

Phase 2 주문 체결확정

단계동작설명
locked_cash −= ?
cash_balance −= ?
계좌 재잠금 → 동결 해제와 실제 출금을 동시에 반영합니다.
UPSERT holding 수량 증가 + 이동평균 단가·환율 재계산.
락 순서는 항상 accountholding. 엇갈리면 데드락이 납니다.
UPDATE … WHERE status='PENDING' 조건부 UPDATE 로 경합을 막습니다. 영향 행이 0 이면 이미 취소됐거나 다른 워커가 가져간 것이므로 롤백합니다.
INSERT ledger_entry 원장 기록. append only. BUY 한 줄에 수수료까지 포함해 넣고, exchange_rate 를 함께 남깁니다.

거래 비용 매수는 수수료만 · 매도는 시장별로 다름확정

// .env — 요율은 여기가 유일한 정의 지점. DB 에는 없다.
FEE_RATE      = 0.0001       0.01% · 매수/매도 · 국내/미국 공통
K_TAX_RATE    = 0.002        한국 증권거래세 0.2%
A_TAX_RATE    = 0.0000206    미국 SEC Fee · $20.60 per million
A_TAX_MIN_USD = 0.01         미국 최소 수수료

// 계산 — 결과값만 trade_order.fee / trade_order.tax 에 저장
매수        net = gross + fee                                    tax 는 두 시장 모두 0
매도(국내)  net = gross − fee − k_tax
매도(미국)  net = gross − fee − a_tax

  fee   = gross × FEE_RATE
  k_tax = gross × K_TAX_RATE
  a_tax = max(gross × A_TAX_RATE, A_TAX_MIN_USD × 환율)
요율은 DB 에 저장하지 않고 계산 결과값만 저장합니다. trade_order 에는 feetax 두 컬럼만 있고, 요율 컬럼은 없습니다.

요율은 주문마다 다른 값이 아니라 전역 정책입니다. 주문 행마다 복사하면 같은 값이 수만 번 중복되고, 정책이 바뀔 때 어디가 진실인지 모호해집니다.
결과값만 있어도 충분합니다. 요율이 바뀌어도 과거 거래는 그대로 남습니다 — 원장이 지켜야 하는 것은 "그때 얼마를 냈는가"이지 "몇 %였는가"가 아닙니다.
굳이 적용 요율을 되짚어야 하면 tax / gross_amount 로 역산됩니다.

팀원 전원이 같은 .env 값을 써야 합니다. 한 사람만 다르면 같은 주문인데 금액이 달라지고, 원인을 찾기 어려운 종류의 버그가 됩니다. 값을 바꿨다면 반드시 공지하세요.
미국에는 증권거래세가 없습니다. 대신 SEC 가 Section 31 수수료를 매도에만 부과합니다 — $20.60 per million = 0.0000206(FY2026 기준, SEC 가 연 1회 조정). 최소 $0.01 이 있어서 소액 매도는 정률이 아니라 최소액이 붙습니다.

손익분기는 0.01 ÷ 0.0000206 = $485.44 입니다 — 485달러 미만 매도는 전부 $0.01 이 적용됩니다. 초보자가 다루는 금액대는 대부분 여기에 들어가므로, 실제로는 최소액 쪽이 기본 경로라고 보는 편이 맞습니다.

두 계산은 수학적으로 같습니다(환율이 공통 인수) — max(gross원 × 0.0000206, 0.01 × 환율) 이든 환율 × max(gross달러 × 0.0000206, 0.01) 이든 결과가 동일합니다. 다만 달러로 계산하고 마지막에 환산하는 쪽이 "최소 $0.01" 이라는 규칙에 더 가깝습니다.
예시gross feetaxnet (매도)
삼성전자 10주
@ 241,500 · 국내
2,415,000242 4,830
k_tax · 정률
2,409,928
엔비디아 2주
@ $182.30 · 환율 1,398.50
509,89351 14
a_tax · 정률 10.50 vs
최소 13.98 → 최소액
509,828
엔비디아 20주
@ $182.30 · 환율 1,398.50
5,098,931510 105
a_tax · 정률 105.04 vs
최소 13.98 → 정률
5,098,316
같은 금액이라도 국내 매도세가 미국의 약 97배입니다. 241만원어치를 국내에서 팔면 4,830원, 미국에서 팔면 50원입니다. 모의투자라 국내 세율을 실제(0.15%)보다 높게 잡은 탓도 있지만, 원래 두 시장의 거래 비용 구조가 이만큼 다릅니다. 이 차이를 화면에 그대로 보여주는 것이 이 서비스의 교육 포인트입니다.
FINRA TAF 는 넣지 않습니다. 실제 미국 매도에는 주당 $0.000166 (최대 $8.30)의 거래활동수수료도 붙습니다. 다만 금액이 아니라 주 수 기반이라 계산 축이 하나 더 늘어나는데, 크기는 SEC Fee 수준이라 교육 효과 대비 복잡도가 큽니다. 넣기로 한다면 trade_order 에 컬럼을 더하지 말고 tax 에 합산하세요 — 어차피 "적용된 금액"을 저장하는 컬럼입니다.
매도는 대칭입니다. Phase 1 에서 holding.locked_quantity 를 늘리고, Phase 2 에서 quantitylocked_quantity 를 함께 줄이며 예수금을 입금합니다. avg_buy_price 는 건드리지 않습니다 — 평가손익의 기준이기 때문입니다.
지정가를 도입하면 반드시 필요한 것 — Phase 1 커밋 직후 장애가 나면 동결액이 영원히 안 풀립니다. 사용자는 "돈이 있는데 왜 주문이 안 되지?"가 됩니다.
타임아웃 기반 자동 해제 배치를 만드세요.

UPDATE trade_order SET status='EXPIRED' WHERE status='PENDING' AND ordered_at < now() − INTERVAL '5 min' → 해당 금액만큼 locked_cash 를 되돌림

MVP 는 한 트랜잭션이라 롤백으로 끝나므로 이 배치가 필요 없습니다.
검증식이 하나 늘어납니다.
locked_cash = SUM(trade_order.net_amount WHERE status='PENDING')
미체결 주문 합계와 동결액이 항상 같아야 합니다. 고아 PENDING 이 생기면 이 식이 깨지므로 즉시 잡아낼 수 있습니다.

지켜야 할 설계 규칙

  1. 금액은 전부 NUMERICdouble 이나 float 을 쓰면 잔고가 미세하게 어긋나기 시작합니다. Java 에서도 BigDecimal 로 받으세요. 토스 API 가 가격을 문자열로 주는 것도 같은 이유입니다.
  2. 시각은 전부 TIMESTAMPTZ — 국내장·미국장·서머타임이 섞이므로 DB 에는 UTC 로 저장하고 표시할 때만 변환합니다.
  3. 매수 트랜잭션은 계좌 행 잠금부터SELECT … FROM account WHERE account_id = ? FOR UPDATE 로 시작해야 동시 주문 시 예수금 이중 차감을 막습니다. 종목 단위로 잠그면 못 막습니다.
  4. 요율은 설정값, DB 에는 결과값FEE_RATE·K_TAX_RATE· A_TAX_RATE·A_TAX_MIN_USD.env 에만 두고, 테이블에는 계산된 fee·tax 금액만 저장합니다. 요율은 전역 정책이라 주문 행마다 복사할 이유가 없고, 금액이 남아 있으면 정책이 바뀌어도 과거 거래가 흔들리지 않습니다.
  5. 원장은 append-only — 잘못 기록했으면 수정하지 말고 반대 부호 항목을 넣어 상쇄합니다.
  6. 포트폴리오 초기화는 삭제가 아니다 — 기존 계좌를 CLOSED 로 바꾸고 round_no + 1 인 새 계좌를 만듭니다.
  7. 지금 안 넣으면 복구 불가능한 것 다섯 가지trade_order.quote_at, trade_order.exchange_rate, ledger_entry.exchange_rate, holding.avg_exchange_rate, 그리고 daily_account_snapshot 배치입니다. 컬럼은 나중에 추가할 수 있지만 과거 값은 복원할 수 없습니다.
검증 테스트로 만들면 좋은 것 — 모든 거래 후 매수 시 net_amount = gross_amount + fee, 매도 시 net_amount = gross_amount − fee − tax 가 항상 성립하는지, 그리고 ledger_entry.amount(수수료 포함) 의 누적 합이 account.cash_balance 와 일치하는지 확인하는 테스트를 두세요. 원장을 제대로 이해했다는 가장 확실한 증거가 됩니다.