Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ data class AirKoreaResponse(

@JsonIgnoreProperties(ignoreUnknown = true)
data class Item(
val stationName: String, // 측정소 이름
val stationName: String?, // 측정소 이름
val dataTime: String, // 측정 일시 (yyyy-MM-dd HH:mm)
val pm10Value: String?, // PM10 농도 (µg/m³)
val pm25Value: String?, // PM2.5 농도 (µg/m³)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ class AirQualityServiceImpl(
pm10 = item.pm10Value?.toIntOrNull() ?: 0,
pm25 = item.pm25Value?.toIntOrNull() ?: 0,
measurementTime = parseDataTime(item.dataTime),
stationName = item.stationName,
// The station name is already resolved by the previous lookup step.
stationName = stationName,
)
} catch (e: ErrorException) {
throw e
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.pida.client.airquality

import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
import java.time.LocalDateTime

class AirQualityServiceImplTest {
@Test
fun `대기질 응답의 stationName이 없어도 조회된 측정소명을 사용한다`() {
val airKoreaClient = mockk<AirKoreaClient>()
val service = AirQualityServiceImpl(airKoreaClient)

every { airKoreaClient.getNearbyStation(any(), any()) } returns "종로구"
every { airKoreaClient.getAirQualityByStation("종로구") } returns
AirKoreaResponse(
response =
AirKoreaResponse.Response(
header = AirKoreaResponse.Header(resultCode = "00", resultMsg = "NORMAL SERVICE"),
body =
AirKoreaResponse.Body(
items =
listOf(
AirKoreaResponse.Item(
stationName = null,
dataTime = "2026-03-20 18:00",
pm10Value = "20",
pm25Value = "10",
khaiValue = null,
so2Value = null,
coValue = null,
o3Value = null,
no2Value = null,
),
),
numOfRows = 1,
pageNo = 1,
totalCount = 1,
),
),
)

val result = service.getAirQuality(latitude = 37.572025, longitude = 127.005028)

result.stationName shouldBe "종로구"
result.pm10 shouldBe 20
result.pm25 shouldBe 10
result.measurementTime shouldBe LocalDateTime.of(2026, 3, 20, 18, 0)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.pida.client.weather

import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.ObjectMapper
import com.pida.support.cache.Cache
import com.pida.support.cache.CacheRepository
import org.springframework.stereotype.Component

@Component
class KmaForecastCache(
_cache: Cache,
private val cacheRepository: CacheRepository,
private val objectMapper: ObjectMapper,
) {
companion object {
private const val FORECAST_CACHE_TTL_MINUTES = 180L
private const val STALE_GRID_CACHE_TTL_MINUTES = 360L
private val FORECAST_ITEM_LIST_TYPE = object : TypeReference<List<KmaWeatherResponse.Item>>() {}
}

fun getOrLoad(
baseDate: String,
baseTime: String,
nx: Int,
ny: Int,
loader: () -> List<KmaWeatherResponse.Item>,
): List<KmaWeatherResponse.Item> =
Cache.cacheBlocking(
ttl = FORECAST_CACHE_TTL_MINUTES,
key = requestKey(baseDate, baseTime, nx, ny),
typeReference = FORECAST_ITEM_LIST_TYPE,
) {
loader().also { items ->
putLatestByGrid(nx, ny, items)
}
}

fun getLatestByGrid(
nx: Int,
ny: Int,
): List<KmaWeatherResponse.Item>? {
val cached = cacheRepository.get(gridKey(nx, ny)) ?: return null
return objectMapper.readValue(cached, FORECAST_ITEM_LIST_TYPE)
}

fun putLatestByGrid(
nx: Int,
ny: Int,
items: List<KmaWeatherResponse.Item>,
) {
cacheRepository.put(
key = gridKey(nx, ny),
value = objectMapper.writeValueAsString(items),
ttl = STALE_GRID_CACHE_TTL_MINUTES,
)
}

private fun requestKey(
baseDate: String,
baseTime: String,
nx: Int,
ny: Int,
): String = "kma:forecast:$baseDate:$baseTime:$nx:$ny"

private fun gridKey(
nx: Int,
ny: Int,
): String = "kma:grid:$nx:$ny"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.pida.client.weather

import java.time.LocalDateTime

interface KmaForecastClient {
fun getVilageForecast(
baseDate: String,
baseTime: String,
nx: Int,
ny: Int,
numOfRows: Int = 1000,
): KmaWeatherResponse

fun getLatestBaseTime(currentTime: LocalDateTime = LocalDateTime.now()): Pair<String, String>
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.concurrent.atomic.AtomicLong

/**
* 기상청 단기예보 API 클라이언트
Expand All @@ -16,9 +17,12 @@ class KmaWeatherClient internal constructor(
// 단일 변수이니 properties 대신 value로 선언
@param:Value("\${kma.api.service-key:}")
private val serviceKey: String,
@param:Value("\${kma.api.min-request-interval-millis:250}")
private val minRequestIntervalMillis: Long,
private val kmaWeatherApi: KmaWeatherApi,
) {
) : KmaForecastClient {
private val logger by logger()
private val nextAvailableRequestAtMillis = AtomicLong(0L)

/**
* 단기예보 조회
Expand All @@ -30,13 +34,14 @@ class KmaWeatherClient internal constructor(
* @param numOfRows 한 페이지 결과 수 (기본값: 1000)
* @return 기상청 단기예보 응답
*/
fun getVilageForecast(
override fun getVilageForecast(
baseDate: String,
baseTime: String,
nx: Int,
ny: Int,
numOfRows: Int = 1000,
numOfRows: Int,
): KmaWeatherResponse {
throttleRequest()
logger.info("Fetching Vilage Forecast: baseDate=$baseDate, baseTime=$baseTime, nx=$nx, ny=$ny")
return kmaWeatherApi.getVilageForecast(
serviceKey = serviceKey,
Expand All @@ -53,7 +58,7 @@ class KmaWeatherClient internal constructor(
* 기상청 단기예보는 하루 8번 발표 (02:00, 05:00, 08:00, 11:00, 14:00, 17:00, 20:00, 23:00)
* API 제공 시간은 발표시각 + 10분
*/
fun getLatestBaseTime(currentTime: LocalDateTime = LocalDateTime.now()): Pair<String, String> {
override fun getLatestBaseTime(currentTime: LocalDateTime): Pair<String, String> {
val baseTimes = listOf("0200", "0500", "0800", "1100", "1400", "1700", "2000", "2300")
val currentHourMinute = currentTime.format(DateTimeFormatter.ofPattern("HHmm")).toInt()

Expand All @@ -80,4 +85,30 @@ class KmaWeatherClient internal constructor(

return Pair(baseDate, baseTime)
}

private fun throttleRequest() {
if (minRequestIntervalMillis <= 0L) {
return
}

while (true) {
val now = System.currentTimeMillis()
val currentNextAvailableAt = nextAvailableRequestAtMillis.get()
val executeAt = maxOf(now, currentNextAvailableAt)

if (nextAvailableRequestAtMillis.compareAndSet(currentNextAvailableAt, executeAt + minRequestIntervalMillis)) {
val waitMillis = executeAt - now
if (waitMillis > 0L) {
runCatching {
Thread.sleep(waitMillis)
}.onFailure { error ->
if (error is InterruptedException) {
Thread.currentThread().interrupt()
}
}
}
return
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.pida.weather.SkyCondition
import com.pida.weather.Weather
import com.pida.weather.WeatherLocation
import com.pida.weather.WeatherService
import feign.FeignException
import org.springframework.stereotype.Service
import java.time.LocalDate
import java.time.LocalDateTime
Expand All @@ -19,7 +20,8 @@ import kotlin.math.abs
*/
@Service
class WeatherServiceImpl(
private val kmaWeatherClient: KmaWeatherClient,
private val kmaForecastClient: KmaForecastClient,
private val kmaForecastCache: KmaForecastCache,
) : WeatherService {
private val logger by logger()
private val dateFormatter = DateTimeFormatter.ofPattern("yyyyMMdd")
Expand Down Expand Up @@ -107,21 +109,37 @@ class WeatherServiceImpl(
}

private fun fetchForecastItems(location: WeatherLocation): List<KmaWeatherResponse.Item> {
val (baseDate, baseTime) = kmaWeatherClient.getLatestBaseTime()

logger.info(
"Fetching weather for location: lat=${location.latitude}, lon=${location.longitude}, nx=${location.nx}, ny=${location.ny}",
)

val response =
try {
kmaWeatherClient.getVilageForecast(baseDate, baseTime, location.nx, location.ny)
} catch (error: Exception) {
logger.error("Failed to fetch weather forecast", error)
throw ErrorException(ErrorType.WEATHER_API_CALL_FAILED)
val (baseDate, baseTime) = kmaForecastClient.getLatestBaseTime()
return try {
kmaForecastCache.getOrLoad(
baseDate = baseDate,
baseTime = baseTime,
nx = location.nx,
ny = location.ny,
) {
logger.info(
"Fetching weather for location: lat=${location.latitude}, lon=${location.longitude}, nx=${location.nx}, ny=${location.ny}",
)
kmaForecastClient
.getVilageForecast(baseDate, baseTime, location.nx, location.ny)
.response.body.items.item
}

return response.response.body.items.item
} catch (error: FeignException.TooManyRequests) {
logger.warn(
"KMA forecast API rate limit exceeded for nx=${location.nx}, ny=${location.ny}, baseDate=$baseDate, baseTime=$baseTime",
error,
)
kmaForecastCache.getLatestByGrid(location.nx, location.ny)?.let { staleItems ->
logger.warn(
"Using stale KMA forecast cache due to rate limit for nx=${location.nx}, ny=${location.ny}",
)
return staleItems
}
throw ErrorException(ErrorType.EXCEED_RATE_LIMIT)
} catch (error: Exception) {
logger.error("Failed to fetch weather forecast", error)
throw ErrorException(ErrorType.WEATHER_API_CALL_FAILED)
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package com.pida.client.weather

import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.pida.support.cache.Cache
import com.pida.support.cache.CacheAdvice
import com.pida.support.cache.CacheRepository
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.format.DateTimeFormatter

class KmaForecastCacheTest {
private val cacheRepository = FakeCacheRepository()
private val objectMapper = jacksonObjectMapper()
private val cache = Cache(CacheAdvice(cacheRepository, objectMapper))
private val kmaForecastCache = KmaForecastCache(cache, cacheRepository, objectMapper)

@Test
fun `같은 base 시각과 격자에 대한 요청은 Redis 캐시를 재사용한다`() {
var loadCount = 0

val first =
kmaForecastCache.getOrLoad("20260321", "1700", 59, 128) {
loadCount += 1
weatherItems(baseDate = "20260321", baseTime = "1700", probabilities = listOf(80))
}

val second =
kmaForecastCache.getOrLoad("20260321", "1700", 59, 128) {
loadCount += 1
weatherItems(baseDate = "20260321", baseTime = "1700", probabilities = listOf(20))
}

loadCount shouldBe 1
first.first().fcstValue shouldBe "80"
second.first().fcstValue shouldBe "80"
}

@Test
fun `성공한 예보는 최근 격자 캐시에도 저장한다`() {
val items =
kmaForecastCache.getOrLoad("20260321", "1700", 59, 128) {
weatherItems(baseDate = "20260321", baseTime = "1700", probabilities = listOf(70))
}

kmaForecastCache.getLatestByGrid(59, 128) shouldBe items
}

private fun weatherItems(
baseDate: String,
baseTime: String,
probabilities: List<Int>,
): List<KmaWeatherResponse.Item> {
val tomorrow = LocalDate.now().plusDays(1).format(DateTimeFormatter.BASIC_ISO_DATE)

return probabilities.mapIndexed { index, probability ->
KmaWeatherResponse.Item(
baseDate = baseDate,
baseTime = baseTime,
category = "POP",
fcstDate = tomorrow,
fcstTime = "${index + 9}00".padStart(4, '0'),
fcstValue = probability.toString(),
nx = 59,
ny = 128,
)
}
}

private class FakeCacheRepository : CacheRepository {
private val storage = mutableMapOf<String, String>()

override fun get(key: String): String? = storage[key]

override fun put(
key: String,
value: String,
ttl: Long,
) {
storage[key] = value
}

override fun delete(key: String) {
storage.remove(key)
}
}
}
Loading
Loading