From d29afd5d57bb76ef1df2359b048174cd547e4761 Mon Sep 17 00:00:00 2001 From: 2-dayeon Date: Wed, 21 Oct 2026 19:40:35 +0900 Subject: [PATCH 01/13] =?UTF-8?q?chore:=20=EA=B8=B0=EC=97=85=20=EA=BC=AC?= =?UTF-8?q?=EB=A6=AC=EC=A7=88=EB=AC=B8=20=ED=99=98=EA=B2=BD=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EC=A0=84=EB=8B=AC=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index d9c84c3..9bec44b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,10 @@ services: INTERVIEW_RESULT_RETRY_ENABLED: ${INTERVIEW_RESULT_RETRY_ENABLED:-false} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY} ANTHROPIC_MODEL: ${ANTHROPIC_MODEL} + ANTHROPIC_COMPANY_FOLLOW_UP_ENABLED: ${ANTHROPIC_COMPANY_FOLLOW_UP_ENABLED:-false} + ANTHROPIC_COMPANY_FOLLOW_UP_TOTAL_TIMEOUT: ${ANTHROPIC_COMPANY_FOLLOW_UP_TOTAL_TIMEOUT:-5s} + ANTHROPIC_COMPANY_FOLLOW_UP_GENERATION_TIMEOUT: ${ANTHROPIC_COMPANY_FOLLOW_UP_GENERATION_TIMEOUT:-2s} + ANTHROPIC_COMPANY_FOLLOW_UP_MAX_TOKENS: ${ANTHROPIC_COMPANY_FOLLOW_UP_MAX_TOKENS:-800} TTS_ENABLED: ${TTS_ENABLED:-false} ELEVENLABS_API_KEY: ${ELEVENLABS_API_KEY:-} ELEVENLABS_MAX_IN_MEMORY_SIZE: ${ELEVENLABS_MAX_IN_MEMORY_SIZE:-8388608} From 3e9925b70cf610912f022978b3b0857ead0a61d0 Mon Sep 17 00:00:00 2001 From: 2-dayeon Date: Wed, 21 Oct 2026 19:47:13 +0900 Subject: [PATCH 02/13] =?UTF-8?q?fix:=20k6=20=EB=A9=B4=EC=A0=91=20?= =?UTF-8?q?=EC=A4=80=EB=B9=84=20=EC=9A=94=EC=B2=AD=20=ED=95=84=EC=88=98=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- load-test/k6/lib/interview.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/load-test/k6/lib/interview.js b/load-test/k6/lib/interview.js index 3893139..c72aa2b 100644 --- a/load-test/k6/lib/interview.js +++ b/load-test/k6/lib/interview.js @@ -170,6 +170,11 @@ function prepareInterview(sessionId) { interviewId: __VU * 1000000 + __ITER, userId: __VU, status: 'IN_PROGRESS', + mode: 'SOLO', + personality: 'FRIENDLY', + tone: 'GENTLE', + major: 'BACKEND', + level: 'NORMAL', questions: [ { id: 1, From 7c10ed0f502291631c09dcda15d2dcf9bb766611 Mon Sep 17 00:00:00 2001 From: 2-dayeon Date: Wed, 21 Oct 2026 20:18:51 +0900 Subject: [PATCH 03/13] =?UTF-8?q?chore:=20Redis=20=ED=98=B8=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=8F=AC=ED=8A=B8=20=EC=A0=91=EA=B7=BC=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.loadtest.yml | 2 +- docker-compose.yml | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/docker-compose.loadtest.yml b/docker-compose.loadtest.yml index e65a8ee..22820c1 100644 --- a/docker-compose.loadtest.yml +++ b/docker-compose.loadtest.yml @@ -2,7 +2,7 @@ services: redis-loadtest: image: redis:7-alpine ports: - - "6380:6379" + - "127.0.0.1:6380:6379" wiremock: image: wiremock/wiremock:3.13.2 diff --git a/docker-compose.yml b/docker-compose.yml index 9bec44b..b21b1c7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,8 +2,6 @@ services: redis: image: redis:7-alpine container_name: repit-redis - ports: - - "6379:6379" command: ["redis-server", "--appendonly", "yes", "--appendfsync", "always", "--maxmemory-policy", "noeviction"] volumes: - redis-data:/data From ddfd64c3ba5501eb481bf2fa8e02d8ea07f48a16 Mon Sep 17 00:00:00 2001 From: 2-dayeon Date: Wed, 21 Oct 2026 21:19:21 +0900 Subject: [PATCH 04/13] =?UTF-8?q?fix:=20=EC=A2=85=EB=A3=8C=20=EB=A9=B4?= =?UTF-8?q?=EC=A0=91=20=EC=9D=8C=EC=84=B1=20=ED=95=A9=EC=84=B1=EA=B3=BC=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=B7=A8=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/person/repit/tts/domain/TtsJob.java | 3 + .../tts/service/InterviewTtsScheduler.java | 6 +- .../service/InterviewTtsCancellationTest.java | 148 ++++++++++++++++++ 3 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 src/test/java/com/person/repit/tts/service/InterviewTtsCancellationTest.java diff --git a/src/main/java/com/person/repit/tts/domain/TtsJob.java b/src/main/java/com/person/repit/tts/domain/TtsJob.java index f8b4976..afcdf8d 100644 --- a/src/main/java/com/person/repit/tts/domain/TtsJob.java +++ b/src/main/java/com/person/repit/tts/domain/TtsJob.java @@ -11,6 +11,7 @@ public class TtsJob implements Comparable { private final long sequence; private final TtsInput input; private final Sinks.One sink = Sinks.one(); + private final Sinks.Empty cancellation = Sinks.empty(); private volatile int priority; private volatile boolean cancelled; @@ -40,6 +41,8 @@ public void changePriority(int priority) { public void cancel() { cancelled = true; + cancellation.tryEmitEmpty(); + sink.tryEmitEmpty(); } @Override diff --git a/src/main/java/com/person/repit/tts/service/InterviewTtsScheduler.java b/src/main/java/com/person/repit/tts/service/InterviewTtsScheduler.java index 360b0a9..2750b4c 100644 --- a/src/main/java/com/person/repit/tts/service/InterviewTtsScheduler.java +++ b/src/main/java/com/person/repit/tts/service/InterviewTtsScheduler.java @@ -161,10 +161,12 @@ private void start(TtsJob job) { generating.setUpdatedAt(Instant.now()); stateRepository.save(generating) - .then(synthesize(job)) - .flatMap(bytes -> upload(job, bytes)) + .then(Mono.defer(() -> synthesize(job))) + .flatMap(bytes -> Mono.defer(() -> job.isCancelled() + ? Mono.empty() : upload(job, bytes))) .flatMap(stored -> completeReady(job, generating, stored)) .onErrorResume(error -> completeFailed(job, generating, error)) + .takeUntilOther(job.getCancellation().asMono()) .doFinally(ignored -> finish(job)) .subscribe(); } diff --git a/src/test/java/com/person/repit/tts/service/InterviewTtsCancellationTest.java b/src/test/java/com/person/repit/tts/service/InterviewTtsCancellationTest.java new file mode 100644 index 0000000..522fa54 --- /dev/null +++ b/src/test/java/com/person/repit/tts/service/InterviewTtsCancellationTest.java @@ -0,0 +1,148 @@ +package com.person.repit.tts.service; + +import com.person.repit.common.metrics.RepitMetrics; +import com.person.repit.interview.domain.ChatQuestion; +import com.person.repit.tts.config.TtsProperties; +import com.person.repit.tts.domain.*; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.test.StepVerifier; +import reactor.test.scheduler.VirtualTimeScheduler; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class InterviewTtsCancellationTest { + private final TtsClient client = mock(TtsClient.class); + private final AudioStorage storage = mock(AudioStorage.class); + private final AudioStateRepository states = mock(AudioStateRepository.class); + private InterviewTtsScheduler scheduler; + + @BeforeEach + void setUp() { + var properties = new TtsProperties(); + properties.setEnabled(true); + var inputs = mock(TtsInputBuilder.class); + when(inputs.build(any(), any())).thenReturn(new TtsInput( + "질문", "voice", "model", "mp3", + new VoiceSettings(0.5, 0.8, 0.1, 1.0, true), "1234567890abcdef")); + when(states.create(any())).thenReturn(Mono.just(true)); + when(states.get(anyString(), anyLong())).thenAnswer(call -> Mono.just(AudioState.builder() + .sessionId(call.getArgument(0)).questionId(call.getArgument(1)) + .status(AudioStatus.QUEUED).build())); + when(states.save(any())).thenReturn(Mono.empty()); + when(client.synthesize(any())).thenReturn(Mono.never()); + when(storage.put(anyString(), anyLong(), anyString(), any())) + .thenReturn(Mono.just(new StoredAudio("audio.mp3", "https://example.invalid/audio.mp3"))); + scheduler = new InterviewTtsScheduler(properties, inputs, client, storage, states, + new RepitMetrics(new SimpleMeterRegistry())); + } + + @Test + void 중단하면_실행중_합성과_오디오대기를_취소하고_슬롯을_반환한다() { + var cancelled = new AtomicBoolean(); + when(client.synthesize(any())).thenReturn(Mono.never() + .doOnCancel(() -> cancelled.set(true))); + register("closed", 1L, 0); + StepVerifier.create(scheduler.await("closed", 1L)) + .then(() -> scheduler.cancelSession("closed")) + .expectComplete().verify(Duration.ofSeconds(2)); + assertThat(cancelled).isTrue(); + assertThat(scheduler.runningCount()).isZero(); + assertThat(scheduler.hasJob("closed", 1L)).isFalse(); + verifyNoInteractions(storage); + } + + @Test + void 중단후_늦은_합성결과는_업로드하지_않는다() { + var late = Sinks.one(); + when(client.synthesize(any())).thenReturn(late.asMono()); + register("closed", 1L, 0); + scheduler.cancelSession("closed"); + late.tryEmitValue(new byte[]{1, 2, 3}); + verifyNoInteractions(storage); + verify(states, never()).save(argThat(state -> state.getStatus() == AudioStatus.READY)); + } + + @Test + void 중단하면_진행중인_업로드도_취소한다() { + var cancelled = new AtomicBoolean(); + when(client.synthesize(any())).thenReturn(Mono.just(new byte[]{1})); + when(storage.put(anyString(), anyLong(), anyString(), any())) + .thenReturn(Mono.never().doOnCancel(() -> cancelled.set(true))); + register("closed", 1L, 0); + scheduler.cancelSession("closed"); + assertThat(cancelled).isTrue(); + assertThat(scheduler.runningCount()).isZero(); + } + + @Test + void 같은_세션의_대기작업을_제거하고_다른_세션은_계속_진행한다() { + register("closed", 1L, 0); + register("closed", 2L, 0); + register("closed", 3L, 0); + register("closed", 4L, 0); + register("other", 5L, 0); + assertThat(scheduler.queuedCount(0)).isEqualTo(2); + scheduler.cancelSession("closed"); + scheduler.cancelSession("closed"); + assertThat(scheduler.queuedCount(0)).isZero(); + assertThat(scheduler.runningCount()).isEqualTo(1); + assertThat(scheduler.hasJob("other", 5L)).isTrue(); + verify(client, times(3)).synthesize(any()); + } + + @Test + void 중단하면_재시도_백오프도_취소한다() { + var clock = VirtualTimeScheduler.getOrSet(); + try { + var requests = new AtomicInteger(); + when(client.synthesize(any())).thenReturn(Mono.defer(() -> { + requests.incrementAndGet(); + return Mono.error(new TtsClientException("일시 실패", true)); + })); + register("closed", 1L, 0); + assertThat(requests).hasValue(1); + scheduler.cancelSession("closed"); + clock.advanceTimeBy(Duration.ofSeconds(10)); + assertThat(requests).hasValue(1); + assertThat(scheduler.runningCount()).isZero(); + verifyNoInteractions(storage); + } finally { + VirtualTimeScheduler.reset(); + } + } + + @Test + void 상태저장_대기중에_중단하면_합성을_시작하지_않는다() { + var saved = Sinks.empty(); + when(states.save(any())).thenReturn(saved.asMono()); + register("closed", 1L, 0); + scheduler.cancelSession("closed"); + saved.tryEmitEmpty(); + verifyNoInteractions(client, storage); + assertThat(scheduler.runningCount()).isZero(); + } + + @Test + void 즉시_완료되는_정상작업도_슬롯과_작업을_정리한다() { + when(client.synthesize(any())).thenReturn(Mono.just(new byte[]{1})); + register("open", 1L, 0); + assertThat(scheduler.runningCount()).isZero(); + assertThat(scheduler.hasJob("open", 1L)).isFalse(); + verify(states, atLeastOnce()).save(argThat(state -> state.getStatus() == AudioStatus.READY)); + } + + private void register(String sessionId, long questionId, int priority) { + scheduler.register(sessionId, ChatQuestion.builder().questionId(questionId).content("질문").build(), + TtsProfile.builder().voiceId("voice").build(), priority, (int) questionId).block(); + } +} From bb04778ddc5cb976c3dbec587a0b1cf1a0201ab1 Mon Sep 17 00:00:00 2001 From: 2-dayeon Date: Wed, 21 Oct 2026 21:19:21 +0900 Subject: [PATCH 05/13] =?UTF-8?q?fix:=20=EC=A2=85=EB=A3=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=EC=9D=B4=20=EC=9E=88=EB=8A=94=20=EB=A9=B4=EC=A0=91=20?= =?UTF-8?q?=EC=9E=AC=EC=A4=80=EB=B9=84=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InterviewResultDeliveryService.java | 8 + .../impl/ChatInterviewServiceImpl.java | 5 + .../impl/ChatInterviewIdentityTest.java | 9 +- .../impl/ChatInterviewServiceImplTest.java | 1 + .../FinalizedInterviewPreparationTest.java | 158 ++++++++++++++++++ 5 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 src/test/java/com/person/repit/interview/service/impl/FinalizedInterviewPreparationTest.java diff --git a/src/main/java/com/person/repit/interview/service/InterviewResultDeliveryService.java b/src/main/java/com/person/repit/interview/service/InterviewResultDeliveryService.java index c041b92..e62fe2d 100644 --- a/src/main/java/com/person/repit/interview/service/InterviewResultDeliveryService.java +++ b/src/main/java/com/person/repit/interview/service/InterviewResultDeliveryService.java @@ -4,6 +4,7 @@ import com.person.repit.interview.dto.request.ChatInterviewAllRequest; import com.person.repit.interview.dto.response.ChatProgressResponse; import com.person.repit.interview.exception.ApiServerResponseException; +import com.person.repit.interview.exception.InterviewSessionAlreadyExistsException; import com.person.repit.interview.type.InterviewStatus; import io.micrometer.core.instrument.MeterRegistry; import lombok.RequiredArgsConstructor; @@ -22,6 +23,13 @@ public class InterviewResultDeliveryService { private final InterviewResultRetryPolicy policy; private final MeterRegistry meters; + public Mono ensureNotFinalized(String sessionId) { + return outbox.find(sessionId) + .flatMap(item -> Mono.error(new InterviewSessionAlreadyExistsException( + "이미 종료 기록이 등록된 면접 세션입니다."))) + .then(); + } + public Mono resume(String sessionId) { return outbox.find(sessionId).flatMap(this::requireTerminalStatus).flatMap(item -> item.getState() == InterviewResultDelivery.State.BLOCKED 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 3e68177..e3f875e 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 @@ -67,6 +67,11 @@ public class ChatInterviewServiceImpl implements ChatInterviewService { @Override public Mono prepareInterview(ChatInterviewPrepareRequest request) { + return operationLock.execute(request.getSessionId(), () -> + resultDelivery.ensureNotFinalized(request.getSessionId()).then(prepareOnce(request))); + } + + private Mono prepareOnce(ChatInterviewPrepareRequest request) { return Mono.defer(() -> { validateQuestionIds(request); validatePersonas(request); diff --git a/src/test/java/com/person/repit/interview/service/impl/ChatInterviewIdentityTest.java b/src/test/java/com/person/repit/interview/service/impl/ChatInterviewIdentityTest.java index 65c410c..b1af4e6 100644 --- a/src/test/java/com/person/repit/interview/service/impl/ChatInterviewIdentityTest.java +++ b/src/test/java/com/person/repit/interview/service/impl/ChatInterviewIdentityTest.java @@ -38,8 +38,13 @@ class ChatInterviewIdentityTest { @BeforeEach void setUp() { when(redis.opsForValue()).thenReturn(values); - service = new ChatInterviewServiceImpl(ai, redis, mock(InterviewResultDeliveryService.class), - mock(InterviewOperationLock.class), new RepitMetrics(new SimpleMeterRegistry()), + var delivery = mock(InterviewResultDeliveryService.class); + when(delivery.ensureNotFinalized(anyString())).thenReturn(Mono.empty()); + var lock = mock(InterviewOperationLock.class); + when(lock.execute(anyString(), any())).thenAnswer(call -> + ((java.util.function.Supplier>) call.getArgument(1)).get()); + service = new ChatInterviewServiceImpl(ai, redis, delivery, + lock, new RepitMetrics(new SimpleMeterRegistry()), mock(PersonaTtsProfileResolver.class), tts); } diff --git a/src/test/java/com/person/repit/interview/service/impl/ChatInterviewServiceImplTest.java b/src/test/java/com/person/repit/interview/service/impl/ChatInterviewServiceImplTest.java index 7ffb1b6..97d468d 100644 --- a/src/test/java/com/person/repit/interview/service/impl/ChatInterviewServiceImplTest.java +++ b/src/test/java/com/person/repit/interview/service/impl/ChatInterviewServiceImplTest.java @@ -81,6 +81,7 @@ void setUp() { ((java.util.function.Supplier>) invocation.getArgument(1)).get()); InterviewResultDeliveryService delivery = mock(InterviewResultDeliveryService.class); when(delivery.resume(anyString())).thenReturn(Mono.empty()); + when(delivery.ensureNotFinalized(anyString())).thenReturn(Mono.empty()); service = new ChatInterviewServiceImpl( aiQuestionClient, redisTemplate, diff --git a/src/test/java/com/person/repit/interview/service/impl/FinalizedInterviewPreparationTest.java b/src/test/java/com/person/repit/interview/service/impl/FinalizedInterviewPreparationTest.java new file mode 100644 index 0000000..345b467 --- /dev/null +++ b/src/test/java/com/person/repit/interview/service/impl/FinalizedInterviewPreparationTest.java @@ -0,0 +1,158 @@ +package com.person.repit.interview.service.impl; + +import com.person.repit.common.config.JacksonConfig; +import com.person.repit.common.metrics.RepitMetrics; +import com.person.repit.interview.domain.InterviewResultDelivery; +import com.person.repit.interview.dto.request.ChatInterviewPrepareRequest; +import com.person.repit.interview.exception.InterviewSessionAlreadyExistsException; +import com.person.repit.interview.service.*; +import com.person.repit.interview.type.*; +import com.person.repit.tts.domain.AudioState; +import com.person.repit.tts.service.InterviewTtsScheduler; +import com.person.repit.tts.service.PersonaTtsProfileResolver; +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.MethodSource; +import org.junit.jupiter.params.provider.Arguments; +import org.springframework.data.redis.core.*; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.stream.Stream; +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 FinalizedInterviewPreparationTest { + private final ReactiveRedisTemplate redis = mock(ReactiveRedisTemplate.class); + private final ReactiveValueOperations values = mock(ReactiveValueOperations.class); + private final ReactiveStringRedisTemplate strings = mock(ReactiveStringRedisTemplate.class); + private final ReactiveValueOperations records = mock(ReactiveValueOperations.class); + private final ApiServerClient api = mock(ApiServerClient.class); + private final InterviewTtsScheduler tts = mock(InterviewTtsScheduler.class); + private final AiQuestionClient ai = mock(AiQuestionClient.class); + private final com.fasterxml.jackson.databind.ObjectMapper mapper = new JacksonConfig().objectMapper(); + private ChatInterviewServiceImpl service; + + @BeforeEach + void setUp() { + when(redis.opsForValue()).thenReturn(values); + when(strings.opsForValue()).thenReturn(records); + when(records.get(anyString())).thenReturn(Mono.empty()); + when(values.setIfAbsent(anyString(), any(), any(Duration.class))).thenReturn(Mono.just(true)); + when(tts.register(anyString(), any(), any(), anyInt(), anyInt())) + .thenReturn(Mono.just(AudioState.builder().build())); + var lock = mock(InterviewOperationLock.class); + when(lock.execute(anyString(), any())).thenAnswer(call -> + ((Supplier>) call.getArgument(1)).get()); + var meters = new SimpleMeterRegistry(); + var delivery = new InterviewResultDeliveryService(new InterviewResultOutbox(strings, mapper), + api, new InterviewResultRetryPolicy(), meters); + service = new ChatInterviewServiceImpl(ai, redis, delivery, lock, new RepitMetrics(meters), + mock(PersonaTtsProfileResolver.class), tts); + } + + static Stream finalizedCases() { + return Stream.of(InterviewMode.values()).flatMap(mode -> + Stream.of(InterviewStatus.COMPLETED, InterviewStatus.ABANDONED).flatMap(status -> + Stream.of(InterviewResultDelivery.State.values()) + .map(state -> Arguments.of(mode, status, state)))); + } + + @ParameterizedTest + @MethodSource("finalizedCases") + void 종료기록이_있으면_모드와_전송상태에_관계없이_재생성을_거절한다( + InterviewMode mode, InterviewStatus status, InterviewResultDelivery.State state) throws Exception { + var item = receipt(status, state); + String original = mapper.writeValueAsString(item); + when(records.get("chat:interview:result:session-id")).thenReturn(Mono.just(original)); + + StepVerifier.create(service.prepareInterview(request(mode))) + .expectError(InterviewSessionAlreadyExistsException.class).verify(Duration.ofSeconds(2)); + + verify(values, never()).setIfAbsent(anyString(), any(), any(Duration.class)); + verify(records, never()).set(anyString(), anyString()); + verifyNoInteractions(tts, ai, api); + assertThat(mapper.writeValueAsString(item)).isEqualTo(original); + } + + @Test + void 종료후_다른_식별자로_같은_세션을_준비해도_거절한다() throws Exception { + var item = receipt(InterviewStatus.COMPLETED, InterviewResultDelivery.State.SENT); + when(records.get(anyString())).thenReturn(Mono.just(mapper.writeValueAsString(item))); + var request = request(InterviewMode.SOLO); + request.setInterviewId(999L); + request.setUserId(888L); + StepVerifier.create(service.prepareInterview(request)) + .expectError(InterviewSessionAlreadyExistsException.class).verify(); + verify(values, never()).setIfAbsent(anyString(), any(), any(Duration.class)); + verifyNoInteractions(tts, ai, api); + } + + @Test + void 전송기록을_읽지_못하면_세션을_새로_만들지_않는다() { + when(records.get(anyString())).thenReturn(Mono.error(new IllegalStateException("redis down"))); + StepVerifier.create(service.prepareInterview(request(InterviewMode.SOLO))) + .expectError(IllegalStateException.class).verify(); + verify(values, never()).setIfAbsent(anyString(), any(), any(Duration.class)); + verifyNoInteractions(tts, ai, api); + } + + @Test + void 구_기록의_종료상태가_비어도_그_세션을_재사용하지_않는다() throws Exception { + var item = receipt(null, InterviewResultDelivery.State.SENT); + when(records.get(anyString())).thenReturn(Mono.just(mapper.writeValueAsString(item))); + StepVerifier.create(service.prepareInterview(request(InterviewMode.SOLO))) + .expectError(InterviewSessionAlreadyExistsException.class).verify(); + verify(values, never()).setIfAbsent(anyString(), any(), any(Duration.class)); + verify(records, never()).set(anyString(), anyString()); + verifyNoInteractions(tts, ai, api); + } + + @Test + void 종료기록이_없는_새_준비요청은_기존대로_성공한다() { + var response = service.prepareInterview(request(InterviewMode.SOLO)).block(); + assertThat(response.getStatus()).isEqualTo(InterviewStatus.IN_PROGRESS); + assertThat(response.getInterviewId()).isEqualTo(1L); + assertThat(response.getUserId()).isEqualTo(2L); + verify(tts).register(eq("session-id"), any(), any(), eq(0), eq(0)); + } + + private InterviewResultDelivery receipt(InterviewStatus status, InterviewResultDelivery.State state) { + var item = new InterviewResultDelivery(); + item.setSessionId("session-id"); + item.setInterviewId(1L); + item.setInterviewStatus(status); + item.setState(state); + item.setNextAttemptAt(Instant.EPOCH); + item.setPayload(state == InterviewResultDelivery.State.SENT ? null : "original-payload"); + return item; + } + + private ChatInterviewPrepareRequest request(InterviewMode mode) { + var request = new ChatInterviewPrepareRequest(); + request.setSessionId("session-id"); + request.setInterviewId(1L); + request.setUserId(2L); + request.setStatus(InterviewStatus.IN_PROGRESS); + request.setMode(mode); + request.setPersonality(InterviewType.FRIENDLY); + request.setTone(InterviewTone.GENTLE); + request.setMajor(InterviewMajor.BACKEND); + request.setLevel(InterviewLevel.NORMAL); + var question = new ChatInterviewPrepareRequest.Question(); + question.setId(3L); + question.setPersonaId(4L); + question.setQuestion("질문"); + request.setQuestions(List.of(question)); + return request; + } +} From 8badeef249b4bd770c63a2898b430b026b1a70c6 Mon Sep 17 00:00:00 2001 From: 2-dayeon Date: Wed, 21 Oct 2026 21:19:21 +0900 Subject: [PATCH 06/13] =?UTF-8?q?fix:=20=EB=A9=B4=EC=A0=91=20=EC=A4=80?= =?UTF-8?q?=EB=B9=84=EC=99=80=20=EC=A2=85=EB=A3=8C=20=EA=B2=BD=ED=95=A9=20?= =?UTF-8?q?=ED=9A=8C=EA=B7=80=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/InterviewResultRedisTest.java | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) 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 c7b2f7e..703a33f 100644 --- a/src/test/java/com/person/repit/interview/service/InterviewResultRedisTest.java +++ b/src/test/java/com/person/repit/interview/service/InterviewResultRedisTest.java @@ -286,6 +286,61 @@ void stopRedis() throws Exception { } } + @ParameterizedTest + @EnumSource(value = InterviewStatus.class, names = {"COMPLETED", "ABANDONED"}) + void 종료전송과_준비요청이_경합해도_같은_세션을_재생성하지_않는다(InterviewStatus status) { + var objects = new RedisConfig().reactiveRedisTemplate(factory); + var active = session(); + active.setStatus(InterviewStatus.IN_PROGRESS); + active.setCurrentQuestionIndex(-1); + objects.opsForValue().set("chat:interview:s", active).block(); + var called = reactor.core.publisher.Sinks.empty(); + var accepted = reactor.core.publisher.Sinks.empty(); + var client = mock(ApiServerClient.class); + when(client.saveInterviewPayload(anyString())).thenReturn(Mono.defer(() -> { + called.tryEmitEmpty(); + return accepted.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 service = new com.person.repit.interview.service.impl.ChatInterviewServiceImpl( + mock(AiQuestionClient.class), objects, delivery, lock, + new com.person.repit.common.metrics.RepitMetrics(meters), + mock(com.person.repit.tts.service.PersonaTtsProfileResolver.class), tts); + var request = new com.person.repit.interview.dto.request.ChatInterviewPrepareRequest(); + request.setSessionId("s"); + request.setInterviewId(1L); + request.setUserId(2L); + request.setStatus(InterviewStatus.IN_PROGRESS); + request.setMode(com.person.repit.interview.type.InterviewMode.SOLO); + var question = new com.person.repit.interview.dto.request.ChatInterviewPrepareRequest.Question(); + question.setId(17L); + question.setPersonaId(1L); + question.setQuestion("질문"); + request.setQuestions(List.of(question)); + var closing = (status == InterviewStatus.COMPLETED + ? service.completeInterview("s") : service.quitInterview("s")).toFuture(); + try { + called.asMono().block(Duration.ofSeconds(5)); + StepVerifier.create(service.prepareInterview(request)).expectError(AlreadyUsingException.class) + .verify(Duration.ofSeconds(5)); + accepted.tryEmitEmpty(); + assertThat(closing.orTimeout(5, java.util.concurrent.TimeUnit.SECONDS).join().getStatus()).isEqualTo(status); + String receipt = redis.opsForValue().get("chat:interview:result:s").block(); + StepVerifier.create(service.prepareInterview(request)) + .expectError(com.person.repit.interview.exception.InterviewSessionAlreadyExistsException.class) + .verify(Duration.ofSeconds(5)); + assertThat(objects.hasKey("chat:interview:s").block()).isFalse(); + assertThat(redis.opsForValue().get("chat:interview:result:s").block()).isEqualTo(receipt); + verify(tts).cancelSession("s"); + verifyNoMoreInteractions(tts); + } finally { + accepted.tryEmitEmpty(); + closing.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 cc49f9dfc0628a70b89294c9afc4fe7f742328ae Mon Sep 17 00:00:00 2001 From: 2-dayeon Date: Wed, 21 Oct 2026 21:32:07 +0900 Subject: [PATCH 07/13] =?UTF-8?q?fix:=20=EB=A9=B4=EC=A0=91=20=EB=8B=B5?= =?UTF-8?q?=EB=B3=80=20=EC=9E=AC=EC=9A=94=EC=B2=AD=EA=B3=BC=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=20=EC=83=81=ED=83=9C=20=EA=B2=80=EC=A6=9D=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/ChatInterviewServiceImpl.java | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) 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 e3f875e..d951427 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 @@ -73,6 +73,9 @@ public Mono prepareInterview(ChatInterviewPrepareRequest private Mono prepareOnce(ChatInterviewPrepareRequest request) { return Mono.defer(() -> { + if (request.getStatus() != InterviewStatus.IN_PROGRESS) { + return Mono.error(new InvalidRequestException("새 면접은 진행 중 상태로만 준비할 수 있습니다.")); + } validateQuestionIds(request); validatePersonas(request); log.debug("sessionId={}, interviewId={}", request.getSessionId(), request.getInterviewId()); @@ -116,7 +119,7 @@ private Mono prepareOnce(ChatInterviewPrepareRequest requ .ttsProfileVersion(profiles == null || profiles.isEmpty() ? null : profiles.values().iterator().next().getProfileVersion()) - .status(request.getStatus()) + .status(InterviewStatus.IN_PROGRESS) .questions(new ArrayList<>(questions)) .answers(new ArrayList<>()) .currentQuestionIndex(0) @@ -185,7 +188,17 @@ private Mono submitAnswerOnce(String sessionId, ChatAnswer if (currentQuestion == null) { return finishInterview(session, InterviewStatus.COMPLETED); } + ChatAnswer previous = session.getAnswers().stream() + .filter(item -> item.getQuestionId().equals(request.getQuestionId())) + .findFirst().orElse(null); + if (previous != null && (!Objects.equals(previous.getContent(), request.getContent()) + || !Objects.equals(previous.getResponseTime(), request.getResponseTime()))) { + return Mono.error(new InvalidRequestException("이미 접수한 답변과 다릅니다.")); + } if (!currentQuestion.getQuestionId().equals(request.getQuestionId())) { + if (previous != null) { + return Mono.just(ChatProgressResponse.next(currentQuestion)); + } return Mono.error(new InterviewQuestionMismatchException( "현재 질문과 요청한 질문이 일치하지 않습니다." )); @@ -200,14 +213,8 @@ private Mono submitAnswerOnce(String sessionId, ChatAnswer .createdAt(LocalDateTime.now()) .build(); - ChatAnswer previous = session.getAnswers().stream() - .filter(item -> item.getQuestionId().equals(request.getQuestionId())) - .findFirst().orElse(null); if (previous == null) { session.getAnswers().add(answer); - } else if (!java.util.Objects.equals(previous.getContent(), request.getContent()) - || !java.util.Objects.equals(previous.getResponseTime(), request.getResponseTime())) { - return Mono.error(new InvalidRequestException("이미 접수한 답변과 다릅니다.")); } log.info( "[ANSWER ACCEPTED] sessionId={}, interviewId={}, questionId={}", From 53348262b5e6ac9d7a2ee114195b54f59d3e7686 Mon Sep 17 00:00:00 2001 From: 2-dayeon Date: Wed, 21 Oct 2026 21:32:07 +0900 Subject: [PATCH 08/13] =?UTF-8?q?fix:=20=EB=A9=B4=EC=A0=91=20=EB=8B=B5?= =?UTF-8?q?=EB=B3=80=20=EC=9E=AC=EC=A0=84=EC=86=A1=20=ED=9A=8C=EA=B7=80=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/InterviewAnswerRetryTest.java | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 src/test/java/com/person/repit/interview/service/impl/InterviewAnswerRetryTest.java diff --git a/src/test/java/com/person/repit/interview/service/impl/InterviewAnswerRetryTest.java b/src/test/java/com/person/repit/interview/service/impl/InterviewAnswerRetryTest.java new file mode 100644 index 0000000..274b2c4 --- /dev/null +++ b/src/test/java/com/person/repit/interview/service/impl/InterviewAnswerRetryTest.java @@ -0,0 +1,156 @@ +package com.person.repit.interview.service.impl; + +import com.person.repit.common.exception.InvalidRequestException; +import com.person.repit.common.metrics.RepitMetrics; +import com.person.repit.interview.domain.ChatInterviewSession; +import com.person.repit.interview.domain.ChatQuestion; +import com.person.repit.interview.dto.request.ChatAnswerRequest; +import com.person.repit.interview.dto.response.FollowQuestionAiResponse; +import com.person.repit.interview.exception.InterviewQuestionMismatchException; +import com.person.repit.interview.service.*; +import com.person.repit.interview.type.*; +import com.person.repit.tts.domain.AudioState; +import com.person.repit.tts.service.InterviewTtsScheduler; +import com.person.repit.tts.service.PersonaTtsProfileResolver; +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.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.data.redis.core.ReactiveValueOperations; +import org.springframework.data.redis.serializer.RedisSerializer; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.test.StepVerifier; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.concurrent.atomic.AtomicReference; +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 InterviewAnswerRetryTest { + private final ReactiveRedisTemplate redis = mock(ReactiveRedisTemplate.class); + private final ReactiveValueOperations values = mock(ReactiveValueOperations.class); + private final AiQuestionClient ai = mock(AiQuestionClient.class); + private final InterviewTtsScheduler tts = mock(InterviewTtsScheduler.class); + private final RedisSerializer serializer = RedisSerializer.json(); + private final AtomicReference stored = new AtomicReference<>(); + private ChatInterviewServiceImpl service; + + @BeforeEach + void setUp() { + when(redis.opsForValue()).thenReturn(values); + when(values.get("chat:interview:s")).thenAnswer(call -> + Mono.fromSupplier(() -> serializer.deserialize(stored.get()))); + when(values.set(anyString(), any(), any(Duration.class))).thenAnswer(call -> Mono.fromSupplier(() -> { + stored.set(serializer.serialize(call.getArgument(1))); + return true; + })); + when(ai.decideFollowQuestion(any())).thenReturn(Mono.just(FollowQuestionAiResponse.notRequired())); + when(tts.register(anyString(), any(), any(), anyInt(), anyInt())) + .thenReturn(Mono.just(AudioState.builder().build())); + var lock = mock(InterviewOperationLock.class); + when(lock.execute(anyString(), any())).thenAnswer(call -> ((Supplier>) call.getArgument(1)).get()); + var delivery = mock(InterviewResultDeliveryService.class); + when(delivery.resume(anyString())).thenReturn(Mono.empty()); + service = new ChatInterviewServiceImpl(ai, redis, delivery, lock, + new RepitMetrics(new SimpleMeterRegistry()), mock(PersonaTtsProfileResolver.class), tts); + var questions = new ArrayList(); + for (long id = 1; id <= 3; id++) { + questions.add(ChatQuestion.builder().questionId(id).content("질문 " + id) + .type(QuestionType.ORIGINAL).askedByPersonaId(4L).build()); + } + stored.set(serializer.serialize(ChatInterviewSession.builder().sessionId("s").interviewId(1L).userId(2L) + .personality(InterviewType.FRIENDLY).tone(InterviewTone.GENTLE) + .major(InterviewMajor.BACKEND).level(InterviewLevel.NORMAL).questions(questions).build())); + } + + @Test + void 이미_접수한_답변_재요청은_현재_질문을_반환하고_부수효과를_반복하지_않는다() { + assertThat(service.submitAnswer("s", answer(1L)).block().getQuestion().getQuestionId()).isEqualTo(2L); + byte[] original = stored.get().clone(); + clearInvocations(values, ai, tts); + assertThat(service.submitAnswer("s", answer(1L)).block().getQuestion().getQuestionId()).isEqualTo(2L); + assertThat(stored.get()).isEqualTo(original); + verify(values, never()).set(anyString(), any(), any(Duration.class)); + verifyNoInteractions(ai, tts); + } + + @Test + void 오래된_답변의_재전송도_질문을_되돌리지_않는다() { + service.submitAnswer("s", answer(1L)).block(); + service.submitAnswer("s", answer(2L)).block(); + clearInvocations(ai, tts); + assertThat(service.submitAnswer("s", answer(1L)).block().getQuestion().getQuestionId()).isEqualTo(3L); + assertThat(session().getAnswers()).hasSize(2); + assertThat(session().getCurrentQuestionIndex()).isEqualTo(2); + verifyNoInteractions(ai, tts); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void 같은_질문에_다른_내용이나_응답시간을_보내면_거절한다(boolean changeContent) { + service.submitAnswer("s", answer(1L)).block(); + var changed = ChatAnswerRequest.builder().questionId(1L) + .content(changeContent ? "다른 답변" : "접수 답변").responseTime(changeContent ? 10 : 11).build(); + StepVerifier.create(service.submitAnswer("s", changed)) + .expectErrorSatisfies(error -> assertThat(error).isInstanceOf(InvalidRequestException.class) + .hasMessage("이미 접수한 답변과 다릅니다.")) + .verify(Duration.ofSeconds(2)); + assertThat(session().getAnswers()).hasSize(1); + } + + @Test + void 꼬리질문을_생성한_원답변_재요청은_같은_꼬리질문을_반환한다() { + var follow = new FollowQuestionAiResponse(); + follow.setRequired(true); + follow.setContent("꼬리질문"); + follow.setExpectedAnswer("참고 답안"); + when(ai.decideFollowQuestion(any())).thenReturn(Mono.just(follow)); + assertThat(service.submitAnswer("s", answer(1L)).block().getQuestion().getQuestionId()).isEqualTo(-1L); + clearInvocations(ai, tts); + assertThat(service.submitAnswer("s", answer(1L)).block().getQuestion().getQuestionId()).isEqualTo(-1L); + assertThat(session().getQuestions()).hasSize(4); + verifyNoInteractions(ai, tts); + assertThat(service.submitAnswer("s", answer(-1L)).block().getQuestion().getQuestionId()).isEqualTo(2L); + clearInvocations(ai, tts); + assertThat(service.submitAnswer("s", answer(-1L)).block().getQuestion().getQuestionId()).isEqualTo(2L); + verifyNoInteractions(ai, tts); + } + + @Test + void 답변저장후_AI대기중_요청취소는_같은_답변으로_진행을_재개한다() { + var decision = Sinks.one(); + when(ai.decideFollowQuestion(any())).thenReturn(decision.asMono()); + var pending = service.submitAnswer("s", answer(1L)).subscribe(); + var acceptedAt = session().getAnswers().getFirst().getCreatedAt(); + pending.dispose(); + when(ai.decideFollowQuestion(any())).thenReturn(Mono.just(FollowQuestionAiResponse.notRequired())); + assertThat(service.submitAnswer("s", answer(1L)).block().getQuestion().getQuestionId()).isEqualTo(2L); + assertThat(session().getAnswers()).hasSize(1); + assertThat(session().getAnswers().getFirst().getCreatedAt()).isEqualTo(acceptedAt); + verify(ai, times(2)).decideFollowQuestion(any()); + } + + @Test + void 접수한_적_없는_다른_질문의_답변은_계속_거절한다() { + StepVerifier.create(service.submitAnswer("s", answer(99L))) + .expectError(InterviewQuestionMismatchException.class).verify(Duration.ofSeconds(2)); + assertThat(session().getAnswers()).isEmpty(); + verifyNoInteractions(ai, tts); + } + + private ChatInterviewSession session() { + return (ChatInterviewSession) serializer.deserialize(stored.get()); + } + + private ChatAnswerRequest answer(long questionId) { + return ChatAnswerRequest.builder().questionId(questionId).content("접수 답변").responseTime(10).build(); + } +} From ff577d002a49fba46a008b83d8123ca0872fdf5e Mon Sep 17 00:00:00 2001 From: 2-dayeon Date: Wed, 21 Oct 2026 21:32:07 +0900 Subject: [PATCH 09/13] =?UTF-8?q?fix:=20=EB=A9=B4=EC=A0=91=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=20=EC=83=81=ED=83=9C=20=ED=9A=8C=EA=B7=80=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/InterviewInitialStatusTest.java | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/test/java/com/person/repit/interview/service/impl/InterviewInitialStatusTest.java diff --git a/src/test/java/com/person/repit/interview/service/impl/InterviewInitialStatusTest.java b/src/test/java/com/person/repit/interview/service/impl/InterviewInitialStatusTest.java new file mode 100644 index 0000000..e3496b8 --- /dev/null +++ b/src/test/java/com/person/repit/interview/service/impl/InterviewInitialStatusTest.java @@ -0,0 +1,86 @@ +package com.person.repit.interview.service.impl; + +import com.person.repit.common.exception.handler.GlobalExceptionHandler; +import com.person.repit.common.metrics.RepitMetrics; +import com.person.repit.interview.controller.ChatInterviewController; +import com.person.repit.interview.dto.request.ChatInterviewPrepareRequest; +import com.person.repit.interview.service.*; +import com.person.repit.interview.type.*; +import com.person.repit.tts.domain.AudioState; +import com.person.repit.tts.service.InterviewTtsScheduler; +import com.person.repit.tts.service.PersonaTtsProfileResolver; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.data.redis.core.ReactiveValueOperations; +import org.springframework.test.web.reactive.server.WebTestClient; +import reactor.core.publisher.Mono; + +import java.time.Duration; +import java.util.List; +import java.util.function.Supplier; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@SuppressWarnings("unchecked") +class InterviewInitialStatusTest { + private final ReactiveRedisTemplate redis = mock(ReactiveRedisTemplate.class); + private final ReactiveValueOperations values = mock(ReactiveValueOperations.class); + private final InterviewTtsScheduler tts = mock(InterviewTtsScheduler.class); + private WebTestClient client; + + @BeforeEach + void setUp() { + when(redis.opsForValue()).thenReturn(values); + when(values.setIfAbsent(anyString(), any(), any(Duration.class))).thenReturn(Mono.just(true)); + when(tts.register(anyString(), any(), any(), anyInt(), anyInt())) + .thenReturn(Mono.just(AudioState.builder().build())); + var delivery = mock(InterviewResultDeliveryService.class); + when(delivery.ensureNotFinalized(anyString())).thenReturn(Mono.empty()); + var lock = mock(InterviewOperationLock.class); + when(lock.execute(anyString(), any())).thenAnswer(call -> ((Supplier>) call.getArgument(1)).get()); + var service = new ChatInterviewServiceImpl(mock(AiQuestionClient.class), redis, delivery, lock, + new RepitMetrics(new SimpleMeterRegistry()), mock(PersonaTtsProfileResolver.class), tts); + client = WebTestClient.bindToController(new ChatInterviewController(service)) + .controllerAdvice(new GlobalExceptionHandler()).build(); + } + + @ParameterizedTest + @CsvSource({"SOLO,COMPLETED", "SOLO,ABANDONED", "MULTI,COMPLETED", "MULTI,ABANDONED"}) + void 종료상태로_새_면접을_생성하면_400을_반환한다(InterviewMode mode, InterviewStatus status) { + client.post().uri("/chat/interviews").bodyValue(request(mode, status)).exchange() + .expectStatus().isBadRequest().expectBody().jsonPath("$.code").isEqualTo("INVALID_REQUEST"); + verify(values, never()).setIfAbsent(anyString(), any(), any(Duration.class)); + verifyNoInteractions(tts); + } + + @ParameterizedTest + @EnumSource(InterviewMode.class) + void 정상_진행상태의_준비는_그대로_성공한다(InterviewMode mode) { + client.post().uri("/chat/interviews").bodyValue(request(mode, InterviewStatus.IN_PROGRESS)).exchange() + .expectStatus().isOk().expectBody().jsonPath("$.status").isEqualTo("IN_PROGRESS"); + } + + private ChatInterviewPrepareRequest request(InterviewMode mode, InterviewStatus status) { + var request = new ChatInterviewPrepareRequest(); + request.setSessionId("s"); + request.setInterviewId(1L); + request.setUserId(2L); + request.setStatus(status); + request.setMode(mode); + request.setPersonality(InterviewType.FRIENDLY); + request.setTone(InterviewTone.GENTLE); + request.setMajor(InterviewMajor.BACKEND); + request.setLevel(InterviewLevel.NORMAL); + var question = new ChatInterviewPrepareRequest.Question(); + question.setId(3L); + question.setPersonaId(4L); + question.setQuestion("질문"); + request.setQuestions(List.of(question)); + return request; + } +} From a4f5cfd66ce49c26daee988af4c5db49443f3a29 Mon Sep 17 00:00:00 2001 From: 2-dayeon Date: Wed, 21 Oct 2026 21:32:07 +0900 Subject: [PATCH 10/13] =?UTF-8?q?fix:=20=EC=A2=85=EB=A3=8C=20=EB=A9=B4?= =?UTF-8?q?=EC=A0=91=20=EC=9D=8C=EC=84=B1=20=EB=B3=B5=EA=B5=AC=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repit/tts/service/TtsRecoveryService.java | 11 +- .../tts/service/TtsRecoveryServiceTest.java | 110 ++++++++++++++++++ 2 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 src/test/java/com/person/repit/tts/service/TtsRecoveryServiceTest.java diff --git a/src/main/java/com/person/repit/tts/service/TtsRecoveryService.java b/src/main/java/com/person/repit/tts/service/TtsRecoveryService.java index af6e52c..b086ec4 100644 --- a/src/main/java/com/person/repit/tts/service/TtsRecoveryService.java +++ b/src/main/java/com/person/repit/tts/service/TtsRecoveryService.java @@ -3,7 +3,9 @@ import com.person.repit.interview.domain.ChatInterviewSession; import com.person.repit.interview.domain.ChatQuestion; import com.person.repit.interview.domain.PersonaSnapshot; +import com.person.repit.interview.service.InterviewOperationLock; import com.person.repit.interview.type.InterviewRole; +import com.person.repit.interview.type.InterviewStatus; import com.person.repit.tts.domain.AudioState; import com.person.repit.tts.domain.TtsProfile; import lombok.RequiredArgsConstructor; @@ -24,6 +26,7 @@ public class TtsRecoveryService { private final AudioStateRepository stateRepository; private final InterviewTtsScheduler scheduler; private final PersonaTtsProfileResolver profileResolver; + private final InterviewOperationLock operationLock; @EventListener(ApplicationReadyEvent.class) public void recover() { @@ -34,13 +37,15 @@ public void recover() { } private Mono recoverState(AudioState state) { - return redisTemplate.opsForValue().get(SESSION_KEY_PREFIX + state.getSessionId()) + return operationLock.execute(state.getSessionId(), () -> + redisTemplate.opsForValue().get(SESSION_KEY_PREFIX + state.getSessionId()) .ofType(ChatInterviewSession.class) + .filter(session -> session.getStatus() == InterviewStatus.IN_PROGRESS) .flatMap(session -> recoverQuestion(session, state).thenReturn(true)) .defaultIfEmpty(false) .flatMap(recovered -> recovered ? Mono.empty() - : stateRepository.delete(state.getSessionId(), state.getQuestionId())); + : stateRepository.delete(state.getSessionId(), state.getQuestionId()))); } private Mono recoverQuestion(ChatInterviewSession session, AudioState state) { @@ -54,7 +59,7 @@ private Mono recoverQuestion(ChatInterviewSession session, AudioState stat TtsProfile profile = profileFor(session, question.getAskedByPersonaId()); int order = session.getQuestions().indexOf(question); return stateRepository.delete(state.getSessionId(), state.getQuestionId()) - .then(scheduler.register(session.getSessionId(), question, profile, 1, order)) + .then(Mono.defer(() -> scheduler.register(session.getSessionId(), question, profile, 1, order))) .then(); } diff --git a/src/test/java/com/person/repit/tts/service/TtsRecoveryServiceTest.java b/src/test/java/com/person/repit/tts/service/TtsRecoveryServiceTest.java new file mode 100644 index 0000000..029d162 --- /dev/null +++ b/src/test/java/com/person/repit/tts/service/TtsRecoveryServiceTest.java @@ -0,0 +1,110 @@ +package com.person.repit.tts.service; + +import com.person.repit.interview.domain.ChatInterviewSession; +import com.person.repit.interview.domain.ChatQuestion; +import com.person.repit.interview.service.InterviewOperationLock; +import com.person.repit.interview.type.InterviewStatus; +import com.person.repit.tts.domain.*; +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.NullSource; +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 reactor.core.publisher.Sinks; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@SuppressWarnings("unchecked") +class TtsRecoveryServiceTest { + private final ReactiveRedisTemplate redis = mock(ReactiveRedisTemplate.class); + private final ReactiveValueOperations values = mock(ReactiveValueOperations.class); + private final AudioStateRepository states = mock(AudioStateRepository.class); + private final InterviewTtsScheduler scheduler = mock(InterviewTtsScheduler.class); + private final PersonaTtsProfileResolver profiles = mock(PersonaTtsProfileResolver.class); + private final InterviewOperationLock lock = mock(InterviewOperationLock.class); + private final AtomicReference session = new AtomicReference<>(); + private final AudioState state = AudioState.builder().sessionId("s").questionId(1L) + .status(AudioStatus.GENERATING).build(); + private TtsRecoveryService recovery; + + @BeforeEach + void setUp() { + when(redis.opsForValue()).thenReturn(values); + when(values.get("chat:interview:s")).thenReturn(Mono.fromSupplier(session::get)); + when(states.findRecoverable()).thenReturn(Flux.just(state)); + when(states.delete(anyString(), anyLong())).thenReturn(Mono.empty()); + when(scheduler.register(anyString(), any(), any(), anyInt(), anyInt())).thenReturn(Mono.just(state)); + when(lock.execute(anyString(), any())).thenAnswer(call -> + Mono.defer(() -> ((Supplier>) call.getArgument(1)).get())); + session.set(ChatInterviewSession.builder().sessionId("s") + .questions(List.of(ChatQuestion.builder().questionId(1L).askedByPersonaId(2L).build())) + .ttsProfiles(Map.of(2L, TtsProfile.builder().voiceId("stored-voice").build())).build()); + recovery = new TtsRecoveryService(redis, states, scheduler, profiles, lock); + } + + @ParameterizedTest + @NullSource + @EnumSource(value = InterviewStatus.class, names = {"COMPLETED", "ABANDONED"}) + void 종료되었거나_상태가_없는_세션은_오디오를_복구하지_않는다(InterviewStatus status) { + session.get().setStatus(status); + recovery.recover(); + verify(states).delete("s", 1L); + verifyNoInteractions(scheduler, profiles); + } + + @ParameterizedTest + @EnumSource(value = AudioStatus.class, names = {"QUEUED", "GENERATING"}) + void 진행중인_면접의_음성은_저장된_프로필로_복구한다(AudioStatus status) { + state.setStatus(status); + recovery.recover(); + verify(states).delete("s", 1L); + verify(scheduler).register(eq("s"), any(), eq(session.get().getTtsProfiles().get(2L)), eq(1), eq(0)); + verifyNoInteractions(profiles); + } + + @Test + void 세션이나_질문이_없으면_남은_음성상태만_정리한다() { + session.set(null); + recovery.recover(); + verify(states).delete("s", 1L); + verifyNoInteractions(scheduler); + clearInvocations(states); + session.set(ChatInterviewSession.builder().sessionId("s").build()); + recovery.recover(); + verify(states).delete("s", 1L); + verifyNoInteractions(scheduler); + } + + @Test + void 잠금_획득전에_종료된_면접도_복구하지_않는다() { + var acquired = Sinks.empty(); + when(lock.execute(anyString(), any())).thenAnswer(call -> acquired.asMono() + .then(Mono.defer(() -> ((Supplier>) call.getArgument(1)).get()))); + recovery.recover(); + verify(values, never()).get(anyString()); + session.get().setStatus(InterviewStatus.ABANDONED); + acquired.tryEmitEmpty(); + verify(states).delete("s", 1L); + verifyNoInteractions(scheduler, profiles); + } + + @Test + void 이전_복구_실패가_다른_세션의_복구를_막지_않는다() { + var failed = AudioState.builder().sessionId("failed").questionId(9L).status(AudioStatus.QUEUED).build(); + when(states.findRecoverable()).thenReturn(Flux.just(failed, state)); + when(values.get("chat:interview:failed")).thenReturn(Mono.error(new IllegalStateException("read failed"))); + recovery.recover(); + verify(scheduler).register(eq("s"), any(), any(), eq(1), eq(0)); + verify(states, never()).delete("failed", 9L); + } +} From 1c7c997336a3ee9e30e42b30082ebc5c85098f6c Mon Sep 17 00:00:00 2001 From: 2-dayeon Date: Wed, 21 Oct 2026 23:35:19 +0900 Subject: [PATCH 11/13] =?UTF-8?q?fix:=20=EB=A9=B4=EC=A0=91=20=EC=9D=8C?= =?UTF-8?q?=EC=84=B1=20=EB=B3=B5=EA=B5=AC=20=EC=9E=A0=EA=B8=88=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repit/tts/service/TtsRecoveryService.java | 7 +- .../service/TtsRecoveryConcurrencyTest.java | 76 +++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 src/test/java/com/person/repit/tts/service/TtsRecoveryConcurrencyTest.java diff --git a/src/main/java/com/person/repit/tts/service/TtsRecoveryService.java b/src/main/java/com/person/repit/tts/service/TtsRecoveryService.java index b086ec4..37c893a 100644 --- a/src/main/java/com/person/repit/tts/service/TtsRecoveryService.java +++ b/src/main/java/com/person/repit/tts/service/TtsRecoveryService.java @@ -31,8 +31,11 @@ public class TtsRecoveryService { @EventListener(ApplicationReadyEvent.class) public void recover() { stateRepository.findRecoverable() - .flatMap(this::recoverState) - .onErrorContinue((error, ignored) -> log.warn("TTS 작업 복구 실패", error)) + .concatMap(state -> recoverState(state) + .onErrorResume(error -> { + log.warn("TTS 작업 복구 실패", error); + return Mono.empty(); + })) .subscribe(); } diff --git a/src/test/java/com/person/repit/tts/service/TtsRecoveryConcurrencyTest.java b/src/test/java/com/person/repit/tts/service/TtsRecoveryConcurrencyTest.java new file mode 100644 index 0000000..90c0feb --- /dev/null +++ b/src/test/java/com/person/repit/tts/service/TtsRecoveryConcurrencyTest.java @@ -0,0 +1,76 @@ +package com.person.repit.tts.service; + +import com.person.repit.interview.domain.ChatInterviewSession; +import com.person.repit.interview.domain.ChatQuestion; +import com.person.repit.interview.service.InterviewOperationLock; +import com.person.repit.tts.domain.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.data.redis.core.*; +import org.springframework.data.redis.core.script.RedisScript; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@SuppressWarnings("unchecked") +class TtsRecoveryConcurrencyTest { + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void 같은_면접의_음성을_잠금_충돌_없이_복구하고_개별_실패_후에도_계속한다(boolean failFirst) { + var redis = mock(ReactiveRedisTemplate.class); + var values = mock(ReactiveValueOperations.class); + var stringRedis = mock(ReactiveStringRedisTemplate.class); + var stringValues = mock(ReactiveValueOperations.class); + var held = new ConcurrentHashMap(); + when(stringRedis.opsForValue()).thenReturn(stringValues); + when(stringValues.setIfAbsent(anyString(), anyString(), any(Duration.class))) + .thenAnswer(call -> Mono.fromSupplier(() -> + held.putIfAbsent(call.getArgument(0), call.getArgument(1)) == null)); + when(stringRedis.execute(any(RedisScript.class), anyList(), anyList())) + .thenAnswer(call -> Flux.defer(() -> { + List keys = call.getArgument(1); + List tokens = call.getArgument(2); + return Flux.just(held.remove(keys.getFirst(), tokens.getFirst()) ? 1L : 0L); + })); + var states = mock(AudioStateRepository.class); + var scheduler = mock(InterviewTtsScheduler.class); + var profiles = mock(PersonaTtsProfileResolver.class); + var questions = List.of(1L, 2L, 3L).stream().map(id -> + ChatQuestion.builder().questionId(id).askedByPersonaId(2L).build()).toList(); + var session = ChatInterviewSession.builder().sessionId("s").questions(questions) + .ttsProfiles(Map.of(2L, TtsProfile.builder().voiceId("stored-voice").build())).build(); + var pendingRead = Sinks.one(); + var reads = new AtomicInteger(); + when(redis.opsForValue()).thenReturn(values); + when(values.get("chat:interview:s")).thenReturn(Mono.defer(() -> { + if (reads.getAndIncrement() == 0 && failFirst) { + return Mono.error(new IllegalStateException("read failed")); + } + return pendingRead.asMono(); + })); + when(states.findRecoverable()).thenReturn(Flux.fromIterable(questions).map(question -> + AudioState.builder().sessionId("s").questionId(question.getQuestionId()) + .status(AudioStatus.QUEUED).build())); + when(states.delete(anyString(), anyLong())).thenReturn(Mono.empty()); + when(scheduler.register(anyString(), any(), any(), anyInt(), anyInt())) + .thenReturn(Mono.just(AudioState.builder().build())); + + new TtsRecoveryService(redis, states, scheduler, profiles, + new InterviewOperationLock(stringRedis)).recover(); + pendingRead.tryEmitValue(session); + + verify(scheduler, times(failFirst ? 2 : 3)).register(eq("s"), any(), any(), eq(1), anyInt()); + if (failFirst) { + verify(states, never()).delete("s", 1L); + } + } +} From 21321dc991a6c85fb95918d608f437003a33d077 Mon Sep 17 00:00:00 2001 From: 2-dayeon Date: Wed, 21 Oct 2026 23:52:26 +0900 Subject: [PATCH 12/13] =?UTF-8?q?fix:=20=EC=9E=A0=EA=B8=B4=20=EB=A9=B4?= =?UTF-8?q?=EC=A0=91=20=EC=9D=8C=EC=84=B1=20=EB=B3=B5=EA=B5=AC=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=9C=EB=8F=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repit/tts/service/TtsRecoveryService.java | 6 + .../tts/service/TtsRecoveryLockRetryTest.java | 111 ++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 src/test/java/com/person/repit/tts/service/TtsRecoveryLockRetryTest.java diff --git a/src/main/java/com/person/repit/tts/service/TtsRecoveryService.java b/src/main/java/com/person/repit/tts/service/TtsRecoveryService.java index 37c893a..8725073 100644 --- a/src/main/java/com/person/repit/tts/service/TtsRecoveryService.java +++ b/src/main/java/com/person/repit/tts/service/TtsRecoveryService.java @@ -1,5 +1,6 @@ package com.person.repit.tts.service; +import com.person.repit.common.exception.AlreadyUsingException; import com.person.repit.interview.domain.ChatInterviewSession; import com.person.repit.interview.domain.ChatQuestion; import com.person.repit.interview.domain.PersonaSnapshot; @@ -15,6 +16,9 @@ import org.springframework.data.redis.core.ReactiveRedisTemplate; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; +import reactor.util.retry.Retry; + +import java.time.Duration; @Slf4j @Component @@ -32,6 +36,8 @@ public class TtsRecoveryService { public void recover() { stateRepository.findRecoverable() .concatMap(state -> recoverState(state) + .retryWhen(Retry.fixedDelay(Long.MAX_VALUE, Duration.ofSeconds(1)) + .filter(AlreadyUsingException.class::isInstance)) .onErrorResume(error -> { log.warn("TTS 작업 복구 실패", error); return Mono.empty(); diff --git a/src/test/java/com/person/repit/tts/service/TtsRecoveryLockRetryTest.java b/src/test/java/com/person/repit/tts/service/TtsRecoveryLockRetryTest.java new file mode 100644 index 0000000..d51b305 --- /dev/null +++ b/src/test/java/com/person/repit/tts/service/TtsRecoveryLockRetryTest.java @@ -0,0 +1,111 @@ +package com.person.repit.tts.service; + +import com.person.repit.interview.domain.ChatInterviewSession; +import com.person.repit.interview.domain.ChatQuestion; +import com.person.repit.interview.service.InterviewOperationLock; +import com.person.repit.interview.type.InterviewStatus; +import com.person.repit.tts.domain.AudioState; +import com.person.repit.tts.domain.AudioStatus; +import com.person.repit.tts.domain.TtsProfile; +import org.junit.jupiter.api.AfterEach; +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.ReactiveStringRedisTemplate; +import org.springframework.data.redis.core.ReactiveValueOperations; +import org.springframework.data.redis.core.script.RedisScript; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.scheduler.VirtualTimeScheduler; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@SuppressWarnings("unchecked") +class TtsRecoveryLockRetryTest { + private static final String LOCK_KEY = "chat:interview:operation-lock:s"; + private final ReactiveRedisTemplate redis = mock(ReactiveRedisTemplate.class); + private final ReactiveValueOperations values = mock(ReactiveValueOperations.class); + private final ReactiveStringRedisTemplate lockRedis = mock(ReactiveStringRedisTemplate.class); + private final ReactiveValueOperations lockValues = mock(ReactiveValueOperations.class); + private final AudioStateRepository states = mock(AudioStateRepository.class); + private final InterviewTtsScheduler scheduler = mock(InterviewTtsScheduler.class); + private final PersonaTtsProfileResolver profiles = mock(PersonaTtsProfileResolver.class); + private final Map held = new ConcurrentHashMap<>(); + private VirtualTimeScheduler clock; + private ChatInterviewSession session; + private TtsRecoveryService recovery; + + @BeforeEach + void setUp() { + clock = VirtualTimeScheduler.getOrSet(); + when(lockRedis.opsForValue()).thenReturn(lockValues); + when(lockValues.setIfAbsent(anyString(), anyString(), any(Duration.class))) + .thenAnswer(call -> Mono.fromSupplier(() -> + held.putIfAbsent(call.getArgument(0), call.getArgument(1)) == null)); + when(lockRedis.execute(any(RedisScript.class), anyList(), anyList())) + .thenAnswer(call -> Flux.defer(() -> { + List keys = call.getArgument(1); + List tokens = call.getArgument(2); + return Flux.just(held.remove(keys.getFirst(), tokens.getFirst()) ? 1L : 0L); + })); + var questions = List.of(1L, 2L, 3L).stream().map(id -> + ChatQuestion.builder().questionId(id).askedByPersonaId(2L).build()).toList(); + session = ChatInterviewSession.builder().sessionId("s").questions(questions) + .ttsProfiles(Map.of(2L, TtsProfile.builder().voiceId("stored-voice").build())).build(); + when(redis.opsForValue()).thenReturn(values); + when(values.get("chat:interview:s")).thenReturn(Mono.fromSupplier(() -> session)); + when(states.findRecoverable()).thenReturn(Flux.fromIterable(questions).map(question -> + AudioState.builder().sessionId("s").questionId(question.getQuestionId()) + .status(AudioStatus.QUEUED).build())); + when(states.delete(anyString(), anyLong())).thenReturn(Mono.empty()); + when(scheduler.register(anyString(), any(), any(), anyInt(), anyInt())) + .thenReturn(Mono.just(AudioState.builder().build())); + recovery = new TtsRecoveryService(redis, states, scheduler, profiles, + new InterviewOperationLock(lockRedis)); + held.put(LOCK_KEY, "other-owner"); + } + + @AfterEach + void resetClock() { + VirtualTimeScheduler.reset(); + } + + @Test + void 다른_요청의_잠금이_풀리면_모든_음성을_복구한다() { + recovery.recover(); + clock.advanceTimeBy(Duration.ofSeconds(3)); + verifyNoInteractions(scheduler); + verify(values, never()).get(anyString()); + held.remove(LOCK_KEY); + clock.advanceTimeBy(Duration.ofSeconds(1)); + verify(scheduler, times(3)).register(eq("s"), any(), any(), eq(1), anyInt()); + } + + @Test + void 재시작_전_잠금의_임대시간만큼_기다린_뒤에도_복구한다() { + recovery.recover(); + clock.advanceTimeBy(Duration.ofMinutes(2)); + verifyNoInteractions(scheduler); + held.remove(LOCK_KEY); + clock.advanceTimeBy(Duration.ofSeconds(1)); + verify(scheduler, times(3)).register(eq("s"), any(), any(), eq(1), anyInt()); + } + + @Test + void 잠금_대기_중_종료된_면접은_복구하지_않는다() { + recovery.recover(); + session.setStatus(InterviewStatus.ABANDONED); + held.remove(LOCK_KEY); + clock.advanceTimeBy(Duration.ofSeconds(1)); + verifyNoInteractions(scheduler); + for (long id = 1; id <= 3; id++) { + verify(states).delete("s", id); + } + } +} From 5651bd88e7c39d982b6dc9440429ae6713b71ab8 Mon Sep 17 00:00:00 2001 From: 2-dayeon Date: Wed, 21 Oct 2026 23:52:26 +0900 Subject: [PATCH 13/13] =?UTF-8?q?chore:=20Redis=20=EB=A1=9C=EC=BB=AC=20?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8=20=EC=97=B0=EA=B2=B0=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 2 ++ docs/chat-reliability.md | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index b21b1c7..282399d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,6 +2,8 @@ services: redis: image: redis:7-alpine container_name: repit-redis + ports: + - "127.0.0.1:6379:6379" command: ["redis-server", "--appendonly", "yes", "--appendfsync", "always", "--maxmemory-policy", "noeviction"] volumes: - redis-data:/data diff --git a/docs/chat-reliability.md b/docs/chat-reliability.md index 1cf6466..fc8a52a 100644 --- a/docs/chat-reliability.md +++ b/docs/chat-reliability.md @@ -51,6 +51,10 @@ BLOCKED 작업은 토큰 또는 payload 계약의 원인을 해결한 뒤 기존 ## Redis 영속성 및 배포 compose의 Redis는 named volume과 AOF, `appendfsync always`, `noeviction`을 사용한다. +호스트 포트는 `127.0.0.1:6379`에만 열어 같은 호스트의 로컬 실행에서 접속할 수 있다. +`docker compose up -d redis` 후 기본 프로필로 실행하면 `localhost:6379`를 사용한다. +Compose의 Chat 컨테이너에서 같은 Redis를 사용할 때는 `.env`에 `REDIS_HOST=redis`, +`REDIS_PORT=6379`를 지정한다. 컨테이너의 `localhost`는 Redis 컨테이너를 가리키지 않는다. 기존 컨테이너에 볼륨 없이 저장된 데이터를 사용 중이라면 교체 전에 백업·이관해야 한다. `REDIS_HOST`가 외부 Redis를 가리키면 compose의 설정이 적용되지 않으므로 외부 Redis에도 동등한 디스크 영속성·퇴거 방지 설정과 백업이 필요하다.