이 글의 응답 예시 값(금액, 종목명, 거래량 등)은 전부 가짜 값입니다. 실제 계좌 데이터가 아닙니다.
인증과 URL 문제를 해결하고 나니, 이번엔 응답 자체가 예상과 다른 문제들이 이어졌습니다. 문서에 스키마가 없으니, 모든 걸 실제 호출로 확인해야 했습니다.
보유종목 API를 호출한 결과를 배열로 파싱하려고 했는데 실패했습니다.
Cannot deserialize value of type `HoldingDto[]` from Object value (token JsonToken.START_OBJECT)
실제 응답을 원문으로 확인해보니, result가 배열이 아니라 계좌 집계 + 종목 배열을 함께 담은 객체였습니다.
{
"result": {
"totalPurchaseAmount": { "krw": "100000", "usd": "..." },
"marketValue": { "amount": { "krw": "96000" } },
"dailyProfitLoss": { "amount": { "krw": "-250" }, "rate": "-0.0026" },
"items": [
{ "symbol": "000000", "name": "샘플 종목", "marketCountry": "KR", "quantity": "2", "lastPrice": "48000", ... }
]
}
}
시세 조회(/prices)나 계좌 목록(/accounts) 응답은 result가 배열이었는데, 보유종목만 유독 객체 안에 배열이 들어있는 구조였습니다. 문서 없이 추측했다면 계속 배열이라고 가정했을 지점입니다.
이 사실을 알고 나니 오히려 구조를 더 단순하게 만들 수 있었습니다. 계좌 요약(총 평가금액, 당일 손익)을 종목별 시세를 일일이 다시 조회해서 합산하던 방식에서, 이 응답 하나에 이미 들어있는 집계값을 그대로 읽는 방식으로 바꿨습니다. API 호출이 줄어드는 부수 효과까지 얻었습니다.
캔들 차트를 실 데이터로 확인해보니, 날짜가 실제보다 하루씩 이르게 표시되고 있었습니다.
원인은 타임스탬프 형식과 Jackson의 기본 동작이 만난 지점이었습니다. 캔들 응답의 timestamp는 이런 형태였습니다.
"timestamp": "2026-06-08T00:00:00.000+09:00"
자정 + KST(+09:00) 오프셋입니다. 이걸 Instant나 OffsetDateTime으로 자동 바인딩하면, Jackson의 기본 설정인 ADJUST_DATES_TO_CONTEXT_TIME_ZONE이 이 값을 UTC로 정규화합니다. 2026-06-08T00:00+09:00은 UTC로 2026-06-07T15:00이 되고, 여기서 날짜만 뽑으면 6월 7일이 되어버립니다. 한국 시간 기준 거래일이었던 6월 8일이 통째로 하루 밀리는 겁니다.
해결은 타임스탬프를 자동 바인딩하지 않고, 문자열로 받아서 오프셋을 보존한 채로 직접 파싱하는 것이었습니다.
record CandleDto(
String timestamp, // Instant/OffsetDateTime으로 자동 바인딩하지 않고 문자열로 받음
BigDecimal openPrice,
BigDecimal highPrice,
BigDecimal lowPrice,
BigDecimal closePrice,
long volume,
String currency) {
/**
* API가 보낸 오프셋 그대로 거래일을 읽는다.
* OffsetDateTime으로 자동 바인딩하면 Jackson의 ADJUST_DATES_TO_CONTEXT_TIME_ZONE이
* 자정+KST 타임스탬프를 UTC로 정규화해서, 모든 한국 캔들이 전날로 밀린다.
*/
LocalDate tradingDate() {
return OffsetDateTime.parse(timestamp).toLocalDate();
}
}
이 버그는 테스트를 먼저 작성해서 잡았습니다. 캔들 3개를 오름차순으로 정렬했을 때 날짜가 기대한 순서로 나오는지 검증하는 테스트를 짜는 과정에서, 실제 날짜가 하루씩 어긋나 있는 걸 발견했습니다.
시세 조회 응답을 실제로 받아보고 알게 된 사실이 있습니다.
{ "result": [{ "symbol": "000000", "timestamp": "...", "lastPrice": "48000", "currency": "KRW" }] }
마지막 체결가만 줍니다. 등락률, 거래량, 52주 고저가 전혀 없습니다. 이건 알림 조건 6개 중 4개(등락률, 거래량 급증, 52주 신고가/신저가)가 실연동 모드에서 쓸 데이터가 없다는 뜻이었습니다.
전용 엔드포인트를 문서에서 찾지 못한 상태라, 이미 확인된 캔들 API(고가/저가/거래량/종가)로 부족한 값을 직접 계산하기로 했습니다.
static QuoteEnrichment computeEnrichment(List<Candle> candles, BigDecimal lastPrice, LocalDate quoteDate) {
if (candles.isEmpty()) {
return QuoteEnrichment.EMPTY;
}
// 52주 고저 = 받아온 캔들 히스토리 전체의 최댓값/최솟값, 오늘 현재가도 후보에 포함
BigDecimal week52High = candles.stream().map(Candle::high).max(Comparator.naturalOrder()).get().max(lastPrice);
BigDecimal week52Low = candles.stream().map(Candle::low).min(Comparator.naturalOrder()).get().min(lastPrice);
// 등락률 = 마지막 체결가 vs 직전 거래일 종가
List<Candle> priorDays = candles.stream().filter(c -> c.date().isBefore(quoteDate)).toList();
BigDecimal changeRate = null;
if (!priorDays.isEmpty()) {
BigDecimal previousClose = priorDays.get(priorDays.size() - 1).close();
if (previousClose.signum() != 0) {
changeRate = lastPrice.subtract(previousClose)
.divide(previousClose, 6, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100))
.setScale(2, RoundingMode.HALF_UP);
}
}
// 거래량 급증 = 가장 최근 캔들 거래량 vs 그 이전 20일 평균
// (날짜가 아니라 "마지막 vs 나머지" 구조로 판단 — 최신 캔들이 자기 자신을
// 평균에 중복으로 반영하지 않도록)
long volume = candles.get(candles.size() - 1).volume();
List<Candle> volumeBaseline = candles.size() > 1 ? candles.subList(0, candles.size() - 1) : List.of();
// ... 이전 20일 평균 계산
...
}
이 파생 계산에는 한계가 명확합니다. 그리고 그 한계를 과장하지 않고 그대로 문서화해뒀습니다.
count 파라미터를 받지 않아서 실제로는 API가 기본으로 주는 히스토리만큼입니다. 정확히 252거래일을 보장하지 않습니다.이 함수는 캔들 API를 호출해서 계산하는데, 시세 조회마다 매번 캔들을 다시 받아오면 API 호출이 배로 늘어납니다. 그래서 캔들 히스토리는 심볼당 30분 캐싱했습니다 — 하루 단위로 바뀌는 데이터라 60초 폴링마다 새로 받아올 이유가 없기 때문입니다. 캔들 조회가 실패해도 시세 조회 자체는 계속 동작하도록, 실패 시 빈 값으로 감싸서 반환하게 만들었습니다.
이 두 편(5~6편)을 거쳐, 목표가 조건은 원본 데이터로, 나머지 4개 조건은 캔들 기반 파생값으로 실연동 모드에서 전부 동작하게 됐습니다.
| 조건 | Mock | 실연동 |
|---|---|---|
| 목표가 이상/이하 | ✅ | ✅ (원본 데이터) |
| 등락률 | ✅ | ✅ (파생) |
| 거래량 급증 | ✅ | ✅ (파생) |
| 52주 신고가/신저가 근접 | ✅ | ✅ (파생) |
다음 편부터는 기능이 아니라 서비스 품질을 개선한 이야기입니다. 시세를 종목마다 개별 조회하던 구조를 배치로 바꾼 과정과, 같은 조건이 계속 충족될 때 알림이 반복해서 오던 문제를 다룹니다.