실제 주식 데이터로 차트 예측 게임 만들기 — 2편: 게임 로직 설계
목차
가짜 차트로는 재미가 안 났다
차트 예측 게임을 만든다고 했을 때, 가장 먼저 결정해야 할 문제는 **"어떤 데이터를 보여줄 것인가"**였습니다.
크게 두 가지 선택지가 있었습니다. 하나는 랜덤으로 생성한 가짜 차트, 다른 하나는 실제 주식 데이터입니다. 랜덤 차트가 구현은 훨씬 간단하지만, 금방 패턴이 반복된다는 느낌을 줄 수 있습니다. 무엇보다, "이 차트 어디서 본 것 같은데?" 하는 현실감이 게임의 몰입도를 높여줄 거라 판단했습니다.
결국 삼성전자, SK하이닉스, LG에너지솔루션, 삼성바이오로직스, 현대자동차 — 한국 대표 블루칩 5종목의 실제 거래 데이터를 게임에 넣기로 했습니다. 각 종목별로 약 250거래일 분량의 OHLCV(시가·고가·저가·종가·거래량) 데이터를 JSON 파일로 준비했습니다.
2. 데이터 파이프라인: 원본에서 게임 시나리오까지
원본 데이터 구조
준비한 JSON 데이터는 이런 형태입니다:
원본 데이터 형태:
- 날짜 (Date)
- 시가 (Open)
- 고가 (High)
- 저가 (Low)
- 종가 (Close)
- 거래량 (Volume)
이 원본 데이터를 게임에서 바로 쓸 수는 없습니다. 차트 라이브러리가 요구하는 형식으로 변환하고, 게임 한 판에 필요한 100개 캔들을 추출해야 합니다.
시나리오 생성 프로세스
게임 시나리오를 만드는 과정은 크게 4단계로 나뉩니다:
[시나리오 생성 흐름]
1. 종목 선택
→ 5개 종목 중 하나를 랜덤으로 선택
2. 구간 추출
→ 250개 데이터 중 연속된 100개를 랜덤 시작점에서 추출
→ 시작점 범위: 0 ~ (전체 길이 - 100)
3. 데이터 변환
→ 날짜 문자열을 Unix 타임스탬프로 변환
→ 가격 데이터를 숫자형으로 파싱
→ 차트 라이브러리가 이해하는 캔들 포맷으로 매핑
4. 정답 판정
→ 90번째 캔들의 종가 vs 100번째 캔들의 종가 비교
→ 올랐으면 'up', 내렸으면 'down'으로 정답 설정
여기서 가장 중요한 건 4번 정답 판정 로직입니다.
정답은 어떻게 결정하는가
플레이어에게는 처음 90개의 캔들만 보여주고, 나머지 10개를 맞히라고 합니다. 그런데 "오른다" 또는 "내린다"의 기준이 뭘까요?
처음에는 단순히 "91번째 캔들이 90번째보다 높으면 상승"으로 판정하려고 했습니다. 하지만 이렇게 하면 문제가 있습니다. 하루치 등락은 노이즈가 너무 크기 때문입니다. 90번째 캔들에서 소폭 빠졌다가 이후 크게 반등하는 케이스에서, 플레이어는 "올랐다"고 느끼지만 시스템은 "틀렸다"고 판정하는 상황이 생깁니다.
그래서 10거래일의 시작과 끝을 기준으로 잡았습니다. 90번째 캔들(마지막으로 보이는 캔들)의 종가와 100번째 캔들의 종가를 비교해, 그 사이의 방향을 정답으로 삼았습니다. 중간에 어떻게 출렁였는지는 보지 않습니다.
const visibleLastClose = candles[VISIBLE_CANDLES - 1].close; // 89번 인덱스
const finalClose = candles[SCENARIO_CANDLE_COUNT - 1].close; // 99번 인덱스
const actualResult: 'up' | 'down' = finalClose > visibleLastClose ? 'up' : 'down';두 종가가 정확히 같으면 어떻게 하나
이 코드를 쓰고 나서야 빠뜨린 경우가 하나 보였습니다. 두 종가가 정확히 같은 경우입니다.
위 삼항 연산자대로면 같을 때 down으로 떨어집니다. 오르지도 내리지도 않았는데 LONG을 고른 사람만 틀리는 겁니다. 확률은 낮지만 한국 주식은 호가 단위가 정해져 있어서 생각만큼 드물지도 않습니다. 그리고 이런 판이 한 번이라도 나오면 플레이어 입장에서는 그냥 게임이 이상한 겁니다.
무승부 처리를 넣을 수도 있었지만, 하트가 두 개뿐인 게임에서 "이번 판은 무효입니다" 화면이 뜨는 건 흐름을 끊습니다. 그래서 아예 그런 시나리오를 뽑지 않기로 했습니다.
const MAX_RETRY = 10;
// 같은 값이면 새 시나리오로 재생성 (무한 루프 방지: 최대 10회)
if (finalClose === visibleLastClose && retryCount < MAX_RETRY) {
return generateRandomScenario(excludeId, retryCount + 1);
}재귀로 다시 뽑되 최대 10회로 막았습니다. 이론상 열 번 연속 같은 값이 나올 확률은 무시할 만하고, 상한이 없으면 데이터가 잘못됐을 때 스택이 터집니다. 재시도 로직에는 항상 상한을 둔다는 건 이런 데서 습관이 됩니다.
같은 종목이 연속으로 나오지 않게
또 하나 넣은 게 excludeId입니다. 다음 라운드 시나리오를 뽑을 때 직전 종목을 후보에서 뺍니다.
const availableStocks = excludeId
? stocks.filter((s) => s.id !== excludeId)
: stocks;종목이 다섯 개뿐이라 순수 랜덤으로 두면 삼성전자가 세 판 연속 나오는 일이 꽤 자주 생깁니다. 확률적으로는 정상인데 플레이어는 "이거 랜덤 맞아?"라고 느낍니다. 사람이 체감하는 무작위성과 실제 무작위성은 다르다는 걸 여기서도 확인했습니다.
3. 점수 시스템 설계
기본 점수 + 콤보 보너스
점수 체계는 심플하면서도 중독성을 줘야 했습니다. 최종 설계는 이렇습니다:
점수 계산:
기본 점수 = 100
콤보 보너스 = (콤보 횟수 / 3)의 내림값 × 50
최종 점수 = 기본 점수 + 콤보 보너스
예시:
1연속 정답: 100 + 0 = 100점
2연속 정답: 100 + 0 = 100점
3연속 정답: 100 + 50 = 150점
6연속 정답: 100 + 100 = 200점
9연속 정답: 100 + 150 = 250점
3콤보마다 보너스가 붙는 구조로 한 이유가 있습니다. 매 라운드마다 보너스가 1씩 증가하면, 점수 인플레이션이 너무 빠르게 일어납니다. 3라운드에 한 번씩 보상이 주어지면 "다음 보너스까지 버텨야지" 하는 소소한 목표 의식이 생기고, 3연속 정답의 성취감이 확실히 커집니다.
왜 2개의 하트인가
하트 수를 결정하는 건 생각보다 고민이 많았습니다.
- 하트 1개: 긴장감은 최고지만, 첫 실수에서 바로 게임 오버. 이탈률이 높아질 위험
- 하트 3개: 여유로운 대신 긴장감이 떨어짐. "한두 번 틀려도 돼"라는 안일함
- 하트 2개: 한 번의 실수는 허용하되, 두 번째 실수부터는 조심해야 하는 적절한 긴장감
실제로 테스트해보니 하트 2개일 때 "아, 한 번 틀렸으니까 이번엔 신중하게 봐야지" 하는 심리가 자연스럽게 작동했습니다. 게임 디자인에서 말하는 **"허용 가능한 실패(tolerable failure)"**를 정확히 1번으로 설정한 셈입니다.
4. 결과 공개: 선형 회귀 트렌드라인
예측 결과를 공개할 때, 단순히 나머지 10개 캔들만 보여주면 밋밋합니다. 그래서 선형 회귀(Linear Regression) 트렌드라인을 추가했습니다.
트렌드라인의 역할
결과 화면에서 10개 캔들 위에 노란색 직선이 하나 그려집니다. 이 직선은 "이 10개 데이터를 가장 잘 대표하는 추세선"입니다. 선이 우상향이면 상승 추세, 우하향이면 하락 추세를 한눈에 파악할 수 있습니다.
플레이어 입장에서는 "아, 내가 예측한 방향이 맞았구나" 또는 "확실히 반대로 갔네" 하는 시각적 피드백을 받게 됩니다. 이 한 줄의 트렌드라인이 결과에 대한 납득감을 크게 높여줍니다.
구현에서의 챌린지: 부동소수점 정밀도
선형 회귀 자체는 고등학교 수학 수준의 알고리즘입니다. 하지만 여기서 까다로운 문제가 하나 있었습니다 — 부동소수점 정밀도 문제입니다.
주식 가격은 보통 수만~수십만 원 단위입니다. 이 값들을 그대로 곱하고 합산하면 JavaScript의 부동소수점 연산에서 오차가 누적됩니다. 특히 sumXY(x와 y의 곱의 합)를 계산할 때, 숫자가 매우 커져서 정밀도가 떨어지는 현상이 발생했습니다.
문제 상황:
가격 데이터: [52300, 52500, 52100, ...]
sumXY 계산 시: 0 × 52300 + 1 × 52500 + 2 × 52100 + ...
→ 중간 계산 값이 매우 커져서 부동소수점 오차 발생
→ 기울기(slope)가 실제와 미세하게 달라짐
→ 트렌드라인이 데이터와 어긋나 보이는 현상
해결 방법은 스케일링이었습니다:
해결 방법:
1. 모든 가격을 SCALE(1억)로 나눠서 0~1 범위의 작은 수로 변환
2. 작은 수로 선형 회귀 계산 수행
3. 결과 기울기와 절편을 다시 SCALE로 복원
이렇게 하면 중간 계산값이 작아져서
부동소수점 오차가 실질적으로 무시할 수준이 됨
금융 데이터를 다루는 프론트엔드에서는 꽤 흔하게 마주치는 문제인데, 처음 겪으면 "왜 계산이 안 맞지?" 하며 한참을 헤맬 수 있는 함정입니다.
5. 데이터 설계에서 배운 것들
클라이언트 사이드 완결 구조의 장점
이 게임은 백엔드 서버가 없습니다. 모든 데이터가 빌드 타임에 번들에 포함되고, 게임 로직도 전부 클라이언트에서 실행됩니다.
이 구조의 장점은 명확합니다:
- 응답 속도: 네트워크 지연 없이 즉시 다음 시나리오를 생성할 수 있습니다
- 서버 비용 제로: 사이드 프로젝트에서 이건 정말 큰 장점입니다
- 오프라인 가능: 한 번 로딩되면 네트워크 없이도 플레이 가능합니다
물론 단점도 있습니다. 주식 데이터가 정적이기 때문에 무한히 플레이하면 같은 차트가 반복될 수 있습니다. 하지만 5종목 × 약 150개의 가능한 시작점을 조합하면 750가지 이상의 시나리오가 나오고, 캐주얼 게임으로서는 충분한 양입니다.
시나리오 ID로 중복 방지
각 시나리오에는 종목코드_시작인덱스_타임스탬프 형식의 고유 ID를 부여했습니다. 현재는 중복 체크를 엄격하게 하지 않지만, 향후 "이전에 나온 시나리오 제외" 같은 기능을 추가할 때 이 ID가 유용할 것입니다.
시나리오는 몇 판이나 나올까
데이터를 정적으로 박아 넣기로 했으니, 시나리오가 몇 가지나 나오는지 계산해 볼 필요가 있었습니다.
종목은 5개, 종목당 약 250거래일 분량입니다. 한 판에 연속된 100개 캔들을 쓰니 시작점은 0부터 150까지 잡을 수 있습니다.
5종목 × 151개 시작점 ≈ 755가지
755판. 캐주얼 게임으로는 충분해 보이지만, 여기엔 함정이 있습니다. 시작점이 한 칸 차이 나는 두 시나리오는 사실상 같은 차트입니다. 100개 중 99개가 겹치니까요. 사람 눈에 확실히 다른 차트로 느껴지려면 시작점이 수십 칸은 벌어져야 합니다.
그 기준으로 다시 세면 종목당 대여섯 개, 전체로는 서른 개 안팎이 "체감상 다른 차트"입니다. 열 판쯤 하면 슬슬 겹치기 시작합니다.
이걸 알고 나서 두 가지 중 하나를 골라야 했습니다. 데이터를 더 넣거나, 겹쳐도 괜찮게 만들거나. 후자를 택했습니다.
이유는 이 게임이 차트를 외워서 푸는 게임이 아니기 때문입니다. 같은 구간이 다시 나와도 90개 캔들만 보고 방향을 판단하는 일 자체는 똑같이 어렵습니다. 오히려 "어? 이거 아까 틀렸던 그 차트인데" 하면서 이번엔 맞히는 경험은 나쁘지 않습니다. 학습이 일어나는 순간이니까요.
대신 연속으로 같은 종목이 나오지 않게 하는 것만 챙겼습니다. 앞서 excludeId로 처리한 부분입니다. 사람은 "같은 차트가 또 나왔다"보다 "삼성전자만 세 번 나왔다"를 훨씬 먼저 알아챕니다. 반복 자체보다 반복이 눈에 띄는 방식을 관리하는 게 실질적이었습니다.
데이터를 늘리는 건 언제든 할 수 있습니다. JSON 파일 하나만 더 넣으면 되니까요. 다만 번들 크기가 늘어나고 첫 로딩이 느려집니다. 토스 앱 안에서 열리는 미니앱에서 그 대가가 755판의 다양성보다 크다고 보기는 어려웠습니다.
이런 판단은 데이터 양을 늘리는 것보다 훨씬 싸게 먹힙니다. 체감을 관리하는 것과 실체를 관리하는 것 중 어느 쪽이 비용 대비 효과가 큰지 따져 보는 게, 리소스가 한정된 사이드 프로젝트에서는 거의 항상 먼저 할 일이었습니다.
뒷이야기: 웹으로 다시 만들면서 판정을 바꿨다
이 글을 쓴 뒤에 같은 게임을 웹 버전(Chart God Challenge)으로 다시 만들었는데, 그때 판정 방식을 통째로 바꿨습니다.
앱인토스 버전은 위에서 설명한 대로 90번 종가와 100번 종가를 비교합니다. 회귀 추세선은 결과 화면에 그려 주기만 할 뿐 판정에는 관여하지 않습니다. 즉 추세선은 설명용이고 판정은 두 점 비교입니다.
웹 버전에서는 이 관계를 뒤집었습니다. 회귀선의 기울기 부호가 곧 정답이 됩니다.
두 방식은 대부분의 판에서 같은 결과를 냅니다. 갈리는 건 이런 경우입니다. 10일 내내 우하향하다가 마지막 하루에 크게 반등해서 시작점보다 살짝 위에서 끝나는 구간. 두 점 비교로는 상승이고, 회귀선으로는 하락입니다. 플레이어가 "이건 누가 봐도 내리는 차트인데"라고 느끼는 판이 바로 이런 판입니다.
어느 쪽이 맞다기보다 묻는 질문이 다릅니다.
- 두 점 비교 → "열흘 뒤에 지금보다 위에 있을까?" (실제 매매에 가까움)
- 회귀선 → "지금 이 구간이 어느 방향으로 가고 있나?" (추세 판독에 가까움)
앱인토스 버전은 토스 증권 사용자를 염두에 뒀으니 "열흘 뒤 수익이 났나"가 더 자연스러웠고, 웹 버전은 차트 읽는 연습이 목적이라 추세 쪽이 맞았습니다. 같은 게임인데 어디에 놓이느냐에 따라 규칙이 달라져야 한다는 걸, 두 번째로 만들면서 알았습니다.
데이터를 정적으로 박아 넣는 구조에는 유효기간도 있습니다. 지금 들어 있는 건 특정 시점까지의 거래 데이터라 시간이 지날수록 "옛날 차트"가 됩니다. 게임 자체는 시점을 숨기니 문제가 없지만, 최근 몇 년의 시장 성격이 반영되지 않는다는 한계는 남습니다. 데이터를 갱신하려면 앱을 새로 빌드해서 심사를 다시 받아야 하는데, 그게 정적 번들 구조의 진짜 비용입니다.
정리
이번 편에서는 게임의 핵심 로직을 설계하는 과정을 다뤘습니다. 요약하면:
- 실제 한국 블루칩 5종목의 데이터를 사용해 현실감을 높임
- 10개 캔들의 전체 추세를 기준으로 정답을 판정해 체감과 시스템의 괴리를 줄임
- 3콤보마다 보너스가 붙는 점수 체계로 단기 목표 의식을 설계
- 선형 회귀 트렌드라인을 추가해 결과의 시각적 납득감을 높임
- 부동소수점 정밀도 문제를 스케일링으로 해결
게임 로직은 코드 양 자체는 많지 않지만, "왜 이렇게 설계했는가"에 대한 고민이 결과물의 품질을 크게 좌우했습니다.
다음 편에서는 이 로직들을 어떤 아키텍처로 상태 관리했는지 이야기하겠습니다. Zustand의 선택적 구독 패턴을 활용해 게임의 4가지 페이즈를 깔끔하게 관리한 방법을 다룹니다.