Merge branch 'dev'
Deploy / deploy (push) Failing after 11m29s
CI / build (push) Failing after 13m15s

This commit is contained in:
ByungCheol
2026-06-06 08:13:03 +09:00
6 changed files with 185 additions and 8 deletions
@@ -3,8 +3,10 @@ package com.sb.web;
import org.mybatis.spring.annotation.MapperScan; import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @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"}) @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 { public class SbBtApplication {
@@ -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();
}
}
@@ -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);
}
@@ -1,5 +1,6 @@
package com.sb.web.auth.service; 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.domain.Member;
import com.sb.web.auth.dto.LoginRequest; import com.sb.web.auth.dto.LoginRequest;
import com.sb.web.auth.dto.LoginResponse; import com.sb.web.auth.dto.LoginResponse;
@@ -35,6 +36,7 @@ public class AuthService {
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final RedisTemplate<String, Object> redisTemplate; private final RedisTemplate<String, Object> redisTemplate;
private final com.sb.web.admin.service.AppSettingService appSettingService; 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 String SESSION_PREFIX = "session:";
private static final Duration SESSION_TTL = Duration.ofMinutes(60); // 일반 세션 private static final Duration SESSION_TTL = Duration.ofMinutes(60); // 일반 세션
@@ -106,7 +108,14 @@ public class AuthService {
session.setRememberMe(req.isRememberMe()); session.setRememberMe(req.isRememberMe());
String token = UUID.randomUUID().toString().replace("-", ""); 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()); log.info("[login] {} (token issued, rememberMe={})", member.getLoginId(), req.isRememberMe());
return LoginResponse.builder() return LoginResponse.builder()
@@ -118,24 +127,62 @@ public class AuthService {
/** /**
* 토큰으로 세션을 조회하고, 유효하면 TTL 을 갱신(슬라이딩 만료)한다. * 토큰으로 세션을 조회하고, 유효하면 TTL 을 갱신(슬라이딩 만료)한다.
* Redis 에 없으면(재시작/유실/장애) DB 백업(auth_session)에서 복원하고 Redis 를 재수화한다.
*/ */
public SessionUser getSession(String token) { public SessionUser getSession(String token) {
if (token == null || token.isBlank()) { if (token == null || token.isBlank()) {
return null; return null;
} }
String key = SESSION_PREFIX + token; String key = SESSION_PREFIX + token;
Object cached = redisTemplate.opsForValue().get(key); // 1) Redis 우선 (장애 시 예외는 무시하고 DB 백업으로)
if (cached instanceof SessionUser user) { try {
// 슬라이딩 만료: 자동 로그인 세션이면 길게(30일), 아니면 60분으로 갱신 Object cached = redisTemplate.opsForValue().get(key);
redisTemplate.expire(key, user.isRememberMe() ? REMEMBER_TTL : SESSION_TTL); if (cached instanceof SessionUser user) {
return 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) { public void logout(String token) {
if (token != null && !token.isBlank()) { 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());
} }
} }
+17
View File
@@ -30,3 +30,20 @@ CREATE TABLE IF NOT EXISTS app_setting (
PRIMARY KEY (id) PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO app_setting (id, signup_enabled) VALUES (1, 1); 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;
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.sb.web.auth.mapper.AuthSessionMapper">
<insert id="insert" parameterType="com.sb.web.auth.domain.AuthSession">
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}
</insert>
<select id="findByToken" resultType="com.sb.web.auth.domain.AuthSession">
SELECT token, member_id, login_id, name, role, provider, remember_me, expires_at, created_at
FROM auth_session
WHERE token = #{token}
</select>
<update id="updateExpiry">
UPDATE auth_session SET expires_at = #{expiresAt} WHERE token = #{token}
</update>
<delete id="delete">
DELETE FROM auth_session WHERE token = #{token}
</delete>
<delete id="deleteExpired">
DELETE FROM auth_session WHERE expires_at &lt; #{now}
</delete>
</mapper>