티스토리 뷰

개요

제가 진행중인 프로젝트에서는 러닝을 달리는 사용자에게 음성을 통해 정보를 제공합니다. 아래 글은 해당 기능을 위한 서버 설정을 다룹니다. 클라우드는 GCP를 사용하기에 관련 설정도 GCP를 통해 이루어집니다.

서버 설정

우선 GCP Storage에 음성 파일을 저장해두고 API 요청 시 해당 음성 파일을 응답하는 기능을 만들어봅시다.

음성 파일 저장하기

가장 먼저 음성 파일이 필요합니다. 음성 파일을 wav 형식의 파일이면 됩니다.

OpenAI에서 간단하게 원하는 TTS 파일을 다운 받을 수 있어서 해당 기능을 활용해 음성 파일을 만들었습니다.

https://www.openai.fm/

음성 타입도 선택할 수 있고, 스크립트를 통해 원하는 스타일을 선택해줄 수 있습니다.

이제 음성 파일을 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를 호출해보면 아래와 같이 음성 파일이 오는 것을 확인할 수 있습니다.