-
Notifications
You must be signed in to change notification settings - Fork 0
[EDMT-459] AI 실패 시 포인트 보상을 위한 Saga 패턴 구현 #80
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
Conversation
… AITaskFailedEvent and StudentRecordAITask
…ensationListener and RedisStreamConsumer
WalkthroughAI 작업 실패 추적을 위한 DB 스키마 추가와 실패 이벤트 발행/처리, 멱등성 기반 포인트 보상 및 클라이언트 SSE 오류 전달을 위한 엔드투엔드 변경입니다. Changes
Sequence Diagram(s)sequenceDiagram
participant Redis as Redis Stream
participant RSC as RedisStreamConsumer
participant Pub as ApplicationEventPublisher
participant ACL as AICompensationListener
participant ATS as AITaskService
participant PS as PointService
participant SCM as SSEChannelManager
participant Client as SSE Client
Redis->>RSC: AI task failure message
RSC->>RSC: Deserialize AIErrorMessage
RSC->>ATS: markTaskAsFailed(taskId, errorType)
RSC->>Pub: publish AITaskFailedEvent
Pub->>ACL: handleAITaskFailure(event) [async]
alt Compensation path (ACL)
ACL->>ACL: tryClaimCompensation(key) via Redis.setIfAbsent
ACL->>ATS: getTaskById(taskId)
ACL->>PS: compensatePoints(memberId, 100, taskId)
ACL->>ACL: record compensation key (TTL: 7d)
else Already claimed
ACL-->>ACL: log and exit
end
RSC->>SCM: sendErrorMessage(taskId, AIErrorMessage)
SCM->>Client: send SSE "ERROR" event
SCM->>SCM: remove channel
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60–90 minutes
Possibly related PRs
Suggested reviewers
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
Comment |
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.
Actionable comments posted: 8
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
edukit-core/src/main/java/com/edukit/core/studentrecord/service/RedisStreamConsumer.java (2)
160-168: JSON 필드 누락 시 NPE 가능성: null‑safe 파싱 필요parseData에서 node.get(target).asText()는 필드 누락 시 NPE가 납니다. 재시도 폭주/메시지 적체 유발 가능.
적용 제안(diff):
- private String parseData(final String messageJson, final String target) { + private String parseData(final String messageJson, final String target) { try { - JsonNode node = objectMapper.readTree(messageJson); - return node.get(target).asText(); + JsonNode node = objectMapper.readTree(messageJson); + JsonNode v = node.path(target); + if (v.isMissingNode() || v.isNull()) { + throw new StudentRecordException(StudentRecordErrorCode.MESSAGE_PROCESSING_FAILED); + } + return v.asText(); } catch (JsonProcessingException e) { log.error("JSON 파싱 오류: {}", e.getMessage(), e); throw new StudentRecordException(StudentRecordErrorCode.MESSAGE_PROCESSING_FAILED, e); } }
106-111: data 필드 null/타입 체크 누락messageBody.get("data")가 null이거나 String이 아닐 수 있습니다. 방어 코드가 필요합니다.
적용 제안(diff):
- String messageJson = (String) messageBody.get("data"); - log.info("Received message from Redis Stream: {}", messageJson); + Object dataObj = messageBody.get("data"); + if (!(dataObj instanceof String messageJson) || messageJson.isEmpty()) { + throw new StudentRecordException(StudentRecordErrorCode.MESSAGE_PROCESSING_FAILED); + } + log.debug("Received Redis message size: {} bytes", messageJson.length());
🧹 Nitpick comments (9)
edukit-api/src/main/resources/db/migration/V12__Add_ai_task_failure_tracking.sql (1)
3-5: 실패 추적을 위한 인덱스 추가를 고려하세요.실패 작업 조회 및 에러 유형별 분석 쿼리의 성능을 위해 인덱스 추가를 검토하는 것이 좋습니다.
다음과 같이 인덱스를 추가할 수 있습니다:
-- Add failure tracking columns to student_record_ai_task table ALTER TABLE student_record_ai_task ADD COLUMN failed_at DATETIME NULL AFTER completed_at, ADD COLUMN error_type VARCHAR(50) NULL AFTER failed_at; -- Add indexes for failure tracking queries CREATE INDEX idx_failed_at ON student_record_ai_task(failed_at); CREATE INDEX idx_error_type ON student_record_ai_task(error_type);edukit-core/src/main/java/com/edukit/core/event/ai/dto/AITaskFailedEvent.java (1)
12-17: errorType의 타입 안정성을 고려하세요.errorType이 String으로 정의되어 있어 잘못된 값이 전달될 수 있습니다. AIErrorType enum을 직접 사용하는 것을 검토해보세요.
다음과 같이 enum을 직접 사용할 수 있습니다:
public record AITaskFailedEvent( String taskId, - String errorType + AIErrorType errorType ) { - public static AITaskFailedEvent of(final String taskId, final String errorType) { + public static AITaskFailedEvent of(final String taskId, final AIErrorType errorType) { return new AITaskFailedEvent(taskId, errorType); } public static AITaskFailedEvent fromErrorMessage(final AIErrorMessage errorMessage) { return AITaskFailedEvent.of( errorMessage.taskId(), - errorMessage.errorType() + AIErrorType.fromString(errorMessage.errorType()) ); } }단, AIErrorType에 fromString 메서드가 필요합니다.
edukit-core/src/main/java/com/edukit/core/event/ai/dto/AIErrorMessage.java (2)
5-16: 필드 검증 추가를 고려하세요.Lambda로부터 전달되는 메시지의 필수 필드에 대한 검증이 없어, 잘못된 데이터가 시스템에 전파될 수 있습니다.
Jakarta Validation을 사용하여 검증을 추가할 수 있습니다:
public record AIErrorMessage( @JsonProperty("task_id") + @NotBlank String taskId, @JsonProperty("status") + @NotBlank String status, @JsonProperty("error_type") + @NotBlank String errorType, @JsonProperty("error_message") + @NotBlank String errorMessage, @JsonProperty("retryable") + @NotNull Boolean retryable ) { }
14-15: retryable 필드의 타입을 검토하세요.retryable이 Boolean 래퍼 타입으로 정의되어 null 값을 가질 수 있습니다. Lambda에서 항상 이 값을 제공한다면 primitive boolean을 사용하는 것이 안전합니다.
다음과 같이 primitive 타입을 사용할 수 있습니다:
@JsonProperty("retryable") - Boolean retryable + boolean retryableedukit-core/src/main/java/com/edukit/core/studentrecord/service/RedisStreamConsumer.java (1)
120-134: SSE 채널 미활성 시 불필요 로그/정리 로직 재검토채널 미활성 && 현재 서버 할당이면 removeChannel 수행 중인데, 이 로직이 재시도/다른 서버로의 reassignment 정책과 충돌하지 않는지 확인 바랍니다. 필요시 WARN → DEBUG 완화 제안.
edukit-core/src/main/java/com/edukit/core/studentrecord/service/AITaskService.java (1)
47-53: 실패 마킹 API를 enum 기반으로 오버로드하고 문자열 변환은 위임하세요문자열 기반만 노출하면 호출부 오타/케이스 이슈가 런타임까지 지연됩니다. enum 오버로드를 추가하고 문자열 변환 메서드는 위임하도록 리팩터링 권장.
적용 제안(diff):
@Transactional - public void markTaskAsFailed(final Long taskId, final String errorType) { - StudentRecordAITask task = aiTaskRepository.findById(taskId) - .orElseThrow(() -> new StudentRecordException(StudentRecordErrorCode.AI_TASK_NOT_FOUND)); - AIErrorType aiErrorType = AIErrorType.fromString(errorType); - task.markAsFailed(aiErrorType); - } + public void markTaskAsFailed(final Long taskId, final String errorType) { + markTaskAsFailed(taskId, AIErrorType.fromString(errorType)); + } + + @Transactional + public void markTaskAsFailed(final Long taskId, final AIErrorType errorType) { + StudentRecordAITask task = aiTaskRepository.findById(taskId) + .orElseThrow(() -> new StudentRecordException(StudentRecordErrorCode.AI_TASK_NOT_FOUND)); + task.markAsFailed(errorType); + }edukit-core/src/main/java/com/edukit/core/point/service/PointService.java (1)
41-50: 보상 금액 유효성 검사 추가 권장 및 주석 정정pointsToCompensate가 0/음수일 때 조용히 통과하면 히스토리만 남을 수 있습니다. 또한 주석(음수 차감)과 구현(addPoints)이 불일치합니다.
적용 제안(diff):
@Transactional public void compensatePoints(final Long memberId, final int pointsToCompensate, final Long taskId) { Member member = memberRepository.findByIdWithLock(memberId) .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); - member.addPoints(pointsToCompensate); // 음수 차감으로 복구 + if (pointsToCompensate <= 0) { + throw new PointException(PointErrorCode.INVALID_POINT_AMOUNT); + } + member.addPoints(pointsToCompensate); // 보상 포인트 적립PointErrorCode.INVALID_POINT_AMOUNT 존재 여부 확인 부탁드립니다. 없으면 새 에러코드로 추가 제안드립니다.
edukit-core/src/main/java/com/edukit/core/event/ai/AICompensationListener.java (2)
45-55: taskId 파싱 실패 가능성event.taskId()가 비정수면 NumberFormatException 발생. 이벤트에서 Long 타입으로 들고 다니는 것이 안전합니다. 단기적으로는 try/catch 추가를 권장합니다.
27-55: PointHistory 조회를 통해 실제 차감액 기반 보상으로 변경 필요현재 코드는 고정된
DEDUCTED_POINTS(100)상수로 보상하고 있습니다. 더 견고한 설계를 위해:
PointHistoryRepository에 조회 메서드 추가:
findByTaskIdAndTransactionType(Long taskId, PointTransactionType type)메서드 추가AICompensationListener.handleAITaskFailure() 수정:
- PointHistory에서 해당 taskId의 DEDUCT 레코드를 조회
- 실제 차감액으로 보상 (예:
pointService.compensatePoints(memberId, deductedAmount, taskId))이렇게 하면 차감액이 변경되는 경우에도 정확한 금액으로 보상되며, 현재처럼 두 상수가 분리되어 동기화 위험이 없어집니다.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (13)
edukit-api/src/main/resources/db/migration/V12__Add_ai_task_failure_tracking.sql(1 hunks)edukit-core/src/main/java/com/edukit/core/event/ai/AICompensationListener.java(1 hunks)edukit-core/src/main/java/com/edukit/core/event/ai/dto/AIErrorMessage.java(1 hunks)edukit-core/src/main/java/com/edukit/core/event/ai/dto/AITaskFailedEvent.java(1 hunks)edukit-core/src/main/java/com/edukit/core/event/ai/dto/SSEMessage.java(2 hunks)edukit-core/src/main/java/com/edukit/core/member/db/entity/Member.java(1 hunks)edukit-core/src/main/java/com/edukit/core/point/service/PointService.java(1 hunks)edukit-core/src/main/java/com/edukit/core/studentrecord/db/entity/StudentRecordAITask.java(3 hunks)edukit-core/src/main/java/com/edukit/core/studentrecord/db/enums/AIErrorType.java(1 hunks)edukit-core/src/main/java/com/edukit/core/studentrecord/service/AITaskService.java(2 hunks)edukit-core/src/main/java/com/edukit/core/studentrecord/service/RedisStreamConsumer.java(5 hunks)edukit-core/src/main/java/com/edukit/core/studentrecord/service/SSEChannelManager.java(2 hunks)edukit-core/src/main/java/com/edukit/core/studentrecord/service/enums/AITaskStatus.java(2 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
edukit-core/src/main/java/com/edukit/core/studentrecord/db/entity/StudentRecordAITask.java (2)
edukit-core/src/main/java/com/edukit/core/member/db/entity/Member.java (1)
Entity(24-157)edukit-core/src/main/java/com/edukit/core/auth/db/entity/VerificationCode.java (1)
Entity(23-106)
edukit-core/src/main/java/com/edukit/core/event/ai/AICompensationListener.java (1)
edukit-core/src/main/java/com/edukit/core/studentrecord/service/RedisStreamConsumer.java (1)
Slf4j(32-178)
🔇 Additional comments (10)
edukit-core/src/main/java/com/edukit/core/event/ai/dto/SSEMessage.java (2)
16-18: LGTM!에러 메시지를 위한 팩토리 메서드가 기존 패턴과 일관성 있게 구현되었습니다.
32-36: LGTM!ErrorData 레코드가 에러 정보를 명확하게 표현하고 있습니다.
edukit-core/src/main/java/com/edukit/core/event/ai/dto/AITaskFailedEvent.java (1)
3-10: 이벤트 설계가 명확합니다.AITaskFailedEvent의 구조와 팩토리 메서드가 잘 설계되었습니다.
edukit-core/src/main/java/com/edukit/core/studentrecord/db/entity/StudentRecordAITask.java (1)
45-49: 실패 추적 필드가 올바르게 정의되었습니다.failedAt과 errorType 필드가 마이그레이션 스키마와 일치하며 적절히 매핑되었습니다.
edukit-core/src/main/java/com/edukit/core/studentrecord/service/SSEChannelManager.java (1)
122-125: finally 블록에서의 채널 제거가 적절합니다.에러 발생 시 채널을 제거하여 리소스 누수를 방지하는 것이 잘 구현되었습니다. 이는 기존 패턴과도 일관성이 있습니다.
edukit-core/src/main/java/com/edukit/core/studentrecord/service/enums/AITaskStatus.java (3)
15-16: LGTM!FAILED 상태가 명확한 메시지와 함께 추가되었습니다. 사용자에게 포인트 복구를 안내하는 메시지가 적절합니다.
28-30: LGTM!isInProgress 메서드가 FAILED를 진행 중이 아닌 상태로 올바르게 처리합니다.
32-34: LGTM!isFailure 메서드가 실패 상태 확인을 위한 명확한 유틸리티를 제공합니다.
edukit-core/src/main/java/com/edukit/core/studentrecord/service/RedisStreamConsumer.java (1)
95-101: ACK 시점 확인 필요현재는 processMessage 호출 후 무조건 ACK 합니다. 위의 이벤트 발행 실패 전파 개선 전에는 손실 위험이 있었고, 개선 후에는 예외 전파 시 ACK가 스킵됩니다. 이 전파 계약이 깨지지 않도록 유지가 필요합니다. processMessage가 실패 시 반드시 예외를 던지는지 확인해주세요.
edukit-core/src/main/java/com/edukit/core/studentrecord/service/AITaskService.java (1)
41-45: getTaskById 읽기 전용 트랜잭션 OK조회 전용으로 적절합니다.
edukit-core/src/main/java/com/edukit/core/event/ai/AICompensationListener.java
Outdated
Show resolved
Hide resolved
edukit-core/src/main/java/com/edukit/core/event/ai/AICompensationListener.java
Outdated
Show resolved
Hide resolved
edukit-core/src/main/java/com/edukit/core/studentrecord/db/entity/StudentRecordAITask.java
Show resolved
Hide resolved
edukit-core/src/main/java/com/edukit/core/studentrecord/db/enums/AIErrorType.java
Show resolved
Hide resolved
edukit-core/src/main/java/com/edukit/core/studentrecord/service/RedisStreamConsumer.java
Show resolved
Hide resolved
edukit-core/src/main/java/com/edukit/core/studentrecord/service/SSEChannelManager.java
Show resolved
Hide resolved
…skFailedEvent handling
…atomic claim logic
…er logging and compensation flow
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.
Actionable comments posted: 0
♻️ Duplicate comments (1)
edukit-core/src/main/java/com/edukit/core/event/ai/AICompensationListener.java (1)
44-47: NumberFormatException 처리 누락: taskId 검증 필요
Long.valueOf(taskId)는taskId가 유효한 숫자 문자열이 아닐 경우NumberFormatException을 발생시킵니다. 이벤트의taskId가 항상 올바른 형식임을 보장할 수 없다면 예외 처리가 필요합니다.다음 중 하나를 적용하세요:
옵션 1: 검증 후 조기 반환
try { + Long taskIdLong; + try { + taskIdLong = Long.valueOf(taskId); + } catch (NumberFormatException e) { + log.error("Invalid taskId format: {}, cannot compensate", taskId); + redisStoreService.delete(compensationKey); + return; + } + // Task 정보 조회 - StudentRecordAITask task = aiTaskService.getTaskById(Long.valueOf(taskId)); + StudentRecordAITask task = aiTaskService.getTaskById(taskIdLong); // Task를 실패로 마킹 - aiTaskService.markTaskAsFailed(Long.valueOf(taskId), event.errorType()); + aiTaskService.markTaskAsFailed(taskIdLong, event.errorType());옵션 2: 상위 catch 블록에 의존
현재 catch 블록(line 59)이 모든 예외를 처리하므로,
NumberFormatException도 포착되어 키가 삭제되고 재시도 가능합니다. 하지만 명시적 검증이 더 명확합니다.
🧹 Nitpick comments (2)
edukit-core/src/main/java/com/edukit/core/event/ai/AICompensationListener.java (2)
71-74: "COMPENSATED" 값의 의미적 혼동: "IN_PROGRESS" 사용 권장현재 선점 시 즉시
"COMPENSATED"를 설정하지만, 보상 로직이 진행 중일 때는 의미적으로 혼란스럽습니다. 다음과 같이 개선하면 모니터링/디버깅 시 상태를 명확히 파악할 수 있습니다:
- 선점 시:
"IN_PROGRESS"설정- 보상 성공 시:
redisStoreService.store(compensationKey, "COMPENSATED", COMPENSATION_RECORD_TTL)호출- 보상 실패 시: 키 삭제 (현재와 동일)
기능적으로는 현재 구현도 동작하지만, 상태가 더 명확해집니다.
적용 제안:
private boolean tryClaimCompensation(final String compensationKey) { - Boolean claimed = redisStoreService.setIfAbsent(compensationKey, "COMPENSATED", COMPENSATION_RECORD_TTL); + Boolean claimed = redisStoreService.setIfAbsent(compensationKey, "IN_PROGRESS", COMPENSATION_RECORD_TTL); return Boolean.TRUE.equals(claimed); } + +private void markAsCompensated(final String compensationKey) { + redisStoreService.store(compensationKey, "COMPENSATED", COMPENSATION_RECORD_TTL); +}그리고 line 57 다음에
markAsCompensated(compensationKey);를 추가하세요.
59-64:throw e;제거 권장 - 이미AsyncUncaughtExceptionHandler가 처리함검증 결과,
CustomAsyncExceptionHandler가AsyncConfigurer를 통해 올바르게 등록되어 있으며, void 반환 타입의@Async메서드에서 예외를 재발생시키는 것은 중복입니다.현재 상황:
- Line 60에서 이미 로깅됨
- Line 63의
throw e;는 void 반환 타입이므로 호출자에게 전파되지 않음CustomAsyncExceptionHandler가 예외를 catch하여 로깅하고 Slack 알림 전송따라서 Line 63의
throw e;는 제거 가능합니다. 이미 로깅되었고AsyncUncaughtExceptionHandler가 처리하므로, Redis 키 삭제 후 메서드를 종료하면 됩니다.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (5)
edukit-core/src/main/java/com/edukit/core/common/service/RedisStoreService.java(1 hunks)edukit-core/src/main/java/com/edukit/core/event/ai/AICompensationListener.java(1 hunks)edukit-core/src/main/java/com/edukit/core/studentrecord/db/enums/AIErrorType.java(1 hunks)edukit-core/src/main/java/com/edukit/core/studentrecord/service/RedisStreamConsumer.java(5 hunks)edukit-external/src/main/java/com/edukit/external/redis/RedisStoreServiceImpl.java(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- edukit-core/src/main/java/com/edukit/core/studentrecord/db/enums/AIErrorType.java
🧰 Additional context used
🧬 Code graph analysis (1)
edukit-core/src/main/java/com/edukit/core/event/ai/AICompensationListener.java (1)
edukit-core/src/main/java/com/edukit/core/studentrecord/service/RedisStreamConsumer.java (1)
Slf4j(32-178)
🔇 Additional comments (6)
edukit-external/src/main/java/com/edukit/external/redis/RedisStoreServiceImpl.java (1)
23-25: LGTM! SET NX + TTL 원자적 구현
setIfAbsent메서드가 Redis의 SET NX 연산을 TTL과 함께 원자적으로 수행하도록 올바르게 구현되었습니다. 멱등성 보장에 필요한 핵심 기능입니다.edukit-core/src/main/java/com/edukit/core/common/service/RedisStoreService.java (1)
11-11: 인터페이스 선언 확인 완료
setIfAbsent메서드 시그니처가 올바르게 선언되었으며, 구현체에서 제대로 구현되었습니다.edukit-core/src/main/java/com/edukit/core/studentrecord/service/RedisStreamConsumer.java (1)
141-158: 이전 리뷰 피드백 반영 완료: 이벤트 발행 실패 시 재처리 보장이전 리뷰에서 지적된 이슈가 해결되었습니다:
publishEvent(lines 146-148)가 try-catch 밖에 있어 예외 발생 시 상위로 전파되어 메시지 ACK가 방지됩니다- SSE 전송 실패(lines 151-157)만 개별적으로 보호되어 보상 로직에 영향을 주지 않습니다
이벤트 발행 실패 시 재처리가 보장되는 올바른 구조입니다.
edukit-core/src/main/java/com/edukit/core/event/ai/AICompensationListener.java (3)
30-31: 이전 리뷰 피드백 반영 완료: @eventlistener 사용으로 비트랜잭션 컨텍스트 대응이전 리뷰에서 지적된
@TransactionalEventListener이슈가 해결되었습니다.RedisStreamConsumer가 트랜잭션 외부에서 이벤트를 발행하므로@EventListener를 사용하는 것이 올바른 선택입니다.@Async와 함께 보상 로직이 비동기적으로 실행됩니다.
36-40: 이전 리뷰 피드백 반영 완료: 원자적 선점으로 레이스 컨디션 해결이전 리뷰에서 지적된
get() → store()레이스 컨디션이 해결되었습니다.setIfAbsent를 사용한 원자적 선점 패턴으로 멀티 인스턴스 환경에서 중복 보상을 방지합니다.
30-30:aiTaskExecutor빈이 올바르게 정의되어 있습니다.검증 결과,
edukit-external/src/main/java/com/edukit/external/common/config/AsyncConfig.java파일의 39번 라인에서@Bean(name = "aiTaskExecutor")로 정의된Executor빈을 확인했습니다. 빈의 이름과 타입이 모두@Async("aiTaskExecutor")애노테이션의 요구사항을 만족하므로, 애플리케이션 시작 시 정상적으로 주입될 것입니다. 추가 조치가 필요하지 않습니다.
📣 Jira Ticket
EDMT-459
👩💻 작업 내용
AI 생성 요청이 실패했을 때 차감된 포인트를 자동으로 복구하는 Choreography-based Saga 패턴을 구현했습니다.
주요 구현 사항
1. 실패 상태 추가
AITaskStatus에FAILED상태 추가isFailure(),isInProgress()메서드 수정2. 실패 메시지 처리
AIErrorMessageDTO: Lambda에서 Redis Stream으로 전송하는 실패 메시지 구조AITaskFailedEvent: 보상 트랜잭션을 트리거하는 이벤트AIErrorTypeenum: 에러 타입 분류 (OPENAI_API_ERROR, LAMBDA_ERROR, UNKNOWN_ERROR)3. DB 스키마 변경
student_record_ai_task테이블에 실패 추적 컬럼 추가failed_at: 실패 시간error_type: 에러 타입 (enum)V12__Add_ai_task_failure_tracking.sql4. SSE 에러 알림
SSEMessage에ERROR타입 추가SSEChannelManager.sendErrorMessage(): 클라이언트에게 실시간 에러 알림5. Redis Stream 실패 감지
RedisStreamConsumer에서 FAILED 상태 메시지 감지handleAITaskFailure(): 실패 이벤트 발행 및 SSE 전송6. 보상 트랜잭션 (핵심)
AICompensationListener: 실패 이벤트 구독 및 포인트 보상 실행@Async("aiTaskExecutor"): 비동기 처리@TransactionalEventListener(AFTER_COMMIT): 트랜잭션 안정성 보장Saga 패턴 흐름
실패 케이스:
기술적 특징
✅ Choreography-based Saga: 중앙 조정자 없이 이벤트 기반으로 보상 트랜잭션 실행
✅ 멱등성 보장: Redis를 활용한 중복 보상 방지
✅ 비동기 처리: @async + @TransactionalEventListener
✅ 포인트 동시성 제어: Pessimistic Lock 유지
✅ 감사 추적: PointHistory에 COMPENSATION 타입 기록
📝 리뷰 요청 & 논의하고 싶은 내용
Test Plan
✅ Manual Testing Checklist
🧪 Automated Tests
./gradlew test./gradlew build🔍 Code Quality
Deployment Notes
📊 변경된 파일
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Refactor