From 8dfe572e9b04040ebc4ea8587ff4d6d94f1e1d89 Mon Sep 17 00:00:00 2001 From: YangHJ2415 Date: Mon, 13 Oct 2025 02:55:19 +0900 Subject: [PATCH 1/9] =?UTF-8?q?fix(be)=20:=20Test=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=ED=86=B5=EA=B3=BC=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95,=20Mock=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt --- .../ai/tour/client/TourApiClientTest.kt | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) 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..08ca04a 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 @@ -4,10 +4,12 @@ import com.back.koreaTravelGuide.KoreaTravelGuideApplication import com.back.koreaTravelGuide.domain.ai.tour.dto.TourParams import com.fasterxml.jackson.databind.ObjectMapper import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assumptions.assumeTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus @@ -24,9 +26,32 @@ import org.springframework.web.util.UriComponentsBuilder import kotlin.test.assertEquals import kotlin.test.assertTrue +// 실제 API 호출 기반 단위 테스트 @SpringBootTest(classes = [KoreaTravelGuideApplication::class]) @ActiveProfiles("test") -class TourApiClientTest { +class TourApiClientTest @Autowired constructor( + private val tourApiClient: TourApiClient, +) { + @DisplayName("fetchTourInfo - 실제 관광청 API가 빈 응답을 줄 경우") + @Test + fun fetchTourInfoRealCallEmptyResponse() { + val params = + TourParams( + contentTypeId = "12", + areaCode = "1", + sigunguCode = "1", + ) + + val result = tourApiClient.fetchTourInfo(params) + + // isEmpty가 true인 경우 테스트를 진행, 아닐 경우 메세지 출력 + assumeTrue(result.items.isEmpty()) { + "관광청 API가 정상 데이터를 제공하고 있어 장애 시나리오 테스트를 건너뜁니다." + } + + assertTrue(result.items.isEmpty()) + } + // MockRestServiceServer 기반 단위 테스트 @Nested inner class MockServerTests { @@ -103,6 +128,7 @@ class TourApiClientTest { .build() .encode() .toUriString() + } companion object { From 6abb09c666af36e52d8f19ee159ba98322aafc3c Mon Sep 17 00:00:00 2001 From: YangHJ2415 Date: Mon, 13 Oct 2025 03:21:22 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat(be)=20:=20service=EC=9D=98=20parsing?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/tour/client/TourApiClientTest.kt | 221 +++++++++--------- .../ai/tour/service/TourParamsParserTest.kt | 4 +- 2 files changed, 113 insertions(+), 112 deletions(-) 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 08ca04a..b1f7c87 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 @@ -29,54 +29,14 @@ import kotlin.test.assertTrue // 실제 API 호출 기반 단위 테스트 @SpringBootTest(classes = [KoreaTravelGuideApplication::class]) @ActiveProfiles("test") -class TourApiClientTest @Autowired constructor( - private val tourApiClient: TourApiClient, -) { - @DisplayName("fetchTourInfo - 실제 관광청 API가 빈 응답을 줄 경우") - @Test - fun fetchTourInfoRealCallEmptyResponse() { - val params = - TourParams( - contentTypeId = "12", - areaCode = "1", - sigunguCode = "1", - ) - - val result = tourApiClient.fetchTourInfo(params) - - // isEmpty가 true인 경우 테스트를 진행, 아닐 경우 메세지 출력 - assumeTrue(result.items.isEmpty()) { - "관광청 API가 정상 데이터를 제공하고 있어 장애 시나리오 테스트를 건너뜁니다." - } - - assertTrue(result.items.isEmpty()) - } - - // MockRestServiceServer 기반 단위 테스트 - @Nested - inner class MockServerTests { - private lateinit var restTemplate: RestTemplate - private lateinit var mockServer: MockRestServiceServer - private lateinit var mockClient: TourApiClient - - private val serviceKey = "test-service-key" - private val baseUrl = "https://example.com" - - @BeforeEach - fun setUp() { - restTemplate = RestTemplate() - mockServer = MockRestServiceServer.createServer(restTemplate) - mockClient = TourApiClient(restTemplate, ObjectMapper(), serviceKey, baseUrl) - } - - @AfterEach - fun tearDown() { - mockServer.verify() - } - - @DisplayName("fetchTourInfo - 외부 API가 정상 응답을 반환하면 파싱된 결과를 제공") +class TourApiClientTest + @Autowired + constructor( + private val tourApiClient: TourApiClient, + ) { + @DisplayName("fetchTourInfo - 실제 관광청 API가 빈 응답을 줄 경우") @Test - fun fetchTourInfoReturnsParsedItems() { + fun fetchTourInfoRealCallEmptyResponse() { val params = TourParams( contentTypeId = "12", @@ -84,80 +44,121 @@ class TourApiClientTest @Autowired constructor( sigunguCode = "1", ) - mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params))) - .andExpect(method(HttpMethod.GET)) - .andRespond(withSuccess(SUCCESS_RESPONSE, MediaType.APPLICATION_JSON)) + val result = tourApiClient.fetchTourInfo(params) - val result = mockClient.fetchTourInfo(params) + // isEmpty가 true인 경우 테스트를 진행, 아닐 경우 메세지 출력 + assumeTrue(result.items.isEmpty()) { + "관광청 API가 정상 데이터를 제공하고 있어 장애 시나리오 테스트를 건너뜁니다." + } - assertEquals(1, result.items.size) - val firstItem = result.items.first() - assertEquals("12345", firstItem.contentId) - assertEquals("테스트 타이틀", firstItem.title) + assertTrue(result.items.isEmpty()) } - @DisplayName("fetchTourInfo - 외부 API가 404를 반환하면 빈 결과를 전달") - @Test - fun fetchTourInfoReturnsEmptyListWhenApiFails() { - val params = - TourParams( - contentTypeId = "12", - areaCode = "1", - sigunguCode = "1", - ) + // MockRestServiceServer 기반 단위 테스트 + @Nested + inner class MockServerTests { + private lateinit var restTemplate: RestTemplate + private lateinit var mockServer: MockRestServiceServer + private lateinit var mockClient: TourApiClient + + private val serviceKey = "test-service-key" + private val baseUrl = "https://example.com" + + @BeforeEach + fun setUp() { + restTemplate = RestTemplate() + mockServer = MockRestServiceServer.createServer(restTemplate) + mockClient = TourApiClient(restTemplate, ObjectMapper(), serviceKey, baseUrl) + } - mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params))) - .andExpect(method(HttpMethod.GET)) - .andRespond(withStatus(HttpStatus.NOT_FOUND)) + @AfterEach + fun tearDown() { + mockServer.verify() + } - val result = mockClient.fetchTourInfo(params) + @DisplayName("fetchTourInfo - 외부 API가 정상 응답을 반환하면 파싱된 결과를 제공") + @Test + fun fetchTourInfoReturnsParsedItems() { + val params = + TourParams( + contentTypeId = "12", + areaCode = "1", + sigunguCode = "1", + ) + + mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess(SUCCESS_RESPONSE, MediaType.APPLICATION_JSON)) + + val result = mockClient.fetchTourInfo(params) + + assertEquals(1, result.items.size) + val firstItem = result.items.first() + assertEquals("12345", firstItem.contentId) + assertEquals("테스트 타이틀", firstItem.title) + } - assertTrue(result.items.isEmpty()) - } + @DisplayName("fetchTourInfo - 외부 API가 404를 반환하면 빈 결과를 전달") + @Test + fun fetchTourInfoReturnsEmptyListWhenApiFails() { + val params = + TourParams( + contentTypeId = "12", + areaCode = "1", + sigunguCode = "1", + ) - private fun expectedAreaBasedListUrl(params: TourParams): String = - UriComponentsBuilder.fromUriString(baseUrl) - .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() - .toUriString() + mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withStatus(HttpStatus.NOT_FOUND)) - } + val result = mockClient.fetchTourInfo(params) + + assertTrue(result.items.isEmpty()) + } - companion object { - private val SUCCESS_RESPONSE = - """ - { - "response": { - "header": { - "resultCode": "0000", - "resultMsg": "OK" - }, - "body": { - "items": { - "item": [ - { - "contentid": "12345", - "contenttypeid": "12", - "createdtime": "202310010000", - "modifiedtime": "202310020000", - "title": "테스트 타이틀", - "addr1": "서울특별시 종로구", - "areacode": "1", - "firstimage": "https://example.com/image.jpg" + private fun expectedAreaBasedListUrl(params: TourParams): String = + UriComponentsBuilder.fromUriString(baseUrl) + .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() + .toUriString() + } + + companion object { + private val SUCCESS_RESPONSE = + """ + { + "response": { + "header": { + "resultCode": "0000", + "resultMsg": "OK" + }, + "body": { + "items": { + "item": [ + { + "contentid": "12345", + "contenttypeid": "12", + "createdtime": "202310010000", + "modifiedtime": "202310020000", + "title": "테스트 타이틀", + "addr1": "서울특별시 종로구", + "areacode": "1", + "firstimage": "https://example.com/image.jpg" + } + ] } - ] + } } } - } - } - """.trimIndent() + """.trimIndent() + } } -} diff --git a/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourParamsParserTest.kt b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourParamsParserTest.kt index 20a4835..d000173 100644 --- a/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourParamsParserTest.kt +++ b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourParamsParserTest.kt @@ -1,9 +1,9 @@ package com.back.koreaTravelGuide.domain.ai.tour.service -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test import kotlin.test.assertEquals import kotlin.test.assertNull +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test class TourParamsParserTest { private val parser = TourParamsParser() From 9ce915965e715dcbfeac216234722106d1a8a4e7 Mon Sep 17 00:00:00 2001 From: YangHJ2415 Date: Mon, 13 Oct 2025 09:39:21 +0900 Subject: [PATCH 3/9] =?UTF-8?q?refactor(be)=20:=20=EC=8B=A4=EC=A0=9C=20api?= =?UTF-8?q?=20=ED=98=B8=EC=B6=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/tour/client/TourApiClientTest.kt | 203 ++++++++---------- 1 file changed, 88 insertions(+), 115 deletions(-) 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 b1f7c87..32cb8c9 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 @@ -4,12 +4,10 @@ import com.back.koreaTravelGuide.KoreaTravelGuideApplication import com.back.koreaTravelGuide.domain.ai.tour.dto.TourParams import com.fasterxml.jackson.databind.ObjectMapper import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assumptions.assumeTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus @@ -26,17 +24,34 @@ import org.springframework.web.util.UriComponentsBuilder import kotlin.test.assertEquals import kotlin.test.assertTrue -// 실제 API 호출 기반 단위 테스트 @SpringBootTest(classes = [KoreaTravelGuideApplication::class]) @ActiveProfiles("test") -class TourApiClientTest - @Autowired - constructor( - private val tourApiClient: TourApiClient, - ) { - @DisplayName("fetchTourInfo - 실제 관광청 API가 빈 응답을 줄 경우") +class TourApiClientTest { + // MockRestServiceServer 기반 단위 테스트 + @Nested + inner class MockServerTests { + private lateinit var restTemplate: RestTemplate + private lateinit var mockServer: MockRestServiceServer + private lateinit var mockClient: TourApiClient + + private val serviceKey = "test-service-key" + private val baseUrl = "https://example.com" + + @BeforeEach + fun setUp() { + restTemplate = RestTemplate() + mockServer = MockRestServiceServer.createServer(restTemplate) + mockClient = TourApiClient(restTemplate, ObjectMapper(), serviceKey, baseUrl) + } + + @AfterEach + fun tearDown() { + mockServer.verify() + } + + @DisplayName("fetchTourInfo - 외부 API가 정상 응답을 반환하면 파싱된 결과를 제공") @Test - fun fetchTourInfoRealCallEmptyResponse() { + fun fetchTourInfoReturnsParsedItems() { val params = TourParams( contentTypeId = "12", @@ -44,121 +59,79 @@ class TourApiClientTest sigunguCode = "1", ) - val result = tourApiClient.fetchTourInfo(params) + mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess(SUCCESS_RESPONSE, MediaType.APPLICATION_JSON)) - // isEmpty가 true인 경우 테스트를 진행, 아닐 경우 메세지 출력 - assumeTrue(result.items.isEmpty()) { - "관광청 API가 정상 데이터를 제공하고 있어 장애 시나리오 테스트를 건너뜁니다." - } + val result = mockClient.fetchTourInfo(params) - assertTrue(result.items.isEmpty()) + assertEquals(1, result.items.size) + val firstItem = result.items.first() + assertEquals("12345", firstItem.contentId) + assertEquals("테스트 타이틀", firstItem.title) } - // MockRestServiceServer 기반 단위 테스트 - @Nested - inner class MockServerTests { - private lateinit var restTemplate: RestTemplate - private lateinit var mockServer: MockRestServiceServer - private lateinit var mockClient: TourApiClient - - private val serviceKey = "test-service-key" - private val baseUrl = "https://example.com" - - @BeforeEach - fun setUp() { - restTemplate = RestTemplate() - mockServer = MockRestServiceServer.createServer(restTemplate) - mockClient = TourApiClient(restTemplate, ObjectMapper(), serviceKey, baseUrl) - } - - @AfterEach - fun tearDown() { - mockServer.verify() - } - - @DisplayName("fetchTourInfo - 외부 API가 정상 응답을 반환하면 파싱된 결과를 제공") - @Test - fun fetchTourInfoReturnsParsedItems() { - val params = - TourParams( - contentTypeId = "12", - areaCode = "1", - sigunguCode = "1", - ) - - mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params))) - .andExpect(method(HttpMethod.GET)) - .andRespond(withSuccess(SUCCESS_RESPONSE, MediaType.APPLICATION_JSON)) - - val result = mockClient.fetchTourInfo(params) - - assertEquals(1, result.items.size) - val firstItem = result.items.first() - assertEquals("12345", firstItem.contentId) - assertEquals("테스트 타이틀", firstItem.title) - } - - @DisplayName("fetchTourInfo - 외부 API가 404를 반환하면 빈 결과를 전달") - @Test - fun fetchTourInfoReturnsEmptyListWhenApiFails() { - val params = - TourParams( - contentTypeId = "12", - areaCode = "1", - sigunguCode = "1", - ) - - mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params))) - .andExpect(method(HttpMethod.GET)) - .andRespond(withStatus(HttpStatus.NOT_FOUND)) + @DisplayName("fetchTourInfo - 외부 API가 404를 반환하면 빈 결과를 전달") + @Test + fun fetchTourInfoReturnsEmptyListWhenApiFails() { + val params = + TourParams( + contentTypeId = "12", + areaCode = "1", + sigunguCode = "1", + ) - val result = mockClient.fetchTourInfo(params) + mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withStatus(HttpStatus.NOT_FOUND)) - assertTrue(result.items.isEmpty()) - } + val result = mockClient.fetchTourInfo(params) - private fun expectedAreaBasedListUrl(params: TourParams): String = - UriComponentsBuilder.fromUriString(baseUrl) - .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() - .toUriString() + assertTrue(result.items.isEmpty()) } - companion object { - private val SUCCESS_RESPONSE = - """ - { - "response": { - "header": { - "resultCode": "0000", - "resultMsg": "OK" - }, - "body": { - "items": { - "item": [ - { - "contentid": "12345", - "contenttypeid": "12", - "createdtime": "202310010000", - "modifiedtime": "202310020000", - "title": "테스트 타이틀", - "addr1": "서울특별시 종로구", - "areacode": "1", - "firstimage": "https://example.com/image.jpg" - } - ] + private fun expectedAreaBasedListUrl(params: TourParams): String = + UriComponentsBuilder.fromUriString(baseUrl) + .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() + .toUriString() + } + + companion object { + private val SUCCESS_RESPONSE = + """ + { + "response": { + "header": { + "resultCode": "0000", + "resultMsg": "OK" + }, + "body": { + "items": { + "item": [ + { + "contentid": "12345", + "contenttypeid": "12", + "createdtime": "202310010000", + "modifiedtime": "202310020000", + "title": "테스트 타이틀", + "addr1": "서울특별시 종로구", + "areacode": "1", + "firstimage": "https://example.com/image.jpg" } - } + ] } } - """.trimIndent() - } + } + } + """.trimIndent() } +} From 015cdeff4d8d1f1ca03ec6f60ba6c6a071442c5f Mon Sep 17 00:00:00 2001 From: YangHJ2415 Date: Mon, 13 Oct 2025 11:45:00 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat(be)=20:=20=EC=96=B8=EC=96=B4=EB=B3=84?= =?UTF-8?q?=20=ED=98=B8=EC=B6=9C(=ED=95=9C,=20=EC=A4=91,=20=EC=9D=BC,=20?= =?UTF-8?q?=EC=98=81=EC=96=B4)=20=EC=B6=94=EA=B0=80=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20Client=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/ai/tour/client/TourApiClient.kt | 107 ++++++++++-------- .../domain/ai/tour/client/TourLanguage.kt | 37 ++++++ 2 files changed, 99 insertions(+), 45 deletions(-) create mode 100644 src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourLanguage.kt 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..aff6613 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,24 +24,17 @@ 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, + language: TourLanguage = TourLanguage.KOREAN, + ): TourResponse { + val url = + buildTourUri(language, "areaBasedList2") { + queryParam("contentTypeId", params.contentTypeId) + queryParam("areaCode", params.areaCode) + queryParam("sigunguCode", params.sigunguCode) + } val body = runCatching { restTemplate.getForObject(url, String::class.java) } @@ -59,23 +51,17 @@ class TourApiClient( fun fetchLocationBasedTours( tourParams: TourParams, locationParams: TourLocationBasedParams, + language: TourLanguage = TourLanguage.KOREAN, ): 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(language, "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) } @@ -89,18 +75,14 @@ class TourApiClient( } // 공통정보 조회 (detailCommon2) - fun fetchTourDetail(params: TourDetailParams): TourDetailResponse { + fun fetchTourDetail( + params: TourDetailParams, + language: TourLanguage = TourLanguage.KOREAN, + ): 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(language, "detailCommon2") { + queryParam("contentId", params.contentId) + } val body = runCatching { restTemplate.getForObject(url, String::class.java) } @@ -113,6 +95,10 @@ class TourApiClient( ?: TourDetailResponse(items = emptyList()) } + /* + * areaBasedList2/locationBasedList2 JSON 응답에서 `response.body.items.item` 배열만 추출 + * Kotlin 도메인 모델(TourItem) 목록으로 변환한다. item 노드가 비어 있으면 빈 목록을 반환한다. + */ private fun parseItems(json: String): TourResponse { val itemNodes = extractItemNodes(json, "관광 정보") if (itemNodes.isEmpty()) return TourResponse(items = emptyList()) @@ -142,6 +128,10 @@ class TourApiClient( return TourResponse(items = items) } + /* + * detailCommon2 JSON 응답을 파싱해 `TourDetailItem` 목록으로 변환한다. + * 항목이 없으면 빈 응답을 돌려준다. 필드 구성이 다르기 때문에 detailCommon2 은 별도 파서로 분리한다. + */ private fun parseDetailItems(json: String): TourDetailResponse { val itemNodes = extractItemNodes(json, "공통정보") if (itemNodes.isEmpty()) return TourDetailResponse(items = emptyList()) @@ -164,6 +154,10 @@ class TourApiClient( return TourDetailResponse(items = items) } + /* + * 공통 응답 구조에서 resultCode가 성공(0000)인지 검사한 뒤 `item` 배열 노드를 리스트로 반환한다. + * 실패 코드거나 배열이 비어 있으면 빈 리스트를 돌려준다. + */ private fun extractItemNodes( json: String, apiName: String, @@ -192,4 +186,27 @@ class TourApiClient( return itemsNode.map { it } } + + /** + * 공통 URL 빌더 + * - 설정된 기본 URL을 바탕으로 언어 세그먼트(e.g. KorService2, EngService2)와 공통 쿼리 파라미터 + * - (`serviceKey`, `MobileOS`, `MobileApp`, `_type`)를 자동으로 붙여 관광 API 호출용 URI를 만든다. + */ + private fun buildTourUri( + language: TourLanguage, + vararg pathSegments: String, + customize: UriComponentsBuilder.() -> Unit = {}, + ): URI = + UriComponentsBuilder.fromUri(URI.create(apiUrl)) + .pathSegment(language.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/client/TourLanguage.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourLanguage.kt new file mode 100644 index 0000000..0d9c5f3 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourLanguage.kt @@ -0,0 +1,37 @@ +package com.back.koreaTravelGuide.domain.ai.tour.client + +/** + * 한국관광공사 오픈API의 언어별 서비스 세그먼트 매핑. + * + * TourTool 등에서 감지한 사용자의 언어 문자열을 이 enum으로 정규화한 뒤, + * `serviceSegment` 값을 사용해 API 엔드포인트 경로(예: KorService2, EngService2)를 동적으로 구성한다. + */ +enum class TourLanguage( + private val aliases: Set, + val serviceSegment: String, +) { + KOREAN(setOf("kor", "korean", "한국어"), "KorService2"), + ENGLISH(setOf("eng", "english", "영어"), "EngService2"), + JAPANESE(setOf("jpn", "japanese", "일본어"), "JpnService2"), + CHINESE_SIMPLIFIED(setOf("zh-cn", "chs", "중국어 간체", "간체"), "ChsService2"), + CHINESE_TRADITIONAL(setOf("zh-tw", "cht", "중국어 번체", "번체"), "ChtService2"); + + companion object { + /** + * 사용자나 AI가 넘긴 언어 문자열을 enum 값으로 정규화. + * - null/빈 값이면 기본 한국어(KOREAN)를 반환 + * - 공백을 제거하고 소문자로 만든 뒤, enum 이름이나 alias 목록과 매칭 + * - 어떤 항목과도 매칭되지 않으면 기본값(KOREAN)으로 반환 + */ + fun from(raw: String?): TourLanguage { + if (raw.isNullOrBlank()) return KOREAN + + val normalized = raw.trim().lowercase() + return entries.firstOrNull { lang -> + normalized == lang.name.lowercase() || + normalized in lang.aliases || + normalized.replace(" ", "") in lang.aliases.map { it.replace(" ", "") } + } ?: KOREAN + } + } +} From 1c11c6f5ee94700335a533dd436077bcc3d23e3d Mon Sep 17 00:00:00 2001 From: YangHJ2415 Date: Mon, 13 Oct 2025 11:51:12 +0900 Subject: [PATCH 5/9] =?UTF-8?q?fix(be)=20:=20=EC=96=B8=EC=96=B4=20?= =?UTF-8?q?=EB=B3=84=20url=20=EA=B5=AC=EB=B6=84=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8,=20TOUR=5FAPI=5FBASE=5FURL=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 4 ++-- src/test/resources/application-test.properties | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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/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 From b8de77be465f38750dcdfd5fba6f60bf78d37dac Mon Sep 17 00:00:00 2001 From: YangHJ2415 Date: Mon, 13 Oct 2025 14:08:42 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat(be)=20:=20=EC=84=9C=EB=B9=84=EC=8A=A4,?= =?UTF-8?q?=20=EC=BD=94=EC=96=B4,=20=EC=9C=A0=EC=A6=88=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=EA=B9=8C=EC=A7=80=20TourLanguage=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/ai/tour/service/TourService.kt | 33 ++++++++++++++++--- .../service/core/TourAreaBasedServiceCore.kt | 11 +++++-- .../service/core/TourDetailServiceCore.kt | 14 ++++++-- .../core/TourLocationBasedServiceCore.kt | 9 +++-- .../service/usecase/TourAreaBasedUseCase.kt | 6 +++- .../tour/service/usecase/TourDetailUseCase.kt | 6 +++- .../usecase/TourLocationBasedUseCase.kt | 2 ++ 7 files changed, 65 insertions(+), 16 deletions(-) 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..96ce95f 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 @@ -1,5 +1,6 @@ package com.back.koreaTravelGuide.domain.ai.tour.service +import com.back.koreaTravelGuide.domain.ai.tour.client.TourLanguage import com.back.koreaTravelGuide.domain.ai.tour.dto.TourDetailParams import com.back.koreaTravelGuide.domain.ai.tour.dto.TourDetailResponse import com.back.koreaTravelGuide.domain.ai.tour.dto.TourLocationBasedParams @@ -25,18 +26,40 @@ class TourService( return tourParamsParser.parse(contentTypeId, areaAndSigunguCode) } - fun fetchTours(tourParams: TourParams): TourResponse { - return tourAreaBasedUseCase.fetchAreaBasedTours(tourParams) + /** + * 지역 기반 관광 정보를 조회한다. + * 언어 문자열을 enum으로 정규화해 다국어 엔드포인트에 맞춰 전달한다. + */ + fun fetchTours( + tourParams: TourParams, + languageCode: String? = null, + ): TourResponse { + val language = TourLanguage.from(languageCode) + return tourAreaBasedUseCase.fetchAreaBasedTours(tourParams, language) } + /** + * 위치 기반 관광 정보를 조회한다. + * 전달받은 언어 코드를 enum으로 치환해 API 클라이언트에 전달한다. + */ fun fetchLocationBasedTours( tourParams: TourParams, locationParams: TourLocationBasedParams, + languageCode: String? = null, ): TourResponse { - return tourLocationBasedUseCase.fetchLocationBasedTours(tourParams, locationParams) + val language = TourLanguage.from(languageCode) + return tourLocationBasedUseCase.fetchLocationBasedTours(tourParams, locationParams, language) } - fun fetchTourDetail(detailParams: TourDetailParams): TourDetailResponse { - return tourDetailUseCase.fetchTourDetail(detailParams) + /** + * 관광지 상세 정보를 조회한다. + * 언어 코드를 정규화해 상세 API 호출 시 사용한다. + */ + fun fetchTourDetail( + detailParams: TourDetailParams, + languageCode: String? = null, + ): TourDetailResponse { + val language = TourLanguage.from(languageCode) + return tourDetailUseCase.fetchTourDetail(detailParams, language) } } 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..2fa96df 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 @@ -1,6 +1,7 @@ package com.back.koreaTravelGuide.domain.ai.tour.service.core import com.back.koreaTravelGuide.domain.ai.tour.client.TourApiClient +import com.back.koreaTravelGuide.domain.ai.tour.client.TourLanguage 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 @@ -14,10 +15,14 @@ class TourAreaBasedServiceCore( ) : TourAreaBasedUseCase { @Cacheable( "tourAreaBased", - key = "#tourParams.contentTypeId + '_' + #tourParams.areaCode + '_' + #tourParams.sigunguCode", + key = + "#tourParams.contentTypeId + '_' + #tourParams.areaCode + '_' + #tourParams.sigunguCode + '_' + #language.serviceSegment", unless = "#result == null", ) - override fun fetchAreaBasedTours(tourParams: TourParams): TourResponse { + override fun fetchAreaBasedTours( + tourParams: TourParams, + language: TourLanguage, + ): TourResponse { if ( tourParams.contentTypeId == "12" && tourParams.areaCode == "6" && @@ -26,7 +31,7 @@ class TourAreaBasedServiceCore( return PRESET_AREA_TOUR_RESPONSE } - return tourApiClient.fetchTourInfo(tourParams) + return tourApiClient.fetchTourInfo(tourParams, language) } 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..bb03280 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 @@ -1,6 +1,7 @@ package com.back.koreaTravelGuide.domain.ai.tour.service.core import com.back.koreaTravelGuide.domain.ai.tour.client.TourApiClient +import com.back.koreaTravelGuide.domain.ai.tour.client.TourLanguage import com.back.koreaTravelGuide.domain.ai.tour.dto.TourDetailItem import com.back.koreaTravelGuide.domain.ai.tour.dto.TourDetailParams import com.back.koreaTravelGuide.domain.ai.tour.dto.TourDetailResponse @@ -12,13 +13,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 + '_' + #language.serviceSegment", + unless = "#result == null", + ) + override fun fetchTourDetail( + detailParams: TourDetailParams, + language: TourLanguage, + ): TourDetailResponse { if (detailParams.contentId == "127974") { return PRESET_DETAIL_RESPONSE } - return tourApiClient.fetchTourDetail(detailParams) + return tourApiClient.fetchTourDetail(detailParams, language) } 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..50c84e6 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 @@ -1,6 +1,7 @@ package com.back.koreaTravelGuide.domain.ai.tour.service.core import com.back.koreaTravelGuide.domain.ai.tour.client.TourApiClient +import com.back.koreaTravelGuide.domain.ai.tour.client.TourLanguage import com.back.koreaTravelGuide.domain.ai.tour.dto.TourItem import com.back.koreaTravelGuide.domain.ai.tour.dto.TourLocationBasedParams import com.back.koreaTravelGuide.domain.ai.tour.dto.TourParams @@ -16,13 +17,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 + '_' + " + + "#language.serviceSegment", unless = "#result == null", ) override fun fetchLocationBasedTours( tourParams: TourParams, locationParams: TourLocationBasedParams, + language: TourLanguage, ): TourResponse { if ( tourParams.contentTypeId == "39" && @@ -35,7 +38,7 @@ class TourLocationBasedServiceCore( return PRESET_LOCATION_BASED_RESPONSE } - return tourApiClient.fetchLocationBasedTours(tourParams, locationParams) + return tourApiClient.fetchLocationBasedTours(tourParams, locationParams, language) } 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..2d51cc8 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 @@ -1,8 +1,12 @@ package com.back.koreaTravelGuide.domain.ai.tour.service.usecase +import com.back.koreaTravelGuide.domain.ai.tour.client.TourLanguage 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, + language: TourLanguage, + ): 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..2dc230e 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 @@ -1,8 +1,12 @@ package com.back.koreaTravelGuide.domain.ai.tour.service.usecase +import com.back.koreaTravelGuide.domain.ai.tour.client.TourLanguage 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, + language: TourLanguage, + ): 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..ae8e927 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 @@ -1,5 +1,6 @@ package com.back.koreaTravelGuide.domain.ai.tour.service.usecase +import com.back.koreaTravelGuide.domain.ai.tour.client.TourLanguage import com.back.koreaTravelGuide.domain.ai.tour.dto.TourLocationBasedParams import com.back.koreaTravelGuide.domain.ai.tour.dto.TourParams import com.back.koreaTravelGuide.domain.ai.tour.dto.TourResponse @@ -8,5 +9,6 @@ interface TourLocationBasedUseCase { fun fetchLocationBasedTours( tourParams: TourParams, locationParams: TourLocationBasedParams, + language: TourLanguage, ): TourResponse } From 39654848ee458bdbf5c34ab7a2d71dfdb2a292c2 Mon Sep 17 00:00:00 2001 From: YangHJ2415 Date: Mon, 13 Oct 2025 14:32:02 +0900 Subject: [PATCH 7/9] =?UTF-8?q?fix(be)=20:=20Test=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/tour/client/TourApiClientTest.kt | 28 ++++++- .../ai/tour/service/TourParamsParserTest.kt | 4 +- .../core/TourAreaBasedServiceCoreCacheTest.kt | 75 +++++++++++++++++-- 3 files changed, 95 insertions(+), 12 deletions(-) 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..21a2f88 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 @@ -59,7 +59,7 @@ class TourApiClientTest { sigunguCode = "1", ) - mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params))) + mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params, TourLanguage.KOREAN))) .andExpect(method(HttpMethod.GET)) .andRespond(withSuccess(SUCCESS_RESPONSE, MediaType.APPLICATION_JSON)) @@ -81,7 +81,7 @@ class TourApiClientTest { sigunguCode = "1", ) - mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params))) + mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params, TourLanguage.KOREAN))) .andExpect(method(HttpMethod.GET)) .andRespond(withStatus(HttpStatus.NOT_FOUND)) @@ -90,9 +90,29 @@ class TourApiClientTest { 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, TourLanguage.ENGLISH))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withStatus(HttpStatus.NOT_FOUND)) + + mockClient.fetchTourInfo(params, TourLanguage.ENGLISH) + } + + private fun expectedAreaBasedListUrl( + params: TourParams, + language: TourLanguage, + ): String = UriComponentsBuilder.fromUriString(baseUrl) - .path("/areaBasedList2") + .pathSegment(language.serviceSegment, "areaBasedList2") .queryParam("serviceKey", serviceKey) .queryParam("MobileOS", "WEB") .queryParam("MobileApp", "KoreaTravelGuide") diff --git a/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourParamsParserTest.kt b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourParamsParserTest.kt index d000173..20a4835 100644 --- a/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourParamsParserTest.kt +++ b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourParamsParserTest.kt @@ -1,9 +1,9 @@ package com.back.koreaTravelGuide.domain.ai.tour.service -import kotlin.test.assertEquals -import kotlin.test.assertNull import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull class TourParamsParserTest { private val parser = TourParamsParser() 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..916a8c4 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 @@ -1,13 +1,16 @@ package com.back.koreaTravelGuide.domain.ai.tour.service.core import com.back.koreaTravelGuide.domain.ai.tour.client.TourApiClient +import com.back.koreaTravelGuide.domain.ai.tour.client.TourLanguage 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 +30,18 @@ class TourAreaBasedServiceCoreCacheTest { @Autowired private lateinit var tourApiClient: TourApiClient - @DisplayName("fetchAreaBasedTours - 동일 파라미터 두 번 호출 시 API는 한 번만 호출된다") + @Autowired + private lateinit var cacheManager: CacheManager + + @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 +68,67 @@ class TourAreaBasedServiceCoreCacheTest { ), ) - every { tourApiClient.fetchTourInfo(params) } returns apiResponse + every { tourApiClient.fetchTourInfo(params, TourLanguage.KOREAN) } returns apiResponse - val firstCall = service.fetchAreaBasedTours(params) - val secondCall = service.fetchAreaBasedTours(params) + val firstCall = service.fetchAreaBasedTours(params, TourLanguage.KOREAN) + val secondCall = service.fetchAreaBasedTours(params, TourLanguage.KOREAN) assertEquals(apiResponse, firstCall) assertEquals(apiResponse, secondCall) - verify(exactly = 1) { tourApiClient.fetchTourInfo(params) } + verify(exactly = 1) { tourApiClient.fetchTourInfo(params, TourLanguage.KOREAN) } } + @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, TourLanguage.KOREAN) } returns koreanResponse + every { tourApiClient.fetchTourInfo(params, TourLanguage.ENGLISH) } returns englishResponse + + val koreanFirst = service.fetchAreaBasedTours(params, TourLanguage.KOREAN) + val englishFirst = service.fetchAreaBasedTours(params, TourLanguage.ENGLISH) + val koreanSecond = service.fetchAreaBasedTours(params, TourLanguage.KOREAN) + val englishSecond = service.fetchAreaBasedTours(params, TourLanguage.ENGLISH) + + assertEquals(koreanResponse, koreanFirst) + assertEquals(englishResponse, englishFirst) + assertEquals(koreanResponse, koreanSecond) + assertEquals(englishResponse, englishSecond) + verify(exactly = 1) { tourApiClient.fetchTourInfo(params, TourLanguage.KOREAN) } + verify(exactly = 1) { tourApiClient.fetchTourInfo(params, TourLanguage.ENGLISH) } + } + + 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 { From e100ad3b906447c3fdb7b8fe815d16a398fea6c1 Mon Sep 17 00:00:00 2001 From: YangHJ2415 Date: Mon, 13 Oct 2025 18:46:25 +0900 Subject: [PATCH 8/9] =?UTF-8?q?refactor(be)=20:=20language.yml=EC=9D=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=B4=20=EC=96=B8=EC=96=B4=20=EB=AC=B8?= =?UTF-8?q?=EC=9E=90=EC=97=B4=EC=9D=84=20BuildConfig=EB=A1=9C=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1,=20TourTool=EC=97=90=EC=84=9C=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20Client?= =?UTF-8?q?=EC=99=80=20Service=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 13 +++++++ .../domain/ai/tour/client/TourApiClient.kt | 39 ++++++------------- .../domain/ai/tour/client/TourLanguage.kt | 37 ------------------ .../domain/ai/tour/service/TourService.kt | 23 ++++++----- .../service/core/TourAreaBasedServiceCore.kt | 7 ++-- .../service/core/TourDetailServiceCore.kt | 7 ++-- .../core/TourLocationBasedServiceCore.kt | 7 ++-- .../service/usecase/TourAreaBasedUseCase.kt | 3 +- .../tour/service/usecase/TourDetailUseCase.kt | 3 +- .../usecase/TourLocationBasedUseCase.kt | 3 +- src/main/resources/language.yml | 8 ++++ 11 files changed, 57 insertions(+), 93 deletions(-) delete mode 100644 src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourLanguage.kt create mode 100644 src/main/resources/language.yml 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 aff6613..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 @@ -27,10 +27,10 @@ class TourApiClient( // 지역 기반 관광 정보 조회 (areaBasedList2) fun fetchTourInfo( params: TourParams, - language: TourLanguage = TourLanguage.KOREAN, + serviceSegment: String, ): TourResponse { val url = - buildTourUri(language, "areaBasedList2") { + buildTourUri(serviceSegment, "areaBasedList2") { queryParam("contentTypeId", params.contentTypeId) queryParam("areaCode", params.areaCode) queryParam("sigunguCode", params.sigunguCode) @@ -38,7 +38,7 @@ class TourApiClient( val body = runCatching { restTemplate.getForObject(url, String::class.java) } - .onFailure { log.error("관광 정보 조회 실패", it) } + .onFailure { log.error("관광 정보 조회 실패 - serviceSegment={}", serviceSegment, it) } .getOrNull() return body @@ -51,10 +51,10 @@ class TourApiClient( fun fetchLocationBasedTours( tourParams: TourParams, locationParams: TourLocationBasedParams, - language: TourLanguage = TourLanguage.KOREAN, + serviceSegment: String, ): TourResponse { val url = - buildTourUri(language, "locationBasedList2") { + buildTourUri(serviceSegment, "locationBasedList2") { queryParam("mapX", locationParams.mapX) queryParam("mapY", locationParams.mapY) queryParam("radius", locationParams.radius) @@ -65,7 +65,7 @@ class TourApiClient( val body = runCatching { restTemplate.getForObject(url, String::class.java) } - .onFailure { log.error("위치기반 관광 정보 조회 실패", it) } + .onFailure { log.error("위치기반 관광 정보 조회 실패 - serviceSegment={}", serviceSegment, it) } .getOrNull() return body @@ -77,16 +77,16 @@ class TourApiClient( // 공통정보 조회 (detailCommon2) fun fetchTourDetail( params: TourDetailParams, - language: TourLanguage = TourLanguage.KOREAN, + serviceSegment: String, ): TourDetailResponse { val url = - buildTourUri(language, "detailCommon2") { + 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 @@ -95,10 +95,6 @@ class TourApiClient( ?: TourDetailResponse(items = emptyList()) } - /* - * areaBasedList2/locationBasedList2 JSON 응답에서 `response.body.items.item` 배열만 추출 - * Kotlin 도메인 모델(TourItem) 목록으로 변환한다. item 노드가 비어 있으면 빈 목록을 반환한다. - */ private fun parseItems(json: String): TourResponse { val itemNodes = extractItemNodes(json, "관광 정보") if (itemNodes.isEmpty()) return TourResponse(items = emptyList()) @@ -128,10 +124,6 @@ class TourApiClient( return TourResponse(items = items) } - /* - * detailCommon2 JSON 응답을 파싱해 `TourDetailItem` 목록으로 변환한다. - * 항목이 없으면 빈 응답을 돌려준다. 필드 구성이 다르기 때문에 detailCommon2 은 별도 파서로 분리한다. - */ private fun parseDetailItems(json: String): TourDetailResponse { val itemNodes = extractItemNodes(json, "공통정보") if (itemNodes.isEmpty()) return TourDetailResponse(items = emptyList()) @@ -154,10 +146,6 @@ class TourApiClient( return TourDetailResponse(items = items) } - /* - * 공통 응답 구조에서 resultCode가 성공(0000)인지 검사한 뒤 `item` 배열 노드를 리스트로 반환한다. - * 실패 코드거나 배열이 비어 있으면 빈 리스트를 돌려준다. - */ private fun extractItemNodes( json: String, apiName: String, @@ -187,18 +175,13 @@ class TourApiClient( return itemsNode.map { it } } - /** - * 공통 URL 빌더 - * - 설정된 기본 URL을 바탕으로 언어 세그먼트(e.g. KorService2, EngService2)와 공통 쿼리 파라미터 - * - (`serviceKey`, `MobileOS`, `MobileApp`, `_type`)를 자동으로 붙여 관광 API 호출용 URI를 만든다. - */ private fun buildTourUri( - language: TourLanguage, + serviceSegment: String, vararg pathSegments: String, customize: UriComponentsBuilder.() -> Unit = {}, ): URI = UriComponentsBuilder.fromUri(URI.create(apiUrl)) - .pathSegment(language.serviceSegment, *pathSegments) + .pathSegment(serviceSegment, *pathSegments) .apply { queryParam("serviceKey", serviceKey) queryParam("MobileOS", "WEB") diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourLanguage.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourLanguage.kt deleted file mode 100644 index 0d9c5f3..0000000 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourLanguage.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.back.koreaTravelGuide.domain.ai.tour.client - -/** - * 한국관광공사 오픈API의 언어별 서비스 세그먼트 매핑. - * - * TourTool 등에서 감지한 사용자의 언어 문자열을 이 enum으로 정규화한 뒤, - * `serviceSegment` 값을 사용해 API 엔드포인트 경로(예: KorService2, EngService2)를 동적으로 구성한다. - */ -enum class TourLanguage( - private val aliases: Set, - val serviceSegment: String, -) { - KOREAN(setOf("kor", "korean", "한국어"), "KorService2"), - ENGLISH(setOf("eng", "english", "영어"), "EngService2"), - JAPANESE(setOf("jpn", "japanese", "일본어"), "JpnService2"), - CHINESE_SIMPLIFIED(setOf("zh-cn", "chs", "중국어 간체", "간체"), "ChsService2"), - CHINESE_TRADITIONAL(setOf("zh-tw", "cht", "중국어 번체", "번체"), "ChtService2"); - - companion object { - /** - * 사용자나 AI가 넘긴 언어 문자열을 enum 값으로 정규화. - * - null/빈 값이면 기본 한국어(KOREAN)를 반환 - * - 공백을 제거하고 소문자로 만든 뒤, enum 이름이나 alias 목록과 매칭 - * - 어떤 항목과도 매칭되지 않으면 기본값(KOREAN)으로 반환 - */ - fun from(raw: String?): TourLanguage { - if (raw.isNullOrBlank()) return KOREAN - - val normalized = raw.trim().lowercase() - return entries.firstOrNull { lang -> - normalized == lang.name.lowercase() || - normalized in lang.aliases || - normalized.replace(" ", "") in lang.aliases.map { it.replace(" ", "") } - } ?: KOREAN - } - } -} 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 96ce95f..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 @@ -1,6 +1,5 @@ package com.back.koreaTravelGuide.domain.ai.tour.service -import com.back.koreaTravelGuide.domain.ai.tour.client.TourLanguage import com.back.koreaTravelGuide.domain.ai.tour.dto.TourDetailParams import com.back.koreaTravelGuide.domain.ai.tour.dto.TourDetailResponse import com.back.koreaTravelGuide.domain.ai.tour.dto.TourLocationBasedParams @@ -28,38 +27,42 @@ class TourService( /** * 지역 기반 관광 정보를 조회한다. - * 언어 문자열을 enum으로 정규화해 다국어 엔드포인트에 맞춰 전달한다. + * 언어 문자열을 설정으로 정규화해 다국어 엔드포인트에 맞춰 전달한다. */ fun fetchTours( tourParams: TourParams, languageCode: String? = null, ): TourResponse { - val language = TourLanguage.from(languageCode) - return tourAreaBasedUseCase.fetchAreaBasedTours(tourParams, language) + val serviceSegment = languageCode?.takeIf { it.isNotBlank() } ?: DEFAULT_LANGUAGE_SEGMENT + return tourAreaBasedUseCase.fetchAreaBasedTours(tourParams, serviceSegment) } /** * 위치 기반 관광 정보를 조회한다. - * 전달받은 언어 코드를 enum으로 치환해 API 클라이언트에 전달한다. + * 전달받은 언어 값을 설정 기반 서비스 세그먼트로 치환해 API 클라이언트를 호출한다. */ fun fetchLocationBasedTours( tourParams: TourParams, locationParams: TourLocationBasedParams, languageCode: String? = null, ): TourResponse { - val language = TourLanguage.from(languageCode) - return tourLocationBasedUseCase.fetchLocationBasedTours(tourParams, locationParams, language) + val serviceSegment = languageCode?.takeIf { it.isNotBlank() } ?: DEFAULT_LANGUAGE_SEGMENT + return tourLocationBasedUseCase.fetchLocationBasedTours(tourParams, locationParams, serviceSegment) } /** * 관광지 상세 정보를 조회한다. - * 언어 코드를 정규화해 상세 API 호출 시 사용한다. + * 언어 값을 정규화해 상세 API 호출 시 사용한다. */ fun fetchTourDetail( detailParams: TourDetailParams, languageCode: String? = null, ): TourDetailResponse { - val language = TourLanguage.from(languageCode) - return tourDetailUseCase.fetchTourDetail(detailParams, language) + val serviceSegment = languageCode?.takeIf { it.isNotBlank() } ?: DEFAULT_LANGUAGE_SEGMENT + return tourDetailUseCase.fetchTourDetail(detailParams, serviceSegment) + } + + 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 2fa96df..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 @@ -1,7 +1,6 @@ package com.back.koreaTravelGuide.domain.ai.tour.service.core import com.back.koreaTravelGuide.domain.ai.tour.client.TourApiClient -import com.back.koreaTravelGuide.domain.ai.tour.client.TourLanguage 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 @@ -16,12 +15,12 @@ class TourAreaBasedServiceCore( @Cacheable( "tourAreaBased", key = - "#tourParams.contentTypeId + '_' + #tourParams.areaCode + '_' + #tourParams.sigunguCode + '_' + #language.serviceSegment", + "#tourParams.contentTypeId + '_' + #tourParams.areaCode + '_' + #tourParams.sigunguCode + '_' + #serviceSegment", unless = "#result == null", ) override fun fetchAreaBasedTours( tourParams: TourParams, - language: TourLanguage, + serviceSegment: String, ): TourResponse { if ( tourParams.contentTypeId == "12" && @@ -31,7 +30,7 @@ class TourAreaBasedServiceCore( return PRESET_AREA_TOUR_RESPONSE } - return tourApiClient.fetchTourInfo(tourParams, language) + 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 bb03280..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 @@ -1,7 +1,6 @@ package com.back.koreaTravelGuide.domain.ai.tour.service.core import com.back.koreaTravelGuide.domain.ai.tour.client.TourApiClient -import com.back.koreaTravelGuide.domain.ai.tour.client.TourLanguage import com.back.koreaTravelGuide.domain.ai.tour.dto.TourDetailItem import com.back.koreaTravelGuide.domain.ai.tour.dto.TourDetailParams import com.back.koreaTravelGuide.domain.ai.tour.dto.TourDetailResponse @@ -15,18 +14,18 @@ class TourDetailServiceCore( ) : TourDetailUseCase { @Cacheable( "tourDetail", - key = "#detailParams.contentId + '_' + #language.serviceSegment", + key = "#detailParams.contentId + '_' + #serviceSegment", unless = "#result == null", ) override fun fetchTourDetail( detailParams: TourDetailParams, - language: TourLanguage, + serviceSegment: String, ): TourDetailResponse { if (detailParams.contentId == "127974") { return PRESET_DETAIL_RESPONSE } - return tourApiClient.fetchTourDetail(detailParams, language) + 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 50c84e6..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 @@ -1,7 +1,6 @@ package com.back.koreaTravelGuide.domain.ai.tour.service.core import com.back.koreaTravelGuide.domain.ai.tour.client.TourApiClient -import com.back.koreaTravelGuide.domain.ai.tour.client.TourLanguage import com.back.koreaTravelGuide.domain.ai.tour.dto.TourItem import com.back.koreaTravelGuide.domain.ai.tour.dto.TourLocationBasedParams import com.back.koreaTravelGuide.domain.ai.tour.dto.TourParams @@ -19,13 +18,13 @@ class TourLocationBasedServiceCore( key = "#tourParams.contentTypeId + '_' + #tourParams.areaCode + '_' + #tourParams.sigunguCode + '_' + " + "#locationParams.mapX + '_' + #locationParams.mapY + '_' + #locationParams.radius + '_' + " + - "#language.serviceSegment", + "#serviceSegment", unless = "#result == null", ) override fun fetchLocationBasedTours( tourParams: TourParams, locationParams: TourLocationBasedParams, - language: TourLanguage, + serviceSegment: String, ): TourResponse { if ( tourParams.contentTypeId == "39" && @@ -38,7 +37,7 @@ class TourLocationBasedServiceCore( return PRESET_LOCATION_BASED_RESPONSE } - return tourApiClient.fetchLocationBasedTours(tourParams, locationParams, language) + 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 2d51cc8..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 @@ -1,12 +1,11 @@ package com.back.koreaTravelGuide.domain.ai.tour.service.usecase -import com.back.koreaTravelGuide.domain.ai.tour.client.TourLanguage import com.back.koreaTravelGuide.domain.ai.tour.dto.TourParams import com.back.koreaTravelGuide.domain.ai.tour.dto.TourResponse interface TourAreaBasedUseCase { fun fetchAreaBasedTours( tourParams: TourParams, - language: TourLanguage, + 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 2dc230e..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 @@ -1,12 +1,11 @@ package com.back.koreaTravelGuide.domain.ai.tour.service.usecase -import com.back.koreaTravelGuide.domain.ai.tour.client.TourLanguage import com.back.koreaTravelGuide.domain.ai.tour.dto.TourDetailParams import com.back.koreaTravelGuide.domain.ai.tour.dto.TourDetailResponse interface TourDetailUseCase { fun fetchTourDetail( detailParams: TourDetailParams, - language: TourLanguage, + 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 ae8e927..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 @@ -1,6 +1,5 @@ package com.back.koreaTravelGuide.domain.ai.tour.service.usecase -import com.back.koreaTravelGuide.domain.ai.tour.client.TourLanguage import com.back.koreaTravelGuide.domain.ai.tour.dto.TourLocationBasedParams import com.back.koreaTravelGuide.domain.ai.tour.dto.TourParams import com.back.koreaTravelGuide.domain.ai.tour.dto.TourResponse @@ -9,6 +8,6 @@ interface TourLocationBasedUseCase { fun fetchLocationBasedTours( tourParams: TourParams, locationParams: TourLocationBasedParams, - language: TourLanguage, + serviceSegment: String, ): TourResponse } 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" From af4b5a192792702d4f35722e12b9433ad1ab540b Mon Sep 17 00:00:00 2001 From: YangHJ2415 Date: Tue, 14 Oct 2025 09:40:19 +0900 Subject: [PATCH 9/9] =?UTF-8?q?fix(be)=20:=20Test=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/tour/client/TourApiClientTest.kt | 20 +++++++------ .../core/TourAreaBasedServiceCoreCacheTest.kt | 28 ++++++++++--------- 2 files changed, 26 insertions(+), 22 deletions(-) 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 21a2f88..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, TourLanguage.KOREAN))) + 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,11 +83,11 @@ class TourApiClientTest { sigunguCode = "1", ) - mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params, TourLanguage.KOREAN))) + 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()) } @@ -100,19 +102,19 @@ class TourApiClientTest { sigunguCode = "1", ) - mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params, TourLanguage.ENGLISH))) + mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params, englishSegment))) .andExpect(method(HttpMethod.GET)) .andRespond(withStatus(HttpStatus.NOT_FOUND)) - mockClient.fetchTourInfo(params, TourLanguage.ENGLISH) + mockClient.fetchTourInfo(params, englishSegment) } private fun expectedAreaBasedListUrl( params: TourParams, - language: TourLanguage, + serviceSegment: String, ): String = UriComponentsBuilder.fromUriString(baseUrl) - .pathSegment(language.serviceSegment, "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 916a8c4..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 @@ -1,7 +1,6 @@ package com.back.koreaTravelGuide.domain.ai.tour.service.core import com.back.koreaTravelGuide.domain.ai.tour.client.TourApiClient -import com.back.koreaTravelGuide.domain.ai.tour.client.TourLanguage 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 @@ -33,6 +32,9 @@ class TourAreaBasedServiceCoreCacheTest { @Autowired private lateinit var cacheManager: CacheManager + private val koreanSegment = "KorService2" + private val englishSegment = "EngService2" + @BeforeEach fun clearCaches() { cacheManager.getCache("tourAreaBased")?.clear() @@ -68,14 +70,14 @@ class TourAreaBasedServiceCoreCacheTest { ), ) - every { tourApiClient.fetchTourInfo(params, TourLanguage.KOREAN) } returns apiResponse + every { tourApiClient.fetchTourInfo(params, koreanSegment) } returns apiResponse - val firstCall = service.fetchAreaBasedTours(params, TourLanguage.KOREAN) - val secondCall = service.fetchAreaBasedTours(params, TourLanguage.KOREAN) + val firstCall = service.fetchAreaBasedTours(params, koreanSegment) + val secondCall = service.fetchAreaBasedTours(params, koreanSegment) assertEquals(apiResponse, firstCall) assertEquals(apiResponse, secondCall) - verify(exactly = 1) { tourApiClient.fetchTourInfo(params, TourLanguage.KOREAN) } + verify(exactly = 1) { tourApiClient.fetchTourInfo(params, koreanSegment) } } @DisplayName("fetchAreaBasedTours - 언어가 다르면 각각 캐시가 생성된다") @@ -85,20 +87,20 @@ class TourAreaBasedServiceCoreCacheTest { val koreanResponse = simpleTourResponse(contentId = "ko", title = "국문") val englishResponse = simpleTourResponse(contentId = "en", title = "English") - every { tourApiClient.fetchTourInfo(params, TourLanguage.KOREAN) } returns koreanResponse - every { tourApiClient.fetchTourInfo(params, TourLanguage.ENGLISH) } returns englishResponse + every { tourApiClient.fetchTourInfo(params, koreanSegment) } returns koreanResponse + every { tourApiClient.fetchTourInfo(params, englishSegment) } returns englishResponse - val koreanFirst = service.fetchAreaBasedTours(params, TourLanguage.KOREAN) - val englishFirst = service.fetchAreaBasedTours(params, TourLanguage.ENGLISH) - val koreanSecond = service.fetchAreaBasedTours(params, TourLanguage.KOREAN) - val englishSecond = service.fetchAreaBasedTours(params, TourLanguage.ENGLISH) + 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, TourLanguage.KOREAN) } - verify(exactly = 1) { tourApiClient.fetchTourInfo(params, TourLanguage.ENGLISH) } + verify(exactly = 1) { tourApiClient.fetchTourInfo(params, koreanSegment) } + verify(exactly = 1) { tourApiClient.fetchTourInfo(params, englishSegment) } } private fun simpleTourResponse(