블로그 목록

RDB(PostgreSQL/MySQL)를 활용한 경량 분산 락 구현 패턴

서버가 한 대일 때는 문제없던 예약 작업이 서버를 세 대로 늘리자 세 번씩 실행되는 일이 흔히 생깁니다. 이럴 때 "여러 서버 중 한 곳만 이 작업을 하게" 만드는 장치가 분산 락입니다. Redis 같은 별도 인프라가 없다면, 이미 쓰고 있는 PostgreSQL이나 MySQL의 기능으로도 가벼운 분산 락을 만들 수 있습니다. 이 글은 그 방법과 한계를 정리합니다.

1. 비유로 이해하기

카페 화장실 열쇠를 떠올려 보면 쉽습니다. 열쇠는 카운터에 하나뿐이라 먼저 가져간 사람만 들어갈 수 있고, 다 쓰면 돌려놓습니다. 여기서 카운터 역할을 DB가 맡습니다. 모든 서버가 같은 DB에 접속하므로 DB에 "열쇠"를 하나 두면 서버끼리 순서를 정할 수 있습니다. 이 열쇠는 테이블의 행과는 관계없는, 애플리케이션이 이름(또는 번호)만 정해서 쓰는 잠금입니다.

2. 구체적인 시나리오

매일 새벽 정산을 실행하고 결과 메일을 보내는 스케줄러가 서버 3대에 똑같이 배포되어 있다고 합시다. 락이 없으면 정산이 세 번 실행되고 메일도 세 통씩 발송됩니다. 각 서버가 작업 시작 전에 DB 락을 시도하고, 락을 얻은 서버만 정산을 실행하도록 하면 이 문제를 막을 수 있습니다.

3. PostgreSQL Advisory Lock

PostgreSQL은 advisory lock을 제공합니다. 키는 bigint 하나 또는 integer 두 개이며, 잠금 범위에 따라 두 종류가 있습니다.

  • 세션 수준 (pg_advisory_lock, pg_try_advisory_lock): 직접 해제하거나 연결이 끊길 때까지 유지됩니다. 트랜잭션을 롤백해도 풀리지 않습니다.
  • 트랜잭션 수준 (pg_advisory_xact_lock, pg_try_advisory_xact_lock): 트랜잭션이 커밋되거나 롤백될 때 자동으로 풀리며, 명시적 해제 함수가 없습니다.

try가 붙은 함수는 기다리지 않고 즉시 true 또는 false를 돌려줍니다. 커넥션 풀을 쓰는 애플리케이션에서는 해제를 깜빡할 걱정이 없는 트랜잭션 수준 락이 다루기 쉽습니다.

// Go (database/sql)
func RunExclusive(ctx context.Context, db *sql.DB, key int64, job func(*sql.Tx) error) (bool, error) {
	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return false, err
	}
	defer tx.Rollback() // 커밋 뒤에 호출되면 아무 일도 하지 않음

	var locked bool
	err = tx.QueryRowContext(ctx, "SELECT pg_try_advisory_xact_lock($1)", key).Scan(&locked)
	if err != nil || !locked {
		return false, err // locked == false: 다른 서버가 실행 중
	}
	if err := job(tx); err != nil {
		return true, err
	}
	return true, tx.Commit()
}
// NestJS + Prisma
await this.prisma.$transaction(async (tx) => {
  const [row] = await tx.$queryRaw<{ locked: boolean }[]>`
    SELECT pg_try_advisory_xact_lock(${SETTLEMENT_LOCK_KEY}::bigint) AS locked`;
  if (!row?.locked) return; // 다른 서버가 실행 중
  await this.settle(tx);
}, { timeout: 60_000 });

Prisma의 대화형 트랜잭션은 기본 제한 시간이 5초이므로 작업이 길면 timeout 옵션을 늘려야 합니다. Spring이라면 @Transactional 메서드 안에서 같은 SQL을 네이티브 쿼리로 실행하면 됩니다.

4. MySQL Named Lock: GET_LOCK

MySQL은 문자열 이름으로 잠그는 GET_LOCK(name, timeout)을 제공합니다. 제한 시간은 초 단위이고 음수면 무한 대기입니다. 이름은 최대 64자입니다.

SELECT GET_LOCK('daily-settlement', 0);  -- 1: 획득, 0: 시간 초과(다른 세션이 보유), NULL: 오류
-- 같은 커넥션에서 작업 수행
SELECT RELEASE_LOCK('daily-settlement'); -- 1: 해제, 0: 내가 잡은 락 아님, NULL: 존재하지 않음

GET_LOCK은 트랜잭션 커밋이나 롤백으로 풀리지 않고, RELEASE_LOCK을 호출하거나 세션이 끝날 때 풀립니다. 따라서 획득과 해제를 반드시 같은 커넥션에서 실행해야 합니다. 커넥션 풀을 쓰는 ORM에서는 두 쿼리가 서로 다른 커넥션으로 나갈 수 있으므로, 하나의 트랜잭션이나 전용 커넥션으로 묶어 실행하는지 확인합니다. 또한 잠금은 접속한 MySQL 서버 안에서만 유효하며, 공식 문서는 이 함수들이 statement 기반 복제에서 안전하지 않다고 안내합니다.

5. 긴 작업이라면: 두 가지 선택지

트랜잭션 수준 락은 작업하는 내내 트랜잭션을 열어 두어야 합니다. 몇 초짜리 작업이라면 문제없지만, 수십 분짜리 배치라면 트랜잭션이 오래 열려 있게 됩니다. PostgreSQL에서 오래 열린 트랜잭션은 VACUUM이 오래된 행 버전을 정리하지 못하게 만드는 등 부작용이 있으므로 다음 방법도 함께 고려합니다.

  • 세션 수준 락 + 전용 커넥션: 풀에서 커넥션 하나를 따로 꺼내 락을 잡고, 작업이 끝나면 같은 커넥션에서 해제합니다. 해제가 실패했다면 그 커넥션은 풀에 돌려보내지 말고 닫아야 락이 남지 않습니다.
  • 실행 기록 테이블 + 유니크 제약: 스케줄러 중복 실행 방지가 목적이라면 락 대신 기록을 남기는 방법이 더 단순할 때가 많습니다.
CREATE TABLE job_runs (
  job_name  VARCHAR(100) NOT NULL,
  run_date  DATE         NOT NULL,
  PRIMARY KEY (job_name, run_date)
);
-- 가장 먼저 INSERT에 성공한 서버만 작업을 실행하고,
-- 나머지 서버는 중복 키 오류를 받고 건너뜁니다.
INSERT INTO job_runs (job_name, run_date) VALUES ('daily-settlement', CURRENT_DATE);

이 방식은 서버가 작업 도중 죽었을 때 재실행 정책(예: 상태 컬럼과 시작 시각을 두고 일정 시간이 지나면 재시도)을 따로 정해야 하지만, 누가 언제 실행했는지 기록이 남는다는 장점이 있습니다.

6. 한눈에 비교

항목PostgreSQL advisory lockMySQL GET_LOCKRedis 락
키정수(bigint 또는 int 2개)문자열(최대 64자)문자열
자동 해제트랜잭션 종료 또는 연결 종료연결 종료TTL 만료
추가 인프라없음없음Redis 필요
보유 중 비용DB 커넥션 1개 점유DB 커넥션 1개 점유DB 커넥션 점유 없음

7. 언제 쓰고, 언제 안 써도 되나

  • 잘 맞는 경우: 스케줄러 중복 실행 방지, 드물게 실행되는 관리 작업, 락 요청이 많지 않은 서비스.
  • 맞지 않는 경우: 초당 수천 번 락을 잡아야 하는 경우. DB 커넥션과 부하가 곧 한계가 되므로 Redis 분산 락이 더 적합할 수 있습니다.
  • 필요 없는 경우: 보호하려는 대상이 DB의 특정 행이라면 분산 락보다 행 락이나 낙관적 락이 더 단순합니다.

8. 자주 하는 실수

  • 세션 락을 풀에 되돌림: 세션 수준 락을 해제하지 않고 커넥션을 풀에 반납하면, 그 커넥션이 살아 있는 동안 락도 계속 남습니다.
  • PgBouncer 트랜잭션 풀링과 함께 사용: PgBouncer 문서는 트랜잭션 풀링 모드에서 세션 수준 advisory lock을 지원하지 않는다고 명시합니다. 이 경우 트랜잭션 수준 락을 씁니다.
  • 문자열을 정수 키로 대충 변환: 해시로 변환하면 서로 다른 이름이 같은 키로 충돌할 수 있습니다. 키 번호를 상수로 관리하는 편이 안전합니다.
  • 락이 있으니 절대 안전하다고 가정: 연결 끊김이나 DB 장애 조치(failover)가 일어나면 락이 사라진 줄 모르고 작업을 계속할 수 있습니다. 중요한 쓰기에는 유니크 제약이나 멱등 처리를 함께 둡니다.

9. 정리

  • 이미 쓰는 DB만으로 서버 간 "한 번만 실행"을 보장하는 가벼운 방법입니다.
  • PostgreSQL에서는 해제가 자동인 트랜잭션 수준 advisory lock부터 검토합니다.
  • MySQL GET_LOCK은 같은 커넥션에서 획득과 해제를 해야 합니다.
  • 락은 보조 수단이며, 데이터 정합성의 마지막 방어선은 제약 조건과 멱등성입니다.

참고 자료