← 기술 지식

기술 지식 / 데이터 시스템 / 데이터베이스 / 트랜잭션·동시성

다른 행의 갱신이 만드는 쓰기 편향

당직자 두 명이 서로의 존재를 보고 퇴근하는 예에서 스냅샷 읽기·공통 갱신 지점·직렬화 재시도의 차이를 추적합니다.

데이터베이스 · 원리·구현·운영
기술 지식 트리

이 노트는 한 행의 값 충돌이 아니라 여러 행을 함께 읽어야 하는 불변식이 어떻게 깨지는지를 거래의 읽기 집합과 쓰기 집합으로 추적합니다. 따라서 해결책도 자기 행의 버전 검사에 머물지 않고, 규칙이 성립하는 공통 직렬화 지점을 보호하거나 거래 전체를 직렬화해야 합니다.

개별 변경의 타당성과 전체 규칙 위반

당직자 A와 B가 모두 근무 중입니다. 규칙은 “최소 한 명은 남아야 한다”입니다. A는 B가 근무 중임을 확인하고 퇴근하고, B도 A가 근무 중임을 확인하고 퇴근합니다. 같은 시점의 스냅샷을 본 두 거래가 서로 다른 자기 행만 수정하면, 같은 행 충돌이 없어도 둘 다 퇴근할 수 있습니다.

이 현상을 쓰기 편향(write skew)이라고 합니다. 같은 재고 값을 덮어쓰는 갱신 유실과 다릅니다. 각 행은 정상적인 값이고 두 변경도 모두 남았지만, 여러 행을 함께 보아야 하는 규칙이 깨진 것입니다.

여기서 스냅샷 읽기는 각 거래가 판단 시점에 본 일관된 상태이고, write set은 자기 행뿐인 상황을 뜻합니다. A와 B의 쓰기 집합은 서로 다르지만 A의 쓰기는 B의 읽기 집합과, B의 쓰기는 A의 읽기 집합과 겹칩니다. 같은 행에 대한 쓰기·쓰기 충돌만 검사하면 이 읽기·쓰기 교차 의존을 놓칠 수 있습니다. 그래서 “각 UPDATE가 성공했다”와 “전체 불변식이 유지됐다”를 별도 결과로 기록해야 합니다.

읽은 집합과 쓴 행의 분리

단계 거래 A 거래 B 커밋된 당직 수
시작 2
읽기 A·B 모두 근무 중 A·B 모두 근무 중 2
판단 B가 있으니 A 퇴근 가능 A가 있으니 B 퇴근 가능 2
쓰기 A 행만 false B 행만 false 아직 2
둘 다 커밋 A 퇴근 B 퇴근 0
서로 다른 쓰기 뒤의 교차 의존거래 A: B=true를 읽음, A=false를 씀. 거래 B: A=true를 읽음, B=false를 씀B의 상태에 의존A의 상태에 의존거래 AB=true를 읽음A=false를 씀거래 BA=true를 읽음B=false를 씀
서로 다른 쓰기 뒤의 교차 의존 화살표는 상대가 근무 중이라는 읽기 결과에 의존하는 관계입니다. 쓰는 행이 달라도 서로의 판단 근거를 바꾸므로 충돌이 있습니다.
그림을 글로 읽기
  • 거래 A: B=true를 읽음 · A=false를 씀
  • 거래 B: A=true를 읽음 · B=false를 씀
  1. 거래 A → 거래 B: B의 상태에 의존
  2. 거래 B → 거래 A: A의 상태에 의존

A만 먼저 완전히 실행한 뒤 B를 실행했다면 B는 A가 퇴근한 것을 보고 남아야 합니다. 반대 순서도 같습니다. 최종 당직 수 0은 어느 직렬 순서로도 만들 수 없는 결과입니다. 스냅샷이 읽기 중 바뀌지 않는다는 사실은 이 교차 의존을 자동으로 거절한다는 뜻이 아닙니다.

자기 행 버전 검사와 집합 규칙 보호의 한계

각 행에 version을 두어 A는 A의 버전, B는 B의 버전을 검사해도 서로 다른 행을 바꾸므로 둘 다 성공할 수 있습니다. SELECT FOR UPDATE도 각자 자기 행만 잠그면 같은 문제가 남습니다.

보호해야 할 단위는 “A 행”이 아니라 “그룹 안에 최소 한 명이 남는다”는 조건입니다. 이 조건을 표현하는 공통 행을 만들거나, 관련 집합을 정확히 잠그거나, 직렬화 격리로 읽기·쓰기 의존 충돌을 검출하는 방식이 필요합니다.

작은 상태 추적은 A=true, B=true에서 두 거래가 각각 상대 행을 읽고 자기 행만 false로 바꾸는 것입니다. 두 version 검사가 모두 이전 값과 일치해도 읽은 집합 전체를 보호하지 않았기 때문에 최종 상태가 A=false, B=false가 됩니다. 보호 단위를 공통 행이나 정확한 집합으로 옮기는 이유는 이 교차 의존을 하나의 경쟁 지점으로 만들기 위해서입니다.

공통 행 기반 조건부 감소

한 대안은 당직 그룹 행에 active_count를 두고, 퇴근하려는 거래가 그 값을 조건부로 감소시키는 것입니다. 다음은 인가된 사용자의 유효한 요청이며, 모든 당직 변경 경로가 같은 규칙을 사용한다는 전제의 슈도코드입니다.

begin transaction
    person = lock_person(person_id)
    if person is absent: rollback; return not_found
    if not person.on_call: rollback; return already_off

    changed = UPDATE duty_group
              SET active_count = active_count - 1
              WHERE id = person.group_id AND active_count > 1
    if changed != 1:
        rollback
        return must_stay
    UPDATE people SET on_call = false WHERE id = person_id
commit

초기 count=2에서 A가 감소시키면 1이 됩니다. B의 조건 active_count > 1은 실패하거나 엔진의 충돌 경로를 거치므로 둘 다 퇴근하지 못합니다. 사람 행과 count 변경은 같은 거래로 커밋해야 합니다. 중간 오류로 하나만 남으면 새로 만든 count 자체가 잘못됩니다.

이 대안은 people.on_call에서 계산할 수 있는 값을 duty_group.active_count에도 복제해 두는 비용을 감수합니다. 따라서 출근·그룹 이동·관리자 수정처럼 사람 상태를 바꾸는 모든 경로가 두 값을 같은 transaction에서 함께 갱신해야 하고, 초기 데이터와 주기적인 불일치 대사로 count가 실제 인원과 맞는지 확인해야 합니다.

여러 행을 잠그는 경로의 잠금 순서도 통일해야 하며, 공통 행 하나를 잠그는 방식이라도 오래된 snapshot의 COUNT를 그대로 쓰지 말고 잠금을 얻은 뒤 어느 읽기 시점의 값을 사용하는지 엔진 계약을 확인해야 합니다.

직렬화 실패와 판단 재실행

직렬화 격리는 결과가 어떤 직렬 실행과 동등하도록 보장하는 계약입니다. DB에 따라 잠금으로 대기시키거나 충돌을 감지해 일부 거래를 중단할 수 있습니다. 실패는 “같은 UPDATE를 다시 보내라”가 아니라 이번 읽기와 판단 전체를 그대로 확정할 수 없다는 뜻일 수 있습니다.

for attempt in bounded_attempts:
    try:
        begin new serializable transaction
        current = read_current_duty_state()
        decide_whether_person_can_leave(current)
        write_if_allowed()
        commit
        return result
    catch confirmed_serialization_abort:
        rollback_if_active()
        wait_with_jitter_within_original_deadline()
return retry_limit

새 거래에서 B가 다시 읽으면 A는 이미 퇴근했으므로 B의 요청을 거절하는 것이 정상입니다. 처음 계산한 “가능”을 시도 밖에 저장해 재사용하면 안 됩니다. 반대로 논리 요청 ID는 매 시도마다 새로 만들지 않습니다.

커밋 응답 유실은 직렬화 중단이 확인된 경우와 다릅니다. 실제로 커밋됐을 수 있으므로 같은 요청 ID의 결과 조회가 필요합니다. 메일·결제를 재시도 함수 안에서 바로 실행해 놓으면 DB 롤백으로 취소되지 않아 중복될 수 있습니다.

동일 실패 사례 기반 대안 비교

방법 보호하는 경계 남는 비용 또는 전제
자기 행 버전만 검사 같은 행 덮어쓰기 집합 규칙은 보호 못 함
공통 count 조건 갱신 그룹의 공통 변경 지점 중복 count·모든 경로의 일관성
관련 집합 잠금 판단에 쓰는 집합 빈 범위·새 행·엔진별 잠금 범위
직렬화 격리 직렬 실행과 동등한 결과 충돌·거래 전체 재시도

두 연결을 첫 읽기 직후 멈춰 같은 상태를 보게 만든 뒤 각각 수정하도록 합니다. 스냅샷 격리의 반례에서는 최종 0을, 수정된 구조에서는 최종 1 이상과 한 요청의 거절 또는 재시도를 확인합니다. 단순 성공 건수보다 최종 불변식·거래 오류·재시도 중복을 함께 검사해야 합니다. 실제 DB 버전과 격리 설정별 실험은 별도로 수행해야 합니다.

공통 count 행은 규칙을 한 행의 조건부 갱신으로 바꾸므로 구현과 관찰이 단순하지만, count와 실제 사람 행을 함께 갱신해야 한다는 불변식이 새로 생깁니다. 관련 집합 잠금은 원본이 권위일 때 자연스럽지만 빈 범위와 새 삽입을 잠그는 엔진 계약이 어렵습니다. serializable은 모델 중복을 줄이는 대신 거래 전체 재실행과 외부 부작용 분리를 요구합니다.

재현 시험의 판정값은 최종 active 수뿐 아니라 거래별 commit·serialization abort·retry 횟수입니다. 두 거래를 첫 읽기 직후 장벽으로 맞추고, 수정 전에는 최종 0이 관찰되는지, 공통 count 또는 serializable 적용 후에는 한 요청이 거절되거나 재시도되어 최종 1 이상인지 확인합니다. 실제 DB마다 snapshot·lock·serializable의 의미가 다르므로 버전과 설정을 결과와 함께 보관해야 합니다.

후속·관련 개념

함께 읽을 문서

전체 기술 지식
선택 연습 · 관련 질문 2개

질문은 이해를 확인하는 보조 자료입니다. 학습 경로와 문서의 범위는 질문 수와 독립적입니다.