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
@@ -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
Expand All @@ -20,12 +21,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 @@ -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)
Expand All @@ -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 예외가 발생해야 한다") {
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

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,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

Comment on lines +1 to +29
Copy link

@coderabbitai coderabbitai bot Aug 2, 2025

Choose a reason for hiding this comment

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

⚠️ Potential issue

누락된 import 추가 필요

테스트 실행을 위해 필요한 import가 누락되어 있습니다.

 import com.dobby.util.IdGenerator
+import com.dobby.exception.experiment.ExperimentPostKeywordsDailyLimitExceededException
 import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
 import org.junit.jupiter.api.extension.ExtendWith

추가로 다음 import도 필요합니다:

 import java.util.concurrent.atomic.AtomicInteger
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+import kotlin.test.assertEquals
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
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 com.dobby.exception.experiment.ExperimentPostKeywordsDailyLimitExceededException
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.atomic.AtomicInteger
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+import kotlin.test.assertEquals
🤖 Prompt for AI Agents
In
infrastructure/src/test/kotlin/com/dobby/concurrency/ExtractExperimentPostKeywordsConcurrencyTest.kt
at the beginning of the file (lines 1 to 29), there are missing import
statements required for the test to run correctly. Add the necessary import
statements that are currently omitted to ensure all dependencies and annotations
used in the test class are properly imported.

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에서 자동완성 기능 개발을 완료할 때까지 사용 횟수 제한을 풀어달라고 요청을 해서 임시로 관련 기능/테스트 코드는 주석처리한 상태야~

Copy link

Choose a reason for hiding this comment

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

@Ji-soo708 아, 그런 상황이었군요! FE 팀의 자동완성 기능 개발 완료를 기다리는 동안 임시로 사용 횟수 제한을 비활성화한 거네요.

그렇다면 제가 지적한 missing import 문제는 현재로서는 해결할 필요가 없겠습니다. 테스트 코드가 주석처리되어 있는 상태에서는 해당 import들이 불필요하니까요.

나중에 FE 팀의 자동완성 기능 개발이 완료되어 사용 횟수 제한을 다시 활성화할 때, 그때 테스트 코드의 주석을 해제하시면서 필요한 import들을 추가하시면 될 것 같습니다.

임시 조치에 대한 명확한 설명 감사합니다! 🙂


🧠 Learnings used
Learnt from: Ji-soo708
PR: YAPP-Github/Gradmeet-BE#150
File: infrastructure/src/main/kotlin/com/dobby/external/prompt/ExperimentPostKeywordMapper.kt:52-58
Timestamp: 2025-07-17T08:06:16.021Z
Learning: AI가 텍스트에서 키워드를 추출하는 과정에서 enum 변환 실패(IllegalArgumentException)는 정상적인 상황에 가깝다. 따라서 ExperimentPostKeywordMapper에서 TimeSlot, MatchType 등의 enum 변환 실패 시 로깅을 추가하지 않는 것이 적절하다.

Learnt from: Ji-soo708
PR: YAPP-Github/Gradmeet-BE#154
File: application/src/main/kotlin/com/dobby/usecase/experiment/ExtractExperimentPostKeywordsUseCase.kt:33-46
Timestamp: 2025-07-27T12:26:47.814Z
Learning: Gradmeet-BE 프로젝트에서 하루 최대 2회 사용 제한이 있는 기능의 경우, unique constraint나 낙관적 락 대신 DB 조회 기반 검증 방식을 선호한다. 분산 락은 나중에 최적화가 필요할 때 고려한다.

Learnt from: Ji-soo708
PR: YAPP-Github/Gradmeet-BE#150
File: infrastructure/src/main/kotlin/com/dobby/external/feign/openAi/OpenAiFeignClient.kt:10-14
Timestamp: 2025-07-17T08:06:13.596Z
Learning: Ji-soo708의 팀은 현재 OpenAI 공식 API만 사용할 예정이므로, FeignClient URL을 외부화하지 않고 하드코딩하는 것을 선호한다. 팀의 요구사항에 따라 단순함을 우선시하는 접근 방식을 취한다.

Learnt from: chock-cho
PR: YAPP-Github/Gradmeet-BE#148
File: domain/src/main/kotlin/com/dobby/enums/University.kt:288-296
Timestamp: 2025-07-12T18:17:29.186Z
Learning: 사용자 chock-cho는 자동완성 API의 서버 부하를 줄이기 위해 FE와 합의하에 디바운싱 처리로 설계했고, 사용자가 입력하지 않은 상태에서의 의도치 않은 API 호출을 방지하기 위해 @NotBlank 어노테이션을 적용했다. 사용자 행동 패턴까지 고려한 세심한 BE 설계 접근을 보여준다.

Learnt from: Ji-soo708
PR: YAPP-Github/Gradmeet-BE#140
File: domain/src/main/kotlin/com/dobby/gateway/auth/GoogleAuthGateway.kt:7-7
Timestamp: 2025-06-04T09:02:40.657Z
Learning: Ji-soo708의 팀은 로컬 ktlint 기준과 GitHub ktlint 체크 기준이 다를 때, 기존 코드베이스와의 일관성을 위해 로컬 기준을 우선시한다.

Learnt from: Ji-soo708
PR: YAPP-Github/Gradmeet-BE#154
File: infrastructure/src/main/kotlin/com/dobby/mapper/ExperimentPostKeywordsLogMapper.kt:15-22
Timestamp: 2025-07-27T12:37:15.372Z
Learning: Ji-soo708의 팀에서는 JSON 역직렬화 시 예외를 catch하여 래핑하기보다는, 예외를 자연스럽게 상위로 전파시켜 원본 컨텍스트를 보존하고 후속 조치를 용이하게 하는 것을 선호한다.

@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, "테스트 유저", "[email protected]", "[email protected]", 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)
// }
// }
// }
Comment on lines +109 to +149
Copy link

@coderabbitai coderabbitai bot Aug 2, 2025

Choose a reason for hiding this comment

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

⚠️ Potential issue

주석 처리된 테스트 코드 활성화 필요

PR 목적이 동시성 문제 해결인데 관련 테스트가 주석 처리되어 있습니다. 모킹 기반 테스트라도 동시성 검증을 위해 활성화해야 합니다.

-//    @Test
-//    fun `동시에 여러 요청 시 최대 2번까지만 성공하고 나머지는 제한 예외가 발생해야 한다`() {
+    @Test
+    fun `동시에 여러 요청 시 최대 2번까지만 성공하고 나머지는 제한 예외가 발생해야 한다`() {

테스트 전체를 주석 해제하여 동시성 문제 해결을 검증할 수 있도록 해주세요.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In
infrastructure/src/test/kotlin/com/dobby/concurrency/ExtractExperimentPostKeywordsConcurrencyTest.kt
from lines 109 to 149, the entire concurrency test code is commented out, which
prevents verification of the concurrency issue fix. Remove the comment markers
to reactivate the test and ensure it runs during testing to validate the
concurrency behavior as intended.

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에서 자동완성 기능 개발을 완료할 때까지 사용 횟수 제한을 풀어달라고 요청을 해서 임시로 관련 기능/테스트 코드는 주석처리한 상태야~

Copy link

Choose a reason for hiding this comment

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

@Ji-soo708, 이해했습니다! FE 팀의 자동완성 기능 개발 완료를 기다리기 위해 임시로 사용 횟수 제한을 비활성화한 상태군요.

동시성 문제 해결을 위한 Redis INCR 로직은 구현되어 있고, 테스트 코드도 준비되어 있으니 FE 개발이 완료되면 주석을 해제하여 다시 활성화하면 되겠네요. 비즈니스 요구사항에 맞춘 합리적인 접근 방식입니다.


✏️ Learnings added
Learnt from: Ji-soo708
PR: YAPP-Github/Gradmeet-BE#162
File: infrastructure/src/test/kotlin/com/dobby/concurrency/ExtractExperimentPostKeywordsConcurrencyTest.kt:109-149
Timestamp: 2025-08-02T13:37:08.629Z
Learning: Gradmeet-BE 프로젝트에서 FE 팀의 자동완성 기능 개발 완료를 기다리기 위해 사용 횟수 제한 기능과 관련 테스트 코드를 임시로 주석 처리한 상태이다. 동시성 문제 해결을 위한 Redis INCR 로직은 구현되어 있고, FE 개발 완료 후 다시 활성화할 예정이다.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: Ji-soo708
PR: YAPP-Github/Gradmeet-BE#150
File: infrastructure/src/main/kotlin/com/dobby/external/prompt/ExperimentPostKeywordMapper.kt:52-58
Timestamp: 2025-07-17T08:06:16.021Z
Learning: AI가 텍스트에서 키워드를 추출하는 과정에서 enum 변환 실패(IllegalArgumentException)는 정상적인 상황에 가깝다. 따라서 ExperimentPostKeywordMapper에서 TimeSlot, MatchType 등의 enum 변환 실패 시 로깅을 추가하지 않는 것이 적절하다.

Learnt from: Ji-soo708
PR: YAPP-Github/Gradmeet-BE#154
File: application/src/main/kotlin/com/dobby/usecase/experiment/ExtractExperimentPostKeywordsUseCase.kt:33-46
Timestamp: 2025-07-27T12:26:47.814Z
Learning: Gradmeet-BE 프로젝트에서 하루 최대 2회 사용 제한이 있는 기능의 경우, unique constraint나 낙관적 락 대신 DB 조회 기반 검증 방식을 선호한다. 분산 락은 나중에 최적화가 필요할 때 고려한다.

Learnt from: chock-cho
PR: YAPP-Github/Gradmeet-BE#148
File: domain/src/main/kotlin/com/dobby/enums/University.kt:288-296
Timestamp: 2025-07-12T18:17:29.186Z
Learning: 사용자 chock-cho는 자동완성 API의 서버 부하를 줄이기 위해 FE와 합의하에 디바운싱 처리로 설계했고, 사용자가 입력하지 않은 상태에서의 의도치 않은 API 호출을 방지하기 위해 @NotBlank 어노테이션을 적용했다. 사용자 행동 패턴까지 고려한 세심한 BE 설계 접근을 보여준다.

Learnt from: Ji-soo708
PR: YAPP-Github/Gradmeet-BE#140
File: domain/src/main/kotlin/com/dobby/gateway/auth/GoogleAuthGateway.kt:7-7
Timestamp: 2025-06-04T09:02:40.657Z
Learning: Ji-soo708의 팀은 로컬 ktlint 기준과 GitHub ktlint 체크 기준이 다를 때, 기존 코드베이스와의 일관성을 위해 로컬 기준을 우선시한다.

Learnt from: Ji-soo708
PR: YAPP-Github/Gradmeet-BE#154
File: infrastructure/src/main/kotlin/com/dobby/mapper/ExperimentPostKeywordsLogMapper.kt:15-22
Timestamp: 2025-07-27T12:37:15.372Z
Learning: Ji-soo708의 팀에서는 JSON 역직렬화 시 예외를 catch하여 래핑하기보다는, 예외를 자연스럽게 상위로 전파시켜 원본 컨텍스트를 보존하고 후속 조치를 용이하게 하는 것을 선호한다.

}
Loading