본문 바로가기
공부

AOP로 동시성 이슈 해결 - 중복 거래 방지

by son_i 2024. 3. 28.
728x90

지난 번 포스팅에서 Redissson Client와 Embedded Redis를 기본 설정 해봤다.

이번엔 이것들을 바탕으로 현재 만들고 있는 프로젝트의 계좌에 Lock을 걸어서 동시성 이슈가 발생하지 않게 한다.

 

 

동시성 이슈란 ? 

  • 동일한 자원에 대해 둘 이상의 스레드가 동시에 제어할 때 나타나는 문제.
    • 지역 변수에 대해서는 스레드마다 다른 메모리 영역을 할당 받기 때문에 발생하지 않지만 인스턴스 필드 또는 static과 같은 공용 필드에 접근에 대해서 발생한다.
    • 동시성 문제란 동일한 자원에 대해서 접근한다고 무조건 발생하는 것이 아닌⇒ 즉, 변경하지 않고 읽기만 하면 발생하지 않음.
    • 동시에 접근한 자원에 대해서 변경이 일어나는 경우 발생하는 문제

진행 과정

1. 커스텀 어노테이션 AccountLock을 만든다.

 

커스텀 어노테이션이란 ? 메타 어노테이션을 이용하여 만들 수 있다.

  * 메타 어노테이션 : 커스텀 어노테이션을 구성할 때 시점, 위치 등을 지정하기 위한 어노테이션.

 

어노테이션의 필드 타입은 enum, String이나 기본 자료형, 기본 자료형의 배열만 사용 가능.(Wrapper 타입은 안 된다는 말)

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

 

커스텀 어노테이션은 @interface를 붙여서 생성하며 안에 필드들은 abstract로 생성되게 된다.

 

@Target : 어노테이션이 사용될 대상 지정

     ElementType.METHOD 는 메소드에 붙일 수 있는 어노테이션이 된다.

 

@Retention : 컴파일러가 어노테이션을 다루는 방법을 기술. 어느 시점까지 영향을 미칠 지를 결정

     RetetionPolicy.RUNTIME : 런타임 시점에 자바 리플렉션을 이용해서 어노테이션을 참조할 수 있다.

 

@Documented : 자바 doc에 해당 어노테이션을 포함시킨다.

 

@Inherited : 어노테이션의 상속을 가능하게 한다.

 

필드 값으로 tryLockTime()을 넣어줘서 해당 시간 동안 Lock을 기다리게 하는 기본 값 설정.

 


2. 커스텀 어노테이션 AccountLock을 잔액 사용, 잔액 사용 취소 메소드에 붙인다.

@PostMapping("/use")
    @AccountLock
    public ResponseEntity<UseBalance.Response> useBalance(
            @RequestBody @Valid UseBalance.Request request) {

        try {
            return ResponseEntity.ok(
                    UseBalance.Response.from(transactionService.useBalance(
                            request.getUserId(),
                            request.getAccountNumber(),
                            request.getAmount()))
            );
        } catch (AccountException e) {
            log.error("Failed to use balance. ");
            transactionService.saveFailedUseTransaction(
                    request.getAccountNumber(),
                    request.getAmount()
            );
            throw e;
        }
    }

    @PostMapping("/cancel")
    @AccountLock
    public ResponseEntity<CancelBalance.Response> useCancel(
            @RequestBody @Valid CancelBalance.Request request) {

        try {
            return ResponseEntity.ok(
                    CancelBalance.Response.from(transactionService.cancelBalance(
                            request.getTransactionId(),
                            request.getAccountNumber(),
                            request.getAmount()))
            );
        } catch (AccountException e) {
            log.error("Failed to use balance. ");
            transactionService.saveFailedCancelTransaction(
                    request.getAccountNumber(),
                    request.getAmount()
            );
            throw e;
        }

 

어노테이션을 메소드에 붙인다고 되는 것은 아니고 단순히 붙였을 뿐임.

 

이 어노테이션을 붙였을 때 동작하는 부분을 만드려면 AOP를 사용해야 함.


3. LockService 클래스 생성 (lock, unlock 메소드)

@Slf4j
@Service
@RequiredArgsConstructor
public class LockService {
    private final RedissonClient redissonClient;
    //RedisRepositoryConfig에서 만들어둔 redissonClient bean과 이름이 같으므로 자동 주입이 됨.

    public void lock(String accountNumber) {
        RLock lock = redissonClient.getLock(getLockKey(accountNumber));
        log.debug("Trying lock for accountNumber : {}", accountNumber);
        //1. redissonClient에 accouontNumber키로 가져오고

        try { //2. spinLock 시도 1초동안 lock을 시도하고 3초동안 lock을 가지고 있음.
            boolean isLock = lock.tryLock(1, 15, TimeUnit.SECONDS);
            if (!isLock) { //lock 취둑 실패
                log.error("====== Lock acquisition failed =====");
                throw new AccountException(ErrorCode.ACCOUNT_TRANSACTION_LOCK);
            }
        } catch (Exception e) {
            log.error("Redis lock failed");
        }
        //명시적으로 unlock을 하고 있지 않기 때문에
        //다른 녀석이 lock취득하려고 하면 5초동안은 실패하게 될 것임
    }
    public void unlock(String accountNumber) {
        log.debug("Unlock for accountNumber : {}", accountNumber);
        redissonClient.getLock(getLockKey(accountNumber)).unlock();
    }

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

 

- redissonClient.getLock(getLockKey(accountNumber)) : 계좌번호를 키 값으로 하여 Lock을 가져옴.

 

- lock.tryLock(waitTime, leaseTime, TimeUnit) :

      waitTime  동안 Lock 취득을 위해 기다리고,  leaseTime 동안 Lock을 가지고 있음. (leaseTime 후 자동으로 해제)


4. LockAopAspect 클래스 생성

@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class LockAopAspect {
    private final LockService lockService;

    @Around("@annotation(com.zerobase.convpay.aop.AccountLock) && args(request)")
    public Object aroundMethod( //@Around는 pjp.proceed before, after에 모두 동작
            ProceedingJoinPoint pjp,
            AccountLockIdInterface request
    ) throws Throwable {
        //lock 취득 시도
        lockService.lock(request.getAccountNumber());
        try {
            return pjp.proceed();
        } finally {
            // lock 해제
            lockService.unlock(request.getAccountNumber());
        }
    }
}

@Aspect : Aspect 역할을 할 클래스를 선언

 

@Component : 해당 Aspect를 스프링의 Bean으로 등록해서 사용하기 위함.

 

@Around : Advice어노테이션으로 괄호 안에 쓴 부분의 전 후에 처리할 로직을 만들어 준다는 의미.

   * Advice란 Aspect가 추상화하고 있는 내용들을 실제로 적용하는 기능. == AOP에서 실제로 적용하는 기능(로깅, 트랜잭션, 캐시, 인증 등)

 

  Around advice는 ProceedingJoinPoint를 파라미터로 받아 실행할 메소드의 시점을 조절하거나, 각종 정보들을 받아올 수 있다. 

 

@Around("@annotation(com.example.account.aop.AccountLock) && args(request)") :

    @annotation(com.example.account.aop.AccountLock)  Around 괄호 안에 만든 어노테이션을 패키지 이름까지 넣어줌. 커스텀 어노테이션이 붙은 메소드의 전 후에 @Around가 붙은 메소드가 실행됨.

  

    args(request) request를 인자로 가져와서 UseBalance, CancelBalance의 request에서 accountNumber을 가져올 수 있도록 함.

   * UseBalance.Request와 CancelBalance.Request는 타입이 다르므로 공통화 시키기 위해 인터페이스를 생성하고 각 

UseBalance.Request와 CancelBalance.Request가 implements 받도록 한다.

public interface AccountLockIdInterface {
    String getAccountNumber();
}

 

UseBalance.Request와 CancelBalance.Requestdpsms accountNumber 필드가 원래 존재하고, @Getter로 인해 getAccountNumber() 메소드가 존재하기 때문에 AccountLockIdInterface를 구현한 것으로 되어 정상적으로 사용할 수 있다.

 

그리고 arounMethod의 파라미터로 UseBalance.Request 나 CancelBalance의 Request가 아닌

AccountLockIdInterface를 파라미터로 받도록 한다 !

 

 

pjp.proceed() : AOP를 걸어줬던 부분을 동작시킴.

어떤부분 ????? :  @AccountLock 어노테이션이 달린 메소드가 실행되는 전/후에(Around니까) lock을 취득했다가(전에) 해제(후에)했다가 동작을 반복하게 됨.

 

useBalance() 나 cancelBalance()가 pjp.proceed() 할 때 실행되고 실행되기 전에 lock 취득 시도를, 실행된 후에는 lock 취득 해제를 하게됨.

 

finally로 AOP 걸어준 부분이 성공하든 실패하든 lock을 해제해줌. 

 

 

정리)
커스텀 어노테이션 @AccountLock을 만들어줬고, UseBalance()와 CancelBalance()에 이 어노테이션을 달아주었다. 이 어노테이션 달린부분(UseBalance(), CancelBalance()) 에서 동작하게 되는 aroundMethod(AOP)를 LockAopAspect 클래스에 만들어준 것임. !
lock 취득하고 useBalance()나 cancelBalance() 동작 후에 lock 해제를 뱐복함 ! 

실제 동작 확인

useBalance()에 Thread.sleep(3000L) 넣어줌.

 

잔액 사용 요청을 두 번 빠르게 하면 두 번째 요청에서 lock 취득 실패 에러가 난다.

 

그것과 동시에 포스트맨에서는 INTERNAL_SERVER_ERROR가 나버린다. 원인을 찾아보니까

LockService에서 Exception을 catch하고 있어서 그렇다.

 

위의 부분을 아래와 같이 AccountException을 catch해서 throw해주고 우리가 원하는 ErrorResponse 형태로 나올 수 있게 한다.

 

두 번 연속으로 실행 시 AccountException을 다시 한 번 던져서 GlobalExceptionHandler가 잡을 수 있도록 하여 우리가 원하는 ErrorResponse가 나왔다. 

 

 

그런데 Transaction DB에 실패 내역이 저장되지 않음. ==> Y ??????

 


  테스트