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 b2451b97..307153cc 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 6e71a11f..8b2f61eb 100644 --- a/application/src/test/kotlin/com/dobby/usecase/experiment/ExtractExperimentPostKeywordsUseCaseTest.kt +++ b/application/src/test/kotlin/com/dobby/usecase/experiment/ExtractExperimentPostKeywordsUseCaseTest.kt @@ -1,6 +1,7 @@ package com.dobby.usecase.experiment 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 @@ -20,12 +21,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 ) @@ -49,16 +52,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) @@ -79,13 +76,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 예외가 발생해야 한다") { 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/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/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/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()}" + } +} 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..985d8928 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,5 @@ 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 -} +interface ExperimentPostKeywordsLogRepository : JpaRepository 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..9e41c1b6 --- /dev/null +++ b/infrastructure/src/main/resources/db/migration/V202507311527__remove_idx_experiment_keywords_log.sql @@ -0,0 +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); 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..5cf8c9a6 --- /dev/null +++ b/infrastructure/src/test/kotlin/com/dobby/concurrency/ExtractExperimentPostKeywordsConcurrencyTest.kt @@ -0,0 +1,150 @@ +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.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.BeforeEach +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.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ActiveProfiles +import java.time.LocalDateTime +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) +// } +// } +// } +}