PostgreSQL Advisory Lock이란?
Advisory Lock은 PostgreSQL이 제공하는 애플리케이션 레벨 잠금 메커니즘입니다. 테이블이나 행이 아닌 임의의 정수 키에 대해 잠금을 걸 수 있어, 분산 환경에서 크론 작업 중복 실행 방지, 리소스 접근 직렬화, 리더 선출 등에 활용됩니다. 행 잠금(SELECT FOR UPDATE)과 달리 실제 데이터에 영향을 주지 않고 순수하게 동기화 목적으로 사용합니다.
Advisory Lock vs 행 잠금 비교
| 비교 항목 | SELECT FOR UPDATE | Advisory Lock |
|---|---|---|
| 잠금 대상 | 테이블 행 | 임의 정수 키 |
| 데이터 영향 | 행 수정 차단 | 없음 (순수 잠금) |
| 트랜잭션 종속 | 항상 트랜잭션 범위 | 세션 또는 트랜잭션 선택 가능 |
| MVCC 오버헤드 | 있음 | 없음 |
| Deadlock 위험 | 있음 | 잠금 순서 미준수 시 있음 |
| 적합한 상황 | 데이터 동시 수정 제어 | 프로세스 동기화, 중복 실행 방지 |
4가지 Advisory Lock 함수
PostgreSQL은 용도에 따라 네 가지 Advisory Lock 함수를 제공합니다.
-- 1. 세션 레벨 배타적 잠금 (블로킹)
SELECT pg_advisory_lock(12345);
-- 잠금 획득까지 대기. 세션 종료 또는 명시적 해제까지 유지.
SELECT pg_advisory_unlock(12345);
-- 2. 세션 레벨 배타적 잠금 (논블로킹)
SELECT pg_try_advisory_lock(12345);
-- 즉시 true/false 반환. 대기하지 않음.
-- 3. 트랜잭션 레벨 배타적 잠금
SELECT pg_advisory_xact_lock(12345);
-- 트랜잭션 COMMIT/ROLLBACK 시 자동 해제. unlock 불필요.
-- 4. 공유 잠금 (여러 세션 동시 읽기 허용)
SELECT pg_advisory_lock_shared(12345);
-- 배타적 잠금과 충돌하지만, 공유 잠금끼리는 호환.
SELECT pg_advisory_unlock_shared(12345);
-- 2개 int4 키 사용 (네임스페이스 분리)
SELECT pg_advisory_lock(1, 100); -- (class_id, obj_id)
-- 서로 다른 도메인의 잠금을 구분할 때 유용
실전 1: 크론 작업 중복 실행 방지
다중 인스턴스 환경에서 동일한 크론 작업이 한 번만 실행되도록 보장하는 패턴입니다.
-- 크론 작업 래퍼 함수
CREATE OR REPLACE FUNCTION run_daily_settlement()
RETURNS void AS $$
DECLARE
lock_acquired boolean;
BEGIN
-- 논블로킹으로 잠금 시도
SELECT pg_try_advisory_lock(hashtext('daily_settlement'))
INTO lock_acquired;
IF NOT lock_acquired THEN
RAISE NOTICE '다른 인스턴스에서 이미 실행 중입니다.';
RETURN;
END IF;
BEGIN
-- 실제 정산 로직
PERFORM process_settlements();
PERFORM generate_reports();
PERFORM send_notifications();
EXCEPTION WHEN OTHERS THEN
-- 에러 발생해도 잠금 해제 보장
PERFORM pg_advisory_unlock(hashtext('daily_settlement'));
RAISE;
END;
-- 정상 완료 후 잠금 해제
PERFORM pg_advisory_unlock(hashtext('daily_settlement'));
END;
$$ LANGUAGE plpgsql;
hashtext() 함수로 문자열을 정수 키로 변환하면 의미 있는 이름으로 잠금을 관리할 수 있습니다.
실전 2: Spring에서 분산 락 구현
Spring Boot + JPA 환경에서 Advisory Lock을 활용한 분산 락 구현입니다.
@Repository
public class AdvisoryLockRepository {
@PersistenceContext
private EntityManager em;
/**
* 논블로킹 Advisory Lock 획득
* @param lockKey 잠금 키 (문자열 → hashtext 변환)
* @return 잠금 획득 성공 여부
*/
public boolean tryLock(String lockKey) {
BigInteger result = (BigInteger) em
.createNativeQuery(
"SELECT pg_try_advisory_lock(hashtext(:key))")
.setParameter("key", lockKey)
.getSingleResult();
return result.intValue() == 1;
}
/**
* Advisory Lock 해제
*/
public void unlock(String lockKey) {
em.createNativeQuery(
"SELECT pg_advisory_unlock(hashtext(:key))")
.setParameter("key", lockKey)
.getSingleResult();
}
/**
* 트랜잭션 범위 Advisory Lock (자동 해제)
*/
@Transactional
public boolean tryXactLock(String lockKey) {
BigInteger result = (BigInteger) em
.createNativeQuery(
"SELECT pg_try_advisory_xact_lock(hashtext(:key))")
.setParameter("key", lockKey)
.getSingleResult();
return result.intValue() == 1;
}
}
// 어노테이션 기반 분산 락
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AdvisoryLock {
String value(); // 잠금 키
boolean blocking() default false; // 블로킹 여부
}
@Aspect
@Component
public class AdvisoryLockAspect {
private final AdvisoryLockRepository lockRepo;
@Around("@annotation(advisoryLock)")
public Object around(ProceedingJoinPoint pjp,
AdvisoryLock advisoryLock) throws Throwable {
String key = advisoryLock.value();
if (!lockRepo.tryLock(key)) {
if (advisoryLock.blocking()) {
// 블로킹 모드: pg_advisory_lock 사용
lockRepo.lock(key);
} else {
throw new LockNotAcquiredException(
"잠금 획득 실패: " + key);
}
}
try {
return pjp.proceed();
} finally {
lockRepo.unlock(key);
}
}
}
// 사용 예시
@Service
public class SettlementService {
@AdvisoryLock("daily-settlement")
public void processDailySettlement() {
// 한 인스턴스만 실행
}
}
실전 3: NestJS에서 Advisory Lock
NestJS + TypeORM 환경에서의 구현입니다.
@Injectable()
export class AdvisoryLockService {
constructor(private readonly dataSource: DataSource) {}
async withLock<T>(
lockKey: string,
callback: () => Promise<T>,
options: { timeout?: number; blocking?: boolean } = {},
): Promise<T | null> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
try {
// 논블로킹 잠금 시도
const [{ acquired }] = await queryRunner.query(
`SELECT pg_try_advisory_lock(hashtext($1)) as acquired`,
[lockKey],
);
if (!acquired) {
if (!options.blocking) return null;
// 블로킹 + 타임아웃
await queryRunner.query(
`SET lock_timeout = '${options.timeout || 5000}ms'`,
);
await queryRunner.query(
`SELECT pg_advisory_lock(hashtext($1))`,
[lockKey],
);
}
try {
return await callback();
} finally {
await queryRunner.query(
`SELECT pg_advisory_unlock(hashtext($1))`,
[lockKey],
);
}
} finally {
await queryRunner.release();
}
}
}
// 사용 예시: 크론 작업
@Injectable()
export class ReportCronService {
constructor(
private readonly lockService: AdvisoryLockService,
private readonly reportService: ReportService,
) {}
@Cron('0 2 * * *')
async generateDailyReport() {
const result = await this.lockService.withLock(
'daily-report-generation',
() => this.reportService.generate(),
);
if (result === null) {
this.logger.log('다른 인스턴스에서 실행 중 - 스킵');
}
}
}
실전 4: 멱등성 보장 패턴
결제 처리 등 정확히 한 번 실행이 필요한 작업에서 Advisory Lock과 멱등성 키를 조합하는 패턴입니다.
-- 멱등성 테이블
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
result JSONB,
created_at TIMESTAMPTZ DEFAULT now(),
expires_at TIMESTAMPTZ DEFAULT now() + INTERVAL '24 hours'
);
-- 멱등성 + Advisory Lock 조합 함수
CREATE OR REPLACE FUNCTION process_payment_idempotent(
p_idempotency_key TEXT,
p_order_id BIGINT,
p_amount NUMERIC
) RETURNS JSONB AS $$
DECLARE
existing_result JSONB;
payment_result JSONB;
BEGIN
-- 1. Advisory Lock으로 동일 키 동시 실행 차단
PERFORM pg_advisory_xact_lock(hashtext(p_idempotency_key));
-- 2. 이미 처리된 요청인지 확인
SELECT result INTO existing_result
FROM idempotency_keys
WHERE key = p_idempotency_key;
IF existing_result IS NOT NULL THEN
RETURN existing_result; -- 이전 결과 반환
END IF;
-- 3. 결제 처리
INSERT INTO payments (order_id, amount, status)
VALUES (p_order_id, p_amount, 'completed')
RETURNING jsonb_build_object(
'payment_id', id,
'status', 'completed'
) INTO payment_result;
-- 4. 멱등성 키 저장
INSERT INTO idempotency_keys (key, result)
VALUES (p_idempotency_key, payment_result);
RETURN payment_result;
END;
$$ LANGUAGE plpgsql;
잠금 모니터링과 디버깅
운영 중인 Advisory Lock 상태를 확인하는 쿼리입니다.
-- 현재 활성 Advisory Lock 목록
SELECT
classid,
objid,
mode,
granted,
pid,
pg_blocking_pids(pid) AS blocked_by
FROM pg_locks
WHERE locktype = 'advisory'
ORDER BY pid;
-- 잠금 보유 세션의 상세 정보
SELECT
l.pid,
l.objid AS lock_key,
l.mode,
l.granted,
a.usename,
a.application_name,
a.client_addr,
a.state,
a.query,
a.query_start,
now() - a.query_start AS lock_duration
FROM pg_locks l
JOIN pg_stat_activity a ON l.pid = a.pid
WHERE l.locktype = 'advisory'
ORDER BY a.query_start;
-- 잠금 대기 중인 세션 확인
SELECT
blocked.pid AS blocked_pid,
blocked.query AS blocked_query,
blocking.pid AS blocking_pid,
blocking.query AS blocking_query,
now() - blocked.query_start AS wait_duration
FROM pg_stat_activity blocked
JOIN pg_locks bl ON bl.pid = blocked.pid AND NOT bl.granted
JOIN pg_locks kl ON kl.objid = bl.objid AND kl.granted
JOIN pg_stat_activity blocking ON kl.pid = blocking.pid
WHERE bl.locktype = 'advisory';
주의사항과 안티패턴
- 세션 잠금 해제 누락:
pg_advisory_lock은 세션이 끝나야 해제됩니다. 커넥션 풀 환경에서 unlock을 빠뜨리면 다른 요청이 같은 커넥션을 재사용할 때 잠금이 남아 있습니다. 반드시 try/finally로 해제하거나pg_advisory_xact_lock을 사용하세요. - 커넥션 풀 오염: HikariCP 등에서 세션 레벨 잠금을 사용하면, 커넥션 반환 시 잠금이 해제되지 않습니다. 트랜잭션 레벨 잠금(xact_lock)을 우선 사용하세요.
- hashtext 충돌: 서로 다른 문자열이 같은 해시값을 가질 수 있습니다. 2개 키 함수
pg_advisory_lock(class_id, obj_id)로 네임스페이스를 분리하면 충돌 위험이 줄어듭니다. - Deadlock: 여러 Advisory Lock을 순서 없이 획득하면 교착 상태가 발생합니다. 항상 일관된 순서로 잠금을 획득하세요.
Advisory Lock은 Redis 분산 락(Redlock)의 가벼운 대안입니다. 이미 PostgreSQL을 사용 중이라면 별도 인프라 없이 분산 동기화를 구현할 수 있습니다. Spring에서의 분산 스케줄링은 Spring ShedLock 분산 스케줄링 가이드를, PostgreSQL 성능 최적화는 PostgreSQL Partial Index 심화 글을 참고하세요.