직선거리로 짠 여행 일정은 실제 도로랑 2시간 28분 어긋났다
추정치를 실도로 길찾기로 바꾸고, 하루 만에 캐시까지 붙인 이유
#백엔드 #Node #알고리즘 #지도 #API #캐시
직선거리로 짠 여행 일정은 실제 도로랑 2시간 28분 어긋났다 여행 플래너에서 제일 마지막 단계가 담아둔 장소들로 일정을 짜는 일이다. 몇 시에 어디를 가고, 다음 장소까지 몇 분이 걸리고, 하루에 몇 곳까지 넣을 수 있는지를 계산해서 타임라인으로 보여준다. 처음엔 이동 시간을 대충 계산했다. 좌표가 있으니 두 지점 사이 직선거리를 구하고, 도로는 직선보다 도니까 1.25를 곱하고, 시속 45km로 나눴다. 코드로는 몇 줄이면 끝난다. const SPEED = { car: { kmh: 45, overhead: 5 }, transit: { kmh: 25, overhead: 12 } }; function estimateMove(a, b, transport) { const km = haversineKm(a, b) * 1.25; // 직선거리 → 도로거리 보정 if (km 0.8) return { distanceKm: round1(km), minutes: Math.max(3, Math.round((km / 4) * 60)), mode: 'walk' }; const s = SPEED[transport] || SPEED.car; return { distanceKm: round1(km), minutes: Math.round((km / s.kmh) * 60 + s.overhead), mode: transport === 'transit' ? 'transit' : 'car' }; } 그럴듯해 보였는데 실제로 얼마나 맞는지는 재본 적이 없었다. 이 글에서는 그걸 재보고 실제 도로 기준으로 갈아엎은 과정을 정리해본다. 실제로 재봤더니 이랬다 유튜브 영상 하나에서 뽑은 강원도 장소 20곳을 순서대로 이어서, 각 구간마다 추정치와 실제 도로 시간을 나란히 찍어봤다. 구간 직선추정 실도로 차이 만동제과 → 사근진해변 8.1km 16분 | 8.2km 23분 | +7분 사근진해변 → 강릉김밥 7.5km 15분 | 8.5km 23분 | +8분 강릉김밥 → 육구방앗간 70.6km 99분 | 71.9km 72분 | -27분 육구방앗간 → 속초옥수수소금빵 0.7km 6분 | 1.7km 9분 | +3분 조롱박 → 카레노카레 13.2km 23분 | 12.7km 20분 | -3분 삼포해수욕장 → 강릉길감자 87.5km 122분 | 89.8km 81분 | -41분 강릉길감자 → 조개줍깅 68.3km 96분 | 63.3km 68분 | -28분 주문진해변 → 키사 48.3km 69분 | 54.6km 52분 | -17분 패들 → 정커피 79km 110분 | 83.5km 72분 | -38분 칠사당 → 새바람이 오는 그늘 0.3km 5분 | 0.3km 1분 | -4분 구간 17개 합계 — 추정 688분 vs 실도로 540분 (차이 148분) 1박 2일 일정에서 두 시간 반이 어긋난다 . 이 정도면 하루 일정에 장소를 두세 곳 더 넣을 수 있느냐 없느냐가 갈리는 크기다. 왜 이렇게 어긋나나 숫자를 들여다보니 그냥 틀린 게 아니라 방향이 반대인 두 가지가 섞여 있었다. 긴 구간은 추정치가 너무 크게 나온다. 삼포해수욕장에서 강릉길감자까지 90km 가까이 되는 구간을 122분으로 봤는데 실제로는 81분이다. 이런 거리는 대부분 고속도로나 국도를 타기 때문에 평균 시속 45km보다 훨씬 빠르다. 반대로 짧은 구간은 추정치가 너무 작다. 만동제과에서 사근진해변까지 8km를 16분으로 봤는데 실제로는 23분이다. 시내 구간은 신호랑 좌회전 대기 때문에 시속 45km가 안 나온다. 결국 평균 시속 하나로 전부 계산하면 긴 구간도 짧은 구간도 다 틀린다 . 상수를 조정해서 해결될 문제가 아니었다. 45를 올리면 시내가 더 틀리고, 내리면 고속도로가 더 틀린다. 그래서 실제 길찾기를 붙였다 카카오모빌리티 길찾기 API를 붙였다. 출발지와 도착지 좌표를 주면 실제 도로 기준 거리, 소요 시간, 그리고 지도에 그릴 경로선까지 준다. 문제는 호출 수였다. 무료 쿼터가 하루 10,000건인데, 일정을 한 번 만들 때마다 구간 수만큼 호출한다. 게다가 사용자가 "다시 만들기"를 누르면 또 그만큼 나간다. 여행지 조합이야 사람마다 다르지만 강릉에서 속초 가는 구간처럼 반복되는 구간 은 계속 겹칠 게 뻔했다. 그래서 처음부터 캐시를 같이 만들었다. // 카카오모빌리티 길찾기 — 자동차 실도로 거리/소요시간 (+ DB 캐시) // 무료 쿼터 10,000건/일 — 같은 구간은 route_cache(30일)에서 재사용 const CACHE_DAYS = 30; // 좌표를 소수 4자리(약 10m)로 반올림해 키를 만든다 — 미세한 차이로 캐시가 갈리지 않게 const f4 = (v) = Number(v).toFixed(4); // DB DECIMAL은 문자열로 오므로 숫자 변환 여기서 좌표를 그대로 키로 쓰지 않고 소수 4자리로 반올림한 게 핵심 이다. 소수 4자리면 대략 10m 단위다. 같은 가게인데 좌표가 몇 미터 다르게 저장돼 있으면 캐시가 갈려서 매번 새로 호출하게 되는데, 이걸 뭉개주면 그런 일이 없다. 지금 캐시에 88개 구간이 쌓여 있는데, 여기 걸리는 구간은 API를 아예 안 부른다. 그리고 주석에 적어둔 DECIMAL 이야기는 예전에 한 번 데인 적이 있어서 미리 막아둔 것이다. MySQL DECIMAL 컬럼은 드라이버가 문자열로 주기 때문에 그냥 계산에 넣으면 조용히 이상한 값이 나온다. 실패하면 어떻게 하나 외부 API를 붙일 때마다 하는 고민인데, 여기서도 같은 원칙을 썼다. 실패하면 원래 쓰던 추정치로 돌아간다 . async function moveBetween(a, b, transport, date) { const est = estimateMove(a, b, transport); // 추정치를 먼저 만들어두고 if (est.mode === 'walk') return est; const real = await carRoute(a, b); ... return real ? { ...real, mode: 'car' } : est; // 실패하면 추정치로 } 실제로 아까 17개 구간을 재는 동안에도 2개 구간은 길찾기가 실패했다. 섬처럼 도로로 못 가는 경우나 API 쪽 문제일 때인데, 그렇다고 일정 생성 자체가 실패하면 안 되니까 그 구간만 추정치로 채운다. 조금 틀린 일정이 아예 없는 일정보다 낫다. 순서도 문제였다 이동 시간을 정확하게 만들고 나니 다른 게 눈에 들어왔다. 장소를 날짜별로 나누는 방식이다. 처음엔 최근접 이웃으로 풀었다. 첫 장소에서 제일 가까운 곳, 거기서 또 제일 가까운 곳을 이어서 한 줄로 만들고 날짜 수만큼 자르는 방식이다. 그런데 이렇게 하면 하루에 이 동네 저 동네가 섞인다 . 강릉에서 시작해서 속초로 올라갔다가 다시 강릉으로 내려오는 일정이 나오는 것이다. 그래서 자르는 방식을 바꿨다. 장소들이 퍼져 있는 방향, 그러니까 분산이 가장 큰 축에 좌표를 투영해서 정렬 한 다음 날짜 수만큼 연속된 구간으로 나눈다. // 장소 분포의 주축(분산이 가장 큰 방향)에 투영해 정렬 // — 해안선을 따라 남→북 같은 자연스러운 순서 const theta = 0.5 * Math.atan2(2 * sxy, sxx - syy); // 1) 지리적으로 묶기: 주축에 투영해 정렬 → 날짜 수만큼 연속 구간으로 // (담은 순서나 최근접 한 줄 나열이 아니라 '같은 동네끼리 같은 날'이 되게) // 2) 하루 시간 예산 안에서만 채우기: 체류 + 이동이 한도를 넘으면 다음 날로 // 3) 각 날 안에서는 최근접 순으로 다듬기 강원도 동해안은 남북으로 길게 늘어져 있어서, 이렇게 하면 자연스럽게 남쪽 묶음과 북쪽 묶음으로 갈린다. 최근접 이웃은 각 날 안에서 순서를 다듬는 용도로만 남겼다. 결과 실제로 유튜브 영상에서 담은 10곳으로 1박 2일 일정을 만들어봤다. 생성에 2.6초 걸렸다. 이동 시간은 전부 실제 도로 기준이다. 날짜가 고성과 속초, 그리고 양양으로 갈렸다. 앞에서 말한 주축 투영이 실제로 동작한 결과다. 그리고 위쪽에 "1일차 일정이 빡빡해요"라는 경고가 붙었는데, 이건 하루 시간 예산을 넘겼다는 뜻이다. 여유롭게는 7시간, 보통은 9시간, 알차게는 11시간으로 잡아뒀고 넘으면 알려준다. 이런 경고를 띄울 수 있는 것도 이동 시간이 실제 값이라 가능한 일이다. 추정치로 두 시간 반씩 틀리는 상태면 경고 자체가 의미가 없다. 타임라인에는 구간마다 실제 거리와 시간이 붙는다. 10.4km 22분, 2.4km 10분처럼 구간별로 실제 도로 기준 값이 붙는다. 걸어갈 만한 거리는 도보로 표시하고, 차로 이동하는 구간엔 카카오내비로 바로 넘어가는 링크를 달았다. 어차피 사람들은 앱을 보고 나서 내비를 켜니까, 그 사이 단계를 없애는 게 낫다고 봤다. 대중교통은 아직 근사치다 솔직하게 적어두면, 자차는 실제 도로 기준이지만 대중교통은 아직 정확하지 않다. 지역 버스 경로를 한 번에 계산해주는 무료 API를 못 찾아서, 실도로 시간을 기준으로 보정하는 방식으로 두고 있다. // 대중교통은 전용 API가 없어, 실도로 거리와 자차 시간을 기준으로 보정 // (자차 × 1.7 + 환승·대기 15분) // 경로선은 도로 경로가 버스 노선과 다를 수 있어 넣지 않는다 (지도엔 직선 점선) const base = real ? { distanceKm: real.distanceKm, minutes: Math.round(real.minutes * 1.7 + 15) } : { distanceKm: est.distanceKm, minutes: est.minutes }; 대신 도시가 다른 구간은 시외버스랑 열차 시간표를 붙여서, 시간표가 있으면 그 값을 쓴다. 그리고 지도에 경로선을 안 그리는 것도 일부러 그렇게 뒀다. 자동차 경로선을 그려놓으면 버스가 그 길로 간다고 오해할 수 있어서, 차라리 직선 점선으로 두고 정확하지 않다는 걸 보이게 했다. 정리하면 이번 작업의 교훈은 앞에 썼던 성능 글이랑 똑같았다. 고치기 전에 일단 재봐야 한다 . 직선거리에 1.25를 곱하는 방식이 얼마나 틀리는지 나는 재보기 전까지 몰랐다. 그럴듯한 상수를 넣어뒀으니 대충 맞겠거니 했는데, 실제로는 두 시간 반이 어긋났고 그것도 긴 구간과 짧은 구간이 서로 반대 방향으로 틀리고 있었다. 이건 상수를 조정해서는 절대 못 맞추는 종류의 오차다. 그리고 외부 API를 붙일 때는 캐시랑 폴백을 처음부터 같이 만들어두면 나중에 편하다. 쿼터가 넉넉해 보여도 사용자가 "다시 만들기"를 몇 번만 누르면 금방 줄어들고, 그 API가 잠깐 죽었을 때 서비스 전체가 멈추는 것도 막아야 한다. 지도나 길찾기처럼 호출당 비용이 있는 API를 쓸 계획이라면 이 두 가지는 같이 넣어두길 추천한다.