Bus Explorer는 공공데이터포털의 버스 API(TAGO)로 버스가 지금 어디 있는지를 보여 줍니다. 이 API는 누구에게나 열려 있는 대신 호출 횟수에 한도가 있습니다. 사용자가 늘면 한도를 먼저 쓰는 쪽은 화면 갱신입니다.
실시간 조회 앞에는 캐시가 있습니다. 같은 노선의 요청이 겹치면 외부 호출을 합치고, 실패하면 재시도 간격을 두며, 오래된 응답에는 수집 시각을 붙입니다. (구현은 app/cache.py, 설계는 저장소의 docs/api-cache-improvement-plan.md에 있습니다.)
먼저 정한 것: 30초는 “위치가 안 변한다”는 뜻이 아니다
설계 문서의 첫 문단이 이 캐시의 성격을 말해 줍니다(번역).
차량 위치의 30초 캐시는 위치가 변하지 않는다는 가정이 아니라, 최대 30초의 추가 지연을 허용하는 정책이다. 원천 GPS 정보 자체의 지연은 별개이며, 서버가 수집한 시각을 차량이 실제로 관측된 시각으로 단정하지 않는다.
그래서 응답에는 collected_at(서버가 마지막으로 정상 수집한 시각), age_seconds(그 값이 지금 몇 초 묵었는지) 같은 메타 정보(live_meta)를 함께 실어 보냅니다. 화면은 “지금 위치”가 아니라 “몇 초 전에 수집한 위치”임을 알 수 있습니다.
조회 흐름: 위에서부터 차례로
LiveCache.get()은 키(서비스 + 도시 + 원천 노선 ID)마다 아래 순서로 판단합니다.
- 서비스 전체가 쉬는 중인가? 한도 초과 등으로 그 서비스가 대기 상태면 외부 호출 없이 응답합니다.
- 유효한 값이 있는가? 만료 전이면 바로 돌려줍니다(
hit). - 이 키가 재시도 대기 중인가? 최근에 실패했으면 외부에 다시 묻지 않습니다(
cooldown). - 이미 누가 가져오는 중인가? 그렇다면 그 결과를 기다립니다(
coalesced). - 아무도 안 가져오고 있다면 내가 대표로 가져옵니다(
miss).
현재 코드는 서비스 전체 대기를 먼저 확인한 뒤 유효한 캐시를 반환합니다. 키별 재시도 대기는 그다음입니다. 실패 응답은 만료 상태로 저장하므로, 다음 요청은 키별 대기 검사에 걸려 외부 호출을 반복하지 않습니다.
동시에 몰리면 한 번만 묻는다
같은 노선을 여러 사람이 동시에 보면 같은 질문이 동시에 여러 번 나갑니다. 키마다 Event를 하나 두어 처음 온 요청만 대표로 외부에 묻고, 나머지는 그 결과를 기다립니다.
waiter = self.loading.get(key)
if not waiter:
waiter = Event()
self.loading[key] = waiter
owner = True # 내가 대표로 가져온다
else:
owner = False # 이미 누가 가져오는 중이니 기다린다
if not owner:
finished = waiter.wait(timeout=wait_timeout) # 기본 12초
대표가 끝나면(finally에서 waiter.set()) 기다리던 요청들이 같은 결과를 받습니다. 대표가 너무 오래 걸리면 기다리는 쪽은 12초 뒤에 포기하고, 그때 가지고 있는 값이 허용 범위 안이면 stale로, 없으면 refresh_timeout 오류로 답합니다.
실패하면 물러선다
실패 응답을 받으면 재시도 시각을 저장합니다. 오류 종류에 따라 키별 대기와 서비스 전체 대기를 적용합니다.
| 상황 | 상태 | 다음 시도까지 |
|---|---|---|
| 일시적 오류(네트워크 등) | unavailable | 30초 → 60초 → 120초 (연속 실패마다 한 단계씩) |
| 호출 한도 초과(429 등) | rate_limited | 서버가 준 Retry-After, 없으면 300초 |
| 인증 오류 | unavailable | 서비스 전체 300초, 키별 간격은 아래 공통 처리 적용 |
TRANSIENT_BACKOFF = [30.0, 60.0, 120.0]
RATE_LIMIT_BASE_COOLDOWN = 300.0
RATE_LIMIT_MAX_COOLDOWN = 1800.0
성공하면 실패 횟수는 0으로 돌아갑니다. 인증 오류의 경우 _classify_error()가 서비스 전체에 300초 대기를 설정합니다. 이어서 공통 unavailable 처리에서 키별 간격은 30·60·120초로 계산되며, 정상 캐시가 없는 첫 실패이면 5초가 됩니다. 서비스 전체 대기를 먼저 검사하므로 해당 서비스의 외부 호출은 300초 동안 막힙니다.
설계 문서에는 한도·인증 오류가 반복되면 대기 시간을 최대 30분까지 늘린다고 적혀 있는데, 위에 보인 상수(RATE_LIMIT_MAX_COOLDOWN)는 정의만 되어 있고 코드 어디에서도 쓰이지 않습니다. 현재 구현에는 반복 실패에 따라 이 대기 시간을 늘리는 동작이 없습니다.
첫 실패는 빨리 다시 시도한다
커밋 0602119은 첫 조회 실패 뒤 재시도 대기를 줄이는 수정입니다. 기존 정책에서는 캐시가 없는 첫 요청도 30초를 기다려야 했습니다. 수정 뒤에는 “첫 실패이고 보여 줄 값이 없을 때만” 5초 뒤에 다시 시도합니다.
if failure_count == 1 and (old_entry is None or old_entry.collected_at is None):
cooldown = 5.0
마지막 정상 값이 있으면 대기 중에도 그 값을 표시합니다. 값이 없는 첫 실패에는 짧은 재시도 간격을 적용합니다.
한도는 키가 아니라 서비스 단위로 막는다
한도 초과는 한 노선의 문제가 아닐 가능성이 큽니다. 그래서 한도 오류를 받으면 서비스(positions, arrivals) 전체에 쿨다운을 겁니다. 이 상태에서는 다른 노선을 물어도 외부에 나가지 않습니다.
if isinstance(exc, TagoRateLimitError):
cooldown = exc.retry_after if exc.retry_after is not None else RATE_LIMIT_BASE_COOLDOWN
if service:
self.set_service_cooldown(service, cooldown)
설계 문서의 표현은 “한도 초과의 적용 범위가 불명확하면 우선 서비스 단위로 대기 상태를 공유한다”입니다. 범위를 모를 때는 넓게 막는 쪽이 안전하다는 판단입니다.
오래된 값을 신선한 척하지 않는다
실패해도 마지막 정상 값은 지우지 않습니다. 응답에는 stale(오래됨)을 표시하고, 허용 나이를 넘으면 목록을 비웁니다.
- 값이 120초(
max_stale)를 넘으면 위치와 예정 시간을 아예 내보내지 않고(items를 비움) 상태를unavailable로 바꿉니다. - 기다리다 시간이 초과된 요청에도 마지막 값이 허용 범위 안이면
stale, 아니면 오류로 답합니다. 오래된 값을fresh로 돌려주는 경로가 없도록 했습니다.
is_past_max_stale = age_seconds is not None and age_seconds > self.max_stale
items = [] if is_past_max_stale else list(entry.items)
if is_past_max_stale and state in ('fresh', 'stale'):
state = 'unavailable'
120초는 이 캐시가 값을 계속 제공할지 결정하는 정책 상한입니다. 이 시간이 지났다고 차량이 멈췄거나 운행을 마쳤다는 뜻은 아닙니다. 설계 문서에는 “오래된 값에서 도착 완료나 운행 종료를 추론하지 않는다”는 원칙도 적혀 있습니다.
정상적인 빈 목록은 오류가 아니라 성공으로 캐시합니다. 지금 다니는 버스가 없는 것과 API가 실패한 것은 다른 일이기 때문입니다.
빈 응답이 이어지면 덜 묻는다(적응형 TTL)
새벽처럼 버스가 없는 시간대에는 30초마다 “없음”을 확인해도 소용이 없습니다. 위치 조회에서 빈 응답이 연속으로 나오면 캐시 시간을 늘립니다.
| 연속 빈 응답 | 캐시 시간 |
|---|---|
| 2회 이하 | 30초 |
| 3회 이상 | 60초 |
| 5회 이상 | 120초 |
버스가 다시 나타나면 연속 횟수가 0이 되어 곧바로 30초로 돌아갑니다. 대가도 문서에 적혀 있습니다. 운행이 다시 시작된 것을 알아채는 데 최대 약 120초가 더 걸릴 수 있습니다. 현재 ADAPTIVE_TTL_ENABLED의 기본값은 true입니다. 설정으로 끌 수 있어서, 문제가 생기면 기본 30초 정책으로 되돌릴 수 있습니다.
메모리는 무한하지 않다
키가 늘어나면 메모리도 늘어납니다. 항목이 기본 2,000개를 넘으면 가장 오래 사용하지 않은 것부터 지웁니다. 지금 가져오는 중인 항목은 지우지 않습니다(동시 요청 병합이 깨지지 않게).
시간 비교는 시계가 거꾸로 가지 않는 단조 시계(time.monotonic)로 하고, 응답에 담는 수집 시각만 UTC로 기록합니다. 시계를 밖에서 주입할 수 있게 해 두어서, 테스트가 실제로 기다리지 않고 만료 경계를 검사합니다.
같은 발상이 수집기에도: 임대 잠금
정적 데이터를 모으는 수집기에도 중복 실행 방지 장치가 있습니다. 노선과 정류장 전체를 받아 오는 작업이 두 번 동시에 돌면 안 되는데, 프로세스 안의 락(Lock)만으로는 명령줄로 돌린 것과 관리자 API로 돌린 것을 막을 수 없습니다. 그래서 SQLite에 임대(lease) 잠금을 두었습니다. 소유자와 만료 시각을 기록하고, 만료된 잠금만 새로 차지할 수 있습니다.
INSERT INTO locks(name, owner, expires_at) VALUES (?, ?, ?)
ON CONFLICT(name) DO UPDATE SET owner=excluded.owner, expires_at=excluded.expires_at
WHERE locks.expires_at < ?
- 수집 중에는 임대를 연장하고(
renew_lease), 끝나면 자기 것일 때만 풉니다(release_lease는 소유자를 확인). - 프로세스가 죽어도 임대가 30분 뒤 만료되므로 다음 실행이 복구할 수 있습니다.
- 마지막 정상 수집 후 24시간 안이면 수집을 건너뜁니다(
--force로 강제).
아직 하지 않은 것
캐시는 프로세스 메모리에 있습니다. 서버를 여러 개 띄우면 프로세스마다 TAGO를 따로 부릅니다. 설계 문서도 프로세스를 나누기 전에 공유 캐시가 필요하다고 적습니다.
호출이 얼마나 줄었는지는 적지 않습니다. 설계 문서는 측정 전에 절감률을 약속하지 않고, 이 글도 같은 선을 지킵니다. GET /busapi/metrics에 맞음·빗나감·합치기 횟수가 쌓이지만, 그 숫자는 프로세스가 켜져 있는 동안만 유지됩니다.