Blog / Java/Kotlin / Google Drive 이미지 프록시 성능 개선 2

Google Drive 이미지 프록시 성능 개선 2

Lifelog 웹 애플리케이션은 비용을 아끼기 위해 Google 개인 계정의 Google Drive를 이미지 저장소로 사용하고 있다. 비용이 들지 않는 대신 끔찍할 정도로 느린 속도를 자랑하는데, 이미 이 문제를 타파하고자 경로 탐색 시 Cache를 적용한 바가 있다. (아래)

Google Drive 이미지 프록시 성능 개선 — Virtual Thread와 경량 캐시 적용


배경 — 왜 느린가

현재 /photo/** 요청 흐름:

text
Browser → Nginx → Spring Boot(/photo/**) → Google Drive API (이미지 다운로드) → 응답

이미지를 Google Drive에 직접 저장하고 Spring Boot 애플리케이션이 Proxy 역할을 하는 구조이다. fileIdfolderId는 Valkey에 캐시되어 있어 Drive를 매번 탐색하지는 않지만, 이미지 바이트 자체는 매 요청마다 Google Drive에서 새로 다운로드한다. 그럼 Spring Boot 애플리케이션 내에서 다운로드 받은 이미지 바이트를 Stream 형태로 처리하여 Content-Type을 image/jpeg 등의 형태로 설정하고 Client에 응답한다.
즉, 이미지 용량이 예를 들어 5MB 정도라면, 서버에서 Google Drive에서 5MB 가량의 바이트를 다운로드 받고, 그것을 다시 Client에게 내려줘야 한다. 5MB의 두 배인 약 10MB 수준의 네트워크 트래픽이 발생한다.

이거는 어쩔 수 없는 구조이지만, 그래도 서비스로 사용하기에는 지독할 정도로 느리다는게 문제였다.

응답에 거의 수십초가 걸리는 상황
응답에 거의 수십초가 걸리는 상황

느린 이유를 이런 구조적인 한계 이외에 다른 시점에서 찾아보면 두 가지 정도가 있었다.

  1. metadata API 호출 — 이미지를 다운로드하기 전에 mimeType을 조회하기 위해 drive.files().get(fileId).execute()를 별도로 호출하고 있었다. 다운로드와 병렬로 실행되긴 하지만, Google Drive API 왕복이 한 번 더 발생하는 구조이다.
  2. 이미지 바이트 캐시 부재 — 응답 헤더에 Cache-Control: public, max-age=86400이 붙어 있어 브라우저 캐시는 동작하지만(Cloudflare의 DNS에서 Proxy설정을 적용해 놓았다), 서버 측에서는 매 요청마다 Google Drive에서 다운로드한다. 브라우저 캐시가 없는 첫 방문자나 캐시가 만료된 경우에는 항상 느리다.

개선 1 — metadata API 호출 제거
문제

기존 코드는 이미지 다운로드 시 mimeType 확인을 위해 Google Drive에 두 번 요청했다.

kotlin
// 기존 코드
val metaFuture = asyncSupply(virtualThreadExecutor) {
    drive.files().get(fileId)
        .setFields("id, name, mimeType, size")
        .execute()                                   // ← Google Drive 왕복 ①
}
val downloadFuture = asyncSupply(virtualThreadExecutor) {
    val buffer = ByteArrayOutputStream()
    drive.files().get(fileId).executeMediaAndDownloadTo(buffer)  // ← Google Drive 왕복 ②
    buffer.toByteArray()
}
val fileMeta = metaFuture.get()
val mimeType = fileMeta.mimeType

병렬로 실행되므로 직렬보다는 낫지만, API 왕복 자체가 하나 더 있다는 사실은 변하지 않는다.

해결

mimeType은 Google Drive에 물어볼 필요가 없다. 파일명의 확장자로 충분히 판별할 수 있다. 이미지 업로드 시 확장자가 없는 파일은 애초에 걸러지므로 실용적으로 문제가 없다.

kotlin
// 개선 후
private fun resolveMimeType(fileName: String): String? {
    return when (fileName.substringAfterLast('.', "").lowercase()) {
        "jpg", "jpeg" -> "image/jpeg"
        "png"         -> "image/png"
        "webp"        -> "image/webp"
        "gif"         -> "image/gif"
        "heic"        -> "image/heic"
        else          -> null
    }
}

fun getImageByPath(path: String): ImageResource? {
    // ...
    val mimeType = resolveMimeType(fileName)
        ?: throw RuntimeException("file is not image : $fileName (경로: $path)")

    val buffer = ByteArrayOutputStream()
    drive.files().get(fileId).executeMediaAndDownloadTo(buffer)
    val bytes = buffer.toByteArray()

    return ImageResource(
        inputStream = ByteArrayInputStream(bytes),
        mimeType = mimeType,
        fileName = fileName,
        fileSize = bytes.size.toLong()
    )
}

metadata 호출이 사라지면서 Google Drive API 왕복이 1회 줄고, 코드도 단순해졌다.


개선 2 — Valkey/Redis 이미지 캐시를 선택하지 않은 이유

Thumbnail 이미지 바이트를 Valkey/Redis에 캐시하는 방법도 잠깐은 생각해보았다. @DynamicCacheable을 이용하면 코드 몇 줄로 구현 가능하다.

kotlin
// 구현은 간단하지만...
@DynamicCacheable(value = ["driveImageBytes"], key = "#path", ttlMinutes = 1440)
fun getImageBytes(path: String, fileId: String, drive: Drive): ByteArray {
    val buffer = ByteArrayOutputStream()
    drive.files().get(fileId).executeMediaAndDownloadTo(buffer)
    return buffer.toByteArray()
}

그러나 이 방식은 부적절하다. Redis는 인메모리 저장소다. 이 프로젝트는 Aiven Cloud의 SaaS로 Valkey를 사용하고 있는데, 무료 버전이라 스펙이 1 CPU 1GB 메모리가 전부다. 원본 사진 한 장이 수 MB에 달할 수 있는데, 이를 Valkey에 올리면 메모리 압박이 심해진다. 사진이 수십 장만 쌓여도 수백 MB가 Valkey 메모리에 상주하게 된다.

반면 Nginx proxy_cache는 디스크에 쓴다. 디스크는 메모리보다 훨씬 저렴하고 용량도 크다. HTTP 응답 전체(헤더 포함)를 그대로 저장하므로 Spring Boot의 WAS를 완전히 우회할 수 있고, 캐시 히트 시 응답 시간은 5ms 미만으로 줄일 수 있다.

Redis 이미지 캐시 Nginx proxy_cache
저장 위치 인메모리 디스크
용량 제한 메모리 한도에 민감 수 GB 설정 가능
Spring Boot 우회 X (여전히 Spring 거침) O (Nginx에서 직접 서빙)
응답 속도 빠름 더 빠름
구현 복잡도 낮음 중간 (Nginx 설정 필요)

이미지처럼 크기가 크고 변경이 드문 정적 리소스는 Nginx 레벨에서 캐시하는 것이 구조적으로 더 적합하다.


개선 3 — Nginx proxy_cache 적용

정적 리소스는 역시 웹서버에서 서비스하는 것이 효율적이다.


3-1. Nginx 설정
nginx
# 캐시 저장소 선언 (http 블록)
proxy_cache_path /var/cache/nginx/photo
                 levels=1:2
                 keys_zone=photo_cache:10m
                 max_size=3g
                 inactive=14d;

# 이미지 서빙 (server 블록)
location /photo/ {
    proxy_pass http://127.0.0.1:8080;
    proxy_cache photo_cache;
    proxy_cache_key "$uri";
    proxy_cache_valid 200 14d;
    proxy_cache_use_stale error timeout updating;
    add_header X-Cache-Status $upstream_cache_status;

    # ↓ 업스트림의 Cache-Control 무시하고 nginx 기준으로 캐시
    proxy_ignore_headers Cache-Control Expires;
    # 업로드 후 캐시 갱신용 — X-Cache-Refresh 헤더가 있으면 캐시 우회 후 재저장
    proxy_cache_bypass $http_x_cache_refresh;
    proxy_no_cache     $http_x_cache_refresh;
}
캐시 생명주기
조건 설명
proxy_cache_valid 200 14d HTTP 200 응답은 14일간 유효
inactive=14d 14일간 요청 없으면 유효 기간과 무관하게 삭제
max_size=3g 디스크 한도 초과 시 LRU 순으로 삭제

캐시가 적재되는 /var/cache/nginx/photo 경로의 디렉터리는 반드시 nginx 계정이 쓰기 권한을 가지고 있어야 한다. 그렇지 않으면 Nginx 구동 자체가 실패한다.

purge 모듈에 대하여

Debian/Ubuntu 환경이라면 apt install nginx-extrasngx_cache_purge 모듈을 설치해 캐시를 즉시 삭제하는 PURGE 요청을 보낼 수 있다. 그러나 AlmaLinux 등 RHEL 계열에서는 이 모듈이 패키지로 제공되지 않아 소스 컴파일이 필요하며, nginx 업데이트 시마다 재컴파일해야 하는 운영 부담이 생긴다.

대신 proxy_cache_bypass 디렉티브를 활용하면 추가 모듈 없이 동일한 목적을 달성할 수 있다. 특정 요청 헤더(X-Cache-Refresh)가 있을 때 캐시를 건너뛰고 upstream에서 새로 받아와 캐시를 덮어쓴다. 기존 캐시 파일이 디스크에 즉시 삭제되지 않는다는 차이가 있지만, 다음 요청부터는 새 캐시가 사용되므로 실용적으로 문제가 발생할 소지는 적다고 할 수 있다. 오래된 캐시는 inactive 기간이 지나면 자동 삭제된다.


3-2. Spring — 업로드 후 캐시 갱신

업로드 완료 후 해당 경로의 캐시를 즉시 갱신한다. X-Cache-Refresh 헤더를 붙여 Nginx에 요청하면, Nginx는 캐시를 우회하고 Spring으로부터 새 응답을 받아 캐시를 덮어쓴다.

kotlin
private fun refreshNginxCache(path: String) {
    runCatching {
        val headers = HttpHeaders().apply { set("X-Cache-Refresh", "1") }
        restTemplate.exchange(
            "http://127.0.0.1/photo/$path",
            HttpMethod.GET,
            HttpEntity<Void>(headers),
            ByteArray::class.java
        )
    }.onFailure { log.warn("Nginx cache refresh 실패: $path", it) }
}

3-3. Spring — 업로드 후 캐시 워밍 (Cache Warm-up)

신규 업로드된 이미지는 아직 캐시가 없다. 불운하게도 이미지를 업로드한 이후 가장 처음 보는 사용자는 느려터진 속도를 참아야 한다. 이것조차 납득이 어렵다면 서버에서 업로드 완료 직후 /photo/ 엔드포인트를 자기 호출시켜서 Nginx가 응답을 캐시에 미리 저장하는 방법이 있다. 그러면 이후 사용자의 첫 요청부터 이미 캐시 히트가 발생한다.

kotlin
private fun warmUpNginxCache(path: String) {
    runCatching {
        restTemplate.getForEntity("http://127.0.0.1/photo/$path", ByteArray::class.java)
    }.onFailure { log.warn("Nginx cache warm-up 실패: $path", it) }
}
uploadImage()에 통합
kotlin
fun uploadImage(folderPath: String, file: MultipartFile): Pair<File, File> {
    // ... 기존 업로드 로직 ...
    googleDriveCacheSaver.evictAllFileIdCache()
    val result = Pair(mainJobFuture.get(), subJobFuture.get())

    // 원본 + 썸네일 비동기 캐시 워밍 (업로드 응답을 블로킹하지 않음)
    asyncSupply(virtualThreadExecutor) { warmUpNginxCache("$folderPath/${result.first.name}") }
    asyncSupply(virtualThreadExecutor) { warmUpNginxCache("$folderPath/${result.second.name}") }

    return result
}

asyncSupply로 비동기 처리하므로 업로드 API 응답은 캐시 워밍을 기다리지 않는다. Google Drive 다운로드는 워밍 시점에 1회 발생하며, 이후 사용자 요청은 Nginx 캐시에서 서빙된다.


개선 4 — Cloudflare Cache Rule 적용

lifelog 프로젝트는 도메인을 Cloudflare에서 구입했고, DNS 등을 Cloudflare를 통해 서비스 받고 있다. 접속 시 가장 앞에 있는 것이 Cloudflare다.


4-1. 문제 발견

Nginx proxy_cache 설정 후 동작을 확인하던 중, 응답 헤더에서 Cf-Cache-Status: MISS가 반복되는 것을 확인했다.

text
Cache-Control: public, max-age=86400
Cf-Cache-Status: MISS

Cache-Control: public이 정상적으로 붙어 있음에도 Cloudflare가 캐시하지 않고 있었다.

원인

Cloudflare는 기본적으로 URL의 파일 확장자를 기준으로 캐시 여부를 결정한다. /photo/lifelog/pictures/5/DSC09334-2.jpg처럼 확장자가 있는 경우도 있지만, /photo/ 경로 자체가 Cloudflare의 기본 캐시 대상 목록에 포함되지 않아 MISS가 발생하고 있었다.

실제 요청 흐름:

text
Browser → Cloudflare (MISS) → Nginx → Spring Boot → Google Drive

Nginx proxy_cache는 정상 동작 중이었다(X-Cache-Status: HIT 확인). 문제는 Cloudflare 레이어에만 있었다.

bash
# Nginx 캐시 동작 확인 (origin에 직접 요청)
curl -sI http://127.0.0.1:80/photo/lifelog/pictures/5/_DSC0976-2.jpg \
  | grep -i 'x-cache-status\|cache-control'

# 결과
Cache-Control: public, max-age=86400
X-Cache-Status: HIT

4-2. 해결 — Cloudflare Cache Rule

Cloudflare 대시보드 → Caching → Cache Rules → Create rule
Cloudflare 대시보드 → Caching → Cache Rules → Create rule

항목 설정
조건 URI Path starts with /photo/
Cache eligibility Eligible for cache
Edge TTL Ignore cache-control header → 14 days
Browser TTL Respect origin TTL (Cache-Control: max-age=86400 준수)

조건 : URI Path starts with /photo/
조건 : URI Path starts with /photo/

Edge TTL을 Ignore cache-control header로 설정한 이유: Spring Boot가 max-age=86400 (1일)을 보내는데, Cloudflare 엣지는 14일로 유지하기 위해서다. 이미지 변경 시에는 업로드 로직의 X-Cache-Refresh 헤더로 nginx 캐시를 갱신하고, Cloudflare는 TTL 만료 후 자동 갱신된다.


4-3. 적용 결과
text
accept-ranges: bytes
age: 3102
cf-cache-status: HIT        ← Cloudflare 엣지에서 서빙
x-cache-status: EXPIRED     ← Cloudflare가 origin 캐시 채울 때의 Nginx 상태 (현재 요청과 무관)
cache-control: public, max-age=86400

age: 3102는 Cloudflare가 약 52분 전에 캐시를 채웠음을 의미한다. x-cache-status: EXPIRED는 Cloudflare가 처음 origin에 요청했을 때 Nginx 캐시가 만료 상태였던 흔적이 캐시된 헤더에 그대로 남아있는 것으로, 현재 요청이 Nginx를 거친 것이 아니다.


전체 흐름 요약
캐싱 레이어 구조
text
Browser → Cloudflare → Nginx → Spring Boot → Google Drive
레이어 TTL 역할
Browser 1일 Cache-Control: max-age=86400
Cloudflare edge 14일 글로벌 엣지 서빙, 대부분의 요청 처리
Nginx proxy_cache 14일 Cloudflare MISS 시 Spring Boot 보호
왜 Nginx 캐시가 여전히 필요한가

Cloudflare가 항상 HIT일 수는 없다. 14일마다 만료되고, 트래픽이 적은 엣지 서버는 더 자주 캐시가 비워진다. Cloudflare MISS 시 Nginx 캐시가 없으면 Spring Boot → Google Drive API 호출이 발생한다.

text
[Cloudflare HIT — 대부분의 요청]
  Browser → Cloudflare (HIT) → 응답 반환
  ※ Nginx, Spring Boot, Google Drive 모두 우회

[Cloudflare MISS — 캐시 만료 또는 첫 요청]
  Browser → Cloudflare (MISS) → Nginx (HIT) → 응답 반환
  ※ Spring Boot, Google Drive 우회

[nginx도 MISS — nginx 캐시 만료 시]
  Browser → Cloudflare (MISS) → Nginx (MISS) → Spring Boot → Google Drive
  → Nginx 캐시 저장 → Cloudflare 캐시 저장 → 응답 반환

이렇게 캐시를 적용하자, 이미지 응답 속도는 이제서야 비로소 체감이 가능할 정도로 대폭 절감되었다.

Written by
author
풍우래기

여행을 좋아하는 집돌이 개발자입니다.

블로그에 새로운 글이 발행되었습니다.