한 리소스에 대해 한 번만 값을 확정할 수 있는 기능이 있었다. 코드는 먼저 현재 값을 읽고, 비어 있으면 UPDATE하는 전형적인 check-then-act 구조였다.

Allocation allocation = repository.findById(resourceId);
if (allocation.getAmount() != null && allocation.getAmount() > 0) {
    throw new AlreadyAllocatedException();
}

repository.allocate(resourceId, amount);
UPDATE resource_allocations
SET amount = COALESCE(amount, 0) + :amount
WHERE resource_id = :resourceId;

단독 요청에서는 멀쩡했다. 거의 동시에 들어온 두 요청이 모두 SELECT를 통과하고 UPDATE까지 실행했을 때 처음 문제가 드러났다.

처음에는 MVCC 스냅샷을 의심했다

InnoDB의 REPEATABLE READ에서 일반 SELECT는 consistent nonlocking read로 동작한다. 그래서 첫 가설은 이랬다.

두 번째 요청이 오래된 스냅샷을 봐서 첫 번째 요청의 변경을 놓친 것 아닐까?

그럴듯했지만 타임라인을 그리자 바로 틀린 설명이라는 게 보였다.

트랜잭션 A                         트랜잭션 B
──────────                         ──────────
SELECT amount → 0
                                   SELECT amount → 0
UPDATE amount
                                   UPDATE amount
COMMIT
                                   COMMIT

B의 SELECT는 A의 UPDATE보다 먼저 실행됐다. 그 시점에는 스냅샷이든 최신 커밋 상태든 값이 실제로 0이었다. B가 못 본 변경은 아직 존재하지도 않았다. 격리 수준을 READ COMMITTED로 낮추거나 스냅샷을 없애도 이 순서에서는 결과가 같다.

가설이 틀렸다. MVCC는 이 경합의 원인이 아니었다.

진짜 원인은 잠그지 않은 체크였다

READ COMMITTEDREPEATABLE READ의 consistent read는 읽은 인덱스 레코드에 행 잠금을 걸지 않는다. A가 체크를 끝낸 순간 그 결과는 이미 과거 정보가 된다. A의 SELECT와 UPDATE 사이에 B가 똑같이 체크할 수 있다.

check ───────────── act

        다른 요청이 끼어들 수 있는 창

원인은 스냅샷이 오래된 것이 아니라, 확인과 변경이 서로 다른 SQL 문장이라는 데 있었다.

MVCC가 전혀 무관한 것은 아니다. 순서를 바꿔 A가 먼저 커밋했더라도, B의 트랜잭션이 앞선 SELECT에서 이미 스냅샷을 만들었다면 B는 과거 값을 읽을 수 있다.

트랜잭션 A                         트랜잭션 B
──────────                         ──────────
                                   SELECT 다른 행
                                   → 스냅샷 확정
UPDATE amount
COMMIT
                                   SELECT amount
                                   → 과거 스냅샷의 0

이 순서에서도 SELECT 결과만으로 변경 가능 여부를 최종 판단해서는 안 된다.

체크를 UPDATE의 WHERE로 옮겼다

변경 가능 조건을 UPDATE의 WHERE 절에 넣어 조건 확인과 변경을 한 문장으로 처리했다.

UPDATE resource_allocations
SET amount = COALESCE(amount, 0) + :amount
WHERE resource_id = :resourceId
  AND (amount IS NULL OR amount <= 0);

애플리케이션은 UPDATE 결과를 확인한다.

int updated = repository.allocateIfEmpty(resourceId, amount);
if (updated == 0) {
    throw new AlreadyAllocatedException();
}

단일 UPDATE 안에서는 조건 평가와 변경 사이에 다른 트랜잭션이 끼어들 수 없다. 먼저 실행한 요청이 행을 바꾸면 뒤의 UPDATE는 최신 상태에서 WHERE를 다시 평가하고 0을 반환한다.

요청 A                              요청 B
──────                              ──────
UPDATE ... WHERE amount <= 0
→ 조건 일치, updated = 1
→ X-lock
                                    같은 UPDATE 시도
                                    → 필요하면 잠금 대기
COMMIT
                                    WHERE 재평가
                                    → amount가 이미 양수
                                    → updated = 0

문제가 난 경로는 UPDATE 한 문장이 autocommit으로 끝나는 구조였다. 이 경우 A가 즉시 커밋되므로 B는 최신 값을 보고 곧바로 실패한다. 명시적 트랜잭션 안에서도 원리는 같다. 다만 A가 커밋할 때까지 B가 잠금을 기다릴 수 있다.

앞단 SELECT는 단독 요청에서 더 이른 오류를 주기 위해 남겼다. 동시 요청의 최종 판단은 조건부 UPDATE 결과로만 한다.

SELECT FOR UPDATE를 쓰지 않은 이유

처음에는 SELECT ... FOR UPDATE를 “잠금 경합이 커질 것 같다”는 이유로 기각했다. 이 설명도 틀렸다. FOR UPDATE와 UPDATE는 모두 검색한 인덱스 레코드에 배타 잠금을 건다. 조건부 UPDATE라고 잠금이 가벼워지는 것은 아니다.

선택지를 다시 비교하면 이렇다.

방법장점이 사례에서의 비용
SELECT ... FOR UPDATE읽은 뒤 복잡한 결정을 할 수 있음DB 왕복이 늘고 명시적 트랜잭션 경계가 필요
조건부 UPDATEcheck와 act가 한 문장행이 이미 존재해야 함
유니크 제약INSERT 경합도 DB가 보장문제의 단위에 맞는 스키마 제약 필요
분산 락DB 밖 자원까지 조율 가능인프라와 예외 처리 대상이 늘어남

조건부 UPDATE도 행에 배타 잠금을 건다. 이를 고른 이유는 기존 UPDATE에 조건을 추가하고 affected rows만 검사하면 됐기 때문이다.

이 패턴이 해결하지 못하는 것

행이 아직 없으면 쓸 수 없다

UPDATE는 존재하지 않는 행의 상태 전이를 지킬 수 없다. 두 요청이 각각 INSERT하려는 문제라면 유니크 제약이 필요하다. 조건부 UPDATE와 유니크 키는 대체재가 아니라 서로 다른 경합을 막는다.

잠금 범위는 인덱스에 달려 있다

resource_id가 유니크 인덱스라면 한 행을 정확히 찾는다. 적절한 인덱스가 없으면 InnoDB는 넓은 범위를 스캔하고 그만큼 많은 인덱스 레코드를 잠글 수 있다. WHERE에 조건 한 줄을 더했다고 잠금 범위가 자동으로 좁아지지 않는다.

기다리다 예외가 날 수도 있다

앞선 트랜잭션이 오래 X-lock을 유지하면 뒤 요청은 updated == 0까지 도달하지 못하고 lock wait timeout 예외를 받을 수 있다. 중복 비즈니스 오류와 DB 잠금 예외는 서로 다른 경로로 처리해야 한다.

SERIALIZABLE은 별도 검토가 필요하다

autocommit이 꺼진 SERIALIZABLE에서는 평범한 SELECT가 암묵적인 공유 잠금 읽기로 바뀔 수 있다. 두 트랜잭션이 각각 S-lock을 가진 뒤 X-lock으로 전환하려 하면 데드락이 날 수 있다. 이 글의 흐름은 일반적으로 많이 쓰는 READ COMMITTEDREPEATABLE READ를 전제로 한다.

원인 설명도 고쳤다

조건부 UPDATE 자체는 처음부터 잘 동작했다. 문제는 내가 그 이유를 MVCC로 설명하고 있었다는 데 있었다. 원인을 어렵게 짚으니 FOR UPDATE를 배제한 이유도 함께 틀어졌다.

동시성 문제가 생기면 먼저 확인 쿼리가 어떤 잠금을 잡는지, 확인과 변경이 한 SQL 문장인지부터 본다. 이 사례에서는 일반 SELECT가 잠그지 않는다는 점과 WHERE 절의 조건이 답이었다.