Spring Boot에서 AI로 음성 입력 기반 검색 & 음성 안내 기능 구현하기

Hanu·2026년 6월 12일

BACK-END

목록 보기
2/2
post-thumbnail

이음 서비스 구경하기
*아직 프로젝트 진행 중임을 감안해주세요!

SSAFY 1학기 프로젝트로 진행 중인 무장애 여행 플래너 서비스 이음은 초기 기획 단계부터 "모두의 여행을 잇는다"는 서비스 목표에 맞게, 다양한 사용자가 쉽게 이용할 수 있도록 음성 입력 기반의 동작을 지원하기로 했습니다. 음성 입력으로 손 쉽게 원하는 관광지를 검색하고, 관광지에 대한 설명을 듣고, 일정을 수정할 수 있도록요.

이번 글에서는 음성 입력 기반 관광지 검색과 관광지에 대한 음성 안내 기능을 어떻게 설계하였는지, 그리고 구현하면서 겪은 트러블슈팅 내용을 기록하려고 합니다.


1. 기능 소개

이음은 장애인, 영유아 동반 가족 등 이동약자를 위한 무장애 여행 플래너 서비스입니다. 이번에는 음성 입력을 활용한 두 가지 핵심 기능을 구현했습니다.

Type기능설명
1음성 기반 관광지 검색"서울에서 휠체어 타고 갈 수 있는 관광지 알려줘" → AI가 파싱 → 관광지 목록 반환
2관광지 상세 음성 안내"이 관광지 정보 읽어줘" → AI가 안내 스크립트 생성 → TTS로 음성 합성 후 반환

프론트엔드에서는 브라우저의 Web Speech API(SpeechRecognition)를 사용하여 음성을 텍스트로 변환하고, 변환된 텍스트를 백엔드 API에 전달합니다.


2. 전체 아키텍처

┌──────────────────────────────────────────────────────────────────────┐
│  Frontend (Vue.js)                                                   │
│  ┌───────────┐    ┌──────────────┐    ┌─────────────────────────┐    │
│  │ 마이크 버튼  │───▶│ Web Speech   │───▶│ POST /api/voice-search  │    │
│  │           │    │ API (STT)    │    │ { text, type, ... }     │    │
│  └───────────┘    └──────────────┘    └────────────┬────────────┘    │
└────────────────────────────────────────────────────│─────────────────┘
                                                     ▼
┌──────────────────────────────────────────────────────────────────────┐
│  Backend (Spring Boot)                                               │
│                                                                      │
│  VoiceSearchController                                               │
│       │                                                              │
│       ▼                                                              │
│  VoiceSearchService                                                  │
│       │                                                              │
│       ├── type 1 ─▶ Gemini API (자연어 파싱) ─▶ AttractionService       │
│       │                                         (관광지 검색)           │
│       │                                                              │
│       └── type 2 ─▶ Gemini API (안내문 생성) ─▶ OpenAI TTS API          │
│                                                  (음성 합성)           │
└──────────────────────────────────────────────────────────────────────┘

3. 구현 과정

3-1. API 설계

하나의 통합 엔드포인트(POST /api/voice-search)를 두고, type 파라미터로 기능을 분기하는 구조입니다.

Request DTO

@Getter @Setter
public class VoiceSearchRequest {
    @NotBlank
    private String text;       // 음성 인식된 자연어 텍스트

    @NotNull
    private Integer type;      // 1: 검색, 2: 상세 음성 안내

    private Long attractionId; // type 2일 때 필수
}

Controller

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/voice-search")
public class VoiceSearchController {

    private final VoiceSearchService voiceSearchService;

    @PostMapping
    public ResponseEntity<ApiResponse<Object>> search(
            @AuthenticationPrincipal CustomUserDetails userDetails,
            @Valid @RequestBody VoiceSearchRequest request) {

        Long userId = userDetails != null ? userDetails.getUserId() : null;

        Object data = voiceSearchService.search(
                request.getText(), request.getType(),
                userId, request.getAttractionId());

        return ResponseEntity.ok(
                ApiResponse.success(SuccessCode.OK, "음성 검색에 성공했습니다.", data));
    }
}

인증은 필수가 아니도록 처리했습니다.
비로그인 사용자도 음성 검색이 가능하되, 로그인 사용자라면 저장된 접근성 프로필(휠체어, 시각장애 등)을 반영하도록 하였습니다.


3-2. 자연어 → 구조화된 파라미터 변환 프롬프팅

음성 입력 기반 관광지 검색의 핵심은 "서울에서 휠체어 타고 갈 수 있는 맛집" 같은 자연어를 regionCode=11, physical=true, contentTypeIds=["39"] 같은 구조화된 검색 파라미터로 변환하는 것입니다.

프롬프트 설계

Gemini가 정확한 코드 값을 반환할 수 있도록, 지역 코드 매핑 테이블과 접근성 필터 규칙을 프롬프트 자체에 포함했습니다.

public static final String ATTRACTION_SEARCH = """
    너는 관광지 검색 파라미터 변환기야. 
    사용자의 자연어 입력을 분석해서 정확히 아래 JSON 형식으로만 출력해.

    출력 형식:
    {"regionCode":"값","sigunguCode":"값","keyword":"값",
     "page":0,"size":20,
     "physical":false,"infantFamily":false,"visual":false,"hearing":false,
     "contentTypeIds":["값"]}

    [지역 코드 매핑 (법정동 시/2자리 코드)]
    서울=11, 부산=26, 대구=27, 인천=28, 광주=29, 대전=30, 울산=31,
    세종=36110, 경기=41, 충북=43, 충남=44, 전남=46, 경북=47, 경남=48,
    제주=50, 강원=51, 전북=52

    [접근성 필터 규칙]
    - 휠체어, 지체장애, 이동약자, 거동이 불편 → physical=true
    - 시각장애, 점자 → visual=true
    - 청각장애, 수어 → hearing=true
    - 영유아, 유모차, 아이 → infantFamily=true

    사용자 입력: %s""";

AI 응답 파싱

Gemini의 응답을 VoiceSearchParsedResult DTO로 변환합니다.
AI 특성상 JSON 앞뒤에 공백이나 줄바꿈이 붙을 수 있으므로 trim() 처리를 해주었습니다.

public VoiceSearchParsedResult parseVoiceInput(String text, int type) {
    // 1. 프롬프트 생성
    String prompt = String.format(AIPromptTemplate.ATTRACTION_SEARCH, text);

    // 2. Gemini API 호출
    String responseText = gmsGeminiClient.generateContent(prompt);

    // 3. JSON → DTO 변환
    String trimmed = responseText.trim();
    return objectMapper.readValue(trimmed, VoiceSearchParsedResult.class);
}

파싱 결과로 기존 검색 API 재사용

변환된 파라미터를 기존 AttractionSearchRequest로 매핑하여 일반 검색 로직을 재사용했습니다.

private VoiceSearchResponse searchAttractions(String text, Long userId) {
    // AI 파싱
    VoiceSearchParsedResult parsed = aiService.parseVoiceInput(text, 1);

    // 기존 검색 Request로 변환
    AttractionSearchRequest request = toAttractionSearchRequest(parsed);

    // 기존 AttractionService 호출 
    Page<AttractionListResponse> page =
            attractionService.searchAttractions(userId, request);

    return VoiceSearchResponse.of(AttractionPageResponse.from(page), parsed);
}

이처럼 음성 검색 전용 쿼리를 새로 만들지 않고, 기존 AttractionService.searchAttractions()를 그대로 호출하여, 검색 로직에서의 변경 사항(필터링 규칙 등)을 신경쓰지 않아도 되도록 하였습니다.


3-3. 관광지 상세 음성 안내 + TTS 음성 합성

관광지 음성 안내 기능은 특정 관광지의 상세 정보를 접근성 프로필에 맞게 자연어 스크립트로 생성하고, 이를 OpenAI TTS API로 음성 합성하는 과정을 거칩니다.

처리 흐름

관광지 정보 조회 → 유저 접근성 프로필 조회 → 프롬프트 구성 → Gemini 호출
→ 생성된 안내문 파싱 → OpenAI TTS 호출 → Base64 인코딩 → 응답 반환

핵심 구현 코드

private VoiceSearchDetailResponse readAttractionDetail(
        String text, Long userId, Long attractionId) {

    // 1. 관광지 정보 + 접근성 편의시설 조회
    AttractionDetailResponse attraction =
            attractionService.getAttractionDetail(attractionId);

    // 2. 유저의 접근성 프로필 조회
    boolean physical = false, visual = false, hearing = false, infantFamily = false;
    if (userId != null) {
        UserDetail userDetail = userDetailRepository.findById(userId).orElse(null);
        if (userDetail != null) {
            physical = Boolean.TRUE.equals(userDetail.getPhysical());
            // ... 나머지 접근성 필드
        }
    }

    // 3. 프롬프트 구성 → Gemini API 호출
    String prompt = String.format(
            AIPromptTemplate.ATTRACTION_DETAIL_READ,
            attraction.getName(), attraction.getAddress(),
            accessibilityText, attraction.getOverview(),
            physical, infantFamily, visual, hearing, text);

    String responseText = gmsGeminiClient.generateContent(prompt);
    VoiceSearchDetailResponse detailResponse =
            aiService.parseVoiceDetailRead(responseText);

    // 4. TTS 음성 합성
    String audioDataBase64 = null;
    if (detailResponse.getReadText() != null && !detailResponse.getReadText().isBlank()) {
        byte[] speechBytes = gmsOpenAIClient.generateSpeech(detailResponse.getReadText());
        audioDataBase64 = Base64.getEncoder().encodeToString(speechBytes);
    }

    return VoiceSearchDetailResponse.builder()
            .readText(detailResponse.getReadText())
            .audioData(audioDataBase64)
            .build();
}

TTS 처리

OpenAI의 gpt-4o-mini-tts 모델을 사용하여 텍스트를 MP3 바이너리로 변환합니다.

public byte[] generateSpeech(String text) {
    String url = "https://gms.ssafy.io/gmsapi/api.openai.com/v1/audio/speech";

    Map<String, Object> requestBody = Map.of(
            "model", "gpt-4o-mini-tts",
            "input", text,
            "voice", "alloy"
    );

    return webClient.post()
            .uri(url)
            .contentType(MediaType.APPLICATION_JSON)
            .headers(h -> h.setBearerAuth(properties.getApiKey()))
            .bodyValue(requestBody)
            .retrieve()
            .bodyToMono(byte[].class)
            .block();
}

음성 합성 실패 시에도 audioDatanull로 두고 readText(텍스트)는 정상 반환하도록 하였습니다. 그리고 프론트엔드에서 audioData가 없으면 브라우저 내장 TTS(SpeechSynthesis)로 폴백하도록 하여, 음성이 최대한 출력될 수 있게 처리했습니다.


4. 트러블슈팅

4-1. 로컬에서 bootRun 실패 : .env 파일 포맷 인식 오류

문제 상황

API 키, DB 접속 정보 등 민감한 설정값을 .env 파일로 관리하고, application.yaml에서 이를 불러오도록 설정했습니다.

# application.yaml
spring:
  config:
    import: optional:file:.env

배포 환경(Docker/AWS)에서는 정상 동작했지만, 로컬 개발 환경에서 ./gradlew bootRun을 실행하면 빌드 실패가 발생했습니다.

> Task :bootRun FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':bootRun'.
> Process 'command 'java'' finished with non-zero exit value 1

스택트레이스를 확인해보니 핵심 원인은 다음이었습니다:

UnsupportedConfigDataLocationException: 
  Unsupported config data location 'file:.env'

문제 원인

원인은 배포 환경과 로컬 환경의 차이에 있었습니다.

배포 환경

  • Spring Boot가 optional:file:.env를 읽으려 시도하지만, 배포 경로에 파일이 존재하지 않음
  • optional: 키워드 덕분에 파일이 없으면 에러 없이 무시하고 넘어감
  • 설정값은 Docker/OS가 주입한 시스템 환경 변수에서 정상 로드
  • 즉, 파일을 실제로 읽지 않으므로 포맷 문제가 드러나지 않음

로컬 환경

  • .env 파일이 실제로 존재하므로 Spring Boot가 이 파일을 읽으려 시도
  • 하지만 Spring Boot는 .env 확장자를 알지 못함 (기본 지원: .properties, .yml만 해석 가능)
  • 파일 포맷을 판별할 수 없어 UnsupportedConfigDataLocationException 발생

문제 해결

[.properties] 확장자 힌트를 붙여서, Spring Boot에게 "이 파일은 .properties 형식으로 읽을 것"을 명시적으로 알려주면 해결이 됩니다.

  spring:
    config:
-     import: optional:file:.env
+     import: optional:file:.env[.properties]

4-2. audioData가 계속 null로 반환됨 : DataBufferLimitException

문제 상황

관광지 음성 안내 API 호출 시, readText는 정상적으로 반환되는데 audioData가 항상 null로 오는 현상이 발생했습니다.

{
  "success": true,
  "data": {
    "readText": "안녕하세요! 춘천의 아름다운 천년고찰, 청평사에 오신 것을 환영합니다...",
    "audioData": null
  }
}

서버 로그를 확인해보니 다음과 같은 에러가 찍혀 있었습니다:

음성 합성(TTS) 중 오류 발생: Exceeded limit on max bytes to buffer : 262144

문제 원인

Spring WebFlux의 WebClient는 기본적으로 응답 바디를 메모리에 버퍼링할 때 최대 256KB(262,144 bytes) 제한이 있습니다. 생성된 관광지 상세 안내문이 300~1000자 내외의 한국어 텍스트인데, 이를 TTS로 변환하면 MP3 바이너리가 MB 단위까지 커질 수 있습니다.

즉, OpenAI TTS API의 응답(MP3 바이너리)이 256KB를 초과하면서 DataBufferLimitException이 발생하고, catch 블록에서 null 처리된 것입니다.

문제 해결

WebClientConfig에서 maxInMemorySize10MB로 상향 조정했습니다.

@Configuration
public class WebClientConfig {

    @Bean
    public WebClient webClient() {
        return WebClient.builder()
                .codecs(configurer -> configurer
                        .defaultCodecs()
                        .maxInMemorySize(10 * 1024 * 1024)) // 256KB → 10MB
                .build();
    }
}

적용 후, 동일한 요청에서 audioData가 정상적으로 Base64 인코딩된 MP3 데이터로 반환되는 것을 확인할 수 있었습니다.

{
  "success": true,
  "data": {
    "readText": "안녕하세요! 춘천의 아름다운 천년고찰...",
    "audioData": "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwL..."
  }
}

5. 배운 점

1. 재사용의 중요성

  • 음성 검색 전용 쿼리를 새로 만들지 않고, 기존 서비스를 호출하는 구조로 설계했습니다. 이를 통해 검색 로직이 변경되어도 음성 검색에 자동 반영될 수 있도록 하였습니다.

2. 최선이 안 된다면 차선이라도 반환하자

  • TTS 실패 시 500 에러를 내지 않고, 음성 합성되지 않은 텍스트만이라도 반환하도록 설계했습니다. 프론트엔드에서는 audioData가 없으면 브라우저 내장 TTS로 폴백하여 사용자 경험을 최대한 유지할 수 있도록 하였습니다.

3. 로그의 중요성

  • DataBufferLimitException 발생 시 클라이언트 응답에는 audioData가 null로만 나타나서, 서버 로그가 없었다면 원인 파악이 힘들었을 것입니다. catch 블록에서 에러를 삼킬 때 로그를 남기는 것의 중요성을 다시금 느낄 수 있었습니다.

이후에 생각하고 있는 개선 사항으로는 엔드포인트 분리가 우선되어야 할 것 같습니다.
현재는 POST /api/voice-search에 type 파라미터로 검색(1)과 음성 안내(2)를 분기하고 있지만, 두 기능의 응답 구조가 완전히 다르다는 점에서 엔드포인트 분리가 더 적합하다는 생각이 음성 안내 기능 작업 중에 들어버렸습니다.. ㅠㅠ
사실 음성 합성을 할 생각까지는 없었는데 브라우저 TTS가 엄청 인위적이더라고요.

그래서 프론트 작업만 끝나면 바로 엔드포인트를 분리해서 각 기능이 고유한 Request/Response 타입을 가지게 하고, 타입 안정성 측면에서 개선될 수 있도록 하려고 합니다.

그럼 프로젝트 발표날까지 마저 힘내보겠습니다!
피드백은 언제든 환영입니다!

profile
필연적인 프로그래밍

2개의 댓글

comment-user-thumbnail
2026년 6월 15일

퀄리티가 장난아니네요''''

1개의 답글