diff --git a/build.gradle.kts b/build.gradle.kts index 6745ee5..e108434 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-webflux") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml") implementation("org.jetbrains.kotlin:kotlin-reflect") // jwt @@ -140,6 +141,17 @@ buildConfig { "${parts[0].trim()}: ${parts[1].trim().replace("\"", "")}" } + val languageCodesDescription = + file("src/main/resources/language.yml") + .readText() + .substringAfter("codes:") + .lines() + .filter { it.contains(":") } + .joinToString(", ") { line -> + val parts = line.split(":") + "${parts[0].trim()}: ${parts[1].trim().replace("\"", "")}" + } + val regionCodes = file("src/main/resources/region-codes.yml") .readText() @@ -165,6 +177,7 @@ buildConfig { buildConfigField("String", "AREA_CODES_DESCRIPTION", "\"\"\"$areaCodes\"\"\"") buildConfigField("String", "CONTENT_TYPE_CODES_DESCRIPTION", "\"\"\"$contentTypeCodes\"\"\"") + buildConfigField("String", "LANGUAGE_CODES_DESCRIPTION", "\"\"\"$languageCodesDescription\"\"\"") buildConfigField("String", "REGION_CODES_DESCRIPTION", "\"\"\"$regionCodes\"\"\"") buildConfigField("String", "KOREA_TRAVEL_GUIDE_SYSTEM", "\"\"\"$systemPrompt\"\"\"") buildConfigField("String", "AI_ERROR_FALLBACK", "\"\"\"$errorPrompt\"\"\"") 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 b263907..657f2e9 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 @@ -13,11 +13,10 @@ import com.fasterxml.jackson.databind.ObjectMapper import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component import org.springframework.web.client.RestTemplate -import org.springframework.web.reactive.function.server.RequestPredicates.queryParam import org.springframework.web.util.UriComponentsBuilder import java.net.URI -// 09.26 양현준 +// 10.12 양현준 @Component class TourApiClient( private val restTemplate: RestTemplate, @@ -25,28 +24,21 @@ class TourApiClient( @Value("\${tour.api.key}") private val serviceKey: String, @Value("\${tour.api.base-url}") private val apiUrl: String, ) { - // 요청 URL 구성 - private fun buildUrl(params: TourParams): URI = - UriComponentsBuilder.fromUri(URI.create(apiUrl)) - .path("/areaBasedList2") - .queryParam("serviceKey", serviceKey) - .queryParam("MobileOS", "WEB") - .queryParam("MobileApp", "KoreaTravelGuide") - .queryParam("_type", "json") - .queryParam("contentTypeId", params.contentTypeId) - .queryParam("areaCode", params.areaCode) - .queryParam("sigunguCode", params.sigunguCode) - .build() - .encode() - .toUri() - // 지역 기반 관광 정보 조회 (areaBasedList2) - fun fetchTourInfo(params: TourParams): TourResponse { - val url = buildUrl(params) + fun fetchTourInfo( + params: TourParams, + serviceSegment: String, + ): TourResponse { + val url = + buildTourUri(serviceSegment, "areaBasedList2") { + queryParam("contentTypeId", params.contentTypeId) + queryParam("areaCode", params.areaCode) + queryParam("sigunguCode", params.sigunguCode) + } val body = runCatching { restTemplate.getForObject(url, String::class.java) } - .onFailure { log.error("관광 정보 조회 실패", it) } + .onFailure { log.error("관광 정보 조회 실패 - serviceSegment={}", serviceSegment, it) } .getOrNull() return body @@ -59,27 +51,21 @@ class TourApiClient( fun fetchLocationBasedTours( tourParams: TourParams, locationParams: TourLocationBasedParams, + serviceSegment: String, ): TourResponse { val url = - UriComponentsBuilder.fromUri(URI.create(apiUrl)) - .path("/locationBasedList2") - .queryParam("serviceKey", serviceKey) - .queryParam("MobileOS", "WEB") - .queryParam("MobileApp", "KoreaTravelGuide") - .queryParam("_type", "json") - .queryParam("mapX", locationParams.mapX) - .queryParam("mapY", locationParams.mapY) - .queryParam("radius", locationParams.radius) - .queryParam("contentTypeId", tourParams.contentTypeId) - .queryParam("areaCode", tourParams.areaCode) - .queryParam("sigunguCode", tourParams.sigunguCode) - .build() - .encode() - .toUri() + buildTourUri(serviceSegment, "locationBasedList2") { + queryParam("mapX", locationParams.mapX) + queryParam("mapY", locationParams.mapY) + queryParam("radius", locationParams.radius) + queryParam("contentTypeId", tourParams.contentTypeId) + queryParam("areaCode", tourParams.areaCode) + queryParam("sigunguCode", tourParams.sigunguCode) + } val body = runCatching { restTemplate.getForObject(url, String::class.java) } - .onFailure { log.error("위치기반 관광 정보 조회 실패", it) } + .onFailure { log.error("위치기반 관광 정보 조회 실패 - serviceSegment={}", serviceSegment, it) } .getOrNull() return body @@ -89,22 +75,18 @@ class TourApiClient( } // 공통정보 조회 (detailCommon2) - fun fetchTourDetail(params: TourDetailParams): TourDetailResponse { + fun fetchTourDetail( + params: TourDetailParams, + serviceSegment: String, + ): TourDetailResponse { val url = - UriComponentsBuilder.fromUri(URI.create(apiUrl)) - .path("/detailCommon2") - .queryParam("serviceKey", serviceKey) - .queryParam("MobileOS", "WEB") - .queryParam("MobileApp", "KoreaTravelGuide") - .queryParam("_type", "json") - .queryParam("contentId", params.contentId) - .build() - .encode() - .toUri() + buildTourUri(serviceSegment, "detailCommon2") { + queryParam("contentId", params.contentId) + } val body = runCatching { restTemplate.getForObject(url, String::class.java) } - .onFailure { log.error("공통정보 조회 실패", it) } + .onFailure { log.error("공통정보 조회 실패 - serviceSegment={}", serviceSegment, it) } .getOrNull() return body @@ -192,4 +174,22 @@ class TourApiClient( return itemsNode.map { it } } + + private fun buildTourUri( + serviceSegment: String, + vararg pathSegments: String, + customize: UriComponentsBuilder.() -> Unit = {}, + ): URI = + UriComponentsBuilder.fromUri(URI.create(apiUrl)) + .pathSegment(serviceSegment, *pathSegments) + .apply { + queryParam("serviceKey", serviceKey) + queryParam("MobileOS", "WEB") + queryParam("MobileApp", "KoreaTravelGuide") + queryParam("_type", "json") + customize() + } + .build() + .encode() + .toUri() } 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 b25f2eb..36e32aa 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 @@ -25,18 +25,44 @@ class TourService( return tourParamsParser.parse(contentTypeId, areaAndSigunguCode) } - fun fetchTours(tourParams: TourParams): TourResponse { - return tourAreaBasedUseCase.fetchAreaBasedTours(tourParams) + /** + * 지역 기반 관광 정보를 조회한다. + * 언어 문자열을 설정으로 정규화해 다국어 엔드포인트에 맞춰 전달한다. + */ + fun fetchTours( + tourParams: TourParams, + languageCode: String? = null, + ): TourResponse { + val serviceSegment = languageCode?.takeIf { it.isNotBlank() } ?: DEFAULT_LANGUAGE_SEGMENT + return tourAreaBasedUseCase.fetchAreaBasedTours(tourParams, serviceSegment) } + /** + * 위치 기반 관광 정보를 조회한다. + * 전달받은 언어 값을 설정 기반 서비스 세그먼트로 치환해 API 클라이언트를 호출한다. + */ fun fetchLocationBasedTours( tourParams: TourParams, locationParams: TourLocationBasedParams, + languageCode: String? = null, ): TourResponse { - return tourLocationBasedUseCase.fetchLocationBasedTours(tourParams, locationParams) + val serviceSegment = languageCode?.takeIf { it.isNotBlank() } ?: DEFAULT_LANGUAGE_SEGMENT + return tourLocationBasedUseCase.fetchLocationBasedTours(tourParams, locationParams, serviceSegment) + } + + /** + * 관광지 상세 정보를 조회한다. + * 언어 값을 정규화해 상세 API 호출 시 사용한다. + */ + fun fetchTourDetail( + detailParams: TourDetailParams, + languageCode: String? = null, + ): TourDetailResponse { + val serviceSegment = languageCode?.takeIf { it.isNotBlank() } ?: DEFAULT_LANGUAGE_SEGMENT + return tourDetailUseCase.fetchTourDetail(detailParams, serviceSegment) } - fun fetchTourDetail(detailParams: TourDetailParams): TourDetailResponse { - return tourDetailUseCase.fetchTourDetail(detailParams) + companion object { + private const val DEFAULT_LANGUAGE_SEGMENT = "KorService2" } } diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/core/TourAreaBasedServiceCore.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/core/TourAreaBasedServiceCore.kt index 45e6f47..8c5be69 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/core/TourAreaBasedServiceCore.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/core/TourAreaBasedServiceCore.kt @@ -14,10 +14,14 @@ class TourAreaBasedServiceCore( ) : TourAreaBasedUseCase { @Cacheable( "tourAreaBased", - key = "#tourParams.contentTypeId + '_' + #tourParams.areaCode + '_' + #tourParams.sigunguCode", + key = + "#tourParams.contentTypeId + '_' + #tourParams.areaCode + '_' + #tourParams.sigunguCode + '_' + #serviceSegment", unless = "#result == null", ) - override fun fetchAreaBasedTours(tourParams: TourParams): TourResponse { + override fun fetchAreaBasedTours( + tourParams: TourParams, + serviceSegment: String, + ): TourResponse { if ( tourParams.contentTypeId == "12" && tourParams.areaCode == "6" && @@ -26,7 +30,7 @@ class TourAreaBasedServiceCore( return PRESET_AREA_TOUR_RESPONSE } - return tourApiClient.fetchTourInfo(tourParams) + return tourApiClient.fetchTourInfo(tourParams, serviceSegment) } private companion object { diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/core/TourDetailServiceCore.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/core/TourDetailServiceCore.kt index 6e7b23d..de04c70 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/core/TourDetailServiceCore.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/core/TourDetailServiceCore.kt @@ -12,13 +12,20 @@ import org.springframework.stereotype.Service class TourDetailServiceCore( private val tourApiClient: TourApiClient, ) : TourDetailUseCase { - @Cacheable("tourDetail", key = "#detailParams.contentId", unless = "#result == null") - override fun fetchTourDetail(detailParams: TourDetailParams): TourDetailResponse { + @Cacheable( + "tourDetail", + key = "#detailParams.contentId + '_' + #serviceSegment", + unless = "#result == null", + ) + override fun fetchTourDetail( + detailParams: TourDetailParams, + serviceSegment: String, + ): TourDetailResponse { if (detailParams.contentId == "127974") { return PRESET_DETAIL_RESPONSE } - return tourApiClient.fetchTourDetail(detailParams) + return tourApiClient.fetchTourDetail(detailParams, serviceSegment) } private companion object { diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/core/TourLocationBasedServiceCore.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/core/TourLocationBasedServiceCore.kt index 16bda55..cc8841c 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/core/TourLocationBasedServiceCore.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/core/TourLocationBasedServiceCore.kt @@ -16,13 +16,15 @@ class TourLocationBasedServiceCore( @Cacheable( "tourLocationBased", key = - "#tourParams.contentTypeId + '_' + #tourParams.areaCode + '_' + #tourParams.sigunguCode + " + - "'_' + #locationParams.mapX + '_' + #locationParams.mapY + '_' + #locationParams.radius", + "#tourParams.contentTypeId + '_' + #tourParams.areaCode + '_' + #tourParams.sigunguCode + '_' + " + + "#locationParams.mapX + '_' + #locationParams.mapY + '_' + #locationParams.radius + '_' + " + + "#serviceSegment", unless = "#result == null", ) override fun fetchLocationBasedTours( tourParams: TourParams, locationParams: TourLocationBasedParams, + serviceSegment: String, ): TourResponse { if ( tourParams.contentTypeId == "39" && @@ -35,7 +37,7 @@ class TourLocationBasedServiceCore( return PRESET_LOCATION_BASED_RESPONSE } - return tourApiClient.fetchLocationBasedTours(tourParams, locationParams) + return tourApiClient.fetchLocationBasedTours(tourParams, locationParams, serviceSegment) } private companion object { diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/usecase/TourAreaBasedUseCase.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/usecase/TourAreaBasedUseCase.kt index 13cb184..8f5adca 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/usecase/TourAreaBasedUseCase.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/usecase/TourAreaBasedUseCase.kt @@ -4,5 +4,8 @@ import com.back.koreaTravelGuide.domain.ai.tour.dto.TourParams import com.back.koreaTravelGuide.domain.ai.tour.dto.TourResponse interface TourAreaBasedUseCase { - fun fetchAreaBasedTours(tourParams: TourParams): TourResponse + fun fetchAreaBasedTours( + tourParams: TourParams, + serviceSegment: String, + ): TourResponse } diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/usecase/TourDetailUseCase.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/usecase/TourDetailUseCase.kt index dec2eb4..00e5344 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/usecase/TourDetailUseCase.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/usecase/TourDetailUseCase.kt @@ -4,5 +4,8 @@ import com.back.koreaTravelGuide.domain.ai.tour.dto.TourDetailParams import com.back.koreaTravelGuide.domain.ai.tour.dto.TourDetailResponse interface TourDetailUseCase { - fun fetchTourDetail(detailParams: TourDetailParams): TourDetailResponse + fun fetchTourDetail( + detailParams: TourDetailParams, + serviceSegment: String, + ): TourDetailResponse } diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/usecase/TourLocationBasedUseCase.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/usecase/TourLocationBasedUseCase.kt index 507471b..6c042dd 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/usecase/TourLocationBasedUseCase.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/usecase/TourLocationBasedUseCase.kt @@ -8,5 +8,6 @@ interface TourLocationBasedUseCase { fun fetchLocationBasedTours( tourParams: TourParams, locationParams: TourLocationBasedParams, + serviceSegment: String, ): TourResponse } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2f8bc89..9fc1000 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -150,7 +150,7 @@ weather: tour: api: key: ${TOUR_API_KEY:dev-tour-api-key-placeholder} - base-url: ${TOUR_API_BASE_URL:http://apis.data.go.kr/B551011/KorService1} + base-url: ${TOUR_API_BASE_URL:http://apis.data.go.kr/B551011} # 로깅 설정 (주니어 개발자 디버깅용) @@ -197,4 +197,4 @@ custom: jwt: secret-key: ${CUSTOM__JWT__SECRET_KEY:dev-secret-key-for-local-testing-please-change} access-token-expiration-minutes: ${JWT_ACCESS_TOKEN_EXPIRATION_MINUTES:60} - refresh-token-expiration-days: ${JWT_REFRESH_TOKEN_EXPIRATION_DAYS:7} \ No newline at end of file + refresh-token-expiration-days: ${JWT_REFRESH_TOKEN_EXPIRATION_DAYS:7} diff --git a/src/main/resources/language.yml b/src/main/resources/language.yml new file mode 100644 index 0000000..beb9e67 --- /dev/null +++ b/src/main/resources/language.yml @@ -0,0 +1,8 @@ +tour: + language: + codes: + 한국어: "KorService2" + 영어: "EngService2" + 일본어: "JpnService2" + 중국어 간체: "ChsService2" + 중국어 번체: "ChtService2" 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 32cb8c9..9cbe7a8 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 @@ -27,7 +27,6 @@ import kotlin.test.assertTrue @SpringBootTest(classes = [KoreaTravelGuideApplication::class]) @ActiveProfiles("test") class TourApiClientTest { - // MockRestServiceServer 기반 단위 테스트 @Nested inner class MockServerTests { private lateinit var restTemplate: RestTemplate @@ -37,6 +36,9 @@ class TourApiClientTest { private val serviceKey = "test-service-key" private val baseUrl = "https://example.com" + private val koreanSegment = "KorService2" + private val englishSegment = "EngService2" + @BeforeEach fun setUp() { restTemplate = RestTemplate() @@ -59,11 +61,11 @@ class TourApiClientTest { sigunguCode = "1", ) - mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params))) + mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params, koreanSegment))) .andExpect(method(HttpMethod.GET)) .andRespond(withSuccess(SUCCESS_RESPONSE, MediaType.APPLICATION_JSON)) - val result = mockClient.fetchTourInfo(params) + val result = mockClient.fetchTourInfo(params, koreanSegment) assertEquals(1, result.items.size) val firstItem = result.items.first() @@ -81,18 +83,38 @@ class TourApiClientTest { sigunguCode = "1", ) - mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params))) + mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params, koreanSegment))) .andExpect(method(HttpMethod.GET)) .andRespond(withStatus(HttpStatus.NOT_FOUND)) - val result = mockClient.fetchTourInfo(params) + val result = mockClient.fetchTourInfo(params, koreanSegment) assertTrue(result.items.isEmpty()) } - private fun expectedAreaBasedListUrl(params: TourParams): String = + @DisplayName("fetchTourInfo - 언어별 서비스 세그먼트를 선택해 요청한다") + @Test + fun fetchTourInfoRespectsLanguageSegment() { + val params = + TourParams( + contentTypeId = "12", + areaCode = "1", + sigunguCode = "1", + ) + + mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params, englishSegment))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withStatus(HttpStatus.NOT_FOUND)) + + mockClient.fetchTourInfo(params, englishSegment) + } + + private fun expectedAreaBasedListUrl( + params: TourParams, + serviceSegment: String, + ): String = UriComponentsBuilder.fromUriString(baseUrl) - .path("/areaBasedList2") + .pathSegment(serviceSegment, "areaBasedList2") .queryParam("serviceKey", serviceKey) .queryParam("MobileOS", "WEB") .queryParam("MobileApp", "KoreaTravelGuide") diff --git a/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/core/TourAreaBasedServiceCoreCacheTest.kt b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/core/TourAreaBasedServiceCoreCacheTest.kt index cd5e7c7..bdcdc23 100644 --- a/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/core/TourAreaBasedServiceCoreCacheTest.kt +++ b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/core/TourAreaBasedServiceCoreCacheTest.kt @@ -5,9 +5,11 @@ import com.back.koreaTravelGuide.domain.ai.tour.dto.TourItem import com.back.koreaTravelGuide.domain.ai.tour.dto.TourParams import com.back.koreaTravelGuide.domain.ai.tour.dto.TourResponse import com.back.koreaTravelGuide.domain.ai.tour.service.usecase.TourAreaBasedUseCase +import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk import io.mockk.verify +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired @@ -27,9 +29,21 @@ class TourAreaBasedServiceCoreCacheTest { @Autowired private lateinit var tourApiClient: TourApiClient - @DisplayName("fetchAreaBasedTours - 동일 파라미터 두 번 호출 시 API는 한 번만 호출된다") + @Autowired + private lateinit var cacheManager: CacheManager + + private val koreanSegment = "KorService2" + private val englishSegment = "EngService2" + + @BeforeEach + fun clearCaches() { + cacheManager.getCache("tourAreaBased")?.clear() + clearMocks(tourApiClient) + } + + @DisplayName("fetchAreaBasedTours - 동일 파라미터·언어 조합은 한 번만 외부 API를 호출한다") @Test - fun cachesAreaBasedTours() { + fun cachesAreaBasedToursPerLanguage() { val params = TourParams(contentTypeId = "15", areaCode = "3", sigunguCode = "5") val apiResponse = TourResponse( @@ -56,16 +70,67 @@ class TourAreaBasedServiceCoreCacheTest { ), ) - every { tourApiClient.fetchTourInfo(params) } returns apiResponse + every { tourApiClient.fetchTourInfo(params, koreanSegment) } returns apiResponse - val firstCall = service.fetchAreaBasedTours(params) - val secondCall = service.fetchAreaBasedTours(params) + val firstCall = service.fetchAreaBasedTours(params, koreanSegment) + val secondCall = service.fetchAreaBasedTours(params, koreanSegment) assertEquals(apiResponse, firstCall) assertEquals(apiResponse, secondCall) - verify(exactly = 1) { tourApiClient.fetchTourInfo(params) } + verify(exactly = 1) { tourApiClient.fetchTourInfo(params, koreanSegment) } + } + + @DisplayName("fetchAreaBasedTours - 언어가 다르면 각각 캐시가 생성된다") + @Test + fun cachesSeparatelyPerLanguage() { + val params = TourParams(contentTypeId = "15", areaCode = "3", sigunguCode = "5") + val koreanResponse = simpleTourResponse(contentId = "ko", title = "국문") + val englishResponse = simpleTourResponse(contentId = "en", title = "English") + + every { tourApiClient.fetchTourInfo(params, koreanSegment) } returns koreanResponse + every { tourApiClient.fetchTourInfo(params, englishSegment) } returns englishResponse + + val koreanFirst = service.fetchAreaBasedTours(params, koreanSegment) + val englishFirst = service.fetchAreaBasedTours(params, englishSegment) + val koreanSecond = service.fetchAreaBasedTours(params, koreanSegment) + val englishSecond = service.fetchAreaBasedTours(params, englishSegment) + + assertEquals(koreanResponse, koreanFirst) + assertEquals(englishResponse, englishFirst) + assertEquals(koreanResponse, koreanSecond) + assertEquals(englishResponse, englishSecond) + verify(exactly = 1) { tourApiClient.fetchTourInfo(params, koreanSegment) } + verify(exactly = 1) { tourApiClient.fetchTourInfo(params, englishSegment) } } + private fun simpleTourResponse( + contentId: String, + title: String, + ): TourResponse = + TourResponse( + items = + listOf( + TourItem( + contentId = contentId, + contentTypeId = "15", + createdTime = "202401010000", + modifiedTime = "202401020000", + title = title, + addr1 = "대전", + areaCode = "3", + firstimage = null, + firstimage2 = null, + mapX = null, + mapY = null, + distance = null, + mlevel = null, + sigunguCode = "5", + lDongRegnCd = null, + lDongSignguCd = null, + ), + ), + ) + @Configuration @EnableCaching class Config { diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 8154d0f..48612ce 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -7,7 +7,7 @@ weather.api.base-url=https://apis.data.go.kr/1360000/MidFcstInfoService # Tour API Configuration tour.api.key=${TOUR_API_KEY:Pp8aOoKZql09DdDfb4r9SsFWIepIqaocvCQzJphWcvmBj0ff9KuvikfKjgxrXqK03JNrmOIjOZyLyZhjlY43AQ==} -tour.api.base-url=${TOUR_API_BASE_URL:https://apis.data.go.kr/B551011/KorService2} +tour.api.base-url=${TOUR_API_BASE_URL:https://apis.data.go.kr/B551011} # Spring AI Configuration spring.ai.openai.api-key=${OPENAI_API_KEY:test-key} @@ -24,4 +24,4 @@ spring.h2.console.enabled=false # Logging logging.level.com.back.koreaTravelGuide=DEBUG -logging.level.org.springframework.web.client.RestTemplate=DEBUG \ No newline at end of file +logging.level.org.springframework.web.client.RestTemplate=DEBUG