Skip to content

[REFACTOR] 동시성 검증#326

Merged
unifolio0 merged 5 commits intodevelopfrom
refactor/#319
Feb 12, 2026
Merged

[REFACTOR] 동시성 검증#326
unifolio0 merged 5 commits intodevelopfrom
refactor/#319

Conversation

@unifolio0
Copy link
Contributor

closed #319

작업 내용

스크린샷

참고 사항

@unifolio0 unifolio0 self-assigned this Feb 12, 2026
@coderabbitai
Copy link

coderabbitai bot commented Feb 12, 2026

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 분산 락 도입 및 Redis 기반 락 관리로 동시성 제어 강화
    • 토큰 구매 후 유료 토큰 계정 반영
  • 버그 수정

    • 소셜 로그인 중 중복 생성 예외 처리 개선
    • 인터뷰 진행 흐름의 락 안정성 및 예외 복구 강화
    • 인터뷰 조회 집계 동기화 안정화
  • 테스트

    • 토큰 동시성 검증용 통합 테스트 추가

Walkthrough

Redisson 기반 분산 락 인프라와 AOP를 도입하고 RedisService를 Redisson으로 전환했습니다. 토큰·인터뷰·이력서 관련 서비스에 락과 트랜잭션 격리를 적용하고, 테스트 인프라에 RedissonClient 스파이 및 동시성 테스트를 추가했습니다.

Changes

Cohort / File(s) Summary
빌드·의존성
api/build.gradle, common/build.gradle
Redisson (org.redisson:redisson:3.52.0) 의존성 추가 (api 테스트용 및 common).
분산 락 애너테이션 및 AOP
common/src/main/java/com/samhap/kokomen/global/annotation/DistributedLock.java, common/src/main/java/com/samhap/kokomen/global/aop/DistributedLockAspect.java
@DistributedLock 애너테이션 추가 및 SpEL 기반 키 해석과 Redisson으로 락 획득/해제하는 AOP 구현 추가.
Redisson 설정
common/src/main/java/com/samhap/kokomen/global/config/RedissonConfig.java
RedissonClient 빈(redissonClient) 추가, Redis 호스트/포트 구성 주입.
RedisService 리팩토링
common/src/main/java/com/samhap/kokomen/global/service/RedisService.java
RedisTemplate → Redisson 전환, acquireLockWithValue, releaseLockSafely 추가, scanKeys 반환 타입 Cursor→Iterable 변경, 기타 RBucket/RAtomicLong 사용으로 구현 변경.
인터뷰 락 값 전파
api/src/main/java/com/samhap/kokomen/interview/service/InterviewFacadeService.java, api/src/main/java/com/samhap/kokomen/interview/service/InterviewProceedBedrockFlowAsyncService.java
락 획득 시 UUID 기반 lockValue 생성 및 전파, 락 획득 API 변경, 안전한 락 해제(releaseLockSafely)로 전환, 비동기 흐름 시 lockValue 파라미터 추가.
인터뷰 스케줄러·이력서 서비스
api/src/main/java/com/samhap/kokomen/interview/service/InterviewSchedulerService.java, api/src/main/java/com/samhap/kokomen/interview/service/ResumeBasedInterviewService.java
syncInterviewViewCounts 시그니처(Cursor→Iterable) 변경, submitQuestionGeneration@DistributedLock@Transactional(REQUIRES_NEW) 적용.
토큰 서비스 동시성 제어
api/src/main/java/com/samhap/kokomen/token/service/TokenFacadeService.java, api/src/main/java/com/samhap/kokomen/token/service/TokenFacadeServiceConcurrencyTest.java
purchaseTokens, useToken, useTokens, refundTokens@DistributedLock@Transactional(propagation = Propagation.REQUIRES_NEW) 추가. purchaseTokenstokenService.addPaidTokens 호출 추가. 동시성 테스트 클래스 신규 추가.
멤버·인증 변경
api/src/main/java/com/samhap/kokomen/auth/service/AuthService.java, api/src/main/java/com/samhap/kokomen/member/service/MemberService.java
AuthService: TokenService 의존성 제거 및 소셜 멤버 생성 시 DataIntegrityViolationException 처리 추가. MemberService.saveSocialMember에 트랜잭션 REQUIRES_NEW 및 토큰 생성 호출 이동.
테스트 인프라 업데이트
api/src/test/java/com/samhap/kokomen/global/BaseTest.java, api/src/test/java/com/samhap/kokomen/interview/service/InterviewViewCountServiceTest.java
BaseTest에 RedissonClient Mockito SpyBean 추가. InterviewViewCountServiceTest에서 Redis 관련 목 대상 변경(redisTemplate → redissonClient).

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant InterviewFacadeService
    participant RedisService
    participant InterviewProceedBedrockFlowAsyncService
    participant RedissonClient

    Client->>InterviewFacadeService: proceedInterview(memberId, qAndA, interviewId)
    InterviewFacadeService->>InterviewFacadeService: lockValue = UUID.randomUUID()
    InterviewFacadeService->>RedisService: acquireLockWithValue(lockKey, lockValue, ttl)
    RedisService->>RedissonClient: setIfAbsent(lockKey, lockValue, ttl)
    alt Lock Acquired
        RedissonClient-->>RedisService: success
        RedisService-->>InterviewFacadeService: lock acquired
        InterviewFacadeService->>InterviewProceedBedrockFlowAsyncService: proceedInterviewByBedrockFlowAsync(..., lockValue)
        InterviewProceedBedrockFlowAsyncService->>InterviewProceedBedrockFlowAsyncService: process with lockValue
        alt Success or Failure
            InterviewProceedBedrockFlowAsyncService->>RedisService: releaseLockSafely(lockKey, lockValue)
            RedisService->>RedissonClient: eval(luaScript, key, expectedValue)
            RedissonClient-->>RedisService: deleted if match
        end
    else Lock Not Acquired
        RedissonClient-->>RedisService: failed
        RedisService-->>InterviewFacadeService: lock not acquired
        InterviewFacadeService-->>Client: error / retry response
    end
Loading
sequenceDiagram
    participant Client
    participant TokenFacadeService
    participant DistributedLockAspect
    participant RedissonClient
    participant TokenRepository
    participant Database

    Client->>TokenFacadeService: useToken(memberId)
    TokenFacadeService->>DistributedLockAspect: intercepted by `@DistributedLock`
    DistributedLockAspect->>RedissonClient: tryLock(resolvedKey, waitTime, leaseTime)
    alt Lock Acquired
        RedissonClient-->>DistributedLockAspect: locked
        DistributedLockAspect->>TokenFacadeService: proceed()
        TokenFacadeService->>TokenRepository: read/update token
        TokenRepository->>Database: SELECT/UPDATE
        Database-->>TokenRepository: result
        TokenRepository-->>TokenFacadeService: updated entity
        DistributedLockAspect->>RedissonClient: unlock()
        RedissonClient-->>DistributedLockAspect: unlocked
        TokenFacadeService-->>Client: success
    else Lock Not Acquired
        RedissonClient-->>DistributedLockAspect: lock denied
        DistributedLockAspect-->>TokenFacadeService: throw BadRequestException
        TokenFacadeService-->>Client: error
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • prod 배포 #325: Redisson 기반 분산 락 인프라 및 RedisService/인터뷰 흐름 관련 변경사항이 겹치는 PR.

Suggested reviewers

  • nak-honest
  • kargowild

Poem

🐰 락을 쥔 작은 토끼가 말하네,
UUID 손에 들고 줄을 섰네,
Redisson 숲에서 동시성 춤추며,
토큰도 인터뷰도 조용히 흘러가네,
문제가 있던 날들은 이제 안녕~ ✨

🚥 Pre-merge checks | ✅ 3 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Linked Issues check ❓ Inconclusive 코드 변경 사항은 분산 락(DistributedLock) 구현, Redisson 마이그레이션, 동시성 제어 강화 등 동시성 검증 목표를 달성하고 있으나, 링크된 이슈에 명시적인 요구사항이 없어 완전히 검증하기 어렵습니다. 이슈 #319의 상세한 요구사항이나 인수 기준을 확인하여 모든 구현이 요구사항을 충족하는지 재검증하십시오.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목은 '동시성 검증'이라는 리팩토링 작업을 명확하게 설명하고 있으며, 코드 변경 사항의 주요 목적(분산 락 도입, 동시성 제어)을 잘 반영하고 있습니다.
Description check ✅ Passed PR 설명은 #319 이슈를 참조하고 있으며, 작업 내용과 참고 사항 섹션을 통해 리팩토링의 맥락을 제공하고 있습니다.
Out of Scope Changes check ✅ Passed 모든 변경 사항(분산 락 추가, Redisson 마이그레이션, 동시성 제어 강화, 테스트 추가)이 동시성 검증이라는 PR 목표와 일관성 있게 관련되어 있습니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch refactor/#319

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist
Copy link

Summary of Changes

Hello @unifolio0, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

이 PR은 애플리케이션의 동시성 문제를 해결하기 위해 Redisson 기반의 분산 락을 도입하고 기존 Redis 관련 로직을 리팩토링합니다. 특히 소셜 로그인 회원 가입, 인터뷰 진행, 토큰 구매/사용/환불, 이력서 기반 질문 생성 등 여러 핵심 기능에 분산 락을 적용하여 데이터 일관성을 보장하고 동시 요청으로 인한 오류를 방지합니다.

Highlights

  • Redisson 도입 및 분산 락 구현: 애플리케이션 전반의 동시성 문제를 해결하기 위해 Redisson 라이브러리를 도입하고, @DistributedLock 커스텀 어노테이션과 AOP를 활용한 분산 락 시스템을 구축했습니다. 이를 통해 Redis 기반의 락 획득 및 해제 로직을 표준화하고 안전성을 강화했습니다.
  • RedisService Redisson 기반으로 리팩토링: 기존 RedisTemplate을 사용하던 RedisService의 모든 기능을 RedissonClient 기반으로 전환했습니다. 락 획득 시 고유한 lockValue를 사용하여 락을 획득한 스레드만이 락을 해제할 수 있도록 releaseLockSafely 메서드를 추가하여 락의 안정성을 높였습니다.
  • 주요 비즈니스 로직에 동시성 제어 적용: 소셜 로그인 회원 가입, 인터뷰 진행, 토큰 구매/사용/환불, 이력서 기반 질문 생성 등 동시성 문제가 발생할 수 있는 핵심 비즈니스 로직에 분산 락을 적용했습니다. 특히 소셜 로그인 시 DataIntegrityViolationException 발생 시 재조회 로직을 추가하여 동시 가입 문제를 처리했습니다.
  • 토큰 사용 동시성 테스트 추가: 토큰 사용 기능의 동시성 문제를 검증하기 위한 전용 테스트(TokenFacadeServiceConcurrencyTest.java)를 추가하여, 여러 스레드가 동시에 토큰을 사용하거나 일괄 사용하는 시나리오에서 정확하게 토큰이 차감되는지 확인했습니다.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • api/build.gradle
    • Redisson 테스트 의존성 'org.redisson:redisson'을 추가했습니다.
  • api/src/main/java/com/samhap/kokomen/auth/service/AuthService.java
    • TokenService 의존성을 제거했습니다.
    • DataIntegrityViolationException 발생 시 기존 회원 조회 로직을 추가하여 소셜 로그인 동시 가입 문제를 처리했습니다.
    • 소셜 로그인 시 토큰 생성 로직을 MemberService로 이동했습니다.
  • api/src/main/java/com/samhap/kokomen/interview/service/InterviewFacadeService.java
    • UUID import를 추가했습니다.
    • 인터뷰 진행 시 분산 락 획득 및 해제 로직을 acquireLockWithValuereleaseLockSafely로 변경했습니다.
  • api/src/main/java/com/samhap/kokomen/interview/service/InterviewProceedBedrockFlowAsyncService.java
    • 인터뷰 진행 비동기 메서드에 lockValue 파라미터를 추가했습니다.
    • 락 해제 로직을 releaseLockSafely로 변경하여 안전한 락 해제를 구현했습니다.
  • api/src/main/java/com/samhap/kokomen/interview/service/InterviewSchedulerService.java
    • 더 이상 사용되지 않는 org.springframework.data.redis.core.Cursor import를 제거했습니다.
    • redisService.scanKeys의 반환 타입을 Iterable<String>으로 변경했습니다.
  • api/src/main/java/com/samhap/kokomen/interview/service/ResumeBasedInterviewService.java
    • @DistributedLock 어노테이션을 추가하여 이력서 기반 질문 생성 시 동시성 제어를 적용했습니다.
    • 트랜잭션 전파 속성을 Propagation.REQUIRES_NEW로 설정했습니다.
  • api/src/main/java/com/samhap/kokomen/member/service/MemberService.java
    • jakarta.transaction.Transactional import를 제거하고 org.springframework.transaction.annotation.Transactional을 사용하도록 변경했습니다.
    • 트랜잭션 전파 속성을 Propagation.REQUIRES_NEW로 설정했습니다.
    • saveSocialMember 메서드에서 회원 가입 시 토큰 생성 로직을 추가했습니다.
  • api/src/main/java/com/samhap/kokomen/token/service/TokenFacadeService.java
    • @DistributedLock 어노테이션을 추가하여 토큰 구매, 사용, 환불 관련 메서드에 동시성 제어를 적용했습니다.
    • 트랜잭션 전파 속성을 Propagation.REQUIRES_NEW로 설정했습니다.
    • useToken 메서드의 TODO 주석을 제거했습니다.
  • api/src/test/java/com/samhap/kokomen/global/BaseTest.java
    • RedissonClient를 MockitoSpyBean으로 추가했습니다.
  • api/src/test/java/com/samhap/kokomen/interview/service/InterviewViewCountServiceTest.java
    • Redis 예외 처리 테스트에서 redisTemplate.opsForValue() 대신 redissonClient.getBucket(anyString(), any())을 Mocking하도록 수정했습니다.
  • api/src/test/java/com/samhap/kokomen/token/service/TokenFacadeServiceConcurrencyTest.java
    • 토큰 사용 동시성 테스트를 위한 새 파일을 추가했습니다.
  • common/build.gradle
    • Redisson 라이브러리 의존성 'org.redisson:redisson'을 추가했습니다.
  • common/src/main/java/com/samhap/kokomen/global/annotation/DistributedLock.java
    • 분산 락을 위한 커스텀 어노테이션 @DistributedLock을 정의했습니다.
  • common/src/main/java/com/samhap/kokomen/global/aop/DistributedLockAspect.java
    • @DistributedLock 어노테이션을 처리하는 AOP 로직을 구현했습니다.
  • common/src/main/java/com/samhap/kokomen/global/config/RedissonConfig.java
    • Redisson 클라이언트 설정을 위한 구성 클래스를 추가했습니다.
  • common/src/main/java/com/samhap/kokomen/global/service/RedisService.java
    • RedisTemplate 대신 RedissonClient를 사용하도록 RedisService를 리팩토링했습니다.
    • acquireLockWithValuereleaseLockSafely 등 새로운 락 관련 메서드를 추가했습니다.
Activity
  • unifolio0 님이 PR을 생성했습니다.
  • unifolio0 님이 여러 파일에 걸쳐 Redisson 기반의 분산 락을 도입하고 기존 Redis 로직을 리팩토링했습니다.
  • unifolio0 님이 소셜 로그인, 인터뷰 진행, 토큰 관리 등 주요 비즈니스 로직에 동시성 제어를 적용했습니다.
  • unifolio0 님이 토큰 사용 동시성 테스트 코드를 추가했습니다.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.


락 걸어 안전히, 동시성 문제 사라져, 코드 흐름 평화.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@github-actions
Copy link

github-actions bot commented Feb 12, 2026

Test Results

 41 files   41 suites   1m 7s ⏱️
231 tests 230 ✅ 1 💤 0 ❌
233 runs  232 ✅ 1 💤 0 ❌

Results for commit f9b88bf.

♻️ This comment has been updated with latest results.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
api/src/main/java/com/samhap/kokomen/token/service/TokenFacadeService.java (1)

36-49: 🧹 Nitpick | 🔵 Trivial

외부 결제 확인 후 DB 작업 실패 시 보상 트랜잭션이 없습니다.

Line 44에서 paymentClient.confirmPayment()가 성공한 후 Line 45-46의 DB 작업(saveTokenPurchase, addPaidTokens)이 실패하면 트랜잭션이 롤백되지만, 결제는 이미 확인된 상태입니다. 이는 기존 로직에서도 존재하는 문제이지만, 분산 락 도입 시점에서 보상 메커니즘(환불 로직 또는 이벤트 기반 재시도)을 고려해볼 수 있습니다.

api/src/main/java/com/samhap/kokomen/interview/service/InterviewProceedBedrockFlowAsyncService.java (2)

62-75: ⚠️ Potential issue | 🔴 Critical

proceedInterviewByBedrockFlowAsync에서 invokeFlow의 동기적 예외 발생 시 락이 해제되지 않습니다.

bedrockAgentRuntimeAsyncClient.invokeFlow()가 비동기 디스패치 전에 동기적으로 예외를 던지면 (예: 유효성 검증 실패, 연결 오류), 락이 해제되지 않습니다. proceedInterviewByGptFlowAsync (Line 84-94)에서는 try-catch로 이 경우를 처리하고 있으나, Bedrock 경로에는 동일한 보호가 없습니다.

🐛 제안하는 수정
     public void proceedInterviewByBedrockFlowAsync(Long memberId, QuestionAndAnswers questionAndAnswers,
                                                    Long interviewId, String lockValue) {
         Map<String, String> mdcContext = MDC.getCopyOfContextMap();
         String lockKey = InterviewFacadeService.createInterviewProceedLockKey(memberId);
         String interviewProceedStateKey = InterviewFacadeService.createInterviewProceedStateKey(interviewId,
                 questionAndAnswers.readCurQuestion().getId());
 
-        bedrockAgentRuntimeAsyncClient.invokeFlow(
-                InterviewInvokeFlowRequestFactory.createInterviewProceedInvokeFlowRequest(questionAndAnswers),
-                createInterviewProceedInvokeFlowResponseHandler(memberId, questionAndAnswers, interviewId, lockKey,
-                        lockValue, interviewProceedStateKey, mdcContext));
-        redisService.setValue(interviewProceedStateKey, InterviewProceedState.LLM_PENDING.name(),
-                Duration.ofSeconds(300));
+        try {
+            bedrockAgentRuntimeAsyncClient.invokeFlow(
+                    InterviewInvokeFlowRequestFactory.createInterviewProceedInvokeFlowRequest(questionAndAnswers),
+                    createInterviewProceedInvokeFlowResponseHandler(memberId, questionAndAnswers, interviewId, lockKey,
+                            lockValue, interviewProceedStateKey, mdcContext));
+            redisService.setValue(interviewProceedStateKey, InterviewProceedState.LLM_PENDING.name(),
+                    Duration.ofSeconds(300));
+        } catch (Exception e) {
+            redisService.releaseLockSafely(lockKey, lockValue);
+            throw e;
+        }
     }

142-160: ⚠️ Potential issue | 🔴 Critical

플로우 이벤트 타입 처리 누락으로 인한 락 누수 위험

callbackInterviewProceedBedrockFlow(FlowResponseStream, ...) 메서드(라인 142-160)에서 FlowOutputEvent가 아닌 이벤트는 처리되지 않고 락 해제 없이 반환됩니다.

AWS Bedrock의 FlowResponseStreamFlowOutputEvent, FlowCompletionEvent, FlowTraceEvent, FlowMultiTurnInputRequestEvent 등 다양한 이벤트 타입을 방출할 수 있습니다. 현재 코드는 FlowOutputEvent만 처리하며, 다른 이벤트 타입이 도착하면 finally 블록(라인 157-159)에서 MDC만 정리하고 락을 해제하지 않습니다.

onError 콜백은 예외 발생 시에만 작동하므로 이벤트 라우팅 문제는 커버하지 못합니다. 결과적으로 FlowCompletionEvent만 발생하거나 다른 이벤트 타입만 도착하는 경우 락이 영구적으로 잠긴 상태로 남아 사용자의 인터뷰 진행이 차단됩니다.

모든 FlowResponseStream 이벤트 타입에 대해 적절한 처리와 락 해제 로직을 추가하거나, finally 블록에서 무조건적으로 락을 해제해야 합니다. (ResumeEvaluationAsyncService, ResumeBasedQuestionBedrockService에서도 동일한 패턴이 발견됨)

🤖 Fix all issues with AI agents
In `@api/build.gradle`:
- Line 33: Both modules hard-code the same Redisson coordinate
("org.redisson:redisson:3.52.0"), risking drift; consolidate the version into a
single source of truth (e.g., add redissonVersion = '3.52.0' in the root
project's ext block or declare it in the version catalog) and update the
dependency declarations in both modules to reference that single variable
(replace literal "org.redisson:redisson:3.52.0" with the shared version variable
or catalog entry).

In `@api/src/main/java/com/samhap/kokomen/auth/service/AuthService.java`:
- Around line 37-46: Extract the duplicated try-catch social login block into a
single private helper (e.g., handleSocialLogin) that accepts the provider and
the identity fields (provider: SocialProvider, id: String, optional nickname:
String), then call memberService.saveSocialMember(...) inside the helper and
fall back to memberService.readBySocialLogin(...) in the catch, returning new
MemberResponse(...) from the helper; replace the current duplicated blocks
around memberService.saveSocialMember and memberService.readBySocialLogin (used
for SocialProvider.KAKAO and SocialProvider.GOOGLE) with calls to this new
helper to centralize the logic.

In
`@api/src/main/java/com/samhap/kokomen/interview/service/InterviewFacadeService.java`:
- Around line 129-134: Update the exception logging in InterviewFacadeService so
the full stack trace is logged by passing the Throwable as the last argument to
log.error (change log.error("Bedrock API 호출 실패, GPT 폴백에시 기록 - {}", e) to pass e
as the throwable and similarly change log.error("Gpt API 호출 실패 - {}", ex) to
pass ex as the throwable); also fix the typo "폴백에시" to "폴백 시". Target the
log.error calls in the try/catch block around
interviewProceedBedrockFlowAsyncService.proceedInterviewByGptFlowAsync(...) and
the subsequent catch that calls redisService.releaseLockSafely(...).

In
`@api/src/main/java/com/samhap/kokomen/interview/service/InterviewSchedulerService.java`:
- Around line 37-44: The scheduler currently only calls
redisService.releaseLock(INTERVIEW_VIEW_COUNT_SCHEDULER_LOCK) inside the catch
block so the lock remains until TTL expiry on success; decide whether this is
intentional: if not, move the releaseLock call into a finally block (ensuring it
runs after scanKeys(...) and syncInterviewViewCounts(...)) to always release
INTERVIEW_VIEW_COUNT_SCHEDULER_LOCK; if it is intentional, add a clear comment
above the try/catch referencing INTERVIEW_VIEW_COUNT_SCHEDULER_LOCK and
explaining that the lock is intentionally held until TTL to prevent duplicate
daily runs.

In
`@api/src/test/java/com/samhap/kokomen/token/service/TokenFacadeServiceConcurrencyTest.java`:
- Around line 96-108: In TokenFacadeServiceConcurrencyTest, the concurrent
runnable only catches BadRequestException which hides other failures; change the
runnable that calls tokenFacadeService.useToken(member.getId()) to catch
Exception (or add a second catch(Exception e)) so any unexpected exception
increments failCount and is logged (include the exception and stack trace)
before countDown on latch; keep BadRequestException handling if you need
specific assertions but ensure all exceptions are recorded/logged and failCount
is incremented to make assertions deterministic.
- Around line 45-66: The concurrency test TokenFacadeServiceConcurrencyTest may
fail because the default `@DistributedLock` waitTime (3s) might be too short for
10 threads contending on the same memberId; update either the production method
annotation or the test to increase the lock waitTime: locate the useToken(...)
method (tokenFacadeService.useToken / the method annotated with
`@DistributedLock`) and set an explicit larger waitTime value sufficient for your
environment, or adjust the test to reduce contention (fewer threads or staggered
starts) so that all threads can acquire the lock within the configured waitTime.

In
`@common/src/main/java/com/samhap/kokomen/global/aop/DistributedLockAspect.java`:
- Around line 20-25: The annotation order on DistributedLockAspect violates the
guideline—Lombok annotations must come before Spring annotations; move Lombok
annotations (`@Slf4j` and `@RequiredArgsConstructor`) to the top of the annotation
block and place Spring annotations (`@Component`, `@Aspect`, `@Order`(0)) after them,
keeping the more significant Spring annotation lower as needed so the final
order is Lombok first then Spring.
- Around line 57-68: The resolveLockKey method currently concatenates the SpEL
result directly, which can produce "lock:prefix:null" when
PARSER.parseExpression(distributedLock.key()).getValue(context) returns null;
update resolveLockKey to validate the resolvedKey after evaluation (call to
PARSER.parseExpression(...).getValue(context)) and if it is null or empty throw
a clear IllegalStateException (or custom runtime exception) describing the
failing annotation (include distributedLock.key() and distributedLock.prefix()
and the target method from the MethodSignature) so the caller fails fast instead
of generating a meaningless lock key; ensure you still use LOCK_KEY_PREFIX and
distributedLock.prefix() when building the key only after validation.

In `@common/src/main/java/com/samhap/kokomen/global/config/RedissonConfig.java`:
- Around line 17-21: Update the RedissonConfig method that currently creates
Config and returns Redisson.create(config) (the block creating Config, using
useSingleServer(), setAddress and returning Redisson) to read and apply
production-relevant properties: set the Redis password from
spring.data.redis.password via useSingleServer().setPassword(...); choose the
address scheme based on an SSL flag or property (use "rediss://" when TLS is
required); and configure connectionPoolSize, connectionMinimumIdleSize, timeout
and retryAttempts via
useSingleServer().setConnectionPoolSize(...).setConnectionMinimumIdleSize(...).setTimeout(...).setRetryAttempts(...).
Ensure these values are pulled from injected configuration/properties and
applied before calling Redisson.create(config).

In `@common/src/main/java/com/samhap/kokomen/global/service/RedisService.java`:
- Around line 60-67: The Redis keys are accessed with inconsistent codecs
causing runtime deserialization errors: ensure the same codec is used for both
initial set and increments by updating incrementKey to obtain the atomic long
with StringCodec (use redissonClient.getAtomicLong(key, StringCodec.INSTANCE))
or change the initial write in InterviewViewCountService.setIfAbsent to use an
RAtomicLong for initialization; likewise make expireKey use a bucket with the
same codec (redissonClient.getBucket(key, StringCodec.INSTANCE)) so all
operations on viewCountKey share a consistent codec/serialization format.
- Around line 81-88: The null check in RedisService.multiGet is ineffective
because RBuckets.get(...) never returns null; replace the redundant null check
with a check for an empty result (result.isEmpty()) or simply remove the
defensive branch and handle empty maps appropriately; update the method in
RedisService.multiGet to either throw a RedisException when result.isEmpty() (to
preserve current error behavior) or return the empty map directly depending on
intended semantics.

// PDF 텍스트 추출
implementation 'org.apache.pdfbox:pdfbox:3.0.3'

testImplementation 'org.redisson:redisson:3.52.0'
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Redisson 버전이 두 모듈에 하드코딩되어 있습니다.

common/build.gradle(Line 5)과 api/build.gradle(Line 33) 모두 org.redisson:redisson:3.52.0을 개별적으로 선언하고 있어 버전 불일치 위험이 있습니다. 루트 build.gradleext 블록이나 버전 카탈로그를 통해 버전을 한 곳에서 관리하는 것을 권장합니다.

🤖 Prompt for AI Agents
In `@api/build.gradle` at line 33, Both modules hard-code the same Redisson
coordinate ("org.redisson:redisson:3.52.0"), risking drift; consolidate the
version into a single source of truth (e.g., add redissonVersion = '3.52.0' in
the root project's ext block or declare it in the version catalog) and update
the dependency declarations in both modules to reference that single variable
(replace literal "org.redisson:redisson:3.52.0" with the shared version variable
or catalog entry).

Comment on lines +37 to +46
try {
Member member = memberService.saveSocialMember(SocialProvider.KAKAO,
String.valueOf(kakaoUserInfoResponse.id()),
kakaoUserInfoResponse.kakaoAccount().profile().nickname());
return new MemberResponse(member);
} catch (DataIntegrityViolationException exception) {
Member member = memberService.readBySocialLogin(SocialProvider.KAKAO,
String.valueOf(kakaoUserInfoResponse.id()));
return new MemberResponse(member);
}
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Kakao/Google 로그인 동시성 처리 로직이 중복됩니다.

두 메서드의 try-catch 패턴이 provider와 ID 추출 방식만 다르고 구조가 동일합니다. 공통 로직을 private 메서드로 추출하면 유지보수성이 향상됩니다.

♻️ 리팩터링 예시
+    private MemberResponse getOrCreateSocialMember(SocialProvider provider, String socialId, String nickname) {
+        return memberService.findBySocialLogin(provider, socialId)
+                .map(MemberResponse::new)
+                .orElseGet(() -> {
+                    try {
+                        Member member = memberService.saveSocialMember(provider, socialId, nickname);
+                        return new MemberResponse(member);
+                    } catch (DataIntegrityViolationException exception) {
+                        Member member = memberService.readBySocialLogin(provider, socialId);
+                        return new MemberResponse(member);
+                    }
+                });
+    }

Also applies to: 58-66

🤖 Prompt for AI Agents
In `@api/src/main/java/com/samhap/kokomen/auth/service/AuthService.java` around
lines 37 - 46, Extract the duplicated try-catch social login block into a single
private helper (e.g., handleSocialLogin) that accepts the provider and the
identity fields (provider: SocialProvider, id: String, optional nickname:
String), then call memberService.saveSocialMember(...) inside the helper and
fall back to memberService.readBySocialLogin(...) in the catch, returning new
MemberResponse(...) from the helper; replace the current duplicated blocks
around memberService.saveSocialMember and memberService.readBySocialLogin (used
for SocialProvider.KAKAO and SocialProvider.GOOGLE) with calls to this new
helper to centralize the logic.

Comment on lines 129 to +134
log.error("Bedrock API 호출 실패, GPT 폴백에시 기록 - {}", e);
interviewProceedBedrockFlowAsyncService.proceedInterviewByGptFlowAsync(memberAuth.memberId(),
questionAndAnswers, interviewId);
questionAndAnswers, interviewId, lockValue);
} catch (Exception ex) {
log.error("Gpt API 호출 실패 - {}", ex);
redisService.releaseLock(lockKey);
redisService.releaseLockSafely(lockKey, lockValue);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

예외 로깅 시 스택 트레이스가 손실됩니다.

log.error("... - {}", e) 형식은 예외의 toString()만 출력하고 스택 트레이스를 누락합니다. SLF4J에서 스택 트레이스를 포함하려면 예외 객체를 마지막 인자로 전달해야 합니다. 또한 Line 129의 "폴백에시"는 "폴백 시"의 오타로 보입니다.

🔧 수정 제안
-                log.error("Bedrock API 호출 실패, GPT 폴백에시 기록 - {}", e);
+                log.error("Bedrock API 호출 실패, GPT 폴백 시 기록", e);
                 interviewProceedBedrockFlowAsyncService.proceedInterviewByGptFlowAsync(memberAuth.memberId(),
                         questionAndAnswers, interviewId, lockValue);
             } catch (Exception ex) {
-                log.error("Gpt API 호출 실패 - {}", ex);
+                log.error("Gpt API 호출 실패", ex);
📝 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
log.error("Bedrock API 호출 실패, GPT 폴백에시 기록 - {}", e);
interviewProceedBedrockFlowAsyncService.proceedInterviewByGptFlowAsync(memberAuth.memberId(),
questionAndAnswers, interviewId);
questionAndAnswers, interviewId, lockValue);
} catch (Exception ex) {
log.error("Gpt API 호출 실패 - {}", ex);
redisService.releaseLock(lockKey);
redisService.releaseLockSafely(lockKey, lockValue);
log.error("Bedrock API 호출 실패, GPT 폴백 시 기록", e);
interviewProceedBedrockFlowAsyncService.proceedInterviewByGptFlowAsync(memberAuth.memberId(),
questionAndAnswers, interviewId, lockValue);
} catch (Exception ex) {
log.error("Gpt API 호출 실패", ex);
redisService.releaseLockSafely(lockKey, lockValue);
🤖 Prompt for AI Agents
In
`@api/src/main/java/com/samhap/kokomen/interview/service/InterviewFacadeService.java`
around lines 129 - 134, Update the exception logging in InterviewFacadeService
so the full stack trace is logged by passing the Throwable as the last argument
to log.error (change log.error("Bedrock API 호출 실패, GPT 폴백에시 기록 - {}", e) to pass
e as the throwable and similarly change log.error("Gpt API 호출 실패 - {}", ex) to
pass ex as the throwable); also fix the typo "폴백에시" to "폴백 시". Target the
log.error calls in the try/catch block around
interviewProceedBedrockFlowAsyncService.proceedInterviewByGptFlowAsync(...) and
the subsequent catch that calls redisService.releaseLockSafely(...).

Comment on lines +37 to 44
try {
Iterable<String> scannedKeys = redisService.scanKeys(INTERVIEW_VIEW_COUNT_KEY_PATTERN,
REDIS_INTERVIEW_VIEW_COUNT_BATCH_SIZE);
syncInterviewViewCounts(scannedKeys);
} catch (Exception e) {
log.error("인터뷰 조회수를 DB에 반영하는 스케줄러 동작 중 에러 발생", e);
redisService.releaseLock(INTERVIEW_VIEW_COUNT_SCHEDULER_LOCK);
}
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

스케줄러 성공 시 락이 해제되지 않는 것은 의도된 동작인지 확인 필요.

catch 블록에서만 releaseLock을 호출하고 있어, 정상 완료 시에는 TTL(6시간)이 만료될 때까지 락이 유지됩니다. 일일 스케줄러의 중복 실행 방지를 위한 의도적인 설계로 보이지만, finally 블록에서의 해제가 아닌 점이 명확하지 않습니다.

만약 의도된 동작이라면, 해당 의도를 명확히 하는 주석을 추가하는 것을 권장합니다.

🤖 Prompt for AI Agents
In
`@api/src/main/java/com/samhap/kokomen/interview/service/InterviewSchedulerService.java`
around lines 37 - 44, The scheduler currently only calls
redisService.releaseLock(INTERVIEW_VIEW_COUNT_SCHEDULER_LOCK) inside the catch
block so the lock remains until TTL expiry on success; decide whether this is
intentional: if not, move the releaseLock call into a finally block (ensuring it
runs after scanKeys(...) and syncInterviewViewCounts(...)) to always release
INTERVIEW_VIEW_COUNT_SCHEDULER_LOCK; if it is intentional, add a clear comment
above the try/catch referencing INTERVIEW_VIEW_COUNT_SCHEDULER_LOCK and
explaining that the lock is intentionally held until TTL to prevent duplicate
daily runs.

Comment on lines +45 to +66
int threadCount = 10;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger failCount = new AtomicInteger(0);

// when
for (int i = 0; i < threadCount; i++) {
executorService.execute(() -> {
try {
tokenFacadeService.useToken(member.getId());
successCount.incrementAndGet();
} catch (Exception e) {
failCount.incrementAndGet();
} finally {
latch.countDown();
}
});
}

latch.await();
executorService.shutdown();
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

fd "DistributedLock" --type f -e java

Repository: samhap-soft/kokomen-backend

Length of output: 227


🏁 Script executed:

git ls-files | head -20

Repository: samhap-soft/kokomen-backend

Length of output: 633


🏁 Script executed:

cat common/src/main/java/com/samhap/kokomen/global/annotation/DistributedLock.java

Repository: samhap-soft/kokomen-backend

Length of output: 577


🏁 Script executed:

cat common/src/main/java/com/samhap/kokomen/global/aop/DistributedLockAspect.java

Repository: samhap-soft/kokomen-backend

Length of output: 2778


🏁 Script executed:

cat api/src/test/java/com/samhap/kokomen/token/service/TokenFacadeServiceConcurrencyTest.java

Repository: samhap-soft/kokomen-backend

Length of output: 6291


🏁 Script executed:

rg "def useToken|public.*useToken" api/src/main/java/com/samhap/kokomen/token/service/ -A 5

Repository: samhap-soft/kokomen-backend

Length of output: 1470


🏁 Script executed:

fd "TokenFacadeService.java" --type f | grep -v Test

Repository: samhap-soft/kokomen-backend

Length of output: 145


🏁 Script executed:

cat api/src/main/java/com/samhap/kokomen/token/service/TokenFacadeService.java

Repository: samhap-soft/kokomen-backend

Length of output: 6937


기본 waitTime 3초가 충분한지 확인이 필요합니다.

10개 스레드가 동일한 memberId로 락을 순차 획득해야 하므로, @DistributedLock의 기본 waitTime 값(3초)이 모든 스레드의 락 획득을 보장할 수 있을 만큼 충분해야 합니다. 각 스레드의 트랜잭션 처리 시간이 길거나 락 대기 시간이 누적되면, 일부 스레드가 시간 초과로 실패할 수 있어 테스트의 안정성이 떨어질 수 있습니다.

필요시 useToken() 메서드의 @DistributedLock 어노테이션에 명시적으로 충분한 waitTime을 설정하거나, 테스트 환경에서 데이터베이스 성능을 고려하여 조정하세요.

🤖 Prompt for AI Agents
In
`@api/src/test/java/com/samhap/kokomen/token/service/TokenFacadeServiceConcurrencyTest.java`
around lines 45 - 66, The concurrency test TokenFacadeServiceConcurrencyTest may
fail because the default `@DistributedLock` waitTime (3s) might be too short for
10 threads contending on the same memberId; update either the production method
annotation or the test to increase the lock waitTime: locate the useToken(...)
method (tokenFacadeService.useToken / the method annotated with
`@DistributedLock`) and set an explicit larger waitTime value sufficient for your
environment, or adjust the test to reduce contention (fewer threads or staggered
starts) so that all threads can acquire the lock within the configured waitTime.

Comment on lines +20 to +25
@Slf4j
@Order(0)
@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAspect {
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

어노테이션 순서가 코딩 가이드라인에 맞지 않습니다.

가이드라인에 따르면 Lombok 어노테이션이 먼저, Spring 어노테이션이 그 다음에 위치해야 합니다. 현재 @RequiredArgsConstructor(Lombok)가 Spring 어노테이션들 뒤에 있습니다.

♻️ 제안하는 수정
 `@Slf4j`
-@Order(0)
-@Aspect
-@Component
 `@RequiredArgsConstructor`
+@Order(0)
+@Aspect
+@Component
 public class DistributedLockAspect {

As per coding guidelines, "Order annotations: Lombok annotations first, then Spring annotations (more important annotations go below)".

🤖 Prompt for AI Agents
In
`@common/src/main/java/com/samhap/kokomen/global/aop/DistributedLockAspect.java`
around lines 20 - 25, The annotation order on DistributedLockAspect violates the
guideline—Lombok annotations must come before Spring annotations; move Lombok
annotations (`@Slf4j` and `@RequiredArgsConstructor`) to the top of the annotation
block and place Spring annotations (`@Component`, `@Aspect`, `@Order`(0)) after them,
keeping the more significant Spring annotation lower as needed so the final
order is Lombok first then Spring.

Comment on lines 57 to 68
private String resolveLockKey(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String[] parameterNames = signature.getParameterNames();
Object[] args = joinPoint.getArgs();

EvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < parameterNames.length; i++) {
context.setVariable(parameterNames[i], args[i]);
}

Object resolvedKey = PARSER.parseExpression(distributedLock.key()).getValue(context);
return LOCK_KEY_PREFIX + distributedLock.prefix() + ":" + resolvedKey;
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

SpEL 표현식 결과가 null일 경우 락 키가 "lock:prefix:null"이 됩니다.

PARSER.parseExpression(distributedLock.key()).getValue(context)null을 반환하면 문자열 연결 시 "null"로 변환되어, 의미 없는 락 키가 생성됩니다. 이는 서로 다른 요청이 동일한 잘못된 키로 락을 공유하게 되어 심각한 동시성 문제를 초래할 수 있습니다.

🛡️ 제안하는 수정
     private String resolveLockKey(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) {
         MethodSignature signature = (MethodSignature) joinPoint.getSignature();
         String[] parameterNames = signature.getParameterNames();
         Object[] args = joinPoint.getArgs();
 
         EvaluationContext context = new StandardEvaluationContext();
         for (int i = 0; i < parameterNames.length; i++) {
             context.setVariable(parameterNames[i], args[i]);
         }
 
         Object resolvedKey = PARSER.parseExpression(distributedLock.key()).getValue(context);
+        if (resolvedKey == null) {
+            throw new IllegalArgumentException("분산 락 키를 해석할 수 없습니다. key: " + distributedLock.key());
+        }
         return LOCK_KEY_PREFIX + distributedLock.prefix() + ":" + resolvedKey;
     }
📝 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
private String resolveLockKey(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String[] parameterNames = signature.getParameterNames();
Object[] args = joinPoint.getArgs();
EvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < parameterNames.length; i++) {
context.setVariable(parameterNames[i], args[i]);
}
Object resolvedKey = PARSER.parseExpression(distributedLock.key()).getValue(context);
return LOCK_KEY_PREFIX + distributedLock.prefix() + ":" + resolvedKey;
private String resolveLockKey(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String[] parameterNames = signature.getParameterNames();
Object[] args = joinPoint.getArgs();
EvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < parameterNames.length; i++) {
context.setVariable(parameterNames[i], args[i]);
}
Object resolvedKey = PARSER.parseExpression(distributedLock.key()).getValue(context);
if (resolvedKey == null) {
throw new IllegalArgumentException("분산 락 키를 해석할 수 없습니다. key: " + distributedLock.key());
}
return LOCK_KEY_PREFIX + distributedLock.prefix() + ":" + resolvedKey;
}
🤖 Prompt for AI Agents
In
`@common/src/main/java/com/samhap/kokomen/global/aop/DistributedLockAspect.java`
around lines 57 - 68, The resolveLockKey method currently concatenates the SpEL
result directly, which can produce "lock:prefix:null" when
PARSER.parseExpression(distributedLock.key()).getValue(context) returns null;
update resolveLockKey to validate the resolvedKey after evaluation (call to
PARSER.parseExpression(...).getValue(context)) and if it is null or empty throw
a clear IllegalStateException (or custom runtime exception) describing the
failing annotation (include distributedLock.key() and distributedLock.prefix()
and the target method from the MethodSignature) so the caller fails fast instead
of generating a meaningless lock key; ensure you still use LOCK_KEY_PREFIX and
distributedLock.prefix() when building the key only after validation.

Comment on lines +17 to +21
) {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://" + host + ":" + port);
return Redisson.create(config);
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

프로덕션 환경을 위한 Redis 연결 설정이 누락되어 있습니다.

현재 설정에 다음 항목들이 빠져 있습니다:

  1. 비밀번호/인증: spring.data.redis.password 속성을 통한 인증 설정
  2. SSL/TLS: 프로덕션 환경(예: AWS ElastiCache)에서 rediss:// 프로토콜 사용 필요 가능성
  3. 커넥션 풀/타임아웃: connectionPoolSize, connectionMinimumIdleSize, timeout, retryAttempts 등 안정성 관련 설정

현재 개발 환경에서는 동작하지만, 프로덕션 배포 시 이 설정들이 필요할 수 있습니다.

🔧 비밀번호 설정 예시
     `@Bean`(destroyMethod = "shutdown")
     public RedissonClient redissonClient(
             `@Value`("${spring.data.redis.host}") String host,
-            `@Value`("${spring.data.redis.port}") int port
+            `@Value`("${spring.data.redis.port}") int port,
+            `@Value`("${spring.data.redis.password:}") String password
     ) {
         Config config = new Config();
-        config.useSingleServer()
-                .setAddress("redis://" + host + ":" + port);
+        SingleServerConfig serverConfig = config.useSingleServer()
+                .setAddress("redis://" + host + ":" + port);
+        if (!password.isEmpty()) {
+            serverConfig.setPassword(password);
+        }
         return Redisson.create(config);
     }
🤖 Prompt for AI Agents
In `@common/src/main/java/com/samhap/kokomen/global/config/RedissonConfig.java`
around lines 17 - 21, Update the RedissonConfig method that currently creates
Config and returns Redisson.create(config) (the block creating Config, using
useSingleServer(), setAddress and returning Redisson) to read and apply
production-relevant properties: set the Redis password from
spring.data.redis.password via useSingleServer().setPassword(...); choose the
address scheme based on an SSL flag or property (use "rediss://" when TLS is
required); and configure connectionPoolSize, connectionMinimumIdleSize, timeout
and retryAttempts via
useSingleServer().setConnectionPoolSize(...).setConnectionMinimumIdleSize(...).setTimeout(...).setRetryAttempts(...).
Ensure these values are pulled from injected configuration/properties and
applied before calling Redisson.create(config).

Comment on lines 60 to 67
public Long incrementKey(String key) {
Long count = redisTemplate.opsForValue().increment(key, 1);
if (count == null) {
throw new RedisException("Redis 카운트 증가 실패. key: " + key);
}

return count;
RAtomicLong atomicLong = redissonClient.getAtomicLong(key);
return atomicLong.incrementAndGet();
}

public boolean expireKey(String key, Duration ttl) {
Boolean expireSuccess = redisTemplate.expire(key, ttl);
if (expireSuccess == null) {
throw new RedisException("Redis 키 만료 설정 실패. key: " + key);
}

return expireSuccess;
return redissonClient.getBucket(key).expire(ttl);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the RedisService.java file to understand the full context
fd RedisService.java --type f

Repository: samhap-soft/kokomen-backend

Length of output: 143


🏁 Script executed:

# Run the provided verification script
echo "=== incrementKey 사용처 ==="
rg -n --type java 'incrementKey\(' -C 3
echo ""
echo "=== expireKey 사용처 ==="
rg -n --type java 'expireKey\(' -C 3

Repository: samhap-soft/kokomen-backend

Length of output: 3431


🏁 Script executed:

# Also check the full RedisService.java file to see codec usage patterns
cat common/src/main/java/com/samhap/kokomen/global/service/RedisService.java

Repository: samhap-soft/kokomen-backend

Length of output: 3391


setIfAbsentincrementKey의 코덱 불일치로 인한 실행 오류 위험

코드 검증 결과, InterviewViewCountService.java에서 정확히 이 문제가 발생하고 있습니다(line 39-43):

  • Line 41: setIfAbsent(viewCountKey, String.valueOf(interview.getViewCount()), ...)StringCodec.INSTANCE로 문자열 값을 저장
  • Line 43: incrementKey(viewCountKey)RAtomicLong으로 같은 키에 접근하려고 시도

RAtomicLongStringCodec은 서로 다른 직렬화 형식을 사용하므로, 문자열 값으로 저장된 데이터를 RAtomicLong으로 역직렬화할 수 없어 런타임 오류가 발생합니다.

해결 방법:

  • incrementKey에서 명시적으로 StringCodec.INSTANCE를 사용하거나
  • setIfAbsent 대신 RAtomicLong을 사용해 초기값을 설정하거나
  • 일관된 코덱을 사용하도록 통일해야 합니다.

또한 expireKey도 코덱을 명시하지 않아 예상치 못한 동작이 발생할 수 있으니 함께 수정하세요.

🤖 Prompt for AI Agents
In `@common/src/main/java/com/samhap/kokomen/global/service/RedisService.java`
around lines 60 - 67, The Redis keys are accessed with inconsistent codecs
causing runtime deserialization errors: ensure the same codec is used for both
initial set and increments by updating incrementKey to obtain the atomic long
with StringCodec (use redissonClient.getAtomicLong(key, StringCodec.INSTANCE))
or change the initial write in InterviewViewCountService.setIfAbsent to use an
RAtomicLong for initialization; likewise make expireKey use a bucket with the
same codec (redissonClient.getBucket(key, StringCodec.INSTANCE)) so all
operations on viewCountKey share a consistent codec/serialization format.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This PR implements distributed locks using Redisson to address concurrency issues, particularly improving concurrency control for token-related operations and interview processing logic. However, critical security and logic issues were identified. The token deduction logic is incomplete, allowing users to bypass token costs in text-based interviews and GPT model paths. A distributed lock leak was also found in the interview scheduler, which could prevent it from running correctly. Further improvements include correcting an incorrect Redisson dependency version in build.gradle and addressing code duplication in AuthService.

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.redisson:redisson:3.52.0'

Choose a reason for hiding this comment

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

critical

Redisson 의존성의 버전 3.52.0이 잘못된 것 같습니다. org.redisson:redisson의 최신 안정 버전은 3.29.0입니다. 존재하지 않는 버전을 사용하면 빌드가 실패할 수 있습니다. 버전을 확인하고 수정해주세요. 이 변경은 api/build.gradle 파일에도 적용되어야 합니다.

    implementation 'org.redisson:redisson:3.29.0'

Comment on lines +37 to 44
try {
Iterable<String> scannedKeys = redisService.scanKeys(INTERVIEW_VIEW_COUNT_KEY_PATTERN,
REDIS_INTERVIEW_VIEW_COUNT_BATCH_SIZE);
syncInterviewViewCounts(scannedKeys);
} catch (Exception e) {
log.error("인터뷰 조회수를 DB에 반영하는 스케줄러 동작 중 에러 발생", e);
redisService.releaseLock(INTERVIEW_VIEW_COUNT_SCHEDULER_LOCK);
}

Choose a reason for hiding this comment

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

security-medium medium

The distributed lock INTERVIEW_VIEW_COUNT_SCHEDULER_LOCK is leaked if the try block completes successfully. It is currently only released in the catch block. This prevents the lock from being freed, blocking the scheduler for the duration of the lock's TTL (6 hours). To ensure the lock is always released, it should be moved to a finally block.

        try {
            Iterable<String> scannedKeys = redisService.scanKeys(INTERVIEW_VIEW_COUNT_KEY_PATTERN,
                    REDIS_INTERVIEW_VIEW_COUNT_BATCH_SIZE);
            syncInterviewViewCounts(scannedKeys);
        } catch (Exception e) {
            log.error("인터뷰 조회수를 DB에 반영하는 스케줄러 동작 중 에러 발생", e);
        } finally {
            redisService.releaseLock(INTERVIEW_VIEW_COUNT_SCHEDULER_LOCK);
        }

Comment on lines 36 to 47
.orElseGet(() -> {
Member member = memberService.saveSocialMember(SocialProvider.KAKAO,
String.valueOf(kakaoUserInfoResponse.id()),
kakaoUserInfoResponse.kakaoAccount().profile().nickname());
tokenService.createTokensForNewMember(member.getId());
return new MemberResponse(member);
try {
Member member = memberService.saveSocialMember(SocialProvider.KAKAO,
String.valueOf(kakaoUserInfoResponse.id()),
kakaoUserInfoResponse.kakaoAccount().profile().nickname());
return new MemberResponse(member);
} catch (DataIntegrityViolationException exception) {
Member member = memberService.readBySocialLogin(SocialProvider.KAKAO,
String.valueOf(kakaoUserInfoResponse.id()));
return new MemberResponse(member);
}
});

Choose a reason for hiding this comment

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

medium

kakaoLogingoogleLogin 메소드 내에 멤버를 생성하고 잠재적인 경쟁 상태를 처리하는 로직이 중복되어 있습니다. 유지보수성을 높이고 코드 중복을 줄이기 위해 이 로직을 별도의 private 헬퍼 메소드로 추출하는 것을 고려해 보세요.

예시:

private MemberResponse createSocialMember(SocialProvider provider, String socialId, String nickname) {
    try {
        Member member = memberService.saveSocialMember(provider, socialId, nickname);
        return new MemberResponse(member);
    } catch (DataIntegrityViolationException exception) {
        Member member = memberService.readBySocialLogin(provider, socialId);
        return new MemberResponse(member);
    }
}

그런 다음 다음과 같이 호출할 수 있습니다:
.orElseGet(() -> createSocialMember(SocialProvider.KAKAO, ...))

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In
`@common/src/main/java/com/samhap/kokomen/global/aop/DistributedLockAspect.java`:
- Around line 68-70: Replace the BadRequestException thrown when resolvedKey is
null with a more appropriate server-side exception (e.g.,
IllegalArgumentException or IllegalStateException) in DistributedLockAspect;
include the failed SpEL expression or key template in the exception message to
aid debugging (e.g., mention the original expression variable used to resolve
the key along with a short descriptive message).
- Around line 37-42: The tryLock calls inside DistributedLockAspect (where you
set boolean acquired using distributedLock.leaseTime() and lock.tryLock(...))
need InterruptedException handling: wrap the tryLock invocations in a try/catch
that catches InterruptedException, call Thread.currentThread().interrupt() to
restore the interrupt status, and then rethrow a suitable unchecked exception
(e.g., new RuntimeException("Interrupted while acquiring distributed lock", e)
or a custom LockAcquisitionException) so the interruption is preserved and the
caller is informed; ensure both branches (the two lock.tryLock(...) calls) use
the same handling pattern.

Comment on lines +37 to +42
boolean acquired;
if (distributedLock.leaseTime() == -1) {
acquired = lock.tryLock(distributedLock.waitTime(), distributedLock.timeUnit());
} else {
acquired = lock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
}
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

tryLockInterruptedException 처리를 고려해 주세요.

lock.tryLock()InterruptedException을 던질 수 있습니다. 현재 throws Throwable로 전파되지만, 인터럽트가 발생하면 스레드의 인터럽트 플래그가 초기화됩니다. 상위 호출자가 인터럽트 상태를 확인할 수 없게 될 수 있으므로, InterruptedException을 catch하여 Thread.currentThread().interrupt()로 인터럽트 상태를 복원한 후 적절한 예외로 감싸서 던지는 것이 좋습니다.

♻️ 제안하는 수정
         boolean acquired;
-        if (distributedLock.leaseTime() == -1) {
-            acquired = lock.tryLock(distributedLock.waitTime(), distributedLock.timeUnit());
-        } else {
-            acquired = lock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
+        try {
+            if (distributedLock.leaseTime() == -1) {
+                acquired = lock.tryLock(distributedLock.waitTime(), distributedLock.timeUnit());
+            } else {
+                acquired = lock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new BadRequestException("락 획득 중 인터럽트가 발생했습니다.");
         }
🤖 Prompt for AI Agents
In
`@common/src/main/java/com/samhap/kokomen/global/aop/DistributedLockAspect.java`
around lines 37 - 42, The tryLock calls inside DistributedLockAspect (where you
set boolean acquired using distributedLock.leaseTime() and lock.tryLock(...))
need InterruptedException handling: wrap the tryLock invocations in a try/catch
that catches InterruptedException, call Thread.currentThread().interrupt() to
restore the interrupt status, and then rethrow a suitable unchecked exception
(e.g., new RuntimeException("Interrupted while acquiring distributed lock", e)
or a custom LockAcquisitionException) so the interruption is preserved and the
caller is informed; ensure both branches (the two lock.tryLock(...) calls) use
the same handling pattern.

Comment on lines +68 to +70
if (resolvedKey == null) {
throw new BadRequestException("분산 락 키를 생성할 수 없습니다.");
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

BadRequestException은 이 상황에 적합하지 않습니다.

SpEL 키 해석 실패는 클라이언트의 잘못된 요청이 아니라 서버 측 설정/프로그래밍 오류입니다. BadRequestException (HTTP 400) 대신 IllegalArgumentException 또는 IllegalStateException을 사용하는 것이 의미적으로 더 정확합니다. 또한, 디버깅을 위해 실패한 키 표현식을 메시지에 포함하면 좋겠습니다.

♻️ 제안하는 수정
         Object resolvedKey = PARSER.parseExpression(distributedLock.key()).getValue(context);
         if (resolvedKey == null) {
-            throw new BadRequestException("분산 락 키를 생성할 수 없습니다.");
+            throw new IllegalArgumentException(
+                    "분산 락 키를 해석할 수 없습니다. key: " + distributedLock.key());
         }
📝 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
if (resolvedKey == null) {
throw new BadRequestException("분산 락 키를 생성할 수 없습니다.");
}
Object resolvedKey = PARSER.parseExpression(distributedLock.key()).getValue(context);
if (resolvedKey == null) {
throw new IllegalArgumentException(
"분산 락 키를 해석할 수 없습니다. key: " + distributedLock.key());
}
return LOCK_KEY_PREFIX + distributedLock.prefix() + ":" + resolvedKey;
🤖 Prompt for AI Agents
In
`@common/src/main/java/com/samhap/kokomen/global/aop/DistributedLockAspect.java`
around lines 68 - 70, Replace the BadRequestException thrown when resolvedKey is
null with a more appropriate server-side exception (e.g.,
IllegalArgumentException or IllegalStateException) in DistributedLockAspect;
include the failed SpEL expression or key template in the exception message to
aid debugging (e.g., mention the original expression variable used to resolve
the key along with a short descriptive message).

@unifolio0 unifolio0 merged commit bebacd1 into develop Feb 12, 2026
5 checks passed
@unifolio0 unifolio0 deleted the refactor/#319 branch February 12, 2026 08:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[REFACTOR] 동시성 검증

1 participant