Skip to content

Commit b4c6f5c

Browse files
committed
feat(be): TourClient 및 Test 구현, Dto를 Klint에 맞춰 수정
1 parent 46cd22f commit b4c6f5c

File tree

5 files changed

+290
-34
lines changed

5 files changed

+290
-34
lines changed
Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,81 @@
11
package com.back.koreaTravelGuide.domain.ai.tour.client
22

3-
// TODO: 관광청 API 클라이언트 - 관광 정보 API 호출 및 응답 처리
4-
class TourApiClient
3+
import com.back.koreaTravelGuide.domain.ai.tour.dto.InternalData
4+
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourResponse
5+
import com.fasterxml.jackson.databind.ObjectMapper
6+
import org.springframework.beans.factory.annotation.Value
7+
import org.springframework.web.client.RestTemplate
8+
import org.springframework.web.util.UriComponentsBuilder
9+
import java.net.URI
10+
11+
// 09.25 양현준
12+
class TourApiClient(
13+
private val restTemplate: RestTemplate,
14+
private val objectMapper: ObjectMapper,
15+
@Value("\${tour.api.key}") private val serviceKey: String,
16+
@Value("\${tour.api.base-url}") private val apiUrl: String,
17+
) {
18+
// 요청 URL 구성
19+
private fun buildUrl(params: InternalData): URI =
20+
UriComponentsBuilder.fromUri(URI.create(apiUrl))
21+
.path("/areaBasedList2")
22+
.queryParam("serviceKey", serviceKey)
23+
.queryParam("MobileOS", "WEB")
24+
.queryParam("MobileApp", "KoreaTravelGuide")
25+
.queryParam("_type", "json")
26+
.queryParam("numOfRows", params.numOfRows)
27+
.queryParam("pageNo", params.pageNo)
28+
.queryParam("contentTypeId", params.contentTypeId)
29+
.queryParam("areaCode", params.areaCode)
30+
.queryParam("sigunguCode", params.sigunguCode)
31+
.build()
32+
.encode()
33+
.toUri()
34+
35+
// 지역 기반 관광 정보 조회 (areaBasedList2)
36+
fun fetchTourInfo(params: InternalData): TourResponse? {
37+
println("URL 생성")
38+
val url = buildUrl(params)
39+
40+
println("관광 정보 조회 API 호출: $url")
41+
42+
return try {
43+
val jsonResponse = restTemplate.getForObject(url, String::class.java)
44+
println("관광 정보 응답 길이: ${jsonResponse?.length ?: 0}")
45+
46+
if (jsonResponse.isNullOrBlank()) return null // HTTP 호출 결과가 null이거나 공백 문자열일 때
47+
48+
val root = objectMapper.readTree(jsonResponse) // 문자열을 Jackson 트리 구조(JsonNode)로 변환
49+
val itemsNode =
50+
root // path("키") 형태로 노드를 탐색, 응답 Json 형태의 순서에 따라 순차적으로 내려감
51+
.path("response")
52+
.path("body")
53+
.path("items")
54+
.path("item")
55+
56+
if (!itemsNode.isArray || itemsNode.isEmpty) return null // 탐색 결과가 비어 있는 경우
57+
58+
val firstItem = itemsNode.first()
59+
TourResponse(
60+
contentId = firstItem.path("contentid").asText(),
61+
contentTypeId = firstItem.path("contenttypeid").asText(),
62+
createdTime = firstItem.path("createdtime").asText(),
63+
modifiedTime = firstItem.path("modifiedtime").asText(),
64+
title = firstItem.path("title").asText(),
65+
addr1 = firstItem.path("addr1").takeIf { it.isTextual }?.asText(),
66+
areaCode = firstItem.path("areacode").takeIf { it.isTextual }?.asText(),
67+
firstimage = firstItem.path("firstimage").takeIf { it.isTextual }?.asText(),
68+
firstimage2 = firstItem.path("firstimage2").takeIf { it.isTextual }?.asText(),
69+
mapX = firstItem.path("mapx").takeIf { it.isTextual }?.asText(),
70+
mapY = firstItem.path("mapy").takeIf { it.isTextual }?.asText(),
71+
mlevel = firstItem.path("mlevel").takeIf { it.isTextual }?.asText(),
72+
sigunguCode = firstItem.path("sigungucode").takeIf { it.isTextual }?.asText(),
73+
lDongRegnCd = firstItem.path("lDongRegnCd").takeIf { it.isTextual }?.asText(),
74+
lDongSignguCd = firstItem.path("lDongSignguCd").takeIf { it.isTextual }?.asText(),
75+
)
76+
} catch (e: Exception) {
77+
println("관광 정보 조회 오류: ${e.message}")
78+
null
79+
}
80+
}
81+
}
Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
package com.back.koreaTravelGuide.domain.ai.tour.dto
22

3-
// 09.25 양현준
4-
// 관광 정보 호출용 파라미터
5-
// 생략 가능한 필드는 생략 하였음 (arrange : 제목 순으로 정렬, cat : 대,중,소 분류, crpyrhtDivCd: 저작권유형)
6-
data class TourSearchParams(
7-
val numOfRows: Int = 10, // 한 페이지 데이터 수, 미 입력시 10
8-
val pageNo: Int = 1, // 페이지 번호, 미 입력시 10
3+
/**
4+
* 9.25 양현준
5+
* 관광 정보 호출용 파라미터
6+
* 기능상, 생략 가능한 필드는 생략 (arrange : 제목 순으로 정렬, cat : 대,중,소 분류, crpyrhtDivCd: 저작권유형)
7+
*/
98

10-
val contentTypeId: String = "12", // 관광타입 ID, 미 입력시 전체 조회 (12:관광지, 38 : 쇼핑...), 우선 관광지로 하드코딩
11-
val areaCode: String, // 지역코드, 미 입력시 지역 전체 (1:서울, 2:인천...)
12-
val sigunguCode: String, // 시군구코드, 미 입력시 전체
13-
)
9+
data class InternalData(
10+
// 한 페이지 데이터 수, 미 입력시 10
11+
val numOfRows: Int = 10,
12+
// 페이지 번호, 미 입력시 10
13+
val pageNo: Int = 1,
14+
// 관광타입 ID, 미 입력시 전체 조회 (12:관광지, 38 : 쇼핑...), 우선 관광지로 하드코딩
15+
val contentTypeId: String = "12",
16+
// 지역코드, 미 입력시 지역 전체 (1:서울, 2:인천...)
17+
val areaCode: String,
18+
// 시군구코드, 미 입력시 전체
19+
val sigunguCode: String,
20+
)
Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,39 @@
11
package com.back.koreaTravelGuide.domain.ai.tour.dto
22

3-
// 09.25 양현준
4-
// 관광 정보 응답 DTOd
5-
// API 매뉴얼에서 필수로 받는 값은 NonNull로 지정.
3+
/**
4+
* 9.25 양현준
5+
* 관광 정보 응답 DTO
6+
* API 매뉴얼에서 필수인 값은 NonNull로 지정.
7+
*/
68
data class TourResponse(
7-
val contentId: String, // 콘텐츠ID (고유 번호)
8-
val contentTypeId: String, // 관광타입 ID (12: 관광지, 14: 문화시설 ..)
9-
val createdTime: String, // 등록일
10-
val modifiedTime: String, // 수정일
11-
12-
val title: String, // 제목
13-
val addr1 : String?, // 주소
14-
val areaCode : String?, // 지역코드
15-
val firstimage: String?, // 이미지 (URL)
16-
val firstimage2: String?, // 썸네일 이미지 (URL)
17-
val mapX: String?, // 경도
18-
val mapY: String?, // 위도
19-
val mlevel: String?, // 지도 레벨
20-
val sigunguCode: String?, // 시군구코드
21-
val lDongRegnCd: String?, // 법정동 시도 코드, 응답 코드가 IDongRegnCd 이므로,
22-
val lDongSignguCd: String? // 법정동 시군구 코드
23-
)
9+
// 콘텐츠ID (고유 번호)
10+
val contentId: String,
11+
// 관광타입 ID (12: 관광지, 14: 문화시설 ..)
12+
val contentTypeId: String,
13+
// 등록일
14+
val createdTime: String,
15+
// 수정일
16+
val modifiedTime: String,
17+
// 제목
18+
val title: String,
19+
// 주소
20+
val addr1: String?,
21+
// 지역코드
22+
val areaCode: String?,
23+
// 이미지 (URL)
24+
val firstimage: String?,
25+
// 썸네일 이미지 (URL)
26+
val firstimage2: String?,
27+
// 경도
28+
val mapX: String?,
29+
// 위도
30+
val mapY: String?,
31+
// 지도 레벨
32+
val mlevel: String?,
33+
// 시군구코드
34+
val sigunguCode: String?,
35+
// 법정동 시도 코드, 응답 코드가 IDongRegnCd 이므로,
36+
val lDongRegnCd: String?,
37+
// 법정동 시군구 코드
38+
val lDongSignguCd: String?,
39+
)
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package com.back.koreaTravelGuide.domain.ai.tour.client
2+
3+
import com.back.koreaTravelGuide.application.KoreaTravelGuideApplication
4+
import com.back.koreaTravelGuide.domain.ai.tour.dto.InternalData
5+
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourResponse
6+
import com.fasterxml.jackson.databind.ObjectMapper
7+
import org.junit.jupiter.api.BeforeEach
8+
import org.junit.jupiter.api.Test
9+
import org.junit.jupiter.api.extension.ExtendWith
10+
import org.springframework.beans.factory.annotation.Autowired
11+
import org.springframework.beans.factory.annotation.Value
12+
import org.springframework.boot.test.context.SpringBootTest
13+
import org.springframework.boot.test.context.TestConfiguration
14+
import org.springframework.boot.web.client.RestTemplateBuilder
15+
import org.springframework.context.annotation.Bean
16+
import org.springframework.http.HttpMethod
17+
import org.springframework.http.MediaType
18+
import org.springframework.test.context.ActiveProfiles
19+
import org.springframework.test.context.junit.jupiter.SpringExtension
20+
import org.springframework.test.web.client.MockRestServiceServer
21+
import org.springframework.test.web.client.match.MockRestRequestMatchers.method
22+
import org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo
23+
import org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess
24+
import org.springframework.web.client.RestTemplate
25+
import org.springframework.web.util.UriComponentsBuilder
26+
import java.net.URI
27+
import kotlin.test.assertEquals
28+
import kotlin.test.assertNotNull
29+
import kotlin.test.assertNull
30+
31+
// 09.25 양현준
32+
@ExtendWith(SpringExtension::class)
33+
// 패키지 경로에서 메인 설정을 찾지 못하는 오류를 해결하기 위해 애플리케이션 클래스를 명시.
34+
@SpringBootTest(classes = [KoreaTravelGuideApplication::class])
35+
@ActiveProfiles("test")
36+
class TourApiClientTest {
37+
@Autowired private lateinit var restTemplateBuilder: RestTemplateBuilder
38+
39+
@Autowired private lateinit var objectMapper: ObjectMapper
40+
41+
@Value("\${tour.api.key}")
42+
private lateinit var serviceKey: String
43+
44+
@Value("\${tour.api.base-url}")
45+
private lateinit var apiUrl: String
46+
47+
private lateinit var restTemplate: RestTemplate
48+
private lateinit var mockServer: MockRestServiceServer
49+
private lateinit var tourApiClient: TourApiClient
50+
51+
// 테스트마다 클라이언트와 Mock 서버를 새로 구성해 호출 상태를 초기화.
52+
@BeforeEach
53+
fun setUp() {
54+
restTemplate = restTemplateBuilder.build()
55+
mockServer = MockRestServiceServer.createServer(restTemplate)
56+
tourApiClient = TourApiClient(restTemplate, objectMapper, serviceKey, apiUrl)
57+
}
58+
59+
// 첫 번째 관광 정보를 반환하는지.
60+
@Test
61+
fun testReturnsFirstTourInfo() {
62+
val params = InternalData(numOfRows = 2, pageNo = 1, areaCode = "1", sigunguCode = "7")
63+
expectTourRequest(params, responseWithItems(sampleTourItem()))
64+
65+
val result: TourResponse? = tourApiClient.fetchTourInfo(params)
66+
67+
mockServer.verify()
68+
assertNotNull(result)
69+
assertEquals("2591792", result.contentId)
70+
assertEquals("개봉유수지 생태공원", result.title)
71+
assertEquals("7", result.sigunguCode)
72+
}
73+
74+
// item 배열이 비어 있으면 null을 돌려주는지.
75+
@Test
76+
fun testReturnsNullWhenItemsMissing() {
77+
val params = InternalData(numOfRows = 1, pageNo = 1, areaCode = "1", sigunguCode = "7")
78+
expectTourRequest(params, responseWithItems())
79+
80+
val result = tourApiClient.fetchTourInfo(params)
81+
82+
mockServer.verify()
83+
assertNull(result)
84+
}
85+
86+
// 요청 URL과 응답 바디를 미리 세팅해 Mock 서버가 기대한 호출을 검증.
87+
private fun expectTourRequest(
88+
params: InternalData,
89+
responseBody: String,
90+
) {
91+
mockServer.expect(requestTo(buildExpectedUrl(params)))
92+
.andExpect(method(HttpMethod.GET))
93+
.andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON))
94+
}
95+
96+
// 실제 클라이언트가 조합하는 URL과 동일한 형태를 만들어 비교한다.
97+
private fun buildExpectedUrl(params: InternalData): String =
98+
UriComponentsBuilder.fromUri(URI.create(apiUrl))
99+
.path("/areaBasedList2")
100+
.queryParam("serviceKey", serviceKey)
101+
.queryParam("MobileOS", "WEB")
102+
.queryParam("MobileApp", "KoreaTravelGuide")
103+
.queryParam("_type", "json")
104+
.queryParam("numOfRows", params.numOfRows)
105+
.queryParam("pageNo", params.pageNo)
106+
.queryParam("contentTypeId", params.contentTypeId)
107+
.queryParam("areaCode", params.areaCode)
108+
.queryParam("sigunguCode", params.sigunguCode)
109+
.build()
110+
.encode()
111+
.toUriString()
112+
113+
// Jackson을 활용해 테스트 응답 JSON을 손쉽게 조립한다.
114+
private fun responseWithItems(vararg items: Map<String, String>): String {
115+
val response =
116+
mapOf(
117+
"response" to
118+
mapOf(
119+
"header" to mapOf("resultCode" to "0000", "resultMsg" to "OK"),
120+
"body" to
121+
mapOf(
122+
"items" to mapOf("item" to items.toList()),
123+
),
124+
),
125+
)
126+
127+
return objectMapper.writeValueAsString(response)
128+
}
129+
130+
// 테스트용 샘플 관광지 정의한다.
131+
private fun sampleTourItem(): Map<String, String> =
132+
mapOf(
133+
"contentid" to "2591792",
134+
"contenttypeid" to "12",
135+
"createdtime" to "20190313221125",
136+
"modifiedtime" to "20250316162225",
137+
"title" to "개봉유수지 생태공원",
138+
"addr1" to "서울특별시 구로구 개봉동",
139+
"areacode" to "1",
140+
"firstimage" to "",
141+
"firstimage2" to "",
142+
"mapx" to "126.8632141714",
143+
"mapy" to "37.4924524597",
144+
"mlevel" to "6",
145+
"sigungucode" to "7",
146+
"lDongRegnCd" to "11",
147+
"lDongSignguCd" to "530",
148+
)
149+
150+
// 테스트에서 RestTemplateBuilder 빈을 보장해 컨텍스트 로딩 실패 해결.
151+
@TestConfiguration
152+
class TestConfig {
153+
@Bean
154+
fun restTemplateBuilder(): RestTemplateBuilder = RestTemplateBuilder()
155+
}
156+
}

src/test/resources/application-test.properties

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ weather.api.key=8298f7760d58f7d4a0104d016d5792a33b0048953afda89d5c0bb3fef251141c
66
weather.api.base-url=https://apis.data.go.kr/1360000/MidFcstInfoService
77

88
# Tour API Configuration
9-
tour.api.key=${TOUR_API_KEY:test-key}
10-
tour.api.base-url=${TOUR_API_BASE_URL:https://apis.data.go.kr/B551011/KorService1}
9+
tour.api.key=${TOUR_API_KEY:Pp8aOoKZql09DdDfb4r9SsFWIepIqaocvCQzJphWcvmBj0ff9KuvikfKjgxrXqK03JNrmOIjOZyLyZhjlY43AQ==}
10+
tour.api.base-url=${TOUR_API_BASE_URL:https://apis.data.go.kr/B551011/KorService2}
1111

1212
# Spring AI Configuration
1313
spring.ai.openai.api-key=${OPENAI_API_KEY:test-key}

0 commit comments

Comments
 (0)