/api/orders/{orderId}를 로그인한 사용자만 볼 수 있게 했는데, 남의 주문 번호를 넣어도 통과하면 어떡하죠?
지난 글에서는 JWT와 OAuth2 resource server를 봤어요. API 서버는 access token을 발급하는 쪽이 아니라, 들어온 token을 검증하고 Authentication으로 바꾼 뒤 요청을 허용할지 판단한다고 했죠.
그럼 이제 이런 코드를 생각해볼게요.
“로그인한 사용자만 주문 API를 부를 수 있잖아요?”맞아요. 그런데 이 규칙은 로그인했는지는 확인하지만, 그 주문이 이 사용자의 주문인지는 확인하지 않아요.
/api/orders/42가 내 주문인지, 다른 사람 주문인지는 URL pattern만 봐서는 알 수 없어요.
오늘은 이 지점을 볼 거예요.
- URL 단위 인가와 method 단위 인가는 무엇이 다를까요?
@EnableMethodSecurity는 왜 따로 켜야 할까요?@PreAuthorize는 method 실행 전에 무엇을 볼 수 있을까요?@PostAuthorize는 왜 편하지만 조심해야 할까요?- role과 permission, 소유권 검사는 어디에 두는 게 읽기 좋을까요?
- 테스트에서는 method security를 어떻게 확인해야 할까요?
@PreAuthorize 문법을 많이 외우는 게 아니에요. 인가(authorization)를 URL 문 앞에서 끝낼지, service method 경계까지 가져갈지, 도메인 소유권 판단은 어디에 둘지를 나눠서 읽는 거예요.
이 글은 Spring Boot 4.x의 Spring Security 문서와 Spring Security 7.x 계열의 Method Security, request authorization, domain object security, testing method security 문서를 기준으로 작성했어요. Spring Boot 3.x 프로젝트에서도
SecurityFilterChain, @EnableMethodSecurity, @PreAuthorize, 프록시 기반 method security라는 큰 모델은 같은 방향으로 읽을 수 있어요.URL 규칙은 입구를 막고, method security는 업무 경계를 막아요
Spring Security를 처음 설정할 때는 보통 URL부터 막아요.
하지만 주문 상세 조회는 한 단계 더 들어가야 해요.
/api/orders/**라는 사실뿐이에요. 42번 주문의 owner가 현재 사용자와 같은가?는 DB에서 주문을 읽거나, 권한 정책을 따로 확인해야 알 수 있어요.
이 그림에서 URL 규칙은 대문이에요. 대문을 통과해도 방마다 권한이 다를 수 있죠. Method security는 이 방의 문, 즉 업무 method 경계에서 한 번 더 묻는 방식이에요.
Method security는 starter만 넣는다고 켜지지 않아요
Spring Boot에서spring-boot-starter-security를 넣으면 web application은 기본적으로 보호돼요. 지난 보안 글에서 본 것처럼 filter chain이 생기고, 기본 로그인이나 HTTP Basic 흐름이 준비될 수 있어요.
하지만 method-level authorization은 별도로 켜야 해요.
@EnableMethodSecurity를 추가하면 Spring Security가 method security용 interceptor를 등록해요. 그 다음 Spring이 관리하는 bean의 method에 @PreAuthorize, @PostAuthorize, @PreFilter, @PostFilter 같은 Annotation을 붙여서 method 호출을 검사할 수 있어요.
여기서 중요한 점이 있어요.
그래서 공식 문서도 method security를 쓸 때 unannotated method가 자동으로 보호되는 것은 아니라고 설명해요. 실무에서는 URL 단위의 catch-all 규칙도 같이 두는 편이 안전해요.
@PreAuthorize는 method 실행 전에 parameter와 인증 정보를 같이 봐요
가장 자주 만나는 Annotation은 @PreAuthorize예요. 이름 그대로 method가 실행되기 전에 조건을 확인해요.
SCOPE_orders:read 권한이 필요하다”라고 읽을 수 있어요. 지난 JWT 글에서 봤듯이 OAuth2 resource server는 scope나 scp claim을 SCOPE_... authority로 바꿔 읽을 수 있어요.
하지만 아직 소유권 검사는 없어요. orders:read scope가 “주문을 읽을 수 있는 종류의 token”이라는 뜻이라면, “42번 주문이 내 주문인가”는 별개의 질문이에요.
처음에는 이렇게 parameter와 인증 이름을 비교하고 싶을 수 있어요.
#customerId는 method parameter고, authentication.name은 현재 인증된 사용자 이름이에요. 둘이 같아야 method가 실행돼요.
그런데 실무에서는 path나 request body로 넘어온 customerId를 그대로 믿으면 위험할 수 있어요. 클라이언트가 그 값을 바꿔 보낼 수 있기 때문이에요. 그래서 소유권 검사는 보통 서버가 신뢰하는 데이터를 기준으로 해야 해요.
도메인 권한은 권한 전용 bean으로 빼면 읽기 쉬워져요
주문 하나를 읽는 use case로 돌아가볼게요.@orderPermission은 Spring bean 이름이에요. SpEL 표현식에서 bean method를 호출해서 권한 판단을 맡길 수 있어요.
이 방식의 장점은 “권한 판단이 service 코드 중간에 흩어지지 않는다”는 거예요.
if (!owner) throw ...가 여러 method에 퍼지는 대신, 권한 정책 이름을 method 경계에 드러낼 수 있어요.
하지만 비용도 있어요. OrderPermission.canRead(...)에서 DB를 한 번 보고, 실제 getOrder(...)에서 다시 주문을 조회하면 query가 두 번 나갈 수 있어요. 글로 보면 깔끔하지만, 조회량이 많거나 latency가 중요한 API에서는 설계를 더 봐야 해요.
대안은 use case에 따라 달라져요.
처음에는 단순하게 시작해도 돼요. 다만 중요한 건 “관리자 role이면 통과”와 “이 resource의 owner라서 통과”를 같은 말로 섞지 않는 거예요.
Role, scope, permission, owner는 다른 말이에요
보안 코드를 읽다 보면ROLE_ADMIN, SCOPE_orders:read, permission:order:read, owner check가 한 화면에 섞여요. 이름이 다 권한처럼 보여서 헷갈리죠.
이렇게 나눠보면 좋아요.
관리자 API처럼 역할 자체가 중요한 곳은 role이 자연스러워요.
@PostAuthorize는 반환값을 볼 수 있지만, 조회 후 거절이에요
가끔은 method를 실행해야만 권한 판단에 필요한 객체를 얻을 수 있어요. 이때 @PostAuthorize를 쓸 수 있어요.
returnObject는 method가 반환하려던 값이에요. 이 방식은 읽기 쉽고, “반환된 주문의 소유자가 현재 사용자와 같아야 한다”는 뜻도 분명해요.
하지만 조심할 점이 있어요. @PostAuthorize는 method 실행 후 판단해요. 즉, 이미 DB 조회와 domain 로직 일부가 실행된 다음에 거절될 수 있어요.
특히 쓰기 작업은 “실행하고 나서 권한 없으면 거절”이 안전하지 않아요. 주문 취소, 결제, 파일 삭제, 관리자 변경처럼 side effect가 있는 작업은 실행 전에 권한을 판단해야 해요.
Method security도 프록시 경계를 지나야 동작해요
이전 AOP 글과 transaction 글에서 봤던 함정이 여기서도 다시 나와요. Method security는 Spring AOP 기반으로 동작해요. 즉, 보안이 걸린 method 호출이 Spring이 만든 프록시(proxy)를 지나야 interceptor가 권한을 검사할 수 있어요. 문제가 되는 코드를 볼게요.getOrderForScreen(...)이 같은 객체 안의 getOrder(...)를 부르면 보통 프록시를 다시 거치지 않아요. 그러면 @PreAuthorize가 기대한 시점에 적용되지 않을 수 있어요. 이 문제를 self-invocation이라고 불러요.
이 그림에서 처음 controller 호출은 프록시를 지나요. 하지만 target 객체 안에서 자기 method를 다시 부르는 호출은 프록시가 볼 수 없는 내부 호출이에요.
해결은 보통 구조를 명확히 하는 쪽이에요.
OrderScreenService가 다른 Spring bean인 OrderQueryService를 호출하므로, method security 프록시가 호출을 볼 수 있어요.
테스트는 HTTP 테스트와 service method 테스트를 나눠요
Method security를 쓰면 테스트도 두 층으로 나눠 보는 게 좋아요. 첫 번째는 HTTP 입구 테스트예요.- 로그인하지 않은 요청이 401이 되는지
- 권한 없는 token이 403이 되는지
- public endpoint가 의도대로 열려 있는지
/api/admin/**같은 URL 규칙이 맞는지
@PreAuthorize가 실제로 막는지 확인해요.
gildong의 주문이라는 fixture를 먼저 만들어야 해요.
중요한 건 controller 테스트만으로 method security를 다 검증했다고 착각하지 않는 거예요. URL 규칙이 우연히 막아준 것인지, service method 경계가 실제로 막는 것인지 분리해서 봐야 해요.
실무에서는 권한 정책 이름을 먼저 설계해요
Method security가 들어가면 Annotation 표현식이 길어지기 쉬워요.
권한은 한 곳에만 있어야 깔끔하다는 생각보다, 각 경계가 무엇을 알 수 있는지를 기준으로 두는 편이 안전해요. URL은 path를 알고, service는 use case를 알고, repository는 데이터 조건을 알고, domain model은 업무 상태를 알아요.
참고한 링크
- Spring Boot Reference - Spring Security
- Spring Security Reference - Method Security
- Spring Security Reference - Authorize HttpServletRequests
- Spring Security Reference - Domain Object Security ACLs
- Spring Security Reference - Testing Method Security
자, 정리해볼까요?
- URL 규칙은 요청 입구를 막는 데 좋고, method security는 service method 경계에서 업무 권한을 검사하는 데 좋아요.
- Spring Boot Security starter만으로 method security가 자동 활성화되지는 않아요.
@EnableMethodSecurity를 명시해야 해요. @PreAuthorize는 method 실행 전에Authentication, authority, method parameter를 보고 접근을 결정할 수 있어요.- 도메인 소유권 검사는 role이나 scope만으로 끝나지 않아요. 서버가 신뢰하는 데이터로 owner를 확인해야 해요.
@PostAuthorize는 반환값을 볼 수 있지만 method 실행 후 판단하므로 side effect가 있는 쓰기 작업에는 조심해야 해요.- Method security도 프록시 경계를 지나야 동작하므로 self-invocation 함정을 피해야 해요.
- 테스트는 HTTP 입구 규칙과 service method 권한 규칙을 분리해서 확인하는 편이 좋아요.