1. 개요
인구감소 소도시 여행 코스 서비스 TripPick의 백엔드 API 문서입니다.
이 문서의 요청/응답 예시는 테스트 실행 중 실제로 오간 것을 그대로 담습니다. 코드가 바뀌면 문서도 같이 바뀌고, 문서와 코드가 어긋나면 테스트가 실패합니다.
1.1. 기본 정보
| 항목 | 값 |
|---|---|
개발계 서버 |
|
로컬 서버 |
|
요청/응답 형식 |
|
인증 |
카카오 로그인으로 받은 토큰을 |
1.2. 오류 응답 형식
모든 오류는 상태 코드와 무관하게 아래 한 가지 형태로 옵니다.
{
"code": "REGION_AMBIGUOUS",
"message": "'고성군'는 여러 시도에 있습니다. province를 함께 지정해 주세요.",
"details": ["강원특별자치도 고성군", "경상남도 고성군"]
}
|
분기는 |
`details`는 보조 정보입니다. 대부분 비어 있고, 지역명이 모호할 때처럼 고를 수 있는 후보가 있으면 채워집니다.
| 상태 코드 | code |
의미 |
|---|---|---|
|
|
`generate-by-name`에 `regionName`을 보내지 않음 |
|
|
|
|
|
선택한 지형으로 분류된 소도시가 없음. 분류 시드( |
|
|
지원하지 않는 지역. 없는 `regionId`거나, 소도시 목록에 없는 이름이거나 `province`와 맞지 않음 |
|
|
같은 이름의 시군구가 여러 시도에 있음. `details`의 후보를 보여주고 `province`를 받아 다시 요청 |
|
|
지원하지 않는 테마·지형이거나 여행 일수가 범위(1~3)를 벗어남 |
|
|
로그인 요청 값이 잘못됨. 인가코드( |
|
|
본문이 없거나 JSON이 깨짐 |
|
|
그 밖의 잘못된 요청 |
|
|
토큰이 없거나, 만료됐거나, 신뢰할 수 없음. 카카오 로그인부터 다시 |
|
|
인가코드가 무효·만료됐거나 이미 사용됨. 카카오 로그인부터 다시 |
|
|
없는 경로 |
|
|
지원하지 않는 HTTP 메서드 |
|
|
지원하지 않는 Content-Type |
|
|
서버 오류 |
|
|
TourAPI 장애로 관광 정보를 가져오지 못함. 재시도 안내가 적절함 |
|
|
카카오 서버 장애·타임아웃으로 로그인을 마치지 못함. 재시도 안내가 적절함 |
2. 인증
카카오 로그인으로 우리 서비스 토큰을 받고, 이후 요청에 그 토큰을 실어 보냅니다.
2.1. 로그인 흐름
프론트는 카카오 인가코드까지만 받고, 토큰 교환부터는 백엔드가 처리합니다. 카카오 REST API 키와 시크릿이 프론트에 노출되지 않도록 하기 위해서입니다.
-
프론트: 카카오 로그인 화면으로 이동, 사용자가 로그인하고 동의
-
카카오:
redirect_uri로?code=…를 붙여 프론트로 돌려보냄 -
프론트: 그
code를 카카오 로그인 API로 전달 -
백엔드: 카카오와 토큰을 교환하고 사용자 정보를 조회해 로그인 또는 회원가입
-
백엔드: 우리 서비스 access token 발급
|
인가코드는 일회용입니다. 같은 |
닉네임은 카카오 선택 동의 항목이라 사용자가 거부하면 내려오지 않습니다.
이 경우 백엔드가 사용자 + 임의의 숫자 6자리로 기본 닉네임을 만들어 가입시킵니다.
닉네임은 가입할 때만 저장되므로, 이미 가입한 사용자가 나중에 동의해도 기존 닉네임이 유지됩니다.
2.2. 카카오 로그인
인가코드를 우리 서비스 토큰으로 바꿉니다. 가입 여부는 백엔드가 판단하므로 로그인과 회원가입을 프론트에서 구분할 필요가 없습니다. 처음 오는 사용자면 자동으로 가입됩니다.
2.2.1. 요청
POST /api/v1/auth/kakao/login HTTP/1.1
Content-Type: application/json
Content-Length: 57
Host: trippick.kro.kr
{
"code" : "rqg8rCME_1nE__HG65-ARfKBAnuhIhmOITUfDBrp"
}
| Path | Type | Description |
|---|---|---|
|
|
카카오 인가코드. 카카오 로그인 후 redirect_uri로 돌아올 때 쿼리스트링으로 받은 code를 그대로 넣는다. 일회용이라 재사용하면 401이 온다. 토큰 교환에 쓰는 redirect_uri는 서버 설정값이므로 인가코드를 받을 때 쓴 값과 정확히 같아야 한다 |
$ curl 'https://trippick.kro.kr/api/v1/auth/kakao/login' -i -X POST \
-H 'Content-Type: application/json' \
-d '{
"code" : "rqg8rCME_1nE__HG65-ARfKBAnuhIhmOITUfDBrp"
}'
2.2.2. 응답
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 253
{
"accessToken" : "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxNTdmYjhkZi04MDE2LTQxM2UtYmNiNC0yZjJhODgzNGRjYzYiLCJpYXQiOjE3ODU2ODQwMDAsImV4cCI6MTc4NTY5MTIwMH0.Zm9yLWRvY3VtZW50YXRpb24tb25seS1zaWduYXR1cmUtc2FtcGxl",
"tokenType" : "Bearer",
"expiresIn" : 7200
}
| Path | Type | Description |
|---|---|---|
|
|
우리 서비스 access token. 이후 요청의 Authorization 헤더에 실어 보낸다 |
|
|
항상 Bearer. Authorization 헤더에 붙일 접두사다. |
|
|
만료까지 남은 시간(초). 현재 7200(2시간). 리프레시 토큰이 없으므로 만료되면 카카오 로그인부터 다시 해야 한다 |
2.3. 토큰 사용
발급받은 accessToken 을 이후 요청의 Authorization 헤더에 넣습니다.
GET /api/v1/some-protected-api HTTP/1.1
Host: trippick.kro.kr
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxNTdmYjhkZi04...
|
리프레시 토큰이 없습니다. 토큰이 만료되면(발급 후 2시간) 카카오 로그인부터 다시 해야 합니다.
응답의 |
토큰이 없거나, 만료됐거나, 서명이 맞지 않으면 모두 401 AUTH_REQUIRED 로 응답합니다.
원인을 구분해 알려주지 않습니다 — 공격자에게 힌트가 되기 때문입니다.
{
"code": "AUTH_REQUIRED",
"message": "로그인이 필요합니다.",
"details": []
}
2.3.1. 인증이 필요 없는 경로
아래는 토큰 없이 호출할 수 있습니다. 그 밖의 모든 경로는 토큰이 필요합니다.
| 경로 | 비고 |
|---|---|
|
로그인. 토큰을 받기 전 단계라 열려 있음 |
|
|
|
코스 생성. 아래 안내 참고 |
|
헬스체크 |
|
코스 API는 한시적으로만 공개입니다. 로그인이 방금 추가되어 프론트가 아직 토큰을 붙이지 않았기 때문에, 지금 잠그면 코스 기능이 곧바로 멈춥니다. 그래서 연동 기간 동안만 열어 둔 상태입니다. 로그인 연동이 끝나면 코스 API도 토큰 필수로 전환합니다. 코스를 호출하는 코드에도
|
2.4. 로그인 실패
인가코드에 문제가 있으면 401, 카카오 쪽 장애면 502 로 구분해 응답합니다.
프론트 대응이 정반대이므로 반드시 나눠서 처리하세요 — 401 은 재로그인, 502 는 잠시 후 재시도입니다.
2.4.1. 인가코드가 무효한 경우
HTTP/1.1 401 Unauthorized
Content-Type: application/json
Content-Length: 141
{
"code" : "KAKAO_AUTH_FAILED",
"message" : "카카오 인증에 실패했습니다. 다시 로그인해 주세요.",
"details" : [ ]
}
| Path | Type | Description |
|---|---|---|
|
|
오류 식별자. 분기는 메시지가 아니라 이 값으로 한다. 목록은 오류 응답 형식 참고 |
|
|
사용자에게 보여줄 설명. 문구는 바뀔 수 있다 |
|
|
보조 정보. 이 오류에서는 비어 있다 |
2.4.2. 카카오 장애
카카오 서버 오류, 타임아웃, 예상치 못한 응답 형식이 모두 여기에 해당합니다. 사용자 잘못이 아니므로 재로그인을 유도하지 마세요.
HTTP/1.1 502 Bad Gateway
Content-Type: application/json
Content-Length: 156
{
"code" : "KAKAO_UNAVAILABLE",
"message" : "카카오 서버가 응답하지 않습니다. 잠시 후 다시 시도해 주세요.",
"details" : [ ]
}
3. 지역
3.1. 소도시 목록 조회
코스를 만들 수 있는 소도시 목록입니다. 지역 선택 화면을 채울 때 사용합니다. 여기서 받은 `name`과 `province`를 그대로 코스 생성 요청에 넣으면 됩니다.
3.1.1. 요청
GET /api/v1/regions/small-cities HTTP/1.1
Host: trippick.kro.kr
3.1.2. 응답
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 387
[ {
"id" : "8b1f0d6e-3c7a-4a1e-9a2b-6f0c5d4e3a21",
"name" : "평창군",
"province" : "강원특별자치도",
"population" : 39897,
"smallCity" : true,
"populationDeclineArea" : true
}, {
"id" : "2c9a7b41-5d3e-4c8f-b0a6-1e2d3c4b5a60",
"name" : "단양군",
"province" : "충청북도",
"population" : 26628,
"smallCity" : true,
"populationDeclineArea" : true
} ]
| Path | Type | Description |
|---|---|---|
|
|
지역 식별자 |
|
|
시군구명. 코스 생성 요청의 regionName에 그대로 넣으면 된다 |
|
|
시도명. 같은 이름의 시군구가 있을 때 코스 생성 요청의 province에 넣는다 |
|
|
주민등록인구 |
|
|
소도시 여부. 이 목록은 항상 true |
|
|
인구감소지역 여부 |
4. 코스
4.1. 코스 생성
지역·테마·지형·기간을 받아 Day별 일정을 만들어 돌려줍니다. 호출할 때마다 한국관광공사 TourAPI를 실시간으로 조회하므로 응답에 1~2초가 걸립니다.
-
`themes`는 고른 순서가 가중치입니다. `["미식", "힐링"]`과 `["힐링", "미식"]`은 다른 코스가 나옵니다.
-
테마를 고르지 않아도 코스는 나옵니다. 테마는 후보의 *순위*를 정할 뿐, 명소·식사·숙소는 항상 확보합니다.
-
`themes`와 `terrains`는 작동 방식이 다릅니다. 테마는 순위를 바꾸고(예: '미식’은 카페보다 한식당을 위로), 지형은 관광지 후보를 걸러냅니다('바다’면 해수욕장·해안절경 등만 남김).
-
지형에 맞는 곳이 부족하면 다른 장소로 채우고 `warnings`로 이유를 알립니다. 아예 없으면 지형 조건을 무시하고 평범한 코스를 만듭니다 — 내륙 소도시에서 '바다’를 골라도 빈 코스가 나오지 않습니다.
-
테마가 하루 구성을 바꿉니다. 힐링은 간격이 넓어 체류시간이 길고(5자리), 액티비티는 명소가 더 많고(7자리), 미식은 카페 자리가 추가됩니다(6자리). 로컬·미지정은 기본 5자리입니다.
-
가볼 만한 곳이 넉넉하지 않은 소도시에서는 자리를 줄여서 내보내고 `warnings`로 알립니다.
-
마지막 날에는 숙소가 들어가지 않습니다(귀가).
-
자리 분기는
seatId`로 하세요.`slot`은 화면에 보여줄 이름이라 문구가 바뀔 수 있습니다. `STAY(숙소)·CAFE(카페)처럼 특정 자리를 골라내야 하면 `seatId`를 쓰세요. 오류 응답의 `code`와 같은 원칙입니다. -
숙소는 여행 내내 같은 곳입니다. 2박이면 1일차와 2일차
숙소항목의 `contentId`가 같습니다. 숙소를 뺀 나머지 장소는 코스에 두 번 나오지 않습니다. -
코스는 그 소도시 안에서의 일정만 담습니다. 출발지에서 가는 길, 마지막 날 돌아오는 길은 포함되지 않습니다. 그날 첫 항목의 `travelMinutesFromPrevious`가 `null`인 것도 같은 이유입니다(직전 장소가 없음).
-
좌표가 없는 장소는 코스에 넣지 않습니다.
-
시간과 이동 시간은 실측이 아니라 고정 예측치입니다. 동선 최적화는 후속 작업입니다.
-
코스가 만들어져도 `warnings`가 채워질 수 있습니다. 오류가 아니라 참고 사항이니 화면에 노출할지 판단해 주세요.
-
지역을 지정하는 방법이 셋입니다. 이 API는 그중 둘을 받습니다.
regionId소도시 목록 API 응답의
id. 목록·자동완성에서 고른 경우 이걸 보내세요`terrains`만
산/바다 버튼만 누른 경우. 서버가 해당 지형의 소도시 중 하나를 고릅니다
사용자가 지역명을 직접 타이핑하는 화면이면 지역명으로 코스 생성을 쓰세요.
regionId`를 보내면 `terrains`는 후보 필터로만 쓰입니다. 비우면 지역 선정에도 쓰이고, 고른 뒤에도 같은 필터를 겁니다. 매핑은 `regions.is_mountain/`is_sea`이며 TourAPI 자연관광지 분포로 산출했습니다. -
버튼으로 고른 지역은 매번 달라질 수 있습니다. 후보 중 무작위로 하나를 뽑기 때문에 같은 요청이라도 결과가 다릅니다. 어떤 지역이 뽑혔는지는 응답의 `region`을 보세요.
4.1.1. 요청
POST /api/v1/courses/generate HTTP/1.1
Content-Type: application/json
Content-Length: 134
Host: trippick.kro.kr
{
"regionId" : "3f2a1c40-0000-0000-0000-000000000015",
"themes" : [ "힐링", "미식" ],
"terrains" : [ "산" ],
"days" : 2
}
| Path | Type | Description |
|---|---|---|
|
|
지역 식별자. 소도시 목록 API 응답의 id를 그대로 넣는다. 생략하면 terrains(산/바다)에 매핑된 소도시 중에서 서버가 하나를 고른다. regionId와 terrains가 모두 비면 REGION_INPUT_REQUIRED |
|
|
여행 테마. 미식 | 힐링 | 액티비티 | 로컬 (영문 GOURMET | HEALING | ACTIVITY | LOCAL도 받는다). 고른 순서가 가중치다 — 먼저 고를수록 반영이 커진다 |
|
|
지형. 산 | 바다 (영문 MOUNTAIN | SEA도 받는다). 역할이 둘이다. ① 테마와 달리 관광지 후보를 걸러낸다 — 부족하면 다른 장소로 채우고 warnings로 알린다. ② regionId가 비었을 때 지역 선정에도 쓰인다 — 그 지형의 소도시 중 하나를 고른다 |
|
|
여행 일수. 1~3. 생략하면 1 |
$ curl 'https://trippick.kro.kr/api/v1/courses/generate' -i -X POST \
-H 'Content-Type: application/json' \
-d '{
"regionId" : "3f2a1c40-0000-0000-0000-000000000015",
"themes" : [ "힐링", "미식" ],
"terrains" : [ "산" ],
"days" : 2
}'
4.1.2. 응답
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 1958
{
"region" : {
"name" : "평창군",
"province" : "강원특별자치도",
"areaCode" : "32",
"sigunguCode" : "15",
"smallCity" : true,
"populationDeclineArea" : true
},
"themes" : [ "힐링", "미식" ],
"terrains" : [ "산" ],
"days" : 2,
"plan" : [ {
"day" : 1,
"items" : [ {
"order" : 1,
"seatId" : "MORNING_SPOT",
"slot" : "오전 일정",
"startTime" : "09:00",
"travelMinutesFromPrevious" : null,
"contentId" : "125591",
"title" : "오대산국립공원",
"contentTypeId" : 12,
"contentType" : "TOURIST_SPOT",
"address" : "강원특별자치도 평창군 진부면 오대산로 2",
"lat" : 37.7029269542,
"lng" : 128.6021975495,
"imageUrl" : "http://tong.visitkorea.or.kr/cms/resource/82/3549482_image2_1.jpg",
"tel" : null
}, {
"order" : 2,
"seatId" : "LUNCH",
"slot" : "점심",
"startTime" : "12:00",
"travelMinutesFromPrevious" : 22,
"contentId" : "2721447",
"title" : "청산회관",
"contentTypeId" : 39,
"contentType" : "RESTAURANT",
"address" : "강원특별자치도 평창군 진부면 진부중앙로 51",
"lat" : 37.644,
"lng" : 128.554,
"imageUrl" : "http://tong.visitkorea.or.kr/cms/resource/82/3549482_image2_1.jpg",
"tel" : null
} ]
}, {
"day" : 2,
"items" : [ {
"order" : 1,
"seatId" : "MORNING_SPOT",
"slot" : "오전 일정",
"startTime" : "09:00",
"travelMinutesFromPrevious" : null,
"contentId" : "2645678",
"title" : "장전계곡",
"contentTypeId" : 12,
"contentType" : "TOURIST_SPOT",
"address" : "강원특별자치도 평창군 진부면 장전리",
"lat" : 37.69,
"lng" : 128.53,
"imageUrl" : "http://tong.visitkorea.or.kr/cms/resource/82/3549482_image2_1.jpg",
"tel" : null
} ]
} ],
"warnings" : [ ]
}
| Path | Type | Description |
|---|---|---|
|
|
시군구명 |
|
|
시도명 |
|
|
TourAPI 시도코드 |
|
|
TourAPI 시군구코드 |
|
|
소도시 여부 |
|
|
인구감소지역 여부 |
|
|
해석된 테마 목록 |
|
|
해석된 지형 목록 |
|
|
여행 일수 |
|
|
며칠째인지. 1부터 |
|
|
그날 안에서의 순서. 1부터 |
|
|
자리 식별자. 화면 분기는 표시 문자열이 아니라 이 값으로 한다. MORNING_SPOT | MORNING_SPOT_2 | LUNCH | CAFE | AFTERNOON_SPOT | AFTERNOON_SPOT_2 | DINNER | STAY |
|
|
일정 자리 이름. 고른 테마에 따라 구성이 달라진다(예: 액티비티는 '오전 일정 2’가 추가, 미식은 '카페’가 추가). 숙소는 여행 내내 같은 곳이라 날마다 반복된다 |
|
|
시작 시각(HH:mm). 고정 예측치 |
|
|
직전 장소에서의 이동 시간(분). 직선거리 기반 예측치이며 그날 첫 항목은 null |
|
|
TourAPI 콘텐츠 ID |
|
|
장소명 |
|
|
TourAPI 콘텐츠 타입 ID. 12 관광지 / 14 문화시설 / 28 레포츠 / 32 숙박 / 39 음식점 |
|
|
콘텐츠 타입 이름 |
|
|
주소 |
|
|
위도. 좌표 없는 장소는 코스에 넣지 않는다 |
|
|
경도 |
|
|
대표 이미지 URL |
|
|
전화번호. 없으면 null |
|
|
코스는 만들어졌지만 알아야 할 사항. 후보 부족으로 인근 지역까지 넓혀 조회했거나 채우지 못한 자리가 있을 때 채워진다 |
4.2. 지역명으로 코스 생성
사용자가 직접 타이핑한 지역명을 받는 경로입니다.
|
현재는 이름이 정확히 일치해야 합니다. |
같은 이름의 시군구가 여러 시도에 있으면(예: 고성군) REGION_AMBIGUOUS`로 후보를 돌려줍니다.
`province`를 함께 받아 다시 요청하세요. `regionId 경로에는 이 문제가 없습니다.
이 경로에서는 terrains`가 후보 필터로만 쓰입니다 — 지역은 이름으로 이미 정해졌습니다.
`themes·`days`의 의미와 응답 형태는 코스 생성과 같습니다.
4.2.1. 요청
POST /api/v1/courses/generate-by-name HTTP/1.1
Content-Type: application/json
Content-Length: 149
Host: trippick.kro.kr
{
"regionName" : "평창군",
"province" : "강원특별자치도",
"themes" : [ "힐링", "미식" ],
"terrains" : [ "산" ],
"days" : 2
}
| Path | Type | Description |
|---|---|---|
|
|
사용자가 입력한 시군구명. 현재는 정확히 일치해야 한다 — '평창’은 찾지 못하고 '평창군’이어야 한다 |
|
|
시도명. 같은 이름의 시군구가 여러 시도에 있을 때 필요하다(예: 고성군). 유일하면 생략 가능 |
|
|
generate와 동일 |
|
|
generate와 동일하나 후보 필터로만 쓰인다 — 지역은 regionName으로 이미 정해졌다 |
|
|
generate와 동일 |
4.3. 지역코드로 코스 생성
지역명 대신 TourAPI 지역코드를 직접 주는 경로입니다. regions 테이블을 거치지 않습니다.
|
일반적인 화면 흐름에서는 코스 생성을 쓰세요. 이 경로는 지역 해석 없이 TourAPI 조회·조립만 확인하고 싶을 때, 또는 지역코드를 이미 알고 있을 때 씁니다. 개발·디버깅용이라 `@Profile("!prod")`로 막혀 있어 운영에서는 열리지 않습니다.
|
themes·terrains·`days`의 의미와 응답 형태는 코스 생성과 완전히 같습니다.
4.3.1. 요청
POST /api/v1/courses/generate-by-code HTTP/1.1
Content-Type: application/json
Content-Length: 114
Host: trippick.kro.kr
{
"areaCode" : "32",
"sigunguCode" : "15",
"themes" : [ "힐링" ],
"terrains" : [ "산" ],
"days" : 1
}
| Path | Type | Description |
|---|---|---|
|
|
TourAPI 시도코드. 예: 32(강원) |
|
|
TourAPI 시군구코드. 생략하면 시도 전체에서 찾는다 |
|
|
코스 생성과 동일 |
|
|
코스 생성과 동일 |
|
|
코스 생성과 동일 |
4.4. 지역명이 모호한 경우
같은 이름의 시군구가 여러 시도에 있으면(예: 고성군 — 강원특별자치도·경상남도) `400`과 함께 후보를 돌려줍니다. 사용자에게 후보를 보여주고 고르게 한 뒤 `province`를 채워 다시 요청하세요.
HTTP/1.1 400 Bad Request
Content-Type: application/json
Content-Length: 215
{
"code" : "REGION_AMBIGUOUS",
"message" : "'고성군'는 여러 시도에 있습니다. province를 함께 지정해 주세요.",
"details" : [ "강원특별자치도 고성군", "경상남도 고성군" ]
}
| Path | Type | Description |
|---|---|---|
|
|
오류 식별자. 분기는 메시지가 아니라 이 값으로 한다. 목록은 오류 응답 형식 참고 |
|
|
사용자에게 보여줄 설명. 문구는 바뀔 수 있다 |
|
|
보조 정보. 지역명이 모호하면 고를 수 있는 후보가 들어온다 |