RDB(PostgreSQL/MySQL)를 활용한 경량 분산 락 구현 패턴
Redis나 ZooKeeper 같은 추가 인프라 구축 없이, 이미 가동 중인 관계형 데이터베이스(RDB)의 전용 네임드 락 기능을 활용해 동시성을 제어하는 분산 락 설계 가이드입니다.
1. 데이터베이스 분산 락의 특징
데이터베이스 분산 락은 테이블 데이터 레코드에 락을 거는 `SELECT ... FOR UPDATE`와 달리, DB 세션(Session)이나 커넥션 수준에서 전역적인 명명 규칙을 기반으로 독립된 가상의 락 슬롯을 획득하는 구조입니다.
- PostgreSQL Advisory Locks: 64비트 정수 키를 기준으로 세션 단위 또는 트랜잭션 단위로 락을 걸 수 있습니다 (`pg_advisory_lock`, `pg_try_advisory_lock`).
- MySQL Named Locks: 임의의 문자열 이름으로 락을 획득하며 초 단위 타임아웃을 지정할 수 있습니다 (`GET_LOCK`, `RELEASE_LOCK`).
2. 장단점
별도의 메모리 분산 시스템을 구축할 예산이 부족하거나 데이터 정합성의 물리적인 일관성이 절대적인 환경에 유용합니다. 단, 커넥션 풀을 직접 점유하여 긴 시간 대기할 수 있으므로 락 획득 대기 타임아웃을 매우 짧게 설정하거나 비차단형 함수(`try` 계열)를 사용해야 합니다.
3. Go, NestJS, Spring Boot 구현 예제
Go (Golang) - PostgreSQL Advisory Lock
Go에서는 데이터베이스 커넥션 풀로부터 전용 커넥션을 점유하여 PostgreSQL 세션 레벨 Advisory Lock을 수행합니다.
package main
import (
"context"
"database/sql"
"errors"
"fmt"
)
type DbLock struct {
db *sql.DB
}
func (l *DbLock) ExecuteWithAdvisoryLock(ctx context.Context, lockID int64, action func() error) error {
// advisory lock은 세션 종속적이므로 동일 커넥션을 지속적으로 사용해야 함
conn, err := l.db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
// 1. 비차단형 락 획득 시도 (pg_try_advisory_lock)
var acquired bool
err = conn.QueryRowContext(ctx, "SELECT pg_try_advisory_lock($1)", lockID).Scan(&acquired)
if err != nil {
return err
}
if !acquired {
return errors.New("lock busy: postgres advisory lock failed")
}
defer func() {
// 2. 락 해제
conn.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", lockID)
}()
// 비즈니스 로직 실행
return action()
}
Node.js (NestJS / Prisma) - queryRaw lock
Prisma ORM은 트랜잭션 API(`$transaction`)가 커넥션을 독점하여 수행하므로, 이를 활용해 PostgreSQL advisory lock 쿼리를 수행할 수 있습니다.
import { Injectable } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Injectable()
export class DbLockService {
constructor(private prisma: PrismaService) {}
async executeWithDbLock(lockId: number, action: () => Promise): Promise {
// 트랜잭션 세션 범위 안에서 락을 획득하고 커넥션 유지
return this.prisma.$transaction(async (tx) => {
// 1. pg_try_advisory_lock 획득 시도
const result = await tx.$queryRaw>`
SELECT pg_try_advisory_lock(${lockId})
`;
const acquired = result[0]?.pg_try_advisory_lock;
if (!acquired) {
throw new Error('Lock is busy: DB Advisory lock acquisition failed');
}
try {
return await action();
} finally {
// 2. 사용 완료 후 즉시 해제
await tx.$queryRaw`SELECT pg_advisory_unlock(${lockId})`;
}
});
}
}
Kotlin (Spring Boot) - JPA Native Query Lock
Spring Boot 환경에서는 `@Transactional`이 부여된 트랜잭션 영속성 컨텍스트 범주 내에서 Native Query로 advisory lock을 실행하여 구현합니다.
@Repository
interface LockRepository : JpaRepository {
@Query(value = "SELECT pg_try_advisory_lock(:lockId)", nativeQuery = true)
fun tryAdvisoryLock(lockId: Long): Boolean
@Query(value = "SELECT pg_advisory_unlock(:lockId)", nativeQuery = true)
fun unlockAdvisoryLock(lockId: Long): Boolean
}
@Service
class DbLockService(private val lockRepository: LockRepository) {
@Transactional
fun executeWithAdvisoryLock(lockId: Long, action: () -> T): T {
// 1. 락 획득 시도
val acquired = lockRepository.tryAdvisoryLock(lockId)
if (!acquired) {
throw RuntimeException("Could not acquire Database lock for ID: $lockId")
}
try {
return action() // 비즈니스 로직 수행
} finally {
// 2. 락 해제
lockRepository.unlockAdvisoryLock(lockId)
}
}
}
Lightweight Distributed Locks using PostgreSQL & MySQL in Go, NestJS, and Spring Boot
When introducing external cache layers like Redis is not viable, you can leverage native database-level Named/Advisory Locks in relational databases (RDB) to coordinate concurrent tasks.
1. What are Database Advisory Locks?
Unlike `SELECT ... FOR UPDATE` which locks physical table records, database advisory locks allocate an abstract locking slot bound to a connection session or transaction boundary based on a numerical or string key.
- PostgreSQL Advisory Locks: Locks slots bound to session or transaction namespaces using 64-bit integer keys (`pg_try_advisory_lock`).
- MySQL Named Locks: Locks arbitrary strings for customizable timeout durations (`GET_LOCK`).
2. Trade-offs
This strategy is easy to launch and avoids adding operational complexity. However, since locks consume database connections from your pool, long locking durations can exhaust your pool. You must keep lock windows short and use non-blocking `try`-locks.
3. Go, NestJS, and Spring Boot Implementations
Go (Golang) - PostgreSQL Advisory Lock
Use a dedicated `sql.Conn` to ensure the session lock persists throughout the transaction duration.
// Refer to the KO tab code for Go implementation details.
// conn.QueryRowContext with pg_try_advisory_lock avoids locking up database connections.
Node.js (NestJS / Prisma) - queryRaw lock
Execute Prisma transaction blocks using `$queryRaw` calls to PostgreSQL session managers.
// Refer to the KO tab code for NestJS implementation details.
// tx.$transaction keeps the database connection locked to the query execution sequence.
Kotlin (Spring Boot) - JPA Native Query Lock
Spring Boot executes Native SQL locking calls on transactional JPA contexts, releasing them during transaction completion.
// Refer to the KO tab code for Kotlin/Spring Boot implementation details.
// Native JpaRepository calls trigger PostgreSQL session functions.