Method Security란?
Spring Security의 Method Security는 URL 기반 필터 체인이 아닌, 메서드 단위로 권한을 검사하는 기능입니다. @PreAuthorize, @PostAuthorize, @Secured 어노테이션으로 서비스 레이어에서 직접 인가 로직을 선언합니다.
URL 패턴 기반 보안만으로는 한계가 있습니다. 같은 엔드포인트라도 리소스 소유자만 수정 가능하거나, 관리자는 모든 데이터 조회, 일반 사용자는 자기 데이터만 조회하는 규칙은 Method Security로 구현해야 합니다. 이 글에서는 SpEL 기반 표현식, 커스텀 권한 평가자, 도메인 객체 수준 보안까지 심화 분석합니다.
활성화와 기본 설정
@Configuration
@EnableMethodSecurity(
prePostEnabled = true, // @PreAuthorize, @PostAuthorize 활성화
securedEnabled = true, // @Secured 활성화
jsr250Enabled = true // @RolesAllowed 활성화
)
public class MethodSecurityConfig {
// Spring Boot 3.x에서는 @EnableMethodSecurity 사용
// (기존 @EnableGlobalMethodSecurity는 deprecated)
}
Spring Boot 3.x부터 @EnableMethodSecurity가 기본이며, @EnableGlobalMethodSecurity는 더 이상 권장되지 않습니다. 가장 큰 차이는 기본적으로 모든 어노테이션을 AOP 어드바이저 기반으로 처리한다는 점입니다.
@PreAuthorize: 실행 전 권한 검사
@PreAuthorize는 SpEL(Spring Expression Language)로 메서드 실행 전 권한을 평가합니다:
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
// 역할 기반 접근 제어
@PreAuthorize("hasRole('ADMIN')")
public List<User> getAllUsers() {
return userRepository.findAll();
}
// 여러 역할 허용
@PreAuthorize("hasAnyRole('ADMIN', 'MODERATOR')")
public void suspendUser(Long userId) {
userRepository.updateStatus(userId, UserStatus.SUSPENDED);
}
// 권한(Authority) 기반 검사
@PreAuthorize("hasAuthority('USER_DELETE')")
public void deleteUser(Long userId) {
userRepository.deleteById(userId);
}
// 메서드 파라미터 참조: 자기 자신만 수정 가능
@PreAuthorize("#userId == authentication.principal.id")
public void updateProfile(Long userId, ProfileDto dto) {
User user = userRepository.findById(userId).orElseThrow();
user.updateProfile(dto);
userRepository.save(user);
}
// 복합 조건: 관리자이거나 자기 자신
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")
public UserDto getUser(Long userId) {
return UserDto.from(userRepository.findById(userId).orElseThrow());
}
// 파라미터 객체의 필드 참조
@PreAuthorize("#dto.email == authentication.principal.email or hasRole('ADMIN')")
public void updateEmail(UpdateEmailDto dto) {
// ...
}
}
@PostAuthorize: 실행 후 결과 기반 검사
@PostAuthorize는 메서드가 실행된 후 반환값을 기반으로 권한을 검사합니다:
@Service
public class DocumentService {
// 반환된 문서의 소유자만 조회 가능 (아니면 AccessDeniedException)
@PostAuthorize("returnObject.ownerId == authentication.principal.id or hasRole('ADMIN')")
public Document getDocument(Long documentId) {
return documentRepository.findById(documentId).orElseThrow();
}
// 반환된 리스트에서 비공개 문서 필터링
@PostFilter("filterObject.isPublic or filterObject.ownerId == authentication.principal.id")
public List<Document> searchDocuments(String keyword) {
return documentRepository.searchByKeyword(keyword);
}
// @PreFilter: 입력 컬렉션 필터링
@PreFilter("filterObject.departmentId == authentication.principal.departmentId")
public void batchUpdate(List<Document> documents) {
documentRepository.saveAll(documents);
}
}
주의: @PostAuthorize는 메서드가 먼저 실행되므로, 부수효과(DB 변경 등)가 있는 메서드에서는 사용하지 마세요. 조회 메서드에만 사용하는 것이 안전합니다.
커스텀 Security Expression
복잡한 권한 로직을 SpEL에 직접 작성하면 가독성이 떨어집니다. 커스텀 메서드를 등록하여 깔끔하게 처리합니다:
// 커스텀 보안 서비스
@Component("authz")
@RequiredArgsConstructor
public class AuthorizationService {
private final TeamMemberRepository teamMemberRepo;
private final DocumentRepository documentRepo;
// 팀 멤버인지 확인
public boolean isTeamMember(Long teamId, Authentication auth) {
Long userId = ((UserPrincipal) auth.getPrincipal()).getId();
return teamMemberRepo.existsByTeamIdAndUserId(teamId, userId);
}
// 문서 소유자 또는 팀 멤버인지 확인
public boolean canAccessDocument(Long documentId, Authentication auth) {
Long userId = ((UserPrincipal) auth.getPrincipal()).getId();
Document doc = documentRepo.findById(documentId).orElse(null);
if (doc == null) return false;
if (doc.getOwnerId().equals(userId)) return true;
return teamMemberRepo.existsByTeamIdAndUserId(doc.getTeamId(), userId);
}
// 부서 관리자인지 확인
public boolean isDepartmentManager(Long departmentId, Authentication auth) {
UserPrincipal principal = (UserPrincipal) auth.getPrincipal();
return principal.getManagedDepartments().contains(departmentId);
}
}
// 서비스에서 사용 — 깔끔한 SpEL
@Service
public class TeamService {
@PreAuthorize("@authz.isTeamMember(#teamId, authentication)")
public TeamDto getTeam(Long teamId) {
return TeamDto.from(teamRepository.findById(teamId).orElseThrow());
}
@PreAuthorize("@authz.canAccessDocument(#documentId, authentication)")
public Document getDocument(Long documentId) {
return documentRepository.findById(documentId).orElseThrow();
}
@PreAuthorize("@authz.isDepartmentManager(#deptId, authentication)")
public void assignTeamLead(Long deptId, Long userId) {
// ...
}
}
메타 어노테이션: 재사용 가능한 보안 규칙
반복되는 권한 표현식을 커스텀 어노테이션으로 추출합니다:
// 커스텀 보안 어노테이션 정의
@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 {}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("@authz.isTeamMember(#teamId, authentication)")
public @interface TeamMemberOnly {}
// 사용 — 의도가 명확해짐
@Service
public class AdminService {
@AdminOnly
public SystemStats getSystemStats() { /* ... */ }
@AdminOrSelf
public UserDetail getUserDetail(Long userId) { /* ... */ }
@TeamMemberOnly
public List<Task> getTeamTasks(Long teamId) { /* ... */ }
}
PermissionEvaluator: 도메인 객체 권한
Spring Security의 hasPermission() 표현식을 커스텀 구현하면, ACL 스타일의 도메인 객체 수준 권한 검사가 가능합니다:
@Component
public class CustomPermissionEvaluator implements PermissionEvaluator {
@Autowired
private DocumentPermissionService docPermService;
@Autowired
private ProjectPermissionService projectPermService;
@Override
public boolean hasPermission(
Authentication auth, Object targetDomainObject, Object permission) {
if (targetDomainObject instanceof Document doc) {
return docPermService.hasPermission(
getUserId(auth), doc, (String) permission);
}
if (targetDomainObject instanceof Project project) {
return projectPermService.hasPermission(
getUserId(auth), project, (String) permission);
}
return false;
}
@Override
public boolean hasPermission(
Authentication auth, Serializable targetId,
String targetType, Object permission) {
return switch (targetType) {
case "Document" -> docPermService.hasPermission(
getUserId(auth), (Long) targetId, (String) permission);
case "Project" -> projectPermService.hasPermission(
getUserId(auth), (Long) targetId, (String) permission);
default -> false;
};
}
private Long getUserId(Authentication auth) {
return ((UserPrincipal) auth.getPrincipal()).getId();
}
}
// 설정 등록
@Configuration
public class PermissionConfig {
@Bean
static MethodSecurityExpressionHandler methodSecurityExpressionHandler(
CustomPermissionEvaluator evaluator) {
var handler = new DefaultMethodSecurityExpressionHandler();
handler.setPermissionEvaluator(evaluator);
return handler;
}
}
// 사용
@Service
public class DocumentService {
@PreAuthorize("hasPermission(#docId, 'Document', 'READ')")
public Document getDocument(Long docId) { /* ... */ }
@PreAuthorize("hasPermission(#docId, 'Document', 'WRITE')")
public void updateDocument(Long docId, DocumentDto dto) { /* ... */ }
@PreAuthorize("hasPermission(#doc, 'DELETE')")
public void deleteDocument(Document doc) { /* ... */ }
}
테스트: @WithMockUser
@SpringBootTest
class UserServiceSecurityTest {
@Autowired
private UserService userService;
@Test
@WithMockUser(roles = "ADMIN")
void adminCanGetAllUsers() {
List<User> users = userService.getAllUsers();
assertThat(users).isNotEmpty();
}
@Test
@WithMockUser(roles = "USER")
void regularUserCannotGetAllUsers() {
assertThatThrownBy(() -> userService.getAllUsers())
.isInstanceOf(AccessDeniedException.class);
}
// 커스텀 인증 컨텍스트
@Test
@WithMockUser(username = "user1", authorities = {"USER_DELETE", "ROLE_MODERATOR"})
void moderatorWithDeletePermission() {
assertDoesNotThrow(() -> userService.deleteUser(1L));
}
// 커스텀 어노테이션으로 반복 제거
@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(roles = "ADMIN", username = "admin@test.com")
@interface WithAdmin {}
@Test
@WithAdmin
void adminTest() { /* ... */ }
}
관련 글: Spring AOP 프록시 심화 가이드에서 AOP 기반 메서드 인터셉션 원리를, Spring Actuator 운영 심화에서 Actuator 보안 설정을 함께 확인하세요.
마무리
Spring Method Security는 URL 패턴 보안의 한계를 메서드 수준으로 확장합니다. @PreAuthorize SpEL로 파라미터 기반 권한 검사, 커스텀 @Component를 SpEL에서 호출하여 복잡한 비즈니스 규칙 캡슐화, PermissionEvaluator로 도메인 객체 수준 ACL 구현 — 이 세 패턴으로 대부분의 인가 요구사항을 깔끔하게 처리할 수 있습니다. 메타 어노테이션으로 의도를 명확히 하고, @WithMockUser로 보안 로직을 반드시 테스트하세요.