기상청 공식 앱 UI 너무 구려서 직접 만들어서 쓰기

Peter JU·2026년 6월 8일

개발일지

목록 보기
6/8
post-thumbnail

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

0. 왜 서버가 필요해졌나?

Peters Weather는 기상청 공공 API(data.go.kr)를 그대로 붙여서 만든 개인 프로젝트 날씨 앱입니다. 처음엔 Flutter 앱 하나로 끝나는 구조였습니다. 앱이 기상청 API를 직접 호출하고, 응답을 파싱해서 화면에 뿌려주는 게 전부였습니다.

그런데 기능을 늘려가다 보니 두 가지 벽에 부딪혔습니다. 하나는 서비스 키가 앱 번들 안에 그대로 박혀있다는 보안 문제였고(사실 혼자 쓸거라 큰 문제는 아니지만 개발자인데 보안을 모른척할순 없으니), 다른 하나는 "기상특보가 발효되면 사용자에게 푸시를 보내고 싶다"는 요구사항이었습니다. 푸시는 앱이 꺼져있어도 서버가 주기적으로 기상청을 조회해줘야 하는 일이라 클라이언트 단독으로는 불가능했습니다. 그래서 백엔드를 새로 붙이게 됐습니다.

1. 기상청 API를 앱이 직접 부르던 시절의 문제: 프록시 백엔드로 전환

🛑 서비스 키가 앱 안에 그대로 노출된다

기상청 API는 serviceKey라는 발급받은 키를 쿼리 파라미터로 실어 보내야 호출이 됩니다. 앱이 직접 호출하는 구조에서는 이 키가 빌드된 앱 어딘가(예: .env를 번들링하는 방식)에 그대로 들어가게 되고, 마음만 먹으면 디컴파일로 꺼낼 수 있습니다. 키가 유출되면 트래픽 제한에 걸리거나 최악의 경우 키가 정지될 수도 있는 구조였습니다.

💡 Node.js + Express + TypeScript로 프록시 백엔드를 세우다

해결책은 단순했습니다. 앱과 기상청 사이에 얇은 프록시 서버를 하나 두는 것입니다.

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는 서버 환경변수에만 존재하고, 앱 번들에서는 완전히 사라졌습니다.

2. Render 무료 티어 배포 자동화와 그 대가

백엔드는 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에 상태를 두는 쪽으로 방향을 잡았습니다. 추가 인프라를 늘리지 않고도 해결할 수 있었던 것 같네요.

3. GitHub Actions로 CI/CD 자동화하기

워크플로우는 세 개로 나눴습니다.

# .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가 불필요하게 돌아가니까요.

🛑 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 핑인 셈이죠.

4. 기상특보 FCM 푸시: 중복 발송과의 싸움

기상청 특보 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(발표시각) 상태 기반 중복 방지로 전환

고친 방식은 각 관서별로 "마지막으로 푸시를 보낸 발표시각(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')으로 주는 경우도 있어서, 이건 에러가 아니라 정상 상태로 따로 처리해뒀습니다.

5. 프론트: 불편했던 UI들을 하나씩 고치기

🛑 재시도 버튼을 눌러도 반응이 없어 보인다

에러 화면에서 "다시 시도"를 눌렀는데 곧바로 또 실패하면, 스피너가 뜨는 듯 마는 듯 하고 원래 화면 그대로 돌아와서 마치 버튼이 안 눌린 것처럼 보이는 문제가 있었습니다.

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: /* "불러오지 못했습니다" 안내 */,
  ),
)

그 밖에 고친 것들

  • 단기예보와 중기예보를 합칠 때, 단기예보의 꼬리 구간이 중기예보 기온을 덮어써버리는 데이터 병합 버그가 있었습니다. 두 API의 예보 구간이 겹치는 날짜를 제대로 처리하지 못해서 생긴 문제였습니다.
  • 릴리즈 빌드에서만 네트워크가 아예 안 되는 경우가 있었는데, AndroidManifest에 INTERNET 권한이 누락된 전형적인 실수였습니다. 디버그 빌드는 되는데 릴리즈만 안 되는 패턴이라 처음엔 좀 헤맸습니다ㅎ
  • 대구 군위군이 지역 목록에서 빠져 있던 것, 세종시가 "세종시 세종구"처럼 시=구로 중복 표시되던 것도 실제 사용해보며 하나씩 발견해서 고쳤습니다. 행정구역 변경이 기상청 코드 체계에 그대로 반영되지 않는 경우가 있어서, 지역 데이터는 한 번 만들어두고 끝이 아니라 계속 검증이 필요하다는 걸 느꼈습니다.
  • 중기기온예보 지역코드를 도 단위에서 시군 단위로 세분화해서 정확도를 높였고, 라이트/다크 모드 설정을 추가하면서 중기예보 API 두 개를 순차 호출에서 병렬 호출로 바꿔 로딩 시간도 줄였습니다.

6. 홈 화면 위젯 구현기

안드로이드 홈 화면 위젯은 home_widget + workmanager 조합으로 만들었습니다. Flutter 쪽의 WidgetService가 날씨 데이터를 가져와서 네이티브가 읽을 수 있는 공유 저장소에 써주고, 안드로이드 쪽 WeatherWidgetProvider/WeatherWidgetSmallProvider(Kotlin)가 그 값을 RemoteViews에 바인딩하는 구조입니다.

새로고침은 두 경로로 나눴습니다.(기상청 예보 수정사항이 있을때 30분 단위로 적용된다는 정보를 봤음)

  • 수동: 위젯의 새로고침 아이콘을 탭하면 앱을 켜지 않고 백그라운드 Dart 콜백이 실행되어 강제로(스로틀 무시) 즉시 갱신.
  • 자동: 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 호출 빈도의 상한을 잡아주기 때문에, 배터리/네트워크 제약과 무관하게 호출량이 일정 수준 이상으로 튀지 않게 설계했습니다.

🐛 KMA가 429(rate limit)를 주면 위젯이 멀쩡한 데이터까지 지워버렸다

기상청 게이트웨이가 일시적으로 429를 반환하는 경우가 있는데, 초기엔 이 실패를 그대로 에러 화면으로 반영해서 직전까지 잘 보이던 온도·시간별 예보가 전부 "업데이트 실패" 화면으로 덮어써졌습니다.

✅ stale-while-error 전략으로 전환

이미 보여줄 데이터가 있으면, 이번 요청이 실패해도 기존 데이터를 그대로 유지하고 로딩 스피너만 내리도록 바꿨습니다. 완전히 데이터가 없을 때만 에러 화면을 띄웁니다.

네이티브 쪽에서 고친 디테일들

  • 새로고침 아이콘 터치 영역이 너무 작아 탭이 잘 안 먹는 문제는, 아이콘보다 큰 투명 오버레이 뷰(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;
  }
  // 보여줄 데이터가 아예 없을 때만 에러 화면으로 전환
}

업로드중..

업로드중..

7. 느낀점

  • 앱만 있을 땐 안 보이던 문제들(중복 발송, 콜드 스타트, rate limit)이 서버가 끼는 순간 전부 "상태를 어떻게 안전하게 유지할 것인가"의 문제로 수렴한다.
  • 인프라 비용을 늘리지 않고도 GitHub Actions cron, Firestore, Render 무료 티어를 조합해서 웬만한 건 다 해결할 수 있었다.(혼자 쓸거니까~)
  • 사소한 디테일(재시도 버튼 반응, 위젯 터치 영역, 실패 시 이전 데이터 유지)이 역시 가장 손이 많이 가는 부분이었다.
  • 그리고 AI tool 을 적극적으로 활용해서 작업했는데, 기획서 작성하고 디자인 뽑고 개발 초기 세팅부터 기능 개발 후 오류 찾고 수정하는것까지 모든 프로세스를 한번 겪어보고나니 어떻게 써야할지 점점 감이 잡힌다.

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

profile
도전.

0개의 댓글