Redis의 동시성 문제와 Lock 구현
프로젝트 삽질 기록

Redis의 동시성 문제와 Lock 구현

프로젝트 기능 중 지도에 표시되는 마커를 누르면 게임이 진행되는데, 이 마커에 대한 접근은 단 한 명만 가능해야 한다. 이를 구현하기 위해 동시성 문제를 고려해야 하는데, 운영체제를 공부하며 이론적으로 배운 내용을 직접 적용해 볼 수 있는 기능이기 때문에 재미있을 거 같아 내가 맡겠다고 하였다.

마커는 레디스에 저장되어있기 때문에 레디스를 사용하여 동시성 문제를 해결하고자 했다.

 

 

레디스 Lock 구현

레디스는 싱글 스레드 기반이다. 즉, 스프링에서 멀티스레드로 개발을 해도 레디스는 싱글 스레드이기 때문에 레디스 내에서는 한번에 하나의 스레드만 실행된다. 그래서 레디스를 사용하면 어려운 동시성 문제를 쉽게 해결할 수 있다.

싱글 스레드면 동시성 이슈가 발생하지 않을거 같지만, 레디스의 연산은 반환 값이 있기 때문에 I/O가 일어나 다른 스레드가 실행될 수 있다. 그래서 동시성 이슈를 없애기 위해 레디스의 연산을 원자성을 가지게 하면 된다.

 

레디스에는 동시성을 지키기 위한 두가지 방법이 있다. 바로 Luttuce와 Redisson인데, 이 둘의 특징은 아래와 같다.

 

Lettuce

  • Spring Data Redis에 기본으로 내장되어 있어 별도의 라이브러리를 import 하지 않아도 됨
  • 구현이 쉬움
  • 스핀락 방식이기 때문에 오버헤드가 존재
    • 단, 락 획득 재시도를 안 하게 할 수도 있음

Redisson

  • 락 획득 재시도를 기본으로 제공
    • 단, PUB-SUB 방식으로 락을 획득하므로 성능이 Luttuce에 비해 좋음
  • 별도의 라이브러리를 사용해야함
  • 라이브러리 차원에서 락을 제공해기 때문에 기능 수정 불가

 

Luttuce를 사용하여 Lock 구현

이번 프로젝트에서 Spring Data Redis를 사용하고 있고, 락에 걸린 상태면 재시도를 하지 않을 것이기 때문에 구현이 쉬운 Luttuce를 사용하였다.

락은 해당 마커의 ID를 키, value 값으로 “lock”을 가지는 데이터를 레디스에 저장하는 방식으로 진행하였다. 즉, 마커 ID를 lock()에 넣으면 레디스에서 마커 ID로 값을 찾은 뒤, 값이 없으면 값을 넣고 락을 걸고 true를 반환하고, 값이 있으면 이미 락이 걸린 상태여서 false를 반환한다.

@Component
@RequiredArgsConstructor
public class RedisLockRepository {

    private final RedisTemplate<String, String> redisTemplate;

    public Boolean lock(Long key) {
        return redisTemplate.opsForValue().setIfAbsent(generateKey(key), "lock");
    }

    public Boolean unLock(Long key) {
        return redisTemplate.delete(generateKey(key));
    }

    private String generateKey(Long key) {
        return key.toString();
    }
}

동시성이 지켜지는지 테스트를 위해 마커의 락을 스핀락 형식으로 개발을 하였다. 마커에 isPlayGame 이라는 멤버 변수를 선언했다.

@Getter
@RedisHash(value = "marker")
public class Marker {

    @Id
    Long markerId;
		...
    int isPlayGame;

    @Builder
    public Marker(...) {
        // 생성자
				...
    }

    public void increase() {
        isPlayGame++;
    }
}

락이 걸려있으면 0.1초 동안 sleep 하고 다시 락을 확인(스핀락)한다. 만약, 락이 풀려있으면 마커의 isPlayGame 변수의 값을 증가시키고 다시 락을 푼다.

/*
 * true : 퀘스트 시작, 해당 마커 락 걸림
 * false : 퀘스트 시작 X, 해당 마커 다른 사람이 락 걸어놓음
 * */
public boolean questStart(Long key) throws InterruptedException {

    while (!redisLockRepository.lock(key)) {
        Thread.sleep(100);
    }
    Marker marker = mapCharacterRedisRepository.findById(key).orElseThrow();
    marker.increase();
    mapCharacterRedisRepository.save(marker);
    System.out.println("저장 후 : " + marker.getIsPlayGame());
    redisLockRepository.unLock(key);
    return false;
}

테스트 코드를 작성하여 쓰레드 100개가 한번에 접근할 때 isPlayGame 값이 어떻게 되는지 확인을 하였다. ExecutorService는 자바에서 쓰레드 관리를 쉽게 할 수 있는 라이브러리이다. 이것을 사용하여 쓰레드 풀을 32로 잡아 진행하였다. 쓰레드 풀을 32로 잡았다는 것은 32개의 쓰레드까지는 처리를 하고 그 이상은 쓰레드 풀(큐)에 담아 대기를 시킨다는 의미이다.

CountDownLatch는 어떤 쓰레드가 다른 쓰레드가 작업을 끝낼 때까지 기다리게 할 수 있는 클래스이다. 비동기적으로 진행되기 때문에 메인 쓰레드는 latch.await()에서 다른 쓰레드들이 작업을 끝내기를 기다리고 있다. 해당 테스트에서 진행되는 쓰레드들이 모두 끝난 뒤에, 메인 쓰레드는 그 아래 작업들을 수행한다.

@Test
void 마커_접근_동시에_100개() throws InterruptedException {
    int threadCount = 100;
    ExecutorService executorService = Executors.newFixedThreadPool(32);
    CountDownLatch latch = new CountDownLatch(threadCount);
    for (int i = 0; i < threadCount; i++) {
        executorService.submit(() -> {
            try {
                markerService.questStart(1L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            latch.countDown();
        });
        
    }
		// 메인 쓰레드 대기
    latch.await();

    Marker marker = mapCharacterRedisRepository.findById(1L).orElseThrow();
    Assertions.assertThat(marker.getIsPlayGame()).isEqualTo(100);
}

 

결과

1씩 100번을 증가시키기 때문에 결과 값으로 100이 나와야 하는데 결과가 잘 나오는 것을 볼 수 있다.

락을 걸지 않고 실행하면 아래와 같이 레이스 컨디션이 일어나 값이 이상한 것을 볼 수 있다.