Skip to content

Commit b1ad22e

Browse files
authored
feat(be) : Tour 엔티티 및 Client 기초 설계 (#23)
* feat(be) Tour엔티티 구현 (#3) * feat(be): TourClient 및 Test 구현, Dto를 Klint에 맞춰 수정 * fix(be): component 어노테이션 추가 및 AI PR리뷰 반영 * fix(be) : 이전 커밋에 미 반영된 내용 추가
1 parent 71c900d commit b1ad22e

File tree

5 files changed

+297
-8
lines changed

5 files changed

+297
-8
lines changed
Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,83 @@
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.stereotype.Component
8+
import org.springframework.web.client.RestTemplate
9+
import org.springframework.web.util.UriComponentsBuilder
10+
import java.net.URI
11+
12+
// 09.25 양현준
13+
@Component
14+
class TourApiClient(
15+
private val restTemplate: RestTemplate,
16+
private val objectMapper: ObjectMapper,
17+
@Value("\${tour.api.key}") private val serviceKey: String,
18+
@Value("\${tour.api.base-url}") private val apiUrl: String,
19+
) {
20+
// 요청 URL 구성
21+
private fun buildUrl(params: InternalData): URI =
22+
UriComponentsBuilder.fromUri(URI.create(apiUrl))
23+
.path("/areaBasedList2")
24+
.queryParam("serviceKey", serviceKey)
25+
.queryParam("MobileOS", "WEB")
26+
.queryParam("MobileApp", "KoreaTravelGuide")
27+
.queryParam("_type", "json")
28+
.queryParam("numOfRows", params.numOfRows)
29+
.queryParam("pageNo", params.pageNo)
30+
.queryParam("contentTypeId", params.contentTypeId)
31+
.queryParam("areaCode", params.areaCode)
32+
.queryParam("sigunguCode", params.sigunguCode)
33+
.build()
34+
.encode()
35+
.toUri()
36+
37+
// 지역 기반 관광 정보 조회 (areaBasedList2)
38+
fun fetchTourInfo(params: InternalData): TourResponse? {
39+
println("URL 생성")
40+
val url = buildUrl(params)
41+
42+
println("관광 정보 조회 API 호출: $url")
43+
44+
return try {
45+
val jsonResponse = restTemplate.getForObject(url, String::class.java)
46+
println("관광 정보 응답 길이: ${jsonResponse?.length ?: 0}")
47+
48+
if (jsonResponse.isNullOrBlank()) return null // HTTP 호출 결과가 null이거나 공백 문자열일 때
49+
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")
57+
58+
if (!itemsNode.isArray || itemsNode.isEmpty) return null // 탐색 결과가 비어 있는 경우
59+
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
81+
}
82+
}
83+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.back.koreaTravelGuide.domain.ai.tour.dto
2+
3+
/**
4+
* 9.25 양현준
5+
* 관광 정보 호출용 파라미터
6+
* 기능상, 생략 가능한 필드는 생략 (arrange : 제목 순으로 정렬, cat : 대,중,소 분류, crpyrhtDivCd: 저작권유형)
7+
*/
8+
9+
data class InternalData(
10+
// 한 페이지 데이터 수, 10으로 지정
11+
val numOfRows: Int = 10,
12+
// 페이지 번호, 1로 지정
13+
val pageNo: Int = 1,
14+
// 관광타입 ID, 미 입력시 전체 조회 (12:관광지, 38 : 쇼핑...),
15+
val contentTypeId: String? = "",
16+
// 지역코드, 미 입력시 지역 전체 조회 (1:서울, 2:인천...)
17+
val areaCode: String? = "",
18+
// 시군구코드, 미 입력시 전체 조회
19+
val sigunguCode: String? = "",
20+
)
Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,39 @@
11
package com.back.koreaTravelGuide.domain.ai.tour.dto
22

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

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)