멱등성 + 동시성 결합: “요청 ID 선점”으로 중복 차감까지 한 번에 막기
동시성(잠금)만 잘 잡아도 잔액이 마이너스로 내려가는 문제는 많이 줄어듭니다. 그런데 운영에서 더 자주 만나는 현실은 “동시에 두 번”이 아니라 같은 요청이 재전송되어 여러 번 들어오는 것입니다. 네트워크 타임아웃, 버튼 중복 클릭, 서버 재시도 로직은 생각보다 흔합니다.
그래서 포인트 사용 처리에서 정말 중요한 것은 아래 두 가지를 동시에 만족시키는 것입니다.
- 동시에 여러 요청이 들어와도 잔액이 깨지지 않는다(동시성)
- 같은 요청이 여러 번 들어와도 결과는 한 번만 반영된다(멱등성)
이번 글에서는 이 둘을 같이 해결하는 대표 패턴인 “요청 ID 선점(Reservation)” 전략을 다룹니다. 핵심은 간단합니다. “먼저 이 요청을 내가 처리하겠다고 기록하고(중복 차단), 그 다음 실제 차감을 진행한다.”
이 단원의 목적: “중복 요청”을 DB가 구조적으로 거절하게 만들기
멱등성은 코드로도 구현할 수 있지만, 서비스가 커질수록 여러 서버/여러 경로에서 동일 요청이 들어오며 코드만으로 일관성을 유지하기가 어려워집니다. 그래서 데이터베이스에 “중복을 거절하는 규칙(UNIQUE)”을 두고, 그 규칙을 활용해 처리 흐름을 설계하는 것이 강력합니다.
이번 글의 목표는 아래 3가지입니다.
- 요청 ID를 어디에 저장하고 어떤 UNIQUE를 걸어야 하는지 정한다
- 요청을 먼저 선점한 뒤 잔액 차감을 진행하는 트랜잭션 흐름을 만든다
- 실패/재시도 상황에서 “어떤 상태가 남는지”를 명확히 이해한다
1) 요청 ID 선점용 테이블 만들기
point_history는 원장이고, 요청 선점은 “처리 상태”를 관리하는 성격이 강합니다. 그래서 보통 별도 테이블을 둡니다. 이 테이블은 “요청이 들어왔는지”, “처리되었는지”, “실패했는지”를 추적할 수 있어야 합니다.
CREATE TABLE IF NOT EXISTS point_requests ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, uid CHAR(36) NOT NULL, request_id CHAR(36) NOT NULL, -- 멱등 키(클라이언트/서버가 생성) request_type ENUM('CHARGE','USE') NOT NULL, amount INT NOT NULL, ref_id VARCHAR(64) NULL, -- 주문번호 등 추적용 status ENUM('RESERVED','DONE','FAILED') NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_uid_request (uid, request_id), INDEX idx_uid_created (uid, created_at) ); 핵심은 (uid, request_id)에 UNIQUE를 거는 것입니다. 같은 사용자가 같은 request_id로 요청을 여러 번 보내면, 두 번째부터는 INSERT가 실패합니다. 즉, 중복 요청이 DB 단계에서 차단됩니다.
2) 처리 흐름 개요: RESERVED → (성공) DONE / (실패) FAILED
흐름을 단계로 보면 간단합니다.
- 요청이 들어오면 point_requests에 RESERVED로 “선점”한다
- 선점이 성공한 경우에만 잔액 차감(또는 적립)과 원장 기록을 수행한다
- 모든 작업이 성공하면 status를 DONE으로 바꾼다
- 실패하면 status를 FAILED로 바꾸고 롤백/정리 전략에 따라 처리한다
이 방식의 장점은 명확합니다. “이 요청이 이미 처리되었는지”를 point_requests 한 곳에서 판단할 수 있습니다.
3) 핵심 트랜잭션: ‘선점 → 조건부 차감 → 원장 기록 → 완료 처리’
아래는 USE(차감) 요청을 처리하는 기본 흐름입니다. request_id는 외부에서 들어왔다고 가정합니다(클라이언트가 만들거나, 서버가 발급해 응답 후 재시도 시 사용).
SET @uid := '11111111-1111-1111-1111-111111111111'; SET @request_id := 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; SET @use_amount := 150; SET @ref_id := 'ORDER_9001'; START TRANSACTION; -- 1) 요청 선점(중복이면 여기서 막힘) INSERT INTO point_requests (uid, request_id, request_type, amount, ref_id, status, created_at, updated_at) VALUES (@uid, @request_id, 'USE', @use_amount, @ref_id, 'RESERVED', NOW(), NOW()); -- 2) 잔액 조건부 차감(성공이면 1행, 실패면 0행) UPDATE point_balance SET balance = balance - @use_amount, updated_at = NOW() WHERE uid = @uid AND balance >= @use_amount; -- 3) 잔액 부족이면 실패 처리 후 롤백 -- (실제 구현에서는 ROW_COUNT() 확인 후 분기) -- 여기서는 예시로 “성공했다고 가정”하고 진행 -- 4) 원장 기록 INSERT INTO point_history (uid, point_type, action_type, amount, ref_id, memo, created_at) VALUES (@uid, 'FREE', 'USE', -@use_amount, @ref_id, CONCAT('요청ID:', @request_id), NOW()); -- 5) 요청 상태 완료(DONE) UPDATE point_requests SET status = 'DONE', updated_at = NOW() WHERE uid = @uid AND request_id = @request_id; COMMIT; 이 흐름에서 “중복 요청”은 1번에서 막히고, “잔액 부족”은 2번에서 UPDATE 0행으로 감지됩니다. 둘 다 DB 레벨에서 확실한 기준을 만들 수 있다는 점이 포인트입니다.
4) 잔액 부족 케이스: 실패 상태를 남길지, 아예 롤백할지
잔액 부족이면 UPDATE가 0행이 됩니다. 이때 전략은 두 가지가 있습니다.
- 전부 롤백: point_requests의 RESERVED도 남기지 않는다
- FAILED로 남김: 요청이 들어왔고 실패했다는 흔적을 남긴다
어느 쪽이 더 낫다고 단정할 수는 없습니다. 다만 “왜 실패했는지”를 추적해야 한다면 FAILED로 남기는 편이 운영에 유리할 수 있습니다. 반대로 시스템을 단순하게 유지하고 싶다면, 잔액 부족은 정상 흐름이므로 롤백으로 깔끔하게 끝내기도 합니다.
FAILED를 남기는 흐름 예시는 아래처럼 “커밋을 두 번”으로 분리하거나, 같은 트랜잭션 안에서 실패 상태 업데이트 후 커밋하는 방식으로 구성할 수 있습니다. 학습 단계에서는 개념만 잡아도 충분합니다.
-- 개념 예시: 잔액 부족이면 -- 1) point_requests RESERVED는 이미 들어갔다 -- 2) balance UPDATE가 0행이다 -- 3) point_requests를 FAILED로 바꾼 뒤 COMMIT 한다(실패 흔적 유지) 5) “중복 요청”이 들어오면 어떤 일이 벌어질까
같은 (uid, request_id)로 두 번째 요청이 오면, point_requests INSERT가 UNIQUE 위반으로 실패합니다. 이때 중요한 점은 “잔액 차감까지 가지 않는다”는 것입니다. 즉, 중복 요청은 초기에 안전하게 차단됩니다.
그리고 애플리케이션은 이렇게 대응할 수 있습니다.
- 이미 DONE이면 “이미 처리됨”으로 응답
- RESERVED인데 오래됐으면(타임아웃) 재처리/정리 정책 적용
- FAILED면 실패 사유에 따라 재시도 허용/차단 결정
6) RESERVED가 남는 상황: 중간 장애를 어떻게 다룰까
선점 후(RESERVED) 차감/원장 기록 전에 서버가 죽으면, point_requests에는 RESERVED가 남을 수 있습니다. 이건 “중간 실패”의 흔적입니다.
그래서 point_requests에는 보통 updated_at이 있고, 일정 시간이 지난 RESERVED를 정리하거나 재처리하는 정책이 필요합니다. 예를 들어 5분 이상 RESERVED면 FAILED로 전환한다거나, 관리자가 확인 후 수동 정리하는 방식입니다.
이 글에서는 자동 정리 로직까지 깊게 들어가진 않지만, 최소한 “RESERVED가 남을 수 있다”는 사실을 알고 설계하는 것이 중요합니다.
7) 마지막 검증: 원장과 잔액, 요청 상태를 함께 본다
요청 선점 패턴을 적용하면, 문제 발생 시 확인해야 할 테이블이 세 곳으로 늘어납니다. 대신 원인을 훨씬 빠르게 좁힐 수 있습니다.
-- (1) 요청 상태 SELECT uid, request_id, request_type, amount, ref_id, status, created_at, updated_at FROM point_requests WHERE uid = @uid ORDER BY created_at DESC LIMIT 20; -- (2) 잔액 SELECT balance, updated_at FROM point_balance WHERE uid = @uid; -- (3) 원장(요청ID로 추적) SELECT id, action_type, amount, ref_id, memo, created_at FROM point_history WHERE uid = @uid AND memo LIKE CONCAT('%', @request_id, '%') ORDER BY created_at DESC, id DESC; 요청 테이블을 두는 이유는 결국 “상태 기반으로 추적할 수 있게” 만들기 위함입니다. 원장만으로도 가능은 하지만, 중간 상태(RESERVED) 같은 것을 표현하기가 어렵습니다.
다음 글 예고: 뷰(View)로 ‘복잡한 리포트 쿼리’를 재사용 가능하게 만들기
지금까지 작성한 쿼리들은 점점 길어지고 있습니다. 잔액, 최근 30일 적립/사용, 최근 활동, 이상 징후 탐지까지 한 화면에 묶으면 더 길어집니다. 다음 글에서는 VIEW를 사용해 자주 쓰는 조합을 “가상 테이블”로 만들어, 쿼리를 단순하게 유지하는 방법을 다룹니다.
댓글
댓글 쓰기