보안 프록시와 인메모리 캐시를 거쳐 스마트폰 앱으로 데이터가 안전하게 전송되는 과정을 묘사한 테크 3D 일러스트

공공데이터 API 인증키 숨기는 법: 앱 대신 프록시 서버에 두고 기상청 발표 시각에 맞춰 캐시하기 (2026년 10월)

공공데이터포털에서 날씨 API 인증키를 처음 받았을 때, 저는 그 키를 앱 코드에 그대로 붙여 넣었습니다. 화면에 기온이 뜨는 걸 보고 바로 다음 기능으로 넘어갔죠. 나중에 배포 파일을 열어 볼 일이 생겼는데, 디컴파일 도구로 열자마자 그 키가 문자열 그대로 보였습니다. 보안 전문가가 아니어도, 마음만 먹으면 누구나 찾을 수 있는 자리였습니다.

이 글은 그때 바꾼 구조를 정리한 기록입니다. 앱이 공공데이터 API를 직접 부르지 않고, 중간에 작은 프록시 서버를 하나 두는 방식입니다. 이렇게 하면 인증키를 앱 밖으로 빼낼 수 있고, 같은 데이터를 여러 사용자가 요청할 때 캐시로 돌려줄 수 있어서 하루 호출 한도도 훨씬 덜 씁니다. 처음 썼을 때보다 내용을 많이 보탰습니다. 인증키가 Encoding·Decoding 두 벌로 나오는 이유와 이중 인코딩 함정, 개발계정과 운영계정의 차이, 기상청 발표 시각에 맞춘 캐시 설계, Cloudflare Workers나 Vercel 같은 서버리스에 올리는 방법, 그리고 프록시 자체가 남의 호출 창구가 되지 않게 막는 방법까지 2026년 10월 기준 공식 문서를 다시 확인해 넣었습니다. 예제는 기상청 단기예보 조회서비스의 초단기실황을 기준으로 했습니다.

먼저 결론부터

  • 인증키는 앱에 넣지 않습니다. 앱 배포 파일은 JADX 같은 도구로 비교적 쉽게 풀어 볼 수 있고, 그 안에 박힌 키는 그대로 드러납니다. OWASP 모바일 Top 10도 하드코딩된 자격 증명을 첫 번째 위험(M1)으로 꼽고 있습니다.
  • 앱과 공공데이터 API 사이에 프록시를 둡니다. 키는 서버 환경변수나 플랫폼의 비밀 값 저장소에만 두고, 앱은 내 서버만 부릅니다. 웹이나 하이브리드 앱에서 자주 만나는 CORS 오류도 이 단계에서 같이 정리됩니다.
  • 코드에는 Decoding 키를 넣습니다. 쿼리 파라미터를 라이브러리가 인코딩해 주는 방식이라면 Decoding 키가 맞습니다. Encoding 키를 넣으면 한 번 더 인코딩되어 SERVICE_KEY_IS_NOT_REGISTERED_ERROR(30)가 납니다.
  • 캐시는 발표 시각에 맞춥니다. 기상청 단기예보 조회서비스는 개발계정 트래픽이 10,000으로 안내되어 있습니다(2026년 10월 확인). 초단기실황은 매시 정각에 만들어져 10분 이후에 제공되니, 캐시 키에 발표 시각과 격자 좌표를 넣으면 같은 동네 사용자는 한 번의 호출을 나눠 씁니다.
  • 프록시도 지켜야 합니다. 키를 숨겼다고 끝이 아닙니다. 누구나 내 프록시를 부를 수 있으면 남이 내 호출 한도를 대신 써 버릴 수 있습니다. 입력값을 좁히고, 공공 API로 나가는 호출 수에 상한을 거는 게 핵심입니다.

공공데이터 API를 앱에서 직접 호출하면 생기는 문제

공공데이터포털(data.go.kr)에 올라온 날씨, 대기오염, 주차장 정보 같은 오픈 API는 대부분 무료라서 작은 서비스를 만들 때 정말 고마운 재료입니다. 그런데 처음 붙일 때는 저처럼 앱 코드에서 공공데이터 API를 바로 부르게 만들기 쉽습니다. 가장 빨리 화면을 띄울 수 있는 방법이니까요. 이 구조는 사용자가 늘어날수록 아래 세 가지 문제로 돌아옵니다.

인증키가 노출되는 문제

공공데이터포털 API를 호출하려면 포털에서 발급받은 인증키(serviceKey)가 필요합니다. React Native, Flutter, Swift, Kotlin 등으로 만든 앱 소스 코드나 설정 파일에 이 인증키를 하드코딩해 직접 요청을 보내면 문제가 생깁니다. 안드로이드 배포 파일(.apk)은 JADX나 Apktool 같은 공개 도구로 열어 볼 수 있고, 코드 안에 문자열로 들어간 값은 생각보다 쉽게 찾아집니다. 난독화를 걸어도 문자열 자체를 완전히 숨기기는 어렵고, iOS 앱이라고 해서 사정이 크게 다르지 않습니다. 노출된 키를 누군가 가져다 쓰면 그 호출이 전부 내 한도에서 빠져나가고, 정작 내 서비스가 한도 초과로 멈출 수 있습니다. OWASP 모바일 Top 10의 M1(Improper Credential Usage)이 바로 이 상황, 즉 앱 안에 하드코딩된 자격 증명을 공격자가 찾아내 쓰는 경우를 다룹니다.

웹 프런트엔드도 마찬가지입니다. Next.js의 NEXT_PUBLIC_, Vite의 VITE_, Expo의 EXPO_PUBLIC_처럼 접두어가 붙은 환경변수는 빌드할 때 클라이언트 코드에 그대로 박혀 나갑니다. 환경변수에 넣었으니 안전하다고 생각하기 쉬운데, 이름에 이런 접두어가 붙어 있다면 사실상 앱에 하드코딩한 것과 같습니다. 인증키는 접두어 없는 서버 전용 변수에만 두세요.

일일 호출 한도 초과 문제

공공데이터포털 API에는 API마다 트래픽 한도가 정해져 있습니다. 이 글에서 쓰는 기상청 단기예보 조회서비스 페이지에는 개발계정 트래픽이 10,000으로, 운영계정은 활용사례를 등록하고 신청하면 늘릴 수 있다고 안내되어 있습니다. 한도는 API마다 다릅니다. 같은 포털 안에서도 5,000인 API가 있고 100,000인 API도 있으니, 쓰려는 API의 상세 페이지에서 꼭 직접 확인하세요.

문제는 앱이 켜질 때마다, 화면을 당겨 새로고침할 때마다 공공 API를 바로 부르는 구조입니다. 이러면 호출 수가 사용자 수와 사용 횟수에 그대로 비례해서 늘어납니다. 한도를 넘기면 포털은 LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR(코드 22, 일일 호출 허용량 초과)를 돌려주고, 짧은 시간에 요청이 몰리면 LIMITED_NUMBER_OF_SERVICE_REQUESTS_PER_SECOND_EXCEEDS_ERROR(코드 23, 초당 호출 허용량 초과)가 납니다. 그 순간부터 앱의 날씨 화면은 비어 버립니다.

서버 지연과 간헐적 다운타임 문제

공공 API도 결국 누군가 운영하는 서버라서, 점검이 잡히거나 응답이 평소보다 느려지는 날이 있습니다. 앱이 직접 그 서버를 기다리는 구조라면, 그 지연과 오류가 사용자 화면에 고스란히 전달됩니다. 로딩 표시만 계속 돌거나 빈 화면이 뜨면 사용자는 공공 API 탓이 아니라 내 앱이 고장 났다고 느낍니다. 그래서 앱이 의존하는 대상을 내가 통제할 수 있는 서버로 바꿔 두는 게 중요합니다.

인증키부터 제대로 알기: Encoding 키와 Decoding 키

공공데이터포털에서 활용신청을 하고 마이페이지의 활용신청 현황에 들어가면 일반 인증키가 두 줄로 보입니다. 하나는 Encoding, 하나는 Decoding이라고 적혀 있죠. 처음 보면 둘 중 뭘 써야 하는지 헷갈리는데, 사실 같은 키를 두 가지 모양으로 보여 주는 것뿐입니다.

  • Decoding 키는 원래 모양입니다. +, /, = 같은 문자가 그대로 들어 있습니다.
  • Encoding 키는 그 원본을 URL 인코딩해 둔 모양입니다. +가 %2B로, =가 %3D로 바뀌어 있습니다.

규칙은 하나입니다. 서버에 도착할 때 인코딩이 정확히 한 번만 되어 있어야 합니다. 브라우저 주소창이나 curl에 URL을 통째로 붙여 넣는다면 Encoding 키가 맞습니다. 반대로 JavaScript의 URLSearchParams, Python requests의 params, axios의 params처럼 라이브러리가 쿼리 값을 알아서 인코딩해 주는 방식이라면 Decoding 키를 넣어야 합니다. 여기에 Encoding 키를 넣으면 %가 다시 %25로 바뀌어 전혀 다른 문자열이 되고, 포털은 등록되지 않은 키라며 SERVICE_KEY_IS_NOT_REGISTERED_ERROR(30)를 돌려줍니다. 브라우저에서는 잘 되는데 코드에서만 30번 오류가 난다면 이중 인코딩부터 의심해 보세요. 이 글의 예제 코드는 모두 URLSearchParams를 쓰니 Decoding 키를 넣으면 됩니다.

30번 오류가 나는 이유가 인코딩만은 아닙니다. 인증키는 계정에 묶여 있지만 활용신청은 API마다 따로 합니다. 다른 API를 신청하면서 받은 키를 그대로 들고 와서, 정작 이 API는 활용신청을 안 했다면 같은 오류가 납니다. 신청 직후에는 포털과 제공기관 사이에 키가 반영되는 데 시간이 걸릴 수 있다는 안내도 있으니, 방금 신청했다면 조금 기다렸다가 다시 시도해 보는 것도 방법입니다. 몇 시간이 지나도 그대로라면 인코딩과 활용신청 목록을 다시 보고, 그래도 안 되면 포털 문의 게시판에 묻는 게 빠릅니다.

호출 방식 넣을 키 이유
브라우저 주소창, curl에 URL을 통째로 입력 Encoding 키 아무도 다시 인코딩하지 않으므로 이미 인코딩된 값을 넣어야 한다
URLSearchParams, requests의 params, axios의 params Decoding 키 라이브러리가 한 번 인코딩해 준다
문자열을 직접 이어 붙여 URL을 만들고 그대로 요청 Encoding 키 단, 쓰는 HTTP 클라이언트가 URL을 다시 인코딩하지 않는지 확인해야 한다

개발계정과 운영계정, 트래픽 10,000은 어떻게 봐야 할까

공공데이터포털에서 처음 활용신청을 하면 개발계정으로 시작합니다. 개발계정은 제공기관이 정해 둔 트래픽을 넘겨 쓸 수 없고, 기상청 단기예보 조회서비스의 경우 그 값이 10,000입니다. 이 API는 개발 단계와 운영 단계 모두 자동승인으로 표시되어 있어서, 신청하면 바로 쓸 수 있습니다. 다만 심의승인으로 되어 있는 API도 많고, 이 경우 담당자 확인을 거치는 동안 며칠이 걸릴 수 있습니다.

트래픽을 늘리려면 운영계정을 신청해야 합니다. 운영계정을 신청할 때는 필요한 트래픽을 직접 적고, 개발한 서비스를 활용사례로 등록해야 합니다. 활용사례에는 서비스 설명과 화면이 들어가니, 화면이 어느 정도 나온 뒤에 신청하는 게 자연스럽습니다. 출시 직전에 급하게 하지 말고, 베타 테스트를 시작할 즈음 미리 신청해 두면 마음이 편합니다.

그리고 하나 더. 포털 상세 페이지의 트래픽 한도와 별개로, 활용가이드에는 상세기능마다 초당 최대 트랜잭션 같은 처리 용량이 적혀 있습니다. 제가 확인한 기상청 단기예보 활용가이드(2024년 11월판)에는 초단기실황조회가 30tps로 적혀 있었습니다. 2026년 10월 현재 포털에는 더 최근 판(2610) 가이드가 올라와 있으니, 실제 값은 최신 가이드로 확인해 주세요. 일일 한도가 남아 있어도 한꺼번에 몰아서 부르면 23번 오류를 만날 수 있다는 정도만 기억하면 충분합니다.

캐시 TTL은 기상청 발표 시각에 맞춘다

캐시를 처음 붙일 때 저는 TTL을 그냥 1시간으로 잡았습니다. 그런데 이렇게 하면 두 가지가 어긋납니다. 첫째, 캐시가 언제 채워졌느냐에 따라 새 자료가 나왔는데도 최대 한 시간 가까이 지난 값을 보여 줄 수 있습니다. 둘째, 정작 요청할 때 넣는 base_time은 고정값이라서 날짜가 바뀌면 엉뚱한 자료를 부르거나 데이터가 없다는 응답을 받습니다. 원래 글의 예제 코드가 딱 그랬습니다. base_date를 특정 날짜로 박아 둬서, 다음 날부터는 의미 없는 요청이 나갔습니다. 이번에 코드를 고치면서 가장 먼저 바꾼 부분입니다.

기상청 단기예보 조회서비스에는 기능이 여러 개 있고, 기능마다 발표 시각이 다릅니다. 활용가이드에 적힌 기준을 정리하면 이렇습니다.

기능 base_time API 제공 시각 캐시 설계
초단기실황 getUltraSrtNcst 매시 정각(0000~2300) 정각 10분 이후. 생성 뒤 10분마다 최신 값으로 갱신 키에 발표 시각을 넣고, 같은 발표 시각 안에서는 10분까지만 신선한 값으로 본다
초단기예보 getUltraSrtFcst 매시 30분(0030~2330) 매시 45분 이후 다음 발표분이 나오는 시각(다음 시의 45분)까지
단기예보 getVilageFcst 0200, 0500, 0800, 1100, 1400, 1700, 2000, 2300(하루 8회) 각 발표 시각 10분 이후(02:10, 05:10 …) 다음 발표분 제공 시각까지. 최대 3시간

여기서 가장 많이 실수하는 지점이 시간대입니다. Cloudflare Workers나 대부분의 클라우드 서버는 UTC로 돕니다. new Date().getHours()로 발표 시각을 계산하면 아홉 시간이 밀린 값이 나옵니다. 아래 코드는 현재 시각에 9시간을 더해 한국 시간으로 옮긴 뒤 10분을 빼고, 그 시각의 정각을 base_time으로 씁니다. 이렇게 하면 0시 5분에 들어온 요청은 전날 2300 발표분을, 0시 10분부터는 당일 0000 발표분을 자연스럽게 부릅니다. 날짜가 바뀌는 경계도 따로 처리할 필요가 없습니다.

캐시 키도 중요합니다. 단기예보는 전국을 5km 간격 격자로 나눠 제공하고, 요청할 때 위경도가 아니라 격자 좌표(nx, ny)를 넣습니다. 그러니 캐시 키를 사용자 단위가 아니라 격자와 발표 시각 단위로 잡으면, 같은 동네에 있는 사용자는 모두 하나의 캐시를 나눠 씁니다. 사용자의 GPS 좌표를 격자로 바꾸는 일은 앱이나 프록시에서 하고, 공공 API에는 격자 좌표만 보내면 됩니다. 좌표 변환표는 활용가이드에 첨부된 엑셀 자료에 있습니다.

대략적인 계산도 해 볼 수 있습니다. 같은 격자를 10분마다 한 번씩 새로 받는다면 격자 하나당 하루 144번입니다. 개발계정 10,000건이면 약 69개 격자를 하루 종일 최신으로 유지할 수 있다는 뜻입니다. 한 시간에 한 번만 받는다면 하루 24번이니 400개 넘는 격자를 감당합니다. 사용자 수가 아니라 서비스하는 동네 수가 호출량을 정한다는 점, 이게 프록시와 캐시를 두는 진짜 이유입니다. 몇 분 단위의 신선도가 꼭 필요한지, 한 시간이면 충분한지는 서비스 성격에 따라 정하면 됩니다.

프록시 서버 아키텍처는 이렇게 동작한다

앱 클라이언트가 공공데이터 서버와 직접 통신하는 대신, 직접 관리하는 백엔드 서버(프록시)를 거쳐서 통신하도록 바꾸면 앞서 본 문제들이 구조적으로 해소됩니다. 전체 흐름은 다음과 같습니다.

sequenceDiagram
    autonumber
    actor User as 앱 사용자
    participant App as 모바일 앱 (Client)
    participant Proxy as 자체 프록시 서버
    participant Public as 공공데이터포털 API

    User->>App: 앱 실행 (날씨 화면 진입)
    App->>Proxy: GET /api/weather?nx=60&ny=127 (인증키 없음)
    note over Proxy: 1. 입력값 검증<br/>2. 발표 시각 계산<br/>3. 격자+발표 시각으로 캐시 조회
    alt 캐시 히트
        Proxy-->>App: 캐시 데이터 즉시 응답
    else 캐시 미스
        note over Proxy: 공공 API 호출 상한 확인
        Proxy->>Public: GET /getUltraSrtNcst?serviceKey=비밀키 (서버 간 통신)
        Public-->>Proxy: JSON 또는 XML 오류 응답
        note over Proxy: 필요한 필드만 추려 캐시에 저장
        Proxy-->>App: 가벼운 JSON 응답
    end
    App-->>User: 날씨 UI 렌더링

이 구조로 얻는 이점은 네 가지입니다. 첫째, 인증키 은닉입니다. 인증키는 프록시 서버의 환경변수나 비밀 값 저장소에만 있으므로 앱을 디컴파일하거나 패킷을 들여다봐도 나오지 않습니다. 둘째, CORS 문제 해결입니다. 웹 브라우저 환경(하이브리드 앱, PWA 등)에서 출처가 달라 생기는 CORS(교차 출처 리소스 공유) 오류를, 내 프록시 서버가 응답 헤더를 내려주는 방식으로 정리할 수 있습니다. 실제 서비스에서는 * 대신 내 앱의 출처만 허용하는 편이 안전합니다. 셋째, 데이터 경량화입니다. 공공 API 응답에는 앱이 쓰지 않는 필드가 많이 섞여 있는데, 프록시가 기온이나 습도처럼 필요한 값만 골라 내려 주면 앱 쪽 파싱 코드도 단순해집니다. 넷째, 트래픽 통제입니다. 공공 API로 나가는 호출이 전부 내 서버를 거치니, 호출 수를 세고 상한을 거는 일이 가능해집니다.

외부 의존성 없이 Node.js로 구현하기

프록시 서버를 만드는 데 Express나 Axios가 꼭 필요하지는 않습니다. 혼자 운영하는 작은 서비스라면 설치하고 업데이트할 패키지가 적을수록 편합니다. 아래 코드는 Node.js에 기본으로 들어 있는 http 모듈과 fetch(Node.js 문서 기준 v18부터 플래그 없이 쓸 수 있고, v21부터 실험 기능 딱지가 떨어졌습니다), 그리고 메모리 안의 Map만으로 돌아갑니다. 처음 글에 올렸던 코드에서 고친 점은 이렇습니다.

  • 날짜와 시각을 박아 두지 않고, 요청이 들어온 순간의 한국 시간으로 base_date와 base_time을 계산합니다.
  • 캐시 키를 고정 문자열 하나가 아니라 격자 좌표와 발표 시각 조합으로 바꿨습니다. 예전 코드는 모든 사용자에게 한 지점의 날씨만 돌려주는 구조였습니다.
  • 같은 키로 동시에 몰린 요청은 공공 API를 한 번만 부르게 했습니다. 정각 10분처럼 캐시가 한꺼번에 비는 순간에 23번 오류를 피하는 데 도움이 됩니다.
  • 공공 API로 나가는 호출에 분당·일일 상한을 걸었습니다.
  • 인증 오류가 JSON이 아니라 XML(OpenAPI_ServiceResponse)로 돌아오는 경우가 있다고 많이 보고되어 있어서, 두 형식을 모두 읽게 했습니다.
  • resultCode가 00인데 항목이 비어 있는 응답을 정상으로 넘기지 않습니다. 성공했는데 내용이 없는 응답은 화면을 조용히 비워 버리니까요.
  • 오류 내용을 앱에 그대로 내려보내지 않고, 인증키가 담긴 요청 URL을 로그에 남기지 않습니다.
/**
 * 공공데이터(기상청 초단기실황) 프록시 - 외부 패키지 없이 Node.js 18+ 에서 실행
 * - 인증키는 환경변수에만 둔다 (Decoding 키를 넣을 것)
 * - 캐시 키는 격자(nx, ny) + 발표시각(base_date, base_time)
 * - 같은 키로 동시에 들어온 요청은 공공 API를 한 번만 부른다
 * - 공공 API 호출(캐시 미스)에 분당·일일 상한을 건다
 *
 * 실행: PUBLIC_DATA_SERVICE_KEY="Decoding키" ALLOWED_ORIGIN="https://내도메인" node server.js
 */
const http = require('http');

const SERVICE_KEY = process.env.PUBLIC_DATA_SERVICE_KEY;
if (!SERVICE_KEY) {
  console.error('PUBLIC_DATA_SERVICE_KEY 환경변수가 없습니다.');
  process.exit(1);
}
if (SERVICE_KEY.includes('%')) {
  // Encoding 키(%2B, %3D 등이 섞인 값)를 넣으면 URLSearchParams가 한 번 더 인코딩한다
  console.warn('인증키에 %가 들어 있습니다. Encoding 키 대신 Decoding 키를 쓰세요.');
}

const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || '';
const ENDPOINT = 'https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getUltraSrtNcst';
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const TEN_MIN_MS = 10 * 60 * 1000;
const FRESH_TTL_MS = 10 * 60 * 1000;            // 실황은 10분마다 갱신되므로 최대 10분만 신선하다고 본다
const STALE_LIMIT_MS = 3 * 60 * 60 * 1000;      // 장애 때 내려줄 수 있는 오래된 캐시의 한계
const UPSTREAM_PER_MIN = 30;                    // 내 프록시가 1분에 공공 API를 부를 수 있는 최대 횟수
const UPSTREAM_PER_DAY = 9000;                  // 개발계정 10,000건보다 여유 있게 잡은 일일 상한

const cache = new Map();     // key -> { data, savedAt }
const inflight = new Map();  // key -> Promise
let minuteWindow = { start: Date.now(), count: 0 };
let dayWindow = { day: '', count: 0 };

// 초단기실황 발표시각 계산: 매시 정시 생성, 정시 10분 이후 제공 (KST 기준)
function ultraSrtNcstBase(now = Date.now()) {
  const shifted = new Date(now + KST_OFFSET_MS - TEN_MIN_MS); // KST로 옮기고 10분을 뺀다
  const yyyy = shifted.getUTCFullYear();
  const mm = String(shifted.getUTCMonth() + 1).padStart(2, '0');
  const dd = String(shifted.getUTCDate()).padStart(2, '0');
  const hh = String(shifted.getUTCHours()).padStart(2, '0');
  return { base_date: `${yyyy}${mm}${dd}`, base_time: `${hh}00` };
}

function kstDay(now = Date.now()) {
  return new Date(now + KST_OFFSET_MS).toISOString().slice(0, 10);
}

function allowUpstreamCall() {
  const now = Date.now();
  if (now - minuteWindow.start >= 60 * 1000) minuteWindow = { start: now, count: 0 };
  const today = kstDay(now);
  if (dayWindow.day !== today) dayWindow = { day: today, count: 0 };
  if (minuteWindow.count >= UPSTREAM_PER_MIN || dayWindow.count >= UPSTREAM_PER_DAY) return false;
  minuteWindow.count += 1;
  dayWindow.count += 1;
  return true;
}

// 인증 오류 등은 dataType=JSON을 줘도 XML로 오는 경우가 있어 둘 다 처리한다
function parseUpstream(text) {
  try {
    const json = JSON.parse(text);
    const header = json.response?.header || {};
    return { code: header.resultCode, msg: header.resultMsg, items: json.response?.body?.items?.item || [] };
  } catch {
    const pick = (tag) => (text.match(new RegExp(`<${tag}>([^<]*)</${tag}>`)) || [])[1];
    return {
      code: pick('returnReasonCode') || pick('resultCode') || 'UNKNOWN',
      msg: pick('returnAuthMsg') || pick('resultMsg') || 'XML 오류 응답',
      items: [],
    };
  }
}

const NAMES = { T1H: 'temperature', REH: 'humidity', WSD: 'wind_speed', RN1: 'rain_1h', PTY: 'precip_type' };

async function fetchFromPortal(nx, ny, base) {
  const params = new URLSearchParams({
    serviceKey: SERVICE_KEY, // Decoding 키. URLSearchParams가 한 번만 인코딩한다
    pageNo: '1',
    numOfRows: '20',
    dataType: 'JSON',
    base_date: base.base_date,
    base_time: base.base_time,
    nx: String(nx),
    ny: String(ny),
  });
  const res = await fetch(`${ENDPOINT}?${params}`, { signal: AbortSignal.timeout(5000) });
  const text = await res.text();
  if (!res.ok) throw new Error(`HTTP ${res.status}`); // URL에는 키가 들어 있으니 로그에 남기지 않는다
  const parsed = parseUpstream(text);
  if (parsed.code !== '00') throw new Error(`공공 API 오류 ${parsed.code} ${parsed.msg}`);
  if (parsed.items.length === 0) throw new Error('resultCode 00인데 항목이 비어 있음');
  return parsed.items
    .filter((item) => NAMES[item.category])
    .map((item) => ({ metric: NAMES[item.category], value: Number(item.obsrValue) }));
}

function send(res, status, body, extraHeaders = {}) {
  res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...extraHeaders });
  res.end(JSON.stringify(body));
}

const server = http.createServer(async (req, res) => {
  if (ALLOWED_ORIGIN) {
    // CORS는 브라우저만 지키는 규칙이다. 앱이나 curl 요청을 막아 주지는 않는다
    res.setHeader('Access-Control-Allow-Origin', ALLOWED_ORIGIN);
    res.setHeader('Vary', 'Origin');
    res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
  }
  if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }

  const url = new URL(req.url, 'http://localhost');
  if (req.method !== 'GET' || url.pathname !== '/api/weather') {
    return send(res, 404, { error: '없는 경로입니다.' });
  }

  // 격자 범위(동서 149 x 남북 253) 밖이거나 숫자가 아니면 공공 API까지 보내지 않는다
  const nx = Number(url.searchParams.get('nx'));
  const ny = Number(url.searchParams.get('ny'));
  if (!Number.isInteger(nx) || !Number.isInteger(ny) || nx < 1 || nx > 149 || ny < 1 || ny > 253) {
    return send(res, 400, { error: 'nx, ny 값을 확인해 주세요.' });
  }

  const base = ultraSrtNcstBase();
  const key = `${nx},${ny},${base.base_date}${base.base_time}`;
  const hit = cache.get(key);
  if (hit && Date.now() - hit.savedAt < FRESH_TTL_MS) {
    return send(res, 200, { source: 'cache', base, data: hit.data }, { 'Cache-Control': 'public, max-age=60' });
  }

  try {
    if (!inflight.has(key)) {
      if (!allowUpstreamCall()) throw new Error('프록시 호출 상한 도달');
      inflight.set(key, fetchFromPortal(nx, ny, base).finally(() => inflight.delete(key)));
    }
    const data = await inflight.get(key);
    cache.set(key, { data, savedAt: Date.now() });
    return send(res, 200, { source: 'portal', base, data }, { 'Cache-Control': 'public, max-age=60' });
  } catch (err) {
    console.error('[proxy]', err.message);
    // 같은 격자의 가장 최근 캐시를 찾아, 너무 오래되지 않았다면 경고와 함께 돌려준다
    const prefix = `${nx},${ny},`;
    let latest = null;
    for (const [k, v] of cache) {
      if (k.startsWith(prefix) && (!latest || v.savedAt > latest.savedAt)) latest = v;
    }
    if (latest && Date.now() - latest.savedAt < STALE_LIMIT_MS) {
      return send(res, 200, { source: 'stale_cache', warning: '최신 자료를 받지 못해 이전 값을 보여 줍니다.', savedAt: new Date(latest.savedAt).toISOString(), data: latest.data });
    }
    return send(res, 503, { error: '날씨 정보를 잠시 불러올 수 없습니다.' });
  }
});

// 오래된 캐시 정리 (10분마다)
setInterval(() => {
  const now = Date.now();
  for (const [k, v] of cache) if (now - v.savedAt > STALE_LIMIT_MS) cache.delete(k);
}, 10 * 60 * 1000).unref();

const PORT = Number(process.env.PORT) || 3000;
server.listen(PORT, () => console.log(`프록시 실행 중: http://localhost:${PORT}/api/weather?nx=60&ny=127`));

숫자 몇 개는 제 기준으로 잡은 값입니다. 분당 30회는 앞서 본 가이드의 처리 용량과 맞춰 둔 것이고, 일일 9,000회는 개발계정 10,000건에서 테스트와 실수에 쓸 여유를 남긴 값입니다. 운영계정으로 한도를 늘렸다면 그에 맞게 바꾸면 됩니다. 이 코드의 캐시와 카운터는 프로세스 메모리에 있으니, 서버를 재시작하면 초기화되고 인스턴스를 여러 대 띄우면 각자 따로 셉니다. 한 대로 시작하는 작은 서비스에는 이 정도로 충분하고, 여러 대로 늘릴 때 Redis 같은 공유 저장소로 옮기면 됩니다.

서버리스에 올린다면: Cloudflare Workers와 Vercel

서버 한 대를 계속 켜 두는 게 부담이라면 서버리스 함수에 올리는 방법도 있습니다. 다만 서버리스는 인스턴스가 자주 새로 뜨기 때문에 위 코드의 메모리 캐시는 거의 기대할 수 없습니다. 캐시를 플랫폼이 제공하는 쪽으로 옮겨야 합니다.

Cloudflare Workers

Workers에서는 인증키를 npx wrangler secret put KMA_SERVICE_KEY로 넣습니다. 비밀 값은 암호화되어 저장되고, 코드에서는 env.KMA_SERVICE_KEY로 읽습니다. 로컬 개발용 값은 .dev.vars나 .env 파일에 두되, 이 파일은 git에 올리지 말라고 공식 문서에 적혀 있습니다. 캐시는 Cache API(caches.default)를 쓰는데, 문서에 따르면 캐시는 요청이 처리된 데이터센터 안에만 저장되고 다른 지역으로 복제되지 않습니다. 또 커스텀 도메인에 연결한 Worker에서 동작한다고 안내되어 있으니, 실제 서비스는 내 도메인에 붙여 두는 게 좋습니다.

호출 상한은 Rate Limiting 바인딩으로 겁니다. 설정 파일에 ratelimits 항목을 추가하고 코드에서 env.이름.limit({ key })를 부르면 허용 여부가 돌아옵니다. 기간은 10초나 60초 중에서 고를 수 있고, 문서에서도 이 제한은 근사치이며 데이터센터마다 따로 센다고 분명히 밝히고 있습니다. 그래서 저는 이걸 사용자 단위 제한이 아니라 캐시 미스일 때 공공 API로 나가는 호출의 안전장치로만 씁니다. 공공 API의 일일 한도를 정확히 지켜야 한다면 Durable Objects로 카운터를 한 곳에 모으라는 게 문서의 권고입니다.

// Cloudflare Workers 버전 (요점만). 인증키는 `npx wrangler secret put KMA_SERVICE_KEY` 로 넣는다.
// wrangler 설정에 ratelimits 바인딩(UPSTREAM_LIMITER, simple: { limit: 30, period: 60 })을 추가해 둔다.
export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    if (url.pathname !== '/api/weather') return new Response('Not found', { status: 404 });

    const nx = Number(url.searchParams.get('nx'));
    const ny = Number(url.searchParams.get('ny'));
    if (!Number.isInteger(nx) || !Number.isInteger(ny) || nx < 1 || nx > 149 || ny < 1 || ny > 253) {
      return Response.json({ error: 'nx, ny 값을 확인해 주세요.' }, { status: 400 });
    }

    // Workers는 UTC로 돈다. KST로 옮긴 뒤 10분을 빼서 초단기실황 발표시각을 구한다
    const t = new Date(Date.now() + 9 * 3600e3 - 10 * 60e3);
    const base_date = t.toISOString().slice(0, 10).replaceAll('-', '');
    const base_time = String(t.getUTCHours()).padStart(2, '0') + '00';

    // 캐시 키에는 인증키가 아니라 격자와 발표시각만 넣는다
    const cacheKey = new Request(`https://cache.internal/ncst/${nx}/${ny}/${base_date}${base_time}`);
    const cache = caches.default;
    const cached = await cache.match(cacheKey);
    if (cached) return cached;

    // 캐시 미스일 때만 공공 API 호출 횟수를 센다 (근사치 제한이므로 일일 한도는 따로 여유를 둔다)
    const { success } = await env.UPSTREAM_LIMITER.limit({ key: 'kma-ncst' });
    if (!success) return Response.json({ error: '잠시 후 다시 시도해 주세요.' }, { status: 429 });

    const params = new URLSearchParams({
      serviceKey: env.KMA_SERVICE_KEY, // Decoding 키
      pageNo: '1', numOfRows: '20', dataType: 'JSON',
      base_date, base_time, nx: String(nx), ny: String(ny),
    });
    const upstream = await fetch(`https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getUltraSrtNcst?${params}`);
    const text = await upstream.text();
    let items = [];
    try {
      const json = JSON.parse(text);
      if (json.response?.header?.resultCode !== '00') throw new Error(json.response?.header?.resultMsg);
      items = json.response.body?.items?.item ?? [];
    } catch (e) {
      console.log('upstream error', upstream.status, String(e?.message ?? e).slice(0, 100));
      return Response.json({ error: '날씨 정보를 잠시 불러올 수 없습니다.' }, { status: 503 });
    }
    if (items.length === 0) return Response.json({ error: '자료가 비어 있습니다.' }, { status: 503 });

    const body = items.map((i) => ({ category: i.category, value: Number(i.obsrValue) }));
    const response = Response.json({ base_date, base_time, data: body }, {
      headers: { 'Cache-Control': 'public, max-age=600' },
    });
    ctx.waitUntil(cache.put(cacheKey, response.clone()));
    return response;
  },
};

무료 플랜 한도도 알아 두면 좋습니다. 2026년 10월 기준 Workers Free는 하루 요청 10만 건(UTC 자정에 초기화), 요청당 CPU 시간 10ms, 요청당 하위 요청 50개입니다. 이 프록시는 요청 하나에 공공 API를 많아야 한 번 부르고 계산도 가벼워서 무료 플랜 안에서 충분히 돕니다. 하루 10만 건을 넘기면 1027 오류가 나니, 사용자가 늘면 유료 플랜을 검토하세요.

Vercel Functions

Vercel에서는 대시보드나 CLI로 환경변수를 추가하는데, 이때 Secret으로 지정하면 저장한 뒤에는 다시 읽어 볼 수 없는 값이 됩니다. 환경변수를 바꿔도 이미 배포된 버전에는 반영되지 않고 새 배포부터 적용된다는 점을 잊기 쉽습니다. 키를 교체했는데 계속 30번 오류가 난다면 재배포를 했는지부터 확인하세요. 함수 응답을 Vercel CDN에 캐시하려면 응답에 Cache-Control: public, s-maxage=600처럼 s-maxage가 들어간 헤더를 붙이면 됩니다. stale-while-revalidate를 함께 쓰면 캐시가 만료된 직후에도 이전 값을 먼저 내려 주고 뒤에서 새로 받아 옵니다. 다만 이 CDN 캐시는 URL 단위라서, 앱이 nx, ny를 같은 형식으로 보내야 같은 캐시를 나눠 씁니다.

서버리스로 갈지 작은 서버 한 대로 갈지는 운영 습관에 따라 갈립니다. 서버를 직접 띄운다면 Railway 요금과 사용법이나 Caddy 서버 가이드가 도움이 될 거고, 어차피 n8n 같은 자동화 서버를 하나 돌리고 있다면 그 옆에 붙여 두는 것도 방법입니다. 셀프호스팅에 실제로 얼마가 드는지는 n8n 셀프호스팅 vs 클라우드 요금 비교에 정리해 두었습니다.

프록시가 남의 호출 창구가 되지 않게

키를 서버로 옮기고 나면 오히려 놓치기 쉬운 게 이 부분입니다. 키는 숨겼는데, 정작 내 프록시 주소는 누구나 부를 수 있었습니다. 누군가 내 프록시를 찾아내 반복해서 호출하면 결국 내 인증키로 공공 API를 부르는 셈이고, 한도는 똑같이 줄어듭니다. 앱 안에 비밀 토큰을 하나 더 넣어 프록시를 보호하려는 생각도 들 수 있지만, 그 토큰 역시 앱에서 꺼낼 수 있으니 근본적인 해결은 아닙니다. 그래서 막는 방향을 바꿔야 합니다.

  • 입력값을 좁힙니다. 프록시가 아무 파라미터나 공공 API에 그대로 넘기면 안 됩니다. 위 코드는 격자 범위(동서 149, 남북 253) 안의 정수만 받고, 기능도 초단기실황 하나로 고정했습니다. 서비스 지역이 정해져 있다면 허용할 격자 목록을 아예 정해 두는 게 더 좋습니다.
  • 캐시 미스에 상한을 겁니다. 캐시가 맞는 요청은 아무리 많아도 공공 API 한도를 쓰지 않습니다. 비용이 생기는 건 캐시 미스뿐이니, 그 횟수에 상한을 거는 게 가장 효과가 큽니다. OWASP API Security Top 10의 API4(Unrestricted Resource Consumption)가 다루는 문제도 결국 이것입니다.
  • CORS를 보안 장치로 믿지 않습니다. CORS는 브라우저가 지키는 규칙입니다. 네이티브 앱이나 curl, 서버에서 보내는 요청은 CORS와 상관없이 그대로 들어옵니다. 브라우저에서 남의 사이트가 내 프록시를 가져다 쓰는 걸 줄여 주는 정도로만 기대하세요.
  • 더 강하게 막아야 한다면 앱 증명을 붙입니다. Firebase App Check나 Play Integrity, App Attest처럼 요청이 내 정품 앱에서 왔는지 확인해 주는 서비스가 있습니다. 작은 서비스에서 처음부터 필요하지는 않지만, 호출량이 늘고 이상한 트래픽이 보이기 시작하면 검토할 만합니다.
  • 로그와 오류 메시지를 조심합니다. 공공 API 요청 URL에는 인증키가 들어 있습니다. 예외 객체나 요청 URL을 통째로 로그에 찍거나 앱에 돌려주면, 숨겨 둔 키가 거기서 새어 나갑니다.

자주 만나는 오류 코드와 대응

공공데이터포털의 에러코드 안내(2026년 10월 확인)와 기상청 단기예보 활용가이드를 기준으로, 프록시를 운영하면서 실제로 신경 써야 하는 것만 추렸습니다. 이름이 비슷한 코드가 많으니 메시지 문자열보다 숫자 코드로 분기하는 편이 안전합니다.

코드 메시지 뜻 프록시에서의 대응
03 NODATA_ERROR 데이터 없음(기상청 가이드) 격자 좌표나 발표 시각이 잘못된 경우가 많다. 좌표 변환과 base_time 계산을 먼저 본다
05 SERVICETIMEOUT_ERROR 기관 API 연결 실패 또는 응답 시간 초과 잠시 후 재시도. 그사이 이전 캐시를 경고와 함께 내려 준다
10 INVALID_REQUEST_PARAMETER_ERROR 파라미터 값이나 형식 오류 재시도해도 안 된다. 코드 수정 대상
12 NO_OPENAPI_SERVICE_ERROR 없거나 폐기된 서비스 URL 오타 확인. 폐기 공지가 있었는지 포털에서 확인
20 SERVICE_ACCESS_DENIED_ERROR, PERMISSION_DENIED, SERVICE_KEY_IS_NULL 이용 권한이 확인되지 않거나 인증키가 빠짐 이 API를 활용신청했는지, 승인·일시중지 상태인지, 환경변수가 비어 있지 않은지 확인
22 LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR 일일 호출 허용량 초과 호출량이 초기화될 때까지 캐시로 버틴다. 반복되면 운영계정 신청
23 LIMITED_NUMBER_OF_SERVICE_REQUESTS_PER_SECOND_EXCEEDS_ERROR 초당 호출 허용량 초과 동시 요청 합치기, 분당 상한 낮추기
29 BLACKLIST_IP_ACCESS_ERROR 차단된 IP에서 호출 호출 서버 IP 확인 후 활용지원센터 문의
30 SERVICE_KEY_IS_NOT_REGISTERED_ERROR 등록되지 않은 인증키 이중 인코딩, 다른 API용 키 사용, 신청 직후 반영 지연 순으로 확인
31 DEADLINE_HAS_EXPIRED_ERROR 인증키 사용 기한 만료 포털에서 이용 기간 확인 후 갱신. 만료일을 달력에 적어 둔다

재시도할 오류와 고쳐야 할 오류를 나눠 두는 게 핵심입니다. 05나 23은 잠깐 기다리면 풀리지만, 10이나 30은 백 번을 다시 불러도 똑같습니다. 고쳐야 할 오류에 자동 재시도를 걸어 두면 한도만 더 빨리 줄어듭니다. 고쳐야 할 오류는 재시도 없이 바로 알림이 오게 해 두는 편이 낫습니다.

세 가지 상황에서 검증하기

코드를 올린 뒤에는 아래 세 가지 상황을 직접 만들어 보면서 의도대로 움직이는지 확인합니다. 응답의 source 값만 봐도 어느 경로를 탔는지 알 수 있게 해 두었습니다.

상황 만드는 방법 기대 결과 확인할 것
① 첫 요청, 캐시 미스 서버를 띄운 직후 /api/weather?nx=60&ny=127 호출 source: "portal"과 함께 발표 시각(base), 기온·습도 등이 담긴 JSON 응답의 base_time이 지금 한국 시간 기준으로 맞는지. 서버 로그에 오류가 없는지
② 같은 격자 재요청, 캐시 히트 같은 주소를 몇 번 더 호출하고, 다른 격자도 하나 섞어 본다 같은 격자는 source: "cache", 다른 격자는 다시 "portal" 다음 날 포털의 활용 현황에서 호출 수가 요청 수보다 훨씬 적은지
③ 공공 API 장애 한 번 정상 응답을 받은 뒤 서버의 외부 네트워크를 잠시 끊고, 10분이 지나 캐시가 신선 기간을 넘긴 다음 다시 호출한다 캐시가 있으면 source: "stale_cache"와 경고 문구, 없으면 503과 사용자용 안내 문구 앱이 빈 화면이 아니라 이전 값이나 안내 문구를 보여 주는지. 응답이나 로그에 인증키가 찍히지 않는지

③번에서 하나 덧붙이면, 성공 응답인데 내용이 비어 있는 경우를 꼭 따로 시험해 보세요. 오류는 눈에 띄니까 금방 고치지만, resultCode가 00인데 항목이 없는 응답은 그냥 지나가기 쉽습니다. 앱 출시 직전에 이런 빈 응답 때문에 고생한 이야기는 드라이브라디오·MEMVIA 출시 직전 사후분석에 따로 적어 두었습니다. 앱이 정말로 공공데이터포털 주소를 더 이상 부르지 않는지는 코드 검색만으로는 확신하기 어려우니, 실제로 나가는 요청을 가로채 보는 것도 좋습니다. 방법은 HTTP Toolkit 사용법에 정리했습니다.

언제 프록시를 쓰고, 언제 다른 선택지를 쓸까

프록시를 두면 관리할 서버가 하나 늘어납니다. 얻는 것과 치르는 것을 나란히 놓고 보면 이렇습니다.

비교 항목 앱에서 직접 호출 작은 Node.js 서버 서버리스 함수(Workers·Vercel)
인증키 보호 안 됨 (배포 파일을 열면 키가 드러남) 됨 (서버 환경변수) 됨 (플랫폼 비밀 값 저장소)
초기 공수 없음 보통 (서버 한 대 배포와 관리) 낮음~보통 (플랫폼별 캐시·설정을 익혀야 함)
호출량 절감 어려움 (사용자 수에 비례해 증가) 큼 (메모리 캐시, 동시 요청 합치기) 큼 (Cache API나 CDN 캐시. 단, 지역별로 따로 쌓임)
CORS 막히기 쉬움 (웹 환경) 직접 제어 직접 제어
어울리는 시점 로컬에서 응답 구조를 잠깐 확인할 때만 이미 서버가 하나 있거나, 캐시를 세밀하게 다루고 싶을 때 서버를 켜 두기 싫고 트래픽이 들쭉날쭉할 때

캐싱이 오히려 위험해지는 경우

제공하려는 서비스가 지진 경보, 재난 긴급 알림, 실시간 도로 통제 현황처럼 데이터가 조금만 늦어도 사람이나 재산에 피해가 갈 수 있는 분야라면, 캐시는 오히려 위험합니다. 이런 경우에는 캐시를 아주 짧게 잡거나 건너뛰고, 늘어나는 호출량은 운영계정으로 전환해 트래픽 증설을 신청하는 쪽으로 풀어야 합니다. 그리고 이런 데이터는 공공 API를 받아 다시 보여 주는 앱보다 공식 재난문자나 기관 서비스가 우선이라는 점을 화면에서 분명히 알려 주는 게 맞습니다.

반대로 대기질처럼 정해진 주기로 갱신되는 데이터라면, 그 갱신 주기에 맞춰 TTL을 잡는 게 기본입니다. 저는 API 상세 페이지와 활용가이드에서 발표 시각이나 갱신 주기를 먼저 확인하고, 그보다 짧게 캐시를 잡는 쪽을 택합니다. 캐시 때문에 사용자가 오래된 정보를 보는 것보다는, 호출을 조금 더 쓰는 편이 낫기 때문입니다. 응답에 발표 시각을 같이 내려 주고 앱 화면에 “몇 시 발표 기준”이라고 작게 적어 두면, 캐시 값을 보여 줄 때도 사용자가 헷갈리지 않습니다.

자주 하는 실수

  • Encoding 키를 URLSearchParams에 넣는다. 가장 흔합니다. 30번 오류가 나면 제일 먼저 확인하세요.
  • base_date와 base_time을 고정값으로 둔다. 개발하는 날에는 잘 되다가 다음 날부터 데이터가 없다는 응답이 옵니다. 처음 이 글에 올렸던 예제가 그랬습니다.
  • 서버 시간대를 잊는다. UTC 서버에서 한국 시간 발표 시각을 계산하지 않으면 아홉 시간 전 자료를 부르거나 아직 나오지 않은 발표분을 요청합니다.
  • 제공 시각 전에 부른다. 초단기실황은 정각 10분, 초단기예보는 45분, 단기예보는 발표 10분 이후에 제공됩니다. 정각에 바로 부르면 아직 자료가 없을 수 있습니다.
  • 캐시 키에 사용자 정보를 넣는다. 사용자 ID나 원래 GPS 좌표를 키에 넣으면 캐시가 거의 맞지 않습니다. 격자와 발표 시각이면 충분합니다.
  • 프록시를 그대로 열어 둔다. 아무 파라미터나 넘겨주는 범용 프록시는 남이 쓰기에도 편합니다. 기능과 입력 범위를 좁혀 두세요.
  • 클라이언트용 환경변수에 키를 넣는다. NEXT_PUBLIC_, VITE_, EXPO_PUBLIC_이 붙은 변수는 번들에 들어갑니다.
  • 키 교체 후 재배포를 안 한다. Vercel처럼 환경변수 변경이 새 배포부터 적용되는 플랫폼에서 자주 생깁니다.

AI 도구로 빠르게 만든 앱일수록 이런 구멍이 남아 있기 쉽습니다. 키를 프런트엔드에 두는 문제는 Supabase에서도 똑같이 일어나는데, 그쪽은 Supabase RLS 설정 방법에서 secret 키와 RLS 관점으로 정리했습니다. 출시 전에 전체 구조를 한 번 훑어보고 싶다면 바이브 코딩 앱 프로덕션 가이드도 함께 보세요.

출시 전 체크리스트

  • 앱 코드와 설정 파일 전체에서 apis.data.go.kr과 인증키 문자열을 검색해 하나도 남지 않았는지 확인했다.
  • 프록시에는 Decoding 키를 넣었고, 키는 서버 전용 환경변수나 비밀 값 저장소에만 있다.
  • .env, .dev.vars 같은 파일이 git에 올라가지 않도록 .gitignore에 넣었다. 예전에 한 번이라도 커밋한 적이 있다면 키를 재발급했다.
  • base_date와 base_time을 한국 시간 기준으로 계산하고, 0시 전후와 정각 직후에 제대로 바뀌는지 확인했다.
  • 캐시 키는 격자와 발표 시각 조합이다.
  • 공공 API로 나가는 호출 수에 상한이 있고, 그 상한이 내 계정 트래픽보다 낮다.
  • 오류 코드별로 재시도할지, 이전 캐시를 보여 줄지, 알림을 보낼지 정해 두었다.
  • 응답, 로그, 오류 메시지 어디에도 인증키나 키가 담긴 URL이 찍히지 않는다.
  • 사용자가 늘 것 같다면 활용사례를 등록하고 운영계정 트래픽 증설을 미리 신청했다.
  • 공공누리 출처표시 조건이 붙은 데이터라면 앱 안에 출처를 표시했다. 기상청 단기예보 조회서비스는 이용허락범위가 출처표시(제1유형)로 되어 있습니다.

자주 묻는 질문

인증키를 난독화하거나 암호화해서 앱에 넣으면 안 되나요?

시간을 조금 벌어 줄 뿐입니다. 앱은 결국 실행 중에 원래 키를 꺼내 써야 하고, 그 순간의 값이나 실제로 나가는 요청을 보면 키가 드러납니다. 비밀은 사용자 기기에 내려가지 않는 곳에 두는 게 유일하게 확실한 방법입니다.

인증키가 이미 노출된 것 같으면 어떻게 하나요?

포털에서 키를 재발급받고, 프록시의 환경변수를 새 키로 바꾼 뒤 재배포하세요. 재발급하면 이전 키는 폐기된다고 안내되어 있으니, 예전 키를 쓰는 구버전 앱이 있다면 그 앱의 공공 API 직접 호출은 그때부터 끊깁니다. 프록시 구조로 옮겨 두었다면 앱 업데이트 없이 서버 쪽 키만 바꾸면 됩니다.

개발계정 10,000건으로 서비스를 운영해도 되나요?

작은 서비스라면 캐시만 잘 걸어도 버틸 수 있습니다. 앞서 계산한 대로 호출량은 사용자 수보다 서비스하는 격자 수와 갱신 주기에 달려 있습니다. 다만 개발계정은 이름 그대로 개발 단계용이고, 한도를 넘기면 바로 막힙니다. 실제 사용자를 받기 시작했다면 운영계정으로 넘어가는 게 마음 편합니다.

JSON으로 요청했는데 XML이 돌아와요.

dataType=JSON을 빼먹으면 기본값이 XML이라 XML로 옵니다. 파라미터를 제대로 넣었는데도 XML이라면 인증 오류 같은 게이트웨이 단계의 오류 응답일 가능성이 큽니다. 응답 본문에서 returnReasonCode나 returnAuthMsg를 찾아보면 원인이 적혀 있습니다. 위 예제의 parseUpstream 함수가 이 경우를 처리합니다.

서버리스에서도 메모리 캐시를 쓰면 안 되나요?

코드는 돌아가지만 기대만큼 맞지 않습니다. 인스턴스가 언제 새로 뜰지 모르고, 동시에 여러 인스턴스가 떠 있으면 각자 따로 캐시를 가집니다. Workers라면 Cache API나 KV, Vercel이라면 s-maxage 헤더를 이용한 CDN 캐시처럼 플랫폼이 제공하는 캐시를 쓰는 게 맞습니다.

다른 공공 API에도 같은 구조를 쓸 수 있나요?

네. 바뀌는 건 갱신 주기와 캐시 키뿐입니다. API 상세 페이지에서 트래픽 한도와 업데이트 주기를, 활용가이드에서 요청 파라미터와 오류 코드를 확인한 뒤, 캐시 키를 “사용자마다 달라지는 값”이 아니라 “데이터가 달라지는 단위”로 잡으면 됩니다.

지금 바로 적용할 수 있는 단계

  • 1단계, 로컬에서 프록시 띄워 보기: 위 Node.js 코드를 server.js로 저장하고 PUBLIC_DATA_SERVICE_KEY="Decoding키" node server.js로 실행한 뒤, 브라우저에서 http://localhost:3000/api/weather?nx=60&ny=127을 열어 봅니다. 좌표는 활용가이드 첨부 엑셀에서 내 동네 값으로 바꿔 넣으세요.
  • 2단계, 앱의 호출 주소 바꾸기: 앱 코드에서 apis.data.go.kr을 부르던 부분을 모두 지우고, 내 프록시 주소(예: https://api.내도메인/api/weather)로 바꿉니다. 앱은 위경도를 격자로 바꿔 nx, ny만 보냅니다.
  • 3단계, 배포하고 지켜보기: 작은 서버나 서버리스 중 편한 곳에 올리고, 인증키는 플랫폼의 비밀 값으로 넣습니다. 며칠 동안 포털의 호출 수와 프록시 로그를 비교해 캐시가 제대로 맞는지 확인합니다.

참고 자료