COINFLOW — 암호화폐 실시간 시세
업비트 공개 API로 암호화폐 실시간 시세를 보여주는 웹앱입니다.
안내 — UI 레이아웃은 토스증권 데스크톱 화면을 참고한 학습용 클론입니다.
토스증권과는 무관하며, 시세 데이터는 업비트 공개 API(공포·탐욕 지수만 alternative.me)에서 가져옵니다.
목차
- 프로젝트 개요
- 주요 기능
- 기술 스택과 선택 이유
- 아키텍처
- 성능 최적화
- 접근성
- 실행 방법
프로젝트 개요
- 업비트 KRW 마켓의 코인 목록과 시세를 REST로 한 번 받아 오고, 이후 WebSocket으로 실시간 갱신합니다.
- 퍼블리셔에서 프론트엔드 개발자로 전환하는 과정에서 만든 개인 포트폴리오입니다.
- 데스크톱 전용(최소 1280px)이며 모바일 대응은 하지 않았습니다.
주요 기능
홈
- KRW 마켓 전체 시세 테이블 — 현재가 · 등락률 · 고가 · 저가 · 일중 위치 · 거래대금 · 거래량
- 등락률이 바뀌면 셀 배경이 상승/하락색으로 깜빡임 (가격엔 색을 입히지 않고 등락률에만 — 토스 규칙)
- 상단 시장 요약 — 대표 코인 스파크라인, 상승/하락 종목 수, 공포·탐욕 지수
검색
/ 키 또는 GNB 버튼으로 여는 검색 팝업
- 입력 전엔 인기 코인(거래대금 순) · 급상승 코인 TOP 5, 입력하면 한글명/심볼로 필터
- ↑↓ 탐색 · Enter 이동 · ESC 지우기/닫기, 한글 조합 중 키 입력 무시
관심 · 최근 본
- 오른쪽 패널에서 관심 코인과 최근 본 코인을 현재가 / 전일 대비 2줄로 표시 (localStorage 유지)
코인 상세
- 헤더 — 현재가 · 어제 대비 · 1일/52주 범위 막대 · 거래대금 순위 · 체결강도 · 오늘 매수/매도 체결량
- 패널 대시보드
- 차트 — 분/일/주/월/년 캔들 (lightweight-charts)
- 기간별 수익률 — 1주 · 1개월 · 3개월 · 6개월 · 1년 전 대비 등락률
- 시세 — 실시간 체결 / 일별 시세 전환
- 매수·매도 비중 — 오늘 누적 · 최근 N건 비율, 큰 체결 TOP 5
- 종목정보 — 시가 · 전일 종가 · 24h 거래대금/거래량 · 52주 최고/최저와 날짜
공통
- 다크 / 라이트 테마 (OS 설정 자동 감지 · localStorage 유지 · FOUC 없음)
기술 스택과 선택 이유
| 분류 | 사용 기술 |
|---|
| 빌드 | Vite 8 |
| UI | React 19, TypeScript 6 |
| 라우팅 | react-router-dom 7 (선언형 라우팅) |
| 전역 상태 | Zustand 5 |
| 스타일 | CSS Modules + CSS 변수 디자인 토큰 |
| 차트 | lightweight-charts 5 |
| 폰트 | Pretendard (npm 패키지로 자체 호스팅) |
| 포맷터 | Prettier (홑따옴표·세미콜론 없음·4칸) |
Next.js가 아닌 Vite
실시간 시세는 브라우저가 WebSocket으로 받아 그리는 클라이언트 중심 앱이라 SSR이 필요하지 않았습니다. 설정이 가벼운 Vite SPA로 충분하다고 판단했습니다.
Context가 아닌 Zustand
Context는 값이 바뀌면 그 Context를 구독하는 컴포넌트가 전부 리렌더됩니다. 시세는 초당 수십 번 갱신되기 때문에, 컴포넌트가 필요한 값만 골라 구독하는 선택적 구독이 필요했습니다.
// 각 행은 자기 코인의 시세만 구독
const live = useMarketStore((s) => s.liveMap[market])
Tailwind가 아닌 CSS Modules
퍼블리셔로 쌓아 온 CSS 설계 역량을 드러내고 싶었습니다. 색상·간격 등은 tokens.css의 CSS 변수로 관리하고, 다크/라이트 테마는 data-theme 속성으로 토큰 값을 바꿉니다.
아키텍처
데이터 공급과 소비의 분리
AppLayout
└─ useMarketFeed() ← 앱 전체에서 한 번만 호출
├─ REST 스냅샷 (1회) : 목록 · 순서 · 한글명
└─ 스트림 ① ticker : KRW 전체 시세, 200ms 배칭
│
▼
Zustand 스토어 (market)
│ 읽기만 함
┌────────┬─────────┬──────────────┬─────────────────┬─────────────┐
Home MarketRow TickerBar MarketSummary 관심·최근 사이드바 검색 팝업
CoinDetail (/coins/:market) ← 상세 페이지에 있는 동안만
├─ 스토어 liveMap[market] → 스트림 ①에서 이 코인만 구독
└─ useUpbitTrades(market) → REST 최근 100건 + 스트림 ② trade : 체결 내역 (200ms 배칭)
- 공급:
AppLayout에서 useMarketFeed()를 한 번 호출합니다. 이 훅이 REST 스냅샷을 1회 받고 스트림 ①을 구독해 스토어에 기록합니다.
- 소비: 목록성 화면(홈 테이블, 티커 바, 시장 요약, 사이드바, 검색 팝업)은 스토어에서 읽기만 합니다. 상세 페이지의 거래대금 순위도 이 스토어로 계산합니다.
- 역할 분담: REST는 목록·순서·한글명을 담당하고(1회), WebSocket은 시세 갱신을 담당합니다(지속).
소켓 구성 — 연결은 하나로, 모든 스트림은 200ms 배칭
초기에는 시세가 필요한 컴포넌트마다 각자 WebSocket을 열어서, 같은 데이터를 받는 소켓이 3개 동시에 떠 있었습니다. 그래서 데이터를 받아 오는 곳(공급) 과 그리는 곳(소비) 을 나눠, 시세는 스트림 ① 하나를 스토어로 공유하도록 통합했습니다.
| 스트림 | 대상 | 반영 방식 | 수명 |
|---|
| ① ticker | KRW 전체 | 200ms 배칭 → 스토어 (상세 페이지도 여기서 자기 코인만 구독) | 앱 전체 |
| ② trade | 상세 코인 체결 | 200ms 배칭 → 최근 100건, 페이지 로컬 상태 | 상세 페이지 |
- 한때 상세 코인 시세를 메시지마다 즉시 반영하는 스트림을 따로 뒀지만, 초당 수십 번 상세 페이지 전체(헤더 + 패널 5개)를 리렌더시키면서 얻는 건 최대 200ms 빠른 표시뿐이라 제거하고 ① 스토어에서 읽도록 바꿨습니다. 체결 스트림도 같은 이유로 배칭합니다.
- 전 종목 집계(상승·하락 종목 수, 전체 거래대금, 거래대금 순위)는 배치를 반영할 때 스토어에서 한 번만 계산합니다. 컴포넌트는 결과 원시값만 구독해 값이 실제로 바뀔 때만 리렌더됩니다.
- ②는 effect cleanup에서 구독 해제되므로, 상세 페이지를 벗어나면 스트림 ①만 남습니다.
물리적 연결은 1개입니다. 업비트는 웹사이트 Origin으로 들어온 브라우저 소켓을 동시에 1개 정도만 허용해서(localhost는 예외라 배포 후에야 드러남), 스트림마다 소켓을 열면 상세 페이지에서 연결 직후 끊겼습니다. 그래서 upbitSocket.ts가 연결 하나를 공유하고,
- 구독이 바뀌면 ①②를 합친 구독 메시지만 다시 전송합니다 (코인 이동 시 재연결 없음)
- 받은 메시지를
type·code가 맞는 구독자에게만 전달합니다
- 연결이 끊기거나 거부되면 1s → 2s → 4s … (최대 30s) 간격으로 재연결하고 구독을 복원합니다
API 프록시
브라우저에서 업비트 REST를 직접 호출하면 요청에 Origin 헤더가 붙습니다. 업비트는 Origin이 있는 요청을 별도 그룹으로 분류해 초당 약 1회로 제한하는데, 코인 목록(200개 이상)과 실시간 시세를 동시에 받아야 하는 이 앱에서는 한계가 금방 옵니다. api/upbit.ts(Vercel 서버 함수)를 프록시로 두어 서버에서 Origin 없이 업비트를 호출하고 응답을 그대로 넘기는 방식으로 이를 우회했습니다.
브라우저 → /upbit/:path → api/upbit.ts → api.upbit.com/v1/:path
(Origin 없음)
설계에서 신경 쓴 세 가지입니다.
열린 프록시 방지
경로를 그대로 포워딩하면 누구나 업비트의 임의 엔드포인트를 이 서버를 통해 호출할 수 있습니다. ALLOWED 정규식으로 이 앱이 실제로 쓰는 경로만 허용하고, 그 외 요청은 400으로 거부합니다.
const ALLOWED = /^(market\/all|ticker|trades\/ticks|candles\/(minutes\/(1|15|60)|days|weeks|months))$/
엔드포인트별 엣지 캐시 분리
모든 응답에 같은 캐시 정책을 적용하면 시세가 낡거나 마켓 목록이 과도하게 재요청됩니다. 변경 주기가 다른 엔드포인트를 다르게 처리했습니다.
| 엔드포인트 | s-maxage | 이유 |
|---|
market/all | 3600s | 마켓 목록은 하루에도 거의 안 바뀜 |
| 시세 · 캔들 | 1s | 실시간성이 중요하므로 거의 무효화 |
This is the beginning of the README. Read the rest at the original. Read the full README ↗