$cat projects/5/README.md

OffWay

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

"며칠 쉴 수 있나"라는 질문 하나로 연차를 최대로 살린 일정과 인구감소지역 여행지, 정부 지원 정책을 묶어 국내 여행 계획을 자동으로 만듭니다. 화면 하나를 그리는 데 외부 데이터 30종 이상이 들어가는데, 각 호출은 초 단위이고 쿼터가 있으며 간헐적으로 실패합니다. 그 불안정을 어떻게 흡수하느냐가 이 프로젝트의 실제 과제였습니다.

JavaSpring BootMySQLFlywayDockerCaddyAWS EC2GitHub ActionsTestcontainers
▸ github
$cat overview.md

연차를 언제 붙여 쓰면 가장 길게 쉴 수 있는지부터 계산하고, 그 일정에 맞는 인구감소지역 여행지와 받을 수 있는 정부 지원 정책을 묶어 코스를 만들어 주는 서비스입니다.

데이터를 조합하는 서비스라 화면 하나에 공공 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개 고정 지역이라 프리워밍이 되지만, 코스 생성은 출발지·날짜·이동수단에 따라 매번 달라져 미리 계산해둘 대상이 없습니다. 미리 데울 수 있는 것과 없는 것의 경계를 나누고, 후자는 캐싱 대신 실패 시 직선거리 폴백으로 처리했습니다.

$ls architecture/
홈 화면 한 번의 요청
홈 화면 한 번의 요청
클릭해서 크게 보기
실행 구조
실행 구조
클릭해서 크게 보기
$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 로 만들 수 있고, 사용자 입력에 따라 달라지는 것은 애초에 데울 대상이 없어 다른 방법으로 풀어야 합니다.

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