Lock, 언제 무엇을 쓸까
동시성 문제는 "같은 값을 여러 곳에서 동시에 만지면 어떻게 되지?"에서 시작된다. 답은 대부분 깨진다. lock은 그걸 막기 위한 오래된 도구지만, 어떤 lock을 언제 써야 하는지는 매번 헷갈린다. 우리가 서비스에서 자주 마주치는 세 가지를 짧게 정리한다.
1. DB 행 잠금 — SELECT ... FOR UPDATE
같은 트랜잭션 안에서 특정 행을 "내가 다 쓸 때까지 아무도 못 만지게" 잠근다. 다른 트랜잭션이 같은 행에 UPDATE나 SELECT FOR UPDATE를 걸려 하면 대기한다.
BEGIN;
SELECT * FROM orders WHERE id = 1 FOR UPDATE;
-- 이 사이엔 orders.id = 1 을 남이 못 건드림
UPDATE orders SET status = 'paid' WHERE id = 1;
COMMIT;
언제 쓰나 — 재고 차감, 잔액 이동처럼 "읽고 → 판단 → 쓰기" 순서로 이루어지는 트랜잭션. 안 걸면 두 트랜잭션이 같은 값을 읽고 각자 계산해서 마지막에 쓴 게 이긴다 (lost update).
주의 — 트랜잭션이 오래 열려있으면 그만큼 남을 오래 기다리게 만든다. FOR UPDATE를 걸었으면 그 트랜잭션은 빨리 닫는다.
2. 애플리케이션 락 — advisory lock / Mutex
DB 밖에서 "지금 이 작업을 여러 인스턴스가 동시에 돌리면 안 됨"을 표현할 때 쓴다. Rails 진영에선 Redis 기반 락이나 PostgreSQL의 pg_advisory_lock을 자주 쓴다.
with_advisory_lock("nightly_report") do
# 여러 대의 서버 중 한 대만 이 안을 실행
generate_report!
end
언제 쓰나 — 크론잡이 여러 인스턴스에서 겹쳐 도는 걸 막을 때, 외부 API 호출을 팀 단위로 직렬화할 때.
주의 — 락 획득에 실패했을 때 "다음에 다시 실행"이 안전한지 항상 생각해야 한다. 아무 처리 없이 그냥 지나가면 그 사이클은 통째로 스킵된다.
3. 낙관적 잠금 (optimistic locking) — 버전 컬럼
미리 잠그지 않고, 저장할 때 "내가 읽었던 그 버전이 맞아?"를 검사한다. 다르면 실패시키고 재시도.
post = Post.find(id)
post.title = new_title
post.save! # WHERE lock_version = 원래 값 — 다르면 예외
언제 쓰나 — 편집 충돌 감지. 두 사람이 같은 문서를 열어놓고 각자 저장할 때, 나중에 저장한 쪽에 "누가 먼저 저장했으니 다시 확인해달라"고 알려주는 흐름.
주의 — 재시도 로직이 없으면 그냥 실패로 끝난다. UX 관점에서 "당신이 본 이후 다른 사람이 이 값을 바꿨어요" 같은 안내가 붙어야 실제로 쓸모가 있다.
정리
- 행 단위 데이터가 짧게 정합성 필요 →
SELECT FOR UPDATE - 여러 인스턴스가 같은 작업을 동시에 하면 안 됨 → advisory lock
- 긴 편집 세션에서 충돌만 감지하면 됨 → optimistic lock
락은 문제가 없을 때는 아무 이득이 없고, 문제가 생겼을 때 그 이득을 뒤늦게 확인하게 된다. 그러니 "지금은 안 겹칠 것 같은데"가 아니라 "겹치면 뭐가 깨지는가"부터 확인하고 고르는 게 맞다.