SPARTA 과제/SPRING

RedisTemplate StackOverFlow Error

kjw81024 2026. 5. 3. 18:00

문제 상황

이벤트 조회 횟수를 카운트하는 로직이 정상 동작하는 걸 확인하고, 


이후 redisson-spring-boot-starter 의존성을 추가한 뒤,
이벤트 단건 조회 시 조회수 증가 로직에서 StackOverflowError가 발생

StackOverflowError

 

근데 똑같은 API를 한 번 더 호출하면 정상 동작한다. 첫 조회에서만 터지는 게 이상해서 스택트레이스를 확인해보니, 같은 메서드가 계속 나오고 있다 

at org.springframework.data.redis.connection.DefaultedRedisConnection.pExpireAt

원인 분석

문제가 발생한 코드는 아래 

stringRedisTemplate.expire(dedupKey, Duration.ofSeconds(DAILY_TTL_SECONDS));

String dailyKey = dailyRankingKey(today);
Double newScore = stringRedisTemplate.opsForZSet().incrementScore(dailyKey, String.valueOf(eventId), 1);

stringRedisTemplate.expire(dailyKey, Duration.ofDays(WEEKLY_DAYS + 1));

 

expire()를 호출했을 뿐인데 무한 재귀가 발생했다

 

RedisConnectionFactory의 자동 교체

Redisson이 RedisConnectionFactory를 바꿔버림 → 그 구현체 버그로 expire에서 무한 재귀 발생

 

build.gradle 설정을 잘못 해줌 

 

RedisTemplate은 Redis에 명령을 보낼 때 RedisConnectionFactory가 만들어주는 커넥션 객체를 사용한다.

// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-redis' // 1
implementation 'org.redisson:redisson-spring-boot-starter:3.45.0' // 2

@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
    // connectionFactory에 뭐가 들어오느냐
    // 1을 쓰면 Lettuce 기반의 LettuceConnectionFactory가 주입
    LettuceConnection.pExpireAt() // 으로 해석
    
    // 2를 쓰면 자동으로 RedissonConnectionFactory로 교체
    RedissonConnection.pExpireAt() // 으로 해석
}

 

RedisTemplate
→ Spring 추상화
→ ConnectionFactory
→ Connection 구현체
→ Redis

RedissonClient
→ Redisson 내부 로직
→ Redis (직접)

더보기

이전에 쓰던 RedisTemplate 을 가져와서 쓰는 방식은 아래와 같다

RedisTemplate 은 Spring Data Redis가 제공하는 Redis 추상화 계층

Lettuce 는 Redis 에게 명령을 보내기 위해 TCP 연결을 관리하는 Client Library 

 

같은 expire() 호출이라도,
RedisTemplate은 "어떤 ConnectionFactory를 타느냐"에 따라 동작이 달라지고,
RedissonClient는 내부 구현으로 직접 Redis에 명령을 보내기 때문에 영향을 받지 않는다.

 

RedisClient 를 쓰면 Spring RedisTemplate 을 경유 x

 

 


왜 무한 재귀가 발생하는가

RedisTemplate.expire(key, Duration)을 호출하면, 내부적으로 DefaultRedisConnection.pExpireAt()을 호출하는데

문제는 Redisson이 교체한 커넥션 구현체에서 pExpire()이 자기 자신을 다시 호출하는 구조가 도미 

template.expire()
  → RedissonConnection.pExpire()
    → RedissonConnection.pExpire()   // 자기 자신 재호출
      → RedissonConnection.pExpire()
        → ...StackOverflowError

같은 template.expire() 호출이지만, 뒤에서 어떤 ConnectionFactory 구현체가 주입되었느냐에 따라 결과가 완전히 달라짐

Lettuce일 때:  template.expire() → LettuceConnection.pExpire()  → 정상
Redisson일 때: template.expire() → RedissonConnection.pExpire() → 무한 재귀

 

 


해결 방법

 

-->>> RedisTemplate을 거치지 않고 Redisson API 직접 호출

 

문제의 근본 원인은 RedisTemplate이 Redisson 커넥션을 경유하기 때문

-> RedisTemplate 말고 다른거 쓰자 

 

Redisson은 자체 커넥션으로 Redis에 직접 명령을 보내기 때문에 Spring의 DefaultRedisConnection을 거치지 X

 

변경 전 — RedisTemplate 경유

stringRedisTemplate.expire(dedupKey, Duration.ofSeconds(DAILY_TTL_SECONDS));
stringRedisTemplate.opsForZSet().incrementScore(dailyKey, String.valueOf(eventId), 1);
stringRedisTemplate.expire(dailyKey, Duration.ofDays(WEEKLY_DAYS + 1));

 

변경 후 — RedissonClient 직접 호출

private final RedissonClient redissonClient;

public void countView(Long eventId, Long userId) {
    String dedupKey = dedupKey(eventId);
    LocalDate today = LocalDate.now();

    // Redisson의 RSet을 직접 사용
    RSet<String> dedupSet = redissonClient.getSet(dedupKey);
    boolean isNew = dedupSet.add(String.valueOf(userId));

    if (!isNew) return;

    // RedisTemplate.expire() 대신 Redisson의 expireIfNotSet() 사용
    dedupSet.expireIfNotSet(Duration.ofDays(8));

    String dailyKey = dailyRankingKey(today);
    RScoredSortedSet<String> rankingSet = redissonClient.getScoredSortedSet(dailyKey);

    // RedisTemplate.opsForZSet().incrementScore() 대신 Redisson의 addScore() 사용
    rankingSet.addScore(String.valueOf(eventId), 1);
    rankingSet.expireIfNotSet(Duration.ofDays(WEEKLY_DAYS + 1));
}

 

 

첫번째 요청에서만 exception 이 터지고 두번째부턴 안 터지는 이유는

첫 요청에서만 expire()가 실행되고, 두 번째부터는 실행 안 되기 때문

 

문제 해결이 된 게 아니라 그냥 Early Return 된 거 였다...

boolean isNew = dedupSet.add(String.valueOf(userId));

if (!isNew) return;

 

사실 Redisson 이랑 RedisTemplate 내부의 expire method 가 어떻게 동작하는지를... 알 필요는 없기 때문에 

둘 다 같이 사용하게 되면 자동으로 RedissonTemplate 이 connectionFactory 로 들어가고 

그걸 막기 위해서는 하나만 쓰던가 명시적으로 표기 해주던가 아예 RedissonClient 자체를 선언해서 사용해주면 된다 !!!


다른 해결 방법 1: Redisson 없이 spring-boot-starter-data-redis만 사용

 

만약에 자료구조 어려운거나 랭킹, 중복방지, 분산락 안 걸어도 되는거면 사실 Redisson 을 쓰는게 오버코딩 ...

그럴땐 그냥 spring-boot-starter-data-redis 만 build.gradle 에 넣고 쓰면 됨


다른 해결 방법 2: Redisson의 ConnectionFactory 자동 교체를 막기

 

자동으로 Lettuce 가 아니라 Redisson 을 넣어주느게 싫다면

(Redisson의 기능도 쓰고, RedisTemplate도 쓰고 싶다면)

Redisson Auto Configuration에서 ConnectionFactory 교체만 비활성화할 수 있다.

@SpringBootApplication(exclude = {
    org.redisson.spring.starter.RedissonAutoConfigurationV2.class
})
public class Application { ... }

@Configuration
public class RedisConfig {

    // Lettuce ConnectionFactory를 직접 등록
    @Bean
    public LettuceConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }

    // RedissonClient는 별도로 생성 (Auto Configuration 안 거침)
    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.setCodec(new StringCodec());
        config.useSingleServer()
                .setAddress("redis://" + host + ":" + port);
        return Redisson.create(config);
    }
}

이렇게 하면 RedisTemplate은 Lettuce 커넥션을 사용하고, RedissonClient는 독립적으로 동작한다.


RedisTemplate vs RedissonClient

  RedisTemplate RedisTemplate
커넥션 사용 방식 RedisConnectionFactory를 통해 간접 접근 자체 커넥션으로 직접 Redis 접근
이번 문제 영향 RedissonConnectionFactory로 교체되면서 내부 동작 변경됨 영향 없음 (Spring 추상화 미사용)
expire 동작 Connection 구현체(pExpireAt)에 의존 내부에서 Redis 명령 직접 수행
안정성 포인트 ConnectionFactory에 따라 동작 달라짐 구현체 영향 적음
언제 쓰는게 적절한가 단순 캐싱, Spring Cache 분산락, 랭킹, Set/SortedSet

 

 

 

캐시 레이어는 RedisTemplate + RedisCacheManager

랭킹/중복방지 같은 고급 기능은 RedissonClient로 나눠 쓰기

 

더보기

Lettuce: Java에서 Redis에 TCP 명령을 보내기 위한 클라이언트 라이브러리. spring-boot-starter-data-redis의 기본 커넥션 구현체

 

Netty: Java 고성능 네트워크 통신 프레임워크. Lettuce와 Redisson 모두 내부적으로 Netty를 사용하여 Redis와 통신

 

래퍼(Wrapper): 기존 객체를 감싸서 인터페이스는 유지하면서 내부 구현을 바꾸는 패턴. Redisson starter가 Spring의 RedisConnectionFactory를 자기 구현체로 감싸는 것이 이번 문제의 원인

 

직렬화(Serialization): Redis는 모든 데이터를 바이트 문자열로 저장하므로, Java 객체를 JSON 등의 문자열로 변환하는 과정이 필요. GenericJackson2JsonRedisSerializer가 이 역할을 수행

 

Auto Configuration: Spring Boot가 classpath에 있는 라이브러리를 감지하고 자동으로 빈을 등록하는 기능. redisson-spring-boot-starter를 넣으면 RedissonConnectionFactory가 자동으로 등록되어 기존 LettuceConnectionFactory를 대체

 

 

  Spring RedisTemplate Redisson RedissonClient
정체 Spring Data Redis가 제공하는 Redis 추상화 계층 Redisson 라이브러리 제공 Redis 전용 자바 클라이언트
Redis 접근 경로 사진1 참조 사진2 참조
API 스타일 Redis 명령어 직접 매핑
opsForZSet().incrementScore()
expire(key, duration)
자바 컬렉션 스타일
getScoredSortedSet().addScore()
set.expireIfNotSet(duration)
직렬화 Serializer 설정 필수 (Key/Value/Hash 각각)
잘못 설정하면 이번 같은 StackOverflow 위험
Codec으로 일괄 설정 (StringCodec 등)
직렬화가 커넥션 계층과 분리되어 안전
분산 락 직접 구현해야 함 (SETNX + Lua 스크립트 등) RLock 내장 (Watchdog 자동 연장, 재진입 지원)
분산 자료구조 없음 — Redis 명령어 조합으로 직접 구현 RMap, RSet, RQueue, RBloomFilter 등 다양한 자료구조 내장
Spring Cache 연동 RedisCacheManager로 바로 연동 RedissonSpringCacheManager로 연동 가능
Local Cache + Redis 2단 캐시도 지원
build.gradle spring-boot-starter-data-redis redisson-spring-boot-starter
주의점 • Serializer 타입 불일치 시 런타임 에러
 RedisTemplate<String, Object>로 expire 호출 시 무한 재귀 위험 (이번 케이스)
• Spring Boot 버전과 Redisson 버전 호환 확인 필수
• Redisson 자체 스레드풀/커넥션풀 관리 필요
언제 쓰면 좋은지 단순 캐싱, 간단한 key-value 조회/저장,
Spring Cache 추상화만 필요한 경우
분산 락, 랭킹(SortedSet), 중복 방지(Set),
복잡한 Redis 자료구조를 직접 다루는 경우

 

 

 

'SPARTA 과제 > SPRING' 카테고리의 다른 글

Lock Service 분리 트러블슈팅  (0) 2026.05.11
Distributed Lock 트러블슈팅  (0) 2026.05.07
K6 테스트  (0) 2026.04.29
JOOQ 코드 생성 트러블슈팅  (1) 2026.04.13
Plus spring 도전과제  (1) 2026.04.06