-
Notifications
You must be signed in to change notification settings - Fork 0
[EDMT-401] AI 응답 카운터를 통한 자동 완료 기능 구현 #39
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
… and RedisStoreServiceImpl for clarity
…mConsumer to use RedisStreamServiceImpl
…d update event interfaces
…ompletion logic in SSEChannelManager
…dAIController to implement it
WalkthroughAI 생활기록부 생성을 위한 공용 API 인터페이스를 추가하고, 컨트롤러가 이를 구현하도록 변경. AI 태스크를 ID 중심에서 엔티티 중심으로 전환하며 start/complete 라이프사이클을 도입. Redis Pub/Sub 구독자를 제거하고 Redis Streams 기반 소비자로 교체. Redis 서비스 추상화를 RedisStoreService/RedisStreamService로 분리·도입. SSE 채널 관리에 응답 카운팅 및 자동 완료 흐름 추가. 빌드 의존성 조정. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor Client
participant API as StudentRecordAIApi/Controller
participant Facade as StudentRecordAIFacade
participant SRV as StudentRecordService
participant EVT as EventPublisher
participant LSN as AIEventListener
participant RS as Redis Streams
participant CON as RedisStreamConsumer
participant SSE as SSEChannelManager
Client->>API: POST /records/{recordId}/ai (prompt)
API->>Facade: aiGenerateStudentRecord(...)
Facade->>SRV: createAITask(prompt) -> StudentRecordAITask
SRV-->>Facade: task (id 포함)
Facade->>EVT: publish AITaskCreateEvent(task, ...)
EVT-->>LSN: AITaskCreateEvent
LSN->>LSN: task.start()
LSN->>RS: (AI 응답 메시지 생산 - 별도 프로듀서)
note over RS: ai-response 스트림에 이벤트 기록
Client-->>API: GET /ai/stream/{taskId} (SSE)
API-->>SSE: openEmitter(taskId)
loop Poll
CON->>RS: readFromStream(consumer, ai-response)
RS-->>CON: MapRecord(data=AIResponseMessage)
CON->>SSE: forward(taskId, message)
alt no active channel but same server
CON->>SSE: deleteChannel(taskId)
end
CON->>RS: ACK(messageId)
end
sequenceDiagram
autonumber
participant CON as RedisStreamConsumer
participant SSE as SSEChannelManager
participant R as RedisStoreService
participant SRV as StudentRecordService
participant E as SseEmitter
CON->>SSE: send(taskId, data)
SSE->>R: increment("response-count:taskId", TTL=5m)
R-->>SSE: count
alt count < MAX(3)
SSE-->>E: emit(data)
else count == MAX
SSE-->>E: emit(data), complete()
SSE->>SRV: completeAITask(taskId)
SSE->>R: delete("channel:taskId"), delete("response-count:taskId")
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
Poem
Tip 🔌 Remote MCP (Model Context Protocol) integration is now available!Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats. ✨ Finishing Touches
🧪 Generate unit tests
🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
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
🧹 Nitpick comments (26)
edukit-core/src/main/java/com/edukit/core/studentrecord/util/AIPromptGenerator.java (3)
24-25: 표현 개선: ‘기름’(오탈/어색), 어투 일관화, 인용부호 통일 제안
- ‘자기주도적 학습 태도를 기름’은 ‘길렀음’이 자연스럽습니다.
- 동일 섹션에서 “~하세요” 체/명령형을 혼용하고 있어 어투를 일관화하면 가독성이 좋아집니다.
- 스마트 인용부호(‘ ’)는 환경에 따라 깨질 수 있어 ASCII 인용부호(" ") 사용을 권장합니다.
아래처럼 다듬는 것을 제안드립니다.
- - 단순히 활동 사실을 나열하지 말고, 활동 과정에서의 태도·참여 모습과 이를 통해 이해하거나 깨달은 점, 성장한 부분을 중심으로 서술할 것 - (예: ‘~활동에 적극 참여하여 협력의 중요성을 깨달음’, ‘~에 꾸준히 노력하며 자기주도적 학습 태도를 기름’). + - 단순히 활동 사실을 나열하지 말고, 활동 과정에서의 태도·참여 모습과 이를 통해 이해하거나 깨달은 점, 성장한 부분을 중심으로 서술하세요. + (예: "~활동에 적극 참여하여 협력의 중요성을 깨달음", "~에 꾸준히 노력하며 자기주도적 학습 태도를 길렀음").
15-18: 개인정보(PII) 포함 금지 항목 추가 제안생활기록부 성격상 개인정보 노출 리스크를 줄이기 위해 PII 금지 조항을 유의사항에 명시하는 것을 권장합니다.
### 유의사항 입력된 정보가 부정적이더라도, 직접적이거나 간접적으로라도 부정적인 표현을 작성하면 안됩니다. 무엇을 배우고 성장했는지 최대한 긍정적인 관점이나 개선 방향을 서술해 주세요. '~할 계획임'과 같이 추측성 표현은 작성하지 마세요. + 실명, 학번, 연락처, 계정 정보 등 어떠한 개인정보도 포함하지 마세요.
43-50: 음수/비현실적 최소 바이트 안내 방지: 하한값 클램핑byteCount가 100 이하로 들어오면 “최소 -X바이트”와 같은 비정상 문구가 생성될 수 있습니다. 하한값을 클램핑하여 안전하게 처리하세요.
- public static String createStreamingPrompt(final StudentRecordType recordType, final int byteCount, - final String inputPrompt) { - return String.format(STREAMING_PROMPT_TEMPLATE, recordType.name(), inputPrompt, - byteCount - MAXIMUM_BYTE_BUFFER); - } + public static String createStreamingPrompt(final StudentRecordType recordType, + final int byteCount, + final String inputPrompt) { + final int minBytes = Math.max(0, byteCount - MAXIMUM_BYTE_BUFFER); + return String.format(STREAMING_PROMPT_TEMPLATE, recordType.name(), inputPrompt, minBytes); + }필요 시 0 대신 도메인 요구에 맞는 실질적 하한(예: 200바이트)을 협의해 반영하세요.
최종적으로 byteCount 입력 범위가 어디에서 어떻게 보장되는지(예: SSE 토큰 예산, 모델 온톨로지 등) 확인 부탁드립니다. 필요하면 검증 로직을 호출부에 추가하는 것도 고려해 주세요.
edukit-core/src/main/java/com/edukit/core/common/service/RedisStoreService.java (1)
5-14: increment 동작/TTL 계약을 인터페이스 Javadoc으로 명시 권장“count == 1일 때만 TTL 설정” 같은 중요한 계약이 구현체에만 드러나 있습니다. 인터페이스에 문서화해두면 사용처와 구현체 간 오해를 줄일 수 있습니다.
적용 예시:
public interface RedisStoreService { + /** + * 단순 문자열 값을 TTL과 함께 저장합니다. + */ void store(String key, String value, Duration ttl); + /** + * 키에 저장된 문자열 값을 조회합니다. 값이 없으면 null을 반환합니다. + */ String get(String key); + /** + * 키를 삭제합니다(존재하지 않아도 안전). + */ void delete(String key); - Long increment(String key, Duration ttl); + /** + * 카운터를 원자적으로 1 증가시키고, 증가 결과가 1인 경우(처음 생성된 경우)에만 TTL을 설정합니다. + * 반환값은 증가 후 카운터 값입니다. + * + * @param key 카운터 키 + * @param ttl 키 최초 생성 시 적용할 TTL(양수 Duration 권장) + * @return 증가 후 카운터 값 + */ + Long increment(String key, Duration ttl); }edukit-core/src/main/java/com/edukit/core/studentrecord/exception/StudentRecordErrorCode.java (1)
15-15: 에러 코드 Prefix 정렬 제안: R-50001 → SR-50001StudentRecordErrorCode enum 내 다른 코드들이 모두
SR-*를 사용하고 있으므로, 메시지 처리 실패 코드도 동일하게 변경하는 것이 가독성과 검색성에 유리합니다.
- 파일:
edukit-core/src/main/java/com/edukit/core/studentrecord/exception/StudentRecordErrorCode.java- 위치: 15행
- MESSAGE_PROCESSING_FAILED("R-50001", "Redis 메시지 처리 중 오류가 발생했습니다."); + MESSAGE_PROCESSING_FAILED("SR-50001", "Redis 메시지 처리 중 오류가 발생했습니다.");또한, 이 에러를 보다 범용적인 Redis 처리 오류로 쓰고자 한다면, common 영역의 에러코드 enum으로 이동하는 것도 고려해 보세요.
edukit-external/src/main/java/com/edukit/external/redis/RedisStoreServiceImpl.java (2)
15-33: 구현 메서드에 @OverRide 애너테이션 추가 제안인터페이스 변경 시 시그니처 불일치를 컴파일 타임에 조기에 잡을 수 있습니다.
public class RedisStoreServiceImpl implements RedisStoreService { private final RedisTemplate<String, String> redisTemplate; + @Override public void store(final String key, final String value, final Duration ttl) { redisTemplate.opsForValue().set(key, value, ttl); } + @Override public String get(final String key) { return redisTemplate.opsForValue().get(key); } + @Override public void delete(final String key) { redisTemplate.delete(key); } + @Override public Long increment(final String key, final Duration ttl) { Long count = redisTemplate.opsForValue().increment(key); if (count == 1) { redisTemplate.expire(key, ttl); } return count; } }
27-33: INCR+EXPIRE 원자화 및 TTL 정책 확인 필요현재
RedisStoreServiceImpl.increment메서드는
INCR(원자) → 2) 조건부EXPIRE를 별도 명령으로 실행하므로, 프로세스 장애 시 TTL이 누락될 수 있습니다.
→ Lua 스크립트로INCR과PEXPIRE를 한 번에 실행해 원자성을 보장하는 것을 권장합니다.또한 TTL 전략을 명확히 해주세요:
- “최초 1회만 설정”이 맞는지 (현재 구현)
- “매 호출 시 갱신(슬라이딩 윈도우)”이 필요한지
관련 위치
edukit-external/src/main/java/com/edukit/external/redis/RedisStoreServiceImpl.java–increment(String key, Duration ttl)- 호출부:
edukit-core/src/main/java/com/edukit/core/studentrecord/service/SSEChannelManager.java
redisStoreService.increment(responseCountKey(taskId), RESPONSE_COUNT_TTL)(RESPONSE_COUNT_TTL = Duration.ofMinutes(5))Optional: Lua 스크립트 적용 예시
public Long increment(final String key, final Duration ttl) { - Long count = redisTemplate.opsForValue().increment(key); - if (count == 1) { - redisTemplate.expire(key, ttl); - } - return count; + String millis = String.valueOf(ttl.toMillis()); + return redisTemplate.execute(INCR_EXPIRE_SCRIPT, + Collections.singletonList(key), millis); } // 클래스 상단에 추가 private static final DefaultRedisScript<Long> INCR_EXPIRE_SCRIPT; static { INCR_EXPIRE_SCRIPT = new DefaultRedisScript<>(); INCR_EXPIRE_SCRIPT.setResultType(Long.class); INCR_EXPIRE_SCRIPT.setScriptText( "local c = redis.call('INCR', KEYS[1]); " + "if c == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]); end; " + "return c;" ); }edukit-core/src/main/java/com/edukit/core/common/service/RedisStreamService.java (4)
11-11: 소비자 그룹 생성 시 idempotency/오류 처리 계약을 명시해 주세요
createStreamConsumerGroup가 이미 존재하는 그룹(BUSYGROUP)일 때의 동작(예외 throw vs 무시)을 인터페이스 수준에서 문서화하거나 idempotent하게 보장하는 것이 좋습니다. 구현체에서는XGROUP CREATE ... MKSTREAM사용/예외 매핑을 명확히 해 주세요.구현체(
RedisStreamServiceImpl)에서 BUSYGROUP 예외 처리(무시/로그/재시도) 정책이 일관적인지 점검해 주세요.
13-13: 읽기 제어 파라미터(블로킹/배치/NOACK) 확장 고려현재
readFromStream(Consumer, streamKey, ReadOffset)만으로는 운영환경에서 흔히 필요한 다음 제어가 어렵습니다:
- blockDuration (XREADGROUP BLOCK)
- count/batch size (COUNT)
- noAck 모드
오버로드 추가를 권장합니다.
가능한 시그니처 예시:
- readFromStream(Consumer consumer, String streamKey, ReadOffset readOffset, Duration block, long count, boolean noAck)
15-15: ACK 배치 지원 추가 제안메시지를 하나씩 ACK하면 RTT가 증가합니다.
List<RecordId>를 받아 일괄 ACK하는 오버로드를 추가해 주세요.예) acknowledgeStreamMessages(String groupName, String streamKey, List messageIds)
9-16: 타입/직렬화 전략을 인터페이스 수준에서 명시
MapRecord<String, Object, Object>는 유연하지만, 직렬화 일관성이 흔들릴 수 있습니다. 키/필드/값의 타입 계약(예: String/byte[]/JSON)과 직렬화 전략(Jackson/RedisSerializer)을 인터페이스 문서에 명시하거나, 제네릭/래퍼 타입으로 강화하는 것을 고려해 주세요.edukit-api/src/main/java/com/edukit/studentrecord/controller/StudentRecordAIController.java (1)
23-23: 컨트롤러가 API 인터페이스를 구현하도록 변경된 점 좋습니다컨트랙트 정합성이 높아지고 문서화/테스트에 유리합니다. 아래 메서드에
@Override를 추가하면 컴파일 타임 검증이 더 견고해집니다.추가 제안:
- aiGenerateStudentRecord(...)
- streamStudentRecordResponse(...)
edukit-core/src/main/java/com/edukit/core/common/event/ai/AIEventListener.java (1)
53-54: 예외 로깅 시 스택트레이스를 포함하도록 수정현재는
e.getMessage()만 로깅되어 원인 추적이 어렵습니다. SLF4J는 마지막 인자를 Throwable로 해석하므로 스택트레이스를 함께 남기세요.다음 변경을 권장합니다:
- log.error("SQS 메시지 전송 실패 - taskId: {}, error: {}", taskId, e.getMessage()); + log.error("SQS 메시지 전송 실패 - taskId: {}", taskId, e);edukit-core/src/main/java/com/edukit/core/common/event/ai/AIResponseGenerateEvent.java (1)
3-7: 이행 편의성을 위한 default 브리지 메서드 고려기존
taskId()호출부가 많다면, 바이너리/소스 호환을 위해 default 메서드로 브리지를 제공하면 마이그레이션이 수월합니다.예:
default long taskId() { return task().getId(); }edukit-core/src/main/java/com/edukit/core/studentrecord/db/entity/StudentRecordAITask.java (3)
54-62: 상태 전이 가드 및 멱등성 확보 제안
start()/complete()가 여러 번 호출되거나 잘못된 순서로 호출될 수 있습니다. 상태 전이 가드를 두어 멱등성을 보장하는 편이 안전합니다.다음과 같이 최소 가드를 추가해 주세요:
public void start() { - this.status = AITaskStatus.IN_PROGRESS; - this.startedAt = LocalDateTime.now(); + if (this.status != AITaskStatus.PENDING) { + return; // 혹은 IllegalStateException 고려 + } + this.status = AITaskStatus.IN_PROGRESS; + if (this.startedAt == null) { + this.startedAt = LocalDateTime.now(); + } } public void complete() { - this.status = AITaskStatus.COMPLETED; - this.completedAt = LocalDateTime.now(); + if (this.status != AITaskStatus.IN_PROGRESS) { + return; // 혹은 IllegalStateException 고려 + } + this.status = AITaskStatus.COMPLETED; + if (this.completedAt == null) { + this.completedAt = LocalDateTime.now(); + } }
54-62: 도메인 객체 내 now() 호출 최소화 (테스트/타임존 고려)테스트 용이성과 타임존 일관성을 위해
LocalDateTime.now()를 엔티티 내부에서 직접 호출하기보다 서비스 계층에서 시간을 주입하는 패턴을 권장합니다.예:
public void start(LocalDateTime now) { ... } public void complete(LocalDateTime now) { ... } // 서비스: task.start(clock.now()); task.complete(clock.now());
18-38: 낙관적 락(@Version) 추가 고려동일 태스크에 대해 응답 카운팅/완료 트리거가 경쟁할 수 있습니다. 낙관적 락을 통해 마지막 업데이트 승자 정책을 명확히 하세요.
예:
@Version private Long version;edukit-core/src/main/java/com/edukit/core/studentrecord/service/StudentRecordService.java (1)
45-49: 완료 처리의 예외 코드 및 멱등성 검토 제안
- 현재 태스크 미존재 시
STUDENT_RECORD_NOT_FOUND를 던지는데, 도메인 의미상 “학생 기록”이 아니라 “AI 태스크” 미존재에 가깝습니다. 별도 에러 코드(예:AI_TASK_NOT_FOUND)가 있다면 교체를 고려해 주세요.completeAITask가 여러 번 호출될 수 있는(예: 중복 메시지/동시성) 상황에서 멱등한 동작을 보장하는지 확인이 필요합니다.StudentRecordAITask.complete()가 이미 완료 상태일 때 무해하도록 되어 있지 않다면 방어 로직을 추가하는 것을 권장합니다.예시(엔티티에
isCompleted()가 있다고 가정):public void completeAITask(final Long taskId) { StudentRecordAITask aiTask = aiTaskRepository.findById(taskId) .orElseThrow(() -> new StudentRecordException(StudentRecordErrorCode.STUDENT_RECORD_NOT_FOUND)); - aiTask.complete(); + if (!aiTask.isCompleted()) { + aiTask.complete(); + } }edukit-api/src/main/java/com/edukit/studentrecord/controller/StudentRecordAIApi.java (1)
114-127: 매핑 어노테이션 존재 여부 확인(컨트롤러 구현과 일치 필요)인터페이스에는 @PostMapping/@GetMapping 등 요청 매핑이 없습니다. 구현 컨트롤러에서 두 메서드에 적절한 매핑과
produces/consumes가 선언되어 있는지 확인해 주세요. 특히 SSE 스트리밍은produces = MediaType.TEXT_EVENT_STREAM_VALUE지정이 필수입니다.원하시면 컨트롤러 구현부에 들어갈 표준 매핑 샘플을 제안드리겠습니다.
edukit-core/src/main/java/com/edukit/core/studentrecord/service/SSEChannelManager.java (2)
34-34: SSE 채널 키 TTL을 5분으로 통일 권장(목표와의 정합성)PR 목표가 “Redis 키 TTL 5분”인데, 채널 키는 1시간으로 저장되고 있습니다. 카운터 TTL(5분)과 채널 TTL을 일관되게 맞추는 편이 운영/정리 기준에 부합합니다. 최소 변경으로 5분으로 조정 제안드립니다.
- redisStoreService.store(sseChannelKey(taskId), serverId, Duration.ofHours(1)); + redisStoreService.store(sseChannelKey(taskId), serverId, Duration.ofMinutes(5));
32-37: SSE emitter 콜백으로 누수 방지(타임아웃/완료 시 정리) 제안채널 등록 시
onTimeout/onCompletion콜백에서removeChannel(taskId)를 호출하면 예외 경로가 아닌 정상 종료/타임아웃에서도 안정적으로 정리됩니다.예시(참고 코드 — 본 범위 외 추가 작업):
public void registerTaskChannel(final String taskId, final SseEmitter emitter) { String serverId = serverInstanceManager.getServerId(); redisStoreService.store(sseChannelKey(taskId), serverId, Duration.ofMinutes(5)); activeChannels.put(taskId, emitter); emitter.onCompletion(() -> removeChannel(taskId)); emitter.onTimeout(() -> removeChannel(taskId)); log.info("Registered SSE channel for taskId: {} on server: {}", taskId, serverId); }원하시면 위 콜백 추가를 포함한 패치를 만들어 드리겠습니다.
edukit-external/src/main/java/com/edukit/external/redis/RedisStreamServiceImpl.java (1)
18-18: 제네릭/직렬화 정합성으로 @SuppressWarnings 제거 여지RedisTemplate<String, String>을 사용하면서 MapRecord<String, Object, Object>로 다루고 있어 불가피하게 @SuppressWarnings("unchecked")가 붙어 있습니다. 인터페이스와 구현을 MapRecord<String, String, String>으로 통일하거나, 템플릿을 RedisTemplate<String, Object>로 상향하면 경고 제거가 가능합니다. 대규모 변경이므로 선택적으로 검토해 주세요.
Also applies to: 24-28
edukit-core/src/main/java/com/edukit/core/studentrecord/service/RedisStreamConsumer.java (4)
97-110: payload 필드 누락/형식 오류 대비 및 민감 데이터 로그 축소data 필드가 없거나 형식이 잘못된 메시지가 들어오면 NPE/파싱 예외가 납니다. 또한 전체 JSON을 info 레벨로 로그하면 민감 데이터가 노출될 수 있습니다. 최소한의 메타만 로그하고, data 누락은 건너뛰도록 방어 코드를 넣는 것이 좋습니다.
private void processMessage(final MapRecord<String, Object, Object> message) { try { Map<Object, Object> messageBody = message.getValue(); - String messageJson = (String) messageBody.get("data"); - - log.info("Received message from Redis Stream: {}", messageJson); + String messageJson = (String) messageBody.get("data"); + if (messageJson == null) { + log.warn("Skip stream message without 'data' field. id={}, fields={}", message.getId(), messageBody.keySet()); + return; + } + log.debug("Received message id={} len={}", message.getId(), messageJson.length()); AIResponseMessage responseMessage = objectMapper.readValue(messageJson, AIResponseMessage.class); String taskId = String.valueOf(responseMessage.taskId());참고: 로그 레벨을 debug로 낮추고, 전문 출력 대신 길이나 id만 남기면 PII 노출 위험이 줄어듭니다.
36-39: consumer 이름 고정값 개선 제안: 인스턴스별 유니크 값 사용같은 JVM에 중복 빈이 등록되거나 재시작 시 동일 consumer name("consumer-1")을 사용할 경우 그룹 내 충돌이 날 수 있습니다. 서버 ID 기반으로 consumer name을 부여하면 안전합니다.
- private static final String CONSUMER_NAME = "consumer-1"; + private static final String CONSUMER_NAME_PREFIX = "consumer-";- Consumer consumer = Consumer.from(consumerGroupName, CONSUMER_NAME); + String consumerName = CONSUMER_NAME_PREFIX + serverInstanceManager.getServerId(); + Consumer consumer = Consumer.from(consumerGroupName, consumerName);Also applies to: 83-84
76-79: 폴링 대신 블로킹 읽기 검토1초 주기 폴링은 유휴 시에도 스레드를 계속 깨어 있게 합니다. 블로킹 XREADGROUP(StreamReadOptions.block(...))을 사용하면 지연/자원 사용을 줄일 수 있습니다. 현재 RedisStreamService는 StreamReadOptions를 받지 않으므로, 오버로드 추가를 고려해 주세요.
예시:
// Service에 오버로드 추가 List<MapRecord<String, Object, Object>> readFromStream(Consumer consumer, String streamKey, StreamReadOptions options); // Consumer 사용 var options = StreamReadOptions.empty().block(Duration.ofSeconds(10)); List<MapRecord<...>> messages = redisStreamService.readFromStream(consumer, STREAM_KEY, options);
107-116: PR 목표 검증: SSE 전송 시 응답 카운팅/TTL/자동 완료 흐름이 호출되는지 확인 필요현재는 sseChannelManager.sendMessage(taskId, responseMessage)만 보입니다. 아래가 충족되는지 확인 부탁드립니다:
- Redis에 per-task 카운터가 자동 증가되는지
- 키 TTL(5분)이 설정되는지
- 카운터가 3에 도달하면 completeTask()가 호출되고 채널이 정리되는지
만약 sendMessage 내부에서 모두 처리한다면 OK입니다. 아니면 적절한 위치에 호출 추가가 필요합니다. 필요한 경우 관련 코드/테스트 추가를 도와드릴 수 있습니다.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (24)
edukit-api/src/main/java/com/edukit/studentrecord/controller/StudentRecordAIApi.java(1 hunks)edukit-api/src/main/java/com/edukit/studentrecord/controller/StudentRecordAIController.java(1 hunks)edukit-api/src/main/java/com/edukit/studentrecord/event/AITaskCreateEvent.java(1 hunks)edukit-api/src/main/java/com/edukit/studentrecord/facade/StudentRecordAIFacade.java(2 hunks)edukit-core/build.gradle(1 hunks)edukit-core/src/main/java/com/edukit/core/auth/service/RefreshTokenStoreService.java(1 hunks)edukit-core/src/main/java/com/edukit/core/common/event/ai/AIEventListener.java(2 hunks)edukit-core/src/main/java/com/edukit/core/common/event/ai/AIResponseGenerateEvent.java(1 hunks)edukit-core/src/main/java/com/edukit/core/common/service/RedisStoreService.java(1 hunks)edukit-core/src/main/java/com/edukit/core/common/service/RedisStreamService.java(1 hunks)edukit-core/src/main/java/com/edukit/core/studentrecord/db/entity/StudentRecordAITask.java(1 hunks)edukit-core/src/main/java/com/edukit/core/studentrecord/exception/StudentRecordErrorCode.java(1 hunks)edukit-core/src/main/java/com/edukit/core/studentrecord/service/RedisStreamConsumer.java(1 hunks)edukit-core/src/main/java/com/edukit/core/studentrecord/service/SSEChannelManager.java(3 hunks)edukit-core/src/main/java/com/edukit/core/studentrecord/service/StudentRecordService.java(1 hunks)edukit-core/src/main/java/com/edukit/core/studentrecord/util/AIPromptGenerator.java(1 hunks)edukit-external/build.gradle(0 hunks)edukit-external/src/main/java/com/edukit/external/redis/RedisMessageSubscriber.java(0 hunks)edukit-external/src/main/java/com/edukit/external/redis/RedisStoreServiceImpl.java(2 hunks)edukit-external/src/main/java/com/edukit/external/redis/RedisStreamServiceImpl.java(1 hunks)edukit-external/src/main/java/com/edukit/external/redis/config/RedisConfig.java(2 hunks)edukit-external/src/main/java/com/edukit/external/redis/config/RedisSubscriber.java(0 hunks)edukit-external/src/main/java/com/edukit/external/redis/exception/RedisErrorCode.java(0 hunks)edukit-external/src/main/java/com/edukit/external/redis/exception/RedisException.java(0 hunks)
💤 Files with no reviewable changes (5)
- edukit-external/build.gradle
- edukit-external/src/main/java/com/edukit/external/redis/config/RedisSubscriber.java
- edukit-external/src/main/java/com/edukit/external/redis/exception/RedisException.java
- edukit-external/src/main/java/com/edukit/external/redis/exception/RedisErrorCode.java
- edukit-external/src/main/java/com/edukit/external/redis/RedisMessageSubscriber.java
🧰 Additional context used
🧬 Code Graph Analysis (3)
edukit-core/src/main/java/com/edukit/core/auth/service/RefreshTokenStoreService.java (1)
edukit-external/src/main/java/com/edukit/external/redis/RedisStoreServiceImpl.java (1)
Service(9-34)
edukit-core/src/main/java/com/edukit/core/studentrecord/service/RedisStreamConsumer.java (1)
edukit-core/src/main/java/com/edukit/core/studentrecord/exception/StudentRecordException.java (1)
StudentRecordException(5-10)
edukit-external/src/main/java/com/edukit/external/redis/RedisStoreServiceImpl.java (1)
edukit-core/src/main/java/com/edukit/core/auth/service/RefreshTokenStoreService.java (1)
Service(10-36)
🔇 Additional comments (16)
edukit-core/src/main/java/com/edukit/core/studentrecord/util/AIPromptGenerator.java (1)
39-41: UTF-8 바이트 기준 명시 및 검증 로직 확인 필요프로젝트 전반에서 “최소 %d바이트” 기준이 UTF-8 바이트인지, 또는 문자 수인지 혼동될 여지가 있습니다. 아래를 검토·보완해주세요.
- Prompt 템플릿 수정 제안
- 각 버전은 최소 %d바이트 이상 작성하고, 구분자(===VERSION_N===)는 반드시 포함해야 합니다. + 각 버전은 최소 %d바이트(UTF-8 기준) 이상 작성하고, 구분자(===VERSION_N===)는 반드시 포함해야 합니다.- 바이트 수 검증 로직 위치 점검
- edukit-core/src/main/java/com/edukit/core/studentrecord/util/AIPromptGenerator.java → createStreamingPrompt
- edukit-api/src/main/java/com/edukit/studentrecord/facade/StudentRecordAIFacade.java → createStreamingPrompt 호출부
- 코드베이스에 UTF-8 바이트 길이를 계산·검증하는
getBytes(UTF-8)·StandardCharsets.UTF_8등 관련 구현이 없습니다. 아래 스크립트로 수동 확인하거나, 필요 시 로직을 추가해주세요.# UTF-8 바이트 길이 검증 로직 검색 rg -nP -C3 '(getBytes\s*\(\s*(?:StandardCharsets\.UTF_?8|"UTF-8"))|byteCount|minBytes|bytesLength|byteLength|바이트' .- 프롬프트 내 ‘===VERSION_N===’ 구분자 파싱(split/regex) 로직이 모든 케이스에서 정상 동작하는지도 함께 검증 부탁드립니다.
edukit-core/build.gradle (1)
10-12: Redis 의존성 API 노출 유지 필요core 모듈의 공개 API 및 내부 구현에서 Spring Data Redis 타입을 직접 참조하고 있어, ‘api’ 범위를 유지해야 합니다.
- com/edukit/core/common/service/RedisStreamService.java
• 메서드 시그니처에 Consumer, MapRecord, ReadOffset, RecordId 사용- com/edukit/core/studentrecord/service/RedisStreamConsumer.java
• Redis 스트림 관련 클래스(import org.springframework.data.redis.connection.stream.*) 활용위 타입들이 core 모듈을 사용하는 상위 프로젝트의 컴파일 시 필요하므로,
api 'org.springframework.boot:spring-boot-starter-data-redis'를implementation으로 변경하거나 제거하면 안 됩니다.Likely an incorrect or invalid review comment.
edukit-core/src/main/java/com/edukit/core/common/service/RedisStoreService.java (1)
13-14: 증분(increment) API 추가가 자동 완료(3회 카운트) 요건과 정확히 부합합니다Redis 카운터 값 반환(Long)으로 상위 레이어에서 “3회 도달 시 complete” 흐름을 간단히 구현할 수 있어 설계가 깔끔합니다.
edukit-core/src/main/java/com/edukit/core/auth/service/RefreshTokenStoreService.java (1)
12-31: RedisStoreService로의 마이그레이션과 조건 빈 활성화 사용이 적절합니다
- @ConditionalOnBean(RedisStoreService.class)로 외부 구현체 미존재 시 안전 비활성화되는 점이 👍
- TTL을 JwtProperties 기반 Duration으로 계산해 store 호출에 위임한 구조도 명확합니다.
edukit-api/src/main/java/com/edukit/studentrecord/controller/StudentRecordAIController.java (1)
23-23: 연결 흐름 및closeChannelidempotency 확인 완료
- Controller → Facade → SSEChannelManager.registerTaskChannel 으로 SSE 채널 등록
- sendMessage() 내에서
• redisStoreService.increment(responseCountKey, RESPONSE_COUNT_TTL) 호출로 카운터 자동 증가 및 TTL(5분) 설정
• 응답 3회 도달 시 completeTask(taskId) 실행 → studentRecordService.completeAITask(), emitter.complete(), removeChannel(taskId), response-count 키 삭제- Controller의 onCompletion/onTimeout/onError 콜백에서 facade.closeChannel(taskId) 호출 → 내부적으로 activeChannels.remove 및 sseChannelKey 삭제만 수행하므로 다중 호출 시에도 안전
위 확인 결과, PR 목표(응답 3회 시 completeTask 호출, Redis 카운터 자동 증가 및 TTL 5분 적용)와
closeChannel의 idempotent 동작이 모두 충족됩니다.edukit-core/src/main/java/com/edukit/core/common/event/ai/AIEventListener.java (1)
60-61: 완료 처리 및 SSE 정리 로직 정상 확인됨– SSEChannelManager.java (라인 59–61)
• responseCount >= MAX_RESPONSE_COUNT 일 때completeTask(taskId)호출
– SSEChannelManager.java (라인 79–84)
•completeTask()내부에서
1.studentRecordService.completeAITask(taskId)호출
2.redisStoreService.delete(sseChannelKey(taskId))로 채널 정리
3. 중복 호출에도 안전한 idempotency 보장
– StudentRecordService.java (라인 45–48)
•completeAITask(Long)에서 AI 태스크 상태 변경 로직 확인위 로직이 PR 목표인 “응답 3회 도달 시 자동 completeTask() 호출 및 SSE 정리”를 충족하므로 추가 작업 불필요합니다.
edukit-core/src/main/java/com/edukit/core/common/event/ai/AIResponseGenerateEvent.java (1)
7-7: 태스크 엔티티를 직접 노출하도록 변경된 점 적절합니다생명주기 제어(start/complete)와 함께 컨텍스트를 풍부하게 전달할 수 있어 downstream 로직이 단순해집니다.
edukit-core/src/main/java/com/edukit/core/studentrecord/service/StudentRecordService.java (1)
38-42: 엔티티 반환으로 전환한 점 LGTM
createAITask가 ID 대신StudentRecordAITask엔티티를 반환하도록 바뀐 점이 이벤트/파사드 흐름과 잘 맞습니다. 트랜잭션 내 저장 후 영속 상태 엔티티를 반환하는 것도 자연스럽습니다.edukit-api/src/main/java/com/edukit/studentrecord/facade/StudentRecordAIFacade.java (1)
31-35: Task 객체 중심으로의 파사드 흐름 전환 LGTM
createAITask가StudentRecordAITask를 반환하고, 이벤트/응답에서task.getId()를 사용하도록 일관되게 전환된 점이 좋습니다. 이벤트 발행 시 Task를 그대로 전달하는 것도 이후 라이프사이클 확장에 유리합니다.edukit-api/src/main/java/com/edukit/studentrecord/event/AITaskCreateEvent.java (1)
7-16: 이벤트 페이로드 Task 엔티티 전환 LGTM + 호환성 확인 완료–
AIResponseGenerateEvent인터페이스에는task()메서드만 노출
–AITaskCreateEvent.of(...)호출부 전부 새 시그니처(StudentRecordAITask task) 사용
– 코드베이스에서taskId()/getTaskId()검색 결과, 이벤트 흐름이 아닌AIResponseMessage에서만 등장따라서 이벤트 리스너가 모두
event.task()를 호출하도록 구현되어 있는지만 최종 수동 확인해 주시면 되겠습니다.edukit-core/src/main/java/com/edukit/core/studentrecord/service/SSEChannelManager.java (3)
18-18: RedisStoreService 존재 시에만 로드하는 조건부 빈 등록 LGTMRedis 미사용 환경에서의 빈 로드 방지에 도움 됩니다.
27-31: 응답 카운터/TTL 상수 정의 LGTMMAX=3, TTL=5분 상수화로 가독성이 좋습니다. PR 목적과 부합합니다.
56-62: 카운터 증가 후 자동 완료 트리거 로직 LGTM(원자성 확보)Redis INCR 기반으로 원자적 증가가 보장되고, 임계치 이상에서 완료 트리거하는 흐름이 명확합니다.
edukit-external/src/main/java/com/edukit/external/redis/config/RedisConfig.java (1)
39-48: RedisTemplate 직렬화 설정 적절키/값/해시 모두 StringRedisSerializer로 일관되게 지정되어 Streams에서 JSON 문자열 payload를 다루기에 적합합니다. 별도 이슈 없어 보입니다.
edukit-core/src/main/java/com/edukit/core/studentrecord/service/RedisStreamConsumer.java (2)
67-74: 그룹 생성 시 ReadOffset 전략 확인처음 그룹 생성 시 ReadOffset.from("0")은 기존 모든 레코드를 재처리합니다. 신규 이후만 처리하려면 ReadOffset.latest()를 사용해야 합니다. 운영 의도에 맞는지 확인 바랍니다.
52-65: 정상 종료 처리 좋습니다우아한 셧다운과 인터럽트 처리까지 포함되어 있어 스케줄러 종료 동작이 안정적입니다.
📣 Jira Ticket
EDMT-401
👩💻 작업 내용
completeTask())Summary by CodeRabbit