반응형
프로젝트에서 성능 이슈가 계속 발생해서 Redis 캐싱을 적용해보기로 했다.
생각보다 전략이 다양했다.
정리해보자.
캐싱 전략들
전략 설명 장점 단점 사용 시기
Cache-Aside | 애플리케이션이 직접 캐시 관리 | 필요한 데이터만 캐싱, 장애 격리 | 첫 요청 느림, 복잡한 코드 | 읽기 중심 애플리케이션 |
Write-Through | 캐시와 DB 동시 저장 | 데이터 일관성 보장 | 쓰기 성능 저하 | 일관성이 중요한 시스템 |
Write-Behind | 캐시 먼저, DB는 나중 | 빠른 쓰기 성능 | 데이터 손실 위험 | 고성능 쓰기가 필요한 경우 |
Read-Through | 캐시가 DB 조회 대행 | 간단한 애플리케이션 코드 | 캐시 의존성 증가 | 읽기 패턴이 단순한 경우 |
실제로 써본 Cache-Aside 패턴
@Service
public class UserService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private UserRepository userRepository;
public User getUser(Long userId) {
String cacheKey = "user:" + userId;
// 1. 캐시 확인
String cachedUser = redisTemplate.opsForValue().get(cacheKey);
if (cachedUser != null) {
return JsonUtils.fromJson(cachedUser, User.class);
}
// 2. DB 조회
User user = userRepository.findById(userId);
// 3. 캐시 저장 (1시간)
redisTemplate.opsForValue().set(cacheKey, JsonUtils.toJson(user),
Duration.ofHours(1));
return user;
}
}
간단하다.
하지만 캐시 무효화가 문제다.
캐시 무효화 전략
무효화 방식 구현 방법 장점 단점 적합한 상황
TTL | 시간 기반 자동 만료 | 구현 간단, 메모리 효율적 | 데이터 불일치 가능 | 실시간성이 중요하지 않은 경우 |
Manual | 데이터 변경시 수동 삭제 | 정확한 무효화 | 복잡한 코드, 휴먼 에러 | 정확성이 중요한 핵심 데이터 |
Tag-based | 태그로 그룹화하여 일괄 삭제 | 관련 데이터 일괄 처리 | 태그 관리 복잡 | 연관된 데이터가 많은 경우 |
Event-driven | 이벤트 기반 자동 무효화 | 실시간 반영, 자동화 | 이벤트 시스템 필요 | 마이크로서비스 환경 |
TTL 방식
// 1시간 후 자동 만료
redisTemplate.opsForValue().set("key", value, Duration.ofHours(1));
Manual Invalidation
@Transactional
public void updateUser(Long userId, User userData) {
// DB 업데이트
userRepository.save(userData);
// 캐시 삭제
String cacheKey = "user:" + userId;
redisTemplate.delete(cacheKey);
}
Tag-based Invalidation
public void addUserCache(Long userId, User user) {
String userKey = "user:" + userId;
String postsKey = "user_posts:" + userId;
String tagKey = "user_tags:" + userId;
// 데이터 캐싱
redisTemplate.opsForValue().set(userKey, JsonUtils.toJson(user));
// 태그에 키 추가
redisTemplate.opsForSet().add(tagKey, userKey, postsKey);
}
public void invalidateUserCaches(Long userId) {
String tagKey = "user_tags:" + userId;
Set<String> keys = redisTemplate.opsForSet().members(tagKey);
// 관련 캐시 모두 삭제
redisTemplate.delete(keys);
redisTemplate.delete(tagKey);
}
캐시 무효화 문제들
문제 발생 원인 해결 방법 예제 코드
Race Condition | 캐시 무효화와 생성 타이밍 충돌 | 분산 락, 버전 관리 | 아래 참조 |
Thundering Herd | 캐시 만료시 동시 DB 접근 | 랜덤 TTL, 락 사용 | 아래 참조 |
Cache Stampede | 인기 캐시 만료시 대량 요청 | 분산 락으로 갱신 제어 | 아래 참조 |
Stale Data | 오래된 캐시 데이터 조회 | 버전 체크, 이벤트 기반 갱신 | 아래 참조 |
Race Condition 해결
@Component
public class CacheService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
public User getUserWithLock(Long userId) {
String lockKey = "lock:user:" + userId;
String cacheKey = "user:" + userId;
try {
// 분산 락 획득 (10초 타임아웃)
Boolean lockAcquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(10));
if (lockAcquired) {
// 캐시 재확인
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return JsonUtils.fromJson(cached, User.class);
}
// DB 조회 및 캐싱
User user = userRepository.findById(userId);
redisTemplate.opsForValue().set(cacheKey, JsonUtils.toJson(user),
Duration.ofHours(1));
return user;
} else {
// 락 획득 실패시 잠시 대기 후 재시도
Thread.sleep(100);
return getUserWithLock(userId);
}
} finally {
redisTemplate.delete(lockKey);
}
}
}
Thundering Herd 방지
public void setCacheWithRandomTTL(String key, Object value) {
// 기본 1시간 + 0~5분 랜덤
Random random = new Random();
long baseSeconds = 3600;
long randomSeconds = random.nextInt(300);
Duration ttl = Duration.ofSeconds(baseSeconds + randomSeconds);
redisTemplate.opsForValue().set(key, JsonUtils.toJson(value), ttl);
}
Cache Stampede 해결
public String getPopularData() {
String lockKey = "lock:popular_data";
String cacheKey = "popular_data";
// 캐시 확인
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return cached;
}
// 락 시도
Boolean lockAcquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(10));
if (lockAcquired) {
try {
// 다시 한번 캐시 확인 (Double-checked locking)
cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return cached;
}
// 무거운 DB 쿼리 실행
String data = expensiveDbQuery();
redisTemplate.opsForValue().set(cacheKey, data, Duration.ofHours(1));
return data;
} finally {
redisTemplate.delete(lockKey);
}
} else {
// 다른 스레드가 갱신 중이면 잠시 대기
try {
Thread.sleep(100);
return getPopularData();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
}
}
캐시 무효화 패턴들
패턴 설명 구현 방식 장점 단점
Refresh-Ahead | 만료 전 미리 갱신 | 백그라운드 스케줄링 | 사용자 대기시간 없음 | 리소스 사용량 증가 |
Write-Around | 자주 안쓰는 데이터 제외 | 접근 패턴 기반 필터링 | 메모리 효율적 | 복잡한 판단 로직 |
Circuit Breaker | 장애시 대체 로직 | 상태 기반 분기 처리 | 장애 격리 | 구현 복잡도 증가 |
Circuit Breaker 패턴
@Component
public class CacheCircuitBreaker {
private enum State { CLOSED, OPEN, HALF_OPEN }
private State state = State.CLOSED;
private int failureCount = 0;
private long lastFailureTime = 0;
private final int FAILURE_THRESHOLD = 5;
private final long TIMEOUT = 60000; // 1분
public User getDataWithCircuitBreaker(Long userId) {
if (state == State.OPEN) {
if (System.currentTimeMillis() - lastFailureTime > TIMEOUT) {
state = State.HALF_OPEN;
} else {
return getDefaultUser(); // 기본값 반환
}
}
try {
User user = getFromCache(userId);
if (state == State.HALF_OPEN) {
state = State.CLOSED;
failureCount = 0;
}
return user;
} catch (Exception e) {
recordFailure();
return getFromDatabase(userId); // 캐시 실패시 DB 직접 조회
}
}
private void recordFailure() {
failureCount++;
lastFailureTime = System.currentTimeMillis();
if (failureCount >= FAILURE_THRESHOLD) {
state = State.OPEN;
}
}
}
실전 팁들
구분 항목 권장사항 예제
키 네이밍 | 일관된 패턴 | {도메인}:{ID}:{상세} | user:123:profile, post:456:comments |
메모리 관리 | 정책 설정 | LRU, TTL 조합 | maxmemory-policy allkeys-lru |
모니터링 | 히트율 추적 | 80% 이상 유지 | hit_rate = hits / (hits + misses) * 100 |
압축 | 큰 데이터 처리 | JSON → GZIP | 50% 이상 용량 절약 가능 |
배치 처리 | 다중 키 조회 | Pipeline 사용 | 네트워크 호출 최소화 |
압축 처리
@Component
public class CompressedCacheService {
public void setCompressed(String key, Object value) throws IOException {
String json = JsonUtils.toJson(value);
byte[] compressed = compress(json);
redisTemplate.opsForValue().set(key, Base64.getEncoder().encodeToString(compressed));
}
public <T> T getCompressed(String key, Class<T> clazz) throws IOException {
String compressed = redisTemplate.opsForValue().get(key);
if (compressed == null) return null;
byte[] decoded = Base64.getDecoder().decode(compressed);
String json = decompress(decoded);
return JsonUtils.fromJson(json, clazz);
}
private byte[] compress(String str) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (GZIPOutputStream gzipOut = new GZIPOutputStream(baos)) {
gzipOut.write(str.getBytes(StandardCharsets.UTF_8));
}
return baos.toByteArray();
}
private String decompress(byte[] compressed) throws IOException {
ByteArrayInputStream bais = new ByteArrayInputStream(compressed);
try (GZIPInputStream gzipIn = new GZIPInputStream(bais)) {
return new String(gzipIn.readAllBytes(), StandardCharsets.UTF_8);
}
}
}
Pipeline을 이용한 배치 처리
public Map<String, User> getMultipleUsers(List<Long> userIds) {
List<String> keys = userIds.stream()
.map(id -> "user:" + id)
.collect(Collectors.toList());
// Pipeline으로 한번에 여러 키 조회
List<String> values = redisTemplate.opsForValue().multiGet(keys);
Map<String, User> result = new HashMap<>();
for (int i = 0; i < keys.size(); i++) {
if (values.get(i) != null) {
result.put(keys.get(i), JsonUtils.fromJson(values.get(i), User.class));
}
}
return result;
}
Redis 캐싱은 단순해 보이지만 무효화가 진짜 어렵다.
특히 분산 환경에서는 더 복잡해진다.
일단 간단한 TTL부터 시작해서 점진적으로 개선하는게 좋겠다.
다음엔 Redis Cluster와 분산 캐시에 대해 공부해봐야겠다.
728x90
반응형
'WEB' 카테고리의 다른 글
JWT (Json Web Token) - 토큰과 작동원리 (0) | 2025.03.02 |
---|---|
[HTML] 모바일 페이지 - 휴대폰에서 숫자 키패드로 입력 받기 (0) | 2023.05.18 |
[HTML/Javascript] input 태그, 숫자만 입력 및 개수 제한 (0) | 2023.01.06 |
[HTML/Javascript] a 태그 새로 고침 방지 및 onclick 사용 (0) | 2023.01.06 |
[HTML/Javascript] <form> 과 <button> 태그 (0) | 2023.01.03 |