diff --git a/docker/testing/docker-compose.yml b/docker/testing/docker-compose.yml new file mode 100644 index 00000000..8f853538 --- /dev/null +++ b/docker/testing/docker-compose.yml @@ -0,0 +1,20 @@ +version: '3.8' + +services: + controller: + image: ngrinder/controller + restart: unless-stopped + ports: + - "9000:80" + - "16001:16001" + - "12000-12009:12000-12009" + volumes: + - ./ngrinder-controller:/opt/ngrinder-controller + platform: linux/amd64 + + agent: + image: ngrinder/agent + restart: unless-stopped + links: + - controller + platform: linux/amd64 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 69a1451e..b4311bc5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,7 +34,11 @@ spring-boot-starter-validation = { group = "org.springframework.boot", name = "s spring-boot-starter-cache = { group = "org.springframework.boot", name = "spring-boot-starter-cache" } spring-boot-starter-jpa = { group = "org.springframework.boot", name = "spring-boot-starter-data-jpa" } spring-boot-starter-redis = { group = "org.springframework.boot", name = "spring-boot-starter-data-redis" } + +spring-boot-starter-retry = { group = "org.springframework.retry", name = "spring-retry", version = "1.3.3" } + spring-boot-docs = { group = "org.springdoc", name = "springdoc-openapi-starter-webmvc-ui", version = "2.5.0" } +spring-boot-starter-redisson = { group = "org.redisson", name = "redisson-spring-boot-starter", version = "3.27.0" } postgresql = { group = "org.postgresql", name = "postgresql" } caffeine = { group = "com.github.ben-manes.caffeine", name = "caffeine" } @@ -63,5 +67,5 @@ domain-application = ["spring-boot-docs", "spring-transaction"] adaptor-input-http = ["spring-boot-starter-web", "spring-boot-starter-aop", "spring-boot-docs", "spring-boot-starter-validation"] adaptor-persistence-postgresql = ["spring-boot-starter-jpa", "postgresql"] adaptor-storage = ["spring-web", "aws-sdk-s3", "jaxb-api", "jaxb-runtime"] -adaptor-cache-redis = ["spring-boot-starter-cache", "spring-boot-starter-redis"] +adaptor-cache-redis = ["spring-boot-starter-cache", "spring-boot-starter-redis", "spring-boot-starter-redisson"] adaptor-cache-caffeine = ["spring-boot-starter-cache", "caffeine"] diff --git a/piikii-output-cache/redis/src/main/kotlin/com/piikii/output/redis/config/RedisConfig.kt b/piikii-output-cache/redis/src/main/kotlin/com/piikii/output/redis/config/RedisConfig.kt index 4e21f4e3..9c1602e1 100644 --- a/piikii-output-cache/redis/src/main/kotlin/com/piikii/output/redis/config/RedisConfig.kt +++ b/piikii-output-cache/redis/src/main/kotlin/com/piikii/output/redis/config/RedisConfig.kt @@ -1,6 +1,9 @@ package com.piikii.output.redis.config import com.fasterxml.jackson.databind.ObjectMapper +import org.redisson.Redisson +import org.redisson.api.RedissonClient +import org.redisson.config.Config import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.cache.CacheManager @@ -72,6 +75,19 @@ class RedisConfig { .cacheDefaults(redisCacheConfiguration) .build() } + + @Bean + fun redissonClient(redisProperties: RedisProperties): RedissonClient? { + val config = Config() + val address = "rediss://${redisProperties.host}:${redisProperties.port}" + config.useSingleServer() + .setAddress(address) + .setPassword(redisProperties.password) + .setConnectionMinimumIdleSize(1) + .setRetryAttempts(3) + .setRetryInterval(1500) + return Redisson.create(config) + } } @ConfigurationProperties(prefix = "redis") diff --git a/piikii-output-web/tmap/build.gradle.kts b/piikii-output-web/tmap/build.gradle.kts index a262c7ea..621ee479 100644 --- a/piikii-output-web/tmap/build.gradle.kts +++ b/piikii-output-web/tmap/build.gradle.kts @@ -5,4 +5,6 @@ plugins { dependencies { implementation(project(":piikii-application")) implementation(libs.spring.web) + implementation(libs.spring.boot.starter.redisson) + implementation(libs.spring.boot.starter.retry) } diff --git a/piikii-output-web/tmap/src/main/kotlin/com/piikii/output/web/tmap/adapter/TmapNavigationAdapter.kt b/piikii-output-web/tmap/src/main/kotlin/com/piikii/output/web/tmap/adapter/TmapNavigationAdapter.kt index 01ec2324..81a2f8a0 100644 --- a/piikii-output-web/tmap/src/main/kotlin/com/piikii/output/web/tmap/adapter/TmapNavigationAdapter.kt +++ b/piikii-output-web/tmap/src/main/kotlin/com/piikii/output/web/tmap/adapter/TmapNavigationAdapter.kt @@ -4,29 +4,73 @@ import com.piikii.application.domain.course.Coordinate import com.piikii.application.domain.course.Distance import com.piikii.application.domain.place.Place import com.piikii.application.port.output.web.NavigationPort -import com.piikii.common.exception.ExceptionCode -import com.piikii.common.exception.PiikiiException +import com.piikii.common.logutil.SlackHookLogger +import org.redisson.api.RLock +import org.redisson.api.RedissonClient import org.springframework.cache.annotation.Cacheable +import org.springframework.retry.annotation.Backoff +import org.springframework.retry.annotation.Recover +import org.springframework.retry.annotation.Retryable import org.springframework.stereotype.Component import org.springframework.web.client.RestClient import org.springframework.web.client.body +import java.util.concurrent.TimeUnit @Component class TmapNavigationAdapter( private val tmapApiClient: RestClient, + private val redissonClient: RedissonClient, + private val hookLogger: SlackHookLogger, ) : NavigationPort { @Cacheable( value = ["Distance"], key = "#startPlace.id + '_' + #endPlace.id", unless = "#result == T(com.piikii.application.domain.course.Distance).EMPTY", ) + @Retryable( + value = [InterruptedException::class], + maxAttempts = 3, + backoff = Backoff(delay = 1000), + ) override fun getDistance( startPlace: Place, endPlace: Place, ): Distance { val startCoordinate = startPlace.getCoordinate() val endCoordinate = endPlace.getCoordinate() + + if (!startCoordinate.isValid() || !endCoordinate.isValid()) { + return Distance.EMPTY + } + + val lockKey = "distance-lock-${startPlace.id}_${endPlace.id}" + val lock: RLock = redissonClient.getLock(lockKey) + + return try { + // 락 획득 시도: 10초 대기, 5초 동안 락을 유지 + if (lock.tryLock(10, 5, TimeUnit.SECONDS)) { + getDistanceFromTmap(startCoordinate, endCoordinate) + } else { + throw InterruptedException("락 획득 실패") + } + } finally { + if (lock.isHeldByCurrentThread) { + lock.unlock() + } + } + } + + @Recover + fun recoverFromInterruptedException( + exception: InterruptedException, + startPlace: Place, + endPlace: Place, + ): Distance { + hookLogger.send(exception.message ?: "[redis] 알 수 없는 에러") + val startCoordinate = startPlace.getCoordinate() + val endCoordinate = endPlace.getCoordinate() return if (startCoordinate.isValid() && endCoordinate.isValid()) { + // 락 획득 실패 시 바로 외부 API 호출 getDistanceFromTmap(startCoordinate, endCoordinate) } else { Distance.EMPTY @@ -50,9 +94,15 @@ class TmapNavigationAdapter( .retrieve() .body() ?.toDistance() - ?: throw PiikiiException( - exceptionCode = ExceptionCode.ROUTE_PROCESS_ERROR, - detailMessage = "fail to request tmap api call, start(${start.x}, ${start.y}), end(${end.x}, ${end.y})", - ) + ?: getEmptyDistanceWithError(start, end) + } + + private fun getEmptyDistanceWithError( + start: Coordinate, + end: Coordinate, + ): Distance { + val message = "fail to request tmap api call, start(${start.x}, ${start.y}), end(${end.x}, ${end.y})" + hookLogger.send(message) + return Distance.EMPTY } } diff --git a/piikii-output-web/tmap/src/main/kotlin/com/piikii/output/web/tmap/config/TmapConfig.kt b/piikii-output-web/tmap/src/main/kotlin/com/piikii/output/web/tmap/config/TmapConfig.kt index 8efce5a5..1e13c824 100644 --- a/piikii-output-web/tmap/src/main/kotlin/com/piikii/output/web/tmap/config/TmapConfig.kt +++ b/piikii-output-web/tmap/src/main/kotlin/com/piikii/output/web/tmap/config/TmapConfig.kt @@ -6,10 +6,12 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.http.HttpHeaders import org.springframework.http.MediaType +import org.springframework.retry.annotation.EnableRetry import org.springframework.web.client.RestClient // https://skopenapi.readme.io/reference/%EB%B3%B4%ED%96%89%EC%9E%90-%EA%B2%BD%EB%A1%9C%EC%95%88%EB%82%B4 @Configuration +@EnableRetry @EnableConfigurationProperties(TmapProperties::class) class TmapConfig { @Bean