진행 중인 교환 주문을 FOR UPDATE로 조회한다고 하자. 첫 교환이라 결과가 없으면 무엇이 잠길까? 두 요청이 모두 빈 결과를 읽고 주문을 만들 수 있다. SQL에 락 문법이 있다는 사실과 업무가 직렬화된다는 보장은 다르다.
이 글은 주문이 아직 없을 때 무엇을 잠그고, 그 잠금을 언제까지 유지할 것인가를 다룬다. Kotlin/JVM과 Spring, PostgreSQL 17, READ COMMITTED에서 지갑 행 락과 회원별 transaction advisory lock을 비교한다. 외부 발급 결과의 복구는 이번 범위에서 제외한다.
행 락은 조회된 행을 보호한다
회원마다 지갑 행이 존재한다면 그 행을 기준으로 잔액 검증과 주문 생성을 묶을 수 있다.
BEGIN;
SELECT balance FROM point_wallet
WHERE user_id = 101 FOR UPDATE;
-- 잠긴 잔액으로 검증하고 같은 트랜잭션에서 차감·주문 생성
UPDATE point_wallet SET balance = balance - 7000
WHERE user_id = 101;
COMMIT;
다른 트랜잭션의 일반 조회는 가능하지만 같은 행의 수정이나 충돌하는 락 요청은 기다린다. 락은 조회 메서드가 반환될 때가 아니라 트랜잭션 종료 시 해제된다. PostgreSQL의 행 락 설명을 기준으로 읽어야 한다.
반면 status = 'PROCESSING'인 주문 조회가 비어 있으면 잠근 행도 없다. READ COMMITTED의 FOR UPDATE가 미래에 조건을 만족할 행의 삽입까지 예약하지는 않는다. 항상 존재하는 회원·지갑 행을 잠그거나, 회원별 진행 중 주문 하나라는 정책을 고유 인덱스로 표현하거나, 논리적인 업무 키를 잠그는 선택이 필요하다.
업무 키를 잠그면 참여 규칙이 추가된다
Advisory lock은 행 대신 애플리케이션이 정한 식별자를 잠근다. (교환 도메인, 회원 번호)를 키로 쓰면 주문이 없어도 같은 회원의 교환 준비를 직렬화할 수 있다. 대신 관리자·배치·복구 코드도 같은 키를 획득해야 한다. DB는 주문 생성에 이 키가 필요하다는 업무 규칙을 알지 못한다.
수명도 구분한다. session advisory lock은 명시적 해제 또는 세션 종료까지 남으므로 커넥션 풀 반환과 함께 사라진다고 가정하면 안 된다. 짧은 DB 업무에는 트랜잭션 종료 시 풀리는 pg_advisory_xact_lock이 다루기 쉽다. Advisory Locks의 수명과 참여 조건을 확인한다.
잠금 SQL만 따로 커밋하면 보호할 업무가 남는다
다음 코드에서 첫 메서드에만 트랜잭션이 있다면 락을 얻은 뒤 바로 해제될 수 있다.
locks.lockUser(userId) // 이 메서드의 트랜잭션은 여기서 끝남
orders.requireNoProcessingOrder(userId)
wallets.debit(userId, price)
orders.create(userId, productId)
업무 검사와 저장이 시작될 때는 락이 없다. autocommit으로 transaction advisory lock 한 문장만 실행해도 같은 문제가 생긴다. 따라서 호출자가 락 획득, 진행 중 주문 검사, 차감, 예약 생성을 같은 물리 트랜잭션에 넣는다.
잠금 수명이 이어져야 할 구간을 아래처럼 놓을 수 있다. transaction advisory lock을 얻은 뒤 업무 검사와 예약 저장이 같은 경계 안에 있는지 본다.
그림 1 예약을 보호하는 잠금 수명 — transaction advisory lock은 업무 검사와 예약 저장이 끝나는 트랜잭션 종료까지 유지한다.
try lock을 얻지 못한 경로는 이 업무 구간에 진입하지 않는다. 관리자·배치·복구 코드도 같은 키 규칙에 참여해야 한다. session advisory lock은 종료 수명이 다르므로 이 흐름과 구분한다.
@Service
class ExchangeReservationService(
private val jdbc: JdbcTemplate,
private val orders: OrderRepository,
private val wallets: WalletRepository,
) {
@Transactional(transactionManager = "jdbcTransactionManager")
fun reserve(userId: Int, productId: Long, price: Long): Reservation {
val locked = jdbc.queryForObject(
"select pg_try_advisory_xact_lock(?, ?)",
Boolean::class.javaObjectType, 42, userId,
) == true
if (!locked) {
throw ExchangeBusyException()
}
orders.requireNoProcessingOrder(userId)
wallets.debitIfEnoughOrThrow(userId, price)
return orders.createReservation(userId, productId, price)
}
}
Kotlin 클래스와 메서드는 기본적으로 final이다. 이 @Service 예제는 kotlin-spring의 자동 open 처리를 사용한다. 플러그인을 쓰지 않는 클래스 기반 프록시에서는 클래스와 대상 메서드를 open으로 둬야 한다. OrderRepository·WalletRepository는 업무 메서드의 계약을 나타내는 예제 타입이다. Boolean::class.javaObjectType은 JDBC가 반환할 래퍼 타입을 지정하고 == true는 null도 잠금 실패로 처리한다.
도메인 번호 42와 회원 번호는 예제 식별자다. jdbc, orders, wallets는 같은 DataSource와 트랜잭션 자원에 참여하고, 이 메서드는 다른 빈에서 프록시를 거쳐 호출한다고 가정한다. 기본 Spring 프록시 방식의 내부 호출은 어노테이션을 새로 적용하지 않는다. 트랜잭션 프록시의 내부 호출 제약이 코드 적용 조건이다.
try 계열은 락을 얻지 못하면 기다리지 않고 false를 반환한다. 이때 검사를 계속하면 잠금의 의미가 없어진다. 처리 중 응답을 줄지 기존 주문 상태를 반환할지는 API 계약으로 정한다. 대기형과 try 계열의 차이는 PostgreSQL 잠금 함수에 정의돼 있다.
REQUIRES_NEW는 같은 임계 구역을 만들지 않는다
락 획득을 REQUIRES_NEW로 분리한 메서드가 끝난 뒤 업무를 실행하면 락도 이미 해제된다. 반대로 바깥 트랜잭션이 잠근 행을 안쪽 새 트랜잭션에서 바꾸면 안쪽은 락을 기다리고 바깥쪽은 안쪽 호출을 기다릴 수 있다.
Spring 전파 속성 문서는 REQUIRES_NEW가 독립 물리 트랜잭션과 추가 연결을 사용한다고 설명한다. 어노테이션 이름보다 실제 연결과 시작·종료 시점을 확인해야 한다.
외부 발급을 락 안에 넣으면 상대 시스템의 지연만큼 연결과 락을 유지한다. 짧은 예약 트랜잭션으로 차감과 진행 중 상태를 확정한다면 외부 호출 중에는 DB 락을 유지하지 않고 주문 상태로 다음 요청을 판단할 수 있다. 외부 호출까지 직렬화해야 하는 정책이라면 긴 트랜잭션 비용과 별도 작업 상태·리스 관리 비용을 비교한다.
지킬 규칙으로 잠금 대상을 고른다
| 보호할 조건 | 선택할 수 있는 수단 | 추가로 확인할 것 |
|---|---|---|
| 지갑 한 행의 잔액 | 조건부 갱신·행 락 | 갱신 조건과 같은 트랜잭션의 저장 |
| 행이 없는 회원별 작업 | Advisory lock·기준 행 락 | 모든 진입 경로의 참여와 키 충돌 |
| 진행 중 주문의 고유성 | 정책을 표현한 DB 제약 | 실제 주문 상태와 제약 조건 |
회원 전체를 잠그면 독립 상품 교환도 기다린다. 상품별 키로 좁히면 같은 지갑을 다른 키로 접근할 수 있다. 여러 키가 필요하면 획득 순서도 통일한다.
검증은 독립 DB 연결 두 개로 같은 회원과 다른 회원을 나눠 진행한다. 빈 주문, 롤백 후 재요청, 내부 호출, 배치 경로도 확인한다. 락 로그만 보는 대신 중복 주문 여부, 락 대기 시간, 열린 트랜잭션 시간, 풀 사용량을 함께 본다. 잠금 획득부터 보호할 저장까지 수명이 이어져야 임계 구역이 된다.
