OffWay
국내 여행 수요가 주요 도시로 쏠리는 동안, 행정안전부가 지정한 89개 인구감소지역은 생활인구 유입이 절실합니다. 정부와 지자체가 마련한 지원책도 부처마다 흩어져 여행자에게 닿지 못합니다. 남은 연차에 맞춰 89개 지역의 코스를 자동으로 만들고, 그 여정에서 받을 수 있는 혜택을 연결합니다. 일정 하나를 만드는 데 공공데이터 21종이 들어가는데, 각 호출은 초 단위이고 쿼터가 있으며 간헐적으로 실패합니다. 그 불안정을 어떻게 흡수하느냐가 이 프로젝트의 실제 과제였습니다.
국내 여행 수요의 과도한 쏠림으로 주요 도시는 오버투어리즘을 겪는 반면, 행정안전부가 지정한 89개 인구감소지역은 생활인구 유입이 절실한 상황입니다. 정부와 지자체가 숙박세일페스타, 디지털관광주민증, KTX·SRT 할인 등 지원책을 마련했으나 정보가 각 부처와 지자체에 파편화되어 있어 여행자에게 실질적으로 닿지 못하고 있습니다.
여행지를 고르는 기준을 바꿨습니다. 대부분의 여행 서비스가 '어디로 갈지'를 먼저 정한다면, OffWay 는 '이번 여행에 연차를 얼마나 쓸 수 있는지'부터 파악합니다. 남은 연차와 이동수단, 여행 스타일에 맞춰 실제로 다녀올 수 있는 지역과 코스를 추천합니다.
여행 전후의 연차까지 함께 관리합니다. 남은 연차를 기록하고 황금연휴처럼 연차를 쓰기 좋은 시기를 알려줍니다. 여행을 다녀온 뒤에는 사용한 연차를 반영해, 다음 여행을 계획할 때 다시 활용할 수 있습니다.
89개 인구감소지역을 중심으로 소개합니다. 익숙한 인기 관광지보다 행정안전부가 지정한 인구감소지역 89곳을 중심으로 새로운 여행지를 제안합니다. 지역별 관광정보와 흩어져 있던 정부·지자체의 여행 혜택도 한곳에서 확인할 수 있습니다.
데이터를 조합하는 서비스라 화면 하나에 공공 API 여러 개가 동시에 들어갑니다. 그런데 공공 API 는 호출마다 초 단위가 걸리고, 일일 쿼터가 있으며, 간헐적으로 타임아웃이 납니다. 요청 경로에서 그대로 부르면 홈 화면 한 번에 수십 초가 걸리고 쿼터도 금방 소진됩니다.
그래서 클라이언트가 외부를 직접 부르지 않게 하고, 서버가 외부를 모아 캐싱한 뒤 화면 단위로 한 번에 내려주는 구조로 잡았습니다. 외부 의존을 다루는 규율은 캐시 프리미티브 하나에 담아, 새 소스를 붙이면 같은 견고함을 자동으로 얻게 했습니다.
- ▸외부 데이터 통합 공공데이터 21종에 TMAP 같은 민간 API 까지 붙였습니다. 도메인마다 port 인터페이스를 두고 벤더 구현을 뒤에 숨겨, 외부가 바뀌어도 도메인이 흔들리지 않게 했습니다. 키가 없으면 외부를 부르지 않고 빈 결과를 주도록 해서 키 없이도 클론 즉시 띄울 수 있습니다.
- ▸캐시와 DB 적재 키별 TTL 을 데이터 특성에서 도출해 정했습니다. 처음에는 89개 고정 지역을 요청 경로 밖에서 미리 채웠지만, 캐시가 배포 때마다 비면서 API 한도를 소모해 지금은 홈 데이터를 주기 배치로 DB 에 적재합니다.
- ▸적재 배치 공휴일·지역 콘텐츠·POI·사진·이동시간을 스케줄 배치가 주기적으로 받아 적재합니다. 장소와 유산 풀처럼 자주 바뀌지 않는 것은 압축 파일로 시딩합니다.
- ▸배포 파이프라인 GitHub Actions 가 빌드하고 EC2 로 옮겨 컨테이너를 교체합니다. 서버의 SSH 는 고정 IP 하나에만 열려 있어서, 배포 직전에 러너의 현재 IP 를 보안그룹에 넣고 끝나면 성공하든 실패하든 회수합니다.
느리고 불안정한 외부를 감싸는 캐시 프리미티브를 만들고 모든 외부 클라이언트가 그 위에 올라가게 했습니다. 키별 TTL, 막지 않는 single-flight, double-check, stale-while-error, negative caching 이 한 클래스에 담깁니다. 새 소스를 붙이면 같은 견고함을 자동으로 얻습니다.
랭킹·콘텐츠·대기질·날씨는 없어도 화면이 성립합니다. 그래서 실패하면 빈 값이나 폴백으로 낮춰 200 으로 내보냅니다. 반대로 장소 상세의 콘텐츠는 그게 없으면 보여줄 게 없어 502 를 유지합니다. 없어도 되는 것과 없으면 안 되는 것을 갈라 홈은 간헐 502 에서 항상 200 이 됐습니다.
로드가 시작된 뒤 무효화하면 그 로드가 끝나면서 무효화 이전 값을 다시 넣습니다. "강제 갱신했는데 안 바뀐다"가 되고, 그때 원인을 캐시가 아니라 외부 API 에서 찾기 시작하면 시간을 크게 버립니다. 로드 시작 시점의 세대를 적어두고 저장 직전에 대조해 버리도록 했습니다.
TTL 은 값의 신선도만 관리할 뿐 엔트리를 지우지 않습니다. 만료된 엔트리도 자리를 차지하므로 좌표·날짜가 섞인 키처럼 키 공간이 무한하면 힙이 계속 자랍니다. TTL 이 짧을수록 회전이 빨라 오히려 더 빨리 쌓입니다. 캐시를 만들 때 키가 몇 개까지 늘 수 있는지를 먼저 답하게 했습니다.
solution서버의 22번은 고정 IP 하나만 열려 있는데 Actions 러너는 매번 IP 가 다릅니다. 편하게 가려면 0.0.0.0/0 으로 열어두면 되지만, 그러면 배포 한 번을 위해 SSH 를 온 세상에 상시로 열어두는 셈입니다. 대신 배포 직전에 ==러너의 현재 IP 만== 보안그룹에 넣고, 성공하든 실패하든 마지막에 회수하도록 했습니다. 배포할 때만 잠깐 열리고 끝나면 다시 닫혀, 평소에는 아무 IP 도 들어올 수 없습니다.
solution컨테이너를 내리고 띄우는 사이에 실패하면 다음 스텝이 건너뛰어져 복구가 아예 안 돌았습니다. 배포 전에 되돌아갈 이미지를 파일로 적어두고, 그 구간의 실패는 그 자리에서 직접 복구하도록 바꿨습니다.
solution로컬 DB 와 운영 MySQL 은 문법과 타입이 달라, 마이그레이션이 로컬에서 초록인 채 운영에서 터졌습니다. 로컬도 운영과 같은 MySQL 을 컨테이너로 띄우고, 테스트는 Testcontainers 가 자기 것을 따로 쓰게 해 서로 오염되지 않게 했습니다.
solution홈과 추천은 89개 고정 지역이라 미리 준비해 둘 수 있지만(지금은 주기 배치로 DB 에 적재), 코스 생성은 출발지·날짜·이동수단에 따라 매번 달라져 미리 계산해둘 대상이 없습니다. 미리 데울 수 있는 것과 없는 것의 경계를 나누고, 후자는 캐싱 대신 실패 시 직선거리 폴백으로 처리했습니다.
홈 API 응답 중앙값. 20회 측정하며 캐시를 매번 비운 상태를 baseline 으로 비교했습니다. 캐시 OFF 는 측정용 상태일 뿐 운영 설정이 아닙니다.
외부가 전부 죽어도 화면은 낮춰진 형태로 뜹니다.
콜드는 1초에서 18초까지 출렁였습니다. 캐시가 없앤 것은 지연만이 아니라 그 변동성입니다.
외부 의존을 다루는 판단을 여기저기 흩어놓지 않고 한 프리미티브에 담은 것이 가장 의미 있었습니다. 덕분에 소스를 새로 붙일 때 견고함을 다시 만들지 않아도 됩니다.
판단의 근거를 ADR 로 남겨 왜 그렇게 정했는지가 코드 밖에도 남아 있습니다. TTL 을 데이터 특성에서 도출한 이유, degrade 와 502 를 가른 기준, 라이브러리를 쓰지 않은 이유가 전부 기록돼 있습니다.
캐시가 JVM 인메모리라 단일 인스턴스를 전제합니다. 인스턴스를 늘리면 히트율이 나뉘고 single-flight 가 인스턴스마다 따로 돌아 외부 호출이 인스턴스 수만큼 늡니다. 공모전 규모라 의식적으로 택했지만 전제인 건 분명합니다.
미리 데울 수 있는 것과 없는 것을 나누는 것이 이 전략의 전부였습니다. 고정된 대상은 요청 경로 밖에서 채워 ms 로 만들 수 있고, 사용자 입력에 따라 달라지는 것은 애초에 데울 대상이 없어 다른 방법으로 풀어야 합니다.
한계를 먼저 적어두면 나중에 그 지점부터 손댈 수 있다는 것도 배웠습니다. 스케일아웃하면 동일 인터페이스로 공유 캐시에 옮기고 워밍은 리더 한 대만 돌리면 됩니다.


























