걷고 있었는데 지하철을 탔다고 나왔다
Oftway 는 위치 점을 폰 안에 모아 두고, 그 점들로 머문 곳과 이동을 나누고, 이동마다 걷기·자전거·지하철 같은 수단을 붙입니다. 사용자가 보는 것은 타임라인의 "지하철 1.1km · 1분" 같은 한 줄입니다.
10월 18일에 제 폰의 10월 16일 이동 경로 화면에서 바로 그 줄을 봤습니다. 지하철을 타지 않은 시간이었습니다. intent 에 적은 발의도 짧습니다. "지하철이 아닌데 지하철로 찍히는 경우가 있어."
10월 6일부터 17일까지의 폰 기록을 훑어보니 이런 1초짜리 지하철 이동이 다섯 건이었습니다(10/11, 10/12, 10/13, 10/16, 10/17). 원인은 모두 같았습니다.
iOS 는 GPS 점과 별도로 "방문(CLVisit)" 점을 줍니다. 어딘가에 도착했다고 판단하면 찍어 주는 점인데, 이 점은 도착 시각을 앞당겨(약 1분) 도착할 곳의 좌표로 찍힙니다. 그래서 1초 간격으로 들어오던 걷기 GPS 점들 사이에, 300m 넘게 떨어진 점 하나가 끼어듭니다. 데이터만 보면 1초 만에 300m 를 뛴 셈입니다.
여기서 지하철 판정이 이 점을 받아 버렸습니다. 땅속에서는 GPS 대신 기지국·와이파이 위치만 띄엄띄엄 들어오기 때문에, Oftway 는 "정확한 점(닻) 두 개 사이를 걸을 수 없는 속도로 300m 넘게 건너뛰었으면 땅속을 타고 간 구간"으로 봅니다(railSpans). 그 구간의 양 끝이 1호선 가까이였고, 앱에 넣어 둔 노선망에 맞춰 지하철로 만들었습니다. 거리는 노선을 따라 쟀으니 1km 를 넘었습니다.
고친 것은 한 줄의 상한
railSpans 의 조건은 "충분히 멀고, 충분히 빠르면"이었습니다. 빠른 쪽에 끝이 없었던 겁니다. 고속열차보다 빠른 도약은 탄 것이 아니라 위치 오류로 보고 건너뛰게 했습니다.
const meters = metersBetween(a.lat, a.lon, b.lat, b.lon);
const dt = b.t - a.t;
if (meters < GAP_FILL.minMeters || dt <= 0 || meters / dt < GAP_FILL.minSpeed) continue;
// 고속열차보다 빠른 도약은 탄 것이 아니라 위치 오류다. iOS 방문 점은 도착 시각을 앞당겨 도착할 곳 좌표로 찍혀,
// 걷는 중에 1초 만에 300m를 뛴 것처럼 보였다(PO 폰 2026-10-16 19:03, 1초 · 1.1km 지하철 — subway-glitch).
if (meters / dt > MAX_PLAUSIBLE_SPEED) continue;
MAX_PLAUSIBLE_SPEED 는 새로 만든 값이 아닙니다. 초속 100m 로, 이동 거리를 더할 때(pathDistance)와 점 없는 대중교통 구간을 직선으로 이을 때(isTransitJump) 이미 쓰던 기준입니다. 지하철 판정만 몰랐던 겁니다.
테스트는 그 장면을 그대로 옮겼습니다. spec 의 표현으로는 "1초 간격 걷기 점들 사이 1초 뒤 350m 떨어진 노선 가까운 점 하나"입니다. 고치기 전에는 땅속 구간이 하나 나왔고, 지금은 0개여야 합니다.
it('걷는 중 1초 만에 수백 m 떨어진 점(앞당겨 찍힌 방문 점)은 지하철로 보지 않는다', () => {
const walk = Array.from({ length: 30 }, (_, i) => ({ t: i, lat: 37.5 + (50 + i * 1.3) * M, lon: 127 + (40 * M) / K, hacc: 14 }));
const visit = { t: 10, lat: 37.5 + 400 * M, lon: 127 + (20 * M) / K, hacc: 10 };
expect(railGapsOf([...walk, visit], net)).toHaveLength(0);
});
처리 규칙이 바뀌었으니 DERIVE_VERSION 을 9 에서 10 으로 올렸습니다. 이 값이 다르면 앱은 머문 곳·이동·발자취 집계를 원본 점에서 처음부터 다시 만듭니다(원본 점은 지우지 않습니다, ADR-0007). 손으로 고친 수단은 그때도 지킵니다.
방문 점을 빼지 않은 이유
가장 쉬운 길은 방문 점을 지하철 판정에서 통째로 빼는 것이었습니다. intent 의 제약에 그러면 안 되는 이유가 적혀 있습니다. 10월 9일 11시 42분의 지하철은 방문 점이 도착 닻이었습니다. 땅속에서 나와 GPS 가 잡히기 전에 방문 점이 먼저 "여기 도착했다"를 알려 준 경우입니다. 방문 점을 빼 보면 이 이동이 사라졌습니다. 방문 점은 문제의 원인이기도 하지만, 실제 지하철 도착을 알려 주는 가장 빠른 신호이기도 했습니다.
그래서 "방문 점이냐 아니냐"가 아니라 "물리적으로 가능한 속도냐"로 갈랐습니다. 이 기준의 빈틈도 intent 의 Open questions 에 그대로 남겨 두었습니다. 방문 점이 1초가 아니라 몇 초 차이로 끼어 속도가 초속 100m 아래로 나오면 여전히 걸릴 수 있습니다. 폰 기록 다섯 건이 모두 1초 안이라 이번에는 속도 기준만 두었고, 다시 보이면 "앞뒤 GPS 점과 맞지 않는 방문 점"을 따로 거르기로 했습니다.
spec 에 적힌 판정 기록은 이렇습니다. 10/06~10/17 폰 기록을 다시 만들기 전후로 비교해, 1초짜리 지하철 다섯 건이 사라지고 나머지 지하철·대중교통 열두 건은 그대로였습니다. 하나는 오히려 바로잡혔습니다. 10월 12일 15시 42분 1호선은 열차 안에 낀 방문 점 때문에 1분 일찍 끝나고 있었는데, 끝 시각이 16:03:21 에서 16:04:24 로, 열차가 멈춘 뒤로 옮겨 갔습니다.
같은 날 저녁, 고친 수단은 한 줄로
같은 10월 16일 저녁 19시 무렵에는 다른 문제도 있었습니다. 자동 판정은 한 번의 이동을 여러 조각으로 나눌 때가 있습니다. 직선 잇기 앞뒤, 노선에 맞춘 구간, 동작 인식이 바뀐 지점에서요. 사용자가 그중 한 조각의 수단을 고쳐 앞뒤와 같은 수단으로 만들어도, 타임라인에는 같은 수단 줄이 여러 개로 남고 거리·시간도 조각마다 따로 보였습니다. "한 번 탄 지하철"을 한 줄로 볼 수 없었습니다.
그래서 수단을 고친 뒤, 머문 곳 없이 맞닿은(앞 이동이 끝난 바로 그 시각에 뒤 이동이 시작하는) 같은 수단 이동을 하나로 합치게 했습니다. 거리는 더하고, 시간은 첫 조각 시작부터 마지막 조각 끝까지입니다.
export function joinUserMoves<T extends JoinRow>(rows: readonly T[]): T[] {
const out: T[] = [];
for (const row of rows) {
const prev = out.at(-1);
if (prev && (prev.mode_source === 'user' || row.mode_source === 'user') && prev.mode === row.mode && touches(prev, row)) {
out[out.length - 1] = joinTwo(prev, row);
} else out.push(row);
}
return out;
}
조건에 mode_source === 'user' 가 들어간 것이 이번 판단의 핵심입니다. 사용자가 고치지 않은 자동 조각끼리는 합치지 않습니다. 직선 잇기 지하철과 노선 맞춘 지하철처럼 자동으로도 수단이 같은 조각이 맞닿는 경우가 있지만, 그건 자동 판정 규칙의 영역이라 이번에는 건드리지 않았습니다. 자동으로도 합칠지는 제가 폰에서 보고 판단하기로 intent 에 남겨 두었습니다. 합친 이동을 다시 나누는 기능도 범위 밖입니다. 합친 뒤 다른 수단으로 바꾸면 합친 그대로 한 이동의 수단이 바뀝니다.
까다로운 쪽은 합치기 자체보다 "다시 만들기"였습니다. 방금처럼 DERIVE_VERSION 이 오르면 이동은 원본 점에서 다시 만들어지는데, 합친 결과가 그때 다시 조각으로 돌아가면 안 됩니다. 그래서 다시 만들 때도 같은 함수를 지나게 했습니다. 고친 수단은 이동의 시간 범위로 저장되어 있으므로, 다시 만든 조각들이 그 범위에 들면 같은 고친 수단이 되고, 여기서 다시 하나로 합쳐집니다. 조각 경계가 옮겨 가 고친 수단이 한 조각에만 걸려도 이어지는 같은 수단 자동 조각과 합칩니다. spec 에 이 경우의 실제 예로 적힌 것이 10월 16일 19시 자전거(고침)와 19시 2분 자전거(자동)입니다.
발자취 집계는 반대로 합치기 전 조각마다 셉니다. 점이 없는 직선 잇기 조각을 빼고 세야 하기 때문입니다.
확인한 것과 남은 것
기록에 남은 검증은 이 정도입니다.
- subway-glitch: 위 테스트 하나, spec 의 폰 기록 다시 만들기 전후 비교(5건 사라짐, 12건 그대로, 10/12 끝 시각 바로잡힘). PR #64 로 main 에 합쳤습니다.
- join-moves: spec 에 적힌 테스트는 세 조각 중 가운데를 바꾸면 하나로 합쳐짐(거리 합·시각·id 유지), 처음부터 다시 만들어도 하나, 그 달 집계가 처음부터 센 것과 같음, 머문 곳을 사이에 둔 이동과 수단이 다른 이웃은 합치지 않음. PR #65 로 합쳤습니다.
두 plan 의 마지막 줄은 "PR → 합치기 → OTA"인데, Oftway 는 아직 출시 전이라 사용자에게 나간 업데이트는 없습니다. 두 변경 모두 main 에 합쳐진 상태까지가 기록입니다.
남은 것도 intent 에 있는 그대로입니다. 몇 초 차이로 끼어든 방문 점은 아직 못 막습니다. 다시 만들기로 지난 기록의 이동이 조금 바뀌었으니, 지난 날 타임라인을 한 번 훑어보는 일이 남아 있습니다. 자동 조각끼리도 합칠지는 폰에서 더 써 보고 정합니다.
Oftway 는 따로 기록하지 않아도 다닌 길이 지도에 남고, 자주 다닌 길일수록 짙어지는 발자취 일기입니다. 위치는 폰 밖으로 나가지 않습니다. 지금은 iOS 출시를 준비하고 있습니다.