남기다
홈 › 트랜잭션과 잠금 › 재고 차감 동시성: FOR UPDATE, 조건부 UPDATE, 버전 컬럼 비교

재고 차감 동시성: FOR UPDATE, 조건부 UPDATE, 버전 컬럼 비교

한서윤 · · 트랜잭션과 잠금

한정판 머그컵을 예약 판매로 내놓은 날이었다. 준비한 수량은 300개였고, 판매를 연 지 10분도 안 돼 품절 표시가 떴다. 그런데 주문 내역을 세어 보니 결제 완료가 312건이었다. 상품 테이블의 stock 컬럼에는 -12가 찍혀 있었다. 열두 명에게 보낼 사과 메일을 쓰고 나서 내가 짠 주문 코드를 다시 열었다.

코드는 단순했다. 재고를 읽어서 0보다 크면 주문을 만들고 재고를 하나 줄인다. 요청이 한 번에 하나씩 들어오면 틀릴 데가 없는 흐름이다. 하지만 수백 명이 같은 1초 안에 몰리면 여러 요청이 같은 숫자를 함께 읽고, 모두 검사를 통과한다.

그 뒤로 고치는 방법 세 가지를 같은 조건에서 돌려 봤다. 읽을 때 행을 잠그는 SELECT ... FOR UPDATE, 검사와 차감을 한 문장에 넣는 조건부 UPDATE, 버전 컬럼으로 충돌을 잡아내는 낙관적 잠금이다. 셋 다 음수 재고는 막는다. 대신 부하가 몰릴 때의 모습은 꽤 다르다.

재고가 음수가 되는 순서

DB는 PostgreSQL이었고 격리 수준은 기본값인 READ COMMITTED였다. psql 창 두 개만 있으면 그날 일을 그대로 다시 만들 수 있다.

CREATE TABLE products (
  id      bigint PRIMARY KEY,
  stock   integer NOT NULL,
  version integer NOT NULL DEFAULT 0
);
CREATE TABLE orders (
  id         bigserial PRIMARY KEY,
  product_id bigint NOT NULL REFERENCES products(id),
  qty        integer NOT NULL
);
INSERT INTO products (id, stock) VALUES (1, 1);
-- 창 1
BEGIN;
SELECT stock FROM products WHERE id = 1;            -- 1, 검사 통과
-- 창 2
BEGIN;
SELECT stock FROM products WHERE id = 1;            -- 1, 검사 통과
-- 창 1
UPDATE products SET stock = stock - 1 WHERE id = 1;
-- 창 2
UPDATE products SET stock = stock - 1 WHERE id = 1; -- 여기서 멈춘다
-- 창 1
COMMIT;                                             -- 창 2의 UPDATE가 풀린다
-- 창 2
COMMIT;
SELECT stock FROM products WHERE id = 1;            -- -1

창 2의 UPDATE는 창 1이 잡은 행 잠금 때문에 기다린다. 창 1이 커밋하면 PostgreSQL은 창 2의 연산을 갱신된 최신 행에 그대로 적용한다. 공식 문서에도 READ COMMITTED에서 뒤에 온 UPDATE는 먼저 온 쪽이 커밋한 새 버전의 행에 연산을 적용한다고 나온다. 0에서 다시 1을 빼니 -1이 된다.

애플리케이션이 계산한 값을 SET stock = 0처럼 넣었다면 음수는 안 나온다. 대신 두 건을 팔고 재고는 한 개만 줄어든다. 이쪽이 교과서에 나오는 lost update다. 겉모습만 다를 뿐 원인은 같다. 읽은 시점과 쓰는 시점 사이에 검사 결과가 낡아 버린다.

두 트랜잭션이 같은 재고 1을 읽고 차례로 1을 빼서 -1이 되는 경우와, 조건부 UPDATE에서 두 번째 트랜잭션이 0행을 갱신하고 끝나는 경우를 나란히 보여 주는 시간 흐름도
검사와 차감을 나누면 재고가 음수가 되고, 한 문장으로 합치면 두 번째 요청은 0행으로 끝난다

허탈했던 건 CHECK (stock >= 0) 제약 하나만 걸어 뒀어도 열두 건은 제약 위반 에러로 끝났을 거라는 점이다. 세 방법 중 무엇을 고르든 이 제약은 안전망으로 먼저 걸어 둔다.

ALTER TABLE products ADD CONSTRAINT stock_non_negative CHECK (stock >= 0);

첫 번째: SELECT … FOR UPDATE로 줄 세우기

읽는 순간부터 행을 잠그면 두 번째 요청은 첫 번째가 끝날 때까지 SELECT에서 기다린다. 기다림이 풀린 뒤에는 갱신된 값을 읽으니 검사가 낡지 않는다.

BEGIN;
SELECT stock FROM products WHERE id = 1 FOR UPDATE;
-- 애플리케이션에서 stock >= 주문 수량인지 확인
UPDATE products SET stock = stock - 1 WHERE id = 1;
INSERT INTO orders (product_id, qty) VALUES (1, 1);
COMMIT;

MySQL 문서는 이 잠금 읽기가 그 행에 UPDATE를 건 것과 같은 잠금을 잡고, 잠금은 커밋이나 롤백 때 풀린다고 설명한다. 문서에 실린 카운터 예제도 FOR UPDATE로 읽은 다음 값을 올리는 구조다.

장점은 생각하기 편하다는 것이다. 재고 말고도 회원 등급, 구매 한도, 쿠폰 조건처럼 여러 값을 읽고 판단해야 할 때 그 판단이 끝날 때까지 행이 묶여 있다. 문제는 잠금을 쥔 시간이 곧 대기 시간이라는 점이다. 처음 이 방식으로 바꿨을 때 결제 대행사 API 호출을 트랜잭션 안에 그대로 남겨 뒀다. 외부 응답이 2초쯤 늦어지자 그동안 같은 상품을 사려는 요청이 전부 줄을 섰고, 커넥션 풀이 바닥났다. 잠금 안에서는 DB 일만 하고 나와야 한다.

상품 여러 개를 한 주문에 담을 때는 잠그는 순서도 맞춰야 한다. 한쪽이 1번 다음 2번을, 다른 쪽이 2번 다음 1번을 잠그면 교착이 난다. 나는 상품 id를 정렬한 뒤 그 순서대로 잠근다. 교착이 났다면 InnoDB 교착 로그 읽기에 적어 둔 방법으로 어느 잠금끼리 엉켰는지부터 본다.

두 번째: 조건부 UPDATE 한 문장

검사를 애플리케이션에서 하지 말고 WHERE 절로 옮기면 읽기와 쓰기 사이의 틈 자체가 없어진다.

UPDATE products
   SET stock = stock - 1
 WHERE id = 1 AND stock >= 1
RETURNING stock;

갱신된 행이 1이면 성공이고 0이면 품절이다. 앞의 재현 순서에 이 문장을 넣어 보면, 창 2는 기다렸다가 갱신된 행을 보고 WHERE를 다시 따진다. 그때 재고는 0이니 조건에 맞지 않고 0행으로 끝난다. PostgreSQL 문서가 말하는 ‘WHERE 재평가’가 바로 이 동작이다.

MySQL에는 RETURNING이 없으니 영향받은 행 수로 판단한다. InnoDB는 REPEATABLE READ에서도 스냅숏을 SELECT에만 적용한다. UPDATE는 다른 트랜잭션이 막 커밋한 행에도 손을 댄다. 그래서 같은 문장이 MySQL 기본 설정에서도 그대로 통한다.

주의할 곳은 PostgreSQL의 REPEATABLE READ다. 이 수준에서는 기다린 끝에 재평가하지 않고 could not serialize access due to concurrent update 에러를 내며, 문서는 트랜잭션을 처음부터 다시 돌리라고 한다. 격리 수준을 올려 둔 서비스라면 조건부 UPDATE에도 재시도 코드가 필요하다.

이 방식의 한계는 판단이 WHERE 절 하나에 들어가야 한다는 것이다. 다른 테이블을 여러 개 봐야 하는 규칙이면 서브쿼리가 길어지고, 그쯤 되면 FOR UPDATE로 돌아가는 편이 읽기 쉽다.

세 번째: 버전 컬럼과 재시도

읽을 때는 잠그지 않는다. 쓸 때 “내가 읽은 뒤로 아무도 안 바꿨을 때만” 쓰고, 졌으면 다시 읽는다.

import psycopg

def decrement_optimistic(conn: psycopg.Connection, product_id: int, max_retry: int = 5) -> bool:
    for _ in range(max_retry):
        with conn.transaction():
            with conn.cursor() as cur:
                cur.execute(
                    "SELECT stock, version FROM products WHERE id = %s",
                    (product_id,),
                )
                stock, version = cur.fetchone()
                if stock < 1:
                    return False
                cur.execute(
                    "UPDATE products SET stock = stock - 1, version = version + 1 "
                    "WHERE id = %s AND version = %s",
                    (product_id, version),
                )
                if cur.rowcount == 1:
                    return True
    raise RuntimeError("재시도 한도 초과")

한 시간짜리 타임 세일에서 이 방식을 썼다가 로그가 재시도 경고로 뒤덮인 적이 있다. 이유는 셈해 보면 단순하다. 쉰 명이 한 행에 동시에 덤비면 한 바퀴에 한 명만 이기고 마흔아홉 명이 다시 읽는다. 다음 바퀴도 마찬가지다. 앞에서 읽은 자료도 뜨거운 행에서는 낙관적 잠금의 재시도 비용이 잠금 대기보다 커질 수 있다며, 재시도 횟수에 반드시 한도를 두라고 했다.

한도를 두면 또 다른 문제가 생긴다. 재고가 남았는데도 다섯 번 연속으로 진 요청이 “구매 실패”를 받는다. 그날 실제로 품절 전에 실패 화면을 봤다는 문의가 들어왔다. PostgreSQL의 READ COMMITTED에서는 버전 조건 UPDATE도 행 잠금을 기다린다는 점도 알아 둘 만하다. 잠금이 없어지는 게 아니라 읽고 판단하는 동안만 잠금이 없을 뿐이다.

같은 부하에서 돌려 본 결과

로컬 PostgreSQL에 재고 1,000개짜리 상품 하나를 두고, 연결 50개로 주문 2,000건을 동시에 보냈다. 한 번 돌린 결과라서 절대 수치보다 순서와 경향을 봐야 한다.

방식최종 재고재시도처리량코드
읽고 검사한 뒤 차감음수없음빠르지만 틀림짧음
SELECT … FOR UPDATE0없음중간보통
조건부 UPDATE0없음가장 높음가장 짧음
버전 컬럼0 또는 남음요청 수의 몇 배가장 낮음가장 김

조건부 UPDATE가 가장 빨랐던 건 왕복이 한 번이라서다. 잠금을 쥔 시간이 UPDATE 한 문장뿐이다. FOR UPDATE는 SELECT와 UPDATE 사이에 애플리케이션을 한 번 다녀오니 그만큼 줄이 길어졌다. 버전 컬럼은 진 요청이 다시 읽는 왕복이 쌓여 가장 느렸고, 재시도 한도에 걸린 요청이 생긴 판에서는 재고가 몇 개 남은 채로 끝났다. 한 행에 쓰기가 몰리는 재고 차감은 낙관적 잠금이 가장 약한 상황이다.

지금 내가 고르는 방식

재고 차감은 이제 조건부 UPDATE부터 쓴다. 판단이 “남은 수량이 주문 수량 이상인가” 하나로 끝나면 이보다 짧고 빠른 답을 아직 못 찾았다. 그 아래에는 늘 CHECK (stock >= 0)을 깔아 둔다. 코드가 틀려도 DB가 마지막에 막아 주도록.

구매 한도나 쿠폰 조건처럼 여러 값을 함께 봐야 하면 FOR UPDATE로 간다. 이때는 트랜잭션 안에서 외부 API를 부르지 않고, 상품 id 순서대로 잠근다. 없는 행을 잠글 때 MySQL에서 생기는 함정은 없는 행에 SELECT FOR UPDATE를 걸면 교착되는 이유에 따로 적었다.

버전 컬럼은 버리지 않았다. 다만 자리를 옮겼다. 관리자가 상품 설명을 몇 분씩 고치다 저장하는 화면처럼 충돌은 드물고 사람이 오래 붙잡고 있는 곳에서 쓴다. 거기서는 잠금을 몇 분씩 쥐고 있을 수 없고, 충돌이 나도 “다른 사람이 먼저 고쳤습니다”라고 알려 주면 된다. 그날 사과 메일 열두 통을 쓰고 나서 얻은 결론은 단순하다. 같은 행에 쓰기가 몰리는 곳에서는 DB가 줄을 세우게 두고, 애플리케이션이 다시 읽으며 경쟁하게 만들지 않는다.

참고한 자료