티스토리 뷰
개요
제가 진행중인 프로젝트에서는 러닝을 달리는 사용자에게 음성을 통해 정보를 제공합니다. 아래 글은 해당 기능을 위한 서버 설정을 다룹니다. 클라우드는 GCP를 사용하기에 관련 설정도 GCP를 통해 이루어집니다.
서버 설정
우선 GCP Storage에 음성 파일을 저장해두고 API 요청 시 해당 음성 파일을 응답하는 기능을 만들어봅시다.
음성 파일 저장하기
가장 먼저 음성 파일이 필요합니다. 음성 파일을 wav 형식의 파일이면 됩니다.
OpenAI에서 간단하게 원하는 TTS 파일을 다운 받을 수 있어서 해당 기능을 활용해 음성 파일을 만들었습니다.

음성 타입도 선택할 수 있고, 스크립트를 통해 원하는 스타일을 선택해줄 수 있습니다.
이제 음성 파일을 API 호출 시 가져올 수 있도록 클라우드에 업로드 해야합니다. GCP에서 버킷을 만들고 음성 파일을 업로드 해줍니다.

- 버킷 이름: test-bucket-running
- 경로: test/smaple.wav
이제 권한을 부여할 서비스 계정을 만들어줍니다. 음성 파일을 가져오기 위해선 권한이 필요한데, 이를 위한 서비스 계정을 만들어줘야합니다.

만들어지는 서비스 계정에 대해서는 GCP 버킷에 접근할 수 있는 권한을 부여해야합니다.

부여한 권한은 다음과 같습니다.
- 저장소 개체 관리자
- 저장소 개체 생성자
- 저장소 관리자
그리고 서비스 계정의 키를 json 파일로 다운로드 받습니다.

다운로드 받은 json 파일을 WAS에서 사용할 수 있도록 resources 하위 경로에 둡니다. 해당 json 파일은 민감한 정보이기에 github에 올리지 않도록 주의해야합니다.

서버 설정
gradle
plugins {
id("org.springframework.boot") version "3.5.0"
}
...
extra["springCloudVersion"] = "2025.0.0"
dependencyManagement {
imports {
mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}")
}
}
...
dependencies {
...
// Google Cloud Platform
implementation("com.google.cloud:spring-cloud-gcp-starter-storage:6.2.2")
}
여기서 최신 버전을 확인해서 넣어주었습니다.mvnrepository.com 또한 6.2.2 버전을 사용하기 위해선 최소 3.5.0 이상 버전의 Spring Boot를 사용해줘야해서 버전을 업그레이드 해주었습니다.
application.yml
spring:
cloud:
gcp:
project-id: 프로젝트 ID
credentials:
location: json 파일 경로
audio:
bucket: 버킷 이름
프로젝트 ID와 버킷 이름은 GCP에 설정된 값을 넣어주면 됩니다. json 파일 경로는 절대 경로로 인식되어서 file:src/main/resources/파일이름.json 같이 설정해주면 됩니다.
코드
Service
@Service
class AudioService(
private val storage: Storage,
@Value("\${audio.bucket}") private val bucketName: String,
) {
fun loadBlob(filename: String): Pair<Blob, Resource> {
if (filename.contains("..")) {
throw CustomException(ErrorCode.INVALID_REQUEST)
}
val blobId = BlobId.of(bucketName, filename)
val blob =
storage.get(blobId)
?: throw CustomException(ErrorCode.AUDIO_NOT_FOUND)
val stream = Channels.newInputStream(blob.reader())
val resource = InputStreamResource(stream)
return blob to resource
}
}
Storage를 선언하면 Spring이 알아서 application 설정 파일에 설정한 값에 따라 버킷과 연결시켜줍니다.
Controller
GCP를 통해서 얻은 음성 파일을 API 응답 형식에 맞춰서 잘 보내주면 됩니다.
@RestController
@RequestMapping("/api/v1/audios")
class AudioController(
private val audioService: AudioService
) {
@GetMapping("/파일경로")
fun streamAudio(
request: HttpServletRequest,
@RequestHeader(value = "Range", required = false) rangeHeader: String?
): ResponseEntity<Resource> {
// 1) URL 에서 실제 파일 경로 추출
val rawPath = request.requestURI
val filename = URLDecoder.decode(
rawPath.removePrefix("/api/audios/"),
StandardCharsets.UTF_8
)
// 2) 기존 로직 재사용
val (blob, resource) = audioService.loadBlob(filename)
?: return ResponseEntity.notFound().build()
val totalSize = blob.size
val contentType = MediaType.parseMediaType(blob.contentType ?: "application/octet-stream")
// Range 미지정 시 전체 전송
if (rangeHeader == null) {
val headers = HttpHeaders().apply {
this.contentType = contentType
contentLength = totalSize
cacheControl = CacheControl.maxAge(7, TimeUnit.DAYS).cachePublic().toString()
}
return ResponseEntity.ok().headers(headers).body(resource)
}
// Range 요청 처리 (파싱 로직 동일)
val (start, end) = parseRange(rangeHeader, totalSize)
val chunkSize = end - start + 1
val rangedStream = Channels.newInputStream(blob.reader().apply { seek(start) })
val rangedResource = InputStreamResource(rangedStream)
val headers = HttpHeaders().apply {
this.contentType = contentType
contentLength = chunkSize
add(HttpHeaders.ACCEPT_RANGES, "bytes")
add(HttpHeaders.CONTENT_RANGE, "bytes $start-$end/$totalSize")
cacheControl = CacheControl.noCache().toString()
}
return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
.headers(headers)
.body(rangedResource)
}
private fun parseRange(header: String, total: Long): Pair<Long, Long> {
val matcher = Regex("""bytes=(\d*)-(\d*)""").find(header)
?: throw IllegalArgumentException("Invalid Range header: $header")
val (s, e) = matcher.destructured
val start = s.toLongOrNull() ?: 0L
val end = e.toLongOrNull() ?: (total - 1)
return start.coerceAtLeast(0) to end.coerceAtMost(total - 1)
}
}
파일 경로를 추출하고, 음성 파일 응답에 맞게 API 응답을 설정해줍니다.
저는 버킷 내부에 test 폴더 안에 sample.wav파일이 들어있습니다. 따라서 다음과 같이 요청을 보내면 됩니다.
GET api/v1/audios/test/sample.wav
해당 API를 호출해보면 아래와 같이 음성 파일이 오는 것을 확인할 수 있습니다.

'개발 스토리' 카테고리의 다른 글
| 문서화 툴 openapi3 + Stoplight 적용하기 (1) | 2025.07.15 |
|---|---|
| 요청 로그에 Body가 안나오는 문제 해결 (커스텀 Wrapper 도입) (3) | 2025.07.09 |
| logQL 파싱하여 로그에서 메타 데이터 추출하기 (3) | 2025.06.27 |
| RESTful API 엔드포인트에 대한 고민 (0) | 2025.06.24 |
| 기술 블로그 아카이빙 서비스 - TechLog (5) | 2025.06.02 |
- Total
- Today
- Yesterday
- 코루틴
- JWT
- 토스 next 2025
- 자바
- Assertions
- 우아한테크코스 후기
- 토스 백앤드 합격
- 우테코 준비
- 우아한테크코스
- 캐시 스템피드
- 우아한테크코스 자소서
- stoplight
- 알고리즘
- 우테코
- Cache Stampede
- 우테코 6기
- 게임개발
- 우테코 프리코스
- 6기
- 토큰
- 토스 2025 NEXT
- redis
- 레디스
- API 지연
- 우아한테크코스 6기
- 토스 합격 후기
- 커넥션 데드락
- 토스 NEXT 후기
- 분산락
- 파이썬
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |