diff --git a/src/main/kotlin/com/back/koreaTravelGuide/KoreaTravelGuideApplication.kt b/src/main/kotlin/com/back/koreaTravelGuide/KoreaTravelGuideApplication.kt index cc2558b..c4a5d11 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/KoreaTravelGuideApplication.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/KoreaTravelGuideApplication.kt @@ -1,10 +1,11 @@ package com.back.koreaTravelGuide -// TODO: 메인 애플리케이션 클래스 - 스프링 부트 시작점 및 환경변수 로딩 import io.github.cdimascio.dotenv.dotenv import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication +import org.springframework.cache.annotation.EnableCaching +@EnableCaching @SpringBootApplication(scanBasePackages = ["com.back.koreaTravelGuide"]) class KoreaTravelGuideApplication diff --git a/src/main/kotlin/com/back/koreaTravelGuide/common/logging/Log.kt b/src/main/kotlin/com/back/koreaTravelGuide/common/logging/Log.kt new file mode 100644 index 0000000..13b355a --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/common/logging/Log.kt @@ -0,0 +1,7 @@ +package com.back.koreaTravelGuide.common.logging + +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +val T.log: Logger + get() = LoggerFactory.getLogger(this::class.java) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/weather/cache/WeatherCacheConfig.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/weather/cache/WeatherCacheConfig.kt index dff154c..14c8b41 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/weather/cache/WeatherCacheConfig.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/weather/cache/WeatherCacheConfig.kt @@ -1,4 +1,17 @@ package com.back.koreaTravelGuide.domain.ai.weather.cache -// TODO: 날씨 정보 캐시 설정 - 12시간 캐시 주기 관리 (필요시 구현) -class WeatherCacheConfig +import org.springframework.context.annotation.Configuration + +@Configuration +class WeatherCacheConfig { + // @Bean +// fun cacheManager(connectionFactory: RedisConnectionFactory): RedisCacheManager { +// val config = RedisCacheConfiguration.defaultCacheConfig() +// .entryTtl(Duration.ofHours(12)) +// .disableCachingNullValues() +// +// return RedisCacheManager.builder(connectionFactory) +// .cacheDefaults(config) +// .build() +// } +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/weather/client/WeatherApiClient.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/weather/client/WeatherApiClient.kt index d33a687..c2289f0 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/weather/client/WeatherApiClient.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/weather/client/WeatherApiClient.kt @@ -1,6 +1,7 @@ package com.back.koreaTravelGuide.domain.ai.weather.client // TODO: 기상청 API 클라이언트 - HTTP 요청으로 날씨 데이터 조회 및 JSON 파싱 +import com.back.koreaTravelGuide.common.logging.log import com.back.koreaTravelGuide.domain.ai.weather.client.parser.DataParser import com.back.koreaTravelGuide.domain.ai.weather.client.tools.Tools import com.back.koreaTravelGuide.domain.ai.weather.dto.LandForecastData @@ -25,25 +26,22 @@ class WeatherApiClient( val stnId = tools.getStnIdFromRegionCode(regionId) val url = "$apiUrl/getMidFcst?serviceKey=$serviceKey&numOfRows=10&pageNo=1&stnId=$stnId&tmFc=$baseTime&dataType=JSON" - println("🔮 중기전망조회 API 호출: $url") - return try { @Suppress("UNCHECKED_CAST") val jsonResponse = restTemplate.getForObject(url, Map::class.java) as? Map - println("📡 중기전망 JSON 응답 수신") jsonResponse?.let { response -> // API 오류 응답 체크 val resultCode = dataParser.extractJsonValue(response, "response.header.resultCode") as? String if (resultCode == "03" || resultCode == "NO_DATA") { - println("⚠️ 기상청 API NO_DATA 오류 - 발표시각을 조정해야 할 수 있습니다") + log.warn("기상청 API NO_DATA 오류 - 발표시각을 조정해야 할 수 있습니다") return null } dataParser.extractJsonValue(response, "response.body.items.item[0].wfSv") as? String } } catch (e: Exception) { - println("❌ 중기전망조회 JSON API 오류: ${e.message}") + log.warn("중기전망조회 JSON API 오류: ${e.message}") null } } @@ -55,16 +53,13 @@ class WeatherApiClient( ): TemperatureData? { val url = "$apiUrl/getMidTa?serviceKey=$serviceKey&numOfRows=10&pageNo=1®Id=$regionId&tmFc=$baseTime&dataType=JSON" - println("🌡️ 중기기온조회 API 호출: $url") - return try { @Suppress("UNCHECKED_CAST") val jsonResponse = restTemplate.getForObject(url, Map::class.java) as? Map - println("📡 중기기온 JSON 응답 수신") jsonResponse?.let { dataParser.parseTemperatureDataFromJson(it) } ?: TemperatureData() } catch (e: Exception) { - println("❌ 중기기온조회 JSON API 오류: ${e.message}") + log.warn("중기기온조회 JSON API 오류: ${e.message}") TemperatureData() } } @@ -76,16 +71,13 @@ class WeatherApiClient( ): LandForecastData? { val url = "$apiUrl/getMidLandFcst?serviceKey=$serviceKey&numOfRows=10&pageNo=1®Id=$regionId&tmFc=$baseTime&dataType=JSON" - println("🌧️ 중기육상예보조회 API 호출: $url") - return try { @Suppress("UNCHECKED_CAST") val jsonResponse = restTemplate.getForObject(url, Map::class.java) as? Map - println("📡 중기육상예보 JSON 응답 수신") jsonResponse?.let { dataParser.parsePrecipitationDataFromJson(it) } ?: LandForecastData() } catch (e: Exception) { - println("❌ 중기육상예보조회 JSON API 오류: ${e.message}") + log.warn("중기육상예보조회 JSON API 오류: ${e.message}") LandForecastData() } } diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/weather/config/RegionCodeProperties.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/weather/config/RegionCodeProperties.kt new file mode 100644 index 0000000..17cd83c --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/weather/config/RegionCodeProperties.kt @@ -0,0 +1,14 @@ +package com.back.koreaTravelGuide.domain.ai.weather.config + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.stereotype.Component + +@Component +@ConfigurationProperties(prefix = "weather.region") +data class RegionCodeProperties( + var codes: Map = emptyMap(), +) { + fun getCodeByLocation(location: String): String { + return codes[location] ?: "11B10101" + } +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/weather/service/WeatherService.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/weather/service/WeatherService.kt index bef4bb2..681056d 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/weather/service/WeatherService.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/weather/service/WeatherService.kt @@ -1,51 +1,27 @@ package com.back.koreaTravelGuide.domain.ai.weather.service -// TODO: 날씨 정보 캐싱 서비스 - @Cacheable 어노테이션 기반 캐싱 -import com.back.koreaTravelGuide.domain.ai.weather.client.WeatherApiClient import com.back.koreaTravelGuide.domain.ai.weather.dto.MidForecastDto import com.back.koreaTravelGuide.domain.ai.weather.dto.TemperatureAndLandForecastDto -import com.back.koreaTravelGuide.domain.weather.dto.parser.DtoParser -import org.springframework.cache.annotation.CacheEvict -import org.springframework.cache.annotation.Cacheable -import org.springframework.scheduling.annotation.Scheduled +import com.back.koreaTravelGuide.domain.ai.weather.service.tools.Tools import org.springframework.stereotype.Service @Service class WeatherService( - private val weatherApiClient: WeatherApiClient, - private val parser: DtoParser, + val weatherServiceCore: WeatherServiceCore, + val tools: Tools, ) { - @Cacheable("weatherMidFore", key = "'전국_' + #actualBaseTime") - fun fetchMidForecast(actualBaseTime: String): List? { - val prefixes = listOf("11B", "11D1", "11D2", "11C2", "11C1", "11F2", "11F1", "11H1", "11H2", "11G") - val midForecastList = mutableListOf() + fun getWeatherForecast(): List? { + val actualBaseTime = tools.getCurrentBaseTime() - for (regionId in prefixes) { - val info = weatherApiClient.fetchMidForecast(regionId, actualBaseTime) - if (info.isNullOrBlank()) return null - - val dto = parser.parseMidForecast(regionId, actualBaseTime, info) - midForecastList.add(dto) - } - return midForecastList + return weatherServiceCore.fetchMidForecast(actualBaseTime) } - @Cacheable("weatherTempAndLandFore", key = "#actualRegionCode + '_' + #actualBaseTime") - fun fetchTemperatureAndLandForecast( - actualRegionCode: String, - actualBaseTime: String, - ): List? { - val tempInfo = weatherApiClient.fetchTemperature(actualRegionCode, actualBaseTime) - val landInfo = weatherApiClient.fetchLandForecast(actualRegionCode, actualBaseTime) - - if (tempInfo == null || landInfo == null) return null + fun getTemperatureAndLandForecast(location: String?): List? { + val actualLocation = location ?: "서울" + val actualRegionCode = tools.getRegionCodeFromLocation(actualLocation) - return parser.parseTemperatureAndLandForecast(actualRegionCode, actualBaseTime, tempInfo, landInfo) - } + val actualBaseTime = tools.getCurrentBaseTime() - @CacheEvict(cacheNames = ["weatherMidFore", "weatherTempAndLandFore"], allEntries = true) - @Scheduled(fixedRate = 43200000) // 12시간마다 (12 * 60 * 60 * 1000) - fun clearWeatherCache() { - println("🗑️ 날씨 캐시 자동 삭제 완료") + return weatherServiceCore.fetchTemperatureAndLandForecast(actualRegionCode, actualBaseTime) } } diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/weather/service/WeatherServiceCore.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/weather/service/WeatherServiceCore.kt index 987cc6b..82657f6 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/weather/service/WeatherServiceCore.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/weather/service/WeatherServiceCore.kt @@ -1,34 +1,61 @@ package com.back.koreaTravelGuide.domain.ai.weather.service -// TODO: 날씨 정보 캐싱 서비스 - @Cacheable 어노테이션 기반 캐싱 +import com.back.koreaTravelGuide.common.logging.log +import com.back.koreaTravelGuide.domain.ai.weather.client.WeatherApiClient import com.back.koreaTravelGuide.domain.ai.weather.dto.MidForecastDto import com.back.koreaTravelGuide.domain.ai.weather.dto.TemperatureAndLandForecastDto -import com.back.koreaTravelGuide.domain.ai.weather.service.tools.Tools +import com.back.koreaTravelGuide.domain.weather.dto.parser.DtoParser +import org.springframework.cache.annotation.CacheEvict +import org.springframework.cache.annotation.Cacheable +import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Service @Service class WeatherServiceCore( - val weatherService: WeatherService, - val tools: Tools, + private val weatherApiClient: WeatherApiClient, + private val parser: DtoParser, ) { - fun getWeatherForecast(baseTime: String?): List? { - // baseTime 유효성 검사 - 06시 또는 18시만 허용 - val actualBaseTime = tools.validBaseTime(baseTime) + @Cacheable("weatherMidFore", key = "'전국_' + #actualBaseTime") + fun fetchMidForecast(actualBaseTime: String): List? { + val prefixes = listOf("11B", "11D1", "11D2", "11C2", "11C1", "11F2", "11F1", "11H1", "11H2", "11G") + val midForecastList = mutableListOf() - return weatherService.fetchMidForecast(actualBaseTime) + for (regionId in prefixes) { + val info = weatherApiClient.fetchMidForecast(regionId, actualBaseTime) + if (info.isNullOrBlank()) { + log.warn("MidForecast Api error => regionId: $regionId") + continue + } + + val dto = parser.parseMidForecast(regionId, actualBaseTime, info) + midForecastList.add(dto) + } + // 리스트가 비어있으면 null 반환 -> api 호출 실패 처리해야함. + if (midForecastList.isEmpty()) return null + return midForecastList } - fun getTemperatureAndLandForecast( - location: String?, - regionCode: String?, - baseTime: String?, + @Cacheable("weatherTempAndLandFore", key = "#actualRegionCode + '_' + #actualBaseTime") + fun fetchTemperatureAndLandForecast( + actualRegionCode: String, + actualBaseTime: String, ): List? { - val actualLocation = location ?: "서울" - val actualRegionCode = regionCode ?: tools.getRegionCodeFromLocation(actualLocation) + val tempInfo = weatherApiClient.fetchTemperature(actualRegionCode, actualBaseTime) + val landInfo = weatherApiClient.fetchLandForecast(actualRegionCode, actualBaseTime) + + // 둘 중 하나라도 null이면 null 반환 -> api 호출 실패 처리해야함. + if (tempInfo == null || landInfo == null) { + log.warn("Temp, Land Api error => actualRegionCode: $actualRegionCode") + return null + } - // baseTime 유효성 검사 - 06시 또는 18시만 허용 - val actualBaseTime = tools.validBaseTime(baseTime) + return parser.parseTemperatureAndLandForecast(actualRegionCode, actualBaseTime, tempInfo, landInfo) + } - return weatherService.fetchTemperatureAndLandForecast(actualRegionCode, actualBaseTime) + // 인스턴스가 여러개 있는 상황에서는 중복 삭제될 수 있음. 나중에 분산 락 고려 + @CacheEvict(cacheNames = ["weatherMidFore", "weatherTempAndLandFore"], allEntries = true) + @Scheduled(fixedRate = 43200000) // 12시간마다 (12 * 60 * 60 * 1000) + fun clearWeatherCache() { + log.info("clearWeatherCache") } } diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/weather/service/tools/Tools.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/weather/service/tools/Tools.kt index a692eb6..e3815e1 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/weather/service/tools/Tools.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/ai/weather/service/tools/Tools.kt @@ -1,99 +1,29 @@ package com.back.koreaTravelGuide.domain.ai.weather.service.tools +import com.back.koreaTravelGuide.domain.ai.weather.config.RegionCodeProperties import org.springframework.stereotype.Component import java.time.ZoneId import java.time.ZonedDateTime import java.time.format.DateTimeFormatter @Component("serviceTools") -open class Tools { +class Tools( + private val regionCodeProperties: RegionCodeProperties, +) { fun getRegionCodeFromLocation(location: String): String { - return REGION_MAP[location] ?: "11B10101" + return regionCodeProperties.getCodeByLocation(location) } - fun validBaseTime(baseTime: String?): String { - val actualBaseTime = - if (baseTime != null && (baseTime.endsWith("0600") || baseTime.endsWith("1800"))) { - println("📌 제공된 발표시각 사용: $baseTime") - baseTime - } else { - if (baseTime != null) { - println("⚠️ 잘못된 발표시각 무시: $baseTime (06시 또는 18시만 유효)") - } - getCurrentBaseTime() - } - return actualBaseTime - } - - private fun getCurrentBaseTime(): String { - // 한국시간(KST) 기준으로 현재 시간 계산 + fun getCurrentBaseTime(): String { val now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")) - val hour = now.hour - - println("🕐 현재 KST 시간: ${now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))}") - - return if (hour < 6) { - // 06시 이전이면 전날 18시 발표 - val baseTime = now.minusDays(1).format(DateTimeFormatter.ofPattern("yyyyMMdd")) + "1800" - println("📅 사용할 발표시각: $baseTime (전날 18시)") - baseTime - } else if (hour < 18) { - // 06시~18시 사이면 당일 06시 발표 - val baseTime = now.format(DateTimeFormatter.ofPattern("yyyyMMdd")) + "0600" - println("📅 사용할 발표시각: $baseTime (당일 06시)") - baseTime - } else { - // 18시 이후면 당일 18시 발표 - val baseTime = now.format(DateTimeFormatter.ofPattern("yyyyMMdd")) + "1800" - println("📅 사용할 발표시각: $baseTime (당일 18시)") - baseTime - } - } + val baseHour = + when { + now.hour < 6 -> "1800" // 06시 이전 → 전날 18시 + now.hour < 18 -> "0600" // 06시~18시 → 당일 06시 + else -> "1800" // 18시 이후 → 당일 18시 + } - companion object { - private val REGION_MAP = - mapOf( - "백령도" to "11A00101", "서울" to "11B10101", "과천" to "11B10102", "광명" to "11B10103", "강화" to "11B20101", - "김포" to "11B20102", "인천" to "11B20201", "시흥" to "11B20202", "안산" to "11B20203", "부천" to "11B20204", - "의정부" to "11B20301", "고양" to "11B20302", "양주" to "11B20304", "파주" to "11B20305", "동두천" to "11B20401", - "연천" to "11B20402", "포천" to "11B20403", "가평" to "11B20404", "구리" to "11B20501", "남양주" to "11B20502", - "양평" to "11B20503", "하남" to "11B20504", "수원" to "11B20601", "안양" to "11B20602", "오산" to "11B20603", - "화성" to "11B20604", "성남" to "11B20605", "평택" to "11B20606", "의왕" to "11B20609", "군포" to "11B20610", - "안성" to "11B20611", "용인" to "11B20612", "이천" to "11B20701", "광주" to "11B20702", "여주" to "11B20703", - "충주" to "11C10101", "진천" to "11C10102", "음성" to "11C10103", "제천" to "11C10201", "단양" to "11C10202", - "청주" to "11C10301", "보은" to "11C10302", "괴산" to "11C10303", "증평" to "11C10304", "추풍령" to "11C10401", - "영동" to "11C10402", "옥천" to "11C10403", "서산" to "11C20101", "태안" to "11C20102", "당진" to "11C20103", - "홍성" to "11C20104", "보령" to "11C20201", "서천" to "11C20202", "천안" to "11C20301", "아산" to "11C20302", - "예산" to "11C20303", "대전" to "11C20401", "공주" to "11C20402", "계룡" to "11C20403", "세종" to "11C20404", - "부여" to "11C20501", "청양" to "11C20502", "금산" to "11C20601", "논산" to "11C20602", "철원" to "11D10101", - "화천" to "11D10102", "인제" to "11D10201", "양구" to "11D10202", "춘천" to "11D10301", "홍천" to "11D10302", - "원주" to "11D10401", "횡성" to "11D10402", "영월" to "11D10501", "정선" to "11D10502", "평창" to "11D10503", - "대관령" to "11D20201", "태백" to "11D20301", "속초" to "11D20401", "고성" to "11D20402", "양양" to "11D20403", - "강릉" to "11D20501", "동해" to "11D20601", "삼척" to "11D20602", "울릉도" to "11E00101", "독도" to "11E00102", - "전주" to "11F10201", "익산" to "11F10202", "정읍" to "11F10203", "완주" to "11F10204", "장수" to "11F10301", - "무주" to "11F10302", "진안" to "11F10303", "남원" to "11F10401", "임실" to "11F10402", "순창" to "11F10403", - "군산" to "21F10501", "김제" to "21F10502", "고창" to "21F10601", "부안" to "21F10602", "함평" to "21F20101", - "영광" to "21F20102", "진도" to "21F20201", "완도" to "11F20301", "해남" to "11F20302", "강진" to "11F20303", - "장흥" to "11F20304", "여수" to "11F20401", "광양" to "11F20402", "고흥" to "11F20403", "보성" to "11F20404", - "순천시" to "11F20405", "광주" to "11F20501", "장성" to "11F20502", "나주" to "11F20503", "담양" to "11F20504", - "화순" to "11F20505", "구례" to "11F20601", "곡성" to "11F20602", "순천" to "11F20603", "흑산도" to "11F20701", - "목포" to "21F20801", "영암" to "21F20802", "신안" to "21F20803", "무안" to "21F20804", "성산" to "11G00101", - "제주" to "11G00201", "성판악" to "11G00302", "서귀포" to "11G00401", "고산" to "11G00501", "이어도" to "11G00601", - "추자도" to "11G00800", "울진" to "11H10101", "영덕" to "11H10102", "포항" to "11H10201", "경주" to "11H10202", - "문경" to "11H10301", "상주" to "11H10302", "예천" to "11H10303", "영주" to "11H10401", "봉화" to "11H10402", - "영양" to "11H10403", "안동" to "11H10501", "의성" to "11H10502", "청송" to "11H10503", "김천" to "11H10601", - "구미" to "11H10602", "군위" to "11H10707", "고령" to "11H10604", "성주" to "11H10605", "대구" to "11H10701", - "영천" to "11H10702", "경산" to "11H10703", "청도" to "11H10704", "칠곡" to "11H10705", "울산" to "11H20101", - "양산" to "11H20102", "부산" to "11H20201", "창원" to "11H20301", "김해" to "11H20304", "통영" to "11H20401", - "사천" to "11H20402", "거제" to "11H20403", "고성" to "11H20404", "남해" to "11H20405", "함양" to "11H20501", - "거창" to "11H20502", "합천" to "11H20503", "밀양" to "11H20601", "의령" to "11H20602", "함안" to "11H20603", - "창녕" to "11H20604", "진주" to "11H20701", "산청" to "11H20703", "하동" to "11H20704", "사리원" to "11I10001", - "신계" to "11I10002", "해주" to "11I20001", "개성" to "11I20002", "장연(용연)" to "11I20003", "신의주" to "11J10001", - "삭주(수풍)" to "11J10002", "구성" to "11J10003", "자성(중강)" to "11J10004", "강계" to "11J10005", "희천" to "11J10006", - "평양" to "11J20001", "진남포(남포)" to "11J20002", "안주" to "11J20004", "양덕" to "11J20005", "청진" to "11K10001", - "웅기(선봉)" to "11K10002", "성진(김책)" to "11K10003", "무산(삼지연)" to "11K10004", "함흥" to "11K20001", "장진" to "11K20002", - "북청(신포)" to "11K20003", "혜산" to "11K20004", "풍산" to "11K20005", "원산" to "11L10001", "고성(장전)" to "11L10002", - "평강" to "11L10003", - ) + val baseDate = if (now.hour < 6) now.minusDays(1) else now + return baseDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")) + baseHour } } diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 64df2c5..7d155a3 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -12,3 +12,15 @@ spring: hibernate: format_sql: true dialect: org.hibernate.dialect.PostgreSQLDialect + +logging: + level: + root: INFO + com.back: INFO # 서비스 로그는 정보 레벨 이상만 + org.springframework.web: WARN # 웹 관련 경고/에러만 + org.springframework.security: WARN # 로그인 실패/보안 경고 위주 + org.hibernate.SQL: INFO # SQL 전체 로그는 끄고, 필요시 운영 중에만 열기 + pattern: + console: "[%d{yyyy-MM-dd HH:mm:ss}] %-5level %logger{36} - %msg%n" + +#나중에 개발 환경 레디스 설정 추가 \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6719198..d407ffd 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,7 +2,9 @@ spring: profiles: active: dev config: - import: "optional:file:.env[.properties]" + import: + - "optional:file:.env[.properties]" + - "classpath:region-codes.yml" # Spring AI 1.0.0-M6 설정 (OpenRouter 사용) ai: diff --git a/src/main/resources/region-codes.yml b/src/main/resources/region-codes.yml new file mode 100644 index 0000000..423ef02 --- /dev/null +++ b/src/main/resources/region-codes.yml @@ -0,0 +1,202 @@ +weather: + region: + codes: + 백령도: "11A00101" + 서울: "11B10101" + 과천: "11B10102" + 광명: "11B10103" + 강화: "11B20101" + 김포: "11B20102" + 인천: "11B20201" + 시흥: "11B20202" + 안산: "11B20203" + 부천: "11B20204" + 의정부: "11B20301" + 고양: "11B20302" + 양주: "11B20304" + 파주: "11B20305" + 동두천: "11B20401" + 연천: "11B20402" + 포천: "11B20403" + 가평: "11B20404" + 구리: "11B20501" + 남양주: "11B20502" + 양평: "11B20503" + 하남: "11B20504" + 수원: "11B20601" + 안양: "11B20602" + 오산: "11B20603" + 화성: "11B20604" + 성남: "11B20605" + 평택: "11B20606" + 의왕: "11B20609" + 군포: "11B20610" + 안성: "11B20611" + 용인: "11B20612" + 이천: "11B20701" + 여주: "11B20703" + 충주: "11C10101" + 진천: "11C10102" + 음성: "11C10103" + 제천: "11C10201" + 단양: "11C10202" + 청주: "11C10301" + 보은: "11C10302" + 괴산: "11C10303" + 증평: "11C10304" + 추풍령: "11C10401" + 영동: "11C10402" + 옥천: "11C10403" + 서산: "11C20101" + 태안: "11C20102" + 당진: "11C20103" + 홍성: "11C20104" + 보령: "11C20201" + 서천: "11C20202" + 천안: "11C20301" + 아산: "11C20302" + 예산: "11C20303" + 대전: "11C20401" + 공주: "11C20402" + 계룡: "11C20403" + 세종: "11C20404" + 부여: "11C20501" + 청양: "11C20502" + 금산: "11C20601" + 논산: "11C20602" + 철원: "11D10101" + 화천: "11D10102" + 인제: "11D10201" + 양구: "11D10202" + 춘천: "11D10301" + 홍천: "11D10302" + 원주: "11D10401" + 횡성: "11D10402" + 영월: "11D10501" + 정선: "11D10502" + 평창: "11D10503" + 대관령: "11D20201" + 태백: "11D20301" + 속초: "11D20401" + 양양: "11D20403" + 강릉: "11D20501" + 동해: "11D20601" + 삼척: "11D20602" + 울릉도: "11E00101" + 독도: "11E00102" + 전주: "11F10201" + 익산: "11F10202" + 정읍: "11F10203" + 완주: "11F10204" + 장수: "11F10301" + 무주: "11F10302" + 진안: "11F10303" + 남원: "11F10401" + 임실: "11F10402" + 순창: "11F10403" + 군산: "21F10501" + 김제: "21F10502" + 고창: "21F10601" + 부안: "21F10602" + 함평: "21F20101" + 영광: "21F20102" + 진도: "21F20201" + 완도: "11F20301" + 해남: "11F20302" + 강진: "11F20303" + 장흥: "11F20304" + 여수: "11F20401" + 광양: "11F20402" + 고흥: "11F20403" + 보성: "11F20404" + 순천시: "11F20405" + 광주: "11F20501" + 장성: "11F20502" + 나주: "11F20503" + 담양: "11F20504" + 화순: "11F20505" + 구례: "11F20601" + 곡성: "11F20602" + 순천: "11F20603" + 흑산도: "11F20701" + 목포: "21F20801" + 영암: "21F20802" + 신안: "21F20803" + 무안: "21F20804" + 성산: "11G00101" + 제주: "11G00201" + 성판악: "11G00302" + 서귀포: "11G00401" + 고산: "11G00501" + 이어도: "11G00601" + 추자도: "11G00800" + 울진: "11H10101" + 영덕: "11H10102" + 포항: "11H10201" + 경주: "11H10202" + 문경: "11H10301" + 상주: "11H10302" + 예천: "11H10303" + 영주: "11H10401" + 봉화: "11H10402" + 영양: "11H10403" + 안동: "11H10501" + 의성: "11H10502" + 청송: "11H10503" + 김천: "11H10601" + 구미: "11H10602" + 군위: "11H10707" + 고령: "11H10604" + 성주: "11H10605" + 대구: "11H10701" + 영천: "11H10702" + 경산: "11H10703" + 청도: "11H10704" + 칠곡: "11H10705" + 울산: "11H20101" + 양산: "11H20102" + 부산: "11H20201" + 창원: "11H20301" + 김해: "11H20304" + 통영: "11H20401" + 사천: "11H20402" + 거제: "11H20403" + 고성: "11H20404" + 남해: "11H20405" + 함양: "11H20501" + 거창: "11H20502" + 합천: "11H20503" + 밀양: "11H20601" + 의령: "11H20602" + 함안: "11H20603" + 창녕: "11H20604" + 진주: "11H20701" + 산청: "11H20703" + 하동: "11H20704" + 사리원: "11I10001" + 신계: "11I10002" + 해주: "11I20001" + 개성: "11I20002" + 장연(용연): "11I20003" + 신의주: "11J10001" + 삭주(수풍): "11J10002" + 구성: "11J10003" + 자성(중강): "11J10004" + 강계: "11J10005" + 희천: "11J10006" + 평양: "11J20001" + 진남포(남포): "11J20002" + 안주: "11J20004" + 양덕: "11J20005" + 청진: "11K10001" + 웅기(선봉): "11K10002" + 성진(김책): "11K10003" + 무산(삼지연): "11K10004" + 함흥: "11K20001" + 장진: "11K20002" + 북청(신포): "11K20003" + 혜산: "11K20004" + 풍산: "11K20005" + 원산: "11L10001" + 고성(장전): "11L10002" + 평강: "11L10003" \ No newline at end of file diff --git a/src/test/kotlin/com/back/koreaTravelGuide/application/KoreaTravelGuideApplicationTests.kt b/src/test/kotlin/com/back/koreaTravelGuide/KoreaTravelGuideApplicationTests.kt similarity index 80% rename from src/test/kotlin/com/back/koreaTravelGuide/application/KoreaTravelGuideApplicationTests.kt rename to src/test/kotlin/com/back/koreaTravelGuide/KoreaTravelGuideApplicationTests.kt index 9140875..055d4b3 100644 --- a/src/test/kotlin/com/back/koreaTravelGuide/application/KoreaTravelGuideApplicationTests.kt +++ b/src/test/kotlin/com/back/koreaTravelGuide/KoreaTravelGuideApplicationTests.kt @@ -1,4 +1,4 @@ -package com.back.koreaTravelGuide.application +package com.back.koreaTravelGuide import org.junit.jupiter.api.Test import org.springframework.boot.test.context.SpringBootTest diff --git a/src/test/kotlin/com/back/koreaTravelGuide/WeatherApiRealTest.kt b/src/test/kotlin/com/back/koreaTravelGuide/WeatherApiRealTest.kt deleted file mode 100644 index 951f839..0000000 --- a/src/test/kotlin/com/back/koreaTravelGuide/WeatherApiRealTest.kt +++ /dev/null @@ -1,253 +0,0 @@ -import com.fasterxml.jackson.databind.ObjectMapper -import io.github.cdimascio.dotenv.dotenv -import org.junit.jupiter.api.BeforeAll -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.beans.factory.annotation.Value -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.web.client.RestTemplate -import java.time.ZoneId -import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter - -/** - * 실제 기상청 API 호출 테스트 - * Mock 데이터가 아닌 실제 API 응답 확인 - */ -@SpringBootTest -@org.springframework.test.context.ActiveProfiles("test") -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class WeatherApiRealTest { - companion object { - @JvmStatic - @BeforeAll - fun loadEnv() { - // .env 파일 로드 - val dotenv = - dotenv { - ignoreIfMissing = true - } - - // 환경변수를 시스템 프로퍼티로 설정 - dotenv.entries().forEach { entry -> - System.setProperty(entry.key, entry.value) - } - } - } - - @Autowired - private lateinit var restTemplate: RestTemplate - - @Value("\${weather.api.key}") - private lateinit var serviceKey: String - - @Value("\${weather.api.base-url}") - private lateinit var apiUrl: String - - private val objectMapper = ObjectMapper() - - /** - * 현재 발표시각 계산 (06시 또는 18시) - */ - private fun getCurrentBaseTime(): String { - val now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")) - val hour = now.hour - - return if (hour < 6) { - now.minusDays(1).format(DateTimeFormatter.ofPattern("yyyyMMdd")) + "1800" - } else if (hour < 18) { - now.format(DateTimeFormatter.ofPattern("yyyyMMdd")) + "0600" - } else { - now.format(DateTimeFormatter.ofPattern("yyyyMMdd")) + "1800" - } - } - - @Test - fun `실제 중기전망조회 API JSON 응답 확인`() { - // given - val stnId = "108" // 전국 - val baseTime = getCurrentBaseTime() - val url = "$apiUrl/getMidFcst?serviceKey=$serviceKey&numOfRows=10&pageNo=1&stnId=$stnId&tmFc=$baseTime&dataType=JSON" - - println("========================================") - println("📋 중기전망조회 API 테스트") - println("========================================") - println("📍 지역: 전국 (stnId=108)") - println("📅 발표시각: $baseTime (KST 기준 자동 계산)") - println("🔗 요청 URL: $url") - println() - - // when - val jsonResponse = restTemplate.getForObject(url, Map::class.java) - - // then - println("📦 JSON 응답 (Pretty Print):") - println("----------------------------------------") - println(objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonResponse)) - println() - - // 주요 데이터 추출 - val response = jsonResponse?.get("response") as? Map<*, *> - val body = response?.get("body") as? Map<*, *> - val items = body?.get("items") as? Map<*, *> - val itemList = items?.get("item") as? List<*> - val firstItem = itemList?.firstOrNull() as? Map<*, *> - - println("✅ 중기전망 텍스트 (wfSv):") - println("----------------------------------------") - println(firstItem?.get("wfSv")) - println() - } - - @Test - fun `실제 중기기온조회 API JSON 응답 확인`() { - // given - val regId = "11B10101" // 서울 - val baseTime = getCurrentBaseTime() - val url = "$apiUrl/getMidTa?serviceKey=$serviceKey&numOfRows=10&pageNo=1®Id=$regId&tmFc=$baseTime&dataType=JSON" - - println("========================================") - println("🌡️ 중기기온조회 API 테스트") - println("========================================") - println("📍 지역: 서울 (regId=11B10101)") - println("📅 발표시각: $baseTime (KST 기준 자동 계산)") - println("🔗 요청 URL: $url") - println() - - // when - val jsonResponse = restTemplate.getForObject(url, Map::class.java) - - // then - println("📦 JSON 응답 (Pretty Print):") - println("----------------------------------------") - println(objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonResponse)) - println() - - // 주요 데이터 추출 - val response = jsonResponse?.get("response") as? Map<*, *> - val body = response?.get("body") as? Map<*, *> - val items = body?.get("items") as? Map<*, *> - val itemList = items?.get("item") as? List<*> - val firstItem = itemList?.firstOrNull() as? Map<*, *> - - println("✅ 기온 데이터 (4일~10일):") - println("----------------------------------------") - for (day in 4..10) { - val minTemp = firstItem?.get("taMin$day") - val maxTemp = firstItem?.get("taMax$day") - println("📅 ${day}일 후: 최저 $minTemp℃ / 최고 $maxTemp℃") - } - println() - } - - @Test - fun `실제 중기육상예보조회 API JSON 응답 확인`() { - // given - val regId = "11B00000" // 서울,인천,경기도 - val baseTime = getCurrentBaseTime() - val url = "$apiUrl/getMidLandFcst?serviceKey=$serviceKey&numOfRows=10&pageNo=1®Id=$regId&tmFc=$baseTime&dataType=JSON" - - println("========================================") - println("🌧️ 중기육상예보조회 API 테스트") - println("========================================") - println("📍 지역: 서울,인천,경기도 (regId=11B00000)") - println("📅 발표시각: $baseTime (KST 기준 자동 계산)") - println("🔗 요청 URL: $url") - println() - - // when - val jsonResponse = restTemplate.getForObject(url, Map::class.java) - - // then - println("📦 JSON 응답 (Pretty Print):") - println("----------------------------------------") - println(objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonResponse)) - println() - - // 주요 데이터 추출 - val response = jsonResponse?.get("response") as? Map<*, *> - val body = response?.get("body") as? Map<*, *> - val items = body?.get("items") as? Map<*, *> - val itemList = items?.get("item") as? List<*> - val firstItem = itemList?.firstOrNull() as? Map<*, *> - - println("✅ 강수 확률 데이터 (4일~10일):") - println("----------------------------------------") - for (day in 4..10) { - if (day <= 7) { - // 4~7일: 오전/오후 구분 - val amRain = firstItem?.get("rnSt${day}Am") - val pmRain = firstItem?.get("rnSt${day}Pm") - val amWeather = firstItem?.get("wf${day}Am") - val pmWeather = firstItem?.get("wf${day}Pm") - - if (amRain != null || pmRain != null || amWeather != null || pmWeather != null) { - println("📅 ${day}일 후:") - println(" 오전: ${amWeather ?: "정보없음"} (강수확률: ${amRain ?: 0}%)") - println(" 오후: ${pmWeather ?: "정보없음"} (강수확률: ${pmRain ?: 0}%)") - } - } else { - // 8~10일: 통합 (오전/오후 구분 없음) - val rainPercent = firstItem?.get("rnSt$day") - val weather = firstItem?.get("wf$day") - - if (rainPercent != null || weather != null) { - println("📅 ${day}일 후: ${weather ?: "정보없음"} (강수확률: ${rainPercent ?: 0}%)") - } - } - } - println() - } - - @Test - fun `통합 테스트 - 3개 API 동시 호출`() { - val baseTime = getCurrentBaseTime() - val now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")) - - println("========================================") - println("🚀 통합 테스트 - 3개 API 동시 호출") - println("========================================") - println("⏰ 현재 KST 시간: ${now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))}") - println("📅 사용할 발표시각: $baseTime") - println("📝 설명: 06시 이전 → 전날 18시 / 06~18시 → 당일 06시 / 18시 이후 → 당일 18시") - println() - - // 1. 중기전망조회 (전국) - println("1️⃣ 중기전망조회 - 전국 (stnId=108)") - val midFcstUrl = "$apiUrl/getMidFcst?serviceKey=$serviceKey&numOfRows=10&pageNo=1&stnId=108&tmFc=$baseTime&dataType=JSON" - val midFcstResponse = restTemplate.getForObject(midFcstUrl, Map::class.java) - - // 2. 중기기온조회 (서울) - println("2️⃣ 중기기온조회 - 서울 (regId=11B10101)") - val midTaUrl = "$apiUrl/getMidTa?serviceKey=$serviceKey&numOfRows=10&pageNo=1®Id=11B10101&tmFc=$baseTime&dataType=JSON" - val midTaResponse = restTemplate.getForObject(midTaUrl, Map::class.java) - - // 3. 중기육상예보조회 (서울,인천,경기) - println("3️⃣ 중기육상예보 - 서울,인천,경기 (regId=11B00000)") - val midLandUrl = "$apiUrl/getMidLandFcst?serviceKey=$serviceKey&numOfRows=10&pageNo=1®Id=11B00000&tmFc=$baseTime&dataType=JSON" - val midLandResponse = restTemplate.getForObject(midLandUrl, Map::class.java) - - println("✅ API 호출 결과:") - println("----------------------------------------") - - // 결과 코드 확인 - val fcstCode = - (midFcstResponse?.get("response") as? Map<*, *>) - ?.get("header") as? Map<*, *> - println("1. 중기전망조회: ${fcstCode?.get("resultCode")} - ${fcstCode?.get("resultMsg")}") - - val taCode = - (midTaResponse?.get("response") as? Map<*, *>) - ?.get("header") as? Map<*, *> - println("2. 중기기온조회: ${taCode?.get("resultCode")} - ${taCode?.get("resultMsg")}") - - val landCode = - (midLandResponse?.get("response") as? Map<*, *>) - ?.get("header") as? Map<*, *> - println("3. 중기육상예보: ${landCode?.get("resultCode")} - ${landCode?.get("resultMsg")}") - - println() - println("💡 모든 API가 JSON 형식으로 응답을 반환했습니다!") - } -} diff --git a/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/weather/client/WeatherApiClientTest.kt b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/weather/client/WeatherApiClientTest.kt new file mode 100644 index 0000000..53e8f3f --- /dev/null +++ b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/weather/client/WeatherApiClientTest.kt @@ -0,0 +1,77 @@ +import com.back.koreaTravelGuide.KoreaTravelGuideApplication +import com.back.koreaTravelGuide.domain.ai.weather.client.WeatherApiClient +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ActiveProfiles +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +/** + * 실제 기상청 API 상태를 확인하기 위한 통합 테스트. + */ +@SpringBootTest(classes = [KoreaTravelGuideApplication::class]) +@ActiveProfiles("test") +class WeatherApiClientTest { + @Autowired + private lateinit var weatherApiClient: WeatherApiClient + + @Value("\${weather.api.key}") + private lateinit var serviceKey: String + + private fun getCurrentBaseTime(): String { + val now = LocalDateTime.now() + val baseHour = if (now.hour >= 6) "0600" else "1800" + val date = if (now.hour >= 6) now else now.minusDays(1) + return date.format(DateTimeFormatter.ofPattern("yyyyMMdd")) + baseHour + } + + @DisplayName("fetchMidForecast - 실제 기상청 API 중기전망조회 (데이터 기대)") + @Test + fun fetchMidForecastTest() { + assumeTrue(serviceKey.isNotBlank() && !serviceKey.contains("WEATHER_API_KEY")) { + "API 키가 설정되지 않아 테스트를 건너뜁니다." + } + + val regionId = "11B00000" + val baseTime = getCurrentBaseTime() + + val result = weatherApiClient.fetchMidForecast(regionId, baseTime) + + assertThat(result).isNotNull() + } + + @DisplayName("fetchTemperature - 실제 기상청 API 중기기온조회 (데이터 기대)") + @Test + fun fetchTemperatureTest() { + assumeTrue(serviceKey.isNotBlank() && !serviceKey.contains("WEATHER_API_KEY")) { + "API 키가 설정되지 않아 테스트를 건너뜁니다." + } + + val regionId = "11B10101" + val baseTime = getCurrentBaseTime() + + val result = weatherApiClient.fetchTemperature(regionId, baseTime) + + assertThat(result).isNotNull() + } + + @DisplayName("fetchLandForecast - 실제 기상청 API 중기육상예보조회 (데이터 기대)") + @Test + fun fetchLandForecastTest() { + assumeTrue(serviceKey.isNotBlank() && !serviceKey.contains("WEATHER_API_KEY")) { + "API 키가 설정되지 않아 테스트를 건너뜁니다." + } + + val regionId = "11B00000" + val baseTime = getCurrentBaseTime() + + val result = weatherApiClient.fetchLandForecast(regionId, baseTime) + + assertThat(result).isNotNull() + } +}