[Spring]@PreAuthorize의 한계? 커스텀 어노테이션 + AOP로 해결!

juhyeok01·2025년 2월 22일
14

Project

목록 보기
5/8
post-thumbnail

💡Situation


최근 학교 동아리 게시판 시스템을 개발하던 중, 사용자가 접근할 수 없는 리소스에 접근했을 때, 단순히 403 Forbidden 에러만 반환하는 문제가 발생했다.

현재 구현된 코드를 보면, @PreAuthorize를 통해 JWT에 저장되어 있는 유저의 권한을 체크해서 인가 여부를 정해주고 있다.

❓문제점

  1. 상세 에러 메세지가 없다.
    • 단순히 403 Forbidden 에러를 반환하며, 왜 접근이 거부되었는지에 대한 세부적인 메세지를 전달하지 못하고 있었다.
    • 클라이언트 입장에서는 어떤 권한이 부족한지 모르기 때문에, 사용자에게 정확한 피드백을 주기 어렵다.
  2. @PreAuthorize의 한계
    • @PreAuthorize에서 예외가 발생하면 AccessDeniedException 이 발생하는데, 이때 “어떤 권한이 필요했는지”에 대한 정보가 예외 객체에 명시적으로 담겨있지 않다.
  3. @RestControllerAdvice 활용의 어려움
    • @RestControllerAdvice를 사용하면 메세지를 커스텀할 수는 있겠지만, 필요한 권한에 따라서 예외 메세지를 전달하기엔 다소 어려움이 있다. 결과적으로, @RestControllerAdvice에서는 단순히 접근 거부만 알 수 있고, 왜 거부되었는지에 대한 세부적인 맥락을 알수 없었다.

이 문제를 해결하기 위해, @PreAuthorize의 동작 원리를 이해하고, 필요한 권한 정보를 포함한 더 구체적인 예외 메세지를 제공하는 방법을 연구하기로 했다.



💡Task



🎯 해결 목표

  • 403 Forbidden 응답 시, 필요한 권한현재 사용자의 권한 정보를 포함한 상세한 에러 메시지를 반환하기.
  • 보안성을 유지하면서, 유지보수성이 높은 구조로 설계.
  • 성능 오버헤드 최소화비즈니스 로직 기반의 세밀한 검증이 가능한 구조를 만들기.

해당 문제를 해결하기 위해, 먼저 @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();
}
  • @Target({ElementType.METHOD, ElementType.TYPE})
    • 메서드, 클래스 레벨에 적용할 수 있다!
    • 클래스에 적용시, 해당 클래스의 모든 메서드에 적용됨
  • @Retention(RetentionPolicy.RUNTIME)
    • 런타임까지 어노테이션 정보를 유지하며, AOP를 통해 권한 체크가 가능
  • @Inherited
    • 상속 시, 자식 클래스도 이 어노테이션을 상속 받음
  • String value();
    • Spring Expression Language (SpEL)을 사용하여 권한 체크 로직을 정의한다.

📖 자주 사용하는 표현식:

표현식설명
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)스프링 빈의 메서드를 호출하여 권한 체크

✔️동작 과정

  1. 사용자가 readCustomer 메소드를 실행한다. 이때, readCustomer 메소드를 직접 실행하는게 아니라, 애플리케이션 실행 시점에서 미리 생성되어 있던 AOP 프록시 객체가 해당 요청을 가로챈다. 프록시는 메서드 호출 전에 AuthorizationManagerBeforeMethodInterceptor를 실행한다.

  2. AuthorizationManagerBeforeMethodInterceptor가 PreAuthorizeAuthorizationManager메소드를 실행해서, @PreAuthorize 어노테이션에 설정된 권한 조건을 확인한다.

  3. MethodSecurityExpressionHandler가 @PreAuthorize에 정의된 SpEL 표현식을 파싱한다.
    • 예) @PreAuthorize(”hasAuthority(’ROLE_ADMIN’)”)
    • EvaluationContext를 생성하여, 현재 사용자의 Authentication 정보와 메서드 호출 정보(MethodInvocation)를 넣는다.

  4. SpEL 표현식을 평가해서, 현재 사용자가 해당 권한을 가지고 있는지 확인한다.
    • Supplier를 통해 현재 로그인한 사용자의 인증 정보를 가져온다.
    • 사용자의 권한 목록에 적절한 권한이 있는지 확인한다.

  5. 성공을 하면 메서드가 정상적으로 호출이 된다.

  6. 그렇지 않은 경우, AuthorizationDeniedEvent를 발생시키고, AccessDeniedException 예외를 발생시킨다. 이 예외는 ExceptionTranslationFilter에 의해 포착이 되고, 이후 클라이언트에게 403 상태 코드를 포함한 응답을 반환한다.

✔️사용 예시

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

  • msg : 접근 거부와 관련된 간단한 메세지를 담고 있다.
  • cause: 이 예외를 발생시킨 근본 원인을 담을 수 있지만, 대개 null로 설정된다.

해당 예외가 발생하면, 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로 전달한다. 해당 메소드 로직을 보면 다음과 같이 정리할 수 있다.

  1. 사용자의 Authentication 객체를 가져온다. 사용자가 인증되었는지 여부를 판단한다.
  2. 인증은 되었지만(isAnonymous), 필요한 권한이 없는 경우 this.accessDeniedHandler를 호출한다. 기본적으로는 구현체인 AccessDeniedHandlerImpl를 사용한다.

AccessDeniedHandlerImpl

403 에러만 던지고 메시지가 없는 이유는?

Spring Security의 기본 AccessDeniedHandlerImpl는 response.sendError를 통해 단순히 403 상태 코드만 반환하며, 추가적인 JSON 메시지는 포함하지 않는다. 따라서 403에러만 던져주고 body에 아무런 데이터가 담기지 않았던 것이다!

어? 근데 @RestControllerAdivce를 설정해줬으니까, 전역적으로 설정한 에러 코드가 출력되어야 하는거 아닌가?

아니다! @RestControllerAdivce 포맷에 맞게 return 되지 않는 이유는, MVC의 컨트롤러 레이어에서 발생한 것이 아니라 Spring Security Filter Chain의 필터 레벨에서 발생을 했기 때문이다.

자세한 내용이 궁금하면 아래 포스팅을 확인해보자.

https://velog.io/@kyoung0161/Spring-%ED%95%84%ED%84%B0%EC%97%90%EC%84%9C-%EB%B0%9C%EC%83%9D%ED%95%9C-%EC%98%88%EC%99%B8%EA%B0%80-GlobalExceptionHandler%EB%A1%9C-%EC%B2%98%EB%A6%AC%EB%90%98%EC%A7%80-%EC%95%8A%EB%8A%94-%EC%9D%B4%EC%9C%A0


💡Action


방법 1: CustomAceessDeniedHandler

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);
    }
}

✔️ 작동 방식

  • Spring Security에서 @PreAuthorize, @Secured 등을 사용하여 권한 검증한다.
  • 인증 실패나 권한 부족 시, AccessDeniedHandler가 호출된다.
  • 커스텀 AccessDeniedHandler를 통해 에러 응답 포맷을 통일하거나, 추가 정보를 클라이언트에 전달

😀장점

  1. Spring Security의 기본 흐름 유지
    • 권한 검증 로직을 별도로 작성할 필요 없이, Spring Security의 @PreAuthorize와 같은 어노테이션만으로 권한 검증 가능.
    • 인가 실패 시, *AccessDeniedHandler가 일괄적으로 처리 → 일관된 에러 메시지 제공 가능.
  2. 성능에 유리
    • AOP를 사용하지 않으므로 프록시 객체 생성이나 리플렉션이 발생하지 않음.
    • 필터 체인에서 빠르게 권한 검증 및 에러 처리가 가능 → 오버헤드 최소화.
  3. 구현 난이도 낮음
    • AccessDeniedHandler 인터페이스만 구현하면 되므로, 간단하고 직관적인 구현 가능.

단점

  1. 유연성 부족

    • 커스텀 로직을 추가하거나, 메서드 단위의 세밀한 권한 검증을 넣기가 어렵다.
    • 예시: "특정 유저는 특정 책만 대여할 수 있다" 같은 비즈니스 로직 기반 인가는 힘듦.
  2. 에러 메시지 커스터마이징 한계

    • @PreAuthorize 같은 어노테이션에서는 필요 권한 정보를 직접 전달받을 수 없어,"필요한 권한: ~~ / 현재 권한: ~~" 과 같은 상세한 메시지 구성이 어렵다.
    • 위 사항을 넣으려면, 위 방식과 같이 URL을 직접 하나하나 넣어서 if문으로 설정을 해야함 (화이트 리스트 추가하는 것처럼 url 경로 직접 입력)
  3. 비즈니스 로직 의존성 부족
    - AccessDeniedHandler는 단순히 권한 검증 실패만 알기 때문에,요청에 대한 세부 정보(예: 유저 ID, 요청 파라미터)를 바탕으로 권한 검증 로직을 구현하기 어렵다.

방법2 : 커스텀 어노테이션 + AOP 적용


1️⃣ 커스텀 어노테이션 정의 - @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)
    • 런타임 시에도 어노테이션 정보가 유지되며, AOP에서 읽어올 수 있습니다.
  • 파라미터 설명:
    - roles: 필요한 권한 목록을 지정합니다. (기본값: "USER")
    - mode:
    - ALL: 명시된 모든 권한을 가지고 있어야 합니다.
    - ANY: 명시된 권한 중 하나라도 있으면 통과합니다.

2️⃣ AOP 구현 - 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
            );
        }
    }
}
  • AOP 적용
    • @Before 어노테이션을 사용해, 메서드가 호출되기 전에 권한 검증을 수행합니다.
  • JWT 기반 인증
    • TokenProvider를 사용하여 JWT를 파싱하고, 사용자의 권한 정보를 가져옵니다.
    • JWT의 authorities 필드에서 사용자의 권한 목록을 추출합니다.
  • 권한 검증 로직
    • CheckPermission 어노테이션에서 받은 roles와 사용자의 userRoles를 비교합니다.
    • ALL 모드: 모든 권한이 일치해야 함.
    • ANY 모드: 하나라도 일치하면 허용.
  • 예외 처리
    - 권한이 부족한 경우, CustomAccessDeniedException을 발생시켜 클라이언트에 상세한 에러 메시지를 반환합니다.

3️⃣ Controller 사용법 - @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 사용
    • 메서드에 어노테이션을 붙여 권한 검증 로직을 적용한다.
    • 각 메서드마다 필요한 rolesmode를 자유롭게 설정 가능하다.
  • 권한 조합에 따라 유연한 검증
    • ANY 모드: 지정된 권한 중 하나만 있어도 접근 가능
    • ALL 모드: 모든 권한이 있어야 접근 가능
  • 상세한 에러 메시지 제공
    • 권한 검증 실패 시, 필요한 권한현재 권한을 포함한 상세한 에러 메시지를 반환한다.


😀 장점

  1. 유연한 권한 검증
    • 메서드마다 세밀한 권한 검증이 가능.
    • 예시:
      
      @CheckPermission(roles={"ADMIN", "OWNER"}, mode=CheckPermission.Mode.ANY)
      public void approveBookLoan() { ... }
      
  2. 비즈니스 로직과 결합된 검증 가능
    • 요청에 포함된 유저 ID, 파라미터 등을 활용한 동적인 권한 검증 가능.
    • 예시: 특정 유저가 자신이 대여한 책만 반납할 수 있도록 제한.
  3. 상세한 에러 메시지 제공
    • AOP에서 직접 권한 비교를 수행하므로,"필요한 권한: ~~ / 현재 권한: ~~" 같은 세부적인 에러 메시지를 쉽게 커스터마이징 가능.

단점

  1. 성능 오버헤드

    • AOP를 사용하므로, 메서드 호출마다 프록시 객체를 통한 리플렉션 처리가 발생.
    • 리플렉션으로 어노테이션을 조회하거나, 권한 정보를 파싱하는 과정에서 추가 오버헤드 발생.

    주의:

    대규모 트래픽(수천~수만 RPS)에서는 오버헤드가 누적될 수 있음.

  2. 복잡한 구현

    • AOP, 커스텀 어노테이션, JWT 토큰 파싱 등의 작업이 필요하므로,초기 개발 비용유지보수 비용이 증가.
  3. Spring Security의 필터 체인 우회 가능성

    • AOP는 비즈니스 레이어에서 실행되므로, 필터 체인에서 처리되는 보안 정책을 우회할 가능성 존재.
    • 필터 체인에서 토큰 유효성 검증이 누락되면, AOP에서 권한 검증을 수행하더라도, 비인가 사용자가 접근할 수 있음.
    • 그러나 우리 프로젝트에서는, 토큰 필터를 통해서 인증된 사용자인지 검사하므로, 해결 가능

💡Result


결론적으로, 커스텀 어노테이션과 AOP 방식을 채택하기로 하였습니다.

❓왜 AOP + 커스텀 어노테이션을 선택했는가?

  1. 성능 이슈 없음
    • AOP가 성능 오버헤드를 유발할 수 있지만, 낮은 트래픽 환경에서는 큰 문제가 되지 않는다. 현재 진행중인 프로젝트는 학교 동아리 게시판이므로, 유저 수가 그렇게 많지 않다.
  2. 유연성 확보
    • 메서드마다 세밀한 권한 검증 가능하다.
  3. 상세 에러 메시지 제공
    • 클라이언트에 필요한 권한과 현재 권한 정보를 정확히 전달할 수 있다.
  4. 향후 확장성
    • 새로운 권한 검증 로직이 추가되더라도,어노테이션만 추가하면 적용 가능 → 유지보수가 쉬움
profile
백엔드 개발자를 지망하는 컴퓨터공학과 3학년 학생입니다 https://github.com/Juhye0k

0개의 댓글