실무에서 “딱 한 번만 실행되어야 하는 작업"은 생각보다 많습니다. 같은 배치가 두 번 돌면 정산 금액이 어긋나고, 같은 사용자의 환불 처리기가 동시에 돌면 상태 전이가 꼬입니다. 메시지 소비기 두 대가 같은 집계 작업을 동시에 잡아도 결과는 비슷하게 망가집니다. 그런데 이 문제를 볼 때 많은 팀이 너무 무거운 해법부터 떠올립니다. Redis 분산 락, 별도 coordinator, workflow engine, leader election 같은 것들입니다.

문제는 대부분의 시스템이 그 정도 복잡도까지는 필요 없다는 점입니다. 이미 서비스의 진실 원본이 DB에 있고, 조율 대상도 그 DB 기준으로 판단되는 경우라면 Advisory Lock이 가장 싸고 빠른 1차 해법인 경우가 많습니다. 반대로 락 유지 시간이 길거나, 연결 풀과 세션 경계가 불명확하거나, 조율 범위가 여러 시스템으로 퍼지면 Advisory Lock은 금방 발목을 잡습니다.

이 글은 “Advisory Lock을 써도 되나?”, “어디까지 쓰고 언제 버려야 하나?“를 실무 기준으로 정리한 플레이북입니다. 특히 세션/트랜잭션 경계, 락 키 설계, 풀링 환경, 관측 지표, 대체 패턴 전환 기준에 집중합니다.

이 글에서 얻는 것

  • Advisory Lock이 해결하는 문제와 해결하지 못하는 문제를 구조적으로 구분할 수 있습니다.
  • 배치 중복 실행 방지, 단일 작업자 선출, 같은 aggregate 동시 수정 차단에 적용할 수 있는 실무 기준을 얻습니다.
  • 락 대기 시간, 점유 시간, 실패율, 풀링 주의점 같은 운영 기준을 숫자와 우선순위로 바로 가져갈 수 있습니다.

핵심 개념/이슈

1) Advisory Lock은 “데이터 락"이 아니라 “합의용 락"이다

행 락(row lock)이나 테이블 락은 특정 데이터 변경을 보호합니다. 반면 Advisory Lock은 DB가 제공하는 임의 키 기반의 협조적(cooperative) 락입니다. 즉 DB가 자동으로 무결성을 보장해 주는 게 아니라, 애플리케이션이 같은 규칙으로 같은 키를 잡는다는 전제 위에서만 의미가 있습니다.

예를 들어 아래 같은 상황에서 유용합니다.

  • daily-settlement:2026-04-10 배치가 한 번만 돌도록 보장
  • user:12345:refund 처리를 동시에 하나만 허용
  • 동일 상품 재고 보정 잡이 같은 sku에 대해 중복 실행되지 않도록 차단
  • 스케줄러 여러 대 중 한 대만 특정 maintenance job을 수행하도록 선출

핵심은 “같은 키에 대한 경쟁만 막고 싶다"는 점입니다. 이때 Advisory Lock은 멱등성 설계를 대체하지 않습니다. 락은 동시 실행을 줄이는 장치이고, 멱등성은 중복 효과가 나도 안전하게 만드는 장치입니다. 실무에서는 둘 중 하나만으로는 부족한 경우가 많습니다.

2) Advisory Lock이 잘 맞는 조건은 생각보다 좁고, 그래서 오히려 강력하다

Advisory Lock이 잘 맞는 대표 조건은 아래 다섯 가지입니다.

  1. 조율 기준의 진실 원본이 같은 DB 안에 있다.
  2. 락 유지 시간이 짧다. 보통 p95 기준 2초 이하, 길어도 10초 이하가 관리하기 쉽습니다.
  3. 락을 잡은 뒤 하는 일이 CPU/네트워크 장기 작업이 아니라 짧은 상태 전이나 enqueue 수준이다.
  4. 락 실패 시 즉시 재시도보다 “나중에 다시"가 허용된다.
  5. 연결 세션 경계를 팀이 정확히 이해하고 있다.

예를 들어 주문 상태를 PENDING -> CONFIRMED로 바꾸기 전에 주문 단위로 lock을 잠깐 잡는 건 괜찮습니다. 하지만 3분짜리 외부 API 호출 전체를 락 안에서 수행하는 건 거의 항상 나쁩니다. 그런 흐름은 Timeout/Retry/Backoff와 큐 기반 비동기화로 풀어야지, DB 락으로 버티면 연결 풀만 고갈됩니다.

실무 감으로 정리하면 이렇습니다.

  • 좋은 사용법: 짧은 임계 구역, DB 상태 전이, 중복 배치 시작 차단
  • 나쁜 사용법: 긴 외부 호출 보호, 다중 리전 조율, 사람 승인 대기 포함 작업, 30초 이상 장기 워크플로 제어

3) 가장 흔한 사고는 락 알고리즘보다 세션 경계에서 난다

Advisory Lock을 처음 도입한 팀이 가장 자주 겪는 문제는 “락이 안 풀렸다"가 아니라 다른 커넥션에서 락을 잡고 다른 커넥션에서 풀려고 하거나, 풀에 반납된 세션에 락이 남는 문제입니다.

PostgreSQL 기준으로는 크게 두 종류가 있습니다.

  • 세션 단위 락: 커넥션이 살아 있는 동안 유지
  • 트랜잭션 단위 락: 트랜잭션 종료 시 자동 해제

짧은 임계 구역이면 보통 pg_try_advisory_xact_lock 같은 트랜잭션 범위 락을 우선 고려하는 편이 안전합니다. 이유는 분명합니다.

  • commit/rollback과 함께 해제되어 누수 확률이 낮음
  • 커넥션 풀 환경에서 세션 잔존 락 문제를 줄임
  • 락 해제 코드를 별도로 빼먹을 가능성이 낮음

반대로 세션 락을 써야 하는 경우는 워커 선출처럼 “트랜잭션 하나보다 긴 수명"이 필요할 때인데, 이때는 다음을 꼭 지켜야 합니다.

  • 락 획득과 해제를 같은 커넥션 객체로 수행
  • 풀 반환 전에 해제 보장
  • 헬스체크/idle timeout이 세션을 끊을 때의 동작 확인
  • 락 보유 중 재배포/프로세스 종료 시 graceful release 또는 failover 확인

이 지점에서 사고가 잦다면, 락 자체보다 연결 풀 구조와 워커 생명주기를 먼저 다시 봐야 합니다. 특히 웹 요청 스레드에서 세션 락을 오래 잡는 구조는 거의 항상 문제를 만듭니다.

4) 키 설계가 나쁘면 락은 보호 장치가 아니라 병목이 된다

Advisory Lock은 보통 정수 키나 해시 키로 잡습니다. 여기서 중요한 건 “무엇을 직렬화할지"입니다. 키 범위를 너무 넓게 잡으면 불필요한 직렬화가 생기고, 너무 좁게 잡으면 보호가 안 됩니다.

예를 들어 환불 처리에서 아래 둘은 결과가 크게 다릅니다.

  • lock(refund)
  • lock(refund:user:12345)
  • lock(refund:order:98765)

첫 번째는 모든 환불을 한 줄로 세웁니다. 두 번째는 같은 사용자의 환불만 직렬화합니다. 세 번째는 같은 주문에 대해서만 직렬화합니다. 실무에서 우선순위는 보통 가장 작은 단위로 직렬화하되, 실제 불일치가 나는 경계보다 더 작게 쪼개지 않는 것입니다.

권장 기준 예시는 이렇습니다.

  • 주문 상태 전이: order_id
  • 재고 보정: sku_id 또는 warehouse_id + sku_id
  • 일 배치 실행 보장: job_name + business_date
  • 정산 close 작업: merchant_id + settlement_cycle

하나의 키가 15분 이동창에서 50회 이상 경쟁하고, 그 키의 lock_wait_p95가 500ms를 넘기기 시작하면 단순 락보다는 큐의 visibility timeout/ack-nack 제어나 파티션 기반 순차 처리로 옮기는 편이 낫습니다. 락은 경쟁을 숨기지 못합니다. 경쟁이 크면 병목도 그대로 드러납니다.

5) Advisory Lock은 리더 선출에도 쓰이지만, 장기 리더십 관리에는 약하다

“스케줄러 3대 중 1대만 잡을 돌리자” 같은 요구에는 Advisory Lock이 꽤 잘 맞습니다. 예를 들어 1분마다 도는 배치에서 job:cleanup-cache:2026-04-10T10:13 같은 키를 잡고 성공한 인스턴스만 실행하게 할 수 있습니다.

하지만 이 구조는 짧은 job에 적합하지, 장시간 리더십을 유지해야 하는 구조에는 한계가 있습니다.

  • 프로세스가 멈추지 않았는데 네트워크가 흔들리면 세션 상태 추론이 어려울 수 있음
  • 리더가 오래 잡고 있는 동안 관측이 약하면 “죽은 리더처럼 보이는 살아 있는 세션” 문제가 생김
  • 다중 리전/다중 DB 환경으로 가면 진실 원본이 하나가 아님

이 경우는 leader election을 억지로 DB 락으로 키우기보다, job enqueue만 DB 락으로 보호하고 실제 실행은 별도 워커 체계로 넘기거나, 애초에 workflow engine이나 coordinator로 가는 편이 낫습니다. Transactional Outbox + CDC를 써서 “중복 시작만 막고 실행은 비동기 분산"으로 풀면 구조가 훨씬 안정적인 경우가 많습니다.

실무 적용

1) 가장 안전한 시작점은 트랜잭션 범위 락 + 짧은 임계 구역이다

PostgreSQL 예시는 아래처럼 잡을 수 있습니다.

BEGIN;

SELECT pg_try_advisory_xact_lock(hashtext('refund:order:98765')) AS locked;

-- locked = false 면 즉시 종료 또는 재큐잉
-- locked = true 면 짧은 상태 전이 수행
UPDATE refund_request
SET status = 'PROCESSING', updated_at = now()
WHERE order_id = 98765
  AND status = 'PENDING';

COMMIT;

이 패턴의 장점은 세 가지입니다.

  1. 락 수명이 트랜잭션과 묶여 누수 위험이 낮습니다.
  2. 같은 DB 트랜잭션 안에서 상태 확인과 갱신을 함께 처리할 수 있습니다.
  3. 실패 시 rollback으로 정리돼 운영 복잡도가 낮습니다.

권장 순서는 보통 아래입니다.

  1. 락 시도
  2. 락 획득 성공 시 대상 상태 재검증
  3. 짧은 상태 전이 또는 작업 enqueue
  4. 즉시 commit
  5. 긴 작업은 트랜잭션 밖 워커에서 수행

락 안에서는 긴 일을 하지 말고, 긴 일을 시작할 자격만 결정하는 편이 좋습니다.

2) 의사결정 기준(숫자·조건·우선순위)

Advisory Lock 도입 판단 기준을 숫자로 잡으면 흔들림이 줄어듭니다.

도입을 우선 검토할 조건:

  • 같은 aggregate에 대한 동시 처리 충돌이 주 1회 이상 보인다.
  • 중복 실행으로 인한 복구 시간이 건당 10분 이상 든다.
  • 조율 대상의 진실 원본이 단일 DB에 있다.
  • 보호할 임계 구역의 p95 실행 시간이 2초 이하이다.

반대로 다른 패턴을 먼저 봐야 할 조건:

  • 락을 잡은 뒤 외부 API 호출이 p95 3초 이상이다.
  • 하나의 키 경쟁이 초당 20회 이상 발생한다.
  • 다중 리전 또는 다중 writer DB를 사용한다.
  • 락 없이도 admission control/concurrency limit이나 큐 파티셔닝으로 더 자연스럽게 풀 수 있다.

권장 운영 기준 예시:

  • lock_acquire_success_rate: 95% 이상
  • lock_wait_p95: 100ms 이하, 경고 300ms, 위험 500ms
  • lock_hold_p95: 2초 이하, 최대 10초 미만 권장
  • 락 실패 후 즉시 재시도 횟수: 0~1회
  • 동일 키 재시도 총 횟수: 3회 이내
  • 세션 락 누수 추정 건수: 0건 목표

우선순위는 데이터 무결성 > 중복 실행 차단 > 연결 풀 안정성 > 처리량 순으로 두는 편이 안전합니다. 처리량 때문에 락 범위를 넓히거나 장기 락을 허용하면, 결국 풀 고갈과 지연 확산으로 더 큰 비용을 냅니다.

3) 실패 시 동작을 미리 정해야 락이 운영 도구가 된다

락 획득 실패를 단순 오류로 보면 운영이 시끄러워집니다. 많은 경우 락 실패는 장애가 아니라 이미 누군가 처리 중이라는 정상 신호입니다. 그래서 상태 코드를 아래처럼 나누는 편이 좋습니다.

  • LOCK_ACQUIRED: 바로 처리
  • LOCK_BUSY_EXPECTED: 경쟁 중, 재큐잉 또는 skip
  • LOCK_BUSY_SUSPICIOUS: 장시간 점유, 경보 후보
  • LOCK_RELEASE_FAILED: 구현 결함 가능성, 조사 필요
  • LOCK_CONTEXT_MISMATCH: 다른 커넥션/트랜잭션 경계 오류

예를 들어 정기 배치라면 LOCK_BUSY_EXPECTED는 실패로 세지 말고 “이번 실행 skip"으로 처리하는 편이 맞습니다. 반대로 사용자 요청 플로라면 같은 키가 3회 연속 LOCK_BUSY이면 409/202 응답과 함께 비동기 재처리로 넘기는 편이 낫습니다.

4) 락만으로 끝내지 말고 멱등성과 상태 전이를 같이 설계해야 한다

실무에서는 락이 있어도 아래 상황이 생깁니다.

  • 락 획득 후 작업 직전 프로세스 종료
  • 작업 완료 후 응답 반환 전 네트워크 단절
  • 락 없이 실행된 구버전 워커와 신버전 워커가 동시에 존재

그래서 Advisory Lock은 단독 방어선이 아니라 다음과 같이 묶어야 합니다.

  • 락으로 동시 진입 축소
  • 상태 머신으로 허용 전이만 통과
  • 멱등 키로 중복 효과 차단
  • 재시도는 큐나 스케줄러에서 관리

즉 락은 입구 제어, 상태 전이는 정합성 규칙, 멱등성은 사후 안전장치입니다. 세 개를 같이 설계해야 운영이 편해집니다.

5) 3단계 전환 전략: Advisory Lock에서 더 큰 구조로 넘어가는 시점

처음부터 복잡한 coordinator를 넣기보다 아래 순서가 대체로 현실적입니다.

1단계, Advisory Lock
짧은 임계 구역과 단일 DB 기준 중복 진입 차단

2단계, Queue/Partition 직렬화
특정 키 경쟁이 높아지면 같은 키를 같은 파티션으로 보내 소비기 수준에서 순차 처리

3단계, Workflow/Orchestration
사람 승인, 보상 트랜잭션, 장기 대기, 다중 시스템 조율이 붙으면 workflow engine 검토

아래 조건 중 2개 이상이면 2단계 이상을 검토할 시점입니다.

  • lock_wait_p95가 500ms 이상으로 1주 지속
  • 하나의 작업이 외부 호출 포함 p95 10초 이상
  • 같은 키 충돌이 하루 1,000건 이상
  • 장애 복구 시 운영자가 수동으로 락 상태를 자주 확인해야 함
  • 조율 대상이 DB 밖 시스템 2개 이상으로 퍼짐

트레이드오프/주의점

  1. 구현은 가볍지만, 경계를 잘못 이해하면 위험하다
    락 함수 한 줄은 쉽지만, 커넥션 풀과 세션 수명까지 모르면 오히려 더 디버깅이 어려워집니다.

  2. 짧은 보호에는 강하지만 긴 워크플로에는 약하다
    Advisory Lock은 빠른 임계 구역 보호에는 좋지만, 장시간 비즈니스 프로세스를 대표하지는 못합니다.

  3. 경쟁을 해결하지 않고 노출한다
    같은 키 경쟁이 크면 병목이 그냥 드러납니다. 이건 장점이기도 하지만, 처리량 병목을 락 하나로 숨길 수는 없습니다.

  4. DB 의존성이 강하다
    조율 기준이 DB 밖으로 퍼지면 락의 설명력이 약해집니다. 여러 저장소를 동시에 조율해야 하면 다른 계층을 고려해야 합니다.

  5. 멱등성 없는 락은 과신하기 쉽다
    락을 잡았으니 안전하다고 느끼기 쉽지만, 프로세스 종료나 네트워크 실패는 여전히 남습니다. 락은 중복을 줄여 줄 뿐, 완전히 없애 주지 않습니다.

체크리스트 또는 연습

체크리스트

  • 락 키가 실제 충돌 경계를 반영한다.
  • 세션 락보다 트랜잭션 락을 먼저 검토했다.
  • 락 안에서는 외부 API 호출이나 긴 네트워크 작업을 하지 않는다.
  • lock_wait_p95, lock_hold_p95, lock_busy_rate를 대시보드로 본다.
  • 락 실패를 오류와 정상 skip로 구분한다.
  • 상태 전이 검증과 멱등 키가 락과 함께 설계돼 있다.
  • 특정 키 경쟁이 높을 때 큐/파티션으로 넘길 기준이 문서화돼 있다.

연습 과제

  1. 현재 시스템에서 “동시에 두 번 돌면 곤란한 작업” 3개를 적고, 락 키를 각각 어떤 단위로 잡을지 설계해 보세요.
  2. 같은 작업에 대해 세션 락트랜잭션 락 중 무엇이 맞는지, 연결 풀 동작까지 포함해 설명해 보세요.
  3. 최근 1주 장애나 재처리 사례를 기준으로, Advisory Lock만으로 충분한지 아니면 큐/워크플로로 가야 하는지 판단표를 만들어 보세요.

관련 글