백엔드

파괴적 읽기가 파싱 실패를 영구적인 행으로 전환

워커는 `GETDEL`로 작업 결과를 읽었기 때문에, 파싱할 수 없는 페이로드는 영원히 사라졌습니다. 이를 "아직 준비되지 않음"으로 처리하는 것은 영원히 기다리는 것을 의미했습니다.

이 글은 영어 원문을 AI 모델이 번역한 것입니다. 표현이 원문과 다를 수 있습니다. 영어 원문 보기

작업이 18시 28분에 실패했습니다. 사용자는 20시 30분까지 "진행 중" 상태를 보았습니다. 실패 알림은 제시간에 도착했고, 워커는 그것을 읽었습니다. 단지 그것을 이해하지 못했을 뿐입니다.

버그는 제어 흐름의 한 줄이었습니다. 대역 외(out-of-band) 결과를 기다리는 워커가 모든 가져오기 오류를 동일하게 처리했기 때문에, "결과가 아직 없습니다"와 "결과에 작업이 실패했다고 나옵니다" 두 경우 모두 계속 기다리는 것을 의미했습니다. 그것만으로도 느린 버그가 되었을 것입니다. 그것을 영구적인 문제로 만든 것은 읽기 자체였습니다. 워커는 파괴적 읽기(destructive read)를 사용했기 때문에, 실패 알림을 잘못 읽는 순간 그 알림은 더 이상 존재하지 않게 되었습니다.

결과를 얻기 위해 키를 폴링하는 비동기 워커가 있다면, 이 게시물은 작은 오분류를 몇 시간 동안의 행(hang)으로 바꾸는 두 가지 요소와, 그런 일이 발생하지 않도록 컨슈머를 구조화하는 방법에 관한 것입니다.

소비자가 구별해야 하는 두 가지 상태

이러한 설정은 흔합니다. 백엔드는 컴퓨팅 서비스에 작업을 제출하고 즉시 ID를 돌려받습니다. 컴퓨팅 서비스는 자신의 작업을 수행하고 그 결과를 공유 키에 쓴 다음, nudge를 발행합니다. 백엔드는 이 nudge를 구독하고, pub/sub 전달이 보장되지 않기 때문에 티커(ticker)를 이용해 해당 키를 폴링하기도 합니다.

소비자 루프는 대략 다음과 같았습니다.

for {
    select {
    case <-ctx.Done():
        return rescueOrError(ctx.Err())

    case msg := <-notifyCh:
        result, err := fetchResult(ctx, key)
        if err != nil {
            slog.Warn("fetch after notify failed", "error", err)
            continue          // keep waiting
        }
        return result, nil

    case <-ticker.C:
        result, err := fetchResult(ctx, key)
        if err == nil {
            return result, nil
        }
        // no result yet, keep waiting
    }
}

continue를 다시 읽어보세요. 이 구문은 컴퓨팅 서비스가 작업이 실패했다고 명시적으로 알렸을 때 도달합니다. 소비자는 경고를 기록하고 다시 휴면 상태로 돌아갑니다.

대기 중인 소비자는 최소 세 가지 결과를 구별해야 하는데, 이 루프는 그것들을 두 가지로 축소했습니다.

  • 아직 준비되지 않음. 키가 존재하지 않습니다. 계속 대기합니다. 이 경우에만 계속 대기해야 합니다.
  • 일시적인 인프라 오류. 키 저장소에 일시적으로 연결할 수 없거나 로딩 중입니다. 결과가 소비되지 않았을 가능성이 높으므로 계속 대기합니다.
  • 종료. 결과가 도착했지만 명시적인 실패이거나 해석할 수 없는 것입니다. 지금 중지합니다.

해결책은 "오류 사례 처리"가 아닙니다. 세 번째 범주를 루프가 인식할 수 있는 유형으로 만들고, 모든 분기를 동일한 조건자를 통하도록 라우팅하는 것입니다.

type StepError struct{ Step, Server, Message string }      // service said: failed
type ProtocolError struct{ Step, Server, Reason, Raw string } // we cannot parse it

func isTerminal(err error) bool {
    var se *StepError
    if errors.As(err, &se) { return true }
    var pe *ProtocolError
    return errors.As(err, &pe)
}

겉보기보다 더 중요한 두 가지 세부 사항이 있습니다. *StepError 타겟에 대한 errors.As는 값 타입과 일치하지 않을 경우 조용히 실패하므로, 이것들을 포인터로 반환해야 합니다. 그리고 %v가 아닌 %w로 래핑해야 합니다. 그렇지 않으면 첫 번째 래퍼에서 체인이 끊어지고 모든 다운스트림 검사가 조용히 false를 반환합니다.

파괴적 읽기가 위험을 높이는 이유

소비자는 원자적인 읽기 후 삭제(Redis GETDEL) 연산으로 결과를 읽었습니다. 그 선택 자체는 타당합니다. 두 워커가 동일한 결과를 소비하는 것을 방지하고, TTL에 의존하지 않고도 키 공간을 깨끗하게 유지하기 때문입니다.

하지만 이로 인해 전달 보장이 **최대 한 번(at-most-once)**으로 바뀌며, 이는 파싱 실패가 의미하는 바를 바꾸어 놓습니다.

비파괴적 읽기 GET key 파싱 실패 값이 여전히 있음 나중에 재시도 또는 검사 파괴적 읽기 GETDEL key 파싱 실패 값이 사라짐 파싱이 유일한 기회였음

일반적인 GET에서는, 잘못된 형식의 페이로드가 성가신 문제입니다. 이를 기록하고, 값은 그대로 둔 채 나중에 결정합니다. GETDEL에서는, 값을 보는 행위만으로도 값이 소비됩니다. 따라서 규칙은 절대적입니다.

파괴적 읽기가 "키 없음" 이외의 것을 반환하면, 그 시점 이후의 모든 실패는 치명적입니다.

유효하지 않은 JSON은 치명적입니다. 상태 필드가 없는 것도 치명적입니다. 상태 필드가 문자열이 아닌 숫자인 것도 치명적입니다. 기다릴 것이 더는 없으므로, 이 중 어떤 것도 기다린다고 해결되지 않습니다. 원래의 루프는 이 모든 경우를 "계속 대기"로 처리했기 때문에, 각각의 경우는 외부 안전 타이머가 작동할 때까지 몇 시간 동안 응답이 없는 상태를 보장했습니다.

여기에는 실질적인 트레이드오프가 있습니다. 파괴적 읽기를 유지하면, 읽기와 커밋 사이에 컨슈머가 충돌할 경우 결과를 잃게 된다는 점을 감수해야 합니다. GET을 사용하고 자신의 상태를 커밋한 후 삭제하는 방식으로 전환하면, 멱등성은 확보되지만 중복 소비를 처리해야 합니다. 어느 쪽이든 괜찮습니다. 괜찮지 않은 것은, 다시 볼 수 있다고 가정하는 컨슈머 코드를 작성하면서 파괴적 읽기를 사용하는 것입니다.

네거티브 검사가 아닌 포지티브 허용 목록

터미널 유형을 추가하던 중, 제가 수정하던 버그보다 더 오래된 두 번째 버그가 같은 함수에 숨어 있는 것을 발견했습니다.

var status string
if s, ok := raw["status"]; ok {
    json.Unmarshal(s, &status)   // error ignored
}
if status == "error" {
    return nil, errors.New("service reported failure")
}
return &Result{Status: "done", Content: raw}, nil   // everything else

이는 하나의 잘못된 값을 확인하고 나머지 전체 유니버스를 성공으로 처리합니다. 이것이 무엇을 의미하는지 살펴보겠습니다.

페이로드 이전 동작 올바른 동작
{"status":"done", ...} 완료 완료
{"status":"error", ...} 실패, 이후 호출자에 의해 무시됨 종료 실패
{} 완료 종료 프로토콜 오류
{"status":123} 완료 (언마샬 오류 무시됨) 종료 프로토콜 오류
{"status":"failed"} 완료 종료 프로토콜 오류
JSON이 전혀 아님 오류, 이후 호출자에 의해 무시됨 종료 프로토콜 오류

여기서 세 개의 행은 조용한 데이터 손상입니다. 빈 객체는 성공적인 작업으로 보고되었고, 그 결과로 함께 제공된 부분적인 콘텐츠가 레코드에 기록되었습니다. 생산자가 우연히 항상 잘 구성된 페이로드를 보냈기 때문에 아무도 알아차리지 못했습니다. 계약은 강제가 아닌 운에 의해 지켜지고 있었습니다.

이를 반대로 하십시오. 허용하는 값을 명시하고, 그 외의 모든 것을 볼 수 있는 오류로 만드십시오.

switch status {
case "done":
    return &Result{Status: "done", Content: raw}, nil
case "error":
    return nil, &StepError{Step: step, Message: sanitize(msg)}
default:
    return nil, &ProtocolError{Step: step, Reason: "unknown_status:" + status}
}

방금 무시하려 했던 언마샬 오류에도 동일한 원칙이 적용됩니다. status가 존재하지만 문자열이 아닌 경우, 이는 생산자 계약 위반이며, 가짜 성공으로 변환하는 대신 명확하게 오류를 알려야 합니다.

재시도 예산에는 자체 키 스코프가 필요합니다

실패가 즉시 표면화되면 아마도 재시도를 원할 것입니다. 컴퓨팅 측의 일시적인 메모리 부족 오류는 한 번 더 시도할 가치가 있지만, 잘못된 입력은 그렇지 않습니다. 하지만 페이로드만으로는 이 둘을 구별할 수 없는 경우가 많습니다.

저의 첫 시도는 기존의 "이미 시도한 서버" 배열을 예산 카운터로 재사용하는 것이었습니다. 두 명의 리뷰어가 순서대로 그 시도를 거절했는데, 두 번째 이유가 흥미롭습니다.

첫 번째 문제는 오프바이원(off-by-one) 오류였습니다. 현재 서버를 추가한 다음 len(servers) >= 1을 확인하면 첫 시도에서 작업이 실패하므로 재시도가 전혀 일어나지 않습니다. 말하고 나니 명백한 사실입니다.

두 번째 문제는 첫 번째 문제를 해결했을 때 발생하는 일입니다. 상한을 2로 올리면, 추가(append) 작업이 중복을 방지하도록 되어 있기 때문에 동일한 워커가 작업을 다시 가져갈 때마다 배열이 더 이상 커지지 않습니다. 성장이 없다는 것은 종료 조건이 없다는 의미입니다. 작업은 영원히 다시 큐에 들어갑니다. 설상가상으로, 일반적인 탈출구가 없었습니다. 이 경로는 일반 재시도 카운터를 증가시키지 않았고, 작업이 매 사이클마다 깔끔하게 완료되었기 때문에 오래된 작업 모니터(stale-job monitor)는 이 작업이 멈췄다고 보지 않았습니다.

동일한 구조가 인접한 오류 클래스에 대해 안전했던 이유는 서킷 브레이커(circuit breaker) 때문이었습니다. 제출 실패가 여기에 계산되었고, 몇 번의 실패 후 서킷 브레이커가 열리고 워커가 유휴 상태가 되어 우연히 루프가 깨졌습니다. 제가 만들고 있던 변경 사항은 그 우연을 명시적으로 제거했습니다.

따라서 예산은 단조 증가하는 카운터여야 하며, 그 키 스코프는 신중하게 고려해야 합니다.

// key: retry:{job_id}:{step}
const incrWithTTL = `
local v = redis.call("INCR", KEYS[1])
if v == 1 then redis.call("EXPIRE", KEYS[1], ARGV[1]) end
return v`

이 방식을 확정하기 전에 제가 잘못 생각했던 세 가지입니다.

  • 엔티티가 아닌 작업별로 범위 지정. 부모 엔티티와 단계 이름을 함께 키로 사용하는 것이 자연스러워 보이지만, 사용자가 동일한 엔티티와 단계에 대해 여러 독립적인 작업을 트리거할 수 있다면 이 작업들은 하나의 예산을 공유하게 되어 서로를 고갈시킵니다. 인큐(enqueue)별 ID를 사용하면 이들을 분리할 수 있으며, 내부 재큐(requeue) 시에도 이 ID가 보존됩니다.
  • 하위 단계 분리. 동일한 작업 레코드 내에서 실행되는 전처리 단계는 주요 단계의 예산을 사용해서는 안 됩니다. 작업에 선언된 단계가 아니라 실제 계산 단계의 이름을 사용하세요.
  • INCR과 EXPIRE의 원자성 확보. INCR은 TTL 없이 키를 생성합니다. 만약 EXPIRE가 실행되기 전에 프로세스가 중단되면, 영원히 사라지지 않는 카운터가 생기게 되며, 이후 해당 ID에 대한 모든 실패는 자연적인 복구 없이 즉시 치명적인 오류가 됩니다. 하나의 Lua 스크립트로 이 시간차를 없앨 수 있습니다.

TTL은 평균 시간이 아닌, 시도 간의 가능한 가장 긴 간격을 기준으로 설정하세요. 제 경우 단일 대기 시간의 하드 실링(hard ceiling)의 두 배로 설정하게 되었습니다. 제가 처음 추측했던 두 시간(1시간, 그리고 6시간)은 모두 단일 시도가 실행될 수 있는 시간보다 짧았고, 이는 재시도 중간에 카운터가 만료되어 예산을 재설정하게 만들었을 것입니다. 이것은 결국 또 다른 형태의 무한 재큐(requeue) 문제입니다.

서킷 브레이커에 도메인 실패를 포함하지 마세요

마지막으로 고려할 점은 이러한 실패가 생산자의 상태에 불리하게 작용해야 하는 경우입니다.

서킷 브레이커는 "이 엔드포인트에 문제가 있는가?"라는 한 가지 질문에 답하기 위해 존재합니다. 명시적인 실패 응답은 엔드포인트가 정상이라는 증거입니다. 엔드포인트는 요청을 수락하고, 작업을 수행하고, 구조화된 답변을 생성하고, 결과 채널을 통해 전달했습니다. 작업이 실패한 것이지, 서버가 실패한 것이 아닙니다.

따라서 명시적인 실패는 서킷 브레이커 실패로 간주되어서는 안 됩니다. 그렇지 않으면 몇 개의 잘못된 입력만으로도 서킷 브레이커가 열리고, 멀쩡한 노드가 로테이션에서 제외되어 관련 없는 작업이 재큐(requeue)로 밀려나게 됩니다.

프로토콜 오류는 그 반대입니다. 파싱할 수 없는 페이로드는 버전 불일치, 직렬화 버그 또는 배포가 완전히 끝나지 않았음을 시사합니다. 이는 노드 문제이므로 실패로 계산되어야 합니다.

이 때문에 후속 커밋을 해야 했던 미묘한 문제가 있습니다. 만약 단순히 실패 호출을 건너뛰면 성공 호출도 건너뛸 수 있는데, 이때 서킷 브레이커가 half-open 상태에서 진행 중인 프로브 슬롯을 추적하고 있다면 해당 슬롯은 절대 해제되지 않습니다. 그러면 워커는 프로세스가 재시작될 때까지 열리지 않는 게이트 뒤에서 대기하게 됩니다. 저는 명시적인 해제가 필요했습니다:

func (cb *Breaker) ReleaseProbeAsSuccess() {
    cb.mu.Lock()
    defer cb.mu.Unlock()
    if cb.state == "half-open" {
        cb.state = "closed"     // the server answered, it is reachable
        cb.failures = 0
    }
    cb.inFlight = 0
}

해당 메서드에는 두 가지 가드가 있습니다. 차단기가 닫혀 있을 때 시작된 요청에서는 호출하지 마십시오. 차단기는 보통 goroutine 간에 공유되므로 실제 프로브가 성공하지 않은 상태에서 다른 사람의 프로브를 닫게 될 수 있기 때문입니다. 또한 컨텍스트 취소 시에는 절대 호출하지 마십시오. 종료는 원격 호스트에 대해 아무것도 증명하지 않기 때문입니다.

아직 다루지 않은 내용

에지 케이스에 대해 솔직해져야 합니다. 바로 다음 버그가 되기 때문입니다.

  • 하나의 작업에 대한 동시 소비자. 만약 두 워커가 어떻게든 동일한 작업을 처리하게 되면, 하나는 카운트 1에서 재큐잉하고 다른 하나는 카운트 2에서 포기할 수 있으며, 이미 실패한 작업이 프로듀서에게 돌아갑니다. 이를 방지하려면 단순한 카운터가 아니라 재큐잉 시점에 작업 상태에 대한 compare-and-set이 필요합니다.
  • 다양성 없는 재시도. 카운터는 총 시도 횟수를 제한하지만 재시도를 다른 노드로 유도하지는 않습니다. 실패가 노드 로컬인 경우, 맹목적으로 재시도하면 예산을 낭비하게 됩니다.
  • 프로듀서 계약 자체. 이 모든 것은 소비자가 모호함으로부터 자신을 방어하는 것입니다. 진정한 해결책은 실패가 재시도 가능한지 여부를 알려주는 페이로드를 사용하여 소비자가 추측할 필요가 없게 만드는 것입니다.

배포 후, 동일한 실패 작업이 두 노드에 걸쳐 두 번 시도되었고 2분 19초 만에 해결되었으며, 프로듀서 자체의 오류 텍스트가 레코드에 보존되었습니다. 이전 경로는 6시간 이상 걸렸으며 원인을 일반적인 타임아웃 레이블로 덮어썼습니다.

FAQ

파괴적 읽기는 실수인가요?

아니요. 단일 소비를 보장하는 합리적인 방법입니다. 단지 책임을 이전할 뿐입니다. 일단 소비하면 파싱이 마지막 기회이므로, 모든 읽기 후 실패는 최종적(terminal)이어야 합니다. 실수는 파괴적 읽기를 두 번째 조회가 가능하다고 가정하는 소비자 코드와 결합하는 것입니다.

파싱 오류 시 단순히 페치를 재시도하지 않는 이유는 무엇인가요?

파괴적 읽기를 사용하면 페치할 것이 없기 때문입니다. 재시도 시 빈 키를 보고 "아직 준비되지 않음"이라고 결론 내리고 대기합니다. 이것이 바로 파싱 오류가 몇 시간 동안의 행(hang) 상태로 이어지는 방식입니다.

최종 실패 시 몇 번이나 시도해야 하나요?

제 경우에는 한 번의 재시도가 적절했습니다. 실패의 상당 부분이 컴퓨팅 측의 일시적인 리소스 고갈이었고, 최악의 경우 비용은 해당 단계를 한 번 더 실행하는 것뿐이었기 때문입니다. 생산자가 오류 재시도 가능 여부를 보고한다면, 고정된 숫자 대신 그 정보를 사용하세요. 고정된 숫자는 여러분이 가지고 있지 않은 정보를 대신하는 것입니다.

프로토콜 오류와 명시적 실패 모두 재시도해야 하나요?

저는 둘 다 재시도합니다. 저렴한 비용의 통일된 정책이 제가 검증할 수 없는 분류 체계보다 추론하기 더 쉬웠기 때문입니다. 둘의 차이점은 시도 횟수가 아니라 차단기(breaker)에 영향을 미치는 방식에 있습니다.

생산자의 오류 텍스트를 어떻게 안전하게 저장하나요?

자른 후에 정규화하지 말고, 자르기 전에 하십시오. 잘못된 UTF-8을 수정하고, 제어 문자와 양방향 재정의(override)를 제거하고, 내부 경로와 URL을 삭제 처리한 다음, 길이에 맞게 자릅니다. 먼저 자르면 경로 또는 URL의 절반만 남아 삭제 처리 패턴과 더 이상 일치하지 않게 됩니다. 그리고 파싱되지 않은 원시 페이로드는 전체 시스템에서 가장 신뢰할 수 없는 문자열이므로 사용자에게 도달하는 어떤 필드에도 포함되지 않도록 하십시오.

관련 글