2025년 9월 07일·4분 읽기

PostgreSQL 어드바이저리 잠금으로 동시성 안전 워크플로 구현하기

승인, 결제, 스케줄러에서 이중 처리를 막는 PostgreSQL 어드바이저리 잠금 사용법과 실용적 패턴, SQL 예제, 간단한 점검 방법을 소개합니다.

PostgreSQL 어드바이저리 잠금으로 동시성 안전 워크플로 구현하기

진짜 문제: 두 프로세스가 같은 작업을 수행할 때

중복 처리(double-processing)는 같은 항목이 두 번 처리되는 상황으로, 서로 다른 액터들이 자신이 책임이 있다고 생각하기 때문에 발생합니다. 실제 애플리케이션에서는 고객이 두 번 과금되거나, 승인 처리가 중복되거나, "송장 준비 완료" 이메일이 두 번 발송되는 식으로 드러납니다. 테스트에서는 문제없어 보이지만 실제 트래픽에서 깨지는 경우가 많습니다.

보통 타이밍이 빡빡해져서 둘 이상이 동시에 행동할 수 있을 때 발생합니다:

  • 두 워커가 동시에 같은 작업을 가져간다. 네트워크 호출이 느려 재시도가 발생했는데 첫 시도가 아직 실행 중이다. 사용자가 UI가 잠깐 멈추자 승인 버튼을 더블클릭했다. 배포 후 스케줄러들이 겹치거나 시계가 어긋난다. 모바일 앱이 타임아웃 후 재전송하면 하나의 탭이 두 요청으로 바뀔 수 있다.

문제의 고통스러운 점은 각 액터가 개별적으로는 “합리적으로” 행동한다는 것입니다. 버그는 그들 사이의 간극입니다: 어느 쪽도 다른 쪽이 이미 같은 레코드를 처리 중이라는 것을 모릅니다.

목표는 단순합니다: 특정 항목(주문, 승인 요청, 송장 등)에 대해서는 한 번에 하나의 액터만 중요한 작업을 수행하도록 보장하는 것입니다. 다른 모든 사람은 잠깐 기다리거나 포기하고 다시 시도해야 합니다.

PostgreSQL 어드바이저리 잠금이 여기서 도움이 됩니다. 이미 신뢰하는 데이터베이스를 이용해 "나는 항목 X를 처리 중"이라고 가볍게 표시하는 방법을 제공합니다.

단, 기대치를 설정하세요. 잠금은 완전한 큐 시스템이 아닙니다. 작업을 스케줄하거나 순서를 보장하거나 메시지를 저장해 주지 않습니다. 대신 절대 두 번 실행되어서는 안 되는 워크플로 단계 주위의 안전 게이트 역할을 합니다.

어드바이저리 잠금이 무엇이고 아닌 것

PostgreSQL 어드바이저리 잠금은 한 번에 하나의 워커만 특정 작업을 수행하도록 하는 방법입니다. 잠금 키(예: "invoice 123")를 정하고 데이터베이스에 잠금을 요청한 뒤 작업을 수행하고 해제합니다.

"advisory"라는 단어가 중요합니다. Postgres는 키의 의미를 알지 못하고 자동으로 아무것도 보호하지 않습니다. 단지 이 키가 잠겨 있는지 여부만 추적합니다. 코드가 키 포맷에 합의하고 위험한 부분을 실행하기 전에 잠금을 획득해야 합니다.

행 락(row lock)과 비교하면 유용합니다. 행 락(SELECT ... FOR UPDATE 같은)은 실제 테이블 행을 보호합니다. 작업이 한 행과 깔끔하게 매핑될 때 훌륭합니다. 어드바이저리 잠금은 여러분이 선택한 키를 보호하므로 워크플로가 여러 테이블을 건드리거나 외부 서비스를 호출하거나 행이 아직 없을 때도 유용합니다.

어드바이저리 잠금은 다음과 같은 상황에서 유용합니다:

  • 엔티티당 한 번만 실행되어야 하는 작업(요청당 하나의 승인, 송장당 하나의 과금)
  • 별도의 락 서비스를 추가하지 않고 여러 앱 서버 간 조율이 필요할 때
  • 단일 행 업데이트보다 큰 워크플로 단계 주위의 보호가 필요할 때

다른 안전 도구를 대체하지는 않습니다. 멱등성을 보장하지도 않고 비즈니스 규칙을 강제하지도 않으며, 코드 경로가 잠금을 획득하는 것을 잊으면 중복을 막지 못합니다.

많은 경우 어드바이저리 잠금은 스키마 변경이나 추가 인프라 없이 사용할 수 있기 때문에 "가벼운"이라고 불립니다. 대부분의 경우 중요한 섹션 주변에 잠금 호출 하나를 추가하는 것만으로 중복 처리를 해결할 수 있습니다.

실제로 사용할 잠금 유형

사람들이 "PostgreSQL 어드바이저리 잠금"이라 할 때 보통 몇 가지 함수 집합을 의미합니다. 올바른 것을 선택하면 에러, 타임아웃, 재시도 시 동작이 달라집니다.

세션 vs 트랜잭션 잠금

세션 수준 잠금(pg_advisory_lock)은 데이터베이스 연결이 유지되는 한 지속됩니다. 장기 실행 워커에는 편리할 수 있지만, 앱이 풀에 남은 연결을 남기며 비정상 종료되면 잠금이 오래 남을 수 있습니다.

트랜잭션 수준 잠금(pg_advisory_xact_lock)은 현재 트랜잭션에 묶입니다. 커밋하거나 롤백하면 PostgreSQL이 자동으로 해제합니다. 대부분의 요청-응답 워크플로(승인, 결제 클릭, 관리자 작업)에서는 잊어버릴 염려가 적어 안전한 기본값입니다.

블로킹 vs 시도-잠금(try-lock)

블로킹 호출은 잠금이 가능해질 때까지 기다립니다. 단순하지만 다른 세션이 잠금을 잡고 있으면 웹 요청이 멈춘 것처럼 느껴질 수 있습니다.

Try-lock 호출은 즉시 반환합니다:

  • pg_try_advisory_lock (세션 수준)
  • pg_try_advisory_xact_lock (트랜잭션 수준)

UI 동작에는 try-lock이 더 낫습니다. 잠금이 잡혀 있으면 "이미 처리 중" 같은 명확한 메시지를 반환하고 사용자가 재시도하도록 유도할 수 있습니다.

공유 vs 배타

배타 잠금은 "한 번에 하나"입니다. 공유 잠금은 여러 보유자를 허용하지만 배타 잠금을 차단합니다. 대부분의 중복 처리 문제에는 배타 잠금을 씁니다. 공유 잠금은 많은 리더가 진행할 수 있지만 드문 라이터가 단독으로 실행되어야 할 때 유용합니다.

잠금이 해제되는 방법

해제는 타입에 따라 다릅니다:

  • 세션 잠금: 연결 끊김 시 해제되거나 pg_advisory_unlock로 명시 해제
  • 트랜잭션 잠금: 트랜잭션 종료 시 자동 해제

올바른 잠금 키 선택하기

어드바이저리 잠금은 모든 워커가 정확히 동일한 키를 락하려고 시도할 때만 작동합니다. 한 경로가 "invoice 123"을 잠그고 다른 경로가 "customer 45"를 잠그면 여전히 중복이 발생합니다.

보호하려는 "대상"을 먼저 명확히 하세요. 구체적으로 정하세요: 하나의 인보이스, 하나의 승인 요청, 하나의 스케줄 실행, 또는 하나의 고객 월간 청구주기 등. 이 선택이 허용되는 동시성 수준을 결정합니다.

리스크에 맞는 범위 선택하기

대부분 팀은 다음 중 하나를 선택합니다:

  • 레코드별: 승인이나 인보이스에 가장 안전함 (invoice_id나 request_id로 잠금)
  • 고객/계정별: 고객 단위로 직렬화해야 할 때 유용(청구, 크레딧 변경 등)
  • 워크플로 단계별: 서로 다른 단계는 병렬로 실행해도 되지만 각 단계는 한 번에 하나만 실행되어야 할 때

범위 선택은 데이터베이스 세부사항이 아니라 제품 결정으로 다루세요. "레코드별"은 더블 클릭으로 인한 중복 과금을 막고, "고객별"은 두 백그라운드 작업이 겹쳐 명세서를 중복 생성하는 것을 막습니다.

안정적인 키 전략 고르기

일반적으로 두 가지 옵션이 있습니다: 두 개의 32비트 정수(주로 네임스페이스 + id) 또는 하나의 64비트 정수(bigint), 때로는 문자열 ID를 해시해 만듭니다.

두 정수 키는 표준화하기 쉽습니다: 워크플로(예: 승인 vs 결제)마다 고정 네임스페이스 번호를 정하고 레코드 ID를 두 번째 값으로 사용하세요.

UUID 같은 식별자를 사용할 때는 해시가 편리하지만 작은 충돌 위험을 받아들여야 하며 일관성이 중요합니다.

어떤 방식을 선택하든 포맷을 문서화하고 중앙에 모아두세요. 두 군데에서 "거의 같은 키"를 쓰는 것이 중복을 다시 유발하는 흔한 방법입니다.

단계별: 한 번에 하나만 처리하는 안전한 패턴

중복 스케줄 실행 방지
작업 이름과 시간 창에 잠금을 걸어 다중 인스턴스 작업을 안전하게 실행하세요.
앱 생성

좋은 어드바이저리-락 워크플로는 단순합니다: 잠그고, 확인하고, 실행하고, 기록하고, 커밋합니다. 잠금 자체가 비즈니스 규칙은 아닙니다. 여러 워커가 같은 레코드를 동시에 건드릴 때 규칙을 신뢰할 수 있도록 하는 가드레일입니다.

실용적 패턴:

  1. 결과가 원자적이어야 할 때 트랜잭션을 연다.
  2. 특정 작업 단위에 대해 잠금을 획득한다. 자동 해제를 위해 트랜잭션 범위 잠금(pg_advisory_xact_lock)을 선호한다.
  3. DB에서 상태를 재확인한다. 자신이 첫 번째라고 가정하지 마라.
  4. 작업을 수행하고 DB에 영구적인 "완료" 표시(상태 업데이트, 원장 항목, 감사 행)를 기록한다.
  5. 커밋하고 잠금을 해제한다. 세션 수준 잠금을 사용했다면 커넥션을 풀에 반환하기 전에 명시적으로 언락한다.

예: 두 앱 서버가 같은 초에 "인보이스 #123 승인"을 받았다. 둘 다 시작하지만 오직 하나만 123에 대한 잠금을 얻는다. 승리한 쪽은 인보이스 #123이 여전히 pending인지 확인하고 approved로 표시한 뒤 감사/결제 기록을 쓰고 커밋한다. 두 번째 서버는 try-lock이면 빠르게 실패하거나, 기다려서 잠금을 얻은 뒤 상태가 이미 승인으로 바뀐 것을 보고 아무 것도 만들지 않고 종료한다. 이렇게 하면 UI 반응성을 유지하면서 중복 처리를 피할 수 있습니다.

디버깅을 위해 각 시도에 대해 충분히 로깅하세요: 요청 ID, 승인 ID와 계산된 락 키, 액터 ID, 결과(lock_busy, already_approved, approved_ok), 그리고 타이밍.

잠금 대기, 타임아웃, 재시도를 앱이 멈추지 않게 처리하기

잠금을 기다리는 건 무해해 보이지만 스피닝 버튼, 멈춘 워커, 해소되지 않는 백로그로 이어질 수 있습니다. 사람 대기 상황에서는 빠르게 실패(fail fast)하고, 기다려도 안전한 곳에서만 기다리게 하세요.

사용자 동작에는 try-lock과 명확한 응답

누군가 Approve나 Charge를 클릭할 때 몇 초 동안 요청을 블록하지 마세요. try-lock을 사용해 앱이 즉시 응답할 수 있도록 하세요.

실용적 접근: 잠금을 시도하고 실패하면 명확한 "바쁘니 다시 시도하세요" 응답을 반환하거나 항목 상태를 새로 고치게 하세요. 잠긴 섹션은 짧게 유지하세요: 상태 검증, 상태 적용, 커밋.

백그라운드 작업에는 블로킹이 괜찮지만 상한을 두세요

스케줄러와 워커에는 블로킹이 괜찮을 수 있지만, 하나의 느린 작업이 전체를 정체시키지 않도록 상한을 두어야 합니다.

타임아웃을 사용해 워커가 포기하고 다음으로 넘어가게 하세요:

SET lock_timeout = '2s';
SET statement_timeout = '30s';
SELECT pg_advisory_lock(123456);

또한 작업 자체의 최대 예상 실행 시간을 정하세요. 결제가 보통 10초 내에 끝난다면 2분을 사고로 취급하세요. 시작 시간, 작업 ID, 잠금 보유 시간을 추적하세요. 작업 러너가 취소를 지원하면 상한을 초과한 작업을 취소해 세션이 끝나고 잠금이 해제되게 하세요.

재시도는 의도적으로 계획하세요. 잠금을 얻지 못했을 때 다음에 무엇을 할지 결정하세요: 백오프와 약간의 랜덤을 둔 채 곧 재예약하거나, 이번 사이클에서는 베스트에포트 작업을 건너뛰거나, 반복 실패가 필요하면 항목을 경합(contended)으로 표시하세요.

잠긴 상태 또는 중복을 초래하는 흔한 실수

lock-busy를 깔끔하게 처리하기
요청이 멈추지 않도록 이미 처리 중이라는 빠른 메시지를 반환하세요.
UX 개선

가장 흔한 놀라움은 절대 해제되지 않는 세션 수준 잠금입니다. 커넥션 풀은 연결을 열어두기 때문에 세션 잠금을 잡고 언락을 잊으면 그 연결이 재활용될 때까지 잠금이 유지됩니다. 다른 워커는 기다리거나 실패하게 되며 원인을 찾기 힘들 수 있습니다.

또 다른 중복 원인은 잠금을 걸고 상태를 재확인하지 않는 것입니다. 잠금은 단지 한 번에 한 워커가 중요한 섹션을 실행하도록 할 뿐입니다. 레코드가 여전히 적격인지 보장하지 않으므로 항상 같은 트랜잭션 내에서 재확인하세요(예: pending인지 확인하고 나서 approved로 이동).

잠금 키도 팀을 헷갈리게 합니다. 한 서비스가 order_id로 잠그고 다른 서비스가 같은 리소스에 대해 다르게 계산된 키로 잠그면 두 개의 잠금이 존재하게 되어 둘 다 동시에 실행될 수 있습니다. 겉으로는 안전해 보이지만 실상은 아닙니다.

오래 잠금이 유지되는 원인은 대부분 스스로 만든 것입니다. 잠금 중에 느린 네트워크 호출(결제 제공자 호출, 이메일/SMS, 웹훅)을 하면 짧은 가드레일이 병목이 됩니다. 잠긴 섹션은 빠른 DB 작업에 집중하세요: 상태 검증, 새 상태 기록, 다음에 해야 할 일 기록. 그리고 트랜잭션 커밋 후에 부수 효과를 트리거하세요.

마지막으로, 어드바이저리 잠금이 멱등성이나 DB 제약을 대체하지는 않습니다. 잠금을 신호등으로 생각하고 증명 시스템으로 보지 마세요. 적합한 곳에는 고유 제약과 외부 호출을 위한 멱등성 키를 함께 사용하세요.

배포 전 빠른 체크리스트

원시 소스 코드 얻기
생성된 Go, Vue3, Kotlin 또는 SwiftUI 코드를 내보내 제어권을 유지하세요.
코드 내보내기

어드바이저리 잠금을 작은 계약처럼 다루세요: 팀의 모든 사람이 잠금이 무엇을 의미하는지, 무엇을 보호하는지, 잠금이 잡혀 있는 동안 무엇이 허용되는지 알아야 합니다.

대부분 문제를 잡아내는 짧은 체크리스트:

  • 리소스당 하나의 명확한 락 키를 정하고 문서화하여 everywhere 재사용
  • 되돌릴 수 없는 작업(결제, 이메일, 외부 API 호출) 전에 잠금 획득
  • 잠금 획득 후 작성 전에 상태 재확인
  • 잠긴 섹션을 짧고 측정 가능하게 유지(잠금 대기 및 실행 시간 로깅)
  • 각 경로에서 "lock busy"가 의미하는 바 결정(UI 메시지, 백오프로 재시도, 건너뛰기)

다음 단계: 패턴 적용하고 유지보수 가능하게 만들기

중복으로 가장 피해가 큰 한 곳을 골라 거기부터 시작하세요. 좋은 첫 대상은 비용이 들거나 상태를 영구 변경하는 작업, 예를 들어 "인보이스 청구"나 "요청 승인"입니다. 그 중요한 섹션만 어드바이저리 잠금으로 감싸고 동작을 신뢰한 뒤 주변 단계로 확장하세요.

초기에 기본적인 관찰성(observability)을 추가하세요. 워커가 잠금을 얻지 못할 때와 잠긴 작업이 얼마나 오래 걸리는지 로깅하세요. 잠금 대기가 치솟으면 보통 잠긴 섹션이 너무 크거나 느린 쿼리가 숨어 있다는 신호입니다.

잠금은 데이터 안전성 위에서 잘 작동합니다. 상태 필드(pending, processing, done, failed)를 명확히 유지하고 가능한 곳에 제약을 걸어 두세요. 최악의 순간에 재시도가 발생하면 고유 제약이나 멱등성 키가 두 번째 방어선이 될 수 있습니다.

AppMaster (appmaster.io)로 워크플로를 빌드 중이라면 동일한 패턴을 적용할 수 있습니다. 중요한 상태 변경을 하나의 트랜잭션 안에 두고, "마무리" 단계 전에 트랜잭션 수준 어드바이저리 잠금을 거는 작은 SQL 단계를 추가하세요.

어드바이저리 잠금은 우선순위, 지연 작업, 데드레터 처리 같은 큐 기능이 진짜로 필요해질 때까지 적합합니다. 높은 경합이 있고 더 똑똑한 병렬 처리가 필요하거나, 공유 Postgres가 없는 데이터베이스 간 조율이나 더 엄격한 격리 규칙이 필요할 때는 다른 솔루션을 고려하세요. 목표는 지루한 신뢰성입니다: 패턴을 작고 일관되게 유지하고 로그로 가시화하며 제약으로 뒷받침하세요.

자주 묻는 질문

When should I use PostgreSQL advisory locks instead of just trusting my app logic?

특정 단위 작업(요청 승인, 인보이스 결제, 스케줄러 윈도우 실행 등)에 대해 “한 번에 한 액터만” 허용해야 할 때 어드바이저리 잠금을 사용하세요. 특히 여러 앱 인스턴스가 같은 항목을 건드릴 수 있고 별도의 락 서비스 도입을 원치 않을 때 유용합니다.

How are advisory locks different from SELECT ... FOR UPDATE row locks?

행 락은 실제로 선택된 테이블 행들을 보호하며, 작업이 단일 행 업데이트와 깔끔하게 대응될 때 좋습니다. 어드바이저리 잠금은 여러분이 선택한 키를 보호하므로, 워크플로가 여러 테이블을 건드리거나 외부 서비스를 호출하거나 최종 행이 아직 없는 경우에도 효과적입니다.

Should I use transaction-level or session-level advisory locks?

요청/응답형 작업에서는 기본적으로 pg_advisory_xact_lock(트랜잭션 수준)을 권장합니다. 트랜잭션이 끝나면 잠금이 자동으로 해제되기 때문에 잊어버릴 위험이 적습니다. 트랜잭션을 넘어 잠금을 유지해야 하는 특별한 경우에만 pg_advisory_lock(세션 수준)을 사용하세요. 세션 잠금은 더 신중히 다뤄야 합니다.

Is it better to block waiting for a lock or use a try-lock?

UI로 유발되는 작업에는 pg_try_advisory_xact_lock 같은 try-lock을 선호하세요. 실패하면 즉시 ‘이미 처리 중’ 같은 명확한 응답을 줄 수 있습니다. 백그라운드 워커에는 블로킹 잠금이 괜찮지만, lock_timeout 같은 제한을 두어 하나의 느린 작업이 전체를 정체시키지 않도록 하세요.

What should I lock on: record ID, customer ID, or something else?

일반적으로 중복 실행을 피하려는 최소 단위, 보통은 ‘하나의 인보이스’나 ‘하나의 승인 요청’을 잠그세요. 고객 단위로 잠그면 처리량이 줄어들 수 있고, 너무 좁게 잠그면 여전히 중복이 발생할 수 있습니다. 보호할 범위는 제품 결정으로 다루세요.

How do I choose a lock key so all services use the exact same one?

모든 서비스가 동일한 키를 쓰도록 하나의 안정적 키 포맷을 정하고 곳곳에서 재사용하세요. 흔한 방식은 두 개의 정수: 워크플로별 고정 네임스페이스 값 + 엔티티 ID입니다. 이렇게 하면 다른 워크플로가 실수로 서로를 막지 않으면서 같은 리소스는 조율됩니다.

Do advisory locks replace idempotency checks or unique constraints?

아니요. 잠금은 동시 실행을 막을 뿐이며, 작업을 여러 번 안전하게 반복해도 괜찮다는 보증은 아닙니다. 항상 트랜잭션 안에서 상태를 재확인하고, 가능한 곳에서는 고유 제약(unique constraint)이나 멱등성(idempotency) 키를 함께 사용하세요.

What should I do inside the locked section to avoid slowing everything down?

잠금 안에서는 가능한 한 짧고 DB 중심으로 유지하세요: 잠금 획득, 적격성 재확인, 상태 기록, 커밋. 결제나 이메일, 웹훅 같은 느린 외부 호출은 커밋 후에 수행하거나 아웃박스 패턴을 사용해 트랜잭션 동안 잠금을 잡지 않도록 하세요.

Why do advisory locks sometimes seem “stuck” even after a request finishes?

가장 흔한 원인은 세션 수준 잠금이 풀리지 않고 커넥션 풀에 남아 있는 경우입니다. 트랜잭션 수준 잠금을 선호하고, 세션 잠금을 쓸 때는 pg_advisory_unlock이 연결 반환 전에 확실히 실행되도록 하세요.

What should I log or monitor to know advisory locks are working?

엔티티 ID와 계산된 락 키, 잠금 획득 여부, 획득에 걸린 시간, 트랜잭션 실행 시간 등을 로깅하세요. 또한 lock_busy, already_processed, processed_ok 같은 결과를 남기면 경합과 실제 중복 실패를 구분하는 데 도움이 됩니다.

쉬운 시작
멋진만들기

무료 요금제로 AppMaster를 사용해 보세요.
준비가 되면 적절한 구독을 선택할 수 있습니다.

시작하다