[PostgreSQL] 분산 락 구현해보기
시스템을 구현하다보면, 잠재적으로 여러 함수에서 호출될 수 있는 리소스 사용 지점을 통제하기 위해 단일 Lock 지점을 구현해야할 일이 종종 발생한다.
보통은 Redis를 사용해서 이런 분산 락을 구현하는 경우가 많지만, 반드시 Redis일 필요는 없다. PostgreSQL를 비롯해서 Lock을 지원하는 일반적인 DB들은 전부 응용이 가능하다.
여기서는 PostgreSQL를 사용해서 분산 락을 구현해보는 방법을 간단히 정리해본다.
사용 언어는 Go이지만, 핵심은 SQL Query라서 다른 언어를 사용하더라도 응용이 어렵지는 않을 것이다.
배제: pg_advisory_lock
PostgreSQL은 pg_advisory_lock라고 해서 세션 단위 내에서 자유롭게 락을 걸거나 풀 수 있는 기능을 제공한다.
하지만 타임아웃 등 세부적인 구현이나 제어가 불가능해서 여기서는 다루지 않는다.
Lock 테이블 설계
당연히 테이블이 먼저 필요하다.
테이블 구조는 대략 다음과 같이 할 수 있을 것이다.
CREATE TABLE IF NOT EXISTS locks (
name TEXT PRIMARY KEY,
owner TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
);
name은 lock을 잡을 key 이름이다.
key 이름이 같으면 동시에 하나의 프로세스만 락을 점유할 수 있다.
owner는 TTL을 Refresh하거나 Release할 수 있는 권한을 제어하려고 만든 필드다.
필요 없다고 생각하면 빼도 되긴 한다.
expires_at은 TTL을 구현하기 위한 필드다.
이런거 없이 구현하면, lock 잡고 프로세스가 비명횡사했을 경우의 대책이 없어진다. 시간이 좀 지나면 수동으로 풀지 않아도 강제로 풀리게끔 안전장치를 마련하기 위한 것이다.
TryLock & Lock 구현
Query는 다음과 같다.
INSERT INTO locks (name, owner, expires_at)
VALUES ($1, $2, NOW() + make_interval(secs => $3))
ON CONFLICT (name) DO UPDATE
SET owner = EXCLUDED.owner, expires_at = EXCLUDED.expires_at
WHERE locks.expires_at <= NOW()
RETURNING expires_at;
구현 원리는 대단치 않다.
- TTL이 지나지 않은 Lock이 존재한다면 그 즉시 Lock이 실패했다고 판단하고 중단한다. (점유 실패)
- TTL이 지난 Lock이 있거나, Lock이 존재하지 않는다면 Lock을 삽입하고 성공했다고 처리하는 것이다.
이걸 다시 루프 기반으로 감싸면 블로킹 기반 Lock이 되는 것이다.
Lock을 잡는데 실패하면, 성공할 때까지 루프를 돌리면서 락을 대기하는 것이다.
UnLock 구현
Lock을 잡는 녀석이 있으면 당연히 푸는 녀석도 있어야 한다.
이건 구현이 단순하다. 그냥 날리면 되기 때문이다.
DELETE FROM locks WHERE name = $1 AND owner = $2;

RefreshLock 구현
이건 필요하지 않을 수도 있다.
하지만 실행 흐름이 길어서 Lock TTL이 정상적인 상황에서도 초과할 수 있다면, Refresh를 날려서 Lock을 연장하는 것이 필요할 수도 있다.
UPDATE locks
SET expires_at = NOW() + make_interval(secs => $2)
WHERE name = $1 AND owner = $3
RETURNING expires_at;
그냥 TTL만 늘려주는 단순한 기능이다.
그럼 이제 저걸 가져다가 Lock Acquire/Release를 반복하면서 Lock 동작을 구현할 수 있다.

전체 코드는 Github에서 참조할 수 있다.
https://github.com/myyrakle/oddments/tree/master/Go_Example/database/postgresql/pg_lock