Redisson을 활용한 분산락으로 동시성 이슈 해결하기

개발하는 구황작물·2024년 4월 4일
0

개인 프로젝트 중 동시성 이슈를 해결하기 위해 Redisson을 도입하게 되었다.

동시성 이슈

선착순 서비스 같은 경우 한번에 여러 사람이 접속을 할 수 있다.
100명까지 참여 가능한 서비스에 99명까지 참여하여 마지막 한 사람만이 참여 가능한 상황에서
동시에 두 명이 접근한다고 가정해보자

위 그림에 대해 설명하자면

UserA가 서비스 참여 가능 인원(cnt) 조회 (cnt = 1)

UserB가 서비스 참여 가능 인원(cnt) 조회 (cnt = 1)

UserA가 서비스에 참여하여 서비스 참여 가능 인원(cnt) -1 업데이트 (cnt = 0)

UserB도 동시에 서비스에 참여하여 서비스 참여 가능 인원(cnt) -1 업데이트 (cnt = 0)

예상으로는 한 사람만이 참여 가능한데 두 명이 지원을 했으면 한 사람은 무조건 참여를 못해야 하지만 각 유저가 cnt 조회시 1이 나왔기 때문에 결국 두 명 다 참여할 수 있게 되는 동시성 문제가 발생한다.

위와 같은 상황을 Race Condition이라고 한다.

Race Condition
두 개 이상의 cocurrent한 프로세스(혹은 스레드)들이 하나의 자원에 접근하기 위해 경쟁하는 상태.

해결 방법

이를 해결할 수 있는 여러 방법 중 Redis의 Redisson을 사용하기로 하였다.

Redisson 분산락

  • 분산락?

분산락이란 여러의 서버가 하나의 자원에 동시에 접근하려는 것을 막고 한번에 하나의 서버만 작업할 수 있도록 해주는 동기화 매커니즘이다. 이를 통해 데이터 동시변경을 막고 시스템 전체의 데이터 일관성을 보장한다.

  • Redisson 원리

Redisson은 아래와 같은 프로세스로 락을 획득한다.

  1. 대기가 없는 경우 락 획득 후, true를 반환한다.
  2. 이미 누군가 락을 획득한 경우, pub/sub에서 메시지가 올 때까지 대기하다가, 락이 해제되었다고 메시지가 오면 대기를 해제하고 락 획득을 시도한다. 만약 락 획득에 실패하면 락 해제 메시지를 기다리고 타임아웃까지 가다린다.
  3. 타임아웃이 지나며 최종적으로 false를 반환하고 락 획득을 실패했다고 한다.

(자세한 내용은 여기로)

Redisson 분산락 구성하기

  1. gradle dependency
implementation 'org.redisson:redisson:3.27.1'
  1. Lock 로직 구성
@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를 활용하여 분산락 로직과 비즈니스 로직을 분리하였다.

  1. DistributedLock
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
	//락 이름
    String key();

    TimeUnit timeunit() default TimeUnit.SECONDS;

    // 락을 얻기 위해 기다릴 수 있는 시간
    long waitTime() default 5L;

    // 락 획득 후 임대할 수 있는 시간    
    long leaseTime() default 3L;
}
  1. DistributionLockAop
@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 적용 후 테스트

profile
어쩌다보니 개발하게 된 구황작물

0개의 댓글