한강자리 시스템 개요

수집, API, 캐시, 위젯이 사용자의 첫 화면을 나눠 맡는 구조

한강자리는 한강공원에 가기 전과 한강 안에서 필요한 정보를 빠르게 확인하는 iOS 앱이다. 겉으로는 공원 목록, 주차장 상태, 위젯, 알림처럼 보이지만 사용자가 묻는 것은 더 단순하다. “지금 가도 괜찮을까”, “차를 가져가도 될까”, “다른 공원이 나을까”, “근처에서 무엇을 찾을까”를 빨리 알고 싶어 한다.

이 질문을 앱 화면 하나에서 매번 새로 계산하기는 어렵다. 주차 현황은 자주 바뀌고, 행사와 공지는 다른 주기로 갱신되며, 예측은 미리 계산해야 한다. 위젯은 앱이 열려 있지 않아도 바로 읽을 수 있어야 한다.

그래서 한강자리는 먼저 역할을 분리했다. 외부 데이터는 미리 정리하고, API는 앱과 위젯이 바로 읽을 수 있는 응답을 만들고, 클라이언트는 표시와 상호작용에 집중하게 했다.

한 줄로 줄이면

한강자리는 공공/공식 데이터를 그대로 중계하는 앱이 아니다. 느리거나 비어 있거나 늦게 바뀌는 외부 데이터를, 사용자가 믿고 볼 수 있는 화면용 값으로 바꾸는 시스템이다.

핵심은 수집, 정리, 응답, 표시를 한 프로세스에 섞지 않는 것이었다. 외부 출처는 실패할 수 있고 갱신 주기도 제각각이다. 반면 앱과 위젯은 사용자가 보는 순간 빠르게 응답해야 한다.

그래서 사용자 요청을 처리하는 API에는 화면을 만들기 위한 읽기 작업만 남겼다. 수집, 예측, 알림, 번역처럼 시간이 걸리거나 실패 가능성이 큰 일은 worker와 CronJob으로 옮겼다.

앱과 위젯에서 먼저 읽힌다

사용자가 처음 보는 곳은 앱과 위젯이다. 사용자는 앱과 위젯에서 상황을 확인하고, 더 필요하면 지도 앱으로 이동한다. 위젯은 앱 화면의 작은 복사본이 아니라, 앱이 미리 저장해 둔 값을 읽는 별도 실행 환경이다.

flowchart LR
  User["사용자<br/>방문 전후 확인"] --> Widget["위젯<br/>짧게 확인"]
  User --> App["앱<br/>비교 · 상세 · 설정"]
  Widget --> App
  App --> Maps["지도 앱<br/>이동 시작"]

  subgraph Device["iOS 단말"]
    App
    Widget
    Local["앱 내부 캐시<br/>최근 화면 값"]
    Saved["저장된 표시값<br/>앱과 위젯이 공유"]
  end

  App <--> Local
  App --> Saved
  Widget --> Saved
  App --> API["화면용 API 응답"]
  Widget -.-> API

앱이 화면을 그릴 때마다 모든 외부 출처를 다시 확인하면 사용자는 기다리게 된다. 한강자리는 서버가 앱이 바로 표시할 수 있는 응답을 미리 준비하고, 클라이언트는 표시와 상호작용에 집중하게 만들었다.

위젯은 앱 프로세스와 다르게 움직인다. 그래서 저장된 표시값을 먼저 읽고, 필요한 경우에만 API에서 새 값을 가져온다.

외부 데이터는 미리 정리한다

사용자 요청이 들어온 뒤 외부 출처를 차례로 부르면 앱은 출처의 속도와 실패를 그대로 떠안게 된다. 한강자리에서는 worker가 먼저 데이터를 수집하고 정리한다. Postgres는 기준 데이터와 이력을 남기고, Redis는 빠르게 읽을 수 있는 현재 상태와 요약을 맡는다.

flowchart LR
  Sources["공개/공식 출처<br/>주차 · 행사 · 공지 · 시설"] --> Workers["워커 / CronJob<br/>수집 · 정리 · 검사"]
  Workers --> Records[("Postgres<br/>기준 데이터 · 이력")]
  Workers --> Fast[("Redis<br/>빠른 조회값")]

  Records --> Prepared["예측 · 알림 후보<br/>미리 계산"]
  Prepared --> Records
  Prepared --> Fast

  Records --> API["화면용 API 응답"]
  Fast --> API
  API --> Client["앱 · 위젯"]

이렇게 분리하면 “값이 없다”를 한 가지 실패로 뭉개지 않을 수 있다. 외부 출처가 실패했는지, worker가 멈췄는지, 빠른 조회값이 비었는지, Postgres에 기준 데이터가 없는지는 서로 다른 문제다. 화면에서는 모두 비슷하게 보일 수 있지만 운영에서는 원인이 완전히 다르다.

장애가 나도 마지막으로 확인한 값을 보여준다

장애나 지연이 있을 때도 사용자가 보는 화면은 정직해야 한다. 한강자리에서 fallback은 실패를 숨기기 위한 장치가 아니다. 오래된 값과 정보 없음의 차이를 표시하기 위한 장치다.

flowchart TD
  Open["앱 또는 위젯 열기"] --> Saved{"저장된 표시값이<br/>있는가?"}
  Saved -->|예| ShowSaved["마지막으로 확인한 값 표시<br/>갱신 시각 함께 표시"]
  Saved -->|아니오| Request["새 응답 요청"]

  Request --> Result{"응답을 만들 수 있는가?"}
  Result -->|예| Fresh["새 값 표시<br/>갱신 상태 함께 표시"]
  Result -->|마지막 성공 값| Last["마지막 성공 값 표시<br/>지연 상태 표시"]
  Result -->|아니오| Empty["정보 없음 표시<br/>과신하지 않게 표시"]

주차 앱에서는 빠른 응답만큼 정직한 응답도 필요하다. 40분 전 값을 최신처럼 보여주면 화면은 빠를 수 있지만 사용자는 잘못 움직일 수 있다.

그래서 home-summary, forecast, widget snapshot에는 값뿐 아니라 갱신 상태와 출처 상태가 따라다닌다.

주차에서 시작해 역할이 늘어났다

처음부터 큰 시스템을 만들려고 한 것은 아니었다. 시작은 “한강공원 주차장 잔여 대수를 매번 웹에서 확인하기 불편하다”는 작은 문제였다.

그런데 실제로 앱을 쓰면 주차만으로 끝나지 않는다. 한강에 갈 때는 공원 혼잡도, 행사, 시설, 공지, 날씨, 대중교통, 즐겨찾기, 위젯, 알림을 함께 보게 된다. 사용자는 데이터 출처가 몇 개인지보다, 지금 화면을 믿고 움직여도 되는지를 더 중요하게 본다.

그래서 역할도 제품에서 필요한 순서대로 나뉘었다.

  • 앱은 사용자가 지금 결정을 내리는 화면을 만든다.
  • 위젯은 앱을 열기 전에 짧게 확인할 값을 보여준다.
  • API는 DB 테이블 모양이 아니라 화면용 응답을 내려준다.
  • worker는 외부 출처의 느림, 실패, 갱신 주기 차이를 사용자 요청 시간 밖에서 처리한다.
  • Postgres는 기준 데이터, 이력, 감사 기록을 보존한다.
  • Redis는 사라져도 다시 만들 수 있는 캐시와 보조 인덱스를 맡는다.

Redis를 둔 이유는 빠르기 때문만이 아니다. Redis가 없어져도 Postgres에서 다시 조합할 수 있게 만들기 위해서다. worker를 둔 이유도 단순히 배치를 돌리기 위해서가 아니라, 앱 사용자의 요청 시간을 외부 출처의 사정과 분리하기 위해서다.

첫 화면은 한 응답으로 받는다

현재 앱과 일반 공원 위젯에서 가장 중요한 응답은 home-summary다. 주차, 나들이 정보, 예측, 출처별 갱신 상태를 한 번에 묶어 첫 화면이 읽기 쉬운 모양으로 내려준다.

home-summary는 호출 수를 줄이기 위한 API만은 아니다. 앱과 위젯이 같은 첫 화면 구조를 읽게 하는 응답 형식이다.

클라이언트가 주차, 나들이, 예측 API를 각각 부르고 자기 쪽에서 조합하면 화면마다 해석이 달라지기 쉽다. 서버가 조합한 응답을 내려주면 앱은 표시와 상호작용에 더 집중할 수 있다.

사용자 입장에서는 “어느 API를 불렀는가”보다 앱을 열었을 때 주차와 행사와 예측이 서로 다른 시각을 말하지 않는지가 더 크게 다가온다. home-summary는 이 차이를 줄이기 위해 둔 첫 화면 응답 형식이다.

다음 글에서 이어갈 내용

주제핵심 선택
#1iOS 클라이언트앱, 위젯, 캐시, 딥링크를 어떻게 끊기지 않게 이어받았나
#2백엔드 APIFastAPI route를 어떻게 화면용 응답에 집중하게 했나
#3DB와 캐시무엇을 Postgres에 남기고 무엇을 Redis에 맡겼나
#4Worker와 Job외부 출처, 예측, push를 왜 API 밖으로 뺐나
#5데이터 파싱공식 데이터를 앱에서 쓸 값으로 바꾸려면 무엇을 기록해야 하나
#6예측과 데이터 과학모바일 앱에서 읽을 수 있는 예측을 어떻게 미리 계산했나
#7Push Notification알림을 보내는 일보다 덜 보내는 일이 왜 어려운가
#8CI/CD와 릴리즈개인 프로젝트에서 배포를 어떻게 반복 가능하게 만들었나
#9인프라와 상태 확인미니PC를 운영 환경으로 만들려면 무엇을 봐야 하나
#10Widget snapshot앱과 위젯이 함께 읽는 표시값을 어떻게 만들었나
#11home-summary API첫 화면 응답 형식이 iOS decoding 안정성에 어떤 도움을 줬나
#12수집 계획과 기록출처별 갱신 주기와 실패를 어떻게 추적했나
#13Redis fallback빠른 응답과 정직한 stale 응답을 어떻게 같이 다뤘나
#14주차 예측baseline, confidence, backtest를 왜 나눠 봤나
#15Push delivery보내지 않은 결정까지 왜 감사 기록으로 남겼나
#16다국어원문 보존, 번역 cache, 표시 데이터를 어떻게 분리했나
#17개인정보와 telemetry앱 품질을 보면서도 무엇을 수집하지 않기로 했나
#18SRE 회고작은 운영 환경에서 요청 경로, 관측, 백업을 어떻게 봤나

이미지 확대