최근 학교 동아리 게시판 시스템을 개발하던 중, 사용자가 접근할 수 없는 리소스에 접근했을 때, 단순히 403 Forbidden 에러만 반환하는 문제가 발생했다.
현재 구현된 코드를 보면, @PreAuthorize를 통해 JWT에 저장되어 있는 유저의 권한을 체크해서 인가 여부를 정해주고 있다.
@PreAuthorize
의 한계@PreAuthorize
에서 예외가 발생하면 AccessDeniedException
이 발생하는데, 이때 “어떤 권한이 필요했는지”에 대한 정보가 예외 객체에 명시적으로 담겨있지 않다.@RestControllerAdvice
활용의 어려움@RestControllerAdvice
를 사용하면 메세지를 커스텀할 수는 있겠지만, 필요한 권한에 따라서 예외 메세지를 전달하기엔 다소 어려움이 있다. 결과적으로, @RestControllerAdvice
에서는 단순히 접근 거부만 알 수 있고, 왜 거부되었는지에 대한 세부적인 맥락을 알수 없었다.이 문제를 해결하기 위해, @PreAuthorize의 동작 원리를 이해하고, 필요한 권한 정보를 포함한 더 구체적인 예외 메세지를 제공하는 방법을 연구하기로 했다.
해당 문제를 해결하기 위해, 먼저 @PreAuthorize 어노테이션이 어떻게 동작하는지 알아봅시다.
Spring Security에서 제공하는 메서드 수준 권한 체크를 위한 어노테이션이다.
메서드가 호출되기 전에 사용자 권한을 검사하며, 조건에 맞지 않으면 접근을 차단한다.
package org.springframework.security.access.prepost;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface PreAuthorize {
String value();
}
표현식 | 설명 |
---|---|
hasRole('ROLE_ADMIN') | ROLE_ADMIN 권한이 있는지 검사 |
hasAnyRole('ROLE_USER', 'ROLE_ADMIN') | 둘 중 하나라도 권한이 있으면 허용 |
hasAuthority('READ_PRIVILEGE') | 특정 Authority 권한이 있는지 검사 |
hasAnyAuthority('READ', 'WRITE') | 여러 Authority 중 하나라도 있으면 허용 |
isAuthenticated() | 로그인된 사용자만 접근 허용 |
isAnonymous() | 비로그인 사용자만 허용 |
isRememberMe() | Remember-Me 인증 사용자인지 확인 |
isFullyAuthenticated() | 완전히 인증된 사용자만 허용 (Remember-Me 제외) |
#parameter == authentication.name | 메서드 파라미터와 현재 로그인된 사용자의 아이디 비교 |
@beanName.method(#param) | 스프링 빈의 메서드를 호출하여 권한 체크 |
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
@Service
public class PostService {
// ADMIN 권한만 허용
@PreAuthorize("hasRole('ROLE_ADMIN')")
public void deletePost(Long postId) {
System.out.println("게시글 삭제됨");
}
// USER 또는 ADMIN 권한 허용
@PreAuthorize("hasAnyRole('ROLE_USER', 'ROLE_ADMIN')")
public void createPost(String content) {
System.out.println("게시글 생성됨");
}
// 로그인한 본인만 접근 가능
@PreAuthorize("#userId == authentication.principal.username")
public void updateProfile(String userId, String newName) {
System.out.println("프로필 업데이트됨");
}
}
문제의 핵심은 AccessDeniedException
이다. @PreAuthorize
에서 권한 검증에 실패하면 이 예외가 발생하는데, 이 객체는 단순히 "접근 거부" 정보만 담고 있다.
흐름은 아래 사진과 같다.
@PreAuthorize에서 인가가 거부되면, AccessDeniedException 예외를 발생시킨다.
해당 객체는 아래와 같다.
AccessDenidedException
해당 예외가 발생하면, ExceptionTranslationFilter
가 가로챈다.
private void handleAccessDeniedException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AccessDeniedException exception) throws ServletException, IOException {
Authentication authentication = this.securityContextHolderStrategy.getContext().getAuthentication();
boolean isAnonymous = this.authenticationTrustResolver.isAnonymous(authentication);
if (!isAnonymous && !this.authenticationTrustResolver.isRememberMe(authentication)) {
if (this.logger.isTraceEnabled()) {
this.logger.trace(LogMessage.format("Sending %s to access denied handler since access is denied", authentication), exception);
}
this.accessDeniedHandler.handle(request, response, exception);
} else {
if (this.logger.isTraceEnabled()) {
this.logger.trace(LogMessage.format("Sending %s to authentication entry point since access is denied", authentication), exception);
}
this.sendStartAuthentication(request, response, chain, new InsufficientAuthenticationException(this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication", "Full authentication is required to access this resource")));
}
}
ExceptionTranslationFilter 내부의 hnadleAccessDeniedException이 예외를 처리하기 위해 AccessDeniedHandler로 전달한다. 해당 메소드 로직을 보면 다음과 같이 정리할 수 있다.
AccessDeniedHandlerImpl
403 에러만 던지고 메시지가 없는 이유는?
Spring Security의 기본 AccessDeniedHandlerImpl
는 response.sendError를 통해 단순히 403 상태 코드만 반환하며, 추가적인 JSON 메시지는 포함하지 않는다. 따라서 403에러만 던져주고 body에 아무런 데이터가 담기지 않았던 것이다!
어? 근데 @RestControllerAdivce를 설정해줬으니까, 전역적으로 설정한 에러 코드가 출력되어야 하는거 아닌가?
아니다! @RestControllerAdivce 포맷에 맞게 return 되지 않는 이유는, MVC의 컨트롤러 레이어에서 발생한 것이 아니라 Spring Security Filter Chain의 필터 레벨에서 발생을 했기 때문이다.
자세한 내용이 궁금하면 아래 포스팅을 확인해보자.
package com.club_board.club_board_server.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
// 예시로, 요청 URI에 따라 필요한 권한을 추론합니다.
String requiredRole = "N/A";
String uri = request.getRequestURI();
// /book/admin 관련 요청은 ROLE_ADMIN 필요, /books 관련은 ROLE_USER 이상 필요하다고 가정
if (uri.startsWith("/book/admin")) {
requiredRole = "ROLE_ADMIN";
} else if (uri.startsWith("/books")) {
requiredRole = "ROLE_USER";
}
// 현재 사용자 권한 정보 읽기 (없으면 "None")
String currentRoles = "None";
if (SecurityContextHolder.getContext().getAuthentication() != null) {
currentRoles = SecurityContextHolder.getContext().getAuthentication().getAuthorities().toString();
}
String message = "권한이 없습니다. 필요한 권한: " + requiredRole + ", 현재 권한: " + currentRoles;
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setContentType("application/json;charset=UTF-8");
Map<String, Object> errorDetails = new HashMap<>();
errorDetails.put("status", HttpStatus.FORBIDDEN.value());
errorDetails.put("error", "ACCESS_DENIED");
errorDetails.put("message", message);
errorDetails.put("path", uri);
objectMapper.writeValue(response.getWriter(), errorDetails);
}
}
@PreAuthorize
, @Secured
등을 사용하여 권한 검증한다.AccessDeniedHandler
가 호출된다.AccessDeniedHandler
를 통해 에러 응답 포맷을 통일하거나, 추가 정보를 클라이언트에 전달AccessDeniedHandler
가 일괄적으로 처리 → 일관된 에러 메시지 제공 가능.AccessDeniedHandler
인터페이스만 구현하면 되므로, 간단하고 직관적인 구현 가능.유연성 부족
에러 메시지 커스터마이징 한계
비즈니스 로직 의존성 부족
- AccessDeniedHandler
는 단순히 권한 검증 실패만 알기 때문에,요청에 대한 세부 정보(예: 유저 ID, 요청 파라미터)를 바탕으로 권한 검증 로직을 구현하기 어렵다.
@CheckPermission
메서드에 적용되어, 호출 전에 사용자의 권한을 검사하는 역할을 한다.
package com.club_board.club_board_server.global.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckPermission {
// 검사할 권한 목록 (여러 개 가능)
String[] roles() default {"USER"};
// 검사 모드: ALL - hasAuthority, ANY - hasAnyAuthority
Mode mode() default Mode.ALL;
enum Mode {
ALL, // hasAuthority: 모든 권한 필요
ANY // hasAnyAuthority: 하나라도 일치하면 허용
}
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
roles
: 필요한 권한 목록을 지정합니다. (기본값: "USER"
)mode
:ALL
: 명시된 모든 권한을 가지고 있어야 합니다.ANY
: 명시된 권한 중 하나라도 있으면 통과합니다.PermissionAspect
PermissionAspect
클래스는 AOP를 사용하여 메서드 호출 전에 권한 검증을 수행한다.
package com.club_board.club_board_server.global.aop;
import com.club_board.club_board_server.config.jwt.TokenProvider;
import com.club_board.club_board_server.global.annotation.CheckPermission;
import com.club_board.club_board_server.response.exception.CustomAccessDeniedException;
import com.club_board.club_board_server.response.exception.ExceptionType;
import io.jsonwebtoken.Claims;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List;
@Aspect
@Component
@Slf4j
public class PermissionAspect {
private final TokenProvider tokenProvider; // TokenProvider 주입
public PermissionAspect(TokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
@Before("@annotation(com.club_board.club_board_server.global.annotation.CheckPermission)")
public void checkPermission(JoinPoint joinPoint) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw new CustomAccessDeniedException(ExceptionType.ACCESS_DENIED, "인증되지 않은 사용자입니다.");
}
// 어노테이션에서 권한 정보 가져오기
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
CheckPermission permission = method.getAnnotation(CheckPermission.class);
String[] requiredRoles = permission.roles();
CheckPermission.Mode mode = permission.mode();
// JWT 토큰은 보통 auth.getCredentials()에 저장되어 있다고 가정합니다.
String token = auth.getCredentials().toString();
Claims claims = tokenProvider.getClaims(token);
// JWT 클레임에서 "authorities" 값을 추출 (예: ["ROLE_USER", "ROLE_ADMIN"])
List<String> userRoles = claims.get("authorities", List.class);
boolean hasPermission;
if (mode == CheckPermission.Mode.ALL) {
// 모든 권한이 있어야 함
hasPermission = Arrays.stream(requiredRoles)
.allMatch(role -> userRoles.contains("ROLE_" + role));
} else {
// 하나라도 일치하면 허용
hasPermission = Arrays.stream(requiredRoles)
.anyMatch(role -> userRoles.contains("ROLE_" + role));
}
if (!hasPermission) {
throw new CustomAccessDeniedException(
ExceptionType.ACCESS_DENIED,
"필요한 권한: " + Arrays.toString(requiredRoles) + ", 현재 권한: " + userRoles
);
}
}
}
@Before
어노테이션을 사용해, 메서드가 호출되기 전에 권한 검증을 수행합니다.TokenProvider
를 사용하여 JWT를 파싱하고, 사용자의 권한 정보를 가져옵니다.authorities
필드에서 사용자의 권한 목록을 추출합니다.CheckPermission
어노테이션에서 받은 roles
와 사용자의 userRoles
를 비교합니다.ALL
모드: 모든 권한이 일치해야 함.ANY
모드: 하나라도 일치하면 허용.CustomAccessDeniedException
을 발생시켜 클라이언트에 상세한 에러 메시지를 반환합니다.@CheckPermission
적용package com.club_board.club_board_server.controller.book;
import com.club_board.club_board_server.domain.user.CustomUserDetails;
import com.club_board.club_board_server.dto.book.BookResponse;
import com.club_board.club_board_server.global.annotation.CheckPermission;
import com.club_board.club_board_server.response.ResponseBody;
import com.club_board.club_board_server.response.ResponseUtil;
import com.club_board.club_board_server.service.book.BookService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/books")
@Slf4j
@RequiredArgsConstructor
public class BookController {
private final BookService bookService;
@GetMapping
@CheckPermission(roles = {"USER", "ADMIN", "OWNER"}, mode = CheckPermission.Mode.ANY)
public ResponseEntity<ResponseBody<List<BookResponse>>> getAllBooks(@AuthenticationPrincipal CustomUserDetails customUserDetails){
Long userId=customUserDetails.getUser().getId();
List<BookResponse> bookResponses=bookService.getAllBooks(userId);
return ResponseEntity.ok(ResponseUtil.createSuccessResponse(bookResponses));
}
@GetMapping("/{id}")
@CheckPermission(roles = {"USER", "ADMIN", "OWNER"}, mode = CheckPermission.Mode.ANY)
public ResponseEntity<ResponseBody<BookResponse>> getBookById(@PathVariable("id") Long id,
@AuthenticationPrincipal CustomUserDetails customUserDetails){
Long userId=customUserDetails.getUser().getId();
BookResponse bookResponse= bookService.getBookById(id,userId);
return ResponseEntity.ok(ResponseUtil.createSuccessResponse(bookResponse));
}
@PostMapping("/reservation/{bookId}")
@CheckPermission(roles = {"USER", "ADMIN", "OWNER"}, mode = CheckPermission.Mode.ANY)
public ResponseEntity<ResponseBody<String>> reservation(@PathVariable("bookId") Long bookId,
@AuthenticationPrincipal CustomUserDetails customUserDetails){
Long userId=customUserDetails.getUser().getId();
bookService.addReservation(bookId,userId);
return ResponseEntity.ok(ResponseUtil.createSuccessResponse("Reservation success"));
}
@DeleteMapping("/reservation/{bookId}")
@CheckPermission(roles = {"USER", "ADMIN", "OWNER"}, mode = CheckPermission.Mode.ANY)
public ResponseEntity<ResponseBody<String>> cancelReservation(@PathVariable("bookId") Long bookId,
@AuthenticationPrincipal CustomUserDetails customUserDetails){
Long userId=customUserDetails.getUser().getId();
bookService.cancelReservation(bookId,userId);
return ResponseEntity.ok(ResponseUtil.createSuccessResponse("Reservation cancelled"));
}
}
@CheckPermission
사용roles
와 mode
를 자유롭게 설정 가능하다.ANY
모드: 지정된 권한 중 하나만 있어도 접근 가능ALL
모드: 모든 권한이 있어야 접근 가능
@CheckPermission(roles={"ADMIN", "OWNER"}, mode=CheckPermission.Mode.ANY)
public void approveBookLoan() { ... }
성능 오버헤드
⚠ 주의:
→ 대규모 트래픽(수천~수만 RPS)에서는 오버헤드가 누적될 수 있음.
복잡한 구현
Spring Security의 필터 체인 우회 가능성
결론적으로, 커스텀 어노테이션과 AOP 방식을 채택하기로 하였습니다.