[리팩토링] 서비스 레이어 인가로직 중복 제거

jhkim31·2024년 6월 24일
0

JSHOP 프로젝트

목록 보기
4/8

프로젝트를 진행하며 서비스 레이어에 중복되는 인가 로직이 있었다.
이를 제거하기 위해 AoP를 사용한 과정에 대해 정리한 글이다.

인증과 인가

인증은 사용자의 자격증명을 확인하는 작업을 의미한다. 사용자가 정당한 사용자인지를 증명하는 과정이다.
가장 대표적인 인증 수단으로 비밀번호가 있다.

인가는 사용자가 특정 자원에 대한 권한이 있는지를 확인하는 작업이다. 인증이된 사용자라 하더라도 권한이 없는 자원에 접근하지 못하도록 하는것이 인가 작업이다.

이번 프로젝트에서 인증과 인가

이번 프로젝트에서는 UsernamePassword... 필터를 사용하여 인증을 수행했다.
인증이된 사용자는 토큰을 받아 이후 요청시 서버로부터 인가를 받기 위해 토큰을 같이 던진다.

서버는 토큰의 유효성을 확인해 인증된 사용자인지 확인하고 인증된 사용자라면 필터를 통과시켜준다.

여기까지가 인증의 과정인거고, 자원에 대한 인가 과정이 추가로 필요하다.

즉 내가 네이버 카페의 인증된 회원일지더라도, 다른 회원의 카페 글을 함부로 다루지 못하게 하는 자원에 대한 인가 과정이 필요한 것이다.

인가 로직의 중복

현재 비즈니스 로직을 살펴보면, 인가 로직이 상당히 많은 코드를 차지하고 있다.

아래는 현재 진행중인 프로젝트의 상품 삭제 코드다.

    public void deleteProductDetail(Long userId, Long detailId) {
        Optional<ProductDetail> optionalProductDetail = productDetailRepository.findById(detailId);

        ProductDetail productDetail = optionalProductDetail.orElseThrow(() -> {
            log.error(ErrorCode.INVALID_PRODUCTDETAIL_PRODUCT.getLogMessage(), detailId);
            throw JshopException.of(ErrorCode.INVALID_PRODUCTDETAIL_PRODUCT);
        });

        Product product = productDetail.getProduct();

        if (product.getOwner().getId() != userId) {
            log.error(ErrorCode.UNAUTHORIZED.getLogMessage(), "ProductDetail", detailId, userId);
            throw JshopException.of(ErrorCode.UNAUTHORIZED);
        }

        productDetail.delete();
    }

실제로 상품을 찾아 삭제를 하는 로직만큼 사용자가 해당 상품의 주인인지 확인하는 로직도 길다.

        Product product = productDetail.getProduct();

        if (product.getOwner().getId() != userId) {
            log.error(ErrorCode.UNAUTHORIZED.getLogMessage(), "ProductDetail", detailId, userId);
            throw JshopException.of(ErrorCode.UNAUTHORIZED);
        }

게다가 이 인가로직은 하나의 메서드뿐만 아니라, 자원의 수정이 필요한 모든 메서드에서 사용한다.
중복 코드인 셈이다.

AoP를 사용한 중복 코드 제거

AoP는 관점 지향 프로그래밍으로, OOP를 완성시켜주는 프로그래밍 패러다임이다.
많은 비즈니스 로직에서 나타나는 중복 코드 (횡단 관심사) 를 제거함으로써 비즈니스 로직은 좀 더 비즈니스 로직에 집중할 수 있고 공통된 부가기능은 따로 관리하는 방법이다.

자바에는 AspectJ 라는 구현체를 통해 AoP를 지원한다. 이를 사용하면 거의 모든 곳에 원하는 방법으로 AoP를 적용할 수 있다. 하지만 스프링의 AoP는 제한적으로 빈 메서드에 대하여 프록시 방법으로만 AoP를 제공한다.

나는 다음과 같은 가정으로 AoP를 사용해 인가 로직을 분리하려 한다.

  1. 대부분 요청은 하나의 자원에 대한 요청임.
  2. 대부분 요청은 자원에 대한 id 값을 가지고 있음.

즉 Advice에서 자원의 타입과 id, 그리고 유저를 알 수 있다면 이를 통해 해당 유저가 자원에 대한 권한이 있는지를 확인할 수 있는것이다.

어노테이션 작성

우선 AoP의 포인트컷이자, 정보를 전달하기 위한 어노테이션을 만들었다.

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

    Class<?> value();
}

이 어노테이션은 인가가 필요한 서비스 레이어의 메서드에 사용해, 인가가 필요한 타입을 Advice로 전달하기 위한 목적이다.

Aspect 작성

다음으로는 해당 어노테이션이 붙은 메서드가 실행되기 전에 정보를 가져와 인가작업을 수행하는 Aspect를 작성했다.

    @Before("@annotation(checkOwnership)")
    public void checkOwnership(JoinPoint joinPoint, CheckOwnership checkOwnership) {

        Class<?> clazz = checkOwnership.value();
        Long userId = (Long) joinPoint.getArgs()[0];
        Long targetId = (Long) joinPoint.getArgs()[1];

        if (clazz.equals(ProductDetail.class)) {
            checkProductDetailOwnership(userId, targetId);
        } else if (clazz.equals(Product.class)) {
            checkProductOwnership(userId, targetId);
        }
    }

    public void checkProductOwnership(Long userId, Long productId) {
        Product product = productRepository.findById(productId).orElseThrow(() -> {
            log.error(ErrorCode.PRODUCTID_NOT_FOUND.getLogMessage(), productId);
            throw JshopException.of(ErrorCode.PRODUCTID_NOT_FOUND);
        });
        User owner = product.getOwner();

        if (!owner.getId().equals(userId)) {
            log.error(ErrorCode.UNAUTHORIZED.getLogMessage(), "Product", productId, userId);
            throw JshopException.of(ErrorCode.UNAUTHORIZED);
        }
    }

인가를 진행할 엔티티 타입은 어노테이션으로 부터 받고, 인가에 필요한 id는 파라미터를 통해 받는다. 이를 위해 인가 AoP 기능을 사용하는 메서드의 파라미터는 항상 일관된 순서를 가져야 한다 (유저ID, 타겟ID)

모든 정보를 받았다면, 기존 서비스 레이어에 있는 인가 로직을 수행하게 된다.

서비스 레이어 리팩토링

서비스 레이어에 존재하던 인가 로직이 Advice로 옮겨갔으니 더이상 서비스 레이어에서는 인가 작업을 수행할 필요가 없다.

    @CheckOwnership(ProductDetail.class)
    public void deleteProductDetail(Long userId, Long detailId) {
        ProductDetail productDetail = productDetailRepository.findById(detailId).orElseThrow(() -> {
            log.error(ErrorCode.INVALID_PRODUCTDETAIL_PRODUCT.getLogMessage(), detailId);
            throw JshopException.of(ErrorCode.INVALID_PRODUCTDETAIL_PRODUCT);
        });

        productDetail.delete();
    }

게다가 findById 또한 인가 과정중에 데이터가 1차 캐시에 캐싱되어 있으니 DB로의 불필요한 요청을 걱정할 필요도 없다.

정리

프로젝트를 진행하며 중복 코드가 많이 발생해 이를 어떻게하면 줄일 수 있을지 고민했다.

그러던중 스프링의 특징인 DI/IoC, PSA, AoP 가 떠올랐다.

AoP를 사용한다면 중복된 횡단 관심사를 분리하여 비즈니스 로직이 한층 더 깨끗해질 수 있다 생각해 AoP를 적용하기로 했다.

확실히 AoP를 적용하니 불필요한 작업이 사라져 비즈니스 로직이 더욱 간결해졌고 유지보수 또한 쉬워질것 같다.

profile
김재현입니다.

0개의 댓글