Spring Method Security란?
Spring Security는 URL 기반 필터 체인 외에도 메서드 레벨 보안을 제공합니다. 서비스 계층의 개별 메서드에 직접 권한 검증 로직을 선언할 수 있어, 컨트롤러 외부에서 호출되는 비즈니스 로직까지 보호할 수 있습니다. Spring Boot 3.x부터는 @EnableMethodSecurity가 기본 설정이 되었고, SpEL 기반의 강력한 표현식 엔진을 활용합니다.
@EnableMethodSecurity 설정
Spring Boot 3.x에서는 @EnableGlobalMethodSecurity가 deprecated되었습니다. 새로운 @EnableMethodSecurity를 사용합니다.
@Configuration
@EnableMethodSecurity(
// prePostEnabled = true (기본값)
// securedEnabled = false (기본값)
// jsr250Enabled = false (기본값)
)
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
)
.build();
}
}
| 옵션 | 어노테이션 | 특징 |
|---|---|---|
prePostEnabled |
@PreAuthorize, @PostAuthorize, @PreFilter, @PostFilter | SpEL 표현식 지원, 가장 강력 |
securedEnabled |
@Secured | 단순 역할 기반, SpEL 미지원 |
jsr250Enabled |
@RolesAllowed, @DenyAll, @PermitAll | 표준 JSR-250 어노테이션 |
@PreAuthorize 심화: SpEL 표현식
@PreAuthorize는 메서드 실행 전에 권한을 검증합니다. SpEL(Spring Expression Language)을 활용하여 메서드 파라미터, 인증 객체, 빈 참조까지 조합한 복잡한 권한 로직을 선언적으로 표현할 수 있습니다.
@Service
public class OrderService {
// 기본: 역할 확인
@PreAuthorize("hasRole('ADMIN')")
public void deleteOrder(Long orderId) { /* ... */ }
// 복합 조건: 역할 OR 본인 소유
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")
public Order getOrder(Long userId, Long orderId) { /* ... */ }
// 메서드 파라미터 객체 필드 접근
@PreAuthorize("#request.departmentId == authentication.principal.departmentId")
public Order createOrder(OrderRequest request) { /* ... */ }
// 커스텀 빈 호출
@PreAuthorize("@orderPolicy.canAccess(authentication, #orderId)")
public Order findOrder(Long orderId) { /* ... */ }
// 여러 권한 중 하나 보유
@PreAuthorize("hasAnyAuthority('order:read', 'order:admin')")
public List<Order> listOrders() { /* ... */ }
}
@PostAuthorize: 반환값 기반 검증
@PostAuthorize는 메서드 실행 후 반환값을 기반으로 권한을 검증합니다. returnObject로 반환 객체에 접근할 수 있어, 데이터 소유권 검증에 유용합니다.
@Service
public class DocumentService {
// 반환된 문서의 소유자만 접근 가능
@PostAuthorize("returnObject.ownerId == authentication.principal.id or hasRole('ADMIN')")
public Document findDocument(Long docId) {
return documentRepository.findById(docId)
.orElseThrow(() -> new NotFoundException("Document not found"));
}
// Optional 반환 시 처리
@PostAuthorize("returnObject?.ownerId == authentication.principal.id")
public Document findBySlug(String slug) {
return documentRepository.findBySlug(slug).orElse(null);
}
}
주의: @PostAuthorize는 메서드가 실행된 후 검증하므로, 부작용(side effect)이 있는 메서드에는 적합하지 않습니다. 조회성 메서드에서만 사용하세요.
@PreFilter / @PostFilter: 컬렉션 필터링
컬렉션 파라미터나 반환값에서 권한 조건에 맞지 않는 요소를 자동으로 필터링합니다.
@Service
public class ProjectService {
// 입력 컬렉션에서 본인 부서 프로젝트만 통과
@PreFilter("filterObject.departmentId == authentication.principal.departmentId")
public void batchUpdate(List<Project> projects) {
projectRepository.saveAll(projects);
}
// 반환 컬렉션에서 접근 가능한 항목만 필터링
@PostFilter("filterObject.isPublic or filterObject.ownerId == authentication.principal.id")
public List<Project> findAll() {
return projectRepository.findAll();
}
}
성능 주의: @PostFilter는 전체 데이터를 먼저 조회한 뒤 메모리에서 필터링합니다. 대량 데이터에서는 쿼리 레벨 필터링(WHERE 절)이 더 효율적입니다.
커스텀 권한 평가자(Policy Bean)
복잡한 비즈니스 권한 로직은 SpEL 표현식에 직접 쓰기보다, 별도 빈으로 분리하여 @PreAuthorize에서 호출하는 패턴이 권장됩니다.
@Component("projectPolicy")
public class ProjectPolicy {
private final ProjectMemberRepository memberRepo;
public ProjectPolicy(ProjectMemberRepository memberRepo) {
this.memberRepo = memberRepo;
}
public boolean canEdit(Authentication auth, Long projectId) {
Long userId = ((CustomPrincipal) auth.getPrincipal()).getId();
return memberRepo.existsByProjectIdAndUserIdAndRoleIn(
projectId, userId, List.of("OWNER", "EDITOR")
);
}
public boolean canDelete(Authentication auth, Long projectId) {
Long userId = ((CustomPrincipal) auth.getPrincipal()).getId();
return memberRepo.existsByProjectIdAndUserIdAndRole(
projectId, userId, "OWNER"
);
}
}
@Service
public class ProjectService {
@PreAuthorize("@projectPolicy.canEdit(authentication, #projectId)")
public void updateProject(Long projectId, ProjectUpdateDto dto) { /* ... */ }
@PreAuthorize("@projectPolicy.canDelete(authentication, #projectId)")
public void deleteProject(Long projectId) { /* ... */ }
}
이 패턴은 Spring AOP 기반으로 동작하며, 프록시를 통해 메서드 호출이 인터셉트됩니다. 따라서 같은 클래스 내부 호출(self-invocation)에서는 @PreAuthorize가 작동하지 않는다는 점에 주의하세요.
커스텀 메타 어노테이션
반복되는 권한 표현식을 커스텀 어노테이션으로 추상화하면 가독성과 유지보수성이 향상됩니다.
// 커스텀 메타 어노테이션 정의
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('ADMIN') or @projectPolicy.canEdit(authentication, #projectId)")
public @interface CanEditProject {}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('ADMIN')")
public @interface AdminOnly {}
// 사용
@Service
public class ProjectService {
@CanEditProject
public void updateProject(Long projectId, ProjectUpdateDto dto) { /* ... */ }
@AdminOnly
public void resetAllProjects() { /* ... */ }
}
MethodSecurityExpressionHandler 커스터마이징
기본 SpEL 함수 외에 커스텀 루트 객체나 함수를 등록하여 표현식을 확장할 수 있습니다.
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
@Bean
static MethodSecurityExpressionHandler methodSecurityExpressionHandler(
PermissionEvaluator permissionEvaluator) {
DefaultMethodSecurityExpressionHandler handler =
new DefaultMethodSecurityExpressionHandler();
handler.setPermissionEvaluator(permissionEvaluator);
return handler;
}
}
@Component
public class CustomPermissionEvaluator implements PermissionEvaluator {
private final AclService aclService;
public CustomPermissionEvaluator(AclService aclService) {
this.aclService = aclService;
}
@Override
public boolean hasPermission(Authentication auth, Object target, Object permission) {
if (target instanceof Document doc) {
return aclService.hasAccess(auth, doc.getId(), (String) permission);
}
return false;
}
@Override
public boolean hasPermission(Authentication auth, Serializable targetId,
String targetType, Object permission) {
return aclService.hasAccess(auth, (Long) targetId, (String) permission);
}
}
// 사용: hasPermission SpEL 함수 활용
@Service
public class DocumentService {
@PreAuthorize("hasPermission(#docId, 'Document', 'WRITE')")
public void updateDocument(Long docId, DocumentDto dto) { /* ... */ }
@PostAuthorize("hasPermission(returnObject, 'READ')")
public Document getDocument(Long docId) {
return documentRepository.findById(docId).orElseThrow();
}
}
테스트: @WithMockUser와 커스텀 SecurityContext
Method Security 테스트 시 Spring 테스트 프레임워크의 보안 테스트 지원을 활용합니다.
@SpringBootTest
class ProjectServiceSecurityTest {
@Autowired
private ProjectService projectService;
// 기본 역할 기반 테스트
@Test
@WithMockUser(roles = "ADMIN")
void adminCanDeleteProject() {
assertDoesNotThrow(() -> projectService.deleteProject(1L));
}
@Test
@WithMockUser(roles = "USER")
void userCannotDeleteProject() {
assertThrows(AccessDeniedException.class,
() -> projectService.deleteProject(1L));
}
// 커스텀 principal이 필요한 경우
@Test
void ownerCanEditProject() {
CustomPrincipal principal = new CustomPrincipal(100L, "owner", List.of());
Authentication auth = new UsernamePasswordAuthenticationToken(
principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))
);
SecurityContextHolder.getContext().setAuthentication(auth);
assertDoesNotThrow(() -> projectService.updateProject(1L, new ProjectUpdateDto()));
}
// 커스텀 @WithMockUser 어노테이션
@Test
@WithCustomUser(id = 100, roles = {"USER"}, departmentId = 5)
void departmentMemberCanAccess() {
assertDoesNotThrow(() -> projectService.listByDepartment(5L));
}
}
운영 베스트 프랙티스
- Policy Bean 패턴 사용: 복잡한 권한 로직은 @PreAuthorize SpEL에 직접 쓰지 말고 별도 빈으로 분리하세요
- 메타 어노테이션 활용: 반복되는 권한 표현식은 커스텀 어노테이션으로 추상화하세요
- self-invocation 주의: 같은 클래스 내부 메서드 호출에서는 AOP 프록시가 작동하지 않습니다
- @PostFilter 대신 쿼리 필터링: 대량 데이터 환경에서는 DB 쿼리 레벨에서 필터링하세요
- @PostAuthorize는 조회 전용: 부작용이 있는 메서드에는 @PreAuthorize를 사용하세요
- 테스트 필수: 권한 로직은 반드시 단위 테스트로 검증하세요 — 런타임에 발견되면 보안 사고입니다