처음에는 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'];
}
// ...
}
중요한 점 두 가지:
- 날짜 형식을 ISO(YYYY-MM-DD)로 변환해야 한다. Lightweight Charts는
"2026-03-27"형식을 기대한다. API에서 오는"20260327"을 그대로 넣으면 차트가 안 그려진다. - 시간 오름차순으로 정렬해야 한다.
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 이상이면 캔들을 가린다.
다크모드 지원
우리 사이트에는 다크모드 토글이 있다. body에 dark-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">×</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 캐싱 등 나머지 프론트엔드를 완성하고, 전체 아키텍처를 정리한다.
이 시리즈의 다른 글
- 1편 — 공공데이터 API 연동
- 2편 — 기술적 지표 계산 (MA, RSI, 볼린저 밴드)
- 3편 — 종목 스크리닝 시스템 구현
- [현재 글] 4편 — Lightweight Charts로 캔들스틱 차트 구현
- 5편 — AJAX 연동과 프론트엔드 완성