개요
현재 제가 진행 중인 프로젝트는 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 분산 락을 구현할 때 최소 세 가지를 보장해야 한다고 합니다.
- 상호 배제 : 특정 순간에 단 하나의 클라이언트만 락을 획득할 수 있습니다.
- 교착 상태 없음 : 클라이언트가 락을 획득한 상태에서 충돌이 발생하거나 연결이 끊긴 경우에도 락을 획득할 수 있어야 합니다.
- 내결함성 : 과반수의 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와 결합하는 것입니다.
락을 해제할 때 다음 두 가지 연산이 발생합니다.
- key의 value를 조회한다.
- 일치하는 경우에만 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();
'공부 > Database' 카테고리의 다른 글
[PostgreSQL] postgres SCAN 종류 (Bitmap SCAN) (0) | 2025.02.12 |
---|---|
[PostgreSQL] PostgreSQL Full Text Search (0) | 2025.02.03 |
[redis] redis의 transaction (0) | 2025.01.09 |
[MySQL] Procedure 사용법 (2) | 2024.09.30 |
[Database] InfluxDB 쿼리 (CLI 및 API) (0) | 2023.08.30 |