Skip to content

[REFACOTR] AI_비용_저장_동시성_이슈 해결#85

Merged
coli-geonwoo merged 6 commits intodevelopfrom
refactor/#84-concurrency-ai-charge
Aug 29, 2025
Merged

[REFACOTR] AI_비용_저장_동시성_이슈 해결#85
coli-geonwoo merged 6 commits intodevelopfrom
refactor/#84-concurrency-ai-charge

Conversation

@coli-geonwoo
Copy link
Collaborator

@coli-geonwoo coli-geonwoo commented Aug 29, 2025

🚩 연관 이슈

close #84

🔂 변경 내역

  • ai 비용이 exists -> save 패턴을 사용하다보니 동시성 이슈로 중복 저장문제가 발생
  • 다음달 ai 비용을 매달 말일에 미리 적용하도록 스케쥴링 로직 작성

Summary by CodeRabbit

  • 신규 기능
    • 월말(Asia/Seoul) 23:55에 익월 AI 비용을 자동 생성하는 스케줄러 추가
    • 애플리케이션 전역 스케줄링 활성화
    • 월간 AI 비용 조회·저장·가산을 담당하는 서비스 도입
  • 리팩터
    • PR 분석의 비용 처리 로직을 리포지토리 직접 접근에서 서비스 기반으로 전환
    • 이벤트 리스너의 의존성을 퍼사드 서비스로 교체
  • 동작 변경
    • 해당 월 비용 미존재 시 0 초기화 대신 예외 발생으로 변경 및 관련 오류 코드 추가
  • 테스트
    • 월말 스케줄러 동작 검증 테스트 추가

@coderabbitai
Copy link

coderabbitai bot commented Aug 29, 2025

Walkthrough

AI 요금 조회가 미존재 시 자동 생성 대신 예외를 던지도록 변경하고, AI_CHARGE_NOT_FOUND 에러코드를 추가했습니다. AiCharge 저장 API 및 서비스·스케줄러가 추가되어 월별 요금 초기화 로직이 서비스로 이동했고 PR 분석 흐름이 AiChargeService 기반으로 리팩터링되었습니다.

Changes

Cohort / File(s) Summary
에러 코드 및 예외 경로 변경
gss-common/src/main/java/.../ErrorCode.java, gss-domain/src/main/java/.../repository/analysis/AiChargeRepositoryImpl.java
AI_CHARGE_NOT_FOUND 에러 코드 추가. getByYearAndMonth에서 미존재 시 자동 생성/저장 대신 GssException(AI_CHARGE_NOT_FOUND) 발생으로 변경.
저장 API 추가 (저장소 계약 확장)
gss-domain/src/main/java/.../repository/analysis/AiChargeRepository.java, gss-domain/src/main/java/.../repository/analysis/AiChargeRepositoryImpl.java
인터페이스에 save(AiCharge) 추가 및 구현(JPA 엔티티 변환 후 저장) 추가.
AiCharge 서비스 추가
gss-domain/src/main/java/com/devoops/service/pranalysis/AiChargeService.java
서울 시간 기준 월별 조회(getMonthlyCharge), 가산(addCharge), 다음달 초기화(initializeNextMonth) 로직을 가진 서비스 신규 추가.
스케줄링 설정 및 스케줄러 추가
gss-mcp-app/src/main/java/com/devoops/config/SchedulingConfig.java, gss-mcp-app/src/main/java/com/devoops/service/pranalysis/AiChargeScheduler.java
@EnableScheduling 설정 추가. 매월 말 23:55(Asia/Seoul) 실행되는 스케줄러가 initializeNextMonth() 호출하여 다음달 0원 항목 생성.
PR 분석 흐름 리팩터링
gss-mcp-app/src/main/java/com/devoops/service/pranalysis/PrAnalysisFacadeService.java, gss-mcp-app/src/main/java/com/devoops/event/QuestionEventListener.java
PrAnalysisServicePrAnalysisFacadeService로 명칭 변경. AiChargeRepository 직접 호출을 AiChargeService 사용으로 변경(조회/가산 경로 전환). 이벤트 리스너의 의존성 주입도 수정.
테스트 수정/추가
gss-api-app/src/test/java/.../AiChargeRepositoryTest.java, gss-mcp-app/src/test/java/.../AiChargeSchedulerTest.java
존재하지 않는 월 조회 테스트를 초기화 기대에서 예외 기대로 수정. 스케줄러가 다음달 0원 초기화하는 테스트 추가.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant QEL as QuestionEventListener
  participant PFS as PrAnalysisFacadeService
  participant ACS as AiChargeService
  participant AR as AiChargeRepository

  QEL->>PFS: analyzePullRequest(request, token)
  PFS->>ACS: getMonthlyCharge()
  ACS->>AR: getByYearAndMonth(year, month)
  alt 월 요금 존재
    AR-->>ACS: AiCharge
    ACS-->>PFS: AiCharge
  else 월 요금 미존재
    AR-->>ACS: throw GssException(AI_CHARGE_NOT_FOUND)
    ACS-->>PFS: Exception
    PFS-->>QEL: 예외 전파/처리
  end
  note over PFS,ACS: 분석 후 소비금액 계산
  PFS->>ACS: addCharge(consumedCharge)
  ACS->>AR: addCharge(year, month, consumedCharge)
  AR-->>ACS: void
  ACS-->>PFS: void
  PFS-->>QEL: AnalyzePrResponse
Loading
sequenceDiagram
  autonumber
  participant SCH as AiChargeScheduler
  participant ACS as AiChargeService
  participant AR as AiChargeRepository

  SCH->>ACS: initializeNextMonth()
  ACS->>AR: save(new AiCharge(nextYear, nextMonth, 0))
  AR-->>ACS: AiCharge (persisted)
  ACS-->>SCH: AiCharge
  note over SCH: 다음달 청구 항목 사전 생성
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Assessment against linked issues

Objective Addressed Explanation
미존재 월 조회 시 자동 생성으로 인한 동시성 충돌 제거 [#84]
getByYearAndMonth에서 새 엔티티 저장 로직 제거 [#84]
월별 AI 비용 초기 상태 보장 메커니즘 마련 [#84] 스케줄러로 다음달 초기화가 추가되었으나, 기존 동시성 문제(동일월 최초 생성 시 race 조건)를 완전 대체/보완하는지 불분명함.
PR 분석 경로에서 안전한 월 요금 접근 경로로 전환 [#84]

Assessment against linked issues: Out-of-scope changes

Code Change Explanation
QuestionEventListener 의존성 변경: PrAnalysisServicePrAnalysisFacadeService (gss-mcp-app/src/main/java/com/devoops/event/QuestionEventListener.java`) 이슈 #84는 AI 비용 동시성 문제에 초점을 둠. 리스너의 서비스 타입 교체는 동시성 해결에 직접 명시된 요구가 아니므로 범위를 벗어날 가능성이 있음.
PrAnalysisServicePrAnalysisFacadeService 리네이밍 및 내부 구조 변경 (gss-mcp-app/src/main/java/com/devoops/service/pranalysis/PrAnalysisFacadeService.java`) 리팩터링 자체는 동시성 해결에 기여할 수 있으나 이슈의 핵심(동시성으로 인한 다중 생성 방지)은 이름 변경과는 별개이므로 명시된 목표와 직접적으로 일치하지 않음.

Possibly related PRs

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.


📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between f48f54b and 9c82401.

📒 Files selected for processing (1)
  • gss-mcp-app/src/main/java/com/devoops/service/pranalysis/AiChargeScheduler.java (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • gss-mcp-app/src/main/java/com/devoops/service/pranalysis/AiChargeScheduler.java
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build-and-push
✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch refactor/#84-concurrency-ai-charge

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
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore or @coderabbit ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@github-actions
Copy link

github-actions bot commented Aug 29, 2025

Test Results

0 tests   0 ✅  0s ⏱️
0 suites  0 💤
0 files    0 ❌

Results for commit 9c82401.

♻️ This comment has been updated with latest results.

@github-actions
Copy link

github-actions bot commented Aug 29, 2025

📝 Test Coverage Report

Overall Project NaN% NaN% 🍏

There is no coverage information present for the Files changed

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: 9

🧹 Nitpick comments (4)
gss-mcp-app/src/main/java/com/devoops/config/SchedulingConfig.java (1)

6-10: 스케줄링 활성화 LGTM + 스레드풀/에러핸들러/타임존 제안

기본 설정은 OK. 장기 실행 시나리오/예외 로깅/동시 실행 제어 강화를 위해 스케줄러 빈을 구성하는 편이 좋습니다. 또한 @scheduled 크론에 Asia/Seoul 등 명시적 타임존 설정을 권장합니다.

 package com.devoops.config;

+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.TaskScheduler;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;

 @Configuration
 @EnableScheduling
+@Slf4j
 public class SchedulingConfig {
 
+  @Bean
+  public TaskScheduler taskScheduler() {
+    ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
+    scheduler.setPoolSize(2); // 필요 시 조정
+    scheduler.setThreadNamePrefix("scheduler-");
+    scheduler.setRemoveOnCancelPolicy(true);
+    scheduler.setErrorHandler(t -> log.error("Unhandled exception in scheduler", t));
+    scheduler.initialize();
+    return scheduler;
+  }
 }

추가 제안(클러스터 환경): 스케줄러 단일 실행 보장을 위해 ShedLock 등 분산락 도입을 고려하세요.

gss-domain/src/main/java/com/devoops/jpa/repository/analysis/AiChargeRepositoryImpl.java (1)

21-24: 조회 메서드에 readOnly 트랜잭션 부여

읽기 전용 쿼리이므로 트랜잭션 힌트를 명확히 해 I/O 미세 최적화 및 불필요한 flush 가능성 차단을 권장합니다.

-    public AiCharge getByYearAndMonth(int year, int month) {
+    @Transactional(readOnly = true)
+    public AiCharge getByYearAndMonth(int year, int month) {
         return chargeJpaRepository.findByYearAndMonth(year, month)
                 .orElseThrow(() -> new GssException(ErrorCode.AI_CHARGE_NOT_FOUND))
                 .toDomainEntity();
     }
gss-mcp-app/src/test/java/com/devoops/service/pranalysis/AiChargeSchedulerTest.java (1)

40-43: BigDecimal 비교는 double 변환 대신 BigDecimal 비교 API 사용 권장

doubleValue()로 변환하면 스케일/정밀도 이슈가 섞일 수 있습니다. 아래처럼 수정하면 타입 안전합니다.

-                    () -> assertThat(nextMonthAiCharge.getCharge().doubleValue()).isEqualTo(0.0)
+                    () -> assertThat(nextMonthAiCharge.getCharge()).isEqualByComparingTo(java.math.BigDecimal.ZERO)
gss-mcp-app/src/main/java/com/devoops/service/pranalysis/AiChargeScheduler.java (1)

13-16: 분산 환경 중복 실행 대비

멀티 인스턴스에서 동시에 트리거될 수 있습니다. initializeNextMonth()를 멱등하게 만들고(중복 삽입 무시 후 조회) 필요시 ShedLock 등의 분산락 도입을 고려하세요.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 6c454c4 and f48f54b.

📒 Files selected for processing (10)
  • gss-api-app/src/test/java/com/devoops/repository/analysis/AiChargeRepositoryTest.java (2 hunks)
  • gss-common/src/main/java/com/devoops/exception/errorcode/ErrorCode.java (1 hunks)
  • gss-domain/src/main/java/com/devoops/domain/repository/analysis/AiChargeRepository.java (1 hunks)
  • gss-domain/src/main/java/com/devoops/jpa/repository/analysis/AiChargeRepositoryImpl.java (2 hunks)
  • gss-domain/src/main/java/com/devoops/service/pranalysis/AiChargeService.java (1 hunks)
  • gss-mcp-app/src/main/java/com/devoops/config/SchedulingConfig.java (1 hunks)
  • gss-mcp-app/src/main/java/com/devoops/event/QuestionEventListener.java (3 hunks)
  • gss-mcp-app/src/main/java/com/devoops/service/pranalysis/AiChargeScheduler.java (1 hunks)
  • gss-mcp-app/src/main/java/com/devoops/service/pranalysis/PrAnalysisFacadeService.java (2 hunks)
  • gss-mcp-app/src/test/java/com/devoops/service/pranalysis/AiChargeSchedulerTest.java (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
gss-domain/src/main/java/com/devoops/service/pranalysis/AiChargeService.java (1)
gss-mcp-app/src/main/java/com/devoops/service/pranalysis/PrAnalysisFacadeService.java (1)
  • Service (14-38)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build-and-push
🔇 Additional comments (6)
gss-domain/src/main/java/com/devoops/jpa/repository/analysis/AiChargeRepositoryImpl.java (1)

28-30: updateChargeById 반환 타입을 int로 변경하고 예외 처리 추가
AiChargeJpaRepository.updateChargeById가 현재 void 반환이라 호출부에서 업데이트 건수를 확인할 수 없습니다. JPA 쿼리 메서드를 @Modifying, @Query와 함께 int 반환으로 변경한 뒤 addCharge에서 반환값이 0일 때 GssException(ErrorCode.AI_CHARGE_NOT_FOUND)를 던지세요.

Likely an incorrect or invalid review comment.

gss-api-app/src/test/java/com/devoops/repository/analysis/AiChargeRepositoryTest.java (2)

4-4: AssertJ thrownBy 정적 임포트 추가 LGTM

예외 경로 검증 가독성 향상에 도움이 됩니다.


9-11: 예외/에러코드 임포트 LGTM

테스트 의도에 부합합니다.

gss-mcp-app/src/main/java/com/devoops/event/QuestionEventListener.java (2)

25-25: 의존성 교체 적절

PrAnalysisServicePrAnalysisFacadeService 전환 및 DI 필드 교체가 일관성 있게 반영되었습니다.


34-34: 호출부 업데이트 확인

Facade로의 위임 메서드 호출로 정상 교체되었습니다. 예외 전파 정책이 이전과 동일한지만 한번 확인해 주세요.

gss-domain/src/main/java/com/devoops/service/pranalysis/AiChargeService.java (1)

32-35: 원자적 UPDATE 및 @transactional 적용 완료
AiChargeJpaRepository.updateChargeById@Modifying+JPQL update … set charge = charge + :charge where … 단일 쿼리로 구현되어 있고, AiChargeRepositoryImpl.addCharge에 기본 전파 속성(REQUIRED)의 @Transactional이 이미 적용되어 있어 동시성 손실이 발생하지 않습니다.

Comment on lines +36 to 42
void 가져올_요금이_없다면_에러가_발생한다() {
LocalDate localDate = LocalDate.now();

AiCharge actual = chargeRepository.getByYearAndMonth(localDate.getYear(), localDate.getMonthValue());

assertThat(actual.getCharge().doubleValue()).isEqualTo(0.0);
assertThatThrownBy(() -> chargeRepository.getByYearAndMonth(localDate.getYear(), localDate.getMonthValue()))
.isInstanceOf(GssException.class)
.hasMessage(ErrorCode.AI_CHARGE_NOT_FOUND.getMessage());
}
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

테스트 안정성: 날짜 고정 및 에러코드까지 검증 권장

  • LocalDate.now()는 테스트 환경/시차/사전 데이터에 따라 플래키할 수 있습니다. 고정 일자를 사용하세요.
  • 가능하다면 GssException의 ErrorCode도 함께 단정해 의도를 더 명확히 하세요.
-            LocalDate localDate = LocalDate.now();
+            LocalDate localDate = LocalDate.of(2099, 12, 1); // 존재하지 않을 것이 보장되는 미래 날짜
-
-            assertThatThrownBy(() -> chargeRepository.getByYearAndMonth(localDate.getYear(), localDate.getMonthValue()))
-                    .isInstanceOf(GssException.class)
-                    .hasMessage(ErrorCode.AI_CHARGE_NOT_FOUND.getMessage());
+            assertThatThrownBy(() -> chargeRepository.getByYearAndMonth(localDate.getYear(), localDate.getMonthValue()))
+                    .isInstanceOf(GssException.class)
+                    .hasMessage(ErrorCode.AI_CHARGE_NOT_FOUND.getMessage());
+            // (선택) ErrorCode 필드도 검증하려면 GssException API 확인 필요
+            // .extracting("errorCode").isEqualTo(ErrorCode.AI_CHARGE_NOT_FOUND);

검증 스크립트: GssException이 errorCode 게터를 노출하는지 확인


🏁 Script executed:

#!/bin/bash
rg -nP -C2 'class\s+GssException\b'
rg -nP -C2 '\bgetErrorCode\s*\('

Length of output: 1175


테스트 안정성 강화 및 (선택) 에러코드 검증

  • LocalDate.now() 대신 재현 가능한 고정 날짜 사용(e.g. LocalDate.of(2099, 12, 1))
  • (선택) assertThatThrownBy(...) .extracting("errorCode") .isEqualTo(ErrorCode.AI_CHARGE_NOT_FOUND)
    → GssException에 getErrorCode()가 Lombok @Getter로 생성되어 있어 검증 가능
🤖 Prompt for AI Agents
In
gss-api-app/src/test/java/com/devoops/repository/analysis/AiChargeRepositoryTest.java
around lines 36-42, the test uses LocalDate.now() making it non-deterministic;
replace LocalDate.now() with a fixed reproducible date such as
LocalDate.of(2099, 12, 1) so the test is stable, and optionally strengthen the
assertion by extracting/validating the exception's error code (e.g., assert that
the thrown GssException has errorCode equal to ErrorCode.AI_CHARGE_NOT_FOUND)
using the existing getErrorCode() getter.

Comment on lines +43 to 45
AI_CREATE_QUESTION_ERROR(500, "AI 질문 생성과정에 오류가 발생했습니다"),
AI_CHARGE_NOT_FOUND(500, "당월 AI 비용을 찾을 없습니다.")
;
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

오류 메시지 오타 수정 및 상태코드 검토

  • 사용자 노출 문구 “찾을 없습니다”는 비문입니다. “찾을 수 없습니다”로 수정하세요.
  • 도메인 리소스 부재는 500보다는 404가 더 적합합니다(내부 예외라도 의미적으로 Not Found). 맵핑 정책에 따라 결정하시되, 최소한 문구는 즉시 수정 필요.

문구 수정안:

-    AI_CHARGE_NOT_FOUND(500, "당월 AI 비용을 찾을 없습니다.")
+    AI_CHARGE_NOT_FOUND(500, "당월 AI 비용을 찾을 수 없습니다.")

(선택) 상태코드 정합성 향상:

-    AI_CHARGE_NOT_FOUND(500, "당월 AI 비용을 찾을 수 없습니다.")
+    AI_CHARGE_NOT_FOUND(404, "당월 AI 비용을 찾을 수 없습니다.")
🤖 Prompt for AI Agents
In gss-common/src/main/java/com/devoops/exception/errorcode/ErrorCode.java
around lines 43 to 45, the enum entry AI_CHARGE_NOT_FOUND contains a typo in the
user-facing message ("찾을 없습니다") and an inappropriate 500 status for a missing
resource; update the message to "찾을 수 없습니다" and, depending on your error-mapping
policy, change the status code from 500 to 404 for AI_CHARGE_NOT_FOUND (or
document why 500 is retained) so the enum reflects correct wording and semantic
status.

Comment on lines 9 to 12
void addCharge(int year, int month, double charge);

AiCharge save(AiCharge charge);
}
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

addCharge 결과값 반환 및 save 계약 명시화

  • addCharge가 업데이트 성공 여부를 알려주지 않습니다. 0행 업데이트 시 NotFound 예외 매핑을 위해 업데이트 카운트를 반환하도록 변경을 권장합니다.
  • save는 “신규 생성”인지 “업서트”인지 계약을 자바독으로 명확히 해주세요(동시성 중복 생성 시 동작 포함).
-    void addCharge(int year, int month, double charge);
+    int addCharge(int year, int month, double charge);
 
-    AiCharge save(AiCharge charge);
+    /**
+     * 지정 연/월의 AiCharge를 저장한다.
+     * 동시성으로 인한 중복키 충돌 발생 시의 처리(무시/조회 재시도/예외)를 명확히 보장해야 한다.
+     */
+    AiCharge save(AiCharge charge);

검증 스크립트(리포지토리 구현이 업데이트 카운트를 반환하는지 확인):


🏁 Script executed:

#!/bin/bash
# updateChargeById 시그니처와 @Modifying 여부 확인
rg -nP -C3 'interface\s+AiChargeJpaRepository\b'
rg -nP -C2 '\bupdateChargeById\s*\('
rg -nP -C2 '@Modifying'

Length of output: 4562


addCharge 반환값(int) 및 save 계약 자바독 명시화 필요

  • AiChargeJpaRepository.updateChargeById를 int 반환으로 변경하고, 도메인 레포지토리 addChargeint 반환으로 수정하세요(업데이트 건수 반환).
  • save 메서드는 신규 생성(insert) vs. 기존 수정(upsert) 및 동시성 키 충돌 처리(무시/재시도/예외 등)를 JavaDoc으로 명시하세요.
// gss-domain/src/main/java/com/devoops/domain/repository/analysis/AiChargeRepository.java
-    void addCharge(int year, int month, double charge);
+    int addCharge(int year, int month, double charge);

-    AiCharge save(AiCharge charge);
+    /**
+     * 지정 연/월의 AiCharge를 저장합니다.
+     * 신규 생성(insert) 또는 기존 수정(upsert) 동작을 보장하며,
+     * 동시성 중복 키 충돌 시(무시/조회 재시도/예외 처리) 동작을 문서화해야 합니다.
+     */
+    AiCharge save(AiCharge charge);
📝 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
void addCharge(int year, int month, double charge);
AiCharge save(AiCharge charge);
}
// gss-domain/src/main/java/com/devoops/domain/repository/analysis/AiChargeRepository.java
/**
* 지정된 /월의 요금을 업데이트하며, 영향을 받은 수를 반환합니다.
*
* @param year 연도 (: 2025)
* @param month (1-12)
* @param charge 갱신할 요금
* @return 업데이트된 레코드
*/
int addCharge(int year, int month, double charge);
/**
* 지정 /월의 AiCharge를 저장합니다.
* 신규 생성(insert) 또는 기존 수정(upsert) 동작을 보장하며,
* 동시성 중복 충돌 (무시/재조회 재시도/예외 처리) 동작을 문서화해야 합니다.
*
* @param charge 저장할 AiCharge 엔티티
* @return 저장된(또는 갱신된) AiCharge 엔티티
*/
AiCharge save(AiCharge charge);
}
🤖 Prompt for AI Agents
In
gss-domain/src/main/java/com/devoops/domain/repository/analysis/AiChargeRepository.java
around lines 9-12, change the addCharge signature to return int (number of
updated rows) and update the underlying AiChargeJpaRepository.updateChargeById
to return int as well; additionally add a concise JavaDoc to the save(AiCharge)
method that specifies whether it performs an insert vs update (upsert) and
document the concurrency/key-conflict behavior (e.g., whether conflicts are
ignored, retried, or cause an exception) and any expected transactional
guarantees so callers know how to handle failures.

Comment on lines +32 to +37
@Override
@Transactional
public AiCharge save(AiCharge charge) {
return chargeJpaRepository.save(AiChargeEntity.from(charge))
.toDomainEntity();
}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

save 동시성 중복키 대비 — 멱등 저장으로 보강

다중 노드/스케줄러 중복 실행 시 unique(year, month) 충돌 가능. DataIntegrityViolationException을 잡아 조회 재시도로 멱등화하세요.

 import com.devoops.domain.entity.analysis.AiCharge;
 import com.devoops.domain.repository.analysis.AiChargeRepository;
 import com.devoops.exception.custom.GssException;
 import com.devoops.exception.errorcode.ErrorCode;
 import com.devoops.jpa.entity.analysis.AiChargeEntity;
 import java.math.BigDecimal;
+import org.springframework.dao.DataIntegrityViolationException;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Repository;
 import org.springframework.transaction.annotation.Transactional;
@@
     @Override
     @Transactional
     public AiCharge save(AiCharge charge) {
-        return chargeJpaRepository.save(AiChargeEntity.from(charge))
-                .toDomainEntity();
+        try {
+            return chargeJpaRepository.save(AiChargeEntity.from(charge))
+                    .toDomainEntity();
+        } catch (DataIntegrityViolationException e) {
+            // 동시성으로 이미 생성된 경우: 조회로 멱등 보장
+            return chargeJpaRepository.findByYearAndMonth(charge.getYear(), charge.getMonth())
+                    .orElseThrow(() -> new GssException(ErrorCode.AI_CHARGE_NOT_FOUND))
+                    .toDomainEntity();
+        }
     }

(운영 제안) 스케줄러 단일 실행을 위해 ShedLock 등을 사용하면 DB 충돌 자체를 크게 줄일 수 있습니다.

📝 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
@Override
@Transactional
public AiCharge save(AiCharge charge) {
return chargeJpaRepository.save(AiChargeEntity.from(charge))
.toDomainEntity();
}
++ b/gss-domain/src/main/java/com/devoops/jpa/repository/analysis/AiChargeRepositoryImpl.java
@@
import com.devoops.jpa.entity.analysis.AiChargeEntity;
import java.math.BigDecimal;
import org.springframework.dao.DataIntegrityViolationException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
@@
@Override
@Transactional
public AiCharge save(AiCharge charge) {
- return chargeJpaRepository.save(AiChargeEntity.from(charge))
try {
return chargeJpaRepository.save(AiChargeEntity.from(charge))
.toDomainEntity();
} catch (DataIntegrityViolationException e) {
// 동시성으로 이미 생성된 경우: 조회로 멱등 보장
return chargeJpaRepository.findByYearAndMonth(charge.getYear(), charge.getMonth())
.orElseThrow(() -> new GssException(ErrorCode.AI_CHARGE_NOT_FOUND))
.toDomainEntity();
}
}
🤖 Prompt for AI Agents
In
gss-domain/src/main/java/com/devoops/jpa/repository/analysis/AiChargeRepositoryImpl.java
around lines 32-37, the save method can throw a DataIntegrityViolationException
under concurrent inserts for the same unique(year, month); update the method to
catch DataIntegrityViolationException, and in the catch perform a lookup by year
and month (via the JPA repository) and return the existing entity’s domain
object to achieve idempotent save (optionally retry the lookup a couple times if
necessary); keep the method transactional and ensure any thrown exception types
are handled/translated appropriately.

Comment on lines +9 to +11
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

initializeNextMonth 멱등/동시성 안전성 부족 — 고유키 충돌 흡수 및 재조회

현재는 무조건 save하여 유니크(year, month) 충돌 시 예외가 납니다. 분산 스케줄/동시 트리거에 취약하므로 충돌은 정상 시나리오로 간주하고 재조회로 멱등화하세요.

-import org.springframework.stereotype.Service;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
@@
-    public AiCharge initializeNextMonth() {
+    @Transactional
+    public AiCharge initializeNextMonth() {
         LocalDate now = LocalDate.now(SEOUL_ZONE_ID);
         YearMonth nextMonth = YearMonth.from(now).plusMonths(1);
         AiCharge aiCharge = new AiCharge(nextMonth.getYear(), nextMonth.getMonthValue(), BigDecimal.ZERO);
-        return chargeRepository.save(aiCharge);
+        try {
+            return chargeRepository.save(aiCharge);
+        } catch (DataIntegrityViolationException e) {
+            // 이미 다른 쓰레드/노드가 생성함: 재조회로 멱등 보장
+            return chargeRepository.getByYearAndMonth(nextMonth.getYear(), nextMonth.getMonthValue());
+        }
     }

Also applies to: 20-25

🤖 Prompt for AI Agents
In gss-domain/src/main/java/com/devoops/service/pranalysis/AiChargeService.java
around lines 9-11 and 20-25, the initializeNextMonth method currently
unconditionally calls save and propagates unique (year,month) constraint
exceptions; change it to be idempotent and concurrency-safe by catching the
persistence exception thrown on unique-key conflict (e.g.,
DataIntegrityViolationException / DuplicateKeyException), then re-query the
repository for the record by year and month and return that existing entity
instead of failing; ensure the save call remains inside a transaction where
appropriate and prefer repository.findByYearAndMonth (or equivalent) for the
re-fetch so concurrent triggers simply return the already-created row.

Comment on lines +27 to +30
public AiCharge getMonthlyCharge() {
LocalDate today = LocalDate.now(SEOUL_ZONE_ID);
return chargeRepository.getByYearAndMonth(today.getYear(), today.getMonthValue());
}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

미존재 월 예외 전파는 서비스 책임 회피 — 안전한 지연 생성으로 전환

운영 중 스케줄 누락/초기 데이터 부재 가능성을 고려해 서비스 레이어에서 “조회 또는 생성”을 보장하세요.

-    public AiCharge getMonthlyCharge() {
-        LocalDate today = LocalDate.now(SEOUL_ZONE_ID);
-        return chargeRepository.getByYearAndMonth(today.getYear(), today.getMonthValue());
-    }
+    public AiCharge getOrCreateMonthlyCharge() {
+        LocalDate today = LocalDate.now(SEOUL_ZONE_ID);
+        try {
+            return chargeRepository.getByYearAndMonth(today.getYear(), today.getMonthValue());
+        } catch (com.devoops.exception.GssException ex) {
+            if (ex.getErrorCode() != com.devoops.exception.errorcode.ErrorCode.AI_CHARGE_NOT_FOUND) throw ex;
+            AiCharge newCharge = new AiCharge(today.getYear(), today.getMonthValue(), BigDecimal.ZERO);
+            try {
+                return chargeRepository.save(newCharge);
+            } catch (org.springframework.dao.DataIntegrityViolationException e) {
+                return chargeRepository.getByYearAndMonth(today.getYear(), today.getMonthValue());
+            }
+        }
+    }

사용처(PrAnalysisFacadeService)도 getOrCreateMonthlyCharge()로 교체해 주세요.

📝 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
public AiCharge getMonthlyCharge() {
LocalDate today = LocalDate.now(SEOUL_ZONE_ID);
return chargeRepository.getByYearAndMonth(today.getYear(), today.getMonthValue());
}
// 기존 getMonthlyCharge() 대신 아래 메서드를 사용해 조회 또는 생성을 보장합니다.
public AiCharge getOrCreateMonthlyCharge() {
LocalDate today = LocalDate.now(SEOUL_ZONE_ID);
try {
// 이미 존재하면 바로 반환
return chargeRepository.getByYearAndMonth(today.getYear(), today.getMonthValue());
} catch (com.devoops.exception.GssException ex) {
// 예상된 "월별 요금 없음" 예외가 아니면 그대로 재던짐
if (ex.getErrorCode() != com.devoops.exception.errorcode.ErrorCode.AI_CHARGE_NOT_FOUND) {
throw ex;
}
// 없으면 지연 생성
AiCharge newCharge = new AiCharge(
today.getYear(),
today.getMonthValue(),
BigDecimal.ZERO
);
try {
return chargeRepository.save(newCharge);
} catch (org.springframework.dao.DataIntegrityViolationException e) {
// 동시 생성된 경우, 다시 조회
return chargeRepository.getByYearAndMonth(today.getYear(), today.getMonthValue());
}
}
}
🤖 Prompt for AI Agents
In gss-domain/src/main/java/com/devoops/service/pranalysis/AiChargeService.java
around lines 27-30, replace the current getMonthlyCharge() with a
getOrCreateMonthlyCharge() that guarantees returning an AiCharge for the current
Seoul year/month by first attempting to fetch via
chargeRepository.getByYearAndMonth(...) and, if absent, creating and saving a
new AiCharge (populate year, month and any required defaults) in a safe/durable
way (wrap in a transaction and handle concurrent creation — e.g., attempt insert
and on unique constraint failure re-query or use repository.save with proper
locking), then update usages (PrAnalysisFacadeService) to call
getOrCreateMonthlyCharge(); ensure exceptions are handled and the
repository/save path is tested.

Comment on lines +29 to +33
ZoneId zoneId = ZoneId.of("Asia/Seoul");
YearMonth yearMonth = YearMonth.from(LocalDate.now(zoneId));
YearMonth nextMonth = yearMonth.plusMonths(1);

aiChargeScheduler.createNextMonthCharge();
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

경계 시각(자정/말일) 플래키 발생 가능 — Clock 주입으로 결정론 보장 필요

테스트/서비스가 각각 LocalDate.now(Asia/Seoul)을 호출하므로 자정 전후(특히 말일 23:59~00:01)에 월 계산이 어긋날 수 있습니다. AiChargeServiceClock을 주입해 테스트에서는 고정 Clock을 주입하도록 리팩터링해 주세요.

원하시면 AiChargeServiceClock 도입 및 테스트용 고정 Clock 설정까지 패치 드리겠습니다.


🏁 Script executed:

#!/bin/bash
set -e

# Locate the test file
TEST_FILE=$(find . -type f -name AiChargeSchedulerTest.java)
echo "=== Test file: $TEST_FILE ==="
sed -n '1,200p' "$TEST_FILE"

# Locate the service file defining AiChargeService
SERVICE_FILE=$(grep -R --include="*.java" "class AiChargeService" -l .)
echo "=== Service file(s): $SERVICE_FILE ==="
# Show the createNextMonthCharge method context
grep -R --include="*.java" -n "createNextMonthCharge" -C3 $SERVICE_FILE
sed -n '1,200p' $SERVICE_FILE | sed -n '/createNextMonthCharge/,/^}/p'

# Check for LocalDate.now usage in the service
echo "=== LocalDate.now usages in service ==="
grep -R --include="*.java" "LocalDate.now" -n $SERVICE_FILE

# Check if Clock is injected anywhere in the service or test
echo "=== Clock references in codebase ==="
grep -R --include="*.java" "\bClock\b" -n .

Length of output: 2565


🏁 Script executed:

#!/bin/bash
set -e

# AiChargeScheduler 클래스 파일 위치 확인
SCHEDULER_FILE=$(find . -type f -name AiChargeScheduler.java)
echo "=== Scheduler file: $SCHEDULER_FILE ==="

# createNextMonthCharge 메서드 주변 코드 확인
grep -n -C3 "createNextMonthCharge" "$SCHEDULER_FILE"

# LocalDate.now 사용 여부 확인
grep -n "LocalDate.now" "$SCHEDULER_FILE"

Length of output: 808


🏁 Script executed:

#!/bin/bash
set -e

# Locate AiChargeService file
SERVICE_FILE=$(find . -type f -path "*com/devoops/service/pranalysis/AiChargeService.java")
echo "=== Service file: $SERVICE_FILE ==="

# Show top of file
sed -n '1,200p' "$SERVICE_FILE"

# Show initializeNextMonth method context
grep -n -C3 "initializeNextMonth" "$SERVICE_FILE"

# Check for LocalDate.now usage in service
grep -n "LocalDate.now" "$SERVICE_FILE"

Length of output: 2505


AiChargeService에 Clock 주입하고 테스트에서 고정 Clock 사용
서비스의 LocalDate.now(SEOUL_ZONE_ID) 호출을 생성자 주입된 Clock으로 교체하고, 테스트에서는 고정된 Clock 인스턴스를 주입해 경계 시각(자정/말일) 플래키를 방지하세요.

🤖 Prompt for AI Agents
In
gss-mcp-app/src/test/java/com/devoops/service/pranalysis/AiChargeSchedulerTest.java
around lines 29 to 33, replace direct calls to LocalDate.now(SEOUL_ZONE_ID) in
the AiChargeService with a constructor-injected Clock and update the test to
pass a fixed Clock for Asia/Seoul so boundary timing is deterministic: modify
the service to accept a Clock (use it with LocalDate.now(clock) and
ZoneId.of("Asia/Seoul") as needed), add a constructor or setter to inject the
Clock, and in this test create a fixed Clock at a known Instant with
ZoneId.of("Asia/Seoul") then construct/inject the AiChargeService (or
AiChargeScheduler) with that fixed Clock before calling createNextMonthCharge().

@coli-geonwoo coli-geonwoo merged commit 95f8e17 into develop Aug 29, 2025
4 checks passed
@coli-geonwoo coli-geonwoo deleted the refactor/#84-concurrency-ai-charge branch August 29, 2025 14:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[REFACOTR] AI 비용 객체 동시성 이슈로 분석결과가 날아가는 문제 해결

1 participant