Spring Security JWT 인증·인가 구현과 401·403 디버깅

최정윤·2026년 7월 28일

Spring

목록 보기
27/38

인증과 인가를 필터에서 처리하는 이유

JWT를 사용하는 API에서는 보호된 요청이 들어올 때마다 토큰을 검사해야 한다. 이를 각 Controller에 직접 작성하면 같은 코드가 반복되고, 일부 API에서 인증 검사가 누락될 가능성도 생긴다.

Spring Security는 Controller에 도착하기 전 단계인 Filter에서 인증과 인가를 공통으로 처리한다.

클라이언트 요청
        ↓
SecurityFilterChain
        ↓
JwtFilter에서 토큰 검사
        ↓
SecurityContext에 인증 정보 저장
        ↓
URL 또는 메서드 권한 검사
        ↓
Controller → Service → Repository

이를 회사 건물에 비유하면 JwtFilter는 출입증을 검사하는 경비원이고,
Authentication은 확인이 끝난 방문자에게 걸어주는 목걸이다.
URL 규칙과 @PreAuthorize목걸이에 적힌 권한을 기준으로 출입을 허용하거나 차단하는 규칙이다.


Spring Security 적용 직후 요청이 차단된 이유

build.gradle에 Spring Security 의존성을 추가했다.

implementation 'org.springframework.boot:spring-boot-starter-security'

spring-boot-starter-security를 추가하여 보안 필터가 활성화된 코드

Spring Security가 적용되자 기존에 정상적으로 실행되던 로그인 요청이 401 Unauthorized를 반환했다.

Spring Security 적용 직후 발생한 401
별도의 보안 설정을 작성하기 전, 인증 정보가 없는 로그인 요청이 차단된 결과

기존 Controller 코드가 고장난 것은 아니었다. Spring Security의 보안 필터가 요청을 Controller보다 먼저 검사했지만, ①아직 공개 URL 설정도 없고 ②SecurityContext에 인증 정보도 없었기 때문에 요청이 차단된 것이다.

Spring Security 의존성 추가
        ↓
보안 필터 활성화
        ↓
현재 사용자의 Authentication 없음
        ↓
Controller 도착 전 차단
        ↓
401 Unauthorized

SecurityConfig로 보안 규칙 설정

JWT 기반 REST API에 필요한 설정을 직접 작성하기 위해 SecurityConfig를 생성했다.

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    private final JwtFilter jwtFilter;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http)
            throws Exception {

        return http
                .csrf(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable)
                .addFilterBefore(
                        jwtFilter,
                        SecurityContextHolderAwareRequestFilter.class
                )
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/error").permitAll()
                        .requestMatchers("/api/login").permitAll()
                        .requestMatchers("/api/user/get").permitAll()
                        .requestMatchers("/api/admin/**").hasRole("ADMIN")
                        .requestMatchers("/api/normal/**").hasRole("NORMAL")
                        .anyRequest().authenticated()
                )
                .build();
    }
}
설정역할
csrf().disable()Authorization 헤더로 JWT를 전달하는 현재 API 구조에서 CSRF 기능 비활성화
httpBasic().disable()HTTP Basic 인증 방식 비활성화
formLogin().disable()기본 로그인 화면 비활성화
addFilterBefore()JwtFilter를 Spring Security 필터 체인에 배치
permitAll()URL 인가 검사에서 접근 허용
authenticated()인증된 사용자만 접근 허용
hasRole()특정 역할을 가진 사용자만 접근 허용

로그인 API는 JWT를 발급받는 출입증 발급 창구다. 출입증을 발급받으러 온 사용자에게 기존 출입증을 요구하면 안되므로 /api/login은 permitaAll()(아.묻.따.?)로 열었다.

다만 permitAll()은 URL 인가를 허용하는 설정이지, 커스텀 JwtFilter의 실행 자체를 생략하는 설정은 아니다. 따라서 로그인 요청은 JwtFilter에서도 별도로 검사 대상에서 제외했다.


Request Attribute에서 SecurityContext로 변경

기존 JwtFilter는 토큰에서 username을 추출한 뒤 Request Attribute에 저장했다.

String username = jwtUtil.extractUsername(jwt);
request.setAttribute("username", username);

하지만 Request Attribute에 username이 있다는 것만으로는 Spring Security가 사용자를 인증된 상태로 인식하지 않는다.

Spring Secutiry가 인증된 사용자로 판단하려면 Authentication 객체를 생성해 SecurityContextHolder에 저장해야 한다.

User user = new User(username, "", authorities);

Authentication authentication =
        new UsernamePasswordAuthenticationToken(
                user,
                null,
                user.getAuthorities()
        );

SecurityContextHolder.getContext()
        .setAuthentication(authentication);
SecurityContextHolder
        ↓
SecurityContext
        ↓
Authentication
        ├─ principal: 사용자 정보
        ├─ credentials: 인증 수단
        └─ authorities: 사용자 권한

핵심은 다음 한 문장으로 정리할 수 있다.

JwtFilter가 JWT를 검사해 Authentication을 SecurityContext에 저장하면, Spring Security가 해당 요청을 인증된 사용자의 요청으로 인식한다.


역할 정보를 JWT와 Authentication에 연결

인가를 처리하려면 사용자 이름뿐 아니라 권한 정보도 필요하다.

public enum UserRoleEnum {

    ADMIN("ROLE_ADMIN", "관리자 권한"),
    NORMAL("ROLE_NORMAL", "일반 사용자 권한");

    private final String role;
    private final String description;
}

DB에는 Enum 이름인 ADMIN, NORMAL이 저장되고, Spring Security의 권한 목록에는 ROLE_ADMIN, ROLE_NORMAL이 저장된다.

DB User.role
        ↓
JWT의 auth Claim
        ↓
JwtFilter에서 UserRoleEnum으로 변환
        ↓
SimpleGrantedAuthority
        ↓
Authentication.authorities
String auth = jwtUtil.extractRole(jwt);
UserRoleEnum userRole = UserRoleEnum.valueOf(auth);

SimpleGrantedAuthority authority =
        new SimpleGrantedAuthority(userRole.getRole());

ROLE_ 접두사 규칙

위치사용 값
Authentication에 권한 저장ROLE_ADMIN
URL·메서드 권한 검사hasRole("ADMIN")

다음과 같이 작성하면 ROLE_가 중복될 수 있다.

hasRole("ROLE_ADMIN")

초기 사용자와 비밀번호 저장

인가 테스트를 위해 두 사용자를 초기 데이터로 저장했다.

usernameDB 역할Spring Security 권한
최정윤ADMINROLE_ADMIN
송콩떡NORMALROLE_NORMAL

관리자와 일반 사용자 초기 데이터 저장
users 테이블에 최정윤은 ADMIN, 송콩떡은 NORMAL 역할로 저장되었고, 비밀번호가 BCrypt 해시 형식으로 저장된 결과

비밀번호는 원문을 저장하지 않고 BCrypt 해시값으로 저장했다.

passwordEncoder.encode("1234")

로그인 시에는 equals()가 아니라 matches()를 사용한다.

passwordEncoder.matches(
        request.getPassword(),
        user.getPassword()
);

첫번째 인자는 사용자가 입력한 평문이고, 두번째 인자는 DB에 저장된 BCrypt 해시값이다.


로그인과 JWT 발급 확인

관리자 사용자 JWT 발급

일반 사용자 JWT 발급

로그인 요청은 다음 흐름으로 처리된다.

username과 password 전달
        ↓
DB에서 username으로 사용자 조회
        ↓
PasswordEncoder.matches()로 비밀번호 대조
        ↓
username과 role을 포함한 JWT 생성
        ↓
클라이언트에 JWT 반환

권한별 API 검증 결과

검증 항목결과의미
최정윤 로그인200 + JWT관리자 사용자 인증 성공
송콩떡 로그인200 + JWT일반 사용자 인증 성공
토큰 없음 또는 만료401사용자 신원을 확인할 수 없음
최정윤 → 관리자 API200ROLE_ADMIN URL 인가 성공
송콩떡 → 관리자 API403인증 성공, 관리자 권한 부족
송콩떡 → 일반 API200ROLE_NORMAL URL 인가 성공
최정윤 → 일반 API403ROLE_NORMAL 권한 불일치
송콩떡 → @PreAuthorize 메서드200메서드 인가 성공
최정윤 → @PreAuthorize 메서드403메서드 실행 전 인가 실패

URL 기반 인가 성공

관리자 전용 API 접근 성공
최정윤의 ROLE_ADMIN JWT로 /api/admin/get에 접근하여 200 OK가 반환된 결과


일반 사용자 전용 API 접근 성공
송콩떡의 ROLE_NORMAL JWT로 /api/normal/get에 접근하여 200 OK가 반환된 결과


URL 기반 인가 실패

역할 불일치로 인한 403
ROLE_ADMIN 사용자가 ROME_NORMAL보다 높은 권한이라고 자동으로 해석하지 않는다.

현재 권한: ROLE_ADMIN
요구 권한: ROLE_NORMAL
        ↓
권한 문자열 불일치
        ↓
403 Forbidden

관리자도 일반 사용자 API에 접근하도록 만들려면 다음처럼 명시해야 한다.

.hasAnyRole("NORMAL", "ADMIN")

토큰 만료에 따른 인증 실패

만료 토큰으로 인한 401
만료된 JWT로 관리자 API에 접근하여 인증 단계에서 401 Unauthorized가 반환된 결과

만료된 JWT는 JwtFilter의 유효성 검사를 통과하지 못한다.

만료된 JWT 전달
        ↓
JwtFilter에서 validateToken() 실패
        ↓
Authentication 생성 불가
        ↓
401 Unauthorized

메서드 기반 인가

특정 메서드에 NORMAL 권한 조건을 적용했다.

@PreAuthorize("hasRole('NORMAL')")

@PreAuthorize는 메서드 본문이 실행되기 전에 현재 Authentication의 권한을 검사한다.

관리자 사용자의 메서드 인가 실패
ROLE_ADMIN 사용자가 ROME_NORMAL이 요구되는 메서드에 접근하여 403 Forbidden으로 차단된 결과


일반 사용자의 메서드 인가 성공
ROLE_NORMAL 사용자가 같은 메서드 인가를 통과하여 사용자 이름 송콩떡과 함께 200 OK를 받은 결과

두 어노테이션의 역할은 다르다.

어노테이션역할
@PreAuthorize메서드를 실행할 권한이 있는지 검사
@AuthenticationPrincipal인증된 사용자가 누구인지 조회

401과 403은 차단된 위치가 다르다.

응답의미우선 확인할 부분
401 Unauthorized사용자가 누구인지 확인하지 못함JWT 존재 여부, 만료, 서명, 헤더
403 Forbidden사용자는 확인했지만 권한 부족Role, hasRole(), @PreAuthorize
만료된 토큰
→ JwtFilter 검증 실패
→ 401

유효한 NORMAL 토큰으로 관리자 API 요청
→ JwtFilter 인증 성공
→ ADMIN 권한 조건 불일치
→ 403

트러블 슈팅 : 권한이 정상인데 모든 요청이 403이었던 문제

최정윤의 관리자 JWT로 /api/admin/get을 호출했지만 계속 403 Forbidden이 반환되었다.

JwtFilter 로그에서는 권한이 정상적으로 저장된 것을 확인했다.

username=최정윤
authorities=[ROLE_ADMIN]

ADMIN과 NORMAL 사용자가 모두 403을 반환했기 때문에 단순 권한 불일치가 아닌 다른 원인을 의심했다.

1. JwtFilter 실행 여부 확인
2. Authentication의 ROLE_ADMIN 저장 확인
3. SecurityConfig 인가 규칙 확인
4. 규칙상 차단될 이유가 없음을 확인
5. /error 경로를 permitAll로 개방
6. 기존 403이 실제 404로 변경
7. AdminController가 존재하지 않음을 발견
8. Controller 생성 후 200 OK 확인

실제 흐름은 다음과 같았다.

존재하지 않는 /api/admin/get 요청
        ↓
실제 404 발생
        ↓
Spring Boot가 /error 경로로 오류 처리
        ↓
/error도 Spring Security 인가 검사에 차단
        ↓
최종 응답이 403으로 보임

현재 실습 환경에서 다음 설정을 추가하자 가려져 있던 실제 오류를 확인할 수 있었다.

.requestMatchers("/error").permitAll()

이 문제를 통해 오류 코드만 보고 원인을 바로 단성하지 않고, 요청이 어느 단계까지 통과했는지를 로그로 확인해야 한다는 점을 알게 되었다.

필터가 실행됐는가?
        ↓
Authentication이 저장됐는가?
        ↓
권한이 정확한가?
        ↓
인가 규칙이 일치하는가?
        ↓
요청을 처리할 Controller가 존재하는가?

실습 중 추가로 알게 된 점

상황원인해결 및 교훈
잘 되던 요청이 갑자기 401JWT의 60분 유효기간 만료재로그인 후 새 토큰 발급
Authorization 헤더 입력 오류Pretty 화면에서 복사하며 줄바꿈 포함Raw 화면에서 한 줄로 복사
DB 트리에 users가 보이지 않음IntelliJ 화면 갱신 또는 표시 문제SQL로 실제 데이터 확인
No database selectedSQL 콘솔에서 스키마 미선택DB 이름을 명시하거나 기본 스키마 선택
권한이 Lambda 주소로 출력메서드 참조 객체의 문자열 표현SimpleGrantedAuthority 사용
관리자도 NORMAL API에서 403ADMIN이 NORMAL을 자동 포함하지 않음필요하면 hasAnyRole() 사용

마무리

Spring Security 인증·인가의 전체 흐름은 다음 한 문장으로 정리할 수 있다.

JwtFilter가 토큰을 검사해 SecurityContext에 Authentication이라는 목걸이를 걸면, Spring Security는 URL 규칙과 메서드 어노테이션을 기준으로 접근 가능 여부를 자동으로 판단한다.

토큰이 없거나 만료되면 인증 단계에서 401이 발생하고, 유효한 토큰으로 사용자 신원이 확인되었더라도 필요한 역할이 없으면 인가 단계에서 403이 발생한다.

또한 권한 설정이 정상인데도 403이 발생한 문제를 로그 탐침과 단계별 소거 방식으로 추적하면서, 오류 코드의 표면적인 결과만 보는 것이 아니라 요청이 어느 단계까지 도달했는지 확인하는 디버깅 방법을 정리할 수 있었다.

profile
콩떡

0개의 댓글