Skip to content

Conversation

@pdh0128
Copy link
Member

@pdh0128 pdh0128 commented Oct 23, 2025

Remain Token 증가 사가

  • "remain-token-increase-request" 이벤트 리스너 정의.
  • Avro Schema namespace 변경.

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 토큰 증가 기능이 추가되었습니다. 사용자는 이제 토큰을 증가시킬 수 있습니다.
    • 토큰 증가 요청에 대한 응답 처리 기능이 구현되었습니다.
  • 개선 사항

    • 신규 사용자의 기본 토큰 잔액이 3,000으로 설정되었습니다.

@coderabbitai
Copy link

coderabbitai bot commented Oct 23, 2025

개요

Avro 스키마 리팩토링, 네임스페이스 표준화, 토큰 증가 요청 처리를 위한 새로운 Kafka 이벤트 리스너 메서드 추가, 그리고 관련 서비스 및 도메인 모델 업데이트가 포함된 변경사항입니다.

변경 사항

응집단 / 파일(들) 변경 요약
Avro 스키마 네임스페이스 표준화
src/main/avro/ChatAvroSchema.avsc, src/main/avro/RemainTokenDecreaseResponse.avsc
ChatEvent를 ChatAvroSchema로 이름 변경 및 네임스페이스를 windeath44.server.chatbot.avro로 업데이트; RemainTokenDecreaseResponse 네임스페이스를 windeath44.server.user.avro로 변경
새로운 Avro 스키마 정의
src/main/avro/MemorialBowedAvroSchema.avsc, src/main/avro/RemainTokenIncreaseResponse.avsc
토큰 증가 요청 처리를 위한 MemorialBowedAvroSchema 추가(memorialId, memorialBow, writerId 필드 포함); 토큰 증가 응답 스키마 추가(user_id, success, remaining_token, error_message 필드 포함)
Kafka 이벤트 리스너 확장
src/main/java/com/example/user/domain/eventlistener/KafkaEventListener.java
handleTokenDecreaseRequest 매개변수 타입을 ChatEvent에서 ChatAvroSchema로 변경; TokenIncreaseService 의존성 추가; handleTokenIncreaseRequest 메서드 및 buildIncreaseResponse 헬퍼 메서드 추가
토큰 증가 서비스 추가
src/main/java/com/example/user/domain/service/TokenIncreaseService.java
사용자의 토큰을 증가시키고 남은 토큰 개수를 반환하는 transactional 메서드 increaseToken 구현
도메인 모델 업데이트
src/main/java/com/example/user/domain/model/User.java
@PrePersist에서 기본 remainToken 값을 0L에서 3000L로 변경
코드 포매팅
src/main/java/com/example/user/domain/controller/UserController.java
클래스 필드 선언과 첫 번째 메서드 사이의 빈 줄 제거

시퀀스 다이어그램

sequenceDiagram
    participant Kafka as Kafka Topic<br/>(remain-token-increase-request)
    participant Listener as KafkaEventListener
    participant Service as TokenIncreaseService
    participant Repo as UserRepository
    participant DB as Database

    Kafka->>Listener: handleTokenIncreaseRequest(MemorialBowedAvroSchema)
    Listener->>Service: increaseToken(userId, tokenCount)
    Service->>Repo: findByUserId(userId)
    Repo->>DB: Query User
    DB-->>Repo: User entity
    
    alt User Found
        Service->>Service: user.setRemainToken(current + tokenCount)
        Service->>Repo: save(user)
        Repo->>DB: Update User
        DB-->>Repo: Persisted
        Service-->>Listener: remainingToken (success)
        Listener->>Listener: buildIncreaseResponse(success=true)
        Listener->>Kafka: send RemainTokenIncreaseResponse<br/>(remain-token-increase-response topic)
    else User Not Found
        Service-->>Listener: NotFoundUserException
        Listener->>Listener: buildIncreaseResponse(success=false)
        Listener->>Kafka: send error response<br/>(remain-token-increase-fail-response topic)
    end
Loading

예상 코드 리뷰 노력

🎯 4 (복잡함) | ⏱️ ~45분

추가 검토 필요 영역:

  • KafkaEventListener.java: 기존 메서드 시그니처 변경(ChatEvent → ChatAvroSchema)이 다른 코드에 미치는 영향 확인 필요; 새로운 토큰 증가 핸들러의 Kafka 토픽 구성 및 에러 처리 로직 검증
  • TokenIncreaseService.java: 트랜잭션 경계, 동시성 제어, NotFoundUserException 발생 시나리오 처리 방식 검토
  • User.java: 기본 remainToken 값 변경(3000L)의 비즈니스 영향도 및 기존 데이터와의 호환성 확인
  • Avro 스키마 변경: ChatEvent → ChatAvroSchema 이름 변경이 기존 Kafka 메시지 역직렬화에 영향을 미치는지 검토

관련 가능성 있는 PR

  • Feature 0.2.0/decrease remain token/pw 75 #37: 동일한 KafkaEventListener 및 Avro 기반 토큰 처리 흐름 수정; 이 PR에서는 Chat Avro 스키마 이름 변경 및 리스너 시그니처 업데이트, 토큰 증가 핸들러 추가와 직접 연관
  • fix :: remain_token -> remainToken #32: User 도메인 모델의 remainToken 관련 필드 수정; 한 PR에서는 필드명 변경, 이 PR에서는 기본값 변경으로 연관됨

🐰 토큰 증가의 노래를 부르네,
삼천 개의 신비한 보상이 나타나고,
Kafka의 파도를 타고,
서비스는 춤을 추며 증가하네!
네임스페이스의 정원에 질서가 피어나니,
아름답고 함수적인 변화여~! 🌱✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed PR 제목 "remain token increase saga"는 변경사항의 주요 목표와 직접적으로 연관되어 있습니다. 원시 요약에서 확인할 수 있듯이, 이 PR의 핵심 변경사항은 KafkaEventListener에 새로운 토큰 증가 요청 핸들러를 추가하고, TokenIncreaseService를 새로 도입하며, 관련 Avro 스키마들을 추가/수정하는 것으로, 이는 모두 토큰 증가 사가 패턴 구현을 위한 것입니다. 제목은 구체적이고 명확하며 간결하여, 팀원이 히스토리를 훑어볼 때 주요 변경사항을 쉽게 파악할 수 있습니다.
✨ 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 feature-0.2.1/remain-token-increase-saga/PW-158

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.

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

🧹 Nitpick comments (4)
src/main/java/com/example/user/domain/model/User.java (1)

63-73: 토큰 증감 메서드의 타입/널 안전성 개선 제안

remainToken/파라미터의 박싱 Long과 int 혼용은 NPE/오토언박싱 이슈 소지가 있습니다. 또한 비양수 파라미터 방어가 없습니다.

다음과 같이 primitive long과 검증을 적용해 주세요:

- public void decreaseToken(int tokenCount) {
-   boolean canDecreaseRemainToken = this.remainToken >= tokenCount;
+ public void decreaseToken(long tokenCount) {
+   if (tokenCount <= 0) throw new IllegalArgumentException("tokenCount must be positive");
+   long current = this.remainToken == null ? 0L : this.remainToken;
+   boolean canDecreaseRemainToken = current >= tokenCount;
    if (!canDecreaseRemainToken) throw InsufficientRemainTokenException.getInstance();
-   this.remainToken -= tokenCount;
+   this.remainToken = current - tokenCount;
  }
 
- public void increaseToken(Long tokenCount) {
-   this.remainToken += tokenCount;
+ public void increaseToken(long tokenCount) {
+   if (tokenCount <= 0) throw new IllegalArgumentException("tokenCount must be positive");
+   long current = this.remainToken == null ? 0L : this.remainToken;
+   this.remainToken = current + tokenCount;
  }

TokenDecreaseService.decreaseToken(int) 등 서비스/DTO 타입도 long으로 통일 가능한지 점검 부탁합니다.

src/main/avro/RemainTokenIncreaseResponse.avsc (1)

1-30: 스키마 구조 깔끔합니다.

필드/네임스페이스 일관성 좋습니다.

운영 분석을 위해 error_code(string/int) 필드를 추가해 원인 분류(예: USER_NOT_FOUND, OPTIMISTIC_LOCK)까지 기계가 읽기 쉬운 형태로 전달하는 것을 고려해 보세요.

src/main/avro/MemorialBowedAvroSchema.avsc (1)

1-10: 필드 네이밍/문서화 일관성 제안

다른 스키마들은 snake_case를 주로 사용하지만 본 스키마는 camelCase입니다. 혼용은 사용 시 혼동을 줍니다.

옵션:

  • 그대로 유지하되 각 필드에 doc을 추가해 의미를 명확히 하기.
  • snake_case로 전환하면서 field aliases로 구명을 보존(점진적 마이그레이션).

예시(문서 추가):

 {
   "type": "record",
   "name": "MemorialBowedAvroSchema",
   "namespace": "windeath44.server.memorial.avro",
   "fields": [
-    {"name": "memorialId", "type": "long"},
-    {"name": "memorialBow", "type": "long"},
-    {"name": "writerId", "type": "string"}
+    {"name": "memorialId", "type": "long", "doc": "메모리얼 ID"},
+    {"name": "memorialBow", "type": "long", "doc": "절 횟수(증가 기준)"},
+    {"name": "writerId", "type": "string", "doc": "작성자(=유저) ID"}
   ]
 }
src/main/java/com/example/user/domain/eventlistener/KafkaEventListener.java (1)

63-75: 로그 키 이름 정교화(선택)

tokenBowCount 대신 memorialBow가 더 일관됩니다.

- log.info("토큰 증가 요청 수신 - userId: {}, tokenBowCount: {}", userId, memorialBowedAvroSchema.getMemorialBow());
+ log.info("토큰 증가 요청 수신 - userId: {}, memorialBow: {}", userId, memorialBowedAvroSchema.getMemorialBow());
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2a83428 and a657735.

📒 Files selected for processing (8)
  • src/main/avro/ChatAvroSchema.avsc (1 hunks)
  • src/main/avro/MemorialBowedAvroSchema.avsc (1 hunks)
  • src/main/avro/RemainTokenDecreaseResponse.avsc (1 hunks)
  • src/main/avro/RemainTokenIncreaseResponse.avsc (1 hunks)
  • src/main/java/com/example/user/domain/controller/UserController.java (0 hunks)
  • src/main/java/com/example/user/domain/eventlistener/KafkaEventListener.java (2 hunks)
  • src/main/java/com/example/user/domain/model/User.java (1 hunks)
  • src/main/java/com/example/user/domain/service/TokenIncreaseService.java (1 hunks)
💤 Files with no reviewable changes (1)
  • src/main/java/com/example/user/domain/controller/UserController.java
🧰 Additional context used
🧬 Code graph analysis (2)
src/main/java/com/example/user/domain/service/TokenIncreaseService.java (2)
src/main/java/com/example/user/domain/exception/NotFoundUserException.java (1)
  • NotFoundUserException (6-16)
src/main/java/com/example/user/domain/eventlistener/KafkaEventListener.java (1)
  • Slf4j (16-94)
src/main/java/com/example/user/domain/eventlistener/KafkaEventListener.java (3)
src/main/java/com/example/user/domain/service/TokenIncreaseService.java (1)
  • Slf4j (11-29)
src/main/java/com/example/user/domain/service/TokenDecreaseService.java (1)
  • Slf4j (11-29)
src/main/java/com/example/user/global/infrastructure/KafkaProducer.java (1)
  • Component (8-16)
🔇 Additional comments (3)
src/main/java/com/example/user/domain/eventlistener/KafkaEventListener.java (1)

26-26: 감소 리스너의 새 Avro 타입 적용 확인

ChatAvroSchema 파라미터로의 전환은 적절합니다. 프로듀서도 동일 FQN으로 전환되었는지 배포 순서만 재확인 부탁드립니다.

src/main/avro/ChatAvroSchema.avsc (1)

3-4: <!-- [scratchpad_start]
[task review_verification complete]
Consolidate findings and determine appropriate response

[observations]
VERIFIED FACTS:

  • Git history confirms: chat.avsc (ChatEvent@com.chatbot.events) → ChatAvroSchema.avsc (ChatAvroSchema@windeath44.server.chatbot.avro)
  • Old chat.avsc file DELETED from working directory
  • Code (KafkaEventListener.java) is UPDATED to use new ChatAvroSchema
  • No remaining references to old ChatEvent name
  • Current ChatAvroSchema.avsc has NO aliases field
  • Migration appears intentional and code-synchronized

[analysis]
Review comment assessment:

  • VALID CONCERN: Schema name/namespace migration can break old Kafka messages
  • VALID SUGGESTION: Alias would provide backward compatibility
  • CONTEXT MATTERS: Since old file is deleted and code is migrated, this appears to be controlled migration
  • BEST PRACTICE: Aliases should be added for safe schema evolution
  • MISSING INFO: Whether old Kafka messages exist and need to be consumed

[conclusion]

  • Review comment correctly identifies compatibility risk
  • Suggestion to add alias is appropriate best practice
  • However, deployment order/strategy needs verification
  • Tag: - valid concern but requires manual verification of deployment/rollout context

[done]
Ready to generate rewritten review in Korean (ko-KR)
</scratchpad_end] -->

레코드 이름/네임스페이스 변경의 하위 호환성 보장 필요 — 레코드 Alias 추가 권장

검증 결과, chat.avsc (ChatEvent@com.chatbot.events) → ChatAvroSchema (windeath44.server.chatbot.avro) 마이그레이션이 확인되었습니다. 코드는 이미 새 스키마로 업데이트되었으나, 기존 Kafka 토픽에 이전 스키마 형식으로 발행된 메시지가 있다면 역직렬화 실패가 발생할 수 있습니다.

Avro 스키마 진화 모범 사례에 따라 alias를 추가하여 하위 호환성을 확보하세요:

 {
   "type": "record",
   "name": "ChatAvroSchema",
+  "aliases": ["ChatEvent"],
   "namespace": "windeath44.server.chatbot.avro",

또한 프로듀서 업그레이드 일정과 기존 메시지 소비 계획을 확인해 주세요.

src/main/avro/RemainTokenDecreaseResponse.avsc (1)

4-4: 기존 네임스페이스 검증 필요 — 코드베이스에서 com.example.user.avro 확인 불가

현재 리포지토리에서 검증한 결과:

  • RemainTokenDecreaseResponse.avsc: windeath44.server.user.avro 네임스페이스만 포함, alias 없음
  • Java 코드: 이미 새 네임스페이스(windeath44.server.user.avro) 사용 중
  • 기존 네임스페이스(com.example.user.avro)는 코드베이스에서 발견되지 않음

리뷰 의견의 주의사항은 타당하나, 다음을 확인해야 합니다:

  • 이전 버전에서 실제로 기존 네임스페이스를 사용했는지
  • 외부 시스템(다른 서비스)의 프로듀서가 기존 네임스페이스로 메시지를 발행하는지
  • Confluent Schema Registry의 호환성 모드 설정 및 subject naming strategy

추천: PR 변경 전후 비교, 스키마 레지스트리 설정, 외부 프로듀서 상태를 확인 후 alias 추가 필요 여부를 판단하세요.

Comment on lines +59 to +69
@KafkaListener(topics = "remain-token-increase-request", groupId = "user")
@Transactional
public void handleTokenIncreaseRequest(MemorialBowedAvroSchema memorialBowedAvroSchema) {
String userId = memorialBowedAvroSchema.getWriterId();
log.info("토큰 증가 요청 수신 - userId: {}, tokenBowCount: {}", userId, memorialBowedAvroSchema.getMemorialBow());

try {
Long remainingToken = tokenIncreaseService.increaseToken(
userId,
1000L
);
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

증가량 1000L 하드코딩 — 이벤트/설정 기반으로 계산하세요

memorialBow를 로그에만 쓰고 실제 증가는 상수 1000L입니다. 비즈니스 규칙 변경에 취약합니다.

다음처럼 설정값과 이벤트 값을 결합해 계산을 외부화하세요:

- Long remainingToken = tokenIncreaseService.increaseToken(
-         userId,
-         1000L
- );
+ long tokensToAdd = tokensPerBow * memorialBowedAvroSchema.getMemorialBow();
+ Long remainingToken = tokenIncreaseService.increaseToken(userId, tokensToAdd);

추가(클래스 필드):

@Value("${token.increase.per-bow:1000}")
private long tokensPerBow;

정책이 “bow 1회당 N 토큰”인지 확인 부탁드립니다.


🏁 Script executed:

#!/bin/bash
# Find the KafkaEventListener file and examine it
find . -name "KafkaEventListener.java" -type f 2>/dev/null | head -5

Length of output: 77


🏁 Script executed:

#!/bin/bash
# Search for the handleTokenIncreaseRequest method and surrounding context
rg -n -A 15 "handleTokenIncreaseRequest" --type java -B 3 2>/dev/null | head -50

Length of output: 2349


🏁 Script executed:

#!/bin/bash
# Look for token increase configuration patterns
rg -n "token.*increase\|@Value.*token\|tokensPerBow" --type java -C 3 2>/dev/null | head -40

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Search for tokenIncreaseService implementation
rg -n "tokenIncreaseService\s*=" --type java -A 5 -B 2 2>/dev/null | head -30

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Look for MemorialBowedAvroSchema structure
rg -n "class MemorialBowedAvroSchema\|getMemorialBow\|getWriterId" --type java -A 10 2>/dev/null | head -50

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check for any existing token increase configurations in application properties
find . -name "application*.properties" -o -name "application*.yml" -o -name "application*.yaml" 2>/dev/null | xargs grep -l "token" 2>/dev/null | head -5

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Look for the increaseToken method signature
rg -n "increaseToken\s*\(" --type java -B 3 -A 5 2>/dev/null | head -50

Length of output: 3077


🏁 Script executed:

#!/bin/bash
# Check if memorialBow value is used elsewhere in the codebase
rg -n "memorialBow\|getMemorialBow" --type java -C 2 2>/dev/null | head -60

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check imports and dependencies in KafkaEventListener
rg -n "^import\|^package" src/main/java/com/example/user/domain/eventlistener/KafkaEventListener.java 2>/dev/null

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Look at the complete method implementation
cat src/main/java/com/example/user/domain/eventlistener/KafkaEventListener.java 2>/dev/null | grep -A 30 "handleTokenIncreaseRequest"

Length of output: 1493


🏁 Script executed:

#!/bin/bash
# Check for @Value or @Configuration annotations in the file
rg -n "@Value\|@Configuration\|@PropertySource" src/main/java/com/example/user/domain/eventlistener/KafkaEventListener.java 2>/dev/null

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Search for similar KafkaListener methods to check pattern consistency
rg -n "@KafkaListener" --type java -A 8 2>/dev/null | head -60

Length of output: 2362


🏁 Script executed:

#!/bin/bash
# Look for constants or enums related to token values
rg -n "1000L\|TOKEN.*INCREASE\|per.*bow\|per.*bow" --type java -i -C 2 2>/dev/null | head -50

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check if there's a service layer or util that might handle token calculation
rg -n "class.*TokenService\|interface.*TokenService" --type java -A 5 2>/dev/null | head -30

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Look for memorialBow in logs or business logic
rg -n "memorialBow\|getMemorialBow" --type java -B 2 -A 2 2>/dev/null

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check for test files that might clarify the business logic
find . -path "*/test/*" -name "*TokenIncrease*" -o -path "*/test/*" -name "*KafkaListener*" 2>/dev/null | head -10

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Look at the complete KafkaEventListener file
cat src/main/java/com/example/user/domain/eventlistener/KafkaEventListener.java 2>/dev/null

Length of output: 4209


🏁 Script executed:

#!/bin/bash
# Search for any comments or documentation about token increase logic
rg -n "토큰|token" --type java src/main/java/com/example/user/domain/eventlistener/ 2>/dev/null

Length of output: 2904


🏁 Script executed:

#!/bin/bash
# Look for enum or constant definitions for token values
rg -n "enum\|final.*1000\|public.*static.*TOKEN" --type java -i 2>/dev/null | grep -i token | head -30

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check the entire event listener directory structure
find src/main/java/com/example/user/domain/eventlistener -type f 2>/dev/null

Length of output: 75


🏁 Script executed:

#!/bin/bash
# Look at application configuration files for token settings
find . -type f \( -name "*.properties" -o -name "*.yml" -o -name "*.yaml" \) 2>/dev/null | xargs grep -i "token\|bow" 2>/dev/null | head -20

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check pom.xml or build.gradle for any token-related dependencies
find . -name "pom.xml" -o -name "build.gradle" 2>/dev/null | xargs grep -i "token" 2>/dev/null | head -20

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Look for @SpringBootTest or @ContextConfiguration that might indicate integration tests
find . -path "*/test/*" -name "*.java" -type f 2>/dev/null | xargs grep -l "KafkaEventListener\|handleTokenIncreaseRequest" 2>/dev/null | head -5

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Final check: get the exact code snippet around lines 59-69
sed -n '50,75p' src/main/java/com/example/user/domain/eventlistener/KafkaEventListener.java 2>/dev/null

Length of output: 1211


🏁 Script executed:

#!/bin/bash
# Check if there's any README or documentation
find . -maxdepth 3 -type f \( -name "README*" -o -name "*.md" \) 2>/dev/null | xargs grep -i "token\|bow" 2>/dev/null | head -20

Length of output: 0


토큰 증가량 하드코딩 제거 필요 — 이벤트 기반 계산으로 변경하세요

검증 결과, 코드에서 실제로 memorialBow 값을 로그에만 출력하고 토큰 증가 계산에는 사용하지 않습니다. handleTokenDecreaseRequest와 달리 이벤트 데이터를 무시하고 있습니다.

TokenIncreaseService.increaseToken()은 동적인 토큰 개수를 수용하도록 설계되었으므로, 제안된 방식대로 개선하세요:

- Long remainingToken = tokenIncreaseService.increaseToken(
-         userId,
-         1000L
- );
+ long tokensToAdd = tokensPerBow * memorialBowedAvroSchema.getMemorialBow();
+ Long remainingToken = tokenIncreaseService.increaseToken(userId, tokensToAdd);

클래스에 다음을 추가합니다:

@Value("${token.increase.per-bow:1000}")
private long tokensPerBow;
🤖 Prompt for AI Agents
In src/main/java/com/example/user/domain/eventlistener/KafkaEventListener.java
around lines 59 to 69, the handler logs memorialBow but hardcodes 1000L when
calling tokenIncreaseService.increaseToken; add a configurable per-bow
multiplier and use the event's memorialBow to compute the actual increase
amount. Add a field annotated with @Value("${token.increase.per-bow:1000}")
private long tokensPerBow; then compute long increaseAmount =
memorialBowedAvroSchema.getMemorialBow() * tokensPerBow (guarding against
null/negative values as needed) and pass increaseAmount to increaseToken(userId,
increaseAmount) instead of the hardcoded 1000L.

Comment on lines +59 to +84
@KafkaListener(topics = "remain-token-increase-request", groupId = "user")
@Transactional
public void handleTokenIncreaseRequest(MemorialBowedAvroSchema memorialBowedAvroSchema) {
String userId = memorialBowedAvroSchema.getWriterId();
log.info("토큰 증가 요청 수신 - userId: {}, tokenBowCount: {}", userId, memorialBowedAvroSchema.getMemorialBow());

try {
Long remainingToken = tokenIncreaseService.increaseToken(
userId,
1000L
);

RemainTokenIncreaseResponse response = buildIncreaseResponse(userId, true, remainingToken, null);
kafkaProducer.send("remain-token-increase-response", response);

log.info("토큰 증가 성공 - userId: {}, remainingToken: {}", userId, remainingToken);

} catch (Exception e) {
log.error("토큰 증가 실패 - userId: {}, error: {}", userId, e.getMessage(), e);

RemainTokenIncreaseResponse response = buildIncreaseResponse(userId, false, null, e.getMessage());
kafkaProducer.send("remain-token-increase-fail-response", response);

log.info("토큰 증가 실패 응답 발송 완료 - userId: {}", userId);
}
}
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

멱등성/중복 소비 대비 및 트랜잭션 경계 정렬 필요

Kafka는 최소 1회 전달입니다. 현재는 이벤트 키/ID 추적이 없어 재소비 시 토큰이 중복 증가합니다. 또한 DB 트랜잭션과 Kafka 전송이 분리돼 있어 불일치가 생길 수 있습니다.

권장:

  • 멱등성: memorialId(또는 이벤트 ID) 기준 processed 이벤트 테이블을 두고 UPSERT로 처리, 중복 시 무해하게 무시.
  • 트랜잭션 경계: Outbox 패턴(테이블 → Debezium) 또는 Kafka 트랜잭션(Chained TM / kafkaTemplate.executeInTransaction)으로 DB 커밋과 메시지 전송의 원자성을 확보.

검증 스크립트(레포 내 멱등/버전/아웃박스 존재 여부 탐색):


🏁 Script executed:

#!/bin/bash
# 멱등/락/아웃박스 단서 검색
rg -nI -C2 -e '@Version\b' -e 'OptimisticLock' -e 'outbox' -e 'processed[_-]?event' -e 'executeInTransaction' -e 'KafkaTransactionManager'

Length of output: 0


추가 검증을 위해 코드베이스 구조를 더 깊이 있게 조사하겠습니다.


---

🏁 Script executed:

```shell
#!/bin/bash
# User 엔티티 및 tokenIncreaseService 구현 확인
rg -nI -A5 'class.*User\b|class.*TokenIncreaseService' --type java

Length of output: 386


🏁 Script executed:

#!/bin/bash
# kafkaProducer 구현 및 bean 정의 확인
rg -nI -B2 -A8 'kafkaProducer\s*\.send\(|kafkaTemplate' --type java

Length of output: 2288


🏁 Script executed:

#!/bin/bash
# 현재 @Transactional 범위 및 트랜잭션 관련 설정 확인
rg -nI -B1 -A3 '@Transactional|transactionManager|propagation' --type java

Length of output: 1836


🏁 Script executed:

#!/bin/bash
# memorialId 또는 유사 식별자의 유니크 제약 확인
rg -nI -B2 -A2 '@Unique|@Column.*unique|unique constraint' --type java

Length of output: 117


추가 검증: KafkaTemplate 설정 및 에러 처리 메커니즘을 확인하겠습니다.


---

🏁 Script executed:

```shell
#!/bin/bash
# KafkaTemplate bean 설정 및 retry/error 핸들러 확인
rg -nI -B3 -A10 'KafkaTemplate.*Bean|@Bean.*kafka|ProducerFactory|kafkaTemplate\(' --type java

Length of output: 0


🏁 Script executed:

#!/bin/bash
# DLQ, error-topic, retryTemplate 검색
rg -nI -C3 'dead.*letter|error.*topic|dlt|retryTemplate' --type java --type properties --type yml

Length of output: 34


🏁 Script executed:

#!/bin/bash
# 콜백 또는 ListenableFuture 사용 확인
rg -nI -C2 'ListenableFuture|KafkaSendCallback|addCallback' --type java

Length of output: 0


DB 트랜잭션과 Kafka 메시지 전송의 원자성 부재 - 중복 소비 및 데이터 불일치 위험

검증 결과, 리뷰 코멘트의 우려사항들이 모두 확인되었습니다:

  • 멱등성: processed_event 테이블이나 중복 제거 메커니즘이 없어, Kafka 최소 1회 재전달 시 토큰이 중복 증가됩니다.
  • 트랜잭션 경계: KafkaEventListener.handleTokenIncreaseRequest()@Transactional은 DB 업데이트만 커버하고, kafkaProducer.send()는 콜백/에러 처리 없이 fire-and-forget으로 동작하여 DB 커밋 후 메시지 전송 실패 시 불일치가 발생합니다.
  • 버전 제어 부재: User 엔티티에 @Version 없어 동시 토큰 증가 요청 시 레이스 컨디션 위험.

필수 조치:

  • processed_event 테이블 추가 + memorialBowedAvroSchema ID 기준 UPSERT로 멱등성 확보
  • Outbox 패턴 또는 KafkaTemplate.executeInTransaction() 으로 DB 커밋과 메시지 전송의 원자성 보장
  • User 엔티티에 @Version 추가 (낙관적 잠금)
  • KafkaProducer.send()에 콜백 추가하여 전송 실패 처리
🤖 Prompt for AI Agents
In src/main/java/com/example/user/domain/eventlistener/KafkaEventListener.java
around lines 59-84, the handler currently risks duplicate token increments and
DB/Kafka inconsistencies because it lacks idempotency, an atomic DB+message
boundary, optimistic locking, and send callbacks. Fix by: (1) Introduce a
processed_event table and, at the start of the @Transactional method, UPSERT the
incoming memorialBowedAvroSchema ID and skip processing if already present to
provide idempotency; (2) Change message emission to the Outbox pattern (insert
an outbox row in the same DB transaction) or use
KafkaTemplate.executeInTransaction()/a transactional producer so DB commit and
message publish are atomic; (3) Add @Version to the User entity for optimistic
locking and handle OptimisticLockExceptions with retries; (4) Add a
callback/confirm handler to kafkaProducer.send() (or process outbox send result)
to log and retry on failure and mark outbox rows as sent. Ensure all DB state
changes plus outbox insert occur inside the same @Transactional boundary and
that no fire-and-forget sends happen outside that atomic scope.

Comment on lines 35 to 40
@PrePersist
public void defaultSettings() {
this.remainToken = 0L;
this.remainToken = 3000L;
String defaultImage = "https://windeath44.s3.ap-northeast-2.amazonaws.com/seori_profile.png";
this.profile = defaultImage;
}
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

@PrePersist가 명시적으로 지정된 값을 덮어씁니다 — null일 때만 기본값을 세팅하세요.

빌더/DTO로 remainToken 또는 profile을 미리 지정한 경우에도 항상 3000L/기본 이미지로 덮어씁니다. 신규/테스트/마이그레이션 시 의도치 않은 값 손실 위험이 있습니다.

다음처럼 null일 때만 기본값을 적용해 주세요:

   @PrePersist
   public void defaultSettings() {
-    this.remainToken = 3000L;
+    if (this.remainToken == null) {
+      this.remainToken = 3000L;
+    }
     String defaultImage = "https://windeath44.s3.ap-northeast-2.amazonaws.com/seori_profile.png";
-    this.profile = defaultImage;
+    if (this.profile == null) {
+      this.profile = defaultImage;
+    }
   }

기존 DB에 remain_token이 null인 레코드가 없는지, 생성 시점에 값 주입 케이스가 있는지 확인 부탁드립니다.


🏁 Script executed:

#!/bin/bash
# Find and display the User.java file to inspect field types and full context
fd -t f "User\.java" --path "**/domain/model/**" 2>/dev/null | head -5

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Search for User class field declarations
rg -n -A 50 "class User" --type=java --path "src/main/java/com/example/user/domain/model/" 2>/dev/null | head -80

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Look for builder patterns or constructors for User entity
rg -n "@Builder|public User\(|\.remainToken\(|\.profile\(" --type=java 2>/dev/null | head -30

Length of output: 206


🏁 Script executed:

#!/bin/bash
# Check for test files that create User instances
fd -t f "User.*Test\.java" --path "**test**" 2>/dev/null | head -5

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Search for @PrePersist usage in the codebase to see if this pattern is common
rg -n "@PrePersist" --type=java 2>/dev/null

Length of output: 70


🏁 Script executed:

#!/bin/bash
# Read the full User.java file directly
cat src/main/java/com/example/user/domain/model/User.java

Length of output: 2003


🏁 Script executed:

#!/bin/bash
# Search for remainToken and profile usage/initialization
rg -n "remainToken|profile" --type=java -C 2 | head -60

Length of output: 5454


🏁 Script executed:

#!/bin/bash
# Find service/controller files that create User instances
fd -t f ".*Service\.java|.*Controller\.java" src/main/java/com/example/user/ 2>/dev/null | head -10

Length of output: 648


@PrePersist에서 null이 아닌 값을 무조건 덮어씁니다 — 빌더 패턴이 무시됩니다

검증 결과:

  • remainTokenLong (래퍼 타입, null 가능)
  • profileString (null 가능)
  • @Builder 패턴으로 사전 지정된 값이 있어도 항상 3000L/기본 이미지로 재설정됨
  • 예: User.builder().remainToken(5000L).build() 저장 시 → 3000L로 덮어써짐 (의도 위배)

제안하신 null-check 리팩토링이 반드시 필요합니다:

   @PrePersist
   public void defaultSettings() {
-    this.remainToken = 3000L;
+    if (this.remainToken == null) {
+      this.remainToken = 3000L;
+    }
     String defaultImage = "https://windeath44.s3.ap-northeast-2.amazonaws.com/seori_profile.png";
-    this.profile = defaultImage;
+    if (this.profile == null) {
+      this.profile = defaultImage;
+    }
   }
📝 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
@PrePersist
public void defaultSettings() {
this.remainToken = 0L;
this.remainToken = 3000L;
String defaultImage = "https://windeath44.s3.ap-northeast-2.amazonaws.com/seori_profile.png";
this.profile = defaultImage;
}
@PrePersist
public void defaultSettings() {
if (this.remainToken == null) {
this.remainToken = 3000L;
}
String defaultImage = "https://windeath44.s3.ap-northeast-2.amazonaws.com/seori_profile.png";
if (this.profile == null) {
this.profile = defaultImage;
}
}
🤖 Prompt for AI Agents
In src/main/java/com/example/user/domain/model/User.java around lines 35 to 40,
the @PrePersist method always overwrites remainToken and profile which breaks
any values set by the builder; change the logic to only apply defaults when the
fields are null (e.g., if remainToken == null then set to 3000L; if profile ==
null or blank then set the default image URL) so existing explicit values from
the builder are preserved.

Comment on lines +17 to +28
@Transactional
public Long increaseToken(String userId, Long tokenCount) {
User user = userRepository.findByUserId(userId)
.orElseThrow(NotFoundUserException::getInstance);

user.increaseToken(tokenCount);
userRepository.save(user);

log.info("토큰 증가 완료 - userId: {}, 증가량: {}, 남은 토큰: {}", userId, tokenCount, user.getRemainToken());

return user.getRemainToken();
}
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

동시성 갱신 손실 위험 — 원자적 증가 또는 낙관적 락/재시도 적용 필요

동시에 여러 증가/감소가 발생하면 마지막 커밋이 이전 값을 덮어써 토큰이 유실될 수 있습니다. 현재 User@Version도 없습니다.

대안(둘 중 하나 권장):

  • A. 낙관적 락 + 재시도(권장)
    • User@Version Long version 추가
    • OptimisticLockingFailureException 재시도 정책 적용(예: 3~5회 지수 백오프)
  • B. 원자적 UPDATE 쿼리
    • 리포지토리에 증분 쿼리 추가 후 영향 행 수 확인

또한 입력 검증을 추가해 주세요(널/비양수 방지). 예시:

   @Transactional
   public Long increaseToken(String userId, Long tokenCount) {
+    if (tokenCount == null || tokenCount <= 0) {
+        throw new IllegalArgumentException("tokenCount must be positive");
+    }
     User user = userRepository.findByUserId(userId)
             .orElseThrow(NotFoundUserException::getInstance);
-    user.increaseToken(tokenCount);
-    userRepository.save(user);
+    user.increaseToken(tokenCount); // JPA Dirty checking으로 flush
     log.info("토큰 증가 완료 - userId: {}, 증가량: {}, 남은 토큰: {}", userId, tokenCount, user.getRemainToken());
     return user.getRemainToken();
   }

추가 예시(원자적 업데이트, 리포지토리):

// UserRepository.java
@Modifying
@Query("update User u set u.remainToken = u.remainToken + :token where u.userId = :userId")
int increaseRemainToken(@Param("userId") String userId, @Param("token") long token);

그리고 성공 후 현재 값 조회:

if (userRepository.increaseRemainToken(userId, tokenCount) != 1) throw NotFoundUserException.getInstance();
Long remaining = userRepository.findRemainTokenByUserId(userId);

User 엔티티에 @Version 존재 여부, 또는 위와 유사한 원자적 쿼리 존재 여부를 확인 부탁드립니다.


🏁 Script executed:

#!/bin/bash
# 1. User 엔티티 파일 위치 찾기
fd -e java -e kt | xargs rg -l "class User" --type=java | head -5

Length of output: 317


🏁 Script executed:

#!/bin/bash
# 2. User 엔티티 @Version 존재 확인
fd -e java | xargs rg -nP "@Version|@Entity.*User" --type=java -B5 -A10 | grep -E "(User|@Version)" | head -20

Length of output: 0


🏁 Script executed:

#!/bin/bash
# 3. UserRepository 파일 찾기 및 atomic UPDATE 쿼리 확인
fd -e java -e kt | xargs rg -l "interface UserRepository|class UserRepository" --type=java

Length of output: 68


🏁 Script executed:

#!/bin/bash
# 4. UserRepository의 모든 @Modifying 및 @Query 메서드 검색
find . -name "*UserRepository*" -type f | xargs cat 2>/dev/null | grep -A3 "@Modifying\|@Query.*update\|@Query.*UPDATE"

Length of output: 0


🏁 Script executed:

#!/bin/bash
# 5. TokenIncreaseService의 현재 전체 구현 확인
find . -path "*/user/domain/service/TokenIncreaseService*" -type f | xargs cat 2>/dev/null

Length of output: 954


🏁 Script executed:

#!/bin/bash
# 6. increaseToken 메서드(User 엔티티)의 구현 확인
fd -e java | xargs rg -nP "increaseToken\s*\(" --type=java -B2 -A5

Length of output: 2666


🏁 Script executed:

#!/bin/bash
# User.java 전체 내용 조회
cat src/main/java/com/example/user/domain/model/User.java

Length of output: 2003


🏁 Script executed:

#!/bin/bash
# UserRepository.java 전체 내용 조회
cat src/main/java/com/example/user/domain/repository/UserRepository.java

Length of output: 712


🏁 Script executed:

#!/bin/bash
# TokenIncreaseService에서 tokenCount 검증 여부 확인
rg -nP "tokenCount.*null|tokenCount.*<=|tokenCount.*validate" src/main/java/com/example/user/domain/service/TokenIncreaseService.java --type=java

Length of output: 0


동시성 갱신 손실 위험 — 원자적 증가 또는 낙관적 락/재시도 적용 필수

User 엔티티에 @Version이 없고, UserRepository에 원자적 UPDATE 쿼리가 없습니다. 현재 READ-MODIFY-WRITE 패턴에서 동시에 여러 증가가 발생하면 마지막 커밋이 이전 값을 덮어써 토큰 손실이 발생합니다. (KafkaEventListener에서 동시 호출 시나리오 존재)

추가로 increaseToken 메서드에서 입력 검증이 없습니다. 반면 decreaseToken은 검증이 있으므로 일관성을 위해서도 필요합니다.

필수 수정 사항 (둘 중 하나 적용):

  • A. 낙관적 락 + 재시도 (권장)

    • User 엔티티에 @Version Long version 추가
    • OptimisticLockingFailureException 재시도 정책 적용 (3~5회 지수 백오프)
  • B. 원자적 UPDATE 쿼리

    • UserRepository@Modifying @Query("update User u set u.remainToken = u.remainToken + :token where u.userId = :userId") 메서드 추가
    • 영향 행 수 1 확인 후 현재 값 조회

입력 검증 추가:

   @Transactional
   public Long increaseToken(String userId, Long tokenCount) {
+    if (tokenCount == null || tokenCount <= 0) {
+        throw new IllegalArgumentException("tokenCount must be positive");
+    }
     User user = userRepository.findByUserId(userId)
             .orElseThrow(NotFoundUserException::getInstance);
     user.increaseToken(tokenCount);
     userRepository.save(user);
     log.info("토큰 증가 완료 - userId: {}, 증가량: {}, 남은 토큰: {}", userId, tokenCount, user.getRemainToken());
     return user.getRemainToken();
   }

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

🤖 Prompt for AI Agents
In src/main/java/com/example/user/domain/service/TokenIncreaseService.java
around lines 17 to 28: the current read-modify-write token increase risks lost
updates under concurrent calls and lacks input validation; fix by either (A)
adding optimistic locking and retries: add a @Version Long version field to the
User entity, validate tokenCount (non-null, >0) at the start of increaseToken,
catch OptimisticLockingFailureException and retry the increase up to 3–5 times
with exponential backoff before failing; or (B) implement an atomic update in
UserRepository with a @Modifying @Query that does "update User u set
u.remainToken = u.remainToken + :token where u.userId = :userId", validate
tokenCount (non-null, >0), execute the update, verify affectedRows == 1 and then
re-query the current token value to return; ensure logging remains and surface a
clear error if validation fails or retries exhausted.

@yuuki08noah yuuki08noah merged commit 393ef7c into dev-0.2 Oct 27, 2025
1 check passed
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.

2 participants