낙관적 잠금
고친 사람 github-actions[bot]
낙관적 잠금은 데이터를 고칠 때 미리 잠그지 않고, 저장하는 순간에 그사이 남이 먼저 바꿨는지 확인하는 방식입니다. 바뀌었으면 저장을 거절하고 처음부터 다시 하게 합니다. 부딪히는 일이 드물 것이라고 기대하고 일단 진행하기 때문에 낙관적이라고 부릅니다.
쉽고 빠른 이해
낙관적 잠금은 「고치는 동안 막아 두기」 대신 「저장할 때 어긋났는지 보기」를 고른 방식입니다. 재고 열 개를 두 요청이 동시에 읽고 고치는 경우가 대표적입니다.
이게 없으면 나중에 저장한 쪽이 앞사람의 수정을 덮어 버립니다. 덮인 쪽에는 오류가 하나도 안 납니다.
도는 순서는 이렇습니다.
- 값을 읽을 때 그 값의 판 번호를 같이 받아 둡니다. 판 번호는 저장될 때마다 하나씩 오릅니다
- 저장할 때 「판 번호가 그때와 같으면 써라」라고 조건을 겁니다
- 조건이 안 맞으면 저장이 거절됩니다. 그러면 다시 읽어서 처음부터 합니다
대가도 있습니다. 거절당한 쪽은 그동안 한 일을 버립니다. 같은 데이터를 두고 다툼이 잦으면 다시 하기만 되풀이하다 끝내 못 끝나는 요청이 생길 수 있습니다.
상세
재고 수량 하나를 두 요청이 함께 고치는 장면에서 출발합니다. 그 장면에서 무엇이 사라지는지를 먼저 봅니다. 그다음에 낙관적 잠금이 그것을 어떤 순서로 막는지 봅니다.
끝에서는 미리 잠그는 방식과 나란히 놓고 견줍니다. 견주는 것은 둘입니다. 부딪혔을 때 누가 무엇을 치르나, 그리고 어느 쪽 가정이 맞을 때 이득인가입니다.
갱신 손실
값을 고치는 일은 대개 세 단계입니다. 읽고, 읽은 값으로 새 값을 만들고, 씁니다. 두 요청이 이 단계를 겹쳐 하면 먼저 쓴 쪽의 수정이 사라집니다.
창고에 재고 열 개가 있다고 해 보겠습니다.
A 가 읽는다 // 10
B 가 읽는다 // 10
A 가 3 을 빼고 쓴다 // 7
B 가 2 를 빼고 쓴다 // 8
B 는 A 가 뺀 셋을 못 봤습니다. 둘이 다섯을 뺐습니다. 그런데 남은 수량은 여덟입니다. 한쪽이 나란히 일하던 다른 쪽의 작업을 모르고 덮어쓰는 이 일을 갱신 손실(lost update)이라고 부릅니다.
어느 쪽에도 오류가 나지 않는다는 점이 이 문제를 키웁니다. 두 요청 모두 자기 수정이 반영됐다고 믿고 끝납니다.
잠그지 않고 나중에 확인하는 방식
갱신 손실을 막는 흔한 길은 잠금(lock)입니다. 한 쪽이 데이터를 쓰는 동안 다른 쪽이 그 데이터에 손을 못 대게 막습니다. 1981년 무렵 데이터베이스의 동시성 제어도 대부분 잠금에 기댔습니다.
낙관적 잠금은 반대 방향에서 1981년에 나왔습니다. 잠그지 않는 동시성 제어로 처음 소개됐습니다. 트랜잭션은 여러 읽기와 쓰기를 한 묶음으로 처리하는 단위입니다. 이 방식은 잠금 대신 트랜잭션을 되돌리는 것을 주된 통제 수단으로 씁니다.
지키려는 것은 무결성입니다. 무결성은 데이터가 앞뒤가 맞는 상태를 지키는 것입니다. 여럿이 공유 데이터를 아무 제한 없이 고치게 두면 무결성이 깨집니다. 그래서 이 방식은 한 트랜잭션을 읽기 단계와 검증 단계로 가릅니다. 쓰기 단계는 검증을 통과한 트랜잭션에만 둡니다. 세 단계가 어떻게 이어지는지는 아래 그림입니다.
flowchart TD
R["읽기 단계 · 고칠 데이터의 사본에 쓴다"] --> V{"검증 단계 · 무결성이 깨지나"}
V -->|"안 깨진다"| W["쓰기 단계 · 사본을 모두가 보는 데이터로 바꿔 넣는다"]
V -->|"깨진다"| B["되돌린다"]
B -->|"새 트랜잭션으로"| R
읽기 단계에서는 마음껏 읽습니다. 값을 읽는 것만으로는 무결성이 깨지지 않기 때문입니다. 고치는 일도 이 단계에서 합니다. 다만 모두가 보는 데이터가 아니라 그 데이터의 사본에만 씁니다. 남이 보는 데이터는 읽기만 하므로 읽기 단계라고 부릅니다.
검증 단계에서는 이 트랜잭션이 한 변경이 무결성을 깨지 않는지 확인합니다. 통과하면 쓰기 단계에서 사본을 모두가 보는 데이터로 바꿔 넣습니다. 실패하면 트랜잭션을 되돌리고 새 트랜잭션으로 처음부터 다시 시작합니다. 그래서 세 단계를 다 거치는 트랜잭션은 검증을 통과한 쪽뿐입니다. 되돌려진 쪽은 두 단계에서 끝납니다.
이름의 낙관은 이 검증에 걸려 있습니다. 트랜잭션끼리 부딪히는 일이 없기를 바랍니다. 효율을 그 바람에 겁니다.
잠그는 방식에서 잠금이 꼭 필요한 때가 드물다면, 이 방식에서 검증이 실패하는 때도 그만큼 드뭅니다. 드문 경우를 막으려고 모든 쓰기 앞에 잠금을 거는 품은 들이지 않겠다는 것이 이 선택입니다.
판 번호로 어긋남을 확인하는 순서
처음 나온 방식은 트랜잭션에 번호를 매겨 순서를 세웁니다. 검증은 그 순서에 비추어 합니다. 애플리케이션에서는 데이터마다 판 번호를 하나 붙여 이 검증을 대신하는 일이 많습니다.
판 번호는 데이터가 저장될 때마다 저장하는 쪽이 하나씩 올리는 값입니다. 읽을 때 값과 판 번호를 같이 받아 둡니다. 쓸 때는 그 판 번호를 조건으로 겁니다. 조건이 맞으면 내가 읽은 뒤로 아무도 그 데이터를 안 고친 것입니다. 판 번호처럼 「읽은 뒤로 바뀌었나」를 가려 주는 값을 아래에서는 묶어서 판 표시라고 적습니다.
sequenceDiagram
participant A as 요청 A
participant B as 요청 B
participant 저장소
A->>저장소: 읽는다
저장소-->>A: 값과 판 번호 4
B->>저장소: 읽는다
저장소-->>B: 값과 판 번호 4
A->>저장소: 판 번호가 4 이면 써라
저장소-->>A: 썼다. 판 번호는 5 가 됐다
B->>저장소: 판 번호가 4 이면 써라
저장소-->>B: 못 썼다. 지금은 5 다
A 와 B 가 같은 판을 읽었고 A 가 먼저 썼습니다. A 의 쓰기가 판 번호를 5 로 올렸으므로 4 를 조건으로 건 B 의 쓰기는 거절됩니다. B 는 다시 읽어서 5 를 받고 처음부터 합니다.
재고 장면에 대 보면 이렇습니다. B 가 「판 번호가 4 일 때만 재고를 8 개로 써라」라고 보내는 순간 거절당합니다. A 가 뺀 셋은 사라지지 않습니다.
비즈니스 트랜잭션과 시스템 트랜잭션
데이터베이스에도 트랜잭션이 있습니다. 그런데도 애플리케이션이 따로 판 번호를 견주는 까닭은 트랜잭션의 길이에 있습니다.
시스템 트랜잭션은 데이터베이스가 시작부터 커밋(변경을 확정하는 일)까지 한 묶음으로 지켜 주는 트랜잭션입니다. 비즈니스 트랜잭션은 사용자가 화면을 열어 데이터를 고치고 저장을 누를 때까지 이어지는 일 한 덩어리입니다. 비즈니스 트랜잭션 하나는 흔히 시스템 트랜잭션 여러 개에 걸쳐 돕니다.
시스템 트랜잭션 하나를 벗어나면, 비즈니스 트랜잭션이 데이터를 앞뒤 맞는 상태로 남길지를 데이터베이스만으로는 보장할 수 없습니다. 세션은 사용자 한 사람이 이어 가는 작업의 흐름입니다. 앞에서 두 요청이라고 부른 둘이 여기서는 두 세션입니다. 두 세션이 같은 레코드를 고치기 시작하면 갱신 손실이 충분히 일어날 수 있습니다. 한 세션이 고치는 데이터를 다른 세션이 읽으면 앞뒤가 안 맞는 값을 읽기도 쉽습니다.
낙관적 잠금은 커밋하려는 변경이 다른 세션의 변경과 부딪히지 않는지 저장 직전에 확인해서 이 틈을 메웁니다. 이 확인과 저장이 시스템 트랜잭션 하나 안에서 같이 일어나는 한, 비즈니스 트랜잭션 전체가 앞뒤 맞게 보입니다.
비관적 잠금과 견주기
미리 잠그는 쪽을 비관적 잠금이라고 부릅니다. 세션끼리 부딪힐 가능성이 높다고 봅니다. 그래서 동시에 진행되는 정도를 제한합니다. 낙관적 잠금은 부딪힐 가능성이 낮다고 봅니다. 그 기대 덕분에 여러 사용자가 같은 데이터를 동시에 다룰 수 있습니다.
둘이 각각 부딪히는 골칫거리도 다릅니다. 데드락은 둘 이상이 서로가 쥔 잠금을 기다리다 함께 멈춘 상태입니다. 기아는 특정 트랜잭션이 거듭 밀려 끝나지 못하는 상태입니다.
| 비관적 잠금 | 낙관적 잠금 | |
|---|---|---|
| 부딪힐 가능성에 대한 가정 | 높다 | 낮다 |
| 트랜잭션을 다루는 법 | 정해진 시점에서 기다리게 한다 | 되돌려 다시 하게 한다 |
| 같은 데이터를 동시에 다루나 | 동시성을 제한한다 | 여럿이 함께 다룬다 |
| 큰 골칫거리 | 데드락 | 기아 |
| 그 골칫거리를 푸는 수단 | 되돌림 | 잠금 |
마지막 두 줄은 서로를 비춥니다. 잠그는 쪽의 데드락은 되돌림으로 풀고, 되돌리는 쪽의 기아는 잠금으로 풉니다.
잠그지 않는데 붙은 잠금이라는 이름
이 방식은 잠그지 않는 동시성 제어로 나왔습니다. 그런데 이름에는 잠금이 붙어 있습니다. 잠금이 붙은 꼴은 뒤에 굳었습니다. 그 말을 누가 처음 썼는지는 분명하지 않습니다.
이름이 어긋나 보이지만 견주어 볼 관점은 있습니다. 저장 직전 검증을 통과하는 일은 어떤 의미에서 「이제 고쳐도 된다」는 잠금을 얻는 것으로 볼 수 있습니다.
같은 방식을 낙관적 동시성 제어(optimistic concurrency control)라고도 부릅니다. 낙관적 락이라는 말도 같은 것을 가리킵니다.
웹 요청 위의 같은 결정
잠그지 않고 저장할 때 어긋났는지 보는 이 결정은 HTTP(HyperText Transfer Protocol)에서도 돕니다. PUT 이나 DELETE 처럼 상태를 바꾸는 요청에 조건을 붙이면 갱신 손실을 막을 수 있습니다. 조건을 붙인 요청을 조건부 요청이라고 부릅니다.
클라이언트는 읽을 때 받은 판 표시를 조건에 담아 보냅니다. 서버는 지금 가진 판 표시가 그것과 맞을 때만 요청을 수행합니다. 어떤 헤더와 상태 코드가 오가는지는 뒤에서 실제 값으로 봅니다.
대가
낙관적 잠금은 기다림을 없애는 대신 되돌림을 떠안습니다. 그 되돌림이 누구에게 어떤 값으로 돌아오는지 봅니다.
보는 것은 셋입니다. 거절당한 쪽이 버리는 작업, 새로 생기는 칸과 코드, 그리고 이 맞바꿈이 이득으로 남는 조건입니다.
거절당한 쪽이 버리는 작업
부딪힌 것은 비즈니스 트랜잭션이 끝날 때에야 드러납니다. 거절당한 쪽은 그때까지 한 일을 다 해 놓고 마지막 순간에 전부 실패한다는 것을 압니다. 그동안 쓴 시간은 버려집니다.
이 일이 긴 비즈니스 트랜잭션에서 자주 일어나면 사용자가 버리는 시간이 쌓입니다.
몇 번 만에 성공할지도 미리 정할 수 없습니다. 검증이 거듭 실패하는 트랜잭션이 생길 수 있기 때문입니다. 다시 할 횟수는 그 데이터를 남들이 얼마나 자주 고치느냐에 달려 있습니다.
판 칸과 다시 하기 코드
견줄 판 표시를 어디서 얻을지 먼저 정해야 합니다. 판 번호를 담을 칸을 데이터마다 하나씩 두는 것이 가장 흔한 길입니다. 이식 가능한 JPA(Java Persistence API) 애플리케이션이라면 낙관적 잠금을 켜려는 엔티티에 이 판 칸을 반드시 둬야 합니다. 판 칸을 두지 않고 값 자체를 견주는 길도 있습니다. 판 번호는 저장하는 쪽이 올리는 값이라 애플리케이션이 손으로 고치지 않습니다.
다시 하는 코드를 누가 짤지도 정해야 합니다. 저장소는 어긋남을 알려 주는 데까지 하는 경우가 많습니다. Kubernetes 는 부딪힌 것을 잡아내지만, 다시 시도하는 코드는 클라이언트를 짜는 쪽이 넣어야 합니다. JPA 에서는 객체를 새로 읽고 트랜잭션을 다시 해 보는 것이 예외에 대한 한 가지 대응입니다.
판 번호 하나가 큰 덩어리를 대표하면, 서로 다른 칸을 고친 두 쓰기도 부딪힌 것으로 잡힐 수 있습니다. 내가 고치려는 칸이 아닌 곳에 다툼이 몰려도 저장이 거절될 수 있습니다. 덩어리가 크고 칸이 많을수록 이런 거절이 늘어납니다.
이득이 남는 조건
이 맞바꿈은 다툼이 드물 때 남습니다. 두 조건이 겹치면 부딪히는 일 자체가 드뭅니다. 지금 도는 트랜잭션들이 손대는 데이터가 데이터 전체에 비해 아주 적어야 합니다. 여럿이 몰리는 데이터를 고칠 확률도 낮아야 합니다.
부딪힐 가능성이 아주 낮은 시스템에서는 낙관적 방식이 잠금 방식보다 나을 수 있습니다. 아주 큰 트리 구조 인덱스가 그런 예로 꼽힙니다. 트랜잭션이 대부분 읽기만 하면 동시성 제어에 드는 품이 거의 무시할 만해집니다.
반대로 부딪힐 가능성이 높다고 보면 비관적 잠금을 씁니다. 다툼이 드물긴 해도 낙관적 방식을 쓸 만큼 드물지 않은 시스템이 남습니다. 이상적으로는 잠금과 되돌림을 알맞은 비율로 섞어야 합니다. 그 비율도 다툼의 빈도에 따라 바꿔야 합니다.
예시
세 계층의 실제 구현을 봅니다. 계층마다 셋이 다릅니다. 판 표시로 무엇을 쓰나, 어디에 실어 보내나, 어긋나면 무엇이 돌아오나입니다.
| 계층 | 판 표시 | 실어 보내는 곳 | 어긋나면 |
|---|---|---|---|
| JPA · Hibernate | @Version 칸 |
갱신문의 조건절 | OptimisticLockException |
| HTTP | ETag(Entity Tag) |
If-Match 헤더 |
412 Precondition Failed |
| Kubernetes | resourceVersion |
PUT 본문의 메타데이터 | 409 Conflict |
JPA 의 @Version 과 Hibernate 가 내보내는 갱신문
JPA 는 자바 객체를 관계형 데이터베이스의 행에 대응시키는 자바 표준입니다. 지금 이름은 Jakarta Persistence 입니다. 엔티티는 데이터베이스의 행 하나에 대응하는 자바 객체입니다. 엔티티에 낙관적 잠금을 켜려면 판 칸에 @Version 을 붙입니다.
@Version
@Column(name="OPTLOCK")
protected int getVersionNum() { return versionNum; }
JPA 가 판 칸에 지원하는 타입은 int · Integer · short · Short · long · Long · Timestamp 입니다. 구현체가 그 밖의 타입을 더 받을 수도 있습니다. 판 값은 객체가 데이터베이스에 기록될 때 JPA 구현체가 올립니다.
판이 안 맞는 트랜잭션은 OptimisticLockException 을 받습니다. 그 트랜잭션에는 되돌림(롤백) 표시가 붙습니다. 구현체가 쓰기를 트랜잭션 끝까지 미루는 설정이면 판 확인도 커밋 때 일어날 수 있습니다. 그러면 이 예외도 커밋 직전 단계에서 나옵니다. 예외를 받은 뒤에는 객체를 새로 읽어 트랜잭션을 다시 해 볼 수 있습니다.
Hibernate 는 JPA 를 구현한 ORM(Object-Relational Mapping) 도구입니다. 판 칸이 있는 Phone 엔티티를 고칠 때 이런 갱신문을 내보냅니다.
update
Phone
set
callCount = 0,
"number" = '+123-456-7890',
version = 1
where
id = 1
and version = 0
이 엔티티를 읽을 때 판은 0 이었습니다. set 이 판을 1 로 올립니다. where 는 판이 아직 0 인 행만 고릅니다. 그사이 다른 쓰기가 판을 올렸으면 이 문장이 고를 행이 없습니다. Hibernate 는 그것으로 부딪힌 것을 압니다. 판 번호는 Hibernate 가 매깁니다. 애플리케이션이 그 값을 바꾸는 것은 금지돼 있습니다.
HTTP 의 ETag 와 If-Match
표현은 서버가 리소스의 상태를 담아 내주는 내용입니다. ETag 는 서버가 응답에 실어 보내는 판 표시입니다. 같은 리소스의 여러 표현을 서로 가르는 불투명한 문자열입니다. 불투명하다는 것은 클라이언트가 그 속을 해석하지 않는다는 뜻입니다.
sequenceDiagram
participant 클라이언트
participant 다른 클라이언트
participant 서버
클라이언트->>서버: 읽는다
서버-->>클라이언트: ETag: "xyzzy"
다른 클라이언트->>서버: 같은 리소스를 고친다
Note over 서버: 지금 태그가 "xyzzy" 가 아니다
클라이언트->>서버: If-Match: "xyzzy" 로 고친 내용을 보낸다
서버-->>클라이언트: 412 Precondition Failed
클라이언트는 받아 둔 태그를 고친 내용과 함께 If-Match 헤더에 담아 보냅니다. 이 헤더에는 태그를 여러 개 실을 수 있습니다. 서버는 지금 표현의 태그가 그 가운데 하나와 맞을 때만 요청을 수행합니다. If-Match: * 는 현재 표현이 하나라도 있으면 맞는 것으로 칩니다.
조건이 거짓이면 서버는 요청을 수행하면 안 됩니다(MUST NOT). 대신 412 Precondition Failed 로 조건부 요청이 실패했다고 알릴 수 있습니다(MAY). 412 는 요청 헤더의 조건이 서버에서 거짓으로 판정됐다는 뜻입니다. 대상 리소스가 예상 밖의 상태이면 메서드가 적용되지 않게 막는 상태 코드입니다.
Kubernetes 의 resourceVersion
Kubernetes 의 모든 리소스는 메타데이터에 resourceVersion 칸을 가집니다. 서버는 객체를 고칠 때마다 이 문자열을 바꿉니다.
클라이언트는 고칠 객체를 GET 으로 받을 때 이 값도 받습니다. 클라이언트는 이 값을 불투명한 값으로 다룹니다. 바꾸지 않고 PUT 에 실어 돌려보냅니다. 그사이 리소스가 바뀌어 보낸 값이 낡았으면 API(Application Programming Interface) 서버는 409 Conflict 로 거절합니다.
resourceVersion 을 실어 보낸 요청이면 Kubernetes 는 이렇게 부딪힌 것을 언제나 잡아냅니다. 다시 시도하는 코드는 클라이언트를 짜는 쪽이 직접 넣어야 합니다.
실패
낙관적 잠금을 걸어 뒀는데도 갱신 손실이 되돌아오거나 일이 안 끝나는 조건을 모읍니다. 조건마다 그때 돌아오는 값을 같이 봅니다.
눈여겨볼 것은 둘째 칸입니다. 다섯 중 넷이 「성공」을 돌려줍니다. 보낸 쪽은 오류를 하나도 안 받습니다.
| 조건 | 그때 돌아오는 것 | 결과 |
|---|---|---|
| 같은 데이터에 다툼이 몰린다 | 거절이 거듭 돌아온다 | 특정 트랜잭션이 못 끝날 수 있다 |
| 수정 시각을 판 표시로 쓰고 1초 안에 두 번 고친다 | 성공 | 두 수정을 못 가른다 |
| 판 검사를 거치지 않는 쓰기가 섞인다 | 성공 | 그 쓰기가 남의 수정을 덮거나 남에게 덮인다 |
| 클라이언트가 조건 없이 보낸다 | 성공 · 서버가 조건을 강제하면 428 | 갱신 손실을 못 알아챈다 |
| 이미 적용된 것처럼 보이는 요청에 서버가 2xx 로 답한다 | 성공 | 비슷한 요청들의 걸음 하나를 잃을 수 있다 |
다툼이 한 데이터에 몰릴 때
검증에 실패한 트랜잭션은 중단되고 다시 시작합니다. 다시 시작할 때는 트랜잭션 번호를 새로 받습니다. 판 번호가 아니라 트랜잭션에 매기는 순서 번호입니다. 낙관적 가정 아래에서는 이 실패가 거듭되는 일이 드물어야 합니다. 그래도 거듭 실패하는 트랜잭션이 생길 수 있습니다. 그것이 기아입니다.
기아를 푸는 방법은 잠금입니다. 간단한 해법 하나는 이렇습니다. 검증 실패 횟수를 세어 굶는 트랜잭션을 찾습니다. 그 트랜잭션만은 다른 트랜잭션이 못 끼어들게 막은 채 다시 돌립니다. 데이터베이스 전체에 쓰기 잠금을 건 것과 같은 효과입니다. 그 트랜잭션은 끝까지 갑니다.
Kubernetes 의 API 서버에서도 같은 모양이 나옵니다. 한 객체에 다툼이 많으면 내가 고치지 않는 칸에 몰린 다툼이라도 저장이 계속 거절될 수 있습니다. 객체가 크고 칸이 많을수록 심해집니다.
수정 시각을 판 표시로 쓸 때
수정 시각을 1초 단위로만 적으면 1초 안에 두 번 고친 것을 못 가립니다. 두 수정 사이에 읽은 클라이언트는 두 번째 수정 뒤에도 같은 시각을 봅니다. 그 시각으로 조건을 걸면 검사를 통과해 두 번째 수정을 덮어쓸 수 있습니다.
그래서 수정 시각은 판 번호보다 덜 믿을 만한 판 표시입니다. 표현이 바뀌었는데도 값이 그대로 남을 수 있는 판 표시를 약한 판 표시라고 부릅니다. HTTP 에서 1초 해상도의 수정 시각이 그런 예입니다.
판 검사를 거치지 않는 쓰기
판 검사를 건너뛰는 쓰기가 하나라도 섞이면 그 쓰기와 겹친 수정은 보호받지 못합니다. JPA 의 벌크 갱신이 그런 쓰기입니다. 데이터베이스 갱신으로 곧장 내려가 낙관적 잠금 검사를 건너뜁니다. 판 칸을 올리거나 확인하려면 애플리케이션이 손으로 해야 합니다.
Hibernate 에서 @OptimisticLock(excluded = true) 로 판 검사에서 뺀 칸도 같습니다. 예시 절에서 본 Phone 엔티티의 callCount 가 그런 칸입니다. 그 갱신문 앞에는 다른 쓰기가 하나 있었습니다.
- Alice 가
Phone을 읽습니다. 판은 0 이고callCount는 0 입니다 - Bob 이
callCount를 1 로 고칩니다. 뺀 칸이라 판은 0 에서 안 올라갑니다 - Alice 가 전화번호를 고칩니다. 판이 아직 0 이라
and version = 0조건을 통과합니다 - Alice 의 갱신문은 자기가 읽어 둔
callCount = 0을 함께 써서 Bob 의 수정을 덮습니다
두 쓰기 모두 성공으로 끝납니다. 그래서 이 기능은 뺀 칸의 갱신 손실을 감당할 수 있을 때만 맞습니다.
조건 없이 오는 요청
조건을 안 붙인 요청에는 견줄 판 표시가 없습니다. 이런 요청으로는 갱신 손실을 알아챌 수 없습니다. Kubernetes 에서 조건 없는 PATCH 는 바꾸려는 내용이 기존 데이터에 기대지 않을 때 맞습니다. 갱신 손실을 잡아야 하는 클라이언트라면 PUT 이든 PATCH 든 resourceVersion 을 조건으로 거는 것을 고려해야 합니다.
서버는 조건을 강제할 수 있습니다. 428 Precondition Required 는 서버가 이 요청에 조건이 붙어 있기를 요구한다는 뜻입니다. 클라이언트가 GET 으로 상태를 받아 고친 뒤 PUT 으로 돌려놓는 사이에 제삼자가 서버 쪽 상태를 고치는 경우가 이 코드의 대표적인 쓰임입니다. 이 응답은 요청을 어떻게 다시 보내면 성공하는지 설명해야 합니다(SHOULD).
이미 적용된 것처럼 보일 때 성공으로 답하는 서버
If-Match 조건이 거짓이어도 서버가 2xx 로 답할 수 있습니다(MAY). 상태를 바꾸는 요청에서 그 변경이 이미 적용된 것처럼 보일 때입니다. 클라이언트가 보낸 변경이 실은 성공했을 수 있습니다. 그 응답만 돌아오는 길에 사라진 경우입니다. 다른 클라이언트가 같은 변경을 먼저 했을 수도 있습니다. 이런 경우에 성공으로 답하는 편이 여러 작성 작업에서 효율적입니다.
이 길에는 위험이 따릅니다. 여러 클라이언트가 함께 쓰는 카운터가 그런 경우입니다. 클라이언트마다 숫자를 읽고 하나 올려 씁니다. 그 사이에 남이 끼어들 수 있습니다.
- 클라이언트 A 와 클라이언트 B 가 카운터를 읽습니다. 둘 다 5 를 받습니다
- A 가 6 을 씁니다. 카운터는 6 이 됩니다
- B 도 6 을 씁니다. 서버는 그 변경이 이미 적용된 것으로 보고 성공으로 답합니다
둘이 각각 하나를 올렸습니다. 그런데 카운터는 7 이 아니라 6 입니다. 6 에서 7 로 가는 걸음 하나가 사라졌습니다. 이렇게 값이 한 상태에서 다음 상태로 옮겨 가는 걸음을 상태 전이라고 부릅니다. 여러 클라이언트가 아주 비슷하지만 서로 맞추지 않은 변경을 보내면 부딪혀 중요한 상태 전이를 잃을 수 있습니다. 그런 리소스라면 서버는 조건이 틀린 안전하지 않은 메서드마다 412 로 답하는 편이 낫습니다.
갈래
낙관적 잠금은 무엇을 견주어 「읽은 뒤로 바뀌었나」를 아느냐로 갈립니다. 판 표시를 어디서 얻느냐가 함께 달라집니다.
| 견주는 것 | 판 표시를 어디서 얻나 | 대표 구현 |
|---|---|---|
| 판 번호 | 데이터에 둔 판 칸 | @Version 정수 칸 · resourceVersion |
| 수정 시각 | 데이터에 둔 시각 칸 | @Version 시각 칸 · If-Unmodified-Since |
| 값 자체 | 따로 안 둔다. 행의 값을 그대로 견준다 | OptimisticLockType.ALL · DIRTY |
| 엔티티 태그 | 서버가 표현마다 만들어 내준다 | ETag · If-Match |
판 번호
저장할 때마다 하나씩 올리는 정수를 견줍니다. JPA 가 이식 가능한 낙관적 잠금 검사에 요구하는 것은 이 판 칸 하나뿐입니다. Hibernate 의 기본 방식도 이것입니다. 판 칸을 따로 두므로 행의 어느 칸이 바뀌었든 판 하나로 어긋남을 압니다.
Kubernetes 의 resourceVersion 은 숫자가 아니라 문자열입니다. 서버가 객체를 고칠 때마다 바꿉니다. 클라이언트는 그 속을 해석하지 않습니다. 판을 매기는 쪽이 서버라는 점에서 판 번호와 같은 갈래입니다.
수정 시각
마지막으로 고친 시각을 판 표시로 씁니다. Hibernate 에서는 @Version 을 Date 나 Calendar 칸에 붙이면 시각 방식이 됩니다. 판 번호보다 덜 믿을 만하지만, 그 시각 값을 애플리케이션이 다른 용도로도 쓸 수 있습니다.
HTTP 에서는 If-Unmodified-Since 가 이 갈래입니다. 엔티티 태그를 내주지 않는 리소스에서 상태를 바꾸는 요청이 서로 덮어쓰지 않게 막는 데 주로 씁니다. 1초 해상도 때문에 두 수정을 못 가르는 문제는 앞에서 봤습니다.
값 자체
판 칸 없이 값들을 견줍니다. Hibernate 의 @OptimisticLocking 이 이 방식을 지원합니다. 판 칸을 둘 수 없는 옛 스키마에도 맞춰 쓸 수 있습니다.
-- OptimisticLockType.ALL : 행의 모든 칸을 견준다
UPDATE Person SET city=? WHERE id=? AND city=? AND country=? AND created_on=? AND "name"=?
-- OptimisticLockType.DIRTY : 이번에 바뀐 칸만 견준다
UPDATE Person SET city=? WHERE id=? and city=?
ALL 은 읽은 뒤 어느 칸이든 바뀌었으면 고를 행이 없어 예외가 납니다. DIRTY 는 엔티티를 읽어 온 뒤 바뀐 칸만 조건에 넣습니다. 그래서 서로 겹치지 않는 칸을 고친 두 쓰기가 부딪힌 것으로 잡힐 위험을 줄입니다. 이 방식은 JPA 가 요구하지 않는 구현체 고유 기능이라 다른 구현체로 옮겨 가면 따라오지 않을 수 있습니다.
엔티티 태그
서버가 표현마다 만든 불투명한 문자열을 견줍니다. 만드는 방법은 서버가 고릅니다. 내부 판 번호를 쓸 수 있습니다. 내용을 해시로 줄일 수도 있습니다. 그 해시는 서로 다른 내용이 같은 값으로 겹치기 어렵습니다. 1초보다 잘게 적은 수정 시각을 쓸 수도 있습니다. 수정 시각을 담아 두기 어렵거나 1초 해상도로 모자란 경우 엔티티 태그가 수정 시각보다 믿을 만할 수 있습니다.
태그는 두 가지로 갈립니다. 앞에 W/ 가 붙은 것이 약한 태그이고, 안 붙은 것이 강한 태그입니다. 약한 태그는 표현이 바뀌었는데도 값이 그대로 남을 수 있다는 표시입니다.
두 태그가 같은지 보는 방법도 둘입니다. 강한 비교는 두 태그가 모두 약하지 않고 글자까지 같아야 맞다고 봅니다. 약한 비교는 약한 표시가 붙었든 안 붙었든 글자만 같으면 맞다고 봅니다.
| 태그 1 | 태그 2 | 강한 비교 | 약한 비교 |
|---|---|---|---|
W/"1" |
W/"1" |
안 맞음 | 맞음 |
W/"1" |
W/"2" |
안 맞음 | 안 맞음 |
W/"1" |
"1" |
안 맞음 | 맞음 |
"1" |
"1" |
맞음 | 맞음 |
If-Match 를 판정할 때 서버는 반드시 강한 비교를 써야 합니다(MUST). 표현 데이터가 조금이라도 바뀌었으면 메서드를 막으려는 조건이기 때문입니다. 그래서 약한 태그는 글자가 같아도 If-Match 를 통과하지 못합니다.
관련 항목
낙관적 잠금이 막으려는 문제
갱신 손실 · 불일치 읽기 · 경쟁 상태 · 더티 쓰기 · 쓰기 편향 · 팬텀 읽기
같은 목적을 두고 겨루는 다른 동시성 제어 방식
비관적 잠금 · 락 · 2단계 잠금 · SELECT FOR UPDATE · 행 잠금 · MVCC · 스냅샷 격리 · 직렬가능 스냅샷 격리
어긋남을 알아내는 데 쓰는 표시
리비전 · ETag · 약한 검증자 · 해시 함수 · 타임스탬프 · 논리 시계 · 체크섬
낙관적 잠금이 기대는 동시성 개념
트랜잭션 · 비즈니스 트랜잭션 · 원자성 · 격리 수준 · 직렬화 가능성 · 롤백 · 비교 후 교환 · 원자적 연산 · 동시성
거절당한 요청을 되살리는 수단
재시도 · 지수 백오프 · 멱등성 · 보상 트랜잭션 · 충돌 해결
동시성 제어가 되풀이되거나 막힐 때 터지는 장애
기아 · 데드락 · 라이브락 · 락 경합 · 재시도 폭풍 · 썬더링 허드
이 방식을 쓰는 웹 요청 규격
조건부 요청 · If-Match · If-Unmodified-Since · PUT · JSON Patch · HTTP · HTTP 409 · HTTP 412 · HTTP 428
낙관적 잠금을 구현한 영속성 도구와 API 서버
JPA · Hibernate · ORM · OptimisticLockException · Kubernetes · resourceVersion
다른 이름: optimistic locking · Optimistic Locking · optimistic concurrency control · 낙관적 락 · 낙관적 동시성 제어 · 낙관적잠금