팀 프로젝트로 보드게임 형식의 턴 제 게임을 주제로 했고, 게임 특성 상 모든 플레이어의 화면을 동시에 전환시킬 필요가 있었다.
(서버 -> 클라이언트 방향으로도 데이터를 보내줄 일이 많아 WebSocket 기술을 활용하였다.)
화면 전환 신호를 서버에서 모든 클라이언트(게임 플레이어)에 동시에 보내야 했는데, 그 전에 각각의 클라이언트가 화면 전환 준비를 해야 했다. 때문에 요청을 주고 받는 시퀀스를 설계해야 했다.
3번에서 준비 완료 요청을 카운팅하는 부분을 조금 더 자세히 보자. 먼저 카운팅 전에 Redis에 requestCount라는 변수 값을 0으로 저장해놓았다. 그리고 각 플레이어가 준비 완료 메시지를 보내면 count를 +1 해주고, count 값이 전체 플레이어 인원 수와 동일해지면 ‘화면 전환 메시지’를 전송해주는 방식이었다.
하지만, 특별한 일이 없다면 모든 플레이어는 비슷한 시기에 ‘준비 완료 메시지’를 서버로 보낼 것이고 count 값을 Update 하는 과정에서 Race Condition 문제로 인해 정상적인 카운팅이 되지 않을 것이라 예상했다. 따라서 이 Counting 부분에 Redis를 이용한 Lock을 통해 동시성 처리를 해 주었다.
Redis Lock은 보통 분산 Lock 설정을 할 때 많이 사용되며, Lettuce의 setnx 명령어를 활용한 Spin Lock 기법이나 Redisson에 구현된 Pub/Sub 기반의 Lock을 통해 구현한다. 해당 내용에 대해 먼저 간단히 알아보고 직접 Lock을 활용한 내용을 적어보도록 한다.
분산 락이란 하나의 서버가 아닌 다중 서버에서 공유되는 데이터의 제어를 위해 사용하는 기술이다.
위의 그림처럼 하나의 상품 재고 데이터를 동시 다발적으로 조회하고 갱신한다면 데이터 정합성에 문제가 발생할 것이다. 예를 들어 상품 재고가 100개인 상황에서 주문이 이뤄져 99개로 처리하는 중간에 정산 서버에서 재고를 조회한다면 정산 서버는 99가 아닌 100을 응답 받게 된다.
데이터를 저장하는 DBMS의 자체적인 Lock을 사용하여 정합성을 맞출수도 있겠지만 Redis를 이용한 분산 Lock을 통해 성능 상의 이점을 취할수도 있다. 다음은 Redis 분산 Lock의 기본적인 매커니즘이다.
먼저 데이터와 매핑되는 Key값으로 Redis에 Lock 획득 시도를 한다. Lock 획득에 성공하면 해당 데이터에 접근하여 원하는 작업을 수행 후 다시 UnLock을 해주는 방식이다.
이 방식은 In-Memory DB인 Redis에서 Lock을 잡는 방식이기 때문에 RDBMS의 Lock보다 더 빠른 성능으로 동시성을 제어할 수 있다는 장점이 있다. 하지만, 활용 중인 Redis가 없다면 별도의 구축 비용과 인프라 관리 비용이 발생한다는 단점이 있다.
레디스로 Lock을 구현하는 방법은 크게 Lettuce를 이용한 Spin Lock 방식과 Redisson을 이용한 방식 2가지가 있다.
레디스의 setnx 명령어를 활용하는 방법이다.
Lock을 잡는 행위는 ‘Lock 획득 가능한지 확인’하는 행위와 ‘가능하면 Lock을 획득’하는 행위가 Atomic하게 이뤄져야 하는 작업이다. Redis의 setnx 명령어는 key값이 존재하지 않으면 데이터를 set하는 atomic한 명령어이다. 따라서 이 명령어를 활용하여 개발자가 직접 Lock 프로세스를 구현할 수 있다. 그리고 Spin Lock은 Lock 점유를 무한 루프로 시도하는 방식이다.
아래 코드를 통해 직접 확인해보자.
@RequiredArgsConstructor
@Component
public class RedisRepository {
private final RedisTemplate<String, String> lockRedisTemplate;
public Boolean lock(long key) {
String lockKey = lockKeyGen(key);
return lockRedisTemplate.opsForValue()
.setIfAbsent(lockKey, "lock", Duration.ofMillis(3000L));
}
public Boolean unlock(long key) {
return lockRedisTemplate.delete(lockKeyGen(key));
}
private String lockKeyGen(long key) {
return "lock:" + key;
}
}
lock() 메소드 내부의 setIfAbsent() 메소드는 setnx를 Spring에서 RedisTemplate으로 사용하는 메소드이다.
@RequiredArgsConstructor
@Component
public class LettuceLock {
private final RedisRepository redisRepository;
private final StockService stockService;
public void decrease(Long key, Long quantity) throws InterruptedException {
while (!redisRepository.lock(key)) {
Thread.sleep(50);
}
try {
stockService.decrease(key, quantity);
} finally {
gameRedisRepository.unlock(key);
}
}
}
그리고 반복문으로 Lock 점유를 지속적으로 시도하고 Lock이 성공하면 로직을 수행 후 Lock을 해제한다.
이 방식은 구현이 간단하고, Spring Data Redis를 사용하고 있다면 별도의 라이브러리를 추가할 필요가 없다는 장점이 있다.
하지만, 여러 단점들도 존재한다.
스핀락 방식으로 Lock 점유 시도를 한다는 것은 Redis에 계속 요청을 보내는 것을 의미한다. 따라서 Redis에 부하가 커진다. 부하를 줄이기 위해 반복문 내부의 sleep 시간을 늘린다면, Lock을 점유해도 sleep 시간만큼 대기 후 로직을 수행하므로 비효율이 발생할 것이다.
한 쓰레드가 Lock 획득 후 오류로 인해 UnLock에 실패한다면, 다른 쓰레드는 Lock을 획득하지 못해 무한 대기 상태에 빠지게 된다 때문에 Lock 획득 시도 횟수를 지정해주는 방법으로 이를 예방하는 로직도 추가로 도입할 필요가 있다.
Redisson은 Lettuce와 같이 자바 진영에서 사용하는 레디스 클라이언트이다. 특이한 점은 직접 레디스의 명령어를 제공하지 않고 Bucket, Map 같은 자료구조나 Lock 같은 특정한 구현체의 형태로 레디스 명령어를 제공한다.
별도의 Redisson 라이브러리 의존성을 추가해야 하고 사용법 또한 학습해야 한다는 단점이 있다. 그러나 Redisson 자체에 Lock이 구현되어 있고, 위의 Spin Lock 방식보다 유용한 점이 많아서 분산 Lock 구현 시 더 많이 사용된다. 다음은 장점에 대한 설명이다.
Redisson은 Spin Lock 기법을 사용하지 않고 Pub / Sub 기능을 이용하여 레디스에 발생하는 트래픽을 크게 줄였다. TryLock을 시도하는 클라이언트들은 Lock관련 메시지를 Subscribe하고, Lock이 해제될 때 해당 클라이언트에게 락 획득을 시도하라는 알림을 주는 방식이다.
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException
tryLock() 메소드는 Redisson에서 Lock을 점유하는 시도를 수행하는 메소드이다.
첫 번째 파라미터는 락 획득을 대기하는 시간이고, 두 번째 파라미터는 락을 점유할 수 있는 최대 시간이다. 세 번째 파라미터는 각 시간의 단위를 나타내는 파라미터이다.(TimeUnit.*SECONDS
)*
waitTime 시간 동안 Lock 획득을 시도하므로 재 시도를 위한 로직을 따로 작성할 필요가 없고, Lock을 획득 후 오류로 인해 UnLock을 수행하지 않아도 leaseTime 시간 이후 자동으로 UnLock이 수행된다.
따라서 TimeOut 실패로 인해 쓰레드의 무한 대기 상태를 걱정하지 않아도 된다.
의존성 추가
https://mvnrepository.com/search?q=redisson+
mvnrepository에서 redisson / spring boot starter에 있는 최신 버전 Gradle을 build.gradle에 넣어준다.
Lock 사용 코드
@RequiredArgsConstructor
@Component
public class RedissonLock {
private final RedisRepository redisRepository;
private final StockService stockService;
public void decrease(Long key, Long quantity) throws InterruptedException {
RLock lock = redissonClient.getLock(generateLockKey(key));
try {
boolean available = lock.tryLock(5, 1, TimeUnit.SECONDS);
if (!available) {
//Lock 획득 실패
return;
}
stockService.decrease(key, quantity);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
}
@Service
public class GameConvertUtil {
private final GameRedisRepository gameRedisRepository;
private final RedissonClient redissonClient;
@Transactional
public PostfixDto completeConvertPrepare(long gameId) {
boolean isFullCount;
GameStatus gameStatus;
RLock lock = redissonClient.getLock(generateConvertLockKey(gameId));
try {
boolean available = lock.tryLock(5, 2, TimeUnit.SECONDS);
if (!available) {
throw new CustomRestException(DomainErrorCode.FAIL_TO_ACQUIRE_REDISSON_LOCK);
}
InGame inGame = getInGame(gameId);
inGame.addRequestCount();
gameRedisRepository.saveInGame(gameId, inGame);
isFullCount = inGame.isFullCount();
gameStatus = inGame.getGameStatus();
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
if (isFullCount) {
RLock timeLock = redissonClient.getLock(generateTimeLock(gameId));
try {
boolean isGetTimeLock = timeLock.tryLock(5, 2, TimeUnit.SECONDS);
if (!isGetTimeLock) {
throw new CustomWebSocketException(DomainErrorCode.FAIL_TO_ACQUIRE_REDISSON_LOCK);
}
return new PostfixDto(getPostfix(gameStatus));
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
timeLock.unlock();
}
}
return null;
}
}
첫 번쨰 try - catch 블럭이 각 플레이어의 준비 완료 요청을 받았을 때 해당 요청을 카운팅 하고 Redis 값을 업데이트 하는 로직이다. KEY값으로 Lock을 구분하기 때문에 간편하게 원하는 로직만 Lock을 걸고 동시성을 제어할 수 있었다. 아래는 동시성 카운팅 테스트이다.
@ExtendWith(MockitoExtension.class)
@SpringBootTest
class GameConvertUtilTest {
@Mock
GameRedisRepository gameRedisRepository;
@Autowired
RedissonClient redissonClient;
long gameId = 1L;
int peopleNum = 100;
GameConvertUtil gameConvertUtil;
InGame inGame;
@BeforeEach
void setup() {
gameConvertUtil = new GameConvertUtil(gameRedisRepository, redissonClient);
inGame = InGame.builder()
.gameStatus(GameStatus.ATTACK)
.requestCount(0)
.build();
}
@Test
@DisplayName("전환 완료 카운팅 동시성 테스트")
void convertCompleteCountTest() throws Exception {
//given
given(gameRedisRepository.getInGame(gameId)).willReturn(Optional.of(inGame));
ExecutorService executorService = Executors.newFixedThreadPool(10);
CountDownLatch countDownLatch = new CountDownLatch(peopleNum - 1);
//when
for (int i = 0; i < peopleNum - 1; i++) {
executorService.submit(() -> {
try {
gameConvertUtil.completeConvertPrepare(gameId);
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
//then
assertThat(inGame.getRequestCount()).isEqualTo(peopleNum - 1);
}
}
두 번째 try - catch 블록의 timeLock은 별도의 비 동기 작업 간의 순서를 제어하기 위한 Lock이다. 게임 내에서 모든 행동에는 제한시간이 존재한다는 비즈니스 로직을 정했고, 제한시간이 다 지나면 강제로 화면을 전환해야 했다.
제한시간이 지나면 클라이언트에게 강제 화면 전환 메시지를 보내고, 서버는 화면 전환 전에 처리해야 할 로직을 수행한다. 이 작업이 전부 끝난 뒤에 화면 전환을 수행해야 하는데, 그 전에 모든 클라이언트의 화면 준비 완료 메시지를 받아 이후 로직을 처리한다면 내부 데이터가 꼬이는 상황이 발생하게 된다.
따라서 제한시간이 다 지나면 별도의 Lock을 하나 두고, 서버에서 Lock을 잡은 뒤에 화면 강제 전환 메시지와 별도 로직을 수행하도록 하였다.
해당 Lock이 UnLock되면 이후 화면 전환 완료가 FullCount일 때의 로직 부분에서 Lock을 잡고 화면 전환 작업을 이어서 수행한다.
https://velog.io/@hgs-study/redisson-distributed-lock
https://way-be-developer.tistory.com/m/274
https://hyperconnect.github.io/2019/11/15/redis-distributed-lock-1.html
좋은 정보 공유 감사합니다~^^