PostgreSQL Advisory Lock 심화

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 심화 글을 참고하세요.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux