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

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

이 개인 프로젝트에서 이미지 파일을 Google Drive에 저장하고, 서버가 프록시 역할을 하여 이미지를 서비스하는 구조를 사용하고 있다. 돈을 아끼려고...

text
[브라우저] → /photo/lifelog/nature/cherry.jpg
               ↓
          [Spring Boot 서버]
               ↓
          [Google Drive API]  ← 폴더 탐색 → 파일 검색 → 메타데이터 조회 → 파일 다운로드
               ↓
          [이미지 바이트 응답]

문제는 한 장의 이미지를 서비스하기 위해 Google Drive API를 최소 4~5회 호출해야 한다는 것이다. 포토아카이브 페이지에서 썸네일 여러 장을 동시에 로드하면, 단일 요청당 수백ms씩 걸리는 API 호출이 수십 회 발생하여 페이지 로딩이 매우 느리다.

개선 전 코드
kotlin
fun getImageByPath(path: String): ImageResource? {
    val drive = googleDriveHelper.drive
    val segments = path.split("/").filter { it.isNotEmpty() }

    // 1) 폴더 경로 순차 탐색 — depth마다 API 1회
    var parentId = "root"
    for (i in 0 until segments.size - 1) {
        parentId = googleDriveHelper.findFileId(drive, parentId, segments[i], true)
            ?: throw RuntimeException("폴더를 찾을 수 없습니다")
    }

    // 2) 파일 ID 검색 — API 1회
    val fileId = googleDriveHelper.findFileId(drive, parentId, segments.last(), false)
        ?: throw RuntimeException("파일을 찾을 수 없습니다")

    // 3) 메타데이터 조회 — API 1회
    val fileMeta = drive.files().get(fileId)
        .setFields("id, name, mimeType, size")
        .execute()

    // 4) 파일 다운로드 — API 1회
    val buffer = ByteArrayOutputStream()
    drive.files().get(fileId).executeMediaAndDownloadTo(buffer)
    val bytes = buffer.toByteArray()

    return ImageResource(ByteArrayInputStream(bytes), fileMeta.mimeType, fileMeta.name, bytes.size.toLong())
}

경로가 lifelog/nature/cherry.jpg라면:

  • rootlifelog (폴더 탐색 1회)
  • lifelognature (폴더 탐색 1회)
  • naturecherry.jpg (파일 검색 1회)
  • 메타데이터 조회 1회
  • 파일 다운로드 1회

총 5회의 순차 API 호출이 발생한다. 모두 순차적으로 실행되므로 각 호출의 latency가 누적된다.

개선 전략
1. 경량 캐시 — 폴더 ID, 파일 ID

Google Drive의 폴더 구조와 파일명은 자주 변경되지 않는다. 같은 경로의 폴더 ID와 파일 ID를 ConcurrentHashMap에 캐싱하면, 반복 요청 시 탐색 API 호출을 완전히 제거할 수 있다.

처음에는 이미지 바이트 자체를 인메모리 캐시에 저장하는 방안도 고려했으나, 이미지 파일의 크기가 수 MB에 달하기 때문에 서버가 바로 뻗을 것이 뻔하기 때문에 제외했다. 폴더 ID와 파일 ID는 문자열 수십 바이트에 불과하므로 메모리 부담이 없다.

kotlin
/** 경로 → 폴더 ID 캐시 */
private val folderIdCache = ConcurrentHashMap<String, String>()

/** 파일 경로 → 파일 ID 캐시 */
private val fileIdCache = ConcurrentHashMap<String, String>()
2. Virtual Thread — 메타데이터 조회 + 파일 다운로드 병렬화

파일 ID를 알고 나면, 메타데이터 조회파일 다운로드는 서로 의존성이 없으므로 동시에 실행할 수 있다. JDK 21의 Virtual Thread를 활용하여 두 작업을 병렬로 수행한다.

kotlin
// Virtual Thread로 메타데이터 조회와 파일 다운로드를 병렬 실행
val metaFuture = asyncSupply(virtualThreadExecutor) {
    drive.files().get(fileId)
        .setFields("id, name, mimeType, size")
        .execute()
}
val downloadFuture = asyncSupply(virtualThreadExecutor) {
    val buffer = ByteArrayOutputStream()
    drive.files().get(fileId).executeMediaAndDownloadTo(buffer)
    buffer.toByteArray()
}

val fileMeta = metaFuture.get()   // 먼저 끝나는 쪽은 즉시 반환
val bytes = downloadFuture.get()  // 나중에 끝나는 쪽을 대기

Virtual Thread 설정

java
@Configuration
public class VirtualThreadConfig {
    @Bean
    public TaskExecutor virtualThreadExecutor() {
        return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
    }
}

AsyncSupporter 유틸리티

java
public final class AsyncSupporter {
    public static <T> CompletableFuture<T> asyncSupply(TaskExecutor executor, Supplier<T> supplier) {
        return CompletableFuture.supplyAsync(supplier, executor);
    }
}

CompletableFuture.supplyAsync에 Virtual Thread Executor를 전달하면, 각 task가 경량 Virtual Thread에서 실행된다. Platform Thread pool과 달리 수천 개의 동시 작업도 부담 없이 처리할 수 있다.

개선 후 코드
kotlin
@Service
class GoogleDriveService(
    private val virtualThreadExecutor: TaskExecutor,
    private val googleDriveHelper: GoogleDriveHelper,
) {
    private val folderIdCache = ConcurrentHashMap<String, String>()
    private val fileIdCache = ConcurrentHashMap<String, String>()

    fun getImageByPath(path: String): ImageResource? {
        val segments = path.split("/").filter { it.isNotEmpty() }
        if (segments.isEmpty()) throw RuntimeException("경로가 비어 있습니다: $path")

        val drive = googleDriveHelper.drive

        // ① 폴더 ID 캐시 활용 — 캐시 히트 시 API 호출 0회
        val parentId = resolveFolderPath(segments.dropLast(1))

        // ② 파일 ID 캐시 활용 — 캐시 히트 시 API 호출 0회
        val fileName = segments.last()
        val fileId = fileIdCache.getOrPut(path) {
            googleDriveHelper.findFileId(drive, parentId, fileName, false)
                ?: throw RuntimeException("파일을 찾을 수 없습니다: $fileName")
        }

        // ③ Virtual Thread로 메타데이터 + 다운로드 병렬 실행
        val metaFuture = asyncSupply(virtualThreadExecutor) {
            drive.files().get(fileId).setFields("id, name, mimeType, size").execute()
        }
        val downloadFuture = asyncSupply(virtualThreadExecutor) {
            val buffer = ByteArrayOutputStream()
            drive.files().get(fileId).executeMediaAndDownloadTo(buffer)
            buffer.toByteArray()
        }

        val fileMeta = metaFuture.get()
        val bytes = downloadFuture.get()

        return ImageResource(
            ByteArrayInputStream(bytes), fileMeta.mimeType,
            fileMeta.name, bytes.size.toLong()
        )
    }

    private fun resolveFolderPath(folders: List<String>): String {
        if (folders.isEmpty()) return "root"
        val drive = googleDriveHelper.drive
        var parentId = "root"
        val pathBuilder = StringBuilder()

        for (folder in folders) {
            if (pathBuilder.isNotEmpty()) pathBuilder.append("/")
            pathBuilder.append(folder)
            parentId = folderIdCache.getOrPut(pathBuilder.toString()) {
                googleDriveHelper.findFileId(drive, parentId, folder, true)
                    ?: throw RuntimeException("폴더를 찾을 수 없습니다: $folder")
            }
        }
        return parentId
    }
}
캐시 무효화

새로운 이미지를 업로드하면, 해당 폴더 경로의 파일 ID 캐시를 무효화한다.

kotlin
fun uploadImage(folderPath: String, file: MultipartFile): Pair<File, File> {
    // ... 업로드 로직 ...

    // 업로드 후 해당 폴더 경로의 파일 ID 캐시 무효화
    fileIdCache.keys.removeIf { it.startsWith(folderPath) }

    return Pair(mainJobFuture.get(), subJobFuture.get())
}

폴더 ID 캐시는 폴더 구조가 변경되지 않는 한 무효화할 필요가 없다.

개선 효과 비교
최초 요청 (캐시 미스)
단계 개선 전 개선 후
폴더 탐색 (depth 2) API 2회, 순차 API 2회, 순차 (동일)
파일 ID 검색 API 1회 API 1회 (동일)
메타데이터 조회 API 1회, 순차 API 1회, 병렬
파일 다운로드 API 1회, 순차 API 1회, 병렬
총 소요 시간 5회 순차 누적 3회 순차 + 2회 병렬

메타데이터 조회(~100ms)와 파일 다운로드(~300ms)가 병렬로 실행되므로, 두 작업 중 긴 쪽(300ms)만 소요된다. 최초 요청 기준 약 **2030% 응답 시간 단축**.

재요청 (캐시 히트)
단계 개선 전 개선 후
폴더 탐색 API 2회 캐시 히트 0회
파일 ID 검색 API 1회 캐시 히트 0회
메타데이터 조회 API 1회 API 1회, 병렬
파일 다운로드 API 1회 API 1회, 병렬
총 소요 시간 5회 순차 누적 2회 병렬

캐시 히트 시 폴더 탐색과 파일 검색을 완전히 건너뛰고, 남은 2회 API 호출도 병렬로 실행한다. 재요청 기준 약 60~70% 응답 시간 단축.

정리
  • 경량 캐시(폴더 ID, 파일 ID)로 불필요한 API 호출 제거 — 메모리 부담 없음
  • Virtual Thread 병렬화로 메타데이터 조회 + 파일 다운로드 동시 실행
  • 캐시 무효화 전략으로 데이터 정합성 보장
  • 인메모리 이미지 캐시는 메모리 리스크 때문에 의도적으로 제외

추가 개선 — 인메모리 캐시에서 Valkey/Redis + Spring Cache로 전환
인메모리 캐시의 한계

1차 개선에서 ConcurrentHashMap으로 폴더 ID, 파일 ID를 캐싱했지만, 아무래도 아래와 같은 점 때문에 구려서 Valkey/Redis로 전환하였다.

한계 설명
서버 재시작 시 캐시 소멸 배포할 때마다 Cold Start, 최초 요청마다 Google Drive API를 다시 호출
다중 인스턴스 비공유 Scale-out 시 인스턴스마다 독립적인 캐시 → 캐시 히트율 저하
TTL 미지원 ConcurrentHashMap에는 만료 기능이 없어, 폴더 구조가 변경되면 수동 무효화 필요
보일러플레이트 redisTemplate.opsForValue().get(), set() 등 캐시 로직이 비즈니스 로직에 섞임
개선 방향
  1. ConcurrentHashMapValkey/Redis로 교체하여 서버 재시작/다중 인스턴스에서도 캐시 유지
  2. redisTemplate 직접 호출 → Spring Cache 추상화 (@Cacheable, @CacheEvict)로 캐시 로직 분리
  3. 캐시 전용 Bean을 분리하여 AOP 프록시가 정상 동작하도록 구조 변경
Valkey/Redis 설정

의존성

kotlin
// build.gradle.kts (shared 모듈)
dependencies {
    api("org.springframework.boot:spring-boot-starter-data-redis")
}

application.yml

yaml
# 로컬/테스트 — Embedded Redis 자동 구동
spring:
  data:
    redis:
      host: localhost
      port: 6379

# live 환경 — Valkey SaaS 접속
spring:
  data:
    redis:
      ssl:
        enabled: true
      url: ENC(암호화된_접속_URL)

RedisCacheManager 설정

java
@Configuration
@EnableCaching
public class RedisCacheConfig {
    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        final RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofHours(24))
                .serializeKeysWith(SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(SerializationPair.fromSerializer(new StringRedisSerializer()))
                .disableCachingNullValues();

        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(defaultConfig)
                .build();
    }
}
  • TTL 24시간 — 폴더/파일 ID는 자주 변경되지 않으므로 충분한 유효기간
  • StringRedisSerializer — 폴더 ID, 파일 ID 모두 문자열이므로 경량 직렬화
  • disableCachingNullValues() — 존재하지 않는 경로에 대한 null 캐싱 방지
Spring Cache 추상화 적용

캐시 전용 서비스 분리

@Cacheable은 Spring AOP 프록시를 통해 동작하므로, 같은 클래스 내 private 메서드에서는 작동하지 않는다. 따라서 캐시 조회 로직을 별도 Bean으로 분리한다.

kotlin
@Service
class GoogleDriveCacheSaver(
    private val googleDriveHelper: GoogleDriveHelper,
) {
    @Cacheable(value = ["driveFolderId"], key = "#cacheKey")
    fun getFolderId(cacheKey: String, drive: Drive, parentId: String, folderName: String): String {
        return googleDriveHelper.findFileId(drive, parentId, folderName, true)
            ?: throw RuntimeException("path cannot be found: $folderName")
    }

    @Cacheable(value = ["driveFileId"], key = "#cacheKey")
    fun getFileId(cacheKey: String, drive: Drive, parentId: String, fileName: String): String {
        return googleDriveHelper.findFileId(drive, parentId, fileName, false)
            ?: throw RuntimeException("file cannot be found: $fileName")
    }

    @CacheEvict(value = ["driveFileId"], allEntries = true)
    fun evictAllFileIdCache() {
        log.info("All file ID cache evicted.")
    }
}
어노테이션 캐시 이름 동작
@Cacheable("driveFolderId") driveFolderId 캐시에 있으면 즉시 반환, 없으면 API 호출 후 캐시 저장
@Cacheable("driveFileId") driveFileId 동일
@CacheEvict("driveFileId", allEntries=true) driveFileId 파일 업로드 후 파일 ID 캐시 전체 무효화

GoogleDriveService — 캐시 로직 제거

기존에 redisTemplate.opsForValue().get(), set() 등으로 직접 캐시를 제어하던 코드가 모두 사라지고, GoogleDriveCacheSaver의 메서드 호출만 남는다.

kotlin
@Service
class GoogleDriveService(
    private val virtualThreadExecutor: TaskExecutor,
    private val googleDriveHelper: GoogleDriveHelper,
    private val googleDriveCacheSaver: GoogleDriveCacheSaver,  // 캐시 전용 Bean
) {
    fun getImageByPath(path: String): ImageResource? {
        // ...
        val parentId = resolveFolderPath(segments.dropLast(1))
        val fileId = googleDriveCacheSaver.getFileId(path, drive, parentId, fileName)
        // ... Virtual Thread 병렬 실행 (기존과 동일)
    }

    fun uploadImage(folderPath: String, file: MultipartFile): Pair<File, File> {
        // ... 업로드 로직 ...
        googleDriveCacheSaver.evictAllFileIdCache()  // 캐시 무효화
        return Pair(mainJobFuture.get(), subJobFuture.get())
    }

    private fun resolveFolderPath(folders: List<String>): String {
        // ...
        for (folder in folders) {
            parentId = googleDriveCacheSaver.getFolderId(pathBuilder.toString(), drive, parentId, folder)
        }
        return parentId
    }
}
Before / After 비교

코드 관점

text
[Before — redisTemplate 직접 호출]

val fileIdCacheKey = "$FILE_ID_PREFIX$path"
val fileId = redisTemplate.opsForValue().get(fileIdCacheKey)
    ?: run {
        val id = googleDriveHelper.findFileId(drive, parentId, fileName, false)
            ?: throw RuntimeException("파일을 찾을 수 없습니다")
        redisTemplate.opsForValue().set(fileIdCacheKey, id, CACHE_TTL)
        id
    }
text
[After — @Cacheable]

val fileId = googleDriveCacheSaver.getFileId(path, drive, parentId, fileName)

아키텍처 관점

text
[1차 개선 — 인메모리]

ConcurrentHashMap (JVM 내부)
  └─ 서버 재시작 시 소멸
  └─ 인스턴스 간 비공유
  └─ TTL 없음

[2차 개선 — Valkey/Redis]

Valkey/Redis (외부 저장소)
  └─ 서버 재시작 후에도 캐시 유지
  └─ 다중 인스턴스 간 캐시 공유
  └─ TTL 24시간 자동 만료
  └─ @Cacheable로 비즈니스 로직에서 캐시 관심사 분리
개선 효과
항목 인메모리 (ConcurrentHashMap) Valkey/Redis + Spring Cache
서버 재시작 캐시 전량 소멸, Cold Start 캐시 유지, Warm Start
다중 인스턴스 인스턴스별 독립 캐시 공유 캐시 (히트율 ↑)
TTL 없음 (수동 관리) 24시간 자동 만료
캐시 무효화 keys.removeIf { ... } @CacheEvict 한 줄
코드 가독성 get/set 보일러플레이트 어노테이션으로 선언적 처리
테스트 redisTemplate mock 필요 cacheSaver relaxed mock
로컬 개발 환경 — Embedded Redis

로컬이나 테스트 환경에서는 실제 Redis 서버 없이도 동작하도록 Embedded Redis를 사용한다.

kotlin
@Configuration
@Profile("!live")
class EmbeddedRedisConfig {
    private var redisServer: RedisServer? = null

    @PostConstruct
    fun startRedis() {
        redisServer = RedisServer.newRedisServer().build()
        redisServer?.start()
    }

    @PreDestroy
    fun stopRedis() {
        redisServer?.stop()
    }
}
  • @Profile("!live") — live 환경에서는 실제 Valkey SaaS에 접속하므로 Embedded Redis를 띄우지 않음
  • 애플리케이션 구동 시 자동 시작, 종료 시 자동 정지
최종 정리
text
[최초]                    [1차 개선]                  [2차 개선]
순차 API 5회              인메모리 캐시               Valkey/Redis 캐시
~950ms                   + Virtual Thread            + Spring Cache
                          ~300ms (재요청)             + @Cacheable/@CacheEvict

  ↓ 문제점                   ↓ 문제점                    ✓ 해결
API 호출 과다              서버 재시작 시 소멸           서버 재시작 후 캐시 유지
순차 실행 병목              인스턴스 간 비공유            다중 인스턴스 캐시 공유
                          TTL 없음                    TTL 24시간 자동 만료
                          보일러플레이트                선언적 캐시 처리
  1. Virtual Thread 병렬화로 I/O 병목 해소 — 메타데이터 조회 + 파일 다운로드 동시 실행
  2. 경량 ID 캐시로 불필요한 API 호출 제거 — 이미지 바이트는 메모리 부담으로 캐싱하지 않음
  3. Valkey/Redis로 캐시 영속성 및 공유 확보 — 배포, Scale-out에서도 캐시 유지
  4. Spring Cache 추상화로 캐시 관심사를 비즈니스 로직에서 완전 분리

Google Drive를 이미지 저장소로 사용하면서도, 적절한 캐시 전략과 병렬화만으로 CDN 없이도 합리적인 응답 속도를 확보할 수 있다.

...

그래도 느려...

Written by
author
풍우래기

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

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