Notice
Recent Posts
Recent Comments
Link
«   2024/10   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30 31
Tags more
Archives
Today
Total
관리 메뉴

밤빵's 개발일지

stateless 특성과 JWT의 관계 본문

단체개발일지

stateless 특성과 JWT의 관계

최밤빵 2024. 8. 19. 10:40

팀 프로젝트 발표가 끝나고 받은 피드백의 키워드는 stateless  JWT!

대체 이 개념을 이해하는게 얼마나 웹 애플리케이션 설계에 얼마나 중요한건가 싶어서 다시 정리를 해보았다. 

▶ stateless 란? 

웹 애플리케이션에서 서버가 클라이언트의 이전 요청에대한 정보를 전혀 기억하지 않는 것을 의미한다. 

서버는 각 요청을 독립적으로 처리하고, 이전 요청과의 연관성을 전혀 가지지 않는다. 

예를 들어 사용자가 웹 사이트에서 로그인했다고 해서 서버가 그 사용자의 로그인 상태를 기억하는 것이 아니라, 매 요청마다 그 사용자가 누구인지 다시 확인해야한다. 이렇게 하면 서버가 더 간단해지고 여러 서버에서 요청을 분산 처리 하기도 쉽다. 이를 수평적 확장성이라고도 한다. 

 

▶ JWT란? 

JWT는 이러한 stateless 특성을 유지하면서도 사용자 인증을 가능하게 해주는 방법이다. JWT는 사용자가 로그인 할때 서버가 발급해주는 토큰으로 이 토큰에는 사용자의 정보( 사용자 ID, 권한 등)가 포함되어 있으며, 중요한 부분은 토큰이 서명되어 있다는 것이고, 이 서명을 통해 서버는 토큰이 변조되지 않았음을 확인할 수 있다 .

 

▶ stateless와 JWT의 관계 

JWT가 stateless는 서버가 상태를 기억할 필요가 없게 만들어 준다는 점에서 연관 관계가 있다. 

 

기존의 상태 저장 방식에서는 서버가 사용자 세션을 관리해야 했는데, 사용자가 로그인 하면 서버는 그 사용자의 세션을 메모리에 저장하고, 이후 요청이 올 때마다 그 세션을 참조해 사용자를 확인하는 방식이였다. 하지만 이 방식은 서버에 부담이 될 수 있고, 여러 서버에서 세션을 공유해야 하는경우 관리가 복잡해진다. 

 

반면, JWT를 사용하면 서버는 사용자의 상태를 저장할 필요가 없다. 사용자가 로그인할 때 발급된 JWT를 클라이언트가 보관하고, 이후 요청 시마다 이 토큰을 서버에 보내면 된다. 서버는 이 토큰을 검증하여 사용자를 확인하고, 별도의 세션 저장 없이 요청을 처리할 수 있다. 따라서 서버는 완전히 stateless로 동작할 수 있게 된다. 

 

▶ JWT 저장위치? 쿠키와 세션에 대한 고민

JWT를 쿠키에 저장하는 것은 보안상 좋은 선택이 될 수 있다. 쿠키는 브라우저가 자동으로 서버에 전송해 주기 때문에, 개발자가 직접 토큰을 요청 헤더에 넣는 과정을 생략할 수 있다. 또 쿠키에 저장된 JWT는 HttpOnly속성을 설정해서 자바스크립트에서 접근하지 못하도록 하면 XSS(Cross-Site Scripting)공격을 방지할 수 있다. 

 

프론트엔드의 관점에서 JWT가 쿠키에 저장 될 경우, 프론트 엔드에서는 이 쿠키를 직접 처리 할 필요없이 자동으로 인증이 처리된다. 하지만 JWT를 로컬 스토리지에 저장하는 경우, 요청마다 헤더에 JWT를 수동으로 추가해야 하며, 보안상의 이유로 민감한 데이터를 로컬 스토리지에 저장하는 것은 피해야 한다.

프론트엔드 개발자는 JWT가 어디에 저장되는지에 따라 인증된 요청을 어떻게 처리할지를 명확히 이해해야한다. 또한 토큰이 만료된 경우 이를 처리하기 위한 로직( 예를 들어 자동 로그아웃 처리 또는 토큰 갱신 요청)을 구현해야 한다. 

 

하지만 세션에 JWT를 저장하는 것은 stateless의 장점을 잃게 만든다. 세션 방식에서는 서버가 클라이언트의 상태를 저장해야 하므로, 서버가 무상태가 아니게 되는데 이로 인해 서버의 복잡도가 증가하고, 수평적 확장이 어려워 질 수 있다. 

 

▶ stateless 특성을 따르는 이유 

stateless 특성을 따르는 것이 좋은 이유는 주로 확장성과 성능 때문이다. stateless로 서버를 설계하면 서버는 클라이언트의 상태를 기억할 필요가 없기 때문에, 트래픽이 증가하더라도 서버를 쉽게 확장할 수 있다. 또한 서버 간의 상태 동기화 문제가 없어서 관리가 더 간단해진다. 

 

프론트엔드 입장에서도 stateless의 이점은 크다. 예를 들어 프론트엔드가 요청을 보낼 때마다 서버가 동일하게 JWT를 확인하므로, 특정 서버에 연결되지 않고도 일관된 인증 결과를 얻을 수 잇다. 이는 사용자가 다른 서버에 연결 될  수 있는 상황(예를들어 로드밸런싱)에서도 안정적인 인증을 보장한다. 

 

따라서 stateless 특성을 유지하면서도 보안을 강화하기 위해 JWT를 쿠키에 저장하는 방법이 종종 사용된다. 이 방법은 서버가 상태를 저장하지 않으면서도, 보안적으로 안전한 JWT사용을 가능하게 한다. 

 

▶ 예시 코드 

▽ Security 설정 : 시큐리티 설정을 통해 JWT를 사용해여 인증을 처리하는 방법을 정의한다.

package com.example.security;

import com.example.filter.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
public class JwtSecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    public JwtSecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf().disable() // CSRF 보호 비활성화 (JWT는 무상태라 필요 없음)
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 무상태(stateless)로 설정
                .and()
                .authorizeRequests()
                .antMatchers("/api/auth/**").permitAll() // 인증 없이 접근 가능한 엔드포인트
                .anyRequest().authenticated(); // 그 외의 요청은 인증 필요

        http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); // JWT 필터 추가
        return http.build();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
        return authConfig.getAuthenticationManager();
    }
}

JwtSecurityConfig는 JWT 인증을 사용하도록 스프링 시큐리티를 설정하는 클래스로,SessionCreationPolicy.STATELESS로 설정하여 서버가 상태를 유지하지 않도록 했다.

 

▽ JWT 필터 설정 : JWT 필터를 통해 HTTP 요청이 들어올 때마다 JWT를 확인하고, 유효한 경우 Security Context에 사용자 정보를 설정한다.

package com.example.filter;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Date;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final UserDetailsService userDetailsService;
    private final String SECRET_KEY = "yourSecretKey"; // 실제 환경에서는 더 안전한 방법으로 키를 관리해야 함

    public JwtAuthenticationFilter(UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        String authorizationHeader = request.getHeader("Authorization");

        String token = null;
        String username = null;

        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            token = authorizationHeader.substring(7);
            username = extractUsername(token); // JWT에서 사용자 이름 추출
        }

        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);

            if (validateToken(token, userDetails)) {
                UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }

        chain.doFilter(request, response);
    }

    private String extractUsername(String token) {
        return Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody().getSubject();
    }

    private boolean validateToken(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
    }

    private boolean isTokenExpired(String token) {
        Claims claims = Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody();
        return claims.getExpiration().before(new Date());
    }
}

JwtAuthenticationFilter는 JWT를 통해 사용자를 인증하는 필터로, HTTP 요청 헤더에서 JWT를 추출하고 유효성을 검사하여 사용자를 인증한다.

 

▽ UserDetailsService 구현 : UserDetailsService를 구현하여 사용자 정보를 로드하고, UserDetails 객체를 반환한다.

package com.example.service;

import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.ArrayList;

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 실제 환경에서는 DB에서 사용자 정보를 조회해야 함
        if ("user".equals(username)) {
            return new User("user", "password", new ArrayList<>());
        } else {
            throw new UsernameNotFoundException("User not found with username: " + username);
        }
    }
}

CustomUserDetailsService는 UserDetailsService 인터페이스를 구현하여 사용자 정보를 로드하는 서비스이다. 실제 애플리케이션에서는 데이터베이스와 연동하여 사용자 정보를 가져와야 한다.

 

▶ 결론

정리하자면, stateless는 서버가 클라이언트의 이전 요청 상태를 기억하지 않는 것을 의미하고 JWT는 이러한 무상태 특성을 유지하면서도 사용자 인증을 가능하게 해주는 도구이다. 쿠키에 JWT를 저장함으로써 stateless의 장점을 유지하면서도 보안을 강화할 수 있다. 이를 통해 서버는 더 단순해지고 확장성이 높아지며, 클라이언트는 인증된 상태를 유지할 수 있다.