Spring Redisson 분산락(Distribute Lock) 좀 더 잘 써보기 (2/3) - AOP

들어가며

이 글은 1.템플릿 콜백 패턴으로 구현, 2. AOP 로 구현, 3. Spring Redission 의 한계와 극복 총 3편으로 이루어져 있습니다. 이번 글은 1.템플릿 콜백 패턴으로 구현 에서 참고하는 부분이 있으므로 필요하시면 먼저 읽어주세요.

 

Spring Redisson 분산락(Distribute Lock) 좀 더 잘 써보기 (1/3) - 템플릿 콜백 패턴

들어가며 우선 이 글은 1.템플릿 콜백 패턴으로 구현, 2. AOP 로 구현, 3. Spring Redission 의 한계와 극복 총 3편으로 이루어져 있습니다. 여기서 사용한 예제코드에는 특정 문제가 있습니다. 해당 문제

jongmin4943.tistory.com

여기서 사용한 예제코드에는 특정 문제가 있습니다. 해당 문제에 대한 이야기는 3편에서 할 예정입니다.

사건의 시작

신규 프로젝트를 진행하던 도중 다른 팀에서 이런 얘기를 하는게 들려왔다.

"현재 예약시스템에서 요청이 몰릴 경우 재고보다 더 많이 예약이 되어 Over booking 이 일어나고 있고 있는데 어떻게 해결하죠? 분산환경이라 자바단에서는 해결이 어려울거 같고 RDBMS Lock 은 사용하기 어려운 상황인데..."

 

이 얘기를 듣고 나는 회사 그룹웨어 개발 당시, 칸반 기능을 만들때 동시성 해결을 위해 썼던 Spring Redisson 이 생각났다. 얘기를 나누고 계시는 곳으로 가서 "Redisson Lock 이란게 있으며 Redis 의 pub/sub 구조로 동작하고, 써봤는데 동시성 문제가 잘 해결되었다, 그걸 한번 적용해보는게 어떻겠느냐"고 했더니 채용되었으며 해당 공통 모듈을 직접 만들게 되었다.

AOP 를 이용한 Redisson Lock 구현

기존 그룹웨어에서 썼던 Redisson 은 템플릿콜백 패턴을 이용한 구조로 짰었다. 만든 뒤 다른분들은 보통 어떻게 구현하나 찾아보니 AOP 를 사용한 예제를 많았다. 그래서 이번엔 그 예제들을 참고해 현재 요구사항에 맞게 바꿔 AOP 를 이용해 만들어보았다.

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

    /**
     * Lock 의 key 값 설정
     */
    String keyPrefix();

    /**
     * 메서드의 파라미터값을 Key 에 추가로 붙일때 사용한다. Spring Expression Language (SpEL) 을 사용한다.
     * @see <a href="https://docs.spring.io/spring-framework/docs/3.0.x/reference/expressions.html">SpEl 문서</a>
     */
    String[] dynamicKey() default {};

    /**
     * 락 획득을 위해 waitTime 만큼 대기한다 (default - 5s)
     */
    long waitTime() default RedisLockTime.DEFAULT_WAIT_TIME;

    /**
     * 락을 획득한 이후 leaseTime 이 지나면 락을 해제한다 (default - 3s)
     */
    long leaseTime() default RedisLockTime.DEFAULT_LEASE_TIME;

}

DistributedLock 어노테이션에는 key 와 dynamicKey 두가지를 조합해 키를 생성할 수 있게 했다. dynamicKey 같은 경우에는 배열로 받을 수 있게 만들어 Redisson 의 multiLock 도 쓸 수 있게끔 만들었다.

@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributedLockAop {

    private final RedisLockService redisLockService;

    @Pointcut("@annotation(com.min.redisson.DistributedLock)")
    private void distributeLock() {
    }

    @Around("distributeLock()")
    public Object executeWithLock(final ProceedingJoinPoint joinPoint) throws Throwable {
        final MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        final Method method = methodSignature.getMethod();
        final String[] parameterNames = methodSignature.getParameterNames();
        final Object[] args = joinPoint.getArgs();
        final DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);

        final List<String> keys =
                DistributedLockKeyGenerator.generateKeys(
                        distributedLock.keyPrefix(),
                        distributedLock.dynamicKey(),
                        parameterNames,
                        args
                ); // (1)
        final RedisLockTime redisLockTime = RedisLockTime.from(distributedLock);

        try {
            return this.proceed(joinPoint, keys, redisLockTime); // (2)
        } catch (ExecuteContextException e) {
            throw e.getCause(); // (3)
        }
    }
    
    private Object proceed(final ProceedingJoinPoint joinPoint, final List<String> keys, final RedisLockTime redisLockTime) {
        final Supplier<Object> executeContext = this.createExecuteContext(joinPoint);
        if (keys.size() == 1) {
            return redisLockService.callWithLock(keys.get(0), executeContext, redisLockTime);
        }
        return redisLockService.callWithMultiLock(keys, executeContext, redisLockTime);
    }

    private Supplier<Object> createExecuteContext(final ProceedingJoinPoint joinPoint) throws ExecuteContextException {
        return () -> {
            try {
                return joinPoint.proceed();
            } catch (Throwable e) {
                throw new ExecuteContextException(e);
            }
        };
    }

    public static class ExecuteContextException extends RuntimeException {
        public ExecuteContextException(final Throwable cause) {
            super(cause);
        }
    }

}

(1) 실행 되는 메서드와 어노테이션에 있는 정보를 조합해 Key 를 생성, dynamicKey 는 여러개를 받아 Key 가 여러개 나올 수 있으므로 List 로 반환

(2) RedisLockService 를 사용하기 위해 콜백 메서드를 executeContext 라는 변수에 할당한다. 해당 executeContext 는 ProceedingJoinPoint.proceed() 를 호출하는데 해당 메서드는 Throwable 을 던진다. 그래서 에러 처리를 해줘야 하는데 executeContext 에서 발생한 에러를 ExecuteContextException 으로 한번 감싸서 던진 던진다.

(3) 던져진 ExecuteContextException 에서 안에 중첩되어있던 Cause 다시 던져 에러 처리 로직을 다른 Advice 에게 넘긴다.

 

키 생성을 위해 사용된 DistributedLockKeyGenerator 는 SpEL 을 사용해 키를 만들어준다. 관련 문서 링크

@UtilityClass
public class DistributedLockKeyGenerator {
    private static final String DELIMITER = ":";

    public static List<String> generateKeys(final String keyPrefix,
                                            final String[] spelExpressions,
                                            final String[] parameterNames,
                                            final Object[] args) throws DistributedLockKeyGenerateException {
        if (spelExpressions.length == 0) {
            return Collections.singletonList(keyPrefix);
        }

        final List<String> keys = Arrays.stream(spelExpressions)
                .map(spelExpression -> generateKey(keyPrefix, spelExpression, parameterNames, args))
                .toList();

        if (keys.size() == 0) {
            throw new DistributedLockKeyGenerateException();
        }

        return keys;
    }

    public static String generateKey(final String keyPrefix,
                                     final String spelExpression,
                                     final String[] parameterNames,
                                     final Object[] args) {
        final Object dynamicKey = DistributedLockKeyGenerator.parseExpression(spelExpression, parameterNames, args);
        return keyPrefix + DELIMITER + dynamicKey.toString();
    }

    private static Object parseExpression(final String spelExpression,
                                          final String[] parameterNames,
                                          final Object[] args) {
        final ExpressionParser parser = new SpelExpressionParser();
        final StandardEvaluationContext context = new StandardEvaluationContext();

        for (int i = 0; i < parameterNames.length; i++) {
            context.setVariable(parameterNames[i], args[i]);
        }

        return parser.parseExpression(spelExpression).getValue(context, Object.class);
    }

}

테스트 검증

@Entity
@Getter
@Table(name = "test")
@SequenceGenerator(
        name = "TEST_SEQ_GEN",
        sequenceName = "SEQ_TEST",
        allocationSize = 1
)
public class TestEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "TEST_SEQ_GEN")
    private final Long id;

    private Long count;

    private final LocalDateTime createdAt;

    protected TestEntity() {
        this.id = null;
        this.count = 100L;
        this.createdAt = LocalDateTime.now();
    }

    public static TestEntity create() {
        return new TestEntity();
    }

    public void decrease() {
        this.validateCount();
        this.count -= 1;
    }

    private void validateCount() {
        if (this.count < 1) {
            throw new IllegalArgumentException();
        }
    }
}
@Service
@RequiredArgsConstructor
public class TestDecreaseService {
    private final TestEntityRepository testEntityRepository;

    @Transactional
    public void decrease(final Long testId) {
        final TestEntity entity = this.getEntity(testId);
        entity.decrease();
    }

    @DistributedLock(keyPrefix = "test", dynamicKey = "#testId")
    public void decreaseWithLock(final Long testId) {
        final TestEntity entity = this.getEntity(testId);
        entity.decrease();
    }

    public TestEntity getEntity(final Long testId) {
        return testEntityRepository.findById(testId)
                .orElseThrow(IllegalArgumentException::new);
    }
}
@Slf4j
@SpringBootTest
@ActiveProfiles("test")
class ConcurrentTest {
    @Autowired
    private TestDecreaseService testDecreaseService;
    @Autowired
    private TestEntityRepository testEntityRepository;

    private final TestEntity entity = TestEntity.create();


    @BeforeEach
    void setUp() {
        testEntityRepository.save(entity);
    }

    @AfterEach
    void tearDown() {
        TestEntity testEntity = testEntityRepository.findById(entity.getId())
                .orElseThrow(IllegalArgumentException::new);

        assertThat(testEntity.getCount()).isZero();
        log.info("entity count : {}", testEntity.getCount());
    }

    @Test
    @DisplayName("재고 차감 테스트")
    void testConcurrentRequest() throws InterruptedException {
        ConcurrentTestUtils.execute(
                100,
                () -> testDecreaseService.decrease(entity.getId())
        );
    }

    @Test
    @DisplayName("재고 차감 테스트 - 분산락 적용")
    void testConcurrentRequestWithLock() throws InterruptedException {
        ConcurrentTestUtils.execute(
                100,
                () -> testDecreaseService.decreaseWithLock(entity.getId())
        );
    }
}

테스트 결과 - 분산락 적용 안한 메서드 테스트
테스트 결과 - 분산락 적용 후

마무리

구현 후 "잘 만들었다" 하고 끝났다면 얼마나 좋았을까.. 하지만 테스트를 몇번 하다보니 이상한점을 발견했다. 그리고 그 이상한점이 어떻게 일어나는건지 알아보다보니 현재 코드에 문제가 있다는것을 알아냈다. 그 문제가 왜, 어떻게 일어나는지, 해결할 수 있는 방법은 뭐가 있을지는 다음편인 Spring Redisson 분산락(Distribute Lock) 좀 더 잘 써보기 (3/3) - 한계와 극복 에서 다뤄보겠다.

 

Spring Redisson 분산락(Distribute Lock) 좀 더 잘 써보기 (3/3) - 한계와 극복

들어가며 이 글은 1.템플릿 콜백 패턴으로 구현, 2. AOP 로 구현, 3. Spring Redission 의 한계와 극복 총 3편으로 이루어져 있습니다. 이번 글은 1.템플릿 콜백 패턴으로 구현 과 2.AOP 로 구현 을 기반으로

jongmin4943.tistory.com