로그인 세션 클러스터링
[실습] 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();
}
}

- 에러 로그 보기
더보기
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;
}
}


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");
}
'내일배움캠프' 카테고리의 다른 글
| [내일배움캠프] 대규모 시스템에서의 DB 최적화와 분산 트랜잭션 (0) | 2026.05.12 |
|---|---|
| [내일배움캠프] Redis 심화 (0) | 2026.05.12 |
| [내일배움캠프] Spring Boot에 캐싱 적용하기 (0) | 2026.05.10 |
| [내일배움캠프] Redis 응용 (0) | 2026.05.08 |
| [내일배움캠프] Redis (1) | 2026.05.07 |