어떻게 적용했는지보다 이게 뭔지는 알고 써야하지 않겠냐!!!
그래서 간단한 RedissonLock 을 설명하고자 합니다.
이번 에코노베이션 Recruit HR 플랫폼을 개발하면서 좋아요, 라벨링, 업무 카드 옮기기등 동시성을 고려해야 하는 상황이 많이 발생했습니다.
이 화면은 제가 만든 칸반 보드 디자인입니다.
저 칸의 위치를 여러 사람이 옮기게 되면 안될 뿐더러 저 하트나 댓글의 개수를 한개씩 올리는 것에 있어서 동기화 문제들이 발생합니다.
이런 기능들에 락을 적용한다면 중복된 코드가 많아질 것 같아서 AOP 로 도입해야겠다는 생각이 들었습니다.
Redisson은 Jedis, Lettuce 와 같은 자바진영의 레디스 클라이언트입니다.
Lettuce와 비슷하게 Netty 를 사용하며 non-blocking I/O 를 사용합니다. Redisson 의 특이한 점은 직접 레디스의 명령어를 제공하지 않고 , Bucket 이나 Map 과 같은 자료구조나 Lock 같은 특정한 구현체인 RLock이라는 클래스를 활용한다는 점입니다.
Redisson은tryLock 메소드에 타임아웃과 대기시간을 명시합니다. 대기 시간이 지나면 false가 반환돼서 락 획득에 실패하고, 두번째 파라미터 만큼의 시간이 지나면 락이 만료가 되기 때문에 별도로 Application에서 락을 해제해주지 않아도 됩니다. 이로 락을 얻기 위해 스핀락처럼 무한 루프에 빠질 필요가 없습니다.
Redisson은 아래와 같은 프로세스로 락을 획득합니다
- 대기 없는 tryLock 으로 락 획득시 true를 반환합니다.
- pub/sub으로 메시지가 올 때까지 대기하다 락이 해제됐다는 메시지를 받으면 대기를 해제하고 락 획득을 시도합니다. 이때, 락 획득에 실패하면, 락 해제 메시지를 기다리고 타임아웃시까지 반복합니다.
- 타임아웃이 지나면 최종적으로 false를 반환하고 락 획득을 실패했다고 알립니다. 대기가 풀릴 때, timeOut 을 체크하니까 사실 결과로 넘어온 타임아웃 기간과는 약간의 차이가 있습니다.
이제 Redisson을 알아보았으니 실제로 적용해보겠습니다.
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;
}
leaseTime은 해당 시간이 지나게 되면 IllegalMonitorStateException을 발생시키는데 이는 분산락 안에서 들어간 상황에서 10초(default 로 10를 잡아놨다)로 설정해둔 경우에
이미 자동으로 종료된 트랜잭션을 다른 참여자가 종료시킬 경우. 즉 finally 에서 명시적으로 또 종료된 경우가 되겠죠? 그런 상황에 예외가 발생합니다.
정확히 그러면 어떤 케이스에 예외가 발생할까요?
예시를 들어보자!
result 가 10인데 다름 트랜젝션에서 갱신된 result 9에서 이어져야 하는데 commit 되지 않고 이어져서 데이터의 정합성이 깨지는 현상이 발생합니다.
이런 경우 커밋이 되고 나서 연산의 순서가 정상적으로 진행됩니다.
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 참고해주세요
분산락을 실행할때 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 정보를 넘겨서 다시 조회하게끔 만들어야 한다고 하는데 이럼 쿼리가 길어지지 않나..? 싶네요.
Redission 자료가 많이 없었는데, 참고하고 갑니다 ㅎㅎ