본문 바로가기
카테고리 없음

낙관적 락과 비관적 락, 언제 무엇을 선택할까

by 테크구루스 2026. 7. 15.

낙관적 락과 비관적 락, 언제 무엇을 선택할지를 version 컬럼과 SELECT FOR UPDATE로 정리한 글입니다.

두 접근의 철학 차이

동시에 같은 행을 고치려는 요청이 겹칠 때, 우리가 쓸 수 있는 무기는 크게 두 갈래로 갈립니다. 하나는 "충돌은 어차피 드물 것"이라고 낙관하고 일단 진행한 뒤, 커밋 직전에 "그동안 아무도 안 건드렸지?"를 확인하는 낙관적 락(optimistic lock)입니다. 다른 하나는 "누군가 끼어들지도 모른다"고 비관하고, 읽는 순간부터 그 행에 자물쇠를 채워 놓고 작업하는 비관적 락(pessimistic lock)이죠.

이 둘은 성능 튜닝 옵션이 다른 게 아니라 세상을 보는 전제가 다릅니다. 낙관적 락은 충돌이 예외적이라고 가정합니다. 그래서 평소에는 잠금 비용을 전혀 물지 않고 자유롭게 읽고 계산하다가, 마지막에 딱 한 번 검증합니다. 검증이 실패하면 그 트랜잭션만 되돌리고 다시 시도하면 되죠. 반대로 비관적 락은 충돌이 흔하거나, 한 번이라도 어긋나면 대가가 크다고 가정합니다. 그래서 아예 시작부터 다른 사람을 못 들어오게 막습니다.

 

낙관적 락과 비관적 락
낙관적 락과 비관적 락

여기서 핵심은 비용을 어디에 지불하느냐입니다. 낙관적 락은 평시 비용이 거의 0인 대신, 충돌이 실제로 났을 때 재시도라는 후불 청구서가 날아옵니다. 비관적 락은 반대로 평시에도 잠금과 대기라는 선불 비용을 꾸준히 내지만, 충돌 상황에서 재작업은 없습니다. 대기하던 트랜잭션은 자기 차례가 오면 이미 갱신된 최신 값을 보고 그대로 진행하니까요.

그래서 저는 이 선택을 언제나 한 문장으로 되묻습니다. "이 자원은 충돌이 드문가, 잦은가. 그리고 충돌이 났을 때 재시도가 감당되는가." 충돌이 드물고 재시도가 싸면 낙관적 락이 압도적으로 유리하죠. 충돌이 잦거나 한 트랜잭션 안에서 무거운 계산을 이미 해 버려 재시도가 아깝다면 비관적 락이 낫습니다. 전제를 잘못 잡으면, 멀쩡히 돌던 코드가 트래픽이 몰리는 순간 조용히 무너집니다.

한 가지 덧붙이면, 낙관적 락은 엄밀히 말해 데이터베이스가 잡아 주는 잠금이 아닙니다. 실제 잠금은 없고, 버전 비교라는 규칙을 애플리케이션과 SQL이 합의해서 지키는 것이죠. 그래서 "락"이라는 이름이 붙었지만 성격은 오히려 충돌 감지에 가깝습니다. 반면 비관적 락은 데이터베이스 엔진이 물리적으로 걸어 주는 진짜 row lock입니다. 이 차이를 헷갈리면, 낙관적 락을 걸어 뒀으니 안전하겠거니 방심하다가 갱신 행 수 검사를 빼먹는 실수를 하게 되죠. 저도 초년에 그 검사를 누락해, 충돌이 나도 조용히 마지막 쓰기가 이기는 lost update를 만든 적이 있습니다.

구현 — version 컬럼 vs SELECT FOR UPDATE

두 전략은 SQL 레벨에서 생김새부터 다릅니다. 낙관적 락은 보통 테이블에 version 컬럼(또는 updated_at)을 하나 두고, 갱신할 때 "내가 읽었던 그 버전이 아직 그대로일 때만 써라"라고 조건을 겁니다.

-- 읽기: 값과 함께 버전도 같이 읽어 둡니다
SELECT id, stock, version FROM product WHERE id = 42;
-- 애플리케이션에서 stock을 계산한 뒤, 버전 조건을 걸고 갱신
UPDATE product
   SET stock = stock - 1, version = version + 1
 WHERE id = 42 AND version = 7;

여기서 결정적인 부분은 WHERE ... AND version = 7영향받은 행 수(affected rows) 입니다. 만약 그사이 다른 트랜잭션이 먼저 커밋해 version이 8이 됐다면, 이 UPDATE는 조건에 맞는 행이 없어 0행을 갱신합니다. 애플리케이션은 갱신 행 수가 0인 걸 보고 "충돌이 났다"고 판단해 예외를 던지거나 재시도하죠. 락을 위한 별도 잠금이 전혀 없다는 점이 낙관적 락의 매력입니다. 평범한 UPDATE 한 방이 곧 충돌 감지 장치가 되니까요.

비관적 락은 읽는 순간 행을 잠급니다. 대표적으로 SELECT ... FOR UPDATE를 씁니다.

BEGIN;
-- 이 행에 배타 잠금(row lock)을 걸고 읽습니다
SELECT stock FROM product WHERE id = 42 FOR UPDATE;
-- 잠긴 동안 계산 후 안전하게 갱신
UPDATE product SET stock = stock - 1 WHERE id = 42;
COMMIT;

FOR UPDATE가 걸린 순간부터 COMMIT까지, 같은 행을 FOR UPDATE로 잡으려는 다른 트랜잭션은 줄을 서서 대기합니다. version 비교가 필요 없죠. 내가 잡고 있는 동안엔 아무도 못 바꾸니 계산과 갱신이 원자적으로 보장됩니다. 대신 비용이 분명합니다.

 

락 동시성 구현 비교
락 동시성 구현 비교

항목 낙관적 락(version) 비관적 락(FOR UPDATE)
평시 잠금 없음 행 잠금 유지
충돌 시 재시도 필요 대기 후 진행
주의점 재시도 폭주 잠금 대기·데드락
적합 충돌 드문 자원 충돌 잦은 자원

 

비관적 락에서 제가 늘 강조하는 건 잠금을 잡는 순서와 트랜잭션 길이입니다. 잠근 채로 외부 API를 호출하거나 무거운 로직을 돌리면, 그 행을 기다리는 줄이 순식간에 길어지죠. 여러 행을 잡을 땐 항상 같은 순서(예: id 오름차순)로 잡아야 데드락을 피할 수 있고요. 이 규칙을 어기면 잠금은 곧 장애로 번집니다. 실제로 FOR UPDATE는 잠금 대기가 무한정 늘어지는 걸 막기 위해 NOWAIT이나 SKIP LOCKED 같은 옵션과 함께 쓰기도 합니다. 대기 대신 바로 실패시키거나, 이미 잠긴 행은 건너뛰고 다음 행을 집어 큐 소비 같은 패턴을 만들 때 특히 유용하죠.

정리하면 version 컬럼은 잠금 없는 낙관적 검증이고, SELECT FOR UPDATE는 잠금 있는 비관적 직렬화입니다. 어느 쪽도 공짜가 아니며, 비용의 청구 시점이 다를 뿐입니다. 그 청구서를 어디서 받고 싶은지가 곧 선택의 기준이 되죠.

재시도 폭주를 막으며 낙관적 락 쓰기

작년에 이커머스 트래픽을 다루는 서비스에서 한정 수량 이벤트 상품의 재고 차감을 맡았습니다. 처음엔 전부 낙관적 락으로 갔죠. 대부분의 상품은 동시에 같은 행을 건드릴 일이 거의 없었고, UPDATE ... WHERE id=? AND version=?의 affected rows로 충돌을 감지해 실패하면 재시도하는 구조가 깔끔하게 돌았습니다. 평상시 충돌률은 1%도 안 됐어요.

문제는 인기 상품 몇 개에서 터졌습니다. 오픈과 동시에 초당 수천 건이 같은 product.id로 몰리자, 낙관적 락의 전제가 무너졌죠. 너도나도 version 7을 읽고 UPDATE를 던지는데 성공하는 건 한 번에 한 명뿐이니, 나머지는 전부 0행 갱신으로 실패하고 즉시 재시도했습니다. 재시도가 다시 실패를 부르는 악순환이었죠. 로그를 보니 어떤 요청은 한 건 처리에 재시도가 40번 넘게 찍혔고, DB의 UPDATE QPS가 실제 성공 건수의 20배 가까이 헛돌고 있었습니다. CPU는 멀쩡한데 응답 지연만 치솟는, 전형적인 재시도 폭주였습니다.

 

재시도 폭주
재시도 폭주

두 가지로 다스렸습니다. 첫째, 재시도에 지수 backoff와 지터(jitter)를 넣었습니다. 실패하면 즉시 재도전하지 말고 무작위로 벌린 짧은 간격(대략 20ms에서 시작해 2배씩, 상한 300ms)을 두게 했더니, 같은 순간에 몰리던 재시도가 시간축으로 흩어지면서 헛도는 UPDATE가 눈에 띄게 줄었죠. 둘째, 재시도 횟수 상한을 5회로 두고 그 안에 성공 못 하면 실패로 처리했습니다. 무한 재시도는 그 자체가 장애 증폭기니까요.

그런데 근본 처방은 따로 있었습니다. 자원을 구간으로 나눠 전략을 바꾼 것이죠. 재시도가 5회 넘게 실패하는 초인기 상품 소수만 골라 비관적 락 경로로 태웠습니다. 이 상품들은 SELECT ... FOR UPDATE로 행을 잠근 뒤 재고를 차감하게 바꿨죠. 어차피 이 행은 누가 봐도 충돌이 확실하니, 헛된 재시도로 낭비하느니 줄을 세워 순서대로 처리하는 편이 훨씬 쌌습니다. 나머지 다수 상품은 그대로 낙관적 락에 남겨 뒀고요. 판단 기준은 단순했습니다. 최근 1분 충돌률이 임계치를 넘는 상품 id만 비관적 경로로 라우팅했습니다.

결과적으로 인기 상품의 P99 응답이 절반 아래로 떨어졌고, 헛도는 UPDATE는 거의 사라졌습니다. 여기서 얻은 교훈은 분명합니다. 낙관이냐 비관이냐는 시스템 전체를 두고 한 번에 정하는 게 아니라, 같은 테이블 안에서도 행 단위 충돌 성향에 따라 갈라 적용할 수 있다는 것이죠. 낙관적 락은 충돌이 드문 다수에게 공짜에 가까운 속도를 주고, 비관적 락은 충돌이 확실한 소수의 폭주를 잠재웁니다. 개인적으로는, 처음부터 한쪽만 고집했다면 그날 이벤트는 못 버텼을 거라고 생각합니다. 전제를 의심하고 자원별로 나눠 보는 감각이, 두 락을 제대로 쓰는 진짜 열쇠였죠.