이 문서는 아래에 해당하는 분들을 위한 입문용 교육 자료입니다.
트랜잭션, 락, 분산 시스템이라는 단어가 아직 낯선 분분산락 + 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은 안쪽 금고입니다.둘을 같이 쓰는 이유는:
가장 중요한 결론은 아래 한 문장입니다.
데이터 정합성의 최종 책임은 분산락이 아니라 데이터베이스가 져야 합니다.
초보자가 가장 많이 하시는 오해는 보통 아래 세 가지입니다.
그렇지 않습니다. Redis 락은 "누가 먼저 들어오느냐"를 조절할 뿐입니다.
즉, 분산락만으로는 충분하지 않습니다.
이 역시 아닙니다. 트랜잭션은 데이터베이스 내부 상태를 일관되게 만드는 데는 강하지만:
같은 문제는 DB 트랜잭션만으로 해결되지 않습니다.
둘은 다릅니다.
DB 락은 데이터와 가깝고 강합니다. 분산락은 범위가 넓지만 상대적으로 약합니다.
분산락은 은행 입구의 번호표 시스템입니다.Atomic SQL은 금고 문 자체입니다.입구 통제만 잘해도 어느 정도 안전해 보일 수 있습니다. 하지만 금고 문이 허술하면 내부 자산은 결국 망가집니다.
반대로 금고 문이 튼튼하더라도 사람들이 몰려와 복잡한 외부 절차를 중복 실행하면 운영 문제가 생길 수 있습니다.
그래서 둘은 같은 도구가 아니라, 서로 다른 역할을 하는 도구라고 보셔야 합니다.
Atomic SQL은 주로 아래 문제를 해결합니다.
즉, DB 안의 상태를 안전하게 바꾸는 것이 목표입니다.
대표 기법은 아래와 같습니다.
SELECT ... FOR UPDATE분산락은 주로 아래 문제를 해결합니다.
즉, 애플리케이션 레벨에서 임계 구역 진입을 줄이는 것이 목표입니다.
둘을 같이 쓰는 전형적인 이유는 아래와 같습니다.
예를 들면:
분산락은 보조 장치입니다.
최종 상태는 반드시 아래 방식 중 하나 이상으로 보호하셔야 합니다.
분산락은 최종 정답이 아니라, 경쟁을 줄이는 도우미입니다.
분산락을 오래 잡고 있으면:
DB 커밋과 외부 API 호출은 같은 트랜잭션에 넣을 수 없습니다.
그래서 자주 쓰는 패턴이:
입니다.
분산 시스템에서는 재시도가 거의 필수입니다.
따라서:
결과가 한 번만 반영되도록 설계하셔야 합니다.
원자성은 전부 아니면 전무(all or nothing)를 뜻합니다.
예를 들어 계좌이체를 생각해 보겠습니다.
중간에 장애가 나서 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 되어야 합니다.
꼭 여러 문장을 묶어야만 원자적인 것은 아닙니다.
아래 같은 단일 문장은 매우 강력합니다.
UPDATE inventory
SET stock = stock - 1
WHERE product_id = 100
AND stock > 0;
이 쿼리는 아래 세 단계를 앱에서 따로 하지 않습니다.
대신 DB 안에서 한 번에 수행합니다.
이 방식이 중요한 이유는 레이스 컨디션을 크게 줄여 주기 때문입니다.
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을 읽으면 둘 다 재고 있음이라고 판단할 수 있습니다.
이 패턴은 매우 위험합니다.
UPDATE inventory
SET stock = stock - 1
WHERE product_id = 100
AND stock > 0;
그리고 애플리케이션에서는 영향 받은 행 수(affected rows)를 확인하셔야 합니다.
이 방식은 실무에서 매우 중요합니다.
서버가 1대일 때는 메모리 락이나 synchronized 같은 것으로 어느 정도 제어가 됩니다.
하지만 서버가 3대, 10대, 100대가 되면:
따라서 공통으로 볼 수 있는 외부 저장소가 필요합니다.
이때 자주 쓰는 것이 Redis입니다.
보통 아래와 비슷하게 겁니다.
SET lock:coupon:123 random-token NX PX 5000
뜻은 아래와 같습니다.
NX: 없을 때만 생성PX 5000: 5초 후 자동 만료random-token: 내가 락 주인인지 확인하기 위한 값성공하면 락 획득이고, 실패하면 이미 다른 서버가 선점한 상태입니다.
락 해제 시 단순히 DEL lock:coupon:123을 실행하면 위험합니다.
예를 들어:
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
분산락은 만능이 아닙니다.
등 때문에, 정합성의 유일한 근거로 삼으면 위험합니다.
| 항목 | Atomic SQL | 분산락 |
|---|---|---|
| 주 보호 대상 | DB 내부 상태 | 애플리케이션 임계 구역 |
| 적용 범위 | SQL 문, 트랜잭션, 행, 제약조건 | 여러 서버 인스턴스 간 |
| 강도 | 데이터와 가장 가까워 강함 | 보조적이며 실패 가능성 고려 필요 |
| 대표 기술 | 트랜잭션, 조건부 UPDATE, UNIQUE, UPSERT | Redis SET NX PX, lease, token |
| 잘하는 것 | 정합성 보장, 중복 반영 방지 | 중복 실행 억제, 작업 직렬화 |
| 잘하지 못하는 것 | 외부 API 중복 호출 제어 | DB 정합성 최종 보장 |
이 표를 기억하시면 대부분의 판단이 쉬워집니다.
아래 상황에서는 분산락 없이도 충분한 경우가 많습니다.
예:
UPDATE coupon
SET remaining = remaining - 1
WHERE coupon_id = :coupon_id
AND remaining > 0;
이 경우 중요한 것은 락이 아니라 DB가 실제 감소를 조건부로 수행하는 것입니다.
가능은 하지만 주의가 필요합니다.
예:
이 경우 최종 상태 정합성이 강하게 걸려 있지 않고, "중복 실행만 피하면 되는 작업"이면 분산락만으로도 운영은 가능합니다.
다만 실행 기록 테이블이나 idempotency key가 있으면 훨씬 안전합니다.
아래 상황에서는 둘을 같이 쓰는 가치가 큽니다.
상황:
목표:
이 문제를 네 가지 방식으로 살펴보겠습니다.
위험한 흐름:
문제점:
겉보기에는 좋아 보일 수 있습니다.
문제점:
즉, 분산락만으로는 충분하지 않습니다.
훨씬 나은 구조입니다.
예:
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 제약을 둡니다.
이 구조만으로도 상당수 문제를 해결할 수 있습니다.
아래 상황에서는 이 방식이 더 적절할 수 있습니다.
흐름:
lock:coupon:{coupon_id}:user:{user_id} 획득이 패턴의 의미는 아래와 같습니다.
많은 분들이 아래처럼 생각하십니다.
"락을 잡았으니 이제 안전하지 않나요?"
아직 아닙니다.
왜냐하면:
그래서 재고, 잔액, 포인트 같은 핵심 값은 항상 DB가 최종 보호해야 합니다.
안전한 재고 감소의 대표 패턴은 아래와 같습니다.
UPDATE inventory
SET stock = stock - :qty
WHERE product_id = :product_id
AND stock >= :qty;
이 쿼리는 락이 없어도 DB 레벨에서 최종 판단을 합니다.
분산락은 이 쿼리 앞단의 경쟁을 줄이는 용도일 뿐입니다.
가장 실무적이고 중요한 도구입니다.
UPDATE wallet
SET balance = balance - :amount
WHERE user_id = :user_id
AND balance >= :amount;
의미:
중복 요청 방지의 핵심입니다.
CREATE UNIQUE INDEX ux_coupon_issue_coupon_user
ON coupon_issue (coupon_id, user_id);
이렇게 하면 코드가 실수하더라도 동일 사용자 중복 발급이 DB에서 막힙니다.
이미 있으면 갱신하고 없으면 삽입합니다.
PostgreSQL 예:
INSERT INTO idempotency_request (request_id, created_at)
VALUES (:request_id, NOW())
ON CONFLICT (request_id) DO NOTHING;
이 패턴은 멱등성 구현에 매우 유용합니다.
특정 행을 읽은 후 이어서 갱신할 때 다른 트랜잭션의 변경을 제어할 수 있습니다.
예:
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 한 번으로 끝낼 수 있는지 먼저 검토하시는 것이 좋습니다.
UPDATE document
SET title = :new_title,
version = version + 1
WHERE document_id = :document_id
AND version = :expected_version;
다른 누군가 먼저 바꾸었다면 갱신이 실패하도록 만드는 방식입니다.
락 키는 너무 넓어도 안 되고, 너무 좁아도 안 됩니다.
예:
lock:couponlock:coupon:{coupon_id}lock:coupon:{coupon_id}:user:{user_id}락 키 범위가 너무 넓으면 병렬성이 지나치게 떨어집니다.
분산락은 보통 lease 개념을 가집니다.
즉, 영원히 보유하는 락이 아니라:
TTL을 너무 짧게 잡으면 작업 중 만료됩니다.
TTL을 너무 길게 잡으면 장애 시 회복이 늦어집니다.
반드시 락 값에 random token을 넣고, 그 값이 일치할 때만 해제하셔야 합니다.
작업 시간이 예측하기 어렵다면 갱신 메커니즘이 필요할 수 있습니다.
하지만 갱신이 많아질수록 설계는 복잡해집니다.
초보자 관점에서는:
가 훨씬 안전합니다.
고급 주제이지만 매우 중요합니다.
락 TTL이 만료된 뒤 이전 락 보유자가 늦게 작업을 계속하는 문제를 막으려면 단순 락만으로는 부족할 수 있습니다.
이때 증가하는 fencing token을 발급해서:
하는 방식이 사용됩니다.
초보 단계에서는 개념만 알아두셔도 충분합니다.
아래 패턴이 가장 보편적입니다.
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)
이 패턴의 핵심은 아래와 같습니다.
requestId로 차단합니다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);
UPDATE coupon
SET remaining = remaining - 1
WHERE coupon_id = :coupon_id
AND remaining > 0;
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)
위 흐름에서 insert와 update 순서는 비즈니스 규칙에 따라 달라질 수 있습니다.
예를 들어:
어느 쪽이든 핵심은 아래 세 가지입니다.
이 예제는 실무적으로 더 중요합니다.
주문 생성 시 아래 작업이 함께 필요하다고 가정해 보겠습니다.
여기서 많이 하는 실수는:
입니다.
이 방식은 매우 위험합니다.
왜냐하면:
PENDING_PAYMENT로 생성PAYMENT_REQUESTED 이벤트 기록이 구조의 장점은 아래와 같습니다.
여러 서버가 같은 배치를 동시에 실행하면 안 되는 경우가 많습니다.
예:
이 경우에는 분산락이 매우 유용합니다.
SET lock:daily-settlement token NX PX 60000
락 획득에 성공한 서버만 배치를 실행합니다.
하지만 여기에도 좋은 습관이 있습니다.
즉, 스케줄러에도 Atomic SQL과 멱등성은 여전히 중요합니다.
이 장이 핵심입니다.
상황:
대응:
상황:
대응:
상황:
대응:
상황:
SELECT stockUPDATE stock문제점:
대응:
상황:
이들이 Redis 락을 모르고 테이블을 수정할 수 있습니다.
대응:
UNIQUE + UPSERT + idempotency key용도:
조건부 UPDATE + affected rows 확인용도:
분산락 + Atomic SQL용도:
분산락 + outbox용도:
SELECT FOR UPDATE + 트랜잭션용도:
분산락을 쓰면 종종 아래와 같은 질문이 나옵니다.
그러면
SELECT FOR UPDATE는 안 써도 되나요?
답은 상황에 따라 다릅니다.
중요한 점은:
둘은 대체 관계가 아니라 보완 관계입니다.
또 한 가지:
분산락 대신 사용할 수도 있습니다.
다만 이 경우에는 DB 종속성이 강해진다는 점도 함께 고려하셔야 합니다.
아래 질문 순서대로 생각하시면 됩니다.
예:
그렇다면 먼저 Atomic SQL만으로 해결 가능한지 보셔야 합니다.
그렇다면 분산락이나 작업 직렬화가 필요할 가능성이 큽니다.
그렇다면 idempotency key가 필요합니다.
이 질문에 예라고 답하실 수 있어야 합니다.
그렇지 않다면 설계가 약한 것입니다.
있다면 DB 제약조건을 더 강하게 두셔야 합니다.
이 패턴이 가장 균형이 좋습니다.
예:
오히려 이쪽이 더 깔끔하고 안전한 경우가 많습니다.
예:
가장 흔하고 위험합니다.
TTL 만료와 대기 폭증의 원인이 됩니다.
남의 락을 지울 수 있습니다.
재시도 환경에서 중복 처리가 발생합니다.
불필요하게 전체 서비스를 직렬화합니다.
SELECT -> if -> UPDATE를 앱에서 나누어 처리함가능하면 조건부 UPDATE로 바꾸시는 것이 좋습니다.
배포 전에는 최소한 아래를 확인하시는 것이 좋습니다.
완전히 믿으시면 안 됩니다. 락 만료, 우회 경로, 구현 실수 때문에 DB 자체가 조건부 UPDATE나 제약조건으로 보호되어야 합니다.
매우 많습니다. 단순 재고 차감, 중복 insert 방지, 포인트 차감 등은 오히려 Atomic SQL만으로 푸는 편이 더 단순하고 강합니다.
외부 부작용이 있거나, 요청 폭주가 심하거나, 무거운 작업의 중복 비용이 클 때 추가하시면 됩니다.
SELECT FOR UPDATE와 분산락은 같은 역할인가요아닙니다. 하나는 DB 행 보호이고, 다른 하나는 여러 앱 인스턴스 간 진입 제어입니다.
분산락으로 진입을 줄이고, Atomic SQL로 최종 상태를 지키십시오.
분산락과 Atomic SQL은 같은 문제를 해결하지 않습니다.
둘을 함께 사용할 때 가장 올바른 생각은 아래와 같습니다.
이 세 줄만 흔들리지 않으면 대부분의 설계 실수를 피하실 수 있습니다.
아래는 이 문서를 정리할 때 참고한 공식 문서입니다.