[Spring] AOP 프로그래밍

최동근·2023년 2월 20일
0

스프링

목록 보기
7/8
post-custom-banner

안녕하세요 오늘은 스프링에서의 중요한 개념인 AOP(Aspect Object Programming) 에 대해서 배워보겠습니다 👨‍💻

🧙‍♀️ AOP 개념

어플리케이션은 다양한 공통 기능을 필요로 합니다. 로깅과 같은 기본적인 기능에서부터 트랜잭션이나 보안과 같은 기능에 이르기까지 어플리케이션 전반에 걸쳐 적용되는 공통 기능이 존재합니다.

이들 공통 기능들은 어떤 특정 모듈에서만 필요로 하는 것이 아니라, 어플리케이션 전반에 걸쳐 필요한 기능입니다 👨‍💻
핵심 비즈니스 기능과 구분하기 위해 공통 기능을 공통 관심 사항(Cross-Cutting Concerns) 이라고 표현하며, 핵심 로직을 핵심 관심 사항(Core-Concerns) 이라고 합니다.

AOP 프로그래밍은 문제를 해결하기 위한 핵심 관심 사항과 전체에 적용되는 공통 관심 사항을 기준으로 프로그래밍 하는 것을 의미하며, 공통 모듈을 여러 코드에 쉽게 적용할 수 있도록 도와줍니다.

즉, 어떤 로직을 기준으로 핵심 관심 사항, 공통 관심 사항으로 나누어서 보고 그것을 기준으로 각각 모듈화 하는 것을 의미합니다.

해당 이미지를 통해 AOP 와 더 친해져봅시다 🫶
Presentation Layer, Business Layer 그리고 Data Access Layer 3개의 계층에는 핵심 관심 사항 이 존재합니다. 각 계층에 핵심 관심 사항 은 Logging, Transaction management 그리고 Security 3개의 공통 관심 사항 이 적용됩니다.

이렇게 공통으로 적용되는 공통 관심 사항 을 따로 모듈로 설계를 하면 불필요한 중복 코드를 줄일 수 있으며, 추후 유지 보수에 좋습니다 👨‍💻

스프링 AOP

스프링은 자체적으로 프록시 기반의 AOP 를 지원합니다.
여기서 프록시 기반 이란 개발자가 AOP 클래스를 설계하고 핵심 관심 기능을 구현한 코드에 접근할 때 직접 접근하는 것이 아니라 핵심 관심 기능을 구현한 클래스의 프록시 객체를 이용하여 접근하는 방식을 의미합니다.
따라서 스프링 AOP 는 메소드 호출 Join Point 만 지원합니다. 즉 Join Point 는 대상 객체의 메소드만 가능합니다 👨‍💻

스프링 AOP는 완전한 AOP 기능을 제공하는 것이 목적이 아니라 엔터프라이즈 어플리케이션을 구현하는 데 필요한 기능을 제공하는 것을 목적으로 하고 있습니다.
스프링 AOP 의 특징으로는 자바 기반이라는 것입니다. 따라서 별도의 문법을 익힐 필요가 없이 스프링 AOP 를 사용할 수 있습니다.

JDK Dynamic Proxy VS CGLIB Proxy

JDK Dynamic ProxyCGLIB Proxy 는 프록시 객체를 생성하는 방식을 의미합니다.
두 방식을 결정하는 기준은 대상이 되는 핵심 관심 기능을 구현한 클래스가 인터페이스를 구현했냐의 여부입니다

  • JDK Dynamic Proxy
    JDK Dynamic Proxy 는 Proxy Factory 에 의해 런타임시 동적으로 만들어지는 객체입니다.
    반드시 인터페이스가 정의되어 있어야 하며, 인터페이스에 대한 명세를 기준으로 Proxy를 생성합니다.

    • Spring AOP 근간이 되는 방식
    • 인터페이스를 기준으로 Proxy 객체를 생성함
    • 인터페이스가 반드시 필요함
    • Reflection 을 사용하기 때문에 성능적으로 떨어짐
  • CGLib Proxy

    CGLib Proxy 는 순수 Java JDK 라이브러리를 이용하는 것이 아닌 CGLib 라는 외부 라이브러리르 추가해야만 사용할 수 있습니다. CGLib Proxy 는 핵심 관심 기능을 구현한 클래스를 상속 받아 생성하기 때문에 인터페이스를 만들어야 하는 수고를 덜 수 있습니다.

    • 상속을 이용하기 때문에 클래스나 메소드에 final 이 있으면 안됨
    • 스프링 부트에서 AOP 사용을 위해 채택함
    • CGLib 는 고성능의 코드 생성 라이브러리로 인터페이스를 필요로 하는 JDK Dynamic Proxy 대신 사용될 수 있음.
    • Reflection 을 사용하지 않기 때문에 성능이 비교적 좋음

스프링 부트는 CGLib 방식을 채택하고 있습니다. 하지만 CGLib 는 외부 라이브러리기 때문에 여러가지 한계가 존재합니다.
하지만 스프링부트는 문제가 되는 부분들을 개선하여 안정화 시켰습니다 👍

🧙‍♀️ AOP 관련 용어

AOP 주요 용어

  • Aspect : 흩어진 공통 관심 사항을 모듈화 한 것 (Advice + Point Cut)
  • Advice : Join Point 에서 실행되는 코드 즉 공통 관심 사항 그 자체 + 언제 공통 관심 사항을 핵심 로직에 적용할 지를 정의
  • Join Point : Advice 를 적용 가능한 지점을 의미 (메소드 호출, 필드 값 변경 등)
  • Point Cut : Join Point 의 부분 집합으로서 실제로 Advice 가 적용되는 Join Point 를 나타냄
  • AOP proxy : 대상 객체에 Aspect 를 적용하는 경우 Advice 를 덧붙이기 위해 하는 작업을 AOP proxy 라고 함
  • Weaving : Advice 를 핵심 관심 기능 코드에 적용하는 것을 의미함

Spring Advice 종류

종류설명
Before Advice대상 객체의 메소드 호출 전에 공통 기능을 실행합니다.
After Returning Advice대상 객체의 메소드가 익셉션 없이 실행된 이후에 공통 기능을 실행합니다.
After Throwing Advice대상 객체의 메소드를 실행하는 도중 익셉션이 발생한 경우에 공통 기능을 실행합니다.
After Advice대상 객체의 메소드를 실행하는 도중에 익셉션 여부와 관계 없이 공통 기능을 실행합니다.
Around Advice대상 객체의 메소드 실행 전, 후 또는 익셉션 발생 시점에 공통 기눙을 실행합니다.

🧙‍♀️ AOP 실습

앞에서 살펴본 Spring Advice 를 하나씩 적용하는 실습을 해보겠습니다 🔫
실습은 자바 코드를 기반으로 @Aspect 를 이용해서 진행해보겠습니다.

여기서 우리가 설계할 것은 Advice + Point Cut 정보를 담고 있는 AOP 클래스입니다 🔥

(1) Before Advice

@Aspect
public class TestAspectClass { 
	
    @Before("execution(public * com.example.test..*(..))")
    public void before() { 
        System.out.println("메소드 실행 전 전처리 수행");
   }
}

Before Advice 는 대상 객체의 메소드 호출 전에 공통 기능을 실행합니다.
즉 @Before 에 있는 Point Cut 이전에 해당 기능을 수행합니다 🔥

만약 이때 대상 객체 및 호출되는 메소드에 대한 정보 또는 메소드에 전달되는 파라미터에 대한 정보가 필요하다면 JoinPoint 타입의 파라미터를 메소드에 전달합니다.

@Aspect
public class TestAspectClass { 

	@Before("execution(public * com.example.test..*(..))")
    public void before(JoinPoint joinPoint) { // JoinPoint 타입 파라미터 전달
    	...
    }
}

(2) After Returning Advice

@Aspect
public class TestAspectClass {

	@AfterReturning(
    	pointCut = "execution(public * com.example.test..*(..))",
        returning = "ret") // 대상 객체의 메소드에 리턴 값을 받을 수 있음
    public void afterReturning(JoinPoint joinPoint, Object ret) { 타입 파라미터 전달
    	....
    }
}

After Returning Advice 는 대상 객체의 메소드가 익셉션 없이 정상적으로 리턴 하였을때 실행되는 Advice 입니다.
위에 코드 처럼 returning 속성을 이용해서 대상 객체의 메소드의 리턴값을 받아올 수 있습니다 👨‍💻

(3) After Throwing Advice

@Aspect
public class TestAspectClass { 
	
    @AfterThrowing(
    	pointCut = "execution(public * com.example.test..*(..))",
        throwing = "ex") // 대상 객체의 메소드에 예외 인스턴스를 받아올 수 있음
    public void afterThrowing(JoinPoint joinPoint, Throwable ex) {
    	...
    }
}

After Throwing Advice 는 대상 객체의 메소드가 익셉션을 발생시킬 때 실행되는 Advice 입니다.
위에 코드처럼 Throwing 속성을 이용해서 예외 객체를 받아올 수 있습니다 👨‍💻

(4) After Advice

@Aspect
public class TestAspectClass { 
	
    @After("execution(public * com.example.test..*(..))")
	public void after(JoinPoint joinPoint) {
    	...
    }
}

After Advice 는 대상 객체의 메소드의 예외 발생 여부와 상관 없이 실행되는 Advice 입니다.

(5) Around Advice

@Aspect
public class TestAspectClass { 

	private Map<Integer, Article> cache = new HashMap<Integer, Article>();
    
    @Around("executio(public * * ..ReadArticleService.*(..))")
    public Article( ProceedingJoinPoint pjp) throws Throwable { 
    	
        Integer id = (Integer) pjp.getArgs()[0];
        Article article = cache.get(id);
        
        if(article != null ) {
        	System.out.println("not null");
            return article;
        }
        Article ret = (Article) joinPoint.prceed(); // 대상 프록시 객체 메소드 실행
        if(ret != null ) {
        	cache.put(id,ret);
        }
        return ret;
     }
 }

Around Advice 는 대상 객체의 메소드 전/후에 실행되는 Advice 입니다.
다른 종류의 Advice 와 달리 대상 객체 메소드 , 클래스 정보 및 파라미터를 가져오기 위해 사용되는 객체는
ProceedingJoinPoint 입니다.

JoinPoint & ProceedingJoinPoint

앞에서 살펴보았던 봐와 같이 Spring AOP 를 설계함에 있어 핵심 관심 기능을 구현한 대상 객체의 여러 정보가 필요할 수 있습니다. 이때 우리는 JoinPointProceedingJoinPoint 를 사용할 수 있습니다 👨‍💻

두 타입의 객체는 Spring AOP 메소드에서 반드시 첫 번째 파라미터로 지정해주어야 합니다. 그렇지 않으면 익셉션을 발생시킵니다.

ProceedingJoinPointAround Advice 에서만 사용할 수 있으며 JoinPoint 인터페이스를 상속받고 있습니다. JoinPoint 는 여러 메소드를 제공합니다.

  • Signature getSignature() : 호출되는 메소드에 대한 정보를 구합니다.
  • Object getTarget() : 대상 객체를 구합니다.
  • Object[] getArgs() : 파라미터 목록을 구합니다.

또한 여기서 Signature 인터페이스는 호출되는 메소드와 관련된 정보를 제공하는 메소드를 가집니다.

  • String getName() : 메소드의 이름을 구합니다.
  • String toLongString() : 메소드를 완전하게 표현한 문장을 구합니다.
  • String toShortString() : 메소드를 간략하게 표현한 문장을 구합니다.

ProceedingJoinPoint 는 차별적으로 프록시 대상 객체를 호출할 수 있는 proceed() 메소드를 제공합니다 🔥

🧙‍♀️ Annotation 기반 스프링 AOP

지금까지 Spring 에서의 AOP 개념과 사용 방법에 대해 알아보았습니다.
이번 부분에서는 직접 커스텀 어노테이션을 작성하고 작성한 어노테이션을 AOP 방식으로 적용해보겠습니다.

Annotation 기반 스프링 AOP 에 대한 부분은 계좌 시스템 구현해보기 💸 프로젝트의 일부분을 통해 학습해보겠습니다 🧐
또한 커스텀 어노테이션 작성 방식에 대해 모른다면 Spring Boot Custom Annotation 만들기 포스팅을 참고해주세요 ❗️

구현하기 앞서 필요한 것

  • 개발자가 직접 정의한 커스텀 어노테이션
  • 커스텀 어노테이션이 적용된 메소드
  • Aspect(Advice + PointCut) 을 정의한 Aspect 클래스

(1) 커스텀 어노테이션

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface AccountLock {
    long tryLockTime() default 5000L;
}

해당 커스텀 어노테이션은 사용자가 계좌 거래를 요청 할 때 중복 거래를 방지하기 위해 Lock 을 걸 때 관련된 어노테이션입니다.
참고로 Lock을 취득하고 해제하는 기능을 Embedded Redis 을 이용하여 구현했습니다 ❗️

(2) 커스텀 어노테이션이 적용된 메소드


/**
 * 잔액 관련 컨틀롤러
 * 1. 잔액 사용
 * 2. 잔액 사용 취소
 * 3. 거래 확인
 */

@Slf4j
@RestController
@RequiredArgsConstructor
public class TransactionController {

    private final TransactionService transactionService;

    // 잔액 사용 API
    @AccountLock // 커스텀 어노테이션 사용 ❗️
    @PostMapping("/transaction/use")
    public UseBalance.Response useBalance(
            @RequestBody @Valid UseBalance.Request request
    ) throws InterruptedException {

        try {
            Thread.sleep(3000L);
            return UseBalance.Response.fromTransactionDto(
                    transactionService.useBalance(request.getUserId(),
                            request.getAccountNumber(),request.getAmount())
            );
        } catch (AccountException e) {
            log.error("Failed to use balance");
            transactionService.saveFailedUserTransaction(
                    request.getAccountNumber(),request.getAmount()
            );
            throw e;
        }
    }

    // 잔액 사용 취소 API
    @AccountLock // 커스텀 어노테이션 사용 ❗️
    @PostMapping("/transaction/cancel")
    public CancelBalance.Response cancelBalance(
            @RequestBody @Valid CancelBalance.Request request
    ){

        try{
            return CancelBalance.Response.fromTransactionDto(
                    transactionService.cancelBalance(request.getTransactionId()
                            ,request.getAccountNumber(), request.getAmount()));

        }catch (AccountException e){
            log.error("Failed to cancel balance");
            transactionService.saveFailedCancelTransaction(request.getAccountNumber(),
                    request.getAmount()
            );

            throw e;
        }
    }


	// 거래 정보 확인
    @GetMapping("/transaction/{transactionId}")
    public QueryTransactionResponse queryTransaction(
            @PathVariable String transactionId
    ) {

        return QueryTransactionResponse.fromTransactionDto(
                transactionService.queryTransaction(transactionId)
        );

    }
}

위에서 만든 커스텀 어노테이션 AccountLock 을 잔액 사용 API 와 잔액 사용 취소 API 컨트롤러 메소드에 적용합니다.

(3) Aspect 클래스

@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class LockAopAspect {
    private final LockService lockService;
    
    // @AccountLock 어노테이션 위치
    @Around("@annotation(com.example.accountsystemimpl.aop.AccountLock) && args(request)") 
    public Object aroundMethod(
            ProceedingJoinPoint joinPoint,
            AccountLockIdInterface request
    ) throws Throwable {
        // Lock 획득 시도

        lockService.lock(request.getAccountNumber());
        try {
            return joinPoint.proceed(); // 대상 객체 메소드 실행
        }finally {
            // lock 해제
            lockService.unlock(request.getAccountNumber());
        }
    }
}

Around Advice 를 사용해서 AccountLock 이 선언된 메소드 전 후에 Lock을 취득하고 Lock을 풀어줄 수 있도록 합니다.

@Slf4j
@RequiredArgsConstructor
@Service
public class LockService {

    private final RedissonClient redissonClient; // 이름 동일한 Bean 주입

    public String lock(String accountNumber) {

        RLock lock = redissonClient.getLock(getLockKey(accountNumber));
        log.debug("Trying lock for AccountNumber : {}", accountNumber);

        try {
            boolean isLock = lock.tryLock(1, 5, TimeUnit.SECONDS);
            // waitTime : 기다리는 시간
            // leaseTime : 자동으로 풀리는 시간

            if(!isLock) {
                log.error("=====Lock acquisition failed=====");
                throw new AccountException(ErrorCode.ACCOUNT_TRANSACTION_LOCK);
            }
        }
        catch(AccountException e) {
            throw e;
        }catch(Exception e) {
            log.error("Redis lock Failed");
        }
        return "Lock success";
    }

    public void unlock(String accountNumber) {
        log.debug("Unlock for accountNumber : {}",accountNumber);
        redissonClient.getLock(getLockKey(accountNumber)).unlock();
    }

    private String getLockKey(String accountNumber) {
        return "ACLK:" + accountNumber;
    }
}

LockService 클래스는 LockAspect 에서 실행되는 로직을 담고 있는 클래스입니다.


참고

[Spring] Spring의 AOP 프록시 구현 방법(JDK 동적 프록시, CGLib 프록시)과 @EnableAspectJAutoProxy의 proxyTargetClass - (3/3)
Spring AOP
Annotation기반 스프링 AOP

profile
비즈니스가치를추구하는개발자
post-custom-banner

0개의 댓글