낙관적 락 vs 비관적 락 — 충돌 확률로 고르는 기준

낙관적 잠금은 빠르고 비관적 잠금은 안전하다는 말, 둘 다 맞게 들려서 더 헷갈려요. 실제로는 충돌이 얼마나 자주 나느냐에 따라 유불리가 뒤집혀요. 상황별로 고르는 기준을 정리했어요.

동시성 제어를 어떻게 할지 정해야 해요. 낙관적 잠금과 비관적 잠금 중에 고르라는데, 어느 쪽이 맞는지 판단할 기준이 없어요.

인터넷을 찾아보면 "낙관적이 성능이 좋다"는 말과 "비관적이 안전하다"는 말이 같이 나와요. 둘 다 맞는 말처럼 들려서 더 헷갈려요.

그래서 대개 팀에서 익숙한 쪽을 씁니다. 그리고 잘 돌다가, 트래픽이 오르거나 특정 상품에 주문이 몰리는 날 문제가 드러나요.

선택 자체가 틀린 게 아니라 상황이 바뀐 거예요. 그런데 처음에 왜 그걸 골랐는지 기록이 없으니 다시 판단할 수도 없습니다.

기준은 하나예요. 충돌이 얼마나 자주 나느냐.

왜 그런가

두 방식은 성능의 우열 관계가 아니에요. 충돌이 얼마나 자주 나느냐에 따라 유불리가 뒤집혀요.

낙관적 잠금은 "충돌이 드물 것"이라는 가정 위에 있어요. 평소엔 락을 안 잡으니 비용이 0 이고, 충돌이 나면 재시도해요. 충돌률이 낮으면 재시도 비용이 거의 안 들어서 매우 빨라요.

비관적 잠금은 반대예요. 미리 잠그니 항상 락 비용을 내지만, 충돌이 나도 재시도가 없어요. 한 번에 끝나요.

그래서 손익분기가 생겨요. 충돌률이 낮으면 낙관적이 이기고, 높으면 비관적이 이겨요. 충돌률이 높은데 낙관적을 쓰면 재시도가 반복되면서 오히려 훨씬 느려져요. 실패한 작업이 자원을 쓰고 버려지는 거니까요.

여기에 재시도 비용도 변수예요. 재시도가 싼 작업(간단한 갱신)이면 낙관적이 유리한 구간이 넓어지고, 재시도가 비싼 작업(복잡한 계산을 다시 해야 함)이면 좁아져요.

그리고 이 두 가지가 전부도 아니에요. 앞서 본 원자적 갱신은 둘 다 안 쓰고 문제를 없애는 방법이고, 큐로 직렬화하면 경합 자체를 제거해요. 선택지가 넷이라고 보는 게 정확합니다.

코드로 보면

충돌률로 고른다:

코드
· 충돌이 거의 없다 (사용자별 프로필 수정 등)
 낙관적 — 버전 검사, 실패하면 재시도
 UPDATE t SET ..., version=version+1
 WHERE id=:id AND version=:v;

· 충돌이 잦다 (인기 상품 재고, 좌석 배정)
 비관적 — 읽을 때부터 잠근다
 SELECT * FROM t WHERE id=:id FOR UPDATE;

· 계산이 단순한 증감이면 — 둘 다 필요 없다
 UPDATE t SET qty = qty - 1 WHERE id=:id AND qty >= 1;

· 경합이 극심하면 — 아예 직렬화한다
 요청을 키별 큐에 넣고 워커가 하나씩 처리
 대기는 생기지만 재시도 폭주는 없다

✕ 문제 지점 · ✓ 해법 · 그대로 돌려 보기 전에 자기 환경에 맞게 고쳐 주세요.

실전에서는

판단 기준을 순서대로 적으면 이래요.

먼저 물어야 할 건 "잠글 필요가 있나"예요. 단순 증감이나 조건부 갱신으로 표현할 수 있으면 둘 다 필요 없어요. 원자적 갱신이 가장 싸고 가장 안전합니다. 이 확인을 건너뛰고 잠금부터 고르는 경우가 많아요.

그다음이 충돌률이에요. 실제로 재 봐야 알 수 있어요. 낙관적 잠금을 쓰고 있다면 버전 충돌 횟수를 세면 되고, 아직 없다면 같은 키에 대한 동시 요청 비율을 로그로 추정할 수 있어요. 감으로 정하지 마세요.

세 번째가 대기 허용 여부예요. 비관적 잠금은 대기를 만들어요. 사용자가 기다려도 되는 작업이면 괜찮지만, 응답 시간이 중요한 API 면 대기가 곧 지연이에요. 이럴 땐 NOWAIT 로 즉시 실패시키고 사용자에게 재시도를 안내하는 게 나을 수 있어요.

네 번째가 재시도 가능성이에요. 재시도할 때 부수 효과가 반복되면 안 돼요. 메일을 보냈다거나 외부 결제를 호출했다면 재시도가 그걸 반복해요. 낙관적 잠금을 쓰려면 재시도 블록 안에 부수 효과가 없어야 합니다.

그리고 상황이 변한다는 걸 전제해야 해요. 평소엔 충돌이 없다가 프로모션 날 한 상품에 몰려요. 그날만 다르게 동작하는 구조가 필요할 수도 있어요. 실제로 충돌률에 따라 전략을 바꾸는 구현도 있습니다 — 낙관적으로 시도하다 실패가 반복되면 비관적으로 전환하는 식이요.

마지막으로 교착 위험이 다르다는 걸 기억하세요. 비관적 잠금은 여러 행을 잠그면 교착이 생길 수 있어요. 낙관적은 락을 안 잡으니 교착이 없지만 재시도 폭주가 있고요. 실패 모드가 다른 겁니다.

처방

  1. 먼저 원자적 갱신으로 풀리는지 확인하세요. 잠금을 안 쓰는 게 언제나 최선이에요.
  2. 충돌률을 재세요. 감이 아니라 숫자로요. 낙관적 잠금의 0행 갱신 횟수, 또는 같은 키에 대한 동시 요청 비율.
  3. 충돌이 드물고 재시도가 싸면 낙관적. 충돌이 잦거나 재시도가 비싸면 비관적.
  4. 비관적을 쓸 때는 대기 정책을 정하세요. 무한정 기다릴지, NOWAIT 로 즉시 실패할지, 짧은 타임아웃을 걸지. 기본값(대개 매우 김)에 맡기지 마세요.
  5. 낙관적을 쓸 때는 재시도 상한과 백오프를 정하고, 재시도 블록에서 부수 효과를 빼세요.
  6. 경합이 극심한 자리는 직렬화를 검토하세요. 인기 상품 하나에 요청이 몰리면 어떤 잠금 방식도 힘들어요. 큐로 순서를 만들면 대기는 생겨도 시스템은 안 무너집니다.
  7. 선택을 기록으로 남기세요. "여기는 충돌률 X% 라서 낙관적을 골랐다"를 주석이나 문서에 적어 두면, 나중에 상황이 바뀌었을 때 다시 판단할 수 있어요.

지금 확인해 보세요

충돌률을 실제로 재 본다:

코드
· 낙관적 잠금 — 0행 갱신 횟수가 곧 충돌 횟수다
 앱에서 카운터로 올린다: optimistic_conflict_total
 충돌률 = 충돌 / 전체 갱신 시도
 5% 를 넘기 시작하면 비관적 전환을 검토
 30% 를 넘으면 직렬화를 검토

· 비관적 잠금 — 대기 시간 분포
SELECT wait_event_type, wait_event, count(*)
  FROM pg_stat_activity
 WHERE wait_event_type = 'Lock'
 GROUP BY 1,2;

· 어떤 키에 경합이 몰리는지 (핫 키 찾기)
 갱신 대상 id 별 요청 수를 로그에서 집계한다
 상위 몇 개가 전체의 대부분이면 그 키만 다르게 다룬다

핵심 한 줄낙관과 비관은 취향이 아니라 충돌률의 함수다.

자주 묻는 것

낙관적 락과 비관적 락은 각각 언제 쓰나요?

충돌이 드물고 재시도가 싸면 낙관적 잠금, 충돌이 잦거나 재시도가 비싸면 비관적 잠금이 맞아요. 그 전에 원자적 갱신으로 풀리는지부터 확인하세요.

브루니가 적어요

색을 바꾸며 숨는 카멜레온이에요. 운영에서 실제로 터지는 문제를 한 편씩 적어요. 같은 실수를 두 번 하지 않으려고 쓰는 기록이에요.

트랜잭션·락·DB 운영 글 21편 모두 보기 →