동시성(잠금) 기초: 동시에 차감 요청이 들어와도 잔액이 깨지지 않게 만드는 패턴
트랜잭션을 적용했다고 해서 모든 문제가 끝나지는 않습니다. 트랜잭션은 “중간 실패”를 막아주지만, 동시에 들어오는 요청(동시성)까지 자동으로 해결해주지는 않습니다.
포인트 시스템에서 동시성이 터지는 대표 장면은 이렇습니다. 한 사용자가 거의 동시에 두 번 결제를 눌렀거나, 모바일/웹이 동시에 요청을 보내거나, 네트워크 재시도 때문에 같은 차감 요청이 겹쳐 들어옵니다.
이때 방어가 없으면 아래 같은 일이 생길 수 있습니다.
- 잔액이 충분하다고 판단한 두 요청이 동시에 차감을 진행해 잔액이 마이너스가 된다
- 중복 차감이 발생한다(멱등성 문제와 결합)
- 원장과 balance 테이블이 순간적으로 어긋난다
이번 글에서는 “잠금(락)”을 어렵게 설명하기보다, 포인트 시스템에서 가장 현실적으로 쓰이는 동시성 방어 패턴을 중심으로 정리합니다.
이 단원의 목적: “읽고 판단 → 쓰기” 사이의 틈을 없애기
동시성 문제는 대부분 이 틈에서 발생합니다.
- 현재 잔액을 읽는다(충분하네)
- 차감한다
두 요청이 동시에 1번을 수행하면 둘 다 “충분하다”고 판단할 수 있습니다. 그래서 중요한 것은 1번과 2번 사이를 안전하게 만드는 것입니다. 이번 글의 목표는 아래 3가지입니다.
- 포인트 시스템에서 동시성 문제가 발생하는 구조를 이해한다
- 대표 잠금 패턴 2가지(행 잠금, 조건부 UPDATE)를 익힌다
- 원장(point_history)과 잔액(point_balance)을 함께 쓰는 경우의 안전한 흐름을 만든다
전제: 잔액을 어디에 두는가(원장만 vs balance 테이블)
원장만 쓰는 구조(매번 SUM으로 잔액 계산)는 개념적으로 단순하지만, “차감 가능 여부 확인”이 매번 무겁고, 동시성 제어도 구현 방식이 복잡해질 수 있습니다.
그래서 실무에서는 잔액을 별도 테이블(point_balance)로 캐시하고, 원장은 그대로 기록하는 “원장 + 잔액 캐시” 조합을 많이 씁니다. 이 글은 그 구조를 기준으로 설명합니다.
-- 예시: 잔액 테이블 CREATE TABLE IF NOT EXISTS point_balance ( uid CHAR(36) NOT NULL, balance BIGINT NOT NULL DEFAULT 0, updated_at DATETIME NOT NULL, PRIMARY KEY (uid) ); 1) 패턴 A: SELECT ... FOR UPDATE로 “행 잠금” 걸고 차감하기
가장 직관적인 방법은 “잔액 행을 잠그고” 처리하는 것입니다. 한 트랜잭션이 특정 uid의 balance 행을 잠그면, 다른 트랜잭션은 그 잠금이 풀릴 때까지 기다리게 됩니다.
SET @uid := '11111111-1111-1111-1111-111111111111'; SET @use_amount := 150; SET @ref_id := 'USE_REQ_20251214_010'; START TRANSACTION; -- 1) 잔액 행 잠금(동일 uid 동시 차감 방지) SELECT balance FROM point_balance WHERE uid = @uid FOR UPDATE; -- 2) 잔액 확인(애플리케이션에서 조건 판단한다고 가정) -- balance >= @use_amount 이어야 함 -- 3) 잔액 차감 UPDATE point_balance SET balance = balance - @use_amount, updated_at = NOW() WHERE uid = @uid; -- 4) 원장 기록 INSERT INTO point_history (uid, point_type, action_type, amount, ref_id, memo, created_at) VALUES (@uid, 'FREE', 'USE', -@use_amount, @ref_id, '포인트 사용', NOW()); COMMIT; 이 방식의 장점은 이해가 쉽고, 논리가 명확하다는 점입니다. 단점은 트랜잭션이 길어지면(외부 API 호출 등) 잠금 유지 시간이 늘어 대기/병목이 생길 수 있다는 것입니다. 그래서 잠금을 잡은 뒤에는 가능한 한 빨리 판단하고 UPDATE/INSERT를 끝내는 것이 중요합니다.
2) 패턴 B: 조건부 UPDATE로 “한 번에 차감 성공/실패” 결정하기
동시성에서 더 깔끔한 방식은 “읽고 판단”을 없애는 것입니다. 즉, UPDATE 한 번으로 “잔액이 충분하면 차감, 아니면 실패”를 결정합니다.
이때 핵심은 WHERE 조건에 balance >= 사용금액을 넣는 것입니다. 그러면 동시에 두 요청이 와도, 한쪽이 먼저 balance를 줄여버리면 다른 쪽은 조건을 만족하지 못해 UPDATE가 0행이 됩니다.
SET @uid := '11111111-1111-1111-1111-111111111111'; SET @use_amount := 150; SET @ref_id := 'USE_REQ_20251214_011'; START TRANSACTION; -- 1) 조건부 차감 (성공하면 1행, 실패하면 0행) UPDATE point_balance SET balance = balance - @use_amount, updated_at = NOW() WHERE uid = @uid AND balance >= @use_amount; -- 애플리케이션에서 ROW_COUNT() == 1 인지 확인한다고 가정 -- 2) 성공했을 때만 원장 기록 INSERT INTO point_history (uid, point_type, action_type, amount, ref_id, memo, created_at) VALUES (@uid, 'FREE', 'USE', -@use_amount, @ref_id, '포인트 사용', NOW()); COMMIT; 위 예시에서 중요한 전제는 “UPDATE가 실패했을 때는 INSERT를 하면 안 된다”는 것입니다. UPDATE가 0행이면 잔액이 부족한 것이므로, 원장도 남기지 않거나, 정책에 따라 “실패 로그”를 별도 테이블에 남기는 편이 일반적입니다.
조건부 UPDATE가 동시성에 강한 이유
- 읽기-판단-쓰기의 틈이 사라진다
- 한 번의 UPDATE가 원자적으로 조건을 검사하고 값을 변경한다
- 락 대기 시간이 상대적으로 짧아질 가능성이 크다(트랜잭션이 짧아짐)
3) 원장과 잔액의 순서: “잔액 업데이트가 먼저”가 보통 안전하다
두 테이블을 함께 쓸 때는 보통 아래 순서가 안전합니다.
- 조건부 UPDATE로 잔액 차감 시도(성공/실패 결정)
- 성공이면 원장 INSERT
- COMMIT
반대로 원장 INSERT를 먼저 하고 잔액 UPDATE가 실패하면, “원장에는 차감이 기록됐는데 잔액은 그대로” 같은 불일치가 생길 수 있습니다. 트랜잭션으로 묶었더라도 중간 로직이 꼬이면 디버깅이 어려워질 수 있습니다. 그래서 “성공 여부를 먼저 확정할 수 있는 작업(잔액 UPDATE)”을 앞에 두는 편이 일반적입니다.
4) 멱등성(중복 요청)과 동시성은 같이 온다
동시 차감은 종종 “같은 요청이 두 번” 들어오는 형태로 나타납니다. 그래서 동시성 방어만 해서는 부족하고, 이전 글에서 다룬 UNIQUE(요청 ID/ref_id)로 중복을 막는 장치도 함께 있어야 합니다.
예를 들어 (uid, ref_id, action_type)에 UNIQUE가 걸려 있고, 같은 ref_id의 USE가 두 번 들어오면 두 번째 INSERT는 실패합니다. 이때 잔액 UPDATE가 이미 진행된 상태라면 곤란해질 수 있으므로, “중복 여부를 먼저 확인/차단”하는 설계가 필요합니다.
실무에서는 보통 아래 중 하나로 정리합니다.
- 요청을 처리하기 전에 request_id/ref_id를 기준으로 “처리 여부 테이블”에 먼저 기록(멱등 키 선점)
- 원장 INSERT를 먼저 시도하고(UNIQUE로 중복 거절), 성공했을 때만 잔액을 갱신
어떤 방식이 좋은지는 테이블 구조와 트래픽 패턴에 따라 달라집니다. 다만 중요한 건 “동시성”과 “중복 요청”이 함께 고려되어야 한다는 점입니다. 다음 글에서 멱등성과 잠금을 함께 엮는 패턴을 정리합니다.
5) 잠금과 관련해 꼭 알아야 하는 운영 포인트 3가지
- 트랜잭션을 길게 끌면 병목이 된다
잠금을 잡은 상태에서 외부 API 호출, 긴 계산을 하면 대기 시간이 급격히 늘 수 있습니다. - 락 대기는 “장애”가 아니라 “경합”일 수 있다
동시 요청이 많은 시스템에서는 잠금 대기가 자연스럽게 발생합니다. 문제는 대기 시간이 길어지는 순간입니다. - 필요한 행만 잠그는 것이 중요하다
uid 단위로 잠그는 구조를 만들면 영향 범위를 최소화할 수 있습니다.
6) 간단 검증: 동시성 방어가 잘 되는지 확인하는 감각
동시성 문제는 재현이 어렵지만, 기본적으로는 아래를 확인할 수 있어야 합니다.
- 잔액이 부족하면 UPDATE가 0행이 되고, 원장도 기록되지 않는가
- 동시에 여러 요청이 들어와도 잔액이 음수가 되지 않는가
- 원장 합계(SUM)와 balance 값이 일치하는가
-- 잔액 테이블 SELECT balance FROM point_balance WHERE uid = @uid; -- 원장 합계(숨김 제외) SELECT SUM(amount) AS ledger_balance FROM point_history WHERE uid = @uid AND hide = 0; 다음 글 예고: 멱등성 + 동시성 결합 — “요청 ID 선점”으로 중복 차감까지 한 번에 막기
다음 글에서는 동시성에서 가장 흔히 맞닥뜨리는 현실, “같은 요청이 재시도/중복 클릭으로 여러 번 들어오는 상황”을 데이터베이스 레벨에서 안정적으로 정리하는 패턴을 다룹니다. 요청 ID를 먼저 선점해 중복을 차단하고, 그 다음 잔액 차감을 진행하는 흐름을 예시로 설명하겠습니다.
댓글
댓글 쓰기