사전 낙관적 잠금
패턴

낙관적 잠금

gabury1고친 사람 github-actions[bot]

낙관적 잠금은 데이터를 고칠 때 미리 잠그지 않고, 저장하는 순간에 남이 먼저 바꿨는지만 확인하는 방식입니다. 그사이 바뀌었으면 저장을 거절하고 부른 쪽에 다시 해 보라고 돌려줍니다. 부딪히는 일이 드물 것이라고 보고 일단 진행하기 때문에 낙관적이라고 부릅니다.

쉽고 빠른 이해

낙관적 잠금은 「고치는 동안 막아 두기」 대신 「저장할 때 어긋났는지 보기」를 고른 방식입니다. 글을 고치다 저장을 눌렀을 때 「다른 사람이 먼저 수정했습니다」가 뜨는 것이 이 방식입니다.

이게 없으면 두 사람이 같은 값을 읽어 고칠 때 나중에 저장한 쪽이 앞사람 수정을 덮어 버립니다. 덮인 쪽은 오류를 하나도 못 보고, 한참 뒤에야 자기 수정이 없어진 것을 발견합니다.

어떻게 도나.

  1. 값을 읽을 때 그 값의 판 번호도 같이 읽어 둡니다
  2. 저장할 때 판 번호가 읽어 둔 것과 같을 때만 쓰라고 조건을 겁니다
  3. 조건이 안 맞아 한 건도 안 고쳐졌으면 부딪힌 것으로 보고 되돌립니다

대가가 있습니다. 거절당한 요청은 버려지고 처음부터 다시 해야 합니다. 같은 데이터를 두고 다투는 일이 잦으면 다시 하기만 되풀이하다 일이 안 끝납니다. 판 번호를 담을 칸과 확인 절차도 새로 만들어야 합니다.

상세

이 절은 먼저 이 방식이 무엇을 막으려고 나왔는지 봅니다. 그다음 미리 잠그는 쪽과 견줘 이름의 뜻을 가르고, 판 번호로 어긋남을 알아내는 순서를 그림과 코드로 봅니다. 끝으로 쓰면 나빠지는 것과 안 쓰는 편이 나은 경우를 짚습니다.

나중에 저장한 쪽이 앞사람을 덮는 일

여러 사람이 같은 데이터를 동시에 고칠 때 벌어지는 일부터 봅니다. 데이터베이스에서는 사람 대신 트랜잭션이 그 주체가 됩니다. 트랜잭션은 여러 읽기와 쓰기를 한 묶음으로 처리하는 단위입니다.

값을 고치는 일은 대개 세 단계로 나뉩니다. 읽고, 그 값을 바탕으로 새 값을 만들고, 씁니다. 두 사람이 이 세 단계를 겹쳐서 하면 먼저 쓴 사람의 수정이 없어집니다.

창고에 재고 열 개가 있다고 해 보겠습니다.

A 가 읽는다             // 10
B 가 읽는다             // 10
A 가 3 을 빼고 쓴다      // 7
B 가 2 를 빼고 쓴다      // 8

B 는 A 가 뺀 셋을 못 봤습니다. 둘이 합쳐 다섯을 뺐는데 남은 수량은 여덟입니다. 이렇게 먼저 쓴 수정이 조용히 없어지는 것을 갱신 손실이라고 부릅니다.

조용히 없어진다는 것이 이 문제의 고약한 대목입니다. 어느 쪽에도 오류가 안 나므로 두 사람 다 자기 수정이 반영됐다고 믿습니다.

미리 잠그는 쪽과 나중에 확인하는 쪽

갱신 손실을 막는 방법은 크게 둘로 갈립니다. 하나는 읽을 때부터 그 데이터를 락으로 잠가 남이 못 건드리게 하는 것입니다. 락은 한 번에 한 쪽만 들어오게 막는 표시이고, 이렇게 미리 막아 두는 쪽을 비관적 잠금이라고 부릅니다. 부딪힐 것이라고 미리 보고 막는다는 뜻입니다.

다른 하나가 낙관적 잠금입니다. 잠그지 않고 일단 진행한 뒤, 쓰는 순간에 그동안 값이 바뀌었는지 확인합니다. 바뀌었으면 그 쓰기를 거절합니다.

이름에 잠금이 들어 있지만 무엇을 잠그지는 않습니다. 갱신 손실을 막는다는 목적이 같아서 두 방식이 한 이름으로 묶여 불릴 뿐입니다.

비관적 잠금 낙관적 잠금
언제 막나 읽을 때부터 안 막는다
어긋남을 아는 때 어긋날 일이 없다 쓸 때
부딪히면 뒤에 온 쪽이 기다린다 뒤에 온 쪽이 거절당한다
잘 맞는 데이터 다툼이 잦은 것 다툼이 드문 것

표를 한 줄로 줄이면 이렇습니다. 비관적 잠금은 기다리게 하고, 낙관적 잠금은 다시 하게 합니다.

판 번호로 어긋남을 알아내는 순서

무엇을 보고 「그동안 바뀌었나」를 아는지가 이 방식의 핵심입니다. 가장 흔한 방법은 데이터마다 판 번호를 하나 붙여 두고 고칠 때마다 그 번호를 하나 올리는 것입니다.

읽을 때 값과 판 번호를 같이 읽어 두고, 쓸 때 그 번호를 조건으로 붙입니다. 조건이 맞으면 내가 읽은 뒤로 아무도 안 고친 것입니다.

sequenceDiagram
    participant A as 요청 A
    participant B as 요청 B
    participant 저장소
    A->>저장소: 읽는다
    저장소-->>A: 값과 판 번호 7
    B->>저장소: 읽는다
    저장소-->>B: 값과 판 번호 7
    A->>저장소: 판 번호가 7 이면 써라
    저장소-->>A: 썼다. 판 번호는 8 이 됐다
    B->>저장소: 판 번호가 7 이면 써라
    저장소-->>B: 못 썼다. 지금은 8 이다

그림을 말로 옮기면 이렇습니다. A 와 B 가 같은 판을 읽었고 A 가 먼저 썼습니다. A 의 쓰기가 판 번호를 8 로 올렸으므로, 7 을 조건으로 건 B 의 쓰기는 맞는 데이터를 하나도 못 찾습니다.

관계형 데이터베이스라면 그 조건이 갱신문의 조건절로 들어갑니다. 조건절은 어떤 행을 고칠지 고르는 대목이라, 판 번호를 거기 넣으면 「안 바뀌었을 때만」이 됩니다.

UPDATE 문서 SET 내용 = ?, 판 = 판 + 1
  WHERE 번호 = 42 AND 판 = 7

A 가 먼저 실행      // 고친 행 1
B 가 뒤에 실행      // 고친 행 0

봐야 할 것은 돌아오는 숫자입니다. 고친 행이 한 건이면 성공이고, 없으면 그사이 누가 먼저 고쳤다는 뜻입니다. 이때 부른 쪽에 실패를 알려 다시 하게 합니다.

확인과 쓰기가 한 문장 안에서 끊기지 않고 끝난다는 점이 중요합니다. 먼저 읽어서 번호를 견줘 보고 그다음에 쓰는 식으로 갈라 두면, 그 사이에 또 누가 끼어들어 같은 문제가 되돌아옵니다.

어긋남을 알아내는 데 쓰는 표시

견주는 것이 꼭 판 번호일 필요는 없습니다. 「읽은 뒤로 바뀌었나」를 가릴 수 있으면 무엇이든 됩니다. 네 가지가 흔합니다.

무엇을 견주나 쓸 때
판 번호 칸 하나만 늘리면 되고 비교가 가볍습니다
읽은 값 그 자체 칸을 안 늘려도 되지만 견줄 칸이 많아집니다
마지막으로 고친 시각 이미 그 칸이 있을 때. 같은 순간에 둘이 고치면 못 가릅니다
내용을 줄인 짧은 문자열 본문이 길 때. 문자열을 만드는 품이 듭니다

표의 둘째 줄은 칸을 안 늘려도 된다는 점 때문에 끌리지만 함정이 있습니다. 견주는 칸에 안 들어간 값이 바뀌면 그 변화는 못 잡습니다.

넷째 줄의 짧은 문자열은 해시 함수로 만듭니다. 긴 내용을 짧은 값 하나로 줄이는 함수이고, 내용이 달라지면 값도 달라지므로 긴 본문을 통으로 견주는 대신 쓸 수 있습니다.

웹 요청에서 쓰는 같은 방식

HTTP(HyperText Transfer Protocol) 위에서도 같은 방식이 돕니다. 서버는 리소스를 내줄 때 그 판을 가리키는 짧은 문자열을 엔티티 태그(ETag, Entity Tag)라는 이름으로 응답에 실어 보냅니다. 내용이 바뀌면 이 문자열도 바뀝니다.

클라이언트는 고친 내용을 PUT 으로 보낼 때 읽어 둔 문자열을 If-Match 헤더에 담습니다. 「서버 쪽 문자열이 이것과 같을 때만 하라」는 조건입니다. 이렇게 조건을 붙인 요청을 조건부 요청이라고 부릅니다.

조건이 안 맞으면 서버는 요청을 수행하지 않고 412 Precondition Failed 로 답합니다. 앞서 본 「고친 행 없음」과 같은 뜻입니다. 읽은 뒤로 남이 먼저 고쳤다는 것입니다.

쓰면 나빠지는 것

거절당한 쪽은 처음부터 다시 해야 합니다. 읽고 계산한 것이 버려지므로, 계산이 무거웠다면 그 품도 같이 버려집니다.

다시 하는 횟수도 미리 못 정합니다. 몇 번 만에 성공할지는 그 데이터를 남들이 얼마나 자주 고치느냐에 달려 있습니다. 다툼이 잦으면 다시 하다 또 거절당하는 일이 되풀이되고, 어떤 요청은 계속 밀려 오래 못 끝납니다. 아무도 멈추지 않았는데 특정 요청만 계속 밀리는 이 상태를 기아라고 부릅니다.

거절을 누가 받을지도 정해야 합니다. 서버가 조용히 몇 번 다시 해 보고 끝내도 되는 일이 있고, 사람이 손으로 고쳐 쓴 내용이라 사람에게 물어야 하는 일이 있습니다. 뒤엣것을 조용히 다시 하면 남의 수정을 덮게 되어, 애초에 막으려던 일이 그대로 벌어집니다.

다시 해도 되는 일인지도 봐야 합니다. 메일을 보내거나 결제를 거는 것처럼 되돌릴 수 없는 일을 읽기와 쓰기 사이에 끼워 두면, 다시 할 때 그 일이 두 번 벌어집니다. 여러 번 해도 결과가 한 번 한 것과 같은 성질을 멱등성이라고 하고, 다시 하기를 회복 수단으로 쓰려면 그 사이의 일들이 이 성질을 가져야 합니다.

판 번호를 어디에 붙였는지도 대가로 돌아옵니다. 한 행에 판 번호가 하나면 서로 다른 칸을 고친 두 요청도 부딪힌 것으로 잡힙니다. 겹치지 않는 수정까지 거절당하는 셈입니다.

언제 쓰고 언제 안 쓰나

같은 데이터를 두고 다투는 일이 드물면 잘 맞습니다. 대부분의 쓰기가 한 번에 통과하므로 잠그는 품을 안 들이고, 잠금이 없으니 읽으러 온 쪽도 안 막힙니다.

사람이 화면을 열어 놓고 한참 고치는 경우에도 이쪽을 씁니다. 사람이 저장을 누를 때까지 데이터를 잠가 두면 그동안 아무도 못 고치고, 그 사람이 창을 닫고 가 버리면 잠금이 안 풀린 채 남습니다.

반대로 다툼이 잦은 데이터에는 안 맞습니다. 인기 상품의 재고 한 줄처럼 모두가 같은 데이터를 고치러 오면 거절과 다시 하기가 늘어나, 차라리 줄 세워 기다리게 하는 편이 전체로는 더 빨리 끝납니다.

한 번 실패하면 다시 못 하는 일에도 안 맞습니다. 다시 하기가 이 방식의 회복 수단이라, 다시 할 수 없으면 거절이 그대로 실패로 남습니다.

관련 항목

낙관적 잠금이 막으려는 문제

갱신 손실 · 경쟁 상태 · 더티 쓰기 · 쓰기 편향 · 팬텀 읽기

같은 목적을 두고 겨루는 다른 동시성 제어 방식

비관적 잠금 · 2단계 잠금 · SELECT FOR UPDATE · 행 잠금 · MVCC · 스냅샷 격리 · 직렬가능 스냅샷 격리

어긋남을 알아내는 데 쓰는 표시

리비전 · 엔티티 태그 · 해시 함수 · 타임스탬프 · 논리 시계 · 체크섬

낙관적 잠금이 기대는 동시성 개념

트랜잭션 · 원자성 · 격리 수준 · 직렬화 가능성 · 비교 후 교환 · 원자적 연산 · 동시성

거절당한 요청을 되살리는 수단

재시도 · 지수 백오프 · 멱등성 · 보상 트랜잭션 · 충돌 해결

다시 하기가 되풀이될 때 터지는 장애

라이브락 · 기아 · 락 경합 · 재시도 폭풍 · 썬더링 허드

이 방식을 쓰는 웹 요청 규격

조건부 요청 · If-Match · PUT · 엔티티 태그 검증 · HTTP

다른 이름: optimistic locking · Optimistic Locking · optimistic concurrency control · 낙관적 락 · 낙관적 동시성 제어 · 낙관적잠금