반응형
이번 글에서는 Redis 캐시를 적용하는 방법과 예제를 살펴보도록 하겠습니다.
- Redis 캐시 사용 이유
- 대부분의 백엔드 시스템은 “읽기 비중이 높고 동일 데이터 재조회”가 잦음 → 캐시 효과 큼.
- 하지만 잘못된 TTL/무분별한 키 전략은 오래된 데이터, 메모리 누수, 캐시 스탬피드를 유발.
- Redis 캐시 적용 요소 선정
- 무엇을 캐시할지 정의: “변경 빈도 낮고 조회량 큰 데이터”를 1순위로. (예: 상품 상세, 인기 글, 프로필)
- 키 전략 수립:
캐시이름::주요식별자(예:product::123)로 단순·예측 가능하게. - 일관된 TTL: 도메인별 TTL을 분리(상품 10분, 카테고리 1분 등). 데이터 신선도 요구에 맞춤.
- 동시성/스탬피드 방지: 잠금·조기 갱신(early refresh)·백그라운드 리프레시 고려.
- 모니터링: 히트율, 메모리 사용량, 만료/삭제 카운트, slowlog 관측.
- Redis 캐시 적용후 개선량 확인
- 캐시 도입 전/후 응답 시간·DB QPS·트래픽 비용 비교.
- 캐시 장애 시에도 서비스가 느려질 뿐 멈추진 않는지(폴백 전략) 확인.
- 실전 적용 예시
1. 어떤 데이터를 캐시할까?
- 적합: 읽기 많음, 즉시 일관성 필요 낮음, 소용량(수 KB~수십 KB), 계산 비용 큼
예) 상품 상세, 공지/태그 목록, 추천 결과, 권한 매핑 - 부적합: 강한 즉시 일관성 필수(거래/재고), 민감 데이터(세션/토큰은 별 관리), 대용량 이진
2. 캐시 패턴 선택
- Cache-Aside (권장)
- 캐시에 조회 → 없으면 DB 조회 → 캐시에 저장(TTL) → 응답
- 쓰기는 DB가 기준, 변경 후 캐시 무효화
- 장점: 단순, 장애 시 캐시 우회 가능
- Read-Through/Write-Through: 캐시가 DB 앞단 프락시처럼 동작(운영 난이도↑)
- Write-Behind: 지연 쓰기(데이터 유실·정합성 위험 관리 필요)
3. TTL/무효화 전략
- 시간 기반 TTL: 데이터 신선도 요구에 맞춰 도메인별로 차등(예: 게시글 5분, 랭킹 30초).
- 이벤트 기반 무효화: 업데이트/삭제 시 해당 키 삭제(
@CacheEvict), 리스트 페이지는 부분 파기 또는 짧은 TTL. - 스탬피드 방지:
- Jitter(무작위 편차): TTL에 ±10~20% 랜덤 가산
- 퍼-키 락: 동일 키에 대한 미스 동시 폭주 방지(분산 락 or 로컬 Caffeine 연계)
- Soft TTL: 만료 임박 시 백그라운드 리프레시(요청은 구캐시 제공)
4. 직렬화와 메모리
- Serializer: Jackson JSON 직렬화(사람이 보기 쉬움) vs JDK(느림/크다) vs Kryo(Fast, 세팅 복잡).
- 메모리 정책:
maxmemory-policy(allkeys-lru 권장) + 적절한 key/value 사이즈 유지. - 네임스페이스: 캐시 이름별 prefix로 도메인 분리.
5. 운영 관점 체크리스트
- 히트율(>70% 목표, 도메인별 편차 확인)
- 메모리 사용량/evicted keys 추이
- slowlog(느린 명령) 점검
- 장애 시 폴백(캐시 미스 → DB 조회) 정상 동작
- 재시작/스냅샷(RDB/AOF) 정책
- 보안: 비공개 네트워크, AUTH, TLS(필요 시)
6. 장애·데이터 정합성 고려
- 캐시 일시 장애: 응답 지연↑(DB 부하↑) → Circuit Breaker/레이트리밋으로 보호
- 오래된 데이터: 작은 TTL + 이벤트 무효화 병행
- 핫키: 샤딩 또는 키 분할(예:
rank::today::{A|B})로 집중 완화
- 고급화 전략
- 도메인별 CacheManager 분리: TTL·직렬화 전략을 캐시 이름군 별로 다르게.
- 멀티 레벨 캐시: 로컬(Caffeine) + 원격(Redis) 조합 → 지연·부하 동시 감소.
- 관측성: Micrometer + Prometheus/Grafana로 히트·미스·에빅트 지표 시각화.
- 키 카디널리티 관리: 리스트/검색 결과 캐시는 조합 폭증 주의(페이지/필터를 키에 포함).
- 테스트 전략: 통합 테스트에서 Testcontainers로 실 Redis 구동, 캐시 히트/미스 시나리오 검증.
- 배포 전략: 롤링 재시작 시 대규모 캐시 소실로 콜드 스타트 발생 → 예열(Pre-warm) 스크립트 고려.
- 요약
- 작동 원리를 이해한 뒤 무엇을 캐시할지 선별하고, TTL/무효화/동시성을 먼저 설계하세요.
- 운영 단계에서는 히트율/메모리/슬로우로그를 꾸준히 관측해 스탬피드와 핫키를 관리하면 됩니다.
- Spring의 선언적 캐시(@Cacheable/Put/Evict)와 도메인별 CacheManager를 조합하면, 코드 침습도를 낮추면서도 강력한 캐싱을 구현할 수 있습니다.
예시 코드
- Redis 컨테이너 구동
# Docker
docker run -p 6379:6379 --name redis -d redis:7-alpine
# 또는 docker-compose.yml (옵션)
# version: '3.8'
# services:
# redis:
# image: redis:7-alpine
# ports: ["6379:6379"]
# command: ["redis-server", "--appendonly", "yes", "--maxmemory", "512mb", "--maxmemory-policy", "allkeys-lru"]
- 의존성 추가(Gradle)
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'org.springframework.boot:spring-boot-starter-data-redis' // Lettuce 기본
implementation 'com.fasterxml.jackson.core:jackson-databind'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
- application.yml에 Redis연결 정보 추가
spring:
cache:
type: redis
data:
redis:
host: localhost
port: 6379
# password: yourpass # 보안 환경에서 사용
- CacheConfig (도메인별 TTL/직렬화)
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisConnectionFactory redisConnectionFactory(
RedisProperties props) {
// 기본 Lettuce 사용
return new LettuceConnectionFactory(props.getHost(), props.getPort());
}
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory cf) {
ObjectMapper om = new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
Jackson2JsonRedisSerializer<Object> serializer =
new Jackson2JsonRedisSerializer<>(Object.class);
serializer.setObjectMapper(om);
RedisCacheConfiguration defaultConf = RedisCacheConfiguration.defaultCacheConfig()
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(serializer))
.entryTtl(Duration.ofMinutes(5)) // 기본 TTL 5분
.disableCachingNullValues();
// 캐시 이름별 TTL 커스터마이즈
Map<String, RedisCacheConfiguration> confMap = new HashMap<>();
confMap.put("product", defaultConf.entryTtl(Duration.ofMinutes(10)));
confMap.put("category", defaultConf.entryTtl(Duration.ofMinutes(1)));
return RedisCacheManager.builder(cf)
.cacheDefaults(defaultConf)
.withInitialCacheConfigurations(confMap)
.build();
}
}
- 예시 DTO, Service, Controller
// 예시 DTO
public record ProductDto(Long id, String name, int price) {}
@Service
public class ProductService {
private final Map<Long, ProductDto> fakeDb = new ConcurrentHashMap<>();
public ProductService() {
fakeDb.put(1L, new ProductDto(1L, "Apple Watch", 450000));
fakeDb.put(2L, new ProductDto(2L, "iPad Air", 820000));
}
@Cacheable(cacheNames = "product", key = "#id")
public ProductDto getProduct(Long id) {
simulateSlowDb(); // DB 지연 시뮬
return Optional.ofNullable(fakeDb.get(id))
.orElseThrow(() -> new NoSuchElementException("not found"));
}
@CachePut(cacheNames = "product", key = "#dto.id()")
public ProductDto updateProduct(ProductDto dto) {
fakeDb.put(dto.id(), dto);
return dto;
}
@CacheEvict(cacheNames = "product", key = "#id")
public void deleteProduct(Long id) {
fakeDb.remove(id);
}
private void simulateSlowDb() {
try { Thread.sleep(800); } catch (InterruptedException ignored) {}
}
}
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService svc;
public ProductController(ProductService svc) { this.svc = svc; }
@GetMapping("/{id}")
public ProductDto get(@PathVariable Long id) { return svc.getProduct(id); }
@PutMapping("/{id}")
public ProductDto update(@PathVariable Long id, @RequestBody ProductDto body) {
return svc.updateProduct(new ProductDto(id, body.name(), body.price()));
}
@DeleteMapping("/{id}")
public void delete(@PathVariable Long id) { svc.deleteProduct(id); }
}
- 테스트 CLI
# 첫 호출(캐시 미스): 약 800ms
curl -w '\n%{time_total}s\n' http://localhost:8080/api/products/1
# 두 번째 호출(캐시 히트): 수 ms
curl -w '\n%{time_total}s\n' http://localhost:8080/api/products/1
- 테스트 코드
// build.gradle에 testcontainers 의존성 추가 후 사용 가능
// testImplementation 'org.testcontainers:junit-jupiter'
// testImplementation 'org.testcontainers:redis'
@ExtendWith(SpringExtension.class)
@SpringBootTest
@Testcontainers
class CacheIntegrationTest {
@Container
static RedisContainer redis = new RedisContainer(DockerImageName.parse("redis:7-alpine"))
.withExposedPorts(6379);
@DynamicPropertySource
static void redisProps(DynamicPropertyRegistry r) {
r.add("spring.data.redis.host", () -> redis.getHost());
r.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
}
@Autowired ProductService svc;
@Test
void cacheable_shouldHitCache() {
long t1 = timed(() -> svc.getProduct(1L)); // miss
long t2 = timed(() -> svc.getProduct(1L)); // hit
assertTrue(t1 > t2);
}
long timed(Runnable r) {
long s=System.currentTimeMillis(); r.run(); return System.currentTimeMillis()-s;
}
}
반응형
'Backend > Study' 카테고리의 다른 글
| [Study] 서버 다운 없이 배포하는 방법 (무중단 배포) (0) | 2025.09.30 |
|---|---|
| [Study] Nginx 리버스 프록시 설정 예제 (502/504 에러 해결) (0) | 2025.09.29 |
| [Study] JPA vs MyBatis 비교 – 언제 어떤 걸 써야 할까 (0) | 2025.09.24 |
| [Study] REST API 설계 원칙과 Request/Response 이해하기 (0) | 2025.09.22 |
| [Study] Spring Boot 프로젝트 설정 방법 (0) | 2025.09.22 |