diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b6e5cb0d..69a1451e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -49,11 +49,12 @@ spring-boot-annotation-processor = { group = "org.springframework.boot", name = spring-boot-starter-test = { group = "org.springframework.boot", name = "spring-boot-starter-test" } kotlin-junit5 = { group = "org.jetbrains.kotlin", name = "kotlin-test-junit5" } junit = { group = "org.junit.platform", name = "junit-platform-launcher" } +mockito-kotlin = { group = "org.mockito.kotlin", name = "mockito-kotlin", version = "5.4.0" } [bundles] spring-common = ["spring-boot-starter-autoconfigure"] kotlin-spring = ["kotlin-refelct", "kotlin-jackson", "kotlin-logging"] -test-implementation = ["spring-boot-starter-test", "kotlin-junit5"] +test-implementation = ["spring-boot-starter-test", "kotlin-junit5", "mockito-kotlin"] test-runtime = ["junit"] bootstarp = ["spring-boot-starter-web", "spring-boot-starter-actuator", "opentelemetry-starter"] 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 6571bd32..03ec5473 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 @@ -8,6 +8,7 @@ enum class ExceptionCode( ILLEGAL_ARGUMENT_EXCEPTION(400, "요청 값이 올바르지 않습니다."), VOTE_PLACE_ID_INVALID(400, "투표 항목 데이터(Place Id)이 올바르지 않습니다."), NOT_SUPPORT_AUTO_COMPLETE_URL(400, "자동입력 지원을 하지 않는 주소형식 입니다."), + DUPLICATED_REQUEST(400, "같은 리소스에 대한 중복요청입니다."), UNAUTHORIZED(401, "인증된 토큰으로부터의 요청이 아닙니다."), ROOM_PASSWORD_INVALID(401, "방 패스워드가 틀립니다."), @@ -19,4 +20,5 @@ enum class ExceptionCode( SECRET_MANAGER_CONFIG_NOT_SET(500, "시크릿 매니저 설정 값이 입력되지 않았습니다."), URL_PROCESS_ERROR(500, "URL에 해당하는 장소의 정보를 불러오는 중 예기치 못한 오류가 발생했습니다."), ROUTE_PROCESS_ERROR(500, "경로 거리 정보를 불러오는 중 예기치 못한 오류가 발생했습니다."), + REQUEST_TIMEOUT(500, "요청처리에 Timeout이 발생하였습니다."), } 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 new file mode 100644 index 00000000..dfcabe45 --- /dev/null +++ b/piikii-input-http/src/main/kotlin/com/piikii/input/http/aspect/PreventDuplicateAspect.kt @@ -0,0 +1,93 @@ +package com.piikii.input.http.aspect + +import com.piikii.common.exception.ExceptionCode +import com.piikii.common.exception.PiikiiException +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.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 timeoutMillis default 7 seconds + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class PreventDuplicateRequest( + val key: String, + val timeoutMillis: Long = 7_000, +) + +@Aspect +@Component +class PreventDuplicateAspect { + 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 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) + } 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 PiikiiException( + 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/web/config/WebConfig.kt b/piikii-input-http/src/main/kotlin/com/piikii/input/http/config/WebConfig.kt similarity index 93% rename from piikii-input-http/src/main/kotlin/com/piikii/input/http/web/config/WebConfig.kt rename to piikii-input-http/src/main/kotlin/com/piikii/input/http/config/WebConfig.kt index f6ceb677..faafee22 100644 --- a/piikii-input-http/src/main/kotlin/com/piikii/input/http/web/config/WebConfig.kt +++ b/piikii-input-http/src/main/kotlin/com/piikii/input/http/config/WebConfig.kt @@ -1,4 +1,4 @@ -package com.piikii.input.http.web.config +package com.piikii.input.http.config import org.springframework.context.annotation.Configuration import org.springframework.web.servlet.config.annotation.CorsRegistry diff --git a/piikii-input-http/src/main/kotlin/com/piikii/input/http/controller/PlaceApi.kt b/piikii-input-http/src/main/kotlin/com/piikii/input/http/controller/PlaceApi.kt index a0c2b2d1..b45959f6 100644 --- a/piikii-input-http/src/main/kotlin/com/piikii/input/http/controller/PlaceApi.kt +++ b/piikii-input-http/src/main/kotlin/com/piikii/input/http/controller/PlaceApi.kt @@ -9,6 +9,7 @@ import com.piikii.application.port.input.dto.request.AddPlaceRequest import com.piikii.application.port.input.dto.request.ModifyPlaceRequest import com.piikii.application.port.input.dto.response.PlaceResponse import com.piikii.application.port.input.dto.response.ScheduleTypeGroupResponse +import com.piikii.input.http.aspect.PreventDuplicateRequest import com.piikii.input.http.controller.docs.PlaceDocs import com.piikii.input.http.controller.dto.ResponseForm import jakarta.validation.Valid @@ -35,6 +36,7 @@ class PlaceApi( private val placeUseCase: PlaceUseCase, private val imageUploadUseCase: ImageUploadUseCase, ) : PlaceDocs { + @PreventDuplicateRequest("#roomUid + #addPlaceRequest.name") @ResponseStatus(HttpStatus.CREATED) @PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) override fun addPlace( @@ -54,6 +56,7 @@ class PlaceApi( return ResponseForm(placeUseCase.findAllByRoomUidGroupByPlaceType(UuidTypeId(roomUid))) } + @PreventDuplicateRequest("#roomUid + #placeId") @ResponseStatus(HttpStatus.OK) @PatchMapping("/{placeId}", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) override fun modifyPlace( diff --git a/piikii-input-http/src/main/kotlin/com/piikii/input/http/controller/RoomApi.kt b/piikii-input-http/src/main/kotlin/com/piikii/input/http/controller/RoomApi.kt index c0cd0449..1647e65e 100644 --- a/piikii-input-http/src/main/kotlin/com/piikii/input/http/controller/RoomApi.kt +++ b/piikii-input-http/src/main/kotlin/com/piikii/input/http/controller/RoomApi.kt @@ -7,6 +7,7 @@ import com.piikii.application.port.input.dto.request.RoomSaveRequestForm import com.piikii.application.port.input.dto.request.RoomUpdateRequestForm import com.piikii.application.port.input.dto.response.RoomResponse import com.piikii.application.port.input.dto.response.SaveRoomResponse +import com.piikii.input.http.aspect.PreventDuplicateRequest import com.piikii.input.http.controller.docs.RoomApiDocs import com.piikii.input.http.controller.dto.ResponseForm import jakarta.validation.Valid @@ -34,7 +35,7 @@ class RoomApi( ) : RoomApiDocs { @ResponseStatus(HttpStatus.CREATED) @PostMapping - override fun create( + override fun createRoom( @Valid @NotNull @RequestBody request: RoomSaveRequestForm, ): ResponseForm { return ResponseForm( @@ -42,9 +43,10 @@ class RoomApi( ) } + @PreventDuplicateRequest("#request.roomUid") @ResponseStatus(HttpStatus.OK) @PutMapping - override fun modifyInformation( + override fun modifyRoom( @Valid @NotNull @RequestBody request: RoomUpdateRequestForm, ): ResponseForm { roomUseCase.modify(request) @@ -53,7 +55,7 @@ class RoomApi( @ResponseStatus(HttpStatus.OK) @DeleteMapping("/{roomUid}") - override fun remove( + override fun deleteRoom( @NotNull @PathVariable roomUid: UUID, ): ResponseForm { roomUseCase.remove(UuidTypeId(roomUid)) @@ -62,7 +64,7 @@ class RoomApi( @ResponseStatus(HttpStatus.OK) @GetMapping("/{roomUid}") - override fun search( + override fun retrieveRoom( @NotNull @PathVariable roomUid: UUID, ): ResponseForm { return ResponseForm( diff --git a/piikii-input-http/src/main/kotlin/com/piikii/input/http/controller/VoteApi.kt b/piikii-input-http/src/main/kotlin/com/piikii/input/http/controller/VoteApi.kt index cd00b402..70ed17e2 100644 --- a/piikii-input-http/src/main/kotlin/com/piikii/input/http/controller/VoteApi.kt +++ b/piikii-input-http/src/main/kotlin/com/piikii/input/http/controller/VoteApi.kt @@ -8,6 +8,7 @@ import com.piikii.application.port.input.dto.request.VoteSaveRequest import com.piikii.application.port.input.dto.response.VoteResultResponse import com.piikii.application.port.input.dto.response.VoteStatusResponse import com.piikii.application.port.input.dto.response.VotedPlacesResponse +import com.piikii.input.http.aspect.PreventDuplicateRequest import com.piikii.input.http.controller.docs.VoteApiDocs import com.piikii.input.http.controller.dto.ResponseForm import jakarta.validation.Valid @@ -31,6 +32,7 @@ class VoteApi( private val voteUseCase: VoteUseCase, private val roomUseCase: RoomUseCase, ) : VoteApiDocs { + @PreventDuplicateRequest("#roomUid") @ResponseStatus(HttpStatus.NO_CONTENT) @PatchMapping("/deadline") override fun changeVoteDeadline( diff --git a/piikii-input-http/src/main/kotlin/com/piikii/input/http/web/advice/ExceptionAdvice.kt b/piikii-input-http/src/main/kotlin/com/piikii/input/http/controller/advice/ExceptionAdvice.kt similarity index 98% rename from piikii-input-http/src/main/kotlin/com/piikii/input/http/web/advice/ExceptionAdvice.kt rename to piikii-input-http/src/main/kotlin/com/piikii/input/http/controller/advice/ExceptionAdvice.kt index a079c845..1841675a 100644 --- a/piikii-input-http/src/main/kotlin/com/piikii/input/http/web/advice/ExceptionAdvice.kt +++ b/piikii-input-http/src/main/kotlin/com/piikii/input/http/controller/advice/ExceptionAdvice.kt @@ -1,4 +1,4 @@ -package com.piikii.input.http.web.advice +package com.piikii.input.http.controller.advice import com.piikii.common.exception.PiikiiException import com.piikii.common.logutil.SlackHookLogger diff --git a/piikii-input-http/src/main/kotlin/com/piikii/input/http/controller/docs/RoomApiDocs.kt b/piikii-input-http/src/main/kotlin/com/piikii/input/http/controller/docs/RoomApiDocs.kt index 4b323433..06892f69 100644 --- a/piikii-input-http/src/main/kotlin/com/piikii/input/http/controller/docs/RoomApiDocs.kt +++ b/piikii-input-http/src/main/kotlin/com/piikii/input/http/controller/docs/RoomApiDocs.kt @@ -36,7 +36,7 @@ interface RoomApiDocs { ), ], ) - fun create( + fun createRoom( @Parameter( description = "방 생성 요청 정보", required = true, @@ -58,7 +58,7 @@ interface RoomApiDocs { ), ], ) - fun modifyInformation( + fun modifyRoom( @Parameter( description = "방 수정 요청 정보", required = true, @@ -80,7 +80,7 @@ interface RoomApiDocs { ), ], ) - fun remove( + fun deleteRoom( @Parameter( name = "roomUid", description = "삭제하고자 하는 방 id", @@ -99,7 +99,7 @@ interface RoomApiDocs { ), ], ) - fun search( + fun retrieveRoom( @Parameter( name = "roomUid", description = "조회하고자 하는 방 id",