분산락과 Atomic SQL을 함께 사용하는 방법

개발자 팀·2026년 3월 17일

self-study-series

목록 보기
13/16

이 문서의 대상

이 문서는 아래에 해당하는 분들을 위한 입문용 교육 자료입니다.

  • 트랜잭션, , 분산 시스템이라는 단어가 아직 낯선 분
  • Redis 분산락은 들어보았지만 왜 필요한지 잘 모르시는 분
  • SQL의 원자성(atomicity)은 들어보았지만 실제 설계에 어떻게 쓰는지 감이 잘 오지 않는 분
  • 분산락 + Atomic SQL을 같이 써야 하는지, 둘 중 하나만 써도 되는지 판단이 어려우신 분

이 문서는 개념 설명에서 멈추지 않고, 실제 설계 원칙과 실패 사례, 예제 SQL, 의사코드, 운영 체크리스트까지 포함합니다.


먼저 한 줄 정의

이 문서에서는 Atomic SQL을 아래 뜻으로 사용하겠습니다.

데이터베이스 안에서 하나의 SQL 문 또는 하나의 트랜잭션이 전부 성공하거나 전부 실패하도록 만들고, 경쟁 상황에서도 조건을 만족할 때만 상태를 바꾸는 방식

예를 들어 아래와 같은 SQL이 대표적입니다.

UPDATE inventory
SET stock = stock - 1
WHERE product_id = 100
  AND stock > 0;

이 쿼리는 재고가 1개 이상 있을 때만 감소합니다. 여러 서버가 동시에 실행하더라도 DB가 최종 판단을 하기 때문에, "읽고 계산하고 쓰기"를 애플리케이션에서 나누어 처리하는 것보다 훨씬 안전합니다.

분산락(distributed lock)은 보통 Redis 같은 외부 시스템을 이용해서:

  • 여러 서버 인스턴스 중
  • 한 번에 딱 하나만
  • 특정 작업 구간에 들어가게 만드는 장치

라고 이해하시면 됩니다.

핵심만 먼저 말씀드리면:

  • 분산락은 바깥쪽 문지기입니다.
  • Atomic SQL은 안쪽 금고입니다.

둘을 같이 쓰는 이유는:

  • 분산락으로 "동시에 너무 많이 들어오는 것"을 제어하고
  • Atomic SQL로 "최종 상태 변경"을 안전하게 확정하기 위해서입니다.

가장 중요한 결론은 아래 한 문장입니다.

데이터 정합성의 최종 책임은 분산락이 아니라 데이터베이스가 져야 합니다.


1. 왜 이 주제가 어려운가

초보자가 가장 많이 하시는 오해는 보통 아래 세 가지입니다.

오해 1. Redis 분산락만 있으면 안전하다

그렇지 않습니다. Redis 락은 "누가 먼저 들어오느냐"를 조절할 뿐입니다.

  • 락 TTL이 만료될 수 있습니다.
  • 프로세스가 중간에 멈출 수 있습니다.
  • 락을 걸지 않는 다른 코드 경로가 있을 수 있습니다.
  • 락을 잡았더라도 DB 갱신을 잘못하면 정합성은 깨집니다.

즉, 분산락만으로는 충분하지 않습니다.

오해 2. 트랜잭션만 쓰면 모든 문제가 해결된다

이 역시 아닙니다. 트랜잭션은 데이터베이스 내부 상태를 일관되게 만드는 데는 강하지만:

  • 외부 API 호출
  • 메시지 발행
  • 파일 업로드
  • 여러 서비스에 걸친 순서 제어

같은 문제는 DB 트랜잭션만으로 해결되지 않습니다.

오해 3. 분산락과 DB 락은 같은 것이다

둘은 다릅니다.

  • DB 락: 특정 행, 테이블, 트랜잭션 범위 안의 보호 장치
  • 분산락: 여러 애플리케이션 인스턴스 간 임계 구역 진입 제어 장치

DB 락은 데이터와 가깝고 강합니다. 분산락은 범위가 넓지만 상대적으로 약합니다.


2. 비유로 먼저 이해하기

은행 금고 비유

  • 분산락은 은행 입구의 번호표 시스템입니다.
    • 한 번에 한 사람만 금고실로 보낼 수 있게 해 줍니다.
  • Atomic SQL은 금고 문 자체입니다.
    • 금고 안의 돈은 정확한 절차 없이는 바뀌지 않습니다.

입구 통제만 잘해도 어느 정도 안전해 보일 수 있습니다. 하지만 금고 문이 허술하면 내부 자산은 결국 망가집니다.

반대로 금고 문이 튼튼하더라도 사람들이 몰려와 복잡한 외부 절차를 중복 실행하면 운영 문제가 생길 수 있습니다.

그래서 둘은 같은 도구가 아니라, 서로 다른 역할을 하는 도구라고 보셔야 합니다.


3. 각각이 해결하는 문제

3.1 Atomic SQL이 해결하는 것

Atomic SQL은 주로 아래 문제를 해결합니다.

  • 재고 차감
  • 포인트 차감
  • 중복 insert 방지
  • 상태 전이의 정합성
  • 동일 데이터에 대한 경쟁 업데이트

즉, DB 안의 상태를 안전하게 바꾸는 것이 목표입니다.

대표 기법은 아래와 같습니다.

  • 트랜잭션
  • 조건부 UPDATE
  • UNIQUE 제약조건
  • UPSERT
  • 낙관적 락(version column)
  • SELECT ... FOR UPDATE

3.2 분산락이 해결하는 것

분산락은 주로 아래 문제를 해결합니다.

  • 여러 서버 중 하나만 배치 작업 실행
  • 같은 쿠폰 발급 로직을 동시에 너무 많이 실행하는 것 방지
  • 같은 사용자에 대한 무거운 작업 중복 수행 방지
  • 외부 API 호출, 파일 생성, 모델 실행 같은 비DB 작업의 동시성 제어

즉, 애플리케이션 레벨에서 임계 구역 진입을 줄이는 것이 목표입니다.

3.3 둘을 같이 쓰는 이유

둘을 같이 쓰는 전형적인 이유는 아래와 같습니다.

  • 요청이 매우 많이 몰리는 상황입니다.
  • 비싼 작업이나 외부 연동이 포함되어 있습니다.
  • 그래도 최종 정합성은 반드시 DB가 보장해야 합니다.

예를 들면:

  • 선착순 쿠폰 발급
  • 재고 차감 + 주문 생성 + 외부 결제 승인
  • 중복 실행되면 안 되는 스케줄러
  • RAG 인덱스 재빌드처럼 오직 한 번만 돌아야 하는 작업

4. 가장 중요한 설계 원칙

원칙 1. 정합성은 DB가 책임져야 합니다

분산락은 보조 장치입니다.

최종 상태는 반드시 아래 방식 중 하나 이상으로 보호하셔야 합니다.

  • 조건부 UPDATE
  • UNIQUE 제약조건
  • 트랜잭션
  • 행 락
  • 버전 칼럼 기반 비교 후 갱신

원칙 2. 분산락은 "중복 진입 억제" 용도로 생각하셔야 합니다

분산락은 최종 정답이 아니라, 경쟁을 줄이는 도우미입니다.

원칙 3. 임계 구역은 짧게 유지하셔야 합니다

분산락을 오래 잡고 있으면:

  • TTL 만료 위험이 커지고
  • 대기 시간이 늘고
  • 재시도 폭풍이 생기고
  • 장애 시 복구가 어려워집니다

원칙 4. 외부 부작용은 DB 커밋과 분리해서 다루셔야 합니다

DB 커밋과 외부 API 호출은 같은 트랜잭션에 넣을 수 없습니다.

그래서 자주 쓰는 패턴이:

  • DB에 상태를 먼저 안전하게 기록하고
  • 그 후 outbox/event 방식으로 외부 작업을 이어가는 것

입니다.

원칙 5. 재시도 가능한 시스템은 반드시 멱등성(idempotency)을 가져야 합니다

분산 시스템에서는 재시도가 거의 필수입니다.

따라서:

  • 같은 요청 ID가 두 번 와도
  • 같은 이벤트가 다시 와도
  • 같은 메시지가 재전송되어도

결과가 한 번만 반영되도록 설계하셔야 합니다.


5. Atomic SQL을 아주 기초부터 이해하기

5.1 원자성(atomicity)이란

원자성은 전부 아니면 전무(all or nothing)를 뜻합니다.

예를 들어 계좌이체를 생각해 보겠습니다.

  1. A 계좌에서 1만 원 차감
  2. B 계좌에 1만 원 증가

중간에 장애가 나서 1번만 되고 2번이 안 되면 큰 문제가 됩니다.

그래서 트랜잭션으로 묶습니다.

BEGIN;

UPDATE account
SET balance = balance - 10000
WHERE account_id = 1;

UPDATE account
SET balance = balance + 10000
WHERE account_id = 2;

COMMIT;

중간에 실패하면 ROLLBACK 되어야 합니다.

5.2 단일 SQL 문도 원자적일 수 있습니다

꼭 여러 문장을 묶어야만 원자적인 것은 아닙니다.

아래 같은 단일 문장은 매우 강력합니다.

UPDATE inventory
SET stock = stock - 1
WHERE product_id = 100
  AND stock > 0;

이 쿼리는 아래 세 단계를 앱에서 따로 하지 않습니다.

  1. 재고 읽기
  2. 0보다 큰지 판단
  3. 감소 반영

대신 DB 안에서 한 번에 수행합니다.

이 방식이 중요한 이유는 레이스 컨디션을 크게 줄여 주기 때문입니다.

5.3 초보자가 자주 만드는 위험한 코드

stock = SELECT stock FROM inventory WHERE product_id = 100;
if (stock > 0) {
  UPDATE inventory SET stock = stock - 1 WHERE product_id = 100;
}

서버 A와 서버 B가 동시에 stock = 1을 읽으면 둘 다 재고 있음이라고 판단할 수 있습니다.

이 패턴은 매우 위험합니다.

5.4 안전한 패턴

UPDATE inventory
SET stock = stock - 1
WHERE product_id = 100
  AND stock > 0;

그리고 애플리케이션에서는 영향 받은 행 수(affected rows)를 확인하셔야 합니다.

  • 1이면 성공
  • 0이면 실패 또는 품절

이 방식은 실무에서 매우 중요합니다.


6. 분산락을 아주 기초부터 이해하기

6.1 분산락이 필요한 배경

서버가 1대일 때는 메모리 락이나 synchronized 같은 것으로 어느 정도 제어가 됩니다.

하지만 서버가 3대, 10대, 100대가 되면:

  • 각 서버 메모리는 서로 다르고
  • 각 서버는 서로의 락 상태를 모릅니다

따라서 공통으로 볼 수 있는 외부 저장소가 필요합니다.

이때 자주 쓰는 것이 Redis입니다.

6.2 가장 흔한 Redis 락 형태

보통 아래와 비슷하게 겁니다.

SET lock:coupon:123 random-token NX PX 5000

뜻은 아래와 같습니다.

  • NX: 없을 때만 생성
  • PX 5000: 5초 후 자동 만료
  • random-token: 내가 락 주인인지 확인하기 위한 값

성공하면 락 획득이고, 실패하면 이미 다른 서버가 선점한 상태입니다.

6.3 왜 토큰이 필요한가

락 해제 시 단순히 DEL lock:coupon:123을 실행하면 위험합니다.

예를 들어:

  1. 서버 A가 락 획득
  2. A가 오래 걸려서 TTL 만료
  3. 서버 B가 새 락 획득
  4. 뒤늦게 A가 DEL 실행

그러면 B의 락을 A가 지워버릴 수 있습니다.

그래서 "내가 건 락일 때만 해제"하셔야 합니다.

보통 compare-and-delete Lua 스크립트를 사용합니다.

if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("del", KEYS[1])
else
  return 0
end

6.4 분산락의 한계

분산락은 만능이 아닙니다.

  • 네트워크 지연
  • 프로세스 stop-the-world pause
  • GC pause
  • 락 TTL 만료
  • Redis 장애
  • 락 우회 코드 경로

등 때문에, 정합성의 유일한 근거로 삼으면 위험합니다.


7. 둘의 차이를 표로 비교

항목Atomic SQL분산락
주 보호 대상DB 내부 상태애플리케이션 임계 구역
적용 범위SQL 문, 트랜잭션, 행, 제약조건여러 서버 인스턴스 간
강도데이터와 가장 가까워 강함보조적이며 실패 가능성 고려 필요
대표 기술트랜잭션, 조건부 UPDATE, UNIQUE, UPSERTRedis SET NX PX, lease, token
잘하는 것정합성 보장, 중복 반영 방지중복 실행 억제, 작업 직렬화
잘하지 못하는 것외부 API 중복 호출 제어DB 정합성 최종 보장

이 표를 기억하시면 대부분의 판단이 쉬워집니다.


8. 둘 중 하나만 써도 되는 경우

8.1 Atomic SQL만으로 충분한 경우

아래 상황에서는 분산락 없이도 충분한 경우가 많습니다.

  • 재고 감소
  • 포인트 차감
  • 쿠폰 수량 감소
  • 중복 insert 방지
  • 상태 전이

예:

UPDATE coupon
SET remaining = remaining - 1
WHERE coupon_id = :coupon_id
  AND remaining > 0;

이 경우 중요한 것은 락이 아니라 DB가 실제 감소를 조건부로 수행하는 것입니다.

8.2 분산락만 쓰는 경우

가능은 하지만 주의가 필요합니다.

예:

  • 여러 서버 중 하나만 리포트 배치 실행
  • 캐시 워머 한 인스턴스만 동작
  • 검색 인덱스 리빌드 중복 방지

이 경우 최종 상태 정합성이 강하게 걸려 있지 않고, "중복 실행만 피하면 되는 작업"이면 분산락만으로도 운영은 가능합니다.

다만 실행 기록 테이블이나 idempotency key가 있으면 훨씬 안전합니다.

8.3 둘을 함께 쓰는 경우

아래 상황에서는 둘을 같이 쓰는 가치가 큽니다.

  • 경쟁이 심합니다
  • 같은 요청이 여러 인스턴스로 들어옵니다
  • 외부 API 호출이나 메시지 발행이 포함됩니다
  • 작업이 무겁고 중복 수행 비용이 큽니다
  • 그래도 DB 정합성은 절대 깨지면 안 됩니다

9. 대표 시나리오로 이해하기

시나리오 A. 선착순 쿠폰 발급

상황:

  • 쿠폰 수량 100개
  • 서버 20대
  • 같은 순간 10,000명 요청

목표:

  • 100명까지만 성공
  • 중복 발급 금지
  • 같은 사용자 두 번 발급 금지

이 문제를 네 가지 방식으로 살펴보겠습니다.

A-1. 분산락도 없고 Atomic SQL도 없음

위험한 흐름:

  1. 수량 조회
  2. 아직 남음 확인
  3. 발급 insert
  4. 수량 감소 update

문제점:

  • 동시에 읽어서 중복 발급이 가능합니다
  • 수량 초과가 가능합니다
  • 사용자가 중복 발급을 받을 수 있습니다

A-2. 분산락만 있음

겉보기에는 좋아 보일 수 있습니다.

  1. Redis 락 획득
  2. 수량 조회
  3. 발급
  4. 수량 감소
  5. 락 해제

문제점:

  • 락 TTL 만료 중 다른 서버가 진입할 수 있습니다
  • 락 없이 DB를 만지는 다른 코드가 있으면 무력화됩니다
  • DB 제약이 없으면 중복 insert가 가능합니다

즉, 분산락만으로는 충분하지 않습니다.

A-3. Atomic SQL만 있음

훨씬 나은 구조입니다.

예:

BEGIN;

UPDATE coupon
SET remaining = remaining - 1
WHERE coupon_id = :coupon_id
  AND remaining > 0;

-- affected rows = 1 인 경우에만 계속 진행

INSERT INTO coupon_issue (coupon_id, user_id, issued_at)
VALUES (:coupon_id, :user_id, NOW());

COMMIT;

그리고 coupon_issue(coupon_id, user_id)에 UNIQUE 제약을 둡니다.

이 구조만으로도 상당수 문제를 해결할 수 있습니다.

A-4. 분산락 + Atomic SQL 둘 다 사용

아래 상황에서는 이 방식이 더 적절할 수 있습니다.

  • 발급 과정에 외부 알림 발송이 있습니다
  • 동시에 수만 건이 몰려 DB 경쟁이 심합니다
  • 같은 사용자에 대해 중복 진입을 더 줄이고 싶습니다

흐름:

  1. lock:coupon:{coupon_id}:user:{user_id} 획득
  2. DB 트랜잭션 시작
  3. 중복 발급 여부를 UNIQUE 제약 또는 멱등성 키로 보호
  4. 쿠폰 잔량 감소를 조건부 UPDATE로 수행
  5. 발급 row insert
  6. COMMIT
  7. 락 해제
  8. 커밋 후 알림 발송

이 패턴의 의미는 아래와 같습니다.

  • 분산락: 같은 사용자/쿠폰 조합에 대한 과도한 중복 진입 완화
  • Atomic SQL: 최종 데이터 정합성 보장

10. 정말 중요한 사실: 락만으로는 재고를 보장하지 못합니다

많은 분들이 아래처럼 생각하십니다.

"락을 잡았으니 이제 안전하지 않나요?"

아직 아닙니다.

왜냐하면:

  • 락이 만료될 수 있고
  • 락을 잡지 않는 경로가 있을 수 있고
  • 수동 SQL, 배치, 관리자 페이지가 같은 테이블을 건드릴 수 있기 때문입니다

그래서 재고, 잔액, 포인트 같은 핵심 값은 항상 DB가 최종 보호해야 합니다.

안전한 재고 감소의 대표 패턴은 아래와 같습니다.

UPDATE inventory
SET stock = stock - :qty
WHERE product_id = :product_id
  AND stock >= :qty;

이 쿼리는 락이 없어도 DB 레벨에서 최종 판단을 합니다.

분산락은 이 쿼리 앞단의 경쟁을 줄이는 용도일 뿐입니다.


11. Atomic SQL의 핵심 도구들

11.1 조건부 UPDATE

가장 실무적이고 중요한 도구입니다.

UPDATE wallet
SET balance = balance - :amount
WHERE user_id = :user_id
  AND balance >= :amount;

의미:

  • 잔액이 충분할 때만 차감
  • 실패하면 영향 행 수 0

11.2 UNIQUE 제약조건

중복 요청 방지의 핵심입니다.

CREATE UNIQUE INDEX ux_coupon_issue_coupon_user
ON coupon_issue (coupon_id, user_id);

이렇게 하면 코드가 실수하더라도 동일 사용자 중복 발급이 DB에서 막힙니다.

11.3 UPSERT

이미 있으면 갱신하고 없으면 삽입합니다.

PostgreSQL 예:

INSERT INTO idempotency_request (request_id, created_at)
VALUES (:request_id, NOW())
ON CONFLICT (request_id) DO NOTHING;

이 패턴은 멱등성 구현에 매우 유용합니다.

11.4 SELECT ... FOR UPDATE

특정 행을 읽은 후 이어서 갱신할 때 다른 트랜잭션의 변경을 제어할 수 있습니다.

예:

BEGIN;

SELECT stock
FROM inventory
WHERE product_id = :product_id
FOR UPDATE;

UPDATE inventory
SET stock = stock - 1
WHERE product_id = :product_id;

COMMIT;

다만 가능하면 읽고 계산하고 쓰기보다 조건부 UPDATE 한 번으로 끝낼 수 있는지 먼저 검토하시는 것이 좋습니다.

11.5 낙관적 락(version column)

UPDATE document
SET title = :new_title,
    version = version + 1
WHERE document_id = :document_id
  AND version = :expected_version;

다른 누군가 먼저 바꾸었다면 갱신이 실패하도록 만드는 방식입니다.


12. 분산락의 핵심 도구들

12.1 락 키 설계

락 키는 너무 넓어도 안 되고, 너무 좁아도 안 됩니다.

예:

  • 너무 넓음: lock:coupon
  • 적절함: lock:coupon:{coupon_id}
  • 사용자 단위 중복 억제: lock:coupon:{coupon_id}:user:{user_id}

락 키 범위가 너무 넓으면 병렬성이 지나치게 떨어집니다.

12.2 TTL

분산락은 보통 lease 개념을 가집니다.

즉, 영원히 보유하는 락이 아니라:

  • 일정 시간만 유효하고
  • 작업이 길면 갱신할 수도 있습니다

TTL을 너무 짧게 잡으면 작업 중 만료됩니다.

TTL을 너무 길게 잡으면 장애 시 회복이 늦어집니다.

12.3 해제는 소유자만 해야 합니다

반드시 락 값에 random token을 넣고, 그 값이 일치할 때만 해제하셔야 합니다.

12.4 watchdog 또는 renew

작업 시간이 예측하기 어렵다면 갱신 메커니즘이 필요할 수 있습니다.

하지만 갱신이 많아질수록 설계는 복잡해집니다.

초보자 관점에서는:

  • 락 구간을 짧게 하고
  • 긴 작업은 락 안에서 하지 않고
  • 커밋 후 비동기로 넘기는 설계

가 훨씬 안전합니다.

12.5 fencing token

고급 주제이지만 매우 중요합니다.

락 TTL이 만료된 뒤 이전 락 보유자가 늦게 작업을 계속하는 문제를 막으려면 단순 락만으로는 부족할 수 있습니다.

이때 증가하는 fencing token을 발급해서:

  • 더 큰 토큰만 유효한 작업으로 인정

하는 방식이 사용됩니다.

초보 단계에서는 개념만 알아두셔도 충분합니다.


13. 함께 사용할 때의 안전한 기본 패턴

아래 패턴이 가장 보편적입니다.

패턴 개요

  1. 분산락 획득
  2. DB 트랜잭션 시작
  3. 멱등성 키 또는 UNIQUE 제약으로 중복 차단
  4. 조건부 UPDATE 또는 행 락으로 상태 변경
  5. 관련 row insert/update
  6. COMMIT
  7. 분산락 해제
  8. 외부 API 호출은 가능하면 커밋 후 처리

의사코드

token = tryAcquireRedisLock(lockKey, ttl)
if token == null:
    return "already processing"

try:
    begin transaction

    inserted = insertIdempotencyKey(requestId)
    if inserted == false:
        rollback
        return "duplicate request"

    updated = decrementInventoryIfEnough(productId, qty)
    if updated == false:
        rollback
        return "out of stock"

    createOrder(orderId, userId, productId, qty)

    commit

finally:
    releaseRedisLock(lockKey, token)

이 패턴의 핵심은 아래와 같습니다.

  • 락을 잡았더라도 DB가 마지막 판단을 합니다
  • 중복 요청은 requestId로 차단합니다
  • 재고 감소는 조건부 UPDATE로 처리합니다

14. 상세 예제 1: 선착순 쿠폰 발급

14.1 테이블 설계

CREATE TABLE coupon (
    coupon_id BIGINT PRIMARY KEY,
    remaining INT NOT NULL
);

CREATE TABLE coupon_issue (
    issue_id BIGSERIAL PRIMARY KEY,
    coupon_id BIGINT NOT NULL,
    user_id BIGINT NOT NULL,
    issued_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE UNIQUE INDEX ux_coupon_issue_coupon_user
ON coupon_issue (coupon_id, user_id);

14.2 핵심 SQL

UPDATE coupon
SET remaining = remaining - 1
WHERE coupon_id = :coupon_id
  AND remaining > 0;

14.3 애플리케이션 흐름

lockKey = "lock:coupon:" + couponId + ":user:" + userId
token = acquire(lockKey, ttl=3000)

if token == null:
    return "already trying"

try:
    BEGIN

    INSERT INTO coupon_issue (coupon_id, user_id)
    VALUES (:coupon_id, :user_id);
    -- UNIQUE 위반이면 이미 발급됨

    UPDATE coupon
    SET remaining = remaining - 1
    WHERE coupon_id = :coupon_id
      AND remaining > 0;
    -- affected rows = 0 이면 품절

    COMMIT

finally:
    release(lockKey, token)

14.4 주의할 점

위 흐름에서 insert와 update 순서는 비즈니스 규칙에 따라 달라질 수 있습니다.

예를 들어:

  • 중복 사용자 차단을 먼저 하고 싶다면 insert 먼저
  • 수량 확보를 먼저 하고 싶다면 update 먼저

어느 쪽이든 핵심은 아래 세 가지입니다.

  • 트랜잭션으로 묶어야 합니다
  • 실패 시 전체 롤백되어야 합니다
  • UNIQUE와 조건부 UPDATE를 함께 써야 합니다

15. 상세 예제 2: 주문 생성 + 외부 결제 승인

이 예제는 실무적으로 더 중요합니다.

문제

주문 생성 시 아래 작업이 함께 필요하다고 가정해 보겠습니다.

  1. 재고 차감
  2. 주문 row 생성
  3. 외부 결제 API 승인
  4. 알림 발송

여기서 많이 하는 실수는:

  • 락을 잡은 채로 외부 결제 API를 호출하는 것

입니다.

이 방식은 매우 위험합니다.

왜냐하면:

  • API가 느리면 락 TTL이 만료될 수 있고
  • 실패 재시도 시 중복 승인 위험이 생기고
  • 락 점유 시간이 길어지기 때문입니다

권장 구조

  1. 분산락 획득
  2. DB 트랜잭션 시작
  3. 멱등성 키 insert
  4. 조건부 재고 차감
  5. 주문 상태를 PENDING_PAYMENT로 생성
  6. outbox 테이블에 PAYMENT_REQUESTED 이벤트 기록
  7. COMMIT
  8. 분산락 해제
  9. 별도 소비자가 outbox를 읽어 결제 API 호출

이 구조의 장점은 아래와 같습니다.

  • DB 정합성과 외부 부작용을 분리할 수 있습니다
  • 재시도 설계가 쉬워집니다
  • 락을 오래 잡지 않아도 됩니다

16. 상세 예제 3: 스케줄러는 누가 실행할 것인가

여러 서버가 같은 배치를 동시에 실행하면 안 되는 경우가 많습니다.

예:

  • 매일 정산 배치
  • 캐시 리프레시
  • 인덱스 재빌드

이 경우에는 분산락이 매우 유용합니다.

SET lock:daily-settlement token NX PX 60000

락 획득에 성공한 서버만 배치를 실행합니다.

하지만 여기에도 좋은 습관이 있습니다.

  • 배치 실행 기록 테이블을 남깁니다
  • 실행 단위를 잘게 나눕니다
  • 이미 처리한 데이터는 멱등하게 건너뜁니다

즉, 스케줄러에도 Atomic SQL과 멱등성은 여전히 중요합니다.


17. 분산락 + Atomic SQL의 실패 시나리오

이 장이 핵심입니다.

실패 시나리오 1. 락 TTL 만료 후 이중 진입

상황:

  1. 서버 A가 락 획득
  2. A가 오래 걸림
  3. TTL 만료
  4. 서버 B가 락 획득
  5. A와 B가 동시에 작업 진행

대응:

  • 락 안에서 오래 걸리는 작업을 하지 않습니다
  • 필요하면 renew/watchdog를 사용합니다
  • 최종 DB 갱신은 Atomic SQL로 보호합니다

실패 시나리오 2. DB는 커밋됐는데 응답이 유실됨

상황:

  1. 주문 생성 성공
  2. 커밋 성공
  3. 응답 전에 네트워크 오류 발생
  4. 클라이언트 재시도

대응:

  • idempotency key를 사용합니다
  • 요청 ID를 UNIQUE로 저장합니다

실패 시나리오 3. 락 획득 후 외부 API 중복 호출

상황:

  1. 락 획득
  2. 결제 승인 호출
  3. 타임아웃
  4. 재시도

대응:

  • 결제 API 자체의 idempotency key를 사용합니다
  • 외부 부작용은 커밋 후 별도 처리합니다

실패 시나리오 4. 락은 있는데 SQL이 원자적이지 않음

상황:

  1. 락 획득
  2. SELECT stock
  3. 앱에서 계산
  4. UPDATE stock

문제점:

  • TTL 만료 시 이중 진입이 가능합니다
  • 다른 경로가 업데이트하면 취약합니다

대응:

  • 조건부 UPDATE로 바꾸셔야 합니다

실패 시나리오 5. 락 없이 DB 직접 수정하는 운영 도구 존재

상황:

  • 관리자 페이지
  • 수동 SQL
  • 배치 프로그램

이들이 Redis 락을 모르고 테이블을 수정할 수 있습니다.

대응:

  • 핵심 정합성 규칙은 DB 제약조건으로 강제하셔야 합니다

18. 실무에서 자주 쓰는 조합들

조합 1. UNIQUE + UPSERT + idempotency key

용도:

  • 중복 요청 방지
  • 같은 결제 요청 두 번 처리 방지

조합 2. 조건부 UPDATE + affected rows 확인

용도:

  • 재고
  • 포인트
  • 잔액

조합 3. 분산락 + Atomic SQL

용도:

  • 요청 폭주
  • 같은 키 단위로 직렬화 필요
  • 비싼 작업 중복 수행 방지

조합 4. 분산락 + outbox

용도:

  • 외부 API 호출
  • 메시지 발행
  • 느린 후처리

조합 5. SELECT FOR UPDATE + 트랜잭션

용도:

  • 여러 row를 읽고 복잡한 비즈니스 규칙을 판단한 뒤 갱신할 때

19. 데이터베이스 락과의 관계

분산락을 쓰면 종종 아래와 같은 질문이 나옵니다.

그러면 SELECT FOR UPDATE는 안 써도 되나요?

답은 상황에 따라 다릅니다.

  • 단순 카운터 감소라면 조건부 UPDATE가 더 단순할 수 있습니다
  • 복잡한 읽기 후 판단이 필요하면 행 락이 필요할 수 있습니다

중요한 점은:

  • 분산락은 DB 밖의 조정 장치이고
  • 행 락은 DB 안의 정합성 장치라는 점입니다

둘은 대체 관계가 아니라 보완 관계입니다.

또 한 가지:

  • PostgreSQL의 advisory lock 같은 DB 내부 락은
  • "모든 워커가 같은 DB를 공유"하고
  • "외부 시스템까지 확장할 필요가 없을 때"

분산락 대신 사용할 수도 있습니다.

다만 이 경우에는 DB 종속성이 강해진다는 점도 함께 고려하셔야 합니다.


20. 초보자가 실무에서 바로 적용할 수 있는 판단법

아래 질문 순서대로 생각하시면 됩니다.

질문 1. 이 문제는 순수하게 DB 상태 변경 문제인가

예:

  • 재고 차감
  • 포인트 차감
  • 중복 발급 방지

그렇다면 먼저 Atomic SQL만으로 해결 가능한지 보셔야 합니다.

질문 2. 외부 API, 파일, 메시지, 장시간 작업이 포함되는가

그렇다면 분산락이나 작업 직렬화가 필요할 가능성이 큽니다.

질문 3. 같은 요청의 재시도가 자연스럽게 일어나는가

그렇다면 idempotency key가 필요합니다.

질문 4. 락이 만료되어도 최종 정합성이 유지되는가

이 질문에 라고 답하실 수 있어야 합니다.

그렇지 않다면 설계가 약한 것입니다.

질문 5. 락 없이 들어오는 우회 경로가 있는가

있다면 DB 제약조건을 더 강하게 두셔야 합니다.


21. 권장 아키텍처 패턴

패턴 A. 가장 추천하는 기본형

  • 분산락은 진입량 완화용
  • DB는 조건부 UPDATE, UNIQUE, 트랜잭션으로 최종 보호
  • 외부 연동은 outbox로 분리

이 패턴이 가장 균형이 좋습니다.

패턴 B. 정말 단순한 경우

  • 분산락 없음
  • Atomic SQL만 사용

예:

  • 단순 재고 감소
  • 좋아요 수 증가
  • 포인트 차감

오히려 이쪽이 더 깔끔하고 안전한 경우가 많습니다.

패턴 C. 배치/리더 선출형

  • 분산락 중심
  • 처리 기록 테이블로 보조

예:

  • 스케줄러
  • 리포트 생성
  • 단일 리더 작업

22. 안티패턴

안티패턴 1. 락만 믿고 DB 제약조건이 없음

가장 흔하고 위험합니다.

안티패턴 2. 락을 잡은 채 외부 API를 장시간 호출함

TTL 만료와 대기 폭증의 원인이 됩니다.

안티패턴 3. unlock 시 토큰 검증 없이 DEL 수행

남의 락을 지울 수 있습니다.

안티패턴 4. 멱등성 키가 없음

재시도 환경에서 중복 처리가 발생합니다.

안티패턴 5. 너무 넓은 락 키

불필요하게 전체 서비스를 직렬화합니다.

안티패턴 6. SELECT -> if -> UPDATE를 앱에서 나누어 처리함

가능하면 조건부 UPDATE로 바꾸시는 것이 좋습니다.


23. 운영 체크리스트

배포 전에는 최소한 아래를 확인하시는 것이 좋습니다.

설계 체크

  • 정합성의 최종 보호 장치가 DB에 있는가
  • UNIQUE 또는 조건부 UPDATE가 있는가
  • 멱등성 키가 있는가
  • 락 TTL은 현실적인가
  • 락 키 범위가 적절한가

구현 체크

  • 락 값에 랜덤 토큰을 저장하는가
  • 해제 시 compare-and-delete를 사용하는가
  • affected rows를 검사하는가
  • 트랜잭션 실패 시 전체 롤백되는가

운영 체크

  • 락 획득 실패율을 모니터링하는가
  • 락 점유 시간을 모니터링하는가
  • 중복 요청 수를 기록하는가
  • DB deadlock, lock wait를 관찰하는가

24. 아주 짧은 실무 규칙 10개

  1. 분산락은 보조 장치입니다.
  2. 정합성은 DB가 책임져야 합니다.
  3. 가능한 한 조건부 UPDATE를 먼저 검토하셔야 합니다.
  4. 중복 방지는 UNIQUE와 idempotency key로 해결하셔야 합니다.
  5. 락 구간은 짧게 유지하셔야 합니다.
  6. 락 안에서 외부 API를 오래 호출하지 않으셔야 합니다.
  7. 락 해제는 소유자만 해야 합니다.
  8. 락 만료 후 재진입을 가정하고 설계하셔야 합니다.
  9. 재시도는 반드시 일어난다고 생각하셔야 합니다.
  10. "락이 없어도 최종 상태가 안전한가"를 항상 물어보셔야 합니다.

25. 질문과 답변

Q1. 분산락만 있으면 재고 초과 판매를 막을 수 있나요

완전히 믿으시면 안 됩니다. 락 만료, 우회 경로, 구현 실수 때문에 DB 자체가 조건부 UPDATE나 제약조건으로 보호되어야 합니다.

Q2. Atomic SQL만으로도 충분한 경우가 있나요

매우 많습니다. 단순 재고 차감, 중복 insert 방지, 포인트 차감 등은 오히려 Atomic SQL만으로 푸는 편이 더 단순하고 강합니다.

Q3. 분산락은 언제 추가하나요

외부 부작용이 있거나, 요청 폭주가 심하거나, 무거운 작업의 중복 비용이 클 때 추가하시면 됩니다.

Q4. SELECT FOR UPDATE와 분산락은 같은 역할인가요

아닙니다. 하나는 DB 행 보호이고, 다른 하나는 여러 앱 인스턴스 간 진입 제어입니다.

Q5. 가장 중요한 한 문장만 말하면 무엇인가요

분산락으로 진입을 줄이고, Atomic SQL로 최종 상태를 지키십시오.


26. 마지막 요약

분산락과 Atomic SQL은 같은 문제를 해결하지 않습니다.

  • 분산락은 여러 서버 사이의 경쟁을 줄입니다
  • Atomic SQL은 데이터베이스 안의 상태를 안전하게 바꿉니다

둘을 함께 사용할 때 가장 올바른 생각은 아래와 같습니다.

  1. 가능하면 먼저 Atomic SQL만으로 해결할 수 있는지 봅니다.
  2. 외부 부작용이나 과도한 경쟁이 있으면 분산락을 추가합니다.
  3. 그래도 최종 정합성은 반드시 DB 제약조건과 트랜잭션으로 보호합니다.

이 세 줄만 흔들리지 않으면 대부분의 설계 실수를 피하실 수 있습니다.


27. 참고 자료

아래는 이 문서를 정리할 때 참고한 공식 문서입니다.

profile
공부하고 기록하고 공유하는 개발자 팀(Tim) 입니다. 늘끄적입니다.

0개의 댓글