TechFeedTechFeed
Backend

낙관적 락, 비관적 락, Redis 분산 락, version | 동시 재고·결제는 어떻게 막나?

재고·잔액·배치에서 읽기-수정-쓰기 레이스를 막는 낙관적 락(version 조건부 UPDATE), 비관적 락(SELECT FOR UPDATE), Redis 분산 락(SET NX PX)을 표와 의사 코드로 비교한다. 멱등 키·메트릭·TTL·트랜잭션 밖 외부 API 규칙을 백엔드·API·개발자·PostgreSQL·큐 운영 관점에서 정리한 심층 가이드.

by

재고 1개인 상품에 결제 요청이 3건 동시에 들어오면, 락 없이 SELECTUPDATE만 돌리면 3건 모두 성공할 수 있다. 중복 결제·이중 차감·포인트 이중 적립은 “느린 쿼리”가 아니라 읽기와 쓰기가 분리된 순간에 생긴다. 앱 서버를 2대 이상 띄운 순간부터 메모리 뮤텍스는 의미가 없다. 같은 행을 누가 먼저 바꾸는지 DB 제약·버전 컬럼·분산 락 중 하나로 순서를 고정해야 한다.


아래는 단일 행 재고·잔액 시나리오를 기준으로 낙관적 락, 비관적 락, Redis 분산 락을 비교하고, 실패 시 재시도·멱등 키와 어떻게 맞출지 정리한다. 웹훅 중복 반영은 멱등 키 케이스와, 배치 중복 기동은 크론·데드 레터 체크리스트와 같이 보면 빈칸이 줄어든다.


동시 쓰기가 터지는 지점 | 읽기-수정-쓰기 구간

전형적인 버그 패턴은 짧다. 잔고를 읽고, 애플리케이션에서 빼고, 다시 저장한다. 그 사이에 다른 요청이 같은 잔고를 읽으면 둘 다 “충분하다”고 판단한다. ORM의 find + save도 같은 구멍이다. 트랜잭션을 걸었어도 격리 수준과 잠금 방식을 명시하지 않으면 기본값이 기대한 것과 다를 수 있다.


특히 위험한 업무는 재고 차감, 쿠폰 1회 사용, 포인트 차감, 좌석 예약, 정산 배치의 “하루 한 번” 표시다. 읽기 전용 목록 API에는 락이 거의 필요 없다. 쓰기 경로에만 집중한다.


증상흔한 원인먼저 볼 곳
재고 음수·초과 판매SELECT 후 UPDATE, 유니크·체크 없음행 단위 버전·조건부 UPDATE
결제 2건 승인멱등 키 없이 재시도 + 레이스유니크 주문키 + 상태 머신
배치 이중 정산멀티 파드 크론 동시 기동분산 락 또는 리더 1인
캐시와 DB 불일치캐시 먼저 쓰고 DB 실패쓰기 순서·무효화 정책

외부 API 지연으로 워커가 쌓이면 레이스 면적이 커진다. 타임아웃·회로 차단은 회로 차단기 케이스를 참고하고, 여기서는 “같은 리소스 한 줄”을 어떻게 직렬화할지에 초점을 둔다.


동시에 같은 데이터베이스 행을 수정하려는 두 요청의 충돌 개념도
읽기-수정-쓰기 사이에 끼어든 요청이 중복 성공을 만든다

세 가지 방식 한눈에 | 언제 무엇을 고르나

이름을 외우기보다 충돌을 언제 발견하느냐로 나눈다. 낙관적은 쓰기 순간에 버전을 검사한다. 비관적은 읽기(또는 쓰기) 전에 행을 붙잡는다. 분산 락은 DB 밖(대개 Redis·ZooKeeper)에서 “지금 이 키는 나만”을 표시한다.


방식충돌 감지 시점잘 맞는 경우주의
낙관적 락UPDATE 시 version 불일치충돌 드묾, 읽기 많음재시도 UX·루프 상한 필요
비관적 락SELECT FOR UPDATE 대기충돌 잦음, 행 단위 임계구역 짧음락 보유 시간·데드락
분산 락락 획득 실패·타임아웃DB 밖 작업·배치 단일 실행TTL 만료·클럭·해제 실수

한 서비스 안에서 세 가지를 섞을 수 있다. 예를 들어 주문 행은 낙관적, 일일 정산 잡은 Redis 락, 결제 승인 직전 잔액 행만 FOR UPDATE다. “전부 비관적”은 처리량이 떨어지고, “전부 낙관적”은 핫 스팟 상품에서 재시도 폭풍이 난다.


낙관적 락 | version 컬럼과 조건부 UPDATE

테이블에 version(정수) 또는 updated_at을 두고, 읽은 값과 같을 때만 갱신한다. 영향 행 수가 0이면 다른 트랜잭션이 먼저 쓴 것이다. 애플리케이션은 409·충돌 응답을 주거나, 최신 행을 다시 읽어 제한 횟수 안에서 재시도한다.


SQL 패턴은 단순하다. UPDATE inventory SET qty = qty - 1, version = version + 1 WHERE id = $1 AND version = $2 AND qty >= 1. 여기서 qty >= 1까지 걸면 음수 재고를 한 줄에서 막는다. ORM을 쓰면 “버전 필드 자동 증가” 옵션을 켜고, 수동으로 version을 덮어쓰지 않는지 코드 리뷰에서 본다.


낙관적 락 — 조건부 UPDATE (의사 코드)
// 1) 읽기 const row = await db.query( 'SELECT id, qty, version FROM inventory WHERE id = $1', [skuId] ); // 2) 업무 규칙 (앱 메모리) if (row.qty < orderQty) throw new InsufficientStockError(); // 3) 조건부 쓰기 — version이 그대로일 때만 성공 const result = await db.query( `UPDATE inventory SET qty = qty - $1, version = version + 1 WHERE id = $2 AND version = $3 AND qty >= $1 RETURNING id`, [orderQty, skuId, row.version] ); if (result.rowCount === 0) { // 충돌 또는 재고 부족 — 재조회 후 재시도(상한 필수) throw new ConflictError('inventory_version_mismatch'); }

재시도는 무한 루프가 되면 안 된다. 보통 3~5회, 지수 백오프에 지터를 넣는다. 클라이언트가 “다시 시도” 버튼을 누를 때는 같은 멱등 키로 서버가 이전 성공 결과를 돌려주게 하면 이중 주문을 줄인다. 멱등 키 저장 테이블과 버전 락은 역할이 다르다. 전자는 “이 요청을 두 번 처리하지 않음”, 후자는 “같은 행의 동시 수정 순서”다.


핫 스팟(한정 수량 이벤트 상품)에서는 낙관적 재시도가 CPU와 DB를 같이 태운다. 그때는 비관적 락이나 큐로 직렬화하는 편이 낫다. Rate limit으로 유입만 줄이는 방법은 토큰 버킷·분산 제한 글과 병행한다.


체크version 없이 updated_at만 쓰면 밀리초 충돌·클럭 이슈가 있다. 정수 version이 디버깅도 쉽다. “마지막 쓰기 승리(last-write-wins)”만 허용되는 설정 화면이 아니라면 버전 검사를 빼지 않는다.


데이터베이스 행의 version 컬럼이 증가하며 충돌을 감지하는 흐름
낙관적 락은 쓰기 순간에 version 불일치로 충돌을 알린다

비관적 락 | SELECT FOR UPDATE와 락 범위

PostgreSQL 등에서 SELECT ... FOR UPDATE는 해당 행에 대한 쓰기 락을 잡는다. 다른 트랜잭션의 FOR UPDATE·갱신은 대기다. 임계구역이 짧고 충돌이 잦을 때 예측 가능한 직렬화가 된다. 트랜잭션 안에서 외부 HTTP를 호출하면 락 보유 시간이 네트워크 지연만큼 늘어나 전체 처리량이 무너진다. 락 안에서는 DB 연산만 하는 것이 기본이다.


FOR UPDATE NOWAIT는 즉시 실패, SKIP LOCKED는 이미 잠긴 행을 건너뛴다. 워커 여러 대가 작업 큐 테이블을 나눠 가져갈 때 SKIP LOCKED가 자주 쓰인다. 재고 한 줄을 반드시 처리해야 하는 결제 경로에는 NOWAIT 후 사용자에게 “잠시 후 다시”를 주는 패턴도 있다.


비관적 락 — 짧은 트랜잭션 (의사 코드)
await db.tx(async (t) => { const row = await t.one( `SELECT id, qty FROM inventory WHERE id = $1 FOR UPDATE`, [skuId] ); if (row.qty < orderQty) throw new InsufficientStockError(); await t.none( 'UPDATE inventory SET qty = qty - $1 WHERE id = $2', [orderQty, skuId] ); await t.none( 'INSERT INTO order_line (order_id, sku_id, qty) VALUES ($1, $2, $3)', [orderId, skuId, orderQty] ); // 여기서 외부 결제 API 호출 금지 — 락 해제 후 비동기/아웃박스로 });

데드락은 두 트랜잭션이 서로 다른 순서로 여러 행을 잠글 때 난다. 규칙 하나를 팀 문서에 박아 둔다. 예: “항상 더 작은 sku_id부터 잠근다”. 데드락 에러 코드가 오면 재시도 가능한 예외로 분류하고 메트릭을 남긴다. 커넥션 풀이 고갈되면 락 대기가 풀 대기로 보이기도 한다. 풀 크기는 PgBouncer·연결 관리 쪽 점검을 같이 한다.


MySQL InnoDB도 행 락이 있지만 갭 락·격리 수준 조합이 다르다. “포스틱 문서의 우리 DB 버전”을 기준으로 테스트 DB에서 동시 요청 스크립트를 한 번 돌려 보는 편이 안전하다. 추측으로 프로덕션 격리 수준을 올리지 않는다.


분산 락 | Redis SET NX·PX와 해제 규칙

DB 행이 아니라 “이 잡·이 테넌트·이 파일”처럼 추상 키를 직렬화할 때 분산 락을 쓴다. Redis에서는 흔히 SET key token NX PX ttl_ms 한 줄로 획득한다. NX는 없을 때만, PX는 만료(밀리초)다. 만료 없는 락은 프로세스 크래시 후 영구 잠김을 만든다. TTL은 “최악의 작업 시간 + 여유”보다 길되, 장애 시 복구 가능하도록 상한을 둔다.


해제는 “내가 넣은 토큰일 때만” 지운다. 단순 DEL은 다른 소유자의 락을 지울 수 있다. 토큰 비교 후 삭제하는 스크립트(또는 라이브러리)를 쓴다. 시계 동기화·다수 Redis 노드에서의 Redlock 논쟁은 실무에서 “단일 주 Redis + 짧은 임계구역 + 펜싱 토큰” 조합으로 위험을 줄이는 팀이 많다. 금융급 합의가 필요하면 DB 유니크 제약·합의 스토어를 검토한다.


Redis 분산 락 획득·해제 (의사 코드)
const token = crypto.randomUUID(); const key = `lock:job:settle-daily`; const ttlMs = 60_000; // 획득 const ok = await redis.set(key, token, 'PX', ttlMs, 'NX'); if (ok !== 'OK') { throw new LockBusyError(key); } try { await runSettleDaily(); } finally { // 토큰 일치 시에만 삭제 (의사 Lua) await redis.eval( `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`, 1, key, token ); }

TTL보다 작업이 길어지면 다른 워커가 락을 가져가 이중 실행이 난다. 대응은 세 가지다. ① 작업을 쪼개 짧게 유지 ② 하트비트로 TTL 연장(신중) ③ 펜싱 토큰(단조 증가 번호)을 스토리지에 같이 써서 늦은 쓰기를 거절. 배치 단일 실행은 스케줄 겹침 방지 항목과 같은 표에 “락 키·TTL·소유 토큰”을 적는다.


Redis가 순단 나면 락이 전부 풀리거나, 반대로 장애 중에 획득이 전부 실패한다. 전자는 이중 실행, 후자는 전체 정지다. 크리티컬 경로(결제 차감)를 Redis에만 맡기지 말고, 가능하면 DB 조건부 UPDATE를 최종 방어선으로 둔다. 분산 락은 “편의의 직렬화”, DB 제약은 “진실의 직렬화”에 가깝다.


Redis 키에 만료 시간이 있는 분산 락을 여러 서버가 경쟁하는 구조
분산 락은 TTL·소유 토큰·해제 순서가 빠져도 장애가 난다

시나리오별 선택 | 재고·결제·배치·설정 화면

정답 하나로 통일하지 않는다. 충돌 빈도, 임계구역 길이, 다중 인스턴스 여부, 실패 시 사용자 메시지를 표로 놓고 고른다.


시나리오우선 후보보조
일반 상품 재고낙관적 + qty 조건 UPDATE핫딜만 비관적·큐
지갑·포인트 잔액비관적 또는 원장 append-only멱등 키로 재시도 보호
일일 정산 잡Redis·DB advisory 분산 락business_date 유니크
관리자 설정 폼낙관적(version)충돌 시 diff 보여 주기
멀티 워커 작업 큐SKIP LOCKED가시성 타임아웃

원장(append-only) 방식은 “잔액 행을 고치지 않고 이벤트를 쌓은 뒤 합산”한다. 락 부담이 줄고 감사 추적이 쉽다. 대신 읽기 모델·정합 배치가 필요하다. 팀 규모가 작으면 조건부 UPDATE 한 줄이 유지보수 비용이 더 낮다.


멱등·메트릭·알람 | 락만으로 부족할 때

클라이언트가 타임아웃 후 같은 주문을 다시 보내면, 락이 풀린 뒤 두 번째 요청이 “정당한 신규”로 보일 수 있다. 주문 ID·Idempotency-Key 유니크 제약이 없으면 락을 잘 짜도 이중 결제가 난다. 웹훅 재시도와 같은 구조다. 자세한 순서는 멱등 키·재시도 폭주를 본다.


관측 없이 락을 넣으면 “가끔 느림”만 남는다. 최소 메트릭은 네 개다. ① 낙관적 충돌 횟수 ② 비관적 락 대기 시간(히스토그램) ③ 분산 락 획득 실패 ④ 락 타임아웃·데드락 재시도. p99 대기 시간이 SLO 예산을 깎는지는 SLO·오류 예산 표에 한 줄 추가한다.


로그에는 resource_id, lock_type, attempt, token 앞 8자 정도만 남긴다. 개인정보·카드 원문은 넣지 않는다. 알람은 “충돌률 급증”과 “락 대기 p99 급증”을 분리한다. 전자는 비즈니스 트래픽·핫딜, 후자는 느린 트랜잭션·외부 호출 혼입 신호인 경우가 많다.


자주 깨지는 패턴 | 피해야 할 구현

앱 메모리 락만 믿기. 인스턴스가 하나일 때만 통한다. 오토스케일·서버리스·멀티 리전이면 무효다.


긴 트랜잭션 + FOR UPDATE. 락 테이블이 늘고 풀이 고갈된다. 외부 결제·메일·AI 호출은 트랜잭션 밖·아웃박스로 뺀다.


TTL 없는 Redis 락. 워커가 죽으면 키가 영구히 남는다. 반대로 TTL만 있고 작업이 더 길면 이중 실행이다. 둘 다 설계에 숫자를 적는다.


version 검사 없이 덮어쓰기. “나중에 저장한 관리자 설정이 무조건 승리”는 협업 폼에서 데이터 유실이다. 충돌 시 병합 UI가 없다면 저장을 거절하는 편이 낫다.


락으로 모든 정합을 해결하려 하기. 유니크 인덱스·체크 제약·상태 머신 전이 표가 더 싼 경우가 많다. 락은 동시성 도구이지 스키마 대체재가 아니다.


참고 자료


자주 묻는 질문

낙관적 락과 비관적 락 중 기본값은 무엇으로 두나?

충돌이 드물고 읽기가 많은 도메인은 낙관적(version + 조건부 UPDATE)을 기본으로 둔다. 잔액·좌석처럼 충돌이 잦고 임계구역을 짧게 유지할 수 있으면 비관적(FOR UPDATE)을 검토한다. 측정 없이 전부 비관적으로 올리면 대기 큐가 길어진다.


Redis 분산 락만으로 결제 재고를 막아도 되나?

최종 방어선은 DB 조건부 UPDATE나 유니크·체크 제약에 두는 편이 안전하다. Redis 순단·TTL 만료·버그로 락이 풀려도 DB가 거절해야 음수 재고를 막는다. 분산 락은 배치 단일 실행·다중 단계 작업 직렬화에 더 잘 맞는다.


트랜잭션 안에서 외부 결제 API를 호출하면 안 되는 이유는?

비관적 락을 잡은 채로 네트워크를 기다리면 같은 행을 보려는 모든 요청이 적체된다. 커넥션 풀도 같이 묶인다. 결제 요청은 트랜잭션 커밋 후, 또는 아웃박스에 기록한 뒤 워커가 처리한다.


낙관적 충돌이 자주 나면 어떻게 하나?

재시도 상한을 두고, 핫 스팟 SKU는 큐로 직렬화하거나 비관적으로 옮긴다. 유입 자체를 줄이려면 rate limit·대기열 UX를 붙인다. 충돌 메트릭을 SKU별로 보면 “전체 장애”와 “특정 상품 경쟁”을 구분할 수 있다.


FOR UPDATE SKIP LOCKED는 언제 쓰나?

여러 워커가 작업 큐 테이블에서 “아직 안 잡힌 행”을 나눠 가져갈 때 쓴다. 한 행을 반드시 이 요청이 처리해야 하는 결제·재고 차감에는 보통 쓰지 않는다. 잠긴 행을 건너뛰면 그 요청은 일감을 못 얻기 때문이다.


version 컬럼 대신 updated_at만 써도 되나?

가능은 하지만 동일 밀리초에 두 쓰기가 겹치거나, 시계 보정으로 순서가 어긋날 여지가 있다. 정수 version이 비교·디버깅·ORM 지원 면에서 다루기 쉽다. 이미 updated_at만 있다면 유니크 비즈니스 키와 조건부 UPDATE를 보강한다.


낙관적 락비관적 락분산 락Redisversion동시성재고백엔드APIPostgreSQL개발자멱등

함께 보면 좋은 문제 해결

EXPLORE / Backend

이어서 읽어보기

전체 토픽 둘러보기