궁금한게 많은 코린이의 Developer 노트
[SpringBoot] 캐시 서버와 Redis 본문
사용자 경험을 향상시키기 위해 필수적으로 알아야 하는 6가지 백엔드 성능 최적화 방법 중 캐싱 전략 구현에 대해 알아보자.
백엔드 성능 최적화 방법
- 데이터베이스 최적화
- 인덱스 최적화: 자주 조회되는 컬럼에 대해 적절한 인덱스를 생성하여 조회 성능을 향상시켰습니다. 특히 복합 인덱스를 활용하여 WHERE절과 ORDER BY 절을 커버할 수 있도록 설계했습니다.
- 쿼리 최적화: EXPLAIN을 통해 실행 계획을 분석하고, 불필요한 조인이나 서브쿼리를 제거하여 쿼리 성능을 개선했습니다.
- N+1 문제 해결: JPA를 사용할 때 발생하는 N+1 문제를 fetch join이나 @BatchSize를 사용하여 해결했습니다.
- 캐싱 전략 구현
- Spring Cache와 Redis를 활용하여 자주 조회되는 데이터를 캐싱
- 멀티레벨 캐싱 전략을 도입하여 Local Cache와 Global Cache를 함께 사용
- 캐시 hit ratio를 모니터링하고 캐시 정책을 지속적으로 개선
- 애플리케이션 레벨 최적화
- Connection Pool 튜닝: HikariCP의 maximum pool size와 idle timeout 등을 워크로드에 맞게 조정
- Thread Pool 설정: Tomcat thread pool 크기를 서버 자원과 부하를 고려하여 적절히 설정
- 비동기 처리: @Async를 활용하여 시간이 오래 걸리는 작업을 비동기로 처리
- JVM 튜닝
- GC 모니터링 및 튜닝: G1GC 사용 및 적절한 heap 크기 설정
- JVM 옵션 최적화: OutOfMemoryError 방지를 위한 메모리 설정 및 모니터링
- 아키텍처 개선
- 메시지 큐(Kafka/RabbitMQ)를 활용한 비동기 처리로 시스템 부하 분산
- 모니터링 및 프로파일링
- Actuator, Prometheus, Grafana를 활용한 실시간 모니터링 구축
- JProfiler나 VisualVM을 통한 병목 구간 분석 및 개선
먼저 캐싱의 개념부터 알아보자.
Caching
Caching은 데이터를 일시적인 저장공간(캐시)에 저장하는 것을 의미한다. 캐시는 임시 파일과 임시 데이터들을 저장하는 메모리로 더 빠르게 관련 데이터들에 접근을 할 수 있게해준다.
캐시 메커니즘의 동작 원리
1. Look-aside Cache (Lazy Loading)
주로 읽기 작업에 최적화된 캐시 방식입니다. 데이터 요청이 있을 때, 먼저 캐시에서 데이터를 찾고, 캐시에 없으면 원본 데이터 저장소(예: 데이터베이스)에서 데이터를 가져오는 방식입니다.
public Product getProduct(Long id) {
// 1. 캐시에서 먼저 조회
String cacheKey = "product:" + id;
Product cachedProduct = cacheService.get(cacheKey);
if (cachedProduct != null) {
return cachedProduct; // 캐시 히트
}
// 2. 캐시 미스 시 DB에서 조회
Product product = productRepository.findById(id);
// 3. 조회한 데이터를 캐시에 저장
cacheService.set(cacheKey, product, Duration.ofHours(1));
return product;
}
작동 방식:
클라이언트가 데이터를 요청합니다.
캐시에서 해당 데이터를 찾습니다.
캐시 히트: 데이터가 캐시에 존재하면, 캐시에서 데이터를 반환합니다.
캐시 미스: 데이터가 캐시에 없으면, 원본 데이터 저장소에서 데이터를 가져와 캐시에 저장한 후 클라이언트에게 반환합니다.
장점:
읽기 성능이 향상됩니다.
캐시가 데이터 요청에 따라 동적으로 업데이트되므로, 자주 사용되는 데이터가 캐시에 저장됩니다.
단점:
데이터 쓰기 작업 시, 캐시와 원본 데이터 간의 정합성 문제가 발생할 수 있습니다. 데이터가 변경되었지만 캐시에 반영되지 않으면, 오래된 데이터가 반환될 수 있습니다.
2. Write-Through Cache
데이터 쓰기 작업이 발생할 때, 캐시와 원본 데이터 저장소 모두에 동시에 데이터를 기록하는 방식입니다. 이 방식은 데이터의 정합성을 보장하는 데 유리합니다.
public void updateProduct(Product product) {
// 1. DB 업데이트
productRepository.save(product);
// 2. 캐시 동시 업데이트
String cacheKey = "product:" + product.getId();
cacheService.set(cacheKey, product);
}
작동 방식:
클라이언트가 데이터를 쓰기 요청합니다.
캐시에 데이터를 먼저 기록한 후, 원본 데이터 저장소에도 동일한 데이터를 기록합니다.
장점:
데이터의 정합성이 보장됩니다. 캐시와 원본 데이터가 항상 일치하므로, 데이터 일관성 문제가 발생하지 않습니다.
읽기 작업 시, 캐시에서 데이터를 빠르게 반환할 수 있습니다.
단점:
쓰기 작업이 두 번 발생하므로, 성능이 저하될 수 있습니다. 특히, 데이터 쓰기 작업이 빈번한 경우 성능에 부정적인 영향을 미칠 수 있습니다.
캐시의 크기와 성능에 따라 전체 시스템 성능이 영향을 받을 수 있습니다.
Summary
Look-aside Cache는 주로 읽기 작업에 최적화되어 있으며, 캐시와 원본 데이터 간의 정합성 문제가 발생할 수 있습니다.
Write-Through Cache는 데이터의 정합성을 보장하지만, 쓰기 성능이 저하될 수 있습니다.
**데이터 정합성 문제 : 데이터 저장 시스템에서 데이터가 정확하고 일관된 상태를 유지하는 것.
캐시를 사용하는 주요 이유(장점)
- 응답 시간 개선
- DB 조회보다 메모리 접근이 훨씬 빠름
- 실제 프로젝트에서 평균 응답 시간을 200ms → 20ms로 개선한 경험
- 데이터베이스 부하 감소
- 반복적인 DB 조회를 줄여 서버 리소스 절약
- 특히 고부하 상황에서 DB 부하를 분산시킴
- 배용 효율성
- 데이터베이스 커넥션 수를 줄일 수 있음
- 서버 자원을 효율적으로 사용 가능
캐시를 사용할 때의 단점
사용자가 반복해서 같은 정보가 아닌 다른 정보를 입력할 때 기존에 저장해두었던 데이터가 계속 변경되어야 하기 때문에 다른 정보를 입력할 경우에는 효율성이 떨어진다는 단점이 있습니다.
Redis의 주요 사용 사례
1. 캐싱 시스템
- API 응답 데이터 캐싱이나, 세션 데이터 저장, 캐시 hit ratio 모니터링 및 캐시 정책 관리 등이 있습니다.
2. 실시간 순위 시스템
Sorted Set을 활용한 실시간 랭킹 구현이 가능하며, 게임 순위표, 실시간 차트 등 구현이 가능합니다.
3. 실시간 메시징
Pub/Sub 기능을 활용한 실시간 메시지(채팅 시스템, 실시간 알림 서비스) 전달이 가능합니다.
4. 분산 락(lock) 구현
동시성 제어를 위한 분산 락 처리 및 동시 접근 제어가 필요한 리소스 관리가 가능합니다.
캐시 전략
1. 로컬 캐시 (예: Caffeine)
로컬 캐시는 특정 애플리케이션이나 프로세스 내에서만 사용되는 캐시입니다. 일반적으로 메모리 내에 저장되며, 해당 애플리케이션이 직접 관리합니다. 로컬 캐시는 데이터 접근 속도를 높이고, 네트워크 지연을 줄이는 데 유리합니다.
단점: 데이터 일관성 문제 발생 가능 여러 인스턴스 간의 데이터 공유가 어려움
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
return new CaffeineCacheManager()
.builder()
.maximumSize(10000)
.expireAfterWrite(Duration.ofMinutes(5))
.build();
}
}
2. 분산 캐시 (예: Redis)
분산 캐시는 여러 서버나 노드에 걸쳐 데이터를 저장하는 캐시입니다. 이 방식은 대규모 시스템에서 데이터의 일관성을 유지하면서도 성능을 향상 시키기 위해 사용됩니다. 분산 캐시는 여러 클라이언트가 동시에 접근할 수 있도록 설계되어 있습니다.
단점: 복잡한 구현 및 관리 네트워크 지연이 발생할 수 있음
@EnableCaching
@Configuration
public class RedisCacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.build();
}
}
캐시 운영 시 고려사항
1. 캐시 정합성 관리
캐시와 원본 데이터 간의 일관성을 유지하는 것이 중요합니다. 데이터가 변경될 때 캐시도 적절히 업데이트되어야 하며, 이를 위해 다양한 전략이 필요합니다.
- TTL(Time To Live) 설정
- 이벤트 기반 캐시 갱신
- 캐시 무효화 전략
2. 캐시 크기 관리
- 메모리 사용량 모니터링
- 적절한 캐시 용량 설정
- 캐시 교체 정책 (LRU, LFU 등)
3. 캐시 히트율 최적화
- 캐시 적중률 모니터링
- 캐시 워밍업 전략
- 적절한 캐시 키 설계
Redis의 데이터가 영구적으로 저장되지 않는 주요 이유들
- In-Memory 데이터베이스 특성
- Redis는 모든 데이터를 메모리에 저장하는 In-Memory 데이터 스토어이다
- RAM은 휘발성 메모리로, 전원이 차단되면 데이터가 손실됨
- 디스크 기반 DB보다 빠른 읽기/쓰기 성능을 제공하는 대신 영구성을 희생
- 메모리 관리 정책
- Maxmemory 정책에 따른 데이터 Eviction
- LRU(Least Recently Used), LFU(Least Frequently Used) 등의 알고리즘 기반 삭제
- 메모리 부족 시 새로운 데이터를 위한 공간 확보 과정에서 기존 데이터 삭제
데이터 Eviction : 공간을 확보하기 위해 데이터를 삭제하는 방식
데이터 영구 저장을 위한 해결책
1. Redis Persistence 설정
# redis.conf 설정
# RDB 스냅샷 설정
save 900 1 # 900초 동안 1번 이상 변경 시 저장
save 300 10 # 300초 동안 10번 이상 변경 시 저장
save 60 10000 # 60초 동안 10000번 이상 변경 시 저장
# AOF 설정
appendonly yes
appendfsync everysec # 매 초마다 디스크에 저장
- RDB (Redis Database) 스냅샷
- 작동 방식
- Point-in-time 스냅샷으로 메모리의 데이터를 디스크에 저장
- fork()를 통해 자식 프로세스를 생성하여 스냅샷 생성
- 비동기적으로 진행되어 성능 영향 최소화
- 장점
- 작은 파일 크기로 빠른 백업과 복구
- 재시작 시 빠른 로딩
- 디스크 I/O 부하 감소
- 단점
- 스냅샷 사이의 데이터 유실 가능성
- fork() 프로세스로 인한 메모리 사용량 증가
- 작동 방식
- AOF (Append Only File)
- 작동 방식
- 모든 쓰기 명령을 로그 파일에 순차적으로 기록
- 재시작 시 명령을 재실행하여 데이터 복구
- fsync 정책에 따라 디스크 동기화 수행
- fsync 정책
- always: 매 명령마다 동기화 (가장 안전, 가장 느림)
- everysec: 매 초마다 동기화 (절충안)
- no: OS에 위임 (가장 빠름, 가장 위험)
- 장점
- 데이터 손실 최소화
- 실시간 데이터 보호
- 파일 손상 시 복구 용이
- 단점
- 파일 크기가 RDB보다 큼
- 재시작 시 로딩 시간이 더 김
- 디스크 I/O 부하 증가
- 작동 방식
2. 백업 전략 구현
@Service
@Slf4j
public class RedisBackupService {
private final RedisTemplate<String, Object> redisTemplate;
// 정기적인 백업 실행
@Scheduled(cron = "0 0 * * * *") // 매시간 실행
public void scheduleBackup() {
try {
String backupCommand = "SAVE";
redisTemplate.execute((RedisCallback<String>) connection -> {
connection.execute(backupCommand);
return null;
});
log.info("Redis backup completed successfully");
} catch (Exception e) {
log.error("Redis backup failed", e);
}
}
}
[백엔드 로드맵] 캐싱
캐싱에 대해서 알아보자. 주로 참고한 사이트는 여기이다. : Caching은 데이터를 일시적인 저장공간(캐시)에 저장하는 것을 의미한다. 캐시는 임시 파일과 임시 데이터들을 저장하는 메모리로 더
velog.io
면접 질문 대비 - 성능 최적화 및 캐싱
질문 의도 의도 : 성능 최적화 카테고리는 데이터베이스 및 서버 성능을 최적화하는 능력을 평가합니다. 특히 캐싱, 쿼리 최적화, CDN 등은 실무에서 서버 부하를 줄이고 사용자 경험을 향상시키
velog.io