두 요청이 같은 재고를 읽고 각각 차감한 값을 저장합니다. 각 요청을 트랜잭션으로 묶어도 갱신이 유실될 수 있으며 어떻게 막나요?
잠깐, 답변을 보기 전에
먼저 내 말로 설명해 볼까요?
답변과 점검 포인트 펼치기답변 접기
구두 답변
정의를 외우기보다, 원리와 조건을 연결해 설명해 보세요.
트랜잭션으로 묶는 것만으로 모든 충돌을 막을 수는 없습니다. 원자성은 여러 변경을 전부 반영하거나 전부 취소한다는 뜻이고, 다른 요청과 어떻게 간섭하는지는 격리 수준과 쿼리 방식에 달려 있습니다. 저는 먼저 동시에 요청이 들어와도 반드시 지켜야 하는 데이터 규칙을 정하겠습니다.
예를 들어 카운터가 10일 때 두 요청이 각각 읽고 11을 저장하면, 두 번 증가시켰는데도 결과가 11일 수 있습니다. 뒤의 쓰기가 앞선 변경을 덮어쓴 갱신 손실입니다. 이런 단순 증가는 애플리케이션에서 읽고 계산하기보다 DB에 현재 값에서 1을 더하도록 요청하겠습니다. 재고 차감이라면 재고가 충분한 경우에만 차감하는 조건을 같은 UPDATE에 넣고, 변경된 행 수로 성공 여부를 확인할 수 있습니다.
반면 여러 값을 읽고 복잡한 판단을 해야 한다면 버전 검사를 사용할 수 있습니다. 읽을 때의 버전이 그대로인 경우에만 변경하고 버전도 함께 올리는 방식이 낙관적 동시성 제어입니다. 그사이에 누가 수정했다면 다시 읽고 판단해야 합니다. 저는 충돌이 드물고 잠금을 오래 잡기 싫은 작업에 먼저 검토하겠습니다. 충돌이 잦아 같은 계산을 계속 반복한다면, 대상 행을 먼저 잠그는 비관적 방식이 더 나을 수 있습니다. 이때는 잠금 대기와 교착이 새 비용이 됩니다.
주의할 점은 규칙이 여러 행에 걸쳐 있을 때입니다. 예를 들어 당직자가 한 명은 남아야 하는데, 두 사람이 서로 상대가 남는다고 읽고 자기 행만 비활성화하면 모두 빠질 수 있습니다. 서로 다른 행을 바꿔 직접적인 쓰기 충돌은 없지만 규칙은 깨진 것입니다. 이런 쓰기 편향은 일부 스냅샷 격리에서도 가능하므로, 공통 잠금 대상을 두거나 직렬화 가능 격리 수준으로 해당 판단을 보호하겠습니다.
검증에서는 두 세션이 같은 시점에 읽도록 맞춰 놓고 규칙이 깨지는지 재현하겠습니다. 그리고 충돌 시 다시 읽을지, 기다릴지, 실패로 돌려줄지를 정하겠습니다. 단순히 트랜잭션을 추가했다는 것보다 업무 규칙에 맞는 보호 범위와 실패 처리가 있어야 안전합니다.
불변식에서 갱신 단위를 정합니다
재고는 음수가 되면 안 되고, 성공한 주문의 수량만큼 정확히 줄어야 한다는 것이 이 예에서 지킬 불변식(invariant)입니다. 여러 변경을 함께 확정하거나 함께 취소하는 성질은 원자성(atomicity)이고, 다른 트랜잭션과 섞였을 때 어떤 관찰과 결과를 허용할지 정하는 성질은 격리성(isolation)입니다. 둘은 트랜잭션의 서로 다른 책임입니다. 재고 10을 두 요청이 각각 읽고 9를 저장해 최종 값이 9가 되는 현상을 갱신 손실(lost update)이라고 부릅니다. 하지만 어떤 DB·격리 수준에서는 두 번째 쓰기를 실패시키므로 모든 트랜잭션에서 반드시 같은 결과가 난다고 일반화하지 않겠습니다.
단순 차감은 UPDATE inventory SET stock = stock - :qty WHERE id = :id AND stock >= :qty처럼 현재 값을 기준으로 한 조건부 갱신으로 표현할 수 있습니다. 수량이 양수라는 입력 검증과 한 상품 ID가 한 행이라는 제약도 전제입니다. 영향받은 행이 하나면 차감했고, 0이면 재고 부족인지 대상이 없는지 계약에 맞춰 구분합니다. 주문 생성도 함께 성공해야 한다면 같은 트랜잭션에서 묶겠습니다. 다만 이 쿼리를 동일 요청으로 두 번 실행하면 재고가 충분할 때 두 번 차감됩니다. 서로 다른 요청의 동시성 제어와 같은 요청의 중복 방지는 다른 문제입니다.
복잡한 계산을 락 밖에서 한 뒤 반영해야 한다면 읽을 때의 버전을 조건으로 넣습니다. 예상 버전일 때만 값을 바꾸는 것은 비교 후 교환(compare-and-swap)과 같은 조건부 변경의 생각을 DB에 적용한 것입니다. 버전이 바뀌면 계산의 전제가 무효이므로 값을 다시 읽고 판단해야 합니다. 이것이 낙관적 동시성 제어(optimistic concurrency control)입니다. 충돌이 드물면 잠금 점유를 줄이지만, 인기 상품처럼 충돌이 많으면 같은 계산을 반복해 처리량이 낮아질 수 있습니다. 반면 먼저 잠그는 비관적 방식은 경쟁을 대기로 바꾸며, 긴 트랜잭션과 잠금 순서가 새 병목이 됩니다.
한 행의 버전만으로 해결되지 않는 조건도 있습니다
여러 행을 읽어 ‘누군가는 남아 있다’고 판단한 뒤 각자 다른 행을 수정하면 쓰기 충돌 없이 전체 규칙이 깨질 수 있습니다. 이를 쓰기 편향(write skew)이라고 부릅니다. 자기 행의 버전만 검사해도 다른 행에서 생긴 변경은 놓칩니다. 이 경우 공통 제약으로 표현할 수 있는지, 같은 조정 행을 잠글지, 트랜잭션들이 어떤 직렬 순서로 실행된 것과 같은 결과를 만드는 직렬화 가능성(serializability)이 필요한지 검토하겠습니다. 이는 모든 작업을 실제 한 스레드에서 실행하라는 뜻은 아닙니다.
격리를 강화해도 교착이나 직렬화 실패로 한 트랜잭션이 중단될 수 있습니다. 재시도할 때는 이전 판단을 재사용하지 않고 필요한 읽기부터 전체 단위를 다시 실행하며, 시도 횟수와 시간 예산을 둡니다. 그 안에서 보낸 이메일이나 외부 결제는 DB 롤백으로 취소되지 않으므로 재시도 경로에서 분리해야 합니다. 두 세션을 읽기 직후에서 멈춰 순서를 통제하는 테스트를 만들고, 성공 응답 수·주문 수·차감 합계가 맞는지 확인하겠습니다. 정상 요청 한 번의 성공보다 경쟁 순서가 바뀌어도 불변식이 유지되는지가 검증 기준입니다.
득점 포인트
이해가 드러나는 지점을 확인해 보세요.
- 업무 규칙에서 보호 범위를 정한다. 원자성과 격리성을 구분하고 실제 깨질 수 있는 실행 순서를 설명한다.
- 단순 갱신을 원자적인 쿼리로 바꾼다. 조건부 UPDATE와 영향받은 행 수를 통해 조회·변경 사이의 틈을 줄인다.
- 충돌 빈도에 따라 방식을 선택한다. 낙관적 재시도의 비용과 비관적 잠금의 대기·교착 비용을 비교한다.
- 여러 행의 규칙을 사례로 설명한다. 당직자 예시처럼 직접 쓰기 충돌 없이도 불변식이 깨지는 상황을 짚는다.
감점 포인트
놓치기 쉬운 조건과 오해를 짚어 보세요.
- “트랜잭션이니까 요청들이 섞이지 않는다”고 말한다. 격리 수준과 DB 구현에 따라 읽기·쓰기 간섭이 달라진다.
- “버전이 다르면 같은 UPDATE만 재시도한다”고 말한다. 이전 읽기로 내린 판단이 여전히 맞는지 다시 확인해야 한다.
- “스냅샷으로 읽으면 여러 행의 규칙도 안전하다”고 말한다. 같은 스냅샷을 읽고 다른 행을 수정하는 쓰기 편향이 가능할 수 있다.
- “격리 수준을 높이면 실패 처리는 필요 없다”고 말한다. 충돌 트랜잭션이 중단될 수 있어 재시도 또는 실패 응답이 필요하다.
더 파고들 거리
하나 더 질문하며 이해의 범위를 넓혀 보세요.
- DB별 READ COMMITTED와 SNAPSHOT은 읽기 시점과 갱신 충돌 처리에서 어떻게 다를까요?
- 낙관적 갱신 충돌이 계속 발생합니다. 재시도 중단과 키별 직렬화 전환을 어떤 지표로 판단하나요?
- 트랜잭션 재시도 중 이메일이나 외부 API가 중복 실행되지 않도록 하려면 어떻게 분리해야 할까요?
- 여러 행에 걸친 불변식을 제약조건, 공통 잠금 행, SERIALIZABLE 중 어떤 방식으로 보호할까요?
득점·감점 포인트는 학습용 자기 점검 기준이며, 특정 회사의 실제 채점표가 아닙니다.
원리부터 공부하고 싶다면
답변을 보충하는 개념·예제·슈도코드를 읽어 보세요.