같은 유저가 버튼을 두 번 빠르게 누르면 서버에 요청이 두 개 들어온다.
클라이언트가 막아주기를 기대할 수는 없다.
네트워크 재시도, 매크로, 조작된 클라이언트 모두 같은 결과를 만든다.
핸들러마다 방어를 넣는 방식으로 시작했지만, 새 패킷이 늘어날 때마다 같은 코드를 다시 쓰게 됐다.
누락된 곳은 정산 결과가 어긋난 뒤에야 드러난다.
그래서 방어 지점을 파이프라인 한 곳으로 올렸다.
1. 배경
서버는 요청을 커맨드로 받고 MediatR 파이프라인을 태운다.
인증, 세션 검증, 로깅은 이미 IPipelineBehavior로 붙어 있었다.
동시성 방어도 같은 자리에 들어갈 수 있다.
핸들러가 몇 개든 상관없이 모든 커맨드가 반드시 지나가는 길목이기 때문이다.
2. 문제 정의
파이프라인에 넣기로 정하고 나면 결정할 것이 세 가지 남는다.
- 파이프라인의 어느 위치에 둘 것인가
- 락 키를 무엇으로 잡을 것인가
- 만료와 대기 시간을 얼마로 둘 것인가
세 개 다 틀리기 쉬운 자리였다.
3. 위치: 세션 검증보다 안쪽
처음에는 파이프라인 가장 바깥이 안전해 보였다.
모든 요청을 잠글 수 있기 때문이다.
그런데 바깥에 두면 거절될 요청도 락을 잡는다.
중복 로그인으로 세션이 무효화된 요청은 세션 검증에서 어차피 튕긴다.
그런 요청이 락을 먼저 잡으면, 정상 요청이 그 뒤에서 대기한다.
그래서 세션 검증 뒤에 뒀다.
락은 "실제로 상태를 바꿀 자격이 있는 요청"만 잡아야 한다.
검증에서 떨어질 요청까지 잡으면 락 대기열이 쓸데없이 길어진다.
토큰 발급 전 익명 패킷처럼 캐릭터 식별자가 아직 없는 요청은 잠글 대상이 없다.
이 경우는 그냥 통과시킨다.
4. 키: 캐릭터 ID만으로는 부족하다
여기서 문제가 생겼다.
캐릭터 ID를 그대로 키에 쓰려고 했는데, 이 값은 리전별 DB의 auto-increment PK다.
리전마다 독립적으로 1부터 증가한다.
즉 서로 다른 리전의 서로 다른 캐릭터가 같은 ID를 가진다.
Redis를 리전이 공유하는 순간, 키가 충돌한다.
한국 리전 캐릭터 1024번의 요청이 미국 리전 캐릭터 1024번의 요청을 막아 세운다.
버그로 드러나기도 어렵고, 간헐적인 지연으로만 나타난다.
키에 리전 코드를 함께 묶어 해결했다.
GlobalUserLock:{RegionCode}:{CharacterId}락 키의 유일성 범위는 그 값이 생성된 범위와 같아야 한다.
DB별로 발급된 ID는 DB 경계 안에서만 유일하다.
5. 시간: 만료 15초, 대기 7초
두 값은 역할이 다르다.
| 값 | 역할 | 너무 크면 | 너무 작으면 |
|---|---|---|---|
expiryTime 15초 | 락을 쥔 채 프로세스가 죽었을 때 자동으로 풀리는 시간 | 죽은 락이 오래 남아 그 유저가 계속 막힌다 | 핸들러가 아직 도는데 락이 풀려 동시 실행이 뚫린다 |
waitTime 7초 | 락을 못 잡았을 때 기다려 보는 시간 | 클라이언트가 타임아웃 날 때까지 매달린다 | 정상적인 짧은 경합에도 실패를 돌려준다 |
만료는 가장 느린 핸들러보다 길게, 대기는 클라이언트 타임아웃보다 짧게 잡는 것이 기준이다.
두 값의 관계가 뒤집히면(대기 > 만료) 대기하는 쪽이 만료로 풀린 락을 잡아 동시 실행이 생긴다.
6. 구현
public class GlobalUserLockBehaviour<TRequest, TResponse>(
ICharacterIdentity characterIdentity,
IRedisProvider redisProvider)
: IPipelineBehavior<Command<TRequest, TResponse>, TResponse>
where TRequest : IMessage
where TResponse : new()
{
private const string KeyPrefix = "GlobalUserLock";
private const int LockExpirySeconds = 15;
private const int LockWaitSeconds = 7;
public async Task<TResponse> Handle(
Command<TRequest, TResponse> request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
var token = characterIdentity.CharacterToken;
// 캐릭터 식별자가 없는 익명 패킷은 잠글 대상이 없다
if (token is null)
{
return await next();
}
TResponse response = default!;
await redisProvider.ExecuteWithLock(
// CharacterId는 리전별 DB의 auto-increment PK라 리전이 다르면 값이 겹친다
$"{KeyPrefix}:{token.RegionCode}:{token.CharacterId}",
async () => { response = await next(); },
expiryTime: LockExpirySeconds,
waitTime: LockWaitSeconds);
return response;
}
}핸들러는 락 처리 방식을 알 필요가 없다.
새 패킷을 추가할 때 동시성 처리를 따로 고려하지 않아도 되는 점이 이 구조의 장점이다.
7. 검증
"동시 요청이 직렬화된다"는 주장은 눈으로 확인이 안 된다.
락을 밖에서 잡아두고, 그동안 들어온 요청이 기다렸다가 처리되는지를 테스트로 고정했다.
[Fact]
public async Task 같은_캐릭터의_요청은_기존_락이_풀릴때까지_대기했다가_처리된다()
{
var character = await 테스트캐릭터_생성();
var lockKey = $"GlobalUserLock:{regionCode}:{character.Id}";
var lockAcquired = new TaskCompletionSource();
var releaseLock = new TaskCompletionSource();
// 락을 미리 점유한 채 붙잡아 둔다
var holdTask = redisProvider.ExecuteWithLock(lockKey, async () =>
{
lockAcquired.SetResult();
await releaseLock.Task;
});
await lockAcquired.Task;
// 이 요청은 락이 풀릴 때까지 진행되면 안 된다
var stopwatch = Stopwatch.StartNew();
var packetTask = character.패킷<InboxMailCountReq, InboxMailCountRes>(new());
await Task.Delay(1200);
releaseLock.SetResult();
await holdTask;
var (_, header, _) = await packetTask;
stopwatch.Stop();
Assert.Equal(ResultCode.Success, header.ResultCode);
// 1.2초를 붙잡아 뒀으니 그보다 적게 걸렸다면 락을 무시하고 지나간 것이다
Assert.True(stopwatch.ElapsedMilliseconds >= 1200);
}Task.Delay로 시간을 만들어 검증하는 테스트는 느리고 환경에 따라 흔들린다.
그래도 이 동작은 실제로 기다렸는지가 핵심이라, 시간을 재는 것 말고 대안을 찾지 못했다.
8. 한계
읽기 요청까지 잠그는 게 지금 가장 아쉽다.
우편 개수 조회처럼 상태를 안 바꾸는 패킷도 락을 지난다.
"이 패킷은 읽기 전용"이라고 표시할 장치가 아직 없어서 전부 잠그는 쪽을 택했고, 그 표시를 붙여 우회시키는 게 다음 단계다.
방어 장치가 장애 지점이 됐다는 것도 감수한 부분이다.
Redis가 죽으면 락을 못 잡고, 락을 못 잡으면 요청이 실패한다.
장애 시 락 없이 통과시킬지 실패시킬지는 정책 문제이고 지금은 후자로 뒀다.
여기서 쓰는 락은 단일 인스턴스 기준의 단순한 형태라, Redis가 페일오버하는 순간 두 요청이 동시에 락을 잡을 수 있다.
막으려면 Redlock 계열이 필요한데 그 복잡도가 지금 감수하는 위험보다 큰지는 아직 판단하지 않았다.
락 범위가 넓다는 점도 남는다.
우편과 인벤토리처럼 서로 다른 자원을 다루는 두 요청도 서로를 기다린다.
자원별로 쪼개면 병렬성은 올라가지만 여러 자원을 함께 건드리는 핸들러에서 데드락이 생길 수 있어서, 지금 트래픽에서는 굵은 락의 단순함이 낫다고 봤다.