공공데이터 API 앱 연동 시 필수 보안 아키텍처와 성능 최적화 가이드
핵심 요약
- 보안 위협: 공공데이터 API 인증키(serviceKey)를 앱에 하드코딩해서 호출하면 역컴파일 도구(JADX 등)로 5분 안에 탈취당할 수 있고, 무단 호출로 쿼터가 소진되어 서비스가 멈출 위험이 있습니다.
- 대응 방향(프록시 서버): 앱과 공공데이터 API 사이에 백엔드 프록시 서버를 두고 환경변수로 키를 숨기면 인증키 노출을 막고 브라우저 CORS 문제도 함께 해결됩니다.
- 성능(인메모리 캐싱): 갱신 주기가 정해진 공공데이터 특성에 맞춰 프록시에 메모리 캐시를 두면 일일 10,000건 쿼터 제한을 넘기지 않으면서 응답 속도를 10ms 이하로 줄일 수 있습니다.
공공데이터 API를 앱에서 직접 호출하면 생기는 문제
공공데이터포털(data.go.kr)이 제공하는 날씨, 대기오염, 전국 주차장 정보 같은 Open API는 무료로 쓸 수 있어 모바일 서비스를 만들 때 요긴합니다. 문제는 많은 초보 개발자나 1인 창업가가 구현 편의성만 보고 앱(클라이언트) 코드에서 공공데이터 API 서버를 직접 호출하도록 설계한다는 점입니다. 이 구조는 서비스가 커질수록 아래 세 가지 문제로 이어집니다.
인증키가 노출되는 문제
공공데이터포털 API를 호출하려면 개인별·서비스별로 발급받은 고유의 serviceKey(인증키)가 필요합니다. React Native, Flutter, Swift, Kotlin 등으로 만든 앱 소스 코드나 설정 파일에 이 인증키를 하드코딩해 직접 요청을 보내면 문제가 생깁니다. 모바일 앱 배포 파일(.apk 또는 .ipa)은 JADX, Apktool 같은 역컴파일 도구를 쓰면 5분 이내에 내부 소스 코드와 리소스 텍스트가 평문으로 드러납니다. 노출된 API 키가 무단 복제되어 다른 앱이나 트래픽 유발에 쓰이면, 인증키가 제재되거나 쿼터가 소진되어 정작 본인의 서비스가 멈추는 사태로 이어집니다. 이는 OWASP API Security Top 10이 지적하는 인증 제어 실패, 자산 관리 소홀에 해당하는 사례입니다.
일일 호출 쿼터 초과 문제
공공데이터포털에서 발급되는 일반 API 인증키는 대개 일일 호출 제한 쿼터(보통 하루 10,000건 내외)가 설정되어 있습니다. 서비스 초기에는 사용자가 적어 문제가 없어 보이지만, 실사용자가 500명에서 1,000명만 되어도 앱이 실행되거나 새로고침될 때마다 API를 실시간 호출하면 몇 시간 만에 하루 할당량이 소진됩니다. 쿼터가 소진되면 공공 API 서버는 에러를 반환하고, 앱은 모든 데이터 로딩이 불가능한 상태에 빠집니다.
서버 지연과 간헐적 다운타임 문제
국내 공공 API 서버들은 주기적인 점검 시간(보통 새벽 시간대)이나 트래픽이 몰리는 아침 출근 시간에 서버 지연(Latency)이 심해집니다. 직접 API를 호출할 경우 평소 300ms 수준이던 응답 속도가 5초 이상으로 지연되거나 HTTP 500/503 에러가 나는 일이 잦습니다. 클라이언트 앱에서 네트워크 대기 시간이 과도하게 늘어나면 사용자는 앱이 멈춘 것처럼 느끼거나 강제 종료(ANR) 현상을 겪게 됩니다.
이 세 가지 문제를 근본적으로 해결하려면 클라이언트와 공공데이터 서버 사이에서 보안과 캐싱을 담당할 중계(Proxy) 서버 아키텍처를 두는 것이 좋습니다. 백엔드 배포 환경이나 인증 프레임워크 관리가 부담스럽다면, 역방향 프록시와 SSL 자동 갱신을 지원하는 Caddy 서버 완벽 가이드 포스트를 참고해 서버 라우팅과 배포 환경을 갖출 수 있습니다.
프록시 서버 아키텍처는 이렇게 동작한다
앱 클라이언트가 공공데이터 서버와 직접 통신하는 대신, 직접 관리하는 백엔드 서버(Proxy)를 거쳐서 통신하도록 바꾸면 앞서 본 문제들이 구조적으로 해소됩니다. 전체 흐름은 다음과 같습니다.
sequenceDiagram
autonumber
actor User as 앱 사용자
participant App as 모바일 앱 (Client)
participant Proxy as 자체 프록시 서버 (Node.js)
participant Public as 공공데이터 포털 API
User->>App: 앱 실행 (예: 날씨 정보 화면 진입)
App->>Proxy: GET /api/weather (인증키 노출 없음)
note over Proxy: 1. 보안 키(.env) 로드<br/>2. 메모리 캐시 유효 여부 검증
alt 캐시 히트 (Cache Hit)
Proxy-->>App: 10ms 이내 캐시 데이터 응답
else 캐시 미스 (Cache Miss)
Proxy->>Public: GET /getUltraSrtNcst?serviceKey=SECRET_KEY (서버 간 통신)
Public-->>Proxy: XML/JSON 원본 데이터 반환 (지연 시간 500ms~3000ms)
note over Proxy: 1. 필수 필드 정제 및 가공<br/>2. 메모리 캐시에 저장
Proxy-->>App: 정제된 최소화 JSON 데이터 응답
end
App-->>User: 날씨 UI 렌더링
이 구조로 얻는 이점은 네 가지입니다. 첫째, 인증키 은닉입니다. 비밀 인증키(serviceKey)는 프록시 서버의 환경변수(.env 파일이나 시스템 설정값)에만 보관되므로 클라이언트 패킷 분석이나 앱 디컴파일로는 노출되지 않습니다. 둘째, CORS 문제 해결입니다. 웹 브라우저 환경(Hybrid App, PWA 등)에서 도메인이 달라 발생하는 Cross-Origin Resource Sharing(CORS) 오류를 프록시 서버 단에서 CORS 헤더를 열어주는 것만으로 우회할 수 있습니다. 셋째, 데이터 경량화입니다. 공공데이터 API는 불필요하게 깊은 구조의 XML이나 쓰지 않는 수십 개의 필드를 함께 반환하는 경우가 많은데, 프록시 서버가 중간에서 앱에 필요한 필드(예: 기온, 풍속 등 3~4개)만 필터링한 가벼운 JSON으로 재정리해 보내면 대역폭 비용과 클라이언트 파싱 연산을 줄일 수 있습니다. 넷째, 트래픽 모니터링입니다. 백엔드와 클라이언트 사이의 트래픽을 직접 제어할 수 있으므로 비정상적인 횟수로 요청을 보내는 유저를 파악하고 차단할 수 있습니다. 백엔드 API 단의 통신 검증을 더 다듬고 싶다면 Promptfoo LLM 테스트 프레임워크 가이드에서 테스트 방법을 참고할 만합니다.
외부 의존성 없이 Node.js로 구현하기
프록시 서버를 구현하는 데 Express나 Axios 같은 무거운 프레임워크가 반드시 필요한 것은 아닙니다. 1인 창업가나 소규모 MVP를 만들 때는 외부 라이브러리 설치와 유지보수 비용을 줄이는 편이 낫습니다. 아래 코드는 외부 패키지 설치 없이 Node.js 18 버전 이상에 기본 내장된 네이티브 HTTP 모듈과 fetch API, 그리고 인메모리 Map 구조를 활용해 캐싱과 데이터 정제를 수행하는 프록시 서버 예제입니다.
/**
*
* 공공데이터 API 앱 연동을 위한 네이티브 Node.js 프록시 서버
* - 별도의 npm install 없이 즉시 실행 가능 (Zero-Dependency)
* - 환경변수를 활용한 API 키 보호
* - 인메모리 Map을 통한 캐싱 레이어 구현 (TTL 1시간)
* - 불필요한 공공데이터 필드를 걸러내는 데이터 경량화 탑재
*/
const http = require('http');
const { URL } = require('url');
// 1. 환경변수에서 보안 인증키(serviceKey) 로드
// 실행 시: PUBLIC_DATA_SERVICE_KEY="인증키" node server.js
const SERVICE_KEY = process.env.PUBLIC_DATA_SERVICE_KEY || 'YOUR_SECURE_API_KEY_HERE';
// 2. 인메모리 캐시 저장소 구축
const cacheStore = new Map();
const CACHE_TTL_MS = 60 * 60 * 1000; // 캐시 유지 시간: 1시간 (기상청 단기예보 갱신 주기에 최적화)
const server = http.createServer(async (req, res) => {
// CORS 정책 설정 (모바일 앱 및 브라우저 교차 요청 허용)
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Content-Type', 'application/json; charset=utf-8');
// Preflight(예비 요청) 처리
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
// 날씨 API 중계 엔드포인트: GET /api/weather
if (req.method === 'GET' && parsedUrl.pathname === '/api/weather') {
const cacheKey = 'weather_data';
const cachedItem = cacheStore.get(cacheKey);
// 3. 캐시 유효성 검사 (Cache Hit 판단)
if (cachedItem && (Date.now() - cachedItem.timestamp < CACHE_TTL_MS)) {
res.writeHead(200);
res.end(JSON.stringify({
source: 'local_proxy_cache',
fetchedAt: new Date(cachedItem.timestamp).toISOString(),
data: cachedItem.data
}));
return;
}
// 4. 캐시가 없거나 만료된 경우 (Cache Miss) 공공 API 직접 호출
try {
// 기상청 단기예보(초단기실황조회) API 엔드포인트 조립
const baseUrl = 'https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getUltraSrtNcst';
const queryParams = new URLSearchParams({
serviceKey: SERVICE_KEY, // 환경변수에서 불러온 보안 인증키 자동 주입
pageNo: '1',
numOfRows: '10',
dataType: 'JSON',
base_date: '20260713', // (예시 수치) 실무에서는 날짜 유동적 생성 필요
base_time: '0600',
nx: '55', // 서울 중심부 가상 좌표
ny: '127'
});
const targetUrl = `${baseUrl}?${queryParams.toString()}`;
// Node.js 18+ 내장 fetch 사용
const response = await fetch(targetUrl);
if (!response.ok) {
throw new Error(`공공 API 호출 에러 (HTTP Status: ${response.status})`);
}
const rawData = await response.json();
// 공공데이터 API 내부 에러코드 예외 처리
const responseCode = rawData.response?.header?.resultCode;
if (responseCode !== '00') {
throw new Error(`공공 API 비즈니스 에러: ${rawData.response?.header?.resultMsg || '알 수 없는 오류'}`);
}
// 5. 공공 API의 원본 페이로드 정제 및 가공 (필요한 데이터만 필터링)
const rawItems = rawData.response?.body?.items?.item || [];
const refinedData = rawItems.map(item => {
// 날씨 값 매핑 맵 정의 (T1H: 기온, REH: 습도, WSD: 풍속 등)
let categoryName = item.category;
if (item.category === 'T1H') categoryName = 'temperature';
if (item.category === 'REH') categoryName = 'humidity';
if (item.category === 'WSD') categoryName = 'wind_speed';
return {
metric: categoryName,
value: parseFloat(item.obsrValue)
};
});
// 6. 가공된 최신 데이터를 캐시에 저장
cacheStore.set(cacheKey, {
timestamp: Date.now(),
data: refinedData
});
// 클라이언트에 최종 응답 전달
res.writeHead(200);
res.end(JSON.stringify({
source: 'public_data_portal',
fetchedAt: new Date().toISOString(),
data: refinedData
}));
} catch (error) {
console.error('프록시 서버 에러 로그:', error.message);
// 만약 공공 API 점검 등으로 서버가 맛이 갔다면, 에러를 내뿜기보다
// 만료된 이전 캐시 데이터가 있을 시 그것을 반환하는 회복력(Resilience) 설계 적용
if (cachedItem) {
res.writeHead(200);
res.end(JSON.stringify({
source: 'expired_fallback_cache',
fetchedAt: new Date(cachedItem.timestamp).toISOString(),
warning: '공공 API 장애로 인해 만료된 캐시 데이터를 반환했습니다.',
data: cachedItem.data
}));
return;
}
// 백업 캐시마저 없다면 최후의 수단으로 500 에러 처리
res.writeHead(500);
res.end(JSON.stringify({
error: '공공데이터 동기화 실패',
details: error.message
}));
}
} else {
// 404 경로 예외 처리
res.writeHead(404);
res.end(JSON.stringify({ error: '요청하신 엔드포인트를 찾을 수 없습니다.' }));
}
});
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`[안전 프록시 서버 구동 완료] http://localhost:${PORT}`);
});
세 가지 상황에서 검증하기
이 아키텍처가 의도한 대로 작동하고 예외 상황도 잘 넘기는지는 다음 세 가지 상황을 기준으로 확인할 수 있습니다.
| 시나리오 구분 | 사용자 상황 및 입력 | 중계 서버 동작 및 처리 단계 | 기대 출력 (Expected Output) | 성공 검증 지표 | 실패 대응 방안 |
|---|---|---|---|---|---|
| ① 최초 요청 및 캐시 미스 | 앱이 켜진 후 최초로 날씨 정보를 요청함. (GET /api/weather 호출) |
프록시가 캐시를 검사했으나 데이터가 없음 (Cache Miss). 공공 API 서버로 serviceKey를 실시간 탑재해 직접 요청함. |
source: "public_data_portal" 헤더와 함께 기온 및 습도가 정제된 JSON 형식으로 전달됨. |
원격 서버 응답 속도가 800ms 내외로 반환되고, 캐시 맵에 데이터가 저장됨. | 공공 API 서버 장애 시, 하드코딩된 기본값(서울 평균값 등)을 반환하는 Fallback 로직 실행. |
| ② 단기 재호출 및 캐시 히트 | 앱 사용자가 화면을 아래로 당겨 강제로 새로고침을 시도함. | 프록시가 메모리를 체크하고, 1시간(TTL)이 지나지 않았음을 확인(Cache Hit). 공공데이터 서버로 요청을 보내지 않고 즉시 반환. | source: "local_proxy_cache" 헤더와 함께 기존 저장되었던 날씨 데이터 전달. |
응답 지연 시간이 10ms 이하(0ms~3ms 수준)로 수렴하고 공공 API 호출 카운트가 오르지 않음. | 캐시 맵이 가득 차 메모리 누수가 발생하지 않도록 TTL 검사 후 주기적으로 청소하는 래퍼 적용. |
| ③ 공공 API 장애 대비 안전 모드 | 공공데이터 포털 서버가 정기 점검으로 인해 HTTP 503 에러를 발생시킴. | 프록시가 공공 API 호출 실패를 감지함. 에러를 뿜는 대신 메모리에 보관 중이던 1시간이 넘은 지난 캐시 데이터를 재활용하기로 결정함. | source: "expired_fallback_cache" 와 경고 메시지(warning)가 포함된 응답 반환. |
공공 API가 죽어있는 상태에서도 앱 클라이언트는 튕김 현상 없이 이전 데이터를 정상 렌더링함. | 캐시 데이터조차 없는 아예 극초기 기동 상태일 시, 클라이언트에 빈 배열 대신 “서버 점검 중입니다”라는 사용자 친화적 문구를 내려줌. |
언제 프록시를 쓰고, 언제 다른 선택지를 쓸까
모든 기술 아키텍처는 얻는 게 있으면 대가도 따릅니다. 프록시 서버 도입의 이해득실과 인프라 대안들을 비교해 서비스 규모에 맞는 결정을 내리는 것이 좋습니다.
| 비교 항목 | 클라이언트 직접 호출 (Ad-hoc) | 자체 백엔드 프록시 (Node.js) | Serverless API Gateway (AWS) |
|---|---|---|---|
| 인증키 보안성 | 매우 낮음 (디컴파일러로 5분 내 유출) | 높음 (서버 환경변수에 숨김) | 매우 높음 (AWS Parameter Store 연동) |
| 초기 비용 및 공수 | 없음 (서버 세팅 필요 없음) | 보통 (소형 서버 1대 세팅 및 배포) | 높음 (AWS 콘솔 및 CloudFormation 복잡도) |
| 쿼터 소모 절감 | 불가능 (유저 수 비례 급격한 증가) | 탁월함 (인메모리 캐싱으로 95% 이상 절감) | 우수함 (CloudFront/APIGW 캐시 적용) |
| CORS 해결 여부 | 에러 발생 (공공포털 서버가 브라우저 차단) | 완벽히 가능 (CORS 헤더 제어 가능) | 완벽히 가능 (인프라 수준 설정) |
| 적합한 권장 시점 | 포털 내 API 데이터 구조를 단순히 로컬에서 테스트해볼 때 | 1인 개발자, 스타트업 MVP 출시 및 대중 서비스 기동 시 | 대규모 트래픽 분산과 고부하 분리가 필요한 엔터프라이즈 환경 |
캐싱이 오히려 위험해지는 경우
제공하려는 서비스가 실시간 지진 경보, 재난 긴급 알림 또는 실시간 도로 교통 통제 현황처럼 데이터의 1초 지연이 인명 및 재산 피해로 직결되는 도메인이라면, 1시간짜리 로컬 캐싱은 오히려 위험합니다. 이런 경우에는 캐싱 TTL을 0초로 설정하거나 프록시 캐시를 건너뛰고 즉시 원격을 호출하되, 쿼터 제한을 늘리기 위해 공공데이터포털에 공식 양식으로 “특별 트래픽 제한 완화 승인(Quota Limit Increase)”을 신청하는 절차를 함께 진행해야 합니다.
서비스를 글로벌 스케일로 확장하면서 프록시 서버 주변의 인프라 보안과 규제 이슈가 복잡해진다면, 미국 상무부의 수출 통제로 불거진 Claude Sonnet 5 출시와 미국의 AI 통제 선언 포스트에서 글로벌 공급망 제약에 대한 맥락을 참고할 수 있습니다.
지금 바로 적용할 수 있는 단계
앱을 출시하기 전, 아래 순서대로 오늘부터 적용해볼 수 있습니다.
- 1단계 — 로컬 환경에서 프록시 서버 코드 실행해보기: 위 Node.js 프록시 코드를 복사하여
server.js로 저장합니다. 터미널에서PUBLIC_DATA_SERVICE_KEY="내공공데이터키" node server.js명령어로 서버를 띄운 후 브라우저에서http://localhost:3000/api/weather로 접속해 데이터가 정상 가공되는지 확인합니다. - 2단계 — 클라이언트 API 엔드포인트 전환: 모바일 앱 클라이언트 코드 전체를 검색(
Ctrl+F또는Cmd+F)해 공공데이터포털 주소(apis.data.go.kr)가 들어간 부분을 모두 제거합니다. 대신 프록시 서버의 도메인(예:https://my-api-server.com/api/weather)으로 API 엔드포인트 Base URL을 교체합니다. - 3단계 — 무료 클라우드 플랫폼에 배포하기: AWS 프리티어나 Vercel, Supabase Edge Functions 등 가볍고 무료 크레딧을 제공하는 클라우드 호스팅에 이 프록시 코드를 배포합니다. 환경변수에 공공데이터 인증키를 설정하고 작동 상태를 모니터링해 보안과 일일 호출 쿼터를 함께 관리합니다.
