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
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ services:
ANTHROPIC_MODEL: ${ANTHROPIC_MODEL}
TTS_ENABLED: ${TTS_ENABLED:-false}
ELEVENLABS_API_KEY: ${ELEVENLABS_API_KEY:-}
ELEVENLABS_MAX_IN_MEMORY_SIZE: ${ELEVENLABS_MAX_IN_MEMORY_SIZE:-8388608}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] 기본값 8388608이 세 군데에 있어요

TtsProperties(8 * 1024 * 1024), application.yml(${ELEVENLABS_MAX_IN_MEMORY_SIZE:8388608}), 여기 compose 기본값에 같은 값이 각각 하드코딩돼 있습니다. compose는 빈 문자열이 넘어가지 않도록 기본값이 필요하니 의도된 거라면 그대로 두셔도 됩니다. 기본값을 바꿀 때 세 곳을 함께 바꿔야 한다는 점만 문서에 적어 두면 좋겠어요.

TTS_VOICE_ID_1: ${TTS_VOICE_ID_1:-}
TTS_VOICE_ID_2: ${TTS_VOICE_ID_2:-}
TTS_VOICE_ID_3: ${TTS_VOICE_ID_3:-}
Expand Down
32 changes: 32 additions & 0 deletions docs/interview-answer-troubleshooting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# 녹음 저장 후 다음 질문으로 넘어가지 않을 때

## 저장 응답과 면접 진행

`POST /api/interviews/{id}/recordings`의 201은 녹음 파일 저장 결과다.
Chat의 답변 제출과는 별도이므로 이 응답만으로 다음 질문이 반환되었다고 판단하지 않는다.
Chat은 답변 제출이 접수되어야 세션에 텍스트를 저장하고 다음 질문을 반환한다.
빈 답변을 성공 처리하거나 질문을 자동으로 건너뛰지는 않는다.

## 확인 순서

1. 브라우저 Network에서 `POST /chat/interviews/{sessionId}/answers`의 요청 유무를 확인한다.
2. 요청이 있다면 상태 코드와 응답 본문의 다음 질문 또는 오류를 확인한다.
3. Chat의 `[ANSWER ACCEPTED]` 로그에서 세션·면접·질문 식별자를 확인한다.
4. 요청 자체가 없다면 녹음 종료 후 음성 인식 결과와 화면 오류를 확인한다.
5. 동일 질문에 녹음만 반복 저장되었다면 각 시도의 답변 제출 여부를 따로 확인한다.

`answer: null`인 최종 결과만으로 네트워크·브라우저·음성 인식 중 어느 원인인지 확정하지 않는다.
답변 내용이나 인증 토큰을 진단 로그에 추가하지 않는다.

## Web 동작을 확인할 때

2026-10-19 조사 시 Web `dev`의 `useInterviewSession.ts`는 음성 인식 텍스트가
빈 문자열이면 인식 실패를 표시하고 Chat 제출을 중단했다.
이는 Chat 저장소가 보장하는 계약이 아니라 다른 저장소의 특정 시점 관찰이다.
Web 변경 또는 배포 버전 차이가 있으면 아래 코드와 실제 브라우저 동작을 다시 대조한다.

- [Web 답변 제출 흐름](https://github.com/Alpha-origin/Repit-web/blob/dev/src/features/interview-page/interview/model/useInterviewSession.ts)
- [Web 음성 인식 처리](https://github.com/Alpha-origin/Repit-web/blob/dev/src/features/interview-page/interview/model/useVoiceAnswer.ts)

질문 음성의 수신 오류는 [TTS 수신 한도](tts-audio-buffer.md)를 참고한다.
TTS 오류 수정이 브라우저 음성 인식 실패를 해결하는 것은 아니다.
52 changes: 52 additions & 0 deletions docs/tts-audio-buffer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# ElevenLabs 질문 음성 수신 한도

## 문제와 변경 범위

ElevenLabs 음성 응답은 `ElevenLabsTtsClient`에서 `byte[]`로 모은 뒤 기존 저장 경로로 넘긴다.
전용 WebClient에 별도 수신 한도가 없어 256KiB를 넘는 응답은
`DataBufferLimitException`으로 실패했다. 로컬 HTTP 서버의 320KiB 응답으로 재현한다.

전용 WebClient의 수신 한도를 기본 8MiB로 설정한다. 다른 WebClient에는 적용하지 않는다.
기존 `byte[]` 합성·저장 인터페이스, 타임아웃, 큐 동시성, 오디오 응답 계약은 유지한다.
TTS가 켜져 있으면 0 이하 또는 2147483647바이트를 넘는 설정은 기동 시 거부한다.
TTS가 꺼져 있으면 버퍼 설정 대신 안전한 8MiB 기본값을 사용해 전체 서버의 기동을 막지 않는다.
형식이 잘못된 값(예: `invalid`)은 설정 바인딩 단계에서 거부한다.
한도를 초과하면 재시도 불가능한 `TtsClientException`으로 변환하여
동일한 음성 합성을 반복 요청하지 않는다. 오디오 상태는 FAILED이고,
저장·조회 응답의 `errorCode`는 `TTS_AUDIO_TOO_LARGE`다. 다른 합성 실패는 기존
`TTS_GENERATION_FAILED`, 업로드 실패는 `AUDIO_STORAGE_FAILED`를 유지한다.

## 설정과 배포

| 항목 | 값 |
| --- | --- |
| 설정 키 | `tts.elevenlabs.max-in-memory-size` |
| 환경변수 | `ELEVENLABS_MAX_IN_MEMORY_SIZE` |
| 기본값 | `8388608` 바이트 (8MiB) |
| 허용값 | `8388608`, `8MB`, `512KB` 등 DataSize 형식 |
| 활성화 시 범위 | 1~2147483647바이트 |

Docker Compose도 이 환경변수를 Chat 컨테이너에 전달한다.
설정을 바꾼 뒤 컨테이너를 재생성해야 적용된다.
기본값은 `TtsProperties.ElevenLabs.DEFAULT_MAX_IN_MEMORY_SIZE`, `application.yml`,
`docker-compose.yml` 세 곳에 있으므로 기본값 변경 시 함께 수정한다.
합성 전역 동시성 기본값 4에서 오디오 바이트 배열만 최대 약 32MiB를 사용하며,
수신 버퍼·업로드·런타임 메모리는 별도로 필요하다. 동시성과 한도를 함께 고려한다.
배포 뒤 새로 준비한 면접의 긴 질문에서 오디오 READY와 재생을 확인한다.
이미 FAILED로 저장된 오디오는 이 변경만으로 자동 재생성되지 않는다.

## 클라이언트 수명과 공통 설정

Boot가 제공한 `WebClient.Builder`를 복제하므로 공통 커스터마이저를 유지하면서
ElevenLabs 전용 URL·코덱·커넥터를 설정한다. 원본 빌더와 다른 클라이언트는 바꾸지 않는다.
`elevenLabsConnectionProvider`는 Spring 빈으로 관리하고 컨텍스트 종료 시 dispose한다.
HTTP 테스트도 사용한 풀을 명시적으로 정리한다.

녹음 201 이후 면접 진행 문제는 [답변 제출 진단](interview-answer-troubleshooting.md)을 참고한다.

## 검증

`./gradlew test`로 기본 한도 초과였던 오디오의 원본 바이트 수신,
설정 한도 경계·초과, 단위 바인딩, TTS 활성화별 설정 검증, 풀 종료,
원본 빌더 격리, 오류 코드 전달, 기존 HTTP·빈 오디오 실패 처리를 검증한다.
실제 ElevenLabs 호출·유료 사용·운영 배포 없이 로컬 HTTP 서버를 사용한다.
Original file line number Diff line number Diff line change
@@ -1,33 +1,52 @@
package com.person.repit.tts.config;

import io.netty.channel.ChannelOption;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.util.Assert;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient;
import reactor.netty.resources.ConnectionProvider;

@Configuration
@Configuration(proxyBeanMethods = false)
public class ElevenLabsWebClientConfig {

@Bean(name = "elevenLabsConnectionProvider", destroyMethod = "dispose")
public ConnectionProvider elevenLabsConnectionProvider(TtsProperties properties) {
TtsProperties.Pool pool = properties.getElevenlabs().getPool();
return ConnectionProvider.builder("elevenlabs")
.maxConnections(pool.getMaxConnections())
.pendingAcquireMaxCount(pool.getPendingAcquireMaxCount())
.build();
}

@Bean("elevenLabsWebClient")
public WebClient elevenLabsWebClient(TtsProperties properties) {
public WebClient elevenLabsWebClient(
WebClient.Builder webClientBuilder,
@Qualifier("elevenLabsConnectionProvider") ConnectionProvider provider,
TtsProperties properties
) {
TtsProperties.ElevenLabs config = properties.getElevenlabs();
ConnectionProvider provider = ConnectionProvider.builder("elevenlabs")
.maxConnections(config.getPool().getMaxConnections())
.pendingAcquireMaxCount(config.getPool().getPendingAcquireMaxCount())
.build();
var size = properties.isEnabled()
? config.getMaxInMemorySize() : TtsProperties.ElevenLabs.DEFAULT_MAX_IN_MEMORY_SIZE;
Assert.notNull(size, "tts.elevenlabs.max-in-memory-size는 필수입니다");
long bytes = size.toBytes();
Assert.isTrue(bytes > 0 && bytes <= Integer.MAX_VALUE,
"tts.elevenlabs.max-in-memory-size는 1~2147483647바이트여야 합니다");
int maxInMemorySize = Math.toIntExact(bytes);
HttpClient httpClient = HttpClient.create(provider)
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS,
Math.toIntExact(config.getConnectTimeout().toMillis()))
.responseTimeout(config.getResponseTimeout());

return WebClient.builder()
return webClientBuilder.clone()
.baseUrl(config.getBaseUrl())
.clientConnector(new ReactorClientHttpConnector(httpClient))
.codecs(codecs -> codecs.defaultCodecs().maxInMemorySize(maxInMemorySize))
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT, "audio/mpeg")
.build();
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/com/person/repit/tts/config/TtsProperties.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import org.springframework.util.unit.DataSize;

import java.time.Duration;
import java.util.EnumMap;
Expand Down Expand Up @@ -42,12 +43,14 @@ public static class SpokenText {
@Getter
@Setter
public static class ElevenLabs {
public static final DataSize DEFAULT_MAX_IN_MEMORY_SIZE = DataSize.ofMegabytes(8);
private String baseUrl = "https://api.elevenlabs.io";
private String apiKey = "";
private String modelId = "eleven_multilingual_v2";
private String outputFormat = "mp3_44100_128";
private Duration connectTimeout = Duration.ofSeconds(3);
private Duration responseTimeout = Duration.ofSeconds(20);
private DataSize maxInMemorySize = DEFAULT_MAX_IN_MEMORY_SIZE;
private Pool pool = new Pool();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.person.repit.tts.config.TtsProperties;
import com.person.repit.tts.domain.TtsInput;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.io.buffer.DataBufferLimitException;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.ClientResponse;
Expand Down Expand Up @@ -59,6 +60,10 @@ private Mono<byte[]> readAudio(ClientResponse response) {
));
}
return response.bodyToMono(byte[].class)
.onErrorMap(DataBufferLimitException.class, error -> new TtsClientException(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[제안] 한도 초과를 다른 실패와 구분할 수 있으면 좋겠어요

재시도하지 않게 바꾼 건 좋습니다. 이전에는 TtsClientException이 아니어서 스케줄러가 같은 합성을 2번 더 요청했죠.

다만 InterviewTtsScheduler.completeFailed에서는 이 경우도 TTS_GENERATION_FAILED로 저장됩니다. 그래서 상태나 메트릭만으로는 ElevenLabs 4xx, 빈 오디오와 구분되지 않고, 한도를 낮게 잡아 FAILED가 반복되면 WARN 로그 메시지를 직접 봐야 원인을 알 수 있어요. 예외에 에러 코드(예: TTS_AUDIO_TOO_LARGE)를 실어 스케줄러가 그대로 쓰게 하면 운영에서 바로 보입니다.

"ElevenLabs 오디오가 수신 한도를 초과했습니다: "
+ properties.getElevenlabs().getMaxInMemorySize().toBytes() + "바이트",
false, "TTS_AUDIO_TOO_LARGE", error))
.filter(bytes -> bytes.length > 0)
.switchIfEmpty(Mono.error(
new TtsClientException("ElevenLabs 오디오가 비어 있습니다", false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,9 @@ private Mono<Void> completeFailed(TtsJob job, AudioState state, Throwable error)
}
state.setStatus(AudioStatus.FAILED);
state.setErrorCode(error instanceof AudioStorageException
? "AUDIO_STORAGE_FAILED" : "TTS_GENERATION_FAILED");
? "AUDIO_STORAGE_FAILED"
: error instanceof TtsClientException clientError
? clientError.getErrorCode() : "TTS_GENERATION_FAILED");
state.setUpdatedAt(Instant.now());
log.warn("TTS 작업 실패 sessionId={}, questionId={}",
job.getSessionId(), job.getQuestionId(), error);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,27 @@

public class TtsClientException extends RuntimeException {
private final boolean retryable;
private final String errorCode;

public TtsClientException(String message, boolean retryable) {
super(message);
this(message, retryable, null);
}

public TtsClientException(String message, boolean retryable, Throwable cause) {
this(message, retryable, "TTS_GENERATION_FAILED", cause);
}

public TtsClientException(String message, boolean retryable, String errorCode, Throwable cause) {
super(message, cause);
this.retryable = retryable;
this.errorCode = errorCode;
}

public boolean isRetryable() {
return retryable;
}

public String getErrorCode() {
return errorCode;
}
}
1 change: 1 addition & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ tts:
output-format: mp3_44100_128
connect-timeout: 3s
response-timeout: 20s
max-in-memory-size: ${ELEVENLABS_MAX_IN_MEMORY_SIZE:8388608}
pool:
max-connections: 8
pending-acquire-max-count: 16
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package com.person.repit.tts.config;

import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.http.HttpStatus;
import org.springframework.util.unit.DataSize;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.ClientRequest;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import reactor.netty.resources.ConnectionProvider;

import java.net.InetSocketAddress;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class ElevenLabsWebClientConfigTest {

@ParameterizedTest
@ValueSource(longs = {0, -1, 2147483648L})
void TTS가_켜져_있으면_잘못된_수신_한도로_기동하지_않는다(long bytes) {
var properties = properties(true, bytes);
try (var context = context(properties, WebClient.builder())) {
assertThatThrownBy(context::refresh).hasRootCauseInstanceOf(IllegalArgumentException.class);
}
}

@ParameterizedTest
@ValueSource(longs = {0, -1, 2147483648L})
void TTS가_꺼져_있으면_잘못된_수신_한도로_서버_기동을_막지_않는다(long bytes) {
var properties = properties(false, bytes);
try (var context = context(properties, WebClient.builder())) {
context.refresh();
assertThat(context.getBean("elevenLabsWebClient")).isInstanceOf(WebClient.class);
}
}

@Test
void TTS_클라이언트는_주입된_빌더의_설정을_유지하고_원본을_바꾸지_않는다() {
List<ClientRequest> requests = new ArrayList<>();
var builder = WebClient.builder().baseUrl("http://original.test")
.defaultHeader("X-Customizer", "retained")
.exchangeFunction(request -> {
requests.add(request);
return Mono.just(ClientResponse.create(HttpStatus.OK).body("ok").build());
});
var properties = properties(true, 1024);
properties.getElevenlabs().setBaseUrl("http://tts.test");

try (var context = context(properties, builder)) {
context.refresh();
var client = context.getBean("elevenLabsWebClient", WebClient.class);
client.get().uri("/probe").retrieve().bodyToMono(String.class).block(Duration.ofSeconds(5));
builder.build().get().uri("/probe").retrieve().bodyToMono(String.class).block(Duration.ofSeconds(5));
}

assertThat(requests).hasSize(2);
assertThat(requests.getFirst().headers().getFirst("X-Customizer")).isEqualTo("retained");
assertThat(requests.getFirst().url().getHost()).isEqualTo("tts.test");
assertThat(requests.getLast().url().getHost()).isEqualTo("original.test");
assertThat(requests.getLast().headers().getAccept()).isEmpty();
}

@Test
void 컨텍스트가_닫히면_실제_HTTP_연결을_사용한_풀을_정리한다() throws Exception {
var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/probe", exchange -> {
try (exchange) {
exchange.sendResponseHeaders(204, -1);
}
});
server.start();
try {
var properties = properties(true, 1024);
properties.getElevenlabs().setBaseUrl("http://127.0.0.1:" + server.getAddress().getPort());
ConnectionProvider provider;
try (var context = context(properties, WebClient.builder())) {
context.refresh();
provider = context.getBean("elevenLabsConnectionProvider", ConnectionProvider.class);
context.getBean("elevenLabsWebClient", WebClient.class).get().uri("/probe")
.retrieve().toBodilessEntity().block(Duration.ofSeconds(5));
}
assertThat(provider.isDisposed()).isTrue();
} finally {
server.stop(0);
}
}

private TtsProperties properties(boolean enabled, long bytes) {
var properties = new TtsProperties();
properties.setEnabled(enabled);
properties.getElevenlabs().setMaxInMemorySize(DataSize.ofBytes(bytes));
return properties;
}

private AnnotationConfigApplicationContext context(TtsProperties properties, WebClient.Builder builder) {
var context = new AnnotationConfigApplicationContext();
context.registerBean(TtsProperties.class, () -> properties);
context.registerBean(WebClient.Builder.class, () -> builder);
context.register(ElevenLabsWebClientConfig.class);
return context;
}
}
Loading