From 52cad832e0740164436eec013b98f72522af6812 Mon Sep 17 00:00:00 2001 From: char-yb Date: Thu, 2 Apr 2026 00:27:30 +0900 Subject: [PATCH 1/6] [Fix] Remove Blooming Cache --- .../com/pida/blooming/BloomingFinder.kt | 6 +- .../com/pida/blooming/BloomingRepository.kt | 6 +- .../com/pida/blooming/BloomingService.kt | 62 ++----------- .../com/pida/blooming/BloomingServiceTest.kt | 91 +++++++++++++++++-- .../core/blooming/BloomingCoreRepository.kt | 12 +-- 5 files changed, 103 insertions(+), 74 deletions(-) diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/blooming/BloomingFinder.kt b/pida-core/core-domain/src/main/kotlin/com/pida/blooming/BloomingFinder.kt index 8a4d795..d84c1c9 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/blooming/BloomingFinder.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/blooming/BloomingFinder.kt @@ -31,11 +31,11 @@ class BloomingFinder( suspend fun readRecentlyBloomingByCafeId(cafeId: Long): List = bloomingRepository.findRecentlyByCafeId(cafeId) - fun recentlyBloomingBySpotIds(spotIds: List): List = bloomingRepository.findRecentBySpotIds(spotIds) + suspend fun recentlyBloomingBySpotIds(spotIds: List): List = bloomingRepository.findRecentBySpotIds(spotIds) - fun recentlyBloomingByEventIds(eventIds: List): List = bloomingRepository.findRecentByEventIds(eventIds) + suspend fun recentlyBloomingByEventIds(eventIds: List): List = bloomingRepository.findRecentByEventIds(eventIds) - fun recentlyBloomingByCafeIds(cafeIds: List): List = bloomingRepository.findRecentByCafeIds(cafeIds) + suspend fun recentlyBloomingByCafeIds(cafeIds: List): List = bloomingRepository.findRecentByCafeIds(cafeIds) fun readTodayBloomingByUserId( userId: Long, diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/blooming/BloomingRepository.kt b/pida-core/core-domain/src/main/kotlin/com/pida/blooming/BloomingRepository.kt index 3be0b09..9d3df94 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/blooming/BloomingRepository.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/blooming/BloomingRepository.kt @@ -31,11 +31,11 @@ interface BloomingRepository { suspend fun findRecentlyByCafeId(cafeId: Long): List - fun findRecentBySpotIds(spotIds: List): List + suspend fun findRecentBySpotIds(spotIds: List): List - fun findRecentByEventIds(eventIds: List): List + suspend fun findRecentByEventIds(eventIds: List): List - fun findRecentByCafeIds(cafeIds: List): List + suspend fun findRecentByCafeIds(cafeIds: List): List fun findTodayBloomingByUserId( userId: Long, diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/blooming/BloomingService.kt b/pida-core/core-domain/src/main/kotlin/com/pida/blooming/BloomingService.kt index e271c08..07535b9 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/blooming/BloomingService.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/blooming/BloomingService.kt @@ -1,8 +1,5 @@ package com.pida.blooming -import com.fasterxml.jackson.core.type.TypeReference -import com.pida.support.cache.Cache -import com.pida.support.cache.CacheRepository import com.pida.support.error.ErrorException import com.pida.support.error.ErrorType import org.springframework.stereotype.Service @@ -12,15 +9,7 @@ class BloomingService( private val bloomingAppender: BloomingAppender, private val bloomingValidator: BloomingValidator, private val bloomingFinder: BloomingFinder, - private val cacheRepository: CacheRepository, ) { - companion object { - const val BLOOMING_SPOT_KEY = "blooming:spot" - const val BLOOMING_EVENT_KEY = "blooming:event" - const val BLOOMING_CAFE_KEY = "blooming:cafe" - const val BLOOMING_TTL = 30L - } - suspend fun add(newBlooming: NewBlooming): Blooming { val blooming = when (newBlooming) { @@ -38,9 +27,7 @@ class BloomingService( } bloomingValidator.addValidate(blooming) - val result = bloomingAppender.add(newBlooming) - evictBloomingCache(newBlooming) - return result + return bloomingAppender.add(newBlooming) } suspend fun recentlyBloomingBySpotId(spotId: Long): List = bloomingFinder.readRecentlyBloomingBySpotId(spotId) @@ -49,41 +36,14 @@ class BloomingService( suspend fun recentlyBloomingByCafeId(cafeId: Long): List = bloomingFinder.readRecentlyBloomingByCafeId(cafeId) - fun recentlyBloomingBySpotIds(spotIds: List): List = - spotIds.flatMap { spotId -> cachedRecentlyBloomingBySpotId(spotId) } - - fun recentlyBloomingByEventIds(eventIds: List): List = - eventIds.flatMap { eventId -> cachedRecentlyBloomingByEventId(eventId) } - - fun recentlyBloomingByCafeIds(cafeIds: List): List = - cafeIds.flatMap { cafeId -> cachedRecentlyBloomingByCafeId(cafeId) } + suspend fun recentlyBloomingBySpotIds(spotIds: List): List = + bloomingFinder.recentlyBloomingBySpotIds(spotIds.distinct()) - private fun cachedRecentlyBloomingBySpotId(spotId: Long): List = - Cache.cacheBlocking( - ttl = BLOOMING_TTL, - key = "$BLOOMING_SPOT_KEY:$spotId", - typeReference = object : TypeReference>() {}, - ) { - bloomingFinder.recentlyBloomingBySpotIds(listOf(spotId)) - } + suspend fun recentlyBloomingByEventIds(eventIds: List): List = + bloomingFinder.recentlyBloomingByEventIds(eventIds.distinct()) - private fun cachedRecentlyBloomingByEventId(eventId: Long): List = - Cache.cacheBlocking( - ttl = BLOOMING_TTL, - key = "$BLOOMING_EVENT_KEY:$eventId", - typeReference = object : TypeReference>() {}, - ) { - bloomingFinder.recentlyBloomingByEventIds(listOf(eventId)) - } - - private fun cachedRecentlyBloomingByCafeId(cafeId: Long): List = - Cache.cacheBlocking( - ttl = BLOOMING_TTL, - key = "$BLOOMING_CAFE_KEY:$cafeId", - typeReference = object : TypeReference>() {}, - ) { - bloomingFinder.recentlyBloomingByCafeIds(listOf(cafeId)) - } + suspend fun recentlyBloomingByCafeIds(cafeIds: List): List = + bloomingFinder.recentlyBloomingByCafeIds(cafeIds.distinct()) fun verifyTodayBlooming( userId: Long, @@ -104,12 +64,4 @@ class BloomingService( return bloomingValidator.todayBloomingValidate(blooming) } - - private fun evictBloomingCache(newBlooming: NewBlooming) { - when (newBlooming) { - is NewBlooming.FlowerSpot -> cacheRepository.delete("$BLOOMING_SPOT_KEY:${newBlooming.flowerSpotId}") - is NewBlooming.FlowerEvent -> cacheRepository.delete("$BLOOMING_EVENT_KEY:${newBlooming.flowerEventId}") - is NewBlooming.FlowerSpotCafe -> cacheRepository.delete("$BLOOMING_CAFE_KEY:${newBlooming.flowerSpotCafeId}") - } - } } diff --git a/pida-core/core-domain/src/test/kotlin/com/pida/blooming/BloomingServiceTest.kt b/pida-core/core-domain/src/test/kotlin/com/pida/blooming/BloomingServiceTest.kt index 38fa837..fc075ee 100644 --- a/pida-core/core-domain/src/test/kotlin/com/pida/blooming/BloomingServiceTest.kt +++ b/pida-core/core-domain/src/test/kotlin/com/pida/blooming/BloomingServiceTest.kt @@ -1,6 +1,5 @@ package com.pida.blooming -import com.pida.support.cache.CacheRepository import com.pida.support.error.ErrorException import com.pida.support.error.ErrorType import io.kotest.matchers.shouldBe @@ -17,8 +16,7 @@ class BloomingServiceTest { val bloomingAppender = mockk() val bloomingValidator = mockk() val bloomingFinder = mockk() - val cacheRepository = mockk(relaxed = true) - val service = BloomingService(bloomingAppender, bloomingValidator, bloomingFinder, cacheRepository) + val service = BloomingService(bloomingAppender, bloomingValidator, bloomingFinder) val blooming = Blooming( id = 1L, @@ -44,13 +42,93 @@ class BloomingServiceTest { verify { bloomingValidator.todayBloomingValidate(blooming) } } + @Test + fun `spot batch 조회는 중복 id를 제거한 뒤 한 번의 finder 호출로 위임한다`() { + val bloomingAppender = mockk() + val bloomingValidator = mockk() + val bloomingFinder = mockk() + val service = BloomingService(bloomingAppender, bloomingValidator, bloomingFinder) + val bloomings = + listOf( + Blooming( + id = 1L, + status = BloomingStatus.BLOOMED, + userId = 10L, + flowerSpotId = 30L, + flowerEventId = null, + flowerSpotCafeId = null, + createdAt = LocalDateTime.of(2026, 4, 1, 9, 0), + ), + ) + + every { bloomingFinder.recentlyBloomingBySpotIds(listOf(30L, 31L)) } returns bloomings + + val result = service.recentlyBloomingBySpotIds(listOf(30L, 31L, 30L)) + + result shouldBe bloomings + verify(exactly = 1) { bloomingFinder.recentlyBloomingBySpotIds(listOf(30L, 31L)) } + } + + @Test + fun `event batch 조회는 중복 id를 제거한 뒤 한 번의 finder 호출로 위임한다`() { + val bloomingAppender = mockk() + val bloomingValidator = mockk() + val bloomingFinder = mockk() + val service = BloomingService(bloomingAppender, bloomingValidator, bloomingFinder) + val bloomings = + listOf( + Blooming( + id = 2L, + status = BloomingStatus.LITTLE, + userId = 11L, + flowerSpotId = null, + flowerEventId = 40L, + flowerSpotCafeId = null, + createdAt = LocalDateTime.of(2026, 4, 1, 10, 0), + ), + ) + + every { bloomingFinder.recentlyBloomingByEventIds(listOf(40L, 41L)) } returns bloomings + + val result = service.recentlyBloomingByEventIds(listOf(40L, 41L, 40L)) + + result shouldBe bloomings + verify(exactly = 1) { bloomingFinder.recentlyBloomingByEventIds(listOf(40L, 41L)) } + } + + @Test + fun `cafe batch 조회는 중복 id를 제거한 뒤 한 번의 finder 호출로 위임한다`() { + val bloomingAppender = mockk() + val bloomingValidator = mockk() + val bloomingFinder = mockk() + val service = BloomingService(bloomingAppender, bloomingValidator, bloomingFinder) + val bloomings = + listOf( + Blooming( + id = 3L, + status = BloomingStatus.WITHERED, + userId = 12L, + flowerSpotId = null, + flowerEventId = null, + flowerSpotCafeId = 50L, + createdAt = LocalDateTime.of(2026, 4, 1, 11, 0), + ), + ) + + every { bloomingFinder.recentlyBloomingByCafeIds(listOf(50L, 51L)) } returns bloomings + + val result = service.recentlyBloomingByCafeIds(listOf(50L, 51L, 50L)) + + result shouldBe bloomings + verify(exactly = 1) { bloomingFinder.recentlyBloomingByCafeIds(listOf(50L, 51L)) } + } + @Test fun `flowerSpotId와 flowerEventId가 모두 없으면 INVALID_REQUEST를 던진다`() { val bloomingAppender = mockk() val bloomingValidator = mockk() val bloomingFinder = mockk() - val cacheRepository = mockk(relaxed = true) - val service = BloomingService(bloomingAppender, bloomingValidator, bloomingFinder, cacheRepository) + val service = BloomingService(bloomingAppender, bloomingValidator, bloomingFinder) val exception = assertThrows { @@ -67,8 +145,7 @@ class BloomingServiceTest { val bloomingAppender = mockk() val bloomingValidator = mockk() val bloomingFinder = mockk() - val cacheRepository = mockk(relaxed = true) - val service = BloomingService(bloomingAppender, bloomingValidator, bloomingFinder, cacheRepository) + val service = BloomingService(bloomingAppender, bloomingValidator, bloomingFinder) val exception = assertThrows { diff --git a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/blooming/BloomingCoreRepository.kt b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/blooming/BloomingCoreRepository.kt index 424f7e6..d9ca711 100644 --- a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/blooming/BloomingCoreRepository.kt +++ b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/blooming/BloomingCoreRepository.kt @@ -95,18 +95,18 @@ class BloomingCoreRepository( bloomingCustomRepository.recentlyByCafeId(cafeId).map { it.toBlooming() } } - override fun findRecentBySpotIds(spotIds: List): List = - Tx.readable { + override suspend fun findRecentBySpotIds(spotIds: List): List = + Tx.coReadable { bloomingCustomRepository.recentlyBySpotIds(spotIds).map { it.toBlooming() } } - override fun findRecentByEventIds(eventIds: List): List = - Tx.readable { + override suspend fun findRecentByEventIds(eventIds: List): List = + Tx.coReadable { bloomingCustomRepository.recentlyByEventIds(eventIds).map { it.toBlooming() } } - override fun findRecentByCafeIds(cafeIds: List): List = - Tx.readable { + override suspend fun findRecentByCafeIds(cafeIds: List): List = + Tx.coReadable { bloomingCustomRepository.recentlyByCafeIds(cafeIds).map { it.toBlooming() } } From 50b6a80ff9e2665db9ee234b8d1f88ea8cc9a2f6 Mon Sep 17 00:00:00 2001 From: char-yb Date: Thu, 2 Apr 2026 13:44:19 +0900 Subject: [PATCH 2/6] [Feature] Add external dependency resilience and degraded fallbacks --- gradle/libs.versions.toml | 15 ++- .../pida/client/airquality/AirKoreaClient.kt | 25 ++-- .../client/airquality/AirKoreaClientTest.kt | 20 ++-- .../com/pida/client/aws/config/AwsConfig.kt | 34 ++++++ .../pida/client/aws/config/AwsProperties.kt | 2 + .../pida/client/aws/image/ImageS3Processor.kt | 70 ++++++----- .../aws-client/src/main/resources/aws.yml | 12 +- .../com/pida/client/map/KakaoMapClient.kt | 21 ++-- .../map-client/src/main/resources/map.yml | 4 +- .../notification/FcmClientRepository.kt | 4 +- .../com/pida/client/notification/FcmConfig.kt | 42 ------- .../FirebaseCloudMessageSender.kt | 45 +++++-- .../notification/FirebaseMessagingProvider.kt | 69 +++++++++++ .../FirebaseMessagingProviderTest.kt | 29 +++++ .../com/pida/client/oauth/AppleClient.kt | 26 +++- .../pida/client/oauth/ApplePublicKeysCache.kt | 33 ++++++ .../com/pida/client/oauth/KaKaoClient.kt | 8 +- .../com/pida/client/oauth/OAuthService.kt | 6 +- .../oauth-client/src/main/resources/oauth.yml | 8 +- .../com/pida/client/oauth/AppleClientTest.kt | 104 ++++++++++++++++ .../com/pida/client/oauth/OAuthServiceTest.kt | 57 +++++++++ .../pida/client/weather/KmaWeatherClient.kt | 21 ++-- .../pida/client/weather/WeatherServiceImpl.kt | 26 +++- .../client/weather/WeatherServiceImplTest.kt | 26 +++- pida-core/core-api/build.gradle.kts | 3 + .../resilience/ExternalDependencyState.kt | 12 ++ .../ExternalDependencyStateTracker.kt | 45 +++++++ .../OptionalIntegrationHealthIndicator.kt | 43 +++++++ .../ResilienceExternalDependencyPolicy.kt | 109 +++++++++++++++++ .../presentation/advice/ApiExceptionAdvice.kt | 10 ++ .../core-api/src/main/resources/airkorea.yml | 16 +++ .../src/main/resources/application.yml | 1 + .../src/main/resources/client-resilience.yml | 62 ++++++++++ .../core-api/src/main/resources/weather.yml | 12 ++ .../OptionalIntegrationHealthIndicatorTest.kt | 27 +++++ .../advice/ApiExceptionAdviceTest.kt | 20 ++++ .../com/pida/flowerspot/FlowerSpotFacade.kt | 44 +++++-- .../kotlin/com/pida/notification/FcmSender.kt | 11 ++ .../kotlin/com/pida/place/LandmarkService.kt | 10 +- .../main/kotlin/com/pida/place/PlaceFacade.kt | 31 ++++- .../support/error/AuthenticationErrorType.kt | 48 +++++--- .../support/resilience/ExternalDependency.kt | 13 ++ .../resilience/ExternalDependencyPolicy.kt | 21 ++++ .../pida/flowerspot/FlowerSpotFacadeTest.kt | 37 +++++- .../kotlin/com/pida/place/PlaceFacadeTest.kt | 111 ++++++++++++++++++ .../src/main/resources/monitoring.yml | 3 + settings.gradle.kts | 2 - 47 files changed, 1227 insertions(+), 171 deletions(-) delete mode 100644 pida-clients/notification/src/main/kotlin/com/pida/client/notification/FcmConfig.kt create mode 100644 pida-clients/notification/src/main/kotlin/com/pida/client/notification/FirebaseMessagingProvider.kt create mode 100644 pida-clients/notification/src/test/kotlin/com/pida/client/notification/FirebaseMessagingProviderTest.kt create mode 100644 pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/ApplePublicKeysCache.kt create mode 100644 pida-clients/oauth-client/src/test/kotlin/com/pida/client/oauth/AppleClientTest.kt create mode 100644 pida-clients/oauth-client/src/test/kotlin/com/pida/client/oauth/OAuthServiceTest.kt create mode 100644 pida-core/core-api/src/main/kotlin/com/pida/config/resilience/ExternalDependencyState.kt create mode 100644 pida-core/core-api/src/main/kotlin/com/pida/config/resilience/ExternalDependencyStateTracker.kt create mode 100644 pida-core/core-api/src/main/kotlin/com/pida/config/resilience/OptionalIntegrationHealthIndicator.kt create mode 100644 pida-core/core-api/src/main/kotlin/com/pida/config/resilience/ResilienceExternalDependencyPolicy.kt create mode 100644 pida-core/core-api/src/main/resources/client-resilience.yml create mode 100644 pida-core/core-api/src/test/kotlin/com/pida/config/resilience/OptionalIntegrationHealthIndicatorTest.kt create mode 100644 pida-core/core-api/src/test/kotlin/com/pida/presentation/advice/ApiExceptionAdviceTest.kt create mode 100644 pida-core/core-domain/src/main/kotlin/com/pida/support/resilience/ExternalDependency.kt create mode 100644 pida-core/core-domain/src/main/kotlin/com/pida/support/resilience/ExternalDependencyPolicy.kt create mode 100644 pida-core/core-domain/src/test/kotlin/com/pida/place/PlaceFacadeTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3c0cb92..ab184b0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -60,6 +60,9 @@ spring_cloud_openfeign = "4.2.0" openfeign_hc5 = "13.1" openfeign_micrometer = "13.1" +# Resilience4j +resilience4j = "2.2.0" + # Bucket4j bucket4j = "8.15.0" @@ -186,6 +189,7 @@ epages_restdocs_api_spec_mock_mvc = { module = "com.epages:restdocs-api-spec-moc epages_restdocs_api_spec_restassured = { module = "com.epages:restdocs-api-spec-restassured", version.ref = "epages_restdocs_api_spec" } # Monitoring & Logging Libraries +micrometer_core = { module = "io.micrometer:micrometer-core" } micrometer_tracing_bridge_brave = { module = "io.micrometer:micrometer-tracing-bridge-brave" } micrometer_registry_prometheus = { module = "io.micrometer:micrometer-registry-prometheus" } sentry_spring_boot_starter_jakarta = { module = "io.sentry:sentry-spring-boot-starter-jakarta", version.ref = "sentry" } @@ -194,10 +198,18 @@ slf4j = { module = "org.slf4j:slf4j-api", version = "2.0.9" } # AWS Libraries aws_sdk_s3 = { module = "software.amazon.awssdk:s3", version.ref = "aws" } +aws_sdk_url_connection_client = { module = "software.amazon.awssdk:url-connection-client", version.ref = "aws" } +aws_sdk_netty_nio_client = { module = "software.amazon.awssdk:netty-nio-client", version.ref = "aws" } # Firebase firebase = { module = "com.google.firebase:firebase-admin", version.ref = "firebase" } +# Resilience4j +resilience4j_circuitbreaker = { module = "io.github.resilience4j:resilience4j-circuitbreaker", version.ref = "resilience4j" } +resilience4j_bulkhead = { module = "io.github.resilience4j:resilience4j-bulkhead", version.ref = "resilience4j" } +resilience4j_spring_boot3 = { module = "io.github.resilience4j:resilience4j-spring-boot3", version.ref = "resilience4j" } +resilience4j_micrometer = { module = "io.github.resilience4j:resilience4j-micrometer", version.ref = "resilience4j" } + # Bucket4j bucket4j_core = { module = "com.bucket4j:bucket4j_jdk17-core", version.ref = "bucket4j" } @@ -206,7 +218,8 @@ caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version.ref = "c [bundles] line_kotlin_jdsl = ["line_kotlin_jdsl_jpql_dsl", "line_kotlin_jdsl_jpql_render", "line_kotlin_jdsl_spring_data_jpa_support"] -aws_client = ["aws_sdk_s3"] +aws_client = ["aws_sdk_s3", "aws_sdk_url_connection_client", "aws_sdk_netty_nio_client"] +resilience4j = ["resilience4j_circuitbreaker", "resilience4j_bulkhead"] openfeign = ["spring_cloud_openfeign", "openfeign_hc5", "openfeign_micrometer"] jackson = ["jackson_kotlin", "jackson_datatype_jsr310"] testcontainers_postgres = ["test_containers_postgres", "spring_boot_testcontainers", "test_containers_junit_jupiter", "spring_boot_starter_test"] diff --git a/pida-clients/airquality-client/src/main/kotlin/com/pida/client/airquality/AirKoreaClient.kt b/pida-clients/airquality-client/src/main/kotlin/com/pida/client/airquality/AirKoreaClient.kt index 3228836..0ef8ba3 100644 --- a/pida-clients/airquality-client/src/main/kotlin/com/pida/client/airquality/AirKoreaClient.kt +++ b/pida-clients/airquality-client/src/main/kotlin/com/pida/client/airquality/AirKoreaClient.kt @@ -3,6 +3,8 @@ package com.pida.client.airquality import com.pida.support.error.ErrorException import com.pida.support.error.ErrorType import com.pida.support.extension.logger +import com.pida.support.resilience.ExternalDependency +import com.pida.support.resilience.ExternalDependencyPolicy import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component @@ -17,6 +19,7 @@ class AirKoreaClient internal constructor( private val stationServiceKey: String, private val airKoreaAirQualityApi: AirKoreaAirQualityApi, private val airKoreaStationApi: AirKoreaStationApi, + private val externalDependencyPolicy: ExternalDependencyPolicy, ) { private val logger by logger() @@ -29,10 +32,12 @@ class AirKoreaClient internal constructor( fun getAirQualityByStation(stationName: String): AirKoreaResponse = try { val response = - airKoreaAirQualityApi.getMsrstnAcctoRltmMesureDnsty( - serviceKey = airQualityServiceKey, - stationName = stationName, - ) + externalDependencyPolicy.execute(ExternalDependency.AIR_KOREA) { + airKoreaAirQualityApi.getMsrstnAcctoRltmMesureDnsty( + serviceKey = airQualityServiceKey, + stationName = stationName, + ) + } if (response.response.header.resultCode != "00") { logger.error("AirKorea API error: ${response.response.header.resultMsg}") @@ -62,11 +67,13 @@ class AirKoreaClient internal constructor( return try { val response = - airKoreaStationApi.getNearbyMsrstnList( - serviceKey = stationServiceKey, - tmX = tmX, - tmY = tmY, - ) + externalDependencyPolicy.execute(ExternalDependency.AIR_KOREA) { + airKoreaStationApi.getNearbyMsrstnList( + serviceKey = stationServiceKey, + tmX = tmX, + tmY = tmY, + ) + } if (response.response.header.resultCode != "00") { logger.error("AirKorea station API error: ${response.response.header.resultMsg}") diff --git a/pida-clients/airquality-client/src/test/kotlin/com/pida/client/airquality/AirKoreaClientTest.kt b/pida-clients/airquality-client/src/test/kotlin/com/pida/client/airquality/AirKoreaClientTest.kt index 94e0be3..b6dcdd8 100644 --- a/pida-clients/airquality-client/src/test/kotlin/com/pida/client/airquality/AirKoreaClientTest.kt +++ b/pida-clients/airquality-client/src/test/kotlin/com/pida/client/airquality/AirKoreaClientTest.kt @@ -2,6 +2,7 @@ package com.pida.client.airquality import com.pida.support.error.ErrorException import com.pida.support.error.ErrorType +import com.pida.support.resilience.ExternalDependencyPolicy import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk @@ -13,7 +14,7 @@ class AirKoreaClientTest { fun `근접 측정소 조회 성공 시 첫 번째 측정소명을 반환한다`() { val airKoreaAirQualityApi = mockk() val airKoreaStationApi = mockk() - val client = AirKoreaClient("air-quality-key", "station-key", airKoreaAirQualityApi, airKoreaStationApi) + val client = AirKoreaClient("air-quality-key", "station-key", airKoreaAirQualityApi, airKoreaStationApi, passthroughPolicy()) every { airKoreaStationApi.getNearbyMsrstnList( @@ -57,7 +58,7 @@ class AirKoreaClientTest { fun `근접 측정소 조회 결과 코드가 실패면 AIR_QUALITY_STATION_NOT_FOUND를 던진다`() { val airKoreaAirQualityApi = mockk() val airKoreaStationApi = mockk() - val client = AirKoreaClient("air-quality-key", "station-key", airKoreaAirQualityApi, airKoreaStationApi) + val client = AirKoreaClient("air-quality-key", "station-key", airKoreaAirQualityApi, airKoreaStationApi, passthroughPolicy()) every { airKoreaStationApi.getNearbyMsrstnList( @@ -86,7 +87,7 @@ class AirKoreaClientTest { fun `근접 측정소 조회 결과가 비어 있으면 AIR_QUALITY_STATION_NOT_FOUND를 던진다`() { val airKoreaAirQualityApi = mockk() val airKoreaStationApi = mockk() - val client = AirKoreaClient("air-quality-key", "station-key", airKoreaAirQualityApi, airKoreaStationApi) + val client = AirKoreaClient("air-quality-key", "station-key", airKoreaAirQualityApi, airKoreaStationApi, passthroughPolicy()) every { airKoreaStationApi.getNearbyMsrstnList( @@ -115,7 +116,7 @@ class AirKoreaClientTest { fun `근접 측정소 조회 중 예외가 발생하면 AIR_QUALITY_STATION_NOT_FOUND를 던진다`() { val airKoreaAirQualityApi = mockk() val airKoreaStationApi = mockk() - val client = AirKoreaClient("air-quality-key", "station-key", airKoreaAirQualityApi, airKoreaStationApi) + val client = AirKoreaClient("air-quality-key", "station-key", airKoreaAirQualityApi, airKoreaStationApi, passthroughPolicy()) every { airKoreaStationApi.getNearbyMsrstnList( @@ -137,7 +138,7 @@ class AirKoreaClientTest { fun `대기질 조회 결과 코드가 실패면 AIR_QUALITY_API_CALL_FAILED를 던진다`() { val airKoreaAirQualityApi = mockk() val airKoreaStationApi = mockk() - val client = AirKoreaClient("air-quality-key", "station-key", airKoreaAirQualityApi, airKoreaStationApi) + val client = AirKoreaClient("air-quality-key", "station-key", airKoreaAirQualityApi, airKoreaStationApi, passthroughPolicy()) every { airKoreaAirQualityApi.getMsrstnAcctoRltmMesureDnsty( @@ -165,7 +166,7 @@ class AirKoreaClientTest { fun `대기질 조회 중 예외가 발생하면 AIR_QUALITY_API_CALL_FAILED를 던진다`() { val airKoreaAirQualityApi = mockk() val airKoreaStationApi = mockk() - val client = AirKoreaClient("air-quality-key", "station-key", airKoreaAirQualityApi, airKoreaStationApi) + val client = AirKoreaClient("air-quality-key", "station-key", airKoreaAirQualityApi, airKoreaStationApi, passthroughPolicy()) every { airKoreaAirQualityApi.getMsrstnAcctoRltmMesureDnsty( @@ -186,7 +187,7 @@ class AirKoreaClientTest { fun `메서드별로 서로 다른 service key를 사용한다`() { val airKoreaAirQualityApi = mockk() val airKoreaStationApi = mockk() - val client = AirKoreaClient("air-quality-key", "station-key", airKoreaAirQualityApi, airKoreaStationApi) + val client = AirKoreaClient("air-quality-key", "station-key", airKoreaAirQualityApi, airKoreaStationApi, passthroughPolicy()) every { airKoreaStationApi.getNearbyMsrstnList( @@ -251,4 +252,9 @@ class AirKoreaClientTest { client.getNearbyStation("192968", "4667503") client.getAirQualityByStation("종로구") } + + private fun passthroughPolicy(): ExternalDependencyPolicy = + mockk { + every { execute(any(), any()) } answers { secondArg<() -> Any>().invoke() } + } } diff --git a/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/config/AwsConfig.kt b/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/config/AwsConfig.kt index 061b0cc..baba911 100644 --- a/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/config/AwsConfig.kt +++ b/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/config/AwsConfig.kt @@ -7,11 +7,15 @@ import software.amazon.awssdk.auth.credentials.AwsBasicCredentials import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration +import software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient +import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient import software.amazon.awssdk.regions.Region import software.amazon.awssdk.services.s3.S3AsyncClient import software.amazon.awssdk.services.s3.S3Client import software.amazon.awssdk.services.s3.presigner.S3Presigner import java.net.URI +import java.time.Duration @Configuration class AwsConfig( @@ -35,11 +39,26 @@ class AwsConfig( @Bean(destroyMethod = "close") // 스프링 종료 시 커넥션 풀 정리 fun s3Client(): S3Client { + val connectionTimeout = Duration.ofMillis(awsProperties.connectionTimeout) + val socketTimeout = Duration.ofMillis(awsProperties.socketTimeout) val client = S3Client .builder() .credentialsProvider(credentialProvider()) .region(Region.of(awsProperties.region)) + .httpClient( + UrlConnectionHttpClient + .builder() + .connectionTimeout(connectionTimeout) + .socketTimeout(socketTimeout) + .build(), + ).overrideConfiguration( + ClientOverrideConfiguration + .builder() + .apiCallAttemptTimeout(socketTimeout) + .apiCallTimeout(socketTimeout.plusMillis(500)) + .build(), + ) awsProperties.endpoint?.let { client.endpointOverride(URI.create(awsProperties.endpoint)) } @@ -49,11 +68,26 @@ class AwsConfig( @Bean(destroyMethod = "close") fun s3AsyncClient(): S3AsyncClient { + val connectionTimeout = Duration.ofMillis(awsProperties.connectionTimeout) + val socketTimeout = Duration.ofMillis(awsProperties.socketTimeout) val client = S3AsyncClient .builder() .credentialsProvider(credentialProvider()) .region(Region.of(awsProperties.region)) + .httpClient( + NettyNioAsyncHttpClient + .builder() + .connectionTimeout(connectionTimeout) + .readTimeout(socketTimeout) + .build(), + ).overrideConfiguration( + ClientOverrideConfiguration + .builder() + .apiCallAttemptTimeout(socketTimeout) + .apiCallTimeout(socketTimeout.plusMillis(500)) + .build(), + ) awsProperties.endpoint?.let { client.endpointOverride(URI.create(awsProperties.endpoint)) } diff --git a/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/config/AwsProperties.kt b/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/config/AwsProperties.kt index a798830..9320fe7 100644 --- a/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/config/AwsProperties.kt +++ b/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/config/AwsProperties.kt @@ -8,6 +8,8 @@ data class AwsProperties( val s3: S3Properties, val region: String, val endpoint: String?, + val connectionTimeout: Long, + val socketTimeout: Long, ) data class CredentialsProperties( diff --git a/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/image/ImageS3Processor.kt b/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/image/ImageS3Processor.kt index 70057b5..172b37c 100644 --- a/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/image/ImageS3Processor.kt +++ b/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/image/ImageS3Processor.kt @@ -8,6 +8,8 @@ import com.pida.support.aws.PresignedUrlRateLimiter import com.pida.support.aws.S3ImageInfo import com.pida.support.aws.S3ImageUrl import com.pida.support.aws.S3UploadResult +import com.pida.support.resilience.ExternalDependency +import com.pida.support.resilience.ExternalDependencyPolicy import org.springframework.stereotype.Component import software.amazon.awssdk.services.s3.model.S3Object import java.time.Duration @@ -21,6 +23,7 @@ class ImageS3Processor( private val awsProperties: AwsProperties, private val imageFileConstructor: ImageFileConstructor, private val rateLimiter: PresignedUrlRateLimiter, + private val externalDependencyPolicy: ExternalDependencyPolicy, ) : ImageS3Caller { companion object { private val SEOUL_ZONE_ID: ZoneId = ZoneId.of("Asia/Seoul") @@ -56,14 +59,15 @@ class ImageS3Processor( prefix: String, prefixId: Long, fileName: String?, - ): List { - val imageFilePath = imageFileConstructor.imageFilePath(prefix, prefixId) + ): List = + externalDependencyPolicy.executeSuspend(ExternalDependency.AWS_S3) { + val imageFilePath = imageFileConstructor.imageFilePath(prefix, prefixId) - return fileName - ?.let { - listOf(presignedGet(imageFilePath, it)) // fileName이 있으면 특정 이미지 조회 - } ?: listPresignedGets(imageFilePath) // 아니면 해당 경로 아래 모든 이미지 탐색 - } + fileName + ?.let { + listOf(presignedGet(imageFilePath, it)) + } ?: listPresignedGets(imageFilePath) + } override fun uploadImage( prefix: String, @@ -71,38 +75,40 @@ class ImageS3Processor( subPath: String, contentType: String, bytes: ByteArray, - ): S3UploadResult { - val filePath = imageFileConstructor.imageFilePath(prefix, prefixId) - val fileName = imageFileConstructor.imageFileName() - val s3Key = "$filePath/$subPath/$fileName" + ): S3UploadResult = + externalDependencyPolicy.execute(ExternalDependency.AWS_S3) { + val filePath = imageFileConstructor.imageFilePath(prefix, prefixId) + val fileName = imageFileConstructor.imageFileName() + val s3Key = "$filePath/$subPath/$fileName" - awsS3Client.putObject( - bucketName = awsProperties.s3.bucket, - key = s3Key, - contentType = contentType, - bytes = bytes, - ) + awsS3Client.putObject( + bucketName = awsProperties.s3.bucket, + key = s3Key, + contentType = contentType, + bytes = bytes, + ) - return S3UploadResult( - s3Key = s3Key, - publicUrl = "${awsProperties.s3.imageOriginUrl}/$s3Key", - ) - } + S3UploadResult( + s3Key = s3Key, + publicUrl = "${awsProperties.s3.imageOriginUrl}/$s3Key", + ) + } override suspend fun getPreviewImage( prefix: String, prefixId: Long, - ): S3ImageInfo? { - val imageFilePath = imageFileConstructor.imageFilePath(prefix, prefixId) + ): S3ImageInfo? = + externalDependencyPolicy.executeSuspend(ExternalDependency.AWS_S3) { + val imageFilePath = imageFileConstructor.imageFilePath(prefix, prefixId) - return awsS3AsyncClient - .listObjects( - bucketName = awsProperties.s3.bucket, - filePath = imageFilePath, - ).filterNot { it.key().endsWith("/") } - .maxByOrNull(S3Object::lastModified) - ?.toImageInfo(imageFilePath, Duration.ofSeconds(30)) - } + awsS3AsyncClient + .listObjects( + bucketName = awsProperties.s3.bucket, + filePath = imageFilePath, + ).filterNot { it.key().endsWith("/") } + .maxByOrNull(S3Object::lastModified) + ?.toImageInfo(imageFilePath, Duration.ofSeconds(30)) + } override fun generatePresignedUrl(s3Key: String): String { val filePath = s3Key.substringBeforeLast("/") diff --git a/pida-clients/aws-client/src/main/resources/aws.yml b/pida-clients/aws-client/src/main/resources/aws.yml index 26033c3..b745f07 100644 --- a/pida-clients/aws-client/src/main/resources/aws.yml +++ b/pida-clients/aws-client/src/main/resources/aws.yml @@ -13,8 +13,8 @@ cloud: image-origin-url: ${AWS_S3_IMAGE_ORIGIN_URL} stack: auto: false - connection-time-out: 5000 - socket-time-out: 30000 + connection-time-out: 1000 + socket-time-out: 2000 --- spring: config: @@ -31,8 +31,8 @@ cloud: image-origin-url: ${AWS_S3_IMAGE_ORIGIN_URL} stack: auto: false - connection-time-out: 900000 - socket-time-out: 900000 + connection-time-out: 1000 + socket-time-out: 2000 --- spring: config: @@ -49,5 +49,5 @@ cloud: image-origin-url: ${AWS_S3_IMAGE_ORIGIN_URL} stack: auto: false - connection-time-out: 900000 - socket-time-out: 900000 + connection-time-out: 1000 + socket-time-out: 2000 diff --git a/pida-clients/map-client/src/main/kotlin/com/pida/client/map/KakaoMapClient.kt b/pida-clients/map-client/src/main/kotlin/com/pida/client/map/KakaoMapClient.kt index 317b4d1..4d77dd7 100644 --- a/pida-clients/map-client/src/main/kotlin/com/pida/client/map/KakaoMapClient.kt +++ b/pida-clients/map-client/src/main/kotlin/com/pida/client/map/KakaoMapClient.kt @@ -4,25 +4,30 @@ import com.pida.place.LandmarkSearchClient import com.pida.place.NewLandmark import com.pida.support.extension.logger import com.pida.support.geo.toRegion +import com.pida.support.resilience.ExternalDependency +import com.pida.support.resilience.ExternalDependencyPolicy import org.springframework.stereotype.Component @Component class KakaoMapClient internal constructor( private val kakaoMapApi: KakaoMapApi, private val kakaoMapProperties: KakaoMapProperties, + private val externalDependencyPolicy: ExternalDependencyPolicy, ) : LandmarkSearchClient { private val logger by logger() override fun searchByKeyword(query: String): List { val response = - kakaoMapApi.searchKeyword( - authorization = "KakaoAK ${kakaoMapProperties.restApiKey}", - query = query, - categoryGroupCode = null, - x = null, - y = null, - radius = null, - ) + externalDependencyPolicy.execute(ExternalDependency.KAKAO_MAP) { + kakaoMapApi.searchKeyword( + authorization = "KakaoAK ${kakaoMapProperties.restApiKey}", + query = query, + categoryGroupCode = null, + x = null, + y = null, + radius = null, + ) + } logger.debug("Kakao Map search API requested: query='$query', found=${response.documents.size}") diff --git a/pida-clients/map-client/src/main/resources/map.yml b/pida-clients/map-client/src/main/resources/map.yml index c56f9d7..32d030f 100644 --- a/pida-clients/map-client/src/main/resources/map.yml +++ b/pida-clients/map-client/src/main/resources/map.yml @@ -6,8 +6,8 @@ spring: client: config: kakao-map-api: - connectTimeOut: 2100 - readTimeout: 5000 + connectTimeout: 1000 + readTimeout: 2000 loggerLevel: full # 추후 naver map api 연동 예정 compression: diff --git a/pida-clients/notification/src/main/kotlin/com/pida/client/notification/FcmClientRepository.kt b/pida-clients/notification/src/main/kotlin/com/pida/client/notification/FcmClientRepository.kt index 97ff6da..2ba911f 100644 --- a/pida-clients/notification/src/main/kotlin/com/pida/client/notification/FcmClientRepository.kt +++ b/pida-clients/notification/src/main/kotlin/com/pida/client/notification/FcmClientRepository.kt @@ -52,7 +52,7 @@ class FcmClientRepository( body = firebaseCloudMessage.body, destination = firebaseCloudMessage.destination, ) - val sendResult = firebaseCloudMessageSender.sendAsync(request).get() + val sendResult = firebaseCloudMessageSender.send(request) return FirebaseCloudMessage( fcmKey = firebaseCloudMessage.fcmKey, fcmToken = firebaseCloudMessage.fcmToken, @@ -60,7 +60,7 @@ class FcmClientRepository( body = firebaseCloudMessage.body, destination = firebaseCloudMessage.destination, tryCount = firebaseCloudMessage.tryCount + 1, - sent = sendResult.isNotBlank(), + sent = !sendResult.isNullOrBlank(), ) } } diff --git a/pida-clients/notification/src/main/kotlin/com/pida/client/notification/FcmConfig.kt b/pida-clients/notification/src/main/kotlin/com/pida/client/notification/FcmConfig.kt deleted file mode 100644 index aeccabd..0000000 --- a/pida-clients/notification/src/main/kotlin/com/pida/client/notification/FcmConfig.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.pida.client.notification - -import com.google.auth.oauth2.GoogleCredentials -import com.google.firebase.FirebaseApp -import com.google.firebase.FirebaseOptions -import com.google.firebase.messaging.FirebaseMessaging -import com.pida.support.error.ErrorException -import com.pida.support.error.ErrorType -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import java.io.ByteArrayInputStream -import java.nio.charset.StandardCharsets - -@Configuration -class FcmConfig( - private val fcmProperties: FcmProperties, -) { - @Bean - fun firebaseMessaging(): FirebaseMessaging { - val keyJson = fcmProperties.keyJson - if (keyJson.isEmpty()) { - throw ErrorException(ErrorType.NOT_FOUND_FCM_CREDENTIALS) - } - - if (FirebaseApp.getApps().isEmpty()) { - val firebaseOptions = - FirebaseOptions - .builder() - .setCredentials( - GoogleCredentials.fromStream( - ByteArrayInputStream( - keyJson.toByteArray(StandardCharsets.UTF_8), - ), - ), - ).build() - FirebaseApp.initializeApp(firebaseOptions, FirebaseApp.DEFAULT_APP_NAME) - } - val firebaseApp = FirebaseApp.getInstance(FirebaseApp.DEFAULT_APP_NAME) - val firebaseMessaging = FirebaseMessaging.getInstance(firebaseApp) - return firebaseMessaging - } -} diff --git a/pida-clients/notification/src/main/kotlin/com/pida/client/notification/FirebaseCloudMessageSender.kt b/pida-clients/notification/src/main/kotlin/com/pida/client/notification/FirebaseCloudMessageSender.kt index 8cf57ab..4596b51 100644 --- a/pida-clients/notification/src/main/kotlin/com/pida/client/notification/FirebaseCloudMessageSender.kt +++ b/pida-clients/notification/src/main/kotlin/com/pida/client/notification/FirebaseCloudMessageSender.kt @@ -4,22 +4,38 @@ import com.google.api.core.ApiFuture import com.google.firebase.messaging.ApnsConfig import com.google.firebase.messaging.Aps import com.google.firebase.messaging.BatchResponse -import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.Message import com.google.firebase.messaging.MulticastMessage import com.google.firebase.messaging.Notification +import com.pida.support.resilience.ExternalDependency +import com.pida.support.resilience.ExternalDependencyPolicy import org.springframework.stereotype.Component -import java.util.concurrent.Future +import java.util.concurrent.TimeUnit @Component class FirebaseCloudMessageSender( - private val firebaseMessaging: FirebaseMessaging, + private val firebaseMessagingProvider: FirebaseMessagingProvider, + private val externalDependencyPolicy: ExternalDependencyPolicy, ) { - fun sendAsync(fcmSendRequest: FcmSendRequest): Future = firebaseMessaging.sendAsync(toMessage(fcmSendRequest)) + companion object { + private const val FCM_TIMEOUT_SECONDS = 3L + } + + fun send(fcmSendRequest: FcmSendRequest): String? { + val firebaseMessaging = firebaseMessagingProvider.getOrNull() ?: return unavailable() + return externalDependencyPolicy.execute(ExternalDependency.FCM) { + firebaseMessaging + .sendAsync(toMessage(fcmSendRequest)) + .get(FCM_TIMEOUT_SECONDS, TimeUnit.SECONDS) + } + } - fun sendAsync(requests: List): ApiFuture { - val messages = requests.map { toMessage(it) } - return firebaseMessaging.sendEachAsync(messages) + fun sendAsync(requests: List): ApiFuture? { + val firebaseMessaging = firebaseMessagingProvider.getOrNull() ?: return unavailable() + return externalDependencyPolicy.execute(ExternalDependency.FCM) { + val messages = requests.map { toMessage(it) } + firebaseMessaging.sendEachAsync(messages) + } } private fun toMessage(request: FcmSendRequest): Message = @@ -52,6 +68,7 @@ class FirebaseCloudMessageSender( destination: String, fcmTokens: List, ): BatchResponse? { + val firebaseMessaging = firebaseMessagingProvider.getOrNull() ?: return unavailable() val notification = Notification .builder() @@ -77,6 +94,18 @@ class FirebaseCloudMessageSender( ).build(), ).build() - return firebaseMessaging.sendEachForMulticast(message) + return externalDependencyPolicy.execute(ExternalDependency.FCM) { + firebaseMessaging + .sendEachForMulticastAsync(message) + .get(FCM_TIMEOUT_SECONDS, TimeUnit.SECONDS) + } + } + + private fun unavailable(): T? { + externalDependencyPolicy.recordFallback( + dependency = ExternalDependency.FCM, + reason = "messaging-unavailable", + ) + return null } } diff --git a/pida-clients/notification/src/main/kotlin/com/pida/client/notification/FirebaseMessagingProvider.kt b/pida-clients/notification/src/main/kotlin/com/pida/client/notification/FirebaseMessagingProvider.kt new file mode 100644 index 0000000..a6779f0 --- /dev/null +++ b/pida-clients/notification/src/main/kotlin/com/pida/client/notification/FirebaseMessagingProvider.kt @@ -0,0 +1,69 @@ +package com.pida.client.notification + +import com.google.auth.oauth2.GoogleCredentials +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.messaging.FirebaseMessaging +import com.pida.support.extension.logger +import org.springframework.core.io.ResourceLoader +import org.springframework.stereotype.Component +import java.io.InputStream +import java.nio.charset.StandardCharsets.UTF_8 + +@Component +class FirebaseMessagingProvider( + private val fcmProperties: FcmProperties, + private val resourceLoader: ResourceLoader, +) { + private val logger by logger() + + @Volatile + private var firebaseMessaging: FirebaseMessaging? = null + + fun getOrNull(): FirebaseMessaging? { + firebaseMessaging?.let { return it } + + synchronized(this) { + firebaseMessaging?.let { return it } + + val keySource = fcmProperties.keyJson.trim() + if (keySource.isEmpty()) { + logger.warn("FCM credentials are empty. Push delivery will be disabled.") + return null + } + + return runCatching { + val firebaseApp = + FirebaseApp.getApps().firstOrNull { it.name == FirebaseApp.DEFAULT_APP_NAME } + ?: FirebaseApp.initializeApp( + FirebaseOptions + .builder() + .setCredentials( + GoogleCredentials.fromStream( + openCredentialsStream(keySource), + ), + ).build(), + FirebaseApp.DEFAULT_APP_NAME, + ) + + FirebaseMessaging.getInstance(firebaseApp).also { + firebaseMessaging = it + } + }.onFailure { error -> + logger.error("Failed to initialize FirebaseMessaging lazily. Push delivery will be disabled.", error) + }.getOrNull() + } + } + + private fun openCredentialsStream(keySource: String): InputStream = + when { + keySource.startsWith("classpath:") || keySource.startsWith("file:") -> { + val resource = resourceLoader.getResource(keySource) + require(resource.exists()) { "FCM credential resource does not exist: $keySource" } + resource.inputStream + } + else -> { + keySource.byteInputStream(UTF_8) + } + } +} diff --git a/pida-clients/notification/src/test/kotlin/com/pida/client/notification/FirebaseMessagingProviderTest.kt b/pida-clients/notification/src/test/kotlin/com/pida/client/notification/FirebaseMessagingProviderTest.kt new file mode 100644 index 0000000..ef22b6b --- /dev/null +++ b/pida-clients/notification/src/test/kotlin/com/pida/client/notification/FirebaseMessagingProviderTest.kt @@ -0,0 +1,29 @@ +package com.pida.client.notification + +import io.kotest.matchers.nulls.shouldBeNull +import org.junit.jupiter.api.Test +import org.springframework.core.io.DefaultResourceLoader + +class FirebaseMessagingProviderTest { + @Test + fun `credentials가 비어 있으면 null을 반환하고 예외를 던지지 않는다`() { + val provider = + FirebaseMessagingProvider( + fcmProperties = FcmProperties(keyJson = ""), + resourceLoader = DefaultResourceLoader(), + ) + + provider.getOrNull().shouldBeNull() + } + + @Test + fun `존재하지 않는 resource 경로여도 null을 반환한다`() { + val provider = + FirebaseMessagingProvider( + fcmProperties = FcmProperties(keyJson = "classpath:/missing-firebase.json"), + resourceLoader = DefaultResourceLoader(), + ) + + provider.getOrNull().shouldBeNull() + } +} diff --git a/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/AppleClient.kt b/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/AppleClient.kt index 4a94b8a..284fb34 100644 --- a/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/AppleClient.kt +++ b/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/AppleClient.kt @@ -10,6 +10,8 @@ import com.nimbusds.jwt.JWTClaimsSet import com.nimbusds.jwt.SignedJWT import com.pida.support.error.AuthenticationErrorException import com.pida.support.error.AuthenticationErrorType +import com.pida.support.resilience.ExternalDependency +import com.pida.support.resilience.ExternalDependencyPolicy import org.springframework.stereotype.Component import java.text.ParseException import java.util.Date @@ -18,6 +20,8 @@ import java.util.Date class AppleClient internal constructor( private val appleApi: AppleApi, private val appleProperties: AppleProperties, + private val applePublicKeysCache: ApplePublicKeysCache, + private val externalDependencyPolicy: ExternalDependencyPolicy, private val objectMapper: ObjectMapper, ) { fun getUserInfo(token: String): AppleClientResult { @@ -63,7 +67,7 @@ class AppleClient internal constructor( } private fun isSignatureValid(signedJWT: SignedJWT): Boolean { - val appleKeys = appleApi.getApplePublicKeys().keys + val appleKeys = loadApplePublicKeys().keys for (key in appleKeys) { try { val rsaKey = JWK.parse(objectMapper.writeValueAsString(key)) as RSAKey @@ -82,4 +86,24 @@ class AppleClient internal constructor( } return false } + + private fun loadApplePublicKeys() = + try { + applePublicKeysCache.getOrLoad { + externalDependencyPolicy.execute(ExternalDependency.APPLE_AUTH) { + appleApi.getApplePublicKeys() + } + } + } catch (exception: Exception) { + applePublicKeysCache.getCachedOrNull()?.also { + externalDependencyPolicy.recordFallback( + dependency = ExternalDependency.APPLE_AUTH, + reason = "cached-jwk", + throwable = exception, + ) + } ?: throw AuthenticationErrorException( + AuthenticationErrorType.APPLE_AUTH_PROVIDER_UNAVAILABLE, + exception.message, + ) + } } diff --git a/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/ApplePublicKeysCache.kt b/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/ApplePublicKeysCache.kt new file mode 100644 index 0000000..13257a0 --- /dev/null +++ b/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/ApplePublicKeysCache.kt @@ -0,0 +1,33 @@ +package com.pida.client.oauth + +import com.fasterxml.jackson.databind.ObjectMapper +import com.pida.client.oauth.response.ApplePublicKeysResponse +import com.pida.support.cache.Cache +import com.pida.support.cache.CacheRepository +import org.springframework.stereotype.Component + +@Component +class ApplePublicKeysCache( + _cache: Cache, + private val cacheRepository: CacheRepository, + private val objectMapper: ObjectMapper, +) { + companion object { + private const val CACHE_KEY = "apple:auth:public-keys" + private const val CACHE_TTL_MINUTES = 360L + } + + fun getOrLoad(loader: () -> ApplePublicKeysResponse): ApplePublicKeysResponse = + Cache.cacheBlocking( + ttl = CACHE_TTL_MINUTES, + key = CACHE_KEY, + typeReference = object : com.fasterxml.jackson.core.type.TypeReference() {}, + ) { + loader() + } + + fun getCachedOrNull(): ApplePublicKeysResponse? { + val cached = cacheRepository.get(CACHE_KEY) ?: return null + return objectMapper.readValue(cached, ApplePublicKeysResponse::class.java) + } +} diff --git a/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/KaKaoClient.kt b/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/KaKaoClient.kt index 0faa789..548a312 100644 --- a/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/KaKaoClient.kt +++ b/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/KaKaoClient.kt @@ -1,10 +1,16 @@ package com.pida.client.oauth +import com.pida.support.resilience.ExternalDependency +import com.pida.support.resilience.ExternalDependencyPolicy import org.springframework.stereotype.Component @Component class KaKaoClient internal constructor( private val kaKaoApi: KaKaoApi, + private val externalDependencyPolicy: ExternalDependencyPolicy, ) { - fun getUserInfo(token: String): KaKaoClientResult = kaKaoApi.getKaKaoUserInfo("Bearer $token").toResult() + fun getUserInfo(token: String): KaKaoClientResult = + externalDependencyPolicy.execute(ExternalDependency.KAKAO_OAUTH) { + kaKaoApi.getKaKaoUserInfo("Bearer $token").toResult() + } } diff --git a/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/OAuthService.kt b/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/OAuthService.kt index 1cad4a7..c392ff7 100644 --- a/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/OAuthService.kt +++ b/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/OAuthService.kt @@ -14,11 +14,13 @@ class OAuthService( try { kaKaoClient.getUserInfo(token) } catch (e: FeignException) { - if (e.status() == 401) { + if (e.status() in setOf(400, 401, 403)) { throw AuthenticationErrorException(AuthenticationErrorType.INVALID_KAKAO_TOKEN) } else { - throw AuthenticationErrorException(AuthenticationErrorType.INVALID_KAKAO_TOKEN, e.message) + throw AuthenticationErrorException(AuthenticationErrorType.KAKAO_AUTH_PROVIDER_UNAVAILABLE, e.message) } + } catch (e: Exception) { + throw AuthenticationErrorException(AuthenticationErrorType.KAKAO_AUTH_PROVIDER_UNAVAILABLE, e.message) } fun getAppleUserInfo(token: String): AppleClientResult { diff --git a/pida-clients/oauth-client/src/main/resources/oauth.yml b/pida-clients/oauth-client/src/main/resources/oauth.yml index 9828eac..69a04f6 100644 --- a/pida-clients/oauth-client/src/main/resources/oauth.yml +++ b/pida-clients/oauth-client/src/main/resources/oauth.yml @@ -6,12 +6,12 @@ spring: client: config: kakao-auth-api: - connectTimeOut: 2100 - readTimeout: 5000 + connectTimeout: 1000 + readTimeout: 2000 loggerLevel: full apple-auth-api: - connectTimeOut: 2100 - readTimeout: 5000 + connectTimeout: 1000 + readTimeout: 2000 loggerLevel: full compression: response: diff --git a/pida-clients/oauth-client/src/test/kotlin/com/pida/client/oauth/AppleClientTest.kt b/pida-clients/oauth-client/src/test/kotlin/com/pida/client/oauth/AppleClientTest.kt new file mode 100644 index 0000000..b421473 --- /dev/null +++ b/pida-clients/oauth-client/src/test/kotlin/com/pida/client/oauth/AppleClientTest.kt @@ -0,0 +1,104 @@ +package com.pida.client.oauth + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.nimbusds.jose.JOSEObjectType +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.JWSHeader +import com.nimbusds.jose.crypto.RSASSASigner +import com.nimbusds.jose.jwk.KeyUse +import com.nimbusds.jose.jwk.RSAKey +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.SignedJWT +import com.pida.client.oauth.response.ApplePublicKeysResponse +import com.pida.client.oauth.response.Key +import com.pida.support.resilience.ExternalDependency +import com.pida.support.resilience.ExternalDependencyPolicy +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.Test +import java.time.Instant +import java.util.Date + +class AppleClientTest { + @Test + fun `apple public key 조회가 실패하면 캐시된 jwk로 검증한다`() { + val appleApi = mockk() + val applePublicKeysCache = mockk() + val externalDependencyPolicy = mockk() + every { applePublicKeysCache.getOrLoad(any()) } throws IllegalStateException("apple down") + every { applePublicKeysCache.getCachedOrNull() } returns cachedKeys() + every { externalDependencyPolicy.recordFallback(any(), any(), any()) } just runs + + val client = + AppleClient( + appleApi = appleApi, + appleProperties = AppleProperties(bundleId = "com.pida.app"), + applePublicKeysCache = applePublicKeysCache, + externalDependencyPolicy = externalDependencyPolicy, + objectMapper = jacksonObjectMapper(), + ) + + client.verify(signedAppleIdentityToken()) shouldBe true + verify(exactly = 1) { + externalDependencyPolicy.recordFallback(ExternalDependency.APPLE_AUTH, "cached-jwk", any()) + } + } + + private fun signedAppleIdentityToken(): String { + val claims = + JWTClaimsSet + .Builder() + .subject("apple-user") + .claim("email", "apple@example.com") + .audience("com.pida.app") + .issuer("https://appleid.apple.com") + .expirationTime(Date.from(Instant.now().plusSeconds(3600))) + .issueTime(Date.from(Instant.now())) + .build() + + val signedJwt = + SignedJWT( + JWSHeader + .Builder(JWSAlgorithm.RS256) + .type(JOSEObjectType.JWT) + .keyID(rsaKey.keyID) + .build(), + claims, + ) + + signedJwt.sign(RSASSASigner(rsaKey.toPrivateKey())) + return signedJwt.serialize() + } + + private fun cachedKeys(): ApplePublicKeysResponse { + val publicKey = rsaKey.toPublicJWK() as RSAKey + val publicKeyJson = publicKey.toJSONObject() + return ApplePublicKeysResponse( + keys = + listOf( + Key( + kty = publicKey.keyType.value, + kid = publicKey.keyID, + use = publicKey.keyUse.identifier(), + alg = publicKey.algorithm.name, + n = publicKeyJson.getValue("n").toString(), + e = publicKeyJson.getValue("e").toString(), + ), + ), + ) + } + + companion object { + private val rsaKey: RSAKey = + RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .algorithm(JWSAlgorithm.RS256) + .keyID("apple-key-1") + .generate() + } +} diff --git a/pida-clients/oauth-client/src/test/kotlin/com/pida/client/oauth/OAuthServiceTest.kt b/pida-clients/oauth-client/src/test/kotlin/com/pida/client/oauth/OAuthServiceTest.kt new file mode 100644 index 0000000..2b47881 --- /dev/null +++ b/pida-clients/oauth-client/src/test/kotlin/com/pida/client/oauth/OAuthServiceTest.kt @@ -0,0 +1,57 @@ +package com.pida.client.oauth + +import com.pida.support.error.AuthenticationErrorException +import com.pida.support.error.AuthenticationErrorType +import feign.FeignException +import feign.Request +import feign.Response +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class OAuthServiceTest { + private val kaKaoClient = mockk() + private val appleClient = mockk() + private val service = OAuthService(kaKaoClient, appleClient) + + @Test + fun `카카오 401 응답은 INVALID_KAKAO_TOKEN으로 매핑한다`() { + every { kaKaoClient.getUserInfo("token") } throws feignStatus(401) + + val exception = assertThrows { service.getKaKaoUserInfo("token") } + + exception.authenticationErrorType shouldBe AuthenticationErrorType.INVALID_KAKAO_TOKEN + } + + @Test + fun `카카오 upstream 장애는 provider unavailable로 매핑한다`() { + every { kaKaoClient.getUserInfo("token") } throws IllegalStateException("kakao down") + + val exception = assertThrows { service.getKaKaoUserInfo("token") } + + exception.authenticationErrorType shouldBe AuthenticationErrorType.KAKAO_AUTH_PROVIDER_UNAVAILABLE + exception.authenticationErrorType.status shouldBe 503 + } + + private fun feignStatus(status: Int): FeignException = + FeignException.errorStatus( + "getKaKaoUserInfo", + Response + .builder() + .status(status) + .reason("upstream") + .request( + Request.create( + Request.HttpMethod.GET, + "https://kapi.kakao.com/v2/user/me", + emptyMap(), + null, + Charsets.UTF_8, + null, + ), + ).headers(emptyMap()) + .build(), + ) +} 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 ddc5fbc..e04acc0 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 @@ -1,6 +1,8 @@ package com.pida.client.weather import com.pida.support.extension.logger +import com.pida.support.resilience.ExternalDependency +import com.pida.support.resilience.ExternalDependencyPolicy import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component import java.time.LocalDateTime @@ -20,6 +22,7 @@ class KmaWeatherClient internal constructor( @param:Value("\${kma.api.min-request-interval-millis:250}") private val minRequestIntervalMillis: Long, private val kmaWeatherApi: KmaWeatherApi, + private val externalDependencyPolicy: ExternalDependencyPolicy, ) : KmaForecastClient { private val logger by logger() private val nextAvailableRequestAtMillis = AtomicLong(0L) @@ -43,14 +46,16 @@ class KmaWeatherClient internal constructor( ): KmaWeatherResponse { throttleRequest() logger.info("Fetching Vilage Forecast: baseDate=$baseDate, baseTime=$baseTime, nx=$nx, ny=$ny") - return kmaWeatherApi.getVilageForecast( - serviceKey = serviceKey, - numOfRows = numOfRows, - baseDate = baseDate, - baseTime = baseTime, - nx = nx, - ny = ny, - ) + return externalDependencyPolicy.execute(ExternalDependency.KMA_WEATHER) { + kmaWeatherApi.getVilageForecast( + serviceKey = serviceKey, + numOfRows = numOfRows, + baseDate = baseDate, + baseTime = baseTime, + nx = nx, + ny = ny, + ) + } } /** 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 acd8309..0940ef4 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 @@ -3,6 +3,8 @@ package com.pida.client.weather import com.pida.support.error.ErrorException import com.pida.support.error.ErrorType import com.pida.support.extension.logger +import com.pida.support.resilience.ExternalDependency +import com.pida.support.resilience.ExternalDependencyPolicy import com.pida.weather.PrecipitationType import com.pida.weather.SkyCondition import com.pida.weather.Weather @@ -22,6 +24,7 @@ import kotlin.math.abs class WeatherServiceImpl( private val kmaForecastClient: KmaForecastClient, private val kmaForecastCache: KmaForecastCache, + private val externalDependencyPolicy: ExternalDependencyPolicy, ) : WeatherService { private val logger by logger() private val dateFormatter = DateTimeFormatter.ofPattern("yyyyMMdd") @@ -129,7 +132,12 @@ class WeatherServiceImpl( "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 -> + staleForecastOrNull(location.nx, location.ny)?.let { staleItems -> + externalDependencyPolicy.recordFallback( + dependency = ExternalDependency.KMA_WEATHER, + reason = "stale-forecast-rate-limit", + throwable = error, + ) logger.warn( "Using stale KMA forecast cache due to rate limit for nx=${location.nx}, ny=${location.ny}", ) @@ -137,11 +145,27 @@ class WeatherServiceImpl( } throw ErrorException(ErrorType.EXCEED_RATE_LIMIT) } catch (error: Exception) { + staleForecastOrNull(location.nx, location.ny)?.let { staleItems -> + externalDependencyPolicy.recordFallback( + dependency = ExternalDependency.KMA_WEATHER, + reason = "stale-forecast", + throwable = error, + ) + logger.warn( + "Using stale KMA forecast cache due to upstream failure for nx=${location.nx}, ny=${location.ny}", + ) + return staleItems + } logger.error("Failed to fetch weather forecast", error) throw ErrorException(ErrorType.WEATHER_API_CALL_FAILED) } } + private fun staleForecastOrNull( + nx: Int, + ny: Int, + ): List? = kmaForecastCache.getLatestByGrid(nx, ny) + /** * 예보 일시 파싱 (yyyyMMddHHmm -> LocalDateTime) */ 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 index 126e31b..1888c97 100644 --- 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 @@ -6,11 +6,16 @@ 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.support.resilience.ExternalDependencyPolicy import com.pida.weather.WeatherLocation import feign.FeignException import feign.Request import feign.Response import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Test import java.time.LocalDate @@ -23,7 +28,12 @@ class WeatherServiceImplTest { private val objectMapper = jacksonObjectMapper() private val cache = Cache(CacheAdvice(cacheRepository, objectMapper)) private val kmaForecastCache = KmaForecastCache(cache, cacheRepository, objectMapper) - private val weatherService = WeatherServiceImpl(kmaForecastClient, kmaForecastCache) + private val externalDependencyPolicy = mockk() + private val weatherService = WeatherServiceImpl(kmaForecastClient, kmaForecastCache, externalDependencyPolicy) + + init { + every { externalDependencyPolicy.recordFallback(any(), any(), any()) } just runs + } @Test fun `캐시 loader 결과로 내일 최대 강수확률을 계산한다`() { @@ -70,6 +80,20 @@ class WeatherServiceImplTest { exception.errorType shouldBe ErrorType.EXCEED_RATE_LIMIT } + @Test + fun `upstream failure가 발생하면 최근 성공 캐시로 degrade 한다`() { + val location = weatherLocation() + val cachedResponse = weatherResponse(baseDate = "20260321", baseTime = "1400", probabilities = listOf(65)) + + kmaForecastClient.enqueueBaseTime("20260321" to "1400") + kmaForecastClient.enqueueBaseTime("20260321" to "1700") + kmaForecastClient.enqueueResponse("20260321", "1400", 59, 128, cachedResponse) + kmaForecastClient.enqueueError("20260321", "1700", 59, 128, IllegalStateException("upstream timeout")) + + weatherService.getTomorrowMaxPrecipitationProbability(location) shouldBe 65 + weatherService.getTomorrowMaxPrecipitationProbability(location) shouldBe 65 + } + private fun tooManyRequests(): FeignException.TooManyRequests = FeignException.errorStatus( "getVilageForecast", diff --git a/pida-core/core-api/build.gradle.kts b/pida-core/core-api/build.gradle.kts index 7b074ed..e24d580 100644 --- a/pida-core/core-api/build.gradle.kts +++ b/pida-core/core-api/build.gradle.kts @@ -35,7 +35,10 @@ dependencies { sentryAgent(libs.sentry.opentelemetry.agent) implementation(libs.spring.boot.starter.web) implementation(libs.spring.boot.starter.aop) + implementation(libs.spring.boot.starter.actuator) implementation(libs.spring.boot.starter.validation) + implementation(libs.resilience4j.spring.boot3) + implementation(libs.resilience4j.micrometer) compileOnly(libs.redisson) testImplementation(libs.redisson) diff --git a/pida-core/core-api/src/main/kotlin/com/pida/config/resilience/ExternalDependencyState.kt b/pida-core/core-api/src/main/kotlin/com/pida/config/resilience/ExternalDependencyState.kt new file mode 100644 index 0000000..fd05556 --- /dev/null +++ b/pida-core/core-api/src/main/kotlin/com/pida/config/resilience/ExternalDependencyState.kt @@ -0,0 +1,12 @@ +package com.pida.config.resilience + +import com.pida.support.resilience.ExternalDependency +import java.time.Instant + +internal data class ExternalDependencyState( + val dependency: ExternalDependency, + val reason: String, + val lastError: String?, + val updatedAt: Instant, + val fallbackCount: Long, +) diff --git a/pida-core/core-api/src/main/kotlin/com/pida/config/resilience/ExternalDependencyStateTracker.kt b/pida-core/core-api/src/main/kotlin/com/pida/config/resilience/ExternalDependencyStateTracker.kt new file mode 100644 index 0000000..1da2552 --- /dev/null +++ b/pida-core/core-api/src/main/kotlin/com/pida/config/resilience/ExternalDependencyStateTracker.kt @@ -0,0 +1,45 @@ +package com.pida.config.resilience + +import com.pida.support.resilience.ExternalDependency +import io.micrometer.core.instrument.Counter +import io.micrometer.core.instrument.MeterRegistry +import org.springframework.stereotype.Component +import java.time.Instant +import java.util.concurrent.ConcurrentHashMap + +@Component +class ExternalDependencyStateTracker( + meterRegistry: MeterRegistry, +) { + private val states = ConcurrentHashMap() + private val fallbackCounters = + ExternalDependency.entries.associateWith { dependency -> + Counter + .builder("pida.external.dependency.fallback") + .tag("provider", dependency.id) + .register(meterRegistry) + } + + internal fun clear(dependency: ExternalDependency) { + states.remove(dependency) + } + + internal fun recordFallback( + dependency: ExternalDependency, + reason: String, + throwable: Throwable? = null, + ) { + fallbackCounters.getValue(dependency).increment() + states.compute(dependency) { _, current -> + ExternalDependencyState( + dependency = dependency, + reason = reason, + lastError = throwable?.message ?: throwable?.javaClass?.simpleName, + updatedAt = Instant.now(), + fallbackCount = (current?.fallbackCount ?: 0L) + 1, + ) + } + } + + internal fun snapshot(): List = states.values.sortedBy { it.dependency.id } +} diff --git a/pida-core/core-api/src/main/kotlin/com/pida/config/resilience/OptionalIntegrationHealthIndicator.kt b/pida-core/core-api/src/main/kotlin/com/pida/config/resilience/OptionalIntegrationHealthIndicator.kt new file mode 100644 index 0000000..070da6e --- /dev/null +++ b/pida-core/core-api/src/main/kotlin/com/pida/config/resilience/OptionalIntegrationHealthIndicator.kt @@ -0,0 +1,43 @@ +package com.pida.config.resilience + +import io.github.resilience4j.circuitbreaker.CircuitBreaker +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry +import org.springframework.boot.actuate.health.Health +import org.springframework.boot.actuate.health.HealthIndicator +import org.springframework.stereotype.Component + +@Component("optionalIntegrations") +class OptionalIntegrationHealthIndicator( + private val circuitBreakerRegistry: CircuitBreakerRegistry, + private val stateTracker: ExternalDependencyStateTracker, +) : HealthIndicator { + override fun health(): Health { + val breakerStates = + circuitBreakerRegistry + .allCircuitBreakers + .associate { it.name to it.state.name } + .filterValues { it != CircuitBreaker.State.CLOSED.name && it != CircuitBreaker.State.DISABLED.name } + + val degradedDependencies = stateTracker.snapshot() + + if (breakerStates.isEmpty() && degradedDependencies.isEmpty()) { + return Health.up().build() + } + + return Health + .status("DEGRADED") + .withDetail("circuitBreakers", breakerStates) + .withDetail( + "dependencies", + degradedDependencies.map { status -> + mapOf( + "provider" to status.dependency.id, + "reason" to status.reason, + "lastError" to status.lastError, + "updatedAt" to status.updatedAt.toString(), + "fallbackCount" to status.fallbackCount, + ) + }, + ).build() + } +} diff --git a/pida-core/core-api/src/main/kotlin/com/pida/config/resilience/ResilienceExternalDependencyPolicy.kt b/pida-core/core-api/src/main/kotlin/com/pida/config/resilience/ResilienceExternalDependencyPolicy.kt new file mode 100644 index 0000000..0b31252 --- /dev/null +++ b/pida-core/core-api/src/main/kotlin/com/pida/config/resilience/ResilienceExternalDependencyPolicy.kt @@ -0,0 +1,109 @@ +package com.pida.config.resilience + +import com.pida.support.resilience.ExternalDependency +import com.pida.support.resilience.ExternalDependencyPolicy +import io.github.resilience4j.bulkhead.Bulkhead +import io.github.resilience4j.bulkhead.BulkheadRegistry +import io.github.resilience4j.circuitbreaker.CircuitBreaker +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry +import org.springframework.stereotype.Component + +@Component +class ResilienceExternalDependencyPolicy( + private val circuitBreakerRegistry: CircuitBreakerRegistry, + private val bulkheadRegistry: BulkheadRegistry, + private val stateTracker: ExternalDependencyStateTracker, +) : ExternalDependencyPolicy { + override fun isAvailable(dependency: ExternalDependency): Boolean { + val circuitBreaker = circuitBreakerRegistry.circuitBreaker(dependency.id) + val bulkhead = bulkheadRegistry.bulkhead(dependency.id) + + return circuitBreaker.state !in unavailableStates && + bulkhead.metrics.availableConcurrentCalls > 0 + } + + override fun execute( + dependency: ExternalDependency, + block: () -> T, + ): T = executeInternal(dependency, block) + + override suspend fun executeSuspend( + dependency: ExternalDependency, + block: suspend () -> T, + ): T = executeSuspendInternal(dependency, block) + + override fun recordFallback( + dependency: ExternalDependency, + reason: String, + throwable: Throwable?, + ) { + stateTracker.recordFallback(dependency, reason, throwable) + } + + private fun executeInternal( + dependency: ExternalDependency, + block: () -> T, + ): T { + val bulkhead = bulkheadRegistry.bulkhead(dependency.id) + val circuitBreaker = circuitBreakerRegistry.circuitBreaker(dependency.id) + + acquireBulkheadAndCircuitBreaker(bulkhead, circuitBreaker) + val startedAt = circuitBreaker.currentTimestamp + + return try { + block().also { + circuitBreaker.onSuccess(circuitBreaker.currentTimestamp - startedAt, circuitBreaker.timestampUnit) + stateTracker.clear(dependency) + } + } catch (throwable: Throwable) { + circuitBreaker.onError(circuitBreaker.currentTimestamp - startedAt, circuitBreaker.timestampUnit, throwable) + throw throwable + } finally { + bulkhead.onComplete() + } + } + + private suspend fun executeSuspendInternal( + dependency: ExternalDependency, + block: suspend () -> T, + ): T { + val bulkhead = bulkheadRegistry.bulkhead(dependency.id) + val circuitBreaker = circuitBreakerRegistry.circuitBreaker(dependency.id) + + acquireBulkheadAndCircuitBreaker(bulkhead, circuitBreaker) + val startedAt = circuitBreaker.currentTimestamp + + return try { + block().also { + circuitBreaker.onSuccess(circuitBreaker.currentTimestamp - startedAt, circuitBreaker.timestampUnit) + stateTracker.clear(dependency) + } + } catch (throwable: Throwable) { + circuitBreaker.onError(circuitBreaker.currentTimestamp - startedAt, circuitBreaker.timestampUnit, throwable) + throw throwable + } finally { + bulkhead.onComplete() + } + } + + private fun acquireBulkheadAndCircuitBreaker( + bulkhead: Bulkhead, + circuitBreaker: CircuitBreaker, + ) { + bulkhead.acquirePermission() + try { + circuitBreaker.acquirePermission() + } catch (throwable: Throwable) { + bulkhead.releasePermission() + throw throwable + } + } + + companion object { + private val unavailableStates = + setOf( + CircuitBreaker.State.OPEN, + CircuitBreaker.State.FORCED_OPEN, + ) + } +} diff --git a/pida-core/core-api/src/main/kotlin/com/pida/presentation/advice/ApiExceptionAdvice.kt b/pida-core/core-api/src/main/kotlin/com/pida/presentation/advice/ApiExceptionAdvice.kt index 7a045bf..8f0dc29 100644 --- a/pida-core/core-api/src/main/kotlin/com/pida/presentation/advice/ApiExceptionAdvice.kt +++ b/pida-core/core-api/src/main/kotlin/com/pida/presentation/advice/ApiExceptionAdvice.kt @@ -1,5 +1,6 @@ package com.pida.presentation.advice +import com.pida.support.error.AuthenticationErrorException import com.pida.support.error.ErrorException import com.pida.support.error.ErrorResponse import com.pida.support.error.ErrorType @@ -95,6 +96,15 @@ class ApiExceptionAdvice : ResponseEntityExceptionHandler() { return ResponseEntity.status(errorCode.status).body(apiResponse) } + @ExceptionHandler(AuthenticationErrorException::class) + fun handleAuthenticationErrorException(e: AuthenticationErrorException): ResponseEntity> { + log.error("pida AuthenticationErrorException : {}", e.message, e) + val errorCode = e.authenticationErrorType + val errorResponse = ErrorResponse.of(errorCode.name, errorCode.message) + val apiResponse = ApiResponse.fail(errorCode.status, errorResponse) + return ResponseEntity.status(errorCode.status).body(apiResponse) + } + @ExceptionHandler(Exception::class) protected fun handleException(e: Exception): ResponseEntity> { log.error("Internal Server Error : {}", e.message, e) diff --git a/pida-core/core-api/src/main/resources/airkorea.yml b/pida-core/core-api/src/main/resources/airkorea.yml index 5722955..56e9d2f 100644 --- a/pida-core/core-api/src/main/resources/airkorea.yml +++ b/pida-core/core-api/src/main/resources/airkorea.yml @@ -1,3 +1,19 @@ +spring: + cloud: + discovery: + enabled: false + openfeign: + client: + config: + airkorea-air-quality-api: + connectTimeout: 1000 + readTimeout: 3000 + loggerLevel: full + airkorea-station-api: + connectTimeout: 1000 + readTimeout: 3000 + loggerLevel: full + airkorea: api: air-quality-base-url: ${AIR_KOREA_AIR_QUALITY_API_BASE_URL:http://apis.data.go.kr/B552584/ArpltnInforInqireSvc} diff --git a/pida-core/core-api/src/main/resources/application.yml b/pida-core/core-api/src/main/resources/application.yml index a70afdb..2e36bba 100644 --- a/pida-core/core-api/src/main/resources/application.yml +++ b/pida-core/core-api/src/main/resources/application.yml @@ -11,6 +11,7 @@ spring: - redis.yml - logging.yml - monitoring.yml + - client-resilience.yml - authentication-redis.yml - authentication.yml - oauth.yml diff --git a/pida-core/core-api/src/main/resources/client-resilience.yml b/pida-core/core-api/src/main/resources/client-resilience.yml new file mode 100644 index 0000000..706f62a --- /dev/null +++ b/pida-core/core-api/src/main/resources/client-resilience.yml @@ -0,0 +1,62 @@ +management: + endpoint: + health: + status: + order: degraded,down,out-of-service,unknown,up + health: + circuitbreakers: + enabled: false + +resilience4j: + circuitbreaker: + configs: + default: + slidingWindowType: COUNT_BASED + slidingWindowSize: 20 + minimumNumberOfCalls: 10 + failureRateThreshold: 50 + permittedNumberOfCallsInHalfOpenState: 3 + waitDurationInOpenState: 30s + slowCallDurationThreshold: 2s + slowCallRateThreshold: 100 + automaticTransitionFromOpenToHalfOpenEnabled: true + registerHealthIndicator: false + instances: + kakao-map: + baseConfig: default + kakao-oauth: + baseConfig: default + apple-auth: + baseConfig: default + kma-weather: + baseConfig: default + slowCallDurationThreshold: 3s + air-korea: + baseConfig: default + slowCallDurationThreshold: 3s + aws-s3: + baseConfig: default + fcm: + baseConfig: default + slowCallDurationThreshold: 3s + bulkhead: + configs: + default: + maxConcurrentCalls: 50 + maxWaitDuration: 0 + instances: + kakao-map: + baseConfig: default + kakao-oauth: + baseConfig: default + apple-auth: + baseConfig: default + kma-weather: + baseConfig: default + air-korea: + baseConfig: default + aws-s3: + baseConfig: default + fcm: + baseConfig: default + maxConcurrentCalls: 2 diff --git a/pida-core/core-api/src/main/resources/weather.yml b/pida-core/core-api/src/main/resources/weather.yml index e4d0c76..d4e9eb1 100644 --- a/pida-core/core-api/src/main/resources/weather.yml +++ b/pida-core/core-api/src/main/resources/weather.yml @@ -1,3 +1,15 @@ +spring: + cloud: + discovery: + enabled: false + openfeign: + client: + config: + kma-weather-api: + connectTimeout: 1000 + readTimeout: 3000 + loggerLevel: full + kma: api: base-url: http://apis.data.go.kr/1360000/VilageFcstInfoService_2.0 diff --git a/pida-core/core-api/src/test/kotlin/com/pida/config/resilience/OptionalIntegrationHealthIndicatorTest.kt b/pida-core/core-api/src/test/kotlin/com/pida/config/resilience/OptionalIntegrationHealthIndicatorTest.kt new file mode 100644 index 0000000..ecd085f --- /dev/null +++ b/pida-core/core-api/src/test/kotlin/com/pida/config/resilience/OptionalIntegrationHealthIndicatorTest.kt @@ -0,0 +1,27 @@ +package com.pida.config.resilience + +import com.pida.support.resilience.ExternalDependency +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry +import io.kotest.matchers.shouldBe +import io.micrometer.core.instrument.simple.SimpleMeterRegistry +import org.junit.jupiter.api.Test + +class OptionalIntegrationHealthIndicatorTest { + @Test + fun `degraded dependency가 있으면 DEGRADED 상태를 반환한다`() { + val circuitBreakerRegistry = CircuitBreakerRegistry.ofDefaults() + val stateTracker = ExternalDependencyStateTracker(SimpleMeterRegistry()) + val indicator = OptionalIntegrationHealthIndicator(circuitBreakerRegistry, stateTracker) + + circuitBreakerRegistry.circuitBreaker(ExternalDependency.AWS_S3.id).transitionToOpenState() + stateTracker.recordFallback( + dependency = ExternalDependency.AWS_S3, + reason = "empty-image-list", + throwable = IllegalStateException("s3 down"), + ) + + val health = indicator.health() + + health.status.code shouldBe "DEGRADED" + } +} diff --git a/pida-core/core-api/src/test/kotlin/com/pida/presentation/advice/ApiExceptionAdviceTest.kt b/pida-core/core-api/src/test/kotlin/com/pida/presentation/advice/ApiExceptionAdviceTest.kt new file mode 100644 index 0000000..8345af5 --- /dev/null +++ b/pida-core/core-api/src/test/kotlin/com/pida/presentation/advice/ApiExceptionAdviceTest.kt @@ -0,0 +1,20 @@ +package com.pida.presentation.advice + +import com.pida.support.error.AuthenticationErrorException +import com.pida.support.error.AuthenticationErrorType +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test + +class ApiExceptionAdviceTest { + private val advice = ApiExceptionAdvice() + + @Test + fun `authentication provider unavailable은 503으로 응답한다`() { + val response = + advice.handleAuthenticationErrorException( + AuthenticationErrorException(AuthenticationErrorType.KAKAO_AUTH_PROVIDER_UNAVAILABLE), + ) + + response.statusCode.value() shouldBe 503 + } +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotFacade.kt b/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotFacade.kt index ffc5a7f..5f82c11 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotFacade.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotFacade.kt @@ -5,8 +5,11 @@ import com.pida.support.aws.ImagePrefix import com.pida.support.aws.ImageS3Caller import com.pida.support.aws.S3ImageInfo import com.pida.support.geo.Region +import com.pida.support.resilience.ExternalDependency +import com.pida.support.resilience.ExternalDependencyPolicy import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service @Service @@ -14,18 +17,31 @@ class FlowerSpotFacade( private val flowerSpotService: FlowerSpotService, private val bloomingService: BloomingService, private val imageS3Caller: ImageS3Caller, + private val externalDependencyPolicy: ExternalDependencyPolicy, ) { + private val logger = LoggerFactory.getLogger(javaClass) + suspend fun readFlowerSpotDetails(spotId: Long): FlowerSpotDetails = coroutineScope { val flowerSpotDeferred = async { flowerSpotService.readOneFlowerSpot(spotId) } val bloomings = async { bloomingService.recentlyBloomingBySpotId(spotId) } val images = async { - imageS3Caller.getImageUrl( - prefix = ImagePrefix.FLOWERSPOT.value, - prefixId = spotId, - fileName = null, - ) + runCatching { + imageS3Caller.getImageUrl( + prefix = ImagePrefix.FLOWERSPOT.value, + prefixId = spotId, + fileName = null, + ) + }.getOrElse { error -> + externalDependencyPolicy.recordFallback( + dependency = ExternalDependency.AWS_S3, + reason = "empty-image-list", + throwable = error, + ) + logger.warn("Falling back to empty flower spot image list for spotId={}", spotId, error) + emptyList() + } } return@coroutineScope FlowerSpotDetails.of( @@ -59,9 +75,19 @@ class FlowerSpotFacade( private fun previewImagePresignedUrl(flowerSpot: FlowerSpot): S3ImageInfo? = flowerSpot.previewImageKey?.let { key -> - S3ImageInfo( - url = imageS3Caller.generatePresignedUrl(key), - uploadedAt = flowerSpot.previewImageUploadedAt!!, - ) + runCatching { + S3ImageInfo( + url = imageS3Caller.generatePresignedUrl(key), + uploadedAt = flowerSpot.previewImageUploadedAt!!, + ) + }.getOrElse { error -> + externalDependencyPolicy.recordFallback( + dependency = ExternalDependency.AWS_S3, + reason = "empty-preview-image", + throwable = error, + ) + logger.warn("Falling back to empty flower spot preview image for spotId={}", flowerSpot.id, error) + null + } } } diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/FcmSender.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/FcmSender.kt index 2bbbd87..f7aa197 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/notification/FcmSender.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/FcmSender.kt @@ -1,5 +1,6 @@ package com.pida.notification +import com.pida.support.extension.logger import org.springframework.scheduling.annotation.Async import org.springframework.stereotype.Component @@ -8,6 +9,8 @@ class FcmSender( private val fcmRepository: FcmRepository, private val fcmMessageKeyGenerator: FcmMessageKeyGenerator, ) { + private val logger by logger() + fun sendAll( newMessages: Set, maxTry: Int = 3, @@ -63,6 +66,14 @@ class FcmSender( addAll(sent) addAll(failed) addAll(pending) + }.also { results -> + logger.info( + "FCM delivery finished. total={}, sent={}, failed={}, pending={}", + results.size, + results.count { it.sent }, + results.count { !it.sent && it.tryCount >= maxTry }, + results.count { !it.sent && it.tryCount < maxTry }, + ) } } } diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/place/LandmarkService.kt b/pida-core/core-domain/src/main/kotlin/com/pida/place/LandmarkService.kt index 65ffe5b..b01dd85 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/place/LandmarkService.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/place/LandmarkService.kt @@ -1,6 +1,8 @@ package com.pida.place import com.pida.support.extension.logger +import com.pida.support.resilience.ExternalDependency +import com.pida.support.resilience.ExternalDependencyPolicy import org.springframework.context.event.EventListener import org.springframework.scheduling.annotation.Async import org.springframework.stereotype.Service @@ -10,6 +12,7 @@ class LandmarkService( private val landmarkFinder: LandmarkFinder, private val landmarkAppender: LandmarkAppender, private val landmarkSearchClient: LandmarkSearchClient, + private val externalDependencyPolicy: ExternalDependencyPolicy, ) { private val logger by logger() @@ -17,9 +20,13 @@ class LandmarkService( @Async @EventListener - fun handleFetchEvent(event: LandmarkFetchEvent) = + fun handleFetchEvent(event: LandmarkFetchEvent) { when (event) { is LandmarkFetchEvent.Requested -> { + if (!externalDependencyPolicy.isAvailable(ExternalDependency.KAKAO_MAP)) { + logger.info("Skipping landmark background fetch because Kakao Map is unavailable") + return + } val landmarks = try { landmarkSearchClient.searchByKeyword(event.query) @@ -34,6 +41,7 @@ class LandmarkService( addLandmarks(event.landmarks) } } + } fun addLandmarks(landmarks: List) { val newLandmarks = diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/place/PlaceFacade.kt b/pida-core/core-domain/src/main/kotlin/com/pida/place/PlaceFacade.kt index 0ce371d..832276e 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/place/PlaceFacade.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/place/PlaceFacade.kt @@ -3,6 +3,8 @@ package com.pida.place import com.pida.flowerspot.FlowerSpotSearchEvent import com.pida.flowerspot.FlowerSpotService import com.pida.support.geo.Region +import com.pida.support.resilience.ExternalDependency +import com.pida.support.resilience.ExternalDependencyPolicy import com.pida.user.User import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope @@ -16,6 +18,7 @@ class PlaceFacade( private val districtService: DistrictService, private val landmarkSearchClient: LandmarkSearchClient, private val eventPublisher: ApplicationEventPublisher, + private val externalDependencyPolicy: ExternalDependencyPolicy, ) { companion object { private const val MAX_DISTRICT_SEARCH_COUNT = 2 @@ -44,13 +47,31 @@ class PlaceFacade( val landmarks = if (hasEnough(storedLandmarks)) { // 저장된 랜드마크가 충분한 경우 즉시 응답 후에 비동기적으로 보정 - publishLandmarkFetchEvent(query, null) + if (externalDependencyPolicy.isAvailable(ExternalDependency.KAKAO_MAP)) { + publishLandmarkFetchEvent(query, null) + } storedLandmarks } else { - // 저장된 랜드마크가 충분하지 않은 경우, 외부 API 호출 대기 - val fetched = landmarkSearchClient.searchByKeyword(query) - publishLandmarkFetchEvent(query, fetched) - fetched.map { it.toLandmark() }.filter { it.region in SEARCH_REGIONS } + if (!externalDependencyPolicy.isAvailable(ExternalDependency.KAKAO_MAP)) { + externalDependencyPolicy.recordFallback( + dependency = ExternalDependency.KAKAO_MAP, + reason = "stored-landmarks-only", + ) + storedLandmarks + } else { + runCatching { + val fetched = landmarkSearchClient.searchByKeyword(query) + publishLandmarkFetchEvent(query, fetched) + fetched.map { it.toLandmark() }.filter { it.region in SEARCH_REGIONS } + }.getOrElse { error -> + externalDependencyPolicy.recordFallback( + dependency = ExternalDependency.KAKAO_MAP, + reason = "stored-landmarks-only", + throwable = error, + ) + storedLandmarks + } + } } PlaceSearchResult( diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/support/error/AuthenticationErrorType.kt b/pida-core/core-domain/src/main/kotlin/com/pida/support/error/AuthenticationErrorType.kt index 6d7799c..284ee13 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/support/error/AuthenticationErrorType.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/support/error/AuthenticationErrorType.kt @@ -1,54 +1,72 @@ package com.pida.support.error enum class AuthenticationErrorType( + val status: Int, val kind: AuthenticationErrorKind, val message: String, val level: AuthenticationErrorLevel, ) { /** Common */ - DEFAULT(AuthenticationErrorKind.INTERNAL_SERVER_ERROR, "예기치 못한 오류가 발생했습니다.", AuthenticationErrorLevel.ERROR), - NOT_FOUND_DATA(AuthenticationErrorKind.SERVER_ERROR, "해당 데이터를 찾지 못했습니다.", AuthenticationErrorLevel.INFO), - INVALID_REQUEST(AuthenticationErrorKind.CLIENT_ERROR, "잘못된 요청입니다.", AuthenticationErrorLevel.WARN), + DEFAULT(500, AuthenticationErrorKind.INTERNAL_SERVER_ERROR, "예기치 못한 오류가 발생했습니다.", AuthenticationErrorLevel.ERROR), + NOT_FOUND_DATA(404, AuthenticationErrorKind.SERVER_ERROR, "해당 데이터를 찾지 못했습니다.", AuthenticationErrorLevel.INFO), + INVALID_REQUEST(400, AuthenticationErrorKind.CLIENT_ERROR, "잘못된 요청입니다.", AuthenticationErrorLevel.WARN), /** Authorization */ - INVALID_KAKAO(AuthenticationErrorKind.CLIENT_ERROR, "카카오 요청에 실패하였습니다.", AuthenticationErrorLevel.INFO), - NOT_FOUND_HISTORY(AuthenticationErrorKind.SERVER_ERROR, "히스토리 정보가 존재하지 않습니다.", AuthenticationErrorLevel.INFO), - UNAUTHORIZED_TOKEN(AuthenticationErrorKind.AUTHORIZATION, "인증정보가 필요한 요청입니다.", AuthenticationErrorLevel.WARN), - INVALID_TOKEN(AuthenticationErrorKind.AUTHORIZATION, "유효하지 않은 토큰입니다.", AuthenticationErrorLevel.WARN), - INVALID_KAKAO_TOKEN(AuthenticationErrorKind.AUTHORIZATION, "유효하지 않은 카카오 토큰입니다.", AuthenticationErrorLevel.ERROR), - INVALID_APPLE_TOKEN(AuthenticationErrorKind.AUTHORIZATION, "유효하지 않은 애플 토큰입니다.", AuthenticationErrorLevel.ERROR), - INVALID_ACCESS_TOKEN(AuthenticationErrorKind.AUTHORIZATION, "유효하지 accessToken 입니다.", AuthenticationErrorLevel.WARN), + INVALID_KAKAO(400, AuthenticationErrorKind.CLIENT_ERROR, "카카오 요청에 실패하였습니다.", AuthenticationErrorLevel.INFO), + NOT_FOUND_HISTORY(404, AuthenticationErrorKind.SERVER_ERROR, "히스토리 정보가 존재하지 않습니다.", AuthenticationErrorLevel.INFO), + UNAUTHORIZED_TOKEN(401, AuthenticationErrorKind.AUTHORIZATION, "인증정보가 필요한 요청입니다.", AuthenticationErrorLevel.WARN), + INVALID_TOKEN(401, AuthenticationErrorKind.AUTHORIZATION, "유효하지 않은 토큰입니다.", AuthenticationErrorLevel.WARN), + INVALID_KAKAO_TOKEN(401, AuthenticationErrorKind.AUTHORIZATION, "유효하지 않은 카카오 토큰입니다.", AuthenticationErrorLevel.ERROR), + INVALID_APPLE_TOKEN(401, AuthenticationErrorKind.AUTHORIZATION, "유효하지 않은 애플 토큰입니다.", AuthenticationErrorLevel.ERROR), + INVALID_ACCESS_TOKEN(401, AuthenticationErrorKind.AUTHORIZATION, "유효하지 accessToken 입니다.", AuthenticationErrorLevel.WARN), INVALID_REFRESH_TOKEN( + 401, AuthenticationErrorKind.AUTHORIZATION, "잘못된 refreshToken 입니다.", AuthenticationErrorLevel.WARN, ), + KAKAO_AUTH_PROVIDER_UNAVAILABLE( + 503, + AuthenticationErrorKind.SERVER_ERROR, + "카카오 인증 제공자 호출에 실패했습니다. 잠시 후 다시 시도해주세요.", + AuthenticationErrorLevel.ERROR, + ), + APPLE_AUTH_PROVIDER_UNAVAILABLE( + 503, + AuthenticationErrorKind.SERVER_ERROR, + "애플 인증 제공자 호출에 실패했습니다. 잠시 후 다시 시도해주세요.", + AuthenticationErrorLevel.ERROR, + ), /** Sign */ - INVALID_CREDENTIALS(AuthenticationErrorKind.SERVER_ERROR, "아이디 혹은 비밀번호가 올바르지 않습니다.", AuthenticationErrorLevel.WARN), - DUPLICATED_USER(AuthenticationErrorKind.CLIENT_ERROR, "이미 존재하는 계정입니다.", AuthenticationErrorLevel.INFO), + INVALID_CREDENTIALS(400, AuthenticationErrorKind.SERVER_ERROR, "아이디 혹은 비밀번호가 올바르지 않습니다.", AuthenticationErrorLevel.WARN), + DUPLICATED_USER(409, AuthenticationErrorKind.CLIENT_ERROR, "이미 존재하는 계정입니다.", AuthenticationErrorLevel.INFO), - WITHDRAWAL_USER(AuthenticationErrorKind.AUTHORIZATION, "사용할 수 없는 계정입니다.", AuthenticationErrorLevel.WARN), - INVALID_LOGIN_ID_FORMAT(AuthenticationErrorKind.CLIENT_ERROR, "이메일 형식이 올바르지 않습니다.", AuthenticationErrorLevel.INFO), + WITHDRAWAL_USER(403, AuthenticationErrorKind.AUTHORIZATION, "사용할 수 없는 계정입니다.", AuthenticationErrorLevel.WARN), + INVALID_LOGIN_ID_FORMAT(400, AuthenticationErrorKind.CLIENT_ERROR, "이메일 형식이 올바르지 않습니다.", AuthenticationErrorLevel.INFO), /** User */ - INVALID_PASSWORD(AuthenticationErrorKind.CLIENT_ERROR, "기존 비밀번호가 올바르지 않습니다.", AuthenticationErrorLevel.WARN), + INVALID_PASSWORD(400, AuthenticationErrorKind.CLIENT_ERROR, "기존 비밀번호가 올바르지 않습니다.", AuthenticationErrorLevel.WARN), INVALID_NEW_PASSWORD( + 400, AuthenticationErrorKind.CLIENT_ERROR, "새 비밀번호가 기존 비밀번호와 같을 수 없습니다.", AuthenticationErrorLevel.WARN, ), INVALID_NEW_LOGIN_ID( + 400, AuthenticationErrorKind.CLIENT_ERROR, "새로 입력한 아이디와 기존 아이디는 같을 수 없습니다.", AuthenticationErrorLevel.WARN, ), APPLE_TOKEN_CLIENT_FAILED( + 500, AuthenticationErrorKind.CLIENT_ERROR, "애플 토큰 요청에 실패하였습니다.", AuthenticationErrorLevel.ERROR, ), APPLE_PRIVATE_KEY_ENCODING_FAILED( + 500, AuthenticationErrorKind.CLIENT_ERROR, "애플 개인키 인코딩에 실패하였습니다.", AuthenticationErrorLevel.ERROR, diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/support/resilience/ExternalDependency.kt b/pida-core/core-domain/src/main/kotlin/com/pida/support/resilience/ExternalDependency.kt new file mode 100644 index 0000000..2c4f59a --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/support/resilience/ExternalDependency.kt @@ -0,0 +1,13 @@ +package com.pida.support.resilience + +enum class ExternalDependency( + val id: String, +) { + KAKAO_MAP("kakao-map"), + KAKAO_OAUTH("kakao-oauth"), + APPLE_AUTH("apple-auth"), + KMA_WEATHER("kma-weather"), + AIR_KOREA("air-korea"), + AWS_S3("aws-s3"), + FCM("fcm"), +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/support/resilience/ExternalDependencyPolicy.kt b/pida-core/core-domain/src/main/kotlin/com/pida/support/resilience/ExternalDependencyPolicy.kt new file mode 100644 index 0000000..7351a4a --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/support/resilience/ExternalDependencyPolicy.kt @@ -0,0 +1,21 @@ +package com.pida.support.resilience + +interface ExternalDependencyPolicy { + fun isAvailable(dependency: ExternalDependency): Boolean + + fun execute( + dependency: ExternalDependency, + block: () -> T, + ): T + + suspend fun executeSuspend( + dependency: ExternalDependency, + block: suspend () -> T, + ): T + + fun recordFallback( + dependency: ExternalDependency, + reason: String, + throwable: Throwable? = null, + ) +} diff --git a/pida-core/core-domain/src/test/kotlin/com/pida/flowerspot/FlowerSpotFacadeTest.kt b/pida-core/core-domain/src/test/kotlin/com/pida/flowerspot/FlowerSpotFacadeTest.kt index a56fb3c..242faf4 100644 --- a/pida-core/core-domain/src/test/kotlin/com/pida/flowerspot/FlowerSpotFacadeTest.kt +++ b/pida-core/core-domain/src/test/kotlin/com/pida/flowerspot/FlowerSpotFacadeTest.kt @@ -12,12 +12,15 @@ import com.pida.support.cache.CacheAdvice import com.pida.support.cache.CacheRepository import com.pida.support.geo.GeoJson import com.pida.support.geo.Region +import com.pida.support.resilience.ExternalDependencyPolicy import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.shouldBe import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every +import io.mockk.just import io.mockk.mockk +import io.mockk.runs import io.mockk.verify import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Test @@ -30,8 +33,10 @@ class FlowerSpotFacadeTest { val flowerSpotService = mockk() val bloomingService = mockk() val imageS3Caller = mockk() + val externalDependencyPolicy = mockk() + every { externalDependencyPolicy.recordFallback(any(), any(), any()) } just runs Cache(CacheAdvice(inMemoryCacheRepository(), cacheObjectMapper())) - val facade = FlowerSpotFacade(flowerSpotService, bloomingService, imageS3Caller) + val facade = FlowerSpotFacade(flowerSpotService, bloomingService, imageS3Caller, externalDependencyPolicy) val location = FlowerSpotLocation(swLat = null, swLng = null, neLat = null, neLng = null) val previewKey = "prod/flowerspot/10/abc.jpeg" val previewUploadedAt = LocalDateTime.of(2026, 3, 20, 12, 0) @@ -39,7 +44,7 @@ class FlowerSpotFacadeTest { val secondSpot = flowerSpot(id = 20L, streetName = "두 번째 거리") coEvery { flowerSpotService.readAllFlowerSpot(region = null, location = location) } returns listOf(firstSpot, secondSpot) - every { bloomingService.recentlyBloomingBySpotIds(listOf(10L, 20L)) } returns + coEvery { bloomingService.recentlyBloomingBySpotIds(listOf(10L, 20L)) } returns listOf( Blooming( id = 1L, @@ -92,8 +97,10 @@ class FlowerSpotFacadeTest { val flowerSpotService = mockk() val bloomingService = mockk() val imageS3Caller = mockk() + val externalDependencyPolicy = mockk() + every { externalDependencyPolicy.recordFallback(any(), any(), any()) } just runs Cache(CacheAdvice(inMemoryCacheRepository(), cacheObjectMapper())) - val facade = FlowerSpotFacade(flowerSpotService, bloomingService, imageS3Caller) + val facade = FlowerSpotFacade(flowerSpotService, bloomingService, imageS3Caller, externalDependencyPolicy) val location = FlowerSpotLocation(swLat = null, swLng = null, neLat = null, neLng = null) coEvery { flowerSpotService.readAllFlowerSpot(region = null, location = location) } returns emptyList() @@ -101,11 +108,33 @@ class FlowerSpotFacadeTest { val result = facade.findAllFlowerSpot(region = null, location = location) result shouldBe emptyList() - verify(exactly = 0) { bloomingService.recentlyBloomingBySpotIds(any()) } + coVerify(exactly = 0) { bloomingService.recentlyBloomingBySpotIds(any()) } coVerify(exactly = 0) { imageS3Caller.getPreviewImage(any(), any()) } coVerify(exactly = 0) { imageS3Caller.getImageUrl(any(), any(), any()) } } + @Test + fun `상세 조회에서 S3 이미지 조회가 실패하면 빈 이미지 목록으로 degrade 한다`(): Unit = + runBlocking { + val flowerSpotService = mockk() + val bloomingService = mockk() + val imageS3Caller = mockk() + val externalDependencyPolicy = mockk() + every { externalDependencyPolicy.recordFallback(any(), any(), any()) } just runs + Cache(CacheAdvice(inMemoryCacheRepository(), cacheObjectMapper())) + val facade = FlowerSpotFacade(flowerSpotService, bloomingService, imageS3Caller, externalDependencyPolicy) + val spot = flowerSpot(id = 10L, streetName = "첫 번째 거리") + + coEvery { flowerSpotService.readOneFlowerSpot(10L) } returns spot + coEvery { bloomingService.recentlyBloomingBySpotId(10L) } returns emptyList() + coEvery { imageS3Caller.getImageUrl(any(), any(), any()) } throws IllegalStateException("s3 unavailable") + + val result = facade.readFlowerSpotDetails(10L) + + result.images shouldBe emptyList() + verify(exactly = 1) { externalDependencyPolicy.recordFallback(any(), "empty-image-list", any()) } + } + private fun flowerSpot( id: Long, streetName: String, diff --git a/pida-core/core-domain/src/test/kotlin/com/pida/place/PlaceFacadeTest.kt b/pida-core/core-domain/src/test/kotlin/com/pida/place/PlaceFacadeTest.kt new file mode 100644 index 0000000..f5fbb7b --- /dev/null +++ b/pida-core/core-domain/src/test/kotlin/com/pida/place/PlaceFacadeTest.kt @@ -0,0 +1,111 @@ +package com.pida.place + +import com.pida.flowerspot.FlowerKind +import com.pida.flowerspot.FlowerSpot +import com.pida.flowerspot.FlowerSpotSearchEvent +import com.pida.flowerspot.FlowerSpotService +import com.pida.flowerspot.FlowerSpotType +import com.pida.support.geo.GeoJson +import com.pida.support.geo.Region +import com.pida.support.resilience.ExternalDependency +import com.pida.support.resilience.ExternalDependencyPolicy +import io.kotest.matchers.collections.shouldContainExactly +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import org.springframework.context.ApplicationEventPublisher +import java.time.LocalDateTime + +class PlaceFacadeTest { + @Test + fun `Kakao Map breaker가 열려 있으면 저장된 landmark만 반환하고 보정 이벤트를 생략한다`(): Unit = + runBlocking { + val flowerSpotService = mockk() + val landmarkService = mockk() + val districtService = mockk() + val landmarkSearchClient = mockk() + val eventPublisher = mockk(relaxed = true) + val policy = FakeExternalDependencyPolicy(setOf(ExternalDependency.KAKAO_MAP)) + + val storedLandmark = landmark(id = 1L, name = "강남역") + + coEvery { districtService.searchDistricts("강남") } returns emptyList() + coEvery { landmarkService.searchLandmarks("강남") } returns listOf(storedLandmark) + coEvery { flowerSpotService.searchFlowerSpots("강남") } returns emptyList() + + val facade = + PlaceFacade( + flowerSpotService = flowerSpotService, + landmarkService = landmarkService, + districtService = districtService, + landmarkSearchClient = landmarkSearchClient, + eventPublisher = eventPublisher, + externalDependencyPolicy = policy, + ) + + val result = facade.search("강남", null) + + result.landmarks shouldContainExactly listOf(storedLandmark) + verify(exactly = 1) { eventPublisher.publishEvent(match { it is FlowerSpotSearchEvent }) } + verify(exactly = 0) { landmarkSearchClient.searchByKeyword(any()) } + verify(exactly = 0) { eventPublisher.publishEvent(match { it is LandmarkFetchEvent }) } + } + + companion object { + private fun landmark( + id: Long, + name: String, + ) = Landmark( + id = id, + name = name, + address = "서울 강남구", + pinPoint = GeoJson.Point(listOf(127.0, 37.5)), + region = Region.SEOUL, + category = LandmarkCategory.SUBWAY, + deletedAt = null, + ) + + @Suppress("unused") + private fun flowerSpot(id: Long) = + FlowerSpot( + id = id, + address = "서울특별시 강남구", + streetName = "벚꽃길", + district = "역삼동", + description = "벚꽃길", + geom = GeoJson.LineString(listOf(listOf(127.1, 37.5), listOf(127.2, 37.6))), + pinPoint = GeoJson.Point(listOf(127.15, 37.55)), + region = Region.SEOUL, + kind = FlowerKind.BLOSSOM, + type = FlowerSpotType.WALKING_TRAIL, + deletedAt = null, + previewImageKey = null, + previewImageUploadedAt = LocalDateTime.now(), + ) + } + + private class FakeExternalDependencyPolicy( + private val unavailableDependencies: Set = emptySet(), + ) : ExternalDependencyPolicy { + override fun isAvailable(dependency: ExternalDependency): Boolean = dependency !in unavailableDependencies + + override fun execute( + dependency: ExternalDependency, + block: () -> T, + ): T = block() + + override suspend fun executeSuspend( + dependency: ExternalDependency, + block: suspend () -> T, + ): T = block() + + override fun recordFallback( + dependency: ExternalDependency, + reason: String, + throwable: Throwable?, + ) { + } + } +} diff --git a/pida-supports/monitoring/src/main/resources/monitoring.yml b/pida-supports/monitoring/src/main/resources/monitoring.yml index c4758bd..990faac 100644 --- a/pida-supports/monitoring/src/main/resources/monitoring.yml +++ b/pida-supports/monitoring/src/main/resources/monitoring.yml @@ -3,6 +3,9 @@ management: web: exposure: include: health, prometheus + endpoint: + health: + show-details: always health: defaults: enabled: false diff --git a/settings.gradle.kts b/settings.gradle.kts index 42766ef..fcbc77a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -45,5 +45,3 @@ include( "pida-tests:test-helper", "pida-tests:test-container", ) - - From 70459b9e0ee2c31436a7dd1dddf294d92dac4732 Mon Sep 17 00:00:00 2001 From: yb__char <68099546+char-yb@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:52:23 +0900 Subject: [PATCH 3/6] Update pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/ApplePublicKeysCache.kt Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../main/kotlin/com/pida/client/oauth/ApplePublicKeysCache.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/ApplePublicKeysCache.kt b/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/ApplePublicKeysCache.kt index 13257a0..132ff44 100644 --- a/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/ApplePublicKeysCache.kt +++ b/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/ApplePublicKeysCache.kt @@ -8,7 +8,6 @@ import org.springframework.stereotype.Component @Component class ApplePublicKeysCache( - _cache: Cache, private val cacheRepository: CacheRepository, private val objectMapper: ObjectMapper, ) { From d462d93849b3c332159c19ff950b9d52b51808bb Mon Sep 17 00:00:00 2001 From: yb__char <68099546+char-yb@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:53:16 +0900 Subject: [PATCH 4/6] Update pida-core/core-api/src/main/kotlin/com/pida/config/resilience/ExternalDependencyStateTracker.kt Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../pida/config/resilience/ExternalDependencyStateTracker.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pida-core/core-api/src/main/kotlin/com/pida/config/resilience/ExternalDependencyStateTracker.kt b/pida-core/core-api/src/main/kotlin/com/pida/config/resilience/ExternalDependencyStateTracker.kt index 1da2552..8bfa16f 100644 --- a/pida-core/core-api/src/main/kotlin/com/pida/config/resilience/ExternalDependencyStateTracker.kt +++ b/pida-core/core-api/src/main/kotlin/com/pida/config/resilience/ExternalDependencyStateTracker.kt @@ -34,7 +34,7 @@ class ExternalDependencyStateTracker( ExternalDependencyState( dependency = dependency, reason = reason, - lastError = throwable?.message ?: throwable?.javaClass?.simpleName, + lastError = throwable?.javaClass?.simpleName, updatedAt = Instant.now(), fallbackCount = (current?.fallbackCount ?: 0L) + 1, ) From 4b76eeae8fb65470d47f12df4cb7822055d3257e Mon Sep 17 00:00:00 2001 From: char-yb Date: Mon, 6 Apr 2026 22:14:09 +0900 Subject: [PATCH 5/6] [Test] Fix core-domain suspend mocking and event publisher assertions --- .../com/pida/blooming/BloomingServiceTest.kt | 138 +++++++++--------- .../CafeCategoryItemReadStrategyTest.kt | 7 +- .../EventCategoryItemReadStrategyTest.kt | 7 +- .../FlowerSpotCategoryItemReadStrategyTest.kt | 5 +- .../kotlin/com/pida/place/PlaceFacadeTest.kt | 4 +- 5 files changed, 82 insertions(+), 79 deletions(-) diff --git a/pida-core/core-domain/src/test/kotlin/com/pida/blooming/BloomingServiceTest.kt b/pida-core/core-domain/src/test/kotlin/com/pida/blooming/BloomingServiceTest.kt index fc075ee..d2c1570 100644 --- a/pida-core/core-domain/src/test/kotlin/com/pida/blooming/BloomingServiceTest.kt +++ b/pida-core/core-domain/src/test/kotlin/com/pida/blooming/BloomingServiceTest.kt @@ -3,9 +3,12 @@ package com.pida.blooming import com.pida.support.error.ErrorException import com.pida.support.error.ErrorType import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.verify +import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import java.time.LocalDateTime @@ -43,85 +46,88 @@ class BloomingServiceTest { } @Test - fun `spot batch 조회는 중복 id를 제거한 뒤 한 번의 finder 호출로 위임한다`() { - val bloomingAppender = mockk() - val bloomingValidator = mockk() - val bloomingFinder = mockk() - val service = BloomingService(bloomingAppender, bloomingValidator, bloomingFinder) - val bloomings = - listOf( - Blooming( - id = 1L, - status = BloomingStatus.BLOOMED, - userId = 10L, - flowerSpotId = 30L, - flowerEventId = null, - flowerSpotCafeId = null, - createdAt = LocalDateTime.of(2026, 4, 1, 9, 0), - ), - ) + fun `spot batch 조회는 중복 id를 제거한 뒤 한 번의 finder 호출로 위임한다`(): Unit = + runBlocking { + val bloomingAppender = mockk() + val bloomingValidator = mockk() + val bloomingFinder = mockk() + val service = BloomingService(bloomingAppender, bloomingValidator, bloomingFinder) + val bloomings = + listOf( + Blooming( + id = 1L, + status = BloomingStatus.BLOOMED, + userId = 10L, + flowerSpotId = 30L, + flowerEventId = null, + flowerSpotCafeId = null, + createdAt = LocalDateTime.of(2026, 4, 1, 9, 0), + ), + ) - every { bloomingFinder.recentlyBloomingBySpotIds(listOf(30L, 31L)) } returns bloomings + coEvery { bloomingFinder.recentlyBloomingBySpotIds(listOf(30L, 31L)) } returns bloomings - val result = service.recentlyBloomingBySpotIds(listOf(30L, 31L, 30L)) + val result = service.recentlyBloomingBySpotIds(listOf(30L, 31L, 30L)) - result shouldBe bloomings - verify(exactly = 1) { bloomingFinder.recentlyBloomingBySpotIds(listOf(30L, 31L)) } - } + result shouldBe bloomings + coVerify(exactly = 1) { bloomingFinder.recentlyBloomingBySpotIds(listOf(30L, 31L)) } + } @Test - fun `event batch 조회는 중복 id를 제거한 뒤 한 번의 finder 호출로 위임한다`() { - val bloomingAppender = mockk() - val bloomingValidator = mockk() - val bloomingFinder = mockk() - val service = BloomingService(bloomingAppender, bloomingValidator, bloomingFinder) - val bloomings = - listOf( - Blooming( - id = 2L, - status = BloomingStatus.LITTLE, - userId = 11L, - flowerSpotId = null, - flowerEventId = 40L, - flowerSpotCafeId = null, - createdAt = LocalDateTime.of(2026, 4, 1, 10, 0), - ), - ) + fun `event batch 조회는 중복 id를 제거한 뒤 한 번의 finder 호출로 위임한다`(): Unit = + runBlocking { + val bloomingAppender = mockk() + val bloomingValidator = mockk() + val bloomingFinder = mockk() + val service = BloomingService(bloomingAppender, bloomingValidator, bloomingFinder) + val bloomings = + listOf( + Blooming( + id = 2L, + status = BloomingStatus.LITTLE, + userId = 11L, + flowerSpotId = null, + flowerEventId = 40L, + flowerSpotCafeId = null, + createdAt = LocalDateTime.of(2026, 4, 1, 10, 0), + ), + ) - every { bloomingFinder.recentlyBloomingByEventIds(listOf(40L, 41L)) } returns bloomings + coEvery { bloomingFinder.recentlyBloomingByEventIds(listOf(40L, 41L)) } returns bloomings - val result = service.recentlyBloomingByEventIds(listOf(40L, 41L, 40L)) + val result = service.recentlyBloomingByEventIds(listOf(40L, 41L, 40L)) - result shouldBe bloomings - verify(exactly = 1) { bloomingFinder.recentlyBloomingByEventIds(listOf(40L, 41L)) } - } + result shouldBe bloomings + coVerify(exactly = 1) { bloomingFinder.recentlyBloomingByEventIds(listOf(40L, 41L)) } + } @Test - fun `cafe batch 조회는 중복 id를 제거한 뒤 한 번의 finder 호출로 위임한다`() { - val bloomingAppender = mockk() - val bloomingValidator = mockk() - val bloomingFinder = mockk() - val service = BloomingService(bloomingAppender, bloomingValidator, bloomingFinder) - val bloomings = - listOf( - Blooming( - id = 3L, - status = BloomingStatus.WITHERED, - userId = 12L, - flowerSpotId = null, - flowerEventId = null, - flowerSpotCafeId = 50L, - createdAt = LocalDateTime.of(2026, 4, 1, 11, 0), - ), - ) + fun `cafe batch 조회는 중복 id를 제거한 뒤 한 번의 finder 호출로 위임한다`(): Unit = + runBlocking { + val bloomingAppender = mockk() + val bloomingValidator = mockk() + val bloomingFinder = mockk() + val service = BloomingService(bloomingAppender, bloomingValidator, bloomingFinder) + val bloomings = + listOf( + Blooming( + id = 3L, + status = BloomingStatus.WITHERED, + userId = 12L, + flowerSpotId = null, + flowerEventId = null, + flowerSpotCafeId = 50L, + createdAt = LocalDateTime.of(2026, 4, 1, 11, 0), + ), + ) - every { bloomingFinder.recentlyBloomingByCafeIds(listOf(50L, 51L)) } returns bloomings + coEvery { bloomingFinder.recentlyBloomingByCafeIds(listOf(50L, 51L)) } returns bloomings - val result = service.recentlyBloomingByCafeIds(listOf(50L, 51L, 50L)) + val result = service.recentlyBloomingByCafeIds(listOf(50L, 51L, 50L)) - result shouldBe bloomings - verify(exactly = 1) { bloomingFinder.recentlyBloomingByCafeIds(listOf(50L, 51L)) } - } + result shouldBe bloomings + coVerify(exactly = 1) { bloomingFinder.recentlyBloomingByCafeIds(listOf(50L, 51L)) } + } @Test fun `flowerSpotId와 flowerEventId가 모두 없으면 INVALID_REQUEST를 던진다`() { diff --git a/pida-core/core-domain/src/test/kotlin/com/pida/category/CafeCategoryItemReadStrategyTest.kt b/pida-core/core-domain/src/test/kotlin/com/pida/category/CafeCategoryItemReadStrategyTest.kt index 5cb7e40..8bb34c8 100644 --- a/pida-core/core-domain/src/test/kotlin/com/pida/category/CafeCategoryItemReadStrategyTest.kt +++ b/pida-core/core-domain/src/test/kotlin/com/pida/category/CafeCategoryItemReadStrategyTest.kt @@ -17,7 +17,6 @@ import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf import io.mockk.coEvery -import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Test @@ -55,7 +54,7 @@ class CafeCategoryItemReadStrategyTest { coEvery { mapCategoryService.findAllByCategoryLabel(CategoryLabel.CAFE) } returns listOf(category) coEvery { flowerSpotCafeFinder.readAll() } returns listOf(cafe) - every { bloomingService.recentlyBloomingByCafeIds(listOf(20L)) } returns + coEvery { bloomingService.recentlyBloomingByCafeIds(listOf(20L)) } returns listOf( Blooming( id = 1L, @@ -119,7 +118,7 @@ class CafeCategoryItemReadStrategyTest { coEvery { mapCategoryService.findAllByCategoryLabel(CategoryLabel.CAFE) } returns listOf(category) coEvery { flowerSpotCafeFinder.readAllByLocation(location) } returns listOf(cafe) - every { bloomingService.recentlyBloomingByCafeIds(listOf(21L)) } returns emptyList() + coEvery { bloomingService.recentlyBloomingByCafeIds(listOf(21L)) } returns emptyList() coEvery { mapCategoryBadgeFinder.findAllGroupedByTarget(MapCategoryBadgeTargetType.FLOWER_SPOT_CAFE, listOf(21L)) } returns emptyMap() @@ -220,7 +219,7 @@ class CafeCategoryItemReadStrategyTest { coEvery { mapCategoryService.findAllByCategoryLabel(CategoryLabel.CAFE) } returns listOf(category) coEvery { flowerSpotCafeFinder.readAll() } returns listOf(seoulCafe, busanCafe) - every { bloomingService.recentlyBloomingByCafeIds(listOf(22L)) } returns emptyList() + coEvery { bloomingService.recentlyBloomingByCafeIds(listOf(22L)) } returns emptyList() coEvery { mapCategoryBadgeFinder.findAllGroupedByTarget(MapCategoryBadgeTargetType.FLOWER_SPOT_CAFE, listOf(22L)) } returns emptyMap() diff --git a/pida-core/core-domain/src/test/kotlin/com/pida/category/EventCategoryItemReadStrategyTest.kt b/pida-core/core-domain/src/test/kotlin/com/pida/category/EventCategoryItemReadStrategyTest.kt index b7e36d1..9df730c 100644 --- a/pida-core/core-domain/src/test/kotlin/com/pida/category/EventCategoryItemReadStrategyTest.kt +++ b/pida-core/core-domain/src/test/kotlin/com/pida/category/EventCategoryItemReadStrategyTest.kt @@ -15,7 +15,6 @@ import com.pida.support.geo.Region import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import io.mockk.coEvery -import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Test @@ -46,7 +45,7 @@ class EventCategoryItemReadStrategyTest { ) coEvery { flowerEventFinder.readAllByCategoryId(1L) } returns listOf(event) - every { bloomingService.recentlyBloomingByEventIds(listOf(10L)) } returns + coEvery { bloomingService.recentlyBloomingByEventIds(listOf(10L)) } returns listOf( Blooming( id = 1L, @@ -99,7 +98,7 @@ class EventCategoryItemReadStrategyTest { ) coEvery { flowerEventFinder.readAllByCategoryIdAndLocation(1L, location) } returns listOf(event) - every { bloomingService.recentlyBloomingByEventIds(listOf(11L)) } returns emptyList() + coEvery { bloomingService.recentlyBloomingByEventIds(listOf(11L)) } returns emptyList() coEvery { mapCategoryBadgeFinder.findAllGroupedByTarget(MapCategoryBadgeTargetType.FLOWER_EVENT, listOf(11L)) } returns emptyMap() @@ -154,7 +153,7 @@ class EventCategoryItemReadStrategyTest { ) coEvery { flowerEventFinder.readAllByCategoryId(1L) } returns listOf(seoulEvent, busanEvent) - every { bloomingService.recentlyBloomingByEventIds(listOf(12L)) } returns emptyList() + coEvery { bloomingService.recentlyBloomingByEventIds(listOf(12L)) } returns emptyList() coEvery { mapCategoryBadgeFinder.findAllGroupedByTarget(MapCategoryBadgeTargetType.FLOWER_EVENT, listOf(12L)) } returns emptyMap() diff --git a/pida-core/core-domain/src/test/kotlin/com/pida/category/FlowerSpotCategoryItemReadStrategyTest.kt b/pida-core/core-domain/src/test/kotlin/com/pida/category/FlowerSpotCategoryItemReadStrategyTest.kt index 0174854..7edada5 100644 --- a/pida-core/core-domain/src/test/kotlin/com/pida/category/FlowerSpotCategoryItemReadStrategyTest.kt +++ b/pida-core/core-domain/src/test/kotlin/com/pida/category/FlowerSpotCategoryItemReadStrategyTest.kt @@ -18,7 +18,6 @@ import com.pida.support.geo.Region import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import io.mockk.coEvery -import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Test @@ -65,7 +64,7 @@ class FlowerSpotCategoryItemReadStrategyTest { coEvery { mapCategoryService.findAllByCategoryLabel(CategoryLabel.FLOWER_SPOT) } returns listOf(category) coEvery { flowerSpotService.readAllFlowerSpot(region = null, location = location) } returns listOf(flowerSpot) - every { bloomingService.recentlyBloomingBySpotIds(listOf(30L)) } returns + coEvery { bloomingService.recentlyBloomingBySpotIds(listOf(30L)) } returns listOf( Blooming( id = 1L, @@ -117,7 +116,7 @@ class FlowerSpotCategoryItemReadStrategyTest { coEvery { mapCategoryService.findAllByCategoryLabel(CategoryLabel.FLOWER_SPOT) } returns listOf(category) coEvery { flowerSpotService.readAllFlowerSpot(region = Region.SEOUL, location = location) } returns emptyList() - every { bloomingService.recentlyBloomingBySpotIds(emptyList()) } returns emptyList() + coEvery { bloomingService.recentlyBloomingBySpotIds(emptyList()) } returns emptyList() coEvery { mapCategoryBadgeFinder.findAllGroupedByTarget(MapCategoryBadgeTargetType.FLOWER_SPOT, emptyList()) } returns emptyMap() diff --git a/pida-core/core-domain/src/test/kotlin/com/pida/place/PlaceFacadeTest.kt b/pida-core/core-domain/src/test/kotlin/com/pida/place/PlaceFacadeTest.kt index f5fbb7b..c84a59e 100644 --- a/pida-core/core-domain/src/test/kotlin/com/pida/place/PlaceFacadeTest.kt +++ b/pida-core/core-domain/src/test/kotlin/com/pida/place/PlaceFacadeTest.kt @@ -48,9 +48,9 @@ class PlaceFacadeTest { val result = facade.search("강남", null) result.landmarks shouldContainExactly listOf(storedLandmark) - verify(exactly = 1) { eventPublisher.publishEvent(match { it is FlowerSpotSearchEvent }) } + verify(exactly = 1) { eventPublisher.publishEvent(match { it is FlowerSpotSearchEvent }) } verify(exactly = 0) { landmarkSearchClient.searchByKeyword(any()) } - verify(exactly = 0) { eventPublisher.publishEvent(match { it is LandmarkFetchEvent }) } + verify(exactly = 0) { eventPublisher.publishEvent(match { it is LandmarkFetchEvent }) } } companion object { From aeb94ebbf6d6e3c964853f8d4c19844b6eb154a2 Mon Sep 17 00:00:00 2001 From: char-yb Date: Mon, 6 Apr 2026 22:14:50 +0900 Subject: [PATCH 6/6] [Fix] Harden FlowerSpotFacade preview image fallback --- .../main/kotlin/com/pida/flowerspot/FlowerSpotFacade.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotFacade.kt b/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotFacade.kt index 5f82c11..e3a17e3 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotFacade.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotFacade.kt @@ -7,6 +7,7 @@ import com.pida.support.aws.S3ImageInfo import com.pida.support.geo.Region import com.pida.support.resilience.ExternalDependency import com.pida.support.resilience.ExternalDependencyPolicy +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import org.slf4j.LoggerFactory @@ -34,6 +35,7 @@ class FlowerSpotFacade( fileName = null, ) }.getOrElse { error -> + if (error is CancellationException) throw error externalDependencyPolicy.recordFallback( dependency = ExternalDependency.AWS_S3, reason = "empty-image-list", @@ -75,10 +77,13 @@ class FlowerSpotFacade( private fun previewImagePresignedUrl(flowerSpot: FlowerSpot): S3ImageInfo? = flowerSpot.previewImageKey?.let { key -> + val uploadedAt = requireNotNull(flowerSpot.previewImageUploadedAt) { + "previewImageUploadedAt is required when previewImageKey is set" + } runCatching { S3ImageInfo( url = imageS3Caller.generatePresignedUrl(key), - uploadedAt = flowerSpot.previewImageUploadedAt!!, + uploadedAt = uploadedAt, ) }.getOrElse { error -> externalDependencyPolicy.recordFallback(