From 2797108e745151fa5f5bade84a5c22ef1ca553a6 Mon Sep 17 00:00:00 2001 From: 2-dayeon Date: Mon, 19 Oct 2026 13:51:20 +0900 Subject: [PATCH 01/10] =?UTF-8?q?docs:=20TTS=20=EC=98=A4=EB=94=94=EC=98=A4?= =?UTF-8?q?=20=EC=88=98=EC=8B=A0=20=ED=95=9C=EB=8F=84=EC=99=80=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=A0=88=EC=B0=A8=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/tts-audio-buffer.md | 44 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 docs/tts-audio-buffer.md diff --git a/docs/tts-audio-buffer.md b/docs/tts-audio-buffer.md new file mode 100644 index 0000000..38a8744 --- /dev/null +++ b/docs/tts-audio-buffer.md @@ -0,0 +1,44 @@ +# ElevenLabs 질문 음성 수신 한도 + +## 문제와 변경 범위 + +ElevenLabs 음성 응답은 `ElevenLabsTtsClient`에서 `byte[]`로 모은 뒤 기존 저장 경로로 넘긴다. +전용 WebClient에 별도 수신 한도가 없어 256KiB를 넘는 응답은 +`DataBufferLimitException`으로 실패했다. 로컬 HTTP 서버의 320KiB 응답으로 재현한다. + +전용 WebClient의 수신 한도를 기본 8MiB로 설정한다. 다른 WebClient에는 적용하지 않는다. +기존 `byte[]` 합성·저장 인터페이스, 타임아웃, 큐 동시성, 오디오 응답 계약은 유지한다. +무제한 버퍼는 허용하지 않으며 0 이하 설정은 기동 시 거부한다. +한도를 초과하면 재시도 불가능한 `TtsClientException`으로 변환하여 +동일한 음성 합성을 반복 요청하지 않는다. 기존 오디오 실패 상태로 처리한다. + +## 설정과 배포 + +| 항목 | 값 | +| --- | --- | +| 설정 키 | `tts.elevenlabs.max-in-memory-size` | +| 환경변수 | `ELEVENLABS_MAX_IN_MEMORY_SIZE` | +| 기본값 | `8388608` 바이트 (8MiB) | +| 허용값 | 양의 정수 바이트 수 | + +Docker Compose도 이 환경변수를 Chat 컨테이너에 전달한다. +설정을 바꾼 뒤 컨테이너를 재생성해야 적용된다. +합성 전역 동시성 기본값 4에서 오디오 바이트 배열만 최대 약 32MiB를 사용하며, +수신 버퍼·업로드·런타임 메모리는 별도로 필요하다. 동시성과 한도를 함께 고려한다. +배포 뒤 새로 준비한 면접의 긴 질문에서 오디오 READY와 재생을 확인한다. +이미 FAILED로 저장된 오디오는 이 변경만으로 자동 재생성되지 않는다. + +## 녹음 업로드와 다음 질문의 구분 + +`POST /api/interviews/{id}/recordings`의 201은 녹음 파일 저장 결과다. +Chat은 별도 답변 제출이 접수되어야 세션의 답변을 저장하고 다음 질문을 반환한다. +`[ANSWER ACCEPTED]` 로그가 없다면 브라우저의 답변 제출 요청과 응답을 확인한다. +Web은 음성 인식 텍스트가 비어 있으면 답변 인식 실패를 표시하고 제출하지 않는다. +빈 답변을 성공 처리하거나 질문을 자동으로 건너뛰는 변경은 하지 않는다. +TTS 수신 오류 수정이 음성 인식 실패를 해결하는 것은 아니다. + +## 검증 + +`./gradlew test`로 기본 한도 초과였던 오디오의 원본 바이트 수신, +설정 한도 경계·초과, 잘못된 설정, 기존 HTTP·빈 오디오 실패 처리를 검증한다. +실제 ElevenLabs 호출·유료 사용·운영 배포 없이 로컬 HTTP 서버를 사용한다. From d1d4d44faa4b0b3dc6af4a05d96390642c787e64 Mon Sep 17 00:00:00 2001 From: 2-dayeon Date: Mon, 19 Oct 2026 13:51:20 +0900 Subject: [PATCH 02/10] =?UTF-8?q?fix:=20ElevenLabs=20=EC=98=A4=EB=94=94?= =?UTF-8?q?=EC=98=A4=20=EC=88=98=EC=8B=A0=20=EB=B2=84=ED=8D=BC=20=ED=95=9C?= =?UTF-8?q?=EB=8F=84=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 1 + .../person/repit/tts/config/ElevenLabsWebClientConfig.java | 4 ++++ src/main/java/com/person/repit/tts/config/TtsProperties.java | 1 + .../com/person/repit/tts/service/ElevenLabsTtsClient.java | 4 ++++ .../com/person/repit/tts/service/TtsClientException.java | 5 +++++ src/main/resources/application.yml | 1 + 6 files changed, 16 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 1672d1b..d9c84c3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} TTS_VOICE_ID_1: ${TTS_VOICE_ID_1:-} TTS_VOICE_ID_2: ${TTS_VOICE_ID_2:-} TTS_VOICE_ID_3: ${TTS_VOICE_ID_3:-} diff --git a/src/main/java/com/person/repit/tts/config/ElevenLabsWebClientConfig.java b/src/main/java/com/person/repit/tts/config/ElevenLabsWebClientConfig.java index f0aa0cb..3a6b3c9 100644 --- a/src/main/java/com/person/repit/tts/config/ElevenLabsWebClientConfig.java +++ b/src/main/java/com/person/repit/tts/config/ElevenLabsWebClientConfig.java @@ -6,6 +6,7 @@ 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; @@ -16,6 +17,8 @@ public class ElevenLabsWebClientConfig { @Bean("elevenLabsWebClient") public WebClient elevenLabsWebClient(TtsProperties properties) { TtsProperties.ElevenLabs config = properties.getElevenlabs(); + Assert.isTrue(config.getMaxInMemorySize() > 0, + "tts.elevenlabs.max-in-memory-size는 양수여야 합니다"); ConnectionProvider provider = ConnectionProvider.builder("elevenlabs") .maxConnections(config.getPool().getMaxConnections()) .pendingAcquireMaxCount(config.getPool().getPendingAcquireMaxCount()) @@ -28,6 +31,7 @@ public WebClient elevenLabsWebClient(TtsProperties properties) { return WebClient.builder() .baseUrl(config.getBaseUrl()) .clientConnector(new ReactorClientHttpConnector(httpClient)) + .codecs(codecs -> codecs.defaultCodecs().maxInMemorySize(config.getMaxInMemorySize())) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .defaultHeader(HttpHeaders.ACCEPT, "audio/mpeg") .build(); diff --git a/src/main/java/com/person/repit/tts/config/TtsProperties.java b/src/main/java/com/person/repit/tts/config/TtsProperties.java index 8df419e..7fa5918 100644 --- a/src/main/java/com/person/repit/tts/config/TtsProperties.java +++ b/src/main/java/com/person/repit/tts/config/TtsProperties.java @@ -48,6 +48,7 @@ public static class ElevenLabs { private String outputFormat = "mp3_44100_128"; private Duration connectTimeout = Duration.ofSeconds(3); private Duration responseTimeout = Duration.ofSeconds(20); + private int maxInMemorySize = 8 * 1024 * 1024; private Pool pool = new Pool(); } diff --git a/src/main/java/com/person/repit/tts/service/ElevenLabsTtsClient.java b/src/main/java/com/person/repit/tts/service/ElevenLabsTtsClient.java index 1b3be38..32859c1 100644 --- a/src/main/java/com/person/repit/tts/service/ElevenLabsTtsClient.java +++ b/src/main/java/com/person/repit/tts/service/ElevenLabsTtsClient.java @@ -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; @@ -59,6 +60,9 @@ private Mono readAudio(ClientResponse response) { )); } return response.bodyToMono(byte[].class) + .onErrorMap(DataBufferLimitException.class, error -> new TtsClientException( + "ElevenLabs 오디오가 수신 한도를 초과했습니다: " + + properties.getElevenlabs().getMaxInMemorySize() + "바이트", false, error)) .filter(bytes -> bytes.length > 0) .switchIfEmpty(Mono.error( new TtsClientException("ElevenLabs 오디오가 비어 있습니다", false) diff --git a/src/main/java/com/person/repit/tts/service/TtsClientException.java b/src/main/java/com/person/repit/tts/service/TtsClientException.java index 4c5f26d..f5f32cd 100644 --- a/src/main/java/com/person/repit/tts/service/TtsClientException.java +++ b/src/main/java/com/person/repit/tts/service/TtsClientException.java @@ -11,4 +11,9 @@ public TtsClientException(String message, boolean retryable) { public boolean isRetryable() { return retryable; } + + public TtsClientException(String message, boolean retryable, Throwable cause) { + super(message, cause); + this.retryable = retryable; + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a3a570f..ab85ad4 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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 From 0b5bddfd68370eea47a8a23e3dfea090eece13ce Mon Sep 17 00:00:00 2001 From: 2-dayeon Date: Mon, 19 Oct 2026 13:51:20 +0900 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20=EB=8C=80=EC=9A=A9=EB=9F=89=20TTS?= =?UTF-8?q?=20=EC=9D=91=EB=8B=B5=EA=B3=BC=20=EC=88=98=EC=8B=A0=20=ED=95=9C?= =?UTF-8?q?=EB=8F=84=20=ED=9A=8C=EA=B7=80=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tts/service/ElevenLabsTtsClientTest.java | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 src/test/java/com/person/repit/tts/service/ElevenLabsTtsClientTest.java diff --git a/src/test/java/com/person/repit/tts/service/ElevenLabsTtsClientTest.java b/src/test/java/com/person/repit/tts/service/ElevenLabsTtsClientTest.java new file mode 100644 index 0000000..cc8c7e3 --- /dev/null +++ b/src/test/java/com/person/repit/tts/service/ElevenLabsTtsClientTest.java @@ -0,0 +1,148 @@ +package com.person.repit.tts.service; + +import com.person.repit.tts.config.ElevenLabsWebClientConfig; +import com.person.repit.tts.config.TtsProperties; +import com.person.repit.tts.domain.TtsInput; +import com.person.repit.tts.domain.VoiceSettings; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +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.junit.jupiter.params.provider.CsvSource; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; +import org.springframework.core.io.buffer.DataBufferLimitException; +import reactor.test.StepVerifier; + +import java.net.InetSocketAddress; +import java.time.Duration; +import java.util.Arrays; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ElevenLabsTtsClientTest { + private HttpServer server; + private TtsProperties properties; + + @BeforeEach + void setUp() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.start(); + properties = new TtsProperties(); + properties.getElevenlabs().setBaseUrl("http://127.0.0.1:" + server.getAddress().getPort()); + properties.getElevenlabs().setApiKey("test-key"); + } + + @AfterEach + void tearDown() { + server.stop(0); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void 기본_256KiB보다_큰_오디오를_잘리지_않고_읽는다(boolean chunked) { + byte[] audio = new byte[320 * 1024]; + Arrays.fill(audio, (byte) 42); + respond(200, "audio/mpeg", audio, chunked); + + byte[] result = client().synthesize(input()).block(Duration.ofSeconds(5)); + + assertThat(result).isEqualTo(audio); + } + + @Test + void 설정한_한도와_같은_크기의_오디오는_허용한다() { + properties.getElevenlabs().setMaxInMemorySize(64 * 1024); + byte[] audio = new byte[64 * 1024]; + respond(200, "audio/mpeg", audio, false); + + assertThat(client().synthesize(input()).block(Duration.ofSeconds(5))).isEqualTo(audio); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void 설정한_한도를_넘으면_재시도하지_않는_오류로_반환한다(boolean chunked) { + properties.getElevenlabs().setMaxInMemorySize(64 * 1024); + respond(200, "audio/mpeg", new byte[64 * 1024 + 1], chunked); + + StepVerifier.create(client().synthesize(input())) + .expectErrorSatisfies(error -> { + assertThat(error).isInstanceOf(TtsClientException.class) + .hasCauseInstanceOf(DataBufferLimitException.class); + assertThat(((TtsClientException) error).isRetryable()).isFalse(); + }).verify(Duration.ofSeconds(5)); + } + + @ParameterizedTest + @ValueSource(ints = {0, -1}) + void 무제한이나_잘못된_수신_한도를_허용하지_않는다(int limit) { + properties.getElevenlabs().setMaxInMemorySize(limit); + + assertThatThrownBy(this::client).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("max-in-memory-size"); + } + + @Test + void 수신_한도를_설정에서_바인딩한다() { + var binder = new Binder(new MapConfigurationPropertySource(Map.of( + "tts.elevenlabs.max-in-memory-size", "524288"))); + + var bound = binder.bind("tts", Bindable.of(TtsProperties.class)).get(); + + assertThat(bound.getElevenlabs().getMaxInMemorySize()).isEqualTo(524288); + } + + @ParameterizedTest + @CsvSource({"400,false", "429,true", "503,true"}) + void HTTP_오류의_기존_재시도_분류를_유지한다(int status, boolean retryable) { + respond(status, "application/json", new byte[]{1}, false); + + StepVerifier.create(client().synthesize(input())) + .expectErrorSatisfies(error -> { + assertThat(error).isInstanceOf(TtsClientException.class); + assertThat(((TtsClientException) error).isRetryable()).isEqualTo(retryable); + }).verify(Duration.ofSeconds(5)); + } + + @Test + void 오디오가_아닌_응답을_거절한다() { + respond(200, "application/json", new byte[]{1}, false); + + StepVerifier.create(client().synthesize(input())) + .expectError(TtsClientException.class).verify(Duration.ofSeconds(5)); + } + + @Test + void 빈_오디오는_거절한다() { + respond(200, "audio/mpeg", new byte[0], true); + + StepVerifier.create(client().synthesize(input())) + .expectError(TtsClientException.class).verify(Duration.ofSeconds(5)); + } + + private ElevenLabsTtsClient client() { + return new ElevenLabsTtsClient( + new ElevenLabsWebClientConfig().elevenLabsWebClient(properties), properties); + } + + private TtsInput input() { + return new TtsInput("질문", "voice", "model", "mp3_44100_128", + new VoiceSettings(0.5, 0.8, 0.1, 1.0, true), "test-hash"); + } + + private void respond(int status, String contentType, byte[] body, boolean chunked) { + server.createContext("/v1/text-to-speech/voice", exchange -> { + try (exchange) { + exchange.getRequestBody().readAllBytes(); + exchange.getResponseHeaders().set("Content-Type", contentType); + exchange.sendResponseHeaders(status, chunked ? 0 : body.length); + exchange.getResponseBody().write(body); + } + }); + } +} From 450646ce2f8ab6e5c138e8fa880d6da4fa82c45a Mon Sep 17 00:00:00 2001 From: 2-dayeon Date: Mon, 19 Oct 2026 20:04:29 +0900 Subject: [PATCH 04/10] =?UTF-8?q?fix:=20TTS=20=EC=88=98=EC=8B=A0=20?= =?UTF-8?q?=ED=81=AC=EA=B8=B0=20=ED=83=80=EC=9E=85=EA=B3=BC=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=BD=94=EB=93=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../person/repit/tts/config/TtsProperties.java | 4 +++- .../repit/tts/service/ElevenLabsTtsClient.java | 3 ++- .../tts/service/InterviewTtsScheduler.java | 4 +++- .../repit/tts/service/TtsClientException.java | 17 +++++++++++++---- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/person/repit/tts/config/TtsProperties.java b/src/main/java/com/person/repit/tts/config/TtsProperties.java index 7fa5918..e19ab3b 100644 --- a/src/main/java/com/person/repit/tts/config/TtsProperties.java +++ b/src/main/java/com/person/repit/tts/config/TtsProperties.java @@ -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; @@ -42,13 +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 int maxInMemorySize = 8 * 1024 * 1024; + private DataSize maxInMemorySize = DEFAULT_MAX_IN_MEMORY_SIZE; private Pool pool = new Pool(); } diff --git a/src/main/java/com/person/repit/tts/service/ElevenLabsTtsClient.java b/src/main/java/com/person/repit/tts/service/ElevenLabsTtsClient.java index 32859c1..58903db 100644 --- a/src/main/java/com/person/repit/tts/service/ElevenLabsTtsClient.java +++ b/src/main/java/com/person/repit/tts/service/ElevenLabsTtsClient.java @@ -62,7 +62,8 @@ private Mono readAudio(ClientResponse response) { return response.bodyToMono(byte[].class) .onErrorMap(DataBufferLimitException.class, error -> new TtsClientException( "ElevenLabs 오디오가 수신 한도를 초과했습니다: " - + properties.getElevenlabs().getMaxInMemorySize() + "바이트", false, error)) + + properties.getElevenlabs().getMaxInMemorySize().toBytes() + "바이트", + false, "TTS_AUDIO_TOO_LARGE", error)) .filter(bytes -> bytes.length > 0) .switchIfEmpty(Mono.error( new TtsClientException("ElevenLabs 오디오가 비어 있습니다", false) 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 5803261..360b0a9 100644 --- a/src/main/java/com/person/repit/tts/service/InterviewTtsScheduler.java +++ b/src/main/java/com/person/repit/tts/service/InterviewTtsScheduler.java @@ -209,7 +209,9 @@ private Mono 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); diff --git a/src/main/java/com/person/repit/tts/service/TtsClientException.java b/src/main/java/com/person/repit/tts/service/TtsClientException.java index f5f32cd..5702f27 100644 --- a/src/main/java/com/person/repit/tts/service/TtsClientException.java +++ b/src/main/java/com/person/repit/tts/service/TtsClientException.java @@ -2,18 +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 TtsClientException(String message, boolean retryable, Throwable cause) { - super(message, cause); - this.retryable = retryable; + public String getErrorCode() { + return errorCode; } } From 329bbd8e6ee4f1c5c4f8b396934832e3fe5a4787 Mon Sep 17 00:00:00 2001 From: 2-dayeon Date: Mon, 19 Oct 2026 20:04:29 +0900 Subject: [PATCH 05/10] =?UTF-8?q?fix:=20TTS=20=ED=81=B4=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EC=96=B8=ED=8A=B8=20=EC=84=A4=EC=A0=95=EA=B3=BC=20=EC=BB=A4?= =?UTF-8?q?=EB=84=A5=EC=85=98=20=EC=88=98=EB=AA=85=20=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tts/config/ElevenLabsWebClientConfig.java | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/person/repit/tts/config/ElevenLabsWebClientConfig.java b/src/main/java/com/person/repit/tts/config/ElevenLabsWebClientConfig.java index 3a6b3c9..8705478 100644 --- a/src/main/java/com/person/repit/tts/config/ElevenLabsWebClientConfig.java +++ b/src/main/java/com/person/repit/tts/config/ElevenLabsWebClientConfig.java @@ -1,6 +1,7 @@ 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; @@ -11,27 +12,41 @@ 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(); - Assert.isTrue(config.getMaxInMemorySize() > 0, - "tts.elevenlabs.max-in-memory-size는 양수여야 합니다"); - 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(config.getMaxInMemorySize())) + .codecs(codecs -> codecs.defaultCodecs().maxInMemorySize(maxInMemorySize)) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .defaultHeader(HttpHeaders.ACCEPT, "audio/mpeg") .build(); From 3cc9cea631a88c1223a6c5ac8c4af43b001defcf Mon Sep 17 00:00:00 2001 From: 2-dayeon Date: Mon, 19 Oct 2026 20:04:29 +0900 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20TTS=20=ED=81=AC=EA=B8=B0=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=EC=99=80=20=EC=98=A4=EB=A5=98=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=9A=8C=EA=B7=80=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tts/service/ElevenLabsTtsClientTest.java | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/test/java/com/person/repit/tts/service/ElevenLabsTtsClientTest.java b/src/test/java/com/person/repit/tts/service/ElevenLabsTtsClientTest.java index cc8c7e3..1657378 100644 --- a/src/test/java/com/person/repit/tts/service/ElevenLabsTtsClientTest.java +++ b/src/test/java/com/person/repit/tts/service/ElevenLabsTtsClientTest.java @@ -16,6 +16,9 @@ import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; import org.springframework.core.io.buffer.DataBufferLimitException; import reactor.test.StepVerifier; +import reactor.netty.resources.ConnectionProvider; +import org.springframework.util.unit.DataSize; +import org.springframework.web.reactive.function.client.WebClient; import java.net.InetSocketAddress; import java.time.Duration; @@ -28,18 +31,22 @@ class ElevenLabsTtsClientTest { private HttpServer server; private TtsProperties properties; + private ConnectionProvider provider; @BeforeEach void setUp() throws Exception { server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); server.start(); properties = new TtsProperties(); + properties.setEnabled(true); + provider = new ElevenLabsWebClientConfig().elevenLabsConnectionProvider(properties); properties.getElevenlabs().setBaseUrl("http://127.0.0.1:" + server.getAddress().getPort()); properties.getElevenlabs().setApiKey("test-key"); } @AfterEach void tearDown() { + provider.disposeLater().block(Duration.ofSeconds(5)); server.stop(0); } @@ -57,7 +64,7 @@ void tearDown() { @Test void 설정한_한도와_같은_크기의_오디오는_허용한다() { - properties.getElevenlabs().setMaxInMemorySize(64 * 1024); + properties.getElevenlabs().setMaxInMemorySize(DataSize.ofKilobytes(64)); byte[] audio = new byte[64 * 1024]; respond(200, "audio/mpeg", audio, false); @@ -67,7 +74,7 @@ void tearDown() { @ParameterizedTest @ValueSource(booleans = {false, true}) void 설정한_한도를_넘으면_재시도하지_않는_오류로_반환한다(boolean chunked) { - properties.getElevenlabs().setMaxInMemorySize(64 * 1024); + properties.getElevenlabs().setMaxInMemorySize(DataSize.ofKilobytes(64)); respond(200, "audio/mpeg", new byte[64 * 1024 + 1], chunked); StepVerifier.create(client().synthesize(input())) @@ -75,26 +82,28 @@ void tearDown() { assertThat(error).isInstanceOf(TtsClientException.class) .hasCauseInstanceOf(DataBufferLimitException.class); assertThat(((TtsClientException) error).isRetryable()).isFalse(); + assertThat(((TtsClientException) error).getErrorCode()).isEqualTo("TTS_AUDIO_TOO_LARGE"); }).verify(Duration.ofSeconds(5)); } @ParameterizedTest @ValueSource(ints = {0, -1}) void 무제한이나_잘못된_수신_한도를_허용하지_않는다(int limit) { - properties.getElevenlabs().setMaxInMemorySize(limit); + properties.getElevenlabs().setMaxInMemorySize(DataSize.ofBytes(limit)); assertThatThrownBy(this::client).isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("max-in-memory-size"); } - @Test - void 수신_한도를_설정에서_바인딩한다() { + @ParameterizedTest + @CsvSource({"524288,524288", "512KB,524288", "8MB,8388608"}) + void 바이트와_단위가_있는_수신_한도를_설정에서_바인딩한다(String value, long bytes) { var binder = new Binder(new MapConfigurationPropertySource(Map.of( - "tts.elevenlabs.max-in-memory-size", "524288"))); + "tts.elevenlabs.max-in-memory-size", value))); var bound = binder.bind("tts", Bindable.of(TtsProperties.class)).get(); - assertThat(bound.getElevenlabs().getMaxInMemorySize()).isEqualTo(524288); + assertThat(bound.getElevenlabs().getMaxInMemorySize()).isEqualTo(DataSize.ofBytes(bytes)); } @ParameterizedTest @@ -106,6 +115,7 @@ void tearDown() { .expectErrorSatisfies(error -> { assertThat(error).isInstanceOf(TtsClientException.class); assertThat(((TtsClientException) error).isRetryable()).isEqualTo(retryable); + assertThat(((TtsClientException) error).getErrorCode()).isEqualTo("TTS_GENERATION_FAILED"); }).verify(Duration.ofSeconds(5)); } @@ -127,7 +137,7 @@ void tearDown() { private ElevenLabsTtsClient client() { return new ElevenLabsTtsClient( - new ElevenLabsWebClientConfig().elevenLabsWebClient(properties), properties); + new ElevenLabsWebClientConfig().elevenLabsWebClient(WebClient.builder(), provider, properties), properties); } private TtsInput input() { From cab82cf2099a73924f91000c826dd983c527d731 Mon Sep 17 00:00:00 2001 From: 2-dayeon Date: Mon, 19 Oct 2026 20:04:29 +0900 Subject: [PATCH 07/10] =?UTF-8?q?fix:=20TTS=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EA=B2=A9=EB=A6=AC=EC=99=80=20=EC=BB=A4=EB=84=A5=EC=85=98=20?= =?UTF-8?q?=EC=A2=85=EB=A3=8C=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/ElevenLabsWebClientConfigTest.java | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 src/test/java/com/person/repit/tts/config/ElevenLabsWebClientConfigTest.java diff --git a/src/test/java/com/person/repit/tts/config/ElevenLabsWebClientConfigTest.java b/src/test/java/com/person/repit/tts/config/ElevenLabsWebClientConfigTest.java new file mode 100644 index 0000000..4a37578 --- /dev/null +++ b/src/test/java/com/person/repit/tts/config/ElevenLabsWebClientConfigTest.java @@ -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 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; + } +} From 0873c56df8b4137c8183ebf94c74e84e30286e97 Mon Sep 17 00:00:00 2001 From: 2-dayeon Date: Mon, 19 Oct 2026 20:04:29 +0900 Subject: [PATCH 08/10] =?UTF-8?q?fix:=20TTS=20=ED=95=9C=EB=8F=84=20?= =?UTF-8?q?=EC=B4=88=EA=B3=BC=20=EC=BD=94=EB=93=9C=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EA=B3=BC=20=EB=B9=84=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/InterviewTtsSchedulerTest.java | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/person/repit/tts/service/InterviewTtsSchedulerTest.java b/src/test/java/com/person/repit/tts/service/InterviewTtsSchedulerTest.java index 234752e..14dea84 100644 --- a/src/test/java/com/person/repit/tts/service/InterviewTtsSchedulerTest.java +++ b/src/test/java/com/person/repit/tts/service/InterviewTtsSchedulerTest.java @@ -7,18 +7,27 @@ import com.person.repit.tts.domain.TtsInput; import com.person.repit.tts.domain.TtsProfile; import com.person.repit.tts.domain.VoiceSettings; +import com.person.repit.tts.domain.AudioStatus; +import com.person.repit.tts.dto.InterviewAudioResponse; 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 java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.never; class InterviewTtsSchedulerTest { private InterviewTtsScheduler scheduler; + private TtsClient client; + private AudioStateRepository repository; + private AudioStorage storage; @BeforeEach void setUp() { @@ -29,9 +38,9 @@ void setUp() { "질문", "voice", "model", "mp3", new VoiceSettings(0.5, 0.8, 0.1, 1.0, true), "1234567890abcdef" )); - TtsClient client = mock(TtsClient.class); + client = mock(TtsClient.class); when(client.synthesize(any())).thenReturn(Mono.never()); - AudioStateRepository repository = mock(AudioStateRepository.class); + repository = mock(AudioStateRepository.class); when(repository.create(any(AudioState.class))).thenReturn(Mono.just(true)); when(repository.get(any(), any())).thenAnswer(invocation -> Mono.just( AudioState.builder() @@ -42,11 +51,12 @@ void setUp() { )); when(repository.save(any(AudioState.class))).thenReturn(Mono.empty()); + storage = mock(AudioStorage.class); scheduler = new InterviewTtsScheduler( properties, inputBuilder, client, - mock(AudioStorage.class), + storage, repository, new RepitMetrics(new SimpleMeterRegistry()) ); @@ -68,6 +78,27 @@ void setUp() { assertThat(scheduler.queuedCount(2)).isEqualTo(1); } + @Test + void 오디오_한도_초과_코드를_저장하고_조회_응답에_전달하며_재시도하지_않는다() { + var failed = new AtomicReference(); + when(repository.save(any(AudioState.class))).thenAnswer(invocation -> { + AudioState state = invocation.getArgument(0); + if (state.getStatus() == AudioStatus.FAILED) { + failed.set(InterviewAudioResponse.from(state)); + } + return Mono.empty(); + }); + when(client.synthesize(any())).thenReturn(Mono.error(new TtsClientException( + "수신 한도 초과", false, "TTS_AUDIO_TOO_LARGE", null))); + + register("large-audio", 1L, 0); + + assertThat(failed.get()).isNotNull(); + assertThat(failed.get().errorCode()).isEqualTo("TTS_AUDIO_TOO_LARGE"); + verify(client, times(1)).synthesize(any()); + verify(storage, never()).put(any(), any(), any(), any()); + } + private void register(String sessionId, Long questionId, int priority) { ChatQuestion question = ChatQuestion.builder() .questionId(questionId) From 589fc8c268a56be92becdc4779c68cc3fa4c2d24 Mon Sep 17 00:00:00 2001 From: 2-dayeon Date: Mon, 19 Oct 2026 20:04:29 +0900 Subject: [PATCH 09/10] =?UTF-8?q?docs:=20=EB=85=B9=EC=9D=8C=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=ED=9B=84=20=EB=8B=B5=EB=B3=80=20=EC=A0=9C=EC=B6=9C?= =?UTF-8?q?=20=EC=A7=84=EB=8B=A8=20=EB=AC=B8=EC=84=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/interview-answer-troubleshooting.md | 32 ++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 docs/interview-answer-troubleshooting.md diff --git a/docs/interview-answer-troubleshooting.md b/docs/interview-answer-troubleshooting.md new file mode 100644 index 0000000..0db3387 --- /dev/null +++ b/docs/interview-answer-troubleshooting.md @@ -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 오류 수정이 브라우저 음성 인식 실패를 해결하는 것은 아니다. From 1bbfd238b279043a1e0298134c0af3b0ef43d881 Mon Sep 17 00:00:00 2001 From: 2-dayeon Date: Mon, 19 Oct 2026 20:04:29 +0900 Subject: [PATCH 10/10] =?UTF-8?q?docs:=20TTS=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=EA=B3=BC=20=EA=B8=B0=EB=B3=B8=EA=B0=92=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EC=95=88=EB=82=B4=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/tts-audio-buffer.md | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/docs/tts-audio-buffer.md b/docs/tts-audio-buffer.md index 38a8744..44f131a 100644 --- a/docs/tts-audio-buffer.md +++ b/docs/tts-audio-buffer.md @@ -8,9 +8,13 @@ ElevenLabs 음성 응답은 `ElevenLabsTtsClient`에서 `byte[]`로 모은 뒤 전용 WebClient의 수신 한도를 기본 8MiB로 설정한다. 다른 WebClient에는 적용하지 않는다. 기존 `byte[]` 합성·저장 인터페이스, 타임아웃, 큐 동시성, 오디오 응답 계약은 유지한다. -무제한 버퍼는 허용하지 않으며 0 이하 설정은 기동 시 거부한다. +TTS가 켜져 있으면 0 이하 또는 2147483647바이트를 넘는 설정은 기동 시 거부한다. +TTS가 꺼져 있으면 버퍼 설정 대신 안전한 8MiB 기본값을 사용해 전체 서버의 기동을 막지 않는다. +형식이 잘못된 값(예: `invalid`)은 설정 바인딩 단계에서 거부한다. 한도를 초과하면 재시도 불가능한 `TtsClientException`으로 변환하여 -동일한 음성 합성을 반복 요청하지 않는다. 기존 오디오 실패 상태로 처리한다. +동일한 음성 합성을 반복 요청하지 않는다. 오디오 상태는 FAILED이고, +저장·조회 응답의 `errorCode`는 `TTS_AUDIO_TOO_LARGE`다. 다른 합성 실패는 기존 +`TTS_GENERATION_FAILED`, 업로드 실패는 `AUDIO_STORAGE_FAILED`를 유지한다. ## 설정과 배포 @@ -19,26 +23,30 @@ ElevenLabs 음성 응답은 `ElevenLabsTtsClient`에서 `byte[]`로 모은 뒤 | 설정 키 | `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로 저장된 오디오는 이 변경만으로 자동 재생성되지 않는다. -## 녹음 업로드와 다음 질문의 구분 +## 클라이언트 수명과 공통 설정 -`POST /api/interviews/{id}/recordings`의 201은 녹음 파일 저장 결과다. -Chat은 별도 답변 제출이 접수되어야 세션의 답변을 저장하고 다음 질문을 반환한다. -`[ANSWER ACCEPTED]` 로그가 없다면 브라우저의 답변 제출 요청과 응답을 확인한다. -Web은 음성 인식 텍스트가 비어 있으면 답변 인식 실패를 표시하고 제출하지 않는다. -빈 답변을 성공 처리하거나 질문을 자동으로 건너뛰는 변경은 하지 않는다. -TTS 수신 오류 수정이 음성 인식 실패를 해결하는 것은 아니다. +Boot가 제공한 `WebClient.Builder`를 복제하므로 공통 커스터마이저를 유지하면서 +ElevenLabs 전용 URL·코덱·커넥터를 설정한다. 원본 빌더와 다른 클라이언트는 바꾸지 않는다. +`elevenLabsConnectionProvider`는 Spring 빈으로 관리하고 컨텍스트 종료 시 dispose한다. +HTTP 테스트도 사용한 풀을 명시적으로 정리한다. + +녹음 201 이후 면접 진행 문제는 [답변 제출 진단](interview-answer-troubleshooting.md)을 참고한다. ## 검증 `./gradlew test`로 기본 한도 초과였던 오디오의 원본 바이트 수신, -설정 한도 경계·초과, 잘못된 설정, 기존 HTTP·빈 오디오 실패 처리를 검증한다. +설정 한도 경계·초과, 단위 바인딩, TTS 활성화별 설정 검증, 풀 종료, +원본 빌더 격리, 오류 코드 전달, 기존 HTTP·빈 오디오 실패 처리를 검증한다. 실제 ElevenLabs 호출·유료 사용·운영 배포 없이 로컬 HTTP 서버를 사용한다.