Lifelog 웹 애플리케이션은 비용을 아끼기 위해 Google 개인 계정의 Google Drive를 이미지 저장소로 사용하고 있다. 비용이 들지 않는 대신 끔찍할 정도로 느린 속도를 자랑하는데, 이미 이 문제를 타파하고자 경로 탐색 시 Cache를 적용한 바가 있다. (아래)
Google Drive 이미지 프록시 성능 개선 — Virtual Thread와 경량 캐시 적용
배경 — 왜 느린가
현재 /photo/** 요청 흐름:
Browser → Nginx → Spring Boot(/photo/**) → Google Drive API (이미지 다운로드) → 응답
이미지를 Google Drive에 직접 저장하고 Spring Boot 애플리케이션이 Proxy 역할을 하는 구조이다. fileId와 folderId는 Valkey에 캐시되어 있어 Drive를 매번 탐색하지는 않지만, 이미지 바이트 자체는 매 요청마다 Google Drive에서 새로 다운로드한다. 그럼 Spring Boot 애플리케이션 내에서 다운로드 받은 이미지 바이트를 Stream 형태로 처리하여 Content-Type을 image/jpeg 등의 형태로 설정하고 Client에 응답한다.
즉, 이미지 용량이 예를 들어 5MB 정도라면, 서버에서 Google Drive에서 5MB 가량의 바이트를 다운로드 받고, 그것을 다시 Client에게 내려줘야 한다. 5MB의 두 배인 약 10MB 수준의 네트워크 트래픽이 발생한다.
이거는 어쩔 수 없는 구조이지만, 그래도 서비스로 사용하기에는 지독할 정도로 느리다는게 문제였다.

느린 이유를 이런 구조적인 한계 이외에 다른 시점에서 찾아보면 두 가지 정도가 있었다.
- metadata API 호출 — 이미지를 다운로드하기 전에
mimeType을 조회하기 위해drive.files().get(fileId).execute()를 별도로 호출하고 있었다. 다운로드와 병렬로 실행되긴 하지만, Google Drive API 왕복이 한 번 더 발생하는 구조이다. - 이미지 바이트 캐시 부재 — 응답 헤더에
Cache-Control: public, max-age=86400이 붙어 있어 브라우저 캐시는 동작하지만(Cloudflare의 DNS에서 Proxy설정을 적용해 놓았다), 서버 측에서는 매 요청마다 Google Drive에서 다운로드한다. 브라우저 캐시가 없는 첫 방문자나 캐시가 만료된 경우에는 항상 느리다.
개선 1 — metadata API 호출 제거
문제
기존 코드는 이미지 다운로드 시 mimeType 확인을 위해 Google Drive에 두 번 요청했다.
// 기존 코드
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에 물어볼 필요가 없다. 파일명의 확장자로 충분히 판별할 수 있다. 이미지 업로드 시 확장자가 없는 파일은 애초에 걸러지므로 실용적으로 문제가 없다.
// 개선 후
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을 이용하면 코드 몇 줄로 구현 가능하다.
// 구현은 간단하지만...
@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 설정
# 캐시 저장소 선언 (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-extras로 ngx_cache_purge 모듈을 설치해 캐시를 즉시 삭제하는 PURGE 요청을 보낼 수 있다. 그러나 AlmaLinux 등 RHEL 계열에서는 이 모듈이 패키지로 제공되지 않아 소스 컴파일이 필요하며, nginx 업데이트 시마다 재컴파일해야 하는 운영 부담이 생긴다.
대신 proxy_cache_bypass 디렉티브를 활용하면 추가 모듈 없이 동일한 목적을 달성할 수 있다. 특정 요청 헤더(X-Cache-Refresh)가 있을 때 캐시를 건너뛰고 upstream에서 새로 받아와 캐시를 덮어쓴다. 기존 캐시 파일이 디스크에 즉시 삭제되지 않는다는 차이가 있지만, 다음 요청부터는 새 캐시가 사용되므로 실용적으로 문제가 발생할 소지는 적다고 할 수 있다. 오래된 캐시는 inactive 기간이 지나면 자동 삭제된다.
3-2. Spring — 업로드 후 캐시 갱신
업로드 완료 후 해당 경로의 캐시를 즉시 갱신한다. X-Cache-Refresh 헤더를 붙여 Nginx에 요청하면, Nginx는 캐시를 우회하고 Spring으로부터 새 응답을 받아 캐시를 덮어쓴다.
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가 응답을 캐시에 미리 저장하는 방법이 있다. 그러면 이후 사용자의 첫 요청부터 이미 캐시 히트가 발생한다.
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()에 통합
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가 반복되는 것을 확인했다.
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가 발생하고 있었다.
실제 요청 흐름:
Browser → Cloudflare (MISS) → Nginx → Spring Boot → Google Drive
Nginx proxy_cache는 정상 동작 중이었다(X-Cache-Status: HIT 확인). 문제는 Cloudflare 레이어에만 있었다.
# 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

| 항목 | 설정 |
|---|---|
| 조건 | 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 준수) |


Edge TTL을 Ignore cache-control header로 설정한 이유: Spring Boot가 max-age=86400 (1일)을 보내는데, Cloudflare 엣지는 14일로 유지하기 위해서다. 이미지 변경 시에는 업로드 로직의 X-Cache-Refresh 헤더로 nginx 캐시를 갱신하고, Cloudflare는 TTL 만료 후 자동 갱신된다.
4-3. 적용 결과
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를 거친 것이 아니다.
전체 흐름 요약
캐싱 레이어 구조
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 호출이 발생한다.
[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 캐시 저장 → 응답 반환
이렇게 캐시를 적용하자, 이미지 응답 속도는 이제서야 비로소 체감이 가능할 정도로 대폭 절감되었다.