← Blog

모바일 WebView에서 부드러운 캔들스틱 차트 구현하기 — 4편: 차트 렌더링과 애니메이션

시리즈 · 앱인토스 게임 개발기 4/535편 →
목차

차트가 게임의 얼굴이다

차트예측게임에서 차트는 단순한 데코레이션이 아닙니다. 플레이어가 판단의 근거로 삼는 핵심 정보이자, 게임 화면의 절반 이상을 차지하는 메인 비주얼입니다. 차트가 뭉개지거나 버벅거리면 게임 전체의 신뢰도가 떨어집니다.

특히 이 게임은 토스 앱의 WebView 위에서 동작합니다. 네이티브 앱이 아니라 웹이라는 제약 속에서, 100개의 캔들스틱을 부드럽게 그리고, 결과 공개 시 애니메이션까지 매끄럽게 처리해야 했습니다. 이번 편에서는 그 여정을 이야기하겠습니다.

2. 차트 라이브러리 선택: lightweight-charts

왜 범용 차트 라이브러리를 쓰지 않았나

Chart.js, Recharts, Nivo 등 React 생태계에는 좋은 차트 라이브러리가 많습니다. 하지만 이들은 대부분 범용 차트에 초점이 맞춰져 있습니다. 캔들스틱 차트를 지원하더라도, 금융 차트에 특화된 기능(거래량 히스토그램, 크로스헤어, 가격 스케일 등)은 부족하거나 커스터마이징이 까다롭습니다.

lightweight-charts는 TradingView가 만든 오픈소스 차트 라이브러리입니다. 이름처럼 가볍고(약 45KB gzipped), Canvas 기반으로 렌더링하기 때문에 DOM 조작 오버헤드 없이 수백 개의 캔들도 부드럽게 그릴 수 있습니다.

선택 이유를 정리하면:

  • 캔들스틱 차트 네이티브 지원: 별도 플러그인 없이 OHLC 데이터를 바로 시각화
  • 거래량 히스토그램 통합: 차트 하단 20%에 거래량을 오버레이로 표시 가능
  • Canvas 기반 고성능: SVG 대비 대량 데이터 렌더링에 유리
  • 반응형 리사이즈: 컨테이너 크기 변경 시 자동으로 재조정
  • 미니멀 API: 설정할 것이 적어 빠르게 통합 가능

차트 컴포넌트 설계

차트 컴포넌트는 React와 lightweight-charts 사이의 브릿지 역할을 합니다. 핵심 동작을 정리하면:

[차트 컴포넌트 생명주기]

마운트 시:
  1. 차트 인스턴스 생성 (Canvas 엘리먼트에 바인딩)
  2. 캔들스틱 시리즈 추가
  3. 거래량 히스토그램 시리즈 추가
  4. 초기 스타일 설정 (색상, 그리드, 패딩 등)

데이터 변경 시:
  1. 캔들스틱 데이터 업데이트 (visibleCandles 만큼만)
  2. 거래량 데이터 동기화
  3. fitContent() 호출 → 자동으로 뷰포트 조정
  4. (결과 공개 시) 트렌드라인 시리즈 추가

언마운트 시:
  1. 차트 인스턴스 제거
  2. Canvas 메모리 해제

90개 → 100개 캔들 전환

게임에서 가장 인상적인 시각적 순간은 결과 공개 시점입니다. 플레이어가 예측 버튼을 누르면, 90개였던 캔들이 100개로 늘어나면서 나머지 10개가 드러납니다.

이 전환을 자연스럽게 만들기 위해, 데이터를 한 번에 교체하는 대신 visibleCandles 상태 값을 90에서 100으로 바꾸는 방식을 택했습니다. 차트 컴포넌트는 이 값의 변화를 감지해서 데이터를 슬라이싱하고, fitContent()로 뷰포트를 재조정합니다.

[결과 공개 흐름]

PLAYING 상태:
  visibleCandles = 90
  → 차트 데이터: scenario.data.slice(0, 90)
  → 거래량 데이터: 90개

makePrediction() 호출:
  visibleCandles → 100
  phase → 'revealing'

REVEALING 상태:
  visibleCandles = 100
  → 차트 데이터: scenario.data.slice(0, 100)
  → 거래량 데이터: 100개
  → 트렌드라인: 마지막 10개 캔들에 선형 회귀선 표시

트렌드라인 오버레이

결과 화면에서 노란색 트렌드라인은 별도의 라인 시리즈로 추가됩니다. lightweight-charts는 하나의 차트에 여러 시리즈를 겹칠 수 있어서, 캔들스틱 위에 라인 시리즈를 오버레이하는 것이 간단했습니다.

트렌드라인은 마지막 10개 캔들의 시간축에 매핑되며, 2편에서 설명한 선형 회귀 결과(기울기와 절편)를 기반으로 시작점과 끝점 두 개의 좌표를 계산해서 그립니다.

차트에서 날짜를 지워야 했던 이유

기술적으로 가장 신경 쓴 건 렌더링 성능이 아니라 날짜를 숨기는 일이었습니다.

실제 한국 주식 데이터를 쓴다는 건 정답이 이미 세상에 공개돼 있다는 뜻입니다. 차트 하단에 "2023년 3월" 같은 게 찍혀 있고 종목이 삼성전자라면, 검색 한 번으로 다음 열흘이 어땠는지 알 수 있습니다. 게임이 성립하지 않습니다.

그래서 시간축을 통째로 껐습니다.

timeScale: {
  visible: false,        // 하단 시간축 자체를 숨김
  timeVisible: false,
  secondsVisible: false,
},
crosshair: {
  vertLine: {
    labelVisible: false, // 크로스헤어의 날짜 라벨도 숨김
  },
},

timeScale.visible: false 하나로 끝날 줄 알았는데 아니었습니다. 차트를 손가락으로 짚으면 크로스헤어가 따라오면서 세로선 아래에 날짜 라벨이 뜹니다. 시간축을 껐는데도 이건 별도 옵션이라 따로 꺼야 했습니다. 테스트하다가 우연히 차트를 눌러 보고 발견했습니다.

같은 이유로 캔들 시리즈의 lastValueVisiblepriceLineVisible도 껐습니다. 마지막 값 라벨이 오른쪽 가격축에 붙어 있으면, 아직 안 보여준 구간이 있는데도 시선이 그쪽으로 끌립니다.

가격 표기는 반대로 손을 더 댔습니다. 기본값은 소수점이 붙은 영문 포맷이라 한국 주식에 안 맞아서 포맷터를 직접 넣었습니다.

localization: {
  priceFormatter: (price: number) => price.toLocaleString('ko-KR') + '원',
},

52,300원처럼 보이는 것만으로 "진짜 한국 주식 데이터"라는 느낌이 확 살아납니다.

이 방식의 한계

솔직히 말하면 이건 UI에서만 가린 것입니다. 번들에 들어 있는 JSON에는 실제 날짜가 그대로 있고, 개발자 도구를 열 줄 아는 사람은 언제든 확인할 수 있습니다.

나중에 같은 게임을 웹으로 다시 만들 때는 이 부분을 고쳤습니다. 서버에서 데이터를 내려줄 때 모든 타임스탬프를 통째로 옮겨서 2000년 1월 1일부터 시작하는 것처럼 만들었습니다. 캔들 간격은 그대로라 차트 모양은 안 바뀌고 절대 시점만 사라집니다.

앱인토스 버전에서 그렇게 하지 않은 건 데이터를 빌드 타임에 번들로 넣는 구조라 손댈 지점이 애매했기 때문인데, 지금 돌아보면 JSON을 만드는 단계에서 미리 옮겨 두면 될 일이었습니다. 가리는 것과 없애는 것은 다르다는 걸 두 번째 구현에서야 제대로 정리했습니다.

3. 게임 애니메이션 설계

framer-motion을 선택한 이유

게임에서 애니메이션은 피드백입니다. 플레이어의 행동에 시각적으로 반응해야 "게임을 하고 있다"는 느낌이 듭니다. CSS 트랜지션으로도 기본적인 효과는 가능하지만, 스프링 물리학 기반의 자연스러운 모션, 조건부 애니메이션, mount/unmount 전환 등은 framer-motion이 압도적으로 편합니다.

애니메이션 카탈로그

게임에서 사용한 주요 애니메이션을 정리하면:

하트 애니메이션

하트를 잃을 때:
  → 해당 하트 아이콘이 좌우로 흔들리며(shake) 사라짐
  → 남은 하트는 스케일이 살짝 커졌다 줄어듦(pulse)
  → 스프링: stiffness 300, damping 10

왜 이렇게 했는가:
  → 단순히 하트 개수만 줄이면 변화를 알아채기 어려움
  → 흔들림은 "위험!" 시그널을 직관적으로 전달
  → 마지막 하트의 pulse는 "이제 한 개밖에 없다"는 긴장감 강화

점수 팝업

정답을 맞혔을 때:
  → "+100" 텍스트가 아래에서 위로 올라오며 나타남
  → 0.8초 후 위로 올라가며 투명해짐
  → 콤보 보너스가 있으면 "+150 🔥" 형태로 표시

초기값: opacity 0, y +20
목표값: opacity 1, y 0
퇴장값: opacity 0, y -30

결과 배너

결과 공개 시:
  → "정답!" 또는 "오답!" 배너가 스케일 0에서 1로 팝업
  → 스프링: stiffness 260, damping 20 (약간의 바운스 효과)
  → 정답이면 초록색, 오답이면 빨간색 배경

왜 스프링을 쓰는가:
  → ease-out 같은 곡선보다 바운스가 있는 스프링이
    "짠!" 하는 느낌을 더 잘 전달

화면 플래시

정답을 맞혔을 때:
  → 화면 전체에 반투명 흰색 오버레이가 0.15초간 나타났다 사라짐
  → 일종의 "카메라 플래시" 효과
  → 시각적 보상감을 극대화

타이밍이 중요:
  → 너무 길면 (0.5초 이상) 거슬림
  → 너무 짧으면 (0.05초 이하) 인지 못함
  → 0.15초가 "인지는 되지만 방해는 안 되는" 적정 시간

confetti 효과

높은 콤보 달성 시:
  → canvas-confetti 라이브러리로 화면에 색종이 효과
  → 파티클 수: 50~100개
  → 방향: 화면 중앙에서 방사형으로 퍼짐
  → 색상: 게임 테마에 맞는 초록/파랑/금색

사용 시점:
  → 3콤보 이상 달성 시 트리거
  → 게임 오버 시 최종 결과에서 트리거 (성과에 따라)

4. 모바일 최적화: 저사양 기기와의 전쟁

문제의 발견

PC 브라우저에서는 모든 애니메이션이 부드럽게 동작했습니다. 하지만 실제 토스 앱의 WebView에서 테스트하자 문제가 드러났습니다.

특히 중저가 Android 기기에서:

  • 결과 공개 시 여러 애니메이션이 동시에 실행되면서 프레임 드랍 발생
  • 캔들스틱 100개 + 트렌드라인 + 결과 배너 + 점수 팝업이 한꺼번에 렌더링
  • 체감 프레임이 30fps 이하로 떨어지는 구간 존재

최적화 전략 1: 애니메이션 스태거링

모든 애니메이션을 동시에 실행하지 않고, 시간 차를 두고 순차적으로 실행하도록 변경했습니다.

[최적화 전 - 동시 실행]
0ms: 차트 데이터 업데이트 + 트렌드라인 + 결과 배너 + 점수 팝업 + 플래시
→ 한 프레임에 모든 것이 변경 → GPU/CPU 스파이크

[최적화 후 - 스태거링]
0ms:   차트 데이터 업데이트 (100개 캔들)
100ms: 트렌드라인 표시
200ms: 결과 배너 팝업
350ms: 점수 팝업
400ms: 플래시 효과 (정답인 경우)

→ 각 변경이 분산되어 프레임 유지

이렇게 시간 차를 두면 각 애니메이션의 초기 프레임 비용이 분산됩니다. 사용자 입장에서는 오히려 "결과가 순차적으로 공개되는" 연출처럼 느껴져서, 단순히 성능 최적화가 아니라 UX 개선이기도 했습니다.

최적화 전략 2: 스프링 파라미터 조정

framer-motion의 스프링 애니메이션은 stiffnessdamping 값에 따라 계산량이 달라집니다. stiffness가 높을수록 빠르게 목표값에 도달하지만, 그 과정에서 더 많은 프레임을 계산해야 합니다.

[스프링 파라미터 튜닝]

최초 설정 (PC에서 최적화):
  stiffness: 500, damping: 15
  → 높은 반동, 많은 프레임 계산
  → PC에서는 부드럽지만 모바일에서 버벅임

모바일 최적화 후:
  stiffness: 260, damping: 20
  → 적당한 반동, 빠르게 안정
  → 모바일에서도 60fps에 가까운 성능

damping을 올리면 반동(바운스)이 줄어들어 "톡" 하고 멈추는 느낌이 됩니다. 완전히 제거하면 밋밋해지지만, 20 정도면 미세한 바운스가 남아서 자연스러우면서도 빠르게 수렴합니다.

최적화 전략 3: Canvas 차트 리사이즈 제어

lightweight-charts는 컨테이너 리사이즈 시 자동으로 Canvas를 다시 그립니다. 하지만 게임의 페이즈 전환 시, 레이아웃이 바뀌면서 불필요한 리사이즈가 여러 번 트리거되는 문제가 있었습니다.

[리사이즈 이슈]

페이즈 전환 시:
  1. 기존 섹션 언마운트 → 컨테이너 높이 변화
  2. 새 섹션 마운트 → 컨테이너 높이 다시 변화
  3. 각 변화마다 차트 resize + 데이터 redraw 발생
  → 순간적으로 2-3번의 불필요한 리드로우

해결:
  차트 데이터가 실제로 변경될 때만 fitContent() 호출
  → useEffect의 의존성 배열을 정밀하게 관리
  → 컨테이너 크기 변화만으로는 전체 리드로우 하지 않음

5. 색상 시스템: 금융 차트의 컨벤션

색상은 의외로 오래 고민한 부분입니다. 한국 증시는 상승이 빨강, 하락이 파랑입니다. HTS를 오래 쓴 사람에게는 이게 몸에 배어 있습니다. 반면 글로벌 차트는 상승이 초록, 하락이 빨강입니다.

한국 주식 데이터를 쓰는 게임이니 한국 컨벤션을 따르는 게 맞아 보였지만, 결국 초록/빨강으로 갔습니다.

const candlestickSeries = chart.addSeries(CandlestickSeries, {
  upColor: '#22c55e',      // 상승 — 초록
  downColor: '#ef4444',    // 하락 — 빨강
  borderUpColor: '#22c55e',
  borderDownColor: '#ef4444',
  wickUpColor: '#22c55e',
  wickDownColor: '#ef4444',
  lastValueVisible: false,
  priceLineVisible: false,
});

이유는 두 가지입니다. 첫째, 이 게임의 버튼이 LONG / SHORT입니다. 국내 주식 매매가 아니라 방향을 맞히는 게임이고, 그 어휘 자체가 글로벌 파생 시장의 것입니다. 여기에 한국식 색을 섞으면 오히려 어긋납니다.

둘째, 게임에서 빨강은 거의 항상 "실패"나 "위험"입니다. 하트가 깎이는 것도 빨강이고 오답 배너도 빨강인데, 상승 캔들까지 빨강이면 화면에서 빨강의 의미가 두 개가 됩니다. 한 화면 안에서 같은 색이 상반된 뜻을 가지면 안 된다는 쪽이 더 중요하다고 봤습니다.

배경은 투명으로 두고(뒤의 카드 UI가 비치도록), 그리드는 rgba(255,255,255,0.05)까지 낮춰서 거의 안 보이게 했습니다. 캔들 말고는 아무것도 눈에 걸리지 않는 게 목표였습니다.

결과 화면에서는 차트를 작게

차트 컴포넌트에 compact라는 불리언 프롭을 하나 뒀습니다. 하는 일은 높이를 바꾸는 것뿐입니다.

const chartHeight = compact ? 200 : 300;

게임 중에는 300px로 화면을 크게 쓰고, 결과 화면에서는 200px로 줄입니다. 결과 화면에는 차트 말고도 보여줄 게 많기 때문입니다. 맞았는지 틀렸는지, 획득 점수, 콤보, 남은 하트, 그리고 다음 라운드 버튼까지 한 화면에 들어가야 합니다.

처음에는 결과 화면에서도 차트를 그대로 두려고 했습니다. 방금 맞힌 그 차트를 크게 보여주는 게 성취감에 좋을 거라 생각했으니까요. 그런데 작은 기기에서 테스트해 보니 다음 라운드 버튼이 화면 밖으로 밀려났습니다. 스크롤을 해야 게임이 진행되는 상황이었습니다.

프롭 하나로 해결할 수 있었던 건 차트 컴포넌트가 처음부터 높이를 밖에서 받도록 돼 있었기 때문입니다. 만약 컴포넌트 안에 height: 300이 하드코딩돼 있었다면 결과 화면용 컴포넌트를 따로 만들 뻔했습니다. 바꿀 가능성이 있는 값은 밖으로 빼둔다는 게 이런 데서 값을 합니다.

차트 인스턴스는 언마운트 시 반드시 정리해야 합니다. lightweight-charts는 Canvas와 이벤트 리스너를 직접 잡고 있어서, React가 DOM을 치워도 인스턴스가 남으면 그대로 메모리에 붙어 있습니다. 라운드마다 화면을 오가는 게임에서는 이게 금방 쌓입니다. 그래서 차트·캔들·거래량·트렌드라인 시리즈를 각각 ref로 들고 있다가 정리 함수에서 전부 해제하도록 했습니다.

6. 글래스모피즘과 카드 UI

차트 바깥 영역의 UI는 글래스모피즘(Glassmorphism) 스타일을 적용했습니다. 반투명 배경에 미세한 블러 효과를 준 카드 형태입니다.

[글래스 UI 스타일]

카드 컨테이너:
  배경: 반투명 흰색 (rgba(0, 0, 0, 0.03))
  테두리: 1px solid rgba(0, 0, 0, 0.06)
  둥근 모서리: 16px
  그림자: 미세한 drop shadow

이유:
  → 토스 앱의 미니멀한 디자인 언어에 맞춤
  → 차트가 시각적 주인공이 되도록 나머지 UI는 절제
  → 카드 형태로 정보 영역을 구분하되 무겁지 않게

점수, 하트, 콤보 등의 배지도 같은 글래스 스타일을 적용해서, 전체적으로 통일감 있는 비주얼을 유지했습니다.

정리

이번 편에서 다룬 내용을 정리하면:

  • lightweight-charts: Canvas 기반 금융 차트 라이브러리로 100개 캔들을 고성능 렌더링
  • 90→100 캔들 전환: visibleCandles 상태로 제어해 자연스러운 결과 공개 연출
  • framer-motion 애니메이션: 스프링 물리학 기반의 자연스러운 게임 피드백
  • 모바일 최적화 3가지 전략: 애니메이션 스태거링, 스프링 파라미터 튜닝, Canvas 리사이즈 제어
  • 금융 차트 색상 컨벤션: 토스 증권과 동일한 초록(상승)/빨강(하락) 체계

프론트엔드 개발에서 성능 최적화는 대부분 "측정 → 병목 발견 → 해결"의 사이클입니다. 이번 프로젝트에서도 PC에서는 전혀 문제 없던 코드가 모바일 WebView에서 병목을 일으켰고, 그 원인을 하나씩 추적하면서 해결해 나갔습니다. "모바일 퍼스트"라는 말은 많이 하지만, 실제로 저사양 기기에서 테스트하기 전까지는 성능 문제를 알기 어렵다는 교훈을 다시 한번 얻었습니다.

마지막 편에서는 앱인토스 플랫폼 배포 과정을 다룹니다. 광고 SDK 연동, 네이티브 네비게이션 브릿지 설정, 그리고 앱스토어 심사에서 반려당한 경험과 대응 과정을 이야기하겠습니다.

👉 차트예측게임 바로가기

광고

관련 글