왼쪽은 지금 앱의 초단기 화면(9/28 시뮬레이터에서 캡처한 실제 디자인 값)을 그대로 옮긴 뒤 바꿀 부분만 바꾼 동작하는 화면이에요. 비 그림은 오늘 11:50 기상청 API허브 자료를 서버 로직과 같은 방법으로 변환해서 올렸어요.
색 4단계 = 기상청 공식 강도 구간. 같은 색이 누구에게는 우산, 누구에게는 감속이에요. 열을 누르면 왼쪽 폰이 그 비로 바뀌어요.
핀 = 그 장소의 지금 하늘. 비가 셀수록 빗줄기가 많고 빨라지고, 50mm부터는 물이 차올라요. (mm/h)
저장 없이 바로. 핀들과 나란히 비교. ☆ = 저장.
팝업 🚶 누르면 🚗 운전 문구로.
팝업에 이닝 9칸 · 5회 선.
⚾ 기준은 KBO 공식 수치가 없어서 추정(카드에도 표시). 우천 결정은 경기 약 3시간 전 경기운영위원, 경기 중엔 심판 · 중단 후 약 30분 대기 · 5회 넘기면 정식경기 · 고척돔은 영향 없음. 바람 입자는 시안용 공개 모델 자료이고, 앱에서는 기상청 초단기 바람으로 바꿔요.
이 화면을 여는 사람의 질문은 대부분 하나예요: “내가 갈 곳에 언제 비 와? 우산 필요해?” 지금은 지도를 재생하면서 눈으로 추측해야 해요. 바뀐 화면은 핀·문장·막대로 그 답을 먼저 보여주고, 지도는 확인용으로 둡니다. 각 카드의 버튼을 누르면 왼쪽 폰에서 바로 보여줘요.
새로 들어가는 세 가지(장소 저장 · 여러 곳 한눈에 · 핀 위 비 정보)는 “손이 덜 가는 것”이 전부예요. 이미 잘하는 서비스들이 푼 방식을 기준으로 정했어요.
| 할 일 | 편하게 만드는 방법 | 참고한 서비스 |
|---|---|---|
| ① 장소 저장 | 지도 탭 → “☆ 내 장소에 추가” → 이름 칩 한 번 (집·회사·학교·운동·본가). 글자 입력 없음. 앱에 이미 등록한 지역은 자동으로 핀이 되어 처음부터 비어 있지 않게. | 웨더뉴스 레이더 “숏컷 핀”(최대 10곳), 애플 날씨 저장 위치 자동 동기화 |
| ② 핀 위 정보 | 핀에는 숫자 하나만 (그 시각 mm, 없으면 “비 없음”) → 재생하면 배지가 따라 바뀜. 자세한 문장(“오후 1시쯤 시작 · 최대 5mm”)과 시간별 막대는 핀을 눌렀을 때 카드에. 2시간 넘는 시각엔 “(참고)”. | 애플 날씨 “다음 1시간” 막대(지도 아래), 야후 날씨 등록 지점 강수 그래프 |
| ③ 여러 곳 한눈에 | 한 장에 장소 × 10분 칸 (6시간 = 36칸, 스크롤 없음). 비 오는 곳이 위로 자동 정렬. 칸을 누르면 그 장소·시각으로 지도 이동. | 웨더뉴스 “마이 날씨(マイ天気)” 목록, 야후 날씨 등록 지점 목록 |
| ④ 알림 (다음 단계) | 저장한 장소가 그대로 비 알림 대상 → 따로 설정할 게 없게. | 웨더뉴스 “비구름 알람”(3곳), 애플 날씨 강수 알림 |
왼쪽 폰에서 지도를 아무 데나 누르고 “☆ 내 장소에 추가”를 눌러보세요. Mobbin이 연결되면 이 세 화면을 실제 앱 화면들과 나란히 놓고 한 번 더 다듬을게요.
| 요소 | 현재 앱 | 바뀐 화면 | 이유 |
|---|---|---|---|
| 내 위치 표시 | 파란 핀 40×50 | 점 18 + 번지는 테두리 | 핀이 바로 아래 비를 가림 (시뮬레이터에서 확인) |
| 범례 | 위험/많음/보통/적음 | mm/h 숫자 + 기상청 강도 단계(약한·보통·강한·매우 강한) | “많음”이 몇 mm인지, 우산이 필요한지 알 수 없음 |
| 시간 카드 | 날짜 · 시각 · GIF · 재생 · 슬라이더 | + 관측/예측 칩, 갱신 시각, 장소 한 줄 요약, 시간별 막대 | 지도를 재생하지 않아도 답이 보이게 |
| 슬라이더 눈금 | 9:50 · 지금 · 1:40 … | 오전 10시 · 지금 · 오후 1시 … | 오전/오후가 없어 헷갈림 |
| 재생 버튼 | 36 | 44 | 애플 최소 터치 크기 44pt |
| 상단 알약 | 야구장 · CCTV | + ★ 내 장소 | C2 진입점 |
| 출처 표기 | 없음 | “자료: 기상청 · 날씨날씨 재가공” | 2026-09-18 기상법 개정으로 의무 |
| 공유 · 현위치 · 레이어 | 그대로 (카드 높이에 맞춰 위치만 따라 올라감) | — | |
비 알림(D1·D2), 경로(C3)는 이번 범위에서 빠져 있어요.
| 항목 | 기존 서버 | 바뀐 서버 |
|---|---|---|
| 좌표 기준 | 구체 R=6371km, 원점 38°N | WGS84 타원체, 원점 0°N (기상청 kmap과 동일) |
| 이미지 범위 | x −444…579km (1023km) | x −440…584km (1024km) |
| 오늘 11:50 비구름 중심 차이 | 서쪽 5.8칸 · 남쪽 3.7칸 ≈ 약 5km 남서쪽 (한반도 전체에서 5.5–6.7km) | |
| 기상청 화면과 대조 | 어긋남 | 경계 0px 일치 (9/23 날씨누리 화면과 픽셀 대조) |
| 관측 모양 | HSR (약한 잡음 포함, 일치율 0.59) | HSP (날씨누리 화면과 일치율 0.996–0.997) |
| 시각 라벨 | 요청 시각 (최대 5분 늦게 표기) | 기상청이 돌려준 실제 자료 시각 |
폰의 비교 모드는 같은 기상청 원본을 두 방식으로 옮긴 결과예요. 왼쪽이 기존 서버, 오른쪽이 바뀐 서버이고, 해안선을 기준으로 보면 차이가 보여요. 기존 방식은 색을 섞어 늘린 것처럼 번지게 그려서 앱의 현재 모습도 함께 재현했어요.
기상청 관측은 5분마다(발표까지 2–9분), 예측은 10분마다(발표까지 16–25분) 나와요. 서버는 2분마다 “새 게 나왔나”만 확인하고, 새 자료가 있으면 그것만 받아서 처리합니다.
관측: nph-rdr_cmp_inf로 최신 시각 확인 · 예측: nph-qpf_ana_imgp가 돌려주는 발표 시각 확인. 이미 처리한 시각이면 여기서 끝 (대부분의 실행).
nph-rdr_cmp1_img cmp=HSP — 날씨누리 초단기 화면과 같은 합성. 1453×1453 이미지, 위 20px 제목줄 잘라냄.
nph-qpf_ana_img qpf=B, ef=10…360 (6시간). 8–12개씩 병렬, 실패한 장은 3번 재시도.
24색 범례표로 픽셀 색을 강수 단계(0–24)로 바꿈. 한 색 = 한 값. 해안선·경계선에 가려진 칸은 주변 비 값으로 채움.
기상청 EPSG:980201 → 웹 지도(EPSG:3857), 미리 계산한 대응표로 한 번에. 위치 어긋남은 여기서 고쳐져요.
v1 색 PNG — 기존 앱용 (같은 경로 유지, 위치만 정확해짐) · v2 값 격자 PNG — 새 앱용 (프레임당 약 27KB, 앱이 직접 색칠).
새 폴더에 전부 올린 뒤 manifest.json을 마지막에 덮어씀 → 앱은 반쯤 올라간 자료를 볼 일이 없음. 기존 data-extended.json도 계속 갱신.
manifest 1개(각 프레임 시각 · 관측/예측 구분 · 발표 시각) + 필요한 프레임만. CloudFront를 앞에 두면 사용자가 늘어도 기상청 호출 수는 그대로.
한도의 약 35%. 재시도·장애 여유를 둬도 충분해요.
현재는 한 장씩 차례로 받음 → 병렬로 받으면 대부분이 기상청 응답 대기 시간이라 크게 줄어요. 이 목업을 만들 때 48장을 8개씩 병렬로 받은 실제 결과와 같은 방식이에요.
새 자료가 없으면 manifest를 건드리지 않아요. 앱은 manifest의 시각을 보고 “기상청 11:50 · 6분 전 갱신”처럼 있는 그대로 보여주고, 30분 이상 멈추면 “기상청 자료가 늦어지고 있어요”로 바뀌어요.
export const handler = async () => {
const last = await readManifest(); // S3
const obsTm = await latestObsTime(); // nph-rdr_cmp_inf
const fcBase = await latestForecastBase(); // nph-qpf_ana_imgp → dateTime
if (obsTm === last.obsTm && fcBase === last.fcBase) return; // 대부분 여기서 종료
const jobs = [];
if (obsTm !== last.obsTm) jobs.push({ kind: 'obs', tm: obsTm });
if (fcBase !== last.fcBase)
for (let ef = 10; ef <= 360; ef += 10) jobs.push({ kind: 'fc', tm: fcBase, ef });
const frames = await pMap(jobs, async (j) => {
const png = await fetchKma(j); // 3회 재시도
const idx = colorToClass(png, { header: 20 }); // 24색 → 0..24
const grid = remap980201To3857(idx); // 위치 보정
await putS3(`v2/${key(j)}.png`, encodeGray(grid)); // 새 앱
await putS3(`v1/${key(j)}.png`, paintKma(grid)); // 기존 앱
return meta(j);
}, { concurrency: 10 });
await putS3('v2/manifest.json', merge(last, frames)); // 항상 마지막
await writeLegacyDataExtended(frames); // 기존 앱 호환
};
| 단계 | 할 일 | 확인 |
|---|---|---|
| 1 | dev 서버에 새 Lambda 배포 (2분 주기) | manifest 시각이 날씨누리와 같은지 |
| 2 | 기존 앱·새 앱 모두 dev를 보게 해서 확인 | 기존 앱: 위치만 바뀌고 나머지 동일 |
| 3 | prod 서버 교체 (확인 후) | 기존 사용자 화면 이상 없음 |
| 4 | 새 앱 단계적 배포 (remote config로 v2 켜기) | 메모리·프레임 로딩 시간 |
| 5 | 기존 형식 종료 | 구버전 비율이 충분히 줄었을 때 |
이 폰 화면은 스크린샷을 붙인 게 아니라, 앱에 들어갈 방식 그대로 돌아가요.
| 부분 | 이 웹 화면 | 플러터 앱 |
|---|---|---|
| 비 그리기 | WebGL 셰이더 (아래 코드) | 같은 코드를 .frag로 → FragmentShader (Impeller) |
| 자료 | 값 격자 PNG + manifest | 같은 파일 |
| 색상표 | 256×1 색 표 텍스처 | 같은 방식 — 색상표 바꾸기 = 표 한 줄 교체 |
| 크기·색·글꼴 | 현재 앱 코드 값 (VsmColors, Pretendard, 32/44/60 버튼) | 그대로 |
| 지도 | vworld 타일 + 앱과 같은 흑백 필터 | flutter_map 그대로 |
서버는 “이 칸은 3번 색” 같은 번호만 보내요(색칠 공부 도안). 화면의 점 하나하나가 “나는 어느 칸 사이에 있지?”를 계산해서 주변 4칸의 번호로 경계를 부드럽게 그리고, 번호에 맞는 색을 칠해요. 확대하면 다시 계산하니까 계단이 생기지 않고, 색상표만 바꾸면 전체 색이 즉시 바뀌어요.
Flutter 3.38.5에는 텍스처 필터 옵션이 없어서 위 코드처럼 4칸을 직접 읽어 계산해요. 그래서 웹과 앱이 같은 결과를 냅니다.