URL 단위로는 한계가 있다 — 메소드 수준 권한 부여
authorizeHttpRequests로 URL 패턴에 권한을 거는 방식은 API가 몇 십 개일 때는 잘 작동합니다. 문제는 하나의 URL 안에서도 상황에 따라 권한이 갈리는 경우입니다. "이 게시글은 작성자 본인이거나 관리자만 수정할 수 있다"는 규칙을 URL 패턴만으로는 표현할 수 없습니다. /posts/{id} 라는 경로만 봐서는 그 게시글의 작성자가 누구인지 알 방법이 없기 때문입니다.
@EnableMethodSecurity 켜기
메소드 수준 권한 부여는 별도로 활성화해야 합니다.
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
// ...
}이걸 켜면 @PreAuthorize, @PostAuthorize, @Secured 같은 애너테이션을 서비스 메소드에 붙일 수 있습니다. 실무에서 가장 많이 쓰는 건 @PreAuthorize입니다.
@Service
public class PostService {
@PreAuthorize("hasRole('ADMIN')")
public void deleteAnyPost(Long postId) {
postRepository.deleteById(postId);
}
@PreAuthorize("@postSecurity.isOwner(#postId, authentication.name)")
public void updatePost(Long postId, PostUpdateRequest request) {
Post post = postRepository.findById(postId).orElseThrow();
post.update(request);
}
}hasRole('ADMIN') 같은 단순한 표현식은 읽기 쉽지만, "본인 글인지" 같은 판단은 SpEL만으로 표현하기 버겁습니다. 그래서 두 번째 예시처럼 @postSecurity.isOwner(...)로 별도의 빈에 판단 로직을 위임하는 패턴을 씁니다.
@Component("postSecurity")
public class PostSecurity {
private final PostRepository postRepository;
public boolean isOwner(Long postId, String username) {
return postRepository.findById(postId)
.map(post -> post.getAuthorUsername().equals(username))
.orElse(false);
}
}이 방식이 SpEL 문자열 안에 로직을 다 우겨넣는 것보다 낫다고 생각합니다. @postSecurity.isOwner(...)는 그냥 자바 코드라서 디버거로 중단점을 걸 수 있고, 단위 테스트도 평범하게 짤 수 있습니다. 반면 @PreAuthorize("#post.author == authentication.name and (#post.status == 'DRAFT' or hasRole('EDITOR'))") 처럼 조건이 두세 개만 겹쳐도 SpEL 표현식은 읽기 괴로워지고, 오타가 나도 컴파일 시점에는 안 잡힙니다.
@PreAuthorize와 @PostAuthorize의 차이
이름 그대로 실행 시점이 다릅니다. @PreAuthorize는 메소드를 실행하기 전에 조건을 평가하고, 조건이 거짓이면 메소드 본문은 아예 실행되지 않습니다. @PostAuthorize는 메소드를 실행한 뒤 반환값을 갖고 조건을 평가합니다.
@PostAuthorize("returnObject.authorUsername == authentication.name")
public Post getPost(Long postId) {
return postRepository.findById(postId).orElseThrow();
}@PostAuthorize가 필요한 경우는 "권한 판단에 필요한 데이터가 메소드를 실행해봐야 나오는" 상황뿐입니다. 위 예시처럼 조회 결과의 작성자를 확인해야 하는 경우가 그렇습니다. 반대로 미리 판단할 수 있는 조건까지 @PostAuthorize로 미루면, 권한이 없는 요청도 일단 DB 조회나 비즈니스 로직을 다 실행한 뒤에야 거부하게 됩니다. 불필요한 낭비이고, 부작용이 있는 메소드라면 위험하기까지 합니다. 가능하면 항상 @PreAuthorize를 먼저 검토하고, 정말 실행 결과가 있어야만 판단 가능한 경우에만 @PostAuthorize를 씁니다.
컨트롤러 vs 서비스, 어디에 붙일 것인가
URL 기반 규칙은 SecurityConfig에, 메소드 기반 규칙은 어디에 붙여야 할까요. 저는 서비스 계층을 권합니다. 컨트롤러에 @PreAuthorize를 붙이면 "이 API를 누가 호출할 수 있는가"는 알 수 있지만, 같은 서비스 메소드를 다른 경로(배치 작업, 다른 컨트롤러, 향후 추가될 GraphQL 리졸버 등)에서 재사용할 때 그 경로에는 검증이 빠질 위험이 있습니다. 서비스 계층에 붙여두면 호출 경로와 무관하게 규칙이 항상 적용됩니다. 물론 트레이드오프도 있습니다 — 서비스 메소드가 어떤 HTTP 요청 문맥에서 호출되는지 감춰지므로, 정말 성능이 민감한 내부 배치성 코드에서 이 오버헤드가 문제가 된다면 예외적으로 컨트롤러 쪽에서 처리하는 걸 고려할 수 있습니다. 하지만 이건 정말 예외적인 경우고, 기본값은 서비스 계층입니다.