Lost Update: 두 AI 에이전트가 하나의 파일을 수정할 때 발생하는 덮어쓰기 문제
요약
두 AI 에이전트가 동일한 리소스를 동시에 수정할 때 발생하는 'Lost Update' 현상과 그 위험성을 분석합니다. 에이전트가 작업을 승인받았음에도 불구하고 데이터가 소리 없이 덮어씌워지는 문제를 해결하기 위해 '비교 및 설정(compare-and-set)' 방식의 버전 확인 메커니즘을 제안합니다.
핵심 포인트
- 동시 작업 시 마지막 작성자가 이전 작업을 덮어쓰는 Lost Update 발생
- 에이전트의 쓰기 작업이 성공(ACK)으로 표시되어도 데이터 유실 가능
- 에이전트 수가 늘어날수록 데이터 유실 확률이 급격히 증가
- 해결책으로 쓰기 전 버전을 확인하는 'compare-and-set' 게이트 필요
AI 에이전트 간의 Lost Update(분실된 업데이트): 두 에이전트가 동일한 리소스를 수정하고, 두 번의 쓰기(write) 작업 모두 성공을 반환하지만, 시스템 어디에서도 아무런 경고 없이 하나가 조용히 사라지는 현상입니다. 이는 두 에이전트가 동일한 버전을 읽고 마지막 작성자가 나머지를 덮어쓸 때 발생합니다. 해결책은 더 큰 로그를 만드는 것이 아니라, 쓰기 전 비교 및 설정(compare-and-set) 게이트를 두는 것입니다.
직접 실행해 보았습니다. 최악의 스케줄링 상황(모든 에이전트가 누군가 쓰기 전에 먼저 읽는 상황)에서 5개의 에이전트가 하나의 공유 파일에 커밋을 시도했을 때, 5개의 기여 중 4개가 최종 상태에서 사라졌으며, 이 5개의 쓰기 작업은 모두 승인(acknowledged)되었습니다. 최종 파일에는 정확히 한 에이전트의 작업물만 남아 있었습니다. 나머지 네 개는 비용이 지불되었고, 승인(ACK)되었음에도 불구하고 조용히 덮어씌워졌습니다. 그리고 이러한 최악의 상황은 드문 일이 아닙니다. N=5인 상황에서 실행 순서(interleavings)를 공정하게 샘플링했을 때, 사실상 100%의 실행에서 어떤 쓰기 작업이 유실되었습니다.
이것은 lost update 이상 현상이며, AI 에이전트보다 수십 년이나 더 오래된 문제입니다. 새로운 점은 사라진 각 쓰기 작업마다 실제 모델 토큰(tokens)이 소모되었다는 것이며, 이 손실은 오류도, 예외(exception)도, 빨간색 로그 라인도 남기지 않는다는 것입니다. 그래서 저는 이 손실을 수치화하고, 이를 실제로 막을 수 있는 유일한 해결책인 '쓰기가 반영되기 전의 버전 확인(version check)'을 테스트하기 위해 작은 오프라인 시뮬레이터를 구축했습니다.
AI 공개 사항: 저는 AI 어시스턴트와 함께
lostwrite_sim.py를 작성했으며, Python 3.13.5 환경에서 표준 라이브러리만을 사용하여 오프라인으로 직접 실행했습니다. 네트워크, 키(keys), 스레드(threads), 자금은 사용하지 않았습니다. 동시성(Concurrency)은 실제 병렬성(parallelism)이 아닌 명시적인 실행 순서 스케줄링(interleaving schedules)에 의해 시뮬레이션되므로 출력 결과는 재현 가능합니다. 아래의 모든 숫자와 16진수 문자열은 실제 로컬 실행에서 복사되었습니다. 세 번의 실행 결과는 sha2561df08c5e38894622314a2b052684303853b9b8fa9508eaabdfd877970c88983b와 일치하는 바이트 단위로 동일한 STDOUT을 생성했습니다. 링크된 사양(specs)과 기사들은 타인의 작업물입니다.
요약하자면:
- N개의 에이전트가 리소스의 동일한 버전을 읽고, 각자 작업을 수행한 뒤, 각자 다시 쓰는 경우, 마지막에 쓰는 사람이 승리하며 그 사이의 모든 작업은 소리 없이 덮어쓰여집니다 (overwritten). 최악의 경우 N=5일 때 5번의 작업 중 4번의 쓰기 작업이 유실되었으며, 이 모든 과정은 정상적으로 승인(acknowledged)되었습니다.
- 이 현상이 발생하는지 여부는 스케줄링 (schedule)에 달려 있습니다. 인터리빙 (interleavings, 교차 실행)의 공정한 샘플링 결과, 손실률은 N=2일 때 실행의 75.0%에 달했고 N=5에 이르러서는 사실상 100%에 도달했습니다. 하지만 N=2 실행 중 4분의 1은 직렬화 (serialized)되어 아무것도 유실되지 않았습니다. 따라서 단 한 번의 성공적인 테스트 실행이 아무것도 증명하지 못합니다.
- 추가 전용 로그 (append-only log)는 모든 쓰기 작업을 기록하므로, 각 쓰기가 발생했음을 증명할 수 있습니다. 하지만 유실된 작업을 복구하는 데는 도움이 되지 않습니다. 현재 값을 읽으면 여전히 유실된 업데이트 (lost update)가 반환되는데, 이는 로깅 (logging)이 쓰기라는 사실은 보존하지만, 그 변경 사항의 존재 자체를 보존하는 것은 아니기 때문입니다.
- 쓰기 전 비교 및 설정 (compare-and-set, 읽은 이후 버전이 변경되지 않았는지 확인) 방식은 구조적으로 유실된 업데이트를 0으로 만듭니다. 측정 결과, 제가 샘플링한 모든 N에 대해 46,333번의 공정한 인터리빙 (interleavings) 동안 유실이 전혀 발생하지 않았습니다.
- 이 게이트 (gate)는 비용을 절감해주지 않습니다. 오히려 비용이 더 많이 듭니다. 최악의 경우 N=5일 때 5번의 작업 외에 4번의 재시도 (retries) 작업 단위가 추가되어, 총 작업량이 5에서 9로 늘어났습니다. 이는 환불이 아니라 무결성 (integrity)과 계산 가능한 충돌 (collision)을 사는 것입니다.
- 동일한 게이트 내부에서 버전 확인을 꺼버리면 손실은 즉시 다시 발생합니다 (다시 4번). 이러한 반증은 게이트라는 구조물이 아니라, 비교 (compare) 작업 자체가 실질적인 역할을 수행하고 있다는 증거입니다.
이것은 더 이상 장난 수준의 문제가 아닙니다. 두 개의 하위 에이전트 (sub-agents), 혹은 스웜 (swarm), 또는 워커 (workers)를 생성하는 재시도 플래너 (retrying planner)를 실행하는 순간, 당신은 공유 상태 (shared state)인 계획 파일 (plan file), 연습장 (scratchpad), 데이터베이스 행 (database row), GitHub 이슈 (GitHub issue)에 여러 액터 (actors)가 쓰는 상황을 맞이하게 됩니다. @opsveritas가 이와 쌍을 이루는 내용을 작성했습니다: 한 에이전트가 타임아웃 (timeout)되지만 나머지 세 에이전트는 이를 알아차리지 못하며, 결국 아무도 읽지 않을 작업에 대해 비용이 지불됩니다. 유실된 업데이트 (lost update)는 쓰기 측면에서 바라본 동일한 실패 사례입니다.
두 AI 에이전트 사이의 유실된 업데이트 (lost update)란 무엇인가?
유실된 업데이트 (lost update)는 두 액터 (actor)가 동일한 버전의 리소스를 읽고, 읽은 값을 바탕으로 각각 새로운 값을 계산한 뒤, 두 값 모두를 다시 쓰는 과정에서 발생합니다. 두 번째 쓰기는 첫 번째 쓰기보다 이전의 스냅샷 (snapshot)을 기반으로 하므로, 이를 커밋 (commit)하면 첫 번째 쓰기가 지워지게 됩니다. 이때 두 액터 모두 에러를 인지하지 못합니다. 이 고전적인 정의는 데이터베이스 격리 이론 (database isolation theory)에 존재하며, 에이전트 (agent)들이 쓰기 전제 조건 (write precondition) 없이 리소스를 공유하는 순간 이 문제를 그대로 물려받게 됩니다.
시뮬레이터에서 제가 사용한 멘탈 모델 (mental model)은 의도적으로 구체적입니다: N개의 에이전트가 하나의 공유 파일에 각자의 섹션을 추가합니다. 올바른 최종 파일에는 N개의 섹션이 모두 포함되어야 합니다. 유실된 업데이트란, 승인된 쓰기 (acknowledged write)에 포함되었으나 최종 파일에서는 사라져 버린 섹션을 의미합니다.
여기에 스토어 (store)가 있습니다. 이는 버전 번호와 현재 존재하는 기여분 (contributions)의 집합을 보유합니다.
class VersionedStore:
def __init__(self):
self.version = 0
...
각 에이전트는 세 단계를 수행합니다: 스토어를 READ (읽기), WORK ( snapshot | {my_id} 를 구축하고 1단위 소모), 그리고 WRITE (쓰기). 여기서 동시성 (concurrency)은 스레드 (thread)를 의미하지 않습니다. 이는 명시적인 인터리빙 (interleaving, 교차 실행)이며, 저는 의도적으로 두 가지 방식으로 실행합니다. 첫 번째는 결정론적인 **최악의 경우 (worst case)**입니다: 모든 에이전트가 아무도 쓰기 전에 읽으므로, 모든 읽기 작업이 오래된 데이터 (stale)가 됩니다. 이는 일반적인 실행 상황은 아니며 적대적인 상한선 (adversarial upper bound)을 나타내지만, 실패 사례를 가장 명확하게 보여주기 때문에 헤드라인으로 사용했습니다. 두 번째는 **공정 샘플링 (fair sampler)**입니다: 매 틱 (tick)마다 아직 준비된 에이전트 중 하나를 균등하게 무작위로 선택하며 (random.Random을 사용하고 시드 (seed)를 설정하여 전체 실행이 바이트 단위로 재현 가능하도록 함), 수만 개의 독립적인 인터리빙을 추출하여 분포를 얻습니다. 최악의 경우는 실패 자체를 보여주고, 공정 샘플링은 이 문제가 실제로 얼마나 자주 발생하는지를 보여줍니다.
이에 대해 한 가지 언급하자면, 이 도구의 첫 번째 버전에서 오류가 있었고 이는 중요한 문제이기 때문입니다. 원래 저는 직접 구현한 선형 합동 생성기 (Linear Congruential Generator)와 가장 덜 무작위적인 하위 비트를 읽는 next() % n 방식을 사용하는 에이전트들을 선택했습니다. N=2인 경우 모든 시드(seed)에 대해 엄격하게 교차하기 때문에, 최악의 경우를 조용히 고정시키고 마치 시드된 샘플인 것처럼 출력했습니다. random.Random.randrange는 전체 생성기 상태에서 값을 추출하며 그러한 편향 (bias)이 없습니다. 아래의 공정한 수치들은 수정된 버전에서 도출된 것입니다.
여기서 제시되는 모든 숫자를 어떻게 해석할지 결정하는 요소이기에, 미리 솔직하게 밝히고 싶은 점이 하나 있습니다. 이 도구에서의 비용 (costs)은 달러가 아니라 횟수 (counts)입니다. COST_UNIT은 1로 설정된 회계 상수이며, 소스 코드에는 "달러가 아니며, 측정된 가격도 아님"이라고 표시되어 있습니다. 모든 금액 수치는 해당 상수를 곱한 정수 카운터입니다. 만약 달러 단위가 필요하다면, 실행 횟수에 직접 측정한 실행당 토큰 비용을 곱하십시오. 저는 토큰 가격을 측정하지 않았으며, 합성된 스케줄 (synthetic schedule)이 가격을 알려줄 수 있는 것처럼 가장하지 않겠습니다.
최악의 이상 현상: N=5
5개의 에이전트, 최악의 스케줄 (모두가 누군가 쓰기 전에 읽음), 게이트 차단 (gate off) 대 게이트 활성화 (gate on). 다음은 실행 결과에서 바로 가져온 헤드라인 블록입니다:
WORST CASE N=5, maximum contention (every agent reads stale)
--------------------------------------------------------------------
NO_GATE (LWW) GATE (compare-and-set)
...
먼저 왼쪽 열을 읽어보십시오. 5번의 쓰기가 승인(acknowledged)되었으나, 최종 상태에는 하나의 기여만 남았고 4개는 유실되었습니다. lost_updates 숫자는 "게이트 없음, 따라서 4를 출력"이라고 명시된 플래그에서 출력되는 것이 아닙니다. 이는 재도출된 값입니다. 즉, 도구는 쓰기가 최소 한 번은 승인(ACKed)된 에이전트 집합을 가져온 뒤, 최종 값에 실제로 존재하는 집합을 빼고 남은 것을 계산합니다. 4번의 쓰기가 성공했다고 보고되었으나 파일에는 존재하지 않습니다.
오른쪽 열은 쓰기 작업 시 버전 확인 (version check)을 수행하는 동일한 스케줄입니다. 유실된 업데이트(Lost update)는 0입니다. 5개의 기여(contribution)가 모두 존재합니다. 그리고 왜 이 수치가 0인지에 대해 솔직하게 말씀드려야겠습니다. 이를 놀라운 발견인 것처럼 포장하는 것은 정직하지 못한 일이기 때문입니다. 이는 구조적으로 당연한 결과입니다. Compare-and-set (CAS) 쓰기는 버전이 변경되지 않았을 때만 커밋되며, 이는 이전의 모든 커밋을 이미 포함하고 있는 스냅샷 (snapshot) 위에 커밋된다는 것을 의미합니다. 따라서 중간에 하나를 누락할 수 없습니다. 오른쪽 열에서 흥미롭고 측정할 가치가 있는 부분은 0이라는 수치 자체가 아닙니다. 그 0을 얻기 위해 지불해야 하는 비용이며, 이에 대해서는 나중에 다루겠습니다.
추적은 제어가 아니다
이 부분은 제가 계속해서 다시 배워야 하는 대목입니다. "모든 쓰기 작업을 로그로 남기기만 하면 된다"는 생각은 도움이 될 것 같지만, 그렇지 않습니다.
추적은 제어가 아니다 (TRACKING IS NOT CONTROL) (NO_GATE, 최악의 경우, N=5)
--------------------------------------------------------------------
추가 전용 로그 (append-only log, WAL)에 기록된 쓰기 작업 : 5
...
추가 전용 로그 (append-only log)에는 5개의 쓰기 작업이 모두 기록되어 있습니다. 하나도 빠짐없이 말이죠. 이는 5명의 에이전트가 각각 쓰기 작업을 수행했음을 증명하며, 어떤 에이전트가 어떤 버전에서 작업했는지 알려줍니다. 이는 당신이 5번의 비용을 지불했지만 결과물은 하나만 남았다는 사실을 알기에는 충분합니다. 하지만 누락된 4개를 다시 돌려주기에는 충분하지 않습니다. 저의 WAL 항목은 (version, agent_id)로, 쓰기가 발생했다는 사실을 나타낼 뿐 그 쓰기가 담고 있던 값(value)을 저장하지 않기 때문입니다. [두 명의 코딩 에이전트가 동일한 이슈를 수정할 때 @dipankar_sarkar가 설명한 git-refs 방식처럼) 콘텐츠를 저장하는 더 풍부한 로그는 두 버전을 모두 보관하고 사후에 조정(reconcile)할 수 있으며, 나중에 병합하는(merge-later) 워크플로우에는 적합한 도구입니다.
하지만 마지막 줄을 보십시오. 로그가 아무리 커지더라도 그 내용은 변하지 않습니다. 로그는 다섯 번의 쓰기(write)를 모두 알고 있지만, 현재 값을 읽으면 여전히 네 번의 유실된 업데이트(lost updates)가 반환됩니다. 쓰기를 기록하는 것과 그 유실을 방지하는 것은 서로 다른 시점에 수행되는 서로 다른 작업입니다. 로그는 사후에 작성된 영수증과 같습니다. 사후에 읽은 영수증이 저장되지 않은 필드를 복구할 수 없는 것과 마찬가지로, 로그도 파일의 덮어쓰기를 되돌릴 수 없습니다. 이는 토큰을 계산하지만 루프를 멈출 수는 없는 지출 제한(spend cap)과 같은 맥락입니다. 추적(tracking)은 무엇이 일어났는지를 알려주지만, 제어(control)는 무엇이 일어날 수 있는지를 변경합니다. 커밋(commit) 전에 실행되는 쓰기만이 오래된(stale) 쓰기를 거부할 수 있습니다.
게이트(The gate), 그리고 그 정직한 대가
해결책은 쓰기에 대한 전제 조건(precondition)을 설정하는 것입니다. 락 프리(lock-free) 방식인 낙관적 동시성(Optimistic concurrency)은 다음과 같습니다: 버전을 읽고, 작업을 수행한 뒤, 커밋 시점에 버전이 변경되었다면 거부하는 방식입니다. 이것은 낙관적 동시성 제어 (Optimistic concurrency control)라는 정식 명칭이 있으며, 그 밑단의 기본 원시 연산(primitive)은 비교 및 교체 (compare-and-swap, CAS)입니다. 비관적 임대(pessimistic lease)나 락(lock) 방식도 작동할 수 있지만, 이는 에이전트가 재시도(retry)하는 대신 기다리게 만들 뿐입니다. 어떤 방식이든 공유되는 핵심 아이디어는 동일합니다: 당신이 읽지 않은 상태 위에는 어떤 쓰기도 안착할 수 없게 하는 것입니다.
res = store.write_cas(a.base, a.aid, a.newval, enforce=not disable_version_check)
if res == "ACK":
a.acked = True
...
이제 비용에 대해 이야기하겠습니다. 이것이 바로 이 포스트의 제목이 "게이트가 어떻게 당신의 돈을 아껴주는가"가 아닌 이유입니다. 아껴주지 않습니다. 제목을 다시 보십시오: NO_GATE는 5개의 작업 단위(work-units)를 실행했고, GATE는 9개를 실행했습니다. 게이트는 4개의 작업 단위만큼 재시도(retries)를 추가했습니다. 왜냐하면 각각의 오래된(stale) 쓰기 작성자가 작업을 안착시키기 전에 최신 상태를 다시 읽고 작업을 다시 수행해야 했기 때문입니다. 이 차이(delta)는 양수이며, 설계상 의도된 양수입니다:
게이트의 델타 비용 (Delta cost of the gate) = 9 - 5 = 4 작업 단위 (work-units) (>=0: 게이트는 비용이 더 많이 발생하며, 토큰을 절약하지 않습니다)
게이트가 없을 때 소멸된 비용 (Spend destroyed without gate) = lost_updates * COST_UNIT = 4 작업 단위 (work-units)
...
두 숫자는 서로 다른 것을 나타내며, 이를 잘못 결합하여 잘못된 비용 절감 논리로 흐르기 쉽습니다. NO_GATE 상황에서 소멸된 4 작업 단위는 이미 작업을 위해 지불했지만 결과적으로 사라져 버린 비용입니다. 게이트는 이 비용을 환불해 주지 않습니다. 게이트는 이미 지나간 실행 단계로 되돌아갈 수 없기 때문입니다. 게이트가 하는 일은 다음 4번의 작업이 사라지는 것을 막는 것이며, 이를 위해 4 단위의 재시도(retry) 비용을 청구하는 것입니다. 만약 당신의 걱정이 토큰 청구서라면, 게이트는 절약 수단이 아니라 비용입니다. 하지만 만약 당신의 걱정이 네 명의 에이전트가 실제 작업을 수행했음에도 그 결과가 조용히 사라지는 것이라면, 게이트는 그 현상이 소리 없이 일어나지 않도록 막아주는 장치입니다.
이것이 역설적인 부분이며, 저는 이를 반증 가능한 주장(falsifiable claim)으로 명시하겠습니다: 공유된 에이전트 상태(shared agent state)에 대한 쓰기 전 동시성 게이트(pre-write concurrency gate)는 토큰 지출을 증가시키지만, 그럼에도 불구하고 그만한 가치가 있습니다. 왜냐하면 게이트는 소리 없이 발생하는 무한한 손실을 가시적이고, 계산 가능하며, 재시도 가능한 이벤트로 전환하기 때문입니다. 만약 당신의 데이터가 경합(contention) 상황에서 총 지출을 줄여주는 게이트를 보여준다면, 저는 진심으로 그 워크로드(workload)를 확인해 보고 싶습니다. 저의 실행 결과는 그 반대를 나타내기 때문입니다.
게이트가 작업을 수행하는 것인가, 아니면 내 테스트 스캐폴드(test scaffold)가 속임수를 쓰고 있는 것인가?
이것은 제가 제 자신을 가장 믿지 못하는 실패 모드이며, 따라서 도구에는 이를 검증하기 위한 반증기(falsifier)가 포함되어 있습니다. 전형적인 실수는 메커니즘 자체가 아니라 테스트 스캐폴드에서 '0'이라는 값이 나오도록 게이트를 구축하는 것입니다. 따라서 F1은 전체 게이트 코드 경로를 유지하되, 단 한 가지 요소인 write_cas 내부의 버전 확인(version check) 기능만 비활성화합니다.
반증기(FALSIFIER) F1 게이트 코드 경로, 버전 확인 비활성화 (최악의 경우, N=5)
--------------------------------------------------------------------
비교(compare) 로직 제거 시 lost_updates : 4 (활성화 시에는 0이었음)
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기