[redis] node.js 환경에서 redis 분산 락 구현하기

2025. 1. 10. 13:51공부/Database

728x90

개요

현재 제가 진행 중인 프로젝트는 api 서버와 websocket 서버가 별도의 프로세스로 띄워져서 동작하고 있습니다.

 

여기서 api 서버와 websocket 서버가 같은 자원을 공유하고 있어서 동시성 문제를 해결하기 위해 분산 락을 적용하기로 했습니다.

처음에는 npm의 simple-redis-mutex 패키지를 사용하였는데 원인 모를 에러가 계속 발생하더라고요...

https://www.npmjs.com/package/simple-redis-mutex

 

simple-redis-mutex

Mutex lock implemented using redis. Latest version: 2.1.0, last published: a month ago. Start using simple-redis-mutex in your project by running `npm i simple-redis-mutex`. There are 3 other projects in the npm registry using simple-redis-mutex.

www.npmjs.com

 

아마 제 프로젝트에서는 ioredis를 사용하고 있는데 simple-redis-mutex 패키지에서는 기본적으로 redis를 사용하기 때문에 발생한 문제로 추측하고 있습니다.

 

그렇다고 ioredis를 redis로 바꾸자니 번거로운 작업이 될 것 같아서 redis 분산 락을 직접 구현해보기로 했습니다.

 

분산 락이란?

분산 락은 여러 프로세스가 동일한 공유 자원을 상호 배타적으로 사용하는 환경에서 매우 유용하게 사용할 수 있습니다.

 

예를 들어 분산 락을 사용하면 두 개의 프로세스가 동시에 하나의 데이터를 변경하려고 할 때 한 쪽의 변경 사항만 변경되어 데이터 불일치가 발생하는 것을 방지할 수 있습니다.

 

분산 락은 다양한 방식으로 사용할 수 있지만 그 중 인 메모리 기반 데이터베이스인 redis를 사용해서 분산 락을 구현해보겠습니다.

 

redis 공식 문서를 보면 가장 표준적인 redis 분산 락에 대한 설명이 자세하게 나와있습니다.

https://redis.io/docs/latest/develop/use/patterns/distributed-locks/

 

Distributed Locks with Redis

A distributed lock pattern with Redis

redis.io

 

위 문서는 여러 개의 redis 인스턴스를 사용하는 redlock 알고리즘에 대해 설명하고 있지만 저는 단일 redis 인스턴스를 사용하려고 합니다.

 

이 공식 문서를 기반으로 node.js 환경에서 ioredis를 사용하여 분산 락을 구현해보겠습니다.

redis 분산 락 구현

redis 공식 문서에서는 redis 분산 락을 구현할 때 최소 세 가지를 보장해야 한다고 합니다.

  1. 상호 배제 : 특정 순간에 단 하나의 클라이언트만 락을 획득할 수 있습니다.
  2. 교착 상태 없음 : 클라이언트가 락을 획득한 상태에서 충돌이 발생하거나 연결이 끊긴 경우에도 락을 획득할 수 있어야 합니다.
  3. 내결함성 : 과반수의 redis가 동작하는 한 클라이언트는 잠금을 획득하고 해제할 수 있습니다.

제대로 구현하려면 여러 개의 redis를 띄우고 과반수의 redis에서 락을 획득할 수 있을 때 락을 획득해야 하지만 저희 프로젝트에서 redis를 여러 개까지 띄울 필요는 없어 보입니다.

 

그래서 이번 포스팅에서는 단일 redis 인스턴스를 사용해서 구현해보려고 합니다.

 

우선 락을 획득해봅시다.

 

락 획득

 

락을 획득할 때는 NX와 PX를 사용해야 합니다.

 

NX는 key를 set 할 때 key가 존재하지 않을 때만 set하는 옵션이고 PX는 만료 시간을 지정할 수 있습니다.

SET resource_name my_random_value NX PX 30000

 

NX 옵션을 통해 set을 할 때 key가 존재하지 않다면 ok를 반환하고 존재한다면 null을 반환합니다.

 

127.0.0.1:6379> SET resource_name my_random_value NX PX 30000
OK
127.0.0.1:6379> SET resource_name my_random_value NX PX 30000
(nil)

 

클라이언트는 NX set을 한 뒤 null을 반환받는다면 락 획득에 실패한 것입니다.

 

여기서 중요한 점은 value는 모든 클라이언트와 락 요청 간에 고유해야 합니다.

 

value가 고유해야 하는 이유는 오직 락을 획득한 당사자만 락을 해제할 수 있어야 하기 때문입니다.

 

락 해제

락을 획득하고 락의 유효 시간 보다 더 긴 작업을 수행하는 경우를 생각해봅시다.

 

작업이 끝나지 않았는데 락의 유효 시간이 끝나버리고 나중에 락을 해제할 때 다른 사용자의 락을 해제할 수 있습니다.

 

이를 해결하기 위해 value에 랜덤 값을 넣고 락을 해제할 때 value가 동일할 때만 해제하도록 구현할 수 있습니다.

 

랜덤 문자열을 생성할 때 가장 간단한 방법은 UNIX 타임스탬프를 마이크로 단위로 사용하고 클라이언트 id와 결합하는 것입니다.

 

락을 해제할 때 다음 두 가지 연산이 발생합니다.

  1. key의 value를 조회한다.
  2. 일치하는 경우에만 key를 삭제한다.

이 두 연산 사이에 다른 연산은 발생해서는 안 되고 원자적이어야 합니다.

 

redis에서는 여러 연산을 원자적으로 실행하기 위해 lua script를 제공합니다.

 

다음은 lua script를 통해 락을 해제하는 코드입니다.

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

 

코드 작성

이제 코드를 작성해봅시다.

 

ioredis로 redis client를 생성합니다.

const Redis = require("ioredis");
const redisClient = new Redis({
  host: "localhost",
  port: 6379,
});

 

락 해제를 위한 lua script를 작성합니다.

const releaseScript = `
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end
`;

 

락을 획득하는 코드를 작성합니다.

 

락 획득에 성공하면 락을 해제하는 함수를 반환합니다.

 

만약 락 획득에 실패하면 일정 주기마다 락 재획득을 시도합니다.

const Redis = require("ioredis");
const redisClient = new Redis({
  host: "localhost",
  port: 6379,
});
const releaseScript = `
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end
`;
async function acquireLock(key, retryCount = 10, retryDelay = 100) {
  // retryCount만큼 시도
  for (let i = 0; i < retryCount; i++) {
    // mili초 단위 timestamp + client id
    const value =
      Date.now().toString() + (await redisClient.client("id")).toString();
    console.log(value);
    const acquireResult = await redisClient.set(key, value, "EX", 10, "NX");

    // 락 획득 성공
    if (acquireResult == "OK") {
      return async function release() {
        const releaseResult = await redisClient.eval(
          releaseScript,
          1,
          key,
          value
        );
        // 락 해제 성공
        if (releaseResult === 1) {
          return true;
        }
        // 락 해제 실패
        return false;
      };
    }

    // 락 획득 실패하면 retryDelay이후 다시 획득 시도
    await new Promise((resolve) => {
      setTimeout(() => {
        resolve();
      }, retryDelay);
    });
  }
  throw new Error("락 획득 실패");
}

 

아래와 같이 사용할 수 있습니다.

async function main() {
  // 락 획득
  const release = await acquireLock(redisClient, 1);

  // 시간이 걸리는 작업 진행 후 락 해제
  setTimeout(async () => {
    const res = await release();
  }, 1000);
}
main();

 

728x90