-
Couldn't load subscription status.
- Fork 0
remain token increase saga #38
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| { | ||
| "type": "record", | ||
| "name": "MemorialBowedAvroSchema", | ||
| "namespace": "windeath44.server.memorial.avro", | ||
| "fields": [ | ||
| {"name": "memorialId", "type": "long"}, | ||
| {"name": "memorialBow", "type": "long"}, | ||
| {"name": "writerId", "type": "string"} | ||
| ] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| { | ||
| "type": "record", | ||
| "name": "RemainTokenIncreaseResponse", | ||
| "namespace": "windeath44.server.user.avro", | ||
| "doc": "토큰 증가 응답 스키마", | ||
| "fields": [ | ||
| { | ||
| "name": "user_id", | ||
| "type": "string", | ||
| "doc": "사용자 ID" | ||
| }, | ||
| { | ||
| "name": "success", | ||
| "type": "boolean", | ||
| "doc": "토큰 증가 성공 여부" | ||
| }, | ||
| { | ||
| "name": "remaining_token", | ||
| "type": ["null", "long"], | ||
| "default": null, | ||
| "doc": "남은 토큰 수 (성공 시)" | ||
| }, | ||
| { | ||
| "name": "error_message", | ||
| "type": ["null", "string"], | ||
| "default": null, | ||
| "doc": "에러 발생 시 에러 메시지" | ||
| } | ||
| ] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,25 +1,29 @@ | ||
| package com.example.user.domain.eventlistener; | ||
|
|
||
| import com.chatbot.events.ChatEvent; | ||
| import com.example.user.avro.RemainTokenDecreaseResponse; | ||
| import windeath44.server.chatbot.avro.ChatAvroSchema; | ||
| import com.example.user.domain.service.TokenDecreaseService; | ||
| import com.example.user.domain.service.TokenIncreaseService; | ||
| import com.example.user.global.infrastructure.KafkaProducer; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.kafka.annotation.KafkaListener; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
| import windeath44.server.memorial.avro.MemorialBowedAvroSchema; | ||
| import windeath44.server.user.avro.RemainTokenDecreaseResponse; | ||
| import windeath44.server.user.avro.RemainTokenIncreaseResponse; | ||
|
|
||
| @Slf4j | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class KafkaEventListener { | ||
| private final TokenDecreaseService tokenDecreaseService; | ||
| private final TokenIncreaseService tokenIncreaseService; | ||
| private final KafkaProducer kafkaProducer; | ||
|
|
||
| @KafkaListener(topics = "remain-token-decrease-request", groupId = "user") | ||
| @Transactional | ||
| public void handleTokenDecreaseRequest(ChatEvent request) { | ||
| public void handleTokenDecreaseRequest(ChatAvroSchema request) { | ||
| log.info("토큰 감소 요청 수신 - userId: {}, tokenCount: {}", request.getUserId(), request.getTotalTokenCount()); | ||
|
|
||
| try { | ||
|
|
@@ -51,5 +55,41 @@ private static RemainTokenDecreaseResponse buildResponse(String userId, boolean | |
| .setErrorMessage(errorMessage) | ||
| .build(); | ||
| } | ||
|
|
||
| @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); | ||
| } | ||
| } | ||
|
Comment on lines
+59
to
+84
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain멱등성/중복 소비 대비 및 트랜잭션 경계 정렬 필요 Kafka는 최소 1회 전달입니다. 현재는 이벤트 키/ID 추적이 없어 재소비 시 토큰이 중복 증가합니다. 또한 DB 트랜잭션과 Kafka 전송이 분리돼 있어 불일치가 생길 수 있습니다. 권장:
검증 스크립트(레포 내 멱등/버전/아웃박스 존재 여부 탐색): 🏁 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 추가 검증을 위해 코드베이스 구조를 더 깊이 있게 조사하겠습니다. Length of output: 386 🏁 Script executed: #!/bin/bash
# kafkaProducer 구현 및 bean 정의 확인
rg -nI -B2 -A8 'kafkaProducer\s*\.send\(|kafkaTemplate' --type javaLength of output: 2288 🏁 Script executed: #!/bin/bash
# 현재 @Transactional 범위 및 트랜잭션 관련 설정 확인
rg -nI -B1 -A3 '@Transactional|transactionManager|propagation' --type javaLength of output: 1836 🏁 Script executed: #!/bin/bash
# memorialId 또는 유사 식별자의 유니크 제약 확인
rg -nI -B2 -A2 '@Unique|@Column.*unique|unique constraint' --type javaLength of output: 117 추가 검증: KafkaTemplate 설정 및 에러 처리 메커니즘을 확인하겠습니다. 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 ymlLength of output: 34 🏁 Script executed: #!/bin/bash
# 콜백 또는 ListenableFuture 사용 확인
rg -nI -C2 'ListenableFuture|KafkaSendCallback|addCallback' --type javaLength of output: 0 DB 트랜잭션과 Kafka 메시지 전송의 원자성 부재 - 중복 소비 및 데이터 불일치 위험 검증 결과, 리뷰 코멘트의 우려사항들이 모두 확인되었습니다:
필수 조치:
🤖 Prompt for AI Agents |
||
|
|
||
| private static RemainTokenIncreaseResponse buildIncreaseResponse(String userId, boolean success, Long remainingToken, String errorMessage) { | ||
| return RemainTokenIncreaseResponse.newBuilder() | ||
| .setUserId(userId) | ||
| .setSuccess(success) | ||
| .setRemainingToken(remainingToken) | ||
| .setErrorMessage(errorMessage) | ||
| .build(); | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -34,7 +34,7 @@ public class User { | |||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| @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; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
35
to
40
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain@PrePersist가 명시적으로 지정된 값을 덮어씁니다 — null일 때만 기본값을 세팅하세요. 빌더/DTO로 다음처럼 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에 🏁 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 -5Length 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 -80Length 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 -30Length 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 -5Length 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/nullLength of output: 70 🏁 Script executed: #!/bin/bash
# Read the full User.java file directly
cat src/main/java/com/example/user/domain/model/User.javaLength of output: 2003 🏁 Script executed: #!/bin/bash
# Search for remainToken and profile usage/initialization
rg -n "remainToken|profile" --type=java -C 2 | head -60Length 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 -10Length of output: 648 @PrePersist에서 null이 아닌 값을 무조건 덮어씁니다 — 빌더 패턴이 무시됩니다 검증 결과:
제안하신 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| package com.example.user.domain.service; | ||
|
|
||
| import com.example.user.domain.exception.NotFoundUserException; | ||
| import com.example.user.domain.model.User; | ||
| import com.example.user.domain.repository.UserRepository; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| @Slf4j | ||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class TokenIncreaseService { | ||
| private final UserRepository userRepository; | ||
|
|
||
| @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(); | ||
| } | ||
|
Comment on lines
+17
to
+28
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain동시성 갱신 손실 위험 — 원자적 증가 또는 낙관적 락/재시도 적용 필요 동시에 여러 증가/감소가 발생하면 마지막 커밋이 이전 값을 덮어써 토큰이 유실될 수 있습니다. 현재 대안(둘 중 하나 권장):
또한 입력 검증을 추가해 주세요(널/비양수 방지). 예시: @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);
🏁 Script executed: #!/bin/bash
# 1. User 엔티티 파일 위치 찾기
fd -e java -e kt | xargs rg -l "class User" --type=java | head -5Length 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 -20Length of output: 0 🏁 Script executed: #!/bin/bash
# 3. UserRepository 파일 찾기 및 atomic UPDATE 쿼리 확인
fd -e java -e kt | xargs rg -l "interface UserRepository|class UserRepository" --type=javaLength 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/nullLength of output: 954 🏁 Script executed: #!/bin/bash
# 6. increaseToken 메서드(User 엔티티)의 구현 확인
fd -e java | xargs rg -nP "increaseToken\s*\(" --type=java -B2 -A5Length of output: 2666 🏁 Script executed: #!/bin/bash
# User.java 전체 내용 조회
cat src/main/java/com/example/user/domain/model/User.javaLength of output: 2003 🏁 Script executed: #!/bin/bash
# UserRepository.java 전체 내용 조회
cat src/main/java/com/example/user/domain/repository/UserRepository.javaLength 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=javaLength of output: 0 동시성 갱신 손실 위험 — 원자적 증가 또는 낙관적 락/재시도 적용 필수
추가로 필수 수정 사항 (둘 중 하나 적용):
입력 검증 추가: @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();
}
🤖 Prompt for AI Agents |
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
증가량 1000L 하드코딩 — 이벤트/설정 기반으로 계산하세요
memorialBow를 로그에만 쓰고 실제 증가는 상수 1000L입니다. 비즈니스 규칙 변경에 취약합니다.다음처럼 설정값과 이벤트 값을 결합해 계산을 외부화하세요:
추가(클래스 필드):
정책이 “bow 1회당 N 토큰”인지 확인 부탁드립니다.
🏁 Script executed:
Length of output: 77
🏁 Script executed:
Length of output: 2349
🏁 Script executed:
Length of output: 0
🏁 Script executed:
Length of output: 0
🏁 Script executed:
Length of output: 0
🏁 Script executed:
Length of output: 0
🏁 Script executed:
Length of output: 3077
🏁 Script executed:
Length of output: 0
🏁 Script executed:
Length of output: 0
🏁 Script executed:
Length of output: 1493
🏁 Script executed:
Length of output: 0
🏁 Script executed:
Length of output: 2362
🏁 Script executed:
Length of output: 0
🏁 Script executed:
Length of output: 0
🏁 Script executed:
Length of output: 0
🏁 Script executed:
Length of output: 0
🏁 Script executed:
Length of output: 4209
🏁 Script executed:
Length of output: 2904
🏁 Script executed:
Length of output: 0
🏁 Script executed:
Length of output: 75
🏁 Script executed:
Length of output: 0
🏁 Script executed:
Length of output: 0
🏁 Script executed:
Length of output: 0
🏁 Script executed:
Length of output: 1211
🏁 Script executed:
Length of output: 0
토큰 증가량 하드코딩 제거 필요 — 이벤트 기반 계산으로 변경하세요
검증 결과, 코드에서 실제로
memorialBow값을 로그에만 출력하고 토큰 증가 계산에는 사용하지 않습니다.handleTokenDecreaseRequest와 달리 이벤트 데이터를 무시하고 있습니다.TokenIncreaseService.increaseToken()은 동적인 토큰 개수를 수용하도록 설계되었으므로, 제안된 방식대로 개선하세요:클래스에 다음을 추가합니다:
🤖 Prompt for AI Agents