
이음 서비스 구경하기
*아직 프로젝트 진행 중임을 감안해주세요!
SSAFY 1학기 프로젝트로 진행 중인 무장애 여행 플래너 서비스 이음은 초기 기획 단계부터 "모두의 여행을 잇는다"는 서비스 목표에 맞게, 다양한 사용자가 쉽게 이용할 수 있도록 음성 입력 기반의 동작을 지원하기로 했습니다. 음성 입력으로 손 쉽게 원하는 관광지를 검색하고, 관광지에 대한 설명을 듣고, 일정을 수정할 수 있도록요.
이번 글에서는 음성 입력 기반 관광지 검색과 관광지에 대한 음성 안내 기능을 어떻게 설계하였는지, 그리고 구현하면서 겪은 트러블슈팅 내용을 기록하려고 합니다.
이음은 장애인, 영유아 동반 가족 등 이동약자를 위한 무장애 여행 플래너 서비스입니다. 이번에는 음성 입력을 활용한 두 가지 핵심 기능을 구현했습니다.
| Type | 기능 | 설명 |
|---|---|---|
| 1 | 음성 기반 관광지 검색 | "서울에서 휠체어 타고 갈 수 있는 관광지 알려줘" → AI가 파싱 → 관광지 목록 반환 |
| 2 | 관광지 상세 음성 안내 | "이 관광지 정보 읽어줘" → AI가 안내 스크립트 생성 → TTS로 음성 합성 후 반환 |
프론트엔드에서는 브라우저의 Web Speech API(SpeechRecognition)를 사용하여 음성을 텍스트로 변환하고, 변환된 텍스트를 백엔드 API에 전달합니다.
┌──────────────────────────────────────────────────────────────────────┐
│ 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 │
│ (음성 합성) │
└──────────────────────────────────────────────────────────────────────┘
하나의 통합 엔드포인트(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));
}
}
인증은 필수가 아니도록 처리했습니다.
비로그인 사용자도 음성 검색이 가능하되, 로그인 사용자라면 저장된 접근성 프로필(휠체어, 시각장애 등)을 반영하도록 하였습니다.
음성 입력 기반 관광지 검색의 핵심은 "서울에서 휠체어 타고 갈 수 있는 맛집" 같은 자연어를 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""";
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);
}
변환된 파라미터를 기존 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()를 그대로 호출하여, 검색 로직에서의 변경 사항(필터링 규칙 등)을 신경쓰지 않아도 되도록 하였습니다.
관광지 음성 안내 기능은 특정 관광지의 상세 정보를 접근성 프로필에 맞게 자연어 스크립트로 생성하고, 이를 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();
}
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();
}
음성 합성 실패 시에도 audioData만 null로 두고 readText(텍스트)는 정상 반환하도록 하였습니다. 그리고 프론트엔드에서 audioData가 없으면 브라우저 내장 TTS(SpeechSynthesis)로 폴백하도록 하여, 음성이 최대한 출력될 수 있게 처리했습니다.
.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'
원인은 배포 환경과 로컬 환경의 차이에 있었습니다.
배포 환경
optional:file:.env를 읽으려 시도하지만, 배포 경로에 파일이 존재하지 않음optional: 키워드 덕분에 파일이 없으면 에러 없이 무시하고 넘어감로컬 환경
.env 파일이 실제로 존재하므로 Spring Boot가 이 파일을 읽으려 시도.env 확장자를 알지 못함 (기본 지원: .properties, .yml만 해석 가능)UnsupportedConfigDataLocationException 발생[.properties] 확장자 힌트를 붙여서, Spring Boot에게 "이 파일은 .properties 형식으로 읽을 것"을 명시적으로 알려주면 해결이 됩니다.
spring:
config:
- import: optional:file:.env
+ import: optional:file:.env[.properties]
관광지 음성 안내 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에서 maxInMemorySize를 10MB로 상향 조정했습니다.
@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..."
}
}
1. 재사용의 중요성
2. 최선이 안 된다면 차선이라도 반환하자
3. 로그의 중요성
DataBufferLimitException 발생 시 클라이언트 응답에는 audioData가 null로만 나타나서, 서버 로그가 없었다면 원인 파악이 힘들었을 것입니다. catch 블록에서 에러를 삼킬 때 로그를 남기는 것의 중요성을 다시금 느낄 수 있었습니다. 이후에 생각하고 있는 개선 사항으로는 엔드포인트 분리가 우선되어야 할 것 같습니다.
현재는 POST /api/voice-search에 type 파라미터로 검색(1)과 음성 안내(2)를 분기하고 있지만, 두 기능의 응답 구조가 완전히 다르다는 점에서 엔드포인트 분리가 더 적합하다는 생각이 음성 안내 기능 작업 중에 들어버렸습니다.. ㅠㅠ
사실 음성 합성을 할 생각까지는 없었는데 브라우저 TTS가 엄청 인위적이더라고요.
그래서 프론트 작업만 끝나면 바로 엔드포인트를 분리해서 각 기능이 고유한 Request/Response 타입을 가지게 하고, 타입 안정성 측면에서 개선될 수 있도록 하려고 합니다.
그럼 프로젝트 발표날까지 마저 힘내보겠습니다!
피드백은 언제든 환영입니다!
퀄리티가 장난아니네요''''