공공데이터를 앱에서 바로 부르지 않은 이유
느린 수집과 예측, 알림 준비를 서버 작업으로 옮겨 화면 응답을 가볍게 했다.
iOS 앱과 위젯이 여러 화면과 접점으로 나뉘면서, 백엔드는 외부 데이터를 미리 가져와 앱이 바로 읽을 수 있게 해두는 일을 맡게 됐다.
초기에는 앱이 공공데이터 API를 직접 호출해도 된다고 생각했다. 작게 보면 그게 더 간단하다. 앱에서 주차 데이터 API를 호출하고, 응답을 화면에 표시하면 된다.
그 방식이 매력적인 이유도 있었다. 서버가 얇아지고, 처음 화면을 빨리 만들 수 있고, 문제가 생겨도 코드가 눈앞에 보인다. 하지만 한강자리에서 다루는 데이터는 출처도 다르고 속도도 달랐다. 앱이 그 차이를 모두 떠안으면 화면은 빨리 만들 수 있어도, 사용자는 느린 출처와 빈 응답을 그대로 보게 된다.
기능을 붙일수록 앱 직접 호출 방식에는 한계가 뚜렷해졌다.
외부 출처마다 필드 이름, 갱신 방식, 상태 표현이 달랐다. 어떤 데이터는 자주 바뀌고, 어떤 데이터는 하루에 한 번만 바뀐다. 외부 출처가 느리거나 실패할 때도 마지막으로 확인된 값, 갱신 시각, 출처 상태를 같이 들고 있어야 했다.
예측도 서버가 더 적합했다. 시간대별 주차 변화나 공원 혼잡 변화는 누적 데이터와 백그라운드 작업이 필요했다. 알림도 마찬가지였다. 사용자가 앱을 열지 않아도 상태 변화나 혼잡 신호를 알려주려면, 서버가 구독 설정과 APNs 전송을 맡아야 했다.
이 때문에 백엔드는 FastAPI로 두고, 요청에 답하는 API와 뒤에서 값을 준비하는 worker를 분리했다.
flowchart LR
Sources["공식 공개 출처<br/>주차 · 행사 · 시설 · 실시간 신호"] --> Workers["워커<br/>수집 · 정리 · 검사"]
Workers --> PG[("PostgreSQL / PostGIS<br/>기준 데이터 · 이력 · 위치")]
Workers --> Redis[("Redis<br/>현재 상태 · 예측 요약")]
PG --> Forecast["예측<br/>시간대별 변화"]
Forecast --> Redis
PG --> Notify["알림 후보<br/>조건 변화"]
Redis --> Notify
Notify --> APNs["APNs"]
PG --> API["FastAPI<br/>준비된 응답"]
Redis --> API
API --> Client["iOS 앱 · 위젯"]
APNs --> Client
이 구성에서 API는 가능한 한 읽는 일에만 집중한다. 외부 출처를 직접 긁거나, 예측을 새로 만들거나, 알림 후보를 계산하는 일은 요청을 받는 API 안에 넣지 않았다. 사용자가 앱을 여는 순간에 외부 출처가 느리거나 실패하면, 그 느림과 실패가 그대로 화면에 드러나기 때문이다.
API에서 읽기만 하게 둔 이유는 단순하다. 사용자 요청 경로와 데이터 준비 과정을 나누고 싶었다. 수집과 정리는 느려질 수 있고, 예측은 주기적으로 다시 만들어질 수 있고, 알림 후보는 조건을 따져야 한다. 반면 앱과 위젯이 읽는 응답은 짧고 안정적이어야 했다.
현재 백엔드는 크게 이런 역할로 나뉜다.
- API: 앱과 위젯이 바로 읽는 응답
- 주차 worker: 주차장 목록과 현재 상태 수집
- 나들이 worker: 행사, 시설, 공지, 실시간 도시데이터 수집
- 예측 worker: 주차와 공원 혼잡 변화 계산
- 알림 worker: 알림 후보를 고르고 APNs로 전송
데이터는 PostgreSQL/PostGIS에 저장하고, 자주 읽는 상태와 예측 응답은 Redis에 캐시한다. Postgres에는 기준 데이터와 이력을 남기고, Redis에는 빠르게 꺼내 쓸 짧은 수명의 응답을 둔다.
이 구분은 운영하면서 더 중요해졌다. 무엇을 다시 만들 수 있고, 무엇을 반드시 남겨야 하는지 나눠두면 장애가 났을 때 API, worker, 출처 중 어디를 먼저 확인할지 좁힐 수 있다.
API에는 읽기만 남겼다
앱은 빠르게 확인할 수 있는 화면을 만든다. 서버는 외부 데이터의 차이를 정리하고, 캐시하고, 예측하고, 알림 후보를 만든다. DB는 기준 데이터와 이력을 보존하고, Redis는 짧게 살아도 되는 현재 상태를 빠르게 전달한다.
이 모습이 처음부터 정해져 있던 것은 아니다. 처음에는 API와 배치 작업의 경계가 더 흐릿했다. 하지만 운영을 생각하니 수집, 예측, 알림, API 응답은 서로 다른 속도로 움직여야 했다. 그래서 worker를 분리했고, 관측 지표와 헬스 체크도 역할별로 보게 됐다.
특히 공공데이터 앱에서는 외부 출처의 실패가 곧 앱 실패처럼 보이기 쉽다. 그래서 단순히 실패를 숨기는 것보다, 마지막 성공 값과 갱신 시각, 출처 상태를 함께 들고 있는 일이 중요했다. 사용자가 필요한 건 “항상 맞는 현재”라는 약속이 아니라 “지금 믿고 볼 수 있는 가장 정직한 값”이었기 때문이다.
예측도 같은 맥락이었다. 예측은 미래를 맞히겠다는 약속이 아니라, 시간대별 변화를 보여줘서 더 나은 결정을 돕는 보조 신호에 가까웠다. 그래서 예측값은 현재값과 분리되어야 했고, 캐시 수명과 갱신 경로도 달라야 했다.
개인 프로젝트라도 실제로 운영하려면 “코드가 돌아간다”와 “운영할 수 있다”는 다른 문제였다.
요청 중에는 하지 않는 작업
처음에는 API가 요청을 받으면 필요한 데이터를 그 자리에서 가져오고, 가공해서 응답해도 된다고 생각했다. 작은 앱에서는 그 방식이 빠르게 만들 수 있다. 하지만 한강자리의 요청은 사용자가 이동을 결정하는 순간에 온다. 그 순간에 외부 출처가 느리거나 실패하면, 앱은 같이 느려지고 위젯은 비어 보인다.
그래서 API에서 빼기로 한 일이 생겼다.
- 외부 출처를 직접 호출하는 일.
- 오래 걸리는 정규화와 좌표 보정.
- 예측을 새로 만드는 일.
- 알림 후보를 계산하는 일.
- 실패한 출처를 계속 재시도하는 일.
이 일들은 worker가 주기적으로 맡고, API는 이미 정리된 값을 빠르게 읽어 응답하게 했다. API는 사용자 요청에 답하는 쪽이고, worker는 뒤에서 값을 준비하는 쪽이다. 둘은 느려지는 이유도, 확인해야 할 지점도 달랐다.
서버를 이렇게 나눈 뒤에는 실패를 보는 방법도 달라졌다. API가 느린 것인지, worker가 값을 못 준비한 것인지, 출처가 늦은 것인지 나눠서 볼 수 있었다.
오래된 값은 오래됐다고 말해야 했다
공공데이터 앱에서는 실패를 단순히 숨기면 안 된다. 마지막 성공 값이 있다면 그 값은 여전히 참고가 될 수 있다. 다만 최신 값처럼 보여주면 안 된다. 그래서 한강자리에서는 오래된 값을 실패로만 보지 않고, 화면에 설명해야 할 상태로 다뤘다.
예를 들어 주차장 잔여 대수가 5분 전 값이면 참고할 수 있다. 하지만 40분 전 값이라면 현장 차이를 감안해야 한다. 행사 정보는 몇 시간 전 값이어도 큰 문제가 없을 수 있지만, 실시간 주차 정보는 다르게 봐야 한다. 데이터마다 허용할 수 있는 신선도가 다르기 때문이다.
이 차이를 API 응답에 담아야 화면도 정직해진다. 화면은 “현재값”만 받는 것이 아니라, 이 값이 얼마나 오래됐는지, 마지막 성공은 언제였는지, 출처가 실패 중인지도 함께 알아야 한다. 그래야 위젯과 앱이 사용자를 속이지 않는다.
예측은 현재값을 대신하지 않게 했다
주차 예측도 처음에는 매력적인 기능처럼 보인다. “몇 시에 여유가 있을지 알려준다”는 말은 강하다. 하지만 예측은 실제 주차장 상황을 대신할 수 없다. 행사, 날씨, 통제, 돌발 상황을 모두 맞힐 수 없기 때문이다.
그래서 예측은 현재값보다 한 단계 낮은 신호로 두었다. 지금 남은 자리와 갱신 시각이 먼저이고, 예측은 시간대별 변화를 이해하는 보조 자료다. 사용자가 “지금 바로 가도 되나”를 판단할 때는 현재값이 중심이고, “조금 있다가 가면 나을까”를 볼 때 예측이 도움이 된다.
이 구분이 있으면 화면에서도 예측을 확신처럼 말하지 않는다. 예측은 맞힌다고 말하는 기능이 아니라, 불확실한 이동 결정을 조금 덜 불안하게 만드는 기능이다.
서버가 해야 하는 일은 큰 결론을 대신 내려주는 것이 아니었다. 외부 데이터의 느림과 빈틈을 그대로 사용자에게 넘기지 않고, 지금 믿고 볼 수 있는 값으로 미리 정리해두는 일이었다.