동시에 바꾸는 작업은 어떻게 서로를 방해하지 않을까? 잠금과 격리

반응형

트랜잭션이 여러 변경을 하나의 완료 단위로 묶어도, 두 트랜잭션이 동시에 같은 데이터를 바꾸는 문제까지 자동으로 사라지지는 않는다. 각각은 정상적으로 시작하고 커밋했는데, 함께 실행했을 때만 잘못된 결과가 나올 수 있다.

동시성 제어는 단순히 한 작업을 멈춰 세우는 기능이 아니다. 어떤 중간 상태를 다른 작업이 볼 수 있는지, 같은 데이터를 바꾸려 하면 누가 기다리는지, 충돌한 작업을 실패시킬지 다시 시도할지를 정하는 규칙이다.

재고 1개를 두 주문이 동시에 읽으면 어떻게 될까

재고가 1개 남아 있고 주문 A와 B가 거의 동시에 들어왔다고 하자. 두 작업이 값을 읽고 애플리케이션에서 1을 뺀 뒤 다시 쓴다면 다음 순서가 가능하다.

초기 재고 = 1

A: 재고 1을 읽음
B: 재고 1을 읽음
A: 주문 가능하다고 판단하고 0을 씀
B: 주문 가능하다고 판단하고 0을 씀

최종 재고는 0이라서 겉보기에는 자연스럽다. 하지만 주문은 두 건 모두 성공했다. B가 쓴 0이 A가 쓴 0을 덮었기 때문에, 최종 값만으로는 두 번 감소했어야 한다는 사실이 드러나지 않는다. 이를 사라진 갱신이라고 볼 수 있다.

문제는 뺄셈 계산이 틀린 데 있지 않다. A와 B가 같은 예전 값을 읽고, 그 값이 아직 유효하다고 믿은 채 각각 썼다는 데 있다. 따라서 읽기와 쓰기 사이의 관계를 보호해야 한다.

잠금은 읽은 값이 유효한 동안 다른 변경을 기다리게 한다

한 가지 방법은 A가 재고를 읽고 바꾸는 동안 해당 행에 잠금을 잡는 것이다.

A: 재고 행 잠금 획득
A: 1을 읽고 0으로 변경

B: 같은 행의 잠금을 기다림

A: 커밋하고 잠금 해제
B: 0을 읽고 품절로 판단

B는 처음부터 동시에 실행되지 못한 것이 아니다. 같은 재고를 안전하게 판단할 수 있는 시점까지 기다린다. 잠금은 보호 대상의 현재 상태와 판단을 하나의 임계 구역으로 만든다.

하지만 잠금 범위를 너무 크게 잡으면 서로 관계없는 주문도 함께 기다린다. 모든 재고를 하나의 잠금으로 막기보다 실제로 함께 바뀌어야 하는 행이나 불변식을 기준으로 범위를 정해야 한다.

버전 검증은 기다리는 대신 충돌을 발견한다

충돌이 자주 일어나지 않는다고 예상한다면, 먼저 읽고 나중에 쓸 때 값이 바뀌지 않았는지 확인할 수 있다. 재고 행에 버전 번호가 있다고 생각해 보자.

A: 재고 1, 버전 7을 읽음
B: 재고 1, 버전 7을 읽음

A: 버전이 7일 때만 재고 0, 버전 8로 변경 → 성공
B: 버전이 7일 때만 재고 0, 버전 8로 변경 → 실패

B가 쓰려는 시점에는 버전이 이미 8이다. 데이터베이스는 B가 오래된 상태를 기준으로 판단했다는 사실을 알아낼 수 있다. B는 최신값을 다시 읽고 품절을 알리거나, 다시 계산할 수 있는 작업이라면 재시도한다.

이 방식은 대기를 줄일 수 있지만 충돌이 정상적인 실패 경로가 된다. 호출자는 충돌 오류를 일반적인 서버 장애와 구분하고, 그대로 재시도해도 되는지 사용자 판단을 다시 받아야 하는지를 결정해야 한다.

한 문장으로 조건과 변경을 묶으면 빈틈이 줄어든다

가능하다면 재고를 읽고 애플리케이션에서 계산한 뒤 쓰는 세 단계를, 데이터베이스가 조건을 확인하며 바로 변경하는 한 작업으로 줄일 수 있다.

재고가 0보다 클 때만 1 감소
  → 변경된 행 1개: 주문 진행
  → 변경된 행 0개: 품절

이 방식은 충돌을 없애는 마법이 아니다. 다만 “읽은 뒤 다른 작업이 끼어드는 시간”을 줄이고, 성공 여부를 데이터베이스가 판단할 한 지점으로 모은다. 주문 생성이나 결제 기록처럼 함께 바뀌어야 하는 다른 데이터가 있다면 여전히 트랜잭션 안에서 관계를 지켜야 한다.

잠금을 여러 개 잡으면 교착 상태가 생길 수 있다

잠금 하나만 쓸 때보다 여러 자원을 함께 바꿀 때 규칙이 복잡해진다.

A: 상품 X 잠금 획득 → 상품 Y 잠금을 기다림
B: 상품 Y 잠금 획득 → 상품 X 잠금을 기다림

A와 B는 서로가 놓아야 하는 잠금을 기다리므로 계속 진행할 수 없다. 이를 교착 상태라고 한다. 모든 코드가 문법적으로 맞아도 실행 순서에 따라 생길 수 있다.

잠금을 항상 같은 순서로 잡으면 가능성을 줄일 수 있다. 그래도 데이터베이스는 교착 상태를 발견해 한 작업을 중단시킬 수 있으므로, 호출자는 해당 트랜잭션을 처음부터 다시 시도할 준비가 필요하다. 중간 부분부터 이어서 실행하면 처음 읽은 값이 더 이상 맞지 않을 수 있다.

격리는 다른 작업이 무엇을 볼 수 있는지 정한다

동시성 문제는 같은 행을 덮어쓰는 경우만이 아니다. 한 작업이 같은 행을 두 번 읽었는데 중간에 값이 달라질 수 있고, 처음에는 없던 조건의 행이 다음 조회에서 나타날 수도 있다.

격리 수준은 이런 변화 중 무엇을 허용할지 정한다. 더 강한 격리는 한 작업이 일관된 상태를 보기 쉽게 만들지만, 잠금 대기나 이전 버전 보관, 충돌 재시도가 늘 수 있다. 따라서 이름만 보고 가장 높은 수준을 고르는 것이 아니라, 업무에서 절대 허용할 수 없는 결과가 무엇인지 먼저 정해야 한다.

돈, 재고, 권한처럼 잘못된 한 번의 판단이 실제 손실로 이어지는 데이터와 잠시 늦어도 되는 통계를 같은 규칙으로 다룰 필요는 없다. 격리는 성능 옵션이기 전에 데이터가 지켜야 할 의미를 선택하는 일이다.

이해 확인 질문과 답변

두 주문이 모두 재고 1을 읽으면 왜 최종값 0만 보고 문제를 알 수 없을까?

두 작업이 각각 0을 쓴 결과가 하나로 겹치기 때문이다. 최종값에는 주문이 두 번 성공했다는 과정이 남지 않는다. 읽기와 쓰기의 순서를 함께 봐야 사라진 갱신을 발견할 수 있다.

잠금과 버전 검증은 무엇이 다를까?

잠금은 다른 작업을 기다리게 해 현재 판단이 유효한 동안 변경을 막는다. 버전 검증은 먼저 진행하게 둔 뒤, 쓰는 시점에 충돌을 발견해 한 작업을 실패시킨다. 충돌 빈도와 호출자가 재시도를 처리할 수 있는지에 따라 선택이 달라진다.

잠금이 있으면 항상 안전할까?

아니다. 보호해야 할 범위를 빼먹을 수 있고, 잠금을 여러 개 다른 순서로 잡으면 교착 상태가 생길 수 있다. 잠금 순서, 대기 시간, 실패 뒤 재시도까지 함께 설계해야 한다.

가장 강한 격리 수준을 항상 쓰면 안 될까?

정확성은 높아질 수 있지만 대기와 충돌, 버전 관리 비용도 커질 수 있다. 먼저 금지해야 할 잘못된 결과를 정하고, 그 결과를 막는 데 필요한 격리 규칙을 고르는 편이 맞다.

AI에게 이렇게 요청할 수 있다

재고 1개에 주문 두 건이 동시에 들어오는 시간 순서표를 만들어 줘. 사라진 갱신, 행 잠금의 대기, 버전 검증의 충돌, 교착 상태가 발생하는 경우와 재시도 규칙을 비교해 줘.

동시성 제어를 이해했다면 “잠금을 걸었는가”만 확인하지 않고, 어떤 값을 읽고 판단했으며 다른 작업이 언제 끼어들 수 있고 충돌한 호출자는 무엇을 해야 하는지까지 설명할 수 있어야 한다.

 

반응형
KEEP READING
카테고리 전체 보기 →

댓글