From ed7a5cfb136f1db9b59fb28aa3eda82dd32d0074 Mon Sep 17 00:00:00 2001 From: YangHJ2415 Date: Fri, 26 Sep 2025 14:57:11 +0900 Subject: [PATCH 1/6] =?UTF-8?q?fix(be):=20DisplayName=20=EC=96=B4=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=20Test=EC=97=90=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/ai/tour/client/TourApiClientTest.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) 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") From 10e7f8fdefaaf879f1c03e3f23bf45beb2b9dcfe Mon Sep 17 00:00:00 2001 From: YangHJ2415 Date: Fri, 26 Sep 2025 14:57:52 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix(be):=20TourApiClient=20=EC=88=98?= =?UTF-8?q?=EC=A0=95,=20List=ED=98=95=ED=83=9C=EB=A1=9C=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=EB=A5=BC=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/ai/tour/client/TourApiClient.kt | 97 ++++++++++++------- 1 file changed, 62 insertions(+), 35 deletions(-) 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..fe2afd9 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 @@ -3,13 +3,14 @@ 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.TourResponse 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,6 +18,9 @@ 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 = UriComponentsBuilder.fromUri(URI.create(apiUrl)) @@ -35,49 +39,72 @@ class TourApiClient( .toUri() // 지역 기반 관광 정보 조회 (areaBasedList2) - fun fetchTourInfo(params: InternalData): TourResponse? { - println("URL 생성") + fun fetchTourInfo(params: InternalData): List { + logger.info("지역 기반 관광 정보 조회 시작") + val url = buildUrl(params) + logger.info("Tour API URL 생성 : $url") - println("관광 정보 조회 API 호출: $url") + /* + * runCatching: 예외를 Result로 감싸 예외를 던지지 않고 처리하는 유틸리티 함수 + * getOrNull(): 성공 시 응답 문자열을, 실패 시 null 반환 + * takeUnless { it.isNullOrBlank() }: 공백 응답을 걸러냄 + * ?.let { parseItems(it) } ?: emptyList(): 유효한 응답은 파싱, 아니면 빈 리스트 반환 + */ + return runCatching { restTemplate.getForObject(url, String::class.java) } + .onFailure { logger.error("관광 정보 조회 실패", it) } + .getOrNull() + .takeUnless { it.isNullOrBlank() } + ?.let { parseItems(it) } ?: emptyList() + } - return try { - val jsonResponse = restTemplate.getForObject(url, String::class.java) - println("관광 정보 응답 길이: ${jsonResponse?.length ?: 0}") + private fun parseItems(json: String): List { + val root = objectMapper.readTree(json) - if (jsonResponse.isNullOrBlank()) return null // HTTP 호출 결과가 null이거나 공백 문자열일 때 + // header.resultCode 값 추출위한 노스 탐색 과정 + val resultCode = + root + .path("response") + .path("header") + .path("resultCode") + .asText() + + // resultCode가 "0000"이 아닌 경우 체크 + if (resultCode != "0000") { + logger.warn("관광 정보 API resultCode={}", resultCode) + return emptyList() + } - val root = objectMapper.readTree(jsonResponse) // 문자열을 Jackson 트리 구조(JsonNode)로 변환 - val itemsNode = - root // path("키") 형태로 노드를 탐색, 응답 Json 형태의 순서에 따라 순차적으로 내려감 - .path("response") - .path("body") - .path("items") - .path("item") + // path("키") 형태로 노드를 탐색, 응답 Json 형태의 순서에 따라 순차적으로 내려감 + val itemsNode = + root + .path("response") + .path("body") + .path("items") + .path("item") - if (!itemsNode.isArray || itemsNode.isEmpty) return null // 탐색 결과가 비어 있는 경우 + // 탐색 결과가 비어 있는 경우 + if (!itemsNode.isArray || itemsNode.isEmpty) return emptyList() - val firstItem = itemsNode.first() + // itemsNode가 배열이므로 map으로 각 노드를 TourResponse로 변환 + return itemsNode.map { node -> 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(), + 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(), ) - } catch (e: Exception) { - println("관광 정보 조회 오류: ${e.message}") - null } } } From 37a36eaa60633efd025ee8d915a5d7c1ae7b0ca0 Mon Sep 17 00:00:00 2001 From: YangHJ2415 Date: Fri, 26 Sep 2025 17:02:12 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat(be):=20Tour=20service=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20Dto=EA=B5=AC=EC=A1=B0=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20(#24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/ai/tour/dto/InternalData.kt | 11 ++-- .../domain/ai/tour/service/TourService.kt | 50 ++++++++++++++++++- 2 files changed, 56 insertions(+), 5 deletions(-) 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/InternalData.kt index 87a8a03..fb2dbe6 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/InternalData.kt @@ -8,13 +8,18 @@ package com.back.koreaTravelGuide.domain.ai.tour.dto data class InternalData( // 한 페이지 데이터 수, 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? = "", // 지역코드, 미 입력시 지역 전체 조회 (1:서울, 2:인천...) val areaCode: String? = "", // 시군구코드, 미 입력시 전체 조회 val sigunguCode: String? = "", -) +) { + 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..99d25fd 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.InternalData +import com.back.koreaTravelGuide.domain.ai.tour.dto.TourResponse +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, + ): List { + // InternalData 객체 생성, null 또는 비정상 값은 기본값으로 대체 + val request = + InternalData( + numOfRows = numOfRows?.takeIf { it > 0 } ?: InternalData.DEFAULT_ROWS, + pageNo = pageNo?.takeIf { it > 0 } ?: InternalData.DEFAULT_PAGE, + contentTypeId = contentTypeId?.ifBlank { null } ?: "", + areaCode = areaCode?.ifBlank { null } ?: "", + sigunguCode = sigunguCode?.ifBlank { null } ?: "", + ) + + // request를 바탕으로 관광 정보 API 호출 + val tours = tourApiClient.fetchTourInfo(request) + + // 관광 정보 결과 로깅 + if (tours.isEmpty()) { + logger.info( + "관광 정보 없음: params={} / {} {}", + request.areaCode, + request.sigunguCode, + request.contentTypeId, + ) + } else { + logger.info("관광 정보 {}건 조회 성공", tours.size) + } + return tours + } +} From 2ccabbd8f108f6565626bcd72edaff1ee48cd712 Mon Sep 17 00:00:00 2001 From: YangHJ2415 Date: Sun, 28 Sep 2025 16:16:47 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix(be)=20:=20TourResponse=20dto=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/ai/tour/dto/TourResponse.kt | 21 ++++++++++++------- .../{InternalData.kt => TourSearchParams.kt} | 8 +++---- .../domain/ai/tour/service/TourService.kt | 8 +++---- 3 files changed, 22 insertions(+), 15 deletions(-) rename src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/{InternalData.kt => TourSearchParams.kt} (81%) 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..c5cb87a 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,27 @@ 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 +39,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 81% 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 fb2dbe6..06e1f77 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,12 +1,12 @@ 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 = DEFAULT_ROWS, // 페이지 번호, 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 99d25fd..bb33920 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,8 +1,8 @@ package com.back.koreaTravelGuide.domain.ai.tour.service import com.back.koreaTravelGuide.domain.ai.tour.client.TourApiClient -import com.back.koreaTravelGuide.domain.ai.tour.dto.InternalData 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 @@ -23,9 +23,9 @@ class TourService( ): List { // InternalData 객체 생성, null 또는 비정상 값은 기본값으로 대체 val request = - InternalData( - numOfRows = numOfRows?.takeIf { it > 0 } ?: InternalData.DEFAULT_ROWS, - pageNo = pageNo?.takeIf { it > 0 } ?: InternalData.DEFAULT_PAGE, + 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 } ?: "", From 9d73f856276a4343492ff6f1f96cbb24a9368bbf Mon Sep 17 00:00:00 2001 From: YangHJ2415 Date: Sun, 28 Sep 2025 17:09:32 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat(be):=20ai=20codeReview=20=EB=B0=98?= =?UTF-8?q?=EC=98=81,=C3=A3=C2=85=EA=B8=B0=EB=B3=B8=20=EA=B0=92=20null?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../koreaTravelGuide/domain/ai/tour/dto/TourSearchParams.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/TourSearchParams.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/TourSearchParams.kt index 06e1f77..2ae8604 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/TourSearchParams.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/TourSearchParams.kt @@ -12,11 +12,11 @@ data class TourSearchParams( // 페이지 번호, 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 From 11eda1c3887c160c27c6b0a0914a3a3cfae60988 Mon Sep 17 00:00:00 2001 From: YangHJ2415 Date: Sun, 28 Sep 2025 18:28:10 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat(be)=20TourResponse=20Dto=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EB=B3=80=EA=B2=BD=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?Client,=20Service=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/ai/tour/client/TourApiClient.kt | 69 ++++++++++--------- .../domain/ai/tour/dto/TourResponse.kt | 1 - .../domain/ai/tour/service/TourService.kt | 8 +-- 3 files changed, 42 insertions(+), 36 deletions(-) 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 fe2afd9..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,7 +1,8 @@ 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 @@ -22,7 +23,7 @@ class TourApiClient( 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) @@ -39,7 +40,7 @@ class TourApiClient( .toUri() // 지역 기반 관광 정보 조회 (areaBasedList2) - fun fetchTourInfo(params: InternalData): List { + fun fetchTourInfo(params: TourSearchParams): TourResponse { logger.info("지역 기반 관광 정보 조회 시작") val url = buildUrl(params) @@ -51,14 +52,17 @@ class TourApiClient( * takeUnless { it.isNullOrBlank() }: 공백 응답을 걸러냄 * ?.let { parseItems(it) } ?: emptyList(): 유효한 응답은 파싱, 아니면 빈 리스트 반환 */ - return runCatching { restTemplate.getForObject(url, String::class.java) } - .onFailure { logger.error("관광 정보 조회 실패", it) } - .getOrNull() - .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) } + + return response ?: TourResponse(items = emptyList()) } - private fun parseItems(json: String): List { + private fun parseItems(json: String): TourResponse { val root = objectMapper.readTree(json) // header.resultCode 값 추출위한 노스 탐색 과정 @@ -72,7 +76,7 @@ class TourApiClient( // resultCode가 "0000"이 아닌 경우 체크 if (resultCode != "0000") { logger.warn("관광 정보 API resultCode={}", resultCode) - return emptyList() + return TourResponse(items = emptyList()) } // path("키") 형태로 노드를 탐색, 응답 Json 형태의 순서에 따라 순차적으로 내려감 @@ -84,27 +88,30 @@ class TourApiClient( .path("item") // 탐색 결과가 비어 있는 경우 - if (!itemsNode.isArray || itemsNode.isEmpty) return emptyList() + if (!itemsNode.isArray || itemsNode.isEmpty) return TourResponse(items = emptyList()) - // itemsNode가 배열이므로 map으로 각 노드를 TourResponse로 변환 - return itemsNode.map { node -> - TourResponse( - 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(), - ) - } + // 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 c5cb87a..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 @@ -6,7 +6,6 @@ package com.back.koreaTravelGuide.domain.ai.tour.dto * API 매뉴얼에서 필수인 값은 NonNull로 지정. */ -// 관광 정보 응답 data class TourResponse( val items: List, ) 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 bb33920..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 @@ -20,8 +20,8 @@ class TourService( contentTypeId: String? = null, areaCode: String? = null, sigunguCode: String? = null, - ): List { - // InternalData 객체 생성, null 또는 비정상 값은 기본값으로 대체 + ): TourResponse { + // null 또는 비정상 값은 기본값으로 대체 val request = TourSearchParams( numOfRows = numOfRows?.takeIf { it > 0 } ?: TourSearchParams.DEFAULT_ROWS, @@ -35,7 +35,7 @@ class TourService( val tours = tourApiClient.fetchTourInfo(request) // 관광 정보 결과 로깅 - if (tours.isEmpty()) { + if (tours.items.isEmpty()) { logger.info( "관광 정보 없음: params={} / {} {}", request.areaCode, @@ -43,7 +43,7 @@ class TourService( request.contentTypeId, ) } else { - logger.info("관광 정보 {}건 조회 성공", tours.size) + logger.info("관광 정보 {}건 조회 성공", tours.items.size) } return tours }