Spring/백엔드 실무 지식

백엔드 성능 최적화 - Redis를 활용한 리모트 캐싱

JuNo_12 2025. 7. 14. 19:45

들어가며

백엔드 애플리케이션에서 성능 병목 현상이 발생하는 가장 흔한 원인 중 하나는 데이터베이스 조회입니다. 특히 동일한 데이터를 반복적으로 조회하거나, 복잡한 연산이 필요한 데이터를 매번 새로 계산하는 것은 시스템 전체의 성능을 크게 저하시킬 수 있습니다. 이런 문제를 해결하기 위한 핵심 기술이 바로 캐싱입니다.

 

캐싱이란 무엇인가

캐싱은 자주 사용되거나 계산 비용이 높은 데이터를 임시로 저장해두었다가, 동일한 요청이 들어올 때 빠르게 응답할 수 있도록 하는 기술입니다. 데이터베이스 조회 대신 메모리에서 데이터를 가져오므로 응답 속도가 현저히 빨라집니다.

 

Redis란

Redis(Remote Dictionary Server)는 메모리 기반의 키-값 저장소로, 다음과 같은 특징을 가지고 있습니다.

  • 인메모리 데이터 구조 저장소: 모든 데이터를 메모리에 저장하여 빠른 읽기/쓰기 성능을 제공합니다
  • 다양한 데이터 타입 지원: 문자열, 해시, 리스트, 집합, 정렬된 집합 등을 지원합니다
  • 영속성 옵션: RDB 스냅샷이나 AOF 로그를 통해 데이터를 디스크에 저장할 수 있습니다
  • 분산 환경 지원: 여러 애플리케이션 서버가 동일한 캐시를 공유할 수 있습니다

리모트 캐싱의 장점

1. 성능 향상

데이터베이스 조회 시간을 크게 단축시킬 수 있습니다. 일반적으로 메모리 접근은 디스크 접근보다 수백 배 빠릅니다.

2. 데이터베이스 부하 감소

반복적인 쿼리를 캐시로 처리하여 데이터베이스 서버의 부하를 줄일 수 있습니다.

3. 확장성

여러 애플리케이션 서버가 동일한 캐시를 공유하여 일관된 데이터를 제공할 수 있습니다.

 

실제 구현 예시

Spring Boot + Redis 설정

먼저 의존성을 추가합니다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Redis 설정을 구성합니다.

@Configuration
@EnableCaching
public class RedisConfig {
    
    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        LettuceConnectionFactory factory = new LettuceConnectionFactory(
            new RedisStandaloneConfiguration("localhost", 6379)
        );
        return factory;
    }
    
    @Bean
    public CacheManager 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();
    }
}

 

서비스 클래스에서 캐싱 적용

@Service
public class UserService {
    
    @Autowired
    private UserRepository userRepository;
    
    @Cacheable(value = "users", key = "#userId")
    public User getUserById(Long userId) {
        // 데이터베이스에서 사용자 조회
        return userRepository.findById(userId)
            .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다"));
    }
    
    @CacheEvict(value = "users", key = "#user.id")
    public User updateUser(User user) {
        User updatedUser = userRepository.save(user);
        return updatedUser;
    }
    
    @Cacheable(value = "user-posts", key = "#userId")
    public List<Post> getUserPosts(Long userId) {
        // 복잡한 쿼리나 연산이 필요한 경우
        return postRepository.findPostsWithCommentsAndLikes(userId);
    }
}

 

수동 캐시 제어

더 세밀한 제어가 필요한 경우 RedisTemplate을 직접 사용할 수 있습니다.

@Service
public class ProductService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private ProductRepository productRepository;
    
    public Product getProduct(Long productId) {
        String cacheKey = "product:" + productId;
        
        // 캐시에서 조회
        Product cachedProduct = (Product) redisTemplate.opsForValue().get(cacheKey);
        if (cachedProduct != null) {
            return cachedProduct;
        }
        
        // 데이터베이스에서 조회
        Product product = productRepository.findById(productId)
            .orElseThrow(() -> new ProductNotFoundException("상품을 찾을 수 없습니다"));
        
        // 캐시에 저장 (30분 TTL)
        redisTemplate.opsForValue().set(cacheKey, product, Duration.ofMinutes(30));
        
        return product;
    }
    
    public void invalidateProductCache(Long productId) {
        String cacheKey = "product:" + productId;
        redisTemplate.delete(cacheKey);
    }
}

캐싱 전략

1. Cache-Aside (Lazy Loading)

애플리케이션이 캐시를 직접 관리하는 방식입니다.

public Product getProduct(Long id) {
    // 1. 캐시 확인
    Product product = getFromCache(id);
    if (product != null) {
        return product;
    }
    
    // 2. 데이터베이스 조회
    product = getFromDatabase(id);
    
    // 3. 캐시에 저장
    saveToCache(id, product);
    
    return product;
}

 

2. Write-Through

데이터를 저장할 때 캐시와 데이터베이스에 동시에 저장하는 방식입니다.

public Product saveProduct(Product product) {
    // 1. 데이터베이스 저장
    Product savedProduct = productRepository.save(product);
    
    // 2. 캐시 업데이트
    saveToCache(savedProduct.getId(), savedProduct);
    
    return savedProduct;
}

 

3. Write-Behind (Write-Back)

캐시에만 먼저 저장하고, 나중에 데이터베이스에 비동기적으로 저장하는 방식입니다.


주의사항

1. 캐시 무효화 전략

데이터가 변경될 때 캐시를 적절히 무효화하지 않으면 일관성 문제가 발생할 수 있습니다.

2. 메모리 사용량 관리

Redis는 메모리 기반이므로 적절한 TTL 설정과 메모리 사용량 모니터링이 필요합니다.

3. 캐시 워밍

애플리케이션 시작 시 중요한 데이터를 미리 캐시에 로드하는 전략을 고려해야 합니다.

4. 장애 대응

Redis 서버가 다운되었을 때의 fallback 전략을 마련해야 합니다.

public Product getProduct(Long id) {
    try {
        // Redis 캐시 조회 시도
        Product product = getFromCache(id);
        if (product != null) {
            return product;
        }
    } catch (Exception e) {
        // Redis 장애 시 로그 기록하고 데이터베이스로 fallback
        log.warn("Redis 캐시 조회 실패, 데이터베이스에서 조회합니다", e);
    }
    
    // 데이터베이스에서 조회
    return getFromDatabase(id);
}

마무리

Redis를 활용한 리모트 캐싱은 백엔드 애플리케이션의 성능을 크게 향상시킬 수 있는 핵심 기술입니다. 적절한 캐싱 전략과 함께 구현하면 사용자 경험 개선과 서버 자원 절약이라는 두 마리 토끼를 모두 잡을 수 있습니다. 다만 데이터 일관성과 장애 대응에 대한 충분한 고려가 필요하며, 지속적인 모니터링을 통해 캐시 효율성을 관리해야 합니다.