Skip to content

Commit dc64f1c

Browse files
authored
[YS-532] refactor: 실험 공고 키워드 자동완성 동시성 문제 해결을 위한 Redis INCR 연산 로직 추가 (#162)
* feat: add Redis-based daily usage limit to handle concurrency issues * feat: integrate Redis-based usage limiter into usecase logic * test: add concurrency unit test for Redis-based daily usage limiter * refactor: remove unused repository method * style: apply ktlint format * chore: remove unused index on `experiment_post_keywords_log` * chore: remove unused index on `experiment_post_keywords_log`
1 parent 844a5be commit dc64f1c

File tree

10 files changed

+211
-41
lines changed

10 files changed

+211
-41
lines changed

application/src/main/kotlin/com/dobby/usecase/experiment/ExtractExperimentPostKeywordsUseCase.kt

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,19 @@ package com.dobby.usecase.experiment
22

33
import com.dobby.exception.ExperimentPostKeywordsDailyLimitExceededException
44
import com.dobby.gateway.OpenAiGateway
5+
import com.dobby.gateway.UsageLimitGateway
56
import com.dobby.gateway.experiment.ExperimentPostKeywordsLogGateway
67
import com.dobby.gateway.member.MemberGateway
78
import com.dobby.model.experiment.ExperimentPostKeywordsLog
89
import com.dobby.model.experiment.keyword.ExperimentPostKeywords
910
import com.dobby.usecase.UseCase
1011
import com.dobby.util.IdGenerator
11-
import com.dobby.util.TimeProvider
1212

1313
class ExtractExperimentPostKeywordsUseCase(
1414
private val openAiGateway: OpenAiGateway,
1515
private val experimentPostKeywordsGateway: ExperimentPostKeywordsLogGateway,
1616
private val memberGateway: MemberGateway,
17+
private val usageLimitGateway: UsageLimitGateway,
1718
private val idGenerator: IdGenerator
1819
) : UseCase<ExtractExperimentPostKeywordsUseCase.Input, ExtractExperimentPostKeywordsUseCase.Output> {
1920

@@ -46,17 +47,8 @@ class ExtractExperimentPostKeywordsUseCase(
4647
}
4748

4849
private fun validateDailyUsageLimit(memberId: String) {
49-
val today = TimeProvider.currentDateTime().toLocalDate()
50-
val startOfDay = today.atStartOfDay()
51-
val endOfDay = today.plusDays(1).atStartOfDay()
52-
53-
val todayUsageCount = experimentPostKeywordsGateway.countByMemberIdAndCreatedAtBetween(
54-
memberId = memberId,
55-
start = startOfDay,
56-
end = endOfDay
57-
)
58-
59-
if (todayUsageCount >= DAILY_USAGE_LIMIT) {
50+
val isAllowed = usageLimitGateway.incrementAndCheckLimit(memberId, DAILY_USAGE_LIMIT)
51+
if (!isAllowed) {
6052
throw ExperimentPostKeywordsDailyLimitExceededException
6153
}
6254
}

application/src/test/kotlin/com/dobby/usecase/experiment/ExtractExperimentPostKeywordsUseCaseTest.kt

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.dobby.usecase.experiment
22

33
import com.dobby.gateway.OpenAiGateway
4+
import com.dobby.gateway.UsageLimitGateway
45
import com.dobby.gateway.experiment.ExperimentPostKeywordsLogGateway
56
import com.dobby.gateway.member.MemberGateway
67
import com.dobby.model.experiment.ExperimentPostKeywordsLog
@@ -20,12 +21,14 @@ class ExtractExperimentPostKeywordsUseCaseTest : BehaviorSpec({
2021
val openAiGateway = mockk<OpenAiGateway>()
2122
val experimentPostKeywordsLogGateway = mockk<ExperimentPostKeywordsLogGateway>()
2223
val memberGateway = mockk<MemberGateway>()
24+
val usageLimitGateway = mockk<UsageLimitGateway>()
2325
val idGenerator = mockk<IdGenerator>()
2426

2527
val extractExperimentPostKeywordsUseCase = ExtractExperimentPostKeywordsUseCase(
2628
openAiGateway,
2729
experimentPostKeywordsLogGateway,
2830
memberGateway,
31+
usageLimitGateway,
2932
idGenerator
3033
)
3134

@@ -49,16 +52,10 @@ class ExtractExperimentPostKeywordsUseCaseTest : BehaviorSpec({
4952

5053
every { TimeProvider.currentDateTime() } returns currentDateTime
5154
every { memberGateway.getById(memberId) } returns mockMember
55+
every { usageLimitGateway.incrementAndCheckLimit(memberId, any()) } returns true
5256
every { idGenerator.generateId() } returns "test_log_id"
5357
every { openAiGateway.extractKeywords(inputText) } returns mockExperimentPostKeywords
5458
every { experimentPostKeywordsLogGateway.save(any()) } returns mockLog
55-
every {
56-
experimentPostKeywordsLogGateway.countByMemberIdAndCreatedAtBetween(
57-
memberId = memberId,
58-
start = currentDateTime.toLocalDate().atStartOfDay(),
59-
end = currentDateTime.toLocalDate().plusDays(1).atStartOfDay()
60-
)
61-
} returns 1
6259

6360
`when`("키워드 추출을 요청하면") {
6461
val result = extractExperimentPostKeywordsUseCase.execute(input)
@@ -79,13 +76,7 @@ class ExtractExperimentPostKeywordsUseCaseTest : BehaviorSpec({
7976
//
8077
// every { TimeProvider.currentDateTime() } returns currentDateTime
8178
// every { memberGateway.getById(memberId) } returns mockMember
82-
// every {
83-
// experimentPostKeywordsLogGateway.countByMemberIdAndCreatedAtBetween(
84-
// memberId = memberId,
85-
// start = currentDateTime.toLocalDate().atStartOfDay(),
86-
// end = currentDateTime.toLocalDate().plusDays(1).atStartOfDay()
87-
// )
88-
// } returns 2
79+
// every { usageLimitGateway.incrementAndCheckLimit(memberId, any()) } returns false
8980
//
9081
// `when`("키워드 추출을 요청하면") {
9182
// then("DailyLimitExceededException 예외가 발생해야 한다") {
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package com.dobby.gateway
2+
3+
interface UsageLimitGateway {
4+
fun incrementAndCheckLimit(memberId: String, dailyLimit: Int): Boolean
5+
}
Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
package com.dobby.gateway.experiment
22

33
import com.dobby.model.experiment.ExperimentPostKeywordsLog
4-
import java.time.LocalDateTime
54

65
interface ExperimentPostKeywordsLogGateway {
76
fun save(experimentPostKeywordsLog: ExperimentPostKeywordsLog): ExperimentPostKeywordsLog
8-
fun countByMemberIdAndCreatedAtBetween(memberId: String, start: LocalDateTime, end: LocalDateTime): Int
97
}

infrastructure/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ dependencies {
6767

6868
testImplementation("org.springframework.boot:spring-boot-starter-test")
6969
testImplementation("io.mockk:mockk:1.13.10")
70+
testImplementation("org.mockito.kotlin:mockito-kotlin:4.1.0")
7071
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
7172
testImplementation("org.springframework.security:spring-security-test")
7273
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.dobby.external.gateway.cache
2+
3+
import com.dobby.gateway.UsageLimitGateway
4+
import org.slf4j.LoggerFactory
5+
import org.springframework.core.env.Environment
6+
import org.springframework.data.redis.core.RedisTemplate
7+
import org.springframework.stereotype.Component
8+
import java.time.Duration
9+
import java.time.LocalDate
10+
import java.time.LocalDateTime
11+
import java.util.concurrent.TimeUnit
12+
13+
@Component
14+
class RedisUsageLimitGatewayImpl(
15+
private val redisTemplate: RedisTemplate<String, String>,
16+
private val environment: Environment
17+
) : UsageLimitGateway {
18+
19+
private val logger = LoggerFactory.getLogger(this::class.java)
20+
21+
override fun incrementAndCheckLimit(memberId: String, dailyLimit: Int): Boolean {
22+
val key = getCacheKey(memberId)
23+
val count = redisTemplate.opsForValue().increment(key, 1) ?: 1L
24+
25+
if (count == 1L) {
26+
val expireSeconds = Duration.between(LocalDateTime.now(), LocalDate.now().plusDays(1).atStartOfDay()).seconds
27+
redisTemplate.expire(key, expireSeconds, TimeUnit.SECONDS)
28+
}
29+
30+
logger.debug("Usage count for key=$key is $count")
31+
32+
return count <= dailyLimit
33+
}
34+
35+
private fun getCacheKey(memberId: String): String {
36+
val activeProfile = environment.activeProfiles.firstOrNull() ?: "local"
37+
return "$activeProfile:usage:$memberId:${LocalDate.now()}"
38+
}
39+
}

infrastructure/src/main/kotlin/com/dobby/external/gateway/experiment/ExperimentPostKeywordsLogGatewayImpl.kt

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import com.dobby.mapper.ExperimentPostKeywordsLogMapper
55
import com.dobby.model.experiment.ExperimentPostKeywordsLog
66
import com.dobby.persistence.repository.ExperimentPostKeywordsLogRepository
77
import org.springframework.stereotype.Component
8-
import java.time.LocalDateTime
98

109
@Component
1110
class ExperimentPostKeywordsLogGatewayImpl(
@@ -18,12 +17,4 @@ class ExperimentPostKeywordsLogGatewayImpl(
1817
val savedEntity = experimentPostKeywordsLogRepository.save(entity)
1918
return mapper.toDomain(savedEntity)
2019
}
21-
22-
override fun countByMemberIdAndCreatedAtBetween(
23-
memberId: String,
24-
start: LocalDateTime,
25-
end: LocalDateTime
26-
): Int {
27-
return experimentPostKeywordsLogRepository.countByMemberIdAndCreatedAtBetween(memberId, start, end)
28-
}
2920
}

infrastructure/src/main/kotlin/com/dobby/persistence/repository/ExperimentPostKeywordsLogRepository.kt

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,5 @@ package com.dobby.persistence.repository
22

33
import com.dobby.persistence.entity.experiment.ExperimentPostKeywordsLogEntity
44
import org.springframework.data.jpa.repository.JpaRepository
5-
import java.time.LocalDateTime
65

7-
interface ExperimentPostKeywordsLogRepository : JpaRepository<ExperimentPostKeywordsLogEntity, String> {
8-
fun countByMemberIdAndCreatedAtBetween(memberId: String, start: LocalDateTime, end: LocalDateTime): Int
9-
}
6+
interface ExperimentPostKeywordsLogRepository : JpaRepository<ExperimentPostKeywordsLogEntity, String>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
ALTER TABLE experiment_post_keywords_log DROP FOREIGN KEY fk_experiment_post_keywords_log_member;
2+
3+
DROP INDEX idx_experiment_keywords_log ON experiment_post_keywords_log;
4+
5+
ALTER TABLE experiment_post_keywords_log
6+
ADD CONSTRAINT fk_experiment_post_keywords_log_member FOREIGN KEY (member_id) REFERENCES member (member_id);
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package com.dobby.concurrency
2+
3+
import com.dobby.enums.MatchType
4+
import com.dobby.enums.experiment.TimeSlot
5+
import com.dobby.enums.member.GenderType
6+
import com.dobby.enums.member.MemberStatus
7+
import com.dobby.enums.member.ProviderType
8+
import com.dobby.enums.member.RoleType
9+
import com.dobby.gateway.OpenAiGateway
10+
import com.dobby.gateway.UsageLimitGateway
11+
import com.dobby.gateway.experiment.ExperimentPostKeywordsLogGateway
12+
import com.dobby.gateway.member.MemberGateway
13+
import com.dobby.model.experiment.keyword.ApplyMethodKeyword
14+
import com.dobby.model.experiment.keyword.ExperimentPostKeywords
15+
import com.dobby.model.experiment.keyword.TargetGroupKeyword
16+
import com.dobby.model.member.Member
17+
import com.dobby.usecase.experiment.ExtractExperimentPostKeywordsUseCase
18+
import com.dobby.util.IdGenerator
19+
import org.junit.jupiter.api.BeforeEach
20+
import org.junit.jupiter.api.extension.ExtendWith
21+
import org.mockito.junit.jupiter.MockitoExtension
22+
import org.mockito.kotlin.any
23+
import org.mockito.kotlin.mock
24+
import org.mockito.kotlin.whenever
25+
import org.springframework.boot.test.context.SpringBootTest
26+
import org.springframework.test.context.ActiveProfiles
27+
import java.time.LocalDateTime
28+
import java.util.concurrent.atomic.AtomicInteger
29+
30+
@ExtendWith(MockitoExtension::class)
31+
@SpringBootTest
32+
@ActiveProfiles("test")
33+
class ExtractExperimentPostKeywordsConcurrencyTest {
34+
35+
companion object {
36+
private const val THREAD_COUNT = 5
37+
private const val DAILY_LIMIT = 2
38+
}
39+
40+
private lateinit var useCase: ExtractExperimentPostKeywordsUseCase
41+
42+
private val openAiGateway = mock<OpenAiGateway>()
43+
private val experimentPostKeywordsGateway = mock<ExperimentPostKeywordsLogGateway>()
44+
private val memberGateway = mock<MemberGateway>()
45+
private val usageLimitGateway = mock<UsageLimitGateway>()
46+
private val idGenerator = object {
47+
private var id = 0L
48+
fun generateId() = (++id).toString()
49+
}
50+
51+
private val memberId = "test-user"
52+
private val text = "이 실험은 집중력과 관련된 실험입니다."
53+
54+
@BeforeEach
55+
fun setup() {
56+
whenever(memberGateway.getById(any())).thenReturn(
57+
Member(
58+
memberId, "테스트 유저", "dlawltn123@naver.com", "dlawltn456@naver.com", ProviderType.NAVER,
59+
MemberStatus.ACTIVE, RoleType.RESEARCHER, LocalDateTime.now(), LocalDateTime.now(), null
60+
)
61+
)
62+
63+
val sampleKeywords = ExperimentPostKeywords(
64+
targetGroup = TargetGroupKeyword(
65+
startAge = 18,
66+
endAge = 30,
67+
genderType = GenderType.ALL,
68+
otherCondition = "건강한 대학생 및 직장인 대상"
69+
),
70+
applyMethod = ApplyMethodKeyword(
71+
content = "온라인 설문 작성 및 전화 인터뷰 가능",
72+
isFormUrl = true,
73+
formUrl = "https://example.com/survey-form",
74+
isPhoneNum = true,
75+
phoneNum = "010-1234-5678"
76+
),
77+
matchType = MatchType.ALL,
78+
reward = "실험 참여 시 2만원 상당 상품권 지급",
79+
count = 50,
80+
timeRequired = TimeSlot.ABOUT_1H
81+
)
82+
whenever(openAiGateway.extractKeywords(any())).thenReturn(sampleKeywords)
83+
84+
val usageCount = AtomicInteger(0)
85+
86+
whenever(experimentPostKeywordsGateway.save(any())).thenAnswer {
87+
usageCount.incrementAndGet()
88+
null
89+
}
90+
91+
val callCount = AtomicInteger(0)
92+
whenever(usageLimitGateway.incrementAndCheckLimit(any(), any())).thenAnswer {
93+
val dailyLimit = it.getArgument<Int>(1)
94+
val current = callCount.incrementAndGet()
95+
current <= dailyLimit
96+
}
97+
98+
useCase = ExtractExperimentPostKeywordsUseCase(
99+
openAiGateway,
100+
experimentPostKeywordsGateway,
101+
memberGateway,
102+
usageLimitGateway,
103+
idGenerator = object : IdGenerator {
104+
override fun generateId(): String = idGenerator.generateId()
105+
}
106+
)
107+
}
108+
109+
// @Test
110+
// fun `동시에 여러 요청 시 최대 2번까지만 성공하고 나머지는 제한 예외가 발생해야 한다`() {
111+
// val executor = Executors.newFixedThreadPool(THREAD_COUNT)
112+
//
113+
// val successCount = mutableListOf<Unit>()
114+
// val failCount = mutableListOf<Unit>()
115+
// val lock = Any()
116+
//
117+
// repeat(THREAD_COUNT) {
118+
// executor.submit {
119+
// executeKeywordExtraction(successCount, failCount, lock)
120+
// }
121+
// }
122+
//
123+
// executor.shutdown()
124+
// val finished = executor.awaitTermination(10, TimeUnit.SECONDS)
125+
// if (!finished) {
126+
// throw RuntimeException("Thread pool shutdown timeout occurred")
127+
// }
128+
//
129+
// assertEquals(DAILY_LIMIT, successCount.size)
130+
// assertEquals(THREAD_COUNT - DAILY_LIMIT, failCount.size)
131+
// }
132+
//
133+
// private fun executeKeywordExtraction(
134+
// successCount: MutableList<Unit>,
135+
// failCount: MutableList<Unit>,
136+
// lock: Any
137+
// ) {
138+
// try {
139+
// val input = ExtractExperimentPostKeywordsUseCase.Input(memberId, text)
140+
// useCase.execute(input)
141+
// synchronized(lock) {
142+
// successCount.add(Unit)
143+
// }
144+
// } catch (e: ExperimentPostKeywordsDailyLimitExceededException) {
145+
// synchronized(lock) {
146+
// failCount.add(Unit)
147+
// }
148+
// }
149+
// }
150+
}

0 commit comments

Comments
 (0)