From 5e2bdae37d4a2e8bd1b8742ec5e0f5993f4305fe Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Sat, 6 Jun 2026 08:12:57 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=84=B8=EC=85=98=20DB=20=EC=9D=B4?= =?UTF-8?q?=EC=A4=91=20=EC=A0=80=EC=9E=A5=20=E2=80=94=20Redis=20=EC=9C=A0?= =?UTF-8?q?=EC=8B=A4/=EC=9E=AC=EC=8B=9C=EC=9E=91=EC=97=90=EB=8F=84=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth_session 테이블에 세션 영속 백업(login 시 Redis+DB 동시 저장) - getSession: Redis 미스/장애 시 DB에서 복원 후 Redis 재수화(슬라이딩) - logout: Redis+DB 동시 삭제, 만료분 일별 정리(@Scheduled) - Redis 재시작으로 세션 전멸하던 주기적 로그아웃 해결 Co-Authored-By: Claude Opus 4.8 --- src/main/java/com/sb/web/SbBtApplication.java | 2 + .../com/sb/web/auth/domain/AuthSession.java | 53 ++++++++++++++++ .../sb/web/auth/mapper/AuthSessionMapper.java | 24 +++++++ .../com/sb/web/auth/service/AuthService.java | 63 ++++++++++++++++--- src/main/resources/db/member.sql | 17 +++++ .../resources/mapper/AuthSessionMapper.xml | 34 ++++++++++ 6 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/sb/web/auth/domain/AuthSession.java create mode 100644 src/main/java/com/sb/web/auth/mapper/AuthSessionMapper.java create mode 100644 src/main/resources/mapper/AuthSessionMapper.xml diff --git a/src/main/java/com/sb/web/SbBtApplication.java b/src/main/java/com/sb/web/SbBtApplication.java index 25967c7..b93652d 100644 --- a/src/main/java/com/sb/web/SbBtApplication.java +++ b/src/main/java/com/sb/web/SbBtApplication.java @@ -3,8 +3,10 @@ package com.sb.web; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling @MapperScan({"com.sb.web.user.mapper", "com.sb.web.auth.mapper", "com.sb.web.board.mapper", "com.sb.web.account.mapper", "com.sb.web.admin.mapper"}) public class SbBtApplication { diff --git a/src/main/java/com/sb/web/auth/domain/AuthSession.java b/src/main/java/com/sb/web/auth/domain/AuthSession.java new file mode 100644 index 0000000..e3794cd --- /dev/null +++ b/src/main/java/com/sb/web/auth/domain/AuthSession.java @@ -0,0 +1,53 @@ +package com.sb.web.auth.domain; + +import com.sb.web.auth.dto.SessionUser; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 세션 영속 백업(auth_session). Redis 유실 시 여기서 SessionUser 를 복원한다. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AuthSession { + + private String token; + private Long memberId; + private String loginId; + private String name; + private String role; + private String provider; + private boolean rememberMe; + private LocalDateTime expiresAt; + private LocalDateTime createdAt; + + public static AuthSession of(String token, SessionUser u, LocalDateTime expiresAt) { + return AuthSession.builder() + .token(token) + .memberId(u.getId()) + .loginId(u.getLoginId()) + .name(u.getName()) + .role(u.getRole()) + .provider(u.getProvider()) + .rememberMe(u.isRememberMe()) + .expiresAt(expiresAt) + .build(); + } + + public SessionUser toSessionUser() { + return SessionUser.builder() + .id(memberId) + .loginId(loginId) + .name(name) + .role(role) + .provider(provider) + .rememberMe(rememberMe) + .build(); + } +} diff --git a/src/main/java/com/sb/web/auth/mapper/AuthSessionMapper.java b/src/main/java/com/sb/web/auth/mapper/AuthSessionMapper.java new file mode 100644 index 0000000..5449696 --- /dev/null +++ b/src/main/java/com/sb/web/auth/mapper/AuthSessionMapper.java @@ -0,0 +1,24 @@ +package com.sb.web.auth.mapper; + +import com.sb.web.auth.domain.AuthSession; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.time.LocalDateTime; + +/** + * 세션 영속 백업(auth_session) 접근. + */ +@Mapper +public interface AuthSessionMapper { + + int insert(AuthSession session); + + AuthSession findByToken(@Param("token") String token); + + int updateExpiry(@Param("token") String token, @Param("expiresAt") LocalDateTime expiresAt); + + int delete(@Param("token") String token); + + int deleteExpired(@Param("now") LocalDateTime now); +} diff --git a/src/main/java/com/sb/web/auth/service/AuthService.java b/src/main/java/com/sb/web/auth/service/AuthService.java index d4abc5e..1e789c6 100644 --- a/src/main/java/com/sb/web/auth/service/AuthService.java +++ b/src/main/java/com/sb/web/auth/service/AuthService.java @@ -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 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()); } } diff --git a/src/main/resources/db/member.sql b/src/main/resources/db/member.sql index a348423..6a32d76 100644 --- a/src/main/resources/db/member.sql +++ b/src/main/resources/db/member.sql @@ -30,3 +30,20 @@ CREATE TABLE IF NOT EXISTS app_setting ( PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; INSERT IGNORE INTO app_setting (id, signup_enabled) VALUES (1, 1); + +-- ============================================================ +-- 세션 백업 (Redis 유실/재시작 대비 영속 저장). getSession 미스 시 DB에서 복원. +-- ============================================================ +CREATE TABLE IF NOT EXISTS auth_session ( + token VARCHAR(64) NOT NULL COMMENT '세션 토큰', + member_id BIGINT NOT NULL, + login_id VARCHAR(50) NULL, + name VARCHAR(100) NULL, + role VARCHAR(20) NULL, + provider VARCHAR(20) NULL, + remember_me TINYINT(1) NOT NULL DEFAULT 0, + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (token), + KEY idx_auth_session_expires (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/main/resources/mapper/AuthSessionMapper.xml b/src/main/resources/mapper/AuthSessionMapper.xml new file mode 100644 index 0000000..449e7e8 --- /dev/null +++ b/src/main/resources/mapper/AuthSessionMapper.xml @@ -0,0 +1,34 @@ + + + + + + INSERT INTO auth_session + (token, member_id, login_id, name, role, provider, remember_me, expires_at, created_at) + VALUES + (#{token}, #{memberId}, #{loginId}, #{name}, #{role}, #{provider}, + #{rememberMe}, #{expiresAt}, NOW()) + ON DUPLICATE KEY UPDATE + expires_at = #{expiresAt} + + + + + + UPDATE auth_session SET expires_at = #{expiresAt} WHERE token = #{token} + + + + DELETE FROM auth_session WHERE token = #{token} + + + + DELETE FROM auth_session WHERE expires_at < #{now} + + +