From 17894ade72dbe908eb20919a3adee1ee7e849ceb Mon Sep 17 00:00:00 2001 From: 2-dayeon Date: Tue, 20 Oct 2026 01:38:26 +0900 Subject: [PATCH 1/7] =?UTF-8?q?fix:=20=EC=A1=B0=EA=B8=B0=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EA=B1=B0=EC=A0=88=EA=B3=BC=20=EC=A2=85=EB=A3=8C=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B3=B4=EC=A1=B4=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/GlobalExceptionHandler.java | 8 +++++ .../InterviewNotFinishedException.java | 9 ++++++ .../service/FinalizedInterviewRecovery.java | 4 ++- .../impl/ChatInterviewServiceImpl.java | 29 +++++++++++-------- 4 files changed, 37 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/person/repit/interview/exception/InterviewNotFinishedException.java diff --git a/src/main/java/com/person/repit/common/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/person/repit/common/exception/handler/GlobalExceptionHandler.java index 3168e52..3dda9d7 100644 --- a/src/main/java/com/person/repit/common/exception/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/person/repit/common/exception/handler/GlobalExceptionHandler.java @@ -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; @@ -80,6 +81,13 @@ public ResponseEntity> handleNotFoundException( return error(HttpStatus.NOT_FOUND, "NOT_FOUND", exception.getMessage()); } + @ExceptionHandler(InterviewNotFinishedException.class) + public ResponseEntity> handleInterviewNotFinishedException( + InterviewNotFinishedException exception + ) { + return error(HttpStatus.CONFLICT, "INTERVIEW_NOT_FINISHED", exception.getMessage()); + } + @ExceptionHandler(AlreadyUsingException.class) public ResponseEntity> handleAlreadyUsingException( AlreadyUsingException exception diff --git a/src/main/java/com/person/repit/interview/exception/InterviewNotFinishedException.java b/src/main/java/com/person/repit/interview/exception/InterviewNotFinishedException.java new file mode 100644 index 0000000..be220d1 --- /dev/null +++ b/src/main/java/com/person/repit/interview/exception/InterviewNotFinishedException.java @@ -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("아직 처리할 질문이 남아 있어 면접을 완료할 수 없습니다."); + } +} diff --git a/src/main/java/com/person/repit/interview/service/FinalizedInterviewRecovery.java b/src/main/java/com/person/repit/interview/service/FinalizedInterviewRecovery.java index 7ac2d3a..ca0c40c 100644 --- a/src/main/java/com/person/repit/interview/service/FinalizedInterviewRecovery.java +++ b/src/main/java/com/person/repit/interview/service/FinalizedInterviewRecovery.java @@ -34,11 +34,13 @@ public Mono 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={}", diff --git a/src/main/java/com/person/repit/interview/service/impl/ChatInterviewServiceImpl.java b/src/main/java/com/person/repit/interview/service/impl/ChatInterviewServiceImpl.java index 590aa11..08c34e0 100644 --- a/src/main/java/com/person/repit/interview/service/impl/ChatInterviewServiceImpl.java +++ b/src/main/java/com/person/repit/interview/service/impl/ChatInterviewServiceImpl.java @@ -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; @@ -211,8 +212,14 @@ public Mono quitInterview(String sessionId) { private Mono 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 createFollowQuestionIfRequired( @@ -310,17 +317,15 @@ private Mono 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 getSession(String sessionId) { From 8c96b23ec982a5f5fa4cf3b656ffa39af7900342 Mon Sep 17 00:00:00 2001 From: 2-dayeon Date: Tue, 20 Oct 2026 01:38:26 +0900 Subject: [PATCH 2/7] =?UTF-8?q?fix:=20=EC=9B=B9=EC=86=8C=EC=BC=93=20?= =?UTF-8?q?=EC=A2=85=EB=A3=8C=20=EC=9D=91=EB=8B=B5=EC=97=90=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=20=EB=A9=B4=EC=A0=91=20=EC=83=81=ED=83=9C=20=EC=A0=84?= =?UTF-8?q?=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ChatWebSocketMessageResponse.java | 23 ++++++------------- ...ReactiveChatInterviewWebSocketHandler.java | 7 ++++-- 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/person/repit/interview/dto/response/ChatWebSocketMessageResponse.java b/src/main/java/com/person/repit/interview/dto/response/ChatWebSocketMessageResponse.java index fabdf9d..a59dace 100644 --- a/src/main/java/com/person/repit/interview/dto/response/ChatWebSocketMessageResponse.java +++ b/src/main/java/com/person/repit/interview/dto/response/ChatWebSocketMessageResponse.java @@ -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; @@ -9,6 +11,8 @@ public class ChatWebSocketMessageResponse { private MessageType type; + @JsonInclude(JsonInclude.Include.NON_NULL) + private InterviewStatus status; private ChatQuestionResponse question; private String message; @@ -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(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/person/repit/interview/websocket/ReactiveChatInterviewWebSocketHandler.java b/src/main/java/com/person/repit/interview/websocket/ReactiveChatInterviewWebSocketHandler.java index 3fe8367..6f727f7 100644 --- a/src/main/java/com/person/repit/interview/websocket/ReactiveChatInterviewWebSocketHandler.java +++ b/src/main/java/com/person/repit/interview/websocket/ReactiveChatInterviewWebSocketHandler.java @@ -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; @@ -141,12 +142,14 @@ private Mono processAnswer(String sessionId, String payload) { private Mono 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 processQuit(String sessionId) { return chatInterviewService.quitInterview(sessionId) - .map(progress -> closeNormally(ChatWebSocketMessageResponse.end(progress.getMessage()))) + .map(progress -> progressMessage(progress, true)) .doOnSuccess(ignored -> metrics.webSocketMessageProcessed()); } From f2699798b8a4caaa646f510ba134343d31cfaadb Mon Sep 17 00:00:00 2001 From: 2-dayeon Date: Tue, 20 Oct 2026 01:38:26 +0900 Subject: [PATCH 3/7] =?UTF-8?q?chore:=20=EC=A1=B0=EA=B8=B0=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=EC=99=80=20=EC=A4=91=EB=8B=A8=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EB=B3=B4=EC=A1=B4=20=ED=9A=8C=EA=B7=80=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/ChatInterviewCompletionTest.java | 79 ++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/person/repit/interview/service/impl/ChatInterviewCompletionTest.java b/src/test/java/com/person/repit/interview/service/impl/ChatInterviewCompletionTest.java index 6151c5c..0b88fb4 100644 --- a/src/test/java/com/person/repit/interview/service/impl/ChatInterviewCompletionTest.java +++ b/src/test/java/com/person/repit/interview/service/impl/ChatInterviewCompletionTest.java @@ -1,6 +1,10 @@ package com.person.repit.interview.service.impl; import com.person.repit.common.metrics.RepitMetrics; +import com.person.repit.common.exception.handler.GlobalExceptionHandler; +import com.person.repit.interview.controller.ChatInterviewController; +import com.person.repit.interview.domain.ChatAnswer; +import com.person.repit.interview.exception.InterviewNotFinishedException; import com.person.repit.interview.domain.ChatInterviewSession; import com.person.repit.interview.domain.ChatQuestion; import com.person.repit.interview.dto.request.ChatAnswerRequest; @@ -14,6 +18,10 @@ import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.data.redis.core.ReactiveRedisTemplate; import org.springframework.data.redis.core.ReactiveValueOperations; import reactor.core.publisher.Mono; @@ -83,6 +91,7 @@ void setUp() { @Test void 일반완료도_최종기록을_보존한다() { + session.setCurrentQuestionIndex(-1); assertThat(service.completeInterview("s").block().getStatus()).isEqualTo(InterviewStatus.COMPLETED); verify(delivery).submit(argThat(payload -> payload.getStatus() == InterviewStatus.COMPLETED)); } @@ -94,7 +103,7 @@ void setUp() { assertThat(session.getAnswers()).hasSize(1); assertThat(finalSaved).isTrue(); verify(redis, never()).delete(anyString()); - verify(tts, never()).cancelSession(anyString()); + verify(tts).cancelSession("s"); doReturn(Mono.just(ChatProgressResponse.end())).when(delivery).submit(any()); service.submitAnswer("s", answer()).block(); assertThat(session.getAnswers()).hasSize(1); @@ -102,10 +111,12 @@ void setUp() { @Test void 최종기록_저장실패는_성공처리하지_않는다() { + session.setCurrentQuestionIndex(-1); when(values.set(eq("chat:interview:s"), any())).thenReturn(Mono.just(false)); StepVerifier.create(service.completeInterview("s")).expectError(IllegalStateException.class).verify(); verify(delivery, never()).submit(any()); verify(redis, never()).delete(anyString()); + verifyNoInteractions(tts); } @Test @@ -118,6 +129,72 @@ void setUp() { verify(delivery, never()).submit(any()); } + @Test + void 조기_완료는_REST_409로_거절하고_뒤이은_중단은_허용한다() { + var client = WebTestClient.bindToController(new ChatInterviewController(service)) + .controllerAdvice(new GlobalExceptionHandler()).build(); + client.post().uri("/chat/interviews/s/complete").exchange() + .expectStatus().isEqualTo(409).expectBody() + .jsonPath("$.code").isEqualTo("INTERVIEW_NOT_FINISHED"); + assertThat(finalSaved).isFalse(); + assertThat(session.getStatus()).isEqualTo(InterviewStatus.IN_PROGRESS); + verify(delivery, never()).submit(any()); + verifyNoInteractions(tts); + client.post().uri("/chat/interviews/s/quit").exchange() + .expectStatus().isOk().expectBody().jsonPath("$.status").isEqualTo("ABANDONED"); + } + + @ParameterizedTest + @ValueSource(longs = {2L, -1L}) + void 답변이_있어도_다음_원질문이나_꼬리질문이_남으면_완료하지_않는다(long nextId) { + session.getQuestions().add(ChatQuestion.builder().questionId(nextId) + .type(nextId < 0 ? QuestionType.FOLLOW : QuestionType.ORIGINAL).build()); + session.getAnswers().add(ChatAnswer.builder().questionId(1L).content("기존 답변").build()); + session.setCurrentQuestionIndex(1); + StepVerifier.create(service.completeInterview("s")).expectError(InterviewNotFinishedException.class).verify(); + assertThat(finalSaved).isFalse(); + assertThat(session.getCurrentQuestionIndex()).isEqualTo(1); + verify(delivery, never()).submit(any()); + verifyNoInteractions(tts); + } + + @ParameterizedTest + @EnumSource(value = InterviewStatus.class, names = {"COMPLETED", "ABANDONED"}) + void 대기_등록_전_최종_세션도_반대_종료나_늦은_답변으로_바꾸지_않는다(InterviewStatus status) { + session.setStatus(status); + assertThat(service.completeInterview("s").block().getStatus()).isEqualTo(status); + assertThat(service.quitInterview("s").block().getStatus()).isEqualTo(status); + assertThat(service.submitAnswer("s", answer()).block().getStatus()).isEqualTo(status); + assertThat(session.getAnswers()).isEmpty(); + assertThat(session.getCurrentQuestionIndex()).isZero(); + verify(delivery, times(3)).submit(argThat(payload -> payload.getStatus() == status)); + } + + @Test + void 중단은_최종_저장_후_API_전송이_끝나기_전에_TTS를_정지한다() { + doReturn(Mono.never()).when(delivery).submit(any()); + StepVerifier.create(service.quitInterview("s")) + .then(() -> { + assertThat(finalSaved).isTrue(); + assertThat(session.getStatus()).isEqualTo(InterviewStatus.ABANDONED); + verify(tts).cancelSession("s"); + verify(redis, never()).delete(anyString()); + }).thenCancel().verify(); + } + + @Test + void 중단_대기등록_실패_후_늦은_완료요청도_중단_기록을_재전달한다() { + session.getAnswers().add(ChatAnswer.builder().questionId(1L).content("접수한 답변").build()); + doReturn(Mono.error(new IllegalStateException("outbox down"))).when(delivery).submit(any()); + StepVerifier.create(service.quitInterview("s")).expectError(IllegalStateException.class).verify(); + verify(tts).cancelSession("s"); + verify(redis, never()).delete(anyString()); + doReturn(Mono.just(ChatProgressResponse.quit())).when(delivery).submit(any()); + assertThat(service.completeInterview("s").block().getStatus()).isEqualTo(InterviewStatus.ABANDONED); + verify(delivery, times(2)).submit(argThat(payload -> payload.getStatus() == InterviewStatus.ABANDONED + && payload.getQnaRequests().getFirst().getAnswer().getAnswerContent().equals("접수한 답변"))); + } + private ChatAnswerRequest answer() { return ChatAnswerRequest.builder().questionId(1L).content("마지막 답변").responseTime(12).build(); } From e9effab4af15853fb8805250a249b5be471162cc Mon Sep 17 00:00:00 2001 From: 2-dayeon Date: Tue, 20 Oct 2026 01:38:26 +0900 Subject: [PATCH 4/7] =?UTF-8?q?chore:=20=EC=A2=85=EB=A3=8C=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EB=B3=B5=EA=B5=AC=EC=99=80=20TTS=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20=ED=9A=8C=EA=B7=80=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FinalizedInterviewRecoveryTest.java | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 src/test/java/com/person/repit/interview/service/FinalizedInterviewRecoveryTest.java diff --git a/src/test/java/com/person/repit/interview/service/FinalizedInterviewRecoveryTest.java b/src/test/java/com/person/repit/interview/service/FinalizedInterviewRecoveryTest.java new file mode 100644 index 0000000..ec1766d --- /dev/null +++ b/src/test/java/com/person/repit/interview/service/FinalizedInterviewRecoveryTest.java @@ -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 redis = mock(ReactiveRedisTemplate.class); + private final ReactiveValueOperations 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>) 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()); + } +} From 4b36ea94f4c016872ff147eb00994954862269ad Mon Sep 17 00:00:00 2001 From: 2-dayeon Date: Tue, 20 Oct 2026 01:38:26 +0900 Subject: [PATCH 5/7] =?UTF-8?q?chore:=20Redis=20=EC=A2=85=EB=A3=8C=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B3=B5=EC=9B=90=EA=B3=BC=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=20=EC=9A=94=EC=B2=AD=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/InterviewResultRedisTest.java | 74 +++++++++++++++++-- 1 file changed, 68 insertions(+), 6 deletions(-) diff --git a/src/test/java/com/person/repit/interview/service/InterviewResultRedisTest.java b/src/test/java/com/person/repit/interview/service/InterviewResultRedisTest.java index 9fc44ce..bbd839f 100644 --- a/src/test/java/com/person/repit/interview/service/InterviewResultRedisTest.java +++ b/src/test/java/com/person/repit/interview/service/InterviewResultRedisTest.java @@ -12,6 +12,9 @@ import com.person.repit.tts.service.InterviewTtsScheduler; import org.junit.jupiter.api.*; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.ReactiveStringRedisTemplate; import reactor.core.publisher.Mono; @@ -77,9 +80,11 @@ void stopRedis() throws Exception { } } - @Test - void Redis_강제종료후에도_전송대기와_마지막답변을_복원한다() throws Exception { + @ParameterizedTest + @EnumSource(value = InterviewStatus.class, names = {"COMPLETED", "ABANDONED"}) + void Redis_강제종료후에도_전송대기와_마지막답변을_복원한다(InterviewStatus status) throws Exception { var session = session(); + session.setStatus(status); var first = outbox.enqueue(ChatInterviewAllRequest.from(session)).block(); var ttl = new org.springframework.data.redis.core.script.DefaultRedisScript( "return redis.call('TTL', KEYS[1])", Long.class); @@ -90,6 +95,7 @@ void stopRedis() throws Exception { // New repository instance simulates application restart; no in-memory queue participates. var restored = new InterviewResultOutbox(redis, new JacksonConfig().objectMapper()).find("s").block(); assertThat(restored.getPayload()).isEqualTo(first.getPayload()).contains("마지막 답변"); + assertThat(restored.getInterviewStatus()).isEqualTo(status); assertThat(outbox.findPending().collectList().block()).hasSize(1); ApiServerClient client = mock(ApiServerClient.class); when(client.saveInterviewPayload(restored.getPayload())).thenReturn(Mono.empty()); @@ -131,16 +137,72 @@ void stopRedis() throws Exception { assertThat(lock.execute("s", () -> Mono.just("recovered")).block()).isEqualTo("recovered"); } - @Test - void 최종세션_저장직후_장애가_나도_복구워커가_전송대기를_등록한다() { + @ParameterizedTest + @EnumSource(value = InterviewStatus.class, names = {"COMPLETED", "ABANDONED"}) + void 최종세션_저장직후_장애가_나도_복구워커가_전송대기를_등록한다(InterviewStatus status) { var objects = new RedisConfig().reactiveRedisTemplate(factory); - objects.opsForValue().set("chat:interview:s", session()).block(); - var recovery = new FinalizedInterviewRecovery(objects, outbox, lock, mock(InterviewTtsScheduler.class)); + var finalized = session(); + finalized.setStatus(status); + objects.opsForValue().set("chat:interview:s", finalized).block(); + var tts = mock(InterviewTtsScheduler.class); + var recovery = new FinalizedInterviewRecovery(objects, outbox, lock, tts); recovery.recover().block(); assertThat(outbox.find("s").block().getPayload()).contains("마지막 답변"); + assertThat(outbox.find("s").block().getInterviewStatus()).isEqualTo(status); + verify(tts).cancelSession("s"); assertThat(objects.hasKey("chat:interview:s").block()).isFalse(); } + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void 종료_요청_경합과_늦은_답변은_최초_확정한_최종상태와_본문을_보존한다(boolean quitFirst) { + var objects = new RedisConfig().reactiveRedisTemplate(factory); + var active = session(); + active.setStatus(InterviewStatus.IN_PROGRESS); + active.setAnswers(new java.util.ArrayList<>()); + active.getQuestions().getFirst().setType(com.person.repit.interview.type.QuestionType.ORIGINAL); + objects.opsForValue().set("chat:interview:s", active).block(); + ApiServerClient client = mock(ApiServerClient.class); + var sending = reactor.core.publisher.Sinks.empty(); + var called = reactor.core.publisher.Sinks.one(); + when(client.saveInterviewPayload(anyString())).thenReturn(Mono.defer(() -> { + called.tryEmitValue(true); + return sending.asMono(); + })); + var meters = new io.micrometer.core.instrument.simple.SimpleMeterRegistry(); + var delivery = new InterviewResultDeliveryService(outbox, client, new InterviewResultRetryPolicy(), meters); + var tts = mock(InterviewTtsScheduler.class); + var ai = mock(AiQuestionClient.class); + var service = new com.person.repit.interview.service.impl.ChatInterviewServiceImpl(ai, objects, delivery, lock, + new com.person.repit.common.metrics.RepitMetrics(meters), + mock(com.person.repit.tts.service.PersonaTtsProfileResolver.class), tts); + var answer = com.person.repit.interview.dto.request.ChatAnswerRequest.builder() + .questionId(17L).content("마지막 답변").responseTime(1).build(); + var first = (quitFirst ? service.quitInterview("s") : service.submitAnswer("s", answer)).toFuture(); + try { + called.asMono().block(Duration.ofSeconds(5)); + verify(tts).cancelSession("s"); + StepVerifier.create(quitFirst ? service.completeInterview("s") : service.quitInterview("s")) + .expectError(AlreadyUsingException.class).verify(Duration.ofSeconds(5)); + sending.tryEmitEmpty(); + var expected = quitFirst ? InterviewStatus.ABANDONED : InterviewStatus.COMPLETED; + assertThat(first.orTimeout(5, java.util.concurrent.TimeUnit.SECONDS).join().getStatus()).isEqualTo(expected); + assertThat(service.completeInterview("s").block().getStatus()).isEqualTo(expected); + assertThat(service.quitInterview("s").block().getStatus()).isEqualTo(expected); + assertThat(service.submitAnswer("s", answer).block().getStatus()).isEqualTo(expected); + assertThat(outbox.find("s").block().getInterviewStatus()).isEqualTo(expected); + var payload = org.mockito.ArgumentCaptor.forClass(String.class); + verify(client).saveInterviewPayload(payload.capture()); + assertThat(payload.getValue()).contains("\"status\":\"" + expected.name() + "\""); + assertThat(payload.getValue().contains("마지막 답변")).isEqualTo(!quitFirst); + assertThat(objects.hasKey("chat:interview:s").block()).isFalse(); + verifyNoInteractions(ai); + } finally { + sending.tryEmitEmpty(); + first.cancel(true); + } + } + private ChatInterviewSession session() { return ChatInterviewSession.builder().sessionId("s").interviewId(1L).userId(2L) .status(InterviewStatus.COMPLETED).createdAt(LocalDateTime.of(2026, 10, 15, 10, 0)) From 44b5a58f0a271468b9db5e15ad3208cc9d2b3495 Mon Sep 17 00:00:00 2001 From: 2-dayeon Date: Tue, 20 Oct 2026 01:38:26 +0900 Subject: [PATCH 6/7] =?UTF-8?q?chore:=20=EC=9B=B9=EC=86=8C=EC=BC=93=20?= =?UTF-8?q?=EC=A2=85=EB=A3=8C=20=EC=83=81=ED=83=9C=EC=99=80=20=EC=A1=B0?= =?UTF-8?q?=EA=B8=B0=20=EC=99=84=EB=A3=8C=20=EC=98=A4=EB=A5=98=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...tiveChatInterviewWebSocketHandlerTest.java | 63 ++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/person/repit/interview/websocket/ReactiveChatInterviewWebSocketHandlerTest.java b/src/test/java/com/person/repit/interview/websocket/ReactiveChatInterviewWebSocketHandlerTest.java index bfe1371..4108218 100644 --- a/src/test/java/com/person/repit/interview/websocket/ReactiveChatInterviewWebSocketHandlerTest.java +++ b/src/test/java/com/person/repit/interview/websocket/ReactiveChatInterviewWebSocketHandlerTest.java @@ -7,8 +7,10 @@ import com.person.repit.interview.dto.response.ChatProgressResponse; import com.person.repit.interview.dto.response.ChatQuestionResponse; import com.person.repit.interview.service.ChatInterviewService; +import com.person.repit.interview.exception.InterviewNotFinishedException; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; @@ -98,12 +100,71 @@ void setUp() { StepVerifier.create(handler.handle(session)).verifyComplete(); assertThat(outbound).hasSize(2); - assertThat(outbound.get(1)).contains("\"type\":\"QUESTION\""); + assertThat(outbound.get(1)).contains("\"type\":\"QUESTION\"", "\"status\":\"IN_PROGRESS\""); ArgumentCaptor captured = ArgumentCaptor.forClass(ChatAnswerRequest.class); verify(service).submitAnswer(eq("s"), captured.capture()); assertThat(captured.getValue().getResponseTime()).isEqualTo(responseTime); } + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void 중단_END는_요청_종류가_아닌_서버의_확정_상태를_전달한다(boolean alreadyCompleted) { + when(service.quitInterview("s")).thenReturn(Mono.just( + alreadyCompleted ? ChatProgressResponse.end() : ChatProgressResponse.quit())); + when(session.receive()).thenReturn(Flux.just(textMessage("{\"type\":\"QUIT\"}"), textMessage(answer(1)))); + StepVerifier.create(handler.handle(session)).verifyComplete(); + assertThat(outbound).hasSize(2); + assertThat(outbound.get(1)).contains("\"type\":\"END\"", "\"status\":\"" + + (alreadyCompleted ? "COMPLETED" : "ABANDONED") + "\""); + verify(service, never()).submitAnswer(anyString(), any()); + verify(session).close(CloseStatus.NORMAL); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void 완료_END도_먼저_확정된_중단_상태를_그대로_전달한다(boolean abandoned) { + when(service.completeInterview("s")).thenReturn(Mono.just( + abandoned ? ChatProgressResponse.quit() : ChatProgressResponse.end())); + when(session.receive()).thenReturn(Flux.just(textMessage("{\"type\":\"COMPLETE\"}"))); + StepVerifier.create(handler.handle(session)).verifyComplete(); + assertThat(outbound.get(1)).contains("\"type\":\"END\"", "\"status\":\"" + + (abandoned ? "ABANDONED" : "COMPLETED") + "\""); + verify(session).close(CloseStatus.NORMAL); + } + + @Test + void 조기_완료_오류_뒤에도_중단_요청을_처리한다() { + when(service.completeInterview("s")).thenReturn(Mono.error(new InterviewNotFinishedException())); + when(service.quitInterview("s")).thenReturn(Mono.just(ChatProgressResponse.quit())); + when(session.receive()).thenReturn(Flux.just( + textMessage("{\"type\":\"COMPLETE\"}"), textMessage("{\"type\":\"QUIT\"}"))); + StepVerifier.create(handler.handle(session)).verifyComplete(); + assertThat(outbound).hasSize(3); + assertThat(outbound.get(1)).contains("\"type\":\"ERROR\"", "처리할 질문"); + assertThat(outbound.get(2)).contains("\"type\":\"END\"", "\"status\":\"ABANDONED\""); + verify(service).quitInterview("s"); + verify(session).close(CloseStatus.NORMAL); + } + + @Test + void 마지막_답변의_END에도_완료_상태가_포함된다() { + when(service.submitAnswer(eq("s"), any())).thenReturn(Mono.just(ChatProgressResponse.end())); + when(session.receive()).thenReturn(Flux.just(textMessage(answer(1)))); + StepVerifier.create(handler.handle(session)).verifyComplete(); + assertThat(outbound.get(0)).contains("\"type\":\"QUESTION\"").doesNotContain("\"status\""); + assertThat(outbound.get(1)).contains("\"type\":\"END\"", "\"status\":\"COMPLETED\""); + verify(session).close(CloseStatus.NORMAL); + } + + @Test + void 소켓만_끊긴_경우_면접을_완료나_중단으로_바꾸지_않는다() { + when(session.receive()).thenReturn(Flux.empty()); + StepVerifier.create(handler.handle(session)).verifyComplete(); + assertThat(outbound).hasSize(1); + verify(service, never()).completeInterview(anyString()); + verify(service, never()).quitInterview(anyString()); + } + private String answer(int responseTime) { return """ {"type":"ANSWER","questionId":1,"responseTime":%d,"content":"답변"} From 324f33131a76e994e0baf8ed658a646c63a36593 Mon Sep 17 00:00:00 2001 From: 2-dayeon Date: Tue, 20 Oct 2026 01:38:26 +0900 Subject: [PATCH 7/7] =?UTF-8?q?docs:=20=EB=A9=B4=EC=A0=91=20=EC=A4=91?= =?UTF-8?q?=EB=8B=A8=EA=B3=BC=20=EC=A0=95=EC=83=81=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=A0=95=EC=B1=85=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/chat-reliability.md | 38 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/docs/chat-reliability.md b/docs/chat-reliability.md index 9c3db81..1cf6466 100644 --- a/docs/chat-reliability.md +++ b/docs/chat-reliability.md @@ -1,6 +1,6 @@ # Chat 기록 전송 안정화 (N-1 · N-2 · T-3) -기준: `8c1f71f`, `fix/#37-chat-reliability`. Web 응답 JSON 및 면접 생성 규칙은 유지한다. +기준: `8c1f71f`, `fix/#37-chat-reliability`. 면접 생성 규칙을 유지하며, 종료 상태 전달 보강은 아래 절을 따른다. ## 내부 인증 @@ -15,7 +15,7 @@ API 요청은 10초 안에 종료하며 2xx만 성공으로 취급한다. - 답변을 AI 판단 전에 저장한다. 최종 세션은 TTL 없이 보존한 뒤 전송 대기를 등록한다. - Redis `chat:interview:result:`에 최초 확정 JSON 문자열과 기존 interviewId를 저장한다. sessionId를 전송 작업 키로 사용하며 같은 세션의 중복 종료는 최초 상태를 반환한다. -- 전송 성공 또는 일시 실패의 대기 상태 저장이 확인된 뒤 세션과 TTS 작업을 정리한다. +- 최종 세션 저장이 확인되면 TTS를 즉시 중단한다. 전송 성공 또는 일시 실패의 대기 상태 저장이 확인된 뒤 세션을 정리한다. - 대기 등록·상태 저장 실패는 기존 오류 응답으로 전달하고 최종 세션을 유지한다. - 프로세스가 최종 세션 저장과 대기 등록 사이에 종료되면 `FinalizedInterviewRecovery`가 복구한다. - 성공 시 본문을 제거하고 SENT 접수 기록을 유지한다. 중복 요청은 답변이나 최종 기록을 재생성하지 않는다. @@ -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 경합과 늦은 답변 무시를 포함한다.