JWT를 사용하는 API에서는 보호된 요청이 들어올 때마다 토큰을 검사해야 한다. 이를 각 Controller에 직접 작성하면 같은 코드가 반복되고, 일부 API에서 인증 검사가 누락될 가능성도 생긴다.
Spring Security는 Controller에 도착하기 전 단계인 Filter에서 인증과 인가를 공통으로 처리한다.
클라이언트 요청
↓
SecurityFilterChain
↓
JwtFilter에서 토큰 검사
↓
SecurityContext에 인증 정보 저장
↓
URL 또는 메서드 권한 검사
↓
Controller → Service → Repository
이를 회사 건물에 비유하면 JwtFilter는 출입증을 검사하는 경비원이고,
Authentication은 확인이 끝난 방문자에게 걸어주는 목걸이다.
URL 규칙과 @PreAuthorize는 목걸이에 적힌 권한을 기준으로 출입을 허용하거나 차단하는 규칙이다.
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
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에서도 별도로 검사 대상에서 제외했다.
기존 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가 해당 요청을 인증된 사용자의 요청으로 인식한다.
인가를 처리하려면 사용자 이름뿐 아니라 권한 정보도 필요하다.
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());
| 위치 | 사용 값 |
|---|---|
| Authentication에 권한 저장 | ROLE_ADMIN |
| URL·메서드 권한 검사 | hasRole("ADMIN") |
다음과 같이 작성하면 ROLE_가 중복될 수 있다.
hasRole("ROLE_ADMIN")
인가 테스트를 위해 두 사용자를 초기 데이터로 저장했다.
| username | DB 역할 | Spring Security 권한 |
|---|---|---|
| 최정윤 | ADMIN | ROLE_ADMIN |
| 송콩떡 | NORMAL | ROLE_NORMAL |

관리자와 일반 사용자 초기 데이터 저장
users 테이블에 최정윤은 ADMIN, 송콩떡은 NORMAL 역할로 저장되었고, 비밀번호가 BCrypt 해시 형식으로 저장된 결과
비밀번호는 원문을 저장하지 않고 BCrypt 해시값으로 저장했다.
passwordEncoder.encode("1234")
로그인 시에는 equals()가 아니라 matches()를 사용한다.
passwordEncoder.matches(
request.getPassword(),
user.getPassword()
);
첫번째 인자는 사용자가 입력한 평문이고, 두번째 인자는 DB에 저장된 BCrypt 해시값이다.

관리자 사용자 JWT 발급

일반 사용자 JWT 발급
로그인 요청은 다음 흐름으로 처리된다.
username과 password 전달
↓
DB에서 username으로 사용자 조회
↓
PasswordEncoder.matches()로 비밀번호 대조
↓
username과 role을 포함한 JWT 생성
↓
클라이언트에 JWT 반환
| 검증 항목 | 결과 | 의미 |
|---|---|---|
| 최정윤 로그인 | 200 + JWT | 관리자 사용자 인증 성공 |
| 송콩떡 로그인 | 200 + JWT | 일반 사용자 인증 성공 |
| 토큰 없음 또는 만료 | 401 | 사용자 신원을 확인할 수 없음 |
| 최정윤 → 관리자 API | 200 | ROLE_ADMIN URL 인가 성공 |
| 송콩떡 → 관리자 API | 403 | 인증 성공, 관리자 권한 부족 |
| 송콩떡 → 일반 API | 200 | ROLE_NORMAL URL 인가 성공 |
| 최정윤 → 일반 API | 403 | ROLE_NORMAL 권한 불일치 |
송콩떡 → @PreAuthorize 메서드 | 200 | 메서드 인가 성공 |
최정윤 → @PreAuthorize 메서드 | 403 | 메서드 실행 전 인가 실패 |

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

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

역할 불일치로 인한 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 Unauthorized | 사용자가 누구인지 확인하지 못함 | JWT 존재 여부, 만료, 서명, 헤더 |
403 Forbidden | 사용자는 확인했지만 권한 부족 | Role, hasRole(), @PreAuthorize |
만료된 토큰
→ JwtFilter 검증 실패
→ 401
유효한 NORMAL 토큰으로 관리자 API 요청
→ JwtFilter 인증 성공
→ ADMIN 권한 조건 불일치
→ 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가 존재하는가?
| 상황 | 원인 | 해결 및 교훈 |
|---|---|---|
| 잘 되던 요청이 갑자기 401 | JWT의 60분 유효기간 만료 | 재로그인 후 새 토큰 발급 |
| Authorization 헤더 입력 오류 | Pretty 화면에서 복사하며 줄바꿈 포함 | Raw 화면에서 한 줄로 복사 |
DB 트리에 users가 보이지 않음 | IntelliJ 화면 갱신 또는 표시 문제 | SQL로 실제 데이터 확인 |
No database selected | SQL 콘솔에서 스키마 미선택 | DB 이름을 명시하거나 기본 스키마 선택 |
| 권한이 Lambda 주소로 출력 | 메서드 참조 객체의 문자열 표현 | SimpleGrantedAuthority 사용 |
| 관리자도 NORMAL API에서 403 | ADMIN이 NORMAL을 자동 포함하지 않음 | 필요하면 hasAnyRole() 사용 |
Spring Security 인증·인가의 전체 흐름은 다음 한 문장으로 정리할 수 있다.
JwtFilter가 토큰을 검사해 SecurityContext에 Authentication이라는 목걸이를 걸면, Spring Security는 URL 규칙과 메서드 어노테이션을 기준으로 접근 가능 여부를 자동으로 판단한다.
토큰이 없거나 만료되면 인증 단계에서 401이 발생하고, 유효한 토큰으로 사용자 신원이 확인되었더라도 필요한 역할이 없으면 인가 단계에서 403이 발생한다.
또한 권한 설정이 정상인데도 403이 발생한 문제를 로그 탐침과 단계별 소거 방식으로 추적하면서, 오류 코드의 표면적인 결과만 보는 것이 아니라 요청이 어느 단계까지 도달했는지 확인하는 디버깅 방법을 정리할 수 있었다.