Skip to content
튜닝 연습Lock과 동시성 제어
LOCK-033

적립금은 덜 쌓이고 환불은 두 번 나가고 결제 완료 주문은 결제 대기로 돌아갔다

실전UPDATE한빛유통528 / 700

상황 ​

한빛유통 내부 감사가 지난 분기의 데이터 불일치 세 가지를 넘겼다. 결제 완료 주문 9건이 결제 대기(PENDING)로 되돌아갔고, 고객 17명의 적립금 잔액이 적립과 사용 이력의 합보다 모두 41,300원 적었고, 같은 주문에 환불이 두 번 나간 일이 4건 있었다. 세 곳 모두 에러 로그는 깨끗했다. 박팀장은 세 곳을 한 번에 정리하자고 했다. 개발팀은 "세 곳 다 처음 읽을 때 SELECT … FOR UPDATE로 잠그면 끝납니다"라고 제안했다.

테이블과 인덱스 ​

  • T_ORD 주문: 100,000행 · 896블록 · PK_T_ORD (ORD_SEQ) · 이 문제에서는 배송 메모 DLV_MEMO를 더했다 · 결제 콜백과 배송 상태 배치가 ORD_ST, PAY_DT를 하루 약 46,000번 고친다(한 번에 수 ms)
  • M_CUS 고객: 2,000행 · 30블록 · PK_M_CUS (CUS_ID) · 이 문제에서는 적립금 잔액 PNT_BAL을 더했다
  • T_ORD_RFND 환불(이 문제를 위해 더한 테이블): RFND_SEQ(시퀀스 SQ_RFND) · ORD_SEQ · RFND_AMT · RFND_DT · 인덱스 IX_T_ORD_RFND_ORD (ORD_SEQ) · 한 주문의 환불 합계는 PAY_AMT를 넘을 수 없다
  • 세 처리의 길이와 빈도는 실행계획 칸의 마지막 표에 있다. 이 문제에서는 테이블에 칸을 더해도 된다.

원본 SQL ​

sql
-- ① 「배송 메모 수정」 화면 — 열 때 읽고, 상담원이 통화하며 2 ~ 5분 뒤 저장
SELECT ORD_SEQ, CUS_ID, ORD_ST, DLV_MEMO FROM T_ORD WHERE ORD_SEQ = :ord_seq;

UPDATE T_ORD SET DLV_MEMO = :memo, ORD_ST = :ord_st WHERE ORD_SEQ = :ord_seq;
COMMIT;

-- ② 적립금 적립 · 사용 — 주문 확정 배치와 결제 화면이 같은 프로시저를 부른다 (적립은 +, 사용은 -)
SELECT PNT_BAL INTO v_bal FROM M_CUS WHERE CUS_ID = :cus_id;
v_bal := v_bal + :delta;
IF v_bal < 0 THEN RAISE_APPLICATION_ERROR(-20021, '적립금이 모자랍니다'); END IF;
UPDATE M_CUS SET PNT_BAL = v_bal WHERE CUS_ID = :cus_id;
COMMIT;

-- ③ 「환불」 버튼 — 상담원이 금액을 넣고 누른다
SELECT PAY_AMT INTO v_pay FROM T_ORD WHERE ORD_SEQ = :ord_seq;
SELECT NVL(SUM(RFND_AMT), 0) INTO v_done FROM T_ORD_RFND WHERE ORD_SEQ = :ord_seq;
IF v_done + :amt > v_pay THEN RAISE_APPLICATION_ERROR(-20031, '환불 한도를 넘습니다'); END IF;
INSERT INTO T_ORD_RFND (RFND_SEQ, ORD_SEQ, RFND_AMT, RFND_DT)
VALUES (SQ_RFND.NEXTVAL, :ord_seq, :amt, SYSDATE);
COMMIT;

실행계획 학습용 예시 ​

xplan
[① 시간표 — 주문 98,402]
10:11:02   상담원 화면 열기 → ORD_ST PENDING · DLV_MEMO '경비실'
10:11:40   결제 콜백 UPDATE ORD_ST = 'COMPLETE' · COMMIT
10:14:15   상담원 저장 → DLV_MEMO '문 앞' · ORD_ST = 'PENDING' (화면 값) · COMMIT   ← 결제 완료가 되돌아갔다

[② 시간표 — 고객 CUS_001288 · 잔액 5,000]
19:20:31.402   주문 확정 배치 (적립 +1,200)   SELECT → 5,000 · UPDATE 6,200 · COMMIT 19:20:31.409
19:20:31.404   결제 화면 (사용 -3,000)        SELECT → 5,000 · 2,000 계산 · UPDATE → 기다림
19:20:31.409                                  깨어나 계산해 둔 2,000 을 쓴다 · COMMIT
               → 잔액 2,000 (맞는 값 3,200 · 적립 1,200 이 사라졌다)

[③ 시간표 — 주문 97,551 · PAY_AMT 32,000 · 상담원이 「환불」을 두 번 눌렀다]
15:02:10.20   요청 1   SUM → 0 · 0 + 32,000 ≤ 32,000 · INSERT · COMMIT 15:02:10.23
15:02:10.21   요청 2   SUM → 0 (요청 1 은 COMMIT 전) · INSERT · COMMIT 15:02:10.25
              → 환불 64,000 · 두 요청 모두 기다림 0 (서로 다른 새 행을 넣었다)

[처리의 길이와 빈도 — 지난 분기 하루 평균]
                  한 번의 길이        하루 횟수     같은 행이 겹치는 일
① 메모 수정        2 ~ 5분 (사람)      약 1,400      하루 0 ~ 2번
② 적립 · 사용      약 5ms              약 31,000     저녁 피크에 같은 고객이 분당 몇 번
③ 환불             약 15ms             약 260        버튼 두 번 · 주 1번쯤
T_ORD 를 고치는 결제 콜백 · 배송 배치   하루 약 46,000번 · 한 번에 수 ms

문제 ​

  1. 세 시간표에서 각각 어느 쓰기가 어떻게 사라지거나 겹쳤는지 설명하시오.
  2. 세 곳에 각각 맞는 동시성 제어 방식을 골라 SQL로 작성하시오. 같은 방식을 세 곳에 모두 쓰지 않는 이유를 처리의 길이와 겹치는 빈도로 설명하시오.
  3. 개발팀의 "처음 읽을 때 FOR UPDATE" 안을 ①에 쓰면 무엇이 일어나는지 쓰시오.
원본 SQL을 고쳐 쓰세요. 인덱스 DDL과 힌트도 쓸 수 있습니다.
무엇이 문제였고 왜 이렇게 고쳤는지 쓰세요.0자