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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions docs/chat-reliability.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Chat 기록 전송 안정화 (N-1 · N-2 · T-3)

기준: `8c1f71f`, `fix/#37-chat-reliability`. Web 응답 JSON 및 면접 생성 규칙은 유지한다.
기준: `8c1f71f`, `fix/#37-chat-reliability`. 면접 생성 규칙을 유지하며, 종료 상태 전달 보강은 아래 절을 따른다.

## 내부 인증

Expand All @@ -15,7 +15,7 @@ API 요청은 10초 안에 종료하며 2xx만 성공으로 취급한다.
- 답변을 AI 판단 전에 저장한다. 최종 세션은 TTL 없이 보존한 뒤 전송 대기를 등록한다.
- Redis `chat:interview:result:<sessionId>`에 최초 확정 JSON 문자열과 기존 interviewId를 저장한다.
sessionId를 전송 작업 키로 사용하며 같은 세션의 중복 종료는 최초 상태를 반환한다.
- 전송 성공 또는 일시 실패의 대기 상태 저장이 확인된 뒤 세션과 TTS 작업을 정리한다.
- 최종 세션 저장이 확인되면 TTS를 즉시 중단한다. 전송 성공 또는 일시 실패의 대기 상태 저장이 확인된 뒤 세션을 정리한다.
- 대기 등록·상태 저장 실패는 기존 오류 응답으로 전달하고 최종 세션을 유지한다.
- 프로세스가 최종 세션 저장과 대기 등록 사이에 종료되면 `FinalizedInterviewRecovery`가 복구한다.
- 성공 시 본문을 제거하고 SENT 접수 기록을 유지한다. 중복 요청은 답변이나 최종 기록을 재생성하지 않는다.
Expand Down Expand Up @@ -79,3 +79,37 @@ AOF 강제 종료 복원, 중복 등록, 워커 경합, 최종 세션 복구를
redis-server가 없는 환경에서는 이 통합 테스트만 건너뛴다.
재시도 간격만큼 기다리는 테스트는 작성하지 않는다.
compose 검증은 `docker compose config --quiet`로 수행하며 실제 비밀값을 출력하지 않는다.


## 중도 종료와 정상 완료 구분

면접 진행 중 `POST /chat/interviews/{sessionId}/complete`는 처리할 질문이 남아 있으면
HTTP 409, code `INTERVIEW_NOT_FINISHED`를 반환한다. 거절 시 상태·진행 인덱스·최종 기록을 바꾸지 않는다.
WebSocket COMPLETE도 같은 검사를 사용하며, ERROR를 보낸 뒤 연결을 유지해 답변 또는 QUIT를 받을 수 있다.
서버가 마지막 답변을 처리해 다음 질문이 없으면 기존처럼 COMPLETED로 자동 종료한다.

QUIT는 ABANDONED를 확정한다. 이미 확정된 COMPLETED/ABANDONED는 뒤이은 반대 종료나 답변으로 변경하지 않는다.
요청·복구 워커는 같은 Redis operation lock과 최초 outbox 결과를 사용한다.
동시 요청이 잠금을 얻지 못하면 기존 409 규칙을 따르며, 다시 요청하면 먼저 확정된 결과를 반환한다.
단순 WebSocket 종료는 정상 완료나 중단으로 변환하지 않는다.

WebSocket END에는 실제 최종 상태를 추가한다.

```json
{"type":"END","status":"ABANDONED","question":null,"message":"면접이 중단되었습니다."}
```

정상 종료는 status=COMPLETED다. QUIT 요청이어도 이미 완료됐다면 COMPLETED를 반환하고,
COMPLETE 요청이어도 먼저 중단됐다면 ABANDONED를 반환한다. 메시지 문자열이나 요청 종류로 상태를 추측하지 않는다.
일반 progress 응답도 서비스의 실제 status를 복사한다. 초기 QUESTION·ERROR에는 상태를 임의로 부여하지 않는다.

최종 세션을 Redis에 보존한 뒤, API 전송·outbox 등록을 기다리기 전에 TTS를 정지한다.
대기 등록이나 전송이 실패하면 세션은 남기고 복구한다. 복구 워커도 잠금 안에서 최종 상태를 다시 확인하고
대기 등록 전 TTS를 정지한다. IN_PROGRESS로 바뀐 세션에는 종료 복구를 수행하지 않는다.

ABANDONED 기록도 기존 API result 경로로 계속 전달한다. 질문·접수 답변·부모 관계·면접관·기업 정보는 보존한다.
Chat은 피드백을 직접 요청하지 않는다. **피드백 생성을 실제로 차단하려면 API에도 COMPLETED 전용 생성·재시도·콜백 정책이 적용돼야 한다.**
Analysis 접수 검증과 Web의 종료 상태 분기 역시 각 서버 명세에 따라 별도로 배포한다.

검증에는 조기 COMPLETE의 REST/WS 거절, 종료 상태 직렬화, 중단 기록 보존, 대기 등록 전 TTS 중단,
Redis 강제 종료 후 정상/중단 복원, 실제 잠금 아래 마지막 답변/QUIT 경합과 늦은 답변 무시를 포함한다.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.person.repit.common.exception.InvalidRequestException;
import com.person.repit.common.exception.NotFoundException;
import com.person.repit.common.exception.ServerLogicException;
import com.person.repit.interview.exception.InterviewNotFinishedException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
Expand Down Expand Up @@ -80,6 +81,13 @@ public ResponseEntity<Map<String, Object>> handleNotFoundException(
return error(HttpStatus.NOT_FOUND, "NOT_FOUND", exception.getMessage());
}

@ExceptionHandler(InterviewNotFinishedException.class)
public ResponseEntity<Map<String, Object>> handleInterviewNotFinishedException(
InterviewNotFinishedException exception
) {
return error(HttpStatus.CONFLICT, "INTERVIEW_NOT_FINISHED", exception.getMessage());
}

@ExceptionHandler(AlreadyUsingException.class)
public ResponseEntity<Map<String, Object>> handleAlreadyUsingException(
AlreadyUsingException exception
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.person.repit.interview.dto.response;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.person.repit.common.type.MessageType;
import com.person.repit.interview.type.InterviewStatus;
import lombok.Builder;
import lombok.Getter;

Expand All @@ -9,6 +11,8 @@
public class ChatWebSocketMessageResponse {

private MessageType type;
@JsonInclude(JsonInclude.Include.NON_NULL)
private InterviewStatus status;
private ChatQuestionResponse question;
private String message;

Expand All @@ -21,31 +25,18 @@ public static ChatWebSocketMessageResponse question(ChatQuestionResponse questio
}

public static ChatWebSocketMessageResponse progress(ChatProgressResponse progress) {
if (progress.getQuestion() == null) {
return ChatWebSocketMessageResponse.builder()
.type(MessageType.END)
.message(progress.getMessage())
.build();
}

return ChatWebSocketMessageResponse.builder()
.type(MessageType.QUESTION)
.type(progress.getQuestion() == null ? MessageType.END : MessageType.QUESTION)
.status(progress.getStatus())
.question(progress.getQuestion())
.message(progress.getMessage())
.build();
}

public static ChatWebSocketMessageResponse end(String message) {
return ChatWebSocketMessageResponse.builder()
.type(MessageType.END)
.message(message)
.build();
}

public static ChatWebSocketMessageResponse error(String message) {
return ChatWebSocketMessageResponse.builder()
.type(MessageType.ERROR)
.message(message)
.build();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.person.repit.interview.exception;

import com.person.repit.common.exception.BusinessException;

public class InterviewNotFinishedException extends BusinessException {
public InterviewNotFinishedException() {
super("아직 처리할 질문이 남아 있어 면접을 완료할 수 없습니다.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,13 @@ public Mono<Void> recover() {
.concatMap(session -> lock.execute(session.getSessionId(), () ->
redis.opsForValue().get(PREFIX + session.getSessionId())
.ofType(ChatInterviewSession.class)
.filter(current -> current.getStatus() == InterviewStatus.COMPLETED
|| current.getStatus() == InterviewStatus.ABANDONED)
.doOnNext(current -> tts.cancelSession(current.getSessionId()))
.flatMap(current -> outbox.find(current.getSessionId())
.switchIfEmpty(Mono.defer(() -> outbox.enqueue(
ChatInterviewAllRequest.from(current)))))
.filter(item -> item.getState() != InterviewResultDelivery.State.BLOCKED)
.doOnNext(item -> tts.cancelSession(item.getSessionId()))
.flatMap(item -> redis.delete(PREFIX + item.getSessionId())))
.onErrorResume(error -> {
log.warn("종료 면접 복구 대기 sessionId={}, type={}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import com.person.repit.interview.dto.response.ChatQuestionResponse;
import com.person.repit.interview.dto.response.FollowQuestionAiResponse;
import com.person.repit.interview.exception.InterviewQuestionMismatchException;
import com.person.repit.interview.exception.InterviewNotFinishedException;
import com.person.repit.interview.exception.InterviewQuestionNotFoundException;
import com.person.repit.interview.exception.InterviewSessionDataException;
import com.person.repit.interview.exception.InterviewSessionNotFoundException;
Expand Down Expand Up @@ -211,8 +212,14 @@ public Mono<ChatProgressResponse> quitInterview(String sessionId) {
private Mono<ChatProgressResponse> finishOnce(String sessionId, InterviewStatus status) {
return operationLock.execute(sessionId, () -> resultDelivery.resume(sessionId)
.switchIfEmpty(Mono.defer(() -> getSession(sessionId)
.flatMap(session -> finishInterview(session,
session.getStatus() == InterviewStatus.IN_PROGRESS ? status : session.getStatus())))));
.flatMap(session -> {
if (session.getStatus() == InterviewStatus.IN_PROGRESS
&& status == InterviewStatus.COMPLETED && session.getCurrentQuestion() != null) {
return Mono.error(new InterviewNotFinishedException());
}
return finishInterview(session,
session.getStatus() == InterviewStatus.IN_PROGRESS ? status : session.getStatus());
}))));
}

private Mono<FollowQuestionAiResponse> createFollowQuestionIfRequired(
Expand Down Expand Up @@ -310,17 +317,15 @@ private Mono<ChatProgressResponse> finishInterview(
return redisTemplate.opsForValue().set(createKey(session.getSessionId()), session)
.filter(Boolean.TRUE::equals)
.switchIfEmpty(Mono.error(new IllegalStateException("최종 면접 기록 보존 실패")))
.doOnNext(ignored -> ttsScheduler.cancelSession(session.getSessionId()))
.then(Mono.defer(() -> resultDelivery.submit(ChatInterviewAllRequest.from(session))))
.flatMap(response -> {
ttsScheduler.cancelSession(session.getSessionId());
return deleteSession(session.getSessionId())
.onErrorResume(error -> {
log.warn("종료 세션 정리 실패 sessionId={}, type={}",
session.getSessionId(), error.getClass().getSimpleName());
return Mono.empty();
})
.thenReturn(response);
});
.flatMap(response -> deleteSession(session.getSessionId())
.onErrorResume(error -> {
log.warn("종료 세션 정리 실패 sessionId={}, type={}",
session.getSessionId(), error.getClass().getSimpleName());
return Mono.empty();
})
.thenReturn(response));
}

private Mono<ChatInterviewSession> getSession(String sessionId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import com.person.repit.interview.dto.request.ChatWebSocketMessageRequest;
import com.person.repit.interview.dto.response.ChatProgressResponse;
import com.person.repit.interview.dto.response.ChatWebSocketMessageResponse;
import com.person.repit.interview.exception.InterviewNotFinishedException;
import com.person.repit.interview.service.ChatInterviewService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
Expand Down Expand Up @@ -141,12 +142,14 @@ private Mono<OutboundMessage> processAnswer(String sessionId, String payload) {
private Mono<OutboundMessage> processCompletion(String sessionId) {
return chatInterviewService.completeInterview(sessionId)
.map(progress -> progressMessage(progress, true))
.doOnSuccess(ignored -> metrics.webSocketMessageProcessed());
.doOnSuccess(ignored -> metrics.webSocketMessageProcessed())
.onErrorResume(InterviewNotFinishedException.class,
exception -> Mono.just(error(exception.getMessage())));
}

private Mono<OutboundMessage> processQuit(String sessionId) {
return chatInterviewService.quitInterview(sessionId)
.map(progress -> closeNormally(ChatWebSocketMessageResponse.end(progress.getMessage())))
.map(progress -> progressMessage(progress, true))
.doOnSuccess(ignored -> metrics.webSocketMessageProcessed());
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.person.repit.interview.service;

import com.person.repit.interview.domain.ChatInterviewSession;
import com.person.repit.interview.domain.InterviewResultDelivery;
import com.person.repit.interview.type.InterviewStatus;
import com.person.repit.tts.service.InterviewTtsScheduler;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.core.ReactiveRedisTemplate;
import org.springframework.data.redis.core.ReactiveValueOperations;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.util.function.Supplier;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;

@SuppressWarnings("unchecked")
class FinalizedInterviewRecoveryTest {
private final ReactiveRedisTemplate<String, Object> redis = mock(ReactiveRedisTemplate.class);
private final ReactiveValueOperations<String, Object> values = mock(ReactiveValueOperations.class);
private final InterviewResultOutbox outbox = mock(InterviewResultOutbox.class);
private final InterviewOperationLock lock = mock(InterviewOperationLock.class);
private final InterviewTtsScheduler tts = mock(InterviewTtsScheduler.class);
private final FinalizedInterviewRecovery recovery = new FinalizedInterviewRecovery(redis, outbox, lock, tts);
private ChatInterviewSession terminal;

@BeforeEach
void setUp() {
terminal = ChatInterviewSession.builder().sessionId("s").interviewId(1L).userId(2L)
.status(InterviewStatus.ABANDONED).build();
when(redis.opsForValue()).thenReturn(values);
when(redis.scan(any())).thenReturn(Flux.just("chat:interview:s"));
when(values.get("chat:interview:s")).thenReturn(Mono.just(terminal));
when(lock.execute(anyString(), any())).thenAnswer(call -> ((Supplier<Mono<?>>) call.getArgument(1)).get());
when(outbox.find("s")).thenReturn(Mono.empty());
when(redis.delete("chat:interview:s")).thenReturn(Mono.just(1L));
}

@Test
void 대기등록이_실패해도_최종세션을_남기고_TTS는_즉시_정리한다() {
when(outbox.enqueue(any())).thenReturn(Mono.error(new IllegalStateException("redis down")));
recovery.recover().block();
verify(tts).cancelSession("s");
verify(redis, never()).delete(anyString());
var delivery = new InterviewResultDelivery();
delivery.setSessionId("s");
delivery.setInterviewStatus(InterviewStatus.ABANDONED);
when(outbox.enqueue(any())).thenReturn(Mono.just(delivery));
recovery.recover().block();
verify(outbox, times(2)).enqueue(argThat(request -> request.getStatus() == InterviewStatus.ABANDONED));
verify(redis).delete("chat:interview:s");
assertThat(terminal.getStatus()).isEqualTo(InterviewStatus.ABANDONED);
}

@Test
void 잠금_획득후_다시_읽은_세션이_진행중이면_복구하지_않는다() {
var active = ChatInterviewSession.builder().sessionId("s").build();
when(values.get("chat:interview:s")).thenReturn(Mono.just(terminal), Mono.just(active));
recovery.recover().block();
verifyNoInteractions(outbox, tts);
verify(redis, never()).delete(anyString());
}
}
Loading