Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
Expand Up @@ -3,20 +3,24 @@ 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,
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 =
UriComponentsBuilder.fromUri(URI.create(apiUrl))
Expand All @@ -35,49 +39,72 @@ class TourApiClient(
.toUri()

// 지역 기반 관광 정보 조회 (areaBasedList2)
fun fetchTourInfo(params: InternalData): TourResponse? {
println("URL 생성")
fun fetchTourInfo(params: InternalData): List<TourResponse> {
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<TourResponse> {
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
}
}
}
Original file line number Diff line number Diff line change
@@ -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<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 +39,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? = "",
// 지역코드, 미 입력시 지역 전체 조회 (1:서울, 2:인천...)
val areaCode: String? = "",
// 시군구코드, 미 입력시 전체 조회
val sigunguCode: String? = "",
)
) {
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,
): List<TourResponse> {
// InternalData 객체 생성, 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.isEmpty()) {
logger.info(
"관광 정보 없음: params={} / {} {}",
request.areaCode,
request.sigunguCode,
request.contentTypeId,
)
} else {
logger.info("관광 정보 {}건 조회 성공", tours.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