LabHub

동시성 — 두 사람이 같은 행을 건드릴 때 · 잠금과 데드락 · 이론

혼자서는 절대 안 나는 버그

LabHub 에서 이어서 보기

한 줄 요약

동시성 버그는 코드가 틀려서가 아니라 두 사람이 같은 순간에 같은 행을 건드려서 난다. 그래서 혼자 테스트하면 절대 재현되지 않는다.

왜 필요한가 — 재고가 음수가 되는 사고

select qty from stock where id = 1;      -- 1 이 남았다-- (여기서 다른 사람도 똑같이 1 을 읽는다)update stock set qty = qty - 1 where id = 1;

둘 다 "1 남았네" 를 읽고 둘 다 차감한다. 재고가 -1 이 된다.

읽고 → 판단하고 → 쓰는 사이에 남이 끼어들 수 있다는 것이 문제다. 이걸 잃어버린 갱신(lost update) 이라고 한다.

고치는 방법은 셋이다.

1. select ... for update — 읽을 때 그 행을 잠근다. 남은 기다린다
2. 한 문장으로 끝낸다update stock set qty = qty - 1 where id = 1 and qty > 0
3. 낙관적 잠금 — 버전 컬럼을 두고 where version = %s, 0행이면 재시도

2번이 가능하면 2번이 가장 싸다. 읽고 판단해야만 하는 경우에 1번을 쓴다.

잠금은 트랜잭션이 끝날 때까지 유지된다

for update 로 잡은 행은 commit 이나 rollback 까지 잠긴 채다. 그래서 트랜잭션 안에서 외부 API 를 부르면 안 된다. 그 API 가 3초 걸리면 그 행은 3초 동안 잠긴다.

> 트랜잭션은 짧게. 잠금을 잡은 채로 사람을 기다리거나 네트워크를 기다리지 않는다.

데드락 — 서로 상대가 가진 것을 기다린다

A: id=1 잠금 → id=2 요청B: id=2 잠금 → id=1 요청

둘 다 영원히 기다린다. PostgreSQL 은 deadlock_timeout(기본 1초) 뒤에 이 순환을 발견하고 한쪽을 죽인다.

ERROR:  deadlock detectedDETAIL:  Process 123 waits for ShareLock on transaction 773; blocked by process 122.         Process 122 waits for ShareLock on transaction 774; blocked by process 123.

에러 메시지가 누가 무엇을 기다렸는지 정확히 알려 준다. 이 두 줄을 읽는 법을 알면 원인 찾기가 훨씬 빨라진다.

막는 방법은 하나뿐이다 — 잠그는 순서를 모두가 같게 한다. 항상 id 오름차순으로 잠그면 순환이 생길 수 없다. 정렬 기준이 무엇이든 상관없고, 모두가 같은 기준을 쓰는 것만 중요하다.

그리고 데드락은 완전히 없앨 수는 없다. 그래서 애플리케이션은 40P01 을 만나면 재시도할 수 있어야 한다.

무한히 기다리지 않게 한다

set lock_timeout = '3s';

이걸 안 걸면 잠금 대기가 커넥션을 물고 늘어지고, 그 커넥션이 풀을 다 먹으면 DB 가 아니라 애플리케이션이 멈춘다. 증상은 "DB 가 느리다" 가 아니라 "서버가 응답을 안 한다" 로 나타난다.

마이그레이션에서는 특히 중요하다. alter table 은 ACCESS EXCLUSIVE 를 요구하는데, 긴 쿼리 하나가 그 앞에 있으면 그 뒤의 모든 쿼리가 줄을 선다. lock_timeout 없이 배포하면 그 순간 서비스가 멈춘다.

누가 누구를 막고 있나

select pid, pg_blocking_pids(pid), queryfrom pg_stat_activitywhere cardinality(pg_blocking_pids(pid)) > 0;

한 줄로 차단 관계가 나온다. pg_locks 를 직접 조인하는 것보다 이게 빠르다.

큐를 만들 때 — SKIP LOCKED

워커 여럿이 같은 테이블에서 작업을 가져가야 한다면, for update 만 쓰면 워커들이 줄을 선다. 첫 워커가 잡은 행을 두 번째가 기다리기 때문이다.

select id from jobs where state = 'ready'order by id limit 10for update skip locked;

skip locked잠긴 행을 건너뛴다. 두 번째 워커는 기다리지 않고 다음 것을 집는다. 전용 큐 미들웨어 없이 DB 로 작업 큐를 만들 때의 핵심 한 줄이다.

행이 없는 것에 잠금을 걸어야 할 때

"이 사용자에 대한 정산은 한 번에 하나만" 같은 규칙은 잠글 행이 없다. 이럴 때 어드바이저리 잠금을 쓴다.

select pg_try_advisory_lock(12345);   -- 얻으면 true, 아니면 즉시 false

주의 두 가지. 키 공간이 전역이다 — 다른 기능이 같은 숫자를 쓰면 충돌한다. 그리고 세션에 묶인다 — 커넥션 풀에서는 반납 전에 반드시 풀어야 한다. 안 그러면 다음 사람이 잠긴 커넥션을 받는다.

정리

동시성 문제는 재현이 어려워서 어렵다. 그래서 이 코스의 실습은 전부 두 세션을 실제로 띄워 부딪히게 한다. 화면에서 기다림과 데드락을 직접 보는 것이 이해의 대부분이다.

현장에서

이 문제는 개발 환경에서 거의 재현되지 않습니다. 혼자 클릭해 보면 언제나 정상이고, 부하 시험도 서로 다른 상품을 사면 겹치지 않습니다. 실제로는 한정 수량 판매나 쿠폰 발급처럼 모두가 같은 행 하나를 노리는 순간에 터집니다.

그래서 장애 후 조사에서 잠금을 의심하기까지가 오래 걸립니다. 데이터베이스 지표는 전부 정상이고, 애플리케이션 로그에는 '재고 차감 성공' 만 줄줄이 남아 있기 때문입니다. 남는 단서는 결과 데이터의 모순 하나뿐입니다 — 재고가 음수이거나, 발급 수량이 한도를 넘었거나.

운영에 나가기 전에 확인할 것은 두 가지입니다. 같은 행을 동시에 건드리는 경로가 어디인지 목록을 만들고, 그 경로마다 한 문장으로 끝낼 수 있는지 아니면 잠금이 필요한지를 정합니다. 그리고 lock_timeout 을 세션 기본값으로 걸어 두어, 잘못되더라도 커넥션 풀까지 무너지지 않게 합니다.