공통 규칙
Base URL
개발 http://localhost:8080/api
운영 https://{도메인}/api
인증
로그인 후 발급받은 토큰을 헤더에 담습니다.
Authorization: Bearer {accessToken}
user_id = 1)으로 고정해
동작합니다 (.env 의 AUTH_ENABLED=false).
아래 🔒 표시된 엔드포인트도 1주차에는 토큰 없이 호출되며,
서버가 고정 사용자의 계좌를 씁니다.2주차에 로그인을 붙일 때 인증 방식(JWT vs 세션 쿠키)을 정하세요. Next.js 를 쓰신다면 Route Handler 를 BFF 로 두고 httpOnly 쿠키에 토큰을 담는 방식이 가장 안전합니다 — 토큰이 브라우저 JS 에 노출되지 않습니다.
| 구분 | 대상 |
|---|---|
| 비로그인 허용 | 랭킹 · 검색 · 종목 상세 · 차트 · 환율 · 이용 가이드 |
| 🔒 로그인 필수 | 주문 · 계좌 · 보유종목 · 체결내역 · 포트폴리오 초기화 |
응답 형식
성공 시 데이터를 그대로 반환하고, 목록은 커서를 함께 내려줍니다.
{
"items": [ ... ],
"nextCursor": "eyJ0YSI6IjEyNDAwMDAwMDAwMDAiLCJpZCI6MTAyNH0",
"hasNext": true
}
에러 형식
{
"error": {
"code": "INSUFFICIENT_CASH",
"message": "주문가능금액이 부족합니다.",
"data": { "required": "2415242", "available": "1200000" }
}
}
message 는 사용자에게 그대로 보여줄 수 있는 문장으로 작성합니다.
표현 규칙
| 항목 | 규칙 | 예시 |
|---|---|---|
| 금액 · 수량 | 문자열 | "241500", "0.5" |
| 시각 | ISO 8601 + 오프셋 | "2026-08-11T12:36:59+09:00" |
| 날짜 | YYYY-MM-DD | "2026-08-11" |
| 등락률 | 소수 비율 문자열 | "0.0231" = +2.31% |
| 통화 | ISO 4217 | "KRW", "USD" |
number 는 배정밀도 부동소수라 큰 금액이나 소수점 주문에서
오차가 생깁니다. 토스 API 가 가격을 문자열로 주는 것과 같은 이유입니다.
프론트에서는 Decimal.js 를 쓰거나 문자열 그대로 표시하세요.커서 페이지네이션
GET /stocks/rankings?market=KR&size=20
→ { "items": [...], "nextCursor": "abc", "hasNext": true }
GET /stocks/rankings?market=KR&size=20&cursor=abc
랭킹은 순위가 바뀔 수 있어 OFFSET 방식이면 항목이 중복·누락됩니다.
cursor 는 서버가 인코딩한 불투명 문자열이며 클라이언트는 해석하지 않습니다.
랭킹 정렬 기준은 거래대금 내림차순 하나뿐이므로, 커서에도 그 값을 담습니다.
// 커서에 담기는 값 { "ta": "1240000000000", "id": 1024 } tradingAmount · stockId // Base64URL 로 인코딩해서 내려보낸다 "eyJ0YSI6IjEyNDAwMDAwMDAwMDAiLCJpZCI6MTAyNH0"
-- 다음 페이지 조회 SELECT ... FROM stock s JOIN quote_snapshot q USING (stock_id) WHERE s.is_ranked AND s.market_country = :market AND (s.trading_amount, s.stock_id) < (:ta, :id) -- 튜플 비교 ORDER BY s.trading_amount DESC, s.stock_id DESC LIMIT :size + 1;
stock_id 를 반드시 함께 넣으세요.
거래대금이 완전히 같은 종목이 있으면 trading_amount 만으로는
그 경계에서 순서가 매번 달라져 항목이 중복되거나 사라집니다.
stock_id 를 2차 정렬 키로 두면 순서가 유일하게 결정됩니다.PostgreSQL 의 튜플 비교
(a, b) < (:a, :b) 를 쓰면
a < :a OR (a = :a AND b < :b) 를 직접 쓰지 않아도 되고,
(trading_amount DESC, stock_id DESC) 복합 인덱스를 그대로 탑니다.엄밀히 막으려면 커서에 유니버스 버전(갱신 시각)을 함께 담고 달라지면
409 로 첫 페이지부터 다시 받게 하면 됩니다. 2주차 과제로 두세요.rank_no 를 커서로 쓰지 마세요.
rank_no 는 배치가 통째로 다시 쓰는 값이라, 갱신 직후 같은 번호가
다른 종목을 가리키게 됩니다. 화면에 순위를 표시하는 용도로만 쓰고,
페이지 이동의 기준은 거래대금 + stock_id 로 잡으세요.인증 · 회원
{
"email": "user@example.com",
"password": "********",
"nickname": "홍길동"
}
{
"userId": 1,
"nickname": "홍길동",
"accessToken": "eyJhbGciOi...",
"account": {
"accountId": 1,
"roundNo": 1,
"initialCash": "50000000",
"cashBalance": "50000000"
}
}
users INSERT → account INSERT →
ledger_entry(INITIAL_DEPOSIT) INSERT 가
한 트랜잭션이어야 합니다.| 에러 코드 | 상황 |
|---|---|
| DUPLICATE_EMAIL | 이미 가입된 이메일 |
| INVALID_PASSWORD | 비밀번호 정책 미충족 |
{ "email": "user@example.com", "password": "********" }
응답은 회원가입과 동일한 형태입니다.
| 에러 코드 | 상황 |
|---|---|
| LOGIN_FAILED | 이메일 또는 비밀번호 불일치 |
{ "userId": 1, "email": "user@example.com", "nickname": "홍길동" }
시장
{
"markets": [
{
"marketCountry": "KR",
"open": true,
"opensAt": "2026-08-11T09:00:00+09:00",
"closesAt": "2026-08-11T15:30:00+09:00",
"nextOpensAt": null
},
{
"marketCountry": "US",
"open": false,
"opensAt": null,
"closesAt": null,
"nextOpensAt": "2026-08-11T22:30:00+09:00"
}
],
"serverTime": "2026-08-11T12:36:59+09:00"
}
프론트는 이 응답으로 거래 버튼 활성화와 "실시간 / 종가" 문구를 판단합니다.
토스 /market-calendar 를 하루 1회 받아 캐싱한 값에서 계산합니다.
서머타임(3월 둘째 일요일 ~ 11월 첫째 일요일) 22:30 ~ 05:00 KST ← 지금(8월)
표준시(11월 첫째 일요일 ~ 3월 둘째 일요일) 23:30 ~ 06:00 KST
하드코딩하면 11월 첫째 주에 장 시작 후 한 시간 동안 거래가 막힙니다.
| 파라미터 | 필수 | 설명 |
|---|---|---|
| base | — | 기본 USD |
| quote | — | 기본 KRW |
{
"baseCurrency": "USD",
"quoteCurrency": "KRW",
"rate": "1398.50",
"changeRate": "0.0016",
"rateAt": "2026-08-11T15:00:00+09:00"
}
exchange_rate 테이블의 최신 행에서 응답합니다.
매시 정각에 적재되므로 프론트 폴링도 1시간이면 충분합니다 — 더 자주 불러도 같은 값입니다.
환율은 하루에 0.3~0.5% 정도만 움직입니다.체결에 쓰는 환율은 이 경로가 아닙니다. 주문 처리 시에는 별도의 1분 TTL 메모리 캐시에서 가져옵니다 — 최대 1시간 오래된 값으로 체결하면 안 되니까요.
| 파라미터 | 값 |
|---|---|
| period | 1d · 1w ·
1m · 3m · 1y |
{
"items": [
{ "rateAt": "2026-07-11T00:00:00+09:00", "rate": "1385.20" },
{ "rateAt": "2026-07-11T01:00:00+09:00", "rate": "1385.60" }
]
}
exchange_rate 테이블(매시 정각 적재)에서 집계합니다.
종목
| 파라미터 | 필수 | 설명 |
|---|---|---|
| market | O | KR / US |
| size | — | 기본 20, 최대 100 |
| cursor | — | 다음 페이지 커서. 거래대금 + stockId 를 인코딩한 값
(공통 규칙 참고) |
sort 파라미터를 두지 않습니다 — 정렬 축이 늘어나면
커서 페이로드도 축마다 달라져야 하므로, 기준을 하나로 고정하는 편이
구현도 설명도 단순합니다. 등락률순·거래량순은 2주차에 추가하세요.{
"items": [
{
"rank": 1,
"symbol": "005930",
"name": "삼성전자",
"market": "KOSPI",
"category": "INDIVIDUAL",
"isDividend": false,
"leverageFactor": null,
"currency": "KRW",
"lastPrice": "241500",
"prevClose": "236050",
"changeAmount": "5450",
"changeRate": "0.0231",
"tradingAmount": "1240000000000",
"quoteAt": "2026-08-11T12:36:59+09:00",
"realtime": true
}
],
"nextCursor": "eyJ0YSI6IjEyNDAwMDAwMDAwMDAiLCJpZCI6MTAyNH0",
"hasNext": true
}
realtime — quoteAt 이 현재 정규장 시간 내이면
true. 프론트가 "12:36:59 기준 · 실시간" 과
"8월 11일 종가" 를 구분하는 근거입니다.화면 컬럼 매핑 — 종목명(name) · 티커(symbol) ·
종류(category) · 현재가(lastPrice) ·
전일대비(changeAmount, changeRate) ·
거래대금(tradingAmount)
tradingAmount 는 최근 1주 누적입니다
(duration=1w). 선정 기준이 곧 표시 값이라 사용자가 "왜 이 순서인지"를
이해할 수 있습니다. 화면에 "최근 1주 거래대금" 이라고 밝혀주세요.| 파라미터 | 필수 | 설명 |
|---|---|---|
| q | O | 검색어 (2자 이상) |
| size | — | 기본 10 |
{
"items": [
{
"symbol": "005930",
"name": "삼성전자",
"englishName": "SamsungElec",
"market": "KOSPI",
"marketCountry": "KR",
"category": "INDIVIDUAL"
}
]
}
stock 테이블 전체가 대상이며, 상위 100 여부와 무관하게 모두 검색됩니다.
클릭하면 상세 페이지도 정상적으로 열립니다 — 차이는 실시간이냐 전일 종가냐뿐입니다.quote_snapshot 이 비어 있습니다.
스케줄러가 도는 대상은 상위 100 뿐이니까요.
상세를 여는 순간 /prices 와 /candles 를 함께 호출해 채우고
quote_snapshot 에 UPSERT 해두세요. 한 번 조회된 종목은 다음부터 DB 에서 나갑니다.검색 결과 목록에 가격을 같이 보여주려면 20건을
/prices
배치 1콜로 받아오세요 — 종목마다 따로 부르면 20콜이 되어 rate limit 에 걸립니다.
가격 없이 종목명만 먼저 보여주고 클릭 후에 채우는 편이 1주차에는 더 간단합니다.SamsungElec, HyundaiMtr,
KIA CORP.) 공백 제거 + 소문자 정규화 후 부분 일치를 권합니다.
PostgreSQL 생성 컬럼으로 검색 키를 만들어두면 편합니다.LIKE '%검색어%' 로 갑니다.
8,500행이면 풀스캔이어도 수 ms 라 문제되지 않습니다.
다만 앞뒤 % 는 인덱스를 타지 않습니다.
데이터가 커지면 pg_trgm 확장 + GIN 인덱스로 바꾸세요 —
쿼리는 그대로 두고 인덱스만 추가하면 됩니다.정렬은 ① 정확 일치 → ② 앞부분 일치 → ③ 부분 일치 순으로 주세요. "삼성"을 쳤을 때
삼성전자가 미래에셋삼성... 보다 위에 와야 합니다.| 에러 코드 | 상황 |
|---|---|
| INVALID_QUERY | 검색어 2자 미만 |
| 상황 | 주가 | 차트 |
|---|---|---|
| 해당 시장 정규장 + 상위 100 |
5초 실시간quote_snapshot · realtime: true |
1분봉 온디맨드 + 60초 캐시 |
| 장 마감 · 다른 나라 종목 또는 상위 100 밖 |
전일 종가realtime: false |
마지막 장의 분봉 (토스가 그대로 돌려줌) |
{
"symbol": "005930",
"name": "삼성전자",
"englishName": "SamsungElec",
"market": "KOSPI",
"marketCountry": "KR",
"currency": "KRW",
"isinCode": "KR7005930003",
"category": "INDIVIDUAL",
"leverageFactor": null,
"isDividend": false,
"price": {
"lastPrice": "241500",
"prevClose": "236050",
"changeAmount": "5450",
"changeRate": "0.0231",
"upperLimit": "313500",
"lowerLimit": "169500",
"quoteAt": "2026-08-11T12:36:59+09:00",
"realtime": true
},
"info": {
"marketCap": "1441000000000000",
"sharesOutstanding": "5969782550",
"listDate": "1975-06-11"
},
"warnings": [
{ "type": "INVESTMENT_WARNING", "label": "투자경고" }
],
"tradable": true,
"tradableReason": null
}
| 필드 | 의미 |
|---|---|
| tradable | 지금 이 종목을 거래할 수 있는가 |
| tradableReason | tradable=false 일 때의 사유 코드 |
| 코드 | 화면 문구 |
|---|---|
| MARKET_CLOSED | 장 마감 · 09:00~15:30 거래 가능 |
| NOT_IN_UNIVERSE | 이 종목은 아직 거래를 지원하지 않아요 |
| SUSPENDED | 거래정지 종목 |
| LIQUIDATION | 정리매매 종목 |
quote_snapshot 이 전 종목을 담고 있어서,
상위 100 여부와 무관하게 이 엔드포인트 하나로 모든 종목의 상세가 응답됩니다.
차이는 realtime 과 tradable 두 플래그뿐이라
프론트가 분기를 짤 필요가 없습니다.| 에러 코드 | 상황 |
|---|---|
| STOCK_NOT_FOUND | 존재하지 않는 심볼 |
| 파라미터 | 필수 | 값 |
|---|---|---|
| interval | O | 봉 하나의 시간 단위 — 1m · 5m ·
10m · 1d · 1w |
| range | O | 조회 기간 — 1D · 1W · 1M ·
6M · 1Y · 3Y |
| interval | 허용 range | 봉 개수 | 데이터 출처 |
|---|---|---|---|
| 1m | 1D | 약 390 | 토스 /candles?interval=1m (온디맨드) |
| 5m | 1D · 1W | 78 / 390 | 1분봉을 집계 |
| 10m | 1W | 195 | 1분봉을 집계 |
| 1d | 1M · 6M · 1Y |
22 / 130 / 250 | daily_candle |
| 1w | 3Y | 156 | 일봉을 집계 |
1m 과 1d 두 가지만 제공합니다.
5m·10m·1w 는 우리가 집계해서 만들어야 합니다.
1분봉 200개를 5개씩 묶으면 5분봉 40개가 나옵니다.1m + 1Y 같은 조합은 반드시 막으세요.
1분봉으로 1년이면 12만 개가 됩니다.{
"symbol": "005930",
"interval": "1d",
"range": "6M",
"currency": "KRW",
"items": [
{
"at": "2026-08-11T00:00:00+09:00",
"open": "237000",
"high": "242500",
"low": "236500",
"close": "241500",
"volume": "12345678"
}
]
}
daily_candle 의 과거 봉 +
quote_snapshot.last_price 로 만든 오늘 봉을 붙입니다.
그러면 프론트가 차트를 다시 안 받아도 끝점이 살아 움직입니다.count 상한 200 은 수집할 때만 해당합니다
(일봉 200개 ≈ 10개월이라 1년치는 before 로 두 번 받습니다).
daily_candle 에 쌓아두면 250봉을 그대로 내려주면 됩니다.| 에러 코드 | 상황 |
|---|---|
| INVALID_INTERVAL_RANGE | 허용되지 않은 interval × range 조합 |
| STOCK_NOT_FOUND | 존재하지 않는 심볼 |
1d 와 1m 둘입니다.
일봉은 daily_candle(스케줄러가 마감 후 적재),
분봉은 minute_candle 을 캐시 삼아 온디맨드로 채웁니다.
5m·10m·1w 집계는 2주차로 미룹니다.// 1주차 분봉 처리 흐름 GET /stocks/NVDA/candles?interval=1m&range=1D ↓ minute_candle 에 60초 이내 데이터가 있나? ├ 있다 → DB 에서 바로 반환 토스 호출 없음 └ 없다 → 토스 /candles?interval=1m&count=200 호출 ↓ ON CONFLICT DO NOTHING 으로 UPSERT DB 에서 반환
/candles 를 부르면 마지막 장의 분봉이 그대로 옵니다 —
한국 낮에 엔비디아를 열면 전일 종가 + 지난 미국장 분봉 차트가 보입니다.
프론트는 realtime 값으로 "실시간 / 종가" 문구만 바꾸면 되고,
차트 자체는 분기가 필요 없습니다. 빈 차트는 "고장난 화면"으로 읽히므로
거래 불가와 조회 불가를 반드시 분리하세요.비용은 동시 시청 30명 기준 0.5 req/s —
MARKET_DATA_CHART 20 TPS 의 2.5% 입니다.
아무도 안 보는 종목까지 1분마다 긁는 상시 적재보다 오히려 쌉니다.
스케줄러 상시 적재는 2주차(지정가 체결 판정 · 5m·10m 집계)에 시작합니다.before 로 2회 호출해야 합니다.
1주차 차트를 "최근 200분"으로 잡으면 1콜로 끝납니다 —
기본은 1콜로 두고 전체 보기를 누를 때만 2콜을 쓰는 편이 단순합니다.실측 필요 —
before 가 inclusive 인지, 마감 동시호가 봉(15:30)이 존재하는지.
15:30 봉이 없으면 330개가 아니라 329개입니다.
경계 봉이 중복돼도 PRIMARY KEY (stock_id, candle_at) 라
ON CONFLICT DO NOTHING 이 걸러줍니다.거래
?symbol=005930&side=BUY&quantity=10
{
"symbol": "005930",
"side": "BUY",
"quantity": "10",
"executedPrice": "241500",
"exchangeRate": "1",
"grossAmount": "2415000",
"fee": "242",
"tax": "0",
"netAmount": "2415242",
"availableCash": "48240000",
"quoteAt": "2026-08-11T12:36:59+09:00",
"executable": true,
"reason": null
}
매수 netAmount = grossAmount + fee (예수금에서 차감) 매도 netAmount = grossAmount − fee − tax (예수금으로 입금) grossAmount = executedPrice × quantity × exchangeRate fee = grossAmount × FEE_RATE 0.0001 · 매수/매도 · 국내/미국 공통 tax 매도 시에만. 시장에 따라 계산식이 다르다 국내 k_tax = grossAmount × K_TAX_RATE 0.002 미국 a_tax = max(grossAmount × A_TAX_RATE, A_TAX_MIN_USD × exchangeRate) 0.0000206, 최소 $0.01
$20.60 per million = 0.0000206(FY2026 기준, SEC 가 연 1회 조정).
최소 $0.01 이 있어서 소액 매도는 정률이 아니라 최소액이 붙습니다.손익분기는
0.01 ÷ 0.0000206 = $485.44 —
485달러 미만 매도는 전부 $0.01 이 적용됩니다.
초보자가 다루는 금액대는 대부분 여기에 들어가므로 실제로는
최소액 쪽이 기본 경로라고 보는 편이 맞습니다.두 계산은 수학적으로 같습니다(환율이 공통 인수). 다만 달러로 계산하고 마지막에 환산하는 쪽이 "최소 $0.01" 이라는 규칙에 더 가깝습니다.
| 종목 | grossAmount | fee | tax | netAmount |
|---|---|---|---|---|
| 삼성전자 10주 @ 241,500 · 국내 |
2,415,000 | 242 | 4,830 정률 | 2,409,928 |
| 엔비디아 2주 @ $182.30 · 1,398.50 |
509,893 | 51 | 14 정률 10.50 vs 최소 13.98 |
509,828 |
| 엔비디아 20주 @ $182.30 · 1,398.50 |
5,098,931 | 510 | 105 정률 105.04 vs 최소 13.98 |
5,098,316 |
매수는 훨씬 단순합니다 — 두 시장 모두 tax = 0 이라
삼성전자 10주는 2,415,000 + 242 = 2,415,242 차감입니다.
.env 에만 있고 DB 에는 결과값만 저장합니다.
FEE_RATE · K_TAX_RATE ·
A_TAX_RATE · A_TAX_MIN_USDtrade_order 에는 fee 와 tax 두 컬럼만 있습니다 —
요율 컬럼은 없습니다. 요율은 주문마다 다른 값이 아니라 전역 정책이고,
결과 금액이 남아 있으면 정책이 바뀌어도 과거 거래가 흔들리지 않습니다.응답에 요율을 내려보내지도 않습니다. 화면에 "수수료 0.01%" 같은 문구가 필요하면 프론트가 자기 설정값을 쓰거나, 정말 필요하면
GET /market/status 같은 곳에 정책 조회용 필드를 따로 만드세요 —
주문 응답마다 실어 보낼 값은 아닙니다.팀원 전원이 같은 값을 써야 합니다. 한 사람만 다르면 같은 주문인데 금액이 달라집니다.
원화 환산 금액은 원 단위 반올림(HALF_UP) 후 정수로 저장합니다 —
grossAmount 를 먼저 반올림하고 그 값으로 fee·tax 를
계산한 뒤 다시 반올림합니다. 원장 금액을 전부 정수로 유지해야 검증식이 정확히 맞습니다.견적과 실제 체결 사이에 가격이 바뀔 수 있습니다. 견적은 참고값이고, 체결 시점에 서버가 다시 계산합니다. 미국 종목은 환율도 함께 바뀌므로 견적과의 차이가 국내보다 큽니다.
{
"clientOrderId": "018f2c9e-4a1b-7c3d-9e5f-1a2b3c4d5e6f",
"symbol": "005930",
"side": "BUY",
"quantity": "10"
}
clientOrderId 는 프론트가 UUID v4 로 생성합니다.
주문 화면 진입 시 한 번 만들고, 성공하면 새로 발급합니다.
같은 값으로 재요청하면 중복 체결 대신 기존 주문 결과를 반환합니다 —
버튼 두 번 클릭과 네트워크 재시도를 모두 막습니다.{
"orderId": 1024,
"status": "FILLED",
"symbol": "005930",
"side": "BUY",
"quantity": "10",
"executedPrice": "241500",
"exchangeRate": "1",
"grossAmount": "2415000",
"fee": "242",
"tax": "0",
"netAmount": "2415242",
"quoteAt": "2026-08-11T12:36:59+09:00",
"orderedAt": "2026-08-11T12:37:02+09:00",
"account": {
"cashBalance": "45824758",
"totalAsset": "50412300"
}
}
응답에 갱신된 계좌 요약을 포함하면 프론트가 재조회하지 않아도 됩니다.
① SELECT ... FROM account WHERE account_id = ? FOR UPDATE ② 검증 — 장 시간 · is_ranked · 거래정지 · 시세 유효시간 · 예수금/보유수량 ③ INSERT trade_order (clientOrderId 유니크 위반 → 중복 요청) ④ UPDATE account.cash_balance ⑤ INSERT ledger_entry (append only) ⑥ UPSERT holding (이동평균 단가 재계산)
| 코드 | HTTP | 화면 문구 |
|---|---|---|
| MARKET_CLOSED | 422 | 지금은 거래할 수 없는 시간이에요 |
| NOT_IN_UNIVERSE | 422 | 이 종목은 아직 거래를 지원하지 않아요 |
| STOCK_SUSPENDED | 422 | 거래정지 종목이에요 |
| INSUFFICIENT_CASH | 422 | 주문가능금액이 부족해요 |
| INSUFFICIENT_QUANTITY | 422 | 보유 수량이 부족해요 |
| STALE_QUOTE | 422 | 시세 정보가 오래되었어요. 다시 시도해주세요 |
| INVALID_QUANTITY | 400 | 수량은 1주 이상의 정수로 입력해주세요 |
STALE_QUOTE —
quote_at 이 현재보다 15초 이상 오래되면 거절합니다.
오래된 가격으로 체결되면 원장의 신뢰가 무너집니다.계좌 · 마이페이지
{
"accountId": 1,
"roundNo": 1,
"initialCash": "50000000",
"cashBalance": "48240000",
"stockValue": "2172300",
"totalAsset": "50412300",
"unrealizedPnl": "137300",
"unrealizedPnlRate": "0.0675",
"exchangeRate": "1398.50",
"asOf": "2026-08-11T12:36:59+09:00"
}
1주차는 평가손익만 제공합니다. 실현손익은 체결 내역이 쌓인 뒤 2주차에 분리합니다.
stockValue 는 holding × quote_snapshot.last_price 로 계산하며,
해외 종목은 exchangeRate 로 원화 환산합니다.
{
"items": [
{
"symbol": "005930",
"name": "삼성전자",
"currency": "KRW",
"quantity": "6",
"avgBuyPrice": "228000",
"avgExchangeRate": "1",
"lastPrice": "241500",
"evaluationAmount": "1449000",
"unrealizedPnl": "81000",
"unrealizedPnlRate": "0.0592",
"realtime": true
}
],
"asOf": "2026-08-11T12:36:59+09:00"
}
ledger_entry)을 보여줍니다.
"무엇을 샀나"가 아니라 "돈이 어떻게 움직였나"가 됩니다.
초기 지급과 포트폴리오 초기화까지 한 줄로 들어와서 계좌의 전체 이력이 되고,
balanceAfter 를 그대로 찍으면 사용자가 잔고 변화를 눈으로 따라갈 수 있습니다.| 파라미터 | 필수 | 설명 |
|---|---|---|
| cursor | — | 이전 응답의 nextCursor |
| size | — | 기본 20 |
| entryType | — | 필터. 생략하면 전체 |
{
"items": [
{
"entryId": 3041,
"entryType": "BUY",
"amount": "-2415242", // gross 2,415,000 + fee 242
"balanceAfter": "47584758",
"exchangeRate": "1",
"memo": "삼성전자 10주 @ 241,500 (수수료 포함)",
"orderId": 1024,
"symbol": "005930",
"name": "삼성전자",
"occurredAt": "2026-08-11T12:37:02+09:00"
},
{
"entryId": 3040,
"entryType": "INITIAL_DEPOSIT",
"amount": "50000000",
"balanceAfter": "50000000",
"exchangeRate": "1",
"memo": "모의투자금 지급",
"orderId": null,
"symbol": null,
"name": null,
"occurredAt": "2026-08-10T09:00:00+09:00"
}
],
"nextCursor": "eyJlbnRyeUlkIjozMDQwfQ",
"hasNext": false
}
| 코드 | 부호 | 화면 문구 | amount |
|---|---|---|---|
| INITIAL_DEPOSIT | + | 모의투자금 지급 | initial_cash |
| BUY | − | 매수 | −(gross + fee) |
| SELL | + | 매도 | +(gross − fee − tax) |
trade_order.net_amount 하나에 대응하므로 목록이 절반으로 짧아지고
커서 처리도 단순해집니다. 수수료 총액이 필요해지면
SUM(trade_order.fee) 로 언제든 구할 수 있습니다.RESET 항목도 두지 않습니다. 포트폴리오 초기화는 새 계좌를 만드는 일이라
새 계좌의 INITIAL_DEPOSIT 한 줄이 그 역할을 대신합니다.체결 시점 환율입니다. 원화 종목은 1, 미국 종목은 그때의 USD/KRW.
amount 는 이미 원화 환산값이라 계산에 쓰이지는 않습니다 —
"이 거래를 얼마짜리 환율로 했는가"를 원장만 보고 알 수 있게 하는 감사 항목입니다.
1주차 화면에는 안 띄워도 되지만, 지금 안 남기면 과거 값은 복원할 수 없습니다.
entryId 로 잡으세요. occurredAt 은
안 됩니다. 연속 주문이면 TIMESTAMPTZ 정밀도 안에서 시각이 겹칠 수 있고,
그 경계에서 항목이 누락되거나 무한 루프에 빠집니다.
entryId 는 단조 증가라 중복도 누락도 구조적으로 불가능합니다.최신순이므로
WHERE account_id = ? AND entry_id < :cursor
ORDER BY entry_id DESC LIMIT :size + 1 로 조회하고,
size + 1 번째 행의 존재 여부로 hasNext 를 판단합니다.trade_order 기반 주문 내역 탭을 따로 두거나, 2주차로 미루세요.
1주차 화면에는 원장 하나만 있으면 충분합니다.{
"accountId": 2,
"roundNo": 2,
"initialCash": "50000000",
"cashBalance": "50000000"
}
UPDATE account SET status='CLOSED', closed_at=now() WHERE account_id = 현재; INSERT INTO account (user_id, round_no, ...) VALUES (?, 이전+1, 50000000, 50000000); INSERT INTO ledger_entry (entry_type='INITIAL_DEPOSIT', ...);
account_id
기준이라 화면에서는 자동으로 비워집니다. 나중에 "지난 회차 성적" 기능으로 확장할 수 있습니다.프론트는 확인 모달을 반드시 띄우세요.
화면 ↔ API 매핑
| 화면 | 호출 API |
|---|---|
| 메인 | /market/status (선택) |
| 주식 랭킹 | /exchange-rates/latest ·
/stocks/rankings · /stocks/search |
| 종목 상세 | /stocks/{symbol} ·
/stocks/{symbol}/candles |
| 거래 패널 | /orders/quote ·
POST /orders |
| 마이페이지 | /accounts/me ·
/accounts/me/holdings · /accounts/me/ledger |
| 포트폴리오 초기화 | POST /accounts/me/reset |
| 이용 가이드 | 없음 (정적 콘텐츠) |
| 회원가입 유도 | 1주차에는 호출 없음 — 화면 UX 만 만들고
/auth/* 는 2주차에 붙입니다 |
서버 수집 스케줄 확정
프론트가 폴링하는 것은 우리 API 이고, 우리 서버가 토스를 호출하는 주기는 아래와 같습니다. 둘은 완전히 분리되어 있습니다 — 사용자가 100명이 되어도 토스 호출량은 그대로입니다.
| 시각 (KST) | 주기 | 하는 일 |
|---|---|---|
| 월 07:00 | 주 1회 | 전체 종목 마스터 갱신 — /stocks/all + /stocks 배치 |
| 월 08:00 | 주 1회 | 국내 거래대금 상위 100 선정 — /rankings?market=KR&duration=1w
1콜 |
| 월 21:00 | 주 1회 | 미국 거래대금 상위 100 선정 — 1콜. 미국장 시작 1시간 30분 전 |
| 08:50 | 일 1회 | 국내 prev_close ← 전일 종가. 상하한가 동시 수집 |
| 09:00 ~ 15:30 | 5초 | 국내 상위 100 현재가 — /prices 배치 1콜 (한도의 1.3%) |
| 15:40 | 일 1회 | 국내 일봉 적재 — 마감 10분 후 |
| 22:00 * | 일 1회 | 미국 prev_close 갱신 — 정규장 시작 30분 전 |
| 22:30 ~ 05:00 * | 5초 | 미국 상위 100 현재가 — 배치 1콜. 겨울에는 23:30 ~ 06:00 |
| 05:10 * | 일 1회 | 미국 일봉 적재 — 마감 10분 후. 겨울에는 06:10 |
| 매시 정각 | 1시간 | 환율 적재 — 하루 24콜 |
/market-calendar/US 의 세션 시각을 그대로 쓰세요.스케줄러에 넣지 않은 것 — 분봉과 상위 100 밖 종목의 시세는 사용자가 상세를 열 때 온디맨드로 채웁니다. 상시 적재는 2주차 과제입니다.
클라이언트 폴링 정책
| 대상 | 주기 | 엔드포인트 |
|---|---|---|
| 랭킹 목록 | 5초 | /stocks/rankings |
| 종목 상세 | 5초 | /stocks/{symbol} |
| 마이페이지 | 10초 | /accounts/me + /holdings |
| 차트 | 60초 | /stocks/{symbol}/candles |
| 환율 배너 | 1시간 | /exchange-rates/latest |
① 백그라운드 탭에서는 폴링 중단 —
document.visibilityState 확인만으로
실사용 트래픽이 절반 가까이 줄어듭니다.② 장 마감 시 폴링 중단 —
/market/status 의 open 이
false 면 갱신할 것이 없습니다. 수집기도 함께 멈추므로
quote_snapshot.last_price 에 종가가 그대로 남아 자동으로
전일 종가 역할을 합니다. 차트는 minute_candle 에 쌓아둔
마지막 장 분봉을 그대로 보여주면 됩니다.③ 응답에 다음 조회 시각 힌트 — 서버 수집 주기(5초)와 클라이언트 폴링 주기가 어긋나면 지연이 누적됩니다.
nextUpdateAt 을 담고 그 직후에 재요청하면
지연이 고정됩니다.{ "asOf": "...", "nextUpdateAt": "2026-08-11T12:37:04+09:00", "items": [...] }
남은 결정 사항
구현 시작 전에 팀 회의에서 한 번에 정하시면 중간에 막히지 않습니다. 아래 확정 항목은 이미 정해져서 목록에서 뺐습니다.
| 항목 | 결정 |
|---|---|
| 검색 범위 | 전 종목(약 8,500개) · LIKE '%q%' |
| 수수료 | FEE_RATE 0.0001 · 매수/매도 · 국내/미국 공통 |
| 매도 세금 | 국내 k_tax = gross × K_TAX_RATE ·
미국 a_tax = max(gross × A_TAX_RATE, A_TAX_MIN_USD × 환율) |
| 요율 저장 위치 | .env 만. DB 에는 계산 결과값
(trade_order.fee · trade_order.tax)만 저장.
tax 는 한 컬럼 — 한 주문은 국내 아니면 미국이라
k_tax·a_tax 로 나누지 않습니다 |
| 인증 | 1주차 미구현 — 시드 사용자 1명 고정 |
| 소수점 거래 | 2주차 — 1주차는 정수 주 단위만. 화면에서 토글 자체를 제거했습니다 |
| 체결 내역 | 원장 기준 GET /accounts/me/ledger |
| 원장 항목 | 매수 · 매도 · 초기지급 3종. 수수료·세금은 매수·매도 금액에 포함(한 줄) |
| 랭킹 정렬·커서 | 거래대금 내림차순 ·
커서는 (tradingAmount, stockId) |
| 분봉 수집 | 1주차 온디맨드 + 60초 캐시. 상시 적재는 2주차 |
| 유니버스 갱신 | 월요일 KR 08:00 · US 21:00 |
| 배당주 판정 | 1주차 비활성 |
| 항목 | 선택지 |
|---|---|
before 경계 |
토스 /candles 의 before 가 inclusive 인지,
마감 동시호가(15:30) 봉이 존재하는지 실측 필요 |
STALE_QUOTE 임계값 | 15초 기준이 적절한지 |
| 인증 방식 (2주차) | JWT vs 세션 쿠키 |
| 소수점 자릿수 (2주차) | 미국 주식 최소 주문 단위 (0.1? 0.001?) |
2주차 이후 예정
| 엔드포인트 | 내용 |
|---|---|
| POST /auth/signup · /auth/login | 인증 구현 — 1주차에는 화면만 있고 서버는 시드 사용자 고정 |
| POST /orders | 지정가 주문 (limitPrice, PENDING 상태) |
| POST /orders (소수점) | 미국 종목 소수점 주문 개방. 그때 allowsFractional 필드를
종목 상세 응답에 추가하고, 미국 종목에서만 입력 단위를 바꿉니다 |
| GET /accounts/me/orders | 주문 내역 탭 — 거절된 주문까지 포함 (원장에는 안 남음) |
| DELETE /orders/{id} | 주문 취소 |
| GET /accounts/me/assets/history | 자산 추이 그래프 (일별 스냅샷) |
| GET /accounts/me/report | 투자 습관 진단 |
| GET /stocks/{symbol}/orderbook | 호가 |
| WebSocket | 실시간 시세 push (폴링 대체) |
지금 만들지는 않지만 URL 설계가 충돌하지 않게 미리 자리를 잡아둔 것입니다.