Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,24 +1,29 @@
package com.back.koreaTravelGuide.domain.ai.tour.client

import com.back.koreaTravelGuide.domain.ai.tour.dto.InternalData
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourItem
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourResponse
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourSearchParams
import com.fasterxml.jackson.databind.ObjectMapper
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import org.springframework.web.client.RestTemplate
import org.springframework.web.util.UriComponentsBuilder
import java.net.URI

// 09.25 양현준
// 09.26 양현준
@Component
class TourApiClient(
private val restTemplate: RestTemplate,
private val objectMapper: ObjectMapper,
@Value("\${tour.api.key}") private val serviceKey: String,
@Value("\${tour.api.base-url}") private val apiUrl: String,
) {
// println 대신 SLF4J 로거 사용
private val logger = LoggerFactory.getLogger(TourApiClient::class.java)

// 요청 URL 구성
private fun buildUrl(params: InternalData): URI =
private fun buildUrl(params: TourSearchParams): URI =
UriComponentsBuilder.fromUri(URI.create(apiUrl))
.path("/areaBasedList2")
.queryParam("serviceKey", serviceKey)
Expand All @@ -35,49 +40,78 @@ class TourApiClient(
.toUri()

// 지역 기반 관광 정보 조회 (areaBasedList2)
fun fetchTourInfo(params: InternalData): TourResponse? {
println("URL 생성")
val url = buildUrl(params)
fun fetchTourInfo(params: TourSearchParams): TourResponse {
logger.info("지역 기반 관광 정보 조회 시작")

println("관광 정보 조회 API 호출: $url")
val url = buildUrl(params)
logger.info("Tour API URL 생성 : $url")

return try {
val jsonResponse = restTemplate.getForObject(url, String::class.java)
println("관광 정보 응답 길이: ${jsonResponse?.length ?: 0}")
/*
* runCatching: 예외를 Result로 감싸 예외를 던지지 않고 처리하는 유틸리티 함수
* getOrNull(): 성공 시 응답 문자열을, 실패 시 null 반환
* takeUnless { it.isNullOrBlank() }: 공백 응답을 걸러냄
* ?.let { parseItems(it) } ?: emptyList(): 유효한 응답은 파싱, 아니면 빈 리스트 반환
*/
val response =
runCatching { restTemplate.getForObject(url, String::class.java) }
.onFailure { logger.error("관광 정보 조회 실패", it) }
.getOrNull()
.takeUnless { it.isNullOrBlank() }
?.let { parseItems(it) }

if (jsonResponse.isNullOrBlank()) return null // HTTP 호출 결과가 null이거나 공백 문자열일 때
return response ?: TourResponse(items = emptyList())
}

val root = objectMapper.readTree(jsonResponse) // 문자열을 Jackson 트리 구조(JsonNode)로 변환
val itemsNode =
root // path("키") 형태로 노드를 탐색, 응답 Json 형태의 순서에 따라 순차적으로 내려감
.path("response")
.path("body")
.path("items")
.path("item")
private fun parseItems(json: String): TourResponse {
val root = objectMapper.readTree(json)

if (!itemsNode.isArray || itemsNode.isEmpty) return null // 탐색 결과가 비어 있는 경우
// header.resultCode 값 추출위한 노스 탐색 과정
val resultCode =
root
.path("response")
.path("header")
.path("resultCode")
.asText()

val firstItem = itemsNode.first()
TourResponse(
contentId = firstItem.path("contentid").asText(),
contentTypeId = firstItem.path("contenttypeid").asText(),
createdTime = firstItem.path("createdtime").asText(),
modifiedTime = firstItem.path("modifiedtime").asText(),
title = firstItem.path("title").asText(),
addr1 = firstItem.path("addr1").takeIf { it.isTextual }?.asText(),
areaCode = firstItem.path("areacode").takeIf { it.isTextual }?.asText(),
firstimage = firstItem.path("firstimage").takeIf { it.isTextual }?.asText(),
firstimage2 = firstItem.path("firstimage2").takeIf { it.isTextual }?.asText(),
mapX = firstItem.path("mapx").takeIf { it.isTextual }?.asText(),
mapY = firstItem.path("mapy").takeIf { it.isTextual }?.asText(),
mlevel = firstItem.path("mlevel").takeIf { it.isTextual }?.asText(),
sigunguCode = firstItem.path("sigungucode").takeIf { it.isTextual }?.asText(),
lDongRegnCd = firstItem.path("lDongRegnCd").takeIf { it.isTextual }?.asText(),
lDongSignguCd = firstItem.path("lDongSignguCd").takeIf { it.isTextual }?.asText(),
)
} catch (e: Exception) {
println("관광 정보 조회 오류: ${e.message}")
null
// resultCode가 "0000"이 아닌 경우 체크
if (resultCode != "0000") {
logger.warn("관광 정보 API resultCode={}", resultCode)
return TourResponse(items = emptyList())
}

// path("키") 형태로 노드를 탐색, 응답 Json 형태의 순서에 따라 순차적으로 내려감
val itemsNode =
root
.path("response")
.path("body")
.path("items")
.path("item")

// 탐색 결과가 비어 있는 경우
if (!itemsNode.isArray || itemsNode.isEmpty) return TourResponse(items = emptyList())

// itemsNode가 배열이므로 map으로 각 노드를 TourItem으로 변환 후 컨테이너로 감싼다.
val items =
itemsNode.map { node ->
TourItem(
contentId = node.path("contentid").asText(),
contentTypeId = node.path("contenttypeid").asText(),
createdTime = node.path("createdtime").asText(),
modifiedTime = node.path("modifiedtime").asText(),
title = node.path("title").asText(),
addr1 = node.path("addr1").takeIf { it.isTextual }?.asText(),
areaCode = node.path("areacode").takeIf { it.isTextual }?.asText(),
firstimage = node.path("firstimage").takeIf { it.isTextual }?.asText(),
firstimage2 = node.path("firstimage2").takeIf { it.isTextual }?.asText(),
mapX = node.path("mapx").takeIf { it.isTextual }?.asText(),
mapY = node.path("mapy").takeIf { it.isTextual }?.asText(),
mlevel = node.path("mlevel").takeIf { it.isTextual }?.asText(),
sigunguCode = node.path("sigungucode").takeIf { it.isTextual }?.asText(),
lDongRegnCd = node.path("lDongRegnCd").takeIf { it.isTextual }?.asText(),
lDongSignguCd = node.path("lDongSignguCd").takeIf { it.isTextual }?.asText(),
)
}

return TourResponse(items = items)
}
}
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
package com.back.koreaTravelGuide.domain.ai.tour.dto

/**
* 9.25 양현준
* 9.27 양현준
* 관광 정보 응답 DTO
* API 매뉴얼에서 필수인 값은 NonNull로 지정.
*/

data class TourResponse(
// 콘텐츠ID (고유 번호)
val items: List<TourItem>,
)

// 관광 정보 단일 아이템
data class TourItem(
// 콘텐츠ID (고유 번호, NonNull)
val contentId: String,
// 관광타입 ID (12: 관광지, 14: 문화시설 ..)
// 관광타입 ID (12: 관광지, NonNull)
val contentTypeId: String,
// 등록일
// 등록일 (NonNull)
val createdTime: String,
// 수정일
// 수정일 (NonNull)
val modifiedTime: String,
// 제목
// 제목 (NonNull)
val title: String,
// 주소
val addr1: String?,
Expand All @@ -32,7 +38,7 @@ data class TourResponse(
val mlevel: String?,
// 시군구코드
val sigunguCode: String?,
// 법정동 시도 코드, 응답 코드가 IDongRegnCd 이므로,
// 법정동 시도 코드
val lDongRegnCd: String?,
// 법정동 시군구 코드
val lDongSignguCd: String?,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
package com.back.koreaTravelGuide.domain.ai.tour.dto

/**
* 9.25 양현준
* 관광 정보 호출용 파라미터
* 기능상, 생략 가능한 필드는 생략 (arrange : 제목 순으로 정렬, cat : 대,중,소 분류, crpyrhtDivCd: 저작권유형)
* 9.27 양현준
* API 요청 파라미터
* 기능상, 생략 가능한 필드는 생략 (arrange : 제목 , cat : 대,중,소 분류, crpyrhtDivCd: 저작권유형)
*/

data class InternalData(
data class TourSearchParams(
// 한 페이지 데이터 수, 10으로 지정
val numOfRows: Int = 10,
val numOfRows: Int = DEFAULT_ROWS,
// 페이지 번호, 1로 지정
val pageNo: Int = 1,
val pageNo: Int = DEFAULT_PAGE,
// 관광타입 ID, 미 입력시 전체 조회 (12:관광지, 38 : 쇼핑...),
val contentTypeId: String? = "",
val contentTypeId: String? = null,
// 지역코드, 미 입력시 지역 전체 조회 (1:서울, 2:인천...)
val areaCode: String? = "",
val areaCode: String? = null,
// 시군구코드, 미 입력시 전체 조회
val sigunguCode: String? = "",
)
val sigunguCode: String? = null,
) {
companion object {
const val DEFAULT_ROWS = 10
const val DEFAULT_PAGE = 1
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,50 @@
package com.back.koreaTravelGuide.domain.ai.tour.service

// TODO: 관광 정보 캐싱 서비스 - 캐시 관리 및 데이터 제공
class TourService
import com.back.koreaTravelGuide.domain.ai.tour.client.TourApiClient
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourResponse
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourSearchParams
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service

// 09.26 양현준
@Service
class TourService(
private val tourApiClient: TourApiClient,
) {
private val logger = LoggerFactory.getLogger(this::class.java)

// 관광 정보 조회
fun fetchTours(
numOfRows: Int? = null,
pageNo: Int? = null,
contentTypeId: String? = null,
areaCode: String? = null,
sigunguCode: String? = null,
): TourResponse {
// null 또는 비정상 값은 기본값으로 대체
val request =
TourSearchParams(
numOfRows = numOfRows?.takeIf { it > 0 } ?: TourSearchParams.DEFAULT_ROWS,
pageNo = pageNo?.takeIf { it > 0 } ?: TourSearchParams.DEFAULT_PAGE,
contentTypeId = contentTypeId?.ifBlank { null } ?: "",
areaCode = areaCode?.ifBlank { null } ?: "",
sigunguCode = sigunguCode?.ifBlank { null } ?: "",
)

// request를 바탕으로 관광 정보 API 호출
val tours = tourApiClient.fetchTourInfo(request)

// 관광 정보 결과 로깅
if (tours.items.isEmpty()) {
logger.info(
"관광 정보 없음: params={} / {} {}",
request.areaCode,
request.sigunguCode,
request.contentTypeId,
)
} else {
logger.info("관광 정보 {}건 조회 성공", tours.items.size)
}
return tours
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull

// 09.25 양현준
// 09.26 양현준
@ExtendWith(SpringExtension::class)
// 패키지 경로에서 메인 설정을 찾지 못하는 오류를 해결하기 위해 애플리케이션 클래스를 명시.
@SpringBootTest(classes = [KoreaTravelGuideApplication::class])
Expand Down Expand Up @@ -57,8 +57,7 @@ class TourApiClientTest {
tourApiClient = TourApiClient(restTemplate, objectMapper, serviceKey, apiUrl)
}

// 첫 번째 관광 정보를 반환하는지.
@DisplayName("TourApiClient - fetchTourInfo")
@DisplayName("fetchTourInfo - 첫 번째 관광 정보를 반환하는지.")
@Test
fun testReturnsFirstTourInfo() {
val params = InternalData(numOfRows = 2, pageNo = 1, areaCode = "1", sigunguCode = "7")
Expand All @@ -73,8 +72,7 @@ class TourApiClientTest {
assertEquals("7", result.sigunguCode)
}

// item 배열이 비어 있으면 null을 돌려주는지.
@DisplayName("TourApiClient - fetchTourInfo")
@DisplayName("fetchTourInfo - item 배열이 비어 있으면 null을 돌려주는지.")
@Test
fun testReturnsNullWhenItemsMissing() {
val params = InternalData(numOfRows = 1, pageNo = 1, areaCode = "1", sigunguCode = "7")
Expand Down