diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index ff03323..78a4ab6 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -460,7 +460,14 @@ docker compose up -d (`OK`|`FAILED`)·`errorCode`·`errorMessage`·`retriable` 추가(구버전 13-arg 생성자는 `status=OK` 위임 오버로드로 하위호환). `FeedbackCallbackService.apply` 가 저장 전에 `isFailed()` 를 확인: 실패면 저장 없이 `SseEventType.ERROR`(`SessionErrorNotice`, scope=`FEEDBACK`, - code=`FEEDBACK_GENERATION_FAILED`)를 세션/유저 채널에 발행하고 멱등 마킹만 한다. AI 의 + code=`FEEDBACK_GENERATION_FAILED`)를 세션/유저 채널에 발행하고, **실패 마커를 영속화**한다 + (`InterviewSession.markFeedbackFailed` — `feedback_failed_at`/`feedback_fail_retriable`, V29). + SSE 는 휘발성이라 그 순간 미접속 클라이언트를 위해 `SessionFeedbackQueryService.get` 이 + 피드백 없음 + 마커 존재 시 404 `FEEDBACK_GENERATION_FAILED`(details.retriable)를 반환해 + "생성 중"(FEEDBACK_NOT_READY)과 구분한다(`ownedFeedback` 공유 경로도 동일 분기). 마커는 + 성공 콜백·재생성 요청에서 클리어하며, 재생성의 `generate.feedback` 발행은 + `FeedbackRegenerateRequestedEvent` → AFTER_COMMIT 리스너로 — 마커 clear 커밋 전에 발행되는 + 역전(§"메시지 발행은 commit 이후" 규칙)을 막는다. AI 의 `errorMessage` 원문은 서버 로그에만 남기고 클라이언트에는 화이트리스트 문구만 보낸다 (QuestionsCallbackService 와 동일 원칙). AI 쪽 발행은 [`ai/CLAUDE.md`](../ai/CLAUDE.md) 참고. - **문장 단위 TTS 세그먼트 프록시 본 구현 (Part B)**: `InterviewMessageService.streamAudioSegment` + `GET /api/sessions/{sid}/messages/{mid}/audio/segments/{seq}?ext=`. AI 가 휘발성으로 쓴 라이브 세그먼트를 규칙(`interview/tts/{sid}/{mid}/seg-{seq}.{ext}`)으로 재구성해 프록시(DB 미기록). 소유권+ext 화이트리스트+seq>=0 검증으로 임의 키 노출 차단. diff --git a/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java b/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java index 228c1d6..01c7f72 100644 --- a/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java +++ b/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java @@ -44,6 +44,8 @@ public enum ApiErrorCode { SESSION_QUESTION_COUNT_CONFLICT(HttpStatus.BAD_REQUEST, "총 질문 상한은 일반질문 수보다 작을 수 없습니다."), FEEDBACK_NOT_READY(HttpStatus.NOT_FOUND, "피드백이 아직 생성되지 않았습니다."), + // 생성 실패 마커(interview_sessions.feedback_failed_at) 존재 — 대기 중단, 재생성 유도. + FEEDBACK_GENERATION_FAILED(HttpStatus.NOT_FOUND, "피드백 생성에 실패했습니다. 다시 생성을 요청해 주세요."), FEEDBACK_NOT_FOUND(HttpStatus.NOT_FOUND, "공유된 피드백을 찾을 수 없습니다."), FEEDBACK_ALREADY_EXISTS(HttpStatus.CONFLICT, "피드백이 이미 생성되어 있습니다."), VOICE_EMPTY_FILE(HttpStatus.BAD_REQUEST, "음성 파일을 업로드할 수 없습니다."), diff --git a/backend/src/main/java/com/stackup/stackup/session/application/FeedbackCallbackService.java b/backend/src/main/java/com/stackup/stackup/session/application/FeedbackCallbackService.java index 077dc59..c6a22b7 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/FeedbackCallbackService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/FeedbackCallbackService.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.stackup.stackup.common.exception.ApiErrorCode; import com.stackup.stackup.common.messaging.domain.ProcessedMessage; import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; import com.stackup.stackup.common.messaging.RealtimeNotifyEvent; @@ -90,16 +91,17 @@ public void apply(FeedbackCallbackEnvelope envelope) { keywordsToJson(payload.highlights()), payload.reportS3Key() ); - try { - feedback = feedbackRepository.save(feedback); - } catch (DataIntegrityViolationException race) { - log.info("callback.feedback unique race — feedback inserted concurrently. sessionId={}", sessionId); - markProcessed(envelope.messageId()); - return; - } + // 동시 콜백 경합의 unique 위반은 잡지 않는다 — IDENTITY 전략이라 save 가 즉시 INSERT 하고 + // 실패 시 트랜잭션이 이미 rollback-only 라, catch 후 markProcessed 는 어차피 폐기되고 + // 커밋에서 UnexpectedRollbackException 이 난다. 예외를 그대로 전파시키면 리스너 재시도가 + // 위의 existsBySession_Id 체크에서 멱등 skip 으로 수렴한다. + feedback = feedbackRepository.save(feedback); applyAnswerCoaching(sessionId, payload.answerCoaching()); + // 이전 시도가 실패로 마킹돼 있었다면(재생성 성공·지연 도착 성공) 마커를 걷는다. + session.clearFeedbackFailure(); + events.publishEvent(RealtimeNotifyEvent.session(sessionId, SseEventType.FEEDBACK_READY, new SessionFeedbackNotice(sessionId, feedback.getId()))); events.publishEvent(RealtimeNotifyEvent.user(session.getUser().getId(), SseEventType.FEEDBACK_READY, @@ -140,14 +142,19 @@ public record SessionFeedbackNotice(Long sessionId, Long feedbackId) { private void applyFeedbackFailed(InterviewSession session, FeedbackCallbackPayload payload) { log.warn("callback.feedback generation failed. sessionId={}, errorCode={}, retriable={}, message={}", session.getId(), payload.errorCode(), payload.retriable(), payload.errorMessage()); + // 영속 마커(V29) — SSE ERROR 는 휘발성이라, 그 순간 미접속 클라이언트도 GET 피드백에서 + // 실패를 구분할 수 있게 남긴다 (dirty checking 으로 UPDATE). + session.markFeedbackFailed(payload.retriable()); QuestionsCallbackService.SessionErrorNotice notice = new QuestionsCallbackService.SessionErrorNotice( session.getId(), "FEEDBACK", FEEDBACK_FAILED_CODE, FEEDBACK_FAILED_MESSAGE, payload.retriable()); events.publishEvent(RealtimeNotifyEvent.session(session.getId(), SseEventType.ERROR, notice)); events.publishEvent(RealtimeNotifyEvent.user(session.getUser().getId(), SseEventType.ERROR, notice)); } - private static final String FEEDBACK_FAILED_CODE = "FEEDBACK_GENERATION_FAILED"; - private static final String FEEDBACK_FAILED_MESSAGE = "피드백 생성에 실패했습니다. 잠시 후 다시 시도해 주세요."; + // REST(GET 피드백)와 같은 코드·문구 — 채널에 따라 다른 안내가 나가지 않게 단일 출처로 묶는다. + private static final String FEEDBACK_FAILED_CODE = ApiErrorCode.FEEDBACK_GENERATION_FAILED.name(); + private static final String FEEDBACK_FAILED_MESSAGE = + ApiErrorCode.FEEDBACK_GENERATION_FAILED.getDefaultMessage(); private String keywordsToJson(java.util.List keywords) { if (keywords == null) { diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackQueryService.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackQueryService.java index 041527a..a1814b2 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackQueryService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackQueryService.java @@ -2,14 +2,17 @@ import com.stackup.stackup.common.exception.ApiErrorCode; import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.session.application.event.FeedbackRegenerateRequestedEvent; import com.stackup.stackup.session.application.dto.FeedbackResult; import com.stackup.stackup.session.domain.InterviewSession; import com.stackup.stackup.session.domain.InterviewSessionRepository; import com.stackup.stackup.session.domain.SessionFeedback; import com.stackup.stackup.session.domain.SessionFeedbackRepository; import com.stackup.stackup.session.domain.SessionStatus; +import java.util.Map; import java.util.UUID; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,16 +23,28 @@ public class SessionFeedbackQueryService { private final InterviewSessionRepository sessionRepository; private final SessionFeedbackRepository feedbackRepository; - private final SessionFeedbackRequester feedbackRequester; + private final ApplicationEventPublisher events; public FeedbackResult get(Long userId, Long sessionId) { - sessionRepository.findByIdAndUser_IdAndDeletedFalse(sessionId, userId) + InterviewSession session = sessionRepository.findByIdAndUser_IdAndDeletedFalse(sessionId, userId) .orElseThrow(() -> new DomainException(ApiErrorCode.SESSION_NOT_FOUND)); SessionFeedback feedback = feedbackRepository.findBySession_Id(sessionId) - .orElseThrow(() -> new DomainException(ApiErrorCode.FEEDBACK_NOT_READY)); + .orElseThrow(() -> notReadyOrFailed(session)); return FeedbackResult.of(feedback); } + // 피드백 없음의 두 얼굴 구분: 실패 마커(V29)가 있으면 "생성 중"이 아니라 "실패"다 — + // SSE ERROR 를 놓친(새로고침·재접속) 클라이언트도 폴링 즉시 복구 UI 로 전환할 수 있게. + private DomainException notReadyOrFailed(InterviewSession session) { + if (session.hasFeedbackFailure()) { + Map details = session.getFeedbackFailRetriable() != null + ? Map.of("retriable", session.getFeedbackFailRetriable()) + : Map.of(); + return new DomainException(ApiErrorCode.FEEDBACK_GENERATION_FAILED, details); + } + return new DomainException(ApiErrorCode.FEEDBACK_NOT_READY); + } + // 공유 활성화: 소유자 검증 후 토큰 보장(없으면 발급). 멱등 — 현재 토큰 반환. @Transactional public String enableShare(Long userId, Long sessionId) { @@ -63,13 +78,18 @@ public void regenerate(Long userId, Long sessionId) { if (feedbackRepository.existsBySession_Id(sessionId)) { throw new DomainException(ApiErrorCode.FEEDBACK_ALREADY_EXISTS); } - feedbackRequester.publishGenerateFeedback(userId, sessionId, "REGENERATE"); + // 재생성 요청 = 새 시도 시작 — 실패 마커를 지워 다른 탭/폴링도 "생성 중"으로 복귀시킨다. + session.clearFeedbackFailure(); + // 발행은 commit 이후(AFTER_COMMIT 리스너) — 마커 clear 가 커밋되기 전에 새 시도의 + // 콜백이 도착하거나, 발행만 되고 커밋이 실패하는 역전을 막는다 (onSessionEnded 와 동일 규칙). + events.publishEvent(new FeedbackRegenerateRequestedEvent(userId, sessionId)); } private SessionFeedback ownedFeedback(Long userId, Long sessionId) { - sessionRepository.findByIdAndUser_IdAndDeletedFalse(sessionId, userId) + InterviewSession session = sessionRepository.findByIdAndUser_IdAndDeletedFalse(sessionId, userId) .orElseThrow(() -> new DomainException(ApiErrorCode.SESSION_NOT_FOUND)); + // 실패 마커가 있으면 여기서도 "생성 중"이 아니라 "실패"로 응답 — GET 과 같은 계약. return feedbackRepository.findBySession_Id(sessionId) - .orElseThrow(() -> new DomainException(ApiErrorCode.FEEDBACK_NOT_READY)); + .orElseThrow(() -> notReadyOrFailed(session)); } } diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackRequester.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackRequester.java index 014ee20..e359ba0 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackRequester.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackRequester.java @@ -9,6 +9,7 @@ import com.stackup.stackup.session.application.dto.GenerateFeedbackPayload.MessageEvaluation; import com.stackup.stackup.session.application.dto.GenerateFeedbackPayload.MessageItem; import com.stackup.stackup.session.application.dto.GenerateFeedbackPayload.VoiceAnalysisSummary; +import com.stackup.stackup.session.application.event.FeedbackRegenerateRequestedEvent; import com.stackup.stackup.session.application.event.SessionEndedEvent; import com.stackup.stackup.session.domain.InterviewMessage; import com.stackup.stackup.session.domain.InterviewMessageRepository; @@ -54,6 +55,13 @@ public void onSessionEnded(SessionEndedEvent event) { publishGenerateFeedback(event.userId(), event.sessionId(), event.reason()); } + // 재생성도 같은 규칙 — regenerate 트랜잭션(실패 마커 clear 포함) commit 후에만 발행. + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onFeedbackRegenerateRequested(FeedbackRegenerateRequestedEvent event) { + publishGenerateFeedback(event.userId(), event.sessionId(), "REGENERATE"); + } + // 재생성(regenerate) 경로에서도 재사용하는 발행 본체. 멱등 가드는 여기서 — // 이벤트·API 어느 쪽으로 들어와도 피드백이 이미 있으면 발행하지 않는다. @Transactional diff --git a/backend/src/main/java/com/stackup/stackup/session/application/event/FeedbackRegenerateRequestedEvent.java b/backend/src/main/java/com/stackup/stackup/session/application/event/FeedbackRegenerateRequestedEvent.java new file mode 100644 index 0000000..e4b50f6 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/event/FeedbackRegenerateRequestedEvent.java @@ -0,0 +1,11 @@ +package com.stackup.stackup.session.application.event; + +// 피드백 재생성 요청 commit 후 발화. FeedbackRequester 가 수신. +// regenerate 트랜잭션(실패 마커 clear 포함)이 커밋된 뒤에만 generate.feedback 이 발행되게 해 +// "메시지 발행은 commit 이후" 규칙(backend/CLAUDE.md)을 지킨다 — 발행 후 커밋 실패로 +// 마커가 남거나, clear 커밋 전에 새 시도의 콜백이 도착하는 역전을 막는다. +public record FeedbackRegenerateRequestedEvent( + Long userId, + Long sessionId +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java index 3453ac3..7bd34fe 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java @@ -115,6 +115,15 @@ public class InterviewSession extends BaseSoftDeleteEntity { @Column(name = "resumed_at") private Instant resumedAt; + // 피드백 생성 실패 마커(V29). SSE ERROR 는 휘발성이라 그 순간 미접속 클라이언트가 + // "생성 중"과 "실패"를 구분할 수 없다 — FAILED 콜백 수신 시 기록하고 + // 성공 콜백·재생성 요청 시 클리어해 GET 피드백이 실패를 즉시 알리게 한다. + @Column(name = "feedback_failed_at") + private Instant feedbackFailedAt; + + @Column(name = "feedback_fail_retriable") + private Boolean feedbackFailRetriable; + private InterviewSession(User user, String title, String memo, SessionMode mode, List jobCategories, Integer maxQuestions, Integer maxDurationMinutes, @@ -225,6 +234,20 @@ public void cancel() { this.status = SessionStatus.CANCELLED; } + public void markFeedbackFailed(Boolean retriable) { + this.feedbackFailedAt = Instant.now(); + this.feedbackFailRetriable = retriable; + } + + public void clearFeedbackFailure() { + this.feedbackFailedAt = null; + this.feedbackFailRetriable = null; + } + + public boolean hasFeedbackFailure() { + return feedbackFailedAt != null; + } + // 메인(일반) 질문만 센다. 꼬리질문은 maxQuestions 한도에 포함하지 않는다 // (꼬리질문은 maxFollowupsPerQuestion 으로 별도 제한). public void incrementQuestionCount() { diff --git a/backend/src/main/resources/db/migration/V29__add_session_feedback_failure_marker.sql b/backend/src/main/resources/db/migration/V29__add_session_feedback_failure_marker.sql new file mode 100644 index 0000000..bafb1ce --- /dev/null +++ b/backend/src/main/resources/db/migration/V29__add_session_feedback_failure_marker.sql @@ -0,0 +1,7 @@ +-- 피드백 생성 실패의 영속 마커. callback.feedback FAILED 는 지금까지 SSE ERROR 로만 알려져 +-- 그 순간 미접속(새로고침·탭 닫힘)이던 클라이언트는 실패를 알 길이 없었다 — GET 피드백이 +-- "아직 없음"(FEEDBACK_NOT_READY)만 돌려줘 "생성 중"과 구분 불가, 폴링 예산(≈2분)까지 헛대기. +-- FAILED 수신 시 기록하고 성공 콜백 도착·재생성 요청 시 클리어해, REST 경로에서도 +-- FEEDBACK_GENERATION_FAILED 로 즉시 구분할 수 있게 한다. +ALTER TABLE interview_sessions ADD COLUMN feedback_failed_at TIMESTAMPTZ; +ALTER TABLE interview_sessions ADD COLUMN feedback_fail_retriable BOOLEAN; diff --git a/backend/src/test/java/com/stackup/stackup/session/application/FeedbackCallbackServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/FeedbackCallbackServiceTest.java index 729930a..3c1d996 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/FeedbackCallbackServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/FeedbackCallbackServiceTest.java @@ -171,6 +171,28 @@ void apply_failedCallbackSkipsSaveAndPushesErrorSse() { assertThat(notice.retriable()).isTrue(); }); verify(processedMessageRepository).save(any()); + // 영속 마커(V29) — SSE 를 놓친 클라이언트가 GET 피드백으로 실패를 구분하는 근거. + assertThat(session.hasFeedbackFailure()).isTrue(); + assertThat(session.getFeedbackFailRetriable()).isTrue(); + } + + @Test + void apply_successClearsFailureMarker() { + // 이전 시도가 실패로 마킹된 뒤 재생성이 성공하면 마커를 걷는다. + InterviewSession session = sessionFixture(50L); + session.markFeedbackFailed(true); + FeedbackCallbackEnvelope env = envelope(50L, "fb-retry-ok", + new FeedbackCallbackPayload(50L, 80.0, null, null, null, null, null, + List.of(), List.of(), List.of(), List.of(), List.of(), null)); + when(processedMessageRepository.existsById("fb-retry-ok")).thenReturn(false); + when(sessionRepository.findById(50L)).thenReturn(Optional.of(session)); + when(feedbackRepository.existsBySession_Id(50L)).thenReturn(false); + when(feedbackRepository.save(any(SessionFeedback.class))).thenAnswer(inv -> inv.getArgument(0)); + + service.apply(env); + + assertThat(session.hasFeedbackFailure()).isFalse(); + verify(feedbackRepository).save(any(SessionFeedback.class)); } @Test diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackQueryServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackQueryServiceTest.java index 55ad129..e8d25f2 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackQueryServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackQueryServiceTest.java @@ -8,6 +8,7 @@ import com.stackup.stackup.common.exception.ApiErrorCode; import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.session.application.event.FeedbackRegenerateRequestedEvent; import com.stackup.stackup.session.domain.InterviewSession; import com.stackup.stackup.session.domain.InterviewSessionRepository; import com.stackup.stackup.session.domain.JobCategory; @@ -22,6 +23,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) @@ -29,7 +31,7 @@ class SessionFeedbackQueryServiceTest { @Mock InterviewSessionRepository sessionRepository; @Mock SessionFeedbackRepository feedbackRepository; - @Mock SessionFeedbackRequester feedbackRequester; + @Mock ApplicationEventPublisher events; @InjectMocks SessionFeedbackQueryService service; @Test @@ -61,6 +63,52 @@ void getByToken_hidesFeedbackOfDeletedSession() { e -> assertThat(e.getErrorCode()).isEqualTo(ApiErrorCode.FEEDBACK_NOT_FOUND)); } + @Test + void get_distinguishesFailureFromPending() { + // 실패 마커(V29)가 있으면 "생성 중"(NOT_READY)이 아니라 실패 — SSE ERROR 를 놓친 + // 새로고침 클라이언트가 폴링 예산을 헛태우지 않고 즉시 복구 UI 로 전환하는 근거. + InterviewSession session = sessionFixture(50L); + session.markFeedbackFailed(true); + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(50L, 1L)) + .thenReturn(Optional.of(session)); + when(feedbackRepository.findBySession_Id(50L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.get(1L, 50L)) + .isInstanceOfSatisfying(DomainException.class, e -> { + assertThat(e.getErrorCode()).isEqualTo(ApiErrorCode.FEEDBACK_GENERATION_FAILED); + assertThat(e.getDetails()).containsEntry("retriable", true); + }); + } + + @Test + void get_returnsNotReadyWithoutFailureMarker() { + InterviewSession session = sessionFixture(50L); + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(50L, 1L)) + .thenReturn(Optional.of(session)); + when(feedbackRepository.findBySession_Id(50L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.get(1L, 50L)) + .isInstanceOfSatisfying(DomainException.class, + e -> assertThat(e.getErrorCode()).isEqualTo(ApiErrorCode.FEEDBACK_NOT_READY)); + } + + @Test + void regenerate_clearsFailureMarker() { + // 재생성 요청 = 새 시도 시작 — 마커를 지워 다른 탭/폴링이 "생성 중"으로 복귀한다. + InterviewSession session = sessionFixture(50L); + session.start(); + session.end(); + session.markFeedbackFailed(true); + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(50L, 1L)) + .thenReturn(Optional.of(session)); + when(feedbackRepository.existsBySession_Id(50L)).thenReturn(false); + + service.regenerate(1L, 50L); + + assertThat(session.hasFeedbackFailure()).isFalse(); + verify(events).publishEvent(new FeedbackRegenerateRequestedEvent(1L, 50L)); + } + @Test void regenerate_publishesForCompletedSessionWithoutFeedback() { InterviewSession session = sessionFixture(50L); @@ -72,7 +120,8 @@ void regenerate_publishesForCompletedSessionWithoutFeedback() { service.regenerate(1L, 50L); - verify(feedbackRequester).publishGenerateFeedback(1L, 50L, "REGENERATE"); + // 발행은 직접 하지 않고 이벤트만 — 실제 generate.feedback 은 AFTER_COMMIT 리스너가 발행. + verify(events).publishEvent(new FeedbackRegenerateRequestedEvent(1L, 50L)); } @Test @@ -87,7 +136,7 @@ void regenerate_conflictsWhenFeedbackAlreadyExists() { assertThatThrownBy(() -> service.regenerate(1L, 50L)) .isInstanceOfSatisfying(DomainException.class, e -> assertThat(e.getErrorCode()).isEqualTo(ApiErrorCode.FEEDBACK_ALREADY_EXISTS)); - verify(feedbackRequester, never()).publishGenerateFeedback(1L, 50L, "REGENERATE"); + verify(events, never()).publishEvent(new FeedbackRegenerateRequestedEvent(1L, 50L)); } @Test diff --git a/docs/api-conventions.md b/docs/api-conventions.md index d0eab0f..3245da5 100644 --- a/docs/api-conventions.md +++ b/docs/api-conventions.md @@ -245,6 +245,12 @@ SESSION_MAX_REACHED 422 최대 질문/시간 도달 SESSION_NOT_FOUND 404 SESSION_FORBIDDEN 403 타인 세션 접근 +# 피드백 (FEEDBACK_*) +FEEDBACK_NOT_READY 404 아직 생성 중 (폴링/SSE 대기 지속) +FEEDBACK_GENERATION_FAILED 404 생성 실패 마커 존재 — 대기 중단, 재생성 유도. details.retriable(boolean) 동봉 +FEEDBACK_NOT_FOUND 404 공유 토큰 무효 +FEEDBACK_ALREADY_EXISTS 409 재생성 요청 시 이미 존재 (재조회 신호) + # 시스템 (SYS_*) SYS_RATE_LIMITED 429 SYS_DEPENDENCY_DOWN 503 RabbitMQ/AI/LLM 다운 diff --git a/docs/database.md b/docs/database.md index a5383ac..62b6ca7 100644 --- a/docs/database.md +++ b/docs/database.md @@ -171,6 +171,10 @@ CREATE TABLE interview_sessions ( target_job_description TEXT, resumed_at TIMESTAMPTZ, -- 이어하기로 재개한 시각. 시간 한도를 이 값 기준으로 다시 잰다(V27) focus_areas JSONB, -- 약점 집중 재도전의 겨냥 축 배열(TECHNICAL|LOGIC|COMMUNICATION). 일반 면접은 NULL + -- 피드백 생성 실패 마커(V29). callback.feedback FAILED 수신 시 기록, 성공 콜백·재생성 요청 시 클리어. + -- 새로고침한 클라이언트가 GET 피드백에서 "생성 중"(FEEDBACK_NOT_READY)과 "실패"(FEEDBACK_GENERATION_FAILED)를 구분하는 근거. + feedback_failed_at TIMESTAMPTZ, + feedback_fail_retriable BOOLEAN, status VARCHAR(20) NOT NULL DEFAULT 'READY' CHECK (status IN ('READY','IN_PROGRESS','INTERRUPTED','COMPLETED','CANCELLED')), total_question_count INT DEFAULT 0, diff --git a/docs/event-stream.md b/docs/event-stream.md index 3bc9a0e..0c55bfc 100644 --- a/docs/event-stream.md +++ b/docs/event-stream.md @@ -222,6 +222,8 @@ SSE 미지원 환경 또는 영구 단절 시: ``` GET /api/documents/{id} # 5초 간격 폴링 GET /api/sessions/{id} # 메시지 변경 감지 +GET /api/sessions/{id}/feedback # 피드백 대기 폴링. 실패는 SSE ERROR(§3.6) 미수신이어도 + # 404 FEEDBACK_GENERATION_FAILED(영속 마커)로 구분된다 ``` 프론트엔드 구현은 `frontend/src/shared/hooks/useEventStream.ts` 단일 책임 훅으로 추상화. SSE 우선 → 실패 시 폴링. diff --git a/docs/messaging.md b/docs/messaging.md index b973151..4ac5672 100644 --- a/docs/messaging.md +++ b/docs/messaging.md @@ -526,7 +526,9 @@ - 생성이 성공했는데 **성공 콜백 발행만** 실패한 경우는 FAILED 로 오인 발행하지 않는다 — 원 예외로 DLQ 에 보내 재처리 가능하게 남긴다(멱등 마킹도 되돌림). - Core 는 FAILED 수신 시 피드백을 저장하지 않고 SSE `ERROR`(scope=FEEDBACK) 로 세션·유저 채널에 - 알린다. `errorMessage` 원문은 서버 로그에만 남긴다(클라이언트 미노출). + 알리며, **실패 마커를 영속화**한다(`interview_sessions.feedback_failed_at`/`feedback_fail_retriable`, + V29) — SSE 를 놓친 클라이언트도 GET 피드백의 404 `FEEDBACK_GENERATION_FAILED` 로 실패를 구분한다. + 마커는 성공 콜백 도착·재생성 요청 시 클리어. `errorMessage` 원문은 서버 로그에만 남긴다(클라이언트 미노출). ### 5.12 `realtime.session.notify` ```json diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 30831c7..5b37248 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -238,7 +238,7 @@ SEED 팔레트 블록에는 `prefers-color-scheme` 미디어쿼리가 없어서, - **SSE + WebSocket 병행** — 작업 상태 푸시(분석·피드백)는 SSE, 라이브 면접 메시지는 WS(`features/interview/model/useInterviewSocket.ts`). (루트 CLAUDE.md §8 과 동일) - 구현: `shared/hooks/useEventStream.ts` — 자동 재연결(지수 백오프) + 연결 상태 반환. 워크스페이스는 단절(closed) 시 배너 표시 + 목록 쿼리 5s 폴백 폴링(`useAnalysisFallbackPolling`) - 생성 진행 문구(휘발성, B2): 질문 풀 대기 화면은 WS `QUESTION_POOL_PROGRESS`(`interviewEvent.ts` → `InterviewPreparing`), 피드백 대기 스켈레톤은 세션 SSE `FEEDBACK_PROGRESS`(`useFeedbackLive` → `FeedbackReportSkeleton`). 둘 다 로컬 state 로만 표시하고 이벤트 미수신 시 기본 안내 문구로 폴백 — 워크스페이스의 `useAnalysisProgress` TTL 스토어(90s, user 채널 전용)는 통과하지 않는다 -- 생성 실패 신호: 피드백 대기 화면은 세션 SSE `ERROR`(scope=`FEEDBACK`, `SessionErrorNotice`)도 소비(`useFeedbackLive.failure`) — 수신 즉시 폴링 예산(≈2분) 소진을 기다리지 않고 재생성 복구 UI 로 전환, `retriable=false` 면 재생성 대신 새 면접 안내. scope 가 다른 ERROR(꼬리질문 실패)는 무시 +- 생성 실패 신호: 피드백 대기 화면은 세션 SSE `ERROR`(scope=`FEEDBACK`, `SessionErrorNotice`)도 소비(`useFeedbackLive.failure`) — 수신 즉시 폴링 예산(≈2분) 소진을 기다리지 않고 재생성 복구 UI 로 전환. scope 가 다른 ERROR(꼬리질문 실패)는 무시. SSE 를 놓친 새로고침·재접속 클라이언트는 REST 로 수렴 — GET 피드백의 404 `FEEDBACK_GENERATION_FAILED`(영속 마커, `isFeedbackFailed`)를 같은 `failure` 로 파생해 폴링을 즉시 중단한다. 마커가 영속이라 `retriable=false` 여도 재생성 버튼은 유지하고 문구만 기대치를 낮춘다(숨기면 해당 세션 피드백이 UI 로 영원히 도달 불가). 실패 해제는 재생성 성공의 `resetQueries` 하나로 일원화(쿼리 재로딩 시 SSE 실패 상태도 함께 걷힘), 실패 settle 후에도 `refetchOnWindowFocus` 로 타 기기 재생성을 흡수. `SESSION_NOT_FOUND`(404)는 pending 오분류에서 제외 - 미디어 스트림(음성/영상)만 WebRTC: `features/interview/lib/media/` - 이벤트 스펙: [`/docs/event-stream.md`](../docs/event-stream.md) diff --git a/frontend/src/features/feedback/model/useFeedback.ts b/frontend/src/features/feedback/model/useFeedback.ts index f9f124d..40ecea8 100644 --- a/frontend/src/features/feedback/model/useFeedback.ts +++ b/frontend/src/features/feedback/model/useFeedback.ts @@ -1,5 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { isApiError } from '@/shared/api' +import type { ApiError } from '@/shared/api' import { toast } from '@/shared/ui' import { disableShare, @@ -14,10 +15,22 @@ export const feedbackKeys = { detail: (sessionId: number) => [...feedbackKeys.all, sessionId] as const, } +// 생성 실패 마커(interview_sessions.feedback_failed_at) — 서버가 "생성 중"이 아니라 +// "실패"라고 알린 것. 폴링을 즉시 중단하고 재생성 복구 UI 로 전환한다. +export function isFeedbackFailed(err: unknown): err is ApiError { + return isApiError(err) && err.code === 'FEEDBACK_GENERATION_FAILED' +} + // 세션 종료 직후엔 피드백이 비동기 생성 중이라 아직 없음(FEEDBACK_NOT_READY/404). // 이 경우는 에러가 아니라 "생성 중"이므로 일정 횟수까지 polling 한다. +// 404 캐치올은 code 없는 게이트웨이 404 방어용 — 의미가 확정된 404 코드는 앞에서 걸러낸다: +// 실패 마커(FEEDBACK_GENERATION_FAILED)와 세션 부재(SESSION_NOT_FOUND — 삭제된/없는 세션에 +// 2분 폴링하는 오분류 방지)는 pending 이 아니다. export function isFeedbackPending(err: unknown): boolean { - return isApiError(err) && (err.code === 'FEEDBACK_NOT_READY' || err.status === 404) + if (!isApiError(err)) return false + // isFeedbackFailed 를 부르지 않고 code 비교 — 타입가드의 부정 분기가 err 를 never 로 좁힌다. + if (err.code === 'FEEDBACK_GENERATION_FAILED' || err.code === 'SESSION_NOT_FOUND') return false + return err.code === 'FEEDBACK_NOT_READY' || err.status === 404 } export function useFeedback(sessionId: number) { diff --git a/frontend/src/features/feedback/model/useFeedbackLive.test.tsx b/frontend/src/features/feedback/model/useFeedbackLive.test.tsx index 2221a70..8ca17a1 100644 --- a/frontend/src/features/feedback/model/useFeedbackLive.test.tsx +++ b/frontend/src/features/feedback/model/useFeedbackLive.test.tsx @@ -44,7 +44,7 @@ function setup() { const wrapper = ({ children }: { children: ReactNode }) => ( {children} ) - return renderHook(() => useFeedbackLive(99, async () => 'tok'), { wrapper }) + return { ...renderHook(() => useFeedbackLive(99, async () => 'tok'), { wrapper }), qc } } beforeEach(() => { @@ -176,7 +176,28 @@ describe('useFeedbackLive', () => { expect(result.current.failure).toBeNull() }) - it('resetFailure 는 대기 상태로 되돌린다 — 재생성 요청 직전에 페이지가 호출', async () => { + it('REST 실패 마커(FEEDBACK_GENERATION_FAILED)도 failure 로 수렴하고 폴링을 중단한다', async () => { + // SSE ERROR 를 놓친 새로고침 클라이언트 시나리오 — GET 피드백이 영속 마커를 반환. + vi.mocked(getFeedback).mockRejectedValue( + new ApiError(404, { + code: 'FEEDBACK_GENERATION_FAILED', + message: '피드백 생성에 실패했습니다. 다시 생성을 요청해 주세요.', + details: { retriable: false }, + }), + ) + + const { result } = setup() + await waitFor(() => expect(result.current.failure).not.toBeNull()) + + expect(result.current.failure).toEqual({ + message: '피드백 생성에 실패했습니다. 다시 생성을 요청해 주세요.', + retriable: false, + }) + // pending 이 아니므로 재시도(폴링) 없이 1회로 끝난다. + expect(getFeedback).toHaveBeenCalledTimes(1) + }) + + it('resetFailure 는 SSE 실패 상태를 걷어 대기 상태로 되돌린다 — 재생성 성공 시 페이지가 호출', async () => { vi.mocked(getFeedback).mockRejectedValue(notReady()) const { result } = setup() @@ -192,4 +213,17 @@ describe('useFeedbackLive', () => { act(() => result.current.resetFailure()) expect(result.current.failure).toBeNull() }) + + it('SESSION_NOT_FOUND(404)는 생성 중으로 오분류하지 않는다 — 폴링 없이 즉시 에러', async () => { + // 다른 탭에서 세션 삭제·stale URL 방문 시나리오. 404 캐치올이 삼키면 없는 세션에 2분 폴링한다. + vi.mocked(getFeedback).mockRejectedValue( + new ApiError(404, { code: 'SESSION_NOT_FOUND', message: '세션을 찾을 수 없습니다.' }), + ) + + const { result } = setup() + await waitFor(() => expect(result.current.isError).toBe(true)) + + expect(getFeedback).toHaveBeenCalledTimes(1) + expect(result.current.failure).toBeNull() + }) }) diff --git a/frontend/src/features/feedback/model/useFeedbackLive.ts b/frontend/src/features/feedback/model/useFeedbackLive.ts index efd8443..44b11b7 100644 --- a/frontend/src/features/feedback/model/useFeedbackLive.ts +++ b/frontend/src/features/feedback/model/useFeedbackLive.ts @@ -3,7 +3,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import { useEventStream } from '@/shared/hooks' import type { StreamConnectionStatus } from '@/shared/hooks' import { getFeedback } from '../api/feedbackApi' -import { feedbackKeys, isFeedbackPending } from './useFeedback' +import { feedbackKeys, isFeedbackFailed, isFeedbackPending } from './useFeedback' // AI 피드백 생성 진행 문구(FEEDBACK_PROGRESS, 휘발성) — 스켈레톤 캡션에 표시. export type FeedbackProgress = { @@ -41,6 +41,9 @@ export function useFeedbackLive(sessionId: number, getToken: () => Promise isFeedbackPending(err) && count < (statusRef.current === 'open' ? 8 : 40), retryDelay: () => (statusRef.current === 'open' ? 15_000 : 3_000), + // 실패로 settle 된 뒤에도 창 포커스 시 재조회 — 다른 탭/기기에서 재생성돼 마커가 걷혔는데 + // 이 탭만 stale 실패 화면에 고정되는 것을 완화 (SSE 단절 창에서 READY 를 놓친 경우 포함). + refetchOnWindowFocus: true, }) const onReady = useCallback(() => { @@ -63,21 +66,36 @@ export function useFeedbackLive(sessionId: number, getToken: () => Promise(null) + const [sseFailure, setSseFailure] = useState(null) const onError = useCallback((raw: unknown) => { const data = ( raw as StreamData<{ scope?: string; message?: string; retriable?: boolean }> | null )?.data if (!data || data.scope !== 'FEEDBACK' || typeof data.message !== 'string') return - setFailure({ + setSseFailure({ message: data.message, retriable: typeof data.retriable === 'boolean' ? data.retriable : null, }) }, []) - // 재생성 요청 시 페이지가 호출 — 대기(스켈레톤) 상태로 되돌린다. - const resetFailure = useCallback(() => setFailure(null), []) + // 재생성 POST "성공" 시점에 페이지가 호출(mutate 의 per-call onSuccess) — 성공 전에 미리 + // 지우지 않으므로 POST 가 실패하면 실패 화면이 그대로 남는다(정확한 상태). REST 경로의 + // restFailure 는 재생성 성공의 resetQueries 가 query.error 를 지워 함께 해소된다. + const resetFailure = useCallback(() => setSseFailure(null), []) + + // 생성 실패 신호(REST 경로, F3 영속 마커). SSE ERROR 를 놓친 새로고침·재접속 클라이언트도 + // GET 피드백의 FEEDBACK_GENERATION_FAILED 로 같은 복구 UI 에 수렴한다. + const restFailure: FeedbackFailure | null = isFeedbackFailed(query.error) + ? { + message: query.error.message, + retriable: + typeof query.error.details?.retriable === 'boolean' + ? query.error.details.retriable + : null, + } + : null + const failure = sseFailure ?? restFailure // 피드백이 도착하면 스트림을 닫는다 (path: null). const streamStatus = useEventStream({ diff --git a/frontend/src/pages/SessionFeedback/ui/SessionFeedbackPage.tsx b/frontend/src/pages/SessionFeedback/ui/SessionFeedbackPage.tsx index 7c19c6d..6693d0e 100644 --- a/frontend/src/pages/SessionFeedback/ui/SessionFeedbackPage.tsx +++ b/frontend/src/pages/SessionFeedback/ui/SessionFeedbackPage.tsx @@ -67,27 +67,20 @@ export default function SessionFeedbackPage() { {!data && failure && (

{failure.message}

- {failure.retriable === false ? ( - // 서버가 재시도 무의미(retriable=false)로 명시한 실패 — 재생성 대신 새 면접을 안내. -

- 같은 오류가 반복될 수 있어 재생성이 어렵습니다. 상단 버튼으로 다시 도전해 보세요. -

- ) : ( - <> -

- 피드백 생성이 실패해 대기를 중단했어요. 다시 생성을 요청해 보세요. -

- - - )} + {/* 마커가 영속이라 버튼을 숨기면 이 세션의 피드백은 UI 로 영원히 도달 불가가 된다 — + retriable=false 여도 LLM 생성은 비결정적이고 서버도 재생성을 수락하므로 버튼은 유지, + 문구만 기대치를 낮춘다. 실패 해제는 재생성 성공(resetQueries)이 담당. */} +

+ {failure.retriable === false + ? '같은 오류가 반복될 수 있지만, 다시 생성을 시도해 볼 수 있어요.' + : '피드백 생성이 실패해 대기를 중단했어요. 다시 생성을 요청해 보세요.'} +

+
)} @@ -102,8 +95,10 @@ export default function SessionFeedbackPage() {

생성 요청이 유실됐을 수 있습니다. 다시 생성을 요청해 보세요.

+ {/* 재생성 직후 뒤늦게 도착하는 SSE ERROR(이전 시도의 실패)가 남지 않도록 + 여기서도 성공 시 failure 를 함께 걷는다. */}