본문 바로가기
개발

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;
  }
}

 

위의 구조에서 문제가 되는것은 redis에 토큰이 존재하지 않고 B서버가 짧은 시간에 많은 요청을 받는 경우 매 요청마다 redis에 토큰을 조회하게 되며, 토큰이 존재하지 않기 때문에 토큰 재발급 요청을 CJ측으로 보냅니다.

 

CJ측의 정책에 따라 기존에 발급된 토큰을 조회하는게 아닌, 토큰을 재발급 받는 요청을 짧은 시간에 많이 보내게 되면 토큰 발급 권한을 일정 시간동안 회수가 하게 되며 이는 서비스에 악영향을 끼칩니다.

 

문제는 B서버가 많은 요청을 받더라도, 매번 토큰 재발급 요청을 하는 것이 아닌 첫번째 요청이 토큰을 재발급 받고 redis에 저장할 때 까지 이후의 요청이 기다리면 됩니다. 이후에는 그냥 redis에 있는 토큰을 사용하면 되니깐요.  

 

여기서 임계영역은 CJ 토큰 재발급 요청 입니다.

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

    if (token) {
      return token;
    } else {
      await mutex.acquire();
      
      const token = await redis.get('토큰 키');
     if(token) return token;

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

      await mutex.release();
      return result.token;
    }
  } catch (err) {
	throw err;
  } finally {
    await mutex.release();
  }
}

 

세마포어 구현은 redis-semaphore를 사용했습니다.

 

임계영역에 들어가기 전에 await mutex.acquire()를 하게되며 acquire()의 코드는 아래와 같습니다.

spin lock을 사용하고 있으며, 특정 시간 && 최대시도횟수 까지 반복을 하면서 redis에 세마포어를 등록 합니다. 만약 실패하면 그냥 대기한다(cpu만 그냥 잡아먹는다). 라이브러리가 busy waiting 방법을 사용하고 있지만, 서버가 1대이고, 하루에 많아봐야 한번 정도 하는 작업이기 때문에 무리가 없다고 생각했습니다.

 

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

 

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

 

release()에서 아래처럼 redis 내부에서 루아 스크립트를 실행시킨건 트랜잭션 때문이라고 생각됩니다.

 

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

 

 

지금은 서버가 한대이지만 분산환경이거나 mutex를 처리해야 하는 횟수가 많아 지게 되면 spin lock을 하는게 아닌, pub sub구조로 세마포어를 반환하게 되면 메시지를 수신하여 처리하는 방법이 더 좋을 것 같습니다.