블로그워드프레스에서 공공데이터 API 활용하기 (2) — 약국/병원 찾기 서비스

워드프레스에서 공공데이터 API 활용하기 (2) — 약국/병원 찾기 서비스

건강보험심사평가원(HIRA) API를 붙이면서 가장 당혹스러웠던 건, 공식 문서에 나온 필드명과 실제 응답의 필드명이 달랐다는 거다. dutyName으로 약국 이름을 가져와야 한다고 문서에 적혀 있는데, 실제로는 yadmNm으로 온다. 이런 걸 어떻게 알았냐고? 빈 카드가 20개 렌더링되는 걸 보고 알았다.

이번 글에서는 약국/병원 찾기 서비스를 처음부터 만드는 과정을 다룬다. 시도 → 시군구 캐스케이딩 드롭다운, API 응답 파싱, 그리고 필드명 불일치 문제까지.

건강보험심사평가원 API 구조

HIRA의 약국 정보서비스는 두 개의 엔드포인트를 제공한다:

  • getParmacyBasisList — 약국 기본 목록 (시도코드/시군구코드 기반)
  • getParmacyListInfoInqire — 약국 상세 목록 (시도명/시군구명 텍스트 기반)

두 번째 엔드포인트가 사용하기 편하지만, 첫 번째가 더 안정적이다. 그래서 첫 번째를 기본으로 쓰고, 실패하면 두 번째로 폴백하는 구조를 짰다.

$url = 'http://apis.data.go.kr/B551182/pharmacyInfoService/getParmacyBasisList?';

// 첫 번째 엔드포인트 실패 시 대체
if ( empty( $body ) ) {
    return nalkkul_pharm_fetch_alt( $city, $district, $name, $cache_key );
}

시도코드/시군구코드 체계

첫 번째 엔드포인트는 시도명이 아니라 시도코드(sidoCd)와 시군구코드(sgguCd)를 요구한다. 이 코드 체계가 별도 API로 제공되면 좋겠지만, 그런 건 없다. 직접 매핑 테이블을 만들어야 한다.

function nalkkul_pharm_sido_codes() {
    return array(
        '서울특별시' => '110000',
        '부산광역시' => '210000',
        '경기도'     => '310000',
    );
}

function nalkkul_pharm_sggu_codes() {
    return array(
        '서울특별시' => array(
            '강남구'=>'110001','강동구'=>'110002','강북구'=>'110003',
            '강서구'=>'110004','관악구'=>'110005','광진구'=>'110006',
            // ... 25개 구 전체
        ),
        '부산광역시' => array(
            '강서구'=>'210001','금정구'=>'210002','기장군'=>'210003',
            // ... 16개 구/군
        ),
        // ...
    );
}

이 코드들은 HIRA 공식 문서에 나와 있긴 한데, PDF 파일 속에 묻혀 있다. 복사-붙여넣기로 하나하나 옮겼다.

캐스케이딩 드롭다운 구현

시도를 선택하면 시군구 목록이 바뀌는 캐스케이딩(종속) 드롭다운. 처음엔 시군구 목록도 AJAX로 가져오려 했는데, 생각해보니 시군구 데이터가 바뀔 일이 거의 없으므로 처음부터 JS 객체에 다 넣어두는 게 낫다.

/* PHP: 전체 데이터를 JSON으로 전달 */
var allDistricts = <?php echo wp_json_encode( $cities ); ?>;

/* JS: 시도 변경 시 시군구 옵션 재생성 */
selCity.addEventListener('change', function(){
    var city = this.value;
    selDistrict.innerHTML = '<option value="">시/군/구 선택</option>';
    if(city && allDistricts[city]){
        var dists = allDistricts[city];
        for(var i=0; i<dists.length; i++){
            var opt = document.createElement('option');
            opt.value = dists[i];
            opt.textContent = dists[i];
            selDistrict.appendChild(opt);
        }
        selDistrict.disabled = false;
    } else {
        selDistrict.disabled = true;
    }
});

AJAX를 한 번 줄이니 UX가 훨씬 빨라졌다. 시도를 고르면 즉시 시군구 목록이 나온다. 네트워크 왕복 0ms.

API 호출: serviceKey 인코딩 주의

1편에서도 다뤘지만, 공공데이터 API의 serviceKey는 반드시 별도로 rawurlencode()해야 한다. http_build_query()를 쓰면 이중 인코딩이 되거나, +가 제대로 처리되지 않는다.

$query_parts = array();
foreach ( $params as $k => $v ) {
    if ( $k === 'serviceKey' ) {
        $query_parts[] = 'serviceKey=' . rawurlencode( $v );
    } else {
        $query_parts[] = rawurlencode( $k ) . '=' . rawurlencode( $v );
    }
}
$url .= implode( '&', $query_parts );

이 패턴은 모든 공공데이터 API에서 반복된다. 1편의 미세먼지, 이번 약국, 다음 편의 기업정보까지 전부.

API 응답 필드 매핑 — 문서와 실제가 다른 경우

이 프로젝트에서 가장 짜증났던 문제다. API 문서에는 약국 이름이 dutyName, 주소가 dutyAddr, 전화번호가 dutyTel1이라고 적혀 있다. 그런데 실제 응답은:

// 문서: dutyName, dutyAddr, dutyTel1
// 실제: yadmNm, addr, telno
// 또는: 어떤 엔드포인트에서는 dutyName을 쓴다

결국 두 가지 필드명을 모두 대응하는 파서를 만들었다:

function nalkkul_pharm_parse_item( $item ) {
    $result = array(
        'name'    => $item['yadmNm']  ?? $item['dutyName'] ?? '',
        'address' => $item['addr']    ?? $item['dutyAddr']  ?? '',
        'phone'   => $item['telno']   ?? $item['dutyTel1']  ?? '',
    );
    // ...
}

??(null coalescing) 연산자로 첫 번째 필드가 없으면 두 번째를 시도한다. 이래도 안 되면 빈 문자열. 이 패턴 덕분에 두 엔드포인트 중 어디서 데이터가 오든 동일하게 파싱된다.

영업시간 파싱

HIRA API는 영업시간을 요일별로 분리해서 보낸다. dutyTime1s(월요일 시작), dutyTime1c(월요일 종료) 형식이다. “0900” 같은 4자리 숫자를 사람이 읽을 수 있는 “09:00″으로 바꿔야 한다.

function nalkkul_pharm_format_time( $t ) {
    $t = str_pad( (string) $t, 4, '0', STR_PAD_LEFT );
    return substr( $t, 0, 2 ) . ':' . substr( $t, 2, 2 );
}

$days_map = array(
    1 => '월', 2 => '화', 3 => '수', 4 => '목',
    5 => '금', 6 => '토', 7 => '일', 8 => '공휴일',
);

$hours = array();
foreach ( $days_map as $num => $day_name ) {
    $start = $item[ 'dutyTime' . $num . 's' ] ?? '';
    $end   = $item[ 'dutyTime' . $num . 'c' ] ?? '';
    if ( ! empty( $start ) && ! empty( $end ) ) {
        $hours[] = $day_name . ' ' . nalkkul_pharm_format_time( $start ) . '~' . nalkkul_pharm_format_time( $end );
    }
}

str_pad로 4자리를 맞추는 이유는, 가끔 "900"처럼 3자리로 오는 경우가 있어서다. 앞에 0을 채워 "0900"으로 만든 뒤 잘라야 "09:00"이 된다.

검색 결과 카드 UI와 전화 통화 기능

검색 결과를 카드 형태로 보여주되, 전화번호에는 tel: 링크를 걸었다. 모바일에서 탭하면 바로 전화가 걸린다.

if(p.phone){
    html += '<a href="tel:' + escAttr(p.phone) + '" class="tg-ph-phone-btn">';
    html += '<svg viewBox="0 0 24 24">...</svg>';
    html += escHtml(p.phone);
    html += '</a>';
}

모바일 접속자가 많은 서비스에서 전화번호를 그냥 텍스트로 두면 사용자 경험이 나빠진다. 이 작은 차이가 체류 시간에 영향을 준다.

약국 API vs 병원 API 차이

HIRA에서는 약국과 병원을 다른 서비스로 제공한다. 약국은 pharmacyInfoService, 병원은 HmcSearchService다. 차이점:

  • 병원 API는 종별코드(dgsbjtCd)로 종합병원, 의원, 치과 등을 구분한다
  • 약국 API에는 종별 구분이 없다 (전부 약국)
  • 병원 API의 응답 필드는 약국과 이름이 또 다르다
  • 병원은 진료과목 정보가 추가로 있다

같은 HIRA인데 필드 네이밍 컨벤션이 다른 게 제일 혼란스럽다. 통합 파서를 쓰지 않았으면 꽤 고생했을 것이다.

Nonce 보안 — AJAX에 인증 추가

1편의 미세먼지 서비스는 GET 방식에 nonce 없이 만들었다. 약국 찾기부터는 nonce 검증을 추가했다. WordPress의 nonce는 CSRF 방지용이다.

// PHP: nonce 생성
$nonce = wp_create_nonce( 'nalkkul_pharm_nonce' );

// PHP: AJAX 핸들러에서 검증
function nalkkul_pharm_ajax_search() {
    check_ajax_referer( 'nalkkul_pharm_nonce', 'nonce' );
    // ...
}

// JS: 요청 시 nonce 포함
var fd = new FormData();
fd.append('action', 'nalkkul_pharm_search');
fd.append('nonce', nonce);
fd.append('city', city);
fd.append('district', district);

nonce 없이 배포하면 외부에서 AJAX 엔드포인트를 무한 호출할 수 있다. API 호출 한도가 금방 소진된다.

이름 검색 — 클라이언트 필터링

HIRA의 첫 번째 엔드포인트에는 이름 검색 파라미터가 없다. 약국명으로 필터링하려면 전체 목록을 받은 뒤 PHP에서 걸러야 한다.

// 이름으로 필터링 (API에서 이름 검색 미지원)
if ( ! empty( $name ) && ! empty( $items ) ) {
    $items = array_values( array_filter( $items, function( $i ) use ( $name ) {
        return mb_strpos( $i['name'], $name ) !== false;
    } ) );
}

mb_strpos를 쓰는 이유는 약국 이름이 한글이라서. strpos로도 동작하긴 하지만, 멀티바이트 문자열에서는 mb_strpos가 정확하다.

실제 겪은 문제: 빈 카드 20개

처음 배포했을 때, 검색하면 카드가 20개 나오는데 전부 비어있었다. 이름도 없고, 주소도 없고, 전화번호도 없다. API 응답을 console에 찍어보니 데이터는 정상이었다. 문제는 파서에서 $item['dutyName']을 읽고 있었는데, 실제 필드는 yadmNm이었다는 것.

디버깅 과정:

  1. 카드 20개가 렌더링됨 → API 응답은 정상
  2. 모든 카드가 비어있음 → 파싱 문제
  3. API raw response 확인 → yadmNm 필드에 데이터가 있음
  4. 코드에서는 dutyName을 읽고 있음 → 필드명 불일치!
  5. 두 필드명 모두 대응하는 파서로 수정 → 해결

교훈: 공공데이터 API 문서를 100% 신뢰하면 안 된다. 반드시 실제 응답을 로그로 찍어보자.

단일 아이템 응답 처리

또 하나 함정. API가 결과를 1건 반환할 때와 여러 건 반환할 때 구조가 다르다.

// 여러 건: items.item = [ {약국1}, {약국2}, ... ]
// 1건:     items.item = {약국1}   (배열이 아니라 객체!)

$raw = $body['response']['body']['items']['item'];
if ( isset( $raw['yadmNm'] ) || isset( $raw['dutyName'] ) ) {
    $raw = array( $raw );  // 단일 객체를 배열로 감싸기
}

PHP에서 JSON을 연관 배열로 파싱하면 이 차이가 미묘하다. 1건일 때 foreach를 돌리면 키-값 쌍을 순회하게 되어 완전히 엉뚱한 결과가 나온다.

다크모드 대응

약국 찾기의 헤더는 linear-gradient(135deg, #0d7c3d, #16a34a)로 녹색 그라데이션을 쓴다. 다크모드에서는 채도를 낮춘다:

body.dark-mode .tg-ph-hero {
    background: linear-gradient(135deg, #064e24 0%, #0d7c3d 100%);
}
body.dark-mode .tg-ph-card {
    background: #1f2937;
    border-color: #374151;
}

마무리

이번 편의 핵심:

  • 건강보험심사평가원 API는 두 엔드포인트가 있고, 필드명이 서로 다르다
  • 시도/시군구 코드 매핑은 직접 만들어야 한다
  • 캐스케이딩 드롭다운은 서버에서 전체 데이터를 JSON으로 내려주면 AJAX 없이 즉시 반응한다
  • API 문서의 필드명을 맹신하지 말고, 실제 응답을 확인하자
  • 단일 결과와 복수 결과의 JSON 구조가 다를 수 있다

다음 편에서는 금융위원회 기업정보 API와 국세청 사업자등록 상태조회 API를 다룬다. 두 API는 도메인이 서로 다르고(apis.data.go.kr vs api.odcloud.kr), 요청 방식도 GET vs POST로 다르다. 게다가 “삼성전자”를 검색하면 삼성전자, 삼성전자서비스, 삼성전자판매가 다 나오는 중복 문제도 해결해야 한다.

시리즈 목차

데이터 출처: 건강보험심사평가원(hira.or.kr), 공공데이터포털(data.go.kr)

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

댓글 남기기

무엇이든 물어보세요! 💬