Skip to content

Conversation

@kgy1008
Copy link
Member

@kgy1008 kgy1008 commented Oct 25, 2025

📣 Jira Ticket

EDMT-459

👩‍💻 작업 내용

AI 생성 요청이 실패했을 때 차감된 포인트를 자동으로 복구하는 Choreography-based Saga 패턴을 구현했습니다.

주요 구현 사항

1. 실패 상태 추가

  • AITaskStatusFAILED 상태 추가
  • isFailure(), isInProgress() 메서드 수정

2. 실패 메시지 처리

  • AIErrorMessage DTO: Lambda에서 Redis Stream으로 전송하는 실패 메시지 구조
  • AITaskFailedEvent: 보상 트랜잭션을 트리거하는 이벤트
  • AIErrorType enum: 에러 타입 분류 (OPENAI_API_ERROR, LAMBDA_ERROR, UNKNOWN_ERROR)

3. DB 스키마 변경

  • student_record_ai_task 테이블에 실패 추적 컬럼 추가
    • failed_at: 실패 시간
    • error_type: 에러 타입 (enum)
  • Flyway 마이그레이션: V12__Add_ai_task_failure_tracking.sql

4. SSE 에러 알림

  • SSEMessageERROR 타입 추가
  • SSEChannelManager.sendErrorMessage(): 클라이언트에게 실시간 에러 알림

5. Redis Stream 실패 감지

  • RedisStreamConsumer에서 FAILED 상태 메시지 감지
  • handleAITaskFailure(): 실패 이벤트 발행 및 SSE 전송

6. 보상 트랜잭션 (핵심)

  • AICompensationListener: 실패 이벤트 구독 및 포인트 보상 실행
    • @Async("aiTaskExecutor"): 비동기 처리
    • @TransactionalEventListener(AFTER_COMMIT): 트랜잭션 안정성 보장
    • 멱등성 보장: Redis에 보상 여부 기록 (7일 TTL)
    • Task 실패 마킹 → 포인트 복구 → 이력 기록

Saga 패턴 흐름

실패 케이스:

1. 사용자 요청 → 포인트 차감 (100 포인트)
2. SQS로 메시지 전송
3. Lambda가 OpenAI API 호출 실패
4. Redis Stream에 FAILED 메시지 발행
5. RedisStreamConsumer 감지 → AITaskFailedEvent 발행
6. AICompensationListener 자동 실행
   - Task 실패 마킹 (DB)
   - 포인트 100 복구
   - 멱등성 보장 (Redis)
7. 클라이언트에게 SSE로 에러 알림

기술적 특징

Choreography-based Saga: 중앙 조정자 없이 이벤트 기반으로 보상 트랜잭션 실행
멱등성 보장: Redis를 활용한 중복 보상 방지
비동기 처리: @async + @TransactionalEventListener
포인트 동시성 제어: Pessimistic Lock 유지
감사 추적: PointHistory에 COMPENSATION 타입 기록

📝 리뷰 요청 & 논의하고 싶은 내용

  1. 멱등성 보장 방식: Redis TTL 7일이 적절한지
  2. 에러 타입 분류: AIErrorType enum에 추가할 타입이 있는지
  3. 보상 타이밍: AFTER_COMMIT 이벤트 리스너가 적절한지

Test Plan

✅ Manual Testing Checklist

  • AI 요청 실패 시 포인트가 자동으로 복구되는지 확인
  • 같은 taskId로 중복 보상이 발생하지 않는지 확인 (멱등성)
  • SSE로 에러 메시지가 클라이언트에게 전달되는지 확인
  • PointHistory에 DEDUCT, COMPENSATION 기록이 정상적으로 남는지 확인
  • student_record_ai_task 테이블에 failedAt, errorType이 기록되는지 확인

🧪 Automated Tests

  • Unit tests pass: ./gradlew test
  • Integration tests pass
  • Build succeeds: ./gradlew build

🔍 Code Quality

  • 모듈 의존성 방향이 올바른지 확인 (api → core)
  • 이벤트 리스너 트랜잭션 처리가 안전한지 검토
  • 멱등성 로직이 충분한지 검토

Deployment Notes

  • Database migrations included: V12__Add_ai_task_failure_tracking.sql
  • 배포 전 Redis 연결 확인
  • Lambda에서 실패 시 Redis Stream 발행 로직 구현 필요
  • 모니터링: 보상 트랜잭션 실행 빈도 추적 권장

📊 변경된 파일

  • 13 files changed, 243 insertions(+), 8 deletions(-)
  • Core 모듈: 이벤트 리스너, 보상 로직
  • DB: Flyway 마이그레이션

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • AI 작업 실패 추적 및 자동 포인트 보상(중복 방지, TTL 기반) 추가
    • 실패 시 에러 타입 및 타임스탬프 저장을 위한 스키마 변경
    • 실패 이벤트에 따른 비동기 보상 처리 및 실시간 에러 SSE 전송
  • Refactor

    • 포인트 보상 로직 및 관련 공개 API(반환 타입 등) 정리
    • AI 작업 상태 관리 체계 확장(FAILED 상태 및 판별 유틸 추가)

@coderabbitai
Copy link

coderabbitai bot commented Oct 25, 2025

Walkthrough

AI 작업 실패 추적을 위한 DB 스키마 추가와 실패 이벤트 발행/처리, 멱등성 기반 포인트 보상 및 클라이언트 SSE 오류 전달을 위한 엔드투엔드 변경입니다.

Changes

Cohort / File(s) 변경 요약
데이터베이스 마이그레이션
edukit-api/src/main/resources/db/migration/V12__Add_ai_task_failure_tracking.sql
student_record_ai_task 테이블에 failed_at (DATETIME) 및 error_type (VARCHAR(50)) 컬럼 추가
AI 오류 DTO / 이벤트
edukit-core/src/main/java/com/edukit/core/event/ai/dto/AIErrorMessage.java, edukit-core/src/main/java/com/edukit/core/event/ai/dto/AITaskFailedEvent.java
AI 오류 메시지 레코드 및 실패 이벤트 레코드/팩토리 메서드 추가
SSE 메시지 확장
edukit-core/src/main/java/com/edukit/core/event/ai/dto/SSEMessage.java
오류 전송용 정적 팩토리 error(...) 및 중첩 ErrorData 레코드 추가
비동기 보상 리스너
edukit-core/src/main/java/com/edukit/core/event/ai/AICompensationListener.java
AITaskFailedEvent 구독 리스너 추가 — Redis 기반 set-if-absent 멱등성, 실패 마킹, 포인트 보상 및 TTL 관리
멤버 포인트 조작
edukit-core/src/main/java/com/edukit/core/member/db/entity/Member.java
addPoints(int) 메서드 추가 및 deductPoints 파라미터명 정규화
포인트 서비스 변경
edukit-core/src/main/java/com/edukit/core/point/service/PointService.java
compensatePoints(...) 반환타입 Membervoid로 변경; 내부에서 addPoints(...) 호출로 보상 처리
AI 작업 엔티티/열거형
edukit-core/src/main/java/com/edukit/core/studentrecord/db/entity/StudentRecordAITask.java, edukit-core/src/main/java/com/edukit/core/studentrecord/db/enums/AIErrorType.java
failedAt, errorType 필드 추가 및 AIErrorType 열거형(OPENAI_API_ERROR, LAMBDA_ERROR, UNKNOWN_ERROR) 추가
AI 작업 서비스
edukit-core/src/main/java/com/edukit/core/studentrecord/service/AITaskService.java
getTaskById(Long)markTaskAsFailed(Long, String) 공개 메서드 추가
Redis 스트림 소비자
edukit-core/src/main/java/com/edukit/core/studentrecord/service/RedisStreamConsumer.java
실패 상태 처리 경로 추가: AIErrorMessage 역직렬화, 이벤트 발행(ApplicationEventPublisher) 및 SSE 에러 전송 분기
SSE 채널 관리자
edukit-core/src/main/java/com/edukit/core/studentrecord/service/SSEChannelManager.java
sendErrorMessage(String, AIErrorMessage) 메서드 추가 — 활성 채널에 ERROR SSE 전송 및 채널 정리
AI 작업 상태 유틸
edukit-core/src/main/java/com/edukit/core/studentrecord/service/enums/AITaskStatus.java
FAILED 상태 추가; isInProgress() 업데이트; isFailure() 유틸 메서드 추가
Redis 저장소 API/구현
edukit-core/src/main/java/com/edukit/core/common/service/RedisStoreService.java, edukit-external/src/main/java/com/edukit/external/redis/RedisStoreServiceImpl.java
setIfAbsent(String, String, Duration) 인터페이스 및 구현 추가 (TTL 포함 set-if-absent)

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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60–90 minutes

  • 주의 영역:
    • AICompensationListener: Redis 멱등성(setIfAbsent)과 예외 시 키 삭제/재시도 로직 검증
    • PointService.compensatePoints 반환형 변경으로 인한 호출부 영향 범위 확인
    • RedisStreamConsumer의 실패 분기와 SSE 전송/채널 정리 예외 처리
    • DB 마이그레이션과 엔티티 매핑(AIErrorType enum, @Enumerated 길이 50) 일치성 확인

Possibly related PRs

Suggested reviewers

  • TaegeunYou

Poem

🐇 실패가 오면 토끼가 와서 말해요
포인트는 돌아오니 걱정 말아요
Redis로 묶고 이벤트로 알리고
SSE로 전하고 채널을 정리하네
홧팅, 다시 시작해요 ✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.86% 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
Title Check ✅ Passed PR 제목 "EDMT-459 AI 실패 시 포인트 보상을 위한 Saga 패턴 구현"은 변경사항의 핵심을 명확하게 반영하고 있습니다. 제목은 AI 생성 실패 시 포인트 자동 복구와 Saga 패턴 구현이라는 주요 목표를 정확하게 전달하며, Jira 티켓 번호를 포함하여 컨텍스트를 제공합니다. 제목이 간결하고 구체적이어서 코드 히스토리를 스캔하는 팀원이 이 PR의 주요 변경사항을 쉽게 이해할 수 있습니다.
Description Check ✅ Passed PR 설명이 템플릿의 모든 필수 섹션을 충실하게 포함하고 있습니다. Jira Ticket(EDMT-459)과 작업 내용(👩‍💻)은 매우 상세하게 작성되었으며, 리뷰 요청 & 논의 사항(📝)도 명확히 제시되어 있습니다. 또한 Saga 패턴 흐름, 기술적 특징, Test Plan, Deployment Notes 등 추가 정보까지 제공하여 리뷰어가 이해하기 매우 용이한 상태입니다. 선택사항인 스크린샷 섹션은 생략되었으나, 이는 기능 변경이 UI가 아닌 백엔드 로직이기 때문에 적절합니다.
✨ 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 feat/EDMT-459

Comment @coderabbitai help to get the list of available commands and usage tips.

@kgy1008 kgy1008 self-assigned this Oct 25, 2025
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: 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 retryable
edukit-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) 상수로 보상하고 있습니다. 더 견고한 설계를 위해:

  1. PointHistoryRepository에 조회 메서드 추가:

    • findByTaskIdAndTransactionType(Long taskId, PointTransactionType type) 메서드 추가
  2. AICompensationListener.handleAITaskFailure() 수정:

    • PointHistory에서 해당 taskId의 DEDUCT 레코드를 조회
    • 실제 차감액으로 보상 (예: pointService.compensatePoints(memberId, deductedAmount, taskId))

이렇게 하면 차감액이 변경되는 경우에도 정확한 금액으로 보상되며, 현재처럼 두 상수가 분리되어 동기화 위험이 없어집니다.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f5af824 and a1396a6.

📒 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

조회 전용으로 적절합니다.

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: 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"를 설정하지만, 보상 로직이 진행 중일 때는 의미적으로 혼란스럽습니다. 다음과 같이 개선하면 모니터링/디버깅 시 상태를 명확히 파악할 수 있습니다:

  1. 선점 시: "IN_PROGRESS" 설정
  2. 보상 성공 시: redisStoreService.store(compensationKey, "COMPENSATED", COMPENSATION_RECORD_TTL) 호출
  3. 보상 실패 시: 키 삭제 (현재와 동일)

기능적으로는 현재 구현도 동작하지만, 상태가 더 명확해집니다.

적용 제안:

 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가 처리함

검증 결과, CustomAsyncExceptionHandlerAsyncConfigurer를 통해 올바르게 등록되어 있으며, 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

📥 Commits

Reviewing files that changed from the base of the PR and between a1396a6 and 72c9f2b.

📒 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") 애노테이션의 요구사항을 만족하므로, 애플리케이션 시작 시 정상적으로 주입될 것입니다. 추가 조치가 필요하지 않습니다.

@kgy1008 kgy1008 merged commit 9b9098c into develop Oct 25, 2025
2 checks passed
@kgy1008 kgy1008 deleted the feat/EDMT-459 branch October 25, 2025 11:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants