Skip to Content

14장. 믿음의 문제 — 낙관적 제어

만화로 보는 요약 — 먼저 읽어보세요

만화로 보는 요약 — 먼저 읽어보세요

잠그지 않고 일단 진행한다 → 커밋 시점에 version을 확인한다 → 충돌이면 실패하고 다시 읽어 재시도한다.

면접 실전 질문: ① 낙관적 제어와 비관적 락의 차이 ② version 컬럼이 충돌을 잡는 방식 ③ 재시도 폭풍이 생기는 조건


배경 — 미리 막지 말고, 나중에 확인하면?

지금까지 본 두 전략은 미리 뭔가를 했어요. 락은 미리 잠그고(12장), MVCC는 미리 버전을 쌓죠(13장). 둘 다 “충돌이 날 수 있으니 대비하자”는 자세입니다.

그런데 곰곰이 생각해보면, 실무의 많은 데이터는 충돌이 거의 안 납니다. 내 프로필, 내 장바구니, 잘 안 겹치는 주문 건들… 이런 데이터에까지 매번 자물쇠를 채우는 건 낭비 아닐까요? 여기서 정반대의 배짱이 나옵니다. “어차피 충돌 잘 안 나. 일단 그냥 진행하고, 정말 부딪쳤는지는 커밋할 때만 확인하자.”

스토리 — 그새 바뀌었나요?

낙관적 제어의 아이디어는 버전 번호 하나로 요약됩니다. 데이터에 “몇 번째 수정본인지” 버전을 달아두는 거예요.

상품 재고를 두 명이 동시에 고치는 상황을 봅시다. 재고 행에 version = 5가 붙어 있어요.

[민수] 재고 읽음 (재고 10, version 5) → 잠그지 않고 그냥 진행 [영희] 재고 읽음 (재고 10, version 5) → 얘도 잠금 없이 진행 [민수] "version이 아직 5면 6으로 올리며 저장" → 성공! (이제 version 6) [영희] "version이 아직 5면 6으로 올리며 저장" → 어라, version이 6이네? 실패!

핵심은 마지막 줄이에요. 영희가 저장할 때, DB는 “내가 읽던 그 version이 아직 그대로인가” 를 확인합니다. 민수가 이미 6으로 올려놨으니, 영희의 “version 5 조건”은 안 맞아요. 그래서 영희의 저장은 실패합니다. 아무도 안 잠갔는데, 충돌이 커밋 시점에 잡힌 거예요.

이걸 SQL로 쓰면 그 유명한 패턴이 됩니다.

-- 읽을 때 version도 같이 읽어둠 (version = 5) -- 저장할 때, version이 그대로일 때만 성공 UPDATE product SET stock = 9, version = 6 WHERE id = 42 AND version = 5; -- 영향받은 행이 0이면 → 그새 남이 바꿨다 = 충돌!

WHERE version = 5가 조건이라, 그새 누가 version을 바꿨으면 0행이 갱신됩니다. 0행이면 “충돌”이고, 앱은 보통 다시 읽고 재시도(retry) 하죠.

핵심 — 도박은 언제 이기나

이렇게 “일단 진행하고 커밋 때 검사”하는 걸 낙관적 동시성 제어(optimistic concurrency control) 라고 해요. 12장의 비관적 제어와 정확히 반대죠.

비관적 (락)낙관적 (버전)
태도”충돌 날 거야, 미리 잠그자""충돌 잘 안 나, 일단 가자”
언제 확인시작할 때 잠금커밋(저장)할 때 버전
충돌 시기다림실패 → 재시도
유리한 상황충돌 잦음충돌 드묾

이게 도박인 이유가 여기 있어요. 충돌이 드물면 낙관적이 이깁니다. 잠그는 비용 없이 대부분 한 번에 통과하니까요. 하지만 충돌이 잦으면 집니다. 계속 실패하고 재시도하느라 오히려 더 느려지거든요(재시도 폭풍). 그래서 “이 데이터가 얼마나 자주 부딪치나”를 보고 고르는 겁니다.

실무에서 JPA를 쓰면 이걸 애노테이션 하나로 합니다.

@Entity public class Product { @Id Long id; int stock; @Version // 이 필드로 낙관적 락을 건다 Long version; }

@Version 필드를 두면, JPA가 UPDATE ... WHERE version = ?를 자동으로 붙이고, 0행이면 OptimisticLockException 을 던져요. (이름은 ‘낙관적 ’이지만, 12장 같은 진짜 자물쇠는 없어요. 잠그는 대신 커밋 때 버전을 대조할 뿐이죠.) 개발자는 그 예외를 잡아 재시도하면 됩니다.

for (int attempt = 0; attempt < 3; attempt++) { try { Product p = repo.findById(42L).orElseThrow(); p.setStock(p.getStock() - 1); // 읽고 → 고치고 repo.save(p); // 저장 시 버전 검사 (WHERE version = ?) return; // 성공 } catch (OptimisticLockException e) { // 그새 남이 바꿈 → 다시 읽고 재시도 } }

낙관적 락이 잘 맞는 건 같은 한 행을 다투는 상황이에요. 7장 오버부킹(재고 한 행 감소), 같은 좌석을 둘이 예약 — 이런 lost updateFOR UPDATE(비관적) 대신 @Version(낙관적)으로도 훌륭히 잡힙니다. 단, 11장 write skew처럼 _서로 다른 행_을 고쳐 공동 규칙을 깨는 충돌은 @Version으론 못 잡아요. 버전은 행 단위라, 각자 다른 행을 건드리면 양쪽 다 검사를 통과하거든요. 그건 진짜 직렬성이나 FOR UPDATE의 몫이었죠.

정리

  • 낙관적 동시성 제어 = 잠그지 않고 일단 진행, 커밋(저장) 시점에 버전으로 충돌 확인. 비관적 락과 정반대.
  • 구현: 행에 version 컬럼 → UPDATE ... WHERE version = 읽었던값. 0행이면 충돌 → 재시도. JPA는 @Version + OptimisticLockException.
  • 도박의 규칙: 충돌이 드물면 낙관적 승(잠금 비용 0), 잦으면 패(재시도 폭풍). 데이터의 충돌 빈도로 고른다.

생각해볼 질문: 이제 격리를 구현하는 세 전략(락·MVCC·낙관적)을 다 봤습니다. 그런데 이 모든 건 조용한 전제 위에 서 있어요. “커밋한 데이터는 절대 안 사라진다.” 5장·8장에서 “되돌릴 정보를 로그에 먼저 적는다”고 흘렸던 그 로그 — 크래시가 나도 데이터를 되살리는 그 장치는 실제로 어떻게 생겼을까요? 제4부의 마지막, WAL로 갑니다.

15장 · 재난 후의 복구 — WAL

Last updated on