Next.js 16과 Zustand로 '신의 두뇌' 트레이딩 게임을 만든 후기 (feat. 부동소수점의 악몽)
목차
감이 실력인지 확인할 방법이 없다
주식이나 코인을 하다 보면 누구나 한 번쯤 이런 생각을 합니다. "아, 이거 딱 보면 오를 자리인데?" 하지만 그 순간의 판단이 정말 실력인지, 아니면 운이었는지 검증해볼 기회는 많지 않습니다. 실전 매매는 돈이 걸려있어 냉정해지기 어렵고, 모의 투자는 너무 느리니까요.
**"Chart God Challenge"**는 이런 단순한 호기심에서 출발했습니다. 24시간 365일 쉬지 않고 돌아가는 암호화폐 시장의 방대한 데이터(OHLCV)를 활용해, 과거의 특정 시점으로 돌아가 **"다음 캔들이 오를까, 내릴까?"**를 맞히는 초단기 트레이딩 게임입니다.
단순한 토이 프로젝트로 시작했지만, 10년 차 개발자로서의 자존심을 걸고 **'금융 데이터의 정합성'**과 '아케이드 게임의 타격감' 두 마리 토끼를 모두 잡기 위해 꽤나 치열하게 고민했습니다. 오늘은 그 개발 과정에서 마주친 기술적 난관들을 이야기해보려 합니다.
보조지표를 전부 걷어낸 이유
이 서비스의 핵심은 복잡한 보조지표를 다 걷어내고, 오직 **'가격의 흐름(Price Action)'**에만 집중하게 만드는 것입니다.
🕹️ 아케이드 감성의 트레이딩
사용자는 2개의 생명(Hearts)을 가지고 게임을 시작합니다. 정답을 맞히면 콤보(Combo)가 쌓이고, 점수 획득 속도가 빨라집니다. 반면 틀리면? 가차 없이 생명이 깎이고 'Game Over' 화면을 마주하게 되죠. 이러한 로그라이크(Roguelike) 요소는 사용자가 매 순간 신중하게 차트를 분석하게 만드는 긴장감을 줍니다.
🎨 레트로 사이버펑크 디자인
금융 앱은 항상 딱딱해야 할까요? 저는 오히려 정반대로 접근했습니다. 8-bit 폰트와 네온 컬러, 그리고 글리치(Glitch) 효과를 사용하여 '사이버펑크 카지노' 같은 분위기를 연출했습니다. 차트 라이브러리인 TradingView의 Lightweight Charts조차 커스텀 테마를 적용해 이질감 없이 녹여냈습니다.
무엇으로 만들었나
안정적인 서비스 운영과 빠른 인터랙션을 위해 다음과 같은 스택을 선정했습니다.
- Next.js 16 (App Router): 초기 로딩 속도와 SEO가 중요했습니다. 특히 16 버전의 향상된 Server Component 패턴을 활용해, 무거운 차트 데이터 로딩 로직을 효율적으로 처리하고자 했습니다.
- TypeScript: 금융 데이터를 다루는 만큼
number타입의 모호함을 피하고, 엄격한 타입 추론이 필수적이었습니다. - Zustand: Redux는 너무 무겁고, Context API는 렌더링 최적화가 까다롭습니다. 게임 특성상 점수, 콤보, 생명 등 빈번하게 바뀌는 상태를 Transient Update 패턴으로 처리하기 위해 가볍고 직관적인 Zustand를 선택했습니다.
- Lightweight Charts: Canvas 기반으로 수천 개의 캔들 데이터를 끊김 없이 렌더링할 수 있는 유일한 대안이었습니다.
처음 만든 판정 규칙은 틀렸다
첫 버전의 판정은 단순했습니다. 가려진 구간의 마지막 캔들 종가가 예측 시점보다 높으면 LONG 정답, 낮으면 SHORT 정답. 코드도 한 줄이면 끝났습니다.
문제는 직접 스무 판쯤 돌려 보고 드러났습니다. 방향을 제대로 읽었는데 지는 판이 계속 나왔습니다. 열 개 캔들 중 아홉 개가 우상향인데 마지막 하나가 크게 음봉으로 닫히면 오답이 됩니다. 반대로 계속 흘러내리다가 마지막에 한 번 튀어 오르면 정답이 됩니다. 이건 차트를 읽는 능력을 재는 게 아니라 마지막 캔들 하나를 찍는 도박이었습니다.
게임이 묻는 질문 자체가 잘못돼 있었던 겁니다. 제가 알고 싶었던 건 "다음 캔들이 뭘까"가 아니라 "이 구간이 어느 방향으로 가고 있나"였습니다. 그래서 판정 대상을 점 하나에서 구간 전체로 옮겼습니다. 가려진 다음 10개 캔들에 선형 회귀를 돌려 그 기울기의 부호로 판정하는 방식입니다. 중간 등락은 회귀선이 알아서 흡수하고, 남는 건 방향뿐입니다.
바꾸고 나니 억울한 판이 사라졌습니다. 대신 새로운 문제가 생겼습니다.
그런데 자바스크립트(JS)의 number 타입은 IEEE 754 부동소수점 방식을 따르기 때문에, 미세한 계산 오차가 발생합니다. 비트코인처럼 가격 단위가 큰($100,000) 자산을 다룰 때, 소수점 아래의 미세한 오차가 누적되면 상승 추세가 하락으로 판정되는 치명적인 버그가 발생할 수 있습니다.
// 실수(Float) 연산의 위험성
0.1 + 0.2 // 0.30000000000000004이 문제를 해결하기 위해 저는 모든 가격 데이터를 **정수(Integer)로 스케일링(Scaling)**하여 처리하는 방식을 도입했습니다.
해결책: BigInt와 Scaling 기법 도입
가격 데이터에 10^8을 곱해 정수로 만들고, 최신 JS 문법인 BigInt를 활용해 연산했습니다. 이를 통해 오버플로우 걱정 없이 정확한 추세선을 계산할 수 있었습니다.
// 실제 프로젝트 코드 예시 (축약)
const SCALE = 100_000_000; // 10^8
// 모든 가격을 정수로 변환하여 합산
let sumY = 0;
for (let x = 0; x < n; x++) {
const yRaw = nextCandles[x];
const yScaled = Math.round(yRaw * SCALE); // 소수점 제거
sumY += yScaled;
}
// 분자(Numerator) 계산 시 BigInt 사용
const bn_n = BigInt(n);
const bn_sumXY = BigInt(sumXY);
// ... (중략)
const numerator = (bn_n * bn_sumXY) - (bn_sumX * bn_sumY);
const isUp = numerator > 0n; // 0과 비교하여 정확한 상승/하락 판정다만 여기서 솔직하게 짚고 넘어갈 부분이 있습니다. BigInt가 보장하는 건 기울기의 부호까지입니다. 승패는 numerator > 0n 하나로 갈리니 이 판정에는 오차가 개입할 수 없습니다. 하지만 결과 화면에 추세선을 그리고 손익률을 표시하려면 기울기와 절편의 실제 값이 필요하고, 그 순간 Number()로 되돌립니다.
const isUp = numerator > 0n; // 승패: 정수 비교, 오차 없음
const m_val = Number(numerator) / Number(denominator); // 표시용: 여기서 실수로 복귀
const b_val = (Number(bn_sumY) - m_val * Number(bn_sumX)) / Number(bn_n);
const regressionEndPrice = ((m_val * 9) + b_val) / SCALE;
const profitPercent = ((regressionEndPrice - currentCandle.close) / currentCandle.close) * 100;즉 화면에 뜨는 +2.34%는 소수점 끝자리에 미세한 오차가 있을 수 있습니다. 승패에는 영향을 주지 않고, 표시용 숫자를 위해 BigInt 나눗셈을 직접 구현하는 건 이 게임에 과한 비용이라 여기서 멈췄습니다. 어디까지 정확해야 하는지를 정하는 것도 설계라는 걸 이때 배웠습니다.
참고로 점수는 콤보에 비례해서 붙습니다. 기본 100점에 콤보 한 번당 10%씩 가산되는 Math.round(100 * (1 + combo * 0.1)) 구조라, 연속으로 맞힐수록 한 판의 무게가 커집니다. 생명은 두 개뿐이라 여덟 콤보쯤 쌓이면 손이 떨립니다.
정답을 어떻게 숨길 것인가
기술적으로 가장 오래 붙잡고 있었던 건 부동소수점이 아니라 커닝 방지였습니다.
실제로 있었던 과거 구간을 쓰는 게 이 게임의 전제입니다. 그런데 실제 데이터를 쓴다는 건 정답이 세상에 이미 공개돼 있다는 뜻이기도 합니다. 차트에 2021년 5월 19일이라고 찍혀 있으면 검색 한 번으로 다음에 무슨 일이 있었는지 알 수 있습니다. 게임이 성립하지 않습니다.
날짜 축을 화면에서 지우는 건 쉽습니다. 문제는 개발자 도구입니다. 네트워크 탭을 열면 서버가 내려준 JSON에 유닉스 타임스탬프가 그대로 들어 있습니다.
그래서 서버에서 데이터를 내려줄 때 모든 타임스탬프를 통째로 이동시켰습니다. 첫 캔들이 2000년 1월 1일에서 시작하도록 오프셋을 계산해 전부 더합니다.
const FAKE_START_TIME = 946684800; // 2000-01-01 00:00:00 UTC
const realStartTime = Number(scenario.data[0].time);
const timeOffset = FAKE_START_TIME - realStartTime;
const obfuscatedData = scenario.data.map((candle) => ({
...candle,
time: Number(candle.time) + timeOffset
}));캔들 사이의 간격은 그대로 유지되니 차트 모양은 조금도 달라지지 않습니다. 절대 시점만 사라집니다. 응답을 뜯어봐도 2000년 어느 날의 데이터로 보일 뿐이고, 가격대로 시점을 역추적하는 건 가능하지만 그 정도 수고를 들일 사람이면 그냥 즐기라고 두기로 했습니다.
시나리오 선택도 매 요청마다 무작위여야 해서 라우트에 export const dynamic = 'force-dynamic'을 걸었습니다. 이걸 빼먹으면 Next.js가 응답을 정적으로 캐싱해서 모든 사용자가 같은 차트를 보게 됩니다. 배포하고 나서야 알아챘습니다.
여섯 판마다 광고를 띄웠다가 되돌린 이야기
수익화도 실험 대상이었습니다. 처음 넣은 건 전면 광고였습니다. 여섯 판을 끝낼 때마다 화면 전체를 덮는 광고를 띄우고, 닫아야 다음 차트로 넘어가는 구조였습니다.
// if (totalRoundsPlayed > 0 && totalRoundsPlayed % 6 === 0) {
// set({ isAdOpen: true });
// return;
// }지금 코드에는 이 블록이 주석으로 남아 있습니다. 지웠기 때문입니다.
이유는 단순합니다. 이 게임의 재미는 한 판이 20초 안에 끝나고 바로 다음 판으로 넘어가는 리듬에 있습니다. 콤보가 쌓일수록 점수 배율이 올라가니 연속으로 몰아치게 되는데, 여섯 판째마다 전면 광고가 그 흐름을 끊었습니다. 저 자신이 테스트하면서 짜증이 났고, 그 지점에서 그만두게 됐습니다. 광고 수익보다 이탈이 더 비싼 구조였던 겁니다.
그래서 상단 320×50, 하단 320×100 배너 두 개만 고정으로 두고 전면 광고는 전부 걷어냈습니다. 광고가 게임 화면을 가리지 않고 항상 같은 자리에 있으니 시야에서 사라집니다. 수익은 줄었지만 한 세션당 플레이 판수가 늘어서 결과적으로 손해는 아니었습니다.
생명을 다 쓰면 나오는 부활 기능도 광고와 묶을 수 있었지만 그렇게 하지 않았습니다. revive()는 그냥 생명 하나를 돌려주고 끝냅니다. 부활에 광고를 걸면 가장 아쉬운 순간, 그러니까 최고 콤보에서 죽은 직후를 인질로 잡는 셈인데 그건 만들고 싶은 게임이 아니었습니다.
주소창이 떴다 사라질 때마다 화면이 깨졌다
모바일 사파리(iOS)에서 주소창(Address Bar)이 떴다 사라질 때마다 화면 영역이 바뀌어 게임 UI가 깨지는 문제가 있었습니다. 특히 하단에 고정된 컨트롤러가 잘리는 현상은 사용자 경험(UX)에 치명적이었습니다.
이를 해결하기 위해 CSS의 표준 단위인 100vh 대신, 동적 뷰포트 높이인 **100dvh**와 100svh를 적절히 섞어 사용했습니다. 또한, ResizeObserver를 활용해 차트 컨테이너의 크기 변화를 실시간으로 감지하고 차트 크기를 동기화하여, 어떤 디바이스에서도 꽉 찬 화면을 유지하도록 구현했습니다.
시나리오가 바닥나지 않게
한 가지 더 신경 쓴 건 같은 차트가 반복되지 않는 것이었습니다. 시나리오 풀이 작으면 몇 판 안 가서 "아 이거 봤는데" 하는 순간이 오고, 그 순간 게임은 끝납니다.
그래서 시나리오를 통째로 저장해 두는 대신 긴 시계열에서 연속 구간을 무작위로 잘라내는 방식을 썼습니다. 같은 데이터라도 시작점을 한 칸 옮기면 완전히 다른 차트가 됩니다. 저장하는 데이터 양은 그대로인데 뽑을 수 있는 경우의 수는 구간 길이만큼 늘어납니다.
여기에 요청마다 무작위 선택이 새로 일어나도록 라우트를 동적으로 잡아 뒀으니, 실질적으로는 같은 차트를 두 번 만나기가 더 어렵습니다.
아직 못 막은 것
타임스탬프는 가렸지만 더 근본적인 구멍이 하나 남아 있습니다. 지금 구조에서는 서버가 시나리오 전체를 한 번에 내려주고 클라이언트가 뒷부분을 가려서 보여줄 뿐입니다. 네트워크 응답에는 정답에 해당하는 미래 캔들이 그대로 들어 있습니다.
제대로 막으려면 이렇게 해야 합니다.
- 최초 응답에는 가려진 지점까지만 내려준다
- 사용자가 LONG/SHORT를 고르면 그때 별도 엔드포인트로 정답을 검증한다
- 판정은 서버에서 하고 클라이언트에는 결과만 보낸다
알면서도 안 했습니다. MVP를 빨리 띄우는 게 우선이었고, 순위표도 보상도 없는 게임이라 커닝의 동기가 약하다고 판단했습니다. 코드에도 주석으로 남겨 뒀습니다. 다만 이런 판단은 시간이 지나면 잊히고 "원래 그런 구조"가 되기 쉬워서, 이렇게 글로 적어 두면 나중에 스스로에게 변명하기 어려워집니다.
세 가지 남은 것
이 프로젝트에서 건진 건 라이브러리 사용법이 아니라 세 가지 판단 기준이었습니다.
첫째, 측정 대상이 잘못되면 나머지가 아무리 정확해도 소용없다. 종가 하나로 판정하던 시절의 코드는 그 자체로는 버그가 없었습니다. 다만 재고 싶은 것을 재지 못했습니다.
둘째, 어디까지 정확해야 하는지를 먼저 정해야 한다. 승패 판정에는 BigInt를 쓰고 표시용 손익률에는 쓰지 않는다는 선을 긋고 나서야 코드가 정리됐습니다. 전부 정확하게 만들려다 보면 끝이 없습니다.
셋째, 알면서 넘어간 것은 기록해 둔다. 위의 커닝 구멍처럼 의도적으로 미룬 결정은 적어 두지 않으면 반년 뒤에 "왜 이렇게 돼 있지"가 됩니다.