Checkpoints · 함정 · 결정

주요확인사항

개발 중 알아두어야 할 함정, 제품·기술 결정, 개발 규칙을 남깁니다. 재현 가능한 버그는 버그 리포트 페이지를 봅니다.

역할 · 함정·결정·개발 규칙을 app/checkpoints/data.ts에 남깁니다. 재현 가능한 버그는 버그 리포트, 정식 추적은 이슈대장(시트).

확인 목록 (52)

버그 리포트 →
c54002026-08-17온체인 · SDK

[결정] L3 — Zenith Core/Wrapper 포크를 진행한다

주문이 우리 프로그램 ID로만 가게 하려면 Manifest Core·Wrapper를 포크해 메인넷에 배포해야 한다. 앱 패키지 이름만 바꿔서는 안 된다.

미해결

배경 · 내용

Core HQY4XUKnn… Wrapper CxbMmW7SWZ…. GPL-3.0 소스 공개. 기존 DOGEK·SPEPE 호가는 안 따라옴. Solscan Public name은 support@solscan.io. npm @zenith/zenith-sdk에 이 ID를 박는다.

주의 · 우회

배포 전에는 LISTED_MARKETS가 아직 Manifest 마켓일 수 있다. create-zenith-markets 후 주소를 교체한다.

후속

zenith-program 공개 원격 · cargo build-sbf · program deploy · npm publish · Solscan 메일

c53002026-08-17SDK · npm

[결정] 사내 패키지 스코프는 @zenith (앞에 @ 필수)

npm 스코프는 `@조직/패키지`. `zenith/shared`처럼 @ 없이 쓸 수 없다. 사내 이름은 `@zenith/shared` · `@zenith/zenith-sdk`다.

해결

배경 · 내용

@ 없으면 슬래시가 패키지 이름이 아니라 경로로 읽힌다. `@manifast/shared`·`@manifast/zenith-sdk`에서 조직만 zenith로 바꿨다. web·trade-api 등 서비스 이름은 아직 `@manifast/…`.

주의 · 우회

import는 `@zenith/shared` · `@zenith/zenith-sdk`. 공개 npm은 `packages/zenith-sdk`에서 `npm login` 후 publish.

후속

packages/shared · packages/zenith-sdk · workspaces 링크

c52002026-08-17차트 · i18n

[함정] 차트 오전/오후는 OS 언어가 아니라 앱 언어를 써야 한다

한국어 Windows에서 영어 UI를 골라도 차트에 오전/오후가 남았다. navigator.language를 쓰고 있었기 때문이다.

해결

배경 · 내용

lightweight-charts가 navigator.language를 썼고, ja-JP·zh-CN은 기본 24시제라 15:14만 나왔다. 앱 locale + hour12로 ko 오전/오후, en AM/PM, ja 午前/午後, zh 上午/下午.

주의 · 우회

헤더 언어: 한국어 오전/오후, 영어 AM/PM, 일본어 午前/午後, 중국어 上午/下午.

후속

MarketCandleChart · localTime.chartIntlLocale

c51002026-08-17차트 · 캔들

[함정] 차트 긴 빨간 봉 ≠ 백필 데이터 소실

SPEPE 1h에서 바닥이 0.01로 보이면 먼저 mid 오염을 의심한다. 백필이 fills를 빠뜨린 것과는 다르다.

완화됨

배경 · 내용

ingest가 mid를 전 해상도 봉 OHLC에 넣으면 low가 호가 중간가로 끌린다. volume=0·가격 고정 1m 봉이 증거. 실제 체결은 tape·volume>0 봉으로 확인.

주의 · 우회

API candles에서 volume=0 여부를 본다. fills 재구성(--candles-only)으로 mid-only 오염을 덮을 수 있다.

후속

applyMidToBar 수정 반영. ingest/web 배포 후 SPEPE --candles-only.

c50002026-08-15상장 · 온체인

[함정] SDK만 바꾸면 Manifest 거래소에 페어가 그대로 보인다

Manifest UI는 SDK 이름이 아니라 프로그램 ID로 마켓을 찾는다. 우리 화면에서만 보이게 하려면 Core를 포크해 새 프로그램을 배포해야 한다.

미해결

배경 · 내용

지금 SPEPE/USDT 등 상장은 Manifest 프로그램 계좌다. zenith-sdk로 import만 바꿔도 북은 공용. Wrapper만 포크해도 안쪽은 Manifest. 새 프로그램이면 북은 0부터·기존 유동성은 안 따라옴. 체인은 공개라 제3자 UI는 막을 수 없다. GPL 검토 필요.

주의 · 우회

지금은 LISTED_MARKETS로 Zenith 화면만 고른다. Manifest에 안 뜨게 하려면 L3(프로그램 배포) 결정이 먼저다.

후속

법률(GPL) → 프로그램 배포 → zenith-sdk에 새 program ID → 마켓 재상장. 일정 m5sdk 1일과 별개.

c49002026-08-15일정

[결정] 예상일정 — zenith-sdk 1일 · 한번에 주문 부하 2일 · 다크 1일

QA를 라이트로만 끝낸 뒤에 다크를 붙이지 않는다. zenith-sdk 1일은 패키지 교체이지 온체인 포크가 아니다.

미해결

배경 · 내용

/schedule에 m5sdk(1일)·m5 한번에 주문가능한 부하(2일)·m5dark(1일, m6 직전)을 넣었다. 아이폰 최적화(m3d)는 완료. 다크는 CSS 토큰+차트 applyOptions. 셸 remount 금지. L3 프로그램 포크는 이 1일 밖.

후속

레이아웃 잠긴 뒤 m5dark → m6에서 라이트/다크 같이 검수.

c48002026-08-15상장

[결정] SPEPE/USDT 상장은 LISTED_MARKETS·MOCK_MARKETS 둘 다

페어를 코드에 넣어야 검색·차트·ingest가 같이 본다. 서버만 고치면 화면에 안 뜨고, 화면만 고치면 캔들 API가 404다.

해결

배경 · 내용

quote mint는 온체인 값 Es9v…wNYB(USDT). 입출금 모달은 wSOL/USDC만이라 USDT는 지갑에 두고 주문하면 자동 deposit. 헤더 지갑 USDC 칸은 그대로다.

주의 · 우회

USDT로 사려면 지갑에 해당 mint USDT가 있어야 한다.

후속

셀프 상장이 열리면 목록을 DB로 옮긴다.

c47002026-08-14지갑 · Phantom

[함정] Phantom ‘Request blocked / dApp could be malicious’는 Zenith 에러가 아니다

아이폰 Phantom이 서명 요청을 막고 빨간 방패와 Request blocked를 띄운다. 앱 크래시가 아니라 Phantom(Blowfish) 보안 경고다.

완화됨

배경 · 내용

호가와 동떨어진 지정가(예: 0.987 vs 0.0098)에서 잘 뜬다. 매수가에 맞추고 수량도 매수 호가와 비슷하면 통과했다. 코드 변경 없음. signAndSend(지갑 직접 전송) 시도는 넣었다가 뺌.

주의 · 우회

지정가·수량을 호가(매수가) 근처에 맞춘다. 팝업은 X로 닫기.

캡처

아이폰 Phantom Request blocked — This dApp could be malicious, 도메인 dev.zenith-dex.xyz

후속

운영 도메인은 덜 뜰 수 있음. 반복되면 Blowfish 리뷰.

c46002026-08-14주문 · 호가

[함정] 지정가가 반대편 호가와 만나면 호가창이 아니라 바로 체결된다

지정가 매수도 이미 나와 있는 더 싼 매도가 있으면 대기하지 않고 즉시 체결된다. 호가창이 아니라 체결 내역에 남는다.

완화됨

배경 · 내용

실측 2026-08-14 19:09 KST: DOGEK 매수 0.0234×0.0011. 체인에는 qty 0.0011이 매도 0.009801에 체결로 기록됨. 지정가 0.0234는 ‘이 가격까지 사겠다’라서 그보다 싼 매도를 먹는다. maker=taker면 자기 주문과 체결된 것.

주의 · 우회

호가에 남기려면 지정가를 최우선 매도보다 아래에 둔다. 체결은 테이프·Trade History.

후속

토스트: 즉시 체결이면 orderFilled, 호가 대기는 orderSubmitted. resolvePlaceOutcome이 체결·미체결을 짧게 폴링.

c45002026-08-14모바일 · iOS

[결정] 모바일 레이아웃 최적화는 아이폰 X 이후만

거래 UI 모바일은 아이폰 X(375×812)와 그 이후 기종만 맞춘다. X보다 작거나 짧은 기종은 최적화 대상이 아니다.

해결

배경 · 내용

기준은 아이폰 X 세로. SE·8(홈버튼, 667 높이)과 그보다 좁은 미니 등은 깨져도 우선 고치지 않는다. 화면 전체를 비율로 줄이지 않고, X 폭에서 주문칸만 압축한다.

주의 · 우회

브라우저 기기 모드는 375×812(아이폰 X 세로). 가로는 720을 넘어 PC 셸이 나온다.

후속

프리셋 짧은 문구·단위 라벨·≤390 주문칸 확대. 전체 scale 금지.

c44002026-08-14모바일 · 주문

[결정] 작은 폰은 화면 전체 축소 대신 주문칸만 압축한다

아이폰 X(375)에서 매수호가·지정가 입력이 줄바꿈·잘림. 페이지를 scale로 줄이지 않고 짧은 프리셋·단위 라벨·주문칸 폭으로 맞춘다. 최적화 대상은 아이폰 X 이후(c4500).

해결

배경 · 내용

전체 축소는 차트·호가·탭·세이프영역까지 작아지고 iOS 고정 레이아웃과 충돌한다. 모바일 CSS는 px 기준이라 비율 축소도 한 번에 안 된다.

주의 · 우회

375~390은 주문칸을 조금 넓히고 호가를 줄인다.

후속

프리셋 매수/중간/매도, ± 28px, 단위는 지정가·수량 라벨 옆.

c43002026-08-14모바일 · 마켓

[함정] 모바일 모달 검색창 autoFocus는 가상 키보드를 바로 연다

코인 선택처럼 검색 input이 있는 모달에 autoFocus를 두면, 모바일에선 목록보다 키보드가 먼저 올라온다.

해결

배경 · 내용

PC는 키보드로 바로 검색하는 게 맞다. 모바일 셸(≤720)은 탭해야 검색되게 autoFocus를 끈다.

주의 · 우회

검색하려면 입력창을 탭한다.

후속

SearchMarketsModal `autoFocusSearch`. MobileTradeShell만 false.

c42002026-08-14지갑 · iOS

[함정] Phantom iOS 앱은 iOS 16+ — 14.8.1은 스토어에서 설치·실행 불가

App Store의 Phantom은 iOS 16 이상. 14.8.1 아이폰은 스토어 버튼을 눌러도 설치가 안 되거나 구버전만 남는다. Safari 14는 Next 15 사이트도 못 연다.

완화됨

배경 · 내용

Zenith 지갑 연결은 Phantom 인앱 브라우저의 provider 주입이 필요. iOS 16 미만은 현재 공식 앱 요구사항을 못 맞춘다.

주의 · 우회

iOS 16.4 이상(예: 16.7.16)으로 올린 뒤 사파리에서 ‘Phantom 앱에서 열기’. 앱이 없으면 App Store.

후속

16.7.16 업그레이드 전제. App Store + browse 딥링크 + 인앱 주입 폴링.

c41002026-08-14웹 · Safari

[함정] Next 15 기본 타깃은 Safari 16.4 — iOS 14 사파리는 청크 파싱에서 죽는다

Next 15+React 19 기본 browserslist는 Safari 16.4+. iOS 14.8.1(Safari 14)은 class static 초기화 블록을 못 읽어 Application error가 난다.

완화됨

배경 · 내용

실측: dev.zenith-dex.xyz 청크 `static{this.memoizedDefaultLocale=null}` (next-intl). iOS 16.7은 16.4 이상이라 기본 타깃으로 충분. 빌드는 safari 16.4 / ios 16.4.

주의 · 우회

설정→일반→정보에서 iOS 확인. 16.4 미만이면 올리거나 다른 기기로 본다.

후속

apps/web/package.json browserslist safari 16.4 · next-intl transpile

c40002026-08-14모바일 · 호가

[함정] 모바일 호가에 PC 1fr 스크롤을 그대로 쓰면 페이지가 늘거나 스크롤이 죽음

PC `.book-scroll` 1fr은 패널 높이가 정해져 있을 때만 칸 스크롤이 된다. 모바일에 그대로 적용하면 50단이 페이지를 늘린다. 셸을 100dvh overflow hidden으로 잠그면 미체결 스크롤이 죽는다.

해결

배경 · 내용

행 높이는 `.m-order`만 정하게 한다. `.m-book-slot`은 overflow hidden + grid min-content라 50단 min-content가 행을 밀어 올리지 않고, 주문 폼(매수 버튼) 높이에 stretch된다. 셸은 min-height:100dvh만 쓰고 overflow는 잠그지 않는다.

주의 · 우회

모바일에서 호가가 매수 버튼보다 길면 슬롯 overflow·grid 행 사이징을 먼저 본다. 페이지가 안 움직이면 `.m-trade-shell` overflow hidden이 남았는지 본다.

후속

.m-book-slot · grid-template-rows min-content · 셸 overflow 잠그지 않음

c39002026-08-14트레이드 UI

[결정] 거래 알림 — PC 오른쪽 하단, 모바일·태블릿은 헤더 아래 한 줄

지정가 제출·즉시 체결·실패, 미체결 취소 성공·실패만 토스트. PC는 오른쪽 하단, ≤1100은 헤더 아래 가로 띠. Phantom 확인·유효성·지갑·RPC는 기존 자리.

해결

배경 · 내용

한 장만. PC 약 3초, 모바일·태블릿 약 2.4초. 태블릿은 페어 줄 아래(차트 위). 로컬 PERF HUD와 PC에서 겹칠 수 있음(알림 z 160).

후속

1·2·3·4만. TradeToasts · globals.css max-width 1100

c38002026-08-14호가 · UI

[결정] 호가창 mid 띠는 중앙 · 매도/매수는 각 최대 50단 · 칸마다 스크롤

mid 띠는 창 세로 중앙. ingest가 매도·매수 각 최대 50단을 SSE로 보내고, 각 칸이 따로 스크롤된다.

해결

배경 · 내용

이전의 ‘칸에 맞는 개수만·스크롤 없음’은 폐기. BOOK_LEVELS_MAX=50. 매도는 column-reverse로 best가 띠 위에 붙고 넘치면 그 칸만 스크롤.

후속

bookPublisher · shared BOOK_LEVELS_MAX · .book-scroll 1fr/auto/1fr

c37002026-08-14인프라 · 비용

[함정] Railway Usage limits — Compute ≠ Agent

Compute는 web·trade-api·Redis 같은 서비스 비용. Agent는 Railway 대시보드/CLI의 AI 에이전트(LLM) 비용. Cursor 요금과 무관.

해결

배경 · 내용

Compute hard에 닿으면 워크로드가 멈춤. Agent hard에 닿으면 에이전트만 꺼지고 서비스는 계속. Hobby Agent 기본 hard $5. Agent 한도는 없앨 수 없음.

주의 · 우회

Zenith만 쓰면 Agent는 낮게(또는 $0으로 차단) 두고 Compute만 올리면 됨. 알림 금액은 hard보다 작거나 같게.

후속

Usage limits 모달에서 두 칸을 따로 본다

c36002026-08-14Mongo · 스크립트

[함정] Mongo upsert에서 $set과 $setOnInsert에 같은 필드 금지

한 update에 marketId를 $setOnInsert와 $set 둘 다 넣으면 code 40 conflict. 신규 insert용 필드와 매번 덮을 라벨을 나눠야 한다.

해결

배경 · 내용

backfill-fills가 fill 전체에 라벨을 넣고 또 $set: labels 해서 sol-usdc 백필이 실패. 라벨은 $set만.

주의 · 우회

$setOnInsert에서 라벨 키를 빼고 $set에만 둔다.

후속

backfill-fills.ts 수정됨

c35002026-08-14차트 · 캔들

[함정] 차트 볼륨은 mid가 아니라 fills — 과거는 candles-only 필요

캔들 OHLC는 ingest mid 틱으로 그려지지만, 하단 볼륨은 체결 qty 합이다. 기존 candle_bars에는 volume 필드가 없어서 백필 전까지 막대가 비어 보인다.

완화됨

배경 · 내용

차트에 fills SSE를 안 붙임(테이프와 스트림 중복). 현재 봉 볼륨은 새로고침·페이지 로드까지 늦을 수 있음. 24h Volume($)과 툴바 V(base)는 단위가 다름.

주의 · 우회

과거 막대: npx tsx scripts/backfill-fills.ts --market dogek-sol --candles-only. 라이브는 ingest 재배포 후 새 체결부터 $inc.

후속

필요하면 테이프 fill 이벤트를 차트와 공유해 현재 봉만 즉시 갱신

c34002026-08-14웹 · SEO · Railway

[함정] web noindex는 빌드 env만 믿으면 안 됨 — Host·런타임 헤더가 최종

NEXT_PUBLIC_APP_ENV는 web 빌드 때 박힌다. 같은 산출물을 production에 올리면 noindex가 따라갈 수 있다. 반대로 빌드 env가 비면 next start(NODE_ENV=production)라서 색인이 열릴 수 있다.

완화됨

배경 · 내용

차단 조건: 환경 ≠ production, 또는 Host가 dev.zenith-dex.xyz / dev.*. HTML은 middleware X-Robots-Tag가 런타임에 붙는다. /robots.txt는 점(.) 경로라 i18n matcher 밖 → robots.ts.

주의 · 우회

dev 도메인 확인은 curl -I https://dev.zenith-dex.xyz 에서 X-Robots-Tag. production은 그 환경에서 다시 빌드.

후속

production 컷오버 때 zenith-dex.xyz는 색인 허용되는지 robots·헤더로 재확인

c33002026-08-13인프라 · DNS

[결정] 문서 사이트 도메인 — docs.zenith-dex.xyz (GoDaddy → Railway)

manifast-typescript-test(web) 커스텀 도메인은 docs.zenith-dex.xyz. GoDaddy(zenith-dex.xyz)에 CNAME docs + TXT _railway-verify.docs 둘 다 넣어야 검증·SSL 완료.

미해결

배경 · 내용

CNAME: 2246vi1x.up.railway.app. TXT: railway-verify=… (README·Railway domain status 참고). 예전 docs.zenith.xyz / hzivwvts 값은 폐기. 기본 URL web-production-33532도 유지.

주의 · 우회

DNS 전파 전은 Railway 기본 URL 사용. 404면 TXT 누락·CNAME이 옛 타깃인지 확인.

후속

GoDaddy에서 CNAME·TXT 수정 후 railway domain status docs.zenith-dex.xyz 로 verified 확인

c32002026-08-13보안 · 주문

[결정] WALLET_SECRET_KEY는 load-test 전용 — trade-api UI는 유저 지갑

서비스(Trade UI) 주문은 Phantom 등 유저 지갑 서명. trade-api에 서버 핫월렛 키 불필요. WALLET_SECRET_KEY는 Railway load-test(scripts/load-orders)만.

해결

배경 · 내용

POST /api/orders·executeServerPlaceOrder는 부하테스트·서버 서명 경로. 운영 UI는 prepare→유저 서명. trade-api Variables에 키가 비어 있어도 정상. load-test에만 JSON 배열 시크릿 설정.

후속

trade-api에 키를 ‘하드닝 미완’으로 취급하지 말 것. /schedule m4 반영.

c31002026-08-13인프라 · RPC · 부하테스트

[결정] 주문 부하 1000건 — Helius RPS ≠ 주문수, Professional 권장

Professional 500은 RPC 500회/초이지 주문 500건/초가 아님. 1000건은 15~30초에 나눠 보내고, 플랜은 Professional 권장(Business는 30초·스로틀 시 가능).

해결

배경 · 내용

문서: helius.dev/docs/billing/rate-limits — Free 10 / Dev 50 / Business 200 / Pro 500 RPS, Enterprise=Custom(무제한 아님). 주문 1건≈reload·blockhash·send·confirm 등 RPC 수 회(대략 3~5+). 동시 100건 실측 fail≈98은 429. 1000건/15~30초: 초당 주문 ~33~67 → RPC ~100~335(가정) → Dev 불가, Business는 ~30초+동시 20~30+전용 키면 △, Professional이면 15~30초 여유. 1초 500주문×2초는 Pro로도 불가에 가깝. load-orders에 동시성 제한·배치·429 백오프 권장. ingest/trade-api와 키 공유 시 헤드룸 필요.

주의 · 우회

부하테스트 전 Helius 대시보드에서 플랜·크레딧 확인. load-test 전용 API 키 분리. COUNT 램프 시 429면 동시 N↓·간격↑.

후속

load-orders 스로틀(동시 30~50, 15~30초) 구현 후 Business vs Pro 실측 비교. /load-test-report 주문 실측 갱신.

c30002026-08-13인프라 · Railway

[결정] realtime replica 1대 증설 — CPU/RAM 사용량 기준 추가 비용

Hobby 월 $5 + 초당 사용량 과금. 요율 CPU $20/vCPU/월 · RAM $10/GB/월. 추가 1 replica ≈ (CPU_used×20)+(RAM_used×10).

해결

배경 · 내용

2026-08-12 스냅샷(realtime 1대): CPU≈0.12 vCPU · RAM≈792MB(0.792GB) → 증분 약 $2.4+$7.92≈$10.3/월. 부하로 상한(8 vCPU·8GB)까지 쓰면 증분 최대 약 $240/월(160+80). Hobby included usage $5는 워크스페이스 전체 사용량과 상쇄되어 실제 청구 증분은 크레딧 잔액에 따라 더 낮을 수 있음. 볼륨·CPU/RAM 구분·한도는 c2600.

주의 · 우회

`railway metrics --service realtime`로 current/avg CPU·RAM 확인 후 같은 식으로 재산정. 청구는 대시보드 Usage/빌링에서 $5 크레딧 잔액까지 같이 볼 것.

후속

full-tab ~900유저/대 가정(부하보고서)과 맞춰 replica 수 결정. /network-costs는 egress 별도.

c29002026-08-12인프라 · 부하테스트

[결정] load-sse --ramp — 점진 입장(예: 2초당 100유저)

동시 몰림 대신 --ramp 100/2s 또는 LOAD_SSE_RAMP로 가상 탭을 웨이브로 연다. --ramp가 있으면 --batch보다 우선.

해결

배경 · 내용

형식: 탭수/간격 (100/2s, 50/500ms). 웨이브마다 해당 탭의 SSE를 한꺼번에 연 뒤 interval 대기. full-tab 500유저·ramp 100/2s면 입장 ~10초 + hold 기본 1분(60초). burst는 --batch·--batch-gap-ms.

후속

1500~2000 full-tab 또는 load-orders. /load-test-report 갱신 완료.

c28002026-08-12인프라 · 부하테스트

[결정] load-sse --full-tab — Trade UI와 동일 SSE 묶음

count=가상 탭 수. --full-tab이면 presence+호가(티커·차트)+체결+미체결(기본). raw SSE≈count×5. presence만 테스트는 실사용과 다름.

해결

배경 · 내용

스트림: /presence/stream, /markets/price/stream( presence=1 ), /markets/price/stream?presence=0, /markets/fills/stream, /orders/open/stream(가짜 trader). --no-open-orders로 지갑 미연결(4 SSE). 기본 batch full-tab=20. LOAD_SSE_FULL_TAB=1. dev만, 후 realtime 재시작.

후속

1500~2000 full-tab 또는 load-orders 램프. /load-test-report 참고.

c27002026-08-12인프라 · 부하테스트

[결정] 접속 부하는 load-sse — Online=presence SSE, 램프 후 주문과 겹침

5k ‘접속’은 브라우저 탭이 아니라 scripts/load-sse.ts가 realtime /presence/stream N개. 주문 1k는 load-orders와 별개·나중에 겹침.

해결

배경 · 내용

확인: load-sse 로그 presenceOpened/fail·health.online before/peak/after, railway metrics realtime·Redis CPU/RAM, 헤더 Online, 연결 중 끊김. 주의: presence마다 daily_visitors +1(dev만). 연결당 Redis subscribe 1개라 고수가 먼저 터질 수 있음. 램프 100→500→1k→2k→5k. --with-price는 호가 SSE(presence=0) 추가.

주의 · 우회

한 번에 5k 말고 COUNT만 올려 재기동. REALTIME_URL은 realtime 공개 URL.

후속

SSE 안정 후 LOAD_TEST_COUNT 램프와 동시 실행.

c26002026-08-12인프라 · Railway

[결정] Railway CPU·RAM ≠ 볼륨 — 한도 안에서 사용량 과금

볼륨은 디스크 용량을 정해 붙이고, CPU·RAM은 서비스마다 고정 할당이 아니라 컨테이너 상한(현 dev 8 vCPU / 8 GB) 안에서 쓴 만큼 과금.

해결

배경 · 내용

실측(dev, 최근 1h): web ~138MB·CPU≈0, trade-api ~70MB, realtime ~48MB, ingest ~155MB·0.07vCPU, Redis ~9MB, Mongo ~331MB. 한도 대비 여유 큼. 부족하면 OOM/스로틀 → 플랜·리소스 한도 검토. 부하·동시접속이 늘면 ingest·Mongo 램부터 보면 됨. 리전·볼륨은 c2300·c2400.

주의 · 우회

대시보드/CLI `railway metrics --cpu --memory`로 서비스별 current/avg/max/limit 확인. 볼륨 크기와 CPU/RAM을 혼동하지 말 것.

후속

스케일업은 실사용이 한도에 근접할 때. replica 규칙은 c2000.

c25002026-08-12차트 · 성능 · 캐시

[보류] 캔들 과거 봉 클라이언트 캐시 — 닫힌 봉은 API 재호출 불필요

과거(닫힌) 캔들은 불변이니 한 번 받은 구간은 클라이언트가 들고, 새 구간·최신 tip만 API/SSE. 구현은 나중에. 캐시 시 오류 추적이 어려워질 수 있어 관측 가능해야 함.

미해결

배경 · 내용

방향: 닫힌 봉 캐시 찬성. staleTime 예 5분(과거는 더 길어도 됨). 맨 끝(라이브) 봉은 캐시 말고 SSE. React Query가 적합하고 zustand는 필수는 아님(서버 캐시와 겹침). 기존 candlesApi 8s 인플라이트는 Strict Mode용 — 키·TTL만 키우는 방법도 있음. 우려: Network에 요청이 안 보이면 원인 오해, hit/miss·에러 삼킴. 넣을 때: hit/miss 로그 또는 PERF, 실패 시 조용한 캐시 폴백 금지, ?nocache=1/캐시 비우기, 키는 address|resolution|before|after|limit 단순화, 윈도우 상한 유지.

주의 · 우회

지금은 매 스크롤·초기 로드마다 trade-api→Mongo. 중복은 짧은 공유 캐시·loadOlder suppress로만 완화(c2200).

후속

나중에 구현 시 RQ vs candlesApi 확장 중 하나 선택 + 디버그 요건(hit/miss·nocache)을 스펙에 먼저 고정.

c24002026-08-12인프라 · 성능

[측정] Railway 리전 SFO → Singapore 후 REST 지연

한국에서 DevTools Network(Fetch/XHR)로 비교. REST는 대략 160~200ms(SFO) → 110~135ms(Singapore). SSE의 초 단위 시간은 연결 유지 시간이라 리전 비교에 쓰지 않음.

해결

배경 · 내용

실측(초기 진입, trade-api): candles SFO ~164ms → SG ~125ms(약 24%↓). fills ~195ms → ~111ms(약 43%↓). volume24h ~161ms → ~133ms(약 17%↓). 전체로 왕복 약 30~80ms 감소. SSE stream이 2~4초로 길게 보이는 것은 EventSource가 열린 채로 경과 시간이 쌓인 것(완료 지연 아님). Waiting(TTFB)로 개통만 보면 됨. Solana RPC가 미국이면 주문·체인 쪽 지연은 리전과 별개로 남을 수 있음.

주의 · 우회

리전 성적표는 candles/fills/volume24h 시간만 본다. stream 초 단위는 무시.

후속

체감이 더 필요하면 RPC 아시아 엔드포인트 검토. 리전 이전 절차는 c2300.

c23002026-08-12인프라 · Railway

[결정] Railway dev 리전 — SFO → Southeast Asia (Singapore)

한국·동남아 지연을 줄이려고 compute를 싱가포르로 옮김. Railway 아시아 컴퓨트는 싱가포르만 있음(도쿄는 미제공).

해결

배경 · 내용

순서: Redis·Mongo(볼륨 마이그레이션) → ingest → trade-api → realtime → web. CLI: railway service scale --service X southeast-asia=1 sfo=0. Solana RPC가 미국이면 체인 왕복은 별개. 실측 비교는 c2400.

후속

체감이 부족하면 RPC도 아시아 엔드포인트 검토.

c22002026-08-12차트 · 성능

[결정] 초기 candles는 차트 1회 — before=는 스크롤 시에만

초기 Network candles 2번(limit=60 + before=)은 setData auto-fit이 loadOlder를 오발한 것. 첫 진입에는 before 불필요.

완화됨

배경 · 내용

24h%는 volume24h.price24hAgo. 차트만 /candles(1m×60). setData·showRecentBars 직후 300ms는 range 페이지 로드 suppress. 사용자가 왼쪽으로 스크롤하면 before= 호출이 정상.

주의 · 우회

초기 진입에 before=가 보이면 suppress 타이밍을 의심. 스크롤 중 before=는 정상.

후속

가격 SSE(티커+차트 presence=0) 통합은 별도.

c21002026-08-12차트 · 캔들

[결정] 차트 과거 봉은 Mongo candle_bars — 첫 로드·스크롤 페이지 60

브라우저 → trade-api GET /api/markets/candles → DB. 화면에 보이는 건 20봉이지만 페이지는 60봉(이전 120).

해결

배경 · 내용

정본은 Mongo `candle_bars`(봉 1문서). ingest가 실시간 mid로 쓰고, 과거 대량은 scripts/backfill-fills. 예외: 최초 조회인데 봉이 거의 없으면 Manifest mid RPC로 시드한 뒤 DB에 채운다. 그다음 스크롤(before/after)은 다시 Mongo만.

주의 · 우회

차트가 비면 MONGO_URL·ingest·backfill을 본다. RPC 시드는 cold start뿐.

후속

VISIBLE_BARS=20 · CANDLE_PAGE_SIZE=60 · CANDLE_WINDOW_MAX=360.

c20002026-08-12아키텍처 · 스케일

[결정] Railway replica — realtime·trade-api·web OK, ingest는 복제 금지

부하가 큰 서비스를 복사본으로 늘린다. ingest를 늘리면 Solana WS가 마켓당 N배가 된다.

해결

배경 · 내용

replica = 같은 코드의 인스턴스 개수. realtime(SSE)·trade-api(REST)·web은 수평 확장. ingest는 마켓당 구독 1개 유지. load-test는 HTTP 서버가 아니라 scripts/load-orders.ts 원샷. 상세는 /architecture.

후속

동접이 커지면 realtime·trade-api부터 replica. ingest는 1대 또는 마켓 샤딩.

c19002026-08-11부하테스트

[결정] 주문 부하는 HTTP 서버가 아니라 scripts/load-orders.ts

services/load-test Express(POST /api/orders/load-test)는 삭제. 같은 프로세스에서 executeServerPlaceOrder를 동시 호출한다.

해결

배경 · 내용

로컬 tsx는 RPS·지연이 배포와 달라 실측이 아니다. Railway load-test 서비스가 기동 시 스크립트를 한 번 실행하고 종료(restart NEVER). LOAD_TEST_COUNT·LOAD_TEST_MARKET·WALLET_SECRET_KEY·SOLANA_RPC_URL.

후속

실측은 railway up --service load-test. 건수는 Variables의 LOAD_TEST_COUNT.

c18002026-08-11차트 · 모바일

[함정] 실기기 기간 버튼 줄바꿈 ≠ DevTools 기기 프리셋

폰이 물리적으로 더 커도 CSS 폭은 더 좁을 수 있다. PC DevTools Galaxy S20 Ultra(412px)와 실기기(Phantom)는 같은 화면이 아니다.

완화됨

배경 · 내용

기간 버튼은 `.candle-resolutions { flex-wrap: wrap; max-width: 70%; justify-content: flex-end }`라서 8개가 안 들어가면 1W만 다음 줄 오른쪽으로 떨어진다. DevTools 프리셋 폭(S20 Ultra=412 CSS px)은 레이아웃용이지 실기기 mm가 아니다. S23 Ultra는 패널이 더 크고 PPI가 높아 CSS 폭이 384px대인 경우가 많고, 삼성 화면 크기/글자 크기·Android text-size-adjust가 10px 버튼을 더 넓힌다. Phantom 인앱 웹뷰는 데스크톱 Chrome 디바이스 모드와 폰트·뷰포트가 다르다.

주의 · 우회

비교할 때 DevTools 기기 이름 대신 `innerWidth`·`devicePixelRatio`를 본다. 프리셋 125%는 폰 프레임 확대일 뿐 CSS 폭을 바꾸지 않는다.

후속

≤720만 기간 nowrap·OHLC ellipsis·text-size-adjust 100%. PC(1101+)는 기존 wrap 유지.

c17002026-08-11트레이드 UI · 레이아웃

[결정] PC 레이아웃은 태블릿~풀HD를 비율 보간 — 줌 전용 분기는 없다

노트북 작은 화면·브라우저 125~150% 줌까지 화면마다 맞추지 않는다. 줌은 CSS 뷰포트가 작아진 것과 같아서 vw/vh clamp로 같이 커버한다. 페이지 전체 zoom/scale은 쓰지 않는다(줌과 겹침).

해결

배경 · 내용

1101~풀HD: 호가·주문 폭 `clamp(280px, 28vw, 440px)`, 미체결 높이 `clamp(240px, 36vh, 480px)`. QHD+는 상한(440/480) 유지. ≤720 모바일 셸, 721–1100 태블릿 스택은 그대로.

후속

풀HD 100%·150%, 1366 노트북, QHD에서 차트 폭·호가 스크롤만 스모크.

c16002026-08-11트레이드 UI · 레이아웃

[결정] PC 미체결 높이 — 풀HD~QHD는 vh 보간, QHD+는 480px

미체결을 480px 고정하면 풀HD에서 호가가 눌려 스크롤이 난다. QHD 비율은 유지하고 그보다 낮은 세로는 36vh로 줄인다.

해결

배경 · 내용

`--bottom-h: clamp(240px, 36vh, 480px)`. 태블릿(721–1100)은 세로 스택이라 height:auto 그대로.

후속

풀HD에서 차트·호가 공간이 충분한지, QHD에서 미체결이 이전과 비슷한지 확인.

c15002026-08-11헤더 · presence

[함정] 헤더 Online — 는 지갑 연결과 무관 · realtime SSE

지갑이 연결되어 있어도 Online이 — 이면 탭 presence SSE가 안 붙은 것이다. realtime(:4002) `/presence/stream`을 본다.

미해결

배경 · 내용

Online은 접속 탭 수다. 첫 SSE 이벤트 전·realtime 미기동·EventSource 실패면 null이라 — 로 둔다. 로컬에서 web·trade-api·ingest만 살아 있고 4002가 LISTEN이 아니면 이 증상.

주의 · 우회

`npm run dev`는 web·api·rt·ingest를 같이 켠다. 로그에 `[rt] listening`이 없고 4002가 비면 realtime이 죽은 것. Redis error에 rt만 내려가던 문제는 핸들러로 막음.

c14002026-08-11차트 · 모바일

[결정] TradingView 고지는 차트 안 로고 — 툴바 문구 금지

툴바에 「차트: TradingView」를 넣으면 모바일에서 OHLC·해상도와 겹쳐 두 줄이 된다. 차트 안 attribution 로고로 되돌린다.

해결

배경 · 내용

lightweight-charts `attributionLogo: true`. 툴바는 OHLC와 간격 버튼만.

후속

툴바에 TV 문구를 다시 넣지 않는다.

c13002026-08-11트레이드 UI

[결정] 첫 진입 전체 화면 로딩은 두지 않는다

유저가 처음 와도 스플래시·전체 스켈레톤은 없다. 셸·주문창은 바로 그리고, 차트·호가·지갑만 뒤에서 채운다.

해결

배경 · 내용

마켓 목록이 번들에 있어 크롬은 즉시 그릴 수 있다. 전체를 기다리면 체감만 느리고 지갑 연결·마켓 선택까지 막힌다. 차트는 배지 Loading…, 호가는 비었다가 채움, 지갑 복원은 조용히. 신경 쓸 것은 잘못된 숫자 깜빡임(목업 lastPrice→실호가, SSR PC→모바일 셸)이지 전체 로딩이 아니다.

후속

가격/셸 플래시가 거슬리면 그때 해당 값만 비우거나 기본값을 맞춘다. 전체 로딩 오버레이는 넣지 않는다.

c12002026-08-06호가 · RPC

[함정] 호가창 빈 화면 ≠ 보안 패치 — Helius RPC 쿼터(429 max usage)

호가가 No orders로만 보이면 CSP/SSE 한도보다 Solana RPC(Helius) 크레딧·레이트리밋을 먼저 본다.

완화됨

배경 · 내용

로컬 `/api/markets/price`가 502 + `429 Too Many Requests: max usage reached`면 Helius 플랜 한도. Trade는 REST+SSE+fills+open-orders가 RPC를 같이 써서 쿼터가 빨리 닳는다. 보안 헤더·SSE 슬롯은 같은 증상처럼 보이지만, 이 경우 서버 로그에 Helius 429가 찍힌다.

주의 · 우회

Helius 대시보드에서 credits 확인·키 교체. 프로세스 내 호가 캐시(stale fallback)와 SSE 초기 loadFromAddress 제거로 중복 RPC를 줄여 둠.

후속

유료 RPC 여유 확보 후에도 비면 해당 마켓 on-chain L2가 실제로 비었는지(또는 Zenith 등 다른 배포) 확인

c11002026-08-06보안 · API

[결정] 서버 서명 API — INTERNAL_API_SECRET · 레이트리밋 · 보안 헤더

무인증 POST /api/orders·load-test·history를 시크릿으로 잠그고, tx/send·verify·SSE에 남용 한도를 걸었다. 상세는 /security-report.

완화됨

배경 · 내용

Trade UI는 prepare+지갑 서명 경로라 영향 없음. 부하테스트·스크립트는 헤더 x-internal-api-secret 필요. 키 로테이션(git .env 히스토리)은 운영 작업으로 남김(s0800).

주의 · 우회

로컬만 비상으로 ALLOW_OPEN_SERVER_ORDERS=true (프로덕션·Railway에서는 금지).

후속

Railway에 INTERNAL_API_SECRET 설정. WALLET_SECRET_KEY·RPC 키 재발급 권장. 동접·SSE는 /load-test-report.

c10002026-08-06지갑 · Phantom · 배포

[함정] Phantom — Railway `*.up.railway.app` 주문 시 ‘요청 차단됨’

dev URL(`web-dev-2f02.up.railway.app`)에서 매수 서명하면 Phantom이 ‘이 dApp은 악성일 수 있습니다’로 요청을 막는다. 주문 코드 오류가 아니다.

미해결

배경 · 내용

Phantom은 평판이 없는·공유 호스팅 도메인(Railway 기본 서브도메인 등)에 대해 서명/연결을 차단하거나 경고한다. 피싱에 자주 쓰이는 패턴이라 기본이 보수적이다. 같은 앱이라도 커스텀 도메인·화이트리스트 심사 후에는 덜 뜬다.

주의 · 우회

본인 배포임을 확인한 뒤 팝업 하단 ‘계속 진행(위험)’으로 진행. 반복되면 Phantom에서 해당 사이트를 신뢰/연결 앱으로 둔 뒤 재시도.

후속

프로덕션은 커스텀 도메인 + TLS. 필요 시 Phantom domain review 폼 제출.

c09102026-08-06Mongo · 차트

[결정] 차트 봉 — candle_bars 봉 단위 컬렉션으로 전환

기간별·해상도별 range 조회가 필요해져 candle_series(시리즈+bars[]) 대신 candle_bars(문서=봉 1개)를 정본으로 쓴다. Redis는 여전히 호가 pub/sub만 — 봉 저장과 무관.

완화됨

배경 · 내용

스키마: _id=`{market}:{resolution}:{time}`, pair/marketId 라벨, OHLC. 인덱스 {market,resolution,time} unique. mid 틱은 해당 버킷 문서만 upsert(같은 주면 H/L/C 갱신). API `/api/markets/candles` 응답 shape·before/after는 유지해 웹 차트 사이드이펙트 최소화. 레거시 candle_series는 조회 시 비어 있으면 1회 flatten 마이그레이션 후 신규 write는 bars만. fills와 별개(차트=mid OHLC).

주의 · 우회

Compass는 test(또는 revolution) DB의 candle_bars를 pair·resolution·time으로 조회. 구 candle_series는 마이그레이션 전 백업용으로 남을 수 있음.

후속

로컬·dev에서 차트 스크롤·해상도 전환 스모크. 과거 백필은 `backfill-fills.ts`가 fills 후 candle_bars를 체결 시간순으로 재구성(--candles-only 가능). WS mid 폭주 디바운스는 별도.

c08002026-08-06지갑 · Phantom

[함정] Phantom 완전 해지 후에도 재연결이 ‘바로 붙음’

disconnect만 하면 Wallet Standard에 accounts가 남아 connect가 승인 없이 기존 주소로 단축된다. Connected apps 철회는 웹 API로 불가.

해당 없음

배경 · 내용

구 코드: accounts[0] 있으면 connect() 스킵. 완전 해지 UI·forceFresh 실험은 제거(2026-08-06). 명시적 connect는 항상 connect() 호출(b0100). Phantom ‘Connected apps’에서만 사이트 철회 가능.

주의 · 우회

승인 UI가 필요하면 Phantom 앱 → 설정 → 연결된 앱에서 사이트 제거 후 재연결.

후속

웹 완전 해지 UI 제거. accounts 단축은 tryRestore(silent/onlyIfTrusted)만.

c06002026-08-06지갑 · 모바일

[함정] 모바일 Phantom — 웹은 앱 설치를 알 수 없고 provider 주입만 감지

Chrome 등 일반 모바일 브라우저에서는 Phantom 앱이 설치돼 있어도 `window.phantom`이 없다. ‘미설치’로 보이면 OS 설치 여부가 아니라 provider 미주입이다.

완화됨

배경 · 내용

Phantom 공식: provider는 확장·인앱 브라우저에서만 inject. OS 앱 설치 여부를 웹 JS로 조회하는 API는 없다. `isPhantomInstalled()`는 Wallet Standard/`isPhantom`만 본다. 모바일 일반 브라우저는 browse 딥링크(`phantom.app/ul/browse/<url>?ref=`)로 인앱을 열게 하고, 미설치 문구는 PC(확장 없음)에만 쓴다.

주의 · 우회

모바일: 모달에서 ‘Phantom 앱에서 열기’ → 인앱에서 Connect. 앱 없으면 iOS는 App Store, Android는 Play Store. 또는 Phantom 앱 브라우저로 사이트를 직접 연다.

후속

iOS도 browse 딥링크 + App Store 버튼 연결(2026-08-14). 현재 Phantom 앱은 iOS 16+.

c05002026-08-06헤더 · 모바일

[함정] 모바일 헤더 Online — 문서만 제거·platform 미커밋으로 ‘원복’처럼 보임

Online은 PC만 표시하기로 했는데 MobileTradeShell에 online-pill이 다시 있어 ‘Phantom이 되돌린 것’처럼 보였다. 실제는 제거가 커밋·배포에 안 들어간 것.

해결

배경 · 내용

extras e0200·에이전트 응답상 모바일 Online 제거로 적혀 있었으나, manifast-platform git에는 제거 커밋이 없다(`git log -S online-pill -- MobileTradeShell` = 추가만 `3bdb592`). 제거는 2026-08-05 로컬 편집만 하고 커밋/푸시하지 않았고, 버전 bump(0.1.2)에도 MobileTradeShell이 포함되지 않았다. Phantom 커밋(`467957b`)은 online-pill을 변경하지 않음 — 부모부터 계속 존재.

주의 · 우회

모바일 정책 변경은 문서(extras)와 같은 날 platform 커밋까지 맞춘다.

후속

2026-08-06 MobileTradeShell에서 online-pill·onlineCount 재제거. 버그 재현 이슈는 b0200.

c04002026-08-06지갑 · web

[적용] Phantom 지갑 — 연결·미설치 안내·해지 모달 (PC·Android)

Zenith web이 MetaMask 경로를 제거하고 Phantom(Wallet Standard + 주입 provider)으로 통일. 연결 해지는 확인 모달. 모바일 미설치 시 설치 안내·Play Store·인앱 딥링크.

완화됨

배경 · 내용

`phantomSolana.ts`: Standard `standard:connect` / `window.phantom.solana`·`window.solana.isPhantom` 폴백. `@metamask/connect-solana` 제거. SelectWalletModal은 미설치 시 설치 문구·다운로드. Android는 `phantom.app/ul/browse/...` 옵션. 헤더 주소 클릭 → DisconnectWalletModal → 확인 시에만 disconnect. iPhone 딥링크는 보류.

주의 · 우회

일반 Chrome(모바일)에서는 Phantom이 주입되지 않음 → 설치 안내 또는 ‘Phantom 앱에서 열기’. PC는 Phantom 확장 필요.

후속

iOS Safari/딥링크 QA는 이후. Android·PC Phantom 확장으로 서명·주문 스모크.

c03002026-08-05Mongo · 개발 규칙

[함정] Mongo 시각 — BSON Date는 UTC, 한국시간은 *Ts/*Kst 쌍

Compass에서 Date 필드가 UTC로 보이거나 ‘한국시간 Date’를 기대하면 헷갈린다. BSON Date는 항상 UTC epoch이라 타임존을 담을 수 없다.

해결

배경 · 내용

Zenith Mongo(`daily_visitors` · `fills` · `candle_series`)는 `*Ts`(Unix ms, 정렬·비교·로직) + `*Kst`(Asia/Seoul `YYYY-MM-DD HH:mm:ss`, Compass 확인용)를 쓴다. 유틸: `@zenith/shared` `time.ts`. 일 경계(`daily_visitors`의 date/_id)는 KST 날짜 키. `blockTime`은 온체인 Unix 초 그대로 두고 표시만 `blockTimeKst`. 구 문서의 `createdAt`/`updatedAt` Date는 남을 수 있음 — 신규 write부터 새 필드.

주의 · 우회

Compass에서 사람 시간은 `*Kst` 필드를 본다. 코드·쿼리·인덱스는 `*Ts`만 쓴다. `*Kst` 문자열로 정렬·필터하지 않는다.

후속

적용됨(2026-08-05). 이후 Mongo 시각 필드는 같은 패턴으로 추가.

c02002026-08-05지갑 · 제품 정책

[결정] 지갑 — PC·모바일 Phantom 통일 (MetaMask 1단계 철회)

Zenith 1단계 지갑을 Phantom(PC 확장 + 모바일 앱)으로 통일. MetaMask 1단계는 철회.

해결

배경 · 내용

Manifest.trade는 PC MetaMask·모바일 Phantom처럼 환경마다 지갑이 갈린다. MetaMask Solana는 모바일·connect-solana QA 부담이 크고, Phantom은 PC 확장과 모바일 앱을 같은 플로우로 쓸 수 있다. dev 단계에서는 지갑 경로를 하나로 두는 편이 낫다고 판단해 Phantom only로 결정했다.

주의 · 우회

PC는 Phantom 확장, Android는 Phantom 앱(또는 인앱 브라우저). iPhone은 추후.

후속

코드 적용됨(2026-08-06). c0400 참고.