Spring Data Redis — RedisTemplate·@Cacheable 한 번에

2026-05-02AWS SAA-C03 스터디

Redis 핵심 정리 시리즈 8편. Spring 환경에서 Redis를 다루는 표준 방법을 처음 보는 사람도 따라올 수 있게 친절하게 풀어쓴 글. RedisTemplate 설정·직렬화 전략부터 @Cacheable·@CachePut·@CacheEvict 같은 캐싱 어노테이션, Lettuce vs Jedis 비교, Spring Session·분산 락(Redis Lock·Redisson)까지 자바 측에서 Redis 사물함을 다루는 표준 도구함을 한 편에 정리.

📚 Redis 핵심 정리 · 8편 / 14편 — RedisTemplate·@Cacheable 한 번에

이 글은 Redis 핵심 정리 시리즈의 여덟 번째 편입니다. 1편부터 7편까지 redis-cli로 직접 명령을 쳐 보고, 자료 구조 7종을 손으로 만져 보고, 영속성·클러스터·캐싱 패턴까지 큰 그림을 잡아 왔어요. 이번 8편은 그 모든 도구를 자바 코드 안에서 자연스럽게 쓰는 단계입니다.

자바 백엔드에서 Redis를 다루는 표준은 Spring Data Redis예요. 이름은 길지만 역할은 단순합니다 — 저수준 클라이언트(Lettuce·Jedis)를 직접 다루는 대신, Spring의 익숙한 패턴(Template·Repository·@Cacheable)으로 Redis를 다룰 수 있게 해 주는 추상화 계층이에요. 연결 관리·직렬화·에러 처리를 Spring이 알아서 해 주니까, 우리는 비즈니스 로직만 신경 쓰면 됩니다.

본문 흐름은 책상 위 메모리 사물함 비유를 따라 갑니다. 1편에서 Redis를 사물함으로, 클라이언트 라이브러리를 얇은 케이블로 풀었던 그림 위에 — 이번에는 Spring이 사물함을 다루는 표준 도구함을 얹는 단계예요.

왜 Spring Data Redis가 처음엔 어렵게 느껴질까요

이유는 세 가지예요.

첫째, 이름이 너무 비슷비슷합니다. RedisTemplate, ReactiveRedisTemplate, RedisCacheManager, RedisConnectionFactory, LettuceConnectionFactory, JedisConnectionFactory, StringRedisSerializer, GenericJackson2JsonRedisSerializer — 한 페이지에 줄지어 나오면 머리가 어지러워요.

둘째, 추상도가 한 번에 두 단계 올라갑니다. "Redis 명령어를 직접 외워야 한다"고 1편부터 강조했는데, 갑자기 @Cacheable 한 줄로 캐시가 자동으로 동작합니다. "그럼 명령어를 안 외워도 되나?" 같은 의문이 자연스럽게 들어요.

셋째, JPA·Hibernate 같은 SQL ORM과 헷갈립니다. @RedisHash·CrudRepository 같은 표현은 JPA에서 본 것과 똑같이 생겼는데, 동작 원리는 다릅니다.

해결법은 한 가지예요. Spring Data Redis를 "Redis 사물함을 다루는 표준 도구함" 으로 잡고, 도구함 안에 들어 있는 도구를 하나씩 꺼내 보는 거예요. RedisTemplate은 가장 큰 손잡이가 달린 만능 도구, @Cacheable은 자동 캐싱 스위치, Spring Session은 세션 전용 보관함 — 이렇게 자리만 정해 두면 갑자기 명확해집니다. 이 글은 그 도구함을 처음부터 풀어 갑니다.

Spring Data Redis가 도대체 어떤 시스템인가요

Spring Data Redis — Spring Framework의 일원으로, Spring 앱에서 Redis를 표준 방식으로 사용하기 위한 추상화 계층입니다. 저수준 Redis 클라이언트(Lettuce·Jedis)를 직접 부르지 않고, Spring의 익숙한 패턴으로 Redis를 다룹니다.

회사 비유로 풀면 — Lettuce·Jedis가 얇은 케이블이라면, Spring Data Redis는 그 케이블 끝에 달린 표준 도구함이에요. 도구함 안에는 자주 쓰는 도구가 정리돼 있고, 케이블은 도구함 안쪽에서 자동으로 관리됩니다. 우리는 케이블 길이·연결 상태를 직접 챙길 필요 없이 도구만 꺼내 쓰면 됩니다.

전체 호출 흐름은 이렇게 정리돼요.

Spring Boot App
    ↓
Spring Data Redis (추상화 계층)
    ↓
RedisTemplate / ReactiveRedisTemplate
    ↓
Lettuce (기본) 또는 Jedis (클라이언트 라이브러리)
    ↓
Redis 서버

핵심 특징을 한 줄로 정리하면 — "Redis 명령어를 자바 메서드 호출로 바꿔 주는 표준 도구함" 입니다. 더 깊은 사양은 Spring Data Redis 공식 문서Lettuce 공식 사이트에서 확인할 수 있어요.

의존성과 application.yml — 시작은 두 줄

가장 빠른 시작은 의존성 두 줄과 yml 설정 한 덩어리예요.

Maven

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

<!-- Lettuce는 기본 포함, Jedis를 굳이 쓰려면 -->
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
</dependency>

<!-- @Cacheable 같은 캐시 어노테이션을 쓰려면 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

Gradle

// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-cache'

// Reactive Redis를 쓰려면
implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive'

여기서 시험 함정이 하나 있어요. Spring Boot 2.x 후반부터 Lettuce가 기본 클라이언트로 자리 잡았습니다. Jedis는 한 시절 표준이었지만, 지금은 Lettuce가 비동기·리액티브를 지원하면서 사실상 표준이 됐어요. "starter-data-redis만 넣으면 자동으로 어느 클라이언트가 붙는가?" 답은 Lettuce입니다.

application.yml — 호스트·포트·풀까지 한 번에

spring:
  data:
    redis:
      host: localhost
      port: 6379
      password: your_password
      timeout: 2000ms
      
      # Lettuce 커넥션 풀 설정
      lettuce:
        pool:
          max-active: 8    # 최대 연결 수
          max-idle: 8      # 최대 유휴 연결 수
          min-idle: 0      # 최소 유휴 연결 수
          max-wait: -1ms   # 연결 대기 시간 (-1: 무한)
          
  # 캐시 기본 설정
  cache:
    type: redis
    redis:
      time-to-live: 3600000  # 1시간 (밀리초)
      cache-null-values: true

RedisTemplate — 도구함의 중심 손잡이

RedisTemplate은 Spring Data Redis 도구함의 가장 큰 손잡이예요. 모든 Redis 자료 구조를 자바에서 부를 수 있게 해 주는 핵심 클래스입니다.

회사 비유로 풀면 — RedisTemplate은 사물함 앞에 놓인 만능 도구함이에요. String·Hash·List·Set·Sorted Set 어떤 자료 구조든 이 한 도구함에서 전용 도구(opsForValue·opsForHash·opsForList·opsForSet·opsForZSet)를 꺼내 쓸 수 있습니다.

Bean 설정 — 한 번 짜 두면 두고두고

@Configuration
@EnableCaching
public class RedisConfig {
    
    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
        config.setHostName("localhost");
        config.setPort(6379);
        config.setPassword("your_password");
        
        LettuceConnectionFactory factory = new LettuceConnectionFactory(config);
        return factory;
    }
    
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        
        // 키 직렬화: String
        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        
        // 값 직렬화: JSON
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.activateDefaultTyping(
            objectMapper.getPolymorphicTypeValidator(),
            ObjectMapper.DefaultTyping.NON_FINAL
        );
        
        GenericJackson2JsonRedisSerializer jsonSerializer = 
            new GenericJackson2JsonRedisSerializer(objectMapper);
        
        template.setValueSerializer(jsonSerializer);
        template.setHashValueSerializer(jsonSerializer);
        
        template.afterPropertiesSet();
        return template;
    }
    
    // 캐시 매니저 설정
    @Bean
    public CacheManager cacheManager(RedisConnectionFactory factory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofHours(1))                    // 기본 TTL 1시간
            .serializeKeysWith(
                RedisSerializationContext.SerializationPair
                    .fromSerializer(new StringRedisSerializer())
            )
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair
                    .fromSerializer(new GenericJackson2JsonRedisSerializer())
            )
            .disableCachingNullValues();  // null 값 캐싱 비활성화
        
        return RedisCacheManager.builder(factory)
            .cacheDefaults(config)
            .withCacheConfiguration("users", 
                config.entryTtl(Duration.ofMinutes(30)))    // users 캐시 30분
            .withCacheConfiguration("products",
                config.entryTtl(Duration.ofHours(2)))       // products 캐시 2시간
            .build();
    }
}

여기서 정말 중요한 시험 함정 — 키와 값에 서로 다른 직렬화기를 쓰는 게 표준입니다. 키는 StringRedisSerializer(redis-cli로도 사람이 읽을 수 있는 형태), 값은 GenericJackson2JsonRedisSerializer(JSON으로 직렬화 + 클래스 정보 포함). 키를 JDK 직렬화로 두면 redis-cli로 봤을 때 깨진 문자가 나와 디버깅이 어려워져요.

> 한 줄 정리 — RedisTemplate은 자바 측 만능 도구함, 키는 String 직렬화, 값은 JSON 직렬화가 표준 조합.

RedisTemplate 사용 — 자료 구조별 도구

도구함을 짰으면 이제 도구를 꺼내 쓸 차례예요. 자료 구조별로 어떤 도구가 있는지 차근차근 풀어 갑시다.

String 작업 — opsForValue()

@Service
public class UserService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    // ValueOperations: String/Object 값 처리
    public void saveUser(String userId, User user) {
        ValueOperations<String, Object> ops = redisTemplate.opsForValue();
        ops.set("users#" + userId, user, Duration.ofHours(1));
    }
    
    public User getUser(String userId) {
        ValueOperations<String, Object> ops = redisTemplate.opsForValue();
        return (User) ops.get("users#" + userId);
    }
    
    // 증감 연산
    public long incrementCounter(String key) {
        return redisTemplate.opsForValue().increment(key);
    }
    
    // 여러 키 동시 조회
    public List<Object> getMultipleUsers(List<String> userIds) {
        List<String> keys = userIds.stream()
            .map(id -> "users#" + id)
            .collect(Collectors.toList());
        return redisTemplate.opsForValue().multiGet(keys);
    }
}

setDuration.ofHours(1) 한 줄을 더한 것 — 이게 1편에서 강조했던 TTL 필수의 자바 측 표현이에요. TTL 없이 무한정 저장하면 메모리가 곧 고갈됩니다.

Hash 작업 — opsForHash()

@Service
public class SessionService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    // HashOperations: Hash 처리
    public void saveSession(String token, Map<String, Object> sessionData) {
        HashOperations<String, String, Object> ops = redisTemplate.opsForHash();
        ops.putAll("sessions#" + token, sessionData);
        redisTemplate.expire("sessions#" + token, Duration.ofHours(24));
    }
    
    public Map<Object, Object> getSession(String token) {
        HashOperations<String, Object, Object> ops = redisTemplate.opsForHash();
        return ops.entries("sessions#" + token);
    }
    
    public void updateSessionField(String token, String field, Object value) {
        HashOperations<String, String, Object> ops = redisTemplate.opsForHash();
        ops.put("sessions#" + token, field, value);
    }
    
    public void deleteSession(String token) {
        redisTemplate.delete("sessions#" + token);
    }
}

Set 작업 — opsForSet()

@Service
public class LikesService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    // SetOperations: Set 처리
    public boolean toggleLike(String userId, String itemId) {
        SetOperations<String, Object> ops = redisTemplate.opsForSet();
        String likesKey = "users:likes#" + userId;
        
        Boolean isMember = ops.isMember(likesKey, itemId);
        if (Boolean.TRUE.equals(isMember)) {
            ops.remove(likesKey, itemId);
            redisTemplate.opsForHash().increment("items#" + itemId, "likes", -1);
            return false;
        } else {
            ops.add(likesKey, itemId);
            redisTemplate.opsForHash().increment("items#" + itemId, "likes", 1);
            return true;
        }
    }
    
    public Set<Object> getLikedItems(String userId) {
        return redisTemplate.opsForSet().members("users:likes#" + userId);
    }
}

Sorted Set 작업 — opsForZSet()

@Service
public class LeaderboardService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    // ZSetOperations: Sorted Set 처리
    public void updateScore(String userId, double score) {
        redisTemplate.opsForZSet().add("leaderboard", userId, score);
    }
    
    public Double incrementScore(String userId, double delta) {
        return redisTemplate.opsForZSet().incrementScore("leaderboard", userId, delta);
    }
    
    public Set<ZSetOperations.TypedTuple<Object>> getTopPlayers(int count) {
        return redisTemplate.opsForZSet()
            .reverseRangeWithScores("leaderboard", 0, count - 1);
    }
    
    public Long getUserRank(String userId) {
        Long rank = redisTemplate.opsForZSet().reverseRank("leaderboard", userId);
        return rank != null ? rank + 1 : null;  // 0-based → 1-based
    }
}

여기까지 따라오셨다면 한 가지 의문이 들 거예요 — "redis-cli로 치던 명령어와 자바 메서드 이름이 거의 똑같네?" 맞습니다. Spring Data Redis는 ORM이 아니라 얇은 매핑이에요. ZADDadd, ZINCRBYincrementScore, ZRANKrank. 1편에서 "Redis 명령어를 알아야 라이브러리도 잘 쓴다"고 했던 게 바로 이 자리입니다.

@Cacheable — 메서드 결과를 자동으로 사물함에

@Cacheable은 Spring Cache가 제공하는 캐싱 어노테이션이에요. 메서드 위에 한 줄 붙이면 결과를 자동으로 Redis에 저장하고, 같은 인자로 다음 호출이 들어오면 메서드 본문을 실행하지 않고 사물함에서 바로 꺼내 줍니다.

회사 비유로 풀면 — @Cacheable자료 들어가는 사물함을 자동으로 등록해 주는 도장이에요. 메서드에 도장을 한 번 찍어 두면, 다음부터 같은 자료를 요청하면 본 부서에 가지 않고 사물함에서 바로 꺼내 줍니다.

기본 사용법

@Service
public class ProductService {
    
    @Autowired
    private ProductRepository productRepository;
    
    // @Cacheable: 메서드 결과를 캐시에 저장
    // 두 번째 호출부터는 메서드 실행 없이 캐시에서 반환
    @Cacheable(value = "products", key = "#productId")
    public Product getProduct(String productId) {
        // 캐시 미스 시 실행됨
        return productRepository.findById(productId)
            .orElseThrow(() -> new NotFoundException("Product not found"));
    }
    
    // @CachePut: 항상 메서드 실행 후 결과를 캐시에 저장 (캐시 업데이트)
    @CachePut(value = "products", key = "#product.id")
    public Product updateProduct(Product product) {
        return productRepository.save(product);
    }
    
    // @CacheEvict: 캐시에서 특정 데이터 삭제
    @CacheEvict(value = "products", key = "#productId")
    public void deleteProduct(String productId) {
        productRepository.deleteById(productId);
    }
    
    // @CacheEvict allEntries: 캐시 전체 삭제
    @CacheEvict(value = "products", allEntries = true)
    public void clearAllProductsCache() {
        // 캐시 전체 무효화
    }
}

세 어노테이션의 자리는 외워 두면 두고두고 써요.

어노테이션역할언제 쓰나
@Cacheable캐시에 있으면 꺼내고, 없으면 메서드 실행 후 저장읽기 메서드
@CachePut항상 메서드 실행 후 결과를 캐시에 덮어쓰기수정 메서드 (캐시도 갱신)
@CacheEvict캐시에서 삭제삭제 메서드

조건부 캐싱 — condition·unless

@Service
public class UserService {
    
    // condition: 캐싱 여부 조건 (메서드 실행 전 평가)
    @Cacheable(value = "users", key = "#userId",
               condition = "#userId != null && #userId.length() > 0")
    public User getUserById(String userId) {
        return userRepository.findById(userId).orElse(null);
    }
    
    // unless: 캐싱 제외 조건 (메서드 실행 후 평가)
    // result가 null이면 캐시하지 않음
    @Cacheable(value = "users", key = "#username", unless = "#result == null")
    public User getUserByUsername(String username) {
        return userRepository.findByUsername(username).orElse(null);
    }
    
    // SpEL 표현식으로 복잡한 키 생성
    @Cacheable(value = "users", key = "#root.methodName + ':' + #userId")
    public UserProfile getUserProfile(String userId) {
        return userProfileRepository.findByUserId(userId);
    }
}

여기서 시험 함정이 하나 있어요. condition은 메서드 실행 전, unless는 실행 후에 평가됩니다. "null 결과는 캐시하지 마"는 unless = "#result == null"로 — 결과를 봐야 하니까. "userId가 null이면 캐시 자체를 건너뛰어"는 condition = "#userId != null"로 — 결과 보기 전에 결정. 이 자리 헷갈리면 캐시가 의도와 다르게 동작합니다.

@Caching — 한 메서드에 여러 캐시 작업

@Service
public class ArticleService {
    
    // 여러 캐시 어노테이션 동시 사용
    @Caching(
        evict = {
            @CacheEvict(value = "articles", key = "#article.id"),
            @CacheEvict(value = "article-list", allEntries = true),
            @CacheEvict(value = "categories", key = "#article.categoryId"),
        }
    )
    public Article updateArticle(Article article) {
        return articleRepository.save(article);
    }
}

기사 한 개를 수정하면 — 그 기사 캐시도, 기사 목록 캐시도, 카테고리 캐시도 함께 무효화해야 일관성이 유지돼요. @Caching이 그 묶음을 한 자리에 박아 줍니다.

> 한 줄 정리 — @Cacheable = 읽기 자동 캐싱, @CachePut = 수정 시 갱신, @CacheEvict = 삭제. 셋이 한 세트.

@Cacheable 자기 호출 함정 — 가장 자주 틀리는 자리

여기서 정말 중요한 시험 함정 — 같은 클래스 안에서 @Cacheable 메서드를 직접 호출하면 캐시가 동작하지 않습니다.

// 잘못된 방법: 같은 클래스 내에서 @Cacheable 메서드 호출
@Service
public class UserService {
    
    @Cacheable("users")
    public User getUser(String id) { ... }
    
    public void someMethod(String id) {
        User user = getUser(id);  // 같은 클래스 내 직접 호출 → 캐시 무시!
        // Spring AOP 프록시를 거치지 않으므로 @Cacheable이 동작하지 않음
    }
}

// 올바른 방법: 별도의 서비스 클래스로 분리
@Service
public class UserCacheService {
    @Autowired
    private UserService userService;
    
    public void someMethod(String id) {
        User user = userService.getUser(id);  // 외부 빈을 통해 호출 → 캐시 동작
    }
}

이유는 한 줄로 정리됩니다 — Spring의 캐시 어노테이션은 AOP 프록시 기반이에요. 외부에서 빈을 호출할 때만 프록시가 끼어들어 캐시 로직을 실행합니다. 같은 클래스 안 this.getUser()는 프록시를 거치지 않아서 어노테이션이 무시돼요.

면접에서도 자주 나오는 자리니까 외워 두면 좋아요. @Cacheable·@Transactional 같은 AOP 기반 어노테이션은 자기 호출 시 동작하지 않는다.

RedisHash와 Repository — JPA처럼 쓰기

JPA의 @Entity·CrudRepository와 비슷한 패턴을 Redis에서도 쓸 수 있어요. @RedisHash 가 그 자리입니다.

// @RedisHash를 사용하면 도메인 객체를 Redis Hash로 자동 관리
@RedisHash(value = "products", timeToLive = 3600)
public class Product {
    
    @Id
    private String id;
    
    @Indexed  // 검색 가능한 인덱스 필드
    private String categoryId;
    
    @Indexed
    private String status;
    
    private String name;
    private BigDecimal price;
    private int stock;
    
    @TimeToLive  // 개별 엔티티별 TTL 설정 가능
    private Long ttl;
    
    // getter, setter, constructors
}
// RedisHashRepository: 자동으로 Redis Hash CRUD 제공
public interface ProductRepository extends CrudRepository<Product, String> {
    // @Indexed 필드로 검색 메서드 자동 생성
    List<Product> findByCategoryId(String categoryId);
    List<Product> findByStatus(String status);
    List<Product> findByCategoryIdAndStatus(String categoryId, String status);
}

// 사용
@Service
public class ProductService {
    
    @Autowired
    private ProductRepository productRepository;
    
    public Product createProduct(Product product) {
        product.setId(UUID.randomUUID().toString());
        return productRepository.save(product);
        // Redis에 products:{id} Hash로 저장
        // 인덱스: products:categoryId:{value} Set에도 저장
    }
    
    public Iterable<Product> getProductsByCategory(String categoryId) {
        return productRepository.findByCategoryId(categoryId);
    }
}

잠깐, 이 부분이 헷갈리는데 — @RedisHash는 JPA의 @Entity와 모양만 비슷하지 동작은 다릅니다. JPA는 SQL DB에 테이블 단위 매핑이지만, @RedisHashRedis Hash 자료 구조에 한 객체를 저장하고, @Indexed 필드는 별도의 Set 키로 인덱스를 만들어 검색을 가능하게 해요. JPA처럼 쿼리 옵티마이저가 일하는 게 아니라, Set 교집합 연산으로 검색이 동작합니다.

트랜잭션과 파이프라이닝 — 묶어 보내기

3편에서 redis-cli 측 MULTI/EXEC을 다뤘다면, 자바 측에서는 어떻게 풀까요. Spring이 두 가지 길을 제공해요.

Spring 트랜잭션 — @Transactional과 함께

@Service
public class TransactionService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    // @Transactional과 함께 사용
    @Transactional
    public void transferBalance(String fromUser, String toUser, int amount) {
        // setEnableTransactionSupport(true) 설정 필요
        redisTemplate.multi();
        
        redisTemplate.opsForValue().decrement("balance:" + fromUser, amount);
        redisTemplate.opsForValue().increment("balance:" + toUser, amount);
        
        redisTemplate.exec();
    }
    
    // SessionCallback으로 WATCH 기반 트랜잭션
    public Object executeWithWatch(String watchKey) {
        return redisTemplate.execute(new SessionCallback<Object>() {
            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
                operations.watch(watchKey);
                operations.multi();
                
                // 명령어 큐잉
                operations.opsForValue().increment(watchKey);
                
                // 실행 (WATCH된 키가 변경되었으면 null 반환)
                return operations.exec();
            }
        });
    }
}

파이프라이닝 — executePipelined

@Service
public class BulkOperationService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    // executePipelined: 여러 명령어를 한 번에 처리
    public List<Object> bulkSetUsers(List<User> users) {
        return redisTemplate.executePipelined(new SessionCallback<Object>() {
            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
                for (User user : users) {
                    operations.opsForHash().putAll("users#" + user.getId(), 
                        Map.of(
                            "id", user.getId(),
                            "username", user.getUsername(),
                            "email", user.getEmail()
                        ));
                    operations.expire("users#" + user.getId(), Duration.ofHours(1));
                }
                return null;  // executePipelined는 내부에서 exec() 호출
            }
        });
    }
}

여기서 시험 함정이 하나 있어요. 트랜잭션과 파이프라이닝은 다른 개념입니다. 트랜잭션은 "원자성 보장(전부 실행 or 전부 롤백)"에 초점, 파이프라이닝은 "네트워크 왕복 줄이기"에 초점이에요. 면접에서 "MULTI/EXEC와 파이프라인 차이"를 물으면 — 원자성 vs 처리량으로 답하면 됩니다. 자세한 성능 측면은 9편에서 풀어 갈게요.

Reactive Redis — 논블로킹 자바

Spring WebFlux 환경이라면 ReactiveRedisTemplate을 씁니다. 호출 결과가 Mono·Flux로 돌아와 논블로킹으로 동작해요.

@Configuration
public class ReactiveRedisConfig {
    
    @Bean
    public ReactiveRedisConnectionFactory reactiveRedisConnectionFactory() {
        return new LettuceConnectionFactory("localhost", 6379);
    }
    
    @Bean
    public ReactiveRedisTemplate<String, Object> reactiveRedisTemplate(
            ReactiveRedisConnectionFactory factory) {
        
        RedisSerializationContext<String, Object> context = 
            RedisSerializationContext.<String, Object>newSerializationContext()
                .key(new StringRedisSerializer())
                .value(new GenericJackson2JsonRedisSerializer())
                .hashKey(new StringRedisSerializer())
                .hashValue(new GenericJackson2JsonRedisSerializer())
                .build();
        
        return new ReactiveRedisTemplate<>(factory, context);
    }
}
@Service
public class ReactiveUserService {
    
    @Autowired
    private ReactiveRedisTemplate<String, Object> redisTemplate;
    
    // Mono/Flux 반환 (논블로킹)
    public Mono<Boolean> saveUser(User user) {
        return redisTemplate.opsForValue()
            .set("users#" + user.getId(), user, Duration.ofHours(1));
    }
    
    public Mono<User> getUser(String userId) {
        return redisTemplate.opsForValue()
            .get("users#" + userId)
            .cast(User.class);
    }
    
    // Reactive Pub/Sub
    public Flux<Message<String, String>> subscribeToChannel(String channel) {
        return redisTemplate.listenToChannel(channel);
    }
    
    public Mono<Long> publishMessage(String channel, String message) {
        return redisTemplate.convertAndSend(channel, message);
    }
}

Reactive 환경에서는 Lettuce가 사실상 유일한 선택이에요. Jedis는 동기 I/O라 리액티브 흐름과 맞지 않습니다. Lettuce가 표준인 이유 중 하나가 이 자리예요.

Lettuce vs Jedis — 면접 단골 비교표

자바 측 Redis 클라이언트는 두 라이브러리 중 하나를 골라야 해요. 비교표가 면접에 자주 나오니까 한 번 정리해 둡니다.

특성Lettuce (기본)Jedis
스레드 안전O (싱글 연결 공유)X (커넥션 풀 필요)
비동기 지원O (Netty 기반)X
Reactive 지원OX
연결 방식공유 비동기 연결연결 풀
성능비동기 I/O로 높음동기 I/O
메모리 사용적음더 많음 (풀 크기)
Sentinel 지원OO
Cluster 지원OO

Jedis로 굳이 가는 설정

@Configuration
public class JedisConfig {
    
    @Bean
    public JedisConnectionFactory jedisConnectionFactory() {
        JedisPoolConfig poolConfig = new JedisPoolConfig();
        poolConfig.setMaxTotal(20);     // 최대 연결 수
        poolConfig.setMaxIdle(10);      // 최대 유휴 연결
        poolConfig.setMinIdle(5);       // 최소 유휴 연결
        poolConfig.setTestOnBorrow(true);
        
        RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
        config.setHostName("localhost");
        config.setPort(6379);
        
        JedisClientConfiguration clientConfig = JedisClientConfiguration.builder()
            .usePooling()
            .poolConfig(poolConfig)
            .build();
        
        return new JedisConnectionFactory(config, clientConfig);
    }
}

비유로 풀면 — Lettuce는 비동기·리액티브 케이블(권장), Jedis는 옛날 동기 케이블이에요. 새 프로젝트라면 거의 항상 Lettuce를 고르는 게 정답입니다. 레거시 코드를 유지하는 이유가 아니라면 Jedis로 갈 이유가 거의 없어요.

> 한 줄 정리 — Lettuce = Netty 기반 비동기 + Reactive 지원, Jedis = 동기 + 풀 필요. 새 프로젝트는 무조건 Lettuce.

Spring Session — 세션을 Redis로

대규모 트래픽 환경에서 HTTP 세션을 톰캣 메모리에 두면 — 인스턴스마다 세션이 따로 살아 있어 사용자가 다른 인스턴스로 라우팅되면 로그아웃돼 버립니다. Spring Session이 이 문제를 한 줄로 해결해요.

의존성과 설정

<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>
@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 3600)
public class HttpSessionConfig {
    // Spring Session이 자동으로 Redis에 HTTP 세션 저장
    // 별도 설정 없이 @EnableRedisHttpSession만으로 동작
}
# application.yml
spring:
  session:
    store-type: redis
    redis:
      flush-mode: on-save        # 세션 저장 시점 (on-save: 저장 시, immediate: 즉시)
      namespace: spring:session  # Redis 키 접두사
    timeout: 3600s               # 세션 타임아웃

사용은 평소 그대로

@RestController
public class AuthController {
    
    // HttpSession이 자동으로 Redis에 저장됨
    @PostMapping("/login")
    public ResponseEntity<String> login(
            @RequestBody LoginRequest request,
            HttpSession session) {
        
        // 인증 처리
        User user = authService.authenticate(request);
        
        // 세션에 저장 (자동으로 Redis에 저장됨)
        session.setAttribute("userId", user.getId());
        session.setAttribute("username", user.getUsername());
        
        return ResponseEntity.ok("Logged in successfully");
    }
    
    @GetMapping("/profile")
    public ResponseEntity<User> getProfile(HttpSession session) {
        String userId = (String) session.getAttribute("userId");
        if (userId == null) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        return ResponseEntity.ok(userService.getUser(userId));
    }
    
    @PostMapping("/logout")
    public ResponseEntity<String> logout(HttpSession session) {
        session.invalidate();  // Redis에서도 자동 삭제
        return ResponseEntity.ok("Logged out");
    }
}

코드는 평소 톰캣 세션과 똑같아요. HttpSession API를 그대로 쓰면 — 저장은 알아서 Redis로 가고, 모든 인스턴스가 같은 세션을 공유합니다. 이게 마법처럼 동작하는 게 Spring Session의 진짜 장점이에요.

분산 락 — 여러 인스턴스가 한 자원을 다툴 때

여러 서버가 같은 자원을 동시에 처리하지 못하게 막아야 할 때 — 예를 들어 같은 주문을 두 번 처리하거나, 같은 쿠폰을 두 번 차감하지 못하게 — 분산 락이 필요합니다. Redis가 이 자리에서 표준이에요.

Spring Integration Redis Lock

@Configuration
public class DistributedLockConfig {
    
    @Bean
    public RedisLockRegistry redisLockRegistry(RedisConnectionFactory factory) {
        return new RedisLockRegistry(factory, "distributed-locks", 30000);
        // 30초 자동 만료
    }
}

// 사용
@Service
public class OrderService {
    
    @Autowired
    private RedisLockRegistry lockRegistry;
    
    public Order processOrder(String orderId) {
        Lock lock = lockRegistry.obtain("order:" + orderId);
        
        try {
            // 30초 안에 락 획득 시도
            if (lock.tryLock(30, TimeUnit.SECONDS)) {
                try {
                    return doProcessOrder(orderId);
                } finally {
                    lock.unlock();
                }
            }
            throw new RuntimeException("Could not acquire lock");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("Lock interrupted");
        }
    }
}

Redisson — 더 강력한 락 도구

// Redisson은 더 강력한 분산 락 기능 제공
@Configuration
public class RedissonConfig {
    
    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer()
            .setAddress("redis://localhost:6379")
            .setPassword("your_password");
        return Redisson.create(config);
    }
}

@Service
public class AdvancedLockService {
    
    @Autowired
    private RedissonClient redissonClient;
    
    public void executeWithLock(String resource, Runnable task) {
        RLock lock = redissonClient.getLock("lock:" + resource);
        
        try {
            // 10초 대기, 30초 자동 해제
            if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
                try {
                    task.run();
                } finally {
                    lock.unlock();
                }
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
    
    // 공정 락 (Fair Lock): 요청 순서대로 락 획득
    public void executeWithFairLock(String resource, Runnable task) {
        RLock fairLock = redissonClient.getFairLock("fairlock:" + resource);
        // 사용법 동일
    }
}

Redis Lock Registry는 가볍지만 기능이 제한적이고, Redisson은 더 풍부한 락 기능(Fair Lock·Read-Write Lock·Semaphore 등)을 제공해요. 단순한 자원 보호에는 Lock Registry, 복잡한 동시성 제어가 필요하면 Redisson이 표준 선택입니다.

직렬화 전략 — 무엇을 무엇으로 바꾸나

Redis에 자바 객체를 저장하려면 직렬화가 필요해요. 어떤 직렬화기를 쓰느냐에 따라 저장 크기·속도·디버깅 편의가 달라집니다.

방식클래스특징
StringStringRedisSerializer문자열만, 가장 빠름
JDKJdkSerializationRedisSerializerJava 기본, 클래스 의존성 높음
JSONGenericJackson2JsonRedisSerializer사람이 읽기 쉬움, 클래스 정보 포함
JSON (타입 없음)Jackson2JsonRedisSerializer타입 정보 없음, 명시적 지정 필요
KryoKryoRedisSerializer매우 빠름, 스키마 진화 어려움

타입별 전용 RedisTemplate

// 타입 정보 없는 JSON 직렬화 (더 간결한 JSON)
@Bean
public RedisTemplate<String, User> userRedisTemplate(RedisConnectionFactory factory) {
    RedisTemplate<String, User> template = new RedisTemplate<>();
    template.setConnectionFactory(factory);
    template.setKeySerializer(new StringRedisSerializer());
    
    // User 타입을 명시적으로 지정
    Jackson2JsonRedisSerializer<User> serializer = 
        new Jackson2JsonRedisSerializer<>(User.class);
    template.setValueSerializer(serializer);
    
    return template;
}

여기서 정말 중요한 시험 함정 — Object를 캐스팅해서 받으면 ClassCastException 위험이 있어요.

// 문제: 역직렬화 시 타입 불일치
Object value = redisTemplate.opsForValue().get("users#123");
User user = (User) value;  // ClassCastException 가능!

// 이유: 직렬화 방식에 따라 클래스 정보 유실
// 해결: 타입별 전용 RedisTemplate 사용
@Autowired
private RedisTemplate<String, User> userRedisTemplate;

User user = userRedisTemplate.opsForValue().get("users#123");  // 안전

타입이 명확한 도메인에는 전용 RedisTemplate을 빈으로 분리하는 게 깔끔해요. @Qualifier로 골라 쓸 수 있습니다.

흔한 실수 5가지 — 운영에서 피해야 할 자리

여기까지 따라오셨다면 한 가지 의문이 들 거예요 — "그럼 어떤 함정이 자주 터지나?" 자주 나오는 다섯 가지를 정리합니다.

1. 자기 호출 — @Cacheable 무시됨

위에서 다뤘듯 — 같은 클래스 안 this.method() 호출은 AOP 프록시를 거치지 않아 캐시가 동작하지 않아요. 다른 빈을 거쳐 호출해야 합니다.

2. RedisTemplate 캐스팅 — ClassCastException

// 문제
Object value = redisTemplate.opsForValue().get("users#123");
User user = (User) value;  // 위험

// 해결: 타입별 전용 RedisTemplate
@Autowired
private RedisTemplate<String, User> userRedisTemplate;
User user = userRedisTemplate.opsForValue().get("users#123");

3. TTL 누락 — 메모리 고갈

// 문제
redisTemplate.opsForValue().set("cache:" + key, data);  // TTL 없음

// 해결
redisTemplate.opsForValue().set("cache:" + key, data, Duration.ofHours(1));

// 또는 캐시 매니저에서 기본 TTL 설정
RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofHours(1))

4. N+1 — 루프 안에서 매번 호출

// 문제: 루프 안에서 Redis 호출
List<User> users = new ArrayList<>();
for (String userId : userIds) {
    User user = (User) redisTemplate.opsForValue().get("users#" + userId);
    users.add(user);
}
// N번의 Redis 네트워크 왕복 발생

// 해결: multiGet 또는 파이프라인
List<String> keys = userIds.stream()
    .map(id -> "users#" + id)
    .collect(Collectors.toList());
List<Object> users = redisTemplate.opsForValue().multiGet(keys);  // 1번의 왕복

이 자리는 9편 성능 단원에서 더 자세히 풀어 갈게요. 미리 한 줄로 — 루프 안 Redis 호출은 거의 항상 성능 안티 패턴입니다.

5. 연결 풀 고갈

# 적절한 풀 크기 설정 필수
spring:
  data:
    redis:
      lettuce:
        pool:
          max-active: 20  # 동시 요청 수에 맞게 설정

기본값(8)으로 두면 트래픽 많은 환경에서 빠르게 고갈돼요. 동시 요청 수를 측정해 적절히 조정하는 게 표준이에요.

시험 직전 한 번 더 — 자주 헷갈리는 함정 모음

여기까지가 Spring Data Redis의 핵심입니다. 시험 직전·실무 실수 방지를 위한 압축 노트로 마무리할게요.

  • Spring Data Redis = Redis 사물함을 다루는 표준 도구함 (추상화 계층)
  • 기본 클라이언트 = Lettuce(Netty 기반 비동기·리액티브 지원), Jedis는 동기·옛날 표준
  • starter-data-redis만 넣으면 자동으로 Lettuce가 붙음
  • RedisTemplate = 모든 Redis 자료 구조를 자바에서 다루는 만능 도구함
  • 도구 — opsForValue(String) / opsForHash(Hash) / opsForList(List) / opsForSet(Set) / opsForZSet(Sorted Set)
  • 직렬화 표준 — 키는 StringRedisSerializer, 값은 GenericJackson2JsonRedisSerializer
  • 키를 JDK 직렬화로 두면 redis-cli에서 깨진 문자 보임 → 디버깅 어려움
  • @Cacheable = 읽기 자동 캐싱 / @CachePut = 수정 시 갱신 / @CacheEvict = 삭제
  • 조건부 캐싱 — condition은 메서드 실행 전, unless는 실행 후 평가
  • 자기 호출 금지 — 같은 클래스 내 @Cacheable 메서드 직접 호출 시 AOP 프록시 미통과로 캐시 무시
  • @Transactional도 같은 자기 호출 함정 — 다른 빈 거쳐 호출해야 동작
  • @RedisHash = JPA @Entity와 모양 비슷하지만 동작은 다름 (Set 인덱스 기반)
  • MULTI/EXEC 트랜잭션 vs 파이프라이닝 — 원자성 vs 처리량 (목적이 다름)
  • executePipelined = N개 명령 1번의 RTT로 처리
  • Reactive 환경 = ReactiveRedisTemplate + Lettuce 필수
  • Spring Session = @EnableRedisHttpSession 한 줄로 HTTP 세션을 Redis에 자동 저장
  • 분산 락 — 단순엔 RedisLockRegistry, 복잡한 동시성엔 Redisson
  • Object 캐스팅은 ClassCastException 위험 → 타입별 전용 RedisTemplate으로 분리
  • TTL 필수Duration.ofHours(1) 또는 캐시 매니저 entryTtl로 기본값 박기
  • 루프 안 Redis 호출 금지multiGet 또는 executePipelined로 묶기
  • 연결 풀 기본값 8 → 트래픽 많으면 max-active: 20+로 조정

시리즈 다른 편

같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!