Spring Boot 3와 JWT, Redis를 활용한 백엔드 권한 인가 및 싱글 사인온(Single Sign-On) 구현

Spring Boot 3 + JWT + Redis를 이용한 백엔드 권한 인가 및 싱글 사인온(Single Sign-On) 심층 분석

서론

현대 웹 애플리케이션, 특히 분산 환경에서는 사용자 인증과 권한 관리 시스템이 매우 중요합니다. 전통적인 세션 기반 인증은 확장성 문제를 가지고 있으며, 순수 JWT 인증은 '강제 로그아웃'이나 '다중 로그인'과 같은 복잡한 비즈니스 요구사항을 처리하기 어렵습니다.

이러한 문제를 해결하기 위해, Spring Boot 3 기반의 Spring MVC 환경에서 JWT와 Redis를 결합하여 '하이브리드' 방식의 백엔드 권한 관리 및 싱글 사인온(SSO) 검증 메커니즘을 구축했습니다.

아키텍처 설계 방향

위 문제를 해결하기 위해 JWT + Redis 이중 검증 하이브리드 아키텍처를 채택했습니다.

이 아키텍처에서는 다음과 같은 역할을 수행합니다:

  • JWT: 사용자 기본 정보(예: userId, username)를 담고 무결성을 보장하는 서명을 제공합니다.
  • Redis: 유효한 토큰을 기록하는 상태 저장소로, 토큰의 생명 주기와 고유성을 관리하는 보조 저장소 역할을 합니다.
  • Spring MVC 인터셉터(Interceptor): 컨트롤러에 요청이 도달하기 전에 통합 검증 로직을 실행하는 전면 관문 역할을 합니다.

핵심 인터셉터 구현: AuthInterceptor

AuthInterceptor는 전체 권한 검증 시스템의 핵심 허브로, HandlerInterceptor 인터페이스를 구현합니다. 이 클래스는 정적인 토큰 파싱뿐만 아니라 Redis와의 동적 상태 비교를 담당합니다.

3.1 인터셉터 전체 코드

package com.example.common.interceptor;

import com.example.common.domain.ApiResponse;
import com.example.common.enums.AuthErrorCodes;
import com.example.common.utils.TokenProcessor;
import com.example.common.constant.RedisKeys;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

@Slf4j
@Component
@RequiredArgsConstructor
public class AuthInterceptor implements HandlerInterceptor {
    private final TokenProcessor tokenProcessor;
    private final StringRedisTemplate redisTemplate;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1. CORS 사전 요청(OPTIONS) 우선 통과
        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
            return true;
        }

        // 2. 토큰 추출 및 기본 검증
        String token = request.getHeader(tokenProcessor.getHeaderName());
        if (token == null || token.isEmpty()) {
            sendAuthError(response, AuthErrorCodes.UNAUTHORIZED);
            return false;
        }

        String tokenPrefix = tokenProcessor.getTokenPrefix();
        if (token.startsWith(tokenPrefix)) {
            token = token.substring(tokenPrefix.length()).trim();
        }

        try {
            // 3. 토큰 파싱
            var claims = tokenProcessor.parseToken(token);
            String loginId = claims.getSubject();

            // 4. Redis 이중 검증 (핵심 SSO 로직)
            String redisKey = RedisKeys.ACTIVE_TOKEN + loginId;
            String storedToken = redisTemplate.opsForValue().get(redisKey);

            if (storedToken == null) {
                sendAuthError(response, AuthErrorCodes.TOKEN_EXPIRED);
                return false;
            }

            if (!storedToken.equals(token)) {
                sendAuthError(response, AuthErrorCodes.TOKEN_REPLACED);
                return false;
            }

            // 5. 컨텍스트 전달
            request.setAttribute("tokenPayload", claims);
            request.setAttribute("loginId", loginId);
            request.setAttribute("userIdentifier", claims.getClaim("userId"));
            return true;
        } catch (Exception e) {
            log.error("토큰 검증 실패: {}", e.getMessage());
            sendAuthError(response, AuthErrorCodes.INVALID_TOKEN);
            return false;
        }
    }

    private void sendAuthError(HttpServletResponse response, AuthErrorCodes errorCode) throws Exception {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_OK);
        ApiResponse errorResponse = ApiResponse.error(errorCode);
        response.getWriter().write(errorResponse.toJson());
    }
}

3.2 핵심 로직 분석

1. CORS 사전 요청 우선 통과
프론트엔드와 백엔드가 분리된 아키텍처에서 브라우저는 복잡한 요청에 대해 OPTIONS 사전 요청을 보냅니다. 사전 요청은 사용자 정의 헤더(예: 토큰)를 포함하지 않으므로, 인터셉터는 이를 우선적으로 통과시켜야 합니다. 이를 통해 크로스 도메인 요청의 원활한 핸드셰이크를 보장합니다.

2. 토큰 추출 및 기본 검증
인터셉터는 요청 헤더에서 토큰을 가져와 비어있는지 확인합니다. 비어있다면 인증되지 않았다는 오류를 반환합니다. OAuth2와 같은 표준 프로토콜과의 호환성을 위해 토큰의 접두사(예: Bearer )를 유연하게 처리하여 실제 토큰 문자열을 추출합니다.

3. JWT 파싱 및 Redis 이중 검증 (핵심 SSO 로직)
TokenProcessor를 통해 loginId를 얻은 후, 프로그램은 Redis에서 해당 레코드를 조회합니다. 여기서 두 가지 차원의 차단 로직이 설계되었습니다:

  • 토큰 만료 차단: Redis에 해당 토큰이 없으면(값이 null), 사용자가 의도적으로 로그아웃했거나 토큰이 자연 만료되어 Redis에서 정리된 것으로 간주합니다. 이 경우 TOKEN_EXPIRED 오류를 반환합니다.
  • 동일 계정 다중 로그인 차단: Redis에 저장된 토큰과 현재 요청에 포함된 토큰이 일치하지 않으면, 해당 계정이 다른 기기나 브라우저에서 재로그인하여 Redis의 기존 값을 덮어쓴 것입니다. 이 경우 TOKEN_REPLACED 오류를 반환하여 현재 클라이언트를 강제로 로그아웃시킵니다.

지식 포인트: JWT(JSON Web Token)는 RFC 7519 표준에 따른 개방형 표준으로, 네트워크 애플리케이션 환경에서 안전하게 클레임을 전달하기 위해 사용됩니다. 본 구현에서는 Redis에 토큰을 저장하여 "토큰 무효화"와 "다중 기기 로그인"을 제어합니다. 사용자가 로그아웃하거나 다른 기기에서 로그인할 경우, Redis의 토큰이 삭제되거나 업데이트되어 기존 토큰이 무효화됩니다.

4. 컨텍스트 전달 메커니즘
검증이 성공하면, 인터셉터는 파싱된 tokenPayload, loginId, userIdentifierHttpServletRequest의 속성(Attribute)에 저장합니다. 이 설계는 매우 우아하며, 하위 ControllerService 계층에서 다시 토큰을 파싱할 필요가 없어 성능 오버헤드를 크게 줄입니다. request.getAttribute("userIdentifier")를 통해 바로 신원 정보를 얻을 수 있습니다.

5. 통일된 응답 규칙
sendAuthError 메서드는 인증 실패 시 HTTP 401 또는 403 상태 코드를 발생시키지 않고, HTTP 200(HttpServletResponse.SC_OK) 상태를 설정합니다. ApiResponse 객체를 JSON으로 변환하여 응답 스트림에 씁니다. 이는 비즈니스 로직의 AuthErrorCodes를 통해 구체적인 오류를 구분하는 방식입니다. 이 설계는 Axios와 같은 현대 프론트엔드 프레임워크의 전역 응답 인터셉터 처리 방식과 매우 잘 어울립니다.

지식 포인트: 이러한 통일된 오류 처리 방식은 HTTP 상태 코드의 혼란을 피하게 해줍니다. 프론트엔드는 errorCode 필드(예: 2001은 미인증, 2003은 권한 없음)를 기반으로 적절한 처리를 할 수 있습니다. 예를 들어, 2001이 반환되면 프론트엔드가 자동으로 로그인 페이지로 리디렉션하여 사용자 경험을 향상시킬 수 있습니다.

전역 웹 설정: WebSecurityConfig

강력한 인터셉터를 가졌다면, 이를 Spring 컨테이너에 등록하고 인터셉션 규칙을 구성해야 합니다. WebSecurityConfig 클래스는 WebMvcConfigurer 인터페이스를 구현하여 라우팅, 크로스 도메인 처리, 정적 리소스 매핑의 책임을 맡습니다.

3.3 WebConfig 전체 코드

package com.example.common.config;

import com.example.common.interceptor.AuthInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@RequiredArgsConstructor
public class WebSecurityConfig implements WebMvcConfigurer {
    private final AuthInterceptor authInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authInterceptor)
                .addPathPatterns("/api/admin/**")
                .excludePathPatterns(
                        "/api/admin/auth/login",
                        "/doc.html",
                        "/webjars/**",
                        "/v3/api-docs/**",
                        "/swagger-resources/**");
    }

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOriginPatterns("*")
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                .allowedHeaders("*")
                .allowCredentials(true)
                .maxAge(3600);
    }

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/uploads/**")
                .addResourceLocations("file:./uploads/");

        registry.addResourceHandler("/doc.html")
                .addResourceLocations("classpath:/META-INF/resources/");

        registry.addResourceHandler("/webjars/**")
                .addResourceLocations("classpath:/META-INF/resources/webjars/");
    }
}

3.4 핵심 설정 분석

1. 인터셉터 라우팅 분배
addInterceptors 메서드를 재정의하여 AuthInterceptor를 시스템에 등록합니다.

  • 정밀 타격: addPathPatterns("/api/admin/**")는 오직 관리자 인터페이스만 엄격한 JWT 검증을 거치도록 명시합니다.
  • 화이트리스트 우회: excludePathPatterns는 로그인 인터페이스를 제외하여, 로그인하지 않은 사용자가 토큰을 얻을 수 없는 무한 루프를 방지합니다. 또한, Swagger/Knife4j의 API 문서 및 정적 리소스 경로를 제외하여 개발 및 디버깅을 용이하게 합니다.

지식 포인트: Spring MVC의 인터셉터 메커니즘은 요청 처리 전후에 특정 로직을 실행할 수 있게 합니다. addPathPatterns로 검증이 필요한 경로 패턴을 지정하고, excludePathPatterns로 검증이 필요 없는 경로를 제외합니다. 이 방식은 백엔드 관리 API는 인증을 통과해야 하지만, 로그인 인터페이스와 같은 공개 인터페이스는 인증 없이 접근할 수 있도록 합니다.

2. 전역 크로스 도메인 지원 (CORS)
addCorsMappings 메서드를 통해 강력한 크로스 도메인 전략을 구성했습니다: 모든 출처, 모든 메서드, 모든 요청 헤더를 허용합니다. allowCredentials(true)를 통해 쿠키와 같은 자격 증명을 허용하며, maxAge(3600)를 설정하여 사전 요청을 1시간 동안 캐시하여 크로스 도메인 핸드셰이크 성능을 효과적으로 향상시킵니다.

지식 포인트: CORS(크로스 오리진 리소스 공유)는 브라우저의 보안 메커니즘으로, 악의적인 웹사이트가 스크립트를 통해 다른 도메인의 리소스에 접근하는 것을 방지합니다. Spring Boot는 CorsRegistry를 통해 크로스 도메인 전략을 구성하며, allowCredentials(true)는 인증이 필요한 API에서 중요합니다. maxAge는 사전 요청의 캐시 시간을 설정하여 브라우저가 사전 요청을 보내는 횟수를 줄여줍니다.

3. 정적 리소스 동적 분리
addResourceHandlers 메서드는 외부에 노출되는 URL /uploads/**를 서버 로컬 파일 디렉터리 file:./uploads/에 매핑합니다. 이는 블로그 시스템의 게시물 이미지, 사용자 프로필 사진 등 로컬에 업로드된 파일을 처리할 때 매우 유용하며, 기본적인 정적/동적 리소스 분리를 구현합니다. 동시에 API 인터페이스 문서 UI 리소스의 정상 로딩도 보장합니다.

지식 포인트: Spring Boot는 기본적으로 static, public 등 디렉터리의 파일을 정적 리소스로 처리합니다. addResourceHandlers를 통해 정적 리소스의 접근 경로를 사용자 정의할 수 있으며, file:./uploads//uploads/** 요청을 프로젝트 루트 디렉터리의 uploads 폴더에 매핑하여 파일 업로드 후의 접근을 용이하게 합니다.

태그: Spring Boot jwt Redis 인터셉터 SSO

5월 22일 00:10에 게시됨