내일배움캠프

[내일배움캠프] Redis 실습

munsik22 2026. 5. 11. 15:44

로그인 세션 클러스터링

[실습] Spring Security의 Form Login의 세션을 클러스터링 해보자.
- Spring Security의 Form Login 기능을 구현하고, 로그인 정보가 여러 애플리케이션 인스턴스에 걸쳐서 공유되는 것을 확인해보자.
- 편의를 위해 csrf 보안은 해제하고 진행하자.
- UserDetailsService를 직접 구현하지 않고 InMemoryUserDetailsManager를 사용해도 괜찮다.
  • SecurityConfig
더보기
@Configuration
public class SecurityConfig {
    @Bean
    public SecurityFilterChain securityFilterChain(
            HttpSecurity http) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(
                                "/auth/my-profile"
                        ).authenticated()  // 인증이 필요하다 설정
                )
                .formLogin(formLogin -> formLogin
                        .loginPage("/auth/login")
                        .defaultSuccessUrl("/auth/my-profile")
                        .failureUrl("/auth/login?fail")
                        .permitAll()
                )
                .logout(logout -> logout
                        .logoutUrl("/auth/logout")
                        .logoutSuccessUrl("/auth/login")
                );
        return http.build();
    }

    @Bean
    public UserDetailsService userDetailsManager(
            PasswordEncoder passwordEncoder) {
        UserDetails user1 = User.withUsername("user1")
                .password(passwordEncoder.encode("password1"))
                .build();
        UserDetails user2 = User.withUsername("user2")
                .password(passwordEncoder.encode("password2"))
                .build();
        return new InMemoryUserDetailsManager(user1, user2);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
  • RedisConfig
더보기
@Configuration
@EnableRedisHttpSession
public class RedisConfig {
    @Bean
    public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
        return RedisSerializer.json();
    }
}

로그인 이후 500 에러가 발생했다.

  • 에러 로그 보기
더보기
org.springframework.data.redis.serializer.SerializationException: Could not read JSON:Cannot construct instance of `org.springframework.security.web.savedrequest.DefaultSavedRequest` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
 at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); line: 1, column: 79] 
	at org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer.deserialize(GenericJackson2JsonRedisSerializer.java:307) ~[spring-data-redis-3.3.1.jar:3.3.1]
	at org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer.deserialize(GenericJackson2JsonRedisSerializer.java:276) ~[spring-data-redis-3.3.1.jar:3.3.1]
	at org.springframework.data.redis.core.AbstractOperations.deserializeHashValue(AbstractOperations.java:380) ~[spring-data-redis-3.3.1.jar:3.3.1]
	at org.springframework.data.redis.core.AbstractOperations.deserializeHashMap(AbstractOperations.java:324) ~[spring-data-redis-3.3.1.jar:3.3.1]
	at org.springframework.data.redis.core.DefaultHashOperations.entries(DefaultHashOperations.java:237) ~[spring-data-redis-3.3.1.jar:3.3.1]
	at org.springframework.session.data.redis.RedisSessionRepository.findById(RedisSessionRepository.java:138) ~[spring-session-data-redis-3.3.1.jar:3.3.1]
	at org.springframework.session.data.redis.RedisSessionRepository.findById(RedisSessionRepository.java:45) ~[spring-session-data-redis-3.3.1.jar:3.3.1]
	at org.springframework.session.web.http.SessionRepositoryFilter$SessionRepositoryRequestWrapper.getRequestedSession(SessionRepositoryFilter.java:352) ~[spring-session-core-3.3.1.jar:3.3.1]
...
Caused by: com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `org.springframework.security.web.savedrequest.DefaultSavedRequest` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
 at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); line: 1, column: 79]
	at com.fasterxml.jackson.databind.exc.InvalidDefinitionException.from(InvalidDefinitionException.java:67) ~[jackson-databind-2.17.1.jar:2.17.1]
	at com.fasterxml.jackson.databind.DeserializationContext.reportBadDefinition(DeserializationContext.java:1887) ~[jackson-databind-2.17.1.jar:2.17.1]
	at com.fasterxml.jackson.databind.DatabindContext.reportBadDefinition(DatabindContext.java:414) ~[jackson-databind-2.17.1.jar:2.17.1]
	at com.fasterxml.jackson.databind.DeserializationContext.handleMissingInstantiator(DeserializationContext.java:1375) ~[jackson-databind-2.17.1.jar:2.17.1]
	at com.fasterxml.jackson.databind.deser.BeanDeserializerBase.deserializeFromObjectUsingNonDefault(BeanDeserializerBase.java:1508) ~[jackson-databind-2.17.1.jar:2.17.1]
	at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:348) ~[jackson-databind-2.17.1.jar:2.17.1]
	at com.fasterxml.jackson.databind.deser.BeanDeserializer._deserializeOther(BeanDeserializer.java:220) ~[jackson-databind-2.17.1.jar:2.17.1]
	at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:187) ~[jackson-databind-2.17.1.jar:2.17.1]
...

에러 로그를 보니 Spring Session이 Redis에 세션 정보를 JSON 형태로 저장하려고 할 때, Spring Security의 내부 객체인 DefaultSavedRequest를 어떻게 역직렬화해야 할지 몰라서 발생하는 문제인 것으로 보인다.

  • RedisConfig 수정
    • Spring Security는 위와 같은 문제를 해결하기 위해 Jackson용 전용 모듈(SecurityJackson2Modules)을 제공한다. 이를 ObjectMapper에 등록해줄 필요가 있다.
더보기
@Configuration
@EnableRedisHttpSession
public class RedisConfig implements BeanClassLoaderAware {
    private ClassLoader loader;

    @Bean
    public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
        ObjectMapper mapper = new ObjectMapper();

        mapper.registerModules(SecurityJackson2Modules.getModules(this.loader));

        return new GenericJackson2JsonRedisSerializer(mapper);
    }

    @Override
    public void setBeanClassLoader(ClassLoader classLoader) {
        this.loader = classLoader;
    }
}

정상적으로 로그인한 모습. Redis에 세션이 저장된 것을 확인할 수 있다.
다른 포트번호에서도 로그인이 정상적으로 적용됨


Spring 캐싱 실습

[실습] 상품을 판매하는 Store를 만들고, CRUD를 구현한 다음 필요한 지점에 캐싱을 구현해보자.
  • CacheConfig
더보기
@Configuration
@EnableCaching
public class CacheConfig {
    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
        RedisCacheConfiguration configuration = RedisCacheConfiguration
                .defaultCacheConfig()
                .disableCachingNullValues()
                .entryTtl(Duration.ofSeconds(120))
                .computePrefixWith(CacheKeyPrefix.simple())
                .serializeValuesWith(
                        SerializationPair.fromSerializer(RedisSerializer.java())
                );

        RedisCacheConfiguration individual = RedisCacheConfiguration
                .defaultCacheConfig()
                .disableCachingNullValues()
                .entryTtl(Duration.ofSeconds(20))
                .enableTimeToIdle()
                .computePrefixWith(CacheKeyPrefix.simple())
                .serializeValuesWith(
                        SerializationPair.fromSerializer(RedisSerializer.json())
                );

        return RedisCacheManager
                .builder(redisConnectionFactory)
                .cacheDefaults(configuration)
                .withCacheConfiguration("storeCache", individual)
                .build();
    }
}
  • StoreService
더보기
@Slf4j
@Service
@RequiredArgsConstructor
public class StoreService {
    private final StoreRepository storeRepository;

    @CacheEvict(cacheNames = "storeAllCache", allEntries = true)
    public StoreDto create(StoreDto dto) {
        return StoreDto.fromEntity(storeRepository.save(Store.builder()
                .name(dto.getName())
                .category(dto.getCategory())
                .build()));
    }

    @Cacheable(cacheNames = "storeCache", key = "args[0]")
    public StoreDto readOne(Long id) {
        return storeRepository.findById(id)
                .map(StoreDto::fromEntity)
                .orElseThrow(() ->
                        new ResponseStatusException(HttpStatus.NOT_FOUND));
    }

    @Cacheable(cacheNames = "storeAllCache", key = "methodName")
    public List<StoreDto> readAll() {
        return storeRepository.findAll()
                .stream()
                .map(StoreDto::fromEntity)
                .toList();
    }

    @CachePut(cacheNames = "storeCache", key = "#result.id")
    @CacheEvict(cacheNames = "storeAllCache", allEntries = true)
    public StoreDto update(Long id, StoreDto dto) {
        Store store = storeRepository.findById(id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
        store.setName(dto.getName());
        store.setCategory(dto.getCategory());
        return StoreDto.fromEntity(storeRepository.save(store));
    }

    @Caching(evict = {
            @CacheEvict(cacheNames = "storeCache", key = "args[0]"),
            @CacheEvict(cacheNames = "storeAllCache", allEntries = true)
    })
    public void delete(Long id) {
        storeRepository.deleteById(id);
    }
}
  • TTL (Time To Live): 생성(Put)된 후 무조건 설정 시간이 지나면 만료됨
    • 예: 10분 설정 시 10분 후 무조건 삭제
    • 사용 예시: 정확히 갱신해야 하는 데이터 (가격 정보 등)
  • TTI (Time To Idle): 마지막으로 사용된 후 설정 시간 동안 사용되지 않으면 만료됨
    • 예: 10분 설정 시 9분 째에 조회하면 다시 10분 연장
    • 사용 예시: 사용자 세션처럼 자주 접속하는 사용자는 계속 유지하고 비활성 사용자는 빨리 정리해야 할 때

Write-back 구현하기

  • ItemService
더보기
public void purchase(ItemOrderDto dto) {
    Item item = itemRepository.findById(dto.getItemId())
            .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
    
    // DB에 바로 저장하지 않고 "orderCache::behind"라는 키의 리스트 오른쪽에 추가 (RPUSH)
    orderOps.rightPush("orderCache::behind", dto);
    rankOps.incrementScore(
            "soldRanks",
            ItemDto.fromEntity(item),
            1
    );
}

@Transactional
@Scheduled(fixedRate = 20, timeUnit = TimeUnit.SECONDS)
public void insertOrders() {
    boolean exists = Optional.ofNullable(orderTemplate.hasKey("orderCache::behind"))
            .orElse(false);
    if (!exists) {
        log.info("no orders in cache");
        return;
    }

    // 처리할 데이터를 orderCache::now로 옮겨서 처리 도중 들어올 새 주문과 분리함
    orderTemplate.rename("orderCache::behind", "orderCache::now");
    
    // DB 일괄 저장 (Batch Insert)
    log.info("saving {} orders to db", orderOps.size("orderCache::now"));
    orderRepository.saveAll(orderOps.range("orderCache::now", 0, -1).stream()
            .map(dto -> ItemOrder.builder()
                    .itemId(dto.getItemId())
                    .count(dto.getCount())
                    .build())
            .toList());
    
    // 처리 완료된 캐시 삭제
    orderTemplate.delete("orderCache::now");
}