AOP 활용하여 로그인 여부 체크하기

AOP 활용하여 로그인 여부 체크하기

요구 사항

진행하는 프로젝트에서 거의 대부분의 API 메서드에서 로그인 정보가 없으면 401 HTTP 상태와 에러 코드를 반환해야 한다는 요구 사항이 추가되었다.
단순히 요구 사항만 충족시킨다면 각 API 메서드에 로그인 여부를 검증하는 로직을 추가하고, 로그인 정보가 없다면 401 상태를 반환하는 식으로 생각할 수 있다.
하지만 그렇게 되면 중복되는 코드가 많아지고, 무엇보다 그러한 로직을 추가해야 하는 API가 많다는 것이 문제이다. 따라서 이러한 상황을 타파하기 위해 AOP를 활용하여 구현해보기로 했다.

AOP 활용하기

1. 어노테이션 생성

먼저 어노테이션을 생성해준다. 나같은 경우는 프로젝트에 aop.annotation 패키지를 만들어 해당 패키지에 어노테이션을 추가해줬다.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface LoginCheck {
}

해당 어노테이션은 런타임 동안에 유지되어야 하기 떄문에 @Retention(RetentionPolicy.RUNTIME) 으로 설정해주었다. 그리고 해당 어노테이션은 메서드에 적용할거라 @Target(ElementType.METHOD)로 설정했다.

2. Aspect 생성 및 적용

다음으로 추가해 준 어노테이션이 존재하면 수행할 동작을 구현해줘야 한다. 이는 Aspect로 구현해줄 수 있다.
Aspect는 AOP에서 이야기하는 개념인 부가 기능을 정의한 Advice와 이를 어디에 적용하는지 결정하는 PointCut을 합친 개념으로, 부가 기능을 핵심 기능 사이에 침투시키는 개념이라고 볼 수 있다. 여기서 말하는 핵심 기능은 기존 API 로직이 되고, 부가 기능은 로그인 여부를 검증하는 로직이 될 것이다.
Spring에서는 @Aspect 어노테이션을 통해 Aspect를 구현할 수 있다.

추가로 JoinPoint라는 개념도 필수로 알아야 한다.
JoinPoint는 Advice가 적용될 수 있는 위치, 즉 AOP를 적용할 수 있는 지점을 의미한다. 모든 Advice는 JoinPoint를 첫 번째 파라미터에 사용할 수 있다.
하지만 @Around 어노테이션이 사용되는 경우에는 ProceedingJoinPoint를 사용해야 하는데, ProceedingJoinPoint는 JoinPoint에서 하나의 주요 기능이 추가된 것이다.
바로 proceed()라는 기능이 추가되었는데, 이는 다음 Advice나 실제 객체(타겟)를 호출하는 기능이다.

다음은 실제로 내가 구현한 Aspect이다.

@Aspect
@Component
public class LoginCheckAspect {
    @Around("@annotation(com.example.project.aop.annotation.LoginCheck)")
    public Object loginCheck(ProceedingJoinPoint joinPoint) throws Throwable {
        final Optional<Account> loggedAccount = BeanUtils.getLoggedAccount();
        if (loggedAccount.isEmpty()) {
            return ApiResponse.unauthorized("로그인 정보가 없습니다.");
        }
        return joinPoint.proceed();
    }
}

위에서 설명한 것처럼 @Aspect 어노테이션을 사용하여 Aspect를 구현했고, @Around 어노테이션과 ProceedingJoinPoint를 사용한 것또한 확인할 수 있다.
@Around("{pattern}"} 어노테이션은 지정된 패턴에 해당하는 메서드가 실행되기 전, 실행된 후 모두에서 동작한다. 그리고 @Around 어노테이션이 붙은 메서드의 반환값은 Object 여야 한다는 특징이 있다.
또한 @Around 어노테이션을 사용했기 때문에 ProceedingJoinPoint를 사용했으며, if (loggedAccount.isEmpty()) 조건문으로 로그인 정보가 존재하지 않으면 401 상태가 담긴 Response를 반환하고, 존재하면 joinPoint.proceed()를 통해 타겟이 되는 API 메서드 동작을 수행하도록 했다.

이제 로그인 여부를 확인해 줄 API 메서드에 생성해 준 @LoginCheck 어노테이션을 달아주기만 하면 원하던 요구 사항을 구현할 수 있게 되었다.

참고. ApiResponse

참고로 위 ApiResponse는 API 응답을 만들어주는 역할을 하는 객체이다. 대략적으로 이렇게 구현하였다.

@Getter
@ToString
@Builder
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class ApiResponse<T> {
    @Builder.Default
    private String message = "success";
    
    private int status;
    
    @JsonInclude(JsonInclude.Include.NON_NULL)
    private T data;
    
    public static <T> ApiResponse<T> ok(T data) {
        return ApiResponse.<T>builder()
            .status(HttpStatus.OK.value())
            .data(data)
            .build();
    }
    
    public static <T> ApiResponse<T> unauthorized(String message) {
        throw new CustomUnauthorizedException(message);
    }
    
    public static <T> ApiResponse<T> badRequest(String message) {
        throw new CustomBadRequestException(message);
    }
    
    public static <T> ApiResponse<T> internalServerError(String message) {
        throw new CustomInternalServerError(message);
    }
}