본문 바로가기
프로젝트

Redis란 무엇인가?: Spring Boot에 캐시로 적용해보기

by Jiyoung Oh 2025. 7. 10.

 

💬 왜 인메모리 데이터베이스가 필요할까?

 

PostDM 프로젝트는 사용자들이 견적서를 생성하고 조회할 수 있는 플랫폼입니다. MySQL 기반인 프로젝트의 핵심 API인 견적서 상세 조회 API에 대해 JMeter를 활용한 성능 테스트를 진행했습니다. 그 결과 평균 응답 속도가 약 60ms로 나타났는데, 이는 일반적인 웹 API 기준으로는 나쁘지 않은 수준입니다. 하지만 견적서 상세 조회 API는 현재로는 수정/삭제 기능이 없고, 내용 변화가 없기 때문에 캐싱을 적용한다면 더 나은 응답 수준을 보여줄 수 있을 거라 생각했습니다.

따라서 이번 글에서는 Redis가 무엇인지 정리하고, 프로젝트에서 어떻게 적용했는지 정리해보고자 합니다.


인메모리 데이터베이스와 Redis

인메모리 데이터베이스?

인메모리 데이터베이스는 데이터를 디스크가 아닌 메모리(RAM)에 저장하는 데이터베이스입니다. 디스크에 따로 엑세스할 필요가 없어 응답시간을 최소화할 수 있습니다. 이런 장점으로 일시적인 데이터의 일부를 저장하는 데이터 스토리지 계층인 캐시로 활용할 수 있습니다. 캐싱을 사용하면 이전에 검색 및 계산한 데이터를 효율적으로 재사용할 수 있습니다.

구분 MySQL (디스크기반) Redis (메모리 가반)
저장 위치 HDD/SSD RAM
속도 느림 (디스크 I/O 대기) 매우 빠름 (메모리 직접 접근)
지속성 영구 보관 휘발성
용량 대용량 (TB 단위) 제한적 (GB 단위)
용도 주 데이터 저장 (영구 데이터 저장) 캐시, 세션 등 (빠른 임시 데이터 저장)
TTL 지원 없음 기본 제공 
캐시 기능 내부 최적화 목적 애플리케이션 캐시 목적

Redis?

Redis(Remote Dictionary Server)는 메모리(RAM)를 기반으로 동작하는 오픈 소스 인메모리 키-값 저장소로, 빠른 데이터 접근 속도를 제공합니다.  아래와 같은 특징으로 인해 Redis는 일반적인 관계형 데이터베이스(MySQL 등)의 부하를 줄이고, 애플리케이션의 전반적인 처리 속도와 구조적 유연성을 향상시키는 캐시 계층으로 활용되고 있습니다.

 

특징

  • 빠른 속도: 메모리 기반으로 디스크 I/O가 없어 마이크로초 단위 응답
  • 다양한 데이터 구조: String, Hash, List, Set, Sorted Set 등 지원
  • 클러스터링: 수평 확장 및 고가용성 지원
  • TT L(Time To Live) 지원: 자동 만료 시간 설정으로 메모리 효율성 (캐시 무효화 전략 구현에 유리)
  • 퍼시스턴스(데이터 영속성) 기능: 제공하여 데이터를 디스크에 주기적으로 저장하거나 AOF(Append-Only File) 로그를 통해 복구 가능

사용 예시

  • 캐싱: API 응답, 데이터베이스 쿼리 결과
  • 세션 관리: 사용자 로그인 상태, 토큰 저장
  • 실시간 데이터: 채팅, 게임 스코어보드
  • 대기열 시스템: 작업 큐, 메시지 브로커

캐싱 전략

Redis를 캐시로 사용할 때 주요 패턴은 아래와 같습니다. 저희 프로젝트에서는 Cache-Aside 패턴을 선택했습니다. 이는 Spring의 @Cacheable 어노테이션을 사용하여 구현했으며,  자주 조회되지만 자주 변경되지 않는 데이터를 효율적으로 캐싱하는 데에 적합하다고 판단했습니다.

1. Cache-Aside (Lazy Loading)

  • 애플리케이션이 직접 캐시를 관리
  • 데이터를 요청할 때 캐시에 없으면 DB에서 조회 후 캐시에 저장
  • Spring에서 `@Cacheable`, `@CacheEvict` 어노테이션으로 구현 가능
  • 읽기 중심, 일관성이 중요하지 않은 경우 적합

2. Write-Through

  • 데이터를 저장할 때 DB와 캐시에 동시에 저장
  • 항상 캐시에 최신 데이터가 존재하지만, 쓰기 시 지연이 발생할 수 있음
  • 쓰기 빈도가 낮고 일관성이 중요한 경우 적합

3. Write-Behind (Write-Back)

  • 먼저 캐시에만 쓰고, 일정 시간 후 배치 또는 큐를 통해 DB에 반영
  • 응답 속도는 빠르지만, 장애 발생 시 DB 일관성 문제가 발생할 수 있음
  • 성능이 매우 중요한 경우나, 일괄 저장이 가능한 경우 적합

프로젝트에 Redis 적용하기

1. Redis 환경 구성

Docker Compose로 Redis 서버 구성

먼저 개발팀이 동일한 Redis 환경을 사용할 수 있도록 Docker Compose를 설정했습니다.

# docker-compose.yml
version: '3.8'
services:
  redis:
    image: redis:7.0
    container_name: redis
    ports:
      - "6379:6379"
# Redis 실행
docker-compose up -d redis

# 연결 확인
docker exec -it redis redis-cli ping
# 응답: PONG
// Spring Boot 의존성 추가
// build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    implementation 'org.springframework.boot:spring-boot-starter-cache'
    implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
}

2. Redis 설정

환경별 설정 분리

1. cache-names: estimates

  • 캐시 이름으로 "estimates" 등록
  • @Cacheable("estimates"), @CachePut("estimates"), @CacheEvict("estimates") 등에서 "estimates"라는 이름을 사용해야 캐시 동작

2. redis.enable-statistics: true

  • Redis 캐시 통계 (히트/미스 횟수 등)를 수집하도록 설정
  • 추후 통계를 모니터링하거나 로그에 활용 가능

3. spring.cache.type: redis

  • Spring Cache 추상화를 사용할 때, Redis를 캐시 저장소로 사용
  • Spring Boot는 기본적으로 ConcurrentMap을 사용하지만, 이 설정을 통해 Redis로 변경

4, spring.data.redis.host / port

  • 실제 Redis 서버의 위치를 지정
  • 개발환경에서 로컬 Redis 서버(localhost:6379)를 사용
# application.yml (공통 설정)
spring:
  cache:
    cache-names: estimates
    redis:
      enable-statistics: true

# application-dev.yml (개발환경)
spring:
  cache:
    type: redis
  data:
    redis:
      host: localhost
      port: 6379

Redis 캐시 매니저 설정

  • TTL 2시간: 견적서 특성상 적절한 캐시 유지 시간
  • JSON 직렬화: Redis CLI에서 확인 가능하고 다른 언어와 호환
  • 타입 정보 포함: 정확한 객체 복원을 위한 설정
  • JavaTimeModule: LocalDateTime 등 Java 8 시간 타입 지원

1. @EnableCaching

  • Spring에서 캐시 기능(@Cacheable, @CacheEvict)을 활성화하는 기본 어노테이션
  • 이 설정이 있어야 Spring의 캐시 추상화가 작동합니다.

2. RedisConnectionFactory (Lettuce)

  • Redis 서버와의 연결을 담당합니다.
  • Lettuce는 넌블로킹, 스레드 세이프한 Redis 클라이언트로 Spring 기본 설정

3. cacheManager - Spring Cache 전용 설정

  • CacheManager는 @Cacheable("estimates") 등 Spring 캐시 어노테이션과 연동되며, Spring Cache로 Redis를 캐시 계층으로 사용하는 핵심 설정
  • entryTtl(Duration.ofHours(2)): 모든 캐시 항목의 TTL을 2시간으로 설정
  • disableCachingNullValues(): null 값은 캐시 하지 않음
  • serializeKeysWith(...): 키는 문자열(String)로 직렬화
  • serializeValuesWith(...): 값은 Jackson으로 JSON 직렬화 (시간 타입 포함)

4. RedisTemplate - Redis 직접 점근용 설정

  • Spring의 RedisTemplate을 통해 Redis에 직접 데이터를 읽고/쓰고 할 수 있게 해 줌
  • Hash, String, Object 타입까지 유연하게 사용 가능
  • 예시: 직접 데이터를 저장하고 싶을 때 ( redisTemplate.opsForValue().set("user:1", userDto); )

5. createObjectMapper() - 직렬화 설정의 핵심

  • JavaTimeModule 등록: LocalDateTime 직렬화/역직렬화 가능하게 함
  • WRITE_DATES_AS_TIMESTAMPS 비활성화: 날짜를 숫자(timestamp)로 저장하지 않고 ISO 8601 문자열로 저장
  • FAIL_ON_UNKNOWN_PROPERTIES 비활성화: 역직렬화 시 정의되지 않은 필드 무시
  • activateDefaultTyping(...): 역직렬화 시 타입 정보를 보존 (객체의 원래 타입을 알 수 있도록 설정)
@Configuration
@EnableCaching
public class RedisConfig {

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory();
    }

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        // LocalDateTime 직렬화를 위한 ObjectMapper 설정
        ObjectMapper objectMapper = createObjectMapper();
        
        Jackson2JsonRedisSerializer<Object> serializer =
                new Jackson2JsonRedisSerializer<>(objectMapper, Object.class);

        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(serializer))
                .entryTtl(Duration.ofHours(2))  // TTL 2시간
                .disableCachingNullValues();

        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(config)
                .build();
    }

    private ObjectMapper createObjectMapper() {
        ObjectMapper objectMapper = new ObjectMapper();
        // Java 8 시간 타입 지원
        objectMapper.registerModule(new JavaTimeModule());
        objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        // 알 수 없는 프로퍼티 무시
        objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        // 타입 정보 포함 (역직렬화 시 올바른 타입으로 변환)
        objectMapper.activateDefaultTyping(
                objectMapper.getPolymorphicTypeValidator(),
                ObjectMapper.DefaultTyping.NON_FINAL
        );
        return objectMapper;
    }
}

 

3. 캐싱 적용

Service Layer에 @Cacheable 적용

Estimate(견적서) Service Layer에서 견적서 상세 내용을 가져오는 getEstimateDetail에 @Cacheable을 적용합니다.

public class EstimateService {
// @Cacheable 적용 
    @Cacheable(value = "estimates", key = "#estimateId")
    public EstimateResponseDto getEstimateDetail(Long estimateId, MemberPrincipalDto principal) {
        log.info("DB에서 조회 수행 - estimateId={}", estimateId);
        
        // 실제 DB 조회 로직
        ...
            
        // 권한 검증 로직
	...
        
        return EstimateResponseDto.(...);
    }
}

DTO 직렬화 지원

직렬화 (Serialization)
- 객체(Object)를 바이트(byte) 형태로 변환하는 과정
- 메모리에 존재하는 자바 객체를 파일, 네트워크, Redis 등에 저장하거나 전송할 수 있게 변환
- 자바 객체는 메모리 안에서만 존재하는 구조체로 객체를 다른 시스템에 전송하거나, 디스크나 Redis에 저장
하려면 객체를 JSON이나 바이너리 같은 저장 가능한 형태로 바꿔야 함

역직렬화 (Deserialization)
- 직렬화된 데이터를 다시 객체로 복원하는 과정
- 예시: Redis에 저장된 JSON 문자열 → EstimateResponseDto 객체로 다시 변환

 

EstimateResponseDto는 Redis 캐시 대상으로, 그 안에 LocalDateTime 필드가 있기 때문에 RedisConfig에서 Jackson + JavaTimeModule 설정을 통해 직렬화 처리를 명확히 합니다.

Spring에서 Redis 캐시 된 JSON 데이터를 역직렬화할 때, Jackson은 내부적으로 기본 생성자 + setter 또는 리플렉션을 사용합니다. 즉, @AllArgsConstructor만 있으면 Jackson은 역직렬화에 실패하여 @NoArgsConstructor가 필요합니다.

@Getter
@Schema(description = "견적서 응답 DTO")
@AllArgsConstructor
@NoArgsConstructor	// Jackson 역직렬화를 위해 필수
@JsonIgnoreProperties(ignoreUnknown = true)
public class EstimateResponseDto {
    ...
}

캐싱 결과 확인 및 분석

 

[Feature] Redis 설정 및 견적서 상세 조회 API에 Redis 캐시 적용 by rimeir · Pull Request #72 · fullstack-dev-hub

📌 개요 견적서 상세 조회 API에 Redis 캐시를 적용하여 응답 성능을 개선했습니다. 두 번째 조회부터 캐시에서 데이터를 반환하여 응답 속도가 약 16배 향상되었습니다. 🚀 관련 이슈 관련된 이

github.com

 

캐싱 테스트

동일한 견적서를 연속으로 2번 조회하여 캐시 효과를 측정했습니다. 아래와 같이 설정한 캐시 동작을 검증합니다.

  1. 캐시 미스: "DB에서 조회 수행" 로그 출력, 느린 응답
  2. 캐시 히트: DB 로그 없음, 빠른 응답
  3. TTL 설정: 2시간 후 자동 만료
  4. 데이터 정합성: JSON 직렬화로 완벽한 객체 복원

첫 번째 호출 (캐시 미스)

2025-07-01T18:20:00.019 - DB에서 조회 수행 - estimateId=20
2025-07-01T18:20:00.035 - [PERF] DB 조회: 8ms
2025-07-01T18:20:00.036 - [PERF] 서비스 로직: 17ms  
2025-07-01T18:20:00.060 - [PERF] 전체 응답: 48ms
  • "DB에서 조회 수행" 로그 출력
  • DB 조회 성능 로그 (findById 8ms)
  • 서비스 메서드 성능 로그 (17ms)
  • 전체 응답 시간 48ms
  • 캐시에 데이터 저장

두 번째 호출 (캐시 히트)

2025-07-01T18:20:45.076 - [PERF] 전체 응답: 3ms
  • "DB에서 조회 수행" 로그 없음
  • DB 관련 성능 로그 없음 ([PERF] 로그)
  • 서비스 메서드 성능 로그 없음
  • 컨트롤러 응답만 3ms
  • 캐시에서 직접 반환

성능 개선 결과

구분 캐시 미스 캐시 히트 개선 효과
응답 시간 48ms 3ms 16배 향상
DB 조회 8ms 0ms 완전 제거
서비스 로직 17ms 건너뜀 100% 절약

Redis CLI로 캐시 확인

# 캐시 키 확인
127.0.0.1:6379> KEYS "estimates:*"
1) "estimates::20"

# TTL 확인 (7200초 = 2시간)
127.0.0.1:6379> TTL "estimates::20"
(integer) 7185

# 캐시 내용 확인
127.0.0.1:6379> GET "estimates::20"
{"@class":"EstimateResponseDto","id":20,"content":"테스트 견적서",...}

💬 마무리하며

Redis를 실제 프로젝트에 적용해 보면서 이론으로만 알고 있던 인메모리 캐시의 동작 방식을 직접 체험할 수 있었습니다. 특히 JSON 직렬화 설정, TTL 관리, 캐시 키 전략 등 세부 설정들이 왜 필요한지 배울 수 있었고, 캐싱은 적용하여 약 16배의 성능 향상 결과를 통해 캐싱의 효과를 확인할 수 있었습니다.

처음에는 단순히 Redis를 적용해보고 싶다는 호기심에서 시작했지만, 실제로 구현하면서 캐시 설정의 중요성, 환경별 설정 분리, 성능 측정 방법 등 많은 것들을 배웠습니다. 견적서 조회라는 상대적으로 단순한 API였지만, 캐싱의 전체 생명주기를 이해하는 데 충분히 도움이 되었습니다.

이번 견적서 캐싱 경험을 바탕으로, 이제 Redis가 정말 필요한 영역인 토큰 관리 시스템에 적용해 볼 계획입니다. 현재 PostDM 프로젝트에서는 MySQL을 바탕으로 JWT 리프레시 토큰 저장, 액세스 토큰 블랙리스트와 같은 토큰 관리 시스템이 있습니다. 이러한 시스템에 Redis의 특성을 활용한다면 성능 향상 및 효율적인 토큰 관리 등을 기대할 수 있습니다.

앞으로도 N+1 쿼리 최적화, 캐시 무효화 전략, 모니터링 도구 구축 등을 통해 전반적인 성능 최적화를 꾸준히 진행해보고자 합니다.


참고자료