AI 응답을 기다리는 동안 원본 데이터가 변경되었다면? Firestore에서 오래된 초안을 확정하지 않는 설계
요약
AI 응답 생성 과정 중 원본 데이터(규칙)가 변경되어 발생하는 '오래된 전제' 문제를 다룹니다. AI의 답변이 정확하더라도, 그 근거가 된 버전이 현재와 다르다면 위험할 수 있습니다. 따라서 작업에 기준 버전 ID를 고정하고, 저장 직전에 현행 버전을 재확인하는 설계 패턴을 제시합니다.
핵심 포인트
- AI 응답과 전제의 유효성은 별개로 판단해야 합니다.
- 작업에는 '기준 버전 ID(baseRuleVersionId)'를 반드시 포함해야 합니다.
- 저장 시점의 현행 버전과 기준 버전을 대조하여 충돌을 방지합니다.
- DB 트랜잭션 내에서 재확인 로직을 구현하는 것이 핵심입니다.
서론
"AI에게 개발 규칙 개정안 작성을 요청했다. 그 응답을 기다리는 사이에, 다른 사람이 새로운 규칙을 승인했다"
AI는 정상적으로 응답하며 JSON 형식도 정확하다. 본문 내용 역시 요청 시점의 규칙에는 맞는다. 하지만 그 답변을 현재 규칙에 대한 제안으로 저장해도 안전하지 않을 수 있다.
AI의 응답이 성공적이었다는 사실과, 그 판단에 사용된 전제가 아직 유효하다는 것은 별개의 문제이다.
developing rule을 관리하는 Rule Branch에서는, 개정안 생성과 데이터 저장 사이에 이러한 시간차가 존재한다. 본고에서는 작업(job)에 기준 버전을 고정하고, Firestore에 저장하기 직전에 재확인하는 설계를 다룬다. 서비스 전체의 사용법은 기능 소개 글에서 다루고, 여기서는 충돌하는 처리 순서와 쓰기(write)를 멈추는 조건에 초점을 맞춘다.
5줄 요약 (클릭하여 펼치기)
TL;DR (5줄 요약)
- '최신 규칙'이 아닌, 생성에 사용할 불변의 버전 ID를 작업(job)에 고정한다.
- 작업 투입 전과 AI 호출 전 확인만으로는, 생성 중 변경을 막을 수 없다.
- 현재 버전 및 대상 지적 사항을 재읽기하고 제안 저장을 같은 트랜잭션에 넣는다.
- AI 호출을 트랜잭션 외부로 빼고, DB 처리의 재시도와 분리한다.
- 버전 ID로 잡히지 않는 전제는 별도로 비교하고, 충돌은 처리 순서를 고정한 테스트에서 확인한다.
1. 문제는 '느린 응답'이 아니라 '오래된 전제에서의 저장'
설명을 위해 현재 유효한 규칙을 v1이라고 가정한다. AI에게 요청하는 것은 v1의 표현을 명확히 하는 개정안이다.
그 처리는 다음 순서로 진행될 수 있다.

답변이 정확할 뿐만 아니라, 무엇을 전제로 한 답변인지 확인해야 한다.
예를 들어 v1에는 "의존 업데이트 시 호환성을 확인한다"라고 되어 있고, AI는 그 확인 절차를 자세히 작성했다. 그 사이에 사람이 v2를 승인하며 "특정 업데이트는 담당자의 확인이 끝날 때까지 보류한다"라는 조건을 추가했다고 하자.
나중에 도착하는 AI의 답변은 이 새로운 조건을 알지 못한다. v1용 답변을 v2에 대한 개정안으로 취급하면, 추가된 조건을 누락할 가능성이 있다.
여기서 필요한 것은 AI를 빠르게 하는 것만이 아니다. 생성했을 때의 기준 버전과, 저장을 확정할 때의 현행 버전을 대조하는 것이다.
참고로 Rule Branch의 제안 생성은 승인된 본문을 그 자리에서 덮어쓰는 처리가 아니다. 새로운 proposed 버전을 만드는 처리다. 그럼에도 불구하고, 오래된 전제의 안을 현행 규칙에 대한 제안으로 연결하면, 이후 검토를 오도할 수 있다. 제안의 저장과, 승인에 의한 현행 버전 업데이트는 구분하여 생각해야 한다.[1][2]
2. 작업에는 '규칙 ID'뿐만 아니라 '기준 버전 ID'를 갖게 해야 한다
규칙 ID가 가리키는 대상과, 그 시점의 규칙 본문은 같지 않다.
Rule Branch에서는, 규칙의 currentVersionId가 현재 승인 버전을 가리킨다. 반면, 편차 지적(Drift Finding)은 ruleVersionId로 지적 대상 버전을 가진다. 제안 작업에는 그 값을 baseRuleVersionId로 저장한다.[3][1:1]
작업 입력을 간소화하면 다음과 같다.
{
"driftFindingId": "finding-a",
"baseRuleVersionId": "version-1"
...
}
version-1은 설명용 ID이다. 중요한 것은, 그 문자열이 아니라, AI에게 요청한 근거의 버전을 나중에 다른 버전으로 대체하지 않는 것이다.
이 값은, 승인된 요청을 받은 서버가 저장된 Finding에서 설정한다. 모델에 "이번 기준 버전도 JSON에 넣어달라"고 요청하고, 그 응답을 정본으로 삼지 않는다.
확인은 3곳에 두어야 한다
공개 구현에서는, 기준 버전에 대한 확인이 다음과 같이 나뉘어 있다.[3:1][1:2][2:1]
| 확인하는 시점 | 주요 비교 대상 | 방지하고 싶은 상황 |
|---|---|---|
| Web에서 작업을 투입하기 전 | Finding의 대상 버전과 현행 버전 | 이미 오래된 지적으로 생성을 시작하는 것 |
| ... |
처음 두 곳은, 불필요한 생성을 일찍 멈추는 데 도움이 된다. 하지만, 확인 직후 변경될 가능성은 남아있다.
마지막 확인과 쓰기가 떨어져 있으면, 그 틈에서도 같은 문제가 발생한다. 저장이 가능한지 결정하는 확인은, 쓰기와 동일한 트랜잭션으로 진행되어야 한다.
여기서 일치하더라도, 이후 저장 시점까지 변경될 수 있다.
current = read_rule(job.rule_id)
if current["currentVersionId"] == job.base_version_id:
...
읽어온 값이 메모리에 있다는 것과, 저장 시점까지 그 값이 유효하다는 것은 다르다.
Firestore의 트랜잭션은 읽기에 기반한 쓰기를 묶어서 확정할 수 있다. 성공하면 쓰기가 한꺼번에 적용되며, 중간까지만 쓰기를 확정하지 않는다. 읽기는 쓰기보다 먼저 이루어진다.[4]
본체에서는, 저장 측에 기대값을 전달한다
공개 Worker의 createRuleVersion
에서는 트랜잭션 내에서 Rule을 다시 가져와서, 호출자가 전달한 expectedCurrentVersionId
과 비교하고 있다. 여기가 일치하지 않으면 RULE_VERSION_CHANGED
로 저장하는 것을 거부한다.[2:2]
if ("expectedCurrentVersionId" in input
and rule.get("currentVersionId") != input.get("expectedCurrentVersionId")):
raise RuleStoreError("RULE_VERSION_CHANGED", 409)
같은 트랜잭션에서는 대상 Finding에 대해서도 소속 Workspace, Rule, accepted
상태, 대상 버전을 확인한다. 그 후에 제안 버전, 근거, 번호 부여, 관련 참조, 감사 기록 등을 작성한다.[2:3]
최소 샘플에서는, 제안・번호 부여・참조를 한꺼번에 저장한다
다음은 본고용 샘플의 저장 처리이다. tx는 get
・create
・update
을 가진 작은 어댑터이며, get은 문서의 딕셔너리 또는 None을 반환한다.
assert_current는 기준 버전과 Finding의 적격성을 검사한다. job은 서버 측에서 고정된 입력이고, candidate는 사전에 형식을 검증한 모델 응답이다. 여기서는 근거 저장이나 감사 처리를 생략하고, 제안의 저장・다음 버전 번호・Finding으로부터의 참조 3가지에 국한하고 있다.
def save_if_current(tx: Transaction, job: Job, candidate: Candidate) -> str:
# 트랜잭션 시도마다, 현재 값을 다시 읽어온다.
rule = tx.get("rules", job.rule_id)
...
result_id는 콜백 외부에서 결정한 새로운 제안의 ID로, 모델에게 선택하게 하지 않는다. 샘플의 baseVersionId는 기준 버전을 출력에도 남기기 위한 설명용 필드이며, 본체 스키마의 문자 그대로의 전사본이 아니다.
또한, 업데이트하는 Rule의 필드는 nextVersion이고, currentVersionId가 아니다. 제안을 저장했다고 해서, 그것을 곧바로 승인된 것으로 취급하지 않는다.
기준 버전과 Finding 검사 처리
다음 함수도 포함 샘플에 넣고 있다. Workspace의 소속 체크는 하지만, 이것만으로 인증・인가를 구현한 것은 아니다. 요구사항의 인증과 조작 권한 검증은 그 전단에서 필요하다.
def assert_current(job: Job, rule: Data | None, finding: Data | None) -> None:
if (not rule or rule.get("ownerWorkspaceId") != job.workspace_id
or rule.get("deletedAt")):
...
4. AI 호출은, 트랜잭션 외부로 빼낸다
확인과 저장을 같이 하기 위해, 입력의 읽기부터 AI 응답까지 모두를 하나의 트랜잭션에 넣고 싶어진다. 하지만 그것은 피하고 싶다.
Firestore에서는 충돌로 인해 트랜잭션 콜백이 재실행되는 경우가 있다. 그 안에서 외부 AI를 호출하면, DB 처리의 재시도에 따라 생성도 다시 실행될 수 있다. DB의 롤백으로 외부 API 호출까지 취소되는 것은 아니다.[4:1]
락을 사용하는 구성에서는 AI의 대기 시간까지 락 유지 시간에 포함될 수 있습니다. Firestore 서버용 클라이언트는 DB 측 동시 실행 제어 설정에 따르므로, 'Firestore는 항상 낙관적 락(Optimistic Lock)'이라고 일괄적으로 설명하는 것이 좋지 않습니다.[5]

긴 대기 시간과 무결성을 확정하는 짧은 처리를 분리합니다.
샘플 처리 전체는 다음과 같은 형태가 됩니다.
def run_proposal(store: Store, job: Job, generate: Callable[[GenerationInput], Candidate]) -> str:
# 설명용 샘플에서는 입력 읽기 또한 짧은 트랜잭션으로 묶습니다.
inputs = store.run_transaction(lambda tx: load_input(tx, job))
...
이 샘플에서는 입력 읽기 또한 짧은 트랜잭션으로 묶고 있습니다. 공개 Worker의 코드를 그대로 이식한 것이 아니라, 입력 획득-생성-저장 경계를 명시하기 위한 구성입니다.
generate
에 전달하는 것은 이미 획득된 본문과 지적이며, 저장 트랜잭션 그 자체는 아닙니다. 저장 측 콜백이 재실행될 때는 동일한 candidate를 사용하고 Rule과 Finding의 읽기 및 검사부터 다시 시작합니다.
재시도 시 기준 버전을 '최신'으로 대체하지 않기
여기서 다음과 같이 기대값까지 업데이트해버리면 의미가 없어집니다.
# 재획득된 최신 ID로 기대값을 변경하여, 오래된 답변을 통과시켜 버립니다.
expected_version_id = freshly_loaded_rule["currentVersionId"]
생성된 답변은 처음에 전달한 v1을 전제로 합니다. 재시도 중에 DB에서 v2를 읽더라도, 그 답변이 v2를 고려했다는 의미가 아닙니다.
기대값은 Job에 고정한 채 현재 값만 다시 획득합니다. 다르다면 오래된 결과의 저장을 중단하고, 다시 생성하거나 사람이 검토해야 합니다.
이는 애플리케이션 측의 '전제(前提)의 신선도'를 검증하는 것이며, Firestore 내부가 어떤 락 방식을 사용할 것인지와는 별개의 문제입니다.
참고로, 여기서 분리하고 있는 것은 저장 트랜잭션의 재시도입니다. 프로세스 재시작이나 Job 재전송까지 포함하여 AI 호출을 단 한 번만 하도록 보장하는 것은 이 구성만으로는 얻을 수 없습니다.
Firestore에 연결하는 어댑터의 요점
동봉된 firestore_adapter.py
에서는 Python의 Firestore 클라이언트에 연결하는 처리를 분리했습니다. 문서 읽기 시에는 일반적인 획득이 아니라, 현재 트랜잭션을 명시하여 전달합니다.[6]
snapshot = ref.get(transaction=self.native)
콜백 실행에는 firestore.transactional
을 사용합니다.
@self.firestore.transactional
def execute(native):
return callback(FirestoreTransaction(self.client, native))
...
이 어댑터는 본 논문의 실행 환경에서는 연결되지 않은 상태입니다. 실수로 운영 환경에 쓰지 않도록, 로컬의 FIRESTORE_EMULATOR_HOST
와 demo-
으로 시작하는 Project ID만 받습니다.
5. 버전 ID로 포착할 수 없는 변경은 비교 대상을 넓히기
지금까지의 검사는 '같은 버전 ID라면, 생성에 사용된 본문도 같다'는 전제에 서 있습니다. 과거의 본문을 그 자리에서 수정하지 않고, 새로운 ID의 버전을 만드는 설계와 결합해야 합니다.
더 나아가, 버전 ID가 바뀌지 않아도 판단을 무효화하는 변경은 있을 수 있습니다.
예를 들어, 적용 대상 설정이 바뀌었거나, 근거 내용이 대체되었거나, 다른 Rule이 추가되어 충돌하게 된 경우 등이 있습니다. currentVersionId
만 비교하는 샘플에서는 이러한 것들을 감지할 수 없습니다.
자동 승인 시에는 평가한 입력과 확정 직전의 입력을 비교합니다
Rule Branch의 자동 승인 경로에서는, 제안, 현행 버전, 근거, 관련 Rule 등으로부터 contextDigest
를 계산합니다. 승인 확정 시에는 트랜잭션 내에서 정보를 다시 읽어와 평가했을 때의 값과 다르다면 CONTEXT_CHANGED
로 하여 사람 확인 단계로 되돌립니다. 이는 제안 저장 측의 버전 ID 체크와는 별개의 처리입니다.[7]

*비교 대상은 '판단에 사용된 정보'입니다. 해시를 붙이는 것만으로는, 비교 대상의 누락을 메울 수 없습니다.
해시화할 때는 먼저 무엇을 비교할지 결정해야 합니다. 객체의 키 순서나 집합으로 다루는 배열의 순서를 맞추는 한편, 순서 자체에 의미가 있는 배열은 임의로 재배열해서는 안 됩니다.
또한, 평가 메타 정보 업데이트만으로 매번 불일치가 발생하지 않도록 판단 자료와 운영 정보를 분리합니다. 공개 구현에서는 제안의 approvalReview와 updatedAt을 비교 대상에서 제외하는 처리가 있습니다. 이는 모든 시간이나 메타 정보를 일률적으로 제거해도 좋다는 의미는 아닙니다.[8]
비교하지 않은 정보의 변화는, 아무리 강력한 해시를 사용해도 감지할 수 없습니다. 관련 규칙 추가를 감지하고 싶다면, 이전부터 존재하는 문서만 보는 것이 아니라, 같은 조건으로 규칙 집합을 다시 가져와 비교하는 설계가 필요합니다.
그리고, 해시가 일치하더라도 AI의 제안이나 평가가 정확하다고 할 수는 없습니다. 확인할 수 있는 것은 비교 대상이 같다는 것까지이며, 내용의 타당성이나 승인 권한은 별도로 검증해야 합니다.
6. 경쟁(競合)은 '몇 초 기다리는가'가 아니라 '어떤 순서인가'로 테스트한다
'AI를 몇 초 기다리면 그 사이에 업데이트가 일어나겠지'라는 테스트는, 기계의 속도에 좌우됩니다.
그래서 테스트용 생성기에 두 가지 이벤트를 배치합니다. 하나는 생성 시작 알림이고, 다른 하나는 응답을 재개시키는 신호입니다. 이를 통해, 입력을 다 읽은 후, 응답을 반환하기 전이라는 시점에 확실하게 업데이트를 끼워 넣을 수 있습니다.
def test_version_changes_while_model_is_waiting(self):
started, resume = Event(), Event()
def delayed(inputs):
...
여기서는 테스트 더블(test double)의 Rule 참조 대상만 변경하여, 저장 측이 오래된 기준 버전을 거부하는 상황을 만들고 있습니다. 제품에서 v2를 생성하고 승인하는 전체 처리를 수행한 것은 아닙니다.
예외가 발생했다는 것만으로 끝내지 않고, 제안이 만들어지지 않은 것, Finding의 제안 참조가 변하지 않은 것, 번호 매기기가 진행되지 않은 것, 현행 버전의 참조가 v2로 남아있는 것을 확인합니다.
재시도와 부분 쓰기도 나누어 테스트한다
포함된 테스트 더블은, 쓰기를 일시적으로 보관하고, 읽은 문서의 리비전이 변경되었을 때 콜백을 재실행합니다. Firestore 자체를 재현하는 것이 아니라, 애플리케이션의 조건 검사를 시도하기 위한 모델입니다.
이번 확인에서는 다음 케이스를 포함한 13건의 단위 테스트(unit test)를 실행했습니다.
| 케이스 | 확인된 결과 |
|---|---|
| 전제가 변하지 않음 | proposed를 저장하고, 현행 버전의 참조는 변경하지 않음 |
| AI를 호출하기 전에 버전이 바뀜 | AI를 호출하지 않고 거부함 |
| AI 응답 대기 중에 버전이 바뀜 | 응답 후 저장을 거부함 |
| 생성 중에 Finding이 대응 대상에서 사라짐 | 제안과 참조를 저장하지 않음 |
| ... | 이 최소 샘플에서는 감지하지 않음을 확인합니다 |
마지막 줄은 성공 보장이 아니라, 검증 범위의 한계를 명시하는 테스트입니다. 5절의 비교 대상을 추가하지 않은 코드로, 설정 변경까지 막을 수는 없다는 의미입니다.
실행용 코드는 원고 일식의 examples/에 모았습니다. Python 표준 라이브러리만으로 다음을 실행할 수 있습니다.
# sns 리포지토리에 원고 일식을 배치했을 경우
python -m unittest discover -s zenn/202610_15/examples -v
Ran 13 tests in 0.002s
OK
확인 환경은 Python 3.13.5, 실행일은 2026년 10월 15일입니다. 0.002s는 테스트 더블을 사용한 단위 테스트의 소요 시간이며, AI나 Firestore의 처리 속도를 나타내지는 않습니다.
Firestore Emulator에서의 연결 확인, 실제 SDK의 재시도나 동시 업데이트, Google Cloud 권한, 여러 Worker의 동시 실행은 별도로 검증합니다. 본고의 13건을, 그러한 결합 테스트(integration test)를 대신할 수는 없습니다.
7. 오래된 결과는 기대값을 수정하여 통과시키지 않는다
기준 버전의 불일치는, 일시적인 통신 실패와 다릅니다. 같은 결과를 그대로 재전송해도, 그 답변이 새로운 버전을 본 것이 되지는 않습니다.
공개 구현에서도, 단계별로 취급이 나뉩니다. 웹에 투입되기 전이라면 FINDING_BASE_VERSION_STALE, 저장 시의 기준 버전 불일치라면 RULE_VERSION_CHANGED
거부한다. 반면, Worker가 생성되기 전에 버전이 오래되었다면 새로운 제안을 만들지 않고 정상 종료하는 경로가 있다. 작업(Job)의 정상 종료와 유효한 제안이 저장된 것은 별개이다.[3:2][1:3][2:4]
애플리케이션 측에서 처리할 때는 중단 이유를 남기고, 현재 규칙에 대해 재검사/재생성하거나, 사람이 내용을 확인한다. 오래된 후보를 조사용으로 남기는 설계를 채택하는 경우에도, 현행 버전을 위한 제안이나 자동 승인 대상에 섞지 않는다.
다만, 모든 예외를 '오래된 결과이므로 문제없음'으로 무마해서는 안 된다. 통신 장애, 재시도 상한 초과, 권한 부족, 스키마 오류 등은 각각 원인을 조사해야 한다. 샘플에서도 재시도 상한 초과 예외는 성공 처리하지 않고 호출자에게 반환한다.
이 확인이 보장하지 않는 것
저장 시의 검사를 통과한 제안이라도, 그 후에 다른 변경이 들어가면 오래된다. 승인 시에도, 그 판단에 필요한 전제를 다시 확인할 필요가 있다. 제안 저장 트랜잭션 한 번으로, 그 안이 미래까지 유효해지는 것은 아니다.
또한, 현행 버전을 v1에서 v2로 진행한 후, 같은 v1의 ID로 돌아갈 수 있는 설계에서는 단순한 ID 비교만으로는 중간 변경을 알 수 없다. 중간 변경도 무효화 조건으로 삼으려면, 복원을 새로운 버전 ID로 표현하거나, 단조 증가하는 세대 번호 등을 비교해야 한다.
중복 작업(Job), 실행 리스 만료, 외부 서비스 배포 역시 각각 별개의 경계이다. 본고의 최소 샘플은, 그것들을 해결한 범용 작업 기반으로 다루는 것은 아니다.
요약
AI를 사용하는 비동기 처리에서는, 응답 내용의 검증뿐만 아니라, 그 응답을 확정해도 좋다는 전제가 지금도 유지되고 있는지를 확인하고 싶다.
생성에 사용할 버전 ID를 작업(Job)에 고정한다. AI는 트랜잭션 외에서 호출된다. 그리고, 현재 버전과 상태의 재읽기, 조건 확인, 쓰기를 하나의 짧은 트랜잭션으로 묶는다.
버전 ID만으로는 판단 자료를 나타낼 수 없는 경우엔, 필요한 입력을 명시하여 비교 범위를 넓힌다. 마지막으로, 의도한 시점에 데이터를 변경하는 테스트로, 오래된 결과와 부분 쓰기가 통하지 않음을 확인한다.
AI의 응답을 기다릴 수는 있다. 그러나, 기다리는 동안에도 세상이 변하지 않는다고 가정해서는 안 된다.
이 생각은, 규칙 개정안뿐만 아니라, 문서 요약, 견적 평가, 승인 워크플로우 등, 시간이 걸리는 판단과 데이터 업데이트를 결합하는 처리에도 응용할 수 있다.
참고
Rule Branch의 참조 대상은, 작성 시 확인한 공개 커밋 30d1e32397bec4b4cb05c0e86b21d427c9f384d5에 고정하고 있다.
- Rule Branch: 개정 제안 Worker. 생성 전 기준 버전 대조, 같은 버전의 지적 취합, 저장 처리에 전달
expectedCurrentVersionId를 참조한다. ↩︎ ↩︎ ↩︎ ↩︎ -
Rule Branch: Python RuleStore.
createRuleVersion 트랜잭션 내 재검증, proposed 버전 생성, 관련 기록 저장 등을 참조한다. ↩︎ ↩︎ ↩︎ ↩︎ -
Rule Branch: Drift의 제안 작업 투입.
propose의 기준 버전 확인, FINDING_BASE_VERSION_STALE, 작업에 대한 baseRuleVersionId 설정을 참조한다. ↩︎ ↩︎ ↩︎ -
Firebase: Transactions and batched writes. 원자적 쓰기, 읽기와 쓰기의 순서, 콜백 재실행에 관한 설명을 참조한다. ↩︎ ↩︎
-
Firebase: Transaction serializability and isolation. 서버용 SDK와 Web/mobile SDK의 동시 실행 제어 차이, 경쟁과 분리성을 참조한다. ↩︎
-
Google Cloud: Python DocumentReference와 Transaction. 같은 트랜잭션을 지정한 읽기 API를 참조한다. ↩︎
-
Rule Branch: 자동 승인의 Firestore 어댑터.
complete에서의 재획득, contextDigest의 비교, CONTEXT_CHANGED에 의한 사람 확인으로 이관을 참조한다. ↩︎ -
Rule Branch: 승인 정책과 입력 해시. 정규화,
contextDigest, 평가 메타 정보 제외 등을 참조한다. ↩︎
토론 (Discussion)

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기