Skip to content

Commit 6c5d346

Browse files
YangHJ2415정동하
authored andcommitted
feat(be) : Tour Service 구현, Tour Client, Dto 개선 (#42)
* fix(be): DisplayName 어노테이션 Test에 반영 * fix(be): TourApiClient 수정, List형태로 결과를 반환 * feat(be): Tour service 구현 및 Dto구조 변경 (#24) * fix(be) : TourResponse dto 구조 변경 * feat(be): ai codeReview 반영,ã�기본 값 null로 변경 * feat(be) TourResponse Dto 구조 변경에 따른 Client, Service 리팩토링
1 parent 3f670c8 commit 6c5d346

File tree

5 files changed

+153
-64
lines changed

5 files changed

+153
-64
lines changed
Lines changed: 74 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,29 @@
11
package com.back.koreaTravelGuide.domain.ai.tour.client
22

3-
import com.back.koreaTravelGuide.domain.ai.tour.dto.InternalData
3+
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourItem
44
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourResponse
5+
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourSearchParams
56
import com.fasterxml.jackson.databind.ObjectMapper
7+
import org.slf4j.LoggerFactory
68
import org.springframework.beans.factory.annotation.Value
79
import org.springframework.stereotype.Component
810
import org.springframework.web.client.RestTemplate
911
import org.springframework.web.util.UriComponentsBuilder
1012
import java.net.URI
1113

12-
// 09.25 양현준
14+
// 09.26 양현준
1315
@Component
1416
class TourApiClient(
1517
private val restTemplate: RestTemplate,
1618
private val objectMapper: ObjectMapper,
1719
@Value("\${tour.api.key}") private val serviceKey: String,
1820
@Value("\${tour.api.base-url}") private val apiUrl: String,
1921
) {
22+
// println 대신 SLF4J 로거 사용
23+
private val logger = LoggerFactory.getLogger(TourApiClient::class.java)
24+
2025
// 요청 URL 구성
21-
private fun buildUrl(params: InternalData): URI =
26+
private fun buildUrl(params: TourSearchParams): URI =
2227
UriComponentsBuilder.fromUri(URI.create(apiUrl))
2328
.path("/areaBasedList2")
2429
.queryParam("serviceKey", serviceKey)
@@ -35,49 +40,78 @@ class TourApiClient(
3540
.toUri()
3641

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

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

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

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

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

58-
if (!itemsNode.isArray || itemsNode.isEmpty) return null // 탐색 결과가 비어 있는 경우
68+
// header.resultCode 값 추출위한 노스 탐색 과정
69+
val resultCode =
70+
root
71+
.path("response")
72+
.path("header")
73+
.path("resultCode")
74+
.asText()
5975

60-
val firstItem = itemsNode.first()
61-
TourResponse(
62-
contentId = firstItem.path("contentid").asText(),
63-
contentTypeId = firstItem.path("contenttypeid").asText(),
64-
createdTime = firstItem.path("createdtime").asText(),
65-
modifiedTime = firstItem.path("modifiedtime").asText(),
66-
title = firstItem.path("title").asText(),
67-
addr1 = firstItem.path("addr1").takeIf { it.isTextual }?.asText(),
68-
areaCode = firstItem.path("areacode").takeIf { it.isTextual }?.asText(),
69-
firstimage = firstItem.path("firstimage").takeIf { it.isTextual }?.asText(),
70-
firstimage2 = firstItem.path("firstimage2").takeIf { it.isTextual }?.asText(),
71-
mapX = firstItem.path("mapx").takeIf { it.isTextual }?.asText(),
72-
mapY = firstItem.path("mapy").takeIf { it.isTextual }?.asText(),
73-
mlevel = firstItem.path("mlevel").takeIf { it.isTextual }?.asText(),
74-
sigunguCode = firstItem.path("sigungucode").takeIf { it.isTextual }?.asText(),
75-
lDongRegnCd = firstItem.path("lDongRegnCd").takeIf { it.isTextual }?.asText(),
76-
lDongSignguCd = firstItem.path("lDongSignguCd").takeIf { it.isTextual }?.asText(),
77-
)
78-
} catch (e: Exception) {
79-
println("관광 정보 조회 오류: ${e.message}")
80-
null
76+
// resultCode가 "0000"이 아닌 경우 체크
77+
if (resultCode != "0000") {
78+
logger.warn("관광 정보 API resultCode={}", resultCode)
79+
return TourResponse(items = emptyList())
8180
}
81+
82+
// path("키") 형태로 노드를 탐색, 응답 Json 형태의 순서에 따라 순차적으로 내려감
83+
val itemsNode =
84+
root
85+
.path("response")
86+
.path("body")
87+
.path("items")
88+
.path("item")
89+
90+
// 탐색 결과가 비어 있는 경우
91+
if (!itemsNode.isArray || itemsNode.isEmpty) return TourResponse(items = emptyList())
92+
93+
// itemsNode가 배열이므로 map으로 각 노드를 TourItem으로 변환 후 컨테이너로 감싼다.
94+
val items =
95+
itemsNode.map { node ->
96+
TourItem(
97+
contentId = node.path("contentid").asText(),
98+
contentTypeId = node.path("contenttypeid").asText(),
99+
createdTime = node.path("createdtime").asText(),
100+
modifiedTime = node.path("modifiedtime").asText(),
101+
title = node.path("title").asText(),
102+
addr1 = node.path("addr1").takeIf { it.isTextual }?.asText(),
103+
areaCode = node.path("areacode").takeIf { it.isTextual }?.asText(),
104+
firstimage = node.path("firstimage").takeIf { it.isTextual }?.asText(),
105+
firstimage2 = node.path("firstimage2").takeIf { it.isTextual }?.asText(),
106+
mapX = node.path("mapx").takeIf { it.isTextual }?.asText(),
107+
mapY = node.path("mapy").takeIf { it.isTextual }?.asText(),
108+
mlevel = node.path("mlevel").takeIf { it.isTextual }?.asText(),
109+
sigunguCode = node.path("sigungucode").takeIf { it.isTextual }?.asText(),
110+
lDongRegnCd = node.path("lDongRegnCd").takeIf { it.isTextual }?.asText(),
111+
lDongSignguCd = node.path("lDongSignguCd").takeIf { it.isTextual }?.asText(),
112+
)
113+
}
114+
115+
return TourResponse(items = items)
82116
}
83117
}

src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/dto/TourResponse.kt

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,26 @@
11
package com.back.koreaTravelGuide.domain.ai.tour.dto
22

33
/**
4-
* 9.25 양현준
4+
* 9.27 양현준
55
* 관광 정보 응답 DTO
66
* API 매뉴얼에서 필수인 값은 NonNull로 지정.
77
*/
8+
89
data class TourResponse(
9-
// 콘텐츠ID (고유 번호)
10+
val items: List<TourItem>,
11+
)
12+
13+
// 관광 정보 단일 아이템
14+
data class TourItem(
15+
// 콘텐츠ID (고유 번호, NonNull)
1016
val contentId: String,
11-
// 관광타입 ID (12: 관광지, 14: 문화시설 ..)
17+
// 관광타입 ID (12: 관광지, NonNull)
1218
val contentTypeId: String,
13-
// 등록일
19+
// 등록일 (NonNull)
1420
val createdTime: String,
15-
// 수정일
21+
// 수정일 (NonNull)
1622
val modifiedTime: String,
17-
// 제목
23+
// 제목 (NonNull)
1824
val title: String,
1925
// 주소
2026
val addr1: String?,
@@ -32,7 +38,7 @@ data class TourResponse(
3238
val mlevel: String?,
3339
// 시군구코드
3440
val sigunguCode: String?,
35-
// 법정동 시도 코드, 응답 코드가 IDongRegnCd 이므로,
41+
// 법정동 시도 코드
3642
val lDongRegnCd: String?,
3743
// 법정동 시군구 코드
3844
val lDongSignguCd: String?,
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,25 @@
11
package com.back.koreaTravelGuide.domain.ai.tour.dto
22

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

9-
data class InternalData(
9+
data class TourSearchParams(
1010
// 한 페이지 데이터 수, 10으로 지정
11-
val numOfRows: Int = 10,
11+
val numOfRows: Int = DEFAULT_ROWS,
1212
// 페이지 번호, 1로 지정
13-
val pageNo: Int = 1,
13+
val pageNo: Int = DEFAULT_PAGE,
1414
// 관광타입 ID, 미 입력시 전체 조회 (12:관광지, 38 : 쇼핑...),
15-
val contentTypeId: String? = "",
15+
val contentTypeId: String? = null,
1616
// 지역코드, 미 입력시 지역 전체 조회 (1:서울, 2:인천...)
17-
val areaCode: String? = "",
17+
val areaCode: String? = null,
1818
// 시군구코드, 미 입력시 전체 조회
19-
val sigunguCode: String? = "",
20-
)
19+
val sigunguCode: String? = null,
20+
) {
21+
companion object {
22+
const val DEFAULT_ROWS = 10
23+
const val DEFAULT_PAGE = 1
24+
}
25+
}
Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,50 @@
11
package com.back.koreaTravelGuide.domain.ai.tour.service
22

3-
// TODO: 관광 정보 캐싱 서비스 - 캐시 관리 및 데이터 제공
4-
class TourService
3+
import com.back.koreaTravelGuide.domain.ai.tour.client.TourApiClient
4+
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourResponse
5+
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourSearchParams
6+
import org.slf4j.LoggerFactory
7+
import org.springframework.stereotype.Service
8+
9+
// 09.26 양현준
10+
@Service
11+
class TourService(
12+
private val tourApiClient: TourApiClient,
13+
) {
14+
private val logger = LoggerFactory.getLogger(this::class.java)
15+
16+
// 관광 정보 조회
17+
fun fetchTours(
18+
numOfRows: Int? = null,
19+
pageNo: Int? = null,
20+
contentTypeId: String? = null,
21+
areaCode: String? = null,
22+
sigunguCode: String? = null,
23+
): TourResponse {
24+
// null 또는 비정상 값은 기본값으로 대체
25+
val request =
26+
TourSearchParams(
27+
numOfRows = numOfRows?.takeIf { it > 0 } ?: TourSearchParams.DEFAULT_ROWS,
28+
pageNo = pageNo?.takeIf { it > 0 } ?: TourSearchParams.DEFAULT_PAGE,
29+
contentTypeId = contentTypeId?.ifBlank { null } ?: "",
30+
areaCode = areaCode?.ifBlank { null } ?: "",
31+
sigunguCode = sigunguCode?.ifBlank { null } ?: "",
32+
)
33+
34+
// request를 바탕으로 관광 정보 API 호출
35+
val tours = tourApiClient.fetchTourInfo(request)
36+
37+
// 관광 정보 결과 로깅
38+
if (tours.items.isEmpty()) {
39+
logger.info(
40+
"관광 정보 없음: params={} / {} {}",
41+
request.areaCode,
42+
request.sigunguCode,
43+
request.contentTypeId,
44+
)
45+
} else {
46+
logger.info("관광 정보 {}건 조회 성공", tours.items.size)
47+
}
48+
return tours
49+
}
50+
}

src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import kotlin.test.assertEquals
2929
import kotlin.test.assertNotNull
3030
import kotlin.test.assertNull
3131

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

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

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

0 commit comments

Comments
 (0)