-
Notifications
You must be signed in to change notification settings - Fork 0
[YS-532] refactor: 실험 공고 키워드 자동완성 동시성 문제 해결을 위한 Redis INCR 연산 로직 추가 #162
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 7 commits
84e38e2
2c8429a
0cd1fd2
489af8e
847d656
205821a
0765a2a
589dfd8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| } |
| 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 | ||
| } | ||
|
|
||
| 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 |
|---|---|---|
| @@ -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; | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 기존 |
||
|
|
||
| ALTER TABLE experiment_post_keywords_log | ||
| ADD CONSTRAINT fk_experiment_post_keywords_log_member FOREIGN KEY (member_id) REFERENCES member (member_id); | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 해당 테스트 코드는 모킹을 통해 동시성 이슈 해결 여부를 확인하는 테스트입니다. 실제 Redis와 연결해 테스트하는 것이 더 정확하지만, 테스트 시간이 길고 무거워서 로컬에서만 통합 테스트를 진행했습니다. 그래서 실제 통합 테스트 코드는 삭제하고 모킹 기반 테스트 코드만 유지했습니다. 🥲 실제 통합 테스트 진행 내용은 블로그에 참고용으로 올렸습니다!
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
동시성 문제 해결을 위해 비관적/낙관적 락과 분산 락을 고려할 수 있었으나, Redis의 INCR 연산을 선택한 이유는 다음과 같습니다.
INCR 연산은 Redis 내부에서 단일 명령으로 처리되므로 여러 요청이 동시에 실행되어도 정확한 카운트가 보장되어 race condition을 방지할 수 있습니다. 그래서 별도의 락 없이도 안전하게 사용 횟수를 제한할 수 있어 효율적입니다.
지금처럼 단순한 로직에 최소 비용으로 동시성 문제를 해결하기 위해, Redis의 INCR 연산을 통한 원자적 처리가 가장 적합하다고 판단했습니다!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Redis의 INCR 연산은 해당 작업을 통해 처음 공부해봤는데요, 지수님 덕분에 좋은 지식을 많이 얻어가네요!