From 62902ddff07b4d5fe4ae43469a6887b21d234117 Mon Sep 17 00:00:00 2001 From: KimDoubleB Date: Fri, 4 Oct 2024 02:11:56 +0900 Subject: [PATCH 1/4] added test api --- gradle/libs.versions.toml | 2 +- .../piikii/input/http/controller/TestApi.kt | 58 +++++++++++++++++++ .../database-config/application-local.yml | 4 +- 3 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 piikii-input-http/src/main/kotlin/com/piikii/input/http/controller/TestApi.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 69a1451e..4452c454 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -60,7 +60,7 @@ test-runtime = ["junit"] bootstarp = ["spring-boot-starter-web", "spring-boot-starter-actuator", "opentelemetry-starter"] bootstarp-runtime = ["micrometer-prometheus"] 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-input-http = ["spring-boot-starter-web", "spring-boot-starter-aop", "spring-boot-docs", "spring-boot-starter-validation", "micrometer-prometheus"] 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"] diff --git a/piikii-input-http/src/main/kotlin/com/piikii/input/http/controller/TestApi.kt b/piikii-input-http/src/main/kotlin/com/piikii/input/http/controller/TestApi.kt new file mode 100644 index 00000000..26cce231 --- /dev/null +++ b/piikii-input-http/src/main/kotlin/com/piikii/input/http/controller/TestApi.kt @@ -0,0 +1,58 @@ +package com.piikii.input.http.controller + +import com.piikii.input.http.aspect.PreventDuplicateRequest +import com.piikii.input.http.controller.dto.ResponseForm +import io.micrometer.core.annotation.Counted +import io.micrometer.core.aop.CountedAspect +import io.micrometer.core.instrument.MeterRegistry +import jakarta.validation.constraints.NotNull +import java.util.UUID +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + + +@Validated +@RestController +@RequestMapping("/api/v1/test") +class TestApi( + private val testService: TestService +) { + @PreventDuplicateRequest("#uuid") + @ResponseStatus(HttpStatus.CREATED) + @PostMapping("/entity/{uuid}") + fun testDuplicateSave( + @NotNull @PathVariable uuid: UUID, + ): ResponseForm { + testService.testSaveBusinessLogic(uuid) + Thread.sleep(3_000) + return ResponseForm.EMPTY_RESPONSE; + } +} + +@Service +class TestService { + + @Counted("test.save.entity") + fun testSaveBusinessLogic(uuid: UUID) { + println("uuid = ${uuid}") + } + +} + +@Configuration +class TestConfig { + + @Bean + fun countedAspect(meterRegistry: MeterRegistry): CountedAspect { + return CountedAspect(meterRegistry) + } + +} diff --git a/piikii-output-persistence/postgresql/src/main/resources/database-config/application-local.yml b/piikii-output-persistence/postgresql/src/main/resources/database-config/application-local.yml index e5457ea8..427bace3 100644 --- a/piikii-output-persistence/postgresql/src/main/resources/database-config/application-local.yml +++ b/piikii-output-persistence/postgresql/src/main/resources/database-config/application-local.yml @@ -19,11 +19,11 @@ spring: database-platform: org.hibernate.dialect.PostgreSQLDialect database: postgresql hibernate: - ddl-auto: validate + ddl-auto: create properties: hibernate: temp.use_jdbc_metadata_defaults: false jdbc.lob.non_contextual_creation: true - generate_statistics: true + generate_statistics: false format_sql: true show-sql: true From 94a4f639b1ed2ba046927a74ed35bd2362a686e4 Mon Sep 17 00:00:00 2001 From: KimDoubleB Date: Fri, 4 Oct 2024 02:14:06 +0900 Subject: [PATCH 2/4] added testing --- docker/.DS_Store | Bin 0 -> 6148 bytes docker/testing/README.md | 3 +++ docker/testing/docker-compose.yml | 14 ++++++++++++++ docker/testing/locust-boot.py | 20 ++++++++++++++++++++ 4 files changed, 37 insertions(+) create mode 100644 docker/.DS_Store create mode 100644 docker/testing/README.md create mode 100644 docker/testing/docker-compose.yml create mode 100644 docker/testing/locust-boot.py diff --git a/docker/.DS_Store b/docker/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..9c9581e5d6436bc52164adc337db2a81112a1bc9 GIT binary patch literal 6148 zcmeHK%}T>S5Z<-brW7Fu1-&hJE!fyr5HBIt7cim+m735}gE3p0)Er77cYPsW#OHBl zcLP>)@FZeqVE3DypWVy{*&oIj_vRyqF`F@FK||!IR0x_YT^lACk*hf(mas{fr_(U5 znCLH>@Y@?KV`G*=|LOZ>2%fia8b@i?>AdreT77e?VKuClb>~0G+|Pn+o_fLT21l1t z#$l-k;Z-!whxX2yOtK(K#xs==jm8jidmSYsnR{}cj8c{BYlqdennQbkv2Yxx*B1S5 zf7up`lcP>s^t#8(Wz*U{I6S==KE_Xpe9>HTpk2wf!4h6U`BK)iKTBenOu$oSmC=O6 z05L!e5Cdz)fH?-N)>>6f#S#O=z)u*!{XsxObPX07)z$$WUZ2rlM??V~-x7$zplh(u z2p$lwQvr1f_9Y-NEZP`2zA83FEH={%$7{$ literal 0 HcmV?d00001 diff --git a/docker/testing/README.md b/docker/testing/README.md new file mode 100644 index 00000000..3db11218 --- /dev/null +++ b/docker/testing/README.md @@ -0,0 +1,3 @@ +Execute + +- `docker compose up --scale worker={worker_number}` diff --git a/docker/testing/docker-compose.yml b/docker/testing/docker-compose.yml new file mode 100644 index 00000000..99ecdc6f --- /dev/null +++ b/docker/testing/docker-compose.yml @@ -0,0 +1,14 @@ +services: + master: + image: locustio/locust + ports: + - "8089:8089" + volumes: + - ./:/mnt/locust + command: -f /mnt/locust/locust-boot.py --master -H http://host.docker.internal:8080 + + worker: + image: locustio/locust + volumes: + - ./:/mnt/locust + command: -f /mnt/locust/locust-boot.py --worker --master-host master diff --git a/docker/testing/locust-boot.py b/docker/testing/locust-boot.py new file mode 100644 index 00000000..253016ab --- /dev/null +++ b/docker/testing/locust-boot.py @@ -0,0 +1,20 @@ +from locust import HttpUser, task, between +import uuid + +class APIUser(HttpUser): + wait_time = between(1, 3) # 각 태스크 사이의 대기 시간을 1~3초로 설정 + + # @task + # def test_entity_random_endpoint(self): + # # UUID 생성 + # uid = str(uuid.uuid4()) + + # # API 엔드포인트에 POST 요청 보내기 + # self.client.post(f"/api/v1/test/entity/{uid}") + @task + def test_entity_fix_endpoint(self): + # 고정 UUID + uid = "7afcc705-8ce8-4bde-9904-9cca8e57a1a1" + + # API 엔드포인트에 POST 요청 보내기 + self.client.post(f"/api/v1/test/entity/{uid}") From 17797ae9720996277fad6e2bac4820561a656e78 Mon Sep 17 00:00:00 2001 From: KimDoubleB Date: Fri, 4 Oct 2024 02:58:32 +0900 Subject: [PATCH 3/4] use redis setnx for lock instead of concurrent hashset --- piikii-input-http/build.gradle.kts | 1 + .../http/aspect/PreventDuplicateAspect.kt | 20 ++++++------ .../output/redis/RedisLockRepository.kt | 31 +++++++++++++++++++ 3 files changed, 41 insertions(+), 11 deletions(-) create mode 100644 piikii-output-cache/redis/src/main/kotlin/com/piikii/output/redis/RedisLockRepository.kt diff --git a/piikii-input-http/build.gradle.kts b/piikii-input-http/build.gradle.kts index 4672dda7..63481ebd 100644 --- a/piikii-input-http/build.gradle.kts +++ b/piikii-input-http/build.gradle.kts @@ -4,5 +4,6 @@ plugins { dependencies { implementation(project(":piikii-application")) + implementation(project(":piikii-output-cache:redis")) implementation(libs.bundles.adaptor.input.http) } diff --git a/piikii-input-http/src/main/kotlin/com/piikii/input/http/aspect/PreventDuplicateAspect.kt b/piikii-input-http/src/main/kotlin/com/piikii/input/http/aspect/PreventDuplicateAspect.kt index dfcabe45..ab977842 100644 --- a/piikii-input-http/src/main/kotlin/com/piikii/input/http/aspect/PreventDuplicateAspect.kt +++ b/piikii-input-http/src/main/kotlin/com/piikii/input/http/aspect/PreventDuplicateAspect.kt @@ -2,6 +2,8 @@ package com.piikii.input.http.aspect import com.piikii.common.exception.ExceptionCode import com.piikii.common.exception.PiikiiException +import com.piikii.output.redis.RedisLockRepository +import java.time.Duration import org.aspectj.lang.ProceedingJoinPoint import org.aspectj.lang.annotation.Around import org.aspectj.lang.annotation.Aspect @@ -25,16 +27,16 @@ import java.util.concurrent.TimeoutException @Retention(AnnotationRetention.RUNTIME) annotation class PreventDuplicateRequest( val key: String, - val timeoutMillis: Long = 7_000, + val timeoutMillis: Long = 5_000, ) @Aspect @Component -class PreventDuplicateAspect { - private val processingRequests = ConcurrentHashMap.newKeySet() +class PreventDuplicateAspect( + private val redisLockRepository: RedisLockRepository, +) { private val parser = SpelExpressionParser() private val parameterNameDiscoverer = DefaultParameterNameDiscoverer() - private val executor = Executors.newVirtualThreadPerTaskExecutor() @Around("@annotation(PreventDuplicateRequest)") fun preventDuplicateRequest(joinPoint: ProceedingJoinPoint): Any? { @@ -43,19 +45,15 @@ class PreventDuplicateAspect { val annotation = method.getAnnotation(PreventDuplicateRequest::class.java) val key = generateKey(joinPoint, signature, annotation) - if (!processingRequests.add(key)) { + if (!redisLockRepository.lock(key, Duration.ofMillis(annotation.timeoutMillis))) { // if exists, throw Duplicated Request Exception throw PiikiiException(ExceptionCode.DUPLICATED_REQUEST) } - val future = executor.submit { joinPoint.proceed() } try { - return future.get(annotation.timeoutMillis, TimeUnit.MILLISECONDS) - } catch (e: TimeoutException) { - future.cancel(true) - throw PiikiiException(ExceptionCode.REQUEST_TIMEOUT) + return joinPoint.proceed() } finally { - processingRequests.remove(key) + redisLockRepository.unlock(key) } } diff --git a/piikii-output-cache/redis/src/main/kotlin/com/piikii/output/redis/RedisLockRepository.kt b/piikii-output-cache/redis/src/main/kotlin/com/piikii/output/redis/RedisLockRepository.kt new file mode 100644 index 00000000..621bbf2a --- /dev/null +++ b/piikii-output-cache/redis/src/main/kotlin/com/piikii/output/redis/RedisLockRepository.kt @@ -0,0 +1,31 @@ +package com.piikii.output.redis + +import java.time.Duration +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.stereotype.Repository + +@Repository +class RedisLockRepository( + private val redisTemplate: RedisTemplate +) { + fun lock(key: String, timeout: Duration): Boolean { + return redisTemplate.opsForValue() + .setIfAbsent(key, LOCK, timeout) ?: false + } + + fun lock(key: String): Boolean { + return lock( + key = key, + timeout = DEFAULT_TIMEOUT + ) + } + + fun unlock(key: String): Boolean { + return redisTemplate.delete(key) + } + + companion object { + const val LOCK = "lock" + val DEFAULT_TIMEOUT: Duration = Duration.ofMinutes(5) + } +} From 415214cf394d801854e132e4891a73797c3b84b1 Mon Sep 17 00:00:00 2001 From: KimDoubleB Date: Sun, 6 Oct 2024 22:25:22 +0900 Subject: [PATCH 4/4] feat: rate limiter using lettuce --- .../piikii/common/exception/ExceptionCode.kt | 1 + .../common/exception/ServiceException.kt | 22 +++++ .../http/aspect/LimitConcurrentRequest.kt | 88 +++++++++++++++++ .../PreventDuplicateAspectWithConcurrent.kt | 96 +++++++++++++++++++ ...teAspect.kt => PreventDuplicateRequest.kt} | 27 +++--- .../piikii/input/http/aspect/RateLimiter.kt | 88 +++++++++++++++++ .../piikii/input/http/controller/TestApi.kt | 42 +++++++- .../output/redis/RedisLockRepository.kt | 19 +++- 8 files changed, 364 insertions(+), 19 deletions(-) create mode 100644 piikii-common/src/main/kotlin/com/piikii/common/exception/ServiceException.kt create mode 100644 piikii-input-http/src/main/kotlin/com/piikii/input/http/aspect/LimitConcurrentRequest.kt create mode 100644 piikii-input-http/src/main/kotlin/com/piikii/input/http/aspect/PreventDuplicateAspectWithConcurrent.kt rename piikii-input-http/src/main/kotlin/com/piikii/input/http/aspect/{PreventDuplicateAspect.kt => PreventDuplicateRequest.kt} (77%) create mode 100644 piikii-input-http/src/main/kotlin/com/piikii/input/http/aspect/RateLimiter.kt diff --git a/piikii-common/src/main/kotlin/com/piikii/common/exception/ExceptionCode.kt b/piikii-common/src/main/kotlin/com/piikii/common/exception/ExceptionCode.kt index 03ec5473..ddf6108d 100644 --- a/piikii-common/src/main/kotlin/com/piikii/common/exception/ExceptionCode.kt +++ b/piikii-common/src/main/kotlin/com/piikii/common/exception/ExceptionCode.kt @@ -9,6 +9,7 @@ enum class ExceptionCode( VOTE_PLACE_ID_INVALID(400, "투표 항목 데이터(Place Id)이 올바르지 않습니다."), NOT_SUPPORT_AUTO_COMPLETE_URL(400, "자동입력 지원을 하지 않는 주소형식 입니다."), DUPLICATED_REQUEST(400, "같은 리소스에 대한 중복요청입니다."), + REQUEST_LIMIT_EXCEEDED_IN_TIME_WINDOW(400, "타임 윈도우 내 한정된 요청 수 초과"), UNAUTHORIZED(401, "인증된 토큰으로부터의 요청이 아닙니다."), ROOM_PASSWORD_INVALID(401, "방 패스워드가 틀립니다."), diff --git a/piikii-common/src/main/kotlin/com/piikii/common/exception/ServiceException.kt b/piikii-common/src/main/kotlin/com/piikii/common/exception/ServiceException.kt new file mode 100644 index 00000000..de2eb66e --- /dev/null +++ b/piikii-common/src/main/kotlin/com/piikii/common/exception/ServiceException.kt @@ -0,0 +1,22 @@ +package com.piikii.common.exception + +class ServiceException : RuntimeException { + var statusCode: Int + var defaultMessage: String + var detailMessage: String? = null + + constructor( + exceptionCode: ExceptionCode, + detailMessage: String, + ) : super("[$exceptionCode] $detailMessage") { + this.statusCode = exceptionCode.statusCode + this.defaultMessage = exceptionCode.defaultMessage + this.detailMessage = detailMessage + } + + constructor(exceptionCode: ExceptionCode) : + super("[$exceptionCode] ${exceptionCode.defaultMessage}") { + this.statusCode = exceptionCode.statusCode + this.defaultMessage = exceptionCode.defaultMessage + } +} diff --git a/piikii-input-http/src/main/kotlin/com/piikii/input/http/aspect/LimitConcurrentRequest.kt b/piikii-input-http/src/main/kotlin/com/piikii/input/http/aspect/LimitConcurrentRequest.kt new file mode 100644 index 00000000..5f34ac65 --- /dev/null +++ b/piikii-input-http/src/main/kotlin/com/piikii/input/http/aspect/LimitConcurrentRequest.kt @@ -0,0 +1,88 @@ +package com.piikii.input.http.aspect + +import com.piikii.common.exception.ExceptionCode +import com.piikii.common.exception.ServiceException +import com.piikii.output.redis.RedisLockRepository +import org.aspectj.lang.ProceedingJoinPoint +import org.aspectj.lang.annotation.Around +import org.aspectj.lang.annotation.Aspect +import org.aspectj.lang.reflect.MethodSignature +import org.springframework.context.expression.MethodBasedEvaluationContext +import org.springframework.core.DefaultParameterNameDiscoverer +import org.springframework.expression.spel.standard.SpelExpressionParser +import org.springframework.stereotype.Component +import java.time.Duration +import java.time.Instant +import java.time.temporal.ChronoUnit + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class LimitConcurrentRequest( + val key: String, + val maxRequestCount: Long = 1, + val timeUnit: ChronoUnit = ChronoUnit.SECONDS, + val amount: Long = 5, +) + +@Aspect +@Component +class LimitConcurrentRequestAspect( + private val redisLockRepository: RedisLockRepository, +) { + private val parser = SpelExpressionParser() + private val parameterNameDiscoverer = DefaultParameterNameDiscoverer() + + @Around("@annotation(RateLimiter)") + fun limitConcurrentRequest(joinPoint: ProceedingJoinPoint): Any? { + val signature = joinPoint.signature as MethodSignature + val method = signature.method + val annotation = method.getAnnotation(LimitConcurrentRequest::class.java) + + val key = "req_concurrent_limit:${method.name}" + val epochSecond = Instant.now().epochSecond.toDouble() + + val member = generateMember( + memberSuffix = epochSecond.toString(), + joinPoint = joinPoint, + signature = signature, + annotation = annotation + ) + + val ttl = Duration.of(annotation.amount, annotation.timeUnit) + redisLockRepository.removeOutOfWindow(key, epochSecond, ttl) + if (redisLockRepository.getCountRequestsInWindow(key) >= annotation.maxRequestCount) { + throw ServiceException(ExceptionCode.REQUEST_LIMIT_EXCEEDED_IN_TIME_WINDOW) + } + + redisLockRepository.increaseRequestsInWindow(key, member, epochSecond, ttl) + return joinPoint.proceed() + } + + /** + * 요청 인식용 Member parsing + * - using SpEL parsed value + */ + private fun generateMember( + memberSuffix: String, + joinPoint: ProceedingJoinPoint, + signature: MethodSignature, + annotation: LimitConcurrentRequest, + ): String { + val method = signature.method + val expression = parser.parseExpression(annotation.key) + val context = + MethodBasedEvaluationContext( + joinPoint.target, + method, + joinPoint.args, + parameterNameDiscoverer, + ) + val member = expression.getValue(context, String::class.java) + ?: throw ServiceException( + ExceptionCode.NOT_FOUNDED, + "LimitConcurrentRequest member 추출에 실패했습니다 (method: ${method.name}, annotation key: ${annotation.key})", + ) + return "$member:$memberSuffix" + } + +} diff --git a/piikii-input-http/src/main/kotlin/com/piikii/input/http/aspect/PreventDuplicateAspectWithConcurrent.kt b/piikii-input-http/src/main/kotlin/com/piikii/input/http/aspect/PreventDuplicateAspectWithConcurrent.kt new file mode 100644 index 00000000..8d233705 --- /dev/null +++ b/piikii-input-http/src/main/kotlin/com/piikii/input/http/aspect/PreventDuplicateAspectWithConcurrent.kt @@ -0,0 +1,96 @@ +package com.piikii.input.http.aspect + +import com.piikii.common.exception.ExceptionCode +import com.piikii.common.exception.ServiceException +import org.aspectj.lang.ProceedingJoinPoint +import org.aspectj.lang.annotation.Around +import org.aspectj.lang.annotation.Aspect +import org.aspectj.lang.reflect.MethodSignature +import org.springframework.context.expression.MethodBasedEvaluationContext +import org.springframework.core.DefaultParameterNameDiscoverer +import org.springframework.expression.spel.standard.SpelExpressionParser +import org.springframework.stereotype.Component +import java.time.temporal.ChronoUnit +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +/** + * 중복요청 방지 제어 Annotation + * + * @property key key to determine duplicate requests (support SpEL) + * @property timeUnit time unit of timeout + * @property amount time amount of timeout + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class PreventDuplicateAspectWithConcurrent( + val key: String, + val timeUnit: ChronoUnit = ChronoUnit.SECONDS, + val amount: Long = 5, +) + +@Aspect +@Component +class PreventDuplicateAspectWithConcurrentAspect { + private val processingRequests = ConcurrentHashMap.newKeySet() + private val parser = SpelExpressionParser() + private val parameterNameDiscoverer = DefaultParameterNameDiscoverer() + private val executor = Executors.newVirtualThreadPerTaskExecutor() + + @Around("@annotation(PreventDuplicateRequest)") + fun preventDuplicateRequest(joinPoint: ProceedingJoinPoint): Any? { + val signature = joinPoint.signature as MethodSignature + val method = signature.method + val annotation = method.getAnnotation(PreventDuplicateRequest::class.java) + val key = generateKey(joinPoint, signature, annotation) + + if (!processingRequests.add(key)) { + // if exists, throw Duplicated Request Exception + throw ServiceException(ExceptionCode.DUPLICATED_REQUEST) + } + + val future = executor.submit { joinPoint.proceed() } + try { + return future.get(annotation.amount, TimeUnit.of(annotation.timeUnit)) + } catch (e: TimeoutException) { + future.cancel(true) + throw ServiceException(ExceptionCode.REQUEST_TIMEOUT) + } finally { + processingRequests.remove(key) + } + } + + /** + * 중복 요청으로 판단할 key 생성 + * - method name + SpEL parsed value + * + * @param joinPoint + * @param signature + * @param annotation + * @return Key to determine duplicate requests + */ + private fun generateKey( + joinPoint: ProceedingJoinPoint, + signature: MethodSignature, + annotation: PreventDuplicateRequest, + ): String { + val method = signature.method + val expression = parser.parseExpression(annotation.key) + val context = + MethodBasedEvaluationContext( + joinPoint.target, + method, + joinPoint.args, + parameterNameDiscoverer, + ) + val parsedValue = + expression.getValue(context, String::class.java) + ?: throw ServiceException( + ExceptionCode.NOT_FOUNDED, + "중복요청 계산에 사용될 key가 없습니다 (method: ${method.name}, annotation key: ${annotation.key})", + ) + return "${method.name}:$parsedValue" + } +} diff --git a/piikii-input-http/src/main/kotlin/com/piikii/input/http/aspect/PreventDuplicateAspect.kt b/piikii-input-http/src/main/kotlin/com/piikii/input/http/aspect/PreventDuplicateRequest.kt similarity index 77% rename from piikii-input-http/src/main/kotlin/com/piikii/input/http/aspect/PreventDuplicateAspect.kt rename to piikii-input-http/src/main/kotlin/com/piikii/input/http/aspect/PreventDuplicateRequest.kt index ab977842..56030099 100644 --- a/piikii-input-http/src/main/kotlin/com/piikii/input/http/aspect/PreventDuplicateAspect.kt +++ b/piikii-input-http/src/main/kotlin/com/piikii/input/http/aspect/PreventDuplicateRequest.kt @@ -1,9 +1,8 @@ package com.piikii.input.http.aspect import com.piikii.common.exception.ExceptionCode -import com.piikii.common.exception.PiikiiException +import com.piikii.common.exception.ServiceException import com.piikii.output.redis.RedisLockRepository -import java.time.Duration import org.aspectj.lang.ProceedingJoinPoint import org.aspectj.lang.annotation.Around import org.aspectj.lang.annotation.Aspect @@ -12,22 +11,22 @@ import org.springframework.context.expression.MethodBasedEvaluationContext import org.springframework.core.DefaultParameterNameDiscoverer import org.springframework.expression.spel.standard.SpelExpressionParser import org.springframework.stereotype.Component -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit -import java.util.concurrent.TimeoutException +import java.time.Duration +import java.time.temporal.ChronoUnit /** * 중복요청 방지 제어 Annotation * * @property key key to determine duplicate requests (support SpEL) - * @property timeoutMillis default 7 seconds + * @property timeUnit time unit of timeout + * @property amount time amount of timeout */ @Target(AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) annotation class PreventDuplicateRequest( val key: String, - val timeoutMillis: Long = 5_000, + val timeUnit: ChronoUnit = ChronoUnit.SECONDS, + val amount: Long = 5, ) @Aspect @@ -45,9 +44,9 @@ class PreventDuplicateAspect( val annotation = method.getAnnotation(PreventDuplicateRequest::class.java) val key = generateKey(joinPoint, signature, annotation) - if (!redisLockRepository.lock(key, Duration.ofMillis(annotation.timeoutMillis))) { + if (!redisLockRepository.lock(key, Duration.of(annotation.amount, annotation.timeUnit))) { // if exists, throw Duplicated Request Exception - throw PiikiiException(ExceptionCode.DUPLICATED_REQUEST) + throw ServiceException(ExceptionCode.DUPLICATED_REQUEST) } try { @@ -59,7 +58,7 @@ class PreventDuplicateAspect( /** * 중복 요청으로 판단할 key 생성 - * - method name + SpEL parsed value + * - req_duplicate + method name + SpEL parsed value * * @param joinPoint * @param signature @@ -82,10 +81,10 @@ class PreventDuplicateAspect( ) val parsedValue = expression.getValue(context, String::class.java) - ?: throw PiikiiException( + ?: throw ServiceException( ExceptionCode.NOT_FOUNDED, - "중복요청 계산에 사용될 key가 없습니다 (method: ${method.name}, annotation key: ${annotation.key})", + "PreventDuplicateRequest key 추출에 실패했습니다 (method: ${method.name}, annotation key: ${annotation.key})", ) - return "${method.name}_$parsedValue" + return "req_duplicate:${method.name}:$parsedValue" } } diff --git a/piikii-input-http/src/main/kotlin/com/piikii/input/http/aspect/RateLimiter.kt b/piikii-input-http/src/main/kotlin/com/piikii/input/http/aspect/RateLimiter.kt new file mode 100644 index 00000000..6adc7dda --- /dev/null +++ b/piikii-input-http/src/main/kotlin/com/piikii/input/http/aspect/RateLimiter.kt @@ -0,0 +1,88 @@ +package com.piikii.input.http.aspect + +import com.piikii.common.exception.ExceptionCode +import com.piikii.common.exception.ServiceException +import com.piikii.output.redis.RedisLockRepository +import org.aspectj.lang.ProceedingJoinPoint +import org.aspectj.lang.annotation.Around +import org.aspectj.lang.annotation.Aspect +import org.aspectj.lang.reflect.MethodSignature +import org.springframework.context.expression.MethodBasedEvaluationContext +import org.springframework.core.DefaultParameterNameDiscoverer +import org.springframework.expression.spel.standard.SpelExpressionParser +import org.springframework.stereotype.Component +import java.time.Duration +import java.time.Instant +import java.time.temporal.ChronoUnit + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class RateLimiter( + val key: String, + val maxRequestCount: Long = 1, + val timeUnit: ChronoUnit = ChronoUnit.SECONDS, + val amount: Long = 5, +) + +@Aspect +@Component +class RateLimiterAspect( + private val redisLockRepository: RedisLockRepository, +) { + private val parser = SpelExpressionParser() + private val parameterNameDiscoverer = DefaultParameterNameDiscoverer() + + @Around("@annotation(RateLimiter)") + fun rateLimiter(joinPoint: ProceedingJoinPoint): Any? { + val signature = joinPoint.signature as MethodSignature + val method = signature.method + val annotation = method.getAnnotation(RateLimiter::class.java) + + val key = "rate_limit:${method.name}" + val epochSecond = Instant.now().epochSecond.toDouble() + + val member = generateMember( + memberSuffix = epochSecond.toString(), + joinPoint = joinPoint, + signature = signature, + annotation = annotation + ) + + val ttl = Duration.of(annotation.amount, annotation.timeUnit) + redisLockRepository.removeOutOfWindow(key, epochSecond, ttl) + if (redisLockRepository.getCountRequestsInWindow(key) >= annotation.maxRequestCount) { + throw ServiceException(ExceptionCode.REQUEST_LIMIT_EXCEEDED_IN_TIME_WINDOW) + } + + redisLockRepository.increaseRequestsInWindow(key, member, epochSecond, ttl) + return joinPoint.proceed() + } + + /** + * 요청 인식용 Member parsing + * - using SpEL parsed value + */ + private fun generateMember( + memberSuffix: String, + joinPoint: ProceedingJoinPoint, + signature: MethodSignature, + annotation: RateLimiter, + ): String { + val method = signature.method + val expression = parser.parseExpression(annotation.key) + val context = + MethodBasedEvaluationContext( + joinPoint.target, + method, + joinPoint.args, + parameterNameDiscoverer, + ) + val member = expression.getValue(context, String::class.java) + ?: throw ServiceException( + ExceptionCode.NOT_FOUNDED, + "RateLimit member 추출에 실패했습니다 (method: ${method.name}, annotation key: ${annotation.key})", + ) + return "$member:$memberSuffix" + } + +} diff --git a/piikii-input-http/src/main/kotlin/com/piikii/input/http/controller/TestApi.kt b/piikii-input-http/src/main/kotlin/com/piikii/input/http/controller/TestApi.kt index 26cce231..74f309e8 100644 --- a/piikii-input-http/src/main/kotlin/com/piikii/input/http/controller/TestApi.kt +++ b/piikii-input-http/src/main/kotlin/com/piikii/input/http/controller/TestApi.kt @@ -1,12 +1,12 @@ package com.piikii.input.http.controller import com.piikii.input.http.aspect.PreventDuplicateRequest +import com.piikii.input.http.aspect.RateLimiter import com.piikii.input.http.controller.dto.ResponseForm import io.micrometer.core.annotation.Counted import io.micrometer.core.aop.CountedAspect import io.micrometer.core.instrument.MeterRegistry import jakarta.validation.constraints.NotNull -import java.util.UUID import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.http.HttpStatus @@ -17,6 +17,8 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController +import java.time.temporal.ChronoUnit +import java.util.UUID @Validated @@ -25,15 +27,49 @@ import org.springframework.web.bind.annotation.RestController class TestApi( private val testService: TestService ) { + + + @PreventDuplicateRequest(key = "#uuid", timeUnit = ChronoUnit.SECONDS, amount = 10) + @ResponseStatus(HttpStatus.CREATED) + @PostMapping("/entity/preventDuplicate/concurrent/{uuid}") + fun testDuplicateSaveConcurrent( + @NotNull @PathVariable uuid: UUID, + ): ResponseForm { + testService.testSaveBusinessLogic(uuid) + Thread.sleep(3_000) + return ResponseForm.EMPTY_RESPONSE + } + @PreventDuplicateRequest("#uuid") @ResponseStatus(HttpStatus.CREATED) - @PostMapping("/entity/{uuid}") + @PostMapping("/entity/preventDuplicate/{uuid}") fun testDuplicateSave( @NotNull @PathVariable uuid: UUID, ): ResponseForm { testService.testSaveBusinessLogic(uuid) Thread.sleep(3_000) - return ResponseForm.EMPTY_RESPONSE; + return ResponseForm.EMPTY_RESPONSE + } + + @RateLimiter(key = "#uuid", maxRequestCount = 5, timeUnit = ChronoUnit.HOURS, amount = 1) + @ResponseStatus(HttpStatus.CREATED) + @PostMapping("/entity/RateLimit/{uuid}") + fun testRateLimitSave( + @NotNull @PathVariable uuid: UUID, + ): ResponseForm { + testService.testSaveBusinessLogic(uuid) + return ResponseForm.EMPTY_RESPONSE + } + + + @RateLimiter(key = "#uuid", maxRequestCount = 3, timeUnit = ChronoUnit.SECONDS, amount = 5) + @ResponseStatus(HttpStatus.CREATED) + @PostMapping("/entity/RateLimit/{uuid}/short") + fun testRateLimitSaveShort( + @NotNull @PathVariable uuid: UUID, + ): ResponseForm { + testService.testSaveBusinessLogic(uuid) + return ResponseForm.EMPTY_RESPONSE } } diff --git a/piikii-output-cache/redis/src/main/kotlin/com/piikii/output/redis/RedisLockRepository.kt b/piikii-output-cache/redis/src/main/kotlin/com/piikii/output/redis/RedisLockRepository.kt index 621bbf2a..41518bfc 100644 --- a/piikii-output-cache/redis/src/main/kotlin/com/piikii/output/redis/RedisLockRepository.kt +++ b/piikii-output-cache/redis/src/main/kotlin/com/piikii/output/redis/RedisLockRepository.kt @@ -1,12 +1,12 @@ package com.piikii.output.redis -import java.time.Duration import org.springframework.data.redis.core.RedisTemplate import org.springframework.stereotype.Repository +import java.time.Duration @Repository class RedisLockRepository( - private val redisTemplate: RedisTemplate + private val redisTemplate: RedisTemplate, ) { fun lock(key: String, timeout: Duration): Boolean { return redisTemplate.opsForValue() @@ -24,8 +24,23 @@ class RedisLockRepository( return redisTemplate.delete(key) } + fun getCountRequestsInWindow(key: String): Long { + return redisTemplate.opsForZSet().zCard(key)!! + } + + fun removeOutOfWindow(key: String, epochSecond: Double, ttl: Duration) { + val timeToLiveSeconds = ttl.seconds.toDouble() + redisTemplate.opsForZSet().removeRangeByScore(key, MIN_TIMESTAMP, epochSecond - timeToLiveSeconds) + } + + fun increaseRequestsInWindow(key: String, member: String, epochSecond: Double, ttl: Duration) { + redisTemplate.opsForZSet().add(key, member, epochSecond) + redisTemplate.expire(key, ttl) + } + companion object { const val LOCK = "lock" + const val MIN_TIMESTAMP: Double = 0.0 val DEFAULT_TIMEOUT: Duration = Duration.ofMinutes(5) } }