전망대에서 90분, 스키장에서 2시간. 여행 일정의 머무는 시간을 장소 종류별로 다시 정했다
이동 시간은 실제 도로로 바꿨는데 머무는 시간은 여전히 관광지면 90분이었다. 관광공사 소분류 코드와 이름 규칙으로 장소마다 기준을 다시 잡고, 실제 일정 27개를 옛 규칙과 새 규칙으로 다시 계산해 비교했다.
#Node.js #알고리즘 #지도 #백엔드
지난 글 에서 여행 일정의 이동 시간을 직선거리 추정에서 실제 도로 시간으로 바꿨다. 그때 마지막에 "평균 시속 하나로 전부 계산하면 다 틀린다"고 썼는데, 다시 일정을 들여다보니 하루를 이루는 나머지 절반이 똑같은 문제를 갖고 있었다. 장소에 머무는 시간이다. 이 글에서는 머무는 시간이 어떻게 정해지고 있었는지, 그걸 장소 종류별로 다시 나누면서 고민한 것들, 그리고 실제로 만들어진 일정 27개에 옛 규칙과 새 규칙을 돌려본 결과를 정리해본다. 관광지면 무조건 90분이었다 위는 9월에 실제로 만들어진 속초, 양양 일정의 첫날이다. 하조대전망대에서 13시 40분부터 15시 10분까지, 휴휴암에서 15시 25분부터 16시 55분까지 머문다. 전망대에서 90분이면 경치를 보고 사진을 찍고도 한참 남는다. 이유는 단순했다. 머무는 시간을 관광공사 데이터의 콘텐츠 유형 하나로만 정하고 있었다. 관광지 90분 문화시설 90분 레포츠 120분 숙박 60분 쇼핑 60분 음식점 60분 그러니까 전망대도 관광지라 90분, 국립공원도 관광지라 90분이었다. 스키장과 골프장은 레포츠라 120분이었다. 스키장에 가서 2시간 만에 나오는 사람은 거의 없을 거다. 등대 하나 보는 데 1시간 반을 잡고, 스키장은 반나절도 안 잡는 식이었다. 일정 생성기는 하루에 쓸 수 있는 시간이 정해져 있다. 보통 템포면 9시간이다. 짧게 봐도 될 곳에 시간을 많이 잡으면 그만큼 다른 장소가 일정에서 빠진다. 위 일정도 생성할 때 "시간이 부족해 9곳은 일정에서 뺐어요"라는 경고가 붙어 있었다. 장소마다 머무는 시간 데이터가 있으면 좋겠지만 제일 좋은 건 장소마다 실제로 사람들이 얼마나 머무는지를 아는 거다. 근데 관광공사 API에는 그런 필드가 없다. 그래서 분류에서 추정할 수밖에 없다. 다행히 관광공사 데이터에는 콘텐츠 유형보다 훨씬 잘게 나눈 소분류 코드가 있다. 관광지 안에서도 국립공원, 해수욕장, 전망대, 등대, 사찰 같은 식으로 나뉜다. 이걸로 먼저 정하고, 소분류가 없으면 장소 이름의 키워드로, 그것도 안 걸리면 예전 기본값으로 떨어지게 했다. // lib/stayTime.js 의 소분류 표 일부 국립공원, 산, 테마공원 180분 스키/스노보드, 골프 240분 온천/스파, 래프팅, 트래킹 150분 해수욕장, 박물관 90분 미술관, 전시관 75분 사찰, 호수, 폭포, 공원 60분 해안절경, 항구/포구 45분 전망대/기념탑, 기암괴석 40분 등대, 약수터, 다리 30분 분수, 동상 20분 판단하는 순서는 이렇다. 1. 관광공사 소분류 코드가 표에 있으면 그 값 2. 음식점, 카페, 숙소는 60분 고정 3. 쇼핑은 60분 (시장, 아울렛, 백화점 같은 이름이어도 60분) 4. 장소 이름 키워드 (스키장, 워터파크, 온천, 둘레길, 전망대, 등대 ...) 5. 예전 콘텐츠 유형 기본값 6. 그래도 없으면 60분 마지막 두 단계가 중요하다. 분류도 없고 이름으로도 못 알아보는 장소는 예전과 똑같이 동작한다. 나빠지는 장소는 없고, 알아볼 수 있는 장소만 좋아진다. 이름 규칙은 생각보다 까다로웠다 이름 키워드는 소분류가 없는 장소, 특히 카카오 장소 검색으로 들어온 장소에 필요하다. 근데 그냥 글자가 들어 있는지만 보면 엉뚱하게 걸린다. 예를 들어 "스파"로 온천을 잡으려고 하면 옷 가게 스파오와 스파이더가 걸린다. 그래서 이름 끝이 스파로 끝날 때만 잡게 했다. "역"으로 끝나는 기차역을 30분으로 잡으려고 했더니 묘역이 걸려서, 앞 글자가 묘가 아닐 때만 잡게 했다. [/스키장|워터파크|골프/, 240], [/온천|스파$/, 150], // '스파오', '스파이더' 제외 [/(? !묘)역$/, 30], // 묘역 제외 음식점은 이름 규칙을 아예 안 탄다. 해변식당 같은 이름이 해변 규칙에 걸려서 밥 먹는 데 90분이 잡히면 안 되기 때문이다. 쇼핑도 같은 이유로 이름에 골프나 스키가 들어간 가게가 240분이 되지 않게 막았다. 저장하는 값과 일정 짤 때 바꾸는 값을 나눴다 머무는 시간은 같은 장소라도 누구랑 가느냐에 따라 달라진다. 아이랑 가면 더 오래 걸리고, 혼자면 더 빨리 본다. 이걸 장소에 저장해버리면 장소 하나를 여행 종류마다 따로 들고 있어야 한다. 그래서 두 층으로 나눴다. 장소에는 기본값만 저장하고, 일정을 짤 때 동행과 템포를 보고 그 자리에서 보정한다. 보정된 값은 저장하지 않는다. function stayMinutesFor(place, ctx) { if (place.category === 'stay') return 30; // 숙소는 체크인 30분만 일정에 놓는다 let m = Number(place.stay_minutes) || baseStayMinutes(place); const isVisit = place.category === 'attraction' || place.category === 'culture'; if (isVisit ctx.companion === 'family') m += 15; if (isVisit ctx.companion === 'solo') m -= 15; if (place.category === 'food' ctx.startMinute = 17 * 60) m = Math.max(m, 75); if (place.category !== 'food' place.category !== 'cafe') { if (ctx.pace === 'relaxed') m *= 1.15; else if (ctx.pace === 'packed') m *= 0.85; } return round5(Math.min(300, Math.max(20, m))); } 저녁 식사만 따로 처리한 건 실제 여행이 그렇기 때문이다. 점심은 한 시간이면 되는데 저녁은 대개 더 오래 먹는다. 17시 이후에 시작하는 식사는 최소 75분으로 잡았다. 그리고 결과는 20분에서 300분 사이로 자르고 5분 단위로 반올림한다. 이미 있는 장소에는 어떻게 넣었나 장소 DB에는 이미 5천 개 넘는 장소가 있었다. 새 규칙으로 전부 다시 계산하면 간단한데, 그 사이에 내가 직접 손본 값들이 있었다. 남이섬 180분, 인제 자작나무숲 150분, 대관령 양떼목장 90분 같은 것들이다. 그래서 다시 계산할 때 예전 콘텐츠 유형 기본값 그대로인 장소만 바꿨다. 값이 기본값과 다르면 누군가 손으로 고친 거라고 보고 건드리지 않았다. const typeDefault = BY_TYPE[r.content_type_name] ?? 60; if (Number(r.stay_minutes) !== typeDefault) { kept++; continue; } 그 결과 1,059곳이 바뀌었고 손으로 고친 24곳은 그대로 남았다. 바뀐 것 중에는 관광지 90분에서 60분으로 내려간 게 344곳으로 제일 많았고, 120분으로 올라간 관광지가 137곳, 레포츠 중 150분으로 올라간 게 66곳, 240분이 된 게 28곳이었다. 손으로 고친 값을 지킨 게 생각보다 중요했다. 확인해보니 원본 분류 자체가 틀린 장소들이 있었다. 인제 자작나무숲은 약수터 코드가 붙어 있어서 규칙대로라면 30분이 되고, 대관령 양떼목장은 카페 코드가 붙어 있다. 이런 건 규칙으로는 못 잡는다. 실제 일정 27개로 비교해봤다 새 규칙이 실제로 어떤 차이를 만드는지 보려고, DB에 저장된 실제 일정 27개(54일치, 항목 305개)를 옛 규칙과 새 규칙으로 다시 계산했다. 장소 순서와 실제 도로 이동 시간은 저장된 그대로 두고 머무는 시간만 바꿨다. 계산이 맞는지부터 확인했다. 일정 27개 모두 옛 규칙으로 다시 계산한 결과가 저장된 시간표와 분 단위까지 완전히 같았다. 계산기가 실제 생성기와 똑같이 동작한다는 뜻이니, 규칙만 바꿔서 돌린 결과도 믿을 수 있다. 옛 규칙 새 규칙 머무는 시간이 바뀐 항목 - 53개 (305개 중) 하루 평균 머무는 시간 395.9분 381.5분 마지막 장소 평균 종료 시각 17:27 17:13 하루 일정이 짧아진 날 - 19일 그대로인 날 - 27일 길어진 날 - 8일 60분 이상 여유가 생긴 날 - 13일 평균으로 보면 하루 15분 정도 짧아졌다. 크지 않아 보이지만 바뀐 건 일부 장소뿐이라 그렇다. 바뀐 날만 보면 차이가 꽤 크다. 위 속초 일정은 이렇게 바뀐다. 옛 규칙 새 규칙 하조대전망대 13:40 - 15:10 (90분) 13:40 - 14:20 (40분) 휴휴암 15:25 - 16:55 (90분) 15:40 - 16:40 (60분) 영월, 태백 일정은 한반도지형이 90분에서 45분, 뗏목마을이 90분에서 60분이 되면서 첫날이 18시 30분에서 17시 15분에 끝나고, 둘째 날은 18시 55분에서 17시 35분에 끝난다. 원래 붙어 있던 "2일차 일정이 빡빡해요" 경고도 사라진다. 반대로 길어진 쪽도 있다. 바우길 일부 구간이나 DMZ 평화의 길처럼 트래킹 코드가 붙은 걷기 코스는 120분에서 150분이 됐고, 정선의 스키장은 120분에서 240분이 됐다. 그 정선 일정은 여유 템포라 15%가 더 붙어서 하루가 20시 40분까지 늘어났다. 이건 틀린 게 아니라 원래 그래야 했던 거다. 실제로 일정을 새로 만들면 생성기가 그날 장소 수를 줄이거나 다른 날로 넘긴다. 줄어든 시간은 어디로 가나 지난 글에서 만든 비는 시간 채우기가 여기서 다시 쓰인다. 전망대나 항구처럼 짧게 보는 곳이 정확해지면 오후에 빈 시간이 생기고, 그 자리에 근처 장소를 추천으로 채운다. 60분 이상 여유가 생긴 날이 13일이었으니 그만큼 일정에 장소 하나씩이 더 들어갈 자리가 생긴 셈이다. 채울 후보도 같은 규칙으로 시간을 잡는다. 저녁 시간에 들어가는 식당 후보는 75분으로 계산해서 넣는다. 아직 맞지 않는 것 두 가지가 남아 있다. 하나는 관광공사가 분류 체계를 새로 바꾸면서 생긴 문제다. 장소 4,646곳 중 2,453곳이 새 체계 코드를 갖고 있는데, 내 표는 옛 코드만 알고 있다. 이 장소들은 지금 이름 규칙에만 기대고 있어서 표를 새 코드로도 채워야 한다. 다른 하나는 화면 쪽이다. 위 스크린샷의 "머무는 시간 5시간 50분"은 장소에 저장된 기본값을 단순히 더한 숫자다. 그런데 시간표는 동행, 템포, 저녁 보정이 들어간 값으로 그려진다. 그래서 요약 카드와 시간표의 합이 안 맞는 날이 있다. 사용자가 순서를 바꿨을 때 화면에서 다시 계산하는 쪽도 아직 기본값과 직선거리 추정을 쓴다. 서버에서 계산한 값을 그대로 내려받아 쓰게 바꿔야 한다. 남은 생각 이동 시간을 실제 도로로 바꿨을 때는 그게 일정의 정확도를 좌우하는 전부라고 생각했다. 근데 위 속초 일정만 봐도 이동은 1시간 8분이고, 하루의 나머지 대부분은 머무는 시간이다. 정확하게 고친 쪽이 작은 쪽이었던 거다. 값 하나로 여러 경우를 덮고 있는 곳이 있다면, 그 값이 실제로 전체에서 얼마나 큰 비중을 차지하는지부터 재보길 추천한다. 비중이 큰 값이 대충 정해져 있으면 다른 걸 아무리 정확하게 고쳐도 결과는 크게 안 바뀐다.