Spring Security+JWT 인증된 사용자 요청 처리 -4

Noah-wilson·2025년 5월 22일

Spring Security

목록 보기
4/4

지금까지 Spring Security와 JWT를 활용한 로그인 기능을 구현해보았다.
이제는 로그인된 사용자의 요청을 어떻게 처리할 것인지에 대해 정리해보고자 한다.
우선, 인증된 사용자 요청의 처리 흐름을 단계별로 정리하여 전체적인 구조를 명확히 이해하고자 한다.

인증된 사용자 요청 처리 과정

  1. 사용자가 로그인 요청을 보내고, 서버는 인증에 성공하면 Access Token과 Refresh Token이 포함된 JWT를 발급한다.
  2. JWT는 Bearer 방식이므로, 클라이언트는 이후 요청 시 Authorization 헤더에 Bearer <Access Token>형식으로 토큰을 포함해 서버에 요청을 보낸다.
  3. 서버는 Spring Security의 FilterChain을 통해 요청을 가로채고,
    JwtAuthenticationFilter에서 Authorization 헤더를 추출하여 JwtTokenProvider.validateToken()을 통해 토큰의 유효성을 검증한다.
    이후 검증이 성공하면 SecurityContextHolder에 인증 정보를 등록한다.
  4. 인증이 완료된 요청만 Spring Security의 인증 절차를 통과하여 Controller로 전달되며, 컨트롤러는 해당 요청을 처리하게 된다.
  5. 예를 들어, 사용자 정보를 조회하는 요청의 경우 컨트롤러에서는 @AuthenticationPrincipal 또는 SecurityContextHolder를 통해
    현재 인증된 사용자 정보를 조회한 후 응답을 반환한다.

코딩을 시작하기 전 이런 의문이 생겼다.

SecurityContextHolder의 인증 정보가 계속 유지되는가?

filterChain

    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .httpBasic(Customizer.withDefaults())
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(session ->session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/members/login","/members/sign-in", "/members/sign-in/test").permitAll()
                        .requestMatchers("/members/test").hasRole("USER")
                        .anyRequest().authenticated()
                )
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class).build();
    }

위 SecurityConfig에서 SessionCreationPolicy.STATELESS 설정을 보면 알 수 있듯,
JWT는 상태를 저장하지 않는(Stateless) 인증 방식이다.
즉, 서버는 로그인 상태나 인증 세션을 별도로 유지하지 않는다.

또한 Spring Security의 SecurityContextHolder는 ThreadLocal 기반으로 동작하므로,
하나의 HTTP 요청에 대해서만 인증 정보를 유지하고,
요청이 끝나면 해당 Context는 자동으로 사라진다.

따라서 "한 번 인증된 사용자의 정보가 계속 유지되는가?" 같은 고민은 하지 않아도 된다.
모든 요청은 JWT 토큰을 통해 매번 독립적으로 인증되기 때문이다.

SecurityContextHolder 사용방식

SecurityConfig

http.authorizeHttpRequests(auth -> auth
        .requestMatchers("/members/test").hasRole("USER")
        .anyRequest().authenticated()
);
http.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);

SecurityConfig에서 인증된 사용자만 접근 가능하도록 엔드포인트를 지정하고
JWT 인증 필터를 등록한다.

MemberController


    @GetMapping("/test")
    public ResponseEntity<MemberInfo> getMemberTest() {
        Member member = memberService.getMemberInfo();
        return ResponseEntity.ok(MemberInfo.builder()
                .name(member.getName())
                .signInId(member.getUsername())
                .email(member.getEmail())
                .gender(member.getGender())
                .birthday(member.getBirthday())
                .score(member.getScore())
                .level(member.getLevel())
                .win(member.getWin())
                .lose(member.getLose())
                .build());
    }

인증이 완료된 요청을 처리하기 위한 Controller를 설계한다.

MemberService

    public Member getMemberInfo() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        String userName = authentication.getName();
        log.info("인증된 사용자 이름:{}", userName);

        return memberRepository.findByUsername(userName)
                .orElseThrow(() -> new UserNotFoundException(userName));

    }

JwtAuthenticationFilter에서 JWT의 유효성 검증이 완료되면, 해당 토큰의 사용자 정보를 담은 Authentication 객체가 SecurityContextHolder에 저장되었기 때문에

MemberService에서 SecurityContextHolder.getContext().getAuthentication()을 통해 인증된 사용자의 username을 조회할 수 있으며,
이를 통해 DB에서 해당 사용자의 상세 정보를 조회할 수 있다.

요청 결과

인증 및 권한 확인이 모두 완료되었기 때문에,
서버는 요청을 성공적으로 처리하고 클라이언트에 HTTP 200 OK 응답과 함께 결과를 반환한다.

@AuthenticationPrincipal 사용방식

@AuthenticationPrincipal은 Spring Security에서 현재 인증된 사용자의 정보를 컨트롤러 메서드 파라미터로 주입받을 수 있도록 도와주는 어노테이션이다.

처음에는 단순히 @AuthenticationPrincipal과 Member를 사용하면 바로 사용자 정보를 조회할 수 있을 거라 생각했다.

MemberController

@GetMapping("/test")
    public ResponseEntity<MemberInfo> getMemberTest(@AuthenticationPrincipal Member member) {

                return ResponseEntity.ok(MemberInfo.builder()
                .name(member.getName())
                .signInId(member.getUsername())
                .email(member.getEmail())
                .gender(member.getGender())
                .birthday(member.getBirthday())
                .score(member.getScore())
                .level(member.getLevel())
                .win(member.getWin())
                .lose(member.getLose())
                .build());
    }

하지만 실제 실행 결과는 500 서버 오류였다.

로그를 확인해보니 다음과 같은 NPE(NullPointerException)가 발생했다

java.lang.NullPointerException: Cannot invoke "com.agora.debate.member.entity.Member.getName()" because "member" is null

문제 원인 분석

분명히 JWT 토큰을 검증하는 JwtAuthenticationFilter에서 다음과 같이 User를 등록했었다.

JwtTokenProvier.getAuthentication()

        UserDetails principal = new User(claims.getSubject(), "", authorities);
        return new UsernamePasswordAuthenticationToken(principal, "", authorities);

문제는 principal에 Spring Security의 기본 User 객체를 사용했다는 점이다.

컨트롤러에서는 @AuthenticationPrincipal Member member로 주입을 받으려 했기 때문에, 타입이 맞지 않아 null이 주입된 것이다.

즉, SecurityContextHolder에 등록된 객체는 User인데,
@AuthenticationPrincipal은 이를 Member로 캐스팅할 수 없으니 null이 된 것이다.

        Member member = memberRepository.findByUsername(claims.getSubject())
                .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));

        return new UsernamePasswordAuthenticationToken(member, "", authorities);

해결방법

JwtTokenProvider.getAuthentication()에서 User 대신 내가 직접 구현한 Member를 principal로 등록하면 된다.

이렇게 수정하면 SecurityContextHolder에 등록된 객체가 Member가 되므로,
컨트롤러에서 @AuthenticationPrincipal Member member를 통해 정상적으로 사용자 정보를 주입받을 수 있다.

@AuthenticationPrincipal을 사용하려면 SecurityContextHolder에 등록되는 principal 객체가
실제로 주입받으려는 타입(Member 또는 CustomUserDetails)과 일치해야 한다.

결론

처음에는 SecurityContextHolder.getContext().getAuthentication()를 통해 인증 정보를 직접 꺼내서 사용하는 방식도 고려했지만,
코드의 가독성과 의도를 명확하게 전달하기 위해 @AuthenticationPrincipal을 사용하는 방식으로 정리하였다.

이 어노테이션을 사용하면 컨트롤러에서 바로 인증된 사용자 객체를 주입받을 수 있어 코드가 훨씬 간결해지고, 비즈니스 로직에 집중할 수 있는 장점이 있다.

따라서 이후 인증된 사용자 정보가 필요한 컨트롤러에서는 @AuthenticationPrincipal을 통해 일관성 있게 처리할 예정이다.

0개의 댓글