1. 배경
이번 팀 프로젝트에서 상기 논리 구조를 가지고 Spring Security를 사용하게 되었습니다. 처음 목표는 권한 검증이 필터 체인에서 처리되도록 하는 것이였지만, Security Config의 가독성있게 관리하자는 2차 목표로 발전하게 되었습니다.
2. 발단: 권한 검증이 글로벌 예외처리 핸들러에서 처리되다.
Security Config에서 경로에 따른 권한 규칙을 설정했고, 검증 실패 시 처리해 줄 JwtAccessDeniedHandler를 만들어 뒀기에 CUSTOMER 권한을 가진 유저가 OWNER 권한 API 접근 시 검증 실패에 대한 예외를 JwtAccessDeniedHandler가 잡아 줄 것으로 기대했습니다.
하지만 예외가 글로벌 예외처리 핸들러로 전달되면서 500 ERROR가 발생하는 문제가 발생했습니다.
3. 의문: AccessDeniedException이 어떻게 MVC로 넘어왔을까?
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write("{\"error\": \"Access denied\", \"message\": \"Access Denied\"}");
}
}
반환 메시지 Access Denied가 기존에 만들어 둔 JwtAccessDeniedHandler의 메세지 Access Denied와 동일했기에 이 예외가 JwtAccessDeniedHandler에서 터진 것이라 잘못 인지했고, 왜 MVC로 넘어온 것인지에 대해서만 원인을 찾으려고 시도했습니다.
4. 가정: JwtAccessDeniedHandler가 동작했다면 MVC로 넘어올 수 없다
해당 가정을 근거로 JwtAccessDeniedHandler의 처리 여부를 확인하고자 message 변경을 통해 디버깅해 본 결과 JwtAccessDeniedHandler를 무시하고 MVC로 넘어온 것을 확인할 수 있었습니다.
글로벌 예외처리 핸들러가 AccessDeniedException을 처리하려고 했지만 이 예외를 처리할 마땅한 커스텀 로직의 부재로 스프링 기본 예외 처리 흐름으로 처리된 것이었습니다.
5. 원인: @PreAuthorize = 메서드 단위 검증 방법
근본적인 원인으로는 Config 설정을 잘못한 데 있었습니다. .requestMatchers(/api/**).hasAnyRole("OWNER","CUSTOMER")는 HTTP 요청 수준에서 동작하며, 특정 경로( /api/**)에 대한 접근 권한을 설정합니다. @PreAuthorize는 Http Security와는 별개로 메서드 수준에서 독립적으로 동작하는 검증 수행 기능이기 때문에 글로벌 예외처리 핸들러가 동작하는게 맞습니다.
따라서 HttpSecurity 설정과 @PreAuthorize는 서로 아무런 영향을 주지 않습니다.
6. 의사결정: 커스텀 로직 vs 필터체인에서 처리
6.1 커스텀 로직 사용
// AccessDeniedException 처리 추가
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponse> handleAccessDeniedException(AccessDeniedException e) {
log.error("AccessDeniedException", e);
return ResponseEntity
.status(HttpStatus.FORBIDDEN)
.body(ErrorResponse.of(ErrorCode.FORBIDDEN_ACCESS, "접근 권한이 없습니다."));
}
글로벌 예외처리 핸들러에 AccessDeniedException을 처리할 수 있는 커스텀 로직을 추가하면 이 문제는 간단히 해결되긴 합니다. 하지만 처음 목표인 필터 체인에서 권한 검증 처리는 아직 해결되지 않은 상태입니다. 이대로 끝낸다면 Security에서 제공하는 기능을 재대로 활용하지 못하고 모든 검증 책임을 @PreAuthorize에 떠넘기는 것과 같습니다.
6.2 필터체인에서 처리
현 프로젝트는 경로 구조가 대부분 (/api/users/**, /api/stores/**)와 같은 간단하면서 고정적인 경로이므로, 이런 부분에 한해서는 필터 체인을 활용하는 방식이 더 효율적이라는 생각에 무게가 실렸습니다.
6.3 고려사항
- 도메인이 많아질 경우 Security Config가 비대해질 수 있다.
- @PreAuthorize와 Security Config에 대한 사용 규칙을 정하지 않으면 추후 유지보수가 더 어려워질 수 있다.
7. 문제해결: 모듈화 및 사용 규칙 설정
private void configureStores(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
// stores
.requestMatchers(HttpMethod.GET, "/api/stores/**").hasAnyRole("CUSTOMER", "OWNER")
.requestMatchers("/api/stores/**").hasRole("OWNER"));
}
Security Config는 정적인 경로 기반 접근 제어를 담당하고, @PreAuthorize는 동적인 세부적인 비즈니스 로직 단위 권한 검증을 담당하도록 규칙을 정했습니다. Stores 도메인의 경우 GET을 제외하면 OWNER만을 위한 리소스이므로 필터체인에서 처리해도 추후 유지보수에 큰 무리가 없을 것으로 판단했습니다.
@PostMapping
@Operation(summary = "주문 생성")
public ResponseEntity<ApiResponse<CreateOrderResponseDto>> creatOrder(
@AuthenticationPrincipal Long userId,
@Valid @RequestBody CreateOrderRequestDto createOrderRequestDto
) {
orderService.createOrder(userId, createOrderRequestDto);
return new ResponseEntity<>(ApiResponse.success("음식을 주문했습니다."), HttpStatus.CREATED);
}
@PatchMapping("/{orderId}/status")
@PreAuthorize("hasRole('OWNER')")
@Operation(summary = "주문 상태 변경", description = "사장님(OWNER) 권한을 가진 사용자만 주문 상태를 변경할 수 있습니다.")
public ResponseEntity<ApiResponse<ChangeOrderStatusResponseDto>> updateOrderStatus(
@AuthenticationPrincipal Long userId,
@PathVariable Long orderId,
@Valid @RequestBody ChangeOrderStatusRequestDto requestDto
) {
Order order = orderService.updateOrderStatus(userId, orderId, requestDto);
ChangeOrderStatusResponseDto responseDto = new ChangeOrderStatusResponseDto(order.getOrderStatus());
return new ResponseEntity<>(
ApiResponse.success("주문상태를 변경했습니다.", responseDto), HttpStatus.OK);
}
그 외 OWNER와 CUSTOMER의 권한이 각 API마다 다르게 요구되는 Order 도메인은 일관성을 위해 @PreAuthorize를 사용하여 처리하기로 했습니다. @Secured를 사용할 수도 있지만 개인적으로 가독성 측면에서 @PreAuthorize가 더 좋다고 판단했습니다.
8. 마치며
Spring Security의 메커니즘에 대한 이해가 부족했던 부분에서 논리 구조에 빈틈이 많았지만, 이를 해결해 나가며 HttpSecurity와 메서드 보안(Method Security)이 각각 어떻게 동작하는지, 그리고 그 차이를 명확히 이해할 수 있었습니다.
아직 프로젝트 크기가 작기 때문에 지금 모듈화와 @PreAuthorize와 Security Config의 사용 규칙을 정하는 것은 사실 큰 의미가 없긴하지만, 시스템 복잡도가 커질수록 이런 규칙이 매우 중요하게 작용할 것이라고 봅니다. 이러한 규칙을 지금부터 정립해 두면 이후 프로젝트 확장 시 혼란을 방지하고 보안 정책을 일관성 있게 관리할 수 있을 것이라 생각합니다.