동시 주문 데드락 — 원인 규명과 처방

거래 · 2026년 10월 19일 기준

  • 실측
  • INNODB STATUS
  • 잠금 순서
  • ABBA
  • 채번
목차 5
  1. 먼저, S락과 X락
  2. 원인을 찾은 과정
  3. 고친 교착 네 가지
  4. 고친 뒤에 드러난 교착
  5. 다른 경로

먼저, S락과 X락

아래 사례는 모두 이 표 하나로 설명된다.

상대가 S 보유 상대가 X 보유
S 요청 바로 획득 대기
X 요청 대기 대기

S끼리만 같이 잡을 수 있고, 나머지 세 경우는 모두 기다린다.

  • S(공유 락)는 “읽는 중이니 바꾸지 마”라는 뜻이다. 여러 트랜잭션이 동시에 쥘 수 있다.
  • X(배타 락)는 “쓰는 중”이라는 뜻이다. 한 트랜잭션만 쥔다.

교착이 생기는 방식

두 트랜잭션이 같은 두 자원을 반대 순서로 잡으면 서로를 기다린다.

TX A : 자원 1 획득  ->  자원 2 대기
TX B : 자원 2 획득  ->  자원 1 대기
       서로가 상대를 기다린다 -> 교착

MySQL은 이걸 감지하면 한쪽을 강제로 롤백한다(ERROR 1213). 보통 언두가 적은 쪽이 롤백되는데, 대개는 가벼운 사용자 요청이다.

S락은 대부분 내가 건 적 없는 락이다

락 만드는 문장
X SELECT ... FOR UPDATE, UPDATE, DELETE, INSERT. 모두 코드에 드러난다
S SELECT ... FOR SHARE(이 코드베이스에는 거의 없다), FK 부모 검사, 유니크 인덱스 중복 검사. 뒤의 두 개는 코드에 드러나지 않는다

FOR UPDATE는 잡는 순서를 내가 정할 수 있다. FK 부모 락은 그렇지 않다. 제약을 정의한 순서대로 잡히기 때문에 코드에서 순서를 바꿀 수 없다. 이 코드베이스의 S락은 거의 다 이쪽이라, 교착을 완전히 없애는 건 설계 목표가 될 수 없다.

INNODB STATUS 표기 읽는 법

표기 뜻
lock mode S 공유 락. 기본은 next-key라 레코드와 그 앞 gap까지 잠근다
lock_mode X locks rec but not gap 배타 락. 레코드만 잠그고 gap은 뺀다
lock_mode X locks gap before rec insert intention 삽입 의도. 이 레코드 앞 틈에 끼워 넣겠다는 뜻이다

특히 next-key S와 삽입 의도가 만나면 문제가 된다. 한쪽의 next-key S가 다른 쪽이 끼워 넣으려는 바로 그 gap을 덮고 있으면 두 트랜잭션이 서로를 기다린다. 다음 절의 교착 기록이 정확히 이 조합이다.


원인을 찾은 과정

동시에 120건을 주문하는 부하 테스트에서 일부가 Deadlock found when trying to get lock으로 실패했다. 단위 테스트로는 잡히지 않는 종류의 문제다.

가설을 두 개 세웠고, 확인해 보고는 둘 다 버렸다.

가설 그때 확인한 결과
“커서에 ORDER BY가 없다” 있었다. 장바구니 순번으로 정렬한다
“사용자마다 상품 잠금 순서가 다르다” 115명 모두 같았다

그래서 SHOW ENGINE INNODB STATUS의 LATEST DETECTED DEADLOCK 기록을 직접 읽어 원인을 확정했다.

*** (1) TRANSACTION 82781 — INSERT INTO 주문 (...) VALUES ('TEMP', ...)
    HOLDS   주문번호 유니크 인덱스  heap#2  lock mode S
            0: len 4; hex 54454d50; asc TEMP;;  <- 'TEMP'
    WAITING 주문번호 유니크 인덱스  heap#5  lock mode S waiting

*** (2) TRANSACTION 82777 — UPDATE 주문 SET 주문번호 = 새 번호
    HOLDS   heap#5  lock_mode X locks rec but not gap
    WAITING heap#2  lock_mode X locks gap before rec insert intention waiting

*** WE ROLL BACK TRANSACTION (1)

고친 교착 네 가지

1. 채번 ↔ 상품

주문할 때마다 시스템은 주문번호(SO-20260915-00001)를 만든다. 오늘 몇 번째 주문인지 세는 카운터를 1씩 올리는 방식이고, 그 카운터는 테이블의 한 행이다.

무슨 일이 있었나

A : 상품 20개를 잠근 채   ->  카운터를 기다린다
B : 카운터를 잠근 채      ->  상품을 기다린다

    <- 서로 못 간다. MySQL 이 A 를 강제 롤백했다

교착 기록에 남은 두 트랜잭션은 둘 다 주문 생성 프로시저 안에 있었다. 같은 시각 앱 로그에도 롤백된 쪽이 주문 생성 단계의 Deadlock으로 남아 있어서, 발주를 만드는 단계에서 난 일이 아니라는 것도 확인할 수 있었다.

*** (1) INSERT IGNORE INTO 채번 ...  <- A : 채번 시작
    HOLDS   상품 PK  lock mode S locks rec but not gap  (상품 20행)
    WAITING 채번 PK  lock_mode X insert intention waiting

*** (2) SELECT ... FROM 상품 ... FOR UPDATE  <- B : 카트 루프
    HOLDS   채번 PK  lock_mode X  (채번 끝남)
    WAITING 상품 PK  lock_mode X locks rec but not gap waiting

*** WE ROLL BACK TRANSACTION (1)

이상한 건 A였다. 채번을 시작하기도 전인데 이미 상품 20개에 S락을 쥐고 있었다. 주문 생성은 채번을 먼저 하고 상품 FOR UPDATE는 그 뒤에 한다. 그렇다면 이 S락은 어디서 왔을까.

원인은 채번 앞의 유효성 검사

주문 생성 프로시저는 주문을 만들기 전에 판매중지·숨김 상품이 섞였는지부터 센다.

SET 개수 = (
    SELECT COUNT(*)
      FROM 장바구니 JOIN 상품 ON 상품번호
      ...
);

평범한 조회처럼 보이지만 이 문장은 락을 건다. InnoDB는 RR 격리수준에서 순수한 SELECT가 아닌 문장 안의 읽기를 S락으로 처리하는데, SET 변수 = (서브쿼리)가 여기에 해당한다. 같은 조회를 SELECT ... INTO로 쓰면 락 없이 스냅샷을 읽는다.

쓰는 방식만 바꿔서 직접 재 봤다.

쓰는 방식 상품에 남는 락
SELECT COUNT(*) INTO 개수 ... 없음
SET 개수 = (SELECT COUNT(*) ...) S락 20건. 교착 기록과 종류(S,REC_NOT_GAP)도 개수도 같다

그래서 주문 한 건 안에서 락을 잡는 순서가 이렇게 된다.

상품 S (검증)  ->  카운터 X (채번)  ->  상품 X (카트 루프 FOR UPDATE)

같은 상품을 S로 잡고, 카운터를 거쳐, 다시 X로 올리는 구조다. S끼리는 같이 잡을 수 있어서 두 주문이 검증을 나란히 통과하고, 그다음에 엇갈린다.

A : 검증 -> 상품 S 획득
B : 검증 -> 상품 S 획득             (S 끼리 호환 -> 둘 다 통과)
B : 채번 -> 카운터 X 획득
B : 카트 루프 -> 상품 X 요청         -> A 의 S 때문에 대기
A : 채번 -> 카운터 요청              -> B 가 쥔 카운터 때문에 대기

    <- 교착. 채번 쪽(A)이 롤백된다

처음에는 A가 왜 카운터를 건너뛰고 상품부터 잠갔는지가 의문이었다. 건너뛴 게 아니었다. 카운터로 가는 길에 검증 문장이 상품을 S로 먼저 잡아 둔 것이다. 기록에 상품 락이 읽기용 S로 찍혀 있던 이유도 이것이다.

해결: 검증을 락 없는 읽기로

- SET 개수 = (
-     SELECT COUNT(*)
-       FROM 장바구니 JOIN 상품 ... );
+ SELECT COUNT(*) INTO 개수
+   FROM 장바구니 JOIN 상품 ... ;

검증이 상품에 S를 남기지 않으니 잠그는 순서가 카운터 → 상품 X 하나로 줄어든다. 모든 주문이 같은 순서로 잡으니 늦게 온 쪽은 줄을 서서 기다릴 뿐 교착하지 않는다.

표준주문 API는 예외다. 채번 전에 같은 트랜잭션 안에서 장바구니를 담으면서 상품에 S를 잡기 때문에, 이 경로에는 같은 순서 문제가 남아 있다.

수정 전 수정 후
잠금 순서 상품 S → 카운터 → 상품 X 카운터 → 상품 X
두 세션 재현 (위 타임라인) 교착 (ERROR 1213) 교착 없음. B가 상품 X를 바로 획득

검사 기능은 그대로 남는다.

  • COUNT(*)는 항상 한 행을 돌려주므로 SELECT ... INTO로 바꿔도 개수 값이 같다.
  • 판매중지·숨김 같은 상품 상태는 바로 뒤 카트 루프의 FOR UPDATE 조회가 최신 값으로 다시 확인한다. 안전망은 그대로다.
  • 장바구니 조건 플래그는 같은 트랜잭션이 직전에 X로 잡은 행이라 스냅샷으로 읽어도 최신이다.
  • 바뀌는 건 회사별 전시 여부 판정 하나다. 판정 기준이 요청을 시작한 시점이 되는데, 몇 ms 차이라 안전망 용도로는 괜찮다고 판단했다.

다른 처방으로는 풀리지 않았다. 채번 루틴(3번)을 고친 버전으로 재현해도 똑같이 교착했고, 기다리는 지점만 인덱스 끝에서 카운터 행으로 옮겨 갔다. 상품 행 선점(5번)도 채번 뒤, 카트 루프 바로 앞에서 잡는 방식이라 그보다 먼저 잡히는 검증의 S락에는 소용이 없다.

같은 패턴이 하나 더 있었다. 카트 루프 안의 상품 존재 확인(SET 존재 = (SELECT COUNT(*) FROM 상품 ...))도 상품마다 FOR UPDATE 바로 앞에서 S락을 잡는다. 이것도 COUNT(*)라 같은 방식으로 바꿨다.

다만 한꺼번에 바꾸면 안 된다. 같은 프로시저의 회사명 조회(SET 회사명 = (SELECT 회사명 ...))처럼 COUNT(*)가 아닌 조회를 SELECT ... INTO로 바꾸면, 결과가 0행일 때 NOT FOUND 핸들러가 돌면서 카트 루프 종료 플래그가 켜진다. SET은 0행이면 NULL이 들어갈 뿐이다.

근거는 교착 기록 원본과 같은 시각의 앱 로그다. 락 종류는 performance_schema.data_locks로 직접 쟀고, 교착과 해소 모두 두 세션으로 재현했다.

상태: 수정·검증 완료. 검증 문장 두 줄(상품 상태 확인 · 상품 존재 확인)을 SELECT ... INTO로 바꿨다. 같은 상품 20개를 담은 25명이 동시에 주문하는 로컬 부하 테스트를 3번 돌려 교착이 23건에서 0건이 됐다. 수정 후 상품에 남는 락은 X뿐이었고(S 0건), 판매중지·전시해제 상품은 그대로 막혔다.

2. 임시 주문번호 'TEMP'

주문번호를 만들려면 주문 행의 PK(자동 증가)가 먼저 있어야 해서 두 단계로 처리한다.

INSERT INTO 주문 (주문번호, ...) VALUES ('TEMP', ...);  -- (1)
SET 새 번호 = 주문번호 생성(새 PK);
UPDATE 주문 SET 주문번호 = 새 번호 WHERE ...;  -- (2)

그런데 이 컬럼에는 UNIQUE 인덱스가 걸려 있다. 동시에 들어온 주문이 모두 같은 값 'TEMP'를 넣으면서 중복 검사 S락을 서로 물고, (2)의 X락을 기다리다 교착한다.

유니크 컬럼인데 'TEMP'가 어떻게 여러 개 있을까. 인덱스 컬럼을 UPDATE하면 InnoDB는 항목을 고쳐 쓰지 않는다. 옛 항목(TEMP)에는 삭제 표시만 하고 새 항목(SO-...)을 따로 넣는다. 삭제 표시된 항목은 커밋 후 정리(purge)될 때까지 남아 있어서, 주문이 몰리면 인덱스에 'TEMP' 흔적이 줄지어 쌓인다.

주문번호 유니크 인덱스  (정렬: 주문번호 -> PK)

 ... | SO-...-00041 | (빈틈) | TEMP/1169 † | TEMP/1172 † | ...
                       ^
             C 의 새 번호 'SO-...-00042' 가 들어갈 자리
             ('S' < 'T' 라 모든 TEMP 보다 앞, 기존 SO 번호 중엔 맨 뒤)

 † = 삭제 표시만 된 옛 항목    1169 = 앞선 주문의 흔적    1172 = C 의 것

중복 검사는 이 흔적도 그냥 지나치지 않는다. 삭제가 확정됐는지 보려고 'TEMP' 항목을 앞에서부터 하나씩 S로 잠그며 훑는다. 그리고 RR의 S락은 항목과 그 바로 앞 빈틈까지 덮는다(next-key).

예를 들어 C 과장과 D 대리가 거의 동시에 [주문하기]를 누른다고 하자. 다투는 자원이 상품이 아니라 유니크 인덱스의 'TEMP'라는 한 지점이라, 담은 상품이 하나도 겹치지 않아도 교착이 난다.

20:59:12.850  [C] INSERT (주문번호='TEMP', PK=1172)  -> TEMP/1172 에 X
20:59:12.860  [C] 채번 중 ...  (아직 UPDATE 전)

20:59:12.870  [D] INSERT (주문번호='TEMP', PK=1174)  -> 중복 검사
                  TEMP/1169 에 S 획득   <- 그 앞 빈틈까지 덮는다
                  TEMP/1172 에 S 요청   -> C 가 X 로 쥐고 있어 대기

20:59:12.880  [C] 채번 끝 -> UPDATE SET 주문번호='SO-...-00042'
                  TEMP/1172 삭제 표시 후 새 항목 삽입 시도
                  들어갈 자리 = TEMP/1169 앞 빈틈 -> D 의 S 가 덮고 있어 대기

              <- 교착. C 는 빈틈을, D 는 TEMP/1172 를 기다린다 -> D 롤백

D가 검사하면서 지나간 옛 흔적 TEMP/1169가 C의 새 번호가 들어갈 자리 바로 뒤에 있다는 게 문제다. 새 SO 번호는 언제나 'TEMP' 묶음 바로 앞에 들어가고, 그 빈틈은 묶음의 첫 항목을 잠그는 순간 같이 잠긴다.

기록을 보면 TEMP/1169와 TEMP/1172 둘 다 info bits 32(삭제 표시)다. C가 쥔 락이 X locks rec but not gap으로 찍힌 건, D가 C의 항목을 기다리는 순간 C의 암묵적 락이 이 형태로 바뀌었기 때문이다. D의 검사가 C의 UPDATE보다 먼저 들어왔다는 뜻이다.

먼저 나타나는 피해는 교착보다 줄서기다. 두 세션으로 재 보니 상대 주문번호가 다르면 3초 만에 통과했고, 둘 다 'TEMP'면 6초를 기다리다 ERROR 1205(대기 시간 초과)로 끝났다. 기다리는 시간이 길어지면 다른 사이클이 끼어들 틈도 커지고, 교착은 그때 따라온다.

주문 종류도 가리지 않는다. 일반·서비스·계약 주문이 주문 테이블을 같이 쓰고 'TEMP'는 테이블 전체에서 유니크라서, 서비스 주문 한 건이 장바구니 주문을 막을 수 있다. 외부 연동 웹훅으로 들어오는 주문은 더 조용하다. 외부는 이미 접수 응답을 받은 뒤라, 여기서 롤백되면 주문이 조용히 유실될 수 있었다.

결제수단과도 상관없다. 결제 분기보다 프로시저를 먼저 부르기 때문에 여신·PG·포인트·직거래 모두 같은 문장을 지나간다. 다른 건 락을 쥐는 시간뿐이다.

상태: 수정·검증 완료. 장바구니 주문과 발주 프로시저의 임시 번호 'TEMP'를 행마다 다른 값(CONCAT('TMP', UUID 일부))으로 바꿨다. 계약발주 프로시저가 이미 쓰던 방식이다. 로컬 부하 테스트에서 25명 동시 주문은 교착이 23~24건에서 0건, 5건 동시 발주는 3~4건에서 0건이 됐고, 주문·발주 번호도 정상으로 붙었다.

3. 채번 루틴

채번 프로시저는 세 문장으로 되어 있다.

INSERT IGNORE INTO 채번 VALUES (오늘 키, 0, ...);  -- (1) 당일 행 확보
UPDATE 채번 SET 값 = 값 + 1 WHERE 키 = 오늘 키;  -- (2) +1
SELECT 값 INTO 결과 FROM 채번 ...;  -- (3) 값 읽기

(1)은 그날 첫 호출에만 의미가 있다. 그 뒤로는 하루 종일 이미 있는 행에 부딪혀 실패한다. IGNORE라 오류는 나지 않지만 S락은 남는다. 그리고 (2)가 같은 행에 X를 요구한다.

09:19:39.500  [F] 채번 끝 -> 카운터 행에 X   (주문 커밋까지 쥔다)
09:19:39.600  [G] INSERT IGNORE -> 이미 있어 중복 -> 해당 행에 S 요청 -> F 의 X 때문에 대기
09:19:39.650  [H] 같은 호출 -> S 요청 -> 대기
09:19:39.680  [I] 같은 호출 -> S 요청 -> 대기

09:19:39.700  [F] 커밋 -> G·H·I 의 S 가 한꺼번에 허용된다   (S 끼리 호환)

09:19:39.701  [G] UPDATE -> 같은 행에 X 요청 -> H·I 의 S 때문에 대기
09:19:39.701  [H] UPDATE -> X 요청 -> G·I 의 S 때문에 대기

              <- 교착. 서로가 상대의 S 를 기다린다

여기서는 사이클이 한 루틴 안의 두 문장 사이에서 닫힌다. 자원이 카운터 행 하나뿐이라 잠금 순서를 맞춰서는 풀 수 없고, S를 아예 잡지 않아야 한다.

자리를 옮겨도 따라온다. 채번만 별도 트랜잭션으로 떼어서 채번 테이블 하나만 건드리게 했는데도 교착이 303건 났다. 상품도 주문도 없이 채번만으로 교착이 생긴다는 증거다.

한 겹이 더 있다. 채번 테이블의 PK가 AUTO_INCREMENT라서 SO-·PO-처럼 키를 나눠도 물리적으로는 인덱스 끝 한 지점으로 몰린다. 실제로 SO 채번이 PO 채번을 ERROR 1205로 막는 걸 두 세션으로 확인했다(아래 표). 전혀 다른 카운터인데도 그날 첫 채번만 빼고 늘 그랬다. 키를 쪼개도 사라지지 않는다.

채번 방식 언제 다른 카운터를 막나?
기존 방식 그날 첫 채번 안 막는다. 23ms 만에 통과
기존 방식 그 이후 전부 막는다. 6초 기다리다 대기 시간 초과
수정안 (아래 해결) 그 이후 전부 안 막는다. 1ms 만에 통과

기술적으로 보면, 채번 루틴의 첫 문장 INSERT IGNORE가 AUTO_INCREMENT PK로 삽입을 시도하면서 클러스터 인덱스 끝(supremum)에 락을 잡는다. 유니크 인덱스에서 중복을 찾아 무시되더라도 그 끝의 락은 커밋까지 남는다.

게다가 주문 한 건이 SO 채번과 PO 채번으로 이 루틴을 두 번 부른다.

해결: 존재 확인은 락 없이, 있으면 UPDATE 한 번

SELECT COUNT(*) INTO 존재 FROM 채번 WHERE 키 = 오늘 키;  -- ① 스냅샷 읽기: 락 없음
IF 존재 > 0 THEN  -- ② 평상시: 처음부터 X. 승격도 INSERT 도 없다
    UPDATE 채번 SET 값 = LAST_INSERT_ID(값 + 1) WHERE 키 = 오늘 키;
    SET 결과 = LAST_INSERT_ID();
ELSE  -- ③ 그날 첫 채번
    INSERT INTO 채번 VALUES (오늘 키, 1, ...)
        ON DUPLICATE KEY UPDATE 값 = LAST_INSERT_ID(값 + 1);
    SET 결과 = IF(ROW_COUNT() = 1, 1, LAST_INSERT_ID());  -- 남이 먼저 만든 행이면 +1 값
END IF;
단계 무엇을 막나
① 락 없는 존재 확인 행이 없을 때 UPDATE로 확인하면 키 자리 빈틈에 갭 락이 남는다. 그러면 동시에 온 첫 채번들이 서로의 INSERT를 막아 교착한다. 1번에서 쓴 SELECT ... INTO를 그대로 가져왔다
② UPDATE 한 문장 처음부터 X라 S→X 승격이 없다. INSERT를 시도하지 않으니 다른 카운터도 막지 않는다. LAST_INSERT_ID(expr)로 값을 받아 다시 조회할 필요도 없다
③ INSERT ... ON DUPLICATE KEY UPDATE 동시에 온 첫 채번 중 하나만 행을 만들고, 나머지는 중복을 만나 처음부터 X로 기다린다. 줄을 설 뿐 교착하지 않는다. 스냅샷이 오래돼 행을 못 본 경우도 여기서 +1로 처리된다

25개 세션이 동시에 채번하는 로컬 부하 테스트(각 3회)로 세 버전을 비교했다.

평상시 그날 첫 채번 다른 카운터 간섭
기존 3문장 교착 20~22 교착 23 2.2초 대기
UPDATE 먼저만 0 교착 0~3 측정 안 함
최종 (위 구조) 0 0 1ms

처음에는 UPDATE를 먼저 하고 0행일 때만 INSERT하는 방식(표의 “UPDATE 먼저만”)으로 고쳤다. 그날 첫 채번은 중복 키 오류로 끝날 거라고 봤는데, 재 보니 UPDATE 0행이 남긴 갭 락 때문에 동시에 온 첫 채번끼리 교착했다(25개 동시 중 0~3건). 그래서 존재 확인을 락 없는 읽기로 바꾼 지금 구조가 됐다.

성공한 채번은 모든 경우에 번호 중복도 빠진 번호도 없었다.

상태: 수정·검증 완료. 채번 루틴을 위 구조로 바꿨다. 25개 세션이 동시에 채번하는 로컬 부하 테스트에서 평상시 교착은 20~22건에서 0건, 그날 첫 채번은 23건에서 0건이 됐고, 다른 카운터 간섭은 2.2초에서 1ms로 줄었다. 번호 중복이나 빠진 번호는 없었다.

4. 즉시결제 ↔ 결재 승인

주문이 결재를 타는지에 따라 처리 시점이 나뉜다. 그러면서 원래 한 트랜잭션에 있던 상품 락과 여신 락이 서로 다른 트랜잭션으로 갈라지고, 잡는 순서가 뒤집힌다.

즉시결제      : 상품 X  ->  여신 X
결재승인 콜백 : 여신 X  ->  상품 S      <- 반대

회사 규정상 일정 금액 이상만 결재를 타기 때문에 두 경로가 늘 함께 돈다. 예를 들면 이렇다.

어제 17:00   박과장이 A4용지 포함 주문 -> 금액이 커서 결재 대기

09:00:01.100 김대리, A4용지 포함 소액 주문 (즉시결제)
             -> 상품(A4용지) X 획득
09:00:01.120 팀장이 박과장 건 [승인] 클릭
             -> 여신 행 X 획득
09:00:01.250 [김대리] 여신 행 X 요청 -> 대기
09:00:01.300 [승인]   발주 라인 FK 로 A4용지 S 요청 -> 대기

             <- 교착

상품이 하나만 겹쳐도 성립한다. 여신 행은 기업마다 살아 있는 행이 하나뿐이라, 같은 기업이면 반드시 같은 행을 잡는다.

채번을 고쳐도 남는다. 승인 콜백은 주문 생성 프로시저를 아예 부르지 않아서 SO 채번을 잡지 않기 때문이다.

근거는 로컬 2세션 재현이다. 같은 회사의 즉시결제와 결재 승인이 같은 상품으로 겹치면 3번 모두 교착했고, 롤백된 쪽은 결재 승인이었다. 교착 기록에도 승인 쪽은 여신 X를 쥔 채 상품 S를, 즉시결제 쪽은 상품 X를 쥔 채 여신을 기다리는 모습이 그대로 남았다.

같은 두 흐름의 두 번째 교착: 발주 채번 카운터

발주 생성 프로시저는 발주번호 채번(카운터 X)을 먼저 하고, 발주 라인 INSERT(FK로 상품 S)를 나중에 한다.

  • 즉시결제는 같은 트랜잭션 안에서 주문 생성이 상품 X를 먼저 잡아 두므로, 실제 순서는 상품 → 카운터다.
  • 지연 발주(PG 결제 확정, 결재 승인, 서비스 주문, 발주요청 승인, 관리자 인터페이스 주문)는 주문 생성이 이미 다른 트랜잭션에서 끝나 상품을 쥐고 있지 않다. 그래서 프로시저 순서 그대로 카운터 → 상품이 된다.
즉시결제 A  : 상품 X 획득 .................. 발주 카운터 요청 -> B 가 쥐고 있어 대기
지연 발주 B :          발주 카운터 X 획득 -> 상품 S 요청     -> A 가 쥐고 있어 대기

                                                     <- 교착

발주 카운터는 회사와 상관없이 하루에 한 행(PO-yyyyMMdd)이라, 회사가 달라도 같은 상품만 겹치면 교착이 난다. 로컬에서 3번 모두 재현됐고, 롤백된 쪽은 지연 발주였다.

해결: 모두 상품부터 잡는다

즉시결제의 순서(상품 → 여신 → 발주 카운터)를 기준으로 나머지 경로를 맞췄다.

바꾼 곳 무엇을 막는 교착
발주 생성 프로시저 앞부분 이 주문 라인의 상품을 상품번호 순서로 FOR SHARE 해서 채번보다 먼저 잡는다 카운터 ↔ 상품. 지연 발주는 모두 이 한 곳을 지난다
결재 승인 처리 (Java) 여신·포인트·예산을 잡기 전에 같은 상품을 같은 순서로 FOR SHARE 여신 ↔ 상품

잠금은 FOR SHARE면 충분하다. 발주 라인의 FK 검사가 잡는 것도 S이고, S끼리는 서로 막지 않아서 승인과 지연 발주가 동시에 와도 줄을 서지 않는다. 즉시결제가 그 상품을 쥐고 있으면 커밋할 때까지 기다렸다가 진행한다.

두 곳을 다 고쳐야 했다. 결재 승인은 발주 생성 프로시저를 부르기 전에 이미 여신을 잡는다. 그래서 프로시저만 고쳤을 때는 여신 ↔ 상품 교착이 그대로 남았다.

교착 수정 전 발주 프로시저만 결재 승인 선잠금까지
카운터 ↔ 상품 3/3 0/3 -
여신 ↔ 상품 3/3 3/3 0/3

상태: 수정·검증 완료. 발주 생성 프로시저와 결재 승인 처리가 여신·포인트·발주 채번보다 상품을 먼저 잡도록 바꿨다. 로컬 2세션 재현에서 두 교착 모두 3/3에서 0/3이 됐고, 늦게 온 쪽은 앞 트랜잭션이 끝날 때까지 기다렸다가 정상으로 끝났다.


고친 뒤에 드러난 교착

결재 승인 쪽 잠금 순서가 4번 수정 전후로 이렇게 바뀌었다. 지연 발주도 여신 자리에 발주 채번 카운터가 들어갈 뿐 같은 모양이다.

4번 수정 전
  결재 승인 : 여신 X  ->  상품 S
  즉시주문  : 상품 X (담은 순서)  ->  여신 X
  => 상품이 하나만 겹쳐도 '여신 ↔ 상품'에서 먼저 엇갈린다 (4번)

4번 수정 후
  결재 승인 : 상품 S (상품번호 순)  ->  여신 X
  즉시주문  : 상품 X (담은 순서)    ->  여신 X
  => 여신에서는 줄을 선다. 남은 차이는 상품을 잡는 순서뿐
  => 상품 2개 이상이 반대 순서로 겹치면 '상품 ↔ 상품' (5번)

5. 상품 간 ABBA

주문 생성 커서는 장바구니 순번으로 정렬돼 있어서 상품을 담은 순서대로 잠근다. 그런데 장바구니 순번은 담은 시각 순서라 상품번호 순서와 맞지 않는다.

09:12  U1 이 B 담기 -> 장바구니 순번 810331 (상품 5001)
09:20  U2 가 A 담기 -> 810392 (4002)
09:40  U1 이 A 담기 -> 810455 (4002)
10:05  U2 가 B 담기 -> 810522 (5001)

U1 잠금 순서: [B, A]      U2 잠금 순서: [A, B]      <- 정반대

교착이 나려면 세 조건이 맞아야 한다. 겹치는 상품이 2개 이상이고, 시간이 겹치고, 겹친 상품의 순서가 서로 반대여야 한다.

상품 테이블은 공급사가 올린 전체 카탈로그라서, 구매기업이 달라도 같은 상품을 두고 부딪힌다. 회사별로 데이터를 나눠 둔 것으로는 막을 수 없다.

주문끼리는 나지 않는다

주문 생성은 카트 루프에 들어가기 전에 SO-yyyyMMdd 채번 X를 잡고 커밋까지 쥔다. 키가 접두어와 날짜뿐이라 회사 전체에서 하루 한 행이고, 그래서 두 주문이 동시에 카트 루프 안에 있을 수 없다. 재 보니 뒤에 온 세션이 앞 세션의 롤백까지 정확히 3.003초를 기다렸고 교착은 없었다. 1~3번을 고친 뒤에도 채번은 주문 트랜잭션 안에 있어서 이 줄서기는 그대로다. 채번 전에 상품을 잡는 표준주문 API만 이 줄 밖에 있다(1번 참고).

결재 승인·지연 발주와는 난다

결재 승인과 지연 발주(PG 결제 확정 등)는 주문 생성 프로시저를 거치지 않아 이 줄에 서지 않는다. 그리고 4번 수정으로 두 흐름 모두 상품을 상품번호 순서로 잡게 됐다. 순서가 다른 건 즉시주문 하나다.

흐름 상품 잠금 순서
결재 승인 · 지연 발주 (4번 수정 후) 상품번호 오름차순
즉시주문 (카트 루프) 담은 순서. 이것만 다르다
어제 17:00   박과장이 A4용지(상품 1001)·토너(상품 2002) 주문 -> 결재 대기
오늘 09:00   김대리가 토너를 먼저, A4용지를 나중에 담고 즉시결제

09:00:01.100 [김대리 즉시주문] 토너(2002) X 획득            <- 담은 순서
09:00:01.105 [팀장 승인]       A4용지(1001) S 획득          <- 상품번호 순
09:00:01.110 [팀장 승인]       토너(2002) S 요청   -> 대기
09:00:01.112 [김대리 즉시주문] A4용지(1001) X 요청 -> 대기

             <- 교착. 승인 쪽이 롤백된다

여신이 끼지 않고 상품끼리만 엇갈리는 사이클이라 회사도 사용자도 달라도 된다. 상품이 2개 이상 겹치고 담은 순서가 상품번호와 반대이기만 하면 된다.

근거는 로컬 2세션 재현이다. 즉시주문 사용자가 상품 20개를 상품번호 역순으로 담고, 같은 상품이 든 결재 대기 주문을 승인(또는 지연 발주)했다. 즉시주문이 카트를 도는 도중에 승인이 들어오도록 타이밍을 맞추면 결재 승인·지연 발주 모두 3번 중 3번 교착했고, 롤백된 쪽은 언제나 승인·발주였다.

*** (1) 결재 승인  HOLDS   상품 900001~900007  lock mode S  (상품번호 오름차순)
                   WAITING 상품 900008         lock mode S waiting
*** (2) 즉시주문   HOLDS   상품 900020~900008  lock_mode X  (담은 순서)
                   WAITING 상품 900007         lock_mode X waiting
*** WE ROLL BACK TRANSACTION (1)

타이밍을 맞추지 않고 시작 시각만 0~70ms 어긋나게 해도 48번 중 6번 교착했다. 승인·발주가 즉시주문이 카트를 도는 10~20ms 안에 들어올 때만 났다. 장바구니를 정순으로 담으면 같은 조건에서 36번 모두 교착 없이 기다리기만 했다.

해결: 즉시주문도 상품번호 순서로 먼저 잡는다

주문 생성 프로시저가 채번 뒤, 카트 루프에 들어가기 직전에 장바구니 상품을 상품번호 순서로 먼저 FOR UPDATE 한다.

수정 전 : 채번 -> [카트 루프 : 담은 순서로 상품 X]
수정 후 : 채번 -> [상품번호 순으로 상품 X 먼저] -> [카트 루프 : 담은 순서 그대로]
-- 카트 커서 OPEN 직전: 장바구니 상품을 상품번호 순으로 먼저 잠근다
DECLARE 잠금 커서 CURSOR FOR
    SELECT DISTINCT 상품번호 FROM 장바구니
     WHERE 사용자 · 회사 · 선택된 행
     ORDER BY 상품번호;  -- 전역 잠금 순서
OPEN 잠금 커서;
LOOP  -- 커서가 끝나면 빠져나온다
    FETCH 잠금 커서 INTO 상품번호;
    SELECT ... FROM 상품 WHERE 상품번호 = ? FOR UPDATE;
END LOOP;

커서 정렬을 상품번호 · 장바구니 순번으로 바꾸는 방법도 있었지만 쓰지 않았다. 주문 라인이 만들어지는 순서가 바뀌면서 눈에 보이는 출력 6곳이 같이 바뀌기 때문이다. 주문상세·배송현황의 라인 나열, 관리자 엑셀의 라인번호(1번이 다른 상품에 붙는다), 결재 알림의 대표 상품, 프론트에서 첫 라인을 대표로 쓰는 5곳, 쿠폰·포인트 잔돈이 붙는 라인이 그렇다. 잠금 순서 하나를 고치려고 화면·리포트·정산 스냅샷을 건드릴 이유는 없었다.

한 문장(WHERE 상품번호 IN (서브쿼리) FOR UPDATE)으로 잡는 방법도 버렸다. 세미조인 실행 계획이 장바구니 테이블부터 읽으면 잡는 순서를 보장할 수 없다. 커서 루프는 구조상 순서가 정해져 있다.

  • 다른 쪽에서는 고칠 수 없다. 승인·발주는 상대가 무슨 순서로 담았는지 알 수 없고, 발주 라인 FK 검사의 S락도 피할 수 없다. 순서가 다른 건 즉시주문뿐이라 그쪽을 맞췄다.
  • 출력은 바뀌지 않는다. 카트 루프 순서는 그대로라 주문 라인 순서·화면·엑셀·정산 스냅샷이 같다. 같은 장바구니로 수정 전후 주문 결과(라인 20·헤더 1·장바구니 20행)를 비교해 차이가 없었다.
  • 잡는 락도 같다. 원래 카트 루프가 잡던 상품 X 20개를 순서와 시점만 앞당긴 것이다. 루프 안의 기존 FOR UPDATE는 이미 쥔 락이라 기다리지 않는다.
  • 채번 뒤에 둔다. 서비스 주문 프로시저의 “채번 → 상품” 순서와 맞추기 위해서다.
수정 전 수정 후
역순 장바구니 × 결재 승인 3/3 0/3
역순 장바구니 × 지연 발주 3/3 0/3
역순, 시작 시각만 어긋나게 (48회) 6 0
정순 장바구니 (대조) 0/36 0/30

상태: 수정·검증 완료. 주문 생성 프로시저가 카트 루프 전에 장바구니 상품을 상품번호 순서로 먼저 잡도록 바꿨다. 로컬 2세션 재현에서 역순 장바구니 교착이 결재 승인·지연 발주 모두 3/3에서 0/3이 됐고, 시작 시각만 어긋나게 한 48번도 6번에서 0번으로 줄었다. 늦게 온 쪽은 앞 트랜잭션이 끝날 때까지 기다렸다가 정상으로 끝났다. 25명 동시 주문 로컬 부하 테스트에서도 교착은 없었고, 같은 장바구니의 주문 결과는 수정 전과 같았다.


다른 경로

6. 구매사 취소 × 공급사 취소

같은 주문 라인을 구매자(마이페이지 [취소])와 공급사(공급사 포털의 발주 [취소])가 거의 동시에 취소할 때 난다. 공급사 취소는 공급사가 아직 접수하지 않은 ‘신규주문’ 상태에서만 할 수 있어서, 품절 같은 한 사건에 양쪽이 같이 반응할 때 겹친다.

처음 분석에서는 범위를 좁게 봤다. 주문 라인끼리의 정렬 기준만 비교해서 묶음 발주에서만 나고 1:1이면 안 난다고 적었다. 코드를 다시 따라가 보니 엇갈리는 건 주문 라인 ↔ 발주 라인의 순서였다. 그래서 1:1 라인에서도 난다.

경로 잠금 순서
구매사 취소 (구매사 화면 · 관리자) 주문 라인 잠금(번호 오름차순) → 발주 라인 갱신
공급사 취소 (공급사 포털) 발주 라인 갱신 → 주문 라인 갱신. 반대다
14:10:00.100 [구매자] 마이페이지 [취소] -> 주문 라인 X 획득 -> 검증·클레임 생성 중
14:10:00.400 [공급사] 재고 없음, 포털 [취소] -> 발주 라인 X 획득 -> 주문 라인 X 요청 -> 대기
14:10:01.100 [구매자] 발주 라인 X 요청 -> 대기

             <- 교착. 구매자 취소가 롤백된다

묶음 발주 라인(한 주문 안에서 같은 상품·같은 옵션의 주문 라인 여러 개를 발주 라인 1건으로 묶은 것)이면 주문 라인끼리의 순서도 반대다. 양쪽 다 교착을 의식해서 정렬해 뒀는데 기준이 달랐다. 구매사 쪽은 주문 라인 번호 오름차순이고(주석에 “데드락 회피”까지 적혀 있다), 공급사 쪽은 환불액을 최대로 하려고 단가 내림차순이다.

[전제]  발주 라인 1건에 주문 라인 2건이 묶여 있다
          주문라인 5001  단가 9,000
          주문라인 5002  단가 12,000
        구매사 잠금 순서 : 5001 -> 5002   (라인번호 오름차순)
        공급사 잠금 순서 : 5002 -> 5001   (단가 내림차순)   <- 완전 역전

11:07:41.028  [구매사] 5001 획득 / 5002 요청
11:07:41.031  [공급사] 5002 획득   <- 근소하게 빨랐다
11:07:41.032  [구매사] 5002 대기
11:07:41.034  [공급사] 5001 대기

              <- 교착

근거는 로컬 2세션 재현이다. 1:1 라인과 묶음 라인 모두 3번 중 3번 교착했고, 롤백된 쪽은 구매사 취소였다. 묶음 라인에서도 실제로 닫힌 사이클은 주문 라인 ↔ 발주 라인이었다.

*** (1) 공급사 취소  HOLDS   발주 라인 30724         lock_mode X
                     WAITING 주문 라인 57356         lock_mode X waiting
*** (2) 구매사 취소  HOLDS   주문 라인 57355, 57356  lock_mode X
                     WAITING 발주 라인 30724         lock_mode X waiting
*** WE ROLL BACK TRANSACTION (2)

돈이 새지는 않는다. 두 경로 모두 여신·PG 환불보다 앞 단계에서 엇갈려서, 한쪽 요청이 오류로 끝날 뿐이다.

해결: 공급사 취소도 주문 라인부터 잡는다

공급사 취소가 맨 처음에 후보 주문 라인 전부를 번호 오름차순으로 잠근다. 어떤 라인에서 몇 개를 취소할지(단가 내림차순 분배)는 그다음에 정한다.

수정 전 : 발주 라인 X -> 주문 라인 X (단가 내림차순)
수정 후 : 주문 라인 X 전부 (번호 오름차순) -> 발주 라인 X -> 분배·갱신 (단가 내림차순 그대로)
  • 양쪽 순서가 같아진다. 구매사 취소와 똑같이 “주문 라인 → 발주 라인” 순서라, 늦게 온 쪽은 기다렸다가 진행한다. 1:1 역전과 묶음 라인 역전이 한 번에 풀린다.
  • 후보를 전부 잠근다. 실제로 깎일 라인은 잠근 뒤에야 정해지므로, 결국 손대지 않는 라인도 커밋까지 잠긴다. 그 라인을 같은 순간 취소하려는 구매자가 잠깐 기다리는 정도다.
  • 환불 금액 분배 규칙은 그대로다. 단가가 높은 라인부터 깎는다.
순서 (구매자가 취소 1개를 커밋하는 동안 기다림) 기다린 뒤 일반 읽기로 본 구매자 취소 수량
일반 읽기 → 잠금 → 일반 읽기 0 (옛 값)
잠금이 첫 문장 → 일반 읽기 1 (최신 값)
수정 전 수정 후
1:1 라인 3/3 0/3
묶음 라인 3/3 0/3

상태: 수정·검증 완료. 공급사 취소가 후보 주문 라인을 번호 순서로 먼저 잠그고, 그 잠금을 트랜잭션의 첫 문장으로 두도록 바꿨다. 로컬 2세션 재현에서 1:1·묶음 라인 모두 3/3에서 0/3이 됐고, 실제 쿼리로 다시 잰 6번도 교착이 없었다. 늦게 온 쪽은 기다렸다가 정상으로 끝났고, 기다린 뒤에도 상대가 커밋한 취소 수량을 최신 값으로 읽었다.