Spring Security Authorization — AuthorizationManager / authorizeHttpRequests / AuthorizationFilter
들어가며
인증(Authentication)이 "당신이 누구인가"라면 인가(Authorization)는
"당신이 이걸 해도 되는가"다. Spring Security에서 이 두 질문은 완전히
다른 계층에서 처리된다. 인증은
UsernamePasswordAuthenticationFilter나
BearerTokenAuthenticationFilter 같은 필터가 앞쪽에서
끝내고, 인가는 필터 체인의 거의 끝에 있는
**AuthorizationFilter**가 최후의 관문으로 막는다.
authorizeHttpRequests(...) DSL에 한 줄 쓰면 끝나는 것처럼
보이지만, 그 뒤에서 AuthorizationManager가 계약 한 벌을
따라 돌고, 매칭과 위임과 예외 변환이 정해진 순서로 이어진다.
Spring Security 5에서 6으로 넘어오면서 이 영역이 꽤 크게 갈아엎혔다.
기존 FilterSecurityInterceptor +
AccessDecisionManager + Voter 3자 구도가
통째로 AuthorizationFilter +
AuthorizationManager<T> 1쌍으로 정리됐다. 설정 DSL도
authorizeRequests에서 authorizeHttpRequests로
바뀌었고, 그 둘은 겉보기만 비슷하고 기본 동작이 다르다.
다음 세 지점에서 거의 모두가 한 번씩 막힌다.
authorizeRequests를authorizeHttpRequests로 바꾸니 갑자기 전부 403이 뜬다 — 매칭되는 규칙이 없을 때 기본 정책이 abstain에서 deny로 바뀐 탓이다hasRole("ROLE_ADMIN")이 아무리 해도 동작을 안 한다 —hasRole은ROLE_접두사를 자기가 알아서 붙인다. 붙여서 쓰면ROLE_ROLE_ADMIN을 찾게 된다- 403이 떠야 할 자리에 로그인 페이지 리다이렉트가
온다 — 거부된 사용자가 anonymous면
AuthenticationEntryPoint가, 이미 인증된 상태면AccessDeniedHandler가 탄다.ExceptionTranslationFilter가 그걸 분기한다
이 글은 왜 → AuthorizationManager 계약 → DSL → 위임 구조 → 권한 표현식 → 커스텀 → 예외 분기 → 감사 이벤트 → 마이그레이션 함정 → 실무 순으로 Spring Security 6.x / Spring Boot 3.x / Java 17+ 기준으로 정리한다.
목차
- 1) 인가는 필터 체인의 마지막 관문: AuthorizationFilter의 위치
- 2) 구 AccessDecisionManager에서 신 AuthorizationManager로 간 이유
- 3) AuthorizationManager 계약: check / authorize / verify
- 4) authorizeHttpRequests DSL: permitAll / hasRole / authenticated
- 5) RequestMatcherDelegatingAuthorizationManager: 첫-일치와 default deny
- 6) hasRole vs hasAuthority와 ROLE_ 접두사 규칙
- 7) 커스텀 AuthorizationManager와 RequestAuthorizationContext
- 8) AuthorizationDeniedException과 ExceptionTranslationFilter 분기
- 9) AuthorizationEventPublisher로 감사 로그 붙이기
- 10) authorizeHttpRequests vs authorizeRequests 마이그레이션 함정
- 11) 실무에서 이렇게 읽고 쓴다
- 12) 한 줄 정리
1) 인가는 필터 체인의 마지막 관문: AuthorizationFilter의 위치
Spring Security의 필터 체인은 대충 요청 해석 → 컨텍스트 로딩
→ 인증 → 예외 변환 → 인가 순으로 흐른다. 이 순서에서
AuthorizationFilter는 거의 끝에 있다. 앞쪽 필터들이
SecurityContextHolder에 Authentication을
채워놓고, 체인의 마지막 근처에서 AuthorizationFilter가 그
컨텍스트를 읽어 "이 요청을 통과시킬지"만 판정한다. 인가가 체인의
마지막 관문이 되는 이유는 단순하다. 그 앞의 모든 단계가
"누구인지"를 확정하기 위한 준비이고, 권한 검사는 준비가 끝난 뒤에만
의미가 있기 때문이다.
위치가 끝 근처이기 때문에 인가에서 나는
AccessDeniedException은 이 뒤에 올 디스패처 서블릿까지 가지
않는다. 바로 앞에 있는 ExceptionTranslationFilter가 잡아서
상황에 맞게 변환한다. 이 분기가 이 글 §8의 주제다. 먼저 필터 체인
관점에서 인가 호출이 어떻게 일어나는지 한 장으로 보자.
이 시퀀스가 이 글 전체의 골격이다. 이후 섹션은 이 그림의 각 상자를
순서대로 들여다본다. AuthorizationFilter가 뭘 하고,
RequestMatcherDelegatingAuthorizationManager가 왜
first-match이며 왜 default deny인지, 거부가 어떻게 401과 403으로
갈라지는지가 순서대로 나온다.
2) 구 AccessDecisionManager에서 신 AuthorizationManager로 간 이유
Spring Security 5 시절의 인가는 좀 더 복잡했다. 필터는
FilterSecurityInterceptor였고, 판정 책임은
AccessDecisionManager 인터페이스가 졌다. 구현체가 3개
있었다 — AffirmativeBased, ConsensusBased,
UnanimousBased. 이 매니저는 내부에
AccessDecisionVoter 리스트를 들고 있었고, 각 보터가
"찬성(GRANT)", "반대(DENY)", "기권(ABSTAIN)" 중 하나를 던지면 매니저가
그 표를 세서 최종 판정을 내렸다. 대표적인 보터는 RoleVoter,
AuthenticatedVoter, WebExpressionVoter.
이 구조는 직관적인 대신 비용이 컸다. 보터 인터페이스가
int vote(...)로 상수를 리턴하는 형태라 타입 안정성이
약했고, ConfigAttribute라는 별도 추상화를 만들어서 "무엇을
검사할지"를 문자열로 표현해야 했다. 객체 수준 권한(예: "본인이 작성한
게시글만 수정")을 붙이려면 ConfigAttribute 커스텀부터 보터
커스텀까지 코드를 여러 겹 작성해야 했다. Spring Security 팀이 이 구조를
단일 함수형 인터페이스 AuthorizationManager<T>로
압축하면서, FilterSecurityInterceptor + 보터 구도는
AuthorizationFilter + 매니저 구도로 정리됐다.
| 항목 | 구 (Security 5) | 신 (Security 6) |
|---|---|---|
| 필터 | FilterSecurityInterceptor |
AuthorizationFilter |
| 판정 | AccessDecisionManager + Voter[] |
AuthorizationManager<T> (단일) |
| 결과 | int(GRANT/DENY/ABSTAIN) |
AuthorizationResult(granted bool) |
| 설정 DSL | authorizeRequests |
authorizeHttpRequests |
| 기본 매칭 없음 | abstain 허용 | deny |
| 커스텀 | ConfigAttribute + 보터 |
람다 한 개 |
오른쪽 끝 행, "기본 매칭 없음 → abstain vs deny"가 마이그레이션에서
가장 많은 사고를 일으킨다. 이건 §10에서 따로 다룬다. 일단 여기서는
신구조가 단일 함수형 인터페이스로 단순해졌다는 것,
그리고 설정 DSL이 authorizeHttpRequests로
바뀌었다는 것만 붙잡고 넘어간다.
authorizeRequests는 6에서 deprecated됐고 향후 릴리스에서
제거 예정이다.
3) AuthorizationManager 계약: check / authorize / verify
AuthorizationManager<T>는 제네릭 T에
"무엇에 대한 권한 검사인가"를 담는 얇은 함수형 인터페이스다. 웹
인가에서는 T가 HttpServletRequest이거나
RequestAuthorizationContext이고, 메서드 수준
인가(@PreAuthorize)에서는 MethodInvocation이
된다. 인터페이스 자체는 다음 세 메서드를 가진다.
public interface AuthorizationManager<T> {
@Deprecated
AuthorizationDecision check(Supplier<Authentication> authentication, T object);
default AuthorizationResult authorize(Supplier<Authentication> authentication, T object) {
return check(authentication, object);
}
default void verify(Supplier<Authentication> authentication, T object) {
AuthorizationResult result = authorize(authentication, object);
if (result != null && !result.isGranted()) {
throw new AccessDeniedException("Access Denied");
}
}
}세 메서드가 있지만 핵심은 하나다. authorize가
판정을 계산하고, verify가 그 결과를 예외로 던진다.
check는 과거 호환용으로 남은 deprecated 메서드이고, 신규
구현은 authorize를 override한다.
AuthorizationFilter는 내부에서 verify를
호출하기 때문에, 구현체는 굳이 AccessDeniedException을 직접
던지지 않고 AuthorizationResult만 리턴하면 된다.
3-1) Supplier이
왜 Supplier인가
첫 번째 인자 Supplier<Authentication>는 지연
평가를 위한 장치다. permitAll()처럼 인증 정보를
전혀 보지 않는 규칙이 꽤 많기 때문에, 매 요청마다
SecurityContextHolder에서 Authentication을
꺼내는 비용을 아끼려고 서플라이어로 감쌌다. 규칙이 실제로 인증을 필요로
할 때만 .get()을 호출해서 컨텍스트를 읽는다. 작은
최적화지만 필터 체인에서 요청마다 불리는 코드라 체감이 있다.
3-2) AuthorizationResult와 AuthorizationDecision
AuthorizationResult는 단순히 "허용됐는가"를 나타내는
인터페이스이고, 기본 구현이 AuthorizationDecision이다. 이
클래스는 생성자에 boolean granted만 받는다. 커스텀
매니저에서 new AuthorizationDecision(true) 또는
false를 그냥 리턴하면 끝이다. 여기에 추가 메타데이터를
붙이고 싶으면 AuthorizationResult를 직접 구현해서 더 풍부한
결과 객체를 만들어도 된다.
4) authorizeHttpRequests DSL: permitAll / hasRole / authenticated
실무에서 AuthorizationManager를 직접 만져야 하는 경우는
드물다. 대부분은 HttpSecurity.authorizeHttpRequests(...)
DSL 뒤에 숨어서 자동으로 조립된다. 이 DSL의 문법은 단순하다 —
requestMatchers(...) 로 경로를 잡고, 뒤에 권한
표현식을 붙인다. 매칭된 규칙마다 그에 대응하는
AuthorizationManager 인스턴스가 내부에서 만들어진다.
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/**").authenticated()
.requestMatchers(HttpMethod.POST, "/api/**").hasRole("USER")
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().denyAll()
)
.build();
}DSL이 제공하는 표현식은 이 몇 가지다.
| 표현식 | 뜻 |
|---|---|
permitAll() |
무조건 허용, 인증도 확인 안 함 |
denyAll() |
무조건 거부 |
authenticated() |
익명이 아니면 허용 (remember-me 포함) |
fullyAuthenticated() |
현재 세션에서 직접 로그인한 경우만 (remember-me 제외) |
rememberMe() |
remember-me 토큰으로 인증된 경우만 |
anonymous() |
익명 사용자만 |
hasRole("X") |
ROLE_X 권한 보유 |
hasAnyRole("X","Y") |
ROLE_X 또는 ROLE_Y 보유 |
hasAuthority("X") |
X 권한 보유 (접두사 없음) |
hasAnyAuthority("X","Y") |
둘 중 하나 보유 |
access(AuthorizationManager) |
임의의 매니저 등록 |
이 표에서 특히 중요한 게 **hasRole vs
hasAuthority**의 차이다. 이건 §6에서 따로 다룬다. 그리고
마지막 줄 access(...)이 커스텀 매니저를 꽂는 출입구다. 이건
§7에서 본다.
4-1) 규칙 순서
DSL에 쓴 순서가 그대로 규칙 매칭 순서다. 위에서부터 아래로 내려가며
첫 번째로 매칭되는 규칙만 적용된다. 구체적인 경로를 먼저 쓰고 넓은
경로를 뒤에 쓰는 게 안전하다. /api/**를 위에 두고
/api/admin/**을 아래에 두면 관리자 경로 규칙은 영영 안
탄다. 이 순서 민감성은 SecurityFilterChain의 복수 체인
first-match와 구조적으로 같은 규칙이다.
5) RequestMatcherDelegatingAuthorizationManager: 첫-일치와 default deny
authorizeHttpRequests 블록 하나가 내부에서 만들어내는
실체가 바로
RequestMatcherDelegatingAuthorizationManager
인스턴스다. 이름이 길지만 역할은 명료하다. 내부에
(RequestMatcher, AuthorizationManager) 쌍의 리스트를 들고
있고, 요청이 들어오면 그 리스트를 순회하면서 첫 매치를 찾는다. 매치가
생기면 해당 매니저의 authorize로 위임하고, 그 결과를 그대로
돌려준다. DSL에
.requestMatchers("/admin/**").hasRole("ADMIN") 한 줄을
썼으면, 그 리스트에
(AntPathRequestMatcher("/admin/**"), AuthorityAuthorizationManager("ROLE_ADMIN"))
한 쌍이 들어가는 식이다.
// 개략 (실제는 AuthorizationResult, GrantedAuthorityDefaults 등 더 있음)
public AuthorizationResult authorize(Supplier<Authentication> auth, HttpServletRequest req) {
for (RequestMatcherEntry<AuthorizationManager<RequestAuthorizationContext>> entry : mappings) {
RequestMatcher matcher = entry.getRequestMatcher();
MatchResult match = matcher.matcher(req);
if (match.isMatch()) {
AuthorizationManager<RequestAuthorizationContext> manager = entry.getEntry();
return manager.authorize(auth,
new RequestAuthorizationContext(req, match.getVariables()));
}
}
return DENY; // Spring Security 6 기본
}핵심 두 가지. 첫 번째 매치만 쓴다(first-match)
그리고 어떤 규칙에도 매치되지 않으면 DENY를
돌려준다. 후자가 Spring Security 5와 달라진 지점이다. 5에서는
매칭이 없을 때 AccessDecisionManager가 보터들에게 물어보고
전원 abstain이면 경우에 따라 허용되는 어중간한 상태가 있었다. 6에서는 그
모호함을 없애고 "매칭 없음 = 거부"로 확정했다(관련 이슈 #11958). 이
덕분에 보안 기본이 deny-by-default로 정렬됐다.
이 기본값이 좋은 방향이지만, 마이그레이션할 때 기존 코드가
.anyRequest() 한 줄을 깜빡했다면 갑자기 전 엔드포인트가
403이 되는 현상을 만난다. 이 함정은 §10에서 따로 짚는다.
6) hasRole vs hasAuthority와 ROLE_ 접두사 규칙
Spring Security에서 "역할(Role)"과 "권한(Authority)"은 문자열 하나
차이다. 내부적으로 둘 다 GrantedAuthority 객체로 저장되고,
판정할 때 Authentication.getAuthorities()에서 해당 문자열을
찾는다. 차이는 hasRole("X")가 내부에서 자동으로
ROLE_ 접두사를 붙여 ROLE_X를 찾는다는
것뿐이다. hasAuthority("X")는 접두사를 건드리지 않고 있는
그대로 X를 찾는다.
// 피하기
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasRole("ROLE_ADMIN") // 실제로는 ROLE_ROLE_ADMIN 을 찾게 됨
);
// 선호
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasRole("ADMIN") // 내부에서 ROLE_ADMIN 으로 변환
);
// 또는 접두사 규칙을 피하고 싶으면
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasAuthority("ROLE_ADMIN") // 문자열 그대로 비교
);UserDetails를 만들 때
SimpleGrantedAuthority("ROLE_ADMIN")으로 등록했다면, 그걸
hasRole("ADMIN")로 검사해야 일치한다.
hasRole("ROLE_ADMIN")으로 검사하면
ROLE_ROLE_ADMIN을 찾게 되어 절대 매치가 안 된다. 이게
들어가며에 언급한 두 번째 함정이다. 정석은 저장은
ROLE_ 접두사 포함해서 하고, 검사는 hasRole에
접두사 없이 하는 것이다.
6-1) GrantedAuthority의 실체
GrantedAuthority는 getAuthority() 한
메서드짜리 인터페이스다. 기본 구현 SimpleGrantedAuthority는
문자열 하나를 감싼다. UserDetails.getAuthorities()가 이
타입의 컬렉션을 반환하고, AuthorityAuthorizationManager가
그 컬렉션에 특정 문자열이 있는지 선형 검사한다. 권한 개수가 수십 개를
넘어가면 매 요청마다 선형 검사가 도는 게 체감될 수 있다 — 그땐 권한
정규화 또는 그룹화를 고민해야 한다.
6-2) 접두사 커스터마이징
ROLE_ 접두사가 마음에 안 들면
GrantedAuthorityDefaults 빈을 등록해서 바꿀 수 있다.
@Bean
static GrantedAuthorityDefaults grantedAuthorityDefaults() {
return new GrantedAuthorityDefaults("PERM_");
}이러면 hasRole("ADMIN")이 내부에서
PERM_ADMIN으로 해석된다. 실무에서 자주 쓰는 기능은
아니지만, 레거시 권한 체계를 옮길 때 가끔 유용하다.
7) 커스텀 AuthorizationManager와 RequestAuthorizationContext
DSL에 나오는 표현식만으로 부족한 경우가 있다. 가장 흔한 예는
객체 수준 권한이다 — "본인이 작성한 게시글만 수정할 수
있다", "자기 테넌트의 리소스만 조회할 수 있다" 같은 규칙은 URL 패턴만
보고 판정할 수 없고, 요청에서 리소스 ID를 꺼내서 DB 또는 도메인 객체와
비교해야 한다. 이럴 때 커스텀
AuthorizationManager<RequestAuthorizationContext>를
만들어서 .access(...)로 꽂는다.
public class PostOwnerAuthorizationManager
implements AuthorizationManager<RequestAuthorizationContext> {
private final PostRepository posts;
public PostOwnerAuthorizationManager(PostRepository posts) {
this.posts = posts;
}
@Override
public AuthorizationResult authorize(
Supplier<Authentication> authentication,
RequestAuthorizationContext context) {
String postId = context.getVariables().get("postId");
Authentication auth = authentication.get();
if (auth == null || !auth.isAuthenticated()) {
return new AuthorizationDecision(false);
}
boolean owner = posts.findById(Long.parseLong(postId))
.map(p -> p.getAuthor().equals(auth.getName()))
.orElse(false);
return new AuthorizationDecision(owner);
}
}RequestAuthorizationContext는 두 가지를 들고 있다. 원본
HttpServletRequest와, 경로 변수
맵(Map<String, String>)이다. 후자가
핵심이다. DSL에서 /posts/{postId} 같은 패턴을 쓰면 매처가
경로 변수를 파싱해서 컨텍스트에 넣어주고, 매니저는 그 맵에서
postId를 꺼내 쓸 수 있다. 매니저 안에서
HttpServletRequest.getParameter(...)로 뒤지지 않아도
된다.
@Bean
SecurityFilterChain security(HttpSecurity http, PostRepository posts) throws Exception {
AuthorizationManager<RequestAuthorizationContext> isOwner =
new PostOwnerAuthorizationManager(posts);
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.PUT, "/posts/{postId}").access(isOwner)
.requestMatchers(HttpMethod.DELETE, "/posts/{postId}").access(isOwner)
.anyRequest().authenticated())
.build();
}여기서 주의할 점. 커스텀 매니저 안에서 DB를 때리는 순간 모든
요청이 DB 라운드트립을 추가로 한다. 권한 검사를 위한 쿼리는
가능한 한 가볍게, 캐시 가능하게 만드는 게 좋다. 더 나아가면
@PreAuthorize 같은 메서드 수준 인가로 옮겨서 서비스 계층의
트랜잭션과 합치는 전략도 있다.
8) AuthorizationDeniedException과 ExceptionTranslationFilter 분기
판정이 거부로 떨어지면 AuthorizationFilter.verify가
AccessDeniedException을 던진다. Security 6.1+ 에서는 더
구체적인 AuthorizationDeniedException을 쓰는데, 이건
AccessDeniedException의 하위이므로 기존 예외 처리
경로에서도 잡힌다. 이 예외는 필터 체인을 거슬러 올라가다가
**ExceptionTranslationFilter**에게 잡힌다.
ExceptionTranslationFilter는
AuthorizationFilter보다 체인에서 앞쪽에 위치하기 때문에,
인가 필터에서 던진 예외가 자연스럽게 이쪽으로 되돌아 흐른다.
여기서 분기가 일어난다. 현재 사용자가 **익명(anonymous)**이면 "인증
자체가 없어서 거부된 것"으로 간주하고
AuthenticationEntryPoint를 호출한다. 반면 이미
인증된 사용자가 거부당한 거면 "인증은 됐지만 권한이 없는
것"으로 보고 AccessDeniedHandler를 호출한다.
| 사용자 상태 | 핸들러 | 기본 동작 (form login) | 기본 동작 (REST) |
|---|---|---|---|
| anonymous | AuthenticationEntryPoint |
/login 으로 302 리다이렉트 |
401 Unauthorized |
| 인증됨 | AccessDeniedHandler |
AccessDeniedException 페이지 |
403 Forbidden |
여기서 틀리기 쉬운 지점이 있다. **"403이 떠야 할 자리에 로그인 페이지
리다이렉트가 온다"**는 현상은 버그가 아니라 정상이다. 거부된 사용자가
anonymous면 "너는 아직 누군지도 모르겠으니 먼저 로그인해"가 맞는
응답이고, 그래서 AuthenticationEntryPoint가 탄다. REST
API만 제공하는 서버에서 이 동작이 거슬리면
http.exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
로 401만 돌려주게 바꿀 수 있다.
반대로 이미 로그인한 사용자가 권한 없는 경로에 접근했을 때는
AccessDeniedHandler가 타고, 기본 구현은 403 응답을 만든다.
이 흐름을 커스터마이징하면 "권한 거부 페이지"를 따로 두거나 JSON 에러
바디를 통일할 수 있다.
http.exceptionHandling(e -> e
.authenticationEntryPoint((req, res, ex) -> {
res.setStatus(401);
res.setContentType("application/json");
res.getWriter().write("{\"error\":\"unauthenticated\"}");
})
.accessDeniedHandler((req, res, ex) -> {
res.setStatus(403);
res.setContentType("application/json");
res.getWriter().write("{\"error\":\"forbidden\"}");
})
);이 두 핸들러를 명시적으로 지정해두면, 프론트엔드가 401과 403을 다르게 해석할 수 있게 된다. 프론트는 401일 때 로그인 화면으로 보내고, 403일 때는 "권한이 없습니다" 메시지를 띄우는 식이다.
9) AuthorizationEventPublisher로 감사 로그 붙이기
인가 결과를 스프링 애플리케이션 이벤트로 발행하고 싶을 때가 있다.
예를 들어 관리자 영역에서 거부된 요청을 모두 감사 로그로 남기거나, 특정
리소스에 대한 접근을 모니터링에 연동하는 경우다. Spring Security 6는
이걸 위해 AuthorizationEventPublisher 인터페이스와
SpringAuthorizationEventPublisher 기본 구현을 제공한다.
빈으로 등록하기만 하면 AuthorizationFilter가 판정 후 이
퍼블리셔를 호출한다.
@Bean
public AuthorizationEventPublisher authorizationEventPublisher(
ApplicationEventPublisher publisher) {
return new SpringAuthorizationEventPublisher(publisher);
}이 퍼블리셔는 기본적으로
AuthorizationDeniedEvent만 발행한다.
AuthorizationGrantedEvent는 모든 허용 요청에 대해 쏘면
이벤트 폭풍이 일어나기 때문에 기본 비활성이다. 허용 이벤트를 선택적으로
켜고 싶으면 setShouldPublishEvent(predicate)에 조건자를
넘겨서 일부 요청만 통과시키면 된다.
@Component
public class AuthorizationAuditListener {
private static final Logger log = LoggerFactory.getLogger(AuthorizationAuditListener.class);
@EventListener
public void onDenied(AuthorizationDeniedEvent<?> event) {
Authentication auth = event.getAuthentication().get();
log.warn("denied user={} object={} decision={}",
auth != null ? auth.getName() : "anonymous",
event.getObject(),
event.getAuthorizationDecision());
}
}실무에서는 이 리스너를 감사 DB에 기록하거나, 메트릭 카운터로 올려서
대시보드에 연동한다. 주의점은 두 가지다. 허용 이벤트를 전부 켜면
성능이 떨어진다. 그리고 리스너가 무거운 I/O를 하면 인가
경로가 느려진다 — 이 경우 리스너를 @Async로
분리하는 게 맞다.
10) authorizeHttpRequests vs authorizeRequests 마이그레이션 함정
이 글에서 가장 많이 사고를 일으키는 한 가지만 골라야 한다면 이
섹션이다. Spring Security 5 → 6으로 올리면서 DSL 이름만
authorizeRequests에서 authorizeHttpRequests로
바꾸면 될 것 같지만, 기본 동작이 미묘하게 다르다. 구
DSL은 내부에서 AccessDecisionManager와 보터를 쓰기 때문에,
매칭되는 규칙이 없을 때 보터가 전부 abstain을 리턴하면 최종적으로
허용으로 떨어지는 경로가 있었다. 신 DSL은
RequestMatcherDelegatingAuthorizationManager를 쓰기 때문에,
매칭이 없으면 바로 deny다.
// Spring Security 5 (구)
http.authorizeRequests(auth -> auth
.antMatchers("/public/**").permitAll()
.antMatchers("/admin/**").hasRole("ADMIN")
// anyRequest() 생략 — 매칭 없으면 abstain 경로로 허용될 수 있었음
);
// Spring Security 6 (신) — 그대로 옮기면 사고
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
// 매칭 없는 모든 요청이 이제 deny 로 떨어진다
);이 상태로 배포하면 /public/**와 /admin/**만
동작하고 그 외 경로는 전부 403이 된다. 기존에
/api/user/profile 같은 엔드포인트를 인증된 사용자에게
열어두고 싶었다면, 지금은 .anyRequest().authenticated() 한
줄이 빠져서 막힌 상태다. 정석 마이그레이션은 항상
.anyRequest()를 명시적으로 적는 것이다.
// 선호
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated() // 또는 denyAll()
);방어적인 관점에서는 .anyRequest().denyAll()로 시작해서,
필요한 경로를 하나씩 허용 리스트에 올리는 게 더 안전하다. 이게
deny-by-default의 정신이다. Spring Security 6의 기본 동작이 이미 이
방향으로 정렬되어 있으므로, 명시하기만 하면 실수가 줄어든다.
10-1) 같이 점검할 포인트
마이그레이션 체크리스트로 묶어두면 좋은 항목 몇 개.
.anyRequest()가 모든authorizeHttpRequests블록에 있는가antMatchers→requestMatchers전환 시 HTTP 메서드 변형(requestMatchers(HttpMethod.POST, ...))이 빠지지 않았는가hasRole("ROLE_ADMIN")처럼 접두사를 직접 붙인 곳이 없는가 (§6)access("hasRole('ADMIN')")같은 SpEL 문자열 표현식이 남아 있지 않은가 (6에서 문자열 access는 더 이상 권장되지 않음,.access(AuthorizationManager)람다 형태로 바꿔야 함)- 커스텀
AccessDecisionVoter가 있으면AuthorizationManager로 옮겨야 한다 (보터 인터페이스 자체가 deprecated)
11) 실무에서 이렇게 읽고 쓴다
- 가장 먼저 체인 순서를 본다. 인가 이슈가 떴을 때
필터
로그(
logging.level.org.springframework.security=DEBUG)에 나오는 필터 목록에서AuthorizationFilter의 위치와, 그 앞에 어떤 인증 필터가 있는지를 확인한다. 인증 필터가 없거나 순서가 틀리면Authentication이null로 들어와서 모든 인가가 anonymous로 판정된다. - 403이면
Authentication의 principal을 찍어본다.SecurityContextHolder.getContext().getAuthentication()을 로그에 찍으면 권한 목록이 나온다. 거기에ROLE_ADMIN이 기대대로 있는지, 아니면ADMIN만 있는지, 아니면Scope_...같은 OAuth2 스코프 형식인지를 먼저 확인한다. - OAuth2 스코프는
hasAuthority("SCOPE_read")형태로 검사한다. OAuth2 리소스 서버에서 JWT 스코프는SCOPE_접두사로 권한에 들어온다. 이건 역할이 아니라 권한이므로hasRole이 아니라hasAuthority를 쓴다. - 인가 규칙은 코드 리뷰에서 "가장 마지막 줄"을 본다.
.anyRequest()가permitAll인지authenticated인지denyAll인지가 이 블록 전체의 기본 정책이다. 이게 애매하면 나머지 규칙을 아무리 잘 써도 구멍이 뚫린다. - 커스텀 매니저 안에서 DB를 때릴 땐 캐시를 고민한다.
권한 검사를 위한 쿼리가 모든 요청마다 돌면 쉽게 병목이 된다. 짧은 TTL의
인메모리 캐시나
@Cacheable같은 래퍼를 한 층 두는 게 좋다. - 감사 로그는
AuthorizationDeniedEvent로 충분하다. 허용 이벤트까지 켜면 이벤트 양이 폭발한다. 거부 이벤트 + 메트릭 카운터 조합이 가성비가 제일 좋다. - 테스트는
@WithMockUser와MockMvc로.@WithMockUser(roles = "ADMIN")이면 내부적으로ROLE_ADMIN권한이 붙은Authentication이 컨텍스트에 세팅된다.MockMvc+.with(csrf())+ 기대 상태 코드(200/401/403) 확인이 인가 테스트의 정석이다.
12) 한 줄 정리
Spring Security 6의 인가는 AuthorizationFilter
하나가 AuthorizationManager 하나에 위임하는 단일 함수
계약으로 단순해졌다. authorizeHttpRequests DSL이
RequestMatcherDelegatingAuthorizationManager를 조립하고, 첫
매치 규칙의 매니저가 판정을 내리며, 매칭이 없으면 deny로
떨어진다. 거부 예외는 ExceptionTranslationFilter가
anonymous면 AuthenticationEntryPoint로, 인증됐으면
AccessDeniedHandler로 갈라서 보낸다 — 이
분기를 이해해야 401과 403 혼동이 사라진다.
태그: Spring Security, Authorization, AuthorizationManager, AuthorizationFilter, authorizeHttpRequests, RequestMatcherDelegatingAuthorizationManager, hasRole, ExceptionTranslationFilter, AccessDeniedHandler, Spring Boot 3