Method Security — URL이 아닌 메서드 단위로 권한을 제어하는 방법
URL 패턴으로 권한을 제어하는 것만으로 충분할까요? 서비스 메서드 자체에 권한을 걸 수는 없을까요?
SecurityFilterChain에서 URL 기반으로 권한을 설정하는 것은 기본이지만, 복잡한 비즈니스 로직에서는 메서드 단위 로 세밀한 권한 제어가 필요합니다. Spring Security의 Method Security 가 이를 해결합니다.
개념 정의
Method Security 는 서비스 계층의 메서드에 직접 권한 검사를 적용하는 기능입니다. AOP를 기반으로 동작하며, 어노테이션으로 선언적 보안을 적용합니다.
설정
Method Security를 사용하려면 설정 클래스에 @EnableMethodSecurity를 추가합니다.
@Configuration
@EnableMethodSecurity // Spring Security 6+
public class MethodSecurityConfig {
}
이 어노테이션 하나로 @PreAuthorize, @PostAuthorize, @PreFilter, @PostFilter가 활성화됩니다. Spring Security 5의 @EnableGlobalMethodSecurity는 deprecated되었으므로 사용하지 않습니다.
@PreAuthorize — 메서드 실행 전 권한 검사
가장 많이 사용하는 어노테이션입니다. 메서드가 실행되기 전에 SpEL 표현식으로 권한을 검사합니다.
@Service
public class AdminService {
@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(Long userId) {
// ADMIN 권한이 없으면 AccessDeniedException 발생
userRepository.deleteById(userId);
}
@PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')")
public List<UserDto> getAllUsers() {
return userRepository.findAll().stream()
.map(UserDto::from)
.toList();
}
}
권한이 없으면 메서드 본문이 실행되지 않고 즉시 AccessDeniedException이 발생합니다. 이것이 @PostAuthorize와의 핵심 차이입니다.
SpEL 표현식
| 표현식 | 설명 |
|---|---|
hasRole('ADMIN') | ROLE_ADMIN 권한 보유 확인 |
hasAnyRole('ADMIN', 'USER') | 하나 이상의 역할 보유 |
hasAuthority('WRITE') | 특정 권한 보유 (ROLE_ 접두사 없이) |
isAuthenticated() | 인증된 사용자 |
isAnonymous() | 익명 사용자 |
#paramName | 메서드 파라미터 참조 |
authentication | 현재 Authentication 객체 |
principal | 현재 Principal 객체 |
파라미터 참조
SpEL에서 #파라미터명으로 메서드 인자를 참조할 수 있습니다. "본인만 수정 가능" 같은 조건을 표현할 때 유용합니다.
// 자신의 정보만 수정 가능
@PreAuthorize("#userId == authentication.principal.id")
public void updateProfile(Long userId, ProfileRequest request) {
// ...
}
// ADMIN이거나 본인만 접근 가능
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")
public UserDto getUser(Long userId) {
return userRepository.findById(userId)
.map(UserDto::from)
.orElseThrow();
}
authentication은 현재 SecurityContext의 Authentication 객체, principal은 UserDetails 구현체를 가리킵니다.
@PostAuthorize — 메서드 실행 후 권한 검사
메서드를 먼저 실행하고, 반환값을 기반으로 권한을 검사합니다.
@PostAuthorize("returnObject.createdBy == authentication.name")
public Document getDocument(Long documentId) {
return documentRepository.findById(documentId).orElseThrow();
// 반환된 Document의 createdBy가 현재 사용자가 아니면 AccessDeniedException
}
returnObject는 메서드의 반환값을 참조하는 SpEL 변수입니다.
@PostAuthorize와 부수 효과
@PostAuthorize는 메서드를 ** 먼저 실행 **합니다. 따라서 부수 효과(side effect)가 있는 메서드에 사용하면 위험합니다.
// 위험! 메서드가 먼저 실행되므로 삭제가 먼저 수행됨
@PostAuthorize("returnObject.owner == authentication.name")
public Document deleteDocument(Long id) {
// 삭제 후에 권한 검사 → 이미 삭제됨!
return documentRepository.delete(id);
}
삭제, 수정 같은 변경 작업에는 반드시 @PreAuthorize를 사용해야 합니다. @PostAuthorize는 조회 메서드에서 "본인이 작성한 문서만 반환"처럼 반환값 기반 검증에만 적합합니다.
@Secured
간단한 역할 기반 검사에 사용합니다. SpEL을 지원하지 않습니다.
@Secured("ROLE_ADMIN")
public void adminOnly() { ... }
@Secured({"ROLE_ADMIN", "ROLE_MANAGER"})
public void adminOrManager() { ... }
@EnableMethodSecurity(securedEnabled = true)로 활성화해야 합니다.
@PreFilter / @PostFilter
컬렉션의 요소를 필터링합니다.
// 입력 리스트에서 현재 사용자 소유의 항목만 필터링
@PreFilter("filterObject.owner == authentication.name")
public void deleteDocuments(List<Document> documents) {
documentRepository.deleteAll(documents);
}
// 반환 리스트에서 현재 사용자 소유의 항목만 필터링
@PostFilter("filterObject.owner == authentication.name")
public List<Document> getAllDocuments() {
return documentRepository.findAll();
}
filterObject는 컬렉션의 각 요소를 참조합니다.
커스텀 권한 로직
SpEL에서 스프링 빈을 참조하여 복잡한 권한 검사를 수행할 수 있습니다.
// 커스텀 권한 검증 빈
@Component("authz")
public class AuthorizationChecker {
private final TeamMemberRepository teamMemberRepository;
public boolean isTeamMember(Long teamId, Long userId) {
return teamMemberRepository.existsByTeamIdAndUserId(teamId, userId);
}
public boolean isDocumentOwner(Long documentId, String username) {
return documentRepository.findById(documentId)
.map(doc -> doc.getCreatedBy().equals(username))
.orElse(false);
}
}
// SpEL에서 커스텀 빈 참조
@PreAuthorize("@authz.isTeamMember(#teamId, authentication.principal.id)")
public void addMemberToTeam(Long teamId, MemberRequest request) {
// ...
}
@PreAuthorize("@authz.isDocumentOwner(#documentId, authentication.name) " +
"or hasRole('ADMIN')")
public void editDocument(Long documentId, DocumentRequest request) {
// ...
}
커스텀 메타 어노테이션
자주 사용하는 권한 검사를 메타 어노테이션으로 만들 수 있습니다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('ADMIN')")
public @interface AdminOnly {
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")
public @interface AdminOrSelf {
}
@Service
public class UserService {
@AdminOnly
public void deleteUser(Long userId) { ... }
@AdminOrSelf
public UserDto getUser(Long userId) { ... }
}
주의할 점
같은 클래스 내부 호출에서 @PreAuthorize가 무시되는 문제
Method Security는 AOP 프록시 기반으로 동작합니다. 같은 클래스 내에서 this.method()로 호출하면 프록시를 거치지 않기 때문에 @PreAuthorize가 적용되지 않습니다.
@Service
public class DocumentService {
public void process(Long docId) {
this.deleteDocument(docId); // ← 프록시를 거치지 않음! 권한 검사 X
}
@PreAuthorize("hasRole('ADMIN')")
public void deleteDocument(Long docId) { ... }
}
이것은 @Transactional의 self-invocation 문제와 동일한 원리입니다. 해결하려면 클래스를 분리하거나 AopContext.currentProxy()를 사용해야 합니다.
hasRole과 hasAuthority의 ROLE_ 접두사 혼동
hasRole('ADMIN')은 내부적으로 ROLE_ADMIN 권한을 확인합니다. DB에 권한을 ADMIN으로 저장했다면 hasAuthority('ADMIN')을 사용해야 합니다. hasRole('ROLE_ADMIN')으로 쓰면 ROLE_ROLE_ADMIN을 찾게 되어 항상 권한 거부가 발생합니다.
@PreAuthorize 표현식에서 SpEL 오류가 500으로 반환되는 문제
SpEL 표현식에 오타가 있으면(#userld 대신 #userId 등) 런타임에 SpelEvaluationException이 발생하여 500 Internal Server Error가 반환됩니다. 컴파일 타임에 검증되지 않으므로 ** 테스트가 필수 **입니다.
정리
| 항목 | 설명 |
|---|---|
| 활성화 | @EnableMethodSecurity (Spring Security 6+) |
| @PreAuthorize | 메서드 실행 ** 전** 권한 검사 — 변경 작업에 적합 |
| @PostAuthorize | 메서드 실행 ** 후** 반환값 기반 검사 — 조회 작업에 적합 |
| SpEL 참조 | #파라미터, authentication, principal, returnObject |
| 커스텀 빈 | @빈이름.메서드() 형태로 복잡한 권한 로직 위임 |
| 메타 어노테이션 | @AdminOnly 같은 커스텀 어노테이션으로 재사용 |
| 동작 원리 | AOP 프록시 기반 — 내부 호출 시 권한 검사 미적용 |