Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExtractExperimentPostKeywordsUseCase.Input, ExtractExperimentPostKeywordsUseCase.Output> {

Expand Down Expand Up @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,12 +23,14 @@ class ExtractExperimentPostKeywordsUseCaseTest : BehaviorSpec({
val openAiGateway = mockk<OpenAiGateway>()
val experimentPostKeywordsLogGateway = mockk<ExperimentPostKeywordsLogGateway>()
val memberGateway = mockk<MemberGateway>()
val usageLimitGateway = mockk<UsageLimitGateway>()
val idGenerator = mockk<IdGenerator>()

val extractExperimentPostKeywordsUseCase = ExtractExperimentPostKeywordsUseCase(
openAiGateway,
experimentPostKeywordsLogGateway,
memberGateway,
usageLimitGateway,
idGenerator
)

Expand All @@ -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)
Expand All @@ -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 예외가 발생해야 한다") {
Expand Down
5 changes: 5 additions & 0 deletions domain/src/main/kotlin/com/dobby/gateway/UsageLimitGateway.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.dobby.gateway

interface UsageLimitGateway {
fun incrementAndCheckLimit(memberId: String, dailyLimit: Int): Boolean
}
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions infrastructure/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, String>,
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
}
Comment on lines +21 to +33
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

동시성 문제 해결을 위해 비관적/낙관적 락과 분산 락을 고려할 수 있었으나, Redis의 INCR 연산을 선택한 이유는 다음과 같습니다.

  1. 비관적 락과 낙관적 락은 주로 UPDATE 작업에서 발생하는 동시성 이슈에 적합합니다.
  2. 분산 락은 단순히 사용자 사용 횟수를 제어하는 데에는 과도한 비용과 복잡성을 가져온다고 생각합니다.

INCR 연산은 Redis 내부에서 단일 명령으로 처리되므로 여러 요청이 동시에 실행되어도 정확한 카운트가 보장되어 race condition을 방지할 수 있습니다. 그래서 별도의 락 없이도 안전하게 사용 횟수를 제한할 수 있어 효율적입니다.

지금처럼 단순한 로직에 최소 비용으로 동시성 문제를 해결하기 위해, Redis의 INCR 연산을 통한 원자적 처리가 가장 적합하다고 판단했습니다!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redis의 INCR 연산은 해당 작업을 통해 처음 공부해봤는데요, 지수님 덕분에 좋은 지식을 많이 얻어가네요!


private fun getCacheKey(memberId: String): String {
val activeProfile = environment.activeProfiles.firstOrNull() ?: "local"
return "$activeProfile:usage:$memberId:${LocalDate.now()}"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExperimentPostKeywordsLogEntity, String> {
fun countByMemberIdAndCreatedAtBetween(memberId: String, start: LocalDateTime, end: LocalDateTime): Int
}
interface ExperimentPostKeywordsLogRepository : JpaRepository<ExperimentPostKeywordsLogEntity, String>
Original file line number Diff line number Diff line change
@@ -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;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

기존 COUNT 쿼리를 사용하지 않게 되어, 매번 추가되는 행에 대해 인덱스를 생성하는 것이 불필요한 오버헤드로 작용한다고 판단하여 해당 인덱스를 삭제했습니다~


ALTER TABLE experiment_post_keywords_log
ADD CONSTRAINT fk_experiment_post_keywords_log_member FOREIGN KEY (member_id) REFERENCES member (member_id);
Copy link
Member Author

@Ji-soo708 Ji-soo708 Jul 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 테스트 코드는 모킹을 통해 동시성 이슈 해결 여부를 확인하는 테스트입니다. 실제 Redis와 연결해 테스트하는 것이 더 정확하지만, 테스트 시간이 길고 무거워서 로컬에서만 통합 테스트를 진행했습니다. 그래서 실제 통합 테스트 코드는 삭제하고 모킹 기반 테스트 코드만 유지했습니다. 🥲

실제 통합 테스트 진행 내용은 블로그에 참고용으로 올렸습니다!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FE에서 자동완성 기능 개발을 완료할 때까지 사용 횟수 제한을 풀어달라고 요청을 해서 임시로 관련 기능/테스트 코드는 주석처리했습니다!

Original file line number Diff line number Diff line change
@@ -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.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.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<OpenAiGateway>()
private val experimentPostKeywordsGateway = mock<ExperimentPostKeywordsLogGateway>()
private val memberGateway = mock<MemberGateway>()
private val usageLimitGateway = mock<UsageLimitGateway>()
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<Int>(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<Unit>()
val failCount = mutableListOf<Unit>()
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<Unit>,
failCount: MutableList<Unit>,
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)
}
}
}
}
Loading