기상청에는 공식 날씨 앱 '날씨 알리미'가 있는데 UI 난이도가;;
보기 어려운건 그렇다쳐도 위치를 추가하고 위치 목록을 컨트롤하는건 앱개발을 3년 한 나도 잘 모르겠어서ㅋㅋㅋ (대체 어떻게들 쓰시는겁니까)
맨날 날씨 확인할때마다 짜증났었는데 알아보니 기상청 공공 API가 하루 10,000건 까지 무료인게 아니겠는가?! 혼자쓰기에는 충분하다는 판단. 그렇게 시작됐다.

Peters Weather는 기상청 공공 API(data.go.kr)를 그대로 붙여서 만든 개인 프로젝트 날씨 앱입니다. 처음엔 Flutter 앱 하나로 끝나는 구조였습니다. 앱이 기상청 API를 직접 호출하고, 응답을 파싱해서 화면에 뿌려주는 게 전부였습니다.
그런데 기능을 늘려가다 보니 두 가지 벽에 부딪혔습니다. 하나는 서비스 키가 앱 번들 안에 그대로 박혀있다는 보안 문제였고(사실 혼자 쓸거라 큰 문제는 아니지만 개발자인데 보안을 모른척할순 없으니), 다른 하나는 "기상특보가 발효되면 사용자에게 푸시를 보내고 싶다"는 요구사항이었습니다. 푸시는 앱이 꺼져있어도 서버가 주기적으로 기상청을 조회해줘야 하는 일이라 클라이언트 단독으로는 불가능했습니다. 그래서 백엔드를 새로 붙이게 됐습니다.
기상청 API는 serviceKey라는 발급받은 키를 쿼리 파라미터로 실어 보내야 호출이 됩니다. 앱이 직접 호출하는 구조에서는 이 키가 빌드된 앱 어딘가(예: .env를 번들링하는 방식)에 그대로 들어가게 되고, 마음만 먹으면 디컴파일로 꺼낼 수 있습니다. 키가 유출되면 트래픽 제한에 걸리거나 최악의 경우 키가 정지될 수도 있는 구조였습니다.
해결책은 단순했습니다. 앱과 기상청 사이에 얇은 프록시 서버를 하나 두는 것입니다.
backend/
src/
kma/
client.ts # fetchKmaJson, extractItems — 공통 호출/파싱
endpoints.ts # KMA_ENDPOINTS 5개 정의
routes/
weather.ts # /api/weather/* 프록시 라우트
internal.ts # /internal/poll-alerts (푸시 트리거)
alerts/ # 특보 폴링 로직
push/
firebase.ts # FCM 발송
config.ts
index.ts
기상청 엔드포인트는 앱이 원래 쓰던 5개를 그대로 옮겼습니다.
export const KMA_ENDPOINTS = {
vilageFcst: `${villageFcstBase}/getVilageFcst`, // 단기예보
ultraSrtNcst: `${villageFcstBase}/getUltraSrtNcst`, // 초단기실황
midLandFcst: `${midFcstBase}/getMidLandFcst`, // 중기육상예보
midTa: `${midFcstBase}/getMidTa`, // 중기기온
wthrWrnMsg: `${wthrWrnBase}/getWthrWrnMsg`, // 기상특보 통보문
} as const;
여기서 신경 쓴 부분은 "응답을 가공하지 않고 그대로 돌려준다"는 원칙이었습니다. serviceKey, dataType, pageNo 같은 공통 파라미터만 서버가 채워주고, 나머지 비즈니스 파라미터(nx, ny, base_date 등)는 앱이 쿼리로 넘긴 걸 그대로 전달합니다. 응답 JSON도 파싱하지 않고 그대로 앱에 넘깁니다. 덕분에 앱에 이미 있던 파싱 로직은 한 줄도 안 건드리고 호출 주소만 기상청 도메인에서 우리 백엔드 도메인으로 바꾸는 걸로 마이그레이션이 끝났습니다.
// routes/weather.ts
const PROXIES: Record<string, ProxyDef> = {
'vilage-fcst': { endpoint: KMA_ENDPOINTS.vilageFcst, required: ['nx','ny','base_date','base_time'], fixed: { numOfRows: 1000 } },
'wthr-wrn-msg': { endpoint: KMA_ENDPOINTS.wthrWrnMsg, required: ['stnId','fromTmFc','toTmFc'], fixed: { numOfRows: 1 } },
// ...
};
이제 serviceKey는 서버 환경변수에만 존재하고, 앱 번들에서는 완전히 사라졌습니다.
백엔드는 Render에 올렸습니다. render.yaml 하나로 배포 설정을 코드화해뒀습니다(Render Blueprint).
services:
- type: web
name: peters-weather-backend
runtime: node
plan: free
rootDir: backend
buildCommand: npm install && npm run build
startCommand: npm start
healthCheckPath: /health
envVars:
- key: KMA_SERVICE_KEY
sync: false
- key: POLL_SECRET
sync: false
- key: FIREBASE_SERVICE_ACCOUNT
sync: false
sync: false로 둔 세 개의 값(기상청 서비스 키, 폴링용 공유 비밀, Firebase 서비스 계정 JSON)은 레포에 절대 커밋하지 않고 Render 대시보드에서 직접 채워 넣습니다.
Render 무료 플랜은 두 가지 제약이 있었습니다.
이 두 가지가 나중에 4번(특보 푸시) 설계에 그대로 영향을 줍니다. 상태를 어딘가에 영구히 저장해야 하는데 디스크를 못 쓰니, 이미 쓰고 있던 Firebase 프로젝트의 Firestore에 상태를 두는 쪽으로 방향을 잡았습니다. 추가 인프라를 늘리지 않고도 해결할 수 있었던 것 같네요.
워크플로우는 세 개로 나눴습니다.
# .github/workflows/ci.yml — 앱(Flutter) 쪽
on:
pull_request: { branches: [main] }
push: { branches: [main] }
jobs:
analyze-and-test:
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { flutter-version: '3.44.2', channel: 'stable' }
- run: flutter pub get
- run: flutter analyze
- run: flutter test
# .github/workflows/backend-ci.yml — 백엔드(Node) 쪽, backend/** 변경 시에만
on:
pull_request: { branches: [main], paths: ['backend/**'] }
push: { branches: [main], paths: ['backend/**'] }
jobs:
typecheck-and-build:
defaults: { run: { working-directory: backend } }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npm run typecheck
- run: npm run build
모노레포처럼 앱과 백엔드가 한 저장소에 있다 보니, paths 필터로 백엔드 CI는 backend/ 변경이 있을 때만 돌게 분리했습니다. 안 그러면 Dart 코드만 고쳐도 매번 Node CI가 불필요하게 돌아가니까요.
flutter analyze용 CI를 처음 붙였을 때 로컬에선 멀쩡한데 CI에서만 실패하는 상황이 있었습니다. 원인은 .env 파일이었습니다. 앱 쪽 설정 코드가 .env의 존재를 전제로 하고 있었는데, .env는 당연히 .gitignore돼있으니 CI 러너에는 그 파일이 없었던 거죠. 해결은 간단했습니다. analyze 실행 전에 .env.example을 .env로 복사하는 스텝을 넣었습니다(ci: analyze 전에 .env.example을 .env로 복사). 실제 키 값 없이도 analyze/test는 통과할 수 있게 만들어 둔 덕분에 가능했던 우회였습니다.
세 번째 워크플로우는 특보 폴링용 cron입니다.
# .github/workflows/poll-alerts.yml
on:
schedule:
- cron: '*/10 * * * *'
workflow_dispatch: {}
jobs:
poll:
steps:
- name: Trigger backend alert poll
run: |
curl --fail-with-body --max-time 60 -X POST \
-H "x-poll-secret: ${{ secrets.POLL_SECRET }}" \
"${{ secrets.BACKEND_URL }}/internal/poll-alerts"
10분마다 백엔드의 내부 엔드포인트를 두드려서 특보를 확인시키는 역할입니다. 별도의 서버나 스케줄러 인프라(예: 별도 워커, cron 서버) 없이 GitHub Actions의 스케줄 트리거만으로 배치 작업을 돌릴 수 있어서 비용을 하나도 안 들이고 해결했습니다. 그리고 이 요청 자체가 Render 무료 티어를 계속 깨워두는 역할도 겸하고 있습니다. 폴링 트리거이자 콜드 스타트 방지용 keep-warm 핑인 셈이죠.
기상청 특보 API는 시군구 단위가 아니라 "관서(stnId)" 단위로만 특보를 내려줍니다. 전국을 9개 관서로 매핑해뒀고, 앱은 사용자가 고른 지역에 맞는 관서의 FCM 토픽을 구독합니다.
const STN_LABELS: Record<string, string> = {
'109': '서울·인천·경기', '105': '강원도', '131': '충청북도',
'133': '대전·세종·충남', '146': '전북', '156': '광주·전남',
'143': '대구·경북', '159': '부산·울산·경남', '184': '제주도',
};
export function topicForStnId(stnId: string) { return `stn_${stnId}`; }
폴러는 10분마다 9개 관서를 순회하며 각 관서의 최신 통보문을 확인하고, 발효 중인 특보가 있으면 해당 토픽으로 푸시를 보냅니다.
초기 구현은 "직전에 조회했던 통보문 텍스트와 이번 텍스트를 비교해서 달라지면 푸시" 하는 방식이었습니다. 그런데 실제로 돌려보니 푸시가 누락되는 경우가 있었습니다. 폴링 타이밍과 통보문 갱신 타이밍이 어긋나거나, 배포/재시작으로 직전 상태를 잃어버리면 "달라졌는지"를 판단할 기준 자체가 사라지는 문제였습니다.
고친 방식은 각 관서별로 "마지막으로 푸시를 보낸 발표시각(tmFc)"을 Firestore에 저장해두고 비교하는 것입니다.
const lastPushed = await getLastPushedTmFc(stnId);
if (lastPushed === tmFc) {
return { stnId, pushed: false, reason: '이미 푸시한 발표(동일 tmFc)' };
}
tmFc는 특보가 활성인 동안은 고정값이라, 같은 tmFc면 폴링이 몇 번을 더 돌아도 건너뜁니다. 반대로 폴링이 지연되거나 한두 번 누락되더라도, 다음 폴링에서 tmFc가 다르면 정확히 한 번은 반드시 푸시가 나갑니다. "내용 비교"라는 불안정한 기준을 "발표시각 비교"라는 안정적인 기준으로 바꾼 것만으로 누락 문제가 사라졌습니다.
여기에 백스톱 로직도 하나 추가했습니다. 콜드 스타트로 상태가 날아갔거나 장애로 한참 폴링을 못 한 뒤에는, 몇 시간 전에 발표된 오래된 특보까지 뒤늦게 푸시로 쏘는 걸 막아야 합니다.
const MAX_ALERT_AGE_MINUTES = 180;
// ...
if (ageMinutes > MAX_ALERT_AGE_MINUTES) {
await setLastPushedTmFc(stnId, tmFc); // 기록만 하고 푸시는 안 보냄
return { stnId, pushed: false, reason: `오래된 발표 — 백스톱 초과` };
}
중복은 상태 비교가 막아주니까, 이 윈도우는 넉넉하게 잡아서 cron 지연 정도는 흡수하되 "몇 시간 지난 특보를 갑자기 통보받는" 어색한 상황만 막도록 했습니다. 그리고 기상청 API가 "최근 5일간 통보문 없음"을 정상 응답(resultCode: '03')으로 주는 경우도 있어서, 이건 에러가 아니라 정상 상태로 따로 처리해뒀습니다.
에러 화면에서 "다시 시도"를 눌렀는데 곧바로 또 실패하면, 스피너가 뜨는 듯 마는 듯 하고 원래 화면 그대로 돌아와서 마치 버튼이 안 눌린 것처럼 보이는 문제가 있었습니다.
Future<void> _handleRetry() async {
if (_retrying) return;
setState(() => _retrying = true);
try {
await widget.onRetry();
} catch (_) {
// 재요청이 또 실패해도 에러 화면이 그대로 유지되면 된다
}
// 최소 표시 시간을 둬서 "다시 시도가 동작했다"는 걸 항상 체감하게 한다
await Future<void>.delayed(const Duration(milliseconds: 500));
if (!mounted) return;
setState(() => _retrying = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('데이터를 다시 불러오지 못했어요')),
);
}
버튼 자체에 로딩 인디케이터를 붙이고, 최소 500ms는 스피너를 유지하도록 강제한 뒤, 그래도 실패하면 스낵바로 명시적으로 알려주게 바꿨습니다. 사소해 보이지만 "눌렀는데 아무 일도 안 일어나는 것 같다"는 불신을 없애는 데는 이런 최소한의 피드백 시간이 은근히 중요했던 것 같습니다.
처음엔 데이터 하나만 파싱에 실패해도 화면 전체를 에러 화면으로 바꿔버렸는데, 사용자 입장에서는 시간별 예보 하나 때문에 현재 날씨까지 다 안 보이는 게 과했습니다. 그래서 에러를 두 종류로 나눴습니다.
ErrorFullScreen): 아예 데이터가 하나도 없을 때만 노출.ErrorPartialOverlay): 시간별/주간 예보처럼 화면의 일부 섹션만 실패했을 때, 그 부분에만 블러 처리된 글래스모피즘 오버레이를 씌우고 나머지 화면은 정상 노출.BackdropFilter(
filter: ImageFilter.blur(sigmaX: 7, sigmaY: 7),
child: Container(
decoration: BoxDecoration(
color: palette.surface.withValues(alpha: 0.55),
border: Border.all(color: Colors.white.withValues(alpha: 0.5)),
),
child: /* "불러오지 못했습니다" 안내 */,
),
)
AndroidManifest에 INTERNET 권한이 누락된 전형적인 실수였습니다. 디버그 빌드는 되는데 릴리즈만 안 되는 패턴이라 처음엔 좀 헤맸습니다ㅎ안드로이드 홈 화면 위젯은 home_widget + workmanager 조합으로 만들었습니다. Flutter 쪽의 WidgetService가 날씨 데이터를 가져와서 네이티브가 읽을 수 있는 공유 저장소에 써주고, 안드로이드 쪽 WeatherWidgetProvider/WeatherWidgetSmallProvider(Kotlin)가 그 값을 RemoteViews에 바인딩하는 구조입니다.
새로고침은 두 경로로 나눴습니다.(기상청 예보 수정사항이 있을때 30분 단위로 적용된다는 정보를 봤음)
WorkManager로 30분 주기 백그라운드 작업을 등록하되, 실행될 때마다 "최근 10분 안에 갱신했으면 건너뛴다"는 스로틀을 한 번 더 체크.static Future<void> refreshPrimaryRegion({bool force = true}) async {
if (!force && await _isThrottled()) return;
// ... 지역/날씨 조회 후 위젯 데이터 저장
}
('vm:entry-point')
Future<void> widgetBackgroundCallback(Uri? uri) async {
if (uri?.host == 'refresh') {
await WidgetService.setLoading();
await WidgetService.refreshPrimaryRegion(force: true);
}
}
OS가 WorkManager를 더 자주 깨우더라도 스로틀이 API 호출 빈도의 상한을 잡아주기 때문에, 배터리/네트워크 제약과 무관하게 호출량이 일정 수준 이상으로 튀지 않게 설계했습니다.
기상청 게이트웨이가 일시적으로 429를 반환하는 경우가 있는데, 초기엔 이 실패를 그대로 에러 화면으로 반영해서 직전까지 잘 보이던 온도·시간별 예보가 전부 "업데이트 실패" 화면으로 덮어써졌습니다.
이미 보여줄 데이터가 있으면, 이번 요청이 실패해도 기존 데이터를 그대로 유지하고 로딩 스피너만 내리도록 바꿨습니다. 완전히 데이터가 없을 때만 에러 화면을 띄웁니다.
w_refresh_touch)를 따로 두고 여기에 배경 인텐트를 바인딩하는 방식으로 해결했습니다. 아이콘 크기는 그대로 두면서 실제 탭 가능 영역만 넓힌 셈입니다.WeatherIcon 커스텀 페인터(광선 8개 + 그라디언트 원)와 미묘하게 달라 보였던 걸, 네이티브 드로어블 path를 앱 쪽 도형 좌표에 맞춰 다시 그려서 통일했습니다.static Future<void> _writeError(Region region) async {
final hasData = await HomeWidget.getWidgetData<String>('w_has_data');
final inError = await HomeWidget.getWidgetData<String>('w_error');
if (hasData == 'true' && inError != 'true') {
await _save({'w_loading': 'false'}); // 기존 데이터 유지, 스피너만 해제
await _update();
return;
}
// 보여줄 데이터가 아예 없을 때만 에러 화면으로 전환
}

기술을 도구로서 영리하게 이용할 줄 아는 엔지니어가 살아남는다