개인 프로젝트 중 동시성 이슈를 해결하기 위해 Redisson을 도입하게 되었습니다.
100명까지 참여 가능한 서비스에 99명까지 참여했다고 가정을 해봅시다. 마지막 한 사람만이 참여 가능한 상황에서
동시에 두 명이 접근하는 경우 동시성 이슈가 발생할 수 있습니다.
위 그림에 대해 설명하자면
UserA가 서비스 참여 가능 인원(cnt) 조회 (cnt = 1)
UserB가 서비스 참여 가능 인원(cnt) 조회 (cnt = 1)
UserA가 서비스에 참여하여 서비스 참여 가능 인원(cnt) -1 업데이트 (cnt = 0)
UserB도 동시에 서비스에 참여하여 서비스 참여 가능 인원(cnt) -1 업데이트 (cnt = 0)
예상으로는 한 사람만이 참여 가능한데 두 명이 지원을 했으면 한 사람은 무조건 참여를 못해야 하지만 각 유저가 cnt 조회시 1이 나왔기 때문에 결국 두 명 다 참여할 수 있게 되는 동시성 문제가 발생합니다.
동시성을 제어하는 방법으로는 락을 거는 방법이 있습니다. 락을 거는 방법에는 MySQL 비관적 락, 낙관적 락, Redis 스핀락 등 여러 방법이 있으나 이 프로젝트에서는 Redisson 분산락을 사용하기로 하였습니다.
분산락이란 여러의 서버가 하나의 자원에 동시에 접근하려는 것을 막고 한번에 하나의 서버만 작업할 수 있도록 해주는 동기화 매커니즘 입니다. 이를 통해 데이터 동시변경을 막고 시스템 전체의 데이터 일관성을 보장합니다.
Redisson은 아래와 같은 프로세스로 락을 획득합니다.
(자세한 내용은 여기로)
implementation 'org.redisson:redisson:3.27.1'
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributedLock {
private static final String REDISSON_LOCK_KEY = "LOCK:";
private final RedissonClient redissonClient;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void lock() throws Throwable {
RLock rLock = redissonClient.getLock(REDISSON_LOCK_KEY); // 락 생성
try {
boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeunit()); //락 획득 시도
if(!available) {
log.error("lock timeout");
return false;
}
// (대충 실행하고자 하는 로직)
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
try {
rLock.unlock(); //락 해제
} catch (IllegalMonitorStateException e) {
log.info("Redisson Lock Already Unlock, Key = {}", REDISSON_LOCK_KEY);
}
}
}
}
이제 위의 redisson lock 로직 사이에 실행하고자 하는 로직을 넣으면 되나
이런식으로 하면 Lock 로직과 비즈니스 로직의 분리가 되지 않습니다.
이를 해결하기 위해 마켓컬리의 기술블로그를 참고하여 AOP를 활용하여 분산락 로직과 비즈니스 로직을 분리하였습니다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
//락 이름
String key();
TimeUnit timeunit() default TimeUnit.SECONDS;
// 락을 얻기 위해 기다릴 수 있는 시간
long waitTime() default 5L;
// 락 획득 후 임대할 수 있는 시간
long leaseTime() default 3L;
}
@Slf4j
public class DistributedLockAop {
private static final String REDISSON_LOCK_KEY = "LOCK:";
private final RedissonClient redissonClient;
private final AopTransaction aopTransaction;
@Around("@annotation(com.quiz.global.lock.DistributedLock)")
public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);
String key = REDISSON_LOCK_KEY + CustomKeyParser.getKeyNameSuffix(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key()); // 키 이름 생성
RLock rLock = redissonClient.getLock(key);
try {
boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeunit());
if (!available) {
log.error("lock timeout");
return false;
}
return aopTransaction.proceed(joinPoint);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
try {
rLock.unlock();
} catch (IllegalMonitorStateException e) {
log.info("Redisson Lock Already Unlock serviceName = {}, Key = {}", method.getName(), REDISSON_LOCK_KEY);
}
}
}
}
여기서 각각의 메서드와 파라미터에 따라 키를 다르게 하기 위해
String key = REDISSON_LOCK_KEY + CustomKeyParser.getKeyNameSuffix(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key());
SpelExpressionParser를 활용하여 키 이름을 생성하였습니다.
public class CustomKeyParser {
//parameterNames : 파라미터 이름
//args: 파라미터 값
public static Object getKeyNameSuffix(String[] parameterNames, Object[] args, String key) {
ExpressionParser parser = new SpelExpressionParser();
StandardEvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < parameterNames.length; i++) { //파라미터 이름과 값들을 합쳐 키 이름 생성
context.setVariable(parameterNames[i], args[i]);
}
return parser.parseExpression(key).getValue(context, Object.class);
}
}
추가적으로 AOPTransaction을 통해 비즈니스 로직이 실행시 부모의 트랜잭션과 상관없이 트랜잭션을 새로 생성하였고, 반드시 비즈니스 로직의 트랜잭션이 커밋된 이후 락을 해제하였습니다.
@Component
public class AopTransaction {
//트랜잭션 커밋보다 락의 해제가 뒤에서 일어나야 한다
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Object proceed(ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
}
만약 락의 해제 시점이 비즈니스 로직 트랜잭션 커밋 이전에 발생하면
비즈니스 로직 트랜잭션이 커밋되기 이전에 다른 스레드에서 락을 얻어 비즈니스 로직을 수행할 수 있기 때문입니다.
이로 인해 정합성이 깨질 수 있습니다.
반면에 비즈니스 로직의 트랜잭션 커밋 후 락 해제시 이러한 문제는 사라집니다.
@Slf4j
@SpringBootTest
public class ParticipantInfoServiceTest {
@Autowired
ParticipantInfoService participantInfoService;
Long quizId = 1L;
int capacity = 90;
@AfterEach
void clear() {
participantInfoService.deleteAll();
}
// 동시성 테스트
// 90명 선착순
// 100 명의 참가자
// 10명은 반드시 참여 불가해야 함
@Test
void saveFcfsTest() throws InterruptedException {
int threadCnt = 100;
AtomicInteger cnt = new AtomicInteger();
CountDownLatch countDownLatch;
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
countDownLatch = new CountDownLatch(threadCnt);
IntStream.range(0, threadCnt).forEach(e -> executor.execute(() -> {
try {
participantInfoService.saveFcfs(quizId, (long) (e + 1), capacity);
} catch (Exception ex) {
cnt.getAndIncrement();
} finally {
countDownLatch.countDown();
}
}));
countDownLatch.await();
}
int participantCnt = participantInfoService.countParticipantInfoCntByQuizId(quizId);
List<ParticipantInfo> participantInfoList = participantInfoService.findParticipantInfoByQuizId(quizId);
for (ParticipantInfo participantInfo : participantInfoList) {
log.info("_id : {}", participantInfo.getId());
log.info("userId : {}", participantInfo.getUserId());
}
log.info("participantCnt = {}", participantCnt);
assertThat(participantCnt)
.isEqualTo(90);
assertThat(cnt.get())
.isEqualTo(10);
}
}
@DistributedLock
적용 전 테스트
@DistributedLock
적용 후 테스트
+) 이후 Redis에 장애가 발생할 경우에 대해서도 조사를 해봤습니다.
Redis가 1대일 경우 Redis 장애 발생 시 무조건 락이 유실됨과 동시에 뒤에 진행되어야 할 락에도 영향을 미칠 것이고
설령 Master Replica로 Redis Cluster를 구성한다 해도 Master 에 장애가 발생하면 Master에 걸린 락은 유실된다는 문제점이 있었습니다.
더 찾아보니 RedLock이라는게 있다는 사실을 알게 되었는데
설명하자면, n개의 Redis가 lock 획득을 시도하여 과반수의 Redis에서 잠금이 획득되면, Lock이 획득된 것으로 간주하고 아니라면 전부 잠금을 해제한다고 합니다.(이로 인해 성능은 떨어진다고 합니다)
참고로 Redisson의 RedLock은 Deprecated 되었다...
Redisson 같은 경우 RLock나 RFencedLock를 대신 사용하라는데 FencedLock는 락 획득시 추가적으로 토큰도 같이 얻어 락 소유권을 확인하는 방식이라 Master-Replica 에서 락이 유실될 수 있는 문제는 해결하지 못했습니다.
결국 결함 허용성을 높이려면 zookeeper를 활용하는게 맞지만 현재 프로젝트에 zookeeper는 좀 오버스펙이라는 생각이 들었습니다.
결론은 Single Redis를 활용한 Lock를 구현하였으나 이 방법이 늘 최적의 방법은 아니라는 것을 알게 되었고 만약 안정성, 가용성이 더 중요한 상황에는 다른 기술을 사용해야 한다는 것을 알게 되었습니다.
Reference
https://helloworld.kurly.com/blog/distributed-redisson-lock/
https://github.com/redisson/redisson/wiki/8.-Distributed-locks-and-synchronizers#84-redlock
https://channel.io/ko/blog/distributedlock_2022_backend