본문 바로가기
Framework/Spring Boot

[Spring Boot] Spring Security 2편: 현대 API 인증의 표준, JWT(JSON Web Token) 완벽 구현

반응형

앞선 14단계에서 우리는 스프링 시큐리티(Spring Security)의 위대한 필터 체인(Filter Chain)과 전통적인 폼 로그인(Session/Cookie 방식)을 마스터했습니다. 세션 기반 로그인은 보안성이 높고 구현이 편하지만, 현대의 트렌드인 '모바일 앱(iOS/Android) 통신'과 서버 수십 대를 띄우는 '마이크로서비스(MSA) 스케일 아웃' 환경을 만나면 치명적인 약점을 드러냅니다. 사용자가 서버 A에 로그인하여 세션을 만들었는데, 다음 요청이 서버 B로 향하면 서버 B는 "너 누구야?" 라며 로그아웃 시켜버리기 때문입니다(세션 불일치). 이 골치 아픈 '상태 유지(Stateful)'의 사슬을 끊어내고, 서버가 사용자 정보를 기억할 필요 없이 "네가 가진 암호화된 방문증(Token)만 보여주면 통과시켜 줄게!"라는 완벽한 '무상태(Stateless)' 아키텍처로 백엔드 생태계를 평정한 기술이 바로 JWT(JSON Web Token)입니다.

이번 포스팅에서는 "React, Vue, App 앱 개발자가 백엔드에게 가장 먼저 요구하는 1순위!" Spring Security와 JWT를 결합한 궁극의 REST API 인증 구현에 대하여 다뤄보겠습니다.

🎫 1. JWT 토큰의 3단 구조 해부

JWT는 그냥 보기에는 xxxxx.yyyyy.zzzzz 처럼 의미 없는 긴 문자열처럼 보이지만, 점(.)을 기준으로 3개의 덩어리로 나뉘어 있는 매우 과학적인 구조입니다.

  • Header (헤더): 어떤 알고리즘(예: HS256)으로 암호화했는지 명시합니다.
  • Payload (페이로드): 가장 중요한 알맹이입니다. 유저의 ID, 이름, 권한(Role), 토큰 만료 시간(exp) 등 클라이언트와 서버가 주고받을 실제 정보(Claim)가 JSON 형태로 들어있습니다. 누구나 디코딩해서 볼 수 있으므로 절대 비밀번호 같은 민감 정보를 넣으면 안 됩니다.
  • Signature (서명): 위조 방지의 핵심입니다. 헤더와 페이로드를 합친 후 서버만 알고 있는 '비밀키(Secret Key)'를 섞어 해시(Hash) 암호화한 값입니다. 만약 해커가 페이로드의 유저 권한을 'USER'에서 'ADMIN'으로 조작해서 보내도, 서버가 가진 비밀키로 다시 서명해 본 결과와 다르면 "이 토큰 위조됐어!" 하고 가차 없이 쳐냅니다.

⚙️ 2. Security 3.x JWT 완벽 설정: 세션을 죽여라!

JWT를 쓰려면 스프링 시큐리티에게 "나 이제 세션 안 쓴다!"라고 명확히 선언하고, 우리가 직접 만든 'JWT 검증용 커스텀 필터(JwtAuthenticationFilter)'를 시큐리티 필터 체인 중간에 억지로 끼워 넣어야 합니다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    // 우리가 만든 JWT 검증 커스텀 필터를 주입받음
    private final JwtAuthenticationFilter jwtAuthFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable()) // REST API이므로 CSRF 끄기
            .formLogin(form -> form.disable()) // 🌟 폼 로그인 기능 완전히 죽이기! (JSON으로 로그인할 거니까)
            .httpBasic(basic -> basic.disable())
            
            // 🌟 1. 세션(Session)을 완전히 끄고 STATELESS 무상태로 전환하는 가장 핵심적인 코드!
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/login", "/api/auth/signup").permitAll() // 로그인, 회원가입은 아무나
                .anyRequest().authenticated()
            )
            
            // 🌟 2. 시큐리티의 기본 인증 필터(UsernamePasswordAuthenticationFilter)가 돌기 전에!
            // 우리가 만든 JWT 토큰 검사 필터를 먼저 실행해라! (토큰만 맞으면 프리패스)
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
            
        return http.build();
    }
}

🕵️ 3. 핵심 커스텀 필터: JwtAuthenticationFilter

클라이언트가 Authorization: Bearer eyJhbGci... 형태로 헤더에 토큰을 꽂아 보내면, 컨트롤러에 가기 전에 이 필터가 먼저 낚아채어 검사합니다.

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider; // 토큰 발급/검증 유틸리티

    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                    HttpServletResponse response, 
                                    FilterChain filterChain) throws ServletException, IOException {
        
        // 1. HTTP 헤더에서 "Authorization" 값을 쏙 빼옴
        String authHeader = request.getHeader("Authorization");
        
        // 2. 토큰이 존재하고, "Bearer "로 시작하는지 꼼꼼히 확인
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String jwt = authHeader.substring(7); // "Bearer " 글자 잘라내고 순수 토큰만 추출
            
            // 3. 서버의 비밀키(Secret Key)로 토큰이 안 썩었는지(유효성, 만료여부) 검사
            if (jwtTokenProvider.validateToken(jwt)) {
                // 4. 토큰이 완벽하다면, 토큰 안에 들어있던 유저 정보(Claims)를 꺼내서 스프링 시큐리티의 '인증 완료 객체(Authentication)'로 포장
                Authentication authentication = jwtTokenProvider.getAuthentication(jwt);
                
                // 5. 시큐리티 전용 임시 보관소(SecurityContext)에 인증 객체를 꽂아 넣어줌 -> "이 유저는 통과시켜!"
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }
        
        // 6. 다음 필터로(또는 컨트롤러로) 요청을 계속 진행시킴
        filterChain.doFilter(request, response);
    }
}

🎯 4. 마무리 및 다음 단계

지금까지 무거운 서버 세션을 걷어내고, 서버 확장(Scale-out)이 무한대로 가능해지며 React, 모바일 앱 환경과의 호환성을 극강으로 끌어올린 현대 백엔드 아키텍처의 꽃, Spring Security와 JWT(JSON Web Token)의 완벽한 융합에 대해 다루어 보았습니다. 이제 여러분의 서비스는 프론트엔드가 가장 선호하는 완벽한 보안 API 규격을 갖추었습니다.

하지만 문제가 하나 남았습니다. 유저 입장에서 매번 회원가입 폼에 이름, 전화번호, 이메일을 치고 새로운 비밀번호를 외우는 것은 귀찮음 그 자체입니다. "귀찮아서 회원가입 안 해!" 하고 이탈하는 유저를 붙잡기 위한 현대 서비스의 필수 덕목은 바로 '소셜 로그인(Social Login)'입니다. "카카오 1초 만에 시작하기", "Google 계정으로 로그인". 이어지는 16단계 포스팅에서는 "JWT와 결합하여 환상적인 인증 경험을 선사한다!" OAuth 2.0 프로토콜의 웅장한 원리 파악과 카카오, 네이버, 구글 소셜 로그인(Social Login) 실무 연동 완벽 가이드에 대해 아주 뼈 때리게 파헤쳐 보겠습니다!

반응형