본문 바로가기
개발

redis-semaphore 사용해서 mutex 해결하기

by 박연호의 개발 블로그 2024. 1. 12.

CJ 택배사 API연동하는 과정에서 semaphore를 사용하여 mutex를 해결한 경험을 공유하고자 글을 작성합니다.

 

 

전체적인 흐름은 A 서버에서 B서버로 주문 등록 요청을 하면 B서버는 redis에서 토큰을 조회 후, 토큰과 함께 CJ API를 호출하게 됩니다.

 

A서버에서 B서버로 요청을 하면 B서버는 validation 이후, 토큰을 조회하는 함수를 실행합니다. getToken 함수에서는 토큰키를 사용하여 redis에서 토큰을 조회하고 존재하면 사용하고, 존재하지 않으면 CJ측에 토큰 재발급 요청 후, redis에 저장합니다. token의 ttl은 하루 입니다.

const getToken = () => {
  try {
    const token = await redis.get('토큰 키');

    if (token) {
      return token;
    } else {
      const result = await request('/getCjToken');
      await redis.set('토큰 키', result.token, result.ttl);
      return result.token;
    }
  } catch (err) {
    throw err;
  }
}

 

주문 등록 요청이 짧은 순간 여러번 오게 되면(A서버에서는 Promise.all로 한번에 많은 요청을 보냄), getToken이 여러번 실행하게 되고, 실행 시점에서는 redis의 토큰값이 없어 else문에서 N번의 cj 토큰 발급 요청 api를 실행하게 됩니다.

 

문제점은 짧은 시간안에 많은 cj 토큰 발급 api를 요청하게 되면 api 호출 권한이 회수 된다는 것입니다. 사실 N번의 토큰 발급 요청을 할 필요 없이 한번만 요청해 토큰을 레디스에 저장하면 됩니다. 

 

그렇기 위해서는 토큰 발급 api 요청 코드 부분을 임계영역으로 지정하여, lock을 획득하기 전까지 대기하도록 할 수 있습니다.

const getToken = () => {
  try {
    const token = await redis.get('토큰 키');

    if (token) {
      return token;
    } else {
      await mutex.acquire();   <-- lock key를 획득하기 전까지 busy wating
      
      const token = await redis.get('토큰 키');
     if(token) return token;

      const result = await request('/getCjToken');
      await redis.set('토큰 키', result.token, result.ttl);

      await mutex.release(); <-- 첫번째 요청이 토큰을 레디스에 저장하면 lock key release
      return result.token;
    }
  } catch (err) {
	throw err;
  } finally {
    await mutex.release();
  }
}

 

세마포어 구현은 redis-semaphore를 사용했는데, lock을 얻고 반납하는 함수들의 코드가 복잡하지 않고 직관적이였고 lock을 반납할 때 루아 스크립트로 여러 명령어를 하나의 트랜잭션에 처리했기 때문입니다. 

 

mutex.acquire()

while문에서는 특정 시간/횟수까지 lock을 얻기 위해 재시도를 합니다. 그리고 redis를 사용하여 key가 NX(키가 없으면 set)면 key에 identifier값은 set하면서 lock을 얻고 true를 반환합니다. 그렇지 않으면 lock을 얻기 위해 계속 대기합니다.

 

위의 코드 덕분에 B서버가 여러 요청을 받았다 해도, 첫번째 요청만 토큰 재발급 요청을 하며, 이후의 요청은 await mutex.acquire() 코드에 메달려 있게 됩니다(spin lock). 또한 await mutex.acquire()가 짧은 순간 많이 호출된다 하더라도, redis는 single thread이기 때문에 문제가 없습니다.

 

첫번째 요청이 토큰을 재발급 받고 redis.set을 하면 await mutex.release()가 실행됩니다. redis에 저장된 key를 삭제함으로 써 세마포어를 반환하게 되며 이렇게 반환된 세마포어는 이후의 요청이 while문에서 획득하게 됩니다.

      const result = await request('/getCjToken');
      await redis.set('토큰 키', result.token, result.ttl);

      await mutex.release(); <-- 첫번째 요청이 토큰을 레디스에 저장하면 lock key release

 

 

 

release()에서는 acquire()에서 lock을 잡은 n번째 요청만 작업을 해제할 수 있습니다. 여기서 lock을 잡은 요청을 보장하는 방법이 identifier입니다. identifier값은 랜덤값이며, 위 lock을 획득할 때 key의 value로 저장되었습니다. 

 

이후 release()에서 key를 조회한 값이 identifier 인지를 비교하여 lock을 획득한 사람이 맞는 경우에만 key를 제거하고 있습니다.

key를 제거하면 위의 acquire()에서 NX조건이 만족하여 새로운 요청이 lock을 획득하게 됩니다. 

 

redis 명령어를 굳이 루아 스크립트로 작성한 이유는 원자성을 보장하기 위함이며(하나의 redis 트랜잭션에서 실행됨), get하고 delete를 하는 사이에 다른 요청이 끼어들지 못하게 하기 위함입니다.

mutex.release()

 

이후의 요청은 토큰 재발급 요청을 하기 전에, 먼저 redis에 저장된 토큰값이 있는지 확인합니다. 물론, 첫번째 요청이 토큰을 저장했기 때문에 있겠죠. 해당 토큰을 반환하게 됩니다. 

      await mutex.acquire();   <-- lock key를 획득하기 전까지 busy wating
      const token = await redis.get('토큰 키');