리팩토링 전 코드

주문과 배달 관련 API에서 사용자 토큰을 통해 접근 권한을 확인하는 로직이 중복되는 문제가 발생하였습니다. 이외에도 주문 상태 변경과 배달 상태 변경 로직에서도 아래와 같이 사용자의 권한을 확인하는 부분이 반복되었습니다.

@Transactional
public Order createOrder(JwtAuthentication jwtAuthentication, OrderRequest orderRequest) {
    if (!jwtAuthentication.getRole().equals(Role.CLIENT)) {
        throw new AccessDeniedException("고객만 주문할 수 있습니다.");
    }
    ...
}
@Transactional
public Delivery assignDelivery(Long orderId, Long riderId, JwtAuthentication jwtAuthentication) {
    if (!jwtAuthentication.getRole().equals(Role.RIDER)) {
        throw new AccessDeniedException("배달원만 배달을 할 수 있습니다.");
    }
    ...
}

예를 들어, 고객이 주문을 완료한 후 사장님이 배달을 수락하거나 배달 상태를 변경할 때에는 사장님 권한으로 로그인이 되어있어야 합니다. 이러한 불필요한 코드 반복을 줄이기 위해서 핵심 비즈니스 로직과 권한 확인 로직을 분리하여 관리하는 것이 더 좋은 방식이라고 생각했습니다.

AOP란 무엇일까?

AOP는 애플리케이션의 공통 관심 사항을 모듈화하는 방법입니다. 이는 코드의 중복을 줄이고, 유지 보수를 용이하게 만듭니다. 소프트웨어 개발 과정에서 로깅, 보안, 트랜잭션 관리와 같은 기능은 여러 모듈이나 함수에서 공통적으로 필요하지만 이렇게 분산된 공통 기능을 효율적으로 관리하는 것은 어렵습니다.

AOP는 이러한 공통 관심 사항을 별도의 Aspect로 분리하여 관리함으로써, 비즈니스 로직의 코드를 깔끔하게 유지할 수 있도록 도와줍니다. 특히, 스프링 프레임워크에서는 AOP를 지원하여 개발자들이 손쉽게 이 패러다임을 적용할 수 있도록 도와줍니다. 스프링 AOP를 통해 개발자는 실행 시점에 동적으로 Aspect를 적용할 수 있습니다.

또한, 스프링 AOP는 프록시 객체를 사용하여 구현됩니다. 프록시 객체는 다른 객체를 대신해 대리 역할을 수행하는 객체를 말합니다. 스프링의 AOP는 다음과 같이 동작합니다.

  • 스프링은 애플리케이션에서 관리하는 모든 빈(Bean)을 조사하여, AOP 관련 설정(예: Aspect, Pointcut)을 확인합니다.
  • 만약 특정 빈에 대해 부가 기능을 적용해야 한다고 판단되면, 스프링은 해당 빈의 프록시 객체를 생성합니다. 프록시 객체는 클라이언트가 원래 빈을 호출하는 것처럼 보이도록, 동일한 인터페이스를 구현하거나 상속받아 생성됩니다.
  • 프록시 객체는 클라이언트가 메소드를 호출할 때마다 포인트컷을 사용해 해당 메소드가 부가 기능의 대상인지 확인합니다.
  • 포인트컷 조건을 만족하는 경우, 프록시 객체는 지정된 부가 기능(Advice)을 실행합니다.

여기서 Aspect, Join point, Advice, Pointcut, Weaving과 같은 주요 개념이 있습니다.

  • Aspect: 공통 관심 사항을 모듈화한 것으로, 로깅이나 보안과 같은 기능을 Aspect로 정의할 수 있습니다. Aspect는 Pointcut과 Advice를 포함하여, 어떤 시점에 어떤 부가 기능을 적용할지를 정의합니다.
  • Join Point: Aspect가 적용될 수 있는 위치, 즉 메소드 호출이나 필드 접근과 같은 프로그램 실행의 특정 지점을 의미합니다.
  • Advice: Join Point에서 실행될 공통 기능의 코드입니다. Advice는 Before, After, Around와 같은 다양한 유형으로 동작할 수 있습니다.
  • Pointcut: Join Point를 정의하는 표현식으로, 실제로 Advice가 적용될 위치를 결정합니다.
  • Target: Advice가 적용되는 대상 객체를 의미합니다.
  • Weaving: Pointcut에 명시된 Join Point에 Advice를 적용하는 과정을 의미합니다.

Role enum

현재 사용자 권한은 CLIENT, ADMIN, OWNER, RIDER로 나뉘어져 있습니다.

public enum Role {
    CLIENT, ADMIN, OWNER, RIDER
}

CheckRole 애노테이션 설정

우선 AOP를 사용하기 위해서는,

  • 부가 기능인 어드바이스(Advice)와
  • 이를 적용할 수 있는 지점인 포인트컷(Pointcut)이 필요합니다.

포인트컷 대상을 정할 때 정규식과 같은 포인트컷 표현식을 사용할 수 있지만, 애노테이션을 사용하는 방식을 선택했습니다.

@CheckRole 애노테이션을 타겟 클래스의 메소드에 붙여주기만 하면 AOP를 적용할 수 있습니다. 서비스 클래스의 API 메소드 위에 적용할 것이기 때문에 Target은 Method로, 스프링 AOP는 런타임 시 프록시를 동적으로 생성하기 때문에 Retention은 Runtime으로 설정합니다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckRole {
    Role requiredRole();
}

@CheckRole 애노테이션을 통해 Role enum 타입으로 사용자 권한을 지정할 수 있습니다. 사용 예시는 다음과 같습니다.

@CheckRole(requiredRole = Role.CLIENT)

@Aspect 클래스

다음은 @Aspect 클래스를 빈으로 등록하고 포인트컷과 어드바이스를 설정해야 합니다. 스프링 컨테이너가 AOP 기능을 하기 위해서는 @Aspect 클래스도 빈으로 등록되어야 합니다.

@Aspect 클래스 내에서 포인트컷과 어드바이스를 설정합니다. 포인트컷은 어떤 메소드에 어드바이스를 적용할지를 결정하며, 어드바이스는 그 메소드에서 실행될 공통 기능을 정의합니다. @Around를 사용하여 커스텀 어노테이션 @CheckRole이 달린 메소드 시작 전,후에 어드바이스가 적용되도록 합니다.

@Around를 사용한 이유는 메소드 호출 전에 사용자의 권한을 검증하고, 메소드 실행 후에는 추가적인 작업을 수행하거나 예외를 처리할 수 있기 때문입니다.

로직은 다음과 같습니다.

  1. 현재 사용자의 인증 정보를 가져옵니다.
  2. @CheckRole 애노테이션을 통해 필요한 권한을 확인 후
  3. 권한 비교 및 검증합니다. 현재 사용자의 역할과 애노테이션의 역할을 비교하여 일치하지 않으면 예외를 던집니다.
  4. 권한이 확인된 경우 실제 메소드를 실행합니다.
@Aspect
@Component
@RequiredArgsConstructor
public class RoleCheckAspect {

    private static final Logger logger = LoggerFactory.getLogger(RoleCheckAspect.class);

    // @CheckRole 애노테이션이 붙은 메소드가 호출되면 해당 애노테이션의 속성인 requiredRole을 가져와 현재 사용자의 역할과 비교합니다.
    @Around("execution(@com.project.food_ordering_service.global.annotaion.CheckRole * *(..)) && @annotation(checkRole)")
    public Object checkUserRole(ProceedingJoinPoint joinPoint, CheckRole checkRole) throws Throwable {
        try {
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            JwtAuthentication jwtAuthentication = (JwtAuthentication) authentication.getPrincipal();

            Role requiredRole = checkRole.requiredRole();

            if (!jwtAuthentication.getRole().equals(requiredRole)) {
                throw new AccessDeniedException("접근이 거부되었습니다.");
            }

            return joinPoint.proceed();
        } catch (Exception e) {
            logger.error("예외 발생: {}", e.getMessage(), e);
            throw e;
        }
    }
}

리팩토링 후 코드

@CheckRole(requiredRole = Role.CLIENT)
@PostMapping("/orders")
public ResponseEntity<OrderResponse> createOrder(
        @AuthenticationPrincipal JwtAuthentication jwtAuthentication,
        @RequestBody @Validated OrderRequest orderRequest) {
    ...
}
@CheckRole(requiredRole = Role.RIDER)
@PostMapping("/{orderId}/assign")
public ResponseEntity<DeliveryResponse> assignDelivery(
        @RequestBody DeliveryRequest deliveryRequest) {
    ...
}

리팩토링을 통해 서비스 메소드 대신 컨트롤러 메소드에 @CheckRole 애노테이션을 적용하여, 메소드 실행 전에 사용자 권한을 체크하는 로직이 실행되도록 하였습니다. 권한 확인 로직을 서비스 메소드에서 제거하고 컨트롤러에서 서비스 호출 전에 Role을 확인하여 올바른 권한이 아닌 경우 서비스 메소드가 호출되지 않도록 합니다. 컨트롤러에서 일관된 방법으로 권한을 확인할 수 있게 되었습니다.

그렇다면 AOP의 단점은 없을까?

명시적으로 어떤 부가기능인지에 대해 메소드 시그니처만 보고는 알 수 없습니다. 그렇기 때문에 다른 개발자들은 해당 코드를 보고 어떤 부가기능이 적용되는지 모를 수 있기 때문에 추가적인 학습이 필요할 수 있습니다.

업데이트: