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/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 오류 수정이 브라우저 음성 인식 실패를 해결하는 것은 아니다. diff --git a/docs/tts-audio-buffer.md b/docs/tts-audio-buffer.md new file mode 100644 index 0000000..44f131a --- /dev/null +++ b/docs/tts-audio-buffer.md @@ -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 서버를 사용한다. 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..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,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(); 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..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,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(); } 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..58903db 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,10 @@ private Mono readAudio(ClientResponse response) { )); } return response.bodyToMono(byte[].class) + .onErrorMap(DataBufferLimitException.class, error -> new TtsClientException( + "ElevenLabs 오디오가 수신 한도를 초과했습니다: " + + 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 4c5f26d..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,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; + } } 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 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; + } +} 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..1657378 --- /dev/null +++ b/src/test/java/com/person/repit/tts/service/ElevenLabsTtsClientTest.java @@ -0,0 +1,158 @@ +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 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; +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; + 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); + } + + @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(DataSize.ofKilobytes(64)); + 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(DataSize.ofKilobytes(64)); + 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(); + assertThat(((TtsClientException) error).getErrorCode()).isEqualTo("TTS_AUDIO_TOO_LARGE"); + }).verify(Duration.ofSeconds(5)); + } + + @ParameterizedTest + @ValueSource(ints = {0, -1}) + void 무제한이나_잘못된_수신_한도를_허용하지_않는다(int limit) { + properties.getElevenlabs().setMaxInMemorySize(DataSize.ofBytes(limit)); + + assertThatThrownBy(this::client).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("max-in-memory-size"); + } + + @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", value))); + + var bound = binder.bind("tts", Bindable.of(TtsProperties.class)).get(); + + assertThat(bound.getElevenlabs().getMaxInMemorySize()).isEqualTo(DataSize.ofBytes(bytes)); + } + + @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); + assertThat(((TtsClientException) error).getErrorCode()).isEqualTo("TTS_GENERATION_FAILED"); + }).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(WebClient.builder(), provider, 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); + } + }); + } +} 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)