서버 애플리케이션에는 여러 요청이 동시에 들어온다. 서로 영향을 주지 않는 요청이라면 문제가 없지만, 같은 상태나 업무 조건에 의존해 변경하거나 외부 작업을 실행하는 요청은 순서가 엇갈릴 수 있다. 요청 하나만 실행할 때는 올바른 코드라도 여러 요청이 함께 실행되면 결과가 달라지는 이유다. 계좌 잔액처럼 현재 상태와 처리 기록이 맞아야 하고, 그 상태에 근거한 행동도 올바르게 이루어져야 하는 데이터에서는 이 차이가 단순한 화면 오류로 끝나지 않는다.
이번에는 계좌 출금을 예로 들어 동시성 문제가 무엇인지 살펴본다. 이어 조건부 UPDATE로 해결할 수 있는 경우를 확인하고, 낙관적 락과 비관적 락의 충돌 처리 방식을 비교한다. 애플리케이션 인스턴스 하나와 MySQL 하나에 출금 요청 50건을 동시에 보내 대기와 재시도가 실제 처리 결과에 어떤 차이를 만드는지도 확인한다.
무엇이 잘못될 수 있을까?
Lost update: 먼저 쓴 값을 나중 요청이 덮어쓴다
잔액이 1억 원인 계좌에서 두 사람이 각각 10만 원을 출금한다고 해보자. 각 요청은 잔액을 읽어 출금 가능한지 확인하고, 읽은 값에서 10만 원을 뺀 새 잔액을 저장한다.
| 순서 | 요청 A: 10만 원 출금 요청 | B: 10만 원 출금 DB 잔액 | |
| 초기 | 1억 원 | ||
| 1 | 잔액 1억 원을 읽고 출금 가능하다고 판단한다. | 1억 원 | |
| 2 | 잔액 1억 원을 읽고 출금 가능하다고 판단한다. | 1억 원 | |
| 3 | 읽은 1억 원에서 10만 원을 뺀 9,990만 원을 저장하고 커밋한다. | 9,990만 원 | |
| 4 | 자신이 읽은 1억 원에서 10만 원을 뺀 9,990만 원을 저장하고 커밋한다. | 9,990만 원 |
10만 원씩 두 번 출금했으니 잔액은 9,980만 원이어야 한다. 하지만 출금 기록은 두 건인데 DB 잔액은 9,990만 원으로, 한 건만 차감됐다. 두 요청 모두 각자의 트랜잭션을 정상적으로 커밋할 수 있다. 트랜잭션을 사용했다는 사실만으로 이전에 읽은 값에 근거한 계산이 최신 상태인지 검증되지는 않는다. 이것이 lost update다.
Write skew: 서로 다른 행을 바꿔 전체 조건을 깨뜨린다
이번에는 계좌 A와 B에 각각 1천만 원이 있고, 두 계좌의 합계를 최소 1천만 원으로 유지한다는 가상의 업무 규칙이 있다고 해보자. 각 요청은 두 계좌의 합계를 확인한 뒤 자기 계좌에서 1천만 원을 출금한다.
| 순서 | 요청 A: 계좌 A에서 1천만 원 | 출금 요청 B: 계좌 B에서 1천만 원 출금 | DB 상태: A / B / 합계 |
| 초기 | 1천만 원 / 1천만 원 / 2천만 원 | ||
| 1 | 합계 2천만 원을 읽는다. 자신만 출금하면 합계가 1천만 원이므로 승인한다. | 1천만 원 / 1천만 원 / 2천만 원 | |
| 2 | 같은 합계 2천만 원을 읽고, 자신만 출금하면 합계가 1천만 원이므로 승인한다. | 1천만 원 / 1천만 원 / 2천만 원 | |
| 3 | 계좌 A를 0원으로 바꾸고 커밋한다. | 0원 / 1천만 원 / 1천만 원 | |
| 4 | 계좌 B를 0원으로 바꾸고 커밋한다. | 0원 / 0원 / 0원 |
각 요청은 자신이 읽은 상태에서는 최소 합계 1천만 원을 지킨다고 판단했다. 그러나 둘 다 커밋하자 합계는 0원이 됐다. 두 요청은 서로 다른 행을 갱신하므로 같은 값을 덮어쓰는 lost update도 발생하지 않는다.
이 상황이 write skew다. MySQL InnoDB의 REPEATABLE READ에서도 발생할 수 있다. 이 격리 수준에서 일반 SELECT를 반복하면 트랜잭션 안에서는 같은 스냅샷을 읽지만, 읽은 행을 잠그지는 않는다. A와 B는 각각 합계 2천만 원을 보고 출금을 승인한 뒤 서로 다른 계좌 행을 갱신하므로 둘 다 커밋할 수 있다. 반복 조회 결과가 같다는 보장과, 조회할 때 확인한 합계 조건이 커밋 뒤에도 유지된다는 보장은 다르다.
TOCTOU: 확인할 때와 사용할 때 상태가 달라진다
TOCTOU(Time-of-check to time-of-use)는 상태를 확인한 시점과 그 결과를 사용한 시점 사이에 상태가 바뀌는 경쟁 상태다. 동시성 문제는 DB의 최종 값이 틀릴 때만 생기지 않는다. 잔액이 10만 원인 계좌에서 요청 A는 10만 원을 출금하고, 요청 B는 잔액을 확인해 예정된 10만 원 자동이체를 처리할 수 있다는 알림을 보낸다고 해보자.
| 순서 | 요청 A: 10만 원 출금 요청 | B: 자동이체 안내 DB | 잔액 / 알림 |
| 초기 | 10만 원 / 없음 | ||
| 1 | 잔액 10만 원을 읽고 자동이체 예정액을 낼 수 있다고 판단한다. | 10만 원 / 없음 | |
| 2 | 10만 원을 출금하고 커밋한다. | 0원 / 없음 | |
| 3 | 앞서 확인한 결과를 바탕으로 '자동이체 준비 완료' 알림을 보낸다. | 0원 / 준비 완료 알림 |
출금은 정상적으로 처리돼 DB 잔액도 0원이다. 하지만 요청 B가 읽은 10만 원은 알림을 보낼 때 더는 현재 잔액이 아니다. 이 사례는 읽기 자체가 처음부터 잘못된 stale read라기보다, 확인한 상태가 바뀌었는데도 그 결과를 나중에 그대로 사용한 문제다. 잔액을 조회한 시점의 정보만 안내할 수는 있지만, 그 조회만으로 미래의 자동이체 성공을 확정해서는 안 된다. 실제 이체를 처리할 때 잔액 조건을 다시 확인해야 한다.
어떻게 해결해야할까?
한 행의 조건이라면 조건부 UPDATE부터
어떤 락 방식을 선택하기 전에, 조건부 UPDATE 한 문장으로 조건 검사와 변경을 함께 처리할 수 있는지도 검토해야 한다. 앞의 lost update 사례처럼 출금 가능 여부가 한 계좌 행의 잔액으로 결정된다면 WHERE balance >= :amount를 붙여 차감하고, 변경된 행이 1개인지 확인하면 된다. 별도의 잔액 조회와 애플리케이션의 판단을 거치지 않으므로, 조회 후 갱신 사이에 값이 바뀌는 문제와 그에 따른 버전 충돌 재시도 로직을 줄일 수 있다.
UPDATE account
SET balance = balance - :amount
WHERE id = :accountId AND balance >= :amount;
조건부 UPDATE도 해당 행에 쓰기 잠금을 건다. 장점은 잠금이 없거나 무조건 더 빠르다는 것이 아니라, 조건 확인과 변경을 DB의 한 문장에 묶어 처리 경로를 단순하게 만든다는 데 있다. 잠금은 트랜잭션이 끝날 때까지 유지되므로, 출금 기록 저장 등 함께 처리할 작업이 있다면 트랜잭션을 짧게 유지해야 한다. 여러 행의 합계처럼 한 행의 조건만으로 판단할 수 없는 경우에는 이 방식만으로 충분하지 않다. 앞의 알림 사례처럼 별도로 조회한 잔액에 근거한 행동이 나중에도 올바른지는 조건부 UPDATE 한 문장으로 보장할 수 없다.
낙관적 락: 충돌하면 누가 다시 시도할까?
낙관적 락은 읽을 때 잠그지 않고, 갱신할 때 읽었던 버전이 그대로인지 확인한다. 계좌의 잔액과 버전이 각각 1억 원, 0이었다면 다음과 같이 버전 0을 조건으로 출금한다.
SELECT balance, version FROM account WHERE id = :accountId;
UPDATE account
SET balance = balance - :amount, version = version + 1
WHERE id = :accountId
AND version = :readVersion
AND balance >= :amount;
변경된 행이 1개면 출금 기록을 같은 트랜잭션에 저장하고 커밋한다. 0개면 잔액 부족이나 버전 충돌을 구분해야 한다. 버전 충돌이라면 이전에 읽은 값으로 UPDATE만 반복해서는 안 된다. 새 트랜잭션에서 잔액과 버전을 다시 읽고 출금 가능 여부부터 판단한다. 락의 흐름을 더 작게 보면 재고가 1개 남은 상품에 구매 요청 A와 B가 동시에 들어오는 상황과 같다.
| 순서 | 요청 A: 상품 1개 구매 요청 | B: 상품 1개 구매 | 재고 / 버전 |
| 초기 | 1개 / 0 | ||
| 1 | 재고 1개, 버전 0을 읽는다. | 재고 1개, 버전 0을 읽는다. | 1개 / 0 |
| 2 | 버전 0을 조건으로 재고를 0개로 바꾸고 커밋한다. 구매 성공 | 0개 / 1 | |
| 3 | 버전 0으로 갱신하지만 변경된 행이 0개다. 버전 충돌 | 0개 / 1 | |
| 4 | 새 트랜잭션에서 재고 0개를 읽고 품절로 구매 실패 처리한다. | 0개 / 1 |
표에서 B의 첫 갱신 실패는 품절 판정이 아니라 충돌 감지다. 출금 실험에서는 50건의 총 요청액이 잔액보다 작으므로, 충돌한 요청도 최신 잔액을 읽으면 다시 출금할 수 있다. 문제는 그 재시도를 누가, 몇 번, 얼마나 기다렸다가 하느냐다. 서버가 재시도하면 요청 처리 시간이 늘고 같은 작업을 다시 수행한다. 서버가 곧바로 409를 반환하면 클라이언트가 재요청 정책을 가져야 한다. 사용자가 직접 다시 누르더라도 그 요청이 또 충돌할 수 있고, 이전 실패와 사용자 반응 시간까지 최종 완료 시간에 더해진다.
이번 구현은 충돌 시 트랜잭션을 끝내고 1~10ms를 무작위로 기다린 뒤 새 트랜잭션에서 재시도한다. 최대 100번 시도하고도 성공하지 못하면 409를 반환한다. 재시도 없는 정책도 별도 API로 실행해 두 정책의 차이를 확인했다.
여러 행의 조건을 검증할 때
앞의 write skew 사례처럼 두 계좌의 합계를 지켜야 한다면 한 계좌의 버전만 검사하는 낙관적 락으로는 부족하다. 요청 A가 계좌 A의 버전만, 요청 B가 계좌 B의 버전만 검사하면 서로 다른 행을 갱신하므로 버전 충돌이 없고, 둘 다 합계 조건을 깨뜨릴 수 있다. 이때는 관련 행의 상태를 공통으로 검증할 수 있는 버전 관리 지점을 두거나, 조건을 DB에서 원자적으로 검사하도록 설계를 바꿔야 한다. 충돌 후에는 읽었던 여러 행과 그에 따른 계산을 함께 다시 해야 하므로 재시도 범위도 넓어진다.
비관적 락: 먼저 잠그고 순서를 만든다
비관적 락은 잔액을 읽을 때 MySQL의 SELECT ... FOR UPDATE로 해당 행을 잠근다. 뒤따르는 요청은 앞 트랜잭션이 끝날 때까지 기다렸다가 갱신된 잔액을 읽는다.
START TRANSACTION;
SELECT balance FROM account WHERE id = :accountId FOR UPDATE;
-- 잔액이 충분하면 계좌를 갱신하고 출금 기록을 저장한다.
COMMIT;
같은 재고 1개 사례에서는 다음처럼 진행된다.
| 순서 | 요청 A: 상품 1개 구매 요청 | B: 상품 1개 구매 재고 | 재고 |
| 초기 | 1개 | ||
| 1 | FOR UPDATE로 재고 행을 잠그고 1개를 읽는다. | 1개 | |
| 2 | FOR UPDATE에서 잠금 해제를 기다린다. | 1개 | |
| 3 | 재고를 0개로 바꾸고 커밋한다. 구매 성공 | 0개 | |
| 4 | 잠금을 얻은 뒤 0개를 읽고 품절로 구매 실패 처리한다. | 0개 |
계좌에 모든 출금을 처리할 돈이 있다면 뒤 요청은 기다린 다음 성공할 수 있다. 다만 대기 시간이 무한정 허용되는 것은 아니다. 잠금 대기 시간 초과나 데드락이 발생하면 실패 처리와 트랜잭션 재시도 정책이 필요하다. 잠근 채 외부 API를 호출하거나 긴 계산을 하면 뒤 요청의 대기가 그만큼 길어지므로 트랜잭션은 짧게 유지해야 한다.
여러 행을 잠글 때
앞의 write skew 사례를 비관적 락으로 풀려면 합계 판단에 필요한 행을 같은 트랜잭션에서 잠가야 한다. 한 행만 FOR UPDATE로 읽고 다른 행은 일반 SELECT로 읽으면 전체 조건을 보호하지 못한다. 여러 행을 잠글 때는 모든 코드 경로가 같은 순서로 잠그도록 정해야 한다.
계좌 A와 B를 함께 다루는 두 트랜잭션이 반대 순서로 행을 잠그면 서로를 기다리며 데드락이 발생할 수 있다. 여러 행을 다루는 트랜잭션에는 일정한 잠금 순서를 적용하고, 데드락이 발생하면 실패한 트랜잭션 전체를 다시 시도해야 한다.
낙관적 락과 비관적 락 비교
계좌 잔액은 1억 원이고, 서로 다른 요청 ID를 가진 50명이 동시에 10만 원씩 출금하는 상황을 가정해보자. 요청액 합계는 500만 원이므로 모두 처리되면 출금 기록은 50건이며 총 500만 원, 최종 잔액은 9,500만 원이어야 한다. 낙관적 락과 비관적 락 모두 잔액을 읽은 뒤 같은 5ms의 업무 처리 지연을 넣었다.
계좌 잔액을 1억 원으로 초기화한 뒤 50건의 출금 요청을 동시에 보냈다. 이를 정책마다 다섯 번 반복했으며, 표의 시간과 재시도 횟수는 다섯 회차의 중앙값이다.
| 정책 | 성공 | 실패 | 재시도 | 전체 응답 완료시간 | 최종 잔액 |
| 낙관적 락, 서버 재시도 없음 | 2건 | 48건 | 0회 | 32.5ms | 9,980만 원 |
| 낙관적 락, 최대 100번 시도 | 50건 | 0건 | 707회 | 363.2ms | 9,500만 원 |
| 비관적 락 | 50건 | 0건 | 해당 없음 | 337.4ms | 9,500만 원 |
재시도 없는 낙관적 락의 32.5ms는 50건의 출금이 모두 끝난 시간이 아니다. 48건에 실패 응답을 돌려준 시점까지 포함한 전체 응답 완료 시간이다. 사용자가 다시 요청하거나 클라이언트가 재시도한다면 최종 출금 완료까지 걸리는 시간은 더 늘어난다. 서버 재시도를 적용한 정책은 50건을 모두 처리했지만, 성공 응답에 포함된 재시도 횟수의 합계는 중앙값 707회였다.
이 조건에서는 비관적 락이 50건을 모두 처리하면서 낙관적 락의 서버 재시도보다 약 26ms 먼저 모든 응답을 마쳤다. 같은 행에 충돌이 몰리면 낙관적 락의 반복 작업이 잠금 대기보다 비쌀 수 있음을 보여준다. 비관적 락이 언제나 빠르다는 결과는 아니다. 서버 스레드와 DB 커넥션 풀 대기, 5ms로 설정한 작업 시간, 재시도 간격과 횟수가 결과에 영향을 준다.
정리
충돌이 드문 데이터에서는 낙관적 락이 읽는 동안 잠금을 잡지 않는 장점이 있다. 대신 충돌을 정상적인 처리 경로로 보고, 최신 값으로 다시 판단하는 로직과 재시도 횟수·간격·실패 응답을 정해야 한다. 충돌이 잦고 짧은 트랜잭션으로 끝낼 수 있다면 비관적 락의 대기가 더 단순한 사용자 경험을 만들 수 있다. 이 경우에도 잠금 대기 시간과 데드락은 처리해야 한다.
특히 실패 응답을 받은 사용자가 직접 다시 요청해야 한다면, 그 시간과 다시 충돌할 가능성도 사용자 경험에 포함된다. 어떤 방식을 쓰든 먼저 보호할 조건이 한 행에 있는지, 충돌이 얼마나 자주 일어나는지, 실패와 대기를 어떻게 처리할지 기준을 정해야 한다.
다음 편에는 Redis를 활용한 분산 락에 대해서 알아본다.
'Knowledge' 카테고리의 다른 글
| 트랜잭션 격리 수준 - 왜 MySQL은 Repeatable Read인가 (0) | 2026.10.15 |
|---|---|
| 분산 트랜잭션과 Saga - 분산 트랜잭션은 왜 필요할까? (0) | 2026.10.14 |
| 동시성 제어 (2) - Redis 분산 락은 언제 필요한가 (0) | 2026.10.13 |