Spring Method Security 심화

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를 사용하세요
  • 테스트 필수: 권한 로직은 반드시 단위 테스트로 검증하세요 — 런타임에 발견되면 보안 사고입니다
위로 스크롤
WordPress Appliance - Powered by TurnKey Linux