본문 바로가기
Project

[Toss] 중복거래 방지 기능

by sangyunpark 2023. 8. 29.

동시성 이슈(concurrency issue)란?

여러 요청이 동일한 자원에 접근하며 발생하는 문제들을 통칭, 주로 DB에서 동일한 레코드를 동시 접근하며 문제가 발생한다.

 

A사용자의 2번인 40000원 사용으로 인해 남은 금액이 60,000원이여야 하는데,

동시성 이슈로 인해 B사용자의 계좌에는 여전히 100,000만원이 조회되었고, 그중 20,000원을 송금하여서 최종적으로 80,000원이 DB에 남게되었다.

 

해결하는 방법

(1) DB에 의존하기

(2) 기타 인프라 활용하기

(3) 비즈니스 로직으로 해결하기

 

어떻게 해결하면 좋을까?

aop를 사용해서 Lock을 걸어준다.

 

@AccountLock - Annotation

package com.example.account.aop;


import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented // api 문서를 만들 때 어노테이션에 대한 설명도 포함하도록 지정해주는 것
@Inherited
public @interface AccountLock {
    long tryLockTime() default 5000L;
}

AOP 설정시 Annotation을 Custom하는 경우

meta-annotation인 @Target과 @Retention 어노테이션을 사용한다.

 

Meta-annotation에 대해서 짚고 넘어가자면

meta-annotation은 다른 annotation에서도 사용되는 annotation의 경우를 말하며 custom-annotation을 생성할 때 주로 사용된다.

 

예시)

@Service는 bean으로 등록해주기 위해 @Component를 내포하고 있는 형태로, 여기서 @Component가 meta-annotatino이다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component // Spring will see this and treat @Service in the same way as @Component
public @interface Service {

// ....
}

 

@Target

JavaCompiler가 annotation이 어디에 적용될지 결정하기 위해 사용한다.

ex) ElementType.TYPE : 해당 Annotation은 타입 선언시 사용한다는 의미

 

종류

ElementType.PACKAGE : 패키지 선언
ElementType.TYPE : 타입 선언
ElementType.ANNOTATION_TYPE : 어노테이션 타입 선언
ElementType.CONSTRUCTOR : 생성자 선언
ElementType.FIELD : 멤버 변수 선언
ElementType.LOCAL_VARIABLE : 지역 변수 선언
ElementType.METHOD : 메서드 선언
ElementType.PARAMETER : 전달인자 선언
ElementType.TYPE_PARAMETER : 전달인자 타입 선언
ElementType.TYPE_USE : 타입 선언

 

@Retention

Annotation이 실제로 적용되고 유지되는 범위를 말한다.

Policy에 관련된 Annotation으로 컴파일 이후에도 JVM에서 참조가 가능한 RUNTIME으로 지정한다.

 

종류

RetentionPolicy.RUNTIME // 컴파일 이후에도 JVM에 의해서 계속 참조가 가능하다. 주로 리플렉션, 로깅에 사용
RetentionPolicy.CLASS // 컴파일러가 클래스를 참조할 때까지 유효하다.
RetentionPolicy.SOURCE // 컴파일 전까지만 유효하다. 컴파일 이후에는 사라지게 된다.

Reflection이란?

구체적인 클래스 타입을 알지 못해도 그 클래스의 메소드, 타입, 변수들에 접근할 수 있도록 해주는 자바 API

 

LockAopAspect Service

package com.example.account.service;

import com.example.account.exception.AccountException;
import com.example.account.type.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;

@Slf4j
@Service
@RequiredArgsConstructor // final
public class LockService {
    private final RedissonClient redissonClient; // 생성자 자동 주입

    public void lock(String accountNumber){
        RLock lock = this.redissonClient.getLock(getLockKey(accountNumber)); // Lock에 사용하는 key
        log.debug("Trying lock for accountNumber : {}", accountNumber);

        try {
            boolean isLock = lock.tryLock(1,9, TimeUnit.SECONDS); // wait time : lock 취득에 걸리는 시간
            // leaseTime : lock이 자동으로 해제가 되는 시간
            if(!isLock) {
                log.error("=====Lock acquisition failed=====");
                throw new AccountException(ErrorCode.ACCOUNT_TRANSACTION_LOCK);
            }
        } catch(AccountException e){ // global exception handler가 인식 할 수 있도록 구현
            throw e;
        }
        catch (Exception e) {
            log.error("Redis lock failed");
        }
    }

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

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

LockAopAspect를 사용하게되는 상황은 2가지가 존재한다.

잔액을 사용할 경우, 잔액 사용을 취소할 경우

UseBalance와 CancelBalance라는 두가지 타입이 존재한다.

두 타입에서 계좌번호를 가져와야 하기 때문에, 이 두타입을 공통화 시킬 수있는 AccountLockIdInterface를 사용한다.

 

AccountLockIdInterface

package com.example.account.aop;

public interface AccountLockIdInterface {
    String getAccountNumber();
}

 

UseBalance의 innerClass에 인터페이스를 implement해준다.

public class UseBalance {

    @Getter
    @Setter
    @NoArgsConstructor
    @AllArgsConstructor
    @Builder
    public static class Request implements AccountLockIdInterface {
        @NotNull
        @Min(1)
        private Long id;

        @NotBlank
        @Size(min = 10, max = 10)
        private String accountNumber;

        @NotNull
        @Min(10)

 

마찬가지로 CancelBalance에도 해준다.

public class CancelBalance {

    @Getter
    @Setter
    @AllArgsConstructor
    @Builder
    public static class Request implements AccountLockIdInterface {
        @NotNull
        private String transactionId;

        @NotBlank
        @Size(min = 10, max = 10)
        private String accountNumber;

        @NotNull
        @Min(10)
        @Max(1000_000_000)
        private Long amount;
    }

 

 

ErrorCode

package com.example.account.type;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public enum ErrorCode {

    INTERNAL_SERVER_ERROR("내부 서버 오류가 발생했습니다."),

    USER_NOT_FOUND("사용자가 존재하지 않습니다."),
    MAX_ACCOUNT_PER_USER_10("사용자 최대 계좌는 10개입니다."),
    ACCOUNT_NOT_FOUND("계좌가 존재하지 않습니다."),
    USER_ACCOUNT_UN_MATCH("사용자 아이디와 계좌 소유주가 다릅니다."),
    ACCOUNT_TRANSACTION_LOCK("해당 계좌는 사용 중입니다."),

    ACCOUNT_ALREADY_UNREGISTERD("계좌가 이미 해지된 생태입니다."),
    BALANCE_NOT_EMPTY("계좌에 잔액이 남아있습니다."),
    AMOUNT_EXCEED_BALANCE("거래 금액이 계좌 잔액보다 큽니다."),

    TRANSACTION_NOT_FOUND("거래 아이디에 해당하는 거래가 없습니다."),

    TRANSACTION_ACCOUNT_UN_MATCH("이 거래는 해당 계좌에서 발생한 거래가 아닙니다."),
    CANCEL_MUST_FULLY("부분 취소는 허용되지 않습니다."),
    TOO_OLD_ORDER_TO_CANCEL("1년이 지난 거래는 취소가 불가능합니다."),
    INVALID_REQUEST("잘못된 요청입니다.");



    private final String description;
}

해당 계좌가 Lock이 걸려있을때, 사용할 에러코드인 ACCOUNT_TRANSACTION_LOCK을 추가해주자

'Project' 카테고리의 다른 글

[Toss] 리팩토링  (0) 2023.08.30
[Toss] 중복거래 방지기능 (2)  (0) 2023.08.30
[Toss] 예외처리 수정  (0) 2023.08.28
[Toss] 잔액사용 확인(2)  (0) 2023.08.26
[Toss] 잔액 사용 확인(1)  (0) 2023.08.25