Posted by DoYoon Kim on April 2, 2026 | 32 min read
캐싱은 백엔드 성능 최적화의 핵심이다. 캐싱 전략 기초에서 Cache-Aside, Write-Through, Write-Behind 패턴의 개념을 다뤘다면, 이 글에서는 Redis를 활용한 구체적인 구현과 실전에서 마주치는 문제, 해결책을 정리한다.
1. 캐시 전략 비교
1.1 전략 비교 표
전략
읽기 흐름
쓰기 흐름
장점
단점
적합한 상황
Cache-Aside
캐시 조회 → miss 시 DB 조회 → 캐시 저장
DB 직접 쓰기 → 캐시 무효화
구현 단순, 필요한 데이터만 캐싱
첫 요청 항상 miss, 일관성 위험
읽기 비중 높은 일반 서비스
Read-Through
캐시에 위임 → miss 시 캐시가 DB 조회
DB 직접 쓰기
애플리케이션 코드 단순
캐시 라이브러리 의존
캐시 미들웨어 사용 시
Write-Through
캐시 조회
캐시 쓰기 → 캐시가 DB에 동기 쓰기
캐시-DB 항상 일관
쓰기 지연 증가
데이터 일관성이 중요할 때
Write-Behind
캐시 조회
캐시 쓰기 → 비동기로 DB에 쓰기
쓰기 성능 극대화
데이터 유실 위험
쓰기 빈도 높고 유실 허용 시
1.2 Cache-Aside (Lazy Loading)
가장 널리 사용되는 전략이다. 애플리케이션이 캐시를 직접 관리한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
publicUsergetUser(StringuserId){// 1. 캐시 조회StringcacheKey="user:"+userId;Usercached=(User)redisTemplate.opsForValue().get(cacheKey);if(cached!=null){returncached;// Cache Hit}// 2. Cache Miss → DB 조회Useruser=userRepository.findById(userId).orElseThrow(()->newUserNotFoundException(userId));// 3. 캐시에 저장redisTemplate.opsForValue().set(cacheKey,user,Duration.ofMinutes(30));returnuser;}
쓰기 시 캐시 무효화:
1
2
3
4
5
6
7
publicUserupdateUser(StringuserId,UserUpdateRequestrequest){Useruser=userRepository.save(toEntity(userId,request));// 캐시 삭제 (다음 읽기에서 최신 데이터로 갱신)redisTemplate.delete("user:"+userId);returnuser;}
1.3 Write-Through
1
2
3
4
5
6
7
8
publicUserupdateUser(StringuserId,UserUpdateRequestrequest){Useruser=userRepository.save(toEntity(userId,request));// DB 저장 직후 캐시도 동기적으로 업데이트StringcacheKey="user:"+userId;redisTemplate.opsForValue().set(cacheKey,user,Duration.ofMinutes(30));returnuser;}
publicvoidupdateUserAsync(StringuserId,UserUpdateRequestrequest){StringcacheKey="user:"+userId;Useruser=toEntity(userId,request);// 1. 캐시에 즉시 반영redisTemplate.opsForValue().set(cacheKey,user);// 2. 변경 사항을 큐에 등록 → 비동기로 DB에 쓰기redisTemplate.opsForList().rightPush("write-behind:queue",newWriteTask(cacheKey,user));}// 별도 스케줄러가 큐를 소비하여 DB에 배치 저장@Scheduled(fixedDelay=5000)publicvoidflushWriteBehindQueue(){List<WriteTask>tasks=newArrayList<>();WriteTasktask;while((task=redisTemplate.opsForList().leftPop("write-behind:queue"))!=null){tasks.add(task);}if(!tasks.isEmpty()){userRepository.batchUpdate(tasks);}}
2. TTL 설계 전략 및 만료 패턴
2.1 TTL 설계 가이드라인
데이터 유형
권장 TTL
이유
세션 정보
30분 ~ 2시간
사용자 세션 라이프사이클
사용자 프로필
10분 ~ 1시간
변경 빈도 낮음
상품 목록
5분 ~ 15분
가격/재고 변동 반영
인기 검색어 / 랭킹
1분 ~ 5분
실시간성 중요
설정 / 코드 테이블
1시간 ~ 24시간
거의 변경되지 않음
API Rate Limit 카운터
1분 (sliding window)
정확한 윈도우 필요
2.2 TTL 안티패턴
1
2
3
4
5
6
7
// ❌ Bad: 모든 캐시에 동일한 TTL → 동시 만료 (Stampede 유발)redisTemplate.opsForValue().set(key,value,Duration.ofMinutes(30));// ✅ Good: TTL에 지터(jitter) 추가longbaseTtl=30*60;// 30분longjitter=ThreadLocalRandom.current().nextLong(0,5*60);// 0~5분redisTemplate.opsForValue().set(key,value,Duration.ofSeconds(baseTtl+jitter));
2.3 만료 패턴
Passive Expiration: 키에 접근할 때 만료 확인 → 삭제
Active Expiration: Redis가 주기적으로 만료된 키 샘플링 → 삭제
1
2
3
4
5
6
7
8
9
10
11
// Sliding TTL — 접근할 때마다 TTL 갱신publicUsergetUser(StringuserId){StringcacheKey="user:"+userId;Usercached=(User)redisTemplate.opsForValue().get(cacheKey);if(cached!=null){// 접근 시 TTL 리셋redisTemplate.expire(cacheKey,Duration.ofMinutes(30));returncached;}// ... DB 조회 및 캐시 저장}
3. Cache Stampede / Thundering Herd 문제와 해결책
3.1 문제 정의
Cache Stampede: 인기 키가 만료되는 순간, 수백 개의 요청이 동시에 DB를 조회하는 현상
1
2
3
4
5
6
시간 T: 인기 상품 캐시 만료
→ 요청 1: cache miss → DB 조회
→ 요청 2: cache miss → DB 조회
→ 요청 3: cache miss → DB 조회
→ ... 수백 요청이 동시에 DB hit
→ DB 과부하 / 장애
publicUsergetUserWithLock(StringuserId){StringcacheKey="user:"+userId;StringlockKey="lock:"+cacheKey;Usercached=(User)redisTemplate.opsForValue().get(cacheKey);if(cached!=null){returncached;}// 분산 락 획득 시도 (SETNX + TTL)Booleanacquired=redisTemplate.opsForValue().setIfAbsent(lockKey,"1",Duration.ofSeconds(10));if(Boolean.TRUE.equals(acquired)){try{// 락 획득 성공 → DB 조회 후 캐시 갱신Useruser=userRepository.findById(userId).orElseThrow();redisTemplate.opsForValue().set(cacheKey,user,Duration.ofMinutes(30));returnuser;}finally{redisTemplate.delete(lockKey);}}else{// 락 획득 실패 → 짧은 대기 후 재시도Thread.sleep(50);returngetUserWithLock(userId);}}
publicUsergetUserWithEarlyExpire(StringuserId){StringcacheKey="user:"+userId;CacheEntry<User>entry=(CacheEntry<User>)redisTemplate.opsForValue().get(cacheKey);if(entry!=null){longttlRemaining=redisTemplate.getExpire(cacheKey,TimeUnit.SECONDS);longdelta=entry.getComputeTime();// 원래 계산 소요 시간(초)doublebeta=1.0;// 남은 TTL이 짧을수록 갱신 확률 증가booleanshouldRefresh=(delta*beta*Math.log(Math.random()))*-1>=ttlRemaining;if(!shouldRefresh){returnentry.getData();}// 확률적으로 선택된 요청만 갱신 수행 (아래로 계속)}// Cache miss 또는 갱신 대상 → DB 조회longstart=System.currentTimeMillis();Useruser=userRepository.findById(userId).orElseThrow();longcomputeTime=(System.currentTimeMillis()-start)/1000;CacheEntry<User>newEntry=newCacheEntry<>(user,computeTime);redisTemplate.opsForValue().set(cacheKey,newEntry,Duration.ofMinutes(30));returnuser;}
# 세션 저장
SET session:abc123 '{"userId":"u1","role":"admin"}' EX 1800
# 페이지 조회수 카운터
INCR page:views:home
INCRBY page:views:home 5
1
2
3
4
5
// Spring BootredisTemplate.opsForValue().set("session:"+sessionId,sessionData,Duration.ofMinutes(30));Longviews=redisTemplate.opsForValue().increment("page:views:"+pageId);
4.3 Hash — 객체 필드별 접근
전체 객체 대신 필요한 필드만 읽고 수정할 수 있다.
1
2
3
4
HSET user:123 name "홍길동" email "hong@example.com" age 28
HGET user:123 name # "홍길동"
HINCRBY user:123 age 1 # 나이 1 증가
HGETALL user:123 # 전체 필드
ZADD leaderboard 1500 "player:A" 2300 "player:B" 1800 "player:C"
ZREVRANGE leaderboard 0 2 WITHSCORES # 상위 3명 (높은 점수순)
ZRANK leaderboard "player:A"# 순위 조회
ZINCRBY leaderboard 200 "player:A"# 점수 증가
1
2
3
4
5
6
7
8
9
10
ZSetOperations<String,String>zSetOps=redisTemplate.opsForZSet();zSetOps.add("leaderboard","player:A",1500);zSetOps.add("leaderboard","player:B",2300);// 상위 10명Set<ZSetOperations.TypedTuple<String>>top10=zSetOps.reverseRangeWithScores("leaderboard",0,9);// 점수 증가zSetOps.incrementScore("leaderboard","player:A",200);
@ServicepublicclassUserService{@Cacheable(value="users",key="#userId")publicUsergetUser(StringuserId){// Cache miss 시에만 실행returnuserRepository.findById(userId).orElseThrow();}@CachePut(value="users",key="#userId")publicUserupdateUser(StringuserId,UserUpdateRequestrequest){// 항상 실행, 결과를 캐시에 저장returnuserRepository.save(toEntity(userId,request));}@CacheEvict(value="users",key="#userId")publicvoiddeleteUser(StringuserId){// 실행 후 캐시에서 삭제userRepository.deleteById(userId);}@CacheEvict(value="users",allEntries=true)publicvoidclearAllUserCache(){// 모든 users 캐시 삭제}}
6. 캐시 일관성 문제 (Cache Invalidation 전략)
“There are only two hard things in Computer Science: cache invalidation and naming things.”
— Phil Karlton
6.1 일관성 문제 시나리오
1
2
3
4
5
6
7
8
9
10
Thread A: DB 업데이트 (v2) → 캐시 삭제 예정
Thread B: 캐시 miss → DB 조회 (v2) → 캐시 저장 (v2)
Thread A: 캐시 삭제!
→ 캐시 비어 있음 → 다음 요청에서 다시 DB 조회 (괜찮음)
그러나 타이밍이 꼬이면:
Thread A: DB 업데이트 (v2)
Thread B: 캐시 miss → DB 조회 (v1, 아직 커밋 안 됨)
Thread A: 캐시 삭제
Thread B: 캐시 저장 (v1) ← 오래된 데이터!
6.2 전략 1: Delete After Write (기본)
1
2
3
4
publicvoidupdateUser(StringuserId,UserUpdateRequestrequest){userRepository.save(toEntity(userId,request));redisTemplate.delete("user:"+userId);// 쓰기 후 삭제}
단순하지만 위 경합 상황에서 stale 데이터 가능성이 있다.
6.3 전략 2: 짧은 TTL + 삭제
1
2
3
4
5
publicvoidupdateUser(StringuserId,UserUpdateRequestrequest){userRepository.save(toEntity(userId,request));// 즉시 삭제 대신 매우 짧은 TTL로 교체 → 경합 윈도우 최소화redisTemplate.expire("user:"+userId,Duration.ofSeconds(1));}
6.4 전략 3: 버전 기반 무효화
1
2
3
4
5
6
7
8
9
10
publicvoidupdateUser(StringuserId,UserUpdateRequestrequest){Useruser=userRepository.save(toEntity(userId,request));// 버전 번호로 캐시 키 분리StringversionKey="user:version:"+userId;LongnewVersion=redisTemplate.opsForValue().increment(versionKey);StringcacheKey="user:"+userId+":v"+newVersion;redisTemplate.opsForValue().set(cacheKey,user,Duration.ofMinutes(30));}
6.5 전략 4: CDC (Change Data Capture) 기반
데이터 변경을 이벤트로 발행하여 캐시를 비동기 갱신한다.
1
DB 변경 → Debezium (CDC) → Kafka → Cache Updater → Redis 갱신