API 명세서 — 모의 주식 트레이딩 서비스

1주차 MVP 기준 · 26.08.20 ~ 08.25 · ERD 와 와이어프레임에서 도출

17 endpoints Spring Boot PostgreSQL REST · JSON

공통 규칙

Base URL

개발   http://localhost:8080/api
운영   https://{도메인}/api

인증

로그인 후 발급받은 토큰을 헤더에 담습니다.

Authorization: Bearer {accessToken}
1주차에는 인증을 구현하지 않습니다. 회원가입·로그인 화면은 UX 설계만 하고, 서버는 시드 사용자 1명(user_id = 1)으로 고정해 동작합니다 (.envAUTH_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"
금액을 숫자로 내리지 마세요. JavaScript 의 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_id2차 정렬 키로 두면 순서가 유일하게 결정됩니다.

PostgreSQL 의 튜플 비교 (a, b) < (:a, :b) 를 쓰면 a < :a OR (a = :a AND b < :b) 를 직접 쓰지 않아도 되고, (trading_amount DESC, stock_id DESC) 복합 인덱스를 그대로 탑니다.
커서 중에 유니버스가 갱신되면 어떻게 되나요? 월요일 08:00 배치가 도는 그 순간 사용자가 2페이지를 넘기면, 새 유니버스 기준으로 조회되어 일부 종목이 빠지거나 나타납니다. 1주차에는 그냥 두세요 — 주 1회 갱신이라 실제로 걸릴 확률이 거의 없고, 오류가 아니라 "그 사이에 순위가 바뀌었다"는 정상 동작입니다.

엄밀히 막으려면 커서에 유니버스 버전(갱신 시각)을 함께 담고 달라지면 409 로 첫 페이지부터 다시 받게 하면 됩니다. 2주차 과제로 두세요.
rank_no 를 커서로 쓰지 마세요. rank_no 는 배치가 통째로 다시 쓰는 값이라, 갱신 직후 같은 번호가 다른 종목을 가리키게 됩니다. 화면에 순위를 표시하는 용도로만 쓰고, 페이지 이동의 기준은 거래대금 + stock_id 로 잡으세요.

인증 · 회원

POST /auth/signup 회원가입 + 계좌 개설 + 모의 투자금 지급
Request
{
  "email": "user@example.com",
  "password": "********",
  "nickname": "홍길동"
}
Response · 201
{
  "userId": 1,
  "nickname": "홍길동",
  "accessToken": "eyJhbGciOi...",
  "account": {
    "accountId": 1,
    "roundNo": 1,
    "initialCash": "50000000",
    "cashBalance": "50000000"
  }
}
가입과 동시에 계좌를 만들고 5,000만원을 지급합니다. users INSERT → account INSERT → ledger_entry(INITIAL_DEPOSIT) INSERT 가 한 트랜잭션이어야 합니다.
에러 코드상황
DUPLICATE_EMAIL이미 가입된 이메일
INVALID_PASSWORD비밀번호 정책 미충족
POST /auth/login로그인
Request
{ "email": "user@example.com", "password": "********" }

응답은 회원가입과 동일한 형태입니다.

에러 코드상황
LOGIN_FAILED이메일 또는 비밀번호 불일치
GET /users/me🔒 내 정보
{ "userId": 1, "email": "user@example.com", "nickname": "홍길동" }

시장

GET /market/status 장 운영 상태 — 거래 버튼 활성화 판단
{
  "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회 받아 캐싱한 값에서 계산합니다.

미국 정규장 시각은 서머타임에 따라 1시간 이동합니다.
서머타임(3월 둘째 일요일 ~ 11월 첫째 일요일) 22:30 ~ 05:00 KST ← 지금(8월)
표준시(11월 첫째 일요일 ~ 3월 둘째 일요일) 23:30 ~ 06:00 KST
하드코딩하면 11월 첫째 주에 장 시작 후 한 시간 동안 거래가 막힙니다.
GET /exchange-rates/latest 랭킹 페이지 환율 배너
Query
파라미터필수설명
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시간 오래된 값으로 체결하면 안 되니까요.
GET /exchange-rates/history 환율 추이 그래프
Query
파라미터
period1d · 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 테이블(매시 정각 적재)에서 집계합니다.

종목

GET /stocks/rankings 거래대금 상위 100 · 커서 페이지네이션
Query
파라미터필수설명
marketOKR / US
size기본 20, 최대 100
cursor 다음 페이지 커서. 거래대금 + stockId 를 인코딩한 값 (공통 규칙 참고)
정렬 기준은 거래대금 내림차순 하나뿐입니다. 1주차에는 sort 파라미터를 두지 않습니다 — 정렬 축이 늘어나면 커서 페이로드도 축마다 달라져야 하므로, 기준을 하나로 고정하는 편이 구현도 설명도 단순합니다. 등락률순·거래량순은 2주차에 추가하세요.
Response
{
  "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
}
realtimequoteAt 이 현재 정규장 시간 내이면 true. 프론트가 "12:36:59 기준 · 실시간""8월 11일 종가" 를 구분하는 근거입니다.

화면 컬럼 매핑 — 종목명(name) · 티커(symbol) · 종류(category) · 현재가(lastPrice) · 전일대비(changeAmount, changeRate) · 거래대금(tradingAmount)

tradingAmount 는 최근 1주 누적입니다 (duration=1w). 선정 기준이 곧 표시 값이라 사용자가 "왜 이 순서인지"를 이해할 수 있습니다. 화면에 "최근 1주 거래대금" 이라고 밝혀주세요.
GET /stocks/{symbol} 종목 상세 — 전 종목 대상
시세를 어디서 가져올지는 "그 종목의 시장이 열려 있는가"로 갈립니다. 보는 사람의 시각이 아니라 종목이 속한 시장 기준입니다 — 한국 낮에 엔비디아를 열면 미국장이 닫혀 있으므로 전일 종가가 나갑니다.
상황주가차트
해당 시장 정규장
+ 상위 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지금 이 종목을 거래할 수 있는가
tradableReasontradable=false 일 때의 사유 코드
tradableReason 값
코드화면 문구
MARKET_CLOSED장 마감 · 09:00~15:30 거래 가능
NOT_IN_UNIVERSE이 종목은 아직 거래를 지원하지 않아요
SUSPENDED거래정지 종목
LIQUIDATION정리매매 종목
quote_snapshot 이 전 종목을 담고 있어서, 상위 100 여부와 무관하게 이 엔드포인트 하나로 모든 종목의 상세가 응답됩니다. 차이는 realtimetradable 두 플래그뿐이라 프론트가 분기를 짤 필요가 없습니다.
에러 코드상황
STOCK_NOT_FOUND존재하지 않는 심볼
GET /stocks/{symbol}/candles 일봉 · 분봉 차트
Query
파라미터필수
intervalO 봉 하나의 시간 단위1m · 5m · 10m · 1d · 1w
rangeO 조회 기간1D · 1W · 1M · 6M · 1Y · 3Y
유효 조합 — 그 외는 400 으로 거절
interval허용 range 봉 개수데이터 출처
1m1D약 390 토스 /candles?interval=1m (온디맨드)
5m1D · 1W78 / 390 1분봉을 집계
10m1W195 1분봉을 집계
1d1M · 6M · 1Y 22 / 130 / 250daily_candle
1w3Y156 일봉을 집계
토스는 1m1d 두 가지만 제공합니다. 5m·10m·1w우리가 집계해서 만들어야 합니다. 1분봉 200개를 5개씩 묶으면 5분봉 40개가 나옵니다.

1m + 1Y 같은 조합은 반드시 막으세요. 1분봉으로 1년이면 12만 개가 됩니다.
Response
{
  "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 로 만든 오늘 봉을 붙입니다. 그러면 프론트가 차트를 다시 안 받아도 끝점이 살아 움직입니다.
우리 API 에는 200봉 제한이 없습니다. 토스의 count 상한 200 은 수집할 때만 해당합니다 (일봉 200개 ≈ 10개월이라 1년치는 before 로 두 번 받습니다). daily_candle 에 쌓아두면 250봉을 그대로 내려주면 됩니다.
에러 코드상황
INVALID_INTERVAL_RANGE허용되지 않은 interval × range 조합
STOCK_NOT_FOUND존재하지 않는 심볼
1주차 범위는 1d1m 둘입니다. 일봉은 daily_candle(스케줄러가 마감 후 적재), 분봉은 minute_candle 을 캐시 삼아 온디맨드로 채웁니다. 5m·10m·1w 집계는 2주차로 미룹니다.
분봉은 온디맨드로 받아 60초 캐싱합니다
// 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/sMARKET_DATA_CHART 20 TPS 의 2.5% 입니다. 아무도 안 보는 종목까지 1분마다 긁는 상시 적재보다 오히려 쌉니다. 스케줄러 상시 적재는 2주차(지정가 체결 판정 · 5m·10m 집계)에 시작합니다.
한 번에 받을 수 있는 봉은 200개입니다. 국내 정규장 09:00~15:30 은 330분이라 하루치를 다 받으려면 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 이 걸러줍니다.

거래

GET /orders/quote🔒 수수료 · 세금 미리보기
Query
?symbol=005930&side=BUY&quantity=10
Response
{
  "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
미국에는 증권거래세가 없습니다. 대신 SEC 가 Section 31 수수료를 매도에만 부과합니다 — $20.60 per million = 0.0000206(FY2026 기준, SEC 가 연 1회 조정). 최소 $0.01 이 있어서 소액 매도는 정률이 아니라 최소액이 붙습니다.

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

두 계산은 수학적으로 같습니다(환율이 공통 인수). 다만 달러로 계산하고 마지막에 환산하는 쪽이 "최소 $0.01" 이라는 규칙에 더 가깝습니다.
예시 — 매도 기준
종목grossAmount feetaxnetAmount
삼성전자 10주
@ 241,500 · 국내
2,415,000242 4,830 정률 2,409,928
엔비디아 2주
@ $182.30 · 1,398.50
509,89351 14
정률 10.50 vs 최소 13.98
509,828
엔비디아 20주
@ $182.30 · 1,398.50
5,098,931510 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_USD

trade_order 에는 feetax 두 컬럼만 있습니다 — 요율 컬럼은 없습니다. 요율은 주문마다 다른 값이 아니라 전역 정책이고, 결과 금액이 남아 있으면 정책이 바뀌어도 과거 거래가 흔들리지 않습니다.

응답에 요율을 내려보내지도 않습니다. 화면에 "수수료 0.01%" 같은 문구가 필요하면 프론트가 자기 설정값을 쓰거나, 정말 필요하면 GET /market/status 같은 곳에 정책 조회용 필드를 따로 만드세요 — 주문 응답마다 실어 보낼 값은 아닙니다.

팀원 전원이 같은 값을 써야 합니다. 한 사람만 다르면 같은 주문인데 금액이 달라집니다.
하드코딩하지 마세요.

원화 환산 금액은 원 단위 반올림(HALF_UP) 후 정수로 저장합니다 — grossAmount 를 먼저 반올림하고 그 값으로 fee·tax 를 계산한 뒤 다시 반올림합니다. 원장 금액을 전부 정수로 유지해야 검증식이 정확히 맞습니다.

견적과 실제 체결 사이에 가격이 바뀔 수 있습니다. 견적은 참고값이고, 체결 시점에 서버가 다시 계산합니다. 미국 종목은 환율도 함께 바뀌므로 견적과의 차이가 국내보다 큽니다.
POST /orders🔒 매수 · 매도 (시장가 즉시 체결)
Request
{
  "clientOrderId": "018f2c9e-4a1b-7c3d-9e5f-1a2b3c4d5e6f",
  "symbol": "005930",
  "side": "BUY",
  "quantity": "10"
}
clientOrderId 는 프론트가 UUID v4 로 생성합니다. 주문 화면 진입 시 한 번 만들고, 성공하면 새로 발급합니다. 같은 값으로 재요청하면 중복 체결 대신 기존 주문 결과를 반환합니다 — 버튼 두 번 클릭과 네트워크 재시도를 모두 막습니다.
Response · 201
{
  "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_CLOSED422지금은 거래할 수 없는 시간이에요
NOT_IN_UNIVERSE422이 종목은 아직 거래를 지원하지 않아요
STOCK_SUSPENDED422거래정지 종목이에요
INSUFFICIENT_CASH422주문가능금액이 부족해요
INSUFFICIENT_QUANTITY422보유 수량이 부족해요
STALE_QUOTE422시세 정보가 오래되었어요. 다시 시도해주세요
INVALID_QUANTITY400수량은 1주 이상의 정수로 입력해주세요
STALE_QUOTEquote_at 이 현재보다 15초 이상 오래되면 거절합니다. 오래된 가격으로 체결되면 원장의 신뢰가 무너집니다.

계좌 · 마이페이지

GET /accounts/me🔒 계좌 요약
{
  "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주차에 분리합니다. stockValueholding × quote_snapshot.last_price 로 계산하며, 해외 종목은 exchangeRate 로 원화 환산합니다.

GET /accounts/me/holdings🔒 보유 종목
{
  "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"
}
보유 종목은 랭킹에서 빠져도 계속 시세를 수집해야 합니다. 안 그러면 평가금액이 그 시점에 멈춰서 사용자에겐 명백한 버그로 보입니다.
GET /accounts/me/ledger🔒 체결 내역 — 원장 기준 · 커서 페이지네이션
주문 목록이 아니라 원장(ledger_entry)을 보여줍니다. "무엇을 샀나"가 아니라 "돈이 어떻게 움직였나"가 됩니다. 초기 지급과 포트폴리오 초기화까지 한 줄로 들어와서 계좌의 전체 이력이 되고, balanceAfter 를 그대로 찍으면 사용자가 잔고 변화를 눈으로 따라갈 수 있습니다.
Query
파라미터필수설명
cursor이전 응답의 nextCursor
size기본 20
entryType필터. 생략하면 전체
Response
{
  "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
}
entryType — 세 가지뿐입니다
코드부호 화면 문구amount
INITIAL_DEPOSIT+모의투자금 지급 initial_cash
BUY매수 −(gross + fee)
SELL+매도 +(gross − fee − tax)
수수료·세금은 별도 항목으로 쪼개지 않고 매수·매도 금액에 포함합니다. 원장 한 줄이 trade_order.net_amount 하나에 대응하므로 목록이 절반으로 짧아지고 커서 처리도 단순해집니다. 수수료 총액이 필요해지면 SUM(trade_order.fee) 로 언제든 구할 수 있습니다.

RESET 항목도 두지 않습니다. 포트폴리오 초기화는 새 계좌를 만드는 일이라 새 계좌의 INITIAL_DEPOSIT 한 줄이 그 역할을 대신합니다.
exchangeRate

체결 시점 환율입니다. 원화 종목은 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주차 화면에는 원장 하나만 있으면 충분합니다.
POST /accounts/me/reset🔒 포트폴리오 초기화
{
  "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:305초 국내 상위 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콜
국내장과 미국장은 시간대가 겹치지 않습니다. 09:00~15:30 과 22:30~05:00 이라 같은 순간에 도는 수집기는 언제나 하나입니다. 합산 부하를 걱정할 필요가 없습니다.
* 미국 시각은 서머타임에 따라 1시간 이동합니다. 하드코딩하지 말고 /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/statusopenfalse 면 갱신할 것이 없습니다. 수집기도 함께 멈추므로 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 경계 토스 /candlesbefore 가 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 설계가 충돌하지 않게 미리 자리를 잡아둔 것입니다.