블로그워드프레스에서 주식 기술적 분석 대시보드 만들기 (4) — Lightweight Charts로 캔들스틱 차트 구현

워드프레스에서 주식 기술적 분석 대시보드 만들기 (4) — Lightweight Charts로 캔들스틱 차트 구현

처음에는 TradingView 임베드 위젯을 쓰려고 했다. 코드 한 줄이면 프로급 차트가 나오니까. 문제는 한국 종목을 TradingView 위젯에 넣으면 TradingView 사이트로 이동하는 팝업이 자꾸 뜬다는 거다. 무료 위젯은 브랜딩 제거가 안 되고, 클릭하면 자기 사이트로 데려간다. 내 대시보드에서 사용자를 빼앗기는 셈이다.

대안으로 찾은 게 TradingView에서 오픈소스로 공개한 Lightweight Charts다. 이름 그대로 가볍고(gzip 기준 40KB), Apache 2.0 라이선스라 자유롭게 쓸 수 있다. 캔들스틱, 라인, 히스토그램 차트를 지원하고, 팝업 같은 건 당연히 없다. 커스터마이징도 완전히 자유롭다.

TradingView 위젯 vs Lightweight Charts — 비교 정리

두 선택지를 실제로 테스트하고 비교한 결과:

  • TradingView 위젯: 설정 쉬움, 차트 품질 최상급, 하지만 한국 종목 검색 시 팝업 발생, 브랜딩 제거 불가, 커스텀 데이터 불가 (TradingView 자체 데이터만 사용)
  • Lightweight Charts: CDN 한 줄로 로드, 자체 데이터(우리 API 데이터)를 직접 넣을 수 있음, 팝업/브랜딩 없음, 다크모드 커스터마이징 자유, 터치/반응형 기본 지원

결정적 차이는 “자체 데이터를 쓸 수 있느냐”다. TradingView 위젯은 TradingView 서버의 데이터만 보여준다. 우리는 data.go.kr API로 이미 데이터를 갖고 있으므로, 그 데이터를 차트에 넣으려면 Lightweight Charts가 유일한 선택이었다.

CDN 로드 — 필요할 때만 불러오기

차트 라이브러리를 페이지 로드 시점에 무조건 불러오면 낭비다. 사용자가 검색을 해서 차트가 필요한 시점에 동적으로 로드한다:

function loadLWCharts(callback) {
    // 이미 로드되어 있으면 바로 콜백
    if (typeof LightweightCharts !== 'undefined') {
        callback();
        return;
    }
    var script = document.createElement('script');
    script.src = 'https://unpkg.com/lightweight-charts@4/dist/lightweight-charts.standalone.production.js';
    script.onload = callback;
    document.head.appendChild(script);
}

unpkg CDN에서 v4 standalone 빌드를 가져온다. onload 콜백으로 차트 생성 함수를 호출하므로, 스크립트 로딩이 끝난 뒤에 LightweightCharts 전역 객체를 안전하게 쓸 수 있다. 두 번째 호출부터는 이미 로드되어 있으니 바로 콜백이 실행된다.

차트 데이터 준비 — AJAX 엔드포인트

Lightweight Charts에 넘길 데이터는 서버에서 준비한다. 차트 전용 AJAX 핸들러 nalkkul_sa_ajax_chart_data()가 있다:

function nalkkul_sa_ajax_chart_data() {
    // nonce 검증
    if ( ! check_ajax_referer( 'nalkkul_sa_nonce', 'nonce', false ) ) {
        wp_send_json_error( array( 'message' => '보안 검증에 실패했습니다.' ), 403 );
    }

    $name = sanitize_text_field( $_POST['code'] ?? '' );

    // 종목명으로 30일 데이터 조회
    $result = nalkkul_sa_fetch_stock( array(
        'itmsNm'     => $name,
        'numOfRows'  => 30,
        'resultType' => 'json',
    ) );

    $raw_items = $result['items'];

    // API는 최신순으로 돌려줌 → 차트용으로 날짜 오름차순 정렬
    $sorted = array_reverse( $raw_items );

    $items = array();
    $closes = array();
    foreach ( $sorted as $row ) {
        $date_raw = $row['basDt'] ?? '';
        // "20260327" → "2026-03-27" (Lightweight Charts가 요구하는 형식)
        $date_fmt = substr( $date_raw, 0, 4 ) . '-' . substr( $date_raw, 4, 2 ) . '-' . substr( $date_raw, 6, 2 );

        $items[] = array(
            'time'   => $date_fmt,
            'open'   => (int) $row['mkp'],
            'high'   => (int) $row['hipr'],
            'low'    => (int) $row['lopr'],
            'close'  => (int) $row['clpr'],
            'volume' => (int) $row['trqu'],
        );
        $closes[] = (int) $row['clpr'];
    }
    // ...
}

중요한 점 두 가지:

  1. 날짜 형식을 ISO(YYYY-MM-DD)로 변환해야 한다. Lightweight Charts는 "2026-03-27" 형식을 기대한다. API에서 오는 "20260327"을 그대로 넣으면 차트가 안 그려진다.
  2. 시간 오름차순으로 정렬해야 한다. array_reverse()로 뒤집는다. 가장 오래된 날짜가 배열의 첫 번째, 최신이 마지막이어야 차트가 왼쪽에서 오른쪽으로 시간순 진행된다.

MA 시리즈 데이터도 서버에서 계산

차트 위에 이동평균선을 오버레이하려면, 각 날짜별 MA 값이 있는 시리즈 데이터가 필요하다. 단순히 “현재 MA5 = 72,000” 하나가 아니라, 30거래일 전부의 MA5 값이 필요한 거다:

// MA5 시리즈: 5일 이상 데이터가 있는 날부터 계산
$ma5_series = array();
for ( $i = 4; $i < $count; $i++ ) {
    $sum = 0;
    for ( $j = $i - 4; $j <= $i; $j++ ) {
        $sum += $items[$j]['close'];
    }
    $ma5_series[] = array(
        'time'  => $items[$i]['time'],   // 날짜
        'value' => round( $sum / 5 ),    // MA 값
    );
}

// MA20 시리즈
$ma20_series = array();
for ( $i = 19; $i < $count; $i++ ) {
    $sum = 0;
    for ( $j = $i - 19; $j <= $i; $j++ ) {
        $sum += $items[$j]['close'];
    }
    $ma20_series[] = array(
        'time'  => $items[$i]['time'],
        'value' => round( $sum / 20 ),
    );
}

MA5는 인덱스 4(5번째 날)부터 계산 가능하고, MA20은 인덱스 19(20번째 날)부터다. 각 포인트마다 { time, value } 객체를 만들어서 배열에 담는다. 이걸 JSON으로 프론트엔드에 넘기면 addLineSeries().setData()에 바로 넣을 수 있다.

캔들스틱 차트 생성 — JavaScript 코드

이제 핵심인 차트 렌더링 코드다. renderLWChart() 함수가 서버에서 받은 데이터로 차트를 그린다:

function renderLWChart(data, target) {
    var containerId = target === 'modal' ? 'tg-sa-chart-body' : 'tradingview_tab1_chart';
    var container = document.getElementById(containerId);
    if (!container) return;

    container.innerHTML = '';

    var isDark = document.body.classList.contains('dark-mode');
    var chartHeight = target === 'modal' ? 400 : 350;

    // 차트 영역 div 생성
    var priceDiv = document.createElement('div');
    priceDiv.id = 'lw-price-' + target;
    priceDiv.style.height = chartHeight + 'px';
    container.appendChild(priceDiv);

    // ── 가격 차트 (캔들스틱) ──
    var priceChart = LightweightCharts.createChart(priceDiv, {
        width: priceDiv.clientWidth,
        height: chartHeight,
        layout: {
            background: { type: 'solid', color: isDark ? '#1a1a2e' : '#ffffff' },
            textColor: isDark ? '#d1d4dc' : '#333',
        },
        grid: {
            vertLines: { color: isDark ? '#2B2B43' : '#f0f0f0' },
            horzLines: { color: isDark ? '#2B2B43' : '#f0f0f0' },
        },
        crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
        rightPriceScale: { borderColor: isDark ? '#2B2B43' : '#ddd' },
        timeScale: {
            borderColor: isDark ? '#2B2B43' : '#ddd',
            timeVisible: false,
        },
        localization: {
            priceFormatter: function(price) {
                return price.toLocaleString() + '원';
            },
        },
    });
    // ...
}

createChart()에서 중요한 설정들:

  • layout.background: 다크모드 여부에 따라 배경색을 바꾼다. document.body.classList.contains('dark-mode')으로 체크한다.
  • crosshair.mode: Normal은 마우스가 움직일 때 십자선이 따라다니는 모드. 가격을 정확히 읽을 수 있다.
  • localization.priceFormatter: 가격 축에 “72,000원” 형식으로 표시한다. 천 단위 쉼표 + “원” 접미사.
  • timeScale.timeVisible: false: 일봉 차트라서 시간은 안 보여도 된다.

한국 증시 컨벤션: 상승=빨강, 하락=파랑

미국에서는 상승이 초록, 하락이 빨강이다. 한국은 반대다. Lightweight Charts의 캔들 색상을 한국식으로 맞춰야 한다:

var candleSeries = priceChart.addCandlestickSeries({
    upColor: '#d32f2f',         // 상승 몸통: 빨강
    downColor: '#1565c0',       // 하락 몸통: 파랑
    borderUpColor: '#d32f2f',   // 상승 테두리
    borderDownColor: '#1565c0', // 하락 테두리
    wickUpColor: '#d32f2f',     // 상승 꼬리
    wickDownColor: '#1565c0',   // 하락 꼬리
});

var candleData = data.items.map(function(d) {
    return { time: d.time, open: d.open, high: d.high, low: d.low, close: d.close };
});
candleSeries.setData(candleData);

#d32f2f는 Material Design의 Red 700, #1565c0은 Blue 800이다. 네이버 금융, 카카오 증권 등 한국 플랫폼과 비슷한 색감이라 사용자에게 익숙하다.

거래량 히스토그램 차트

캔들스틱 아래에 거래량 바 차트를 별도로 그린다. 별도 차트 인스턴스를 만들고 히스토그램 시리즈를 추가한다:

// 거래량 차트 영역
var volDiv = document.createElement('div');
volDiv.style.height = '100px';
volDiv.style.marginTop = '8px';
container.appendChild(volDiv);

// 차트 생성
var volChart = LightweightCharts.createChart(volDiv, {
    width: volDiv.clientWidth,
    height: 100,
    layout: {
        background: { type: 'solid', color: isDark ? '#1a1a2e' : '#ffffff' },
        textColor: isDark ? '#d1d4dc' : '#333',
    },
    grid: {
        vertLines: { visible: false },       // 세로 그리드 숨김
        horzLines: { color: isDark ? '#2B2B43' : '#f0f0f0' },
    },
    timeScale: { visible: false },           // 시간축 숨김 (위 차트에서 이미 보여줌)
});

// 히스토그램 시리즈
var volSeries = volChart.addHistogramSeries({
    priceFormat: { type: 'volume' },
});

// 거래량 데이터 — 양봉/음봉에 따라 색 다르게
var volData = data.items.map(function(d) {
    return {
        time: d.time,
        value: d.volume,
        color: d.close >= d.open
            ? 'rgba(211,47,47,0.5)'   // 양봉일: 반투명 빨강
            : 'rgba(21,101,192,0.5)', // 음봉일: 반투명 파랑
    };
});
volSeries.setData(volData);

거래량 바의 색을 캔들 색과 맞추되, 투명도를 50%로 낮췄다. 거래량은 보조 정보라서 캔들보다 시각적으로 약해야 한다. priceFormat: { type: 'volume' }은 큰 수를 K, M 단위로 자동 축약해준다.

시간축 동기화 — 가격 차트와 거래량 차트 연동

가격 차트를 좌우로 드래그하면 거래량 차트도 같이 움직여야 한다. Lightweight Charts의 subscribeVisibleLogicalRangeChange로 양방향 동기화를 구현한다:

// 가격 차트 → 거래량 차트 동기화
priceChart.timeScale().subscribeVisibleLogicalRangeChange(function(range) {
    if (range) volChart.timeScale().setVisibleLogicalRange(range);
});

// 거래량 차트 → 가격 차트 동기화
volChart.timeScale().subscribeVisibleLogicalRangeChange(function(range) {
    if (range) priceChart.timeScale().setVisibleLogicalRange(range);
});

양방향으로 걸어놨기 때문에 어느 쪽을 드래그해도 동기화된다. if (range) 체크는 null이 올 수 있는 경우를 방어하는 것이다.

이동평균선 오버레이

캔들스틱 위에 5일/20일 이동평균선을 라인으로 겹쳐 그린다:

// MA5 라인 (주황색)
if (data.ma5_series && data.ma5_series.length > 0) {
    var ma5Series = priceChart.addLineSeries({
        color: '#ff9800',
        lineWidth: 1,
        title: '5일',
    });
    ma5Series.setData(data.ma5_series);
}

// MA20 라인 (파란색)
if (data.ma20_series && data.ma20_series.length > 0) {
    var ma20Series = priceChart.addLineSeries({
        color: '#2196f3',
        lineWidth: 1,
        title: '20일',
    });
    ma20Series.setData(data.ma20_series);
}

// 시간축 맞추기
priceChart.timeScale().fitContent();

addLineSeries()는 같은 차트 인스턴스에 추가하므로 캔들과 같은 Y축, 같은 시간축을 공유한다. fitContent()는 모든 데이터가 화면에 보이도록 자동 줌한다.

lineWidth: 1로 얇게 그리는 이유는 캔들이 주인공이고 MA선은 보조이기 때문이다. 2 이상이면 캔들을 가린다.

다크모드 지원

우리 사이트에는 다크모드 토글이 있다. bodydark-mode 클래스가 붙으면 어두운 테마가 적용된다. 차트도 이에 맞춰야 한다:

var isDark = document.body.classList.contains('dark-mode');

// 차트 생성 시 다크모드 분기
layout: {
    background: { type: 'solid', color: isDark ? '#1a1a2e' : '#ffffff' },
    textColor: isDark ? '#d1d4dc' : '#333',
},
grid: {
    vertLines: { color: isDark ? '#2B2B43' : '#f0f0f0' },
    horzLines: { color: isDark ? '#2B2B43' : '#f0f0f0' },
},

다크모드 배경은 #1a1a2e (짙은 남색), 그리드는 #2B2B43 (약간 밝은 남색). 텍스트는 #d1d4dc (밝은 회색). 완전한 검정(#000)은 눈이 피로하므로 피했다.

모달 팝업에 차트 넣기

3편에서 만든 박스권 종목 카드에 “차트 보기” 버튼이 있다. 이 버튼을 누르면 모달이 뜨면서 해당 종목의 30일 캔들 차트가 로딩된다:

// 이벤트 위임으로 동적 생성된 버튼도 처리
document.addEventListener('click', function(e) {
    if (e.target.classList.contains('tg-sa-box-chart-btn')) {
        var code = e.target.getAttribute('data-code');
        var name = e.target.getAttribute('data-name');
        openChartModal(code, name);
    }
    if (e.target.classList.contains('tg-sa-chart-close')
        || e.target.classList.contains('tg-sa-chart-backdrop')) {
        closeChartModal();
    }
});

// ESC 키로도 닫기
document.addEventListener('keydown', function(e) {
    if (e.key === 'Escape') {
        closeChartModal();
    }
});

function openChartModal(code, name) {
    document.getElementById('tg-sa-chart-title').textContent = name + ' 차트';
    document.getElementById('tg-sa-chart-body').innerHTML =
        '<div style="text-align:center;padding:40px;color:#888;">📊 차트 로딩 중...</div>';
    document.getElementById('tg-sa-chart-modal').style.display = 'block';
    document.body.style.overflow = 'hidden'; // 배경 스크롤 방지

    // AJAX로 차트 데이터 가져오기
    var fd = new FormData();
    fd.append('action', 'nalkkul_sa_chart_data');
    fd.append('nonce', nonce);
    fd.append('code', name); // 종목명으로 조회

    fetch(ajaxUrl, {method:'POST', body:fd, credentials:'same-origin'})
        .then(function(r){ return r.json(); })
        .then(function(res){
            if (res.success) {
                renderLWChart(res.data, 'modal');
            } else {
                // 에러 메시지 표시
            }
        });
}

function closeChartModal() {
    document.getElementById('tg-sa-chart-modal').style.display = 'none';
    document.body.style.overflow = ''; // 스크롤 복원
    document.getElementById('tg-sa-chart-body').innerHTML = ''; // 차트 인스턴스 해제
}

이벤트 위임(document.addEventListener)을 쓰는 이유: 스크리닝 결과는 AJAX로 동적 생성된 HTML이다. 버튼이 나중에 DOM에 추가되므로 직접 바인딩이 안 된다. document 레벨에서 이벤트를 잡아야 한다.

closeChartModal()에서 innerHTML = ''로 차트 컨테이너를 비우는 것도 중요하다. Lightweight Charts 인스턴스가 메모리에 남아있으면 다음에 열 때 중복 생성된다. innerHTML을 비우면 DOM이 제거되면서 차트 인스턴스도 GC 대상이 된다.

반응형 처리 — ResizeObserver

모달 크기나 브라우저 폭이 바뀌면 차트도 따라서 리사이즈되어야 한다:

if (typeof ResizeObserver !== 'undefined') {
    var resizeObserver = new ResizeObserver(function() {
        priceChart.applyOptions({ width: priceDiv.clientWidth });
        volChart.applyOptions({ width: volDiv.clientWidth });
    });
    resizeObserver.observe(priceDiv);
}

ResizeObserver로 컨테이너 div의 크기 변화를 감지하고, applyOptions()로 차트 폭을 업데이트한다. window.resize 이벤트보다 정확하다 — 모달 애니메이션 같은 경우 윈도우 크기는 안 바뀌지만 컨테이너 크기는 바뀔 수 있으니까.

모달의 HTML 구조

모달은 PHP에서 미리 렌더링해두고 display:none으로 숨겨놓는다:

<!-- Chart Modal -->
<div id="tg-sa-chart-modal" style="display:none;">
    <div class="tg-sa-chart-backdrop"></div>
    <div class="tg-sa-chart-dialog">
        <div class="tg-sa-chart-header">
            <h3 id="tg-sa-chart-title">종목명</h3>
            <button class="tg-sa-chart-close">&times;</button>
        </div>
        <div id="tg-sa-chart-body">
            <div class="tg-sa-chart-loading">차트 데이터 로딩 중...</div>
        </div>
    </div>
</div>

backdrop은 반투명 검정 배경, dialog는 중앙 정렬된 흰색(다크모드에서는 #1a1a2e) 패널이다. CSS에서 position:fixed; top:50%; left:50%; transform:translate(-50%,-50%)로 정중앙에 놓는다.

차트 아래 요약 정보

차트만 덜렁 보여주면 맥락이 부족하다. 차트 아래에 30일 최고가/최저가/평균 거래량, 그리고 MA 정보를 보여준다:

var items = data.items;
var latest = items[items.length - 1];
var maxP = Math.max.apply(null, items.map(function(d){ return d.high; }));
var minP = Math.min.apply(null, items.map(function(d){ return d.low; }));
var avgVol = items.reduce(function(s,d){ return s + d.volume; }, 0) / items.length;

// MA 정보
if (data.ma5 !== null) {
    var ma5diff = ((latest.close - data.ma5) / data.ma5 * 100).toFixed(2);
    // "5일 MA: 72,000 (+1.25%)" 형태로 출력
}
if (data.ma20 !== null) {
    var ma20diff = ((latest.close - data.ma20) / data.ma20 * 100).toFixed(2);
    // "20일 MA: 70,500 (+2.13%)" 형태로 출력
}

현재가 대비 MA와의 괴리율을 퍼센트로 표시한다. +면 현재가가 MA 위, -면 아래. 이 숫자 하나로 “현재 추세에서 얼마나 벗어났는지” 감을 잡을 수 있다.

정리: 4편에서 구현한 것

  • Lightweight Charts v4 CDN 동적 로딩 (필요 시에만)
  • 캔들스틱 차트: 한국식 빨강(상승)/파랑(하락) 컬러
  • 거래량 히스토그램: 양봉/음봉 색 연동, 반투명 처리
  • 시간축 동기화: 가격 차트와 거래량 차트 양방향 연동
  • 이동평균선 오버레이: MA5(주황) + MA20(파랑) 라인 시리즈
  • 다크모드 대응: body.dark-mode 클래스 체크
  • 모달 팝업: 이벤트 위임, ESC 닫기, 배경 스크롤 방지
  • ResizeObserver로 반응형 처리
  • 차트 데이터 전용 AJAX 엔드포인트 (30일 캐싱)

마지막 5편에서는 AJAX 구조, nonce 보안, 탭 전환 UI, 검색 기능, localStorage 캐싱 등 나머지 프론트엔드를 완성하고, 전체 아키텍처를 정리한다.


이 시리즈의 다른 글

이 글이 도움이 되셨다면 공유해주세요!

댓글 남기기

무엇이든 물어보세요! 💬