$cat projects/5/README.md

OffWay

2026.07 - 2026.101BE 1FE 1DesignerBackend Developer관광데이터 공모전 출품작

국내 여행 수요가 주요 도시로 쏠리는 동안, 행정안전부가 지정한 89개 인구감소지역은 생활인구 유입이 절실합니다. 정부와 지자체가 마련한 지원책도 부처마다 흩어져 여행자에게 닿지 못합니다. 남은 연차에 맞춰 89개 지역의 코스를 자동으로 만들고, 그 여정에서 받을 수 있는 혜택을 연결합니다. 일정 하나를 만드는 데 공공데이터 21종이 들어가는데, 각 호출은 초 단위이고 쿼터가 있으며 간헐적으로 실패합니다. 그 불안정을 어떻게 흡수하느냐가 이 프로젝트의 실제 과제였습니다.

JavaSpring BootMySQLRedisFlywayDockerCaddyAWS EC2GitHub ActionsTestcontainers
▸ github
$cat overview.md

국내 여행 수요의 과도한 쏠림으로 주요 도시는 오버투어리즘을 겪는 반면, 행정안전부가 지정한 89개 인구감소지역은 생활인구 유입이 절실한 상황입니다. 정부와 지자체가 숙박세일페스타, 디지털관광주민증, KTX·SRT 할인 등 지원책을 마련했으나 정보가 각 부처와 지자체에 파편화되어 있어 여행자에게 실질적으로 닿지 못하고 있습니다.

여행지를 고르는 기준을 바꿨습니다. 대부분의 여행 서비스가 '어디로 갈지'를 먼저 정한다면, OffWay 는 '이번 여행에 연차를 얼마나 쓸 수 있는지'부터 파악합니다. 남은 연차와 이동수단, 여행 스타일에 맞춰 실제로 다녀올 수 있는 지역과 코스를 추천합니다.

여행 전후의 연차까지 함께 관리합니다. 남은 연차를 기록하고 황금연휴처럼 연차를 쓰기 좋은 시기를 알려줍니다. 여행을 다녀온 뒤에는 사용한 연차를 반영해, 다음 여행을 계획할 때 다시 활용할 수 있습니다.

89개 인구감소지역을 중심으로 소개합니다. 익숙한 인기 관광지보다 행정안전부가 지정한 인구감소지역 89곳을 중심으로 새로운 여행지를 제안합니다. 지역별 관광정보와 흩어져 있던 정부·지자체의 여행 혜택도 한곳에서 확인할 수 있습니다.

데이터를 조합하는 서비스라 화면 하나에 공공 API 여러 개가 동시에 들어갑니다. 그런데 공공 API 는 호출마다 초 단위가 걸리고, 일일 쿼터가 있으며, 간헐적으로 타임아웃이 납니다. 요청 경로에서 그대로 부르면 홈 화면 한 번에 수십 초가 걸리고 쿼터도 금방 소진됩니다.

그래서 클라이언트가 외부를 직접 부르지 않게 하고, 서버가 외부를 모아 캐싱한 뒤 화면 단위로 한 번에 내려주는 구조로 잡았습니다. 외부 의존을 다루는 규율은 캐시 프리미티브 하나에 담아, 새 소스를 붙이면 같은 견고함을 자동으로 얻게 했습니다.

$cat role.md
$cat highlights.md
외부 의존을 다루는 규율을 한곳에

느리고 불안정한 외부를 감싸는 캐시 프리미티브를 만들고 모든 외부 클라이언트가 그 위에 올라가게 했습니다. 키별 TTL, 막지 않는 single-flight, double-check, stale-while-error, negative caching 이 한 클래스에 담깁니다. 새 소스를 붙이면 같은 견고함을 자동으로 얻습니다.

외부가 죽어도 화면은 뜬다

랭킹·콘텐츠·대기질·날씨는 없어도 화면이 성립합니다. 그래서 실패하면 빈 값이나 폴백으로 낮춰 200 으로 내보냅니다. 반대로 장소 상세의 콘텐츠는 그게 없으면 보여줄 게 없어 502 를 유지합니다. 없어도 되는 것과 없으면 안 되는 것을 갈라 홈은 간헐 502 에서 항상 200 이 됐습니다.

무효화가 옛 값을 되살리던 문제

로드가 시작된 뒤 무효화하면 그 로드가 끝나면서 무효화 이전 값을 다시 넣습니다. "강제 갱신했는데 안 바뀐다"가 되고, 그때 원인을 캐시가 아니라 외부 API 에서 찾기 시작하면 시간을 크게 버립니다. 로드 시작 시점의 세대를 적어두고 저장 직전에 대조해 버리도록 했습니다.

키 공간의 상한을 먼저 정한다

TTL 은 값의 신선도만 관리할 뿐 엔트리를 지우지 않습니다. 만료된 엔트리도 자리를 차지하므로 좌표·날짜가 섞인 키처럼 키 공간이 무한하면 힙이 계속 자랍니다. TTL 이 짧을수록 회전이 빨라 오히려 더 빨리 쌓입니다. 캐시를 만들 때 키가 몇 개까지 늘 수 있는지를 먼저 답하게 했습니다.

$cat challenges.md
problem배포하려면 SSH 를 열어야 한다

solution서버의 22번은 고정 IP 하나만 열려 있는데 Actions 러너는 매번 IP 가 다릅니다. 편하게 가려면 0.0.0.0/0 으로 열어두면 되지만, 그러면 배포 한 번을 위해 SSH 를 온 세상에 상시로 열어두는 셈입니다. 대신 배포 직전에 ==러너의 현재 IP 만== 보안그룹에 넣고, 성공하든 실패하든 마지막에 회수하도록 했습니다. 배포할 때만 잠깐 열리고 끝나면 다시 닫혀, 평소에는 아무 IP 도 들어올 수 없습니다.

problem자동 배포 중 실패하면 서버가 내려간 채 남는다

solution컨테이너를 내리고 띄우는 사이에 실패하면 다음 스텝이 건너뛰어져 복구가 아예 안 돌았습니다. 배포 전에 되돌아갈 이미지를 파일로 적어두고, 그 구간의 실패는 그 자리에서 직접 복구하도록 바꿨습니다.

problem로컬은 초록인데 운영에서 깨진다

solution로컬 DB 와 운영 MySQL 은 문법과 타입이 달라, 마이그레이션이 로컬에서 초록인 채 운영에서 터졌습니다. 로컬도 운영과 같은 MySQL 을 컨테이너로 띄우고, 테스트는 Testcontainers 가 자기 것을 따로 쓰게 해 서로 오염되지 않게 했습니다.

problem코스 생성은 미리 데울 수 없다

solution홈과 추천은 89개 고정 지역이라 미리 준비해 둘 수 있지만(지금은 주기 배치로 DB 에 적재), 코스 생성은 출발지·날짜·이동수단에 따라 매번 달라져 미리 계산해둘 대상이 없습니다. 미리 데울 수 있는 것과 없는 것의 경계를 나누고, 후자는 캐싱 대신 실패 시 직선거리 폴백으로 처리했습니다.

$ls architecture/
홈 화면 한 번의 요청
홈 화면 한 번의 요청
클릭해서 크게 보기
실행 구조
실행 구조
클릭해서 크게 보기
코스 생성 흐름
코스 생성 흐름
클릭해서 크게 보기
도메인 구성
도메인 구성
클릭해서 크게 보기
$ls screenshots/
온보딩 · 남은 연차 입력
클릭해서 크게 보기
온보딩 · 남은 연차 입력
홈 · 코스 추천 시작
클릭해서 크게 보기
홈 · 코스 추천 시작
연차 사용 방식 선택
클릭해서 크게 보기
연차 사용 방식 선택
이동수단 선택
클릭해서 크게 보기
이동수단 선택
여행 가능한 지역 탐색
클릭해서 크게 보기
여행 가능한 지역 탐색
인구감소지역 선택
클릭해서 크게 보기
인구감소지역 선택
날짜별 코스 추천
클릭해서 크게 보기
날짜별 코스 추천
혜택 확인 · 코스 저장
클릭해서 크게 보기
혜택 확인 · 코스 저장
내 코스 · 예정된 여행과 지난 여행
클릭해서 크게 보기
내 코스 · 예정된 여행과 지난 여행
이동 정보 · 날씨 · 날짜별 일정
클릭해서 크게 보기
이동 정보 · 날씨 · 날짜별 일정
운영시간 · 휴무일
클릭해서 크게 보기
운영시간 · 휴무일
장소 상세 관광정보
클릭해서 크게 보기
장소 상세 관광정보
방문 여부 확인 · 연차 반영
클릭해서 크게 보기
방문 여부 확인 · 연차 반영
남은 연차 · 사용 내역
클릭해서 크게 보기
남은 연차 · 사용 내역
여행 외 연차 직접 등록
클릭해서 크게 보기
여행 외 연차 직접 등록
남은 연차 자동 갱신
클릭해서 크게 보기
남은 연차 자동 갱신
정부 · 지자체 여행 혜택
클릭해서 크게 보기
정부 · 지자체 여행 혜택
관광지 설명 · 방문 정보
클릭해서 크게 보기
관광지 설명 · 방문 정보
지역에서 받을 혜택
클릭해서 크게 보기
지역에서 받을 혜택
위젯 · 다이나믹 아일랜드
클릭해서 크게 보기
위젯 · 다이나믹 아일랜드
백오피스 · 바로가기 링크
클릭해서 크게 보기
백오피스 · 바로가기 링크
백오피스 · 지역 혜택
클릭해서 크게 보기
백오피스 · 지역 혜택
백오피스 · 외부 API 사용량
클릭해서 크게 보기
백오피스 · 외부 API 사용량
$cat results.md
▸
10,086ms → 2ms

홈 API 응답 중앙값. 20회 측정하며 캐시를 매번 비운 상태를 baseline 으로 비교했습니다. 캐시 OFF 는 측정용 상태일 뿐 운영 설정이 아닙니다.

▸
간헐 502 → 항상 200

외부가 전부 죽어도 화면은 낮춰진 형태로 뜹니다.

▸
1~7ms 로 일정

콜드는 1초에서 18초까지 출렁였습니다. 캐시가 없앤 것은 지연만이 아니라 그 변동성입니다.

$cat retrospection.md
잘 됐던 것

외부 의존을 다루는 판단을 여기저기 흩어놓지 않고 한 프리미티브에 담은 것이 가장 의미 있었습니다. 덕분에 소스를 새로 붙일 때 견고함을 다시 만들지 않아도 됩니다.

판단의 근거를 ADR 로 남겨 왜 그렇게 정했는지가 코드 밖에도 남아 있습니다. TTL 을 데이터 특성에서 도출한 이유, degrade 와 502 를 가른 기준, 라이브러리를 쓰지 않은 이유가 전부 기록돼 있습니다.

개선할 것

캐시가 JVM 인메모리라 단일 인스턴스를 전제합니다. 인스턴스를 늘리면 히트율이 나뉘고 single-flight 가 인스턴스마다 따로 돌아 외부 호출이 인스턴스 수만큼 늡니다. 공모전 규모라 의식적으로 택했지만 전제인 건 분명합니다.

배운 것

미리 데울 수 있는 것과 없는 것을 나누는 것이 이 전략의 전부였습니다. 고정된 대상은 요청 경로 밖에서 채워 ms 로 만들 수 있고, 사용자 입력에 따라 달라지는 것은 애초에 데울 대상이 없어 다른 방법으로 풀어야 합니다.

한계를 먼저 적어두면 나중에 그 지점부터 손댈 수 있다는 것도 배웠습니다. 스케일아웃하면 동일 인터페이스로 공유 캐시에 옮기고 워밍은 리더 한 대만 돌리면 됩니다.