개인 블로그에서 수익을 내려면 “다시 와야 할 이유”가 있어야 한다. 글만 잔뜩 쌓아놓으면 한 번 읽고 떠나는 방문자가 대부분이다. 반면 도구형 콘텐츠 — 예를 들면 환율 계산기, 세금 시뮬레이터, 주식 분석 대시보드 같은 것들 — 은 사용자가 반복적으로 방문한다. 방문 횟수가 곧 광고 노출이고, 노출이 곧 수익이다.
그래서 나는 워드프레스 mu-plugin으로 주식 기술적 분석 대시보드를 만들었다. 종목을 검색하면 60거래일 캔들스틱 차트, 이동평균선(MA), RSI, 볼린저 밴드까지 한 화면에 보여주는 도구다. 데이터 소스는 금융위원회가 data.go.kr를 통해 제공하는 주식시세 API를 사용했다. 무료이고, 하루 1,000건 호출이 가능하며, JSON으로 깔끔하게 응답이 온다.
이 시리즈 5편에 걸쳐 처음부터 끝까지 구현 과정을 공유한다. 1편에서는 API 연동과 캐싱까지 다룬다.
금융위원회 주식시세 API란?
공공데이터포털(data.go.kr)에서 “주식시세정보”를 검색하면 금융위원회에서 제공하는 GetStockSecuritiesInfoService를 찾을 수 있다. 여기서 쓸 오퍼레이션은 getStockPriceInfo로, 특정 종목의 일자별 시가/고가/저가/종가/거래량/시가총액 등을 돌려준다.
API 키 발급은 다음 순서다:
- data.go.kr 회원가입
- “금융위원회_주식시세정보” 검색 → 활용신청
- 일반 인증키 발급 (즉시 발급되지만, 실제 API 호출이 가능해지기까지 1~2시간 걸릴 수 있다)
- 마이페이지에서 일반 인증키(Encoding) 복사
여기서 첫 번째 삽질 포인트가 나온다. 키를 발급받자마자 호출하면 HTTP 403이 뜬다. data.go.kr 쪽에서 내부적으로 키를 활성화하는 데 시간이 걸리기 때문이다. “내 코드가 잘못됐나?”하고 한참 뒤졌는데, 그냥 기다리면 해결된다.
mu-plugin 기본 구조 설계
왜 일반 플러그인이 아니라 mu-plugin을 쓰느냐? mu-plugin은 wp-content/mu-plugins/ 디렉토리에 PHP 파일만 넣으면 자동 활성화된다. 관리자 화면에서 “실수로 비활성화”할 위험이 없고, 어떤 테마를 쓰든 항상 로드된다. 주식 대시보드처럼 사이트 핵심 기능은 mu-plugin이 적합하다.
파일 하나에 전부 담는 구조를 택했다. 파일 이름은 stock-analysis.php이고, 내부 섹션은 이렇게 나뉜다:
<?php
/**
* Plugin Name: Nalkkul Stock Technical Analysis (주식 기술적 분석)
* Description: 주식 기술적 분석 대시보드 - 캔들스틱 차트, 이동평균선, RSI, 볼린저 밴드
* Author: Nalkkul
* Version: 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// 1. Constants & Helpers — API URL, 유틸 함수
// 2. API Fetch Functions — data.go.kr 호출 + 캐싱
// 3. Technical Analysis — MA, RSI, 볼린저 계산
// 4. HTML Builders — 서버사이드 분석 결과 HTML
// 5. AJAX Handlers — 프론트엔드 요청 처리
// 6. Shortcode —
본 정보는 투자 참고용이며, 투자 판단은 본인의 책임입니다. 본 사이트는 투자자문업을 영위하지 않습니다.
주식 기술적 분석 대시보드
캔들스틱 차트, 이동평균선, RSI, 볼린저 밴드 등 기술적 지표 분석
60일 기술적 분석 데이터를 계산하고 있습니다...
시장 동향을 불러오고 있습니다...
📋 실시간 공시 바로가기
📰 주요 경제 뉴스 사이트
종목 스크리닝 데이터를 분석하고 있습니다...
// 7. Static tabs (뉴스 등)
API URL과 Rate Limit 상수는 파일 상단에 선언한다:
define( 'NALKKUL_SA_API_URL', 'https://apis.data.go.kr/1160100/service/GetStockSecuritiesInfoService/getStockPriceInfo' );
define( 'NALKKUL_SA_RATE_LIMIT', 20 ); // IP당 시간당 20회
API 키는 wp-config.php에 상수로 넣어야 한다. 코드에 직접 적으면 Git에 올라가므로 반드시 분리한다:
// wp-config.php에 추가
define( 'NALKKUL_DATA_GO_KR_KEY', '여기에_발급받은_인코딩키' );
define( 'NALKKUL_CACHE_STOCKPRICE', 1800 ); // 캐시 TTL 30분
PHP에서 API 호출하기: 실제 코드
핵심 함수는 nalkkul_sa_fetch_stock()이다. 파라미터로 종목명(itmsNm), 기준일(basDt), 가져올 건수(numOfRows) 등을 받고, 워드프레스 HTTP API인 wp_remote_get으로 호출한다.
function nalkkul_sa_fetch_stock( $params = array() ) {
if ( ! defined( 'NALKKUL_DATA_GO_KR_KEY' ) || empty( NALKKUL_DATA_GO_KR_KEY ) ) {
return new WP_Error( 'no_key', '공공데이터 API 키가 설정되지 않았습니다.' );
}
$defaults = array(
'numOfRows' => 60,
'pageNo' => 1,
'resultType' => 'json',
);
$params = wp_parse_args( $params, $defaults );
// 캐시 키 생성
$ttl = defined( 'NALKKUL_CACHE_STOCKPRICE' ) ? NALKKUL_CACHE_STOCKPRICE : 1800;
$cache_key = 'nalkkul_sa_' . md5( wp_json_encode( $params ) );
$cached = get_transient( $cache_key );
if ( false !== $cached ) {
return $cached; // 캐시 히트 → API 호출 안 함
}
// URL 조립 — rawurlencode 필수!
$url = NALKKUL_SA_API_URL . '?serviceKey=' . rawurlencode( NALKKUL_DATA_GO_KR_KEY );
foreach ( $params as $k => $v ) {
if ( $v !== '' ) {
$url .= '&' . $k . '=' . rawurlencode( $v );
}
}
$resp = wp_remote_get( $url, array(
'timeout' => 20,
'headers' => array( 'Accept' => 'application/json' ),
) );
if ( is_wp_error( $resp ) ) {
return new WP_Error( 'api_fail', 'API 요청 실패: ' . $resp->get_error_message() );
}
$code = wp_remote_retrieve_response_code( $resp );
$raw = wp_remote_retrieve_body( $resp );
// 403/401 → 서비스 미승인
if ( $code === 403 || $code === 401 ) {
return new WP_Error( 'not_approved', '서비스 준비 중입니다. data.go.kr에서 API 활용 신청 후 이용 가능합니다.' );
}
if ( $code !== 200 ) {
return new WP_Error( 'api_fail', 'API 응답 오류 (HTTP ' . $code . ')' );
}
$body = json_decode( $raw, true );
// resultCode로도 권한 에러 체크
if ( isset( $body['response']['header']['resultCode'] ) ) {
$rc = $body['response']['header']['resultCode'];
if ( $rc === 'SERVICE_ACCESS_DENIED' || $rc === '30' ) {
return new WP_Error( 'not_approved', '서비스 준비 중입니다.' );
}
}
// ... (아래에서 파싱 계속)
}
rawurlencode를 써야 하는 이유
두 번째 삽질 포인트다. urlencode()를 쓰면 공백을 +로 인코딩하는데, data.go.kr API 키에 포함된 =이나 + 같은 문자가 깨진다. rawurlencode()는 RFC 3986 방식으로 인코딩해서 %2B 같은 안전한 형태로 바뀐다. 공공데이터 API를 호출할 때는 반드시 rawurlencode를 써야 한다.
API 응답 파싱 — 삽질 주의사항
data.go.kr의 JSON 응답 구조는 이렇다:
{
"response": {
"header": { "resultCode": "00", "resultMsg": "NORMAL SERVICE" },
"body": {
"totalCount": 60,
"items": {
"item": [
{ "basDt": "20260327", "srtnCd": "005930", "itmsNm": "삼성전자",
"clpr": "72000", "mkp": "71500", "hipr": "72500", "lopr": "71000",
"trqu": "15432100", "vs": "500", "fltRt": "0.70",
"mrktTotAmt": "429840000000000", "mrktCls": "KOSPI" },
...
]
}
}
}
}
세 번째 삽질 포인트: 결과가 1건일 때 item이 배열이 아니라 단일 객체로 온다. 이것 때문에 루프가 깨지는 버그를 겪었다. 그래서 파싱 코드에 방어 로직이 들어간다:
$items = array();
if ( isset( $body['response']['body']['items']['item'] ) ) {
$items = $body['response']['body']['items']['item'];
// 결과가 1건이면 배열이 아닌 단일 객체로 온다 → 배열로 감싸기
if ( isset( $items['basDt'] ) ) {
$items = array( $items );
}
}
$total = $body['response']['body']['totalCount'] ?? 0;
$result = array(
'totalCount' => (int) $total,
'items' => $items,
);
// 트랜지언트 캐시에 저장
set_transient( $cache_key, $result, $ttl );
return $result;
isset( $items['basDt'] )로 검사하는 게 포인트다. 배열이면 숫자 인덱스(0, 1, 2…)가 키인데, 단일 객체면 basDt 같은 필드명이 키다. 이 차이로 판별한다.
트랜지언트 캐싱 — API 호출 최소화
data.go.kr 무료 API는 하루 1,000건 제한이 있다. 매 검색마다 실시간으로 호출하면 금방 소진된다. 워드프레스 Transient API를 활용해 30분간 결과를 캐싱한다.
캐시 전략의 핵심은 파라미터 기반 캐시 키다:
$cache_key = 'nalkkul_sa_' . md5( wp_json_encode( $params ) );
$cached = get_transient( $cache_key );
if ( false !== $cached ) {
return $cached;
}
// ... API 호출 후 ...
set_transient( $cache_key, $result, $ttl );
파라미터(종목명, 날짜, 페이지 등)를 JSON으로 직렬화한 뒤 MD5 해시로 캐시 키를 만든다. 같은 종목, 같은 조건의 요청은 30분 동안 DB에서 바로 꺼낸다. $ttl은 wp-config.php의 NALKKUL_CACHE_STOCKPRICE 상수로 조절할 수 있게 했다.
AJAX 핸들러 쪽에서는 한 단계 더 캐싱을 한다. 검색 결과(HTML + 지표 데이터)도 별도 트랜지언트로 저장한다:
$search_cache_key = 'nalkkul_sa_search_' . md5( $itms_nm );
$cached_result = get_transient( $search_cache_key );
if ( false !== $cached_result ) {
wp_send_json_success( $cached_result );
return;
}
즉 2중 캐시다. 1차는 raw API 응답, 2차는 분석 완료된 결과. 사용자 A가 “삼성전자”를 검색하면 API 호출 + 지표 계산을 하고, 사용자 B가 같은 검색을 하면 API 호출도 지표 계산도 건너뛰고 바로 캐시된 HTML을 돌려준다.
Rate Limiting — IP당 시간당 20회
캐싱만으로는 부족하다. 악의적인 사용자가 매번 다른 종목을 검색하면서 API를 소진시킬 수 있다. IP 기반 레이트 리밋을 걸어야 한다:
function nalkkul_sa_check_rate_limit() {
$ip = sanitize_text_field( $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0' );
$key = 'nalkkul_sa_rl_' . md5( $ip );
$data = get_transient( $key );
if ( false === $data ) {
// 첫 요청: 카운터 시작
set_transient( $key, array( 'count' => 1, 'start' => time() ), HOUR_IN_SECONDS );
return true;
}
if ( $data['count'] >= NALKKUL_SA_RATE_LIMIT ) {
return false; // 한도 초과
}
$data['count']++;
set_transient( $key, $data, HOUR_IN_SECONDS );
return true;
}
IP를 MD5로 해시하고, 트랜지언트로 1시간 카운터를 관리한다. 20회를 넘으면 false를 반환하고, AJAX 핸들러에서 429 응답을 보낸다.
캐시 히트일 때는 Rate Limit을 카운트하지 않는다는 점이 중요하다. 같은 종목을 반복 검색하는 건 API를 소모하지 않으니까 차단할 이유가 없다:
// 캐시 히트 시 바로 반환 (rate limit 카운트 안 함)
$cached_result = get_transient( $search_cache_key );
if ( false !== $cached_result ) {
wp_send_json_success( $cached_result );
return;
}
// 캐시 미스일 때만 rate limit 체크
if ( ! nalkkul_sa_check_rate_limit() ) {
wp_send_json_error( array( 'message' => '요청 한도를 초과했습니다 (시간당 20회).' ), 429 );
}
유틸리티 함수들 — 가격 포맷팅
API에서 오는 숫자는 문자열이다. 화면에 표시할 때 천 단위 쉼표, 상승/하락 화살표, 퍼센트 부호 등을 붙여야 한다. 이런 걸 매번 하드코딩하면 코드가 지저분해지므로 유틸 함수를 미리 만들어 두었다:
function nalkkul_sa_fmt_price( $val ) {
$val = trim( (string) $val );
return is_numeric( $val ) ? number_format( (int) $val ) : $val;
}
function nalkkul_sa_fmt_change( $val ) {
$num = (int) $val;
if ( $num > 0 ) return '▲ ' . number_format( $num );
if ( $num < 0 ) return '▼ ' . number_format( abs( $num ) );
return '0';
}
function nalkkul_sa_fmt_pct( $val ) {
$num = (float) $val;
$sign = $num > 0 ? '+' : '';
return $sign . number_format( $num, 2 ) . '%';
}
function nalkkul_sa_fmt_volume( $val ) {
$num = (float) $val;
if ( $num >= 100000000 ) {
return number_format( $num / 100000000, 1 ) . '억';
} elseif ( $num >= 10000 ) {
return number_format( $num / 10000, 0 ) . '만';
}
return number_format( $num );
}
fmt_volume은 1억 이상이면 “억”, 1만 이상이면 “만” 단위로 변환한다. 시가총액은 더 큰 수치라서 별도의 fmt_large() 함수가 있다 — 조 단위까지 처리한다.
네 번째 삽질: 종목코드(srtnCd) vs 종목명(itmsNm)
처음에는 종목코드(005930 같은)로 검색하려 했다. 그런데 실제 써보니 srtnCd 파라미터가 잘 안 먹는 경우가 있었다. 결국 itmsNm(종목명)으로 검색하는 게 가장 안정적이었다. 다만 “네이버”로 검색하면 결과가 안 나오고 “NAVER”로 해야 나오는 종목도 있어서, 매핑 테이블을 만들어 두었다. 이건 5편 AJAX 편에서 자세히 다룬다.
정리: 1편에서 만든 것
- mu-plugin 파일 생성 (
stock-analysis.php) - API 키를
wp-config.php에 분리해서 저장 nalkkul_sa_fetch_stock(): data.go.kr API 호출 함수 (rawurlencode, 에러 처리)- JSON 응답 파싱 (단일 객체 방어 로직 포함)
- 트랜지언트 기반 2중 캐싱 (raw 응답 + 분석 결과)
- IP 기반 Rate Limiting (시간당 20회)
- 가격/거래량/퍼센트 포맷팅 유틸 함수
다음 2편에서는 이 API 데이터를 받아서 이동평균선(5/20/60일), RSI(14일), 볼린저 밴드를 PHP로 직접 계산하는 과정을 다룬다. 수학 공식이 나오지만 코드는 의외로 짧다.
이 시리즈의 다른 글
- [현재 글] 1편 — 공공데이터 API 연동
- 2편 — 기술적 지표 계산 (MA, RSI, 볼린저 밴드)
- 3편 — 종목 스크리닝 시스템 구현
- 4편 — Lightweight Charts로 캔들스틱 차트 구현
- 5편 — AJAX 연동과 프론트엔드 완성