소개
이 글에서는 .NET Core 환경에서 Redis를 활용해 분산 잠금(distributed lock)을 구현하는 방법을 알아보겠습니다.
분산 시스템을 구축하다 보면 여러 프로세스가 하나의 공유 자원을 동시에 다뤄야 하는 상황에 자주 직면하게 됩니다. 그런데 공유 자원은 한 번에 하나의 프로세스만 사용할 수 있기 때문에, 적절한 제어 장치가 없으면 예상치 못한 문제가 발생할 수 있습니다.
이러한 문제는 바로 분산 잠금으로 해결할 수 있습니다.
왜 분산 잠금이 필요한가?
흔히 이런 문제를 처리할 때 C#의 lock 키워드를 떠올리곤 합니다. 다음은 lock 사용법을 보여주는 간단한 예제입니다.
public void SomeMethod()
{
// Do something...
lock (obj)
{
// Do ....
}
// Do something...
}
하지만 이 방식으로는 문제를 제대로 해결할 수 없습니다. lock은 프로세스 내부(in-process)에서만 유효한 잠금이기 때문에, 단일 프로세스 안에서의 공유 자원 접근 제어만 가능합니다. 여러 서버, 여러 프로세스에 걸친 동기화에는 무용지물이죠.
바로 이 점이 분산 잠금이 필요한 핵심 이유입니다.
여기서는 Redis를 이용해 간단한 분산 잠금을 만들어 보겠습니다. 왜 하필 Redis일까요? Redis는 싱글 스레드 기반으로 동작하며 원자적(atomic) 연산을 지원하기 때문에, 잠금 구현에 매우 적합한 도구이기 때문입니다.
잠금은 어떻게 생성할까?
설명을 위해 간단한 .NET Core 콘솔 애플리케이션을 만들어 보겠습니다. 다음 단계로 넘어가기 전에 먼저 Redis 서버를 실행해 두어야 합니다.
클라이언트 라이브러리는 .NET 진영에서 가장 널리 쓰이는 StackExchange.Redis를 사용합니다. 먼저 Redis와의 연결을 생성합니다.
/// <summary>
/// The lazy connection.
/// </summary>
private static Lazy<ConnectionMultiplexer> lazyConnection = new Lazy<ConnectionMultiplexer>(() =>
{
ConfigurationOptions configuration = new ConfigurationOptions
{
AbortOnConnectFail = false,
ConnectTimeout = 5000,
};
configuration.EndPoints.Add("localhost", 6379);
return ConnectionMultiplexer.Connect(configuration.ToString());
});
/// <summary>
/// Gets the connection.
/// </summary>
public static ConnectionMultiplexer Connection => lazyConnection.Value;
잠금 요청 명령어
공유 자원에 대한 잠금을 요청하려면 다음 명령어를 사용합니다.
SET resource_name unique_value NX PX duration
각 파라미터의 의미는 다음과 같습니다.
- resource_name: 애플리케이션의 모든 인스턴스가 공유하는 키 이름입니다.
- unique_value: 각 인스턴스마다 고유해야 하는 값으로, 나중에 잠금을 해제(unlock)할 때 "내가 설정한 잠금"인지 확인하는 용도로 사용됩니다.
- duration: 밀리초 단위의 만료 시간으로, 해당 시간이 지나면 Redis가 잠금을 자동으로 삭제합니다.
이를 C# 코드로 구현하면 다음과 같습니다.
/// <summary>
/// Acquires the lock.
/// </summary>
static bool AcquireLock(string key, string value, TimeSpan expiration)
{
bool flag = false;
try
{
flag = Connection.GetDatabase().StringSet(key, value, expiration, When.NotExists);
}
catch (Exception ex)
{
Console.WriteLine($"Acquire lock fail...{ex.Message}");
flag = true;
}
return flag;
}
잠금 획득을 테스트하는 코드입니다. 5명의 "사람"이 병렬로 같은 자원에 접근하는 상황을 시뮬레이션했습니다.
static void Main(string[] args)
{
string lockKey = "lock:eat";
TimeSpan expiration = TimeSpan.FromSeconds(5);
// 5 person eat something...
Parallel.For(0, 5, x =>
{
string person = $"person:{x}";
bool isLocked = AcquireLock(lockKey, person, expiration);
if (isLocked)
{
Console.WriteLine($"{person} begin eat food (with lock) at {DateTimeOffset.Now.ToUnixTimeMilliseconds()}.");
}
else
{
Console.WriteLine($"{person} cannot eat food due to not getting the lock.");
}
});
Console.WriteLine("end");
Console.Read();
}
코드를 실행하면 다음과 같은 결과를 볼 수 있습니다.

단 한 사람만 잠금을 획득하고, 나머지는 모두 대기하게 됩니다.
물론 만료 시간이 지나면 Redis가 잠금을 자동으로 해제하지만, 그때까지 다른 프로세스들이 무작정 기다려야 한다면 공유 자원을 효율적으로 활용하지 못하는 문제가 있습니다. 작업을 마친 프로세스는 즉시 자원을 반납해서 다른 프로세스가 사용할 수 있게 해야겠죠.
따라서 잠금 해제(release) 로직도 함께 구현해야 합니다.
잠금은 어떻게 해제할까?
잠금을 해제하는 방법은 간단합니다. Redis에서 해당 키를 삭제하면 됩니다. 다만, 잠금을 생성할 때 저장했던 고유 값(unique_value)을 비교한 후 삭제해야 합니다. 이렇게 해야 다른 인스턴스가 설정한 잠금을 실수로 삭제하는 일을 막고, 자신이 설정한 잠금만 안전하게 해제할 수 있습니다.
값이 일치하면 잠금을 삭제하여 해제에 성공하고, 일치하지 않으면 해제에 실패한 것으로 판단합니다. 이때 GET과 DEL 명령을 원자적으로 한 번에 실행해야 하므로, Lua 스크립트를 활용하겠습니다.
/// <summary>
/// Releases the lock.
/// </summary>
static bool ReleaseLock(string key, string value)
{
string lua_script = @"
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
redis.call('DEL', KEYS[1])
return true
else
return false
end
";
try
{
var res = Connection.GetDatabase().ScriptEvaluate(lua_script,
new RedisKey[] { key },
new RedisValue[] { value });
return (bool)res;
}
catch (Exception ex)
{
Console.WriteLine($"ReleaseLock lock fail...{ex.Message}");
return false;
}
}
프로세스가 작업을 마친 후에는 반드시 이 메서드를 호출해야 합니다.
재시도와 포기(Fallback) 처리
그런데 현실적으로는 어떤 프로세스가 잠금을 획득한 후 여러 이유(크래시, 버그 등)로 잠금을 해제하지 않는 경우도 발생합니다. 이때 다른 프로세스들이 영원히 기다리게 할 수는 없으므로, 일정 시간 동안 재시도한 뒤 포기하고 다음 작업을 진행하도록 처리하는 것이 좋습니다.
다음은 이러한 시나리오를 처리하는 예제 코드입니다.
Parallel.For(0, 5, x =>
{
string person = $"person:{x}";
var val = 0;
bool isLocked = AcquireLock(lockKey, person, expiration);
while (!isLocked && val <= 5000)
{
val += 250;
System.Threading.Thread.Sleep(250);
isLocked = AcquireLock(lockKey, person, expiration);
}
if (isLocked)
{
Console.WriteLine($"{person} begin eat food (with lock) at {DateTimeOffset.Now.ToUnixTimeMilliseconds()}.");
if (new Random().NextDouble() < 0.6)
{
Console.WriteLine($"{person} release lock {ReleaseLock(lockKey, person)} {DateTimeOffset.Now.ToUnixTimeMilliseconds()}");
}
else
{
Console.WriteLine($"{person} do not release lock ....");
}
}
else
{
Console.WriteLine($"{person} begin eat food (without lock) at {DateTimeOffset.Now.ToUnixTimeMilliseconds()}.");
}
});
샘플을 실행하면 다음과 같은 결과를 얻을 수 있습니다. 실행 결과를 보면 person 3과 person 4는 잠금을 획득하지 못한 채 일정 시간 재시도 후 포기하고, 잠금 없이 다음 작업을 진행한 것을 확인할 수 있습니다.
전체 소스 코드는 아래 GitHub 리포지토리에서 확인할 수 있습니다.
마무리
이 글에서는 .NET Core에서 Redis를 활용해 분산 잠금을 구현하는 기본적인 방법을 살펴보았습니다. SET NX PX 명령으로 잠금을 획득하고, Lua 스크립트로 고유 값을 검증하며 안전하게 해제하는 과정까지 다루었습니다.
여기서 소개한 것은 가장 기본적인 형태입니다. 실제 운영 환경에서는 재시도 전략, 잠금 연장(renewal), Redlock 알고리즘 적용 등을 비즈니스 요구 사항에 맞게 추가로 고민하고 개선할 수 있습니다.
이 글이 여러분의 개발에 도움이 되기를 바랍니다.