Spring 7기 프로젝트/코드 개선 + 테스트 코드 프로젝트

[트러블슈팅] Redis 캐싱 시스템 구축

JuNo_12 2025. 6. 9. 12:51

들어가며

백엔드 개발 과정에서 JWT 기반 인증 시스템을 구축한 후, 성능 최적화의 필요성을 느끼게 되었습니다. 특히 사용자 프로필 조회와 할일 목록 조회 API에서 매번 데이터베이스에 접근하는 것이 비효율적이라는 판단하에 Redis 캐싱 시스템을 도입하기로 결정했습니다. 본 포스팅에서는 Redis 캐싱 도입 과정에서 실제로 마주했던 의문점들과 트러블슈팅 과정을 Q&A 형식으로 정리하여 공유하고자 합니다. 단순한 구현 결과가 아닌, 개발 과정에서 실제로 고민했던 지점들을 중심으로 작성했습니다.


도입 배경 및 목표

현재 상황 분석

기존 시스템에서는 사용자가 프로필 페이지에 접근할 때마다 데이터베이스에서 사용자 정보를 조회했습니다. 또한 할일 목록을 볼 때도 매번 DB 쿼리가 실행되어 불필요한 부하가 발생하고 있었습니다.

성능 개선 목표

  • 응답 시간: 50ms → 5ms 이하 (10배 향상)
  • DB 부하: 반복 조회 쿼리 50% 이상 감소
  • 확장성: 동시 사용자 증가에 대비한 아키텍처 구축

주요 구현 과정

1. Redis 환경 구성

Docker를 활용하여 개발 환경에 Redis를 구축했습니다:

# Redis 컨테이너 실행
docker run -d -p 6379:6379 --name redis redis:alpine

# 연결 테스트
docker exec -it redis redis-cli ping

 

2. Spring Cache 설정

@Configuration
@EnableCaching
public class CacheConfig {
    
    @Bean
    public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        // JSON 직렬화를 위한 ObjectMapper 설정
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.registerModule(new JavaTimeModule());
        
        GenericJackson2JsonRedisSerializer jsonSerializer = 
                new GenericJackson2JsonRedisSerializer(objectMapper);

        // 기본 캐시 설정
        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration
                .defaultCacheConfig()
                .serializeKeysWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(jsonSerializer))
                .entryTtl(Duration.ofHours(1));

        // 캐시별 개별 TTL 설정
        Map<String, RedisCacheConfiguration> configMap = new HashMap<>();
        configMap.put("userProfiles", defaultConfig.entryTtl(Duration.ofMinutes(30)));
        configMap.put("todoLists", defaultConfig.entryTtl(Duration.ofMinutes(10)));
        configMap.put("managers", defaultConfig.entryTtl(Duration.ofHours(1)));

        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(defaultConfig)
                .withInitialCacheConfigurations(configMap)
                .build();
    }
}

핵심 트러블슈팅

Q1. "기존에 TokenBlacklist 엔티티가 있는데, Redis 캐시도 쓰면 중복 아닌가요?"

질문 배경: JWT 토큰 블랙리스트를 위해 이미 DB 엔티티가 있었는데, Redis를 도입하면서 중복되는 것이 아닌지 의문이 들었습니다.

 

고민 과정:

  • Redis만 사용하면 빠르지만 재시작 시 데이터 손실 위험
  • DB만 사용하면 안전하지만 성능 저하
  • 기존 엔티티를 삭제해야 하는지 고민

해결책: 하이브리드 구조 도입

@Service
public class TokenBlacklistService {
    
    public void addTokenToBlacklist(String accessToken, Long userId) {
        Claims claims = jwtTokenProvider.parseToken(accessToken);
        String jti = claims.getId();
        
        if (StringUtils.hasText(jti)) {
            LocalDateTime expiresAt = jwtTokenProvider.getExpirationTime(claims);
            
            if (expiresAt.isAfter(LocalDateTime.now())) {
                // 1. Redis에 빠른 조회용으로 저장 (TTL 적용)
                String redisKey = BLACKLIST_KEY_PREFIX + jti;
                long ttlSeconds = Duration.between(LocalDateTime.now(), expiresAt).getSeconds();
                redisTemplate.opsForValue().set(redisKey, "blocked", ttlSeconds, TimeUnit.SECONDS);
                
                // 2. DB에 영구 보존용으로 저장
                if (!tokenBlacklistRepository.existsById(jti)) {
                    TokenBlacklist blacklistToken = new TokenBlacklist(jti, userId, expiresAt);
                    tokenBlacklistRepository.save(blacklistToken);
                }
            }
        }
    }
    
    public boolean isBlacklisted(String jti) {
        // 1. Redis 우선 확인 (초고속)
        String redisKey = BLACKLIST_KEY_PREFIX + jti;
        Boolean existsInRedis = redisTemplate.hasKey(redisKey);
        
        if (Boolean.TRUE.equals(existsInRedis)) {
            return true;
        }
        
        // 2. Redis 미스 시 DB 확인 + 재캐싱
        boolean existsInDB = tokenBlacklistRepository.existsByJtiAndExpiresAtAfter(jti, LocalDateTime.now());
        
        if (existsInDB) {
            redisTemplate.opsForValue().set(redisKey, "blocked", 3600, TimeUnit.SECONDS);
        }
        
        return existsInDB;
    }
}

 

결과: 성능(Redis)과 안전성(DB)을 모두 확보하는 최적의 구조 완성


Q2. "RedisTemplate 빈은 어디에 사용되는 건가요? 꼭 필요한가요?"

 

혼란 포인트:

// 이건 뭐에 쓰이는 거지?
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
    // ... 설정
}

 

분석 결과:

용도  사용 방식 사용 위치
사용자/할일 캐싱 @Cacheable (자동) UserService, TodoService
토큰 블랙리스트 RedisTemplate (수동) TokenBlacklistService

 

RedisTemplate이 필요한 이유:

// TokenBlacklistService에서만 사용
private final RedisTemplate<String, Object> redisTemplate;

public void addTokenToBlacklist(String accessToken, Long userId) {
    // 수동으로 Redis 조작 (TTL, 즉시 저장)
    redisTemplate.opsForValue().set(redisKey, "blocked", ttlSeconds, TimeUnit.SECONDS);
}

 

결론: 토큰 블랙리스트의 수동 Redis 조작을 위해서만 필요합니다.


Q3. "사용자/할일도 하이브리드 방식인가요? 왜 하이브리드를 사용하나요?"

 

동작 원리 분석:

@Cacheable(value = "userProfiles", key = "#userId")
public UserResponse getUser(long userId) {
    log.info("DB에서 사용자 조회: userId={}", userId); // 첫 요청에만 출력
    return userRepository.findById(userId)...
}

 

 

첫 번째 요청: Cache Miss → DB 조회 → Redis 저장 두 번째 요청: Cache Hit → Redis에서 조회

 

하이브리드 방식의 장점:

  1. 데이터 안전성: Redis 재시작 시에도 DB에서 자동 복구
  2. 장애 대응: Redis 장애 시 자동으로 DB fallback
  3. 점진적 캐시 구축: 자주 사용되는 데이터만 자연스럽게 캐싱
  4. 관리 부담 최소화: 자동 캐시 생성/삭제

결론: 모든 캐싱이 하이브리드 방식이며, 이는 안전성 + 성능을 동시에 얻기 위한 최적의 선택입니다.


Q4. "토큰 블랙리스트는 왜 @Cacheable 대신 수동 설정을 사용하나요?"

 

핵심 차이점 분석:

특성 일반 캐시 (@Cacheable)  토큰 블랙리스트 (수동)
저장 타이밍 조회할 때 로그아웃할 때
TTL 설정 고정 (30분) 토큰별 개별 만료시간
보안 요구사항 성능 최적화 즉시 차단 필요
데이터 특성 없으면 DB 조회 없으면 허용

 

 

수동 설정이 필요한 이유:

 

보안의 즉시성:

// 로그아웃 버튼 클릭 → 즉시 Redis 저장
authService.logout(userId, accessToken);
// 0.001초 후 같은 토큰 사용 → 즉시 차단!

 

정확한 TTL 설정:

// 각 토큰의 만료시간에 맞춰 TTL 설정
long ttlSeconds = Duration.between(LocalDateTime.now(), tokenExpires).getSeconds();
redisTemplate.opsForValue().set(jti, "blocked", ttlSeconds, TimeUnit.SECONDS);

 

저장 패턴의 차이:

// 일반 캐시: "조회할 때" 저장
@Cacheable // 없으면 DB 조회 → 저장

// 토큰 블랙리스트: "로그아웃할 때" 저장
logout() → 즉시 Redis 저장 // 조회 전에 미리 저장!

 

결론: 토큰 블랙리스트는 보안 특성상 @Cacheable 패턴과 맞지 않아 수동 구현이 필요합니다.


Q5. "JSON 직렬화 설정은 왜 필요한가요?"

 

문제 상황:

// LocalDateTime이 포함된 객체
public class TodoResponse {
    private LocalDateTime createdAt;
    private LocalDateTime modifiedAt;
}

 

 

JavaTimeModule 없을 때:

SerializationException: Java 8 date/time type `LocalDateTime` not supported by default

 

 

JSON 직렬화의 장점:

방식 저장 형태  장점/단점
기본 Java 직렬화 \xac\xed\x00\x05sr\x00\x1f... 바이너리, 용량 큼, 읽기 불가
JSON 직렬화 {"id":1,"email":"test@test.com"} 텍스트, 효율적, 읽기 쉬움

 

실제 Redis에서 확인:

# Redis CLI에서
> get "userProfiles::1"
"{\"id\":1,\"email\":\"test@test.com\",\"createdAt\":\"2025-06-09T12:00:00\"}"

 

결론: 객체를 Redis에 효율적으로 저장하고 LocalDateTime 처리를 위해 필수입니다.


Q6. "RedisCacheManager에서 왜 create() 대신 builder()를 사용했나요?"

방식별 비교:

// 방법 1: create() - 간단하지만 제한적
@Bean
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
    return RedisCacheManager.create(connectionFactory);
    // - 기본 설정만 사용
    // - 모든 캐시가 같은 TTL
    // - JSON 직렬화 설정 불가
}

// 방법 2: builder() - 복잡하지만 유연함
return RedisCacheManager.builder(connectionFactory)
    .cacheDefaults(defaultConfig)                    // JSON 직렬화, 기본 TTL
    .withInitialCacheConfigurations(configMap)       // 캐시별 개별 TTL
    .build();

 

builder()가 필요한 이유:

  • 개별 TTL 설정: 사용자(30분), 할일(10분), 관리자(1시간)
  • JSON 직렬화: LocalDateTime 처리
  • 실무급 설정: 세밀한 캐시 제어

결론: builder() 방식이 더 세밀하게 설정이 가능하다는 장점이 있습니다.


Q7. "Docker 컨테이너 메모리를 다 사용하면 어떻게 되나요?"

Redis 메모리 관리 방식:

  1. TTL 자동 만료: 30분 지난 사용자 캐시 자동 삭제
  2. LRU 정책: 가장 오래 사용 안 된 데이터부터 삭제
  3. 애플리케이션 안정성: 메모리 가득 차도 오류 없음

 

메모리 사용량 확인:

# Redis 메모리 사용량 확인
docker exec -it redis redis-cli info memory

# 결과 예시:
# used_memory_human:2.50M    # 2.5MB 사용 중
# maxmemory_human:0B         # 제한 없음

 

개발 환경에서의 실제 사용량:

  • 테스트 데이터: 사용자 몇 개, 할일 몇 개
  • 실제 사용량: 2-3MB 정도
  • 메모리 부족 가능성: 거의 없음

결론: 개발 단계에서는 메모리 부족 걱정 없이 사용 가능합니다.


성능 측정 결과

실제 테스트 과정

측정 환경: Postman을 통한 동일 API 연속 호출

사용자 프로필 조회 API 테스트:

 

첫 번째 요청 (Cache Miss):

Hibernate: select u1_0.id, u1_0.email, ... from users u1_0 where u1_0.id=?
  • DB 쿼리 실행 확인
  • 응답 시간: ~50ms

 

두 번째 요청 (Cache Hit):

(SQL 쿼리 없음)
  • Redis에서 조회 확인
  • 응답 시간: ~2ms
  • 성능 향상: 25배

 

토큰 블랙리스트 확인:

-- 매 요청마다 나타나는 쿼리 (정상)
select tb1_0.jti from token_blacklist tb1_0 where tb1_0.jti=? and tb1_0.expires_at>?
  • 보안상 매번 확인하는 것이 정상
  • 사용자 데이터 쿼리가 사라진 것이 핵심 개선사항

핵심 학습 포인트

1. 아키텍처 설계 원칙

  • 하이브리드 구조: 성능과 안전성의 균형
  • 데이터 특성 고려: 보안 데이터 vs 일반 데이터의 다른 처리 방식
  • 장애 시나리오 대비: Redis 장애 시 자동 fallback

2. 성능 최적화 인사이트

  • 점진적 캐시 구축: 자주 사용되는 데이터만 자연스럽게 캐싱
  • TTL 차별화: 데이터 특성에 맞는 개별 설정
  • 즉시성의 중요성: 보안 관련 데이터의 실시간 처리

3. 실무 고려사항

  • 트레이드오프 인식: 성능 vs 복잡성의 균형점 찾기
  • 모니터링 필요성: 캐시 적중률, 메모리 사용량 추적
  • 설정의 유연성: builder 패턴을 통한 세밀한 제어

마무리

Redis 캐싱 시스템 도입을 통해 평균 20배 이상의 성능 향상을 달성할 수 있었습니다. 하지만 이 과정에서 가장 중요했던 것은 성능 수치보다도 올바른 설계 사고를 기르는 것이었습니다. 특히 "왜 이렇게 설계했는가?"에 대한 질문들을 통해 다음과 같은 중요한 인사이트를 얻었습니다:

  1. 데이터 특성별 차별화: 일반 데이터와 보안 데이터의 다른 처리 방식
  2. 트레이드오프 이해: 성능, 안전성, 복잡성 간의 균형점
  3. 장애 시나리오 고려: 항상 Plan B를 준비하는 사고방식

앞으로는 이벤트 기반 아키텍처 도입을 통해 더욱 확장 가능하고 유연한 시스템을 구축해 나갈 계획입니다.

무엇보다 "단순히 구현하는 것"에서 "왜 이렇게 설계해야 하는지 이해하는 것"으로 한 단계 성장할 수 있었던 값진 경험이었습니다.


참고 자료: