Spring Security Oauth2

Spring Security & JWT Architecture

Spring Security와 JWT

 

1.  JWT

현대의 웹 서비스는 모바일 앱의 등장과 MSA(Microservices Architecture)로 인해 서버가 상태를 직접 관리하지 않는 Stateless(무상태) 아키텍처를 지

회원가입 흐름: POST /join 클라이언트가 회원가입 요청을 보냅니다. (/join, username/password 등)요청이 컨트롤러(JoinController) 로 들어오고, DTO(JoinDTO)로 값을 받습니다.컨트롤러가 서비스(JoinService) 를 호출합니다.서비스는 UserRepository.existsByUsername() 등으로 중복 체크 비밀번호를 BCryptPasswordEncoder.encode() 로 해시 처리 UserEntity를 만들어 UserRepository.save()로 DB에 저장합니다. 응답은 보통 “ok” 같은 단순 응답 또는 상태코드로 마무리됩니다. 즉, /join은 JWT랑 직접 상관 없고, DB에 안전하게 회원을 넣는 과정 2) 로그인 흐름: POST /login → “JWT 발급” 클라이언트가 /login 요청을 보냅니다. (username/password) 이 요청은 컨트롤러로 바로 가지 않고, 먼저 스프링 시큐리티 필터 체인을 탑니다. 기본 UsernamePasswordAuthenticationFilter를 끄고, 대신 만든 커스텀 LoginFilter가 로그인 요청을 가로챕니다. LoginFilter.attemptAuthentication()에서 요청에서 username/password를 꺼냅니다. UsernamePasswordAuthenticationToken(로그인 정보 담는 “바구니”)을 만듭니다. 그 토큰을 AuthenticationManager.authenticate(...) 로 넘깁니다. AuthenticationManager는 내부적으로 UserDetailsService(CustomUserDetailsService) 를 호출해서 UserRepository.findByUsername()로 DB에서 사용자 조회조회한 UserEntity를 기반으로 CustomUserDetails를 만들고 비밀번호는 내부적으로 encoder로 매칭(검증)합니다.검증 성공하면 successfulAuthentication()이 호출되고, 여기서 authentication.getPrincipal()에서 사용자 정보 꺼내고authentication.getAuthorities()에서 role 꺼내고 JWTUtil.createJwt(username, role, 만료시간)으로 JWT 생성 응답 헤더에 Authorization: Bearer <token> 형태로 실어서 클라이언트에 전달합니다. 정리하면 로그인은: LoginFilter → AuthenticationManager → UserDetailsService → DB검증 성공 → JWT 발급(응답 헤더로 전달) 흐름입니다. 3) 로그인 이후 흐름: “요청마다 JWT 검증” (JWTFilter) 로그인 성공 후에는 클라이언트가 매 요청마다 JWT를 붙여서 보냅니다. 클라이언트가 API 요청을 보낼 때마다 헤더에 Authorization: Bearer <JWT> 서버에서는 JWTFilter(OncePerRequestFilter) 가 필터 체인 앞쪽에서 먼저 실행됩니다. JWTFilter가 하는 일:Authorization 헤더 확인 Bearer 토큰이면 토큰 분리 JWTUtil로 서명/만료/클레임(username/role) 검증 문제가 없으면 Authentication 객체를 만들어 SecurityContextHolder.getContext().setAuthentication(authToken) 으로 등록 그 다음부터는 컨트롤러/서비스에서“이미 인증된 사용자”로 인식하고 처리할 수 있습니다. 예: SecurityContextHolder에서 username/role 꺼내기 가능 여기서 중요한 포인트는: JWT는 stateless라서 서버가 “세션을 저장”하진 않지만, 요청 처리 동안만 SecurityContext에 Authentication을 올려서 “인증된 사용자처럼” 다룰 수 있어요. 요청이 끝나면 그 컨텍스트는 사라집니다.

2. SecurityConfig: 커스텀 필터 체인의 설계

Spring Boot 3.x 환경에서는 SecurityFilterChain을 Bean으로 등록하여 보안을 설정합니다. JWT 방식을 채택했기 때문에 기존의 세션 기반 설정들을 과감히 비활성화했습니다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(auth -> auth.disable()) // Stateless 환경이므로 CSRF 보호 비활성화
            .formLogin(auth -> auth.disable()) // 커스텀 필터를 사용할 것이므로 기본 폼 로그인 비활성화
            .httpBasic(auth -> auth.disable()) // HTTP Basic 인증 비활성화
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); // 세션 생성 정책을 무상태로 설정
        
        return http.build();
    }
}

 JWT를 사용하는 REST API는 상태를 저장하지 않으므로 CSRF 공격으로부터 상대적으로 안전하며, 서버 자원을 효율적으로 사용하기 위해 세션을 생성하지 않도록 설정

2. 인증과 인가의 흐름: 필터의 역할 분담

로그인 성공 시 토큰을 발급하는 LoginFilter와, 매 요청마다 토큰을 검증하는 JWTFilter를 직접 구현하여 Spring Security 필터 체인에 등록

① JWTUtil: 최신 버전(0.12.x) 기반의 구현

보안 라이브러리의 버전 변화에 민감하게 대응했습니다. 기존 0.11.x 버전과 달리 0.12.3 버전에서는 verifyWith, parseSignedClaims 등을 사용하는 객체 중심의 검증 방식을 적용했습니다.

public String getUsername(String token) {
    return Jwts.parser().verifyWith(secretKey)
            .build().parseSignedClaims(token)
            .getPayload().get("username", String.class);
}

② JWTFilter: 일시적 세션 생성

무상태 환경에서도 요청이 처리되는 동안에는 SecurityContextHolder를 통해 유저 정보를 참조해야 합니다. OncePerRequestFilter를 상속받아 요청마다 1회 실행을 보장하고, 검증 성공 시 일시적인 세션을 생성하여 유저의 Role에 따른 접근 제어를 가능

3. 실무의 난제: CORS 문제 해결

프론트엔드(3000번 포트)와 백엔드(8080번 포트) 간의 통신 시 브라우저에서 발생하는 CORS 문제는 필수적으로 해결해야 할 과제였습니다.

설정 포인트: setExposedHeaders를 사용하여 클라이언트(프론트) 단에서 서버가 응답한 헤더 중 Authorization 필드에 접근할 수 있도록 명시적으로 허용했습니다. 이 설정을 놓칠 경우, 서버는 토큰을 보내도 브라우저가 이를 차단하게 됩니다.

💡  고민

JWT 구현 중 가장 큰 모순에 빠졌던 지점은 "로그아웃과 토큰 탈취 대응"이다.

탈취된 토큰을 무효화하기 위해 서버측 Redis 저장소를 도입하고 토큰을 관리하려다 보니, 문득 의문이 생겼습니다.
"Stateless를 지향하며 세션을 버렸는데, 결국 Redis에 상태를 저장한다면 세션 방식과 무엇이 다른가?"

고민 을 더 해보았는데 . JWT의 기능은 단순히 '서버의 메모리 절약'만 있는게 아니다. 

  • 모바일 앱 환경: 모바일은 웹보다 토큰 탈취 우려가 적고, 앱 자체에서 토큰을 제거하는 로그아웃 방식이 확실하게 보장됩니다.
  • 확장성: 여러 대의 서버가 운영되는 MSA 환경에서 중앙 집중형 세션 클러스터링 없이도 각 서버가 독립적으로 인증을 검증할 수 있다는 점이 핵심이었습니다.

결국 어느 정도의 상태(State)를 남기더라도 서비스의 환경(웹 vs 모바일)과 확장 가능성을 고려한다면 JWT는 여전히 강력한 도구라는 판단을 내렸습니다. 무조건적인 원칙 준수보다 '상황에 맞는 최선의 선택'을 내리는 것이 핵심인거 같다. 

기술 면접 핵심 Q&A

Q. JWT 방식에서 비밀번호 암호화에 BCrypt를 사용하는 이유는?
A. BCrypt는 단방향 해시 알고리즘으로 복호화가 불가능하며, 솔팅(Salting) 기술을 사용하여 레인보우 테이블 공격으로부터 안전합니다. 사용자의 비밀번호를 데이터베이스에 안전하게 보관하기 위한 표준적인 선택입니다.
Q. JWTFilter를 UsernamePasswordAuthenticationFilter 앞에 배치한 이유는?
A. 이미 발급된 토큰이 있는 유저는 아이디/비밀번호 검증 필터를 거칠 필요 없이 바로 인증된 세션을 얻어야 하기 때문입니다. 효율적인 요청 처리를 위한 설계입니다.
Q. JWT의 페이로드에 민감한 정보를 담지 않은 이유는?
A. JWT는 암호화가 아닌 서명(Signature)에 초점이 맞춰져 있습니다. Base64로 인코딩된 페이로드는 누구나 디코딩할 수 있으므로, 보안상 중요한 정보 대신 식별자(username)와 권한(role) 정보만을 포함시켰습니다.