Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/main/avro/chat.avsc → src/main/avro/ChatAvroSchema.avsc
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"type": "record",
"name": "ChatEvent",
"namespace": "com.chatbot.events",
"name": "ChatAvroSchema",
"namespace": "windeath44.server.chatbot.avro",
"doc": "챗봇 채팅 이벤트 스키마",
"fields": [
{
Expand Down
10 changes: 10 additions & 0 deletions src/main/avro/MemorialBowedAvroSchema.avsc
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"}
]
}
2 changes: 1 addition & 1 deletion src/main/avro/RemainTokenDecreaseResponse.avsc
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"type": "record",
"name": "RemainTokenDecreaseResponse",
"namespace": "com.example.user.avro",
"namespace": "windeath44.server.user.avro",
"doc": "토큰 감소 응답 스키마",
"fields": [
{
Expand Down
30 changes: 30 additions & 0 deletions src/main/avro/RemainTokenIncreaseResponse.avsc
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
Expand Up @@ -22,7 +22,6 @@
public class UserController {
private final UserService userService;


@GetMapping
public ResponseEntity<ResponseDto<List<UserResponse>>> getUsersByIds(@RequestParam("userIds") List<String> userIds) {
List<UserResponse> userResponsesList = userService.findAllByIds(userIds);
Expand Down
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 {
Expand Down Expand Up @@ -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
);
Comment on lines +59 to +69
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.


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


private static RemainTokenIncreaseResponse buildIncreaseResponse(String userId, boolean success, Long remainingToken, String errorMessage) {
return RemainTokenIncreaseResponse.newBuilder()
.setUserId(userId)
.setSuccess(success)
.setRemainingToken(remainingToken)
.setErrorMessage(errorMessage)
.build();
}
}

2 changes: 1 addition & 1 deletion src/main/java/com/example/user/domain/model/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.

Expand Down
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
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.

}