1. 1인 1매를 지키려는 세 가지 코드
쿠폰 이벤트를 만들었습니다. 조건은 두 개입니다. 한 사람이 한 장만 받을 수 있고, 전체 100장까지만 나갑니다.
처음 짠 코드는 이랬습니다.
if (couponRepository.existsBy(eventId, userId)) {
throw new AlreadyIssuedException();
}
couponRepository.save(new Coupon(eventId, userId));
이 코드는 요구사항을 만족시키지 못하죠. 확인과 저장 사이가 벌어져 있어서, 두 요청이 동시에 들어오면 둘 다 통과합니다. 한 사람이 32번 누르게 하고 재봤습니다.
조회 후 삽입 (락 없음) 발급 32장 / 29장 / 7장 (회차마다 다름)
한 장만 나가야 하는데 매번 다른 수가 나갑니다. 예상대로입니다.

그래서 락을 걸었습니다
조회할 때 아예 잠가버리면 그 틈이 없어질 것 같습니다.
@Transactional
public void issue(Long eventId, String userId) {
// SELECT ... WHERE event_id = ? AND user_id = ? FOR UPDATE
Coupon found = couponRepository.findForUpdate(eventId, userId);
if (found != null) throw new AlreadyIssuedException();
couponRepository.save(new Coupon(eventId, userId));
}
같은 조건으로 다시 재보면 이렇습니다.
FOR UPDATE 후 삽입 발급 1장 (3회 모두 동일)
막혔습니다. 한 사람이 아무리 여러 번 눌러도 한 장만 나갑니다. 여기까지만 보면 문제가 해결된 것 같습니다.
그런데 실제 이벤트는 이렇게 생기지 않았습니다
지금 잰 것은 한 사람이 32번 누르는 상황입니다. 진짜 쿠폰 이벤트는 그렇지 않습니다. 32명이 각자 한 번씩 누릅니다. 서로 아무 관계도 없는 사람들입니다.
그래서 user_id를 전부 다르게 해서 다시 돌렸습니다. 32명이 각자 한 장씩 신청하니 32명 전원이 받아야 정상입니다.
성공 데드락
FOR UPDATE 후 삽입 1 31
FOR UPDATE 후 삽입 4 28
FOR UPDATE 후 삽입 1 31
서른 명 안팎이 데드락으로 떨어졌습니다. 서로 다른 쿠폰을 신청한, 아무 상관 없는 사람들이 서로를 막다가 DB에게 강제로 죽었습니다.
락을 지우고 DB에 유니크 제약만 걸어서 같은 부하를 돌리면 이렇습니다.
UNIQUE KEY ux_issue (event_id, user_id)
성공 데드락 평균 응답
유니크 제약에 맡긴다 32 0 4ms
유니크 제약에 맡긴다 32 0 2ms
유니크 제약에 맡긴다 32 0 4ms
전원이 받습니다. 그리고 같은 사람이 여러 번 누르는 앞의 상황에서도 한 장만 나갑니다.
발급 중복 키
유니크 제약, 같은 사람 32번 1 31 (3회 모두 동일)
두 코드가 남남 두 사람 기준으로 어떻게 갈리는지 그리면 이렇습니다.

정리하면 이렇습니다.
락 없이 조회 후 삽입 같은 사람이 여러 장 받는다
FOR UPDATE 후 삽입 같은 사람은 막지만, 남남끼리 서른 명 안팎이 죽는다
유니크 제약 같은 사람만 정확히 막는다
같은 일을 시켰는데 결과가 이렇게 갈립니다. 제약이 안에서 무엇을 하고 있길래 남남을 안 막는지, 그리고 락은 왜 막는지 보겠습니다.
2. 유니크 제약은 넣으려는 시도끼리만 부딪히게 합니다
DB는 유니크 검사를 언제 할까요?
흔한 예상은 이렇습니다. 저장하기 직전에 한 번 조회해서 이미 있는지 보고, 없으면 넣는다. 검사와 삽입이 순서대로 두 번 일어난다는 그림입니다.
실제로는 검사라는 단계가 따로 없습니다.
인덱스는 값이 순서대로 정렬된 나무 구조입니다. 새 값을 넣으려면 뿌리에서 시작해 잎까지 내려가면서 그 값이 어디로 들어갈지 찾아야 합니다. 그런데 거기까지 내려가고 나면 같은 값이 이미 있는지도 함께 드러납니다. 넣을 곳을 찾는 탐색과 중복을 확인하는 탐색이 같은 하나입니다. 검사하고 넣는 것이 아니라 넣으려다 걸리는 것입니다.

MySQL 소스를 보기 전에, 먼저 간단히 짚고갈 부분이 있습니다.
인덱스는 디스크에 있습니다. 회원이 300만 명이면 인덱스도 수 기가바이트라 통째로 메모리에 올려 둘 수 없습니다. 그래서 DB는 필요한 부분만 조각으로 잘라 메모리에 올려 놓고 씁니다. 그 조각을 페이지라고 부릅니다.
새 회원 한 명을 넣는다고 해봅시다. 그 값이 들어갈 페이지가 마침 메모리에 있으면 다행인데, 인덱스가 크면 대개 없습니다. 그러면 디스크에서 그 페이지를 읽어 와야 하고, 이게 느립니다.
그래서 InnoDB는 꾀를 냅니다. 페이지가 메모리에 없으면 디스크를 읽지 않고, "이 페이지에 이 값을 넣기로 했다"는 메모만 따로 적어 두는 것이죠. 그리고 나중에 그 페이지를 다른 이유로 읽게 되는 날 메모를 한꺼번에 반영합니다. 디스크 읽기를 한 번 아끼거나, 여러 변경을 모아 한 번에 처리하게 됩니다.
이게 되는 이유는 일반 인덱스에 넣는 일이 부탁이기 때문입니다. "이 값 추가해 줘"라고 하면 DB는 알겠다고 답하면 그만입니다. 실제로 언제 반영하든 결과가 같습니다.
그런데 유니크 인덱스는 부탁이 아니라 질문입니다. "이 값 추가해 줘, 근데 이미 있으면 알려 줘"입니다. 애플리케이션은 그 답을 받아야 성공을 줄지 거절을 줄지 정합니다. 그리고 이미 있는지 알려면 그 페이지를 지금 읽어야 합니다. 미룰 수가 없습니다.
실제로 InnoDB는 유니크 인덱스에 이 최적화를 쓰지 않습니다. 그 함수의 주석이 이유를 말해줍니다.

세 번째 줄입니다. Does not do it if the index is clustered or unique. 인덱스가 유니크면 이 최적화를 쓰지 않는다고 적혀 있습니다. 즉, 버퍼 오퍼레이션 없이 바로 삽입을 시도합니다.
만약 검사와 삽입이 별개의 두 단계였다면 삽입만 미루는 것이 가능했을 겁니다. 지금 페이지를 읽어서 중복인지 답해 주고, 실제로 넣는 일만 메모로 남겨 두면 되니까요. 그런데 InnoDB는 그렇게 하지 않습니다. 둘이 한 동작이라 쪼갤 수가 없기 때문입니다.
이런 점에서 유니크 제약은 공짜는 아닙니다. 일반 인덱스라면 아꼈을 디스크 읽기를 넣을 때마다 실제로 하니까요. 검사를 삽입 안으로 넣은 대가입니다.
그래서 남남은 안 부딪힙니다
어쨌든 이것으로 원하는 바는 성취합니다. 검사가 인덱스에 그 값을 넣으려는 시도 안에 들어 있으니, 부딪히는 것은 같은 값을 넣으려는 사람끼리입니다. user-3과 user-26은 애초에 다른 값을 넣으므로 만날 일이 없습니다.
매뉴얼에서도 같은 부분을 찾아볼 수 있는데요. 삽입 직전에 잡는 락이 하나 있는데, 그것끼리는 서로 막지 않는다고 적혀 있습니다.
... but do not block each other because the rows are nonconflicting.
(넣으려는 행이 서로 다르므로 서로를 막지 않는다.)
그래서 32명이 각자 다른 쿠폰을 신청하면 32명 전원이 몇 밀리초 안에 받습니다.
3. 락은 없는 것을 잠그려다 남까지 막습니다
이제 반대편입니다. SELECT ... FOR UPDATE는 찾은 행을 잠급니다. 그런데 이 상황에서 찾은 행이 없다는 게 문제입니다. 아무도 아직 그 쿠폰을 안 받았으니까요.
잠글 행이 없는데도 DB는 "지금 없다"는 판단을 트랜잭션이 끝날 때까지 지켜줘야 합니다. 그 사이에 남이 그 값을 넣어버리면 방금 한 판단이 거짓이 되기 때문입니다. 그래서 대신 그 값이 들어올 빈 구간을 잠급니다.
그렇다면 "빈 구간"은 무엇일까요? 인덱스는 값이 정렬된 목록인데,
(1, user-1)
(1, user-5) <- user-5와 user-9 사이는 비어 있다
(1, user-9)
user-7을 FOR UPDATE로 찾으면 없는 것입니다. 그 "없음"을 트랜잭션이 끝날 때까지 지키려면 user-5와 user-9 사이의 빈 틈에 아무도 새 값을 못 넣게 해야 합니다. 그래서 DB가 잠그는 것이 그 틈입니다.
그런데 이 실험에서는 테이블이 비어 있습니다. 값이 하나도 없으니 틈도 하나뿐입니다. 처음부터 끝까지가 통째로 한 틈입니다. 그래서 user-3을 찾아도 user-26을 찾아도 잠기는 것은 같은 틈 하나입니다.
여기서 의문이 한가지 생깁니다. 32명이 전부 같은 틈을 잠그는데 아무도 안 막힌다니, 왜일까요? 잠금이면 하나만 쥘 수 있어야 할 것 같은데 그렇지 않습니다.
두 잠금의 뜻이 다르기 때문입니다.
행 잠금 "이 행은 내가 쓴다" -> 소유. 그래서 한 트랜잭션만 쥔다
틈 잠금 "이 틈에 새 값이 들어오면 안 된다" -> 금지. 여럿이 동시에 말해도 모순이 아니다
서른두 명이 동시에 "여기 아무도 못 들어와"라고 말하는 것은 서로 어긋나지 않습니다. 그래서 32명이 전부 잠그고 전부 "없습니다"를 받습니다. 여기까지는 조용합니다.
그 다음이 문제입니다. 이제 각자 자기 값을 넣으려 합니다. 삽입은 "이 틈에 값을 넣겠다"인데, 남들이 이미 "이 틈에 아무도 못 들어와"를 걸어 두었습니다. 정면으로 반대되는 두 선언이라 하나가 기다려야 합니다.
user-3은 자기가 건 금지에는 안 걸립니다. 자기 트랜잭션이니까요.- 그런데
user-26이 건 금지에는 걸립니다. 그래서 기다립니다. user-26도 똑같이user-3이 건 금지에 걸려서 기다립니다.
둘 다 상대가 트랜잭션을 끝내야 풀리는데, 둘 다 끝내지 못합니다. 순환 대기입니다. DB가 한쪽을 죽여야 풀립니다.
InnoDB가 남긴 데드락 기록을 보면 다음과 같네요.
*** (1) TRANSACTION:
INSERT INTO coupon_no_constraint (event_id, user_id) VALUES (2, 'user-3')
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS ... index ix_issue ... lock_mode X
0: len 8; hex 73757072656d756d; asc supremum;;
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS ... index ix_issue ... lock_mode X insert intention waiting
*** (2) TRANSACTION:
INSERT INTO coupon_no_constraint (event_id, user_id) VALUES (2, 'user-26')
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS ... index ix_issue ... lock_mode X
user-3과 user-26입니다. 서로 아무 상관 없는 두 사람이 같은 구간을 쥐고 서로의 삽입을 기다리다 하나가 죽었습니다. 쥔 대상으로 찍힌 supremum은 항목이 하나도 없는 인덱스의 그 빈 구간을 가리키는 이름입니다.

한 줄로 줄이면 이렇습니다. 없는 것은 잠글 수 없습니다. 락은 이미 있는 것을 남이 못 건드리게 하는 도구입니다. 아직 없는 것이 생기지 못하게 하려면 그 값이 들어올 구간을 통째로 잠그는 수밖에 없고, 그 구간에는 남이 넣으려는 값까지 들어갑니다.
앞에서 같은 사람 32명일 때 락이 잘 막았던 것도 이제 설명됩니다. 그때는 어차피 전원이 같은 값을 노렸으니 구간을 통째로 잠그는 것이 문제가 되지 않았습니다. 락은 그 상황에서만 맞아떨어진 것이지 1인 1매를 지켜준 것이 아닙니다.
여기까지가 첫 번째 조건이었습니다. 그러면 두 번째 조건, 전체 100장은 어떻게 지킬 수 있을까요?
4. 재고는 조건부 갱신이 지킵니다
재고는 존재의 문제가 아닙니다. 행은 이미 있고, 그 행의 값이 조건을 만족하는지가 문제입니다. 그래서 도구가 달라집니다.
UPDATE coupon_event
SET remaining = remaining - 1
WHERE id = ? AND remaining > 0
단순한 업데이트 쿼리입니다. 이 쿼리는 안전하죠. 조건을 보는 일과 값을 고치는 일이 같은 한 문장 안에 있습니다. 그 사이에 다른 요청이 끼어들 틈이 없습니다.
그리고 DB는 이때 트랜잭션이 시작될 때 찍힌 옛날 값이 아니라 다른 사람이 방금 커밋한 최신 값을 보고 조건을 따집니다. 기본 격리 수준인 REPEATABLE READ에서도 그렇습니다.
The snapshot of the database state applies to SELECT statements within a transaction, not necessarily to DML statements. ... If a transaction does update or delete rows committed by a different transaction, those changes do become visible to the current transaction.
(트랜잭션 안에서 찍은 스냅샷은 그 트랜잭션의 SELECT에 적용되고, DML에는 반드시 적용되지는 않는다. 다른 트랜잭션이 커밋한 행을 갱신하거나 삭제하면 그 변경은 현재 트랜잭션에도 보이게 된다.)
- MySQL 8.0 Reference Manual, 17.7.2.3 Consistent Nonlocking Reads dev.mysql.com/doc/refman/8.0/en/innodb-consistent-read.html
그래서 200명이 몰려도 정확히 100명에서 멈춥니다. SELECT로 읽어 두었던 값이 아무리 낡았어도, UPDATE가 조건을 따질 때는 그 순간의 최신 값을 봅니다.
다만 실패가 조용합니다
앞의 두 실험에서 실패는 시끄러웠습니다. 중복 키든 데드락이든 예외가 날아오고 로그에 스택 트레이스가 쌓입니다. 놓치려야 놓칠 수 없습니다.
재고 쪽은 다릅니다. 다음 상황을 살펴봅시다.
CREATE TABLE coupon_event (id INT PRIMARY KEY, remaining INT NOT NULL);
INSERT INTO coupon_event VALUES (1, 100); -- 100장으로 시작
요청 200개를 동시에 넣습니다. 각 요청은 "한 장 주세요"이고, 서버는 위 SQL을 실행한 뒤 사용자에게 성공 또는 실패를 응답합니다. 재고가 100이니 성공 응답은 정확히 100번 나가야 정상입니다.
같은 의도를 세 가지 방식으로 쓰면 다음과 같습니다. 응답을 결정하는 방식이 다릅니다.
// A 조건부 갱신을 쓰고, 몇 행이 바뀌었는지 확인한다
int rows = exec("UPDATE coupon_event SET remaining = remaining - 1 " +
"WHERE id = 1 AND remaining > 0");
if (rows == 1) 성공(); else 실패();
// B 조건부 갱신을 쓰지만, 반환값을 보지 않는다
exec("UPDATE coupon_event SET remaining = remaining - 1 " +
"WHERE id = 1 AND remaining > 0");
성공(); // 예외가 안 났으니 됐다고 친다
// C 먼저 읽고, 애플리케이션에서 계산해서 쓴다
int cur = query("SELECT remaining FROM coupon_event WHERE id = 1");
if (cur > 0) {
exec("UPDATE coupon_event SET remaining = " + (cur - 1) + " WHERE id = 1");
성공();
}
A가 정석입니다. B는 반환값을 안 보는 흔한 실수이고, C는 조회한 엔티티를 고치는 방식이라 ORM을 쓰면 저절로 만들어지는 모양입니다.
200개 요청이 끝난 뒤 세 가지를 셌습니다. 성공 응답 수는 서버가 "받으셨습니다"라고 답한 횟수, 남은 재고는 remaining 최종값, 과다 발급은 성공 응답 수에서 실제로 나간 장수를 뺀 것입니다.
성공 응답 수 남은 재고 과다 발급
A 조건부 갱신, 반환값 확인 100 0 0
B 조건부 갱신, 반환값 무시 200 0 100
C 읽고 계산해서 쓰기 200 92 192
재밌는 게 B입니다. DB는 A와 똑같이 완벽합니다. 재고가 0에서 멈췄고 한 장도 더 나가지 않았습니다. 그런데 200명 전원에게 성공했다고 알렸습니다. 실제로 받은 사람은 100명인데 나머지 100명도 받았다고 믿고 화면을 닫습니다.

그동안 예외는 한 번도 발생하지 않았습니다. UPDATE는 조건에 맞는 행이 없으면 아무것도 고치지 않고 그냥 정상 종료합니다. 실패가 아니라 "0건 갱신 성공"입니다. 패배를 알려주는 것은 예외가 아니라 반환된 행 수뿐입니다.
C는 문제가 있는 방식이죠. 읽은 값으로 계산해서 쓰면 WHERE remaining > 0이 사라지고 SET remaining = 87 같은 절대값이 나갑니다. 조건이 통째로 없어집니다. 열 명이 동시에 remaining을 읽으면 열 명 다 같은 값을 보고 열 명 다 같은 값을 씁니다.
열 장이 나갔는데 재고는 한 장만 줄어듭니다. 그래서 재고가 100에서 92로만 줄었고 200명이 받았다고 믿었습니다.
그리고 C는 3장의 락과 같은 실패입니다. 조건을 DB 문장 밖으로 꺼내서 애플리케이션이 판단했습니다. 앞에서는 "이미 받았나"를 밖에서 확인했고 여기서는 "얼마 남았나"를 밖에서 계산했습니다.
그런데 왜 두 가지로 나뉘어 있을까요
여기서 한 번 생각해볼 부분이 있습니다. 쿠폰 이벤트의 두 조건은 사람이 보기에 비슷합니다. 하나는 "이미 받았으면 안 준다"이고 하나는 "다 나갔으면 안 준다"입니다. 둘 다 "어떤 조건이 맞을 때만 쓴다"입니다.
그런데 MySQL에서는 이 둘을 완전히 다른 장치로 지켰습니다. 존재는 인덱스 구조가 지키고, 값은 UPDATE 문장의 WHERE 절이 지킵니다. 쓰는 문법도 다르고, 진 쪽이 받는 신호도 다릅니다. 한쪽은 예외가 날아오고 한쪽은 반환값이 0입니다. 같은 서비스 안에서 실패를 처리하는 방법이 둘로 갈립니다.
과연 다른 DB에서는 어떻게 할지 궁금해졌습니다. AWS의 DynamoDB는 어떨까요?
5. 다이나모DB는 둘을 하나로 합쳐 두었습니다
다이나모DB에는 조건부 쓰기(conditional write)가 있습니다. 쓰기 요청에 조건을 같이 실어 보내고, 조건이 맞을 때만 쓰게 하는 기능입니다. 공식 문서의 설명은 이렇습니다.

쓰는 방법은 이렇습니다.
붙일 수 있는 연산 PutItem, UpdateItem, DeleteItem
조건을 적는 자리 ConditionExpression 파라미터
조건이 틀렸을 때 ConditionalCheckFailedException
세 연산 전부에 같은 방식으로 붙습니다. 그래서 앞의 두 조건도 같은 모양으로 적힙니다.
// 1인 1매. 이 키를 가진 아이템이 없을 때만 쓴다
{ "TableName": "coupon",
"Item": { "pk": { "S": "user#3" } },
"ConditionExpression": "attribute_not_exists(pk)" }
// 재고. remaining이 0보다 클 때만 쓴다
{ "TableName": "coupon",
"Key": { "pk": { "S": "event#1" } },
"UpdateExpression": "SET remaining = remaining - :one",
"ConditionExpression": "remaining > :zero",
"ExpressionAttributeValues": { ":one": {"N":"1"}, ":zero": {"N":"0"} } }
관계형에서 유니크 제약과 조건부 갱신으로 갈라졌던 두 조건이 여기서는 같은 파라미터 한 줄입니다.
다이나모DB 구조의 핵심은 다음과 같이 생각해볼 수 있을 것 같네요.
- 저장 단위는 아이템 하나이고, 관계형의 한 행에 해당합니다. 키로 특정합니다.
- 테이블은 키를 기준으로 여러 서버에 나뉘어 저장됩니다. 어떤 키가 어느 서버로 가는지는 정해져 있습니다.
로컬에 띄워서 앞과 같은 부하를 돌리면 이렇습니다.
32명이 같은 키로 신청, attribute_not_exists 성공 1 조건 실패 31
200명, 재고 100, remaining > 0 성공 100 조건 실패 100
결과적으로는 앞서 했던 MySQL DB에서 정상적으로 처리됐던 결과와 같습니다. 다른 것은 실패를 알려주는 방식이 두 조건에서 같다는 점입니다. 관계형에서는 존재 쪽이 예외를 던지고 값 쪽은 반환값만 바꿔서, 같은 서비스 안에서 실패를 받는 방법이 둘로 갈렸습니다. 다이나모DB는 둘 다 예외입니다.
{"__type":"...#ConditionalCheckFailedException","Message":"The conditional request failed"}
락이 없는데 순서는 누가 정할까
락이 없는데 어떻게 정확히 100명에서 멈출까요. 200개 요청이 동시에 "남았냐"를 물으면 전부 통과해야 할 것 같습니다.
답은 요청이 지나는 길에 있습니다. 요청이 아무 서버에나 도착하지 않습니다. 앞단의 라우터가 그 키가 어느 서버 담당인지 찾아서 그쪽으로 보냅니다. 그리고 그 서버들 중에서 쓰기를 실제로 처리하는 것은 하나뿐입니다.

같은 키를 건드리는 요청은 예외 없이 같은 서버 하나에 모입니다. 그 서버가 요청을 하나씩 처리하면서 읽고, 비교하고, 씁니다. 그 셋 사이에 다른 요청이 끼어들 방법이 없습니다. 애초에 같은 줄에 서 있기 때문입니다.
줄을 세우는 것이 락이 아니라 경로입니다.
그 서버는 순서를 어떻게 정할까
리더에 도착한 200개도 여전히 "동시에" 온 것입니다. 그러면 리더는 무엇을 기준으로 순서를 매길까요. 위 인용문의 뒷부분이 답입니다.
리더는 요청을 받아도 데이터를 곧바로 고치지 않습니다. 먼저 "이 요청을 이렇게 처리한다"를 기록으로 남깁니다. 기록은 목록이라 적히는 순간 앞뒤가 생깁니다. 동시에 도착한 200개가 여기서 1번부터 200번까지 번호를 받습니다. 조건 평가는 그 번호대로 일어납니다. 100번까지는 remaining이 0보다 크고, 101번부터는 아닙니다.
그리고 그 기록이 과반수 복제본에 저장되어야 응답이 나갑니다. 리더 혼자 적고 끝내면 리더가 죽었을 때 그 순서가 사라지기 때문입니다.
리더가 죽으면 줄도 사라지지 않을까
경로로 줄을 세운다는 말은 그 경로가 흔들리지 않을 때만 성립합니다. 리더도 서버라서 죽습니다. 그러면 두 서버가 동시에 자기가 리더라고 믿는 순간이 생길 수 있고, 그 틈에 양쪽이 각자 조건을 평가하면 100장짜리 쿠폰이 200장 나갑니다.
다이나모DB는 리더 권한에 기한을 붙입니다. 리더는 그 기한을 주기적으로 갱신해야 리더로 남습니다.
Once elected leader, a replica can maintain leadership as long as it periodically renews its leadership lease.
(리더로 뽑힌 복제본은 리더십 임대를 주기적으로 갱신하는 한 리더를 유지한다.)
- Elhemali et al., Amazon DynamoDB, USENIX ATC 2022, 5쪽 왼쪽 단 usenix.org/system/files/atc22-elhemali.pdf
그리고 새 리더가 뽑혀도 곧바로 일하지 않습니다.
The new leader won't serve any writes or consistent reads until the previous leader's lease expires.
(새 리더는 이전 리더의 임대가 만료될 때까지 쓰기와 강한 일관성 읽기를 처리하지 않는다.)
- 같은 논문 같은 쪽
이전 리더가 아직 자기가 리더인 줄 알고 있더라도 그 기한이 끝나면 더는 쓰지 못합니다. 새 리더는 그때까지 기다립니다. 그래서 경로가 두 개로 갈라지는 구간이 생기지 않습니다.

그리고 키가 다르면 아예 다른 서버로 갑니다. 남남끼리 부딪힐 일이 구조적으로 없습니다. 앞에서 MySQL에서의 비관락이 남이 넣으려는 값까지 잠갔던 것과 다르게 작동하는 것을 볼 수 있습니다.
6. 결국 하나로 줄면 이렇습니다
확인하는 곳과 쓰는 곳을 붙여 두면 지켜지고, 떼어 놓으면 깨집니다.
이 글에 나온 코드를 전부 이 한 줄로 가를 수 있습니다. 하나씩 보겠습니다.
이 글에서 깨진 코드는 셋이었습니다.
조회 후 삽입 같은 사람이 여러 장 "이미 있나"를 밖에서 확인했다
FOR UPDATE 후 삽입 남남끼리 서른 명 데드락 "이미 있나"를 밖에서 잠가서 확인했다
읽고 계산해서 UPDATE 192장 과다 발급 "얼마 남았나"를 밖에서 계산했다
세 코드는 겉보기에 아주 다릅니다. 하나는 아무 방어도 없고, 하나는 비관적 락까지 걸었고, 하나는 ORM이 만들어준 것입니다. 그런데 실패하는 이유가 같습니다. 셋 다 조건을 DB 문장 밖으로 꺼내서 애플리케이션이 판단했습니다.
락을 건 코드가 특히 그렇습니다. 락은 그 틈을 없애 줄 것 같지만, 잠글 대상이 아직 없으면 구간을 통째로 잠그는 수밖에 없고 거기엔 남이 넣으려는 값까지 들어갑니다.
지켜진 코드도 셋이었습니다.
유니크 제약 남남 전원 통과 검사가 인덱스 진입 시도 안에 있다
UPDATE ... WHERE remaining > 0 정확히 100명 조건이 UPDATE 문장 안에 있다
조건식 하나 (다이나모DB) 둘 다 정확 조건이 쓰기 요청 안에 있다
이쪽도 셋이 같습니다. 조건을 쓰기 연산 안으로 밀어 넣었습니다. 검사와 쓰기가 한 덩어리라 그 사이가 벌어지지 않고, 부딪히는 대상이 같은 것을 노리는 요청으로 좁혀집니다.
그리고 셋의 내부 구현은 서로 완전히 다릅니다. 유니크 인덱스는 B-tree 진입으로, 조건부 갱신은 최신 커밋 값을 보는 읽기로, 다이나모DB는 키마다 정해진 서버 하나를 지나게 해서 같은 일을 합니다. 방법이 다른데 결과가 같은 이유는 셋 다 같은 원칙 위에 서 있기 때문입니다.
락을 걸어도 안 되는 이유
락이 억울해 보일 수 있습니다. 락은 사실 그 이름에서부터 알 수 있듯이, 좀 직관적입니다. 그러니까 틈을 없애라고 있는 도구 같다는 말이죠. 실제로 락은 이미 있는 행을 다룰 때는 제 몫을 합니다. 잔액이 있는 계좌 행을 잠그고 고치는 것은 정확히 락이 할 일입니다.
문제는 락을 건 대상이 아직 없는 행이었다는 점입니다. 없는 것에는 걸 수가 없으니 DB가 대신 그 근처를 통째로 잠갔고, 거기에 남이 넣으려는 값까지 들어갔습니다. 락이 틀린 도구여서가 아니라 틀린 대상에 걸었기 때문입니다.
그래서 코드에서는
그래서 결론적으로 압축하면 이런 질문을 던져볼 수 있겠습니다. 이 조건을 판단하는 것은 애플리케이션인가요, DB인가요?
코드에서 이런 모양이 보이면 애플리케이션이 판단하고 있는 것입니다.
if (repo.existsBy(...)) throw ...; // 확인은 여기, 쓰기는 아래
repo.save(...);
Coupon found = repo.findForUpdate(...); // 락을 걸어도 마찬가지다
if (found != null) throw ...;
repo.save(...);
int cur = repo.findRemaining(...); // 빼는 계산도 애플리케이션이 한다
repo.updateRemaining(cur - 1);
DB에게 넘기면 이렇게 바뀝니다.
try { // 존재는 유니크 제약이 본다
repo.save(new Coupon(eventId, userId));
} catch (DuplicateKeyException e) {
throw new AlreadyIssuedException();
}
int rows = repo.decreaseIfLeft(eventId); // 값은 WHERE 절이 본다
if (rows == 0) throw new SoldOutException();
두 번째 줄이 특히 중요합니다. rows를 안 보면 아무 일도 없었던 것처럼 지나갑니다. 이 글에서 가장 조용했던 사고가 그것을 확인하지 않아 생긴 사고였습니다. 조건을 DB에게 넘겼으면 그 답도 반드시 읽어야 합니다.
직접 돌려보기
본문에 등장한 테스트 결과들은 모두 도커 환경에서 측정된 수치들입니다. 제 로컬 환경과 완벽히 똑같은 조건에서 코드를 직접 돌려보고 눈으로 결과를 확인해 보실 수 있도록 준비해 두었습니다.
터미널을 열고 아래 명령어를 가볍게 입력해 보세요!
git clone https://github.com/renechoi/only-once-lab
cd only-once-lab && ./run.sh
💡 편안한 실습을 위한 안내:
- 준비물은 도커 하나면 충분합니다: 복잡한 환경 설정이나 JDK 설치로 스트레스받으실 필요가 없습니다. 모든 실험은 도커 컨테이너라는 독립된 공간 안에서 깔끔하게 실행됩니다.
- 알아서 다 해줍니다: 스크립트를 실행하기만 하면 MySQL 8과 DynamoDB 로컬 버전이 자동으로 띄워지고, 앞서 글에서 설명해 드린 실험들이 순서대로 진행됩니다.
- 결과를 비교해 보세요: 프로젝트 폴더 안에 있는 sample-run.txt 파일에는 제가 직접 테스트를 돌렸을 때의 실행 로그가 그대로 담겨 있습니다.
참고 자료
- MySQL 8.0 Reference Manual, 17.7.1 InnoDB Locking https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html
- MySQL 8.0 Reference Manual, 17.7.3 Locks Set by Different SQL Statements https://dev.mysql.com/doc/refman/8.0/en/innodb-locks-set.html
- MySQL Server 소스,
ibuf_insert(태그 mysql-8.0.40) https://github.com/mysql/mysql-server/blob/mysql-8.0.40/storage/innobase/ibuf/ibuf0ibuf.cc#L3272-L3282 - Amazon DynamoDB Developer Guide, Working with items and attributes https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/WorkingWithItems.html
- Elhemali et al., Amazon DynamoDB: A Scalable, Predictably Performant, and Fully Managed NoSQL Database Service, USENIX ATC 2022 https://www.usenix.org/conference/atc22/presentation/elhemali

A Note on Graphics
이 글에 사용된 모든 다이어그램과 일러스트 이미지는 개념의 시각화를 위해 생성형 AI 도구를 활용해 제작되었습니다.
'엔지니어링' 카테고리의 다른 글
| 직접 쓴 논문 리뷰하기: JPA 성능에 대해 우리가 착각하던 세 가지 (0) | 2026.07.29 |
|---|---|
| 실시간처럼 보이지만, 사실은 뒤에서 작업했어요: Pending URL을 활용한 이미지 업로드 UX 개선기 (2) | 2025.08.30 |
| 훔칠 비밀번호가 없습니다: 평문 없는 서버를 위한 Passkey 구현기 (0) | 2025.08.23 |
| 이벤트 드리븐 트러블슈팅 Ep. 2: 사라진 혜택, 중복된 포인트? Kafka 메시지 처리, 어디까지 믿어야 할까 (1) | 2025.06.01 |
| 이벤트 드리븐 트러블슈팅 Ep. 1: 비동기 결제 파이프라인에서 구글 API가 늦을 때 생기는 일 (1) | 2025.05.10 |