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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docker-compose.loadtest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ services:
image: redis:7-alpine
container_name: repit-redis
ports:
- "6379:6379"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redis 포트 접근을 '제한'한 게 아니라 아예 '제거'했습니다

커밋 메시지는 "접근 범위 제한"인데, 실제로는 호스트 포트 매핑이 통째로 빠졌습니다. 부하 테스트 compose 쪽은 127.0.0.1:6380:6379로 바인딩 범위만 줄였습니다. 이렇게 되면 아래 두 경우가 깨집니다.

  • 운영 .env의 REDIS_HOST가 compose 서비스 이름(redis)이 아니라 호스트 주소를 가리키면, chat 컨테이너가 Redis에 연결하지 못합니다.
  • 이 compose로 Redis만 띄우고 기본 프로필(localhost:6379)로 bootRun 하던 로컬 실행도 연결되지 않습니다.

외부 노출만 막는 게 목적이라면 "127.0.0.1:6379:6379"로 바인딩하면 기존 사용처가 깨지지 않습니다. 운영 .env의 REDIS_HOST 값도 한 번 확인 부탁드립니다.

- "127.0.0.1:6379:6379"
command: ["redis-server", "--appendonly", "yes", "--appendfsync", "always", "--maxmemory-policy", "noeviction"]
volumes:
- redis-data:/data
Expand All @@ -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}
Expand Down
4 changes: 4 additions & 0 deletions docs/chat-reliability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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에도
동등한 디스크 영속성·퇴거 방지 설정과 백업이 필요하다.
Expand Down
5 changes: 5 additions & 0 deletions load-test/k6/lib/interview.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,6 +23,13 @@ public class InterviewResultDeliveryService {
private final InterviewResultRetryPolicy policy;
private final MeterRegistry meters;

public Mono<Void> ensureNotFinalized(String sessionId) {
return outbox.find(sessionId)
.flatMap(item -> Mono.<Void>error(new InterviewSessionAlreadyExistsException(
"이미 종료 기록이 등록된 면접 세션입니다.")))
.then();
}

public Mono<ChatProgressResponse> resume(String sessionId) {
return outbox.find(sessionId).flatMap(this::requireTerminalStatus).flatMap(item ->
item.getState() == InterviewResultDelivery.State.BLOCKED
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,15 @@ public class ChatInterviewServiceImpl implements ChatInterviewService {

@Override
public Mono<ChatInterviewResponse> prepareInterview(ChatInterviewPrepareRequest request) {
return operationLock.execute(request.getSessionId(), () ->
resultDelivery.ensureNotFinalized(request.getSessionId()).then(prepareOnce(request)));
}

private Mono<ChatInterviewResponse> 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());
Expand Down Expand Up @@ -111,7 +119,7 @@ public Mono<ChatInterviewResponse> prepareInterview(ChatInterviewPrepareRequest
.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)
Expand Down Expand Up @@ -180,7 +188,17 @@ private Mono<ChatProgressResponse> 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(
"현재 질문과 요청한 질문이 일치하지 않습니다."
));
Expand All @@ -195,14 +213,8 @@ private Mono<ChatProgressResponse> 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={}",
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/com/person/repit/tts/domain/TtsJob.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public class TtsJob implements Comparable<TtsJob> {
private final long sequence;
private final TtsInput input;
private final Sinks.One<AudioState> sink = Sinks.one();
private final Sinks.Empty<Void> cancellation = Sinks.empty();
private volatile int priority;
private volatile boolean cancelled;

Expand Down Expand Up @@ -40,6 +41,8 @@ public void changePriority(int priority) {

public void cancel() {
cancelled = true;
cancellation.tryEmitEmpty();
sink.tryEmitEmpty();
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
24 changes: 19 additions & 5 deletions src/main/java/com/person/repit/tts/service/TtsRecoveryService.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
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;
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;
Expand All @@ -13,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
Expand All @@ -24,23 +30,31 @@ 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() {
stateRepository.findRecoverable()
.flatMap(this::recoverState)
.onErrorContinue((error, ignored) -> log.warn("TTS 작업 복구 실패", error))
.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();
}))
.subscribe();
}

private Mono<Void> recoverState(AudioState state) {
return redisTemplate.opsForValue().get(SESSION_KEY_PREFIX + state.getSessionId())
return operationLock.execute(state.getSessionId(), () ->

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

같은 세션의 음성 상태가 여러 개면 재시작 복구 때 하나만 복구됩니다

recover()는 flatMap(this::recoverState)로 상태들을 동시에 처리하는데, 이제 상태마다 세션 잠금을 잡습니다. InterviewOperationLock은 기다리지 않고 바로 AlreadyUsingException을 내므로, 같은 세션의 상태가 여러 개면 처음 잠금을 잡은 하나만 복구되고 나머지는 onErrorContinue에서 "TTS 작업 복구 실패" 경고만 남기고 버려집니다.

준비 시점에 registerOriginalQuestions가 원질문 전부를 등록하고 perSessionMax 때문에 대부분 QUEUED로 남아 있어서, 재시작 시 진행 중 세션에는 보통 이런 상태가 여러 개 있습니다. 복구되지 못한 상태는 Redis에 QUEUED/GENERATING으로 남고 작업은 없으니, 그 질문이 현재 질문이 되거나 클라이언트가 오디오를 요청할 때에야 합성이 시작됩니다. 즉 재시작 후에는 다음 질문 미리 생성이 사라져 질문마다 음성을 기다리게 됩니다(registerCurrentQuestion의 changePriority(..., 1)도 작업이 없어 효과가 없습니다). 이 PR 전에는 전부 복구됐습니다.

재현: 실제 InterviewOperationLock에 메모리 맵으로 흉내 낸 setIfAbsent를 붙이고 세션 조회에 50ms 지연을 준 뒤, 같은 세션 상태 3개로 recover()를 돌리면 scheduler.register가 1번만 호출되고 나머지 2개는 AlreadyUsingException으로 건너뜁니다.

세션별로 묶어서 한 번만 잠그거나(groupBy(AudioState::getSessionId) 후 세션 단위로 execute), 적어도 concatMap으로 순서대로 처리해야 할 것 같습니다.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 같은 세션의 복구 항목이 잠금 경합으로 누락될 수 있습니다. recover()가 findRecoverable() 결과에 flatMap을 사용하므로 동일 세션의 여러 QUEUED/GENERATING 상태가 병렬 처리됩니다. 각 항목이 여기서 같은 세션 잠금을 각각 요청하지만 InterviewOperationLock은 잠금이 이미 있으면 기다리지 않고 AlreadyUsingException을 반환하고, 바깥 onErrorContinue가 해당 항목을 건너뜁니다. 그 결과 재시작 후 일부 상태는 Redis에 남아도 스케줄러 작업은 복원되지 않아 해당 질문의 TTS가 미리 생성되지 않고 사용자 요청 시점까지 지연됩니다. 세션별로 상태를 묶어 잠금을 한 번만 잡거나, 같은 세션의 항목을 순차 복구하도록 처리해 주세요.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

다른 요청이 잠금을 잡고 있으면 그 상태는 복구되지 않고 그대로 버려집니다

concatMap으로 같은 세션 상태끼리의 충돌은 없어졌지만, 잠금을 다른 쪽이 잡고 있으면 여전히 AlreadyUsingException이 나고, onErrorResume이 WARN 로그만 남긴 채 그 상태를 건너뜁니다. recover()는 ApplicationReadyEvent에서 한 번만 돌기 때문에 다시 시도되지도 않습니다.

재시작 직후에는 이런 충돌이 실제로 생기기 쉽습니다.

  • 죽은 프로세스가 잡고 있던 잠금이 LEASE(2분)가 끝날 때까지 남아 있는 경우
  • 준비 이벤트 시점에는 이미 요청을 받고 있어서, 재접속한 사용자의 답변 제출(AI 꼬리질문 판단 포함)이 잠금을 잡고 있는 경우

이러면 그 질문의 QUEUED/GENERATING 상태가 스케줄러 작업 없이 Redis에 남아서, 음성을 미리 만들어 두지 못합니다. 사용자가 그 질문의 음성을 요청해야 InterviewAudioService가 다시 등록하므로 그만큼 기다리게 됩니다. 반대로 복구가 잠금을 잡은 순간에 들어온 사용자 요청은 ALREADY_IN_USE(409)를 받는데, 이 PR 전에는 없던 충돌입니다.

AlreadyUsingException이 나면 잠깐 기다렸다가 다시 시도하거나, 건너뛴 상태를 나중에 다시 처리하는 경로가 필요해 보입니다.

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<Void> recoverQuestion(ChatInterviewSession session, AudioState state) {
Expand All @@ -54,7 +68,7 @@ private Mono<Void> 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();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.<Void>empty();
var accepted = reactor.core.publisher.Sinks.<Void>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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Mono<?>>) call.getArgument(1)).get());
service = new ChatInterviewServiceImpl(ai, redis, delivery,
lock, new RepitMetrics(new SimpleMeterRegistry()),
mock(PersonaTtsProfileResolver.class), tts);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ void setUp() {
((java.util.function.Supplier<Mono<?>>) 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,
Expand Down
Loading