FastAPI route를 얇게 둔 API 설계

HTTP route는 요청과 응답만 맡기고 화면 조립은 use case로 옮긴 방식

한강자리 백엔드는 FastAPI 기반이다. 하지만 FastAPI 프로세스가 모든 일을 하지는 않는다.

API는 앱과 위젯이 바로 읽을 수 있는 화면용 응답을 내려준다. 외부 데이터 수집, 예측 계산, 푸시 후보 고르기와 발송은 worker로 빠져 있다.

API를 이렇게 나눈 이유는 단순하다. 사용자가 앱을 열 때 필요한 것은 빠른 응답이다. 반면 외부 공공데이터 수집은 느릴 수 있고, 실패할 수 있고, 출처마다 갱신 주기가 다르다.

API를 만들 때 가장 유혹적인 방식은 route 함수 안에서 필요한 값을 다 읽어 조합하는 것이다. 작은 서비스에서는 그게 더 빠르게 느껴진다. 한강자리도 초반에는 그렇게 해도 충분해 보였다. 하지만 공원별 첫 화면, 위젯, 예측, 알림 설정이 붙으면서 route가 외부 출처, Redis, DB schema, DTO 모양을 동시에 알기 시작하면 작은 변경도 여러 방향으로 번졌다.

그래서 FastAPI에는 HTTP 요청을 받는 일만 남기고, 사용자가 묻는 일은 application query/use case로 옮겼다. “이 공원의 첫 화면을 만들라”와 “이 테이블에서 어떤 row를 select하라”를 분리한 셈이다.

HTTP 라우트에는 조립만 남겼다

백엔드는 clean architecture에 가까운 형태로 나눴다.

  • domain: entity와 policy.
  • application: query, command, use case, port.
  • infrastructure: SQLAlchemy, Redis, APNs, 외부 출처 adapter.
  • interfaces: HTTP route와 job entrypoint.
flowchart TB
  Route["FastAPI 라우트"] --> Dependency["의존성 계층"]
  Dependency --> UseCase["애플리케이션 조회 / 명령"]
  UseCase --> Port["저장소 / 캐시 포트"]
  Port --> Infra["SQLAlchemy / Redis / APNs / 출처 클라이언트"]
  Infra --> Store["Postgres / Redis / 외부 서비스"]
  UseCase --> DTO["Pydantic DTO 직렬화"]
  DTO --> Client["iOS 앱 / 위젯"]

FastAPI route는 얇게 둔다. route가 SQL query나 출처 adapter를 직접 알기 시작하면 화면에서 필요한 일과 인프라 세부사항이 섞인다.

이 형태가 이상적인 clean architecture라는 뜻은 아니다. 개인 프로젝트에서 파일과 역할을 너무 촘촘히 나누면 오히려 읽기 힘들어진다. 한강자리에서는 다음 선만 지키려고 했다.

route는 HTTP parameter와 response shape를 다루고, query/use case는 화면이 묻는 일을 다룬다. repository/cache adapter는 저장소의 세부 구현을 맡는다. 이 정도만 지켜도 테스트와 장애 분석이 훨씬 쉬워졌다.

앱이 호출하는 API 묶음

앱이 읽는 공개 /v2 영역은 다음과 같다.

  • Parks/Parking: 공원, 주차장, parking overview, lot status.
  • Home Summary: 첫 화면용 주차, 나들이, 예측, 갱신 상태를 묶은 화면용 응답.
  • Outing: 공원별 일반 화면 overview와 signal detail.
  • Forecast: 주차 예측, 공원 혼잡 예측, horizon timeline.
  • Telemetry: client performance event와 product event batch.
  • Push: APNs subscription 등록/해제.
  • App access bootstrap: 앱 접근 검증과 request proof 발급.

관리용 route는 사용자용 API와 따로 둔다. 관리용 세부사항은 화면용 응답에 섞지 않는다.

주차 화면을 만들 때 서버가 읽는 데이터

주차 overview는 Redis와 Postgres를 함께 사용한다. master data는 Postgres에서 읽고, 최신 status는 Redis를 먼저 본다.

sequenceDiagram
  autonumber
  participant Client as iOS 앱/위젯
  participant Route as FastAPI 라우트
  participant Query as 주차 조회
  participant Cache as 상태 캐시
  participant Repo as 주차 저장소
  participant DB as Postgres

  Client->>Route: 주차 화면 요청
  Route->>Query: 주차 화면 값 만들기 요청
  Query->>Repo: 공원과 주차장 기준 데이터 읽기
  Repo->>DB: 기준 데이터 조회
  Query->>Cache: 최신 주차 상태 확인
  alt 캐시 미스 또는 형식 오류
    Query->>Repo: 저장소의 마지막 상태 확인
    Repo->>DB: 상태 이력 조회
  end
  Query-->>Route: 화면용 읽기 모델 전달
  Route-->>Client: 신선도 포함 응답 반환

첫 화면은 home-summary가 맡는다. 여러 데이터를 하나의 payload로 묶는 endpoint다.

sequenceDiagram
  autonumber
  participant Client as 앱/위젯
  participant Route as home-summary 라우트
  participant Cache as 핫/보관 캐시
  participant Parking as 주차 조회
  participant Outing as 나들이 조회
  participant Forecast as 예측 조회
  participant DB as Postgres

  Client->>Route: 첫 화면 요청
  Route->>Cache: 바로 쓸 첫 화면 캐시 확인
  alt 핫 캐시 적중
    Cache-->>Route: 캐시된 첫 화면 응답
  else 캐시 미스
    Route->>Parking: 주차 요약 만들기
    Route->>Outing: 나들이 신호와 출처 상태 모으기
    Route->>Forecast: 예측 요약 확인
    Parking->>DB: 주차 기준 데이터 읽기
    Outing->>DB: 나들이 기준 데이터 읽기
    Forecast->>Cache: 예측 응답 읽기
    Route->>Cache: 빠른 응답과 마지막 성공값 저장
  end
  Route-->>Client: 첫 화면 응답 반환

이 API는 앱의 첫 화면과 일반 공원 위젯을 단순하게 만든다. 클라이언트가 여러 API를 순서대로 조합하지 않아도 된다.

home-summary를 둔 뒤에 좋아진 점은 성능만이 아니었다. 앱과 위젯이 같은 값을 보게 됐다.

예전처럼 클라이언트가 여러 API를 조합하면 주차는 최신인데 행사 정보는 오래되었거나, 위젯은 일부 값만 받아 화면 의미가 달라지는 일이 생긴다. 서버 응답은 이런 어긋남을 한 번에 맞춰준다.

응답에는 값과 갱신 상태를 함께 담았다

iOS 앱은 API shape에 민감하다. Swift decoding은 서버 응답 변화의 영향을 바로 받는다. 그래서 내부 DB schema를 그대로 내리지 않고 DTO를 둔다.

DTO는 다음 일을 맡는다.

  • Optional field와 default policy를 명확히 한다.
  • datetime serialization을 일관되게 한다.
  • fresh, stale, unavailable을 구분할 수 있게 한다.
  • 출처 갱신 상태와 generated/observed/fetched 계열 시각을 담는다.
  • raw payload와 내부 정책 값을 숨긴다.
  • 앱 버전이 섞여 있어도 약속을 쉽게 깨지 않게 한다.

읽기 요청도 앱 접근을 확인한다

현재 /v2 route는 앱 접근 검증을 지난다. 구체적인 플랫폼 검증 구현과 request proof 발급은 bootstrap 쪽으로 분리되어 있다.

구체적인 플랫폼 검증 절차보다 앞에 둔 선택은 다음과 같다.

  • 읽기 API도 남용 가능성을 고려한다.
  • 쓰기 API는 request proof를 더 엄격하게 확인한다.
  • 인증 실패 metric은 남기되, 사용자 식별 정보를 로그에 직접 남기지 않는다.
  • public /metrics는 일반 사용자 트래픽에서 보이지 않게 둔다.

route를 얇게 둔 뒤 달라진 점

FastAPI route를 얇게 두자 화면에서 필요한 일과 저장소 세부사항이 갈라졌다. 앱용 API는 DB schema가 아니라 사용자가 고르는 화면 단위로 설계했고, worker가 맡을 일을 사용자 요청 안으로 끌고 오지 않았다.

공공데이터 앱에서는 값 하나보다 출처, 시각, 갱신 상태가 함께 내려가야 한다. 첫 화면이 복잡해질수록 서버가 화면용 응답을 묶어 주는 편이 클라이언트 안정성에도 도움이 됐다.

API route를 얇게 둔 뒤 가장 좋아진 점은 변경 이유가 보이기 시작했다는 것이다. route가 모든 것을 아는 대신 use case가 화면의 질문을 맡으니, 화면에서 필요한 일과 저장소 세부사항을 따로 다룰 수 있었다.

이미지 확대