이 개인 프로젝트에서 이미지 파일을 Google Drive에 저장하고, 서버가 프록시 역할을 하여 이미지를 서비스하는 구조를 사용하고 있다. 돈을 아끼려고...
[브라우저] → /photo/lifelog/nature/cherry.jpg
↓
[Spring Boot 서버]
↓
[Google Drive API] ← 폴더 탐색 → 파일 검색 → 메타데이터 조회 → 파일 다운로드
↓
[이미지 바이트 응답]
문제는 한 장의 이미지를 서비스하기 위해 Google Drive API를 최소 4~5회 호출해야 한다는 것이다. 포토아카이브 페이지에서 썸네일 여러 장을 동시에 로드하면, 단일 요청당 수백ms씩 걸리는 API 호출이 수십 회 발생하여 페이지 로딩이 매우 느리다.
개선 전 코드
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라면:
root→lifelog(폴더 탐색 1회)lifelog→nature(폴더 탐색 1회)nature→cherry.jpg(파일 검색 1회)- 메타데이터 조회 1회
- 파일 다운로드 1회
총 5회의 순차 API 호출이 발생한다. 모두 순차적으로 실행되므로 각 호출의 latency가 누적된다.
개선 전략
1. 경량 캐시 — 폴더 ID, 파일 ID
Google Drive의 폴더 구조와 파일명은 자주 변경되지 않는다. 같은 경로의 폴더 ID와 파일 ID를 ConcurrentHashMap에 캐싱하면, 반복 요청 시 탐색 API 호출을 완전히 제거할 수 있다.
처음에는 이미지 바이트 자체를 인메모리 캐시에 저장하는 방안도 고려했으나, 이미지 파일의 크기가 수 MB에 달하기 때문에 서버가 바로 뻗을 것이 뻔하기 때문에 제외했다. 폴더 ID와 파일 ID는 문자열 수십 바이트에 불과하므로 메모리 부담이 없다.
/** 경로 → 폴더 ID 캐시 */
private val folderIdCache = ConcurrentHashMap<String, String>()
/** 파일 경로 → 파일 ID 캐시 */
private val fileIdCache = ConcurrentHashMap<String, String>()
2. Virtual Thread — 메타데이터 조회 + 파일 다운로드 병렬화
파일 ID를 알고 나면, 메타데이터 조회와 파일 다운로드는 서로 의존성이 없으므로 동시에 실행할 수 있다. JDK 21의 Virtual Thread를 활용하여 두 작업을 병렬로 수행한다.
// 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 설정
@Configuration
public class VirtualThreadConfig {
@Bean
public TaskExecutor virtualThreadExecutor() {
return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
}
}
AsyncSupporter 유틸리티
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과 달리 수천 개의 동시 작업도 부담 없이 처리할 수 있다.
개선 후 코드
@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 캐시를 무효화한다.
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() 등 캐시 로직이 비즈니스 로직에 섞임 |
개선 방향
ConcurrentHashMap→ Valkey/Redis로 교체하여 서버 재시작/다중 인스턴스에서도 캐시 유지redisTemplate직접 호출 → Spring Cache 추상화 (@Cacheable,@CacheEvict)로 캐시 로직 분리- 캐시 전용 Bean을 분리하여 AOP 프록시가 정상 동작하도록 구조 변경
Valkey/Redis 설정
의존성
// build.gradle.kts (shared 모듈)
dependencies {
api("org.springframework.boot:spring-boot-starter-data-redis")
}
application.yml
# 로컬/테스트 — Embedded Redis 자동 구동
spring:
data:
redis:
host: localhost
port: 6379
# live 환경 — Valkey SaaS 접속
spring:
data:
redis:
ssl:
enabled: true
url: ENC(암호화된_접속_URL)
RedisCacheManager 설정
@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으로 분리한다.
@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의 메서드 호출만 남는다.
@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 비교
코드 관점
[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
}
[After — @Cacheable]
val fileId = googleDriveCacheSaver.getFileId(path, drive, parentId, fileName)
아키텍처 관점
[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를 사용한다.
@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를 띄우지 않음- 애플리케이션 구동 시 자동 시작, 종료 시 자동 정지
최종 정리
[최초] [1차 개선] [2차 개선]
순차 API 5회 인메모리 캐시 Valkey/Redis 캐시
~950ms + Virtual Thread + Spring Cache
~300ms (재요청) + @Cacheable/@CacheEvict
↓ 문제점 ↓ 문제점 ✓ 해결
API 호출 과다 서버 재시작 시 소멸 서버 재시작 후 캐시 유지
순차 실행 병목 인스턴스 간 비공유 다중 인스턴스 캐시 공유
TTL 없음 TTL 24시간 자동 만료
보일러플레이트 선언적 캐시 처리
- Virtual Thread 병렬화로 I/O 병목 해소 — 메타데이터 조회 + 파일 다운로드 동시 실행
- 경량 ID 캐시로 불필요한 API 호출 제거 — 이미지 바이트는 메모리 부담으로 캐싱하지 않음
- Valkey/Redis로 캐시 영속성 및 공유 확보 — 배포, Scale-out에서도 캐시 유지
- Spring Cache 추상화로 캐시 관심사를 비즈니스 로직에서 완전 분리
Google Drive를 이미지 저장소로 사용하면서도, 적절한 캐시 전략과 병렬화만으로 CDN 없이도 합리적인 응답 속도를 확보할 수 있다.
...
그래도 느려...