feat: 세션 DB 이중 저장 — Redis 유실/재시작에도 로그인 유지

- auth_session 테이블에 세션 영속 백업(login 시 Redis+DB 동시 저장)
- getSession: Redis 미스/장애 시 DB에서 복원 후 Redis 재수화(슬라이딩)
- logout: Redis+DB 동시 삭제, 만료분 일별 정리(@Scheduled)
- Redis 재시작으로 세션 전멸하던 주기적 로그아웃 해결

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-06 08:12:57 +09:00
co-authored by Claude Opus 4.8
parent c05be0880c
commit 5e2bdae37d
6 changed files with 185 additions and 8 deletions
@@ -1,5 +1,6 @@
package com.sb.web.auth.service;
import com.sb.web.auth.domain.AuthSession;
import com.sb.web.auth.domain.Member;
import com.sb.web.auth.dto.LoginRequest;
import com.sb.web.auth.dto.LoginResponse;
@@ -35,6 +36,7 @@ public class AuthService {
private final PasswordEncoder passwordEncoder;
private final RedisTemplate<String, Object> redisTemplate;
private final com.sb.web.admin.service.AppSettingService appSettingService;
private final com.sb.web.auth.mapper.AuthSessionMapper authSessionMapper;
private static final String SESSION_PREFIX = "session:";
private static final Duration SESSION_TTL = Duration.ofMinutes(60); // 일반 세션
@@ -106,7 +108,14 @@ public class AuthService {
session.setRememberMe(req.isRememberMe());
String token = UUID.randomUUID().toString().replace("-", "");
redisTemplate.opsForValue().set(SESSION_PREFIX + token, session, ttl);
java.time.LocalDateTime expiresAt = java.time.LocalDateTime.now().plus(ttl);
try {
redisTemplate.opsForValue().set(SESSION_PREFIX + token, session, ttl);
} catch (Exception e) {
log.warn("[login] Redis 세션 저장 실패(무시, DB 백업 사용): {}", e.toString());
}
// 영속 백업 — Redis 재시작/유실에도 로그인 유지
authSessionMapper.insert(com.sb.web.auth.domain.AuthSession.of(token, session, expiresAt));
log.info("[login] {} (token issued, rememberMe={})", member.getLoginId(), req.isRememberMe());
return LoginResponse.builder()
@@ -118,24 +127,62 @@ public class AuthService {
/**
* 토큰으로 세션을 조회하고, 유효하면 TTL 을 갱신(슬라이딩 만료)한다.
* Redis 에 없으면(재시작/유실/장애) DB 백업(auth_session)에서 복원하고 Redis 를 재수화한다.
*/
public SessionUser getSession(String token) {
if (token == null || token.isBlank()) {
return null;
}
String key = SESSION_PREFIX + token;
Object cached = redisTemplate.opsForValue().get(key);
if (cached instanceof SessionUser user) {
// 슬라이딩 만료: 자동 로그인 세션이면 길게(30일), 아니면 60분으로 갱신
redisTemplate.expire(key, user.isRememberMe() ? REMEMBER_TTL : SESSION_TTL);
return user;
// 1) Redis 우선 (장애 시 예외는 무시하고 DB 백업으로)
try {
Object cached = redisTemplate.opsForValue().get(key);
if (cached instanceof SessionUser user) {
redisTemplate.expire(key, user.isRememberMe() ? REMEMBER_TTL : SESSION_TTL);
return user;
}
} catch (Exception e) {
log.warn("[session] Redis 조회 실패 → DB 백업 사용: {}", e.toString());
}
return null;
// 2) DB 백업에서 복원
AuthSession db = authSessionMapper.findByToken(token);
if (db == null) {
return null;
}
if (db.getExpiresAt() == null || db.getExpiresAt().isBefore(java.time.LocalDateTime.now())) {
authSessionMapper.delete(token); // 만료분 정리
return null;
}
SessionUser user = db.toSessionUser();
Duration ttl = user.isRememberMe() ? REMEMBER_TTL : SESSION_TTL;
try {
redisTemplate.opsForValue().set(key, user, ttl); // Redis 재수화
} catch (Exception ignore) {
// Redis 가 아직 불가해도 DB 로 동작
}
authSessionMapper.updateExpiry(token, java.time.LocalDateTime.now().plus(ttl)); // DB 슬라이딩
return user;
}
public void logout(String token) {
if (token != null && !token.isBlank()) {
redisTemplate.delete(SESSION_PREFIX + token);
try {
redisTemplate.delete(SESSION_PREFIX + token);
} catch (Exception ignore) {
// Redis 장애여도 DB 삭제는 진행
}
authSessionMapper.delete(token);
}
}
/** 만료된 백업 세션 정리 (매일 새벽 4시) */
@org.springframework.scheduling.annotation.Scheduled(cron = "0 0 4 * * *")
public void cleanupExpiredSessions() {
try {
int n = authSessionMapper.deleteExpired(java.time.LocalDateTime.now());
if (n > 0) log.info("[session] 만료 백업 세션 {}건 정리", n);
} catch (Exception e) {
log.warn("[session] 만료 세션 정리 실패: {}", e.toString());
}
}