Qwen3-TTS로 브라우저에서 바로 쓰는 음성 합성 웹 서비스 구축하기

궁금하면 500원·2026년 2월 4일

미생의 개발 이야기

목록 보기
68/73

웹에서 바로 쓸 수 있는 음성 합성 서비스까지

이전에 ai의 api를 사용하면서 tts 기능이 조금 어설픈 tts의 기능에 좀 그랬습니다.
어설프거나.. 발음이 뭉개거나..
하지만 음성파일 이용하지않고도 tts나 stt구현 한다는것이 신기하였고
파이썬이 아니더라도 api를 잘 활용하면 구현한다는게 매우 흥미로웠습니다.
Qwen3-TTS 목소리 카피할뿐만아니라
사용자가 원하는 방면으로 tts가 가능한다고하기에
Qwen3-TTS 공부하면서, “설치 없이 브라우저만으로 쓸 수 있는 서비스”를 목표로 프로젝트를 정리했습니다.
CustomVoice(정해진 스피커), VoiceDesign(자연어로 음색·감정 지정), VoiceClone(참조 음성으로 목소리 복제)을 한 화면에서 지원하고,
참조 음성은 MinIO에 올려서 URL로 바로 쓰도록 했습니다.
이 포스팅에서는 그 과정에서 배운 구조와 코드를 포스팅으로 정리합니다.


1. 왜 이렇게 만들었는지

기존 TTS 웹 서비스들은 응답이 느리거나, API와 UI가 분리돼 있어서 연동이 번거로운 경우가 많습니다.
그래서 다음을 전제로 설계했습니다.

  • 실제 서비스로 배포 가능한 구조: 백엔드·프론트가 명확히 나뉘고, 환경 변수로 TTS 서비스 URL·CORS·MinIO 등을 바꿀 수 있어서 로컬 개발뿐 아니라 서버에 올려도 그대로 동작하게 합니다.

  • TTS 연산은 외부에 위임: 음성 합성 엔진은 Python 등 별도 서비스로 두고, 우리가 만든 API는 “프록시 + 캐탈로그 + 인코딩”만 담당합니다.
    그래서 Qwen3-TTS 호환 HTTP API만 있으면 바로 붙일 수 있습니다.

  • 한 화면에서 세 가지 모드: CustomVoice / VoiceDesign / VoiceClone을 모드 선택으로 전환하고, 스피커 목록·언어·참조 음성 업로드까지 같은 플로우에서 처리합니다.

그래서 “학습용 프로젝트”이면서도, TTS 서비스와 MinIO만 준비하면 그대로 서비스로 올릴 수 있는 형태로 맞췄습니다.


2. 전체 아키텍처

시스템은 사용자 → frontend → backend → 외부 TTS 서비스·MinIO(스토리지) 로 이어집니다.

  • frontend: /api/tts/* 요청을 Next.js rewrites로 voice-api(8081)로 보냅니다. 별도 API 서버를 쓰려면 NEXT_PUBLIC_VOICE_API_URL만 설정하시면 됩니다.

  • backend: TTS 요청을 받아 TtsGateway로 외부 TTS 서비스를 호출하고, 참조 음성이 우리 ref-audio URL이면 MinIO(스토리지서버)에서 읽어 base64로 바꾼 뒤 TTS에 넘깁니다.

  • 외부 TTS: http://localhost:8000에 Qwen3-TTS 호환 API가 떠 있으면 됩니다.

즉, “브라우저만 있으면 되고, 백엔드·TTS·스토리지는 모두 서버에서 처리된다”는 구조라서, 도메인만 연결하면 실제 서비스처럼 운영하실 수 있습니다.


3. 백엔드

백엔드는 Spring Boot 3.5, 포트 8081입니다.
domain / application / infrastructure 로 나누어, 비즈니스 로직이 외부 구현에 직접 의존하지 않도록 했습니다.

3.1 컨트롤러에서 UseCase만 호출

HTTP 진입점은 TtsController 하나입니다. 검증된 DTO를 UseCase에 넘기고, 반환값을 그대로 응답합니다.
스트리밍·업로드·ref-audio 조회까지 같은 prefix 아래에 모아 두었습니다.

@RestController
@RequestMapping("/api/tts")
class TtsController(
    private val synthesizeTts: SynthesizeTtsUseCase,
    private val getCatalog: GetCatalogUseCase,
    private val uploadRefAudio: UploadRefAudioUseCase,
    private val getRefAudio: GetRefAudioUseCase
) {

    @PostMapping("/custom-voice", produces = [MediaType.APPLICATION_JSON_VALUE])
    fun customVoice(@Valid @RequestBody request: CustomVoiceCommand): ResponseEntity<TtsResponseDto> =
        ResponseEntity.ok(synthesizeTts.customVoice(request))

    @PostMapping("/voice-design", produces = [MediaType.APPLICATION_JSON_VALUE])
    fun voiceDesign(@Valid @RequestBody request: VoiceDesignCommand): ResponseEntity<TtsResponseDto> =
        ResponseEntity.ok(synthesizeTts.voiceDesign(request))

    @PostMapping("/voice-clone", produces = [MediaType.APPLICATION_JSON_VALUE])
    fun voiceClone(@Valid @RequestBody request: VoiceCloneCommand): ResponseEntity<TtsResponseDto> =
        ResponseEntity.ok(synthesizeTts.voiceClone(request))

    @PostMapping("/custom-voice/stream", produces = ["audio/wav"])
    fun customVoiceStream(@Valid @RequestBody request: CustomVoiceCommand): ResponseEntity<ByteArray> {
        val wavBytes = synthesizeTts.customVoiceToWav(request)
        val headers = HttpHeaders().apply {
            setContentType(MediaType.parseMediaType("audio/wav"))
            setContentLength(wavBytes.size.toLong())
        }
        return ResponseEntity.ok().headers(headers).body(wavBytes)
    }

    @GetMapping("/speakers")
    fun getSpeakers(): ResponseEntity<SpeakersResponseDto> =
        ResponseEntity.ok(getCatalog.speakers())

    @GetMapping("/languages")
    fun getLanguages(): ResponseEntity<LanguagesResponseDto> =
        ResponseEntity.ok(getCatalog.languages())

    @PostMapping("/upload-ref-audio", produces = [MediaType.APPLICATION_JSON_VALUE])
    fun uploadRefAudio(@RequestParam("file") file: MultipartFile): ResponseEntity<UploadRefAudioResponseDto> {
        if (file.isEmpty) return ResponseEntity.badRequest().build()
        val result = uploadRefAudio.upload(file)
        return ResponseEntity.status(HttpStatus.CREATED).body(result)
    }

    @GetMapping("/ref-audio/{key}", produces = ["audio/wav", "audio/mpeg", "audio/ogg", "application/octet-stream"])
    fun getRefAudio(@PathVariable key: String): ResponseEntity<ByteArray> {
        if (key.contains("..") || key.contains("/")) return ResponseEntity.badRequest().build()
        val bytes = getRefAudio.getByKey(key) ?: return ResponseEntity.notFound().build()
        val headers = HttpHeaders().apply {
            setContentLength(bytes.size.toLong())
            set("Content-Disposition", "inline; filename=\"$key\"")
        }
        return ResponseEntity.ok().headers(headers).body(bytes)
    }
}
  • custom-voice / voice-design / voice-clone: 각각 JSON body를 받아 UseCase 한 번 호출 후 TtsResponseDto(success, audioBase64, sampleRate 등)를 반환합니다.

  • custom-voice/stream: 같은 CustomVoice 파라미터로 합성한 뒤, WAV 바이너리를 audio/wav로 스트리밍합니다.
    재생기나 다운로드에 그대로 쓰실 수 있습니다.

  • upload-ref-audio: multipart file을 받아 UseCase에서 MinIO에 저장하고, 클라이언트가 VoiceClone에 넣을 수 있는 url, key를 반환합니다.

  • ref-audio/{key}: 업로드된 참조 음성을 스트리밍으로 내려줍니다.
    재생·다운로드 둘 다 가능합니다.

이렇게 엔드포인트를 한곳에 모아 두면, 나중에 API 문서화·인증·속도 제한 등을 붙이기 쉬워서 서비스 운영에도 유리합니다.

3.2 DTO와 검증

요청은 모두 data class로 받고, @Valid로 검증합니다.
텍스트 길이 상한, 필수 필드를 명시해 두었습니다.

data class CustomVoiceCommand(
    @field:NotBlank(message = "text는 필수입니다")
    @field:Size(max = 10_000)
    val text: String,
    val language: String = "Auto",
    val speaker: String = "Vivian",
    val instruct: String? = null
)

data class VoiceDesignCommand(
    @field:NotBlank(message = "text는 필수입니다")
    @field:Size(max = 10_000)
    val text: String,
    val language: String = "Auto",
    @field:NotBlank(message = "voice design에는 instruct가 필수입니다")
    val instruct: String
)

data class VoiceCloneCommand(
    @field:NotBlank(message = "text는 필수입니다")
    @field:Size(max = 10_000)
    val text: String,
    val language: String = "English",
    @field:NotBlank(message = "ref_audio는 필수입니다 (URL 또는 base64)")
    val refAudio: String,
    val refText: String? = null,
    val xVectorOnlyMode: Boolean = false
)

data class TtsResponseDto(
    val success: Boolean,
    val audioBase64: String? = null,
    val sampleRate: Int? = null,
    val errorCode: String? = null,
    val message: String? = null
)

응답은 TtsResponseDto로 통일해서, 프론트에서 모드와 관계없이 같은 타입으로 처리할 수 있게 했습니다.

3.3 TtsGateway와 WebClient 구현

실제 TTS 호출은 포트 인터페이스로 추상화했습니다.
그래서 로컬용 HTTP, 나중에 DashScope 등 다른 API로 바꿀 때도 수정하지 않고 어댑터만 갈아끼우시면 됩니다.

// 포트 인터페이스 (application/port/TtsGateway.kt)
interface TtsGateway {
    fun synthesizeCustomVoice(text: String, language: String, speaker: String, instruct: String): RemoteTtsResponse
    fun synthesizeVoiceDesign(text: String, language: String, instruct: String): RemoteTtsResponse
    fun synthesizeVoiceClone(text: String, language: String, refAudio: String, refText: String?, xVectorOnlyMode: Boolean): RemoteTtsResponse
}

구현체는 WebClientTTS_SERVICE_URL에 POST 요청을 보냅니다.
Qwen3-TTS API 형식에 맞춰 text, language, speaker, instruct / ref_audio, ref_text, x_vector_only_mode 등을 넘기고,
응답 map에서 success, audioBase64, sampleRate, message 등을 꺼내 RemoteTtsResponse로 만듭니다.

현재는 bodyToMono(...).block()으로 동기적으로 응답을 기다리도록 되어 있어, 트래픽이 몰리면 스레드 풀 고갈이나 타임아웃 가능성이 있습니다.
실제 트래픽이 예상될 경우에는 비동기 워커나 메시지 큐 도입을 검토하시는 것이 좋습니다

private fun call(path: String, body: Map<String, Any>): RemoteTtsResponse {
    return try {
        val map = webClient.post()
            .uri(baseUrl + path)
            .bodyValue(body)
            .retrieve()
            .bodyToMono<Map<String, Any>>()
            .onErrorResume { e ->
                log.warn("TTS 게이트웨이 호출 실패: {}", e.message)
                val msg = if (baseUrl.contains("localhost") || baseUrl.contains("127.0.0.1"))
                    "TTS 서비스가 실행 중이지 않습니다. TTS_SERVICE_URL을 Qwen3-TTS 엔드포인트로 설정하거나 qwen-tts 서비스를 실행하세요."
                else (e.message ?: "TTS 서비스를 사용할 수 없습니다")
                Mono.just(mapOf("success" to false, "message" to msg))
            }
            .block() ?: mapOf("success" to false, "message" to "TTS 서비스가 빈 응답을 반환했습니다")

        RemoteTtsResponse(
            success = map["success"] as? Boolean ?: false,
            audioBase64 = map["audioBase64"] as? String,
            sampleRate = (map["sampleRate"] as? Number)?.toInt(),
            errorCode = map["errorCode"] as? String,
            message = map["message"] as? String
        )
    } catch (e: Exception) {
        log.error("TTS 게이트웨이 실패", e)
        RemoteTtsResponse(success = false, errorCode = "TTS_ERROR", message = e.message ?: "TTS 합성에 실패했습니다")
    }
}

TTS 서비스가 없거나 오류가 나면 사용자에게 안내 메시지를 담은 JSON을 돌려주도록 해 두어, “서비스가 준비 중”인 상황도 그대로 서빙할 수 있습니다.

3.4 SynthesizeTtsUseCase와 RefAudio 해석

합성 오케스트레이션은 SynthesizeTtsUseCase에서 합니다.
CustomVoice / VoiceDesign은 Gateway만 호출하면 되고, VoiceClone은 참조 음성 이 우리 ref-audio URL인지 먼저 확인합니다.

  • ref-audio base URL로 시작하면: RefAudioResolver가 key를 추출해 RefAudioStoragePort에서 바이트를 읽고, Base64로 인코딩한 문자열을 TTS에 넘깁니다.
  • 그렇지 않으면: URL이든 base64 문자열이든 그대로 Gateway에 전달합니다.
@Component
class SynthesizeTtsUseCase(
    private val ttsGateway: TtsGateway,
    private val refAudioResolver: RefAudioResolver
) {
    fun customVoice(cmd: CustomVoiceCommand): TtsResponseDto =
        mapRemoteToDto(ttsGateway.synthesizeCustomVoice(cmd.text, cmd.language, cmd.speaker, cmd.instruct.orEmpty()))

    fun customVoiceToWav(cmd: CustomVoiceCommand): ByteArray {
        val remote = ttsGateway.synthesizeCustomVoice(cmd.text, cmd.language, cmd.speaker, cmd.instruct.orEmpty())
        if (!remote.success || remote.audioBase64 == null || remote.sampleRate == null)
            throw IllegalStateException(remote.message ?: "TTS 합성에 실패했습니다")
        return WavEncoder.pcmBase64ToWavBytes(remote.audioBase64, remote.sampleRate, 1)
    }

    fun voiceDesign(cmd: VoiceDesignCommand): TtsResponseDto =
        mapRemoteToDto(ttsGateway.synthesizeVoiceDesign(cmd.text, cmd.language, cmd.instruct))

    fun voiceClone(cmd: VoiceCloneCommand): TtsResponseDto {
        val refAudioForTts = refAudioResolver.resolveToRefAudioForTts(cmd.refAudio)
        return mapRemoteToDto(ttsGateway.synthesizeVoiceClone(cmd.text, cmd.language, refAudioForTts, cmd.refText, cmd.xVectorOnlyMode))
    }

    private fun mapRemoteToDto(r: RemoteTtsResponse): TtsResponseDto = ...
}

RefAudioResolver는 설정된 minio.ref-audio-base-url과 비교해, 우리가 내려준 ref-audio URL이면 MinIO에서 읽어 base64로 바꿉니다.

fun resolveToRefAudioForTts(refAudio: String): String {
    val base = refAudioBaseUrl.trimEnd('/')
    if (!refAudio.startsWith("$base/")) return refAudio
    val key = refAudio.removePrefix("$base/").trim()
    if (key.isEmpty()) return refAudio
    val bytes = refAudioStorage.getBytes(key) ?: return refAudio
    return Base64.getEncoder().encodeToString(bytes)
}

그래서 클라이언트는 “업로드 후 받은 URL”만 refAudio에 넣어도 되고, 서버가 알아서 MinIO → base64 변환 후 TTS에 넘겨 줍니다.
이렇게 하면 클라이언트가 큰 base64를 직접 보낼 필요 없이, 서비스 측에서만 참조 음성을 다루게 되어 배포 환경에서도 관리하기 쉽습니다.

3.5 WAV 인코딩

외부 TTS는 보통 PCM을 base64로 줍니다.
브라우저에서 재생하려면 WAV 헤더가 필요하므로, 도메인에 WavEncoder를 두어 PCM base64 → WAV 바이너리 변환을 담당하게 했습니다.

object WavEncoder {
    fun pcmBase64ToWavBytes(base64: String, sampleRate: Int, channels: Int = 1): ByteArray {
        val pcm = Base64.getDecoder().decode(base64)
        return createWavHeader(pcm.size, sampleRate, channels) + pcm
    }

    fun createWavHeader(dataSize: Int, sampleRate: Int, channels: Int): ByteArray {
        val byteRate = sampleRate * channels * 2
        val blockAlign = (channels * 2).toShort()
        val header = ByteArray(44)
        val fileSize = dataSize + 36
        header[0] = 'R'.code.toByte(); header[1] = 'I'.code.toByte(); header[2] = 'F'.code.toByte(); header[3] = 'F'.code.toByte()
        header[4] = (fileSize and 0xff).toByte(); header[5] = (fileSize shr 8 and 0xff).toByte()
        header[6] = (fileSize shr 16 and 0xff).toByte(); header[7] = (fileSize shr 24 and 0xff).toByte()
        header[8] = 'W'.code.toByte(); header[9] = 'A'.code.toByte(); header[10] = 'V'.code.toByte(); header[11] = 'E'.code.toByte()
        header[12] = 'f'.code.toByte(); header[13] = 'm'.code.toByte(); header[14] = 't'.code.toByte(); header[15] = ' '.code.toByte()
        header[16] = 16; header[17] = 0; header[18] = 0; header[19] = 0  // fmt 청크 크기 16
        header[20] = 1; header[21] = 0   // PCM 포맷
        header[22] = (channels and 0xff).toByte(); header[23] = (channels shr 8).toByte()
        header[24] = (sampleRate and 0xff).toByte(); header[25] = (sampleRate shr 8 and 0xff).toByte()
        header[26] = (sampleRate shr 16 and 0xff).toByte(); header[27] = (sampleRate shr 24 and 0xff).toByte()
        header[28] = (byteRate and 0xff).toByte(); header[29] = (byteRate shr 8 and 0xff).toByte()
        header[30] = (byteRate shr 16 and 0xff).toByte(); header[31] = (byteRate shr 24 and 0xff).toByte()
        header[32] = (blockAlign.toInt() and 0xff).toByte(); header[33] = (blockAlign.toInt() shr 8).toByte()
        header[34] = 16; header[35] = 0   // 샘플당 16비트
        header[36] = 'd'.code.toByte(); header[37] = 'a'.code.toByte(); header[38] = 't'.code.toByte(); header[39] = 'a'.code.toByte()
        header[40] = (dataSize and 0xff).toByte(); header[41] = (dataSize shr 8 and 0xff).toByte()
        header[42] = (dataSize shr 16 and 0xff).toByte(); header[43] = (dataSize shr 24 and 0xff).toByte()
        return header
    }
}

JSON 응답에서는 이걸 쓰지 않고 base64 + sampleRate만 내려주고, 프론트에서 WAV를 만듭니다. 스트리밍 엔드포인트에서는 이 WavEncoder로 만든 ByteArray를 그대로 audio/wav로 내려줍니다.

3.6 참조 음성 업로드

UploadRefAudioUseCase는 multipart 파일을 받아 확장자를 보존하고, UUID 기반 key로 MinIO에 저장한 뒤, 클라이언트가 사용할 URL을 만듭니다.

fun upload(file: MultipartFile): UploadRefAudioResponseDto {
    val key = "${UUID.randomUUID()}.${extensionOrWav(file.originalFilename)}"
    val bytes = file.bytes
    val contentType = file.contentType?.takeIf { it.isNotBlank() } ?: "audio/wav"
    refAudioStorage.save(key, bytes, contentType)
    val url = "${refAudioBaseUrl.trimEnd('/')}/$key"
    return UploadRefAudioResponseDto(url = url, key = key)
}

RefAudioStoragePortsave, getBytes만 정의하고, 인프라 쪽에서 MinIO 클라이언트로 구현합니다.
버킷이 없으면 생성하도록 해 두어, 최소 설정만으로도 서비스가 동작하게 했습니다.

이렇게 하면 VoiceClone용 참조 음성을 “업로드 → URL 받기 → 그 URL을 refAudio에 넣기” 한 번에 처리할 수 있어, 실제 서비스 플로우와 동일하게 구성됩니다.


4. 프론트엔드

프론트는 Next.js 14, TypeScript입니다. domain / application / app 으로 나누어, API URL이나 오디오 처리 방식이 바뀌어도 페이지는 최소한만 수정하도록 했습니다.

4.1 API rewrites로 서버 없이 연동

개발 시에는 별도 API URL 설정 없이, 같은 호스트의 /api/tts/*를 backend로 넘깁니다.

// Next 설정 (next.config.js)
const nextConfig = {
  reactStrictMode: true,
  async rewrites() {
    return [
      {
        source: '/api/tts/:path*',
        destination: 'http://localhost:8081/api/tts/:path*',
      },
    ];
  },
};
module.exports = nextConfig;

배포 시에는 NEXT_PUBLIC_VOICE_API_URL에 실제 API 서버 주소를 두시면, ttsApi에서 그 base를 사용해 요청을 보내므로, 같은 코드로 로컬·스테이징·프로덕션을 구분하실 수 있습니다.

4.2 타입과 오디오 유틸

모드·페이로드·응답 타입을 한곳에 두고, API와 훅이 이 타입만 참조하도록 했습니다.

// 도메인 타입 정의 (domain/tts/types.ts)
export type TtsMode = 'custom-voice' | 'voice-design' | 'voice-clone';

export interface TtsResponse {
  success: boolean;
  audioBase64?: string;
  sampleRate?: number;
  errorCode?: string;
  message?: string;
}

export interface CustomVoicePayload {
  text: string;
  language: string;
  speaker: string;
  instruct?: string;
}

export interface VoiceDesignPayload {
  text: string;
  language: string;
  instruct: string;
}

export interface VoiceClonePayload {
  text: string;
  language: string;
  refAudio: string;
  refText?: string;
}

오디오는 “base64 + sampleRate → 재생 가능한 WAV blob URL”로 바꾸는 순수 함수를 도메인에 둡니다.

// 도메인 오디오 유틸 (domain/tts/audio.ts)
export function base64ToWavUrl(base64: string, sampleRate: number): string {
  const binary = atob(base64);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
  const wav = buildWav(bytes, sampleRate, 1);
  const blob = new Blob([wav], { type: 'audio/wav' });
  return URL.createObjectURL(blob);
}

export function buildWav(pcm: Uint8Array, sampleRate: number, channels: number): ArrayBuffer {
  const byteRate = sampleRate * channels * 2;
  const blockAlign = channels * 2;
  const dataSize = pcm.length;
  const fileSize = dataSize + 36;
  const buf = new ArrayBuffer(44 + pcm.length);
  const view = new DataView(buf);
  let offset = 0;
  const write = (bytes: string) => {
    for (let i = 0; i < bytes.length; i++) view.setUint8(offset++, bytes.charCodeAt(i));
  };
  write('RIFF');
  view.setUint32(offset, fileSize, true); offset += 4;
  write('WAVE');
  write('fmt ');
  view.setUint32(offset, 16, true); offset += 4;
  view.setUint16(offset, 1, true); offset += 2;
  view.setUint16(offset, channels, true); offset += 2;
  view.setUint32(offset, sampleRate, true); offset += 4;
  view.setUint32(offset, byteRate, true); offset += 4;
  view.setUint16(offset, blockAlign, true); offset += 2;
  view.setUint16(offset, 16, true); offset += 2;
  write('data');
  view.setUint32(offset, dataSize, true); offset += 4;
  new Uint8Array(buf).set(pcm, 44);
  return buf;
}

백엔드의 WavEncoder와 동일한 논리로, RIFF WAV 헤더 44바이트 뒤에 PCM을 붙여서 브라우저가 재생할 수 있는 형태로 만듭니다.
이렇게 하면 서버는 “원본 PCM base64 + sampleRate”만 넘겨도 되고, 클라이언트가 재생 형식을 책임집니다.

4.3 API 계층

ttsApi는 fetch로 백엔드 엔드포인트를 호출하고, 응답을 그대로 반환합니다.
base URL은 NEXT_PUBLIC_VOICE_API_URL이 비어 있으면 상대 경로를 쓰므로 rewrites와 맞물립니다.

const API_BASE = process.env.NEXT_PUBLIC_VOICE_API_URL || '';

function base(): string {
  return API_BASE || '';
}

export async function fetchCustomVoice(payload: CustomVoicePayload): Promise<TtsResponse> {
  const res = await fetch(`${base()}/api/tts/custom-voice`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      text: payload.text,
      language: payload.language,
      speaker: payload.speaker,
      instruct: payload.instruct ?? undefined,
    }),
  });
  return res.json();
}

export async function uploadRefAudio(file: File): Promise<UploadRefAudioResponse> {
  const form = new FormData();
  form.append('file', file);
  const res = await fetch(`${base()}/api/tts/upload-ref-audio`, {
    method: 'POST',
    body: form,
  });
  if (!res.ok) {
    const err = await res.text();
    throw new Error(err || '업로드에 실패했습니다');
  }
  return res.json();
}

VoiceDesign, VoiceClone, fetchSpeakers도 같은 패턴입니다.
서비스로 배포할 때는 base()가 실제 API 도메인을 가리키게만 하시면 됩니다.

4.4 useSynthesizeTts 훅

모드에 따라 알맞은 API를 호출하고, 성공 시 base64ToWavUrl로 blob URL을 만들어 반환합니다.
페이지는 이 blob URL을 <audio src={...}>에 넣어 재생합니다.

const synthesize = useCallback(
  async (mode: TtsMode, params: { text, language, speaker, instruct, refAudio, refText }) => {
    setError(null);
    setLoading(true);
    try {
      let data: TtsResponse;
      if (mode === 'custom-voice') {
        data = await fetchCustomVoice({ text, language, speaker, instruct: params.instruct || undefined });
      } else if (mode === 'voice-design') {
        data = await fetchVoiceDesign({ text, language, instruct: params.instruct });
      } else {
        data = await fetchVoiceClone({
          text, language, refAudio: params.refAudio, refText: params.refText || undefined,
        });
      }
      if (!data.success) {
        setError(data.message ?? '합성에 실패했습니다');
        return { blobUrl: null };
      }
      if (data.audioBase64 && data.sampleRate) {
        const blobUrl = base64ToWavUrl(data.audioBase64, data.sampleRate);
        return { blobUrl };
      }
      return { blobUrl: null };
    } catch (err) {
      setError(err instanceof Error ? err.message : '요청에 실패했습니다');
      return { blobUrl: null };
    } finally {
      setLoading(false);
    }
  },
  []
);

에러 메시지는 훅에서 state로 두고, 페이지에서 표시합니다.
TTS 서비스가 없을 때 백엔드에서 내려주는 메시지가 그대로 사용자에게 보이므로, “서비스 준비 중” 같은 상태도 자연스럽게 표현할 수 있습니다.

4.5 모드별 입력과 업로드

페이지에서는 모드에 따라 스피커 선택, Instruct, 참조 음성 입력을 보여 줍니다.
VoiceClone일 때는 파일 업로드와 URL/base64 입력을 모두 지원합니다.

  • 파일 선택 시 uploadRefAudio를 호출해 MinIO에 올리고, 응답의 urlrefAudio state에 넣습니다.
  • 사용자는 “업로드된 URL이 자동으로 채워진다”는 것만 알면 되고, Synthesize를 누르면 백엔드가 해당 URL을 MinIO에서 읽어 TTS에 넘깁니다.

이렇게 하면 “참조 음성 업로드 → 합성”이 하나의 플로우로 이어져, 실제 서비스와 동일한 UX를 연습하실 수 있습니다.


5. API 사용 예시

실제로 다른 클라이언트나 스크립트에서 voice-api를 쓸 때의 예시입니다.

5.1 CustomVoice

curl -X POST http://localhost:8081/api/tts/custom-voice \
  -H "Content-Type: application/json" \
  -d "{\"text\":\"Hello world.\",\"language\":\"English\",\"speaker\":\"Ryan\"}"

응답 예:

{
  "success": true,
  "audioBase64": "...",
  "sampleRate": 24000
}

5.2 참조 음성 업로드 후 VoiceClone

# 1) 참조 음성 업로드
curl -X POST http://localhost:8081/api/tts/upload-ref-audio -F "file=@/path/to/ref.wav"

# 응답: { "url": "http://localhost:8081/api/tts/ref-audio/uuid.wav", "key": "uuid.wav" }

# 2) refAudio에 위 url을 넣어 VoiceClone
curl -X POST http://localhost:8081/api/tts/voice-clone \
  -H "Content-Type: application/json" \
  -d "{\"text\":\"Hello.\",\"language\":\"English\",\"refAudio\":\"http://localhost:8081/api/tts/ref-audio/uuid.wav\"}"

같은 방식으로 스크립트나 다른 프론트에서도 업로드 URL만 넘기시면 되므로, 서비스 API 설계와 맞춰져 있습니다.

5.3 WAV 스트리밍

재생만 필요하실 때는 JSON 대신 WAV 바이너리를 받으실 수 있습니다.

curl -X POST http://localhost:8081/api/tts/custom-voice/stream \
  -H "Content-Type: application/json" \
  -d "{\"text\":\"Hello.\",\"language\":\"English\",\"speaker\":\"Ryan\"}" \
  --output out.wav

6. 환경 변수와 서비스 배포

실제로 서비스처럼 쓰시려면 아래만 맞추시면 됩니다.

backend

변수설명
TTS_SERVICE_URLQwen3-TTS 호환 HTTP 서비스 URL (실제 합성)
VOICE_WEB_ORIGINCORS 허용할 프론트 오리진 (예: https://your-domain.com)
MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY, MINIO_BUCKET참조 음성 저장소
REF_AUDIO_BASE_URL업로드 후 반환할 ref-audio URL prefix (VoiceClone 시 이 URL이면 MinIO에서 조회)

voice-web

변수설명
NEXT_PUBLIC_VOICE_API_URLAPI 서버 URL (비우면 같은 호스트의 /api/tts/* → rewrites 사용)

로컬에서는 backend(8081), frontend(3000), 스토리지(9000), TTS 서비스만 띄우면 되고, 배포 시에는 이 네 가지를 각각 배치한 뒤 URL과 CORS만 설정하시면 됩니다.


7. 운영·확장 시 고려사항

현재 구현만으로도 서비스를 띄우고 사용하는 데는 충분하지만, 트래픽이 늘거나 SLA를 요구하는 환경에서는 아래를 미리 고민해 두면 좋습니다.

동기 호출의 한계와 비동기화
TTS 합성은 수 초가 걸릴 수 있는 상대적으로 무거운 작업입니다. 지금처럼 HTTP 요청마다 외부 TTS 응답을 .block()으로 기다리면, 동시 요청이 많아질 때 Tomcat 스레드 풀 고갈이나 타임아웃이 발생할 수 있습니다.
이를 완화하려면 요청을 받아 작업 ID만 반환하고, 백그라운드에서 Kafka·RabbitMQ 같은 메시지 큐로 작업을 넣은 뒤 워커가 TTS를 호출하는 구조로 바꾸거나,
클라이언트가 폴링 또는 서버 센트 이벤트로 결과를 받는 방식으로 전환하는 것을 검토하실 수 있습니다.

서킷 브레이커와 폴백
외부 TTS 서비스에 장애가 나면, 그대로 재시도만 반복하다가 우리 API까지 응답 지연이나 스레드 점유가 이어질 수 있습니다.
Resilience4j 등의 서킷 브레이커를 TtsGateway 호출 앞단에 두고, OPEN 상태에서는 즉시 “TTS 서비스를 일시적으로 사용할 수 없습니다” 같은 폴백 응답을 내려주도록 하면, 연쇄 장애를 막고 사용자에게도 명확한 메시지를 주실 수 있습니다.

용량 및 리소스 관리
참조 음성 업로드는 spring.servlet.multipart.max-file-size(예: 25MB)로 크기 상한을 두어 두었습니다.
운영 시에는 허용 확장자(wav, mp3, ogg 등)를 화이트리스트로 검증하고, Content-Type·매직 바이트 등으로 실제 오디오 여부를 확인하는 것이 좋습니다.
MinIO(스토리지서버)에는 업로드된 파일이 무한히 쌓이지 않도록, 오래된 객체를 주기적으로 삭제하는 Lifecycle 정책을 버킷에 걸어 두면 스토리지 비용과 관리 부담을 줄일 수 있습니다.


8. 정리

Qwen3-TTS를 학습하면서 “웹에서 바로 쓸 수 있는 TTS 서비스”를 목표로, 백엔드와 프론트를 정리했습니다.
TTS 연산은 외부 서비스에 맡기고, 우리가 만든 API는 프록시·캐탈로그·참조 음성 해석·인코딩에만 집중하도록 해서, Qwen3-TTS 호환 엔드포인트만 있으면 그대로 붙일 수 있게 했습니다.
참조 음성은 MinIO에 올려 URL로 쓰고, ref-audio URL은 서버에서 MinIO를 조회해 base64로 변환한 뒤 TTS에 넘기므로, 클라이언트는 “업로드 → URL 사용”만 알면 됩니다.

profile
그냥 코딩할래요 재미있어요

0개의 댓글