연차로 떠나는 로컬 여행 플래너 — "며칠 쉴 수 있나"라는 질문 하나로, 직장인의 연차를 최대로 살려 일정·인구감소지역 로컬 여행지·정부 지원 정책이 결합된 국내 여행 계획을 자동 생성한다.
OffWay 백엔드(Spring Boot 4.1 · Java 25). 공휴일·관광·교통·정책 공공데이터를 조합해 "연차 자원 최적화 + 로컬 여행 추천 + 정책 큐레이션"을 하나의 플로우로 제공한다.
연차는 남는데 어디로 갈지 모른다. 그리고 검색하면 나오는 곳은 늘 같은 몇 군데다.
한편 행정안전부는 인구감소지역 89곳을 고시했다. 사람이 줄어 소멸을 걱정하는 지역들이고, 관광 데이터도 얇아 흔한 여행 서비스가 잘 다루지 못한다. 정부는 이 지역들을 위해 디지털관광주민증·숙박세일페스타 같은 혜택을 내놓지만, 혜택이 있는지조차 모르는 채로 지나가는 경우가 많다.
OffWay는 이 둘을 잇는다.
직장인의 남은 연차를 최대로 살려서, 인구감소지역으로 떠나는 일정을 짜고, 그 여행에 쓸 수 있는 정부 혜택을 함께 붙인다.
| 하는 일 | |
|---|---|
| 연차를 최대로 | 공휴일·주말을 엮어 실제 여행 가능 시간(LNT)을 계산한다. 연차 1~2일로 최대 휴식이 나오는 샌드위치 연휴를 찾아준다 |
| 갈 만한 곳을 고른다 | 출발지에서 닿는 시간·생활인구·콘텐츠 양을 따져 89곳 중 후보를 추린다 |
| 코스를 짜준다 | Day별 타임라인으로 볼거리·끼니·숙소를 배치한다. 이동 시간과 도착 시각까지 반영한다 |
| 혜택을 붙인다 | 7대 혜택 — 숙박세일페스타 · 디지털관광주민증 · 근로자휴가지원 · 지자체바우처 · KTX/SRT 할인 · 로컬100/관광두레 · 농촌체험/치유관광 |
| 연차를 차감한다 | 코스를 확정하면 실제 여행 날짜로 연차를 깎는다. 취소하면 되돌린다 |
연차 몇 개 쓸 수 있나 → 언제 가면 가장 이득인가 → 어디로 갈까 → 코스 짜기 → 연차 차감
LNT 산출 샌드위치 연휴 89곳 추천 Day 타임라인 확정
관광 데이터가 얇은 지역일수록 "볼 게 없다"로 읽히기 쉽다. 그건 데이터가 없는 것이지 갈 곳이 없는 게 아니다.
의성군을 예로 들면, 관광 API에는 숙소가 1곳뿐이다(고택 하나). 실제로는 읍내에 50곳 넘게 있다. 그 차이를 메우지 않으면 "잘 곳이 없는 2박3일"이 사용자에게 그대로 나간다. 아래 데이터 풀은 그래서 만들었다.
89곳 전부에 대해 미리 확보해 둔 장소다. 조회에 외부 API를 쓰지 않는다.
| 종류 | 건수 | 출처 |
|---|---|---|
| 맛집 | 75,565 | 지방행정인허가(일반음식점·관광식당·제과점) |
| 숙소 | 26,365 | 숙박업·농어촌민박·한옥체험·관광숙박·관광펜션 |
| 카페 | 15,619 | 휴게음식점(커피숍·전통찻집·디저트) |
| 관광명소 | 3,844 | 전통사찰·박물관/미술관·테마파크·야영장·휴양시설 |
| 국가유산 | 3,437 | 국가유산청(유적건조물·자연유산·등록문화유산) |
| 합계 | 124,830 | 지역 커버 89/89 · 지역당 최소 305곳 |
- 영업 중인 곳만 담는다. 원본에는 폐업이 섞여 있는데(경북 숙박은 41%가 폐업) 거르지 않으면 없어진 가게가 코스에 들어간다.
- 술집·편의점·부속매점 등 여행지로 볼 수 없는 업태 115,530건을 제외했다. 여기에 더해 백화점·아울렛·쇼핑몰·대형마트 안 식당·카페(216건)와 편의점(465건)을 뺀다 — 업태 필터가 있는데도 남았다. 백화점 안 스타벅스의 업태는 '커피'고, 편의점도 휴게음식점으로 등록되면 '분식'이라 업태로는 가릴 수 없다. 위치 단서가 주소 문자열에만 있어 시설명·브랜드명으로 거르되, 다른 상호에 끼는 것(제이마트·씨유민박·CUBE)은 전수로 확인해 예외로 뒀다.
- 주소의 시도·시군구가 그 지역과 맞는지 확인한다. 예전에는 시군구 이름만 맞으면 붙여서, 전국의 동구·남구가 부산 동구·대구 남구로 빨려 들어왔다(부산 동구는 15,701건 중 부산이 1,816건뿐이었다). 배포 파일을 직접 여는 테스트가 이 불변식을 잠근다.
- 전화번호가 49% 붙어 있다. 관광 API에는 없던 정보다.
- 갱신은 분기 1회, 빌드 타임 스크립트로 처리한다(
scripts/build_place_pool.py).
국가유산은 인허가 볼거리의 빈틈을 메운다. 인허가 관광명소에는 야영장·골프장이 섞여 있는데, 국보·사적·천연기념물은 그 자체가 관광 자원이다. 88/89곳을 덮고 지역당 중앙값 36곳이며, 볼거리가 가장 얇던 경북(1,489)·경남(1,269)·전남(992)이 가장 두껍게 채워진다. 대구 서구만 0곳인데 그 지역 국가유산이 무형유산 3건뿐이라 그렇다.
- 사진 97%·설명 98% 가 함께 온다(적재 3,437건 기준). 인허가 데이터에는 둘 다 없어 카드가 비었던 자리다.
- 갈 수 있는 것만 담는다. 수집한 6,387건 중 유물(1,409)·기록유산(846)·무형유산(234)을 대분류로 빼고, 대분류가 통과한 것에서도 발굴 조사 구역(
신안 해저유물 매장해역)과 개인 묘(이규보 묘) 292건을 중분류로 뺀다. 좌표를 못 채운 126건과 원본에 소재지가 없는 6건도 빠진다. 종목으로는 안 갈린다 — 우리 89곳의 국보·보물 표본 12건이 전부동궐도·자수 초충도 병풍같은 소장 유물이었고, 주소가 소장 기관이라 그대로 쓰면 그림 한 점이 목적지가 된다. 반대로 국보·보물을 통째로 빼면 사찰 대웅전·석탑이 함께 날아간다. 국가유산청이 주는 대분류가 정확히 이 둘을 가른다. - 좌표를 지오코딩으로 채운다. 원본은 72%만 좌표를 주는데, 소재지 주소를 카카오·네이버로 옮겨 89% 로 올렸다.
부산광역시 동구처럼 시군구까지만 있는 주소는 관공서 좌표로 찍히므로 일부러 버린다. - 관광 API 일일 한도와 무관하다 — 국가유산청 자체 API 라 그쪽이 말라도 이 후보는 산다.
- 갱신은 연 1회면 충분하다(
scripts/build_heritage_pool.py).
여기에 관광 API(TourAPI) 가 얹힌다. 그쪽은 사진·소개가 붙어 화면 품질이 높으므로 1순위로 쓰고, 모자란 자리만 위 풀로 채운다.
지역 카드의 대표 사진은 따로 고른다. 관광공사 중심 관광지(실제 이동 데이터 기반 순위) 상위를 훑어 관광사진 갤러리 6,118건에서 사진이 있는 첫 명소를 세운다 — 공주시면 공산성이다. 터미널·역·골프장·스키리조트가 1위인 지역이 흔해서 순위를 그대로 쓰지 않는다. 86/89곳이 갤러리 사진으로 채워지고, 나머지 3곳만 관광 API 사진으로 내려간다.
갤러리가 주는 URL은 19%가 죽어 있어(1,790장 중 345장이 404) 적재할 때 생존을 확인하고 버린다. 확인하지 않으면 지역 15곳에 깨진 이미지가 그대로 나갔다.
이 프로젝트는 느리고 불안정한 공공 API 위에 서 있다. 화면 하나가 외부 API 여러 개를 모으므로, 외부 장애가 곧 서비스 장애가 되기 쉽다. 실제로 겪었다 — 관광 API 일일 한도가 소진되자 코스 생성이 통째로 멈췄다.
그래서 바뀌지 않는 데이터는 미리 DB에 넣는다.
| 방식 | 규모 | |
|---|---|---|
| 숙소·맛집·카페·관광명소 | 지방행정인허가 데이터를 빌드 타임에 가공 → 부팅 시 적재 | 122,085건 |
| 89개 지역 마스터 | 마이그레이션 seed | 89곳 |
| 공휴일·관광·날씨·교통 | 런타임 호출 + 캐시 | 11개 클라이언트 |
효과가 분명하다. 관광 API가 죽어 있어도 코스가 나온다.
관광 API가 넉넉한 지역은 그쪽을 먼저 쓰고(사진·소개가 붙는다), 모자란 자리만 인허가 데이터로 채운다. LIC- 접두어로 출처가 구분되고, 상세 조회도 백엔드가 알아서 처리한다.
- 요청 경로에서 외부 I/O를 뺀다. 캐시 TTL은 데이터 특성에서 도출한다(월간 발행 → 6시간, 실시간 → 수십 초).
- 팬아웃은 병렬. 후보 N개에 외부 호출을 붙이는 순차 루프는 지연이 N배로 곱해진다.
- timeout은 p99에서 정한다. 호출 하나의 상한과 작업 전체의 deadline은 별개로 둔다.
- 빈 응답을 성공으로 캐시하지 않는다. 실패와 결과가 같은데 성공 TTL로 누르면 무의미한 상태가 그만큼 굳는다.
근거와 실측치는 ADR 0001.
package-by-feature. 도메인이 뚜렷하게 나뉘고 외부 API 연동을 격리한다.
com.offway.core.<domain>/
├── controller/ HTTP 진입 · <D>Api(OpenAPI 인터페이스) · dto(API 계약)
├── service/ 유스케이스 조율 · @Transactional 경계 · dto(내부 command/result)
├── domain/ 엔티티 · 값객체 · enum · 예외 ← 프레임워크 의존 최소
├── repository/ port(interface) + adapter(Impl → JpaRepository)
└── infrastructure/ 외부 API port + adapter
의존 방향은 controller → service → domain이고, repository와 infrastructure가 domain의 port를 구현한다.
| 도메인 | 책임 | 소유 외부 API |
|---|---|---|
leave |
연차·가용시간(LNT)·샌드위치 연휴 | 특일정보 |
trip |
인구감소지역·관광지 추천 · 장소 풀 | TourAPI · 관광빅데이터 |
transport |
교통·동선 | TMAP · TAGO(버스·열차) |
weather |
날씨 | 기상청 단기/중기 · 관광기후지수 |
itinerary |
코스 생성(trip+transport 조합) | — |
region |
인구감소지역 89곳 마스터·태그 | — |
policy |
7대 혜택 매칭 | — |
user |
사용자·인증 | OAuth (후순위) |
외부 API는 소유 도메인의 infrastructure/에 port+adapter로 둔다. 다른 도메인이 필요하면 소유 도메인의 service를 거친다 — 직접 호출하지 않는다.
도메인 간 참조는 raw ID + 서비스 조회다. JPA 연관관계는 애그리거트 내부에만 쓴다(Itinerary ↔ ItineraryItem 같은 것). 대부분의 참조 대상이 외부 API에서 온 레퍼런스 데이터라 온전한 엔티티가 아니기 때문이다.
모든 응답이 같은 래퍼를 쓴다. 컨트롤러는 ApiResponseBody<T>를 반환하고 raw DTO를 노출하지 않는다.
{
"status": 200,
"data": { ... }, // 실패 시 null
"detail": "요청이 정상 처리되었습니다.",
"code": "OK", // 실패 시 도메인 코드 (LEAVE-001, TOUR-003 ...)
"pageResponse": null // 목록 응답에만
}문구가 아니라 code(예: TRIP-001)로 사유를 가른다. 도메인별 *ErrorCode enum이 code·category·message를 single source로 갖고, HTTP status는 ErrorCategory에서 파생한다 — 예외가 status를 직접 들지 않는다.
번호는 append-only다. 재사용·재배치하지 않고 결번을 유지한다(코드가 클라이언트 계약이므로).
인허가 데이터로 채운 슬롯은 사진·소개가 없어 매번 null이 실렸다. 코스 응답에서 그런 필드를 뺐다 — 응답이 16% 가벼워졌다(3,077자 → 2,572자, null 필드 26개 → 0개).
다만 래퍼의 data·pageResponse는 null로 나가는 것이 계약이라 전역 설정으로 켜지 않는다.
- HTTP 204를 쓰지 않는다. 래퍼가 항상 body를 만들므로 "body 없음"이 본질인 204와 충돌한다. 내릴 데이터가 없으면 200 +
data: null. - 프레임워크 예외(깨진 JSON·잘못된 메서드)는
ResponseEntityExceptionHandler를 상속해 Spring이 판정한 4xx를 그대로 쓴다. 500으로 덮으면 클라이언트 실수가 서버 오류로 둔갑한다.
세부는 예외·응답 규약.
모든 엔드포인트가 HTTP Basic 인증을 요구한다. 외부에 열린 포트로 아무나 우리 외부 API 키를 태우지 못하게 막는 임시 게이트다 — TMAP 경유지 최적화는 하루 50건이라 봇 한 마리로 고갈된다.
Authorization: Basic base64(아이디:비밀번호)
인증 실패는 401 + 공통 래퍼(code: COMMON-401). 소셜 로그인이 붙으면 이 게이트는 걷어낸다.
현재 배포는 평문 HTTP다. Basic 인증은 자격증명을 Base64로 인코딩만 하므로(암호화가 아니다) 같은 네트워크 경로에서 읽힌다. 팀 내부 테스트용이라 감수하고 있으나, 운영 데이터를 넣기 전에 HTTPS 종단이 선행돼야 한다.
main으로 가는 길목에 네 가지가 걸려 있다.
| 무엇 | |
|---|---|
| build & test | 컴파일 + 전체 테스트 (Testcontainers MySQL) |
| 컨벤션 검사 | FK 제약·FetchType.EAGER·@MockBean·domain setter 등을 소스 전수로 차단 + PR 커밋 메시지 형식 |
| MySQL 마이그레이션 검증 | 실제 MySQL 8.4에 Flyway 20개를 적용해 본다. H2로만 돌면 ALTER COLUMN vs MODIFY 같은 방언 차이를 배포 중에야 안다 |
| CodeRabbit | 코드 리뷰 |
배포는 dev 머지 시 자동으로 돈다. 기동 확인 → 스모크 → 실패 시 이전 이미지로 롤백까지 워크플로우가 처리한다. 스모크는 상태 코드만 보지 않고 적재 건수까지 확인한다 — "200인데 내용이 빈" 배포를 성공으로 넘기지 않기 위해서다.
scripts/prod-logs.sh # 최근 200줄 + 계속 따라가기
scripts/prod-logs.sh --level WARN # WARN 이상만
scripts/prod-logs.sh --trace a1b2c3 # 요청 하나만배포가 성공이고 API가 200인데 화면만 이상한 경우가 있다 — 적재 배치가 조용히 실패했을 때다. 그때 답은 서버 로그의 완료 한 줄에 있다. traceId는 값에서 색을 뽑으므로, 한 요청이 남긴 로그가 흩어져 있어도 색으로 이어 볼 수 있다.
접속하려면 보안그룹에 자기 IP가 22번으로 열려 있어야 한다. 배포 워크플로우는 러너 IP만 잠깐 열고 회수하므로 평소엔 닫혀 있는 것이 정상이다.
이 프로젝트는 규약을 문서에 적고 기계로 강제한다.
- 매직 값 금지(상수·enum), rich domain, DIP, 다형성 — CLAUDE.md
- 판단이 필요 없는 것(FK·EAGER·204·
@MockBean등)은 훅과 CI가 차단한다 - 판단이 필요한 것(순차 팬아웃·캐시 키 상한·timeout 근거)은 PR self-audit이 담당한다
| 문서 | 내용 |
|---|---|
| CLAUDE.md | 개발 규약 (아키텍처·객체지향 스타일·성능·용어집) |
| .claude/rules/ | 주제별 세부 규약 (예외/응답 · 영속성 · API · 테스트) |
| docs/adr/ | 설계 결정 기록 |
| docs/external-api-inventory.md | 외부 공공데이터 인벤토리·발급 체크리스트 |
Spring Boot 4.1 · Java 25 · Lombok · JPA · Flyway · MySQL · Redis · Spring Security · WebFlux(WebClient)
버전의 단일 진실 원천은
build.gradle.kts.
로컬은 도커로 띄운 MySQL을 쓴다(docker compose up -d). 운영과 같은 DB로 돌려야 마이그레이션 문법 오류를 로컬에서 잡을 수 있기 때문이다 — H2 시절엔 로컬이 전부 초록인데 MySQL에서 깨지는 일이 실제로 있었다.
외부 API 키는 없어도 부팅된다(실제 호출만 비활성). 이건 규약이다 — 부팅에 실 키를 강제하는 변경은 금지한다. CI 스모크가 빨간불이 되기 때문이다.