사용자가 버튼을 클릭합니다. 요청이 멈춥니다. 10초간의 정적. 사용자는 폴백(fallback) 버튼을 클릭합니다. 이제 하나의 의도에 대해 두 개의 작업이 실행됩니다. 결국 중복된 사이드 이펙트, 이중 결제, 그리고 오후 시간을 통째로 잡아먹는 데이터 난장판이 발생합니다.
이것은 프론트엔드 버그가 아닙니다. 버튼을 비활성화하거나 React에서 디바운스(debounce) 타이머를 사용하는 것만으로는 해결할 수 없습니다. 첫 번째 요청은 이미 전송 중(in flight)이었기 때문입니다. 네트워크가 단순히 응답을 삼켜버린 것입니다. 백엔드가 들어오는 모든 요청을 완전히 새로운 명령으로 처리한다면, 재시도(retry)는 곧 리스크가 됩니다. API 설계와 데이터베이스 스키마 단계에서 이를 해결해야 합니다.
해결책은 간단한 구조적 분리에서 시작됩니다.
작업(Job)과 시도(Attempt)의 분리
작업(Job)을 사용자가 원하는 바를 기록한 영구적인 레코드로 생각하십시오. 작업은 소유자, 파라미터, 대상 제공자, 그리고 정확한 의도를 담고 있습니다. 시도(Attempt)는 그 의도를 달성하기 위한 구체적인 시도입니다.
인쇄소를 상상해 보세요. 파일을 건네주면 티켓 #45번을 받습니다. 그 티켓이 바로 작업(job)입니다. 인쇄소에서 잉크젯 프린터로 출력을 시도합니다. 종이가 걸립니다. 이것이 첫 번째 시도(attempt one)입니다. 그들은 파일을 레이저 프린터로 옮깁니다. 이것이 두 번째 시도(attempt two)입니다. 이 과정 내내 티켓 #45번은 변하지 않습니다. 만약 인쇄소가 시도하는 프린터마다 새로운 티켓을 발행했다면, 당신은 세 번의 비용을 지불하고 원치 않는 복사본 세 개를 받게 될 것입니다.
데이터베이스도 이를 반영해야 합니다. 하나의 테이블은 작업을 저장하고, 다른 테이블은 시도를 저장합니다. 작업 행(row)은 일정하게 유지되는 반면, 그 아래로 시도들이 쌓이게 됩니다.
이러한 분리는 제어권을 제공합니다. 또한 네트워크 장애(blip) 상황에서도 유지되는 멱등성 키(idempotency key)를 연결할 공간을 제공합니다.
모든 작업에 멱등성 키 요구
작업을 생성하는 모든 POST 요청은 고유한 멱등성 키를 포함해야 합니다. 이 키는 세션이 아닌 사용자에게 속해야 합니다. 소유자 ID(owner ID)와 키를 결합한 뒤, 이 두 컬럼에 대해 데이터베이스의 유니크 제약 조건(unique constraint)을 적용하십시오.
왜 데이터베이스 제약 조건인가요? 삽입 전 애플리케이션 코드에서 존재 여부를 확인하는 방식은 레이스 컨디션(race condition)이 발생하기 딱 좋은 구조이기 때문입니다. 두 개의 동일한 요청이 아주 미세한 시간 차로 동시에 통과할 수 있습니다. 데이터베이스가 집행자(enforcer)가 되게 하십시오. 사용자가 동일한 소유자 ID와 키를 두 번 보내면, 두 번째 요청은 유니크 제약 위반에 걸리게 되고, 시스템은 기존 작업을 반환하면 됩니다. 두 요청 모두 동일한 작업 ID를 받게 되며, 중복 작업은 시작되지 않습니다.
범위(scope)에 대해서는 엄격해야 합니다. 누군가 키는 재사용하면서 입력 페이로드(payload)를 변경했다면, 충돌(conflict)을 반환하십시오. 멱등성 키는 단순히 사용자가 아니라 정확한 의도와 결합되어야 합니다. 동일한 키에 다른 입력이 들어왔다는 것은 클라이언트가 혼란을 겪고 있다는 뜻이며, 시스템은 이를 추측하기보다 거부해야 합니다.
상태 전이(State Transition) 보호
시도는 새로운 작업이 아니라 상태 전이입니다. 이전 시도가 여전히 '시작 중'이거나 '알 수 없는(unknown)' 상태에 머물러 있다면, API는 새로운 시도를 생성하는 것을 거부해야 합니다.
타임아웃이 원인입니다. 제공자(provider) 요청이 타임아웃되면 클라이언트는 실패로 인지하지만, 서버 측 프로세스는 여전히 살아있을 수 있습니다. GPU 클러스터는 여전히 추론 요청을 처리 중일 수 있고, 컨테이너는 여전히 블롭 스토리지(blob storage)에 데이터를 쓰고 있을 수도 있습니다. 타임아웃된 시도를 실패로 표시하고 즉시 두 번째 시도를 실행한다면, 중복된 사이드 이펙트라는 도박을 하는 셈입니다.
타임아웃을 실패가 아닌 '알 수 없는 상태'로 취급하십시오. 이전 시도가 종료 상태(terminal state)에 도달하거나 별도의 프로세스(out-of-band process)에 의해 명시적으로 취소될 때까지 새로운 시도를 차단하십시오. 이 대기 시간은 사용자 입장에서 불편할 수 있습니다. 사용자를 기다리게 만들기 때문입니다. 하지만 이는 두 명의 작업자가 동일한 다운스트림 리소스를 변경하여 혼란을 초래하는 것을 방지합니다.
Compare-and-Swap으로 레이스 컨디션 해결
가장 어려운 문제는 여러 시도가 동시에 완료될 때 발생합니다. 예를 들어, 시스템이 기본 제공자에게 첫 번째 시도를 보냈다고 가정해 봅시다. 10초간 응답이 없자 폴백(fallback) 제공자에게 두 번째 시도를 보냈습니다. 이제 두 시도가 모두 완료되었습니다. 두 시도가 모두 동일한 작업 행에 결과를 기록하게 해서는 안 됩니다.
Compare-and-swap(CAS) 로직을 사용하십시오. 작업 행에 버전 번호를 추가합니다. 시도가 완료되면 다음 조건과 함께 업데이트를 실행합니다:
- 현재 버전이 시도가 시작될 때 읽었던 버전과 일치해야 함.
- 다른 시도가 이미 결과 슬롯을 차지하지 않았어야 함.
- 두 조건이 모두 충족되면 결과를 기록하고 버전을 증가시킴.
SQL 관점에서는 WHERE id = $1 AND version = $2 AND completed_by IS NULL 조건이 포함된 UPDATE 문과 같습니다. 만약 업데이트된 행의 수가 0이라면, 이미 다른 시도가 성공한 것입니다. 늦게 도착한 요청은 무시해야 합니다. 결과를 버리십시오. 병합하거나 추가하지 마십시오. 그냥 버려야 합니다. 먼저 성공한 결과를 덮어쓰는 늦은 결과는 데이터 오염을 의미하며, 유일하게 안전한 방법은 이를 폐기하는 것입니다.
이는 역순 종료 상황을 깔끔하게 처리합니다. 시도 A가 먼저 시작되었지만 30초 후에 반환됩니다. 시도 B는 두 번째로 시작되었지만 5초 후에 반환됩니다. 시도 B가 compare-and-swap에 성공합니다. 시도 A의 업데이트는 영향을 미치는 행이 0개입니다. 시스템은 경합 상황을 로그에 기록하고, 유효하지 않은 페이로드를 무시한 뒤 다음 단계로 넘어갑니다.
임계점 테스트하기
해피 패스(happy-path) 테스트로는 이러한 버그를 잡아낼 수 없습니다. 테스트 스위트는 시스템의 균열을 겨냥해야 합니다.
- 더블 클릭을 시뮬레이션합니다. 동일한 멱등성 키(idempotency key)를 가진 두 개의 동시 POST 요청은 반드시 동일한 job ID를 반환해야 합니다.
- 입력값이 일치하지 않는 동일한 키를 보냅니다. 충돌(conflict) 응답을 예상합니다. 매개변수가 다를 경우 시스템이 기존 작업을 아무런 경고 없이 반환해서는 안 됩니다.
- 타임아웃을 유도합니다. 작업이 실패(failed) 상태가 아닌 알 수 없는(unknown) 상태로 들어가는지, 그리고 모호함이 해소될 때까지 시스템이 추가 시도를 차단하는지 확인합니다.
- 두 시도가 역순으로 종료되도록 강제합니다. 첫 번째로 시작된 것이 공식 기본 제공자(primary provider)였더라도, 나중에 반환된 시도가 패배하는지 확인합니다.
이러한 테스트는 예외적인 상황을 위한 사치가 아닙니다. 이는 API가 시스템의 나머지 부분과 맺는 계약입니다.
장애 조치(Failover) 전 제공자의 의도를 검증하십시오
멀티 제공자 설정을 운영한다면, 서로 다른 AI 모델을 교체 가능한 슬롯처럼 취급하고 싶은 유혹을 느낄 수 있습니다. 모델들은 동일한 코드 경로, 동일한 HTTP 클라이언트, 동일한 JSON 스키마를 공유합니다. 하지만 그렇다고 해서 동작 방식까지 같다는 의미는 아닙니다.
어떤 모델은 최상위 키를 환각(hallucinate)할 수 있습니다. 또 다른 모델은 시스템 프롬프트 형식을 무시할 수도 있습니다. 스키마 검증은 구문 오류는 잡아내지만, 비즈니스 로직이 해석할 수 없는 응답까지 통과시키지는 못합니다. 제공자가 유효한 JSON을 반환하더라도, 프롬프트 템플릿에 대해 잘못된 동작을 수행할 수 있습니다.
자동 모델 전환을 허용하기 전에 제공자별 테스트를 수행하십시오. 폴백(fallback) 모델이 낮은 온도(low temperature) 설정에서도 출력 구조를 실제로 준수하는지 확인하십시오. 해당 제공자의 토크나이저(tokenizer)를 통해 프롬프트가 올바르게 렌더링되는지 검증하십시오. 실제 입력을 사용하여 전체 라운드 트립(round trip)을 테스트하십시오. 자동 장애 조치는 폴백 모델이 동일한 운영 계약을 공유한다는 것을 증명했을 때에만 안전합니다.
의도당 하나의 작업만 유지하십시오
폴백 경로는 유용합니다. 하지만 통제되지 않은 폴백의 증식은 버그입니다. 스택의 모든 계층은 동일한 작업이 이미 수행되었는지 평가해야 합니다. 로드 밸런서, API 핸들러, 데이터베이스, 워커 모두가 동일한 식별자(identity)를 존중해야 합니다.
재시도와 폴백이 하나의 안정적인 작업 아래에서 새로운 시도로 나타나도록 시스템을 구축하십시오. 데이터베이스 기반의 멱등성 키로 작업을 잠그십시오. 상태 전환을 보호하십시오. 시도 간의 경합을 허용하되, 정확히 하나만 승리하게 하십시오. 그래야 사용자의 클릭 한 번이 주말 내내 이어지는 데이터 정리 작업으로 변하는 것을 막을 수 있습니다.
