개요: 사용자 강제 로그아웃 구현 전략
사용자 강제 로그아웃 기능의 핵심은 현재 사용자가 보유한 로그인 자격 증명(credential)의 유효성을 즉시 상실시키고, 클라이언트가 이를 인지하여 능동적으로 로그아웃 처리를 하도록 유도하는 것입니다. 이 과정을 위한 기본 원칙은 다음과 같습니다.
- 사용자의 활성 로그인 토큰 또는 세션 목록을 서버에서 체계적으로 관리합니다.
- 강제 로그아웃 요청이 발생하면, 대상 사용자의 모든 자격 증명을 무효 상태로 전환합니다.
- 클라이언트가 서버에 보내는 모든 요청마다 해당 자격 증명의 유효성을 검증하고, 무효하다고 판단될 경우 강제로 세션을 종료시킵니다.
- (선택 사항) 사용자 경험을 최적화하기 위해, WebSocket과 같은 실시간 통신 방식을 활용하여 클라이언트에 즉시 로그아웃 알림을 전송할 수 있습니다.
만약 토큰 기반 인증(예: JWT)을 사용한다면, 서버에서 무효화된 토큰들을 관리하는 블랙리스트를 운영합니다. 세션 기반 인증의 경우, 해당 사용자의 세션 객체를 직접 찾아 무효화하는 방식으로 처리합니다.
구현 상세: JWT 및 Redis 활용 예시 (Java Spring Boot)
1. 기술 스택 선택
본 구현 예시에서는 아래 기술들을 조합하여 강제 로그아웃 시스템을 구축합니다.
- JWT (JSON Web Token): 사용자 ID, 만료 시간 등의 정보를 포함하는 무상태(stateless) 인증 토큰을 생성합니다.
- Redis: 무효화된 토큰들을 블랙리스트 형태로 저장하거나, 사용자별 활성 토큰 목록을 관리하여 토큰 유효성 검증 속도를 향상시킵니다.
- WebSocket (선택 사항): 클라이언트에게 즉각적인 로그아웃 알림을 푸시하여, 사용자가 다음 요청 시까지 기다리지 않고 실시간으로 로그아웃 상태를 인지하도록 돕습니다.
2. 전체 코드 구현 (Java Spring Boot 기준)
(1) Maven 의존성 (pom.xml)
주요 기능을 위한 라이브러리 의존성을 추가합니다.
<dependencies>
<!-- JWT 라이브러리 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<!-- Redis 연동 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- WebSocket (선택 사항) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- Spring Web (Controller, Interceptor 등) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
(2) JWT 인증 토큰 관리 유틸리티 클래스
토큰 생성, 유효성 검증, 사용자 ID 추출, 그리고 강제 로그아웃 로직을 담당합니다.
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.SignatureException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.Date;
import java.util.Set;
import java.util.concurrent.TimeUnit;
@Component
public class AuthTokenManager {
@Value("${app.jwt.secret:default-secret-key-please-change-in-prod}")
private String secretKey;
@Value("${app.jwt.expire-ms:7200000}") // 기본 2시간 (밀리초)
private long tokenValidityInMilliseconds;
@Resource
private StringRedisTemplate redisTemplate;
// JWT 파서 인스턴스 (한 번만 생성하여 재사용)
private final JwtParser jwtParser;
public AuthTokenManager(@Value("${app.jwt.secret:default-secret-key-please-change-in-prod}") String secretKey) {
this.secretKey = secretKey;
this.jwtParser = Jwts.parser().setSigningKey(secretKey);
}
/**
* 사용자 ID를 기반으로 새로운 JWT 인증 토큰을 생성합니다.
* 생성된 토큰은 Redis에 해당 사용자의 활성 토큰 목록에 추가됩니다.
* @param userId 토큰을 발급받을 사용자 ID
* @return 생성된 JWT 문자열
*/
public String createAuthToken(Long userId) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + tokenValidityInMilliseconds);
String token = Jwts.builder()
.setSubject(userId.toString()) // 토큰의 주체(subject)로 사용자 ID 설정
.setIssuedAt(now) // 토큰 발행 시간
.setExpiration(expiryDate) // 토큰 만료 시간
.signWith(SignatureAlgorithm.HS256, secretKey) // 서명 알고리즘 및 비밀 키
.compact();
// Redis에 사용자별 활성 토큰 목록 저장 (SET 타입)
String userActiveTokensKey = "auth:user:active_tokens:" + userId;
redisTemplate.opsForSet().add(userActiveTokensKey, token);
// 사용자 활성 토큰 목록 키에 만료 시간을 설정 (JWT 만료 시간과 동일)
// 이 키는 해당 사용자의 모든 활성 토큰을 추적하며, 마지막 토큰이 만료되면 이 키도 자동으로 만료됩니다.
redisTemplate.expire(userActiveTokensKey, tokenValidityInMilliseconds, TimeUnit.MILLISECONDS);
return token;
}
/**
* 주어진 JWT 토큰의 유효성을 검사합니다.
* - JWT 서명 및 만료 시간 확인
* - 토큰이 블랙리스트에 있는지 확인
* @param token 검사할 JWT 문자열
* @return 토큰이 유효하면 true, 그렇지 않으면 false
*/
public boolean isValidToken(String token) {
try {
// 1. JWT 서명 및 만료 시간 검사
jwtParser.parseClaimsJws(token).getBody();
// 2. 토큰이 블랙리스트에 있는지 확인
String blacklistTokenKey = "auth:blacklist:jwt:" + token; // 각 블랙리스트 토큰을 개별 Redis 키로 관리
return !redisTemplate.hasKey(blacklistTokenKey); // 블랙리스트에 없어야 유효함
} catch (SignatureException | MalformedJwtException | ExpiredJwtException | UnsupportedJwtException | IllegalArgumentException e) {
// JWT 관련 예외 발생 시 토큰 무효 처리 (예: 잘못된 서명, 만료된 토큰, 잘못된 형식 등)
return false;
}
}
/**
* JWT 토큰에서 사용자 ID를 추출합니다.
* @param token 사용자 ID를 추출할 JWT 문자열
* @return 추출된 사용자 ID
* @throws JwtException 유효하지 않은 토큰일 경우
*/
public Long extractUserId(String token) {
Claims claims = jwtParser.parseClaimsJws(token).getBody();
return Long.parseLong(claims.getSubject());
}
/**
* 특정 사용자의 모든 활성 세션을 무효화하여 강제로 로그아웃 처리합니다.
* 해당 사용자의 모든 활성 토큰을 Redis 블랙리스트에 추가합니다.
* @param userId 강제 로그아웃 시킬 사용자 ID
*/
public void invalidateUserSessions(Long userId) {
String userActiveTokensKey = "auth:user:active_tokens:" + userId;
// 1. Redis에서 해당 사용자의 모든 활성 토큰을 가져옵니다.
Set<String> activeTokens = redisTemplate.opsForSet().members(userActiveTokensKey);
if (activeTokens == null || activeTokens.isEmpty()) {
return; // 활성 토큰이 없으면 종료
}
// 2. 각 활성 토큰을 블랙리스트에 추가하고, 남은 유효 기간 동안 Redis에 저장합니다.
Date now = new Date();
for (String token : activeTokens) {
try {
Claims claims = jwtParser.parseClaimsJws(token).getBody();
long remainingValidity = claims.getExpiration().getTime() - now.getTime(); // 남은 유효 시간 계산
if (remainingValidity > 0) {
String blacklistTokenKey = "auth:blacklist:jwt:" + token;
// 각 블랙리스트 토큰을 고유한 키로 Redis에 저장하고, 남은 유효 시간 동안 유지되도록 설정
redisTemplate.opsForValue().set(blacklistTokenKey, "invalidated", remainingValidity, TimeUnit.MILLISECONDS);
}
} catch (Exception e) {
// 이미 만료되었거나 잘못된 형식의 토큰은 무시하고 다음 토큰 처리
System.err.println("블랙리스트 처리 중 토큰 오류 발생: " + e.getMessage());
}
}
// 3. Redis에서 해당 사용자의 활성 토큰 목록 자체를 삭제합니다.
redisTemplate.delete(userActiveTokensKey);
}
}
(3) 인증 인터셉터 (Interceptor)
모든 인증이 필요한 요청에 대해 JWT 토큰의 유효성을 검사합니다.
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class JwtAuthInterceptor implements HandlerInterceptor, WebMvcConfigurer {
@Resource
private AuthTokenManager authTokenManager;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
// 1. 요청 헤더에서 Authorization 값을 추출 (예: "Bearer <token>")
String authorizationHeader = request.getHeader("Authorization");
if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
sendUnauthorizedResponse(response, "인증 토큰이 누락되었거나 형식이 올바르지 않습니다.");
return false;
}
String jwtToken = authorizationHeader.substring(7); // "Bearer " 부분 제거
// 2. JWT 토큰의 유효성 검사
if (!authTokenManager.isValidToken(jwtToken)) {
sendUnauthorizedResponse(response, "세션이 만료되었거나 강제 종료되었습니다. 다시 로그인해주세요.");
return false;
}
// 3. 토큰이 유효할 경우, 사용자 ID를 요청 속성에 저장하여 컨트롤러에서 활용
Long userId = authTokenManager.extractUserId(jwtToken);
request.setAttribute("authenticatedUserId", userId);
return true; // 요청 계속 진행
}
private void sendUnauthorizedResponse(HttpServletResponse response, String message) throws IOException {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"message\": \"" + message + "\"}");
}
// WebMvcConfigurer 인터페이스를 구현하여 인터셉터 등록
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(this).addPathPatterns("/api/**") // /api/ 경로로 시작하는 모든 요청에 인터셉터 적용
.excludePathPatterns("/api/auth/**", "/api/public/**"); // 인증이 필요 없는 경로 제외
}
}
(4) 관리자 컨트롤러 (강제 로그아웃 API)
관리자가 특정 사용자를 강제 로그아웃 시킬 수 있는 REST API 엔드포인트를 제공합니다.
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
@RestController
@RequestMapping("/api/admin")
public class AdminAuthController {
@Resource
private AuthTokenManager authTokenManager;
// @Resource
// private WebSocketNotificationService webSocketNotificationService; // WebSocket 서비스 (선택 사항)
/**
* 특정 사용자를 강제로 로그아웃 시키는 관리자 API.
* (실제 운영 환경에서는 관리자 권한 검증 로직이 추가되어야 합니다.)
* @param targetUserId 강제 로그아웃 시킬 사용자 ID
* @return 처리 결과 응답
*/
@PostMapping("/force-logout")
public ResponseEntity<String> forceLogoutUser(@RequestParam Long targetUserId) {
// 실제 구현에서는 이 API를 호출하는 사용자가 관리자 권한을 가졌는지 검증하는 로직이 필수입니다.
// 예를 들어, request.getAttribute("authenticatedUserId")를 통해 현재 로그인한 관리자 ID를 확인하고 권한 검사.
authTokenManager.invalidateUserSessions(targetUserId);
// 선택 사항: WebSocket을 통해 사용자에게 즉시 로그아웃 알림 전송
// webSocketNotificationService.sendNotificationToUser(targetUserId, "관리자에 의해 계정이 강제 로그아웃되었습니다.");
return ResponseEntity.ok("사용자 " + targetUserId + "의 세션이 성공적으로 무효화되었습니다.");
}
}
(5) 클라이언트 측 처리 (프런트엔드 Vue.js 예시)
클라이언트 애플리케이션에서는 서버 응답을 통해 강제 로그아웃 상황을 감지하고 적절히 처리해야 합니다.
// Axios HTTP 요청 인터셉터 (응답 처리)
axios.interceptors.response.use(
(response) => response,
(error) => {
// 401 Unauthorized 응답을 포착하여 강제 로그아웃 상황 처리
if (error.response && error.response.status === 401) {
const errorMessage = error.response.data.message || '인증 오류가 발생했습니다.';
// 서버에서 전달하는 특정 메시지를 통해 강제 로그아웃 여부 판단
if (errorMessage.includes("강제 종료되었습니다")) {
// 1. 로컬 저장소(localStorage 또는 sessionStorage)에 저장된 토큰 제거
localStorage.removeItem("authToken"); // 또는 sessionStorage.removeItem
// 2. 사용자에게 알림 메시지 표시
alert("관리자에 의해 계정이 강제 로그아웃되었습니다. 다시 로그인해주세요.");
// 3. 로그인 페이지로 리다이렉트
router.push("/login"); // Vue Router를 사용한 페이지 이동
} else {
// 일반적인 인증 오류 처리
alert(`인증 실패: ${errorMessage}`);
}
}
return Promise.reject(error);
}
);
// 선택 사항: WebSocket을 통한 실시간 로그아웃 알림 수신
// const setupWebSocket = (userId) => {
// const socket = new WebSocket(`ws://localhost:8080/ws/${userId}`); // WebSocket 엔드포인트
//
// socket.onmessage = (event) => {
// const data = JSON.parse(event.data);
// if (data.type === "OFFLINE_NOTIFICATION") {
// alert(data.message);
// localStorage.removeItem("authToken");
// router.push("/login");
// }
// };
//
// socket.onclose = () => {
// console.log("WebSocket 연결 종료");
// };
//
// socket.onerror = (error) => {
// console.error("WebSocket 오류:", error);
// };
// };
//
// // 사용자 로그인 후 WebSocket 연결 설정
// if (userIsLoggedIn) {
// setupWebSocket(currentUserId);
// }
세션 기반 인증 시스템에서의 강제 로그아웃 구현
만약 프로젝트가 JWT 대신 세션 기반 인증을 사용한다면, 구현 방식은 유사하지만 자격 증명 관리 방식에서 차이가 있습니다.
- 세션 매핑 관리: 서버는 `사용자 ID -> 세션 ID(들)`의 매핑 정보를 Redis와 같은 캐시에 저장하여 관리합니다. 한 사용자가 여러 기기에서 로그인할 경우 여러 세션 ID를 가질 수 있습니다.
- 세션 무효화: 강제 로그아웃 요청 시, 대상 사용자 ID에 연결된 모든 세션 ID를 Redis에서 조회합니다. 그런 다음, Spring의 `SessionRegistry` 등을 통해 해당 세션들을 찾아 `session.invalidate()` 메서드를 호출하여 세션을 무효화합니다.
- 클라이언트 유효성 검사: 클라이언트의 모든 요청마다 서버는 HTTP 세션의 유효성을 검사합니다. 세션이 더 이상 유효하지 않으면, 클라이언트에게 로그아웃을 요청하고 로그인 페이지로 리다이렉션합니다.
핵심 요약 및 고려 사항
사용자 강제 로그아웃 기능 구현의 핵심은 다음과 같습니다.
- 자격 증명 무효화: Redis 블랙리스트 등을 활용하여 토큰 또는 세션을 즉시 무효화하는 것이 기능의 핵심입니다.
- 요청 인터셉터 검증: 모든 인증이 필요한 요청에 대해 자격 증명의 유효성을 엄격하게 검사하여, 무효화된 자격 증명으로는 서비스 접근이 불가능하도록 해야 합니다.
- 사용자 경험 개선: WebSocket과 같은 실시간 통신을 통해 클라이언트에게 즉시 로그아웃을 통지함으로써, 사용자가 불편함 없이 상황을 인지하도록 돕습니다.
추가적으로, 운영 환경에서 고려해야 할 사항들입니다.
- JWT 비밀 키는 애플리케이션 코드에 직접 포함하지 않고, 환경 변수나 외부 설정 파일을 통해 안전하게 관리해야 합니다.
- Redis에 저장되는 블랙리스트 토큰들은 불필요한 데이터 축적을 방지하기 위해, JWT의 원래 만료 시간을 기준으로 TTL(Time-To-Live)을 설정하여 자동으로 삭제되도록 해야 합니다.
- 강제 로그아웃을 수행하는 API는 반드시 강력한 권한 검증 절차를 포함해야 하며, 오용을 방지하기 위해 관리자만 접근할 수 있도록 제한해야 합니다.