본문 바로가기

Backend/Study

[Study] Redis 캐시 적용 방법 (Spring Boot 실전 예제)

반응형

 이번 글에서는 Redis 캐시를 적용하는 방법과 예제를 살펴보도록 하겠습니다.

  • Redis 캐시 사용 이유
    • 대부분의 백엔드 시스템은 “읽기 비중이 높고 동일 데이터 재조회”가 잦음 → 캐시 효과 큼.
    • 하지만 잘못된 TTL/무분별한 키 전략은 오래된 데이터, 메모리 누수, 캐시 스탬피드를 유발.
  • Redis 캐시 적용 요소 선정
    1. 무엇을 캐시할지 정의: “변경 빈도 낮고 조회량 큰 데이터”를 1순위로. (예: 상품 상세, 인기 글, 프로필)
    2. 키 전략 수립: 캐시이름::주요식별자(예: product::123)로 단순·예측 가능하게.
    3. 일관된 TTL: 도메인별 TTL을 분리(상품 10분, 카테고리 1분 등). 데이터 신선도 요구에 맞춤.
    4. 동시성/스탬피드 방지: 잠금·조기 갱신(early refresh)·백그라운드 리프레시 고려.
    5. 모니터링: 히트율, 메모리 사용량, 만료/삭제 카운트, slowlog 관측.
  • Redis 캐시 적용후 개선량 확인
    • 캐시 도입 전/후 응답 시간·DB QPS·트래픽 비용 비교.
    • 캐시 장애 시에도 서비스가 느려질 뿐 멈추진 않는지(폴백 전략) 확인.

- 실전 적용 예시

1. 어떤 데이터를 캐시할까?

  • 적합: 읽기 많음, 즉시 일관성 필요 낮음, 소용량(수 KB~수십 KB), 계산 비용 큼
    예) 상품 상세, 공지/태그 목록, 추천 결과, 권한 매핑
  • 부적합: 강한 즉시 일관성 필수(거래/재고), 민감 데이터(세션/토큰은 별 관리), 대용량 이진

2. 캐시 패턴 선택

  • Cache-Aside (권장)
    1. 캐시에 조회 → 없으면 DB 조회 → 캐시에 저장(TTL) → 응답
    2. 쓰기는 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;
    }
}

 

반응형