[Project] Redis로 동시성 제어하기 - Spin Lock

이상혁·2024년 10월 26일

Project

목록 보기
6/12

들어가며

이전 포스트에서는 Redis에서 기본으로 제공을 하는 원자성 명령어를 통해서 동시성을 제어하는 법을 알아보고 프로젝트에 적용을 해보았습니다. 하지만 작업을 하려던 스레드가 Lock을 획득을 하지 못 했을 때, 작업을 중단이 되면서 인기 검색어의 count를 올리는 작업을 하지 못 하지 못 하면서 원하는 결과를 주지 못 하여서 적합하지 않다고 판단을 하였습니다. 그래서 이번 포스트에서는 Redis를 이용한 방식인 Spin Lock을 사용해서 분락 락을 간단히 구현을 해서 동시성을 제어를 해보도록 하겠습니다.

Spin Lock하고 분산 락이 뭔데??

Spin Lock은 임계구역에 Lock을 얻지 못 해서 대기를 할 때, Lock을 얻을 때까지 루프를 돌면서 Lock을 얻기 위해서 시도를 하는 방식을 말합니다.

위의 그림을 보면서 설명을 하면 스레드가 공유자원을 활용하거나 임계 구역에서 Lock을 걸고 작업을 하고 작업이 끝나면 Lock을 해제를 합니다. 스레드가 작업을 하는 동안 대기를 하고 있는 스레드는 공유자원이나 임계구역의 상태를 확인을 해야 하는데 확인을 하는 방법으로 루프를 활용을 해서 계속해서 확인을 하는 방법입니다.

분산 락은 여러 서버 혹은 여러 프로세스가 동일한 자원에 동시에 접근하지 못 하도록하는 방법입니다. 다중 서버를 사용하고 있다면 각각에 서버에서 실행이 되고 있는 애플리케이션에서 동시에 같은 자원에 접근을 하려고 한다면 이를 마찬가지로 동시성 문제가 발생을 합니다. 이 분산 환경에서 동시성 제어를 해주는 방법이 분산 락입니다.

Spin Lock으로 분산 락을 구현해 적용해보자

@Service
@RequiredArgsConstructor
public class PopularSearchService {

    private final StringRedisTemplate redisTemplate;

    private static final String LOCK_PREFIX = "lock:search:";
    private static final String SEARCH_RANKING_KEY = "popular:search:ranking";
    private static final int RETRY_DELAY = 100;
    private static final int LOCK_EXPIRY_TIME = 5;
    private static final int MAX_RETRIES = 20;


    public void incrementSearchCount(String keyword) {
        String lockKey = LOCK_PREFIX + keyword;
        int retries = 0;

        while (retries < MAX_RETRIES) {
            Boolean lockAcquired = redisTemplate.opsForValue().setIfAbsent(lockKey, "LOCKED", LOCK_EXPIRY_TIME, TimeUnit.SECONDS);

            if (Boolean.TRUE.equals(lockAcquired)) {
                try {
                    updateSearchCount(keyword);
                } finally {
                    releaseLock(lockKey);
                }
                return;
            } else {
                retries++;
                try {
                    Thread.sleep(RETRY_DELAY);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }

        }
        System.out.println("다른 프로세스에서 이미 락을 획득했습니다. 요청을 무시합니다.");
    }

    private void releaseLock(String lockKey) {
        redisTemplate.delete(lockKey);
    }

    private void updateSearchCount(String keyword) {
        redisTemplate.opsForZSet().incrementScore(SEARCH_RANKING_KEY, keyword, 1);
    }
}

이전 포스트에서 공유를 했던 원자성 명령어를 통해 구현한 동시성 제어 방법의 로직과 조금 차이가 있습니다. RETRY_DELAY의 경우 Lock을 얻지를 못 했을 때 얼마나 대기를 할지에 대한 값입니다. LOCK_EXPIRY_TIME는 Lock을 가지고 얼마나 유지할 수 있느지에 대한 시간입니다. MAX_RETRIES는 Lock울 갖기 위해서 최대 얼마까지 시도를 할 지에 대한 값입니다.

incrementSearchCount메소드를 살펴보면 검색어마다 고유한 키를 설정을 하고 while문을 통해서 MAX_RETRIES의 값만큼 반복을 하면서 Lock 획득을 위해서 setIfAbsent 메소드를 사용을 하고 있습니다. Lock 획득에 성공을 하면 updateSearchCount 메소드를 실행을 해서 Count를 올리고 Lock을 삭제를 합니다. 만약 Lock을 획득을 하지 못 하면 RETRY_DELAY만큼 대기를 하다가 다시 Lock을 얻기 위해서 시도를 합니다. 이러한 방식으로 Spin Lock을 통해서 구현을 했습니다.

결과 살펴보기

이제 이 로직으로 동시성을 테스트를 해보겠습니다.

결과가 예상했던 결과와 달랐습니다. 그 이유는 MAX_RETRIES 만큼 재시도를 했음에도 불구하고 Lock을 얻지 못 했기 때문에 결국 스레드가 작업을 하지 못 하고 중단이 되는 경우가 발생했기 때문입니다.

프로젝트에 맞는 방법은?

제가 내린 결론은 Spin Lock은 현재 프로젝트에 맞지 않다는 판단입니다. 그 이유는 먼저 위에 결과 살펴보기에서 처럼 우리 원하는 결과를 확실하게 가져다준다는 보장이 없습니다. 큰 경기에 경우 제가 기준으로 잡아던 8000명 보다 더 많은 인원이 몰리는데 더 몰리게 되면 원하는 결과가 나온다는 보장이 휠씬 떨어집니다.

Spin Lock을 사용하게 되면 CPU 자체의 부담이 커집니다. Spin Lock은 Lock을 얻을 때까지 계속 반복을 하는 방법입니다. 만약 임계 구역이 여러 개라면, 락의 반환이 오래 걸린다면 부하가 많이 걸릴 가능성이 매우 큽니다.

또한 분산 락을 완벽하게는 구현을 하지 못한다는 점입니다. Redis는 단일 스레드입니다. 근데 만약 어떤 프로세스가 접근을 해서 작업을 하다가 서버가 다운이 됐다고 했을 때, 다른 프로세스에서 Lock 풀렸다고 판단을 하고 접근을 하게 됩니다. 이는 동시성 제어가 되지 않은 점입니다.

마지막으로 비용입니다. 회사나 같이 하는 분들이 있다면 모르겠지만 프로젝트는 현재 혼자 진행을 하고 있고 이에 대한 비용은 저 혼자 지불을 합니다. 그러다 보니 비용을 생각을 안 할 수 없습니다. 이를 고려를 했을 때 redis를 사용하는 비용이 방법을 쓰는 것보다 크다고 판단이 들었습니다.

결론적으로 프로젝트에서 인기 검색어에 대한 동시성 문제는 Pessimitic Lock을 사용하는 것이 가장 적합다고 판단을 했습니다.

profile
꾸준히!

0개의 댓글