[Coffies Vol.02]Redis로 세션 불일치 상황에서의 세션 공유
목차
1.문제상황
2.적용
1.문제상황
서버가 한대인 경우에는 해당 서버에 세션 정보를 저장해서 사용을 할 수 있지만, 현재 진행중인 프로젝트의 경우에는
Scale-out을 적용한 환경에서는 세션 불일치 문제가 발생을 할 수 있습니다.
세션 불일치?
웹 애플리케이션에서 사용자 세션의 상태가 일관되지 않거나 예상치 못하게 변경될 때 발생하는 문제를 세센 불일치라고 합니다.
세션 불일치를 해결하는 방식에는 크게 3가지가 있습니다.
Stikcy-Session
Session-Clustering
Session-Storage
이중에서 제가 적용을 할 것은 Redis를 Session-Storage로 사용할 것입니다. 우선 Redis를 사용한 이유는 다음과 같습니다.
- 확장성: 모든 서버가 중앙 세션 스토리지(예: Redis)를 참조하므로, 서버를 추가하더라도 세션 데이터의 일관성을 유지할 수 있습니다.
- 고가용성: Redis는 복제와 페일오버 기능을 제공하여 높은 가용성을 보장합니다.
- 속도: Redis는 메모리 기반 데이터 저장소로 매우 빠른 읽기/쓰기 성능을 제공합니다.
- 일관성: 모든 서버가 동일한 세션 저장소를 사용하므로 세션 데이터의 일관성을 유지할 수 있습니다.
- TTL 지원: Redis는 TTL(Time-To-Live)을 지원하여 세션 데이터를 자동으로 만료시킬 수 있습니다.
적용을 하게 되면 아래의 이미지와 같습니다.

2.적용
해당 과정을 코드에 적용을 하는 과정은 다음과 같습니다.
1. Redis 설정 클래스
@Configuration
@EnableRedisHttpSession
public class RedisSessionConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;
//redis 설정
@Bean
public RedisConnectionFactory redisConnectionFactory(){
RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
redisStandaloneConfiguration.setHostName(host);
redisStandaloneConfiguration.setPort(port);
return new LettuceConnectionFactory(redisStandaloneConfiguration);
}
@Bean
public StringRedisTemplate stringRedisTemplate() {
StringRedisTemplate stringRedisTemplate = new StringRedisTemplate();
stringRedisTemplate.setConnectionFactory(redisConnectionFactory());
stringRedisTemplate.setKeySerializer(new StringRedisSerializer());
stringRedisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
stringRedisTemplate.setDefaultSerializer(new StringRedisSerializer());
stringRedisTemplate.afterPropertiesSet();
return stringRedisTemplate;
}
}
2.스프링 시큐리티 설정 클래스
@Log4j2
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableGlobalMethodSecurity(prePostEnabled = true)
@Order(SecurityProperties.DEFAULT_FILTER_ORDER)
public class SecurityConfig {
private final CustomUserDetailService customUserDetailService;
private final FindByIndexNameSessionRepository sessionRepository;
private final SecuritySessionExpiredStrategy securitySessionExpiredStrategy;
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfiguration) throws Exception {
return authConfiguration.getAuthenticationManager();
}
@Bean
public LoginFailHandler loginFailHandler(){
return new LoginFailHandler();
}
@Bean
public LoginSuccessHandler loginSuccessHandler(){
return new LoginSuccessHandler();
}
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web
.httpFirewall(defaultFireWell())
.ignoring()
.antMatchers("/images/**", "/js/**","/font/**", "/webfonts/**","/istatic/**",
"/main/**", "/webjars/**", "/dist/**", "/plugins/**", "/css/**","/favicon.ico","/h2-console/**");
}
@Bean
public AuthenticationProvider authProvider(){
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(customUserDetailService);
provider.setPasswordEncoder(bCryptPasswordEncoder());
return provider;
}
@Bean
public SpringSessionBackedSessionRegistry sessionRegistry() {
return new SpringSessionBackedSessionRegistry(this.sessionRepository);
}
private static final String[] PERMIT_URL_ARRAY = {
"/**",
/* swagger v2 */
"/v2/api-docs",
"/swagger-resources",
"/swagger-resources/**",
"/configuration/ui",
"/configuration/security",
"/swagger-ui.html",
"/webjars/**",
/* swagger v3 */
"/v3/api-docs/**",
"/swagger-ui/**"
};
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeRequests()
.antMatchers("/**").permitAll()
.antMatchers(PERMIT_URL_ARRAY).permitAll()
.anyRequest()
.authenticated();
//세션 설정
http.sessionManagement(session -> session.sessionFixation(SessionManagementConfigurer
.SessionFixationConfigurer::changeSessionId)
.maximumSessions(1)
.maxSessionsPreventsLogin(false)
.expiredUrl("/")
.sessionRegistry(sessionRegistry())
.expiredSessionStrategy(securitySessionExpiredStrategy));
http.formLogin(httpSecurityFormLoginConfigurer -> httpSecurityFormLoginConfigurer
.loginPage("/page/login/loginPage")
.usernameParameter("userId")
.passwordParameter("password")
.loginProcessingUrl("/api/member/login")
.successHandler(loginSuccessHandler())
.failureHandler(loginFailHandler()));
http
.csrf(AbstractHttpConfigurer::disable)
.rememberMe(AbstractHttpConfigurer::disable)
.logout(logout ->logout
.logoutUrl("/api/member/logout")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID"));
return http.build();
}
@Bean
public HttpFirewall defaultFireWell(){
return new DefaultHttpFirewall();
}
}
기존의 시큐리티 인증 설정에서 추가된 부분은 다음과 같습니다.
private final FindByIndexNameSessionRepository sessionRepository
private final SecuritySessionExpiredStrategy securitySessionExpiredStrategy
//세션 설정
http.sessionManagement(session -> session.sessionFixation(SessionManagementConfigurer
.SessionFixationConfigurer::changeSessionId)
.maximumSessions(1)
.maxSessionsPreventsLogin(false)
.expiredUrl("/")
.sessionRegistry(sessionRegistry())
.expiredSessionStrategy(securitySessionExpiredStrategy));
우선은 위에서부터 설명을 하자면
private final FindByIndexNameSessionRepository sessionRepository
- 세션 저장:
- 사용자 세션을 Redis에 저장하고 관리합니다. 이를 통해 서버 간에 세션을 공유하거나, 서버가 재시작되어도 세션이 유지되도록 합니다.
- 세션 조회:
- 세션 ID 또는 인덱스를 사용해 세션을 조회할 수 있습니다. 이 기능은 사용자 ID로 세션을 찾거나, 특정 세션이 유효한지 확인할 때 유용합니다.
- 세션 인덱스 관리:
- 세션과 관련된 추가 인덱스를 관리할 수 있습니다. 예를 들어, 사용자 이름을 인덱스로 사용하여 해당 사용자의 세션을 조회할 수 있습니다.
- FindByIndexNameSessionRepository는 이런 인덱스를 기반으로 세션을 쉽게 찾을 수 있는 메서드를 제공합니다.
private final SecuritySessionExpiredStrategy securitySessionExpiredStrategy
- 세션 만료 처리:
- SecuritySessionExpiredStrategy는 사용자의 세션이 만료되었을 때 호출됩니다. 이 시점에서 사용자에게 알림을 표시하거나, 특정 페이지로 리다이렉트하는 등의 작업을 수행할 수 있습니다.
- 커스터마이징:
- 세션 만료 시의 행동을 개발자가 원하는 대로 커스터마이징할 수 있습니다. 예를 들어, 만료된 세션을 처리하는 페이지를 구성하거나, 사용자에게 경고 메시지를 전달할 수 있습니다.
그리고 세션에 관련된 설명을 하자면 다음과 같습니다.
1.sessionFixation(SessionManagementConfigurer.SessionFixationConfigurer::changeSessionId)
- 세션 고정 보호(Session Fixation Protection):
- 세션 고정 공격(Session Fixation Attack)은 공격자가 미리 정해둔 세션 ID를 피해자에게 강제로 사용하게 하여, 이후에 공격자가 동일한 세션 ID를 사용해 피해자로 가장하는 공격 기법입니다.
- changeSessionId 옵션은 사용자가 인증(로그인)할 때 기존 세션 ID를 새로운 세션 ID로 변경하여 세션 고정 공격을 방지합니다. 인증 후 세션 ID를 변경함으로써, 이전 세션이 악의적으로 재사용되는 것을 방지합니다.
2. maximumSessions(1)
- 최대 세션 수 제한:
- 이 설정은 하나의 계정으로 동시에 허용되는 최대 세션 수를 설정합니다. 여기서 1로 설정했기 때문에, 동일한 계정으로는 하나의 세션만 유지될 수 있습니다.
- 예를 들어, 사용자가 A 장치에서 로그인한 상태에서 B 장치에서 동일한 계정으로 로그인하면, A 장치의 세션은 만료되거나 종료됩니다.
3. maxSessionsPreventsLogin(false)
- 최대 세션 도달 시 처리:
- 이 옵션은 최대 세션 수에 도달했을 때 새로운 로그인을 허용할지 여부를 결정합니다.
- false로 설정된 경우, 기존 세션을 무효화하고 새로운 세션을 생성합니다. 즉, 새로운 로그인은 허용되지만, 기존 세션은 종료됩니다.
- 반대로 true로 설정하면, 새로운 로그인이 차단되며, 기존 세션이 유지됩니다.
4. expiredUrl("/")
- 세션 만료 시 리다이렉트 URL:
- 세션이 만료되었을 때 사용자가 리다이렉트될 URL을 설정합니다. 여기서는 "/"로 설정되어 있으므로, 세션이 만료되면 사용자가 애플리케이션의 루트 페이지로 리다이렉트됩니다.
5. sessionRegistry(sessionRegistry())
- 세션 레지스트리 설정:
- sessionRegistry()는 활성 세션을 추적하는 역할을 합니다. 이를 통해 Spring Security는 현재 활성화된 모든 세션을 관리하고 추적할 수 있습니다.
- 주로 Redis 같은 외부 세션 저장소와 연동하여 사용하며, 여러 서버 간의 세션을 공유할 때 유용합니다.
6. expiredSessionStrategy(securitySessionExpiredStrategy)
- 세션 만료 전략:
- securitySessionExpiredStrategy는 세션이 만료되었을 때 실행될 전략을 정의합니다.
- 이 전략은 주로 사용자에게 세션 만료를 알리거나, 특정 페이지로 리다이렉트하는 등의 작업을 수행합니다. 앞서 설명한 SecuritySessionExpiredStrategy 클래스가 여기에 사용될 수 있습니다.
인증에 필요한 AuthService
@Log4j2
@Service
@Transactional
@AllArgsConstructor
public class AuthService {
private final BCryptPasswordEncoder encoder;
private final CustomUserDetailService customUserDetailService;
public String login(LoginDto loginDto, HttpSession httpSession){
log.info("service");
CustomUserDetails customUserDetails = (CustomUserDetails) customUserDetailService.loadUserByUsername(loginDto.getUserId());
log.info("service user details:::"+customUserDetails.getMember());
if(customUserDetails == null ||!encoder.matches(loginDto.getPassword(),customUserDetails.getMember().getPassword())){
throw new CustomExceptionHandler(ERRORCODE.PASSWORD_NOT_MATCH);
}
httpSession.setAttribute("member",customUserDetails.getMember());
log.info("인증정보::"+httpSession.getAttribute("member"));
return httpSession.getId();
}
public void logout(HttpSession httpSession){
httpSession.removeAttribute("member");
}
}
설정을 하고 난뒤 로그인 api인 /api/member/login 으로 로그인을 작동하고 나면 redis에 다음과 같이 나옵니다.

redis에 저장된 내용은 다음과 같습니다.
1) "spring:session:sessions:expires:$sesssionId (string)
스프링 세션의 만료시간을 관리하는 key입니다.
2) "spring:session:expirations:$expireTime" (set)
스프링 세션의 만료시간입니다.
3) "spring:session:sessions:$sessionId" (hash)
생성된 스프링 세션 데이터입니다.
4)"spring:session:index:org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME:$username"(set)
username으로 세션을 가져올 수 있도록 저장되는 인덱스입니다.
5)
"USER::well4149"
시큐리티 로그인시 CustomUserDetails에 저장된 회원인증정보입니다.
그럼 현재 진행이 되고 있는 프로젝트에서는 어떻게 적용이되는지 화면을 띄워서 확인을 해보겠습니다. 위의 기술이 적용이 되었다면 8081포트뿐만 아니라 82,83포트에도 정확하게 로그인을 하지 않아도 세션의 내용이 정확하게 남아있어야 합니다.


