지난 글에서 Redis가 In-Memory 저장소로서 빠른 속도를 제공하고, 다양한 데이터 타입과 캐싱 전략을 통해 어떻게 시스템 성능을 끌어올리는지 살펴봤다. 그런데 여기서 자연스럽게 의문이 생긴다.
이번 글에서는 이 세 가지 질문을 순서대로 풀어나간다.
Redis가 빠른 이유는 단순하다. 데이터를 디스크가 아닌 메모리(RAM)에 저장하기 때문이다. 그런데 메모리는 전원이 끊기는 순간 모든 데이터가 사라진다. 서버 재시작, 예상치 못한 장애, 배포 과정에서의 재부팅 — 이런 상황에서 메모리 위의 데이터는 순식간에 증발한다.
이 문제를 해결하기 위해 Redis는 데이터를 디스크에 저장하는 영속성(Persistence) 전략을 제공한다. 크게 두 가지 방식이 있다.
RDB(Redis Database)는 특정 시점의 메모리 상태를 통째로 디스크에 저장하는 방식이다. 카메라로 사진을 찍듯이 현재 상태를 .rdb 파일로 기록한다.
save 60 1000 # 60초마다 1000개 이상 변경되면 저장
save 300 10 # 300초마다 10개 이상 변경되면 저장
장점: 파일 크기가 작고 복구 속도가 빠르다. 스냅샷 저장 중 Redis 성능 저하가 비교적 적다.
단점: 스냅샷 주기 사이에 서버가 죽으면 그 사이의 데이터는 유실된다. 5분 주기로 저장하는데 4분 59초에 장애가 발생하면 5분치 데이터가 사라진다.
AOF(Append-Only File)는 Redis에서 발생하는 모든 쓰기 명령을 로그 파일에 순차적으로 기록하는 방식이다. 일기장에 매일 있었던 일을 빠짐없이 적어두는 것과 같다.
appendonly yes
appendfsync everysec # 매 초마다 디스크에 동기화 (기본값)
appendfsync always # 모든 쓰기 명령마다 동기화
appendfsync no # 운영체제에 맡김
장점: 데이터 유실 가능성이 훨씬 낮다. everysec 설정 시 최대 1초치만 유실된다.
단점: 모든 쓰기 명령을 기록하므로 파일 크기가 커진다. 쓰기 빈도가 높으면 디스크 I/O 오버헤드가 발생한다. Redis가 빠른 이유가 메모리 저장 때문인데, always 설정은 매 쓰기마다 디스크와 동기화하므로 결국 디스크 속도에 발목이 잡힌다. 그래서 기본값이 everysec인 것이다.
두 방식을 함께 사용하면 각자의 단점을 보완할 수 있다.
| RDB | AOF | RDB + AOF | |
|---|---|---|---|
| 복구 속도 | 빠름 | 느림 | 빠름 (RDB 활용) |
| 데이터 유실 | 스냅샷 주기만큼 | 최대 1초 | 최소화 |
| 파일 크기 | 작음 | 큼 | - |
병행 사용 시 서버 재시작 때 Redis는 AOF를 우선적으로 읽는다. RDB 스냅샷 이후의 변경사항이 AOF에 기록되어 있기 때문에 AOF가 항상 더 최신 데이터를 갖고 있기 때문이다.
Spring Security를 이야기하기 전에 두 개념을 명확히 구분해야 한다.
순서가 중요하다. 인증이 먼저 되어야 인가가 의미를 갖는다.
이전 글에서 세션 클러스터링을 다뤘다. 다중 서버 환경에서 Sticky Session은 부하 불균형 문제가 있고, Session Replication은 복제 비용이 증가한다. Redis를 중앙 세션 저장소로 사용하면 이 두 문제를 모두 해결할 수 있다.
서버 A ──┐
서버 B ──┼── Redis (세션 저장소)
서버 C ──┘
사용자가 서버 A에서 로그인하고 다음 요청이 서버 B로 라우팅되더라도, 서버 B는 Redis에서 동일한 세션을 꺼내 인증 상태를 유지한다.
Spring Session은 기존 HttpSession 코드를 전혀 바꾸지 않고 저장소만 Redis로 교체해주는 추상화 계층이다.
@Configuration
@EnableRedisHttpSession
public class SessionConfig {
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
return RedisSerializer.java();
}
}
@EnableRedisHttpSession 하나로 Spring에게 "이제부터 HttpSession은 Redis로 관리해"라고 선언한다. 개발자는 기존처럼 HttpSession을 쓰면 된다.
Spring Security가 인증을 처리하려면 사용자 정보를 어떤 형태로 받을지 알아야 한다. 이를 위해 두 가지를 구현한다.
// 사용자 정보를 담는 객체
public class CustomUserDetails implements UserDetails, Serializable {
private final Long userId;
private final String email;
private final String password;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"));
}
// ...
}
// DB에서 사용자를 조회해서 CustomUserDetails로 변환
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String email) {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
return CustomUserDetails.from(user);
}
}
CustomUserDetails: Spring Security가 인식하는 형태로 사용자 정보를 담는 객체CustomUserDetailsService: 이메일로 DB에서 사용자를 조회해서 CustomUserDetails로 변환Serializable을 구현하는 이유는 Redis에 저장할 때 직렬화가 필요하기 때문이다.
@Transactional
public LoginResponse login(LoginRequest loginRequest,
HttpServletRequest request, HttpServletResponse response) {
// 1. 인증 토큰 생성 및 검증
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
loginRequest.getEmail(),
loginRequest.getPassword()
)
);
// 2. SecurityContext에 인증 정보 저장 (스레드 로컬)
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
// 3. HTTP 세션에 저장 → Redis에 영속화
securityContextRepository.saveContext(context, request, response);
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
return LoginResponse.builder()
.userId(userDetails.getUserId())
.email(userDetails.getEmail())
.build();
}
1단계: 인증 검증
authenticationManager.authenticate()가 호출되면 내부적으로 이런 일이 일어난다.
UsernamePasswordAuthenticationToken 생성 (미인증 상태)
→ DaoAuthenticationProvider
→ CustomUserDetailsService.loadUserByUsername() → DB 조회
→ BCryptPasswordEncoder로 비밀번호 검증
→ 성공 시 인증된 Authentication 객체 반환
비밀번호 검증에 BCrypt를 사용하는 이유가 있다. 같은 비밀번호라도 매번 다른 해시값을 생성하기 때문에 단순 해시 비교가 아닌 BCrypt 알고리즘으로 검증한다.
2단계: SecurityContextHolder (스레드 로컬)
SecurityContextHolder는 현재 요청을 처리하는 스레드에 인증 정보를 저장한다. HTTP 요청 하나가 들어오면 스레드 하나가 할당되는데, 이 스레드가 살아있는 동안 Controller, Service, Repository 어디서든 별도 파라미터 없이 인증 정보에 접근할 수 있다.
// 어디서든 현재 인증 정보 접근 가능
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
3단계: HTTP 세션에 저장
스레드 로컬은 요청 처리가 끝나면 사라진다. 다음 요청에서도 인증 상태를 유지하려면 세션에 저장해야 한다. securityContextRepository.saveContext()가 SecurityContext를 HTTP 세션에 저장하고, Spring Session이 이를 Redis에 영속화한다.
다음 요청 시 흐름:
Redis에서 세션 조회 → SecurityContext 복원 → SecurityContextHolder에 설정 → 인증된 상태로 처리
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.securityContext(context -> context
.securityContextRepository(securityContextRepository())
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(1)
.maxSessionsPreventsLogin(false)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers(SECURITY_EXCLUDE_PATHS).permitAll()
.anyRequest().authenticated()
)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
// 커스텀 JSON 에러 응답
})
);
return http.build();
}
주요 설정의 이유를 정리하면 다음과 같다.
| 설정 | 이유 |
|---|---|
csrf().disable() | REST API는 토큰 기반 인증을 사용하므로 불필요 |
formLogin().disable() | REST API 환경에서 폼 로그인 화면 불필요 |
httpBasic().disable() | 커스텀 로그인 API를 사용하므로 불필요 |
maximumSessions(1) | 동일 사용자 동시 세션 1개 제한 |
maxSessionsPreventsLogin(false) | 새 로그인 시 기존 세션 만료 (차단 아님) |
authenticationEntryPoint | 인증 실패 시 커스텀 JSON 응답 반환 |
@GetMapping("/logout")
public ApiResponse<Void> logout(HttpServletRequest request) {
SecurityContextHolder.clearContext(); // 스레드 로컬 제거
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate(); // 세션 무효화 → Redis에서도 삭제
}
return ApiResponse.ok();
}
두 작업이 모두 필요한 이유가 있다.
clearContext()만 하면 → 스레드 로컬은 지워지지만 Redis 세션이 살아있어 다음 요청에서 복원된다session.invalidate()만 하면 → Redis 세션은 지워지지만 현재 요청에서는 스레드 로컬에 인증 정보가 남아있다둘 다 해야 완전한 로그아웃이 된다.
Redis 영속성은 Redis 자체가 꺼졌을 때 데이터를 잃지 않기 위한 전략이고, Spring Session + Redis는 다중 서버 환경에서 인증 세션을 공유하기 위한 전략이다. 두 개념은 모두 "Redis를 더 안정적으로 운영하기 위한" 관점에서 연결된다.