SpringBoot에서 Redisson AOP 를 적용하며

BlackBean99·2023년 7월 30일
6

SpringBoot

목록 보기
20/20
post-thumbnail

어떻게 적용했는지보다 이게 뭔지는 알고 써야하지 않겠냐!!!

그래서 간단한 RedissonLock 을 설명하고자 합니다.

이번 에코노베이션 Recruit HR 플랫폼을 개발하면서 좋아요, 라벨링, 업무 카드 옮기기등 동시성을 고려해야 하는 상황이 많이 발생했습니다.

이 화면은 제가 만든 칸반 보드 디자인입니다.

저 칸의 위치를 여러 사람이 옮기게 되면 안될 뿐더러 저 하트나 댓글의 개수를 한개씩 올리는 것에 있어서 동기화 문제들이 발생합니다.
이런 기능들에 락을 적용한다면 중복된 코드가 많아질 것 같아서 AOP 로 도입해야겠다는 생각이 들었습니다.

Redisson 의 동작 원리

Redisson은 Jedis, Lettuce 와 같은 자바진영의 레디스 클라이언트입니다.

Lettuce와 비슷하게 Netty 를 사용하며 non-blocking I/O 를 사용합니다. Redisson 의 특이한 점은 직접 레디스의 명령어를 제공하지 않고 , Bucket 이나 Map 과 같은 자료구조나 Lock 같은 특정한 구현체인 RLock이라는 클래스를 활용한다는 점입니다.

Redisson은tryLock 메소드에 타임아웃과 대기시간을 명시합니다. 대기 시간이 지나면 false가 반환돼서 락 획득에 실패하고, 두번째 파라미터 만큼의 시간이 지나면 락이 만료가 되기 때문에 별도로 Application에서 락을 해제해주지 않아도 됩니다. 이로 락을 얻기 위해 스핀락처럼 무한 루프에 빠질 필요가 없습니다.

Redis Pub/Sub을 이용했다

Redisson은 아래와 같은 프로세스로 락을 획득합니다

  • 대기 없는 tryLock 으로 락 획득시 true를 반환합니다.
  • pub/sub으로 메시지가 올 때까지 대기하다 락이 해제됐다는 메시지를 받으면 대기를 해제하고 락 획득을 시도합니다. 이때, 락 획득에 실패하면, 락 해제 메시지를 기다리고 타임아웃시까지 반복합니다.
  • 타임아웃이 지나면 최종적으로 false를 반환하고 락 획득을 실패했다고 알립니다. 대기가 풀릴 때, timeOut 을 체크하니까 사실 결과로 넘어온 타임아웃 기간과는 약간의 차이가 있습니다.

이제 Redisson을 알아보았으니 실제로 적용해보겠습니다.

1. 분산락 구성하기

implementation 'org.redisson:redisson-spring-boot-starter:3.17.7
build.gradle

config 코드는 이 링크를 참고해주시길 바랍니당

제 프로젝트에서는 간단하게 원하는 메소드에 분산락을 AOP 로 사용할 수 있게 했습니다.
또한 매개변수가 늘 primitive 한 타입이 아닐 경우도 있을 것 같아서 객체자체를 넘겨줘도 되게 펙토리를 따로 만들어서 유동적으로 사용 가능하게 작성했습니다.

// Aop 적용
@RedissonLock(LockName = "지원서", identifier = "applicantId")
public String execute(String applicantId) {
    Applicant applicant = applicantAdapter.findById(applicantId);
    return applicantId;
}

2. IllegalMonitorStateException

leaseTime은 해당 시간이 지나게 되면 IllegalMonitorStateException을 발생시키는데 이는 분산락 안에서 들어간 상황에서 10초(default 로 10를 잡아놨다)로 설정해둔 경우에
이미 자동으로 종료된 트랜잭션을 다른 참여자가 종료시킬 경우. 즉 finally 에서 명시적으로 또 종료된 경우가 되겠죠? 그런 상황에 예외가 발생합니다.

정확히 그러면 어떤 케이스에 예외가 발생할까요?

TransactionTimeOut 보다 leaseTime이 짧을 경우

예시를 들어보자!

result 가 10인데 다름 트랜젝션에서 갱신된 result 9에서 이어져야 하는데 commit 되지 않고 이어져서 데이터의 정합성이 깨지는 현상이 발생합니다.

TransactionTimeOut 보다 leaseTime이 긴 경우


이런 경우 커밋이 되고 나서 연산의 순서가 정상적으로 진행됩니다.

leaseTime 을 넘어도 커밋이 되면안된다.
이를 해결하기위해선 간단하게 어떻게 해결해보았을까?

간단하게 새로운 트랜젝션을 생성해서 timeout 이 leaseTime 보다 짧게 변경해주는 로직을 구현했습니다.
그런데 이렇게 하면 TransactionTimeOut 예외는 발생합니다. 그래서 이 예외 처리를 해줘야 할 것입니다.

        } catch (RecruitCodeException | RecruitDynamicException | TransactionTimedOutException e) {
            throw e;
        } finally {
            try {
                rLock.unlock();
            } catch (IllegalMonitorStateException e) {
                log.error(e + baseKey + dynamicKey);
                throw e;
            }
        }

이렇게 하면 아무 문제가 없을까요?

transactionTimeOut 이 더 빨라서 정상적으로 종료되면 자동으로 unLock이 될텐데 finally 에서 unLock 을 또 시키고 있습니다. 다른 쓰레드가 기다리다가 들어온 상황에서 leaseTime으로 풀려난 쓰레드가 락을 unLock 하려고 하면 IllegalMonitorStateException이 발생하게 됩니다. 그레서 그냥 예외 처리해줬습니다.
redisson 공식 wiki 참고해주세요

Propagation.REQUIRES_NEW

분산락을 실행할때 AOP는 무조건 새로운 트랜젝션을 생성해야 한다. 격리수준을 그렇게 해주지 않으면 문제가 발생합니다...

mysql 기준 REPEATABLE_READ (id기준, 더 일찍 커밋된 정보만 최신으로 가져온다 ) 입니다.

동일한 연산에 대해서
A 트랜잭션 시작(재고 100) -> B 트랜잭션 시작(재고 100) -> B 분산락 진입 재고 1 감소(재고 99) ->분산락 벗어났지만 아직 Commit 되지 않음 -> A 분산락 진입 재고 1 감소(재고 99) -> 최종 결과 99

@Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 9)
public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
    return joinPoint.proceed();
}

분산락 진입 전에 이전 트랜잭션과 동일 트랜잭션 안에서 분산락이 수행되면 동시성 이슈가 발생할 수 있습니다.
그래서 REQUIRES_NEW 로 트랜잭션 전파 속성을 설정해야 한다.

그런데 앞서 먼저 Transaction으로 감싼 경우에서는 최신 정보를 리턴해주지 못할 경우가 있다.

Entity 를 바로 보내주지 못한다.

public OrderResponse execute(String orderUuid, ConfirmOrderRequest confirmOrderRequest) {
    Long currentUserId = SecurityUtils.getCurrentUserId();
    ...
    Integer applicantId =
            applicantReadUseCase.execute(currentId);
    return applicantMapper.toApplicant(applicantId);
}
@Transactional(readOnly = true)
public OrderResponse toApplicantResponse(Integer applicantId) {
    Applicant applicant = applicantAdapter.findById(applicantId);
	// 어떤 연산들...
    return ApplicantResponse.of(applicant);
}

이처럼 별도의 레이어를 두어서, 응답용 트랜잭션을 새로이 시작할 때 최신의 정보를 제공해줄 수 있습니다.

김영한 선생님 강의에서는 엔티티를 리턴시키지 말고 id 정보를 넘겨서 다시 조회하게끔 만들어야 한다고 하는데 이럼 쿼리가 길어지지 않나..? 싶네요.

reference

profile
like_learning

1개의 댓글

comment-user-thumbnail
2023년 12월 9일

Redission 자료가 많이 없었는데, 참고하고 갑니다 ㅎㅎ

답글 달기