STILLCODING

STILL CODING / NOTES

버스를 지켜보며 구간 시간을 배우다 — 모든 구간이 40초로 나온 이유와 하루 1만 번의 예산

JH Kim

환승 경로를 찾았다면 다음 질문은 “그래서 얼마나 걸리는데?”입니다. Bus Explorer는 처음에 정류장 하나를 지나는 데 100초라는 고정값을 썼습니다. 도착 정보 표본 하나에서 가져온 임시 값이었습니다. 이 글은 그 임시 값을 실제로 버스를 지켜본 값으로 바꾸려고 만든 수집 장치의 이야기입니다. (소요 시간을 추정하는 쪽은 이전 글에서 다뤘고, 여기서는 그 재료인 관측 데이터를 어떻게 얻는지를 봅니다.)

예측이 아니라 관측을 쓰는 이유

버스 도착 정보(몇 분 뒤 도착)는 운영 측의 예측입니다. 그 예측으로 다른 예측을 쌓으면 오차가 겹칩니다. 반면 차량 위치 API는 버스마다 “지금 몇 번째 정류장에 있는지”(nodeord)를 알려 줍니다. 한 버스의 순번 변화를 반복 관측하면 구간 시간을 추정할 수 있습니다. 도착 예측을 쓰지는 않지만, 폴링 사이의 정확한 도착 시점은 알 수 없습니다.

첫 측정은 전부 40초였다

커밋 828ca53은 첫 구현과 관측 결과를 함께 기록합니다. “A 정류장에서 처음 발견한 시각”부터 “B 정류장에서 처음 발견한 시각”까지를 구간 시간으로 삼았습니다. 이 커밋에 따르면 실제 노선에서 수집한 결과는 모든 구간이 40초였고, 그것은 수집 간격(폴링 간격)과 같았습니다. 커밋 메시지의 설명은 이렇습니다(번역).

버스가 아직 움직이지 않은 폴링은 버려졌기 때문에, 어떤 구간도 폴링 간격보다 짧게 잴 수 없었다.

40초마다 위치를 본다고 합시다. 버스가 A에서 B까지 15초 만에 갔어도, 우리는 “A에서 한 번 봤고, 40초 뒤 B에서 봤다”는 사실만 압니다. 처음 발견한 시각끼리 빼면 언제나 폴링 간격의 배수에 가까운 값이 나옵니다.

도착 시각을 “두 관측의 한가운데”에 놓는다

해법은 도착 시각을 마지막으로 이전 정류장에서 본 시각과, 새 정류장에서 처음 본 시각의 중간으로 추정하는 것입니다. 버스가 그 사이 어딘가에 도착했다는 것만 알기 때문에, 불확실성을 한가운데로 모으는 셈입니다.

entered_at = (previous.last_at + at) / 2      # 이전 정류장 마지막 목격과 새 정류장 첫 목격의 중간
departed_at = previous.entered_at             # 그 전 정류장에 들어온 추정 시각
...
elapsed = entered_at - departed_at            # 구간 시간 = 도착 추정 시각의 차이

구간 하나를 재려면 도착이 두 번 필요합니다(첫 도착은 시계를 켜기만 합니다). 커밋에는 수정 뒤 같은 수집에서 20~170초, 중앙값 83초가 나왔다고 기록되어 있습니다. 현재 데이터로 재측정한 수치는 아닙니다. 커밋 메시지는 이것이 “구간 중앙값 396m가 시내 속도로 달릴 때 나올 만한 값”이라고 적었습니다.

한 표본의 오차는 여전히 폴링 간격만큼 남습니다. 코드 주석은 오차 방향이 대칭이라면 여러 표본의 중앙값이 수렴한다는 전제를 둡니다. 폴링 간격과 원천 위치 정보의 지연은 남으므로, 한 표본만으로 구간 시간을 확정하지 않습니다. 코드 주석의 표현도 “표본 하나는 신뢰해서는 안 된다”입니다.

잘못된 측정을 버리는 규칙

버스는 규칙적으로만 움직이지 않습니다. 아래 경우는 측정으로 치지 않습니다.

상황처리
순번이 줄어듦(새 운행이 시작됨)추적을 새로 시작
순번이 4칸 이상 건너뜀(MAX_SKIPPED_STOPS = 3)새 운행으로 보고 버림
마지막 목격 이후 15분 넘게 안 보임시간을 쓸 수 없어 버림
1~3칸 건너뜀걸린 시간을 구간 수로 똑같이 나눔

세 칸까지 건너뛴 경우는 그 사이 정류장을 지나갔다고 보고 시간을 나눠 가집니다. 그 이상은 “그 사이에 다른 일이 있었다”고 봅니다.

관측은 정적 데이터와 다른 창고에 둔다

수집한 시간은 별도의 SQLite 파일(observations.db)에 저장합니다. 정류장·노선 정보(transit.db)는 새 스냅샷을 활성화할 때 통째로 교체되는데, 관측 데이터는 모으는 데 오래 걸리기 때문에 교체와 함께 사라지면 안 됩니다.

구간의 키도 순번이 아니라 정류장 쌍(노선, 출발 정류장, 도착 정류장)으로 잡았습니다. 노선 정보를 다시 받으면 순번이 바뀔 수 있지만 “이 노선의 A에서 B까지”라는 도로 구간은 그대로이기 때문입니다. 사라진 구간은 더 이상 조회되지 않을 뿐입니다.

구간마다 표본은 최대 200개까지만 두고 오래된 것부터 버립니다. 중앙값은 표본이 5개 이상일 때부터 믿고, 그 미만이면 지금 타려는 버스의 실시간 속도나 기본값으로 대신합니다.

하루 1만 번의 예산

수집기에는 하루 호출 예산을 두었습니다. 이 저장소의 수집기 설계는 차량 위치 조회 예산을 키 하나당 하루 10,000회로 잡았고, 사용자가 지도를 열 때 쓰는 실시간 조회도 같은 한도를 씁니다. 수집기가 한도를 다 써 버리면 사용자 지도가 비게 됩니다.

그래서 수집기는 자기 몫(기본 6,000회)만 쓰고 멈춥니다. 커밋 0c585ea의 설계를 요약하면 이렇습니다.

# docker-compose.yml — 수집기 서비스(요지)
collector:
  command: ["python", "-m", "app.scheduler"]
  environment:
    COLLECTOR_ROUTES: "20"            # 한 번에 보는 노선 수
    COLLECTOR_INTERVAL: "60"          # 위치 조회 간격(초)
    COLLECTOR_DAILY_CALLS: "6000"     # 수집기의 하루 몫
    COLLECTOR_ACTIVE_HOURS: "6-23"    # 운행 시간대

한 번의 수집 계획은 순수 함수(plan_budget)가 세웁니다. “몫을 노선 수로 나눈 횟수”와 “원하는 시간 ÷ 간격” 중 작은 쪽이 횟수가 되어, 예산과 시간 중 무엇에 막혔는지도 함께 돌려줍니다. 이렇게 하면 예산 계산을 실제 호출 없이 테스트할 수 있습니다.

폴링 간격과 시간대가 남기는 오차