블로그워드프레스에서 주식 기술적 분석 대시보드 만들기 (1) — 공공데이터 API 연동

워드프레스에서 주식 기술적 분석 대시보드 만들기 (1) — 공공데이터 API 연동

개인 블로그에서 수익을 내려면 “다시 와야 할 이유”가 있어야 한다. 글만 잔뜩 쌓아놓으면 한 번 읽고 떠나는 방문자가 대부분이다. 반면 도구형 콘텐츠 — 예를 들면 환율 계산기, 세금 시뮬레이터, 주식 분석 대시보드 같은 것들 — 은 사용자가 반복적으로 방문한다. 방문 횟수가 곧 광고 노출이고, 노출이 곧 수익이다.

그래서 나는 워드프레스 mu-plugin으로 주식 기술적 분석 대시보드를 만들었다. 종목을 검색하면 60거래일 캔들스틱 차트, 이동평균선(MA), RSI, 볼린저 밴드까지 한 화면에 보여주는 도구다. 데이터 소스는 금융위원회가 data.go.kr를 통해 제공하는 주식시세 API를 사용했다. 무료이고, 하루 1,000건 호출이 가능하며, JSON으로 깔끔하게 응답이 온다.

이 시리즈 5편에 걸쳐 처음부터 끝까지 구현 과정을 공유한다. 1편에서는 API 연동과 캐싱까지 다룬다.

금융위원회 주식시세 API란?

공공데이터포털(data.go.kr)에서 “주식시세정보”를 검색하면 금융위원회에서 제공하는 GetStockSecuritiesInfoService를 찾을 수 있다. 여기서 쓸 오퍼레이션은 getStockPriceInfo로, 특정 종목의 일자별 시가/고가/저가/종가/거래량/시가총액 등을 돌려준다.

API 키 발급은 다음 순서다:

  1. data.go.kr 회원가입
  2. “금융위원회_주식시세정보” 검색 → 활용신청
  3. 일반 인증키 발급 (즉시 발급되지만, 실제 API 호출이 가능해지기까지 1~2시간 걸릴 수 있다)
  4. 마이페이지에서 일반 인증키(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에서 바로 꺼낸다. $ttlwp-config.phpNALKKUL_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로 직접 계산하는 과정을 다룬다. 수학 공식이 나오지만 코드는 의외로 짧다.


이 시리즈의 다른 글

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

댓글 남기기

무엇이든 물어보세요! 💬