From 946d63d07fe8a7c23a059ca617af717402ebaf98 Mon Sep 17 00:00:00 2001 From: char-yb Date: Fri, 20 Mar 2026 23:12:05 +0900 Subject: [PATCH 1/5] [Fix] Harden AirKorea air-quality response decoding --- .../client/airquality/AirKoreaResponse.kt | 2 +- .../airquality/AirQualityServiceImpl.kt | 3 +- .../airquality/AirQualityServiceImplTest.kt | 51 +++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 pida-clients/airquality-client/src/test/kotlin/com/pida/client/airquality/AirQualityServiceImplTest.kt diff --git a/pida-clients/airquality-client/src/main/kotlin/com/pida/client/airquality/AirKoreaResponse.kt b/pida-clients/airquality-client/src/main/kotlin/com/pida/client/airquality/AirKoreaResponse.kt index 7e4b99aa..19efccc9 100644 --- a/pida-clients/airquality-client/src/main/kotlin/com/pida/client/airquality/AirKoreaResponse.kt +++ b/pida-clients/airquality-client/src/main/kotlin/com/pida/client/airquality/AirKoreaResponse.kt @@ -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³) diff --git a/pida-clients/airquality-client/src/main/kotlin/com/pida/client/airquality/AirQualityServiceImpl.kt b/pida-clients/airquality-client/src/main/kotlin/com/pida/client/airquality/AirQualityServiceImpl.kt index cb0db5f6..96b28531 100644 --- a/pida-clients/airquality-client/src/main/kotlin/com/pida/client/airquality/AirQualityServiceImpl.kt +++ b/pida-clients/airquality-client/src/main/kotlin/com/pida/client/airquality/AirQualityServiceImpl.kt @@ -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 diff --git a/pida-clients/airquality-client/src/test/kotlin/com/pida/client/airquality/AirQualityServiceImplTest.kt b/pida-clients/airquality-client/src/test/kotlin/com/pida/client/airquality/AirQualityServiceImplTest.kt new file mode 100644 index 00000000..b52b9a97 --- /dev/null +++ b/pida-clients/airquality-client/src/test/kotlin/com/pida/client/airquality/AirQualityServiceImplTest.kt @@ -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() + 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) + } +} From 565e1866d89217169391d1072d159cd1183d57f9 Mon Sep 17 00:00:00 2001 From: char-yb Date: Fri, 20 Mar 2026 23:12:31 +0900 Subject: [PATCH 2/5] [Feature] Add title and count metadata to category item list API --- .../item/MapCategoryItemAllResponse.kt | 6 +++ .../MapCategoryItemAllResponseTest.kt | 42 ++++++++++++++++++ .../com/pida/category/MapCategoryFacade.kt | 10 ++++- .../category/item/model/MapCategoryItems.kt | 2 + .../support/MapCategoryItemsTitleBuilder.kt | 18 ++++++++ ...rSpotCategoryItemDetailReadStrategyTest.kt | 2 +- .../pida/category/MapCategoryFacadeTest.kt | 2 + .../MapCategoryItemsTitleBuilderTest.kt | 43 +++++++++++++++++++ 8 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 pida-core/core-api/src/test/kotlin/com/pida/presentation/v2/category/response/MapCategoryItemAllResponseTest.kt create mode 100644 pida-core/core-domain/src/main/kotlin/com/pida/category/item/support/MapCategoryItemsTitleBuilder.kt create mode 100644 pida-core/core-domain/src/test/kotlin/com/pida/category/MapCategoryItemsTitleBuilderTest.kt diff --git a/pida-core/core-api/src/main/kotlin/com/pida/presentation/v2/category/response/item/MapCategoryItemAllResponse.kt b/pida-core/core-api/src/main/kotlin/com/pida/presentation/v2/category/response/item/MapCategoryItemAllResponse.kt index d9901cba..bde489bc 100644 --- a/pida-core/core-api/src/main/kotlin/com/pida/presentation/v2/category/response/item/MapCategoryItemAllResponse.kt +++ b/pida-core/core-api/src/main/kotlin/com/pida/presentation/v2/category/response/item/MapCategoryItemAllResponse.kt @@ -11,6 +11,10 @@ data class MapCategoryItemAllResponse( val categoryId: Long, @field:Schema(description = "카테고리 라벨", example = "EVENT") val categoryLabel: CategoryLabel, + @field:Schema(description = "리스트 상단 문구의 count 앞 제목", example = "2026 벚꽃 축제") + val title: String, + @field:Schema(description = "리스트 총 개수", example = "24") + val count: Int, @field:ArraySchema( schema = Schema(implementation = MapCategoryItemResponse::class), arraySchema = Schema(description = "카테고리별 데이터 목록"), @@ -22,6 +26,8 @@ data class MapCategoryItemAllResponse( MapCategoryItemAllResponse( categoryId = mapCategoryItems.categoryId, categoryLabel = mapCategoryItems.categoryLabel, + title = mapCategoryItems.title, + count = mapCategoryItems.count, list = mapCategoryItems.list.map { MapCategoryItemResponse.from(it) }, ) } diff --git a/pida-core/core-api/src/test/kotlin/com/pida/presentation/v2/category/response/MapCategoryItemAllResponseTest.kt b/pida-core/core-api/src/test/kotlin/com/pida/presentation/v2/category/response/MapCategoryItemAllResponseTest.kt new file mode 100644 index 00000000..0e5e09d9 --- /dev/null +++ b/pida-core/core-api/src/test/kotlin/com/pida/presentation/v2/category/response/MapCategoryItemAllResponseTest.kt @@ -0,0 +1,42 @@ +package com.pida.presentation.v2.category.response + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.pida.category.CategoryLabel +import com.pida.category.item.model.MapCategoryItem +import com.pida.category.item.model.MapCategoryItems +import com.pida.presentation.v2.category.response.item.MapCategoryItemAllResponse +import com.pida.support.geo.GeoJson +import com.pida.support.geo.Region +import io.kotest.matchers.string.shouldContain +import org.junit.jupiter.api.Test + +class MapCategoryItemAllResponseTest { + @Test + fun `카테고리별 데이터 목록 응답에 title과 count를 포함한다`() { + val response = + MapCategoryItemAllResponse.from( + MapCategoryItems( + categoryId = 1L, + categoryLabel = CategoryLabel.EVENT, + title = "2026 벚꽃 축제", + count = 1, + list = + listOf( + MapCategoryItem( + id = 21L, + name = "여의도 봄꽃축제", + address = "서울특별시 영등포구 여의서로 330", + description = null, + pinPoint = GeoJson.Point(listOf(126.9340, 37.5284)), + region = Region.SEOUL, + ), + ), + ), + ) + + val json = jacksonObjectMapper().writeValueAsString(response) + + json shouldContain "\"title\":\"2026 벚꽃 축제\"" + json shouldContain "\"count\":1" + } +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/category/MapCategoryFacade.kt b/pida-core/core-domain/src/main/kotlin/com/pida/category/MapCategoryFacade.kt index 4a2a3d9e..f872f553 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/category/MapCategoryFacade.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/category/MapCategoryFacade.kt @@ -2,6 +2,7 @@ package com.pida.category import com.pida.category.item.detail.MapCategoryItemDetail import com.pida.category.item.model.MapCategoryItems +import com.pida.category.item.support.MapCategoryItemsTitleBuilder import com.pida.category.strategy.detail.MapCategoryItemDetailReadStrategy import com.pida.category.strategy.read.MapCategoryItemReadStrategy import com.pida.flowerspot.FlowerSpotLocation @@ -46,11 +47,18 @@ class MapCategoryFacade( requireNotNull(strategiesByCategory[category.categoryLabel]) { "No map category item strategy for ${category.categoryLabel}" } + val items = strategy.read(category.id, region, location) return MapCategoryItems( categoryId = category.id, categoryLabel = category.categoryLabel, - list = strategy.read(category.id, region, location), + title = + MapCategoryItemsTitleBuilder.build( + categoryLabel = category.categoryLabel, + count = items.size, + ), + count = items.size, + list = items, ) } diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/category/item/model/MapCategoryItems.kt b/pida-core/core-domain/src/main/kotlin/com/pida/category/item/model/MapCategoryItems.kt index 365d4826..597ce572 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/category/item/model/MapCategoryItems.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/category/item/model/MapCategoryItems.kt @@ -5,5 +5,7 @@ import com.pida.category.CategoryLabel data class MapCategoryItems( val categoryId: Long, val categoryLabel: CategoryLabel, + val title: String, + val count: Int, val list: List, ) diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/category/item/support/MapCategoryItemsTitleBuilder.kt b/pida-core/core-domain/src/main/kotlin/com/pida/category/item/support/MapCategoryItemsTitleBuilder.kt new file mode 100644 index 00000000..a04c5915 --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/category/item/support/MapCategoryItemsTitleBuilder.kt @@ -0,0 +1,18 @@ +package com.pida.category.item.support + +import com.pida.category.CategoryLabel +import java.time.Clock +import java.time.LocalDate + +object MapCategoryItemsTitleBuilder { + fun build( + categoryLabel: CategoryLabel, + count: Int, + clock: Clock = Clock.systemDefaultZone(), + ): String = + when (categoryLabel) { + CategoryLabel.EVENT -> "${LocalDate.now(clock).year} 벚꽃 축제 ${count}곳" + CategoryLabel.CAFE -> "주변에 벚꽃 뷰 카페 ${count}곳을 찾았어요" + CategoryLabel.FLOWER_SPOT -> "주변에 걷기 좋은 산책로 ${count}곳이 있어요" + } +} diff --git a/pida-core/core-domain/src/test/kotlin/com/pida/category/FlowerSpotCategoryItemDetailReadStrategyTest.kt b/pida-core/core-domain/src/test/kotlin/com/pida/category/FlowerSpotCategoryItemDetailReadStrategyTest.kt index 76a2c5f9..520a14b7 100644 --- a/pida-core/core-domain/src/test/kotlin/com/pida/category/FlowerSpotCategoryItemDetailReadStrategyTest.kt +++ b/pida-core/core-domain/src/test/kotlin/com/pida/category/FlowerSpotCategoryItemDetailReadStrategyTest.kt @@ -9,9 +9,9 @@ import com.pida.category.badge.model.MapCategoryBadge import com.pida.category.badge.model.MapCategoryBadgeTargetType import com.pida.category.badge.model.MapCategoryBadgeType import com.pida.category.strategy.detail.FlowerSpotCategoryItemDetailReadStrategy +import com.pida.flowerspot.FlowerKind import com.pida.flowerspot.FlowerSpotDetails import com.pida.flowerspot.FlowerSpotFacade -import com.pida.flowerspot.FlowerKind import com.pida.flowerspot.FlowerSpotImage import com.pida.support.geo.GeoJson import com.pida.support.geo.Region diff --git a/pida-core/core-domain/src/test/kotlin/com/pida/category/MapCategoryFacadeTest.kt b/pida-core/core-domain/src/test/kotlin/com/pida/category/MapCategoryFacadeTest.kt index a4be3d37..eaa9275c 100644 --- a/pida-core/core-domain/src/test/kotlin/com/pida/category/MapCategoryFacadeTest.kt +++ b/pida-core/core-domain/src/test/kotlin/com/pida/category/MapCategoryFacadeTest.kt @@ -70,6 +70,8 @@ class MapCategoryFacadeTest { result.categoryId shouldBe 1L result.categoryLabel shouldBe CategoryLabel.EVENT + result.title shouldBe "2026 벚꽃 축제 1곳" + result.count shouldBe 1 result.list shouldHaveSize 1 result.list.first() shouldBe item } diff --git a/pida-core/core-domain/src/test/kotlin/com/pida/category/MapCategoryItemsTitleBuilderTest.kt b/pida-core/core-domain/src/test/kotlin/com/pida/category/MapCategoryItemsTitleBuilderTest.kt new file mode 100644 index 00000000..155ad0d1 --- /dev/null +++ b/pida-core/core-domain/src/test/kotlin/com/pida/category/MapCategoryItemsTitleBuilderTest.kt @@ -0,0 +1,43 @@ +package com.pida.category + +import com.pida.category.item.support.MapCategoryItemsTitleBuilder +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Instant +import java.time.ZoneId + +class MapCategoryItemsTitleBuilderTest { + private val fixedClock = + Clock.fixed( + Instant.parse("2026-03-20T00:00:00Z"), + ZoneId.of("Asia/Seoul"), + ) + + @Test + fun `축제 타이틀은 현재 연도와 카테고리명을 붙여 생성한다`() { + MapCategoryItemsTitleBuilder.build( + categoryLabel = CategoryLabel.EVENT, + count = 3, + clock = fixedClock, + ) shouldBe "2026 벚꽃 축제 3곳" + } + + @Test + fun `카페 타이틀은 추천 문구를 반환한다`() { + MapCategoryItemsTitleBuilder.build( + categoryLabel = CategoryLabel.CAFE, + count = 2, + clock = fixedClock, + ) shouldBe "주변에 벚꽃 뷰 카페 2곳을 찾았어요" + } + + @Test + fun `산책길 타이틀은 추천 문구를 반환한다`() { + MapCategoryItemsTitleBuilder.build( + categoryLabel = CategoryLabel.FLOWER_SPOT, + count = 4, + clock = fixedClock, + ) shouldBe "주변에 걷기 좋은 산책로 4곳이 있어요" + } +} From d2d4370c1e34009802316b5981724f579e9a0060 Mon Sep 17 00:00:00 2001 From: char-yb Date: Sun, 22 Mar 2026 19:32:00 +0900 Subject: [PATCH 3/5] [Fix] Add refresh token reissue guards and Redis fallback handling --- pida-core/core-api/build.gradle.kts | 1 + .../main/kotlin/com/pida/jwt/JwtProvider.kt | 195 ++++++++++--- .../kotlin/com/pida/jwt/JwtProviderTest.kt | 265 ++++++++++++++++++ .../com/pida/auth/AuthenticationHistory.kt | 1 + .../pida/auth/AuthenticationHistoryReader.kt | 5 + .../auth/AuthenticationHistoryRepository.kt | 5 + .../com/pida/auth/RedisTokenRepository.kt | 8 + .../AuthenticationHistoryCoreRepository.kt | 8 + .../core/auth/AuthenticationHistoryEntity.kt | 3 + .../storage/redis/RedisTokenCoreRepository.kt | 19 +- 10 files changed, 462 insertions(+), 48 deletions(-) create mode 100644 pida-core/core-api/src/test/kotlin/com/pida/jwt/JwtProviderTest.kt diff --git a/pida-core/core-api/build.gradle.kts b/pida-core/core-api/build.gradle.kts index b2f976d8..7b074ed1 100644 --- a/pida-core/core-api/build.gradle.kts +++ b/pida-core/core-api/build.gradle.kts @@ -37,6 +37,7 @@ dependencies { implementation(libs.spring.boot.starter.aop) implementation(libs.spring.boot.starter.validation) compileOnly(libs.redisson) + testImplementation(libs.redisson) // Security implementation(libs.spring.boot.starter.security) diff --git a/pida-core/core-api/src/main/kotlin/com/pida/jwt/JwtProvider.kt b/pida-core/core-api/src/main/kotlin/com/pida/jwt/JwtProvider.kt index 3b383b4a..e3b42574 100644 --- a/pida-core/core-api/src/main/kotlin/com/pida/jwt/JwtProvider.kt +++ b/pida-core/core-api/src/main/kotlin/com/pida/jwt/JwtProvider.kt @@ -8,6 +8,7 @@ import com.pida.auth.GrantedAuthority import com.pida.auth.Provider import com.pida.auth.ProviderDetail import com.pida.auth.RedisTokenRepository +import com.pida.auth.TokenWithAuthentication import com.pida.auth.UpdateAuthenticationHistory import com.pida.config.AuthenticationProperties import com.pida.support.error.AuthenticationErrorException @@ -18,6 +19,8 @@ import com.pida.token.TokenStatus import com.pida.token.repository.TokenRepository import com.pida.user.SocialUser import com.pida.user.User +import org.redisson.api.RedissonClient +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.security.authentication.AuthenticationServiceException import org.springframework.security.oauth2.jwt.BadJwtException import org.springframework.security.oauth2.jwt.Jwt @@ -28,7 +31,10 @@ import org.springframework.security.oauth2.jwt.JwtEncoderParameters import org.springframework.security.oauth2.jwt.JwtException import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException import org.springframework.stereotype.Component +import java.security.MessageDigest import java.time.Instant +import java.util.UUID +import java.util.concurrent.TimeUnit import javax.naming.AuthenticationException @Component @@ -39,9 +45,15 @@ class JwtProvider( private val redisTokenRepository: RedisTokenRepository, private val authenticationHistoryReader: AuthenticationHistoryReader, private val authenticationHistoryUpdater: AuthenticationHistoryUpdater, + @param:Qualifier("authRedissonClient") + private val redissonClient: RedissonClient, ) : TokenRepository { companion object { val grantedAuthorities = listOf(GrantedAuthority(AuthorityType.USER)) + private const val REFRESH_RENEW_LOCK_KEY_PREFIX = "auth:refresh:renew" + private const val REFRESH_RENEW_LOCK_WAIT_SECONDS = 3L + private const val REFRESH_RENEW_LOCK_LEASE_SECONDS = 10L + private const val REFRESH_TOKEN_ROTATION_GRACE_SECONDS = 10L } override fun create( @@ -112,57 +124,80 @@ class JwtProvider( override fun renew(refreshToken: String): Token { val jwt = validateToken(refreshToken) - val tokenWithAuthentication = redisTokenRepository.findByToken(jwt.tokenValue) - val authenticationHistory = - verifyTokenHistory( - userKey = tokenWithAuthentication.provider.userKey, - deviceId = tokenWithAuthentication.deviceId, - refreshToken = tokenWithAuthentication.refreshToken, - ) + return runWithRefreshRenewLock(jwt.tokenValue) { + val tokenWithAuthentication = findRenewableToken(jwt) + val authenticationHistory = + verifyTokenHistory( + userKey = tokenWithAuthentication.provider.userKey, + deviceId = tokenWithAuthentication.deviceId, + refreshToken = tokenWithAuthentication.refreshToken, + ) - removeRotationToken(tokenWithAuthentication.accessToken, tokenWithAuthentication.refreshToken) + if (tokenWithAuthentication.refreshToken != jwt.tokenValue) { + return@runWithRefreshRenewLock Token( + accessToken = tokenWithAuthentication.accessToken, + refreshToken = tokenWithAuthentication.refreshToken, + ) + } - val newAccessToken = - issueAccessToken( - jwtId = tokenWithAuthentication.provider.userKey, - grantedAuthorities = - tokenWithAuthentication.provider.grantedAuthorities.map { - GrantedAuthority(AuthorityType.valueOf(it)) - }, - ) - val newRefreshToken = - issueRefreshToken( - jwtId = tokenWithAuthentication.provider.userKey, - ) + val renewedToken = + Token( + accessToken = + issueAccessToken( + jwtId = tokenWithAuthentication.provider.userKey, + grantedAuthorities = + tokenWithAuthentication.provider.grantedAuthorities.map { + GrantedAuthority(AuthorityType.valueOf(it)) + }, + ), + refreshToken = + issueRefreshToken( + jwtId = tokenWithAuthentication.provider.userKey, + ), + ) + + val renewedTokenWithAuthentication = + TokenWithAuthentication( + accessToken = renewedToken.accessToken, + refreshToken = renewedToken.refreshToken, + deviceId = tokenWithAuthentication.deviceId, + provider = tokenWithAuthentication.provider, + ) - return Token( - accessToken = newAccessToken, - refreshToken = newRefreshToken, - ).apply { redisTokenRepository.create( - accessToken = this.accessToken, - refreshToken = this.refreshToken, + accessToken = renewedToken.accessToken, + refreshToken = renewedToken.refreshToken, deviceId = tokenWithAuthentication.deviceId, providerDetail = tokenWithAuthentication.provider, accessTokenExpiration = authenticationProperties.accessTokenExpirationSeconds, refreshTokenExpiration = authenticationProperties.refreshTokenExpirationSeconds, ) - authenticationHistoryUpdater.update( - UpdateAuthenticationHistory( - userKey = authenticationHistory.userKey, - deviceId = authenticationHistory.deviceId, - refreshToken = refreshToken, - newToken = - NewToken( - token = - Token( - accessToken = this.accessToken, - refreshToken = this.refreshToken, - ), - ), - ), - ) + try { + authenticationHistoryUpdater.update( + UpdateAuthenticationHistory( + userKey = authenticationHistory.userKey, + deviceId = authenticationHistory.deviceId, + refreshToken = authenticationHistory.token.refreshToken, + newToken = NewToken(token = renewedToken), + ), + ) + } catch (exception: RuntimeException) { + rollbackRenewedToken(renewedTokenWithAuthentication) + throw exception + } + + if (jwt.tokenValue != renewedToken.refreshToken) { + runCatching { + redisTokenRepository.createRefreshAlias( + refreshToken = jwt.tokenValue, + tokenWithAuthentication = renewedTokenWithAuthentication, + expirationSeconds = REFRESH_TOKEN_ROTATION_GRACE_SECONDS, + ) + } + } + runCatching { redisTokenRepository.deleteToken(tokenWithAuthentication.accessToken) } + renewedToken } } @@ -247,6 +282,7 @@ class JwtProvider( .issuedAt(issuedAt) .issuer("pida") .claims { + it["tokenId"] = UUID.randomUUID().toString() if (claims != null) { it.putAll(claims) } @@ -276,11 +312,78 @@ class JwtProvider( return authenticationHistory } - private fun removeRotationToken( - accessToken: String, + private fun findRenewableToken(jwt: Jwt): TokenWithAuthentication = + redisTokenRepository.findByTokenOrNull(jwt.tokenValue) ?: restoreRenewableToken(jwt) + + private fun restoreRenewableToken(jwt: Jwt): TokenWithAuthentication { + val authenticationHistory = + authenticationHistoryReader.readByUserKeyWithRefreshTokenOrNull( + userKey = jwt.id, + refreshToken = jwt.tokenValue, + ) ?: throw AuthenticationErrorException(AuthenticationErrorType.INVALID_TOKEN) + + return TokenWithAuthentication( + accessToken = authenticationHistory.token.accessToken, + refreshToken = authenticationHistory.token.refreshToken, + deviceId = authenticationHistory.deviceId, + provider = + ProviderDetail( + userId = authenticationHistory.userId, + userKey = authenticationHistory.userKey, + grantedAuthorities = grantedAuthorities.map { it.authorityType.name }, + ), + ) + } + + private fun rollbackRenewedToken(tokenWithAuthentication: TokenWithAuthentication) { + runCatching { + redisTokenRepository.deleteToken(tokenWithAuthentication.accessToken) + redisTokenRepository.deleteToken(tokenWithAuthentication.refreshToken) + } + } + + private fun runWithRefreshRenewLock( refreshToken: String, - ) { - redisTokenRepository.deleteToken(accessToken) - redisTokenRepository.deleteToken(refreshToken) + action: () -> Token, + ): Token { + val lock = + runCatching { + redissonClient.getLock(refreshRenewLockKey(refreshToken)) + }.getOrElse { + return action() + } + + val acquired = + try { + lock.tryLock(REFRESH_RENEW_LOCK_WAIT_SECONDS, REFRESH_RENEW_LOCK_LEASE_SECONDS, TimeUnit.SECONDS) + } catch (exception: InterruptedException) { + Thread.currentThread().interrupt() + return action() + } catch (exception: RuntimeException) { + return action() + } + + if (!acquired) { + return action() + } + + return try { + action() + } finally { + runCatching { + if (lock.isHeldByCurrentThread) { + lock.unlock() + } + } + } } + + private fun refreshRenewLockKey(refreshToken: String): String = + "$REFRESH_RENEW_LOCK_KEY_PREFIX:${refreshToken.sha256()}" + + private fun String.sha256(): String = + MessageDigest + .getInstance("SHA-256") + .digest(this.toByteArray()) + .joinToString("") { "%02x".format(it) } } diff --git a/pida-core/core-api/src/test/kotlin/com/pida/jwt/JwtProviderTest.kt b/pida-core/core-api/src/test/kotlin/com/pida/jwt/JwtProviderTest.kt new file mode 100644 index 00000000..2f35c607 --- /dev/null +++ b/pida-core/core-api/src/test/kotlin/com/pida/jwt/JwtProviderTest.kt @@ -0,0 +1,265 @@ +package com.pida.jwt + +import com.pida.auth.AuthenticationHistory +import com.pida.auth.AuthenticationHistoryReader +import com.pida.auth.AuthenticationHistoryUpdater +import com.pida.auth.ProviderDetail +import com.pida.auth.RedisTokenRepository +import com.pida.auth.TokenWithAuthentication +import com.pida.config.AuthenticationProperties +import com.pida.support.error.AuthenticationErrorException +import com.pida.support.error.AuthenticationErrorType +import com.pida.token.NewToken +import com.pida.token.Token +import com.pida.token.TokenStatus +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.redisson.api.RLock +import org.redisson.api.RedissonClient +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.security.oauth2.jwt.JwtDecoder +import org.springframework.security.oauth2.jwt.JwtEncoder +import org.springframework.security.oauth2.jwt.JwtEncoderParameters +import java.time.LocalDateTime +import java.security.MessageDigest +import java.util.ArrayDeque +import java.util.concurrent.TimeUnit + +class JwtProviderTest { + private val jwtEncoder = QueueJwtEncoder() + private val jwtDecoder = mockk() + private val redisTokenRepository = mockk() + private val authenticationHistoryReader = mockk() + private val authenticationHistoryUpdater = mockk() + private val redissonClient = mockk() + private val lock = mockk() + + private val authenticationProperties = + AuthenticationProperties( + accessTokenExpirationSeconds = 30, + refreshTokenExpirationSeconds = 60, + ) + + private val jwtProvider = + JwtProvider( + jwtEncoder = jwtEncoder, + jwtDecoder = jwtDecoder, + authenticationProperties = authenticationProperties, + redisTokenRepository = redisTokenRepository, + authenticationHistoryReader = authenticationHistoryReader, + authenticationHistoryUpdater = authenticationHistoryUpdater, + redissonClient = redissonClient, + ) + + @BeforeEach + fun setUp() { + jwtEncoder.reset() + listOf("old-refresh", "refresh-token", "missing-refresh").forEach { refreshToken -> + every { redissonClient.getLock(lockKey(refreshToken)) } returns lock + } + every { lock.tryLock(3L, 10L, TimeUnit.SECONDS) } returns true + every { lock.isHeldByCurrentThread } returns true + every { lock.unlock() } just runs + } + + @Test + fun `이미 재발급이 완료된 refresh token 재요청은 최신 토큰을 그대로 반환한다`() { + val latestToken = + TokenWithAuthentication( + accessToken = "latest-access", + refreshToken = "latest-refresh", + deviceId = "device-1", + provider = + ProviderDetail( + userId = 1L, + userKey = "user-key", + grantedAuthorities = listOf("USER"), + ), + ) + val latestHistory = + authenticationHistory( + accessToken = latestToken.accessToken, + refreshToken = latestToken.refreshToken, + ) + + every { jwtDecoder.decode("old-refresh") } returns decodedJwt(tokenValue = "old-refresh", id = "user-key") + every { redisTokenRepository.findByTokenOrNull("old-refresh") } returns latestToken + every { + authenticationHistoryReader.readByUserKeyWithDeviceWithRefreshToken( + userKey = "user-key", + deviceId = "device-1", + refreshToken = "latest-refresh", + ) + } returns latestHistory + + jwtProvider.renew("old-refresh") shouldBe Token("latest-access", "latest-refresh") + + jwtEncoder.encodeCount shouldBe 0 + } + + @Test + fun `redis refresh token 이 유실되어도 active 이력이 남아 있으면 안전하게 재발급한다`() { + val currentHistory = + authenticationHistory( + accessToken = "old-access", + refreshToken = "refresh-token", + ) + val newTokenWithAuthentication = + TokenWithAuthentication( + accessToken = "new-access", + refreshToken = "new-refresh", + deviceId = "device-1", + provider = + ProviderDetail( + userId = 1L, + userKey = "user-key", + grantedAuthorities = listOf("USER"), + ), + ) + val expectedUpdate = + com.pida.auth.UpdateAuthenticationHistory( + userKey = "user-key", + deviceId = "device-1", + refreshToken = "refresh-token", + newToken = + NewToken( + token = Token("new-access", "new-refresh"), + ), + ) + + every { jwtDecoder.decode("refresh-token") } returns decodedJwt(tokenValue = "refresh-token", id = "user-key") + every { redisTokenRepository.findByTokenOrNull("refresh-token") } returns null + every { + authenticationHistoryReader.readByUserKeyWithRefreshTokenOrNull( + userKey = "user-key", + refreshToken = "refresh-token", + ) + } returns currentHistory + every { + authenticationHistoryReader.readByUserKeyWithDeviceWithRefreshToken( + userKey = "user-key", + deviceId = "device-1", + refreshToken = "refresh-token", + ) + } returns currentHistory + jwtEncoder.enqueue("new-access", "new-refresh") + every { + redisTokenRepository.create( + accessToken = "new-access", + refreshToken = "new-refresh", + deviceId = "device-1", + providerDetail = newTokenWithAuthentication.provider, + accessTokenExpiration = 30, + refreshTokenExpiration = 60, + ) + } returns newTokenWithAuthentication + every { authenticationHistoryUpdater.update(expectedUpdate) } returns currentHistory + every { + redisTokenRepository.createRefreshAlias( + refreshToken = "refresh-token", + tokenWithAuthentication = newTokenWithAuthentication, + expirationSeconds = 10, + ) + } just runs + every { redisTokenRepository.deleteToken("old-access") } just runs + + jwtProvider.renew("refresh-token") shouldBe Token("new-access", "new-refresh") + + verify(exactly = 1) { + redisTokenRepository.createRefreshAlias( + refreshToken = "refresh-token", + tokenWithAuthentication = newTokenWithAuthentication, + expirationSeconds = 10, + ) + } + verify(exactly = 1) { authenticationHistoryUpdater.update(expectedUpdate) } + jwtEncoder.encodeCount shouldBe 2 + } + + @Test + fun `redis 와 인증 이력 모두에 없는 refresh token 은 거절한다`() { + every { jwtDecoder.decode("missing-refresh") } returns decodedJwt(tokenValue = "missing-refresh", id = "user-key") + every { redisTokenRepository.findByTokenOrNull("missing-refresh") } returns null + every { + authenticationHistoryReader.readByUserKeyWithRefreshTokenOrNull( + userKey = "user-key", + refreshToken = "missing-refresh", + ) + } returns null + + val exception = + assertThrows(AuthenticationErrorException::class.java) { + jwtProvider.renew("missing-refresh") + } + + exception.authenticationErrorType shouldBe AuthenticationErrorType.INVALID_TOKEN + } + + private fun decodedJwt( + tokenValue: String, + id: String, + ): Jwt = + mockk { + every { this@mockk.tokenValue } returns tokenValue + every { this@mockk.id } returns id + } + + private fun encodedJwt(tokenValue: String): Jwt = + mockk { + every { this@mockk.tokenValue } returns tokenValue + } + + private fun lockKey(refreshToken: String): String = "auth:refresh:renew:${refreshToken.sha256()}" + + private fun String.sha256(): String = + MessageDigest + .getInstance("SHA-256") + .digest(this.toByteArray()) + .joinToString("") { "%02x".format(it) } + + private fun authenticationHistory( + accessToken: String, + refreshToken: String, + ): AuthenticationHistory = + AuthenticationHistory( + authenticationId = 1L, + userId = 1L, + userKey = "user-key", + deviceId = "device-1", + token = + Token( + accessToken = accessToken, + refreshToken = refreshToken, + ), + status = TokenStatus.ACTIVE, + loggedInAt = LocalDateTime.of(2026, 3, 22, 10, 0), + ) + + private inner class QueueJwtEncoder : JwtEncoder { + private val tokenValues = ArrayDeque() + + var encodeCount: Int = 0 + private set + + fun enqueue(vararg values: String) { + values.forEach(tokenValues::addLast) + } + + fun reset() { + tokenValues.clear() + encodeCount = 0 + } + + override fun encode(parameters: JwtEncoderParameters): Jwt { + encodeCount += 1 + return encodedJwt(tokenValues.removeFirst()) + } + } +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/auth/AuthenticationHistory.kt b/pida-core/core-domain/src/main/kotlin/com/pida/auth/AuthenticationHistory.kt index f61c1e1c..b25a622d 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/auth/AuthenticationHistory.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/auth/AuthenticationHistory.kt @@ -6,6 +6,7 @@ import java.time.LocalDateTime data class AuthenticationHistory( val authenticationId: Long, + val userId: Long, val userKey: String, val deviceId: String?, val token: Token, diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/auth/AuthenticationHistoryReader.kt b/pida-core/core-domain/src/main/kotlin/com/pida/auth/AuthenticationHistoryReader.kt index 9b1e76ba..b65dceff 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/auth/AuthenticationHistoryReader.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/auth/AuthenticationHistoryReader.kt @@ -16,6 +16,11 @@ class AuthenticationHistoryReader( authenticationHistoryRepository.findUserKeyWithDeviceWithRefreshToken(userKey, deviceId, refreshToken) ?: throw AuthenticationErrorException(AuthenticationErrorType.NOT_FOUND_HISTORY) + fun readByUserKeyWithRefreshTokenOrNull( + userKey: String, + refreshToken: String, + ): AuthenticationHistory? = authenticationHistoryRepository.findUserKeyWithRefreshToken(userKey, refreshToken) + fun readByUserKey(userKey: String): AuthenticationHistory? = authenticationHistoryRepository.findUserKey(userKey) fun readByUserId(userId: Long): AuthenticationHistory? = authenticationHistoryRepository.findUserId(userId) diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/auth/AuthenticationHistoryRepository.kt b/pida-core/core-domain/src/main/kotlin/com/pida/auth/AuthenticationHistoryRepository.kt index 6d43d468..97104126 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/auth/AuthenticationHistoryRepository.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/auth/AuthenticationHistoryRepository.kt @@ -11,6 +11,11 @@ interface AuthenticationHistoryRepository { refreshToken: String, ): AuthenticationHistory? + fun findUserKeyWithRefreshToken( + userKey: String, + refreshToken: String, + ): AuthenticationHistory? + fun update(updateAuthenticationHistory: UpdateAuthenticationHistory): AuthenticationHistory? fun findUserKey(userKey: String): AuthenticationHistory? diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/auth/RedisTokenRepository.kt b/pida-core/core-domain/src/main/kotlin/com/pida/auth/RedisTokenRepository.kt index 036976b4..d1e21cdd 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/auth/RedisTokenRepository.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/auth/RedisTokenRepository.kt @@ -10,8 +10,16 @@ interface RedisTokenRepository { refreshTokenExpiration: Long, ): TokenWithAuthentication + fun findByTokenOrNull(token: String): TokenWithAuthentication? + fun findByToken(token: String): TokenWithAuthentication + fun createRefreshAlias( + refreshToken: String, + tokenWithAuthentication: TokenWithAuthentication, + expirationSeconds: Long, + ) + fun findBy(accessToken: String): Provider? fun deleteToken(token: String) diff --git a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/auth/AuthenticationHistoryCoreRepository.kt b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/auth/AuthenticationHistoryCoreRepository.kt index e28dd12d..f7a3653b 100644 --- a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/auth/AuthenticationHistoryCoreRepository.kt +++ b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/auth/AuthenticationHistoryCoreRepository.kt @@ -34,6 +34,14 @@ class AuthenticationHistoryCoreRepository( return histories.find { it.refreshToken == refreshToken }?.toAuthenticationHistory() } + override fun findUserKeyWithRefreshToken( + userKey: String, + refreshToken: String, + ): AuthenticationHistory? = + repository.findAllByUserKeyAndEntityStatus(userKey, AuthenticationEntityStatus.ACTIVE)?.find { + it.refreshToken == refreshToken + }?.toAuthenticationHistory() + @Transactional override fun update(updateAuthenticationHistory: UpdateAuthenticationHistory): AuthenticationHistory? { val histories = diff --git a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/auth/AuthenticationHistoryEntity.kt b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/auth/AuthenticationHistoryEntity.kt index 4deb2968..2f07a506 100644 --- a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/auth/AuthenticationHistoryEntity.kt +++ b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/auth/AuthenticationHistoryEntity.kt @@ -1,4 +1,5 @@ package com.pida.storage.db.core.auth + import com.pida.auth.AuthenticationHistory import com.pida.auth.NewAuthenticationHistory import com.pida.token.Token @@ -31,6 +32,7 @@ class AuthenticationHistoryEntity( fun toAuthenticationHistory(): AuthenticationHistory = AuthenticationHistory( authenticationId = id!!, + userId = userId, userKey = userKey, deviceId = deviceId, token = @@ -47,6 +49,7 @@ class AuthenticationHistoryEntity( this.refreshToken = token.refreshToken return AuthenticationHistory( authenticationId = id!!, + userId = userId, userKey = userKey, deviceId = deviceId, token = diff --git a/pida-storage/redis/src/main/kotlin/com/pida/storage/redis/RedisTokenCoreRepository.kt b/pida-storage/redis/src/main/kotlin/com/pida/storage/redis/RedisTokenCoreRepository.kt index 89df62d1..7f860967 100644 --- a/pida-storage/redis/src/main/kotlin/com/pida/storage/redis/RedisTokenCoreRepository.kt +++ b/pida-storage/redis/src/main/kotlin/com/pida/storage/redis/RedisTokenCoreRepository.kt @@ -50,9 +50,24 @@ class RedisTokenCoreRepository( } override fun findByToken(token: String): TokenWithAuthentication { + return findByTokenOrNull(token) ?: throw AuthenticationErrorException(AuthenticationErrorType.INVALID_TOKEN) + } + + override fun findByTokenOrNull(token: String): TokenWithAuthentication? = redisTemplate.opsForValue().get(token)?.let { - return objectMapper.readValue(it, TokenWithAuthentication::class.java) - } ?: throw AuthenticationErrorException(AuthenticationErrorType.INVALID_TOKEN) + objectMapper.readValue(it, TokenWithAuthentication::class.java) + } + + override fun createRefreshAlias( + refreshToken: String, + tokenWithAuthentication: TokenWithAuthentication, + expirationSeconds: Long, + ) { + redisTemplate.opsForValue().set( + refreshToken, + objectMapper.writeValueAsString(tokenWithAuthentication), + Duration.ofSeconds(expirationSeconds), + ) } override fun findBy(accessToken: String): Provider? = From d26ecbb40fc0697b5cde1cce6c8e479f3404eb5f Mon Sep 17 00:00:00 2001 From: char-yb Date: Sun, 22 Mar 2026 19:52:19 +0900 Subject: [PATCH 4/5] [Style] Format auth token support files --- .../core-api/src/main/kotlin/com/pida/jwt/JwtProvider.kt | 3 +-- .../src/test/kotlin/com/pida/jwt/JwtProviderTest.kt | 2 +- .../db/core/auth/AuthenticationHistoryCoreRepository.kt | 8 +++++--- .../com/pida/storage/redis/RedisTokenCoreRepository.kt | 5 ++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pida-core/core-api/src/main/kotlin/com/pida/jwt/JwtProvider.kt b/pida-core/core-api/src/main/kotlin/com/pida/jwt/JwtProvider.kt index e3b42574..311770eb 100644 --- a/pida-core/core-api/src/main/kotlin/com/pida/jwt/JwtProvider.kt +++ b/pida-core/core-api/src/main/kotlin/com/pida/jwt/JwtProvider.kt @@ -378,8 +378,7 @@ class JwtProvider( } } - private fun refreshRenewLockKey(refreshToken: String): String = - "$REFRESH_RENEW_LOCK_KEY_PREFIX:${refreshToken.sha256()}" + private fun refreshRenewLockKey(refreshToken: String): String = "$REFRESH_RENEW_LOCK_KEY_PREFIX:${refreshToken.sha256()}" private fun String.sha256(): String = MessageDigest diff --git a/pida-core/core-api/src/test/kotlin/com/pida/jwt/JwtProviderTest.kt b/pida-core/core-api/src/test/kotlin/com/pida/jwt/JwtProviderTest.kt index 2f35c607..d0a4b944 100644 --- a/pida-core/core-api/src/test/kotlin/com/pida/jwt/JwtProviderTest.kt +++ b/pida-core/core-api/src/test/kotlin/com/pida/jwt/JwtProviderTest.kt @@ -27,8 +27,8 @@ import org.springframework.security.oauth2.jwt.Jwt import org.springframework.security.oauth2.jwt.JwtDecoder import org.springframework.security.oauth2.jwt.JwtEncoder import org.springframework.security.oauth2.jwt.JwtEncoderParameters -import java.time.LocalDateTime import java.security.MessageDigest +import java.time.LocalDateTime import java.util.ArrayDeque import java.util.concurrent.TimeUnit diff --git a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/auth/AuthenticationHistoryCoreRepository.kt b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/auth/AuthenticationHistoryCoreRepository.kt index f7a3653b..cb777938 100644 --- a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/auth/AuthenticationHistoryCoreRepository.kt +++ b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/auth/AuthenticationHistoryCoreRepository.kt @@ -38,9 +38,11 @@ class AuthenticationHistoryCoreRepository( userKey: String, refreshToken: String, ): AuthenticationHistory? = - repository.findAllByUserKeyAndEntityStatus(userKey, AuthenticationEntityStatus.ACTIVE)?.find { - it.refreshToken == refreshToken - }?.toAuthenticationHistory() + repository + .findAllByUserKeyAndEntityStatus(userKey, AuthenticationEntityStatus.ACTIVE) + ?.find { + it.refreshToken == refreshToken + }?.toAuthenticationHistory() @Transactional override fun update(updateAuthenticationHistory: UpdateAuthenticationHistory): AuthenticationHistory? { diff --git a/pida-storage/redis/src/main/kotlin/com/pida/storage/redis/RedisTokenCoreRepository.kt b/pida-storage/redis/src/main/kotlin/com/pida/storage/redis/RedisTokenCoreRepository.kt index 7f860967..e7122060 100644 --- a/pida-storage/redis/src/main/kotlin/com/pida/storage/redis/RedisTokenCoreRepository.kt +++ b/pida-storage/redis/src/main/kotlin/com/pida/storage/redis/RedisTokenCoreRepository.kt @@ -49,9 +49,8 @@ class RedisTokenCoreRepository( return tokenWithAuthentication } - override fun findByToken(token: String): TokenWithAuthentication { - return findByTokenOrNull(token) ?: throw AuthenticationErrorException(AuthenticationErrorType.INVALID_TOKEN) - } + override fun findByToken(token: String): TokenWithAuthentication = + findByTokenOrNull(token) ?: throw AuthenticationErrorException(AuthenticationErrorType.INVALID_TOKEN) override fun findByTokenOrNull(token: String): TokenWithAuthentication? = redisTemplate.opsForValue().get(token)?.let { From 705012c972b9389c2eaa20bc3252cea38c8ea98e Mon Sep 17 00:00:00 2001 From: char-yb Date: Sun, 22 Mar 2026 19:52:44 +0900 Subject: [PATCH 5/5] [Fix] Add Redis-backed weather forecast caching and 429 fallback --- .../pida/client/weather/KmaForecastCache.kt | 69 ++++++ .../pida/client/weather/KmaForecastClient.kt | 15 ++ .../pida/client/weather/KmaWeatherClient.kt | 39 ++- .../pida/client/weather/WeatherServiceImpl.kt | 48 ++-- .../client/weather/KmaForecastCacheTest.kt | 87 +++++++ .../client/weather/WeatherServiceImplTest.kt | 222 ++++++++++++++++++ .../kotlin/com/pida/support/cache/Cache.kt | 27 +++ 7 files changed, 488 insertions(+), 19 deletions(-) create mode 100644 pida-clients/weather-client/src/main/kotlin/com/pida/client/weather/KmaForecastCache.kt create mode 100644 pida-clients/weather-client/src/main/kotlin/com/pida/client/weather/KmaForecastClient.kt create mode 100644 pida-clients/weather-client/src/test/kotlin/com/pida/client/weather/KmaForecastCacheTest.kt create mode 100644 pida-clients/weather-client/src/test/kotlin/com/pida/client/weather/WeatherServiceImplTest.kt diff --git a/pida-clients/weather-client/src/main/kotlin/com/pida/client/weather/KmaForecastCache.kt b/pida-clients/weather-client/src/main/kotlin/com/pida/client/weather/KmaForecastCache.kt new file mode 100644 index 00000000..62a9e31f --- /dev/null +++ b/pida-clients/weather-client/src/main/kotlin/com/pida/client/weather/KmaForecastCache.kt @@ -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>() {} + } + + fun getOrLoad( + baseDate: String, + baseTime: String, + nx: Int, + ny: Int, + loader: () -> List, + ): List = + 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? { + 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, + ) { + 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" +} diff --git a/pida-clients/weather-client/src/main/kotlin/com/pida/client/weather/KmaForecastClient.kt b/pida-clients/weather-client/src/main/kotlin/com/pida/client/weather/KmaForecastClient.kt new file mode 100644 index 00000000..df25ce21 --- /dev/null +++ b/pida-clients/weather-client/src/main/kotlin/com/pida/client/weather/KmaForecastClient.kt @@ -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 +} diff --git a/pida-clients/weather-client/src/main/kotlin/com/pida/client/weather/KmaWeatherClient.kt b/pida-clients/weather-client/src/main/kotlin/com/pida/client/weather/KmaWeatherClient.kt index c08027fb..ddc5fbc4 100644 --- a/pida-clients/weather-client/src/main/kotlin/com/pida/client/weather/KmaWeatherClient.kt +++ b/pida-clients/weather-client/src/main/kotlin/com/pida/client/weather/KmaWeatherClient.kt @@ -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 클라이언트 @@ -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) /** * 단기예보 조회 @@ -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, @@ -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 { + override fun getLatestBaseTime(currentTime: LocalDateTime): Pair { val baseTimes = listOf("0200", "0500", "0800", "1100", "1400", "1700", "2000", "2300") val currentHourMinute = currentTime.format(DateTimeFormatter.ofPattern("HHmm")).toInt() @@ -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 + } + } + } } diff --git a/pida-clients/weather-client/src/main/kotlin/com/pida/client/weather/WeatherServiceImpl.kt b/pida-clients/weather-client/src/main/kotlin/com/pida/client/weather/WeatherServiceImpl.kt index 24a76285..acd83093 100644 --- a/pida-clients/weather-client/src/main/kotlin/com/pida/client/weather/WeatherServiceImpl.kt +++ b/pida-clients/weather-client/src/main/kotlin/com/pida/client/weather/WeatherServiceImpl.kt @@ -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 @@ -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") @@ -107,21 +109,37 @@ class WeatherServiceImpl( } private fun fetchForecastItems(location: WeatherLocation): List { - 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) + } } /** diff --git a/pida-clients/weather-client/src/test/kotlin/com/pida/client/weather/KmaForecastCacheTest.kt b/pida-clients/weather-client/src/test/kotlin/com/pida/client/weather/KmaForecastCacheTest.kt new file mode 100644 index 00000000..6061b94d --- /dev/null +++ b/pida-clients/weather-client/src/test/kotlin/com/pida/client/weather/KmaForecastCacheTest.kt @@ -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, + ): List { + 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() + + 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) + } + } +} diff --git a/pida-clients/weather-client/src/test/kotlin/com/pida/client/weather/WeatherServiceImplTest.kt b/pida-clients/weather-client/src/test/kotlin/com/pida/client/weather/WeatherServiceImplTest.kt new file mode 100644 index 00000000..126e31b4 --- /dev/null +++ b/pida-clients/weather-client/src/test/kotlin/com/pida/client/weather/WeatherServiceImplTest.kt @@ -0,0 +1,222 @@ +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 com.pida.support.error.ErrorException +import com.pida.support.error.ErrorType +import com.pida.weather.WeatherLocation +import feign.FeignException +import feign.Request +import feign.Response +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +class WeatherServiceImplTest { + private val kmaForecastClient = FakeKmaForecastClient() + private val cacheRepository = FakeCacheRepository() + private val objectMapper = jacksonObjectMapper() + private val cache = Cache(CacheAdvice(cacheRepository, objectMapper)) + private val kmaForecastCache = KmaForecastCache(cache, cacheRepository, objectMapper) + private val weatherService = WeatherServiceImpl(kmaForecastClient, kmaForecastCache) + + @Test + fun `캐시 loader 결과로 내일 최대 강수확률을 계산한다`() { + val location = weatherLocation() + val response = weatherResponse(baseDate = "20260321", baseTime = "1700", probabilities = listOf(20, 80)) + + kmaForecastClient.enqueueBaseTime("20260321" to "1700") + kmaForecastClient.enqueueResponse("20260321", "1700", 59, 128, response) + + weatherService.getTomorrowMaxPrecipitationProbability(location) shouldBe 80 + + kmaForecastClient.requestCount("20260321", "1700", 59, 128) shouldBe 1 + } + + @Test + fun `429가 발생하면 최근 성공 캐시로 degrade 한다`() { + val location = weatherLocation() + val cachedResponse = weatherResponse(baseDate = "20260321", baseTime = "1400", probabilities = listOf(70)) + + kmaForecastClient.enqueueBaseTime("20260321" to "1400") + kmaForecastClient.enqueueBaseTime("20260321" to "1700") + kmaForecastClient.enqueueResponse("20260321", "1400", 59, 128, cachedResponse) + kmaForecastClient.enqueueError("20260321", "1700", 59, 128, tooManyRequests()) + + weatherService.getTomorrowMaxPrecipitationProbability(location) shouldBe 70 + weatherService.getTomorrowMaxPrecipitationProbability(location) shouldBe 70 + + kmaForecastClient.requestCount("20260321", "1400", 59, 128) shouldBe 1 + kmaForecastClient.requestCount("20260321", "1700", 59, 128) shouldBe 1 + } + + @Test + fun `429이고 최근 성공 캐시도 없으면 초과 요청 예외를 반환한다`() { + val location = weatherLocation() + + kmaForecastClient.enqueueBaseTime("20260321" to "1700") + kmaForecastClient.enqueueError("20260321", "1700", 59, 128, tooManyRequests()) + + val exception = + assertThrows(ErrorException::class.java) { + weatherService.getTomorrowMaxPrecipitationProbability(location) + } + + exception.errorType shouldBe ErrorType.EXCEED_RATE_LIMIT + } + + private fun tooManyRequests(): FeignException.TooManyRequests = + FeignException.errorStatus( + "getVilageForecast", + Response + .builder() + .status(429) + .reason("Too Many Requests") + .request( + Request.create( + Request.HttpMethod.GET, + "http://localhost/getVilageFcst", + emptyMap(), + null, + Charsets.UTF_8, + null, + ), + ).headers(emptyMap()) + .build(), + ) as FeignException.TooManyRequests + + private fun weatherLocation(): WeatherLocation = + WeatherLocation( + latitude = 37.0, + longitude = 127.0, + nx = 59, + ny = 128, + ) + + private fun weatherResponse( + baseDate: String, + baseTime: String, + probabilities: List, + ): KmaWeatherResponse = + KmaWeatherResponse( + response = + KmaWeatherResponse.Response( + header = KmaWeatherResponse.Header(resultCode = "00", resultMsg = "NORMAL_SERVICE"), + body = + KmaWeatherResponse.Body( + dataType = "JSON", + items = KmaWeatherResponse.Items(item = weatherItems(baseDate, baseTime, probabilities)), + pageNo = 1, + numOfRows = 1000, + totalCount = probabilities.size, + ), + ), + ) + + private fun weatherItems( + baseDate: String, + baseTime: String, + probabilities: List, + ): List { + 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() + + 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) + } + } + + private class FakeKmaForecastClient : KmaForecastClient { + private val baseTimes = mutableListOf>() + private val responses = mutableMapOf() + private val errors = mutableMapOf() + private val requestCounts = mutableMapOf() + + fun enqueueBaseTime(baseTime: Pair) { + baseTimes += baseTime + } + + fun enqueueResponse( + baseDate: String, + baseTime: String, + nx: Int, + ny: Int, + response: KmaWeatherResponse, + ) { + responses[key(baseDate, baseTime, nx, ny)] = response + } + + fun enqueueError( + baseDate: String, + baseTime: String, + nx: Int, + ny: Int, + throwable: Throwable, + ) { + errors[key(baseDate, baseTime, nx, ny)] = throwable + } + + fun requestCount( + baseDate: String, + baseTime: String, + nx: Int, + ny: Int, + ): Int = requestCounts[key(baseDate, baseTime, nx, ny)] ?: 0 + + override fun getVilageForecast( + baseDate: String, + baseTime: String, + nx: Int, + ny: Int, + numOfRows: Int, + ): KmaWeatherResponse { + val key = key(baseDate, baseTime, nx, ny) + requestCounts[key] = (requestCounts[key] ?: 0) + 1 + errors[key]?.let { throw it } + return responses[key] ?: error("Missing response for $key") + } + + override fun getLatestBaseTime(currentTime: LocalDateTime): Pair { + check(baseTimes.isNotEmpty()) { "Missing base time" } + return baseTimes.removeAt(0) + } + + private fun key( + baseDate: String, + baseTime: String, + nx: Int, + ny: Int, + ): String = "$baseDate:$baseTime:$nx:$ny" + } +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/support/cache/Cache.kt b/pida-core/core-domain/src/main/kotlin/com/pida/support/cache/Cache.kt index 34a0d1de..97659ba1 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/support/cache/Cache.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/support/cache/Cache.kt @@ -21,6 +21,13 @@ class Cache( typeReference: TypeReference, function: () -> T, ): T = cacheAdvice.invoke(ttl, key, typeReference, function) + + fun cacheBlocking( + ttl: Long, + key: String, + typeReference: TypeReference, + function: () -> T, + ): T = cacheAdvice.invokeBlocking(ttl, key, typeReference, function) } } @@ -48,4 +55,24 @@ class CacheAdvice( } return result } + + fun invokeBlocking( + ttl: Long, + key: String, + typeReference: TypeReference, + function: () -> T, + ): T { + val cached = cacheRepository.get(key) + if (cached != null) { + return objectMapper.readValue(cached, typeReference) + } + + val result = function() + + if (result != null) { + val serialized = objectMapper.writeValueAsString(result) + cacheRepository.put(key, serialized, ttl) + } + return result + } }