diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClient.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClient.kt index 678af78..a965e0b 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClient.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClient.kt @@ -1,15 +1,17 @@ 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, @@ -17,8 +19,11 @@ class TourApiClient( @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) @@ -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) } } diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/TourResponse.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/TourResponse.kt index 63a0a16..69db9a4 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/TourResponse.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/TourResponse.kt @@ -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, +) + +// 관광 정보 단일 아이템 +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?, @@ -32,7 +38,7 @@ data class TourResponse( val mlevel: String?, // 시군구코드 val sigunguCode: String?, - // 법정동 시도 코드, 응답 코드가 IDongRegnCd 이므로, + // 법정동 시도 코드 val lDongRegnCd: String?, // 법정동 시군구 코드 val lDongSignguCd: String?, diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/InternalData.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/TourSearchParams.kt similarity index 50% rename from src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/InternalData.kt rename to src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/TourSearchParams.kt index 87a8a03..2ae8604 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/InternalData.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/TourSearchParams.kt @@ -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 + } +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourService.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourService.kt index 3fa04cf..7541b5c 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourService.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourService.kt @@ -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 + } +} diff --git a/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt index a9d0e29..51d828a 100644 --- a/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt +++ b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt @@ -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]) @@ -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") @@ -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")