Java 음성 인식 API: HttpClient로 오디오를 텍스트로

JDK HttpClient와 Jackson으로 Java에서 음성 인식 API를 호출하세요. 오디오 URL을 POST하고 Job을 폴링한 뒤, 전사문과 단어별 시간을 읽습니다.

읽는 시간 6분Sume
전체 글

Java에서 음성 인식(STT) API를 쓰려면 JDK의 java.net.http.HttpClient로 오디오 파일의 URL을 POST하고, Job이 끝날 때까지 폴링한 뒤, JSON 결과에서 전사문과 단어별 타임스탬프를 읽으세요. Sume STT 1.0에서는 POST https://api.sume.com/v1/stt-1.0/transcribe를 보내고, terminal이 true가 될 때까지 GET /v1/jobs/{id}/status를 호출한 다음, GET /v1/jobs/{id}/result에서 text, words, segments를 읽습니다.

Sume SDK는 TypeScript 클라이언트이므로, Java에서는 HTTP API를 직접 호출합니다. STT 관련 내용은 Sume API 레퍼런스의 STT 1.0 스키마, Job과 결과 (영문), 인증에서 가져왔습니다. Java 호출은 Java SE 21 문서의 HttpClient와 HttpRequest.Builder, Jackson의 databind README를 따릅니다. 모두 2026-09-28에 확인했습니다. 같은 흐름을 Python으로 구현한 글은 Python 음성 인식입니다.

시작하기 전에 무엇이 필요한가요?

  • Java 11 이상이 필요합니다. java.net.http 클라이언트는 11부터 JDK에 들어 있습니다.
  • JSON 라이브러리가 필요합니다. 예제 코드는 Jackson의 jackson-databind를 쓰며, 그 README는 ObjectMapper를 하나 만들어 재사용하라고 안내합니다.
  • 서버의 SUME_API_KEY 환경 변수에 API 키를 넣어 두세요. Sume 문서에 따르면 키는 서버에서만 써야 하며, 클라이언트 JavaScript나 모바일 번들에 절대 넣으면 안 됩니다.
  • 키는 Authorization: Bearer나 x-api-key 중 하나로만 보내세요. 둘 다 보낸 요청은 401 unauthorized로 거부됩니다.
  • 오디오는 공개 HTTPS URL에 있어야 합니다. 요청은 audio_url을 받으며, 파일 바이트를 담을 필드가 없습니다.

Java에서 오디오는 어떻게 보내나요?

HttpClient를 하나 만들어 재사용하세요. JDK 문서에 따르면 빌드된 클라이언트는 불변이며 여러 요청을 보낼 수 있습니다. call 헬퍼는 키를 붙이고, 타임아웃이 없는 요청은 끝없이 블록되므로 30초 타임아웃을 설정한 뒤, 응답의 data 객체를 반환합니다. submit은 Idempotency-Key와 함께 본문을 POST합니다. 같은 키와 본문으로 재시도하면 두 번째 Job을 과금하는 대신 원래 Job을 반환합니다.

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
import java.util.Map;

public class SumeStt {
  static final HttpClient HTTP = HttpClient.newHttpClient();
  static final ObjectMapper JSON = new ObjectMapper(); // create once, reuse

  static JsonNode call(HttpRequest.Builder req) throws Exception {
    HttpResponse<String> res = HTTP.send(req.timeout(Duration.ofSeconds(30))
        .header("Authorization", "Bearer " + System.getenv("SUME_API_KEY")).build(),
        HttpResponse.BodyHandlers.ofString());
    if (res.statusCode() >= 300) throw new IllegalStateException(res.statusCode() + " " + res.body());
    return JSON.readTree(res.body()).get("data");
  }

  static JsonNode submit(String audioUrl, String idempotencyKey) throws Exception {
    String body = JSON.writeValueAsString(
        Map.of("audio_url", audioUrl, "segmentation", Map.of("mode", "sentence")));
    return call(HttpRequest.newBuilder(URI.create("https://api.sume.com/v1/stt-1.0/transcribe"))
        .header("Content-Type", "application/json").header("Idempotency-Key", idempotencyKey)
        .POST(HttpRequest.BodyPublishers.ofString(body)));
  }

전사문은 어떻게 기다렸다가 읽나요?

읽을 때마다 사이에 next_poll_after_seconds만큼 쉬면서 terminal이 true가 될 때까지 status_url을 폴링하세요. 그다음 sume_status가 completed인지 확인하고 result_url을 읽습니다. 대신 mode: sync에 기대지는 마세요. 최대 30초만 기다리며, API 레퍼런스는 더 오래 기다려야 한다면 기본값인 async로 제출하고 클라이언트 쪽에서 폴링하라고 안내합니다. /result는 완료된 Job에만 쓸 수 있고 그렇지 않으면 409 job_not_completed로 응답합니다. 클라이언트 쪽 타임아웃은 Job을 취소하지 않으므로, 나중에 이어서 확인할 수 있도록 Job ID를 보관하세요.

논블로킹 서비스에서는 HTTP.sendAsync로 같은 요청을 보냅니다. JDK 문서에 따르면 이 메서드는 CompletableFuture를 즉시 반환하며, 이 future는 응답을 받을 수 있게 되면 완료됩니다. 스레드를 재우는 대신 next_poll_after_seconds 뒤에 다음 폴링을 예약하세요.

  public static void main(String[] args) throws Exception {
    JsonNode job = submit("https://example.com/audio/interview.m4a", "interview-001");
    URI statusUrl = URI.create(job.get("status_url").asText());
    JsonNode status = job;
    do {
      Thread.sleep(1000L * Math.max(1, status.path("next_poll_after_seconds").asInt(2)));
      status = call(HttpRequest.newBuilder(statusUrl));
    } while (!status.get("terminal").asBoolean());
    if (!"completed".equals(status.get("sume_status").asText()))
      throw new IllegalStateException("STT job ended as " + status.get("sume_status").asText());

    JsonNode result = call(HttpRequest.newBuilder(URI.create(job.get("result_url").asText())))
        .get("result");
    System.out.println(result.path("language_code").asText() + ": " + result.get("text").asText());
    for (JsonNode w : result.get("words")) {
      if (w.has("start") && !"spacing".equals(w.path("type").asText()))
        System.out.printf("%7.2fs  %s%n", w.get("start").asDouble(), w.get("word").asText());
    }
  }
}

결과에는 무엇이 들어 있나요?

현재 코드에서 단어에는 엔진이 반환할 때만 start와 end가 담기며, 루프가 has("start")를 확인하는 것은 이 때문입니다. Jackson의 path는 없는 필드에 대해 null이 아니라 missing node를 반환하므로, 단어에 type이 없으면 path("type").asText()는 빈 문자열입니다.

Sume API 레퍼런스(API 레퍼런스 문서)의 STT 1.0 결과 스키마 기준, 2026-09-28 확인.
필드담긴 내용
text전체 전사문
language_code, language_probability감지되었거나 요청한 언어와 감지 신뢰도(있는 경우)
words[]word, 그리고 오디오 시작부터 초 단위로 잰 start와 end. start 순으로 정렬되며, word나 spacing 같은 선택적 type이 붙음. 최대 20,000개 항목이며, 상한에 걸리면 words_truncated로 표시.
segments[]segmentation: {"mode": "sentence"}를 넣은 경우에만: index, text, start, end, duration_seconds

한도는 어떻게 되고, 비용은 얼마인가요?

STT 1.0의 요금은 오디오 분당 $0.01이며, 기본적으로 5.5% 에이전트 수수료가 더해집니다. 잔액이 예상 금액을 감당하지 못하면 작업이 시작되기 전에 제출이 402 insufficient_credits로 실패하므로, call은 300 이상인 다른 상태 코드와 마찬가지로 이때도 예외를 던집니다.

한도는 호출하는 프로그래밍 언어와 상관없습니다. 요청당 오디오는 최대 10분이고, 화자 라벨은 없으며, Job이 URL에 있는 파일을 읽으므로 실시간 마이크 스트림은 지원하지 않습니다. 전체 목록은 Python 음성 인식에 있고, 더 긴 녹음을 나누는 방법은 긴 오디오 파일 전사하기에서 다룹니다.

출처

관련 글

개발자 카테고리의 다른 글

개발자 글 전체 보기

작성자 Sume