친구랑 같이 망하는 주식 게임을 만들었다 — Chartlog

사이버머니로 친구방에서 같이 매수·매도·숏하고, 급하면 친구에게 돈도 빌리는 소셜 트레이딩 게임 Chartlog. 결정론적 차트 엔진, 심장이 멈췄던 6월, 그리고 출시 전 대청소까지.

친구랑 같이 망하는 주식 게임을 만들었다 — Chartlog

주식 얘기를 하다 보면 항상 뒷북이다. “그때 살걸”, “그때 팔았어야 했는데”. 근데 정작 같은 차트를 같은 시간에 보면서 각자 어떻게 대응하는지는 아무도 모른다. 누가 겁먹고 손절했는지, 누가 물타기하다 청산당했는지. 그게 궁금해서 만든 게 Chartlog다.

Chartlog 랜딩 페이지 — 친구방 사이버머니 소셜 트레이딩 게임

먼저 확실히 해두자. 이건 진짜 투자가 아니라 사이버머니 게임이다. 실제 종목도 없고, 실제 돈도 안 오간다. 방에 들어오면 게임머니가 지급되고, 그걸로 방 안에서만 도는 가상의 차트를 사고판다. 여기서 아무리 벌어도 통장은 그대로고, 아무리 잃어도 인생은 안 망한다. 망하는 건 그날 결과 카드에 찍히는 라벨 정도다.

친구방 하나가 곧 하나의 차트다

핵심 문장은 하나다. 친구를 초대해서 방을 만들면, 그 방에만 존재하는 차트가 하나 생성된다. 다 같이 입장하면 기본금을 받고, KST 9시~15시 동안 그 차트를 실시간으로 매매한다. 시장가·지정가 매수·매도에 숏까지. 무리하게 숏 쳤다가 가격이 오르면 강제청산도 당한다. 그리고 방 안의 모든 거래와 차용 내역이 전원에게 공개된다. 누가 언제 뭘 샀는지 다 보이고, 옆에는 채팅창이 있어서 남이 물릴 때 실시간으로 놀릴 수 있다.

이게 모의투자 앱과 갈라지는 지점이다. 소셜이 나중에 얹은 양념이 아니라는 건 커밋 로그가 증명한다. 5월 2일 12시 56분에 Create Next App으로 시작해서, 13시 12분에 차트 엔진과 DB 스키마, 17시 54분에 매칭 엔진과 차용 정산·강제청산, 18시 6분에 시즌 결과 카드와 OG 이미지 공유까지 들어갔다. 첫날 커밋에 이미 “친구한테 돈 빌리기”와 “결과 카드로 박제하기”가 있다. 차트는 무대고, 소셜이 코어다.

차트는 랜덤인데 재현 가능하다

게임을 만들 때 제일 먼저 고민한 게 차트 엔진이었다. 방마다 다른 차트가 나와야 하는데, 서버가 매 틱마다 가격을 저장하고 있으면 골치 아프다. 여러 클라이언트가 붙는데 누가 봐도 같은 가격이 보여야 하고, 호스트가 바뀌어도, 서버가 재시작해도 차트가 흔들리면 안 된다.

그래서 가격을 상태가 아니라 함수로 만들었다. 모든 가격은 (시드, dayIndex, tickIndex)의 순수 함수다. 방을 만들 때 정수 시드 하나만 정해두면, 그 방의 3일차 500번째 틱 가격은 언제 어디서 계산해도 똑같이 나온다. 저장할 게 시드 하나뿐이다.

내부는 시드 기반 GBM(기하 브라운 운동)이다. mulberry32 PRNG에서 뽑은 난수를 정규분포로 바꿔 로그수익률을 만드는, 주가 시뮬레이션의 교과서적인 방식이다. 엔진 핵심은 이 네 줄이 전부다.

const rng = mulberry32(streamSeed(cfg.seed, day, 1));
const z = gaussian(rng);
const logReturn = drift + perTickStd * z;
price = price * Math.exp(logReturn);

기본 세션은 09:00~15:00에 3초에 한 틱. 6시간을 3초로 쪼개면 하루 7,200틱인데, 이걸 다 저장하는 대신 시드만 알면 어느 틱이든 그 자리에서 뽑아 쓴다. 세션의 시작·종료 분(openMinuteOfDay, closeMinuteOfDay)은 설정값이라, 친구들이 저녁에 모이면 30분짜리 단축장으로 압축해서 돌릴 수도 있다.

그런데 순수 GBM만 돌리면 차트가 밋밋하다. 지지직거리기만 하고 사건이 없다. 사람들이 “헉” 하는 순간이 없으면 게임이 안 된다. 그래서 장중 헤드라인 이벤트를 넣었다. 급등·급락·루머·실적발표·규제·호재 여섯 타입이 하루 14개, 랜덤한 시점에 터져서 ±515%의 가격 점프를 만든다. 이것도 물론 시드에서 결정론적으로 생성된다. 코드 주석에 “게임에 드라마를 주입하는 장치”라고 적어놨는데, 딱 그 역할이다. 실적발표 뜨자마자 급락 꽂히면 채팅창이 난리 난다.

데모 차트의 장중 이벤트 목록 — 급등·급락·루머·실적발표·규제·호재가 하루에 여러 번 터진다

봇도 결정론적이어야 했다

사람 두세 명만 있는 방은 심심하다. 호가가 안 채워지니까. 그래서 봇 트레이더 셋을 넣었다. 민지, 태호, 은지. 코드상으로는 그냥 가중치와 편향 두 숫자다. 태호는 weight 1.4에 매수 편향 +0.6이라 공격적으로 사들이고, 은지는 편향 -0.3이라 슬금슬금 팔고, 민지는 weight 0.6이라 존재감이 옅다.

여기서도 결정론이 발목을 잡았다. 봇이 매 틱 랜덤하게 매매하면 클라이언트마다 봇 행동이 갈라진다. 그래서 봇 결정도 방 시드와 틱 인덱스를 PRNG 입력으로 써서, 어디서 계산하든 같은 봇이 같은 행동을 하게 만들었다.

초반에는 봇이 너무 시끄러웠다. 매 틱마다 세 봇이 다 거래를 쳐서 차트가 봇들끼리 노는 판이 됐다. 5월 4일 커밋 메시지 그대로 “1회 1봇, 30% 침묵”으로 완화했다. 한 틱에 한 봇만 움직이고, 30% 확률로는 아무도 안 움직인다. 이거 넣고 나서야 사람의 매매가 차트에 보이기 시작했다.

자잘한 삽질도 있었다. 처음엔 봇 ID를 u_minji 같은 문자열로 썼는데, DB users.id 컬럼이 uuid라서 방 생성이 자꾸 조용히 실패했다. 결국 00000000-0000-0000-0000-000000000001 같은 고정 UUID를 박았다. 이 실수는 뒤에서 한 번 더 등장한다.

진짜 재밌는 건 숏이랑 차용이다

혼자 하는 모의투자였으면 매수·매도로 충분했을 거다. 근데 친구랑 하니까 인간관계를 태울 장치가 필요했다.

숏과 강제청산. 숏은 가격이 떨어질 걸 걸고 파는 건데, 반대로 오르면 손실이 무한대다. 그래서 유지증거금 개념을 넣었다. 잔여증거금이 유지증거금 아래로 떨어지면 시장가로 자동 커버, 즉 강제청산된다. 자신 있게 숏 쳤다가 이벤트 급등 한 방에 청산당하는 게 이 게임의 백미다.

친구간 차용. 이게 제일 소셜하다. 돈이 부족하면 방 안의 친구에게 원금·이자·만기를 협상해서 빌린다. 만기가 되면 자동 정산되는데, 잔고가 부족하면 있는 만큼만 갚고 나머지는 채무불이행 처리된다. 첫날 짠 DB 스키마의 주석을 그대로 옮기면 이렇다.

-- credit_marks — 친구간 신용 표식 (다른 방에서도 노출되어 사회적 신용으로 작동)

게임 안에서 친구한테 돈 빌리고 안 갚으면, 그 표식이 다른 방까지 따라다닌다. 사이버머니지만 쪽팔림은 진짜다.

매매가 무섭거나 이미 청산당한 사람을 위해 관전 모드도 뒀다(마이그레이션 0006_spectators). 관전자는 거래는 못 하지만 차트를 보고 채팅을 친다. 옆에서 훈수 두는 사람이 있어야 게임이 시끄러워진다. 같은 이유로 친구를 데려오면 초대한 사람에게 리워드가 들어간다(0003_invite_rewards). 사람이 많을수록 재밌어지는 구조니까, 데려오는 행위 자체에 인센티브를 줬다.

차트 엔진은 눈으로 봐야 믿는다

첫날 커밋 로그를 다시 보면, 차트 엔진 커밋(13:12) 바로 1분 뒤가 /demo 페이지 커밋(13:13)이다. 방이고 로그인이고 다 만들기 전에, 엔진이 시드에서 진짜로 그럴듯한 곡선을 뽑는지부터 눈으로 봐야 했다. 시드를 바꿔가며 곡선이 어떻게 달라지는지, 이벤트 점프가 자연스러운지, 변동성을 키우면 얼마나 요동치는지를 보는 페이지다.

시드 기반 결정론 차트 엔진 데모 — 같은 시드는 항상 같은 곡선과 일별 종가를 만든다

숫자로만 보면 GBM이 맞게 도는지 감이 안 온다. 곡선으로 그려놓고 나서야 “σ를 좀 낮춰야겠다” 같은 판단이 섰다. 게임에서 차트는 UI가 아니라 콘텐츠라, 이게 재미없으면 나머지가 다 무의미했다.

시즌이 끝나면 참가자별 수익률에 따라 라벨이 붙는다. +30% 이상이면 “워런버핏 코스프레”, 본전 근처면 “본전치기 장인”, -15%면 “고점매수 장인”, 바닥이면 “오늘의 흑우”. 강제청산이나 채무불이행이면 라벨이고 뭐고 📛 딱지가 붙고, 정렬도 정상 → 청산자 → 채무불이행자 순이라 카드 맨 아래에 이름이 올라가는 순간 그날 저녁은 놀림감이다. 라벨은 SNS 공유용 컨텐츠라 정확도보다 웃긴 게 중요했다. 지난번 Tokka를 만들면서 사람들은 분석의 정확도보다 공유 가능한 카드 한 장에 열광한다는 걸 배웠는데, 그 교훈을 여기 그대로 써먹었다.

게임의 심장이 멈춰 있었다

5월 5일까지 나흘 만에 게임 본체와 배포까지 올려놓고, 잘 돌아가는 줄 알고 한 달을 놔뒀다. 6월 27일 새벽에 다시 열어보니 주문도 틱도 전부 실패하고 있었다. 그날 커밋 메시지는 무심하게 repair broken order/tick loop인데, 실은 심장이 멈춰 있던 거다. 원인은 타입 경계 두 곳이었다.

하나는 주문 ID. ord_<타임스탬프>_<번호> 같은 문자열을 DB uuid 컬럼에 꽂으려 해서 주문이 하나도 안 들어갔다. 봇 ID에서 이미 겪은 실수를 주문 쪽에서 똑같이 반복한 거다. 다른 하나가 더 악질이었는데, node-pg 드라이버가 bigint 컬럼을 정밀도 보존을 위해 문자열로 돌려준다는 것. 잔고에 대금을 더하는 cash + proceeds가 덧셈이 아니라 문자열 연결이 돼서 "1000000" + 50000"100000050000"이 된다. 빼기는 강제로 숫자가 되니 매수는 멀쩡했고, 값을 더하는 매도·숏에서만 95498235014-42 같은 쓰레기값이 튀며 틱이 통째로 중단됐다. 해결은 드라이버 경계 한 곳에서 막았다. int8 → Number 전역 파서를 등록하고, 그 위의 매칭 엔진은 안전 정수 도메인 안에서만 돌게 assert를 깔았다. 게임이라도 돈 계산이 틀리면 그건 버그가 아니라 사기다.

시즌 넘김 크론도 이때 정리했다. 시즌 마감 라우트는 있는데 정작 그걸 호출하는 스케줄러가 없어서 시즌이 실제로 넘어간 적이 없었고, 손으로 호출해보니 인증 미들웨어가 크론 요청을 로그인 페이지로 리다이렉트시키고 있었다. k8s CronJob에 물리고 라우트는 CRON_SECRET 헤더로 자기 보호하게 바꾸고 나서야, 흑우 카드가 15시에 스스로 나오기 시작했다.

구조적으로 제일 찜찜한 건 “누가 시계를 돌리느냐”다. 지금은 방 호스트의 브라우저가 3초마다 틱을 민다(useTickLoop). 호스트가 탭을 닫으면 방 전체가 얼어붙기 때문에, 호스트 heartbeat를 두고 신호가 끊기면 다른 참가자에게 호스트를 넘긴다. 여러 명이 동시에 “내가 호스트”라고 나서는 경합은 잠금과 원자적 갱신으로 막았다. 그래도 근본적으로는 브라우저 의존이라, 틱 드라이버를 서버로 옮기는 건 미뤄둔 숙제다. 멀티플레이 게임에선 “누가 진실의 원천이냐”가 게임 로직보다 훨씬 까다로웠다.

로그인은 직접 안 짰다. OAuth를 앱마다 새로 붙이는 건 시크릿·동의화면 관리가 반복되는 삽질이라, 내 *.jiun.dev 앱들이 공유하는 중앙 인증 브로커(jiun-api)에 카카오 로그인을 얹었다. 물론 친구랑 잠깐 해보는 게임이라 게스트 입장도 열어뒀다. 가입부터 시키면 아무도 안 들어온다. 실시간 갱신은 인증된 SSE로 밀어주는데, SSE 연결과 rate limit이 프로세스 로컬이라 지금은 1 replica로만 뜬다. 알면서 진 기술 부채다. 배포는 기존 파이프라인 그대로 — Next.js 16 + React 19를 Docker로 말아 올리면 ArgoCD가 k8s에 얹는다.

출시 전 대청소

7월 15일, 공개 전에 출시 준비 라운드를 두 번 돌렸다. 라운드마다 코딩 에이전트 셋이 영역을 나눠 병렬로 코드를 훑고 고치는 방식인데, 게임을 실제로 끝까지 돌려보기 전엔 안 보이던 버그가 줄줄이 나왔다.

제일 황당한 건 방이 영원히 안 끝나는 버그였다. 방 종료 코드가 “마지막 틱 다음 틱” 분기에 들어 있었는데, 라우트가 다음 틱 자체를 미리 막아버려서 종료 전이가 도달 불가능했다. 마지막 틱을 처리하는 바로 그 트랜잭션에서 방을 닫도록 옮겼다. 봇도 하나 잡혔다. 시드 6, 틱 1, 현재가 1원인 극단 구간에서 봇이 지정가 -19원을 만들어냈고, DB 제약에 걸려 그 틱 전체가 롤백됐다. 모두의 차트가 멈추는 거라 봇 가격에 하한을 박았다. 일일 챌린지는 어떤 날엔 아예 안 생겼는데, 시드 해시가 unsigned 32비트를 뱉는데 저장 컬럼은 signed INTEGER라 특정 날짜에 insert가 터지고 있었다. 그 밖에 시즌 집계의 스키마 불일치와 손익 표시 배율, KST 날짜 파싱, 그리고 동시성 경로에서 차용 정산·초대 보상이 두 번 지급될 수 있던 걸 잠금과 원자적 갱신으로 막았다.

테스트는 이때 처음 깔았다. 그전까지 0개, 프레임워크조차 없었다. 결정론적 차트, 매칭 엔진, 강제청산처럼 순수 로직이 많아서 회귀 테스트로 묶기 좋았고, 두 라운드가 끝나니 46개가 됐다. 이제 pnpm check 하나로 lint·타입·테스트·빌드가 다 돈다.

정직하게 적자면, 대청소를 끝낸 그날 정작 사이트는 502를 뱉고 있었다. 앱과 DB는 멀쩡한데 같은 홈서버 노드에 얹힌 다른 워크로드들과 함께 노드가 앓고 있던 거라, 코드로는 어쩔 수 없는 종류의 장애였다. 그리고 이 글을 쓰는 시점에 대청소 브랜치는 아직 머지 대기 중이다. 만드는 건 나흘이었는데, 남한테 내놓을 수 있게 만드는 건 두 달 반째다.

마치며

방을 안 파도 해볼 수 있는 모드도 있다. 오늘의 차트(Daily Challenge)는 그날 날짜에서 뽑은 시드로 모두가 같은 차트를 받아 혼자 점수 경쟁을 하는 모드다. 친구 모을 시간 없을 때 혼자 흑우 여부를 확인하는 용도다.

다시 강조하지만 이건 게임이다. 여기서 워런버핏 코스프레 라벨을 받았다고 실전에서 통하는 건 절대 아니고, 흑우 라벨 받았다고 우울해할 필요도 없다. 오히려 친구들이랑 같은 차트 앞에서 각자 얼마나 다르게 겁먹고 욕심내는지 보는 게 더 재밌다. 결국 차트보다 사람이 콘텐츠다.

궁금하면 친구 몇 명 모아서 chartlog.jiun.dev에서 방 하나 파보시길. 누가 오늘의 흑우인지는 15시에 카드로 나온다.

Comments

Loading comments...