OffWay
"며칠 쉴 수 있나"라는 질문 하나로 연차를 최대로 살린 일정과 인구감소지역 여행지, 정부 지원 정책을 묶어 국내 여행 계획을 자동으로 만듭니다. 화면 하나를 그리는 데 외부 데이터 30종 이상이 들어가는데, 각 호출은 초 단위이고 쿼터가 있으며 간헐적으로 실패합니다. 그 불안정을 어떻게 흡수하느냐가 이 프로젝트의 실제 과제였습니다.
연차를 언제 붙여 쓰면 가장 길게 쉴 수 있는지부터 계산하고, 그 일정에 맞는 인구감소지역 여행지와 받을 수 있는 정부 지원 정책을 묶어 코스를 만들어 주는 서비스입니다.
데이터를 조합하는 서비스라 화면 하나에 공공 API 여러 개가 동시에 들어갑니다. 그런데 공공 API 는 호출마다 초 단위가 걸리고, 일일 쿼터가 있으며, 간헐적으로 타임아웃이 납니다. 요청 경로에서 그대로 부르면 홈 화면 한 번에 수십 초가 걸리고 쿼터도 금방 소진됩니다.
그래서 클라이언트가 외부를 직접 부르지 않게 하고, 서버가 외부를 모아 캐싱한 뒤 화면 단위로 한 번에 내려주는 구조로 잡았습니다. 외부 의존을 다루는 규율은 캐시 프리미티브 하나에 담아, 새 소스를 붙이면 같은 견고함을 자동으로 얻게 했습니다.
- ▸외부 데이터 통합 공공데이터 26종에 TMAP 같은 민간 API 까지 30종 넘는 소스를 붙였습니다. 도메인마다 port 인터페이스를 두고 벤더 구현을 뒤에 숨겨, 외부가 바뀌어도 도메인이 흔들리지 않게 했습니다. 키가 없으면 외부를 부르지 않고 빈 결과를 주도록 해서 키 없이도 클론 즉시 띄울 수 있습니다.
- ▸캐시와 프리워밍 키별 TTL 을 데이터 특성에서 도출해 정하고, 89개 고정 지역을 요청 경로 밖에서 미리 채웁니다. 워밍 주기는 캐시마다 다르게 뒀습니다. 한 주기로 묶으면 짧은 TTL 캐시가 워밍 사이에 만료돼, 그때 들어온 사용자가 초 단위 외부 호출을 대신 떠안게 됩니다.
- ▸적재 배치 공휴일·지역 콘텐츠·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개 고정 지역이라 프리워밍이 되지만, 코스 생성은 출발지·날짜·이동수단에 따라 매번 달라져 미리 계산해둘 대상이 없습니다. 미리 데울 수 있는 것과 없는 것의 경계를 나누고, 후자는 캐싱 대신 실패 시 직선거리 폴백으로 처리했습니다.
홈 API 응답 중앙값. 20회 측정하며 캐시를 매번 비운 상태를 baseline 으로 비교했습니다. 캐시 OFF 는 측정용 상태일 뿐 운영 설정이 아닙니다.
외부가 전부 죽어도 화면은 낮춰진 형태로 뜹니다.
콜드는 1초에서 18초까지 출렁였습니다. 캐시가 없앤 것은 지연만이 아니라 그 변동성입니다.
외부 의존을 다루는 판단을 여기저기 흩어놓지 않고 한 프리미티브에 담은 것이 가장 남습니다. 덕분에 소스를 새로 붙일 때 견고함을 다시 만들지 않아도 됩니다.
판단의 근거를 ADR 로 남겨 왜 그렇게 정했는지가 코드 밖에도 남아 있습니다. TTL 을 데이터 특성에서 도출한 이유, degrade 와 502 를 가른 기준, 라이브러리를 쓰지 않은 이유가 전부 기록돼 있습니다.
캐시가 JVM 인메모리라 단일 인스턴스를 전제합니다. 인스턴스를 늘리면 히트율이 나뉘고 워밍과 single-flight 가 각자 돌아 외부 호출이 인스턴스 수만큼 늡니다. 공모전 규모라 의식적으로 택했지만 전제인 건 분명합니다.
미리 데울 수 있는 것과 없는 것을 나누는 것이 이 전략의 전부였습니다. 고정된 대상은 요청 경로 밖에서 채워 ms 로 만들 수 있고, 사용자 입력에 따라 달라지는 것은 애초에 데울 대상이 없어 다른 방법으로 풀어야 합니다.
한계를 먼저 적어두면 나중에 그 지점부터 손댈 수 있다는 것도 배웠습니다. 스케일아웃하면 동일 인터페이스로 공유 캐시에 옮기고 워밍은 리더 한 대만 돌리면 됩니다.

