From 84e38e25af88a700ccbf261c2952f8ddc3837c9a Mon Sep 17 00:00:00 2001 From: jisu Date: Thu, 31 Jul 2025 15:23:07 +0900 Subject: [PATCH 1/7] feat: add Redis-based daily usage limit to handle concurrency issues --- .../com/dobby/gateway/UsageLimitGateway.kt | 5 +++ .../cache/RedisUsageLimitGatewayImpl.kt | 39 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 domain/src/main/kotlin/com/dobby/gateway/UsageLimitGateway.kt create mode 100644 infrastructure/src/main/kotlin/com/dobby/external/gateway/cache/RedisUsageLimitGatewayImpl.kt diff --git a/domain/src/main/kotlin/com/dobby/gateway/UsageLimitGateway.kt b/domain/src/main/kotlin/com/dobby/gateway/UsageLimitGateway.kt new file mode 100644 index 00000000..f4bb2d88 --- /dev/null +++ b/domain/src/main/kotlin/com/dobby/gateway/UsageLimitGateway.kt @@ -0,0 +1,5 @@ +package com.dobby.gateway + +interface UsageLimitGateway { + fun incrementAndCheckLimit(memberId: String, dailyLimit: Int): Boolean +} diff --git a/infrastructure/src/main/kotlin/com/dobby/external/gateway/cache/RedisUsageLimitGatewayImpl.kt b/infrastructure/src/main/kotlin/com/dobby/external/gateway/cache/RedisUsageLimitGatewayImpl.kt new file mode 100644 index 00000000..50b66a30 --- /dev/null +++ b/infrastructure/src/main/kotlin/com/dobby/external/gateway/cache/RedisUsageLimitGatewayImpl.kt @@ -0,0 +1,39 @@ +package com.dobby.external.gateway.cache + +import com.dobby.gateway.UsageLimitGateway +import org.slf4j.LoggerFactory +import org.springframework.core.env.Environment +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.stereotype.Component +import java.time.Duration +import java.time.LocalDate +import java.time.LocalDateTime +import java.util.concurrent.TimeUnit + +@Component +class RedisUsageLimitGatewayImpl( + private val redisTemplate: RedisTemplate, + private val environment: Environment +) : UsageLimitGateway { + + private val logger = LoggerFactory.getLogger(this::class.java) + + override fun incrementAndCheckLimit(memberId: String, dailyLimit: Int): Boolean { + val key = getCacheKey(memberId) + val count = redisTemplate.opsForValue().increment(key, 1) ?: 1L + + if (count == 1L) { + val expireSeconds = Duration.between(LocalDateTime.now(), LocalDate.now().plusDays(1).atStartOfDay()).seconds + redisTemplate.expire(key, expireSeconds, TimeUnit.SECONDS) + } + + logger.debug("Usage count for key=$key is $count") + + return count <= dailyLimit + } + + private fun getCacheKey(memberId: String): String { + val activeProfile = environment.activeProfiles.firstOrNull() ?: "local" + return "$activeProfile:usage:$memberId:${LocalDate.now()}" + } +} From 2c8429a2f0880be2bfbb943a20cf7b98ae38dcf2 Mon Sep 17 00:00:00 2001 From: jisu Date: Thu, 31 Jul 2025 15:23:32 +0900 Subject: [PATCH 2/7] feat: integrate Redis-based usage limiter into usecase logic --- .../ExtractExperimentPostKeywordsUseCase.kt | 16 ++++------------ ...xtractExperimentPostKeywordsUseCaseTest.kt | 19 +++++-------------- 2 files changed, 9 insertions(+), 26 deletions(-) diff --git a/application/src/main/kotlin/com/dobby/usecase/experiment/ExtractExperimentPostKeywordsUseCase.kt b/application/src/main/kotlin/com/dobby/usecase/experiment/ExtractExperimentPostKeywordsUseCase.kt index 15b5b042..733acdf0 100644 --- a/application/src/main/kotlin/com/dobby/usecase/experiment/ExtractExperimentPostKeywordsUseCase.kt +++ b/application/src/main/kotlin/com/dobby/usecase/experiment/ExtractExperimentPostKeywordsUseCase.kt @@ -2,18 +2,19 @@ package com.dobby.usecase.experiment import com.dobby.exception.ExperimentPostKeywordsDailyLimitExceededException import com.dobby.gateway.OpenAiGateway +import com.dobby.gateway.UsageLimitGateway import com.dobby.gateway.experiment.ExperimentPostKeywordsLogGateway import com.dobby.gateway.member.MemberGateway import com.dobby.model.experiment.ExperimentPostKeywordsLog import com.dobby.model.experiment.keyword.ExperimentPostKeywords import com.dobby.usecase.UseCase import com.dobby.util.IdGenerator -import com.dobby.util.TimeProvider class ExtractExperimentPostKeywordsUseCase( private val openAiGateway: OpenAiGateway, private val experimentPostKeywordsGateway: ExperimentPostKeywordsLogGateway, private val memberGateway: MemberGateway, + private val usageLimitGateway: UsageLimitGateway, private val idGenerator: IdGenerator ) : UseCase { @@ -46,17 +47,8 @@ class ExtractExperimentPostKeywordsUseCase( } private fun validateDailyUsageLimit(memberId: String) { - val today = TimeProvider.currentDateTime().toLocalDate() - val startOfDay = today.atStartOfDay() - val endOfDay = today.plusDays(1).atStartOfDay() - - val todayUsageCount = experimentPostKeywordsGateway.countByMemberIdAndCreatedAtBetween( - memberId = memberId, - start = startOfDay, - end = endOfDay - ) - - if (todayUsageCount >= DAILY_USAGE_LIMIT) { + val isAllowed = usageLimitGateway.incrementAndCheckLimit(memberId, DAILY_USAGE_LIMIT) + if (!isAllowed) { throw ExperimentPostKeywordsDailyLimitExceededException } } diff --git a/application/src/test/kotlin/com/dobby/usecase/experiment/ExtractExperimentPostKeywordsUseCaseTest.kt b/application/src/test/kotlin/com/dobby/usecase/experiment/ExtractExperimentPostKeywordsUseCaseTest.kt index 095e6076..bf29c4ea 100644 --- a/application/src/test/kotlin/com/dobby/usecase/experiment/ExtractExperimentPostKeywordsUseCaseTest.kt +++ b/application/src/test/kotlin/com/dobby/usecase/experiment/ExtractExperimentPostKeywordsUseCaseTest.kt @@ -2,6 +2,7 @@ package com.dobby.usecase.experiment import com.dobby.exception.ExperimentPostKeywordsDailyLimitExceededException import com.dobby.gateway.OpenAiGateway +import com.dobby.gateway.UsageLimitGateway import com.dobby.gateway.experiment.ExperimentPostKeywordsLogGateway import com.dobby.gateway.member.MemberGateway import com.dobby.model.experiment.ExperimentPostKeywordsLog @@ -22,12 +23,14 @@ class ExtractExperimentPostKeywordsUseCaseTest : BehaviorSpec({ val openAiGateway = mockk() val experimentPostKeywordsLogGateway = mockk() val memberGateway = mockk() + val usageLimitGateway = mockk() val idGenerator = mockk() val extractExperimentPostKeywordsUseCase = ExtractExperimentPostKeywordsUseCase( openAiGateway, experimentPostKeywordsLogGateway, memberGateway, + usageLimitGateway, idGenerator ) @@ -51,16 +54,10 @@ class ExtractExperimentPostKeywordsUseCaseTest : BehaviorSpec({ every { TimeProvider.currentDateTime() } returns currentDateTime every { memberGateway.getById(memberId) } returns mockMember + every { usageLimitGateway.incrementAndCheckLimit(memberId, any()) } returns true every { idGenerator.generateId() } returns "test_log_id" every { openAiGateway.extractKeywords(inputText) } returns mockExperimentPostKeywords every { experimentPostKeywordsLogGateway.save(any()) } returns mockLog - every { - experimentPostKeywordsLogGateway.countByMemberIdAndCreatedAtBetween( - memberId = memberId, - start = currentDateTime.toLocalDate().atStartOfDay(), - end = currentDateTime.toLocalDate().plusDays(1).atStartOfDay() - ) - } returns 1 `when`("키워드 추출을 요청하면") { val result = extractExperimentPostKeywordsUseCase.execute(input) @@ -81,13 +78,7 @@ class ExtractExperimentPostKeywordsUseCaseTest : BehaviorSpec({ every { TimeProvider.currentDateTime() } returns currentDateTime every { memberGateway.getById(memberId) } returns mockMember - every { - experimentPostKeywordsLogGateway.countByMemberIdAndCreatedAtBetween( - memberId = memberId, - start = currentDateTime.toLocalDate().atStartOfDay(), - end = currentDateTime.toLocalDate().plusDays(1).atStartOfDay() - ) - } returns 2 + every { usageLimitGateway.incrementAndCheckLimit(memberId, any()) } returns false `when`("키워드 추출을 요청하면") { then("DailyLimitExceededException 예외가 발생해야 한다") { From 0cd1fd28811dd4ef184605cf2f8284f5710ac956 Mon Sep 17 00:00:00 2001 From: jisu Date: Thu, 31 Jul 2025 15:24:36 +0900 Subject: [PATCH 3/7] test: add concurrency unit test for Redis-based daily usage limiter --- infrastructure/build.gradle.kts | 1 + ...ctExperimentPostKeywordsConcurrencyTest.kt | 155 ++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 infrastructure/src/test/kotlin/com/dobby/concurrency/ExtractExperimentPostKeywordsConcurrencyTest.kt diff --git a/infrastructure/build.gradle.kts b/infrastructure/build.gradle.kts index 0dcba5de..76e2854e 100644 --- a/infrastructure/build.gradle.kts +++ b/infrastructure/build.gradle.kts @@ -67,6 +67,7 @@ dependencies { testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("io.mockk:mockk:1.13.10") + testImplementation("org.mockito.kotlin:mockito-kotlin:4.1.0") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testImplementation("org.springframework.security:spring-security-test") testRuntimeOnly("org.junit.platform:junit-platform-launcher") diff --git a/infrastructure/src/test/kotlin/com/dobby/concurrency/ExtractExperimentPostKeywordsConcurrencyTest.kt b/infrastructure/src/test/kotlin/com/dobby/concurrency/ExtractExperimentPostKeywordsConcurrencyTest.kt new file mode 100644 index 00000000..c51405cf --- /dev/null +++ b/infrastructure/src/test/kotlin/com/dobby/concurrency/ExtractExperimentPostKeywordsConcurrencyTest.kt @@ -0,0 +1,155 @@ +package com.dobby.concurrency + +import com.dobby.enums.MatchType +import com.dobby.enums.experiment.TimeSlot +import com.dobby.enums.member.GenderType +import com.dobby.enums.member.MemberStatus +import com.dobby.enums.member.ProviderType +import com.dobby.enums.member.RoleType +import com.dobby.exception.ExperimentPostKeywordsDailyLimitExceededException +import com.dobby.gateway.OpenAiGateway +import com.dobby.gateway.UsageLimitGateway +import com.dobby.gateway.experiment.ExperimentPostKeywordsLogGateway +import com.dobby.gateway.member.MemberGateway +import com.dobby.model.experiment.keyword.ApplyMethodKeyword +import com.dobby.model.experiment.keyword.ExperimentPostKeywords +import com.dobby.model.experiment.keyword.TargetGroupKeyword +import com.dobby.model.member.Member +import com.dobby.usecase.experiment.ExtractExperimentPostKeywordsUseCase +import com.dobby.util.IdGenerator +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mockito.junit.jupiter.MockitoExtension +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ActiveProfiles +import java.time.LocalDateTime +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +@ExtendWith(MockitoExtension::class) +@SpringBootTest +@ActiveProfiles("test") +class ExtractExperimentPostKeywordsConcurrencyTest { + + companion object { + private const val THREAD_COUNT = 5 + private const val DAILY_LIMIT = 2 + } + + private lateinit var useCase: ExtractExperimentPostKeywordsUseCase + + private val openAiGateway = mock() + private val experimentPostKeywordsGateway = mock() + private val memberGateway = mock() + private val usageLimitGateway = mock() + private val idGenerator = object { + private var id = 0L + fun generateId() = (++id).toString() + } + + private val memberId = "test-user" + private val text = "이 실험은 집중력과 관련된 실험입니다." + + @BeforeEach + fun setup() { + whenever(memberGateway.getById(any())).thenReturn( + Member( + memberId, "테스트 유저", "dlawltn123@naver.com", "dlawltn456@naver.com", ProviderType.NAVER, + MemberStatus.ACTIVE, RoleType.RESEARCHER, LocalDateTime.now(), LocalDateTime.now(), null + ) + ) + + val sampleKeywords = ExperimentPostKeywords( + targetGroup = TargetGroupKeyword( + startAge = 18, + endAge = 30, + genderType = GenderType.ALL, + otherCondition = "건강한 대학생 및 직장인 대상" + ), + applyMethod = ApplyMethodKeyword( + content = "온라인 설문 작성 및 전화 인터뷰 가능", + isFormUrl = true, + formUrl = "https://example.com/survey-form", + isPhoneNum = true, + phoneNum = "010-1234-5678" + ), + matchType = MatchType.ALL, + reward = "실험 참여 시 2만원 상당 상품권 지급", + count = 50, + timeRequired = TimeSlot.ABOUT_1H + ) + whenever(openAiGateway.extractKeywords(any())).thenReturn(sampleKeywords) + + val usageCount = AtomicInteger(0) + + whenever(experimentPostKeywordsGateway.save(any())).thenAnswer { + usageCount.incrementAndGet() + null + } + + val callCount = AtomicInteger(0) + whenever(usageLimitGateway.incrementAndCheckLimit(any(), any())).thenAnswer { + val dailyLimit = it.getArgument(1) + val current = callCount.incrementAndGet() + current <= dailyLimit + } + + useCase = ExtractExperimentPostKeywordsUseCase( + openAiGateway, + experimentPostKeywordsGateway, + memberGateway, + usageLimitGateway, + idGenerator = object : IdGenerator { + override fun generateId(): String = idGenerator.generateId() + } + ) + } + + @Test + fun `동시에 여러 요청 시 최대 2번까지만 성공하고 나머지는 제한 예외가 발생해야 한다`() { + val executor = Executors.newFixedThreadPool(THREAD_COUNT) + + val successCount = mutableListOf() + val failCount = mutableListOf() + val lock = Any() + + repeat(THREAD_COUNT) { + executor.submit { + executeKeywordExtraction(successCount, failCount, lock) + } + } + + executor.shutdown() + val finished = executor.awaitTermination(10, TimeUnit.SECONDS) + if (!finished) { + throw RuntimeException("Thread pool shutdown timeout occurred") + } + + assertEquals(DAILY_LIMIT, successCount.size) + assertEquals(THREAD_COUNT - DAILY_LIMIT, failCount.size) + } + + private fun executeKeywordExtraction( + successCount: MutableList, + failCount: MutableList, + lock: Any + ) { + try { + val input = ExtractExperimentPostKeywordsUseCase.Input(memberId, text) + useCase.execute(input) + synchronized(lock) { + successCount.add(Unit) + } + } catch (e: ExperimentPostKeywordsDailyLimitExceededException) { + synchronized(lock) { + failCount.add(Unit) + } + } + } +} From 489af8e3328c1467b82dead5c686e856628b7353 Mon Sep 17 00:00:00 2001 From: jisu Date: Thu, 31 Jul 2025 15:25:04 +0900 Subject: [PATCH 4/7] refactor: remove unused repository method --- .../experiment/ExperimentPostKeywordsLogGateway.kt | 2 -- .../experiment/ExperimentPostKeywordsLogGatewayImpl.kt | 9 --------- .../repository/ExperimentPostKeywordsLogRepository.kt | 2 -- 3 files changed, 13 deletions(-) diff --git a/domain/src/main/kotlin/com/dobby/gateway/experiment/ExperimentPostKeywordsLogGateway.kt b/domain/src/main/kotlin/com/dobby/gateway/experiment/ExperimentPostKeywordsLogGateway.kt index 934484fa..1a8b171c 100644 --- a/domain/src/main/kotlin/com/dobby/gateway/experiment/ExperimentPostKeywordsLogGateway.kt +++ b/domain/src/main/kotlin/com/dobby/gateway/experiment/ExperimentPostKeywordsLogGateway.kt @@ -1,9 +1,7 @@ package com.dobby.gateway.experiment import com.dobby.model.experiment.ExperimentPostKeywordsLog -import java.time.LocalDateTime interface ExperimentPostKeywordsLogGateway { fun save(experimentPostKeywordsLog: ExperimentPostKeywordsLog): ExperimentPostKeywordsLog - fun countByMemberIdAndCreatedAtBetween(memberId: String, start: LocalDateTime, end: LocalDateTime): Int } diff --git a/infrastructure/src/main/kotlin/com/dobby/external/gateway/experiment/ExperimentPostKeywordsLogGatewayImpl.kt b/infrastructure/src/main/kotlin/com/dobby/external/gateway/experiment/ExperimentPostKeywordsLogGatewayImpl.kt index 4e46d600..837bace8 100644 --- a/infrastructure/src/main/kotlin/com/dobby/external/gateway/experiment/ExperimentPostKeywordsLogGatewayImpl.kt +++ b/infrastructure/src/main/kotlin/com/dobby/external/gateway/experiment/ExperimentPostKeywordsLogGatewayImpl.kt @@ -5,7 +5,6 @@ import com.dobby.mapper.ExperimentPostKeywordsLogMapper import com.dobby.model.experiment.ExperimentPostKeywordsLog import com.dobby.persistence.repository.ExperimentPostKeywordsLogRepository import org.springframework.stereotype.Component -import java.time.LocalDateTime @Component class ExperimentPostKeywordsLogGatewayImpl( @@ -18,12 +17,4 @@ class ExperimentPostKeywordsLogGatewayImpl( val savedEntity = experimentPostKeywordsLogRepository.save(entity) return mapper.toDomain(savedEntity) } - - override fun countByMemberIdAndCreatedAtBetween( - memberId: String, - start: LocalDateTime, - end: LocalDateTime - ): Int { - return experimentPostKeywordsLogRepository.countByMemberIdAndCreatedAtBetween(memberId, start, end) - } } diff --git a/infrastructure/src/main/kotlin/com/dobby/persistence/repository/ExperimentPostKeywordsLogRepository.kt b/infrastructure/src/main/kotlin/com/dobby/persistence/repository/ExperimentPostKeywordsLogRepository.kt index 506c05c2..606ce183 100644 --- a/infrastructure/src/main/kotlin/com/dobby/persistence/repository/ExperimentPostKeywordsLogRepository.kt +++ b/infrastructure/src/main/kotlin/com/dobby/persistence/repository/ExperimentPostKeywordsLogRepository.kt @@ -2,8 +2,6 @@ package com.dobby.persistence.repository import com.dobby.persistence.entity.experiment.ExperimentPostKeywordsLogEntity import org.springframework.data.jpa.repository.JpaRepository -import java.time.LocalDateTime interface ExperimentPostKeywordsLogRepository : JpaRepository { - fun countByMemberIdAndCreatedAtBetween(memberId: String, start: LocalDateTime, end: LocalDateTime): Int } From 847d656268afa050eb09620b160f967f7482d586 Mon Sep 17 00:00:00 2001 From: jisu Date: Thu, 31 Jul 2025 15:26:24 +0900 Subject: [PATCH 5/7] style: apply ktlint format --- .../repository/ExperimentPostKeywordsLogRepository.kt | 3 +-- .../ExtractExperimentPostKeywordsConcurrencyTest.kt | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/infrastructure/src/main/kotlin/com/dobby/persistence/repository/ExperimentPostKeywordsLogRepository.kt b/infrastructure/src/main/kotlin/com/dobby/persistence/repository/ExperimentPostKeywordsLogRepository.kt index 606ce183..985d8928 100644 --- a/infrastructure/src/main/kotlin/com/dobby/persistence/repository/ExperimentPostKeywordsLogRepository.kt +++ b/infrastructure/src/main/kotlin/com/dobby/persistence/repository/ExperimentPostKeywordsLogRepository.kt @@ -3,5 +3,4 @@ package com.dobby.persistence.repository import com.dobby.persistence.entity.experiment.ExperimentPostKeywordsLogEntity import org.springframework.data.jpa.repository.JpaRepository -interface ExperimentPostKeywordsLogRepository : JpaRepository { -} +interface ExperimentPostKeywordsLogRepository : JpaRepository diff --git a/infrastructure/src/test/kotlin/com/dobby/concurrency/ExtractExperimentPostKeywordsConcurrencyTest.kt b/infrastructure/src/test/kotlin/com/dobby/concurrency/ExtractExperimentPostKeywordsConcurrencyTest.kt index c51405cf..d891a99b 100644 --- a/infrastructure/src/test/kotlin/com/dobby/concurrency/ExtractExperimentPostKeywordsConcurrencyTest.kt +++ b/infrastructure/src/test/kotlin/com/dobby/concurrency/ExtractExperimentPostKeywordsConcurrencyTest.kt @@ -21,10 +21,10 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.whenever -import org.mockito.junit.jupiter.MockitoExtension import org.springframework.boot.test.context.SpringBootTest import org.springframework.test.context.ActiveProfiles import java.time.LocalDateTime From 205821a55ab78cfc0a26703d286db00b3becff2b Mon Sep 17 00:00:00 2001 From: jisu Date: Thu, 31 Jul 2025 15:28:12 +0900 Subject: [PATCH 6/7] chore: remove unused index on `experiment_post_keywords_log` --- .../V202507311527__remove_idx_experiment_keywords_log.sql | 1 + 1 file changed, 1 insertion(+) create mode 100644 infrastructure/src/main/resources/db/migration/V202507311527__remove_idx_experiment_keywords_log.sql diff --git a/infrastructure/src/main/resources/db/migration/V202507311527__remove_idx_experiment_keywords_log.sql b/infrastructure/src/main/resources/db/migration/V202507311527__remove_idx_experiment_keywords_log.sql new file mode 100644 index 00000000..4b2dd8b8 --- /dev/null +++ b/infrastructure/src/main/resources/db/migration/V202507311527__remove_idx_experiment_keywords_log.sql @@ -0,0 +1 @@ +DROP INDEX idx_experiment_keywords_log ON experiment_post_keywords_log; From 0765a2a7cfb0d6abaeb34f5a8a9d8f991241553d Mon Sep 17 00:00:00 2001 From: jisu Date: Thu, 31 Jul 2025 15:30:52 +0900 Subject: [PATCH 7/7] chore: remove unused index on `experiment_post_keywords_log` --- .../V202507311527__remove_idx_experiment_keywords_log.sql | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/infrastructure/src/main/resources/db/migration/V202507311527__remove_idx_experiment_keywords_log.sql b/infrastructure/src/main/resources/db/migration/V202507311527__remove_idx_experiment_keywords_log.sql index 4b2dd8b8..9e41c1b6 100644 --- a/infrastructure/src/main/resources/db/migration/V202507311527__remove_idx_experiment_keywords_log.sql +++ b/infrastructure/src/main/resources/db/migration/V202507311527__remove_idx_experiment_keywords_log.sql @@ -1 +1,6 @@ +ALTER TABLE experiment_post_keywords_log DROP FOREIGN KEY fk_experiment_post_keywords_log_member; + DROP INDEX idx_experiment_keywords_log ON experiment_post_keywords_log; + +ALTER TABLE experiment_post_keywords_log +ADD CONSTRAINT fk_experiment_post_keywords_log_member FOREIGN KEY (member_id) REFERENCES member (member_id);