백엔드

정렬이 파일을 잘못된 레코드에 조용히 다시 바인딩할 때

제자리 정렬과 인덱스에서 파생된 파일 이름을 함께 사용하면, 모든 레코드가 잘못된 파일을 가리키면서도 빌드, 로드, 유효성 검사는 잘 통과하는 출력이 생성됩니다.

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

파이프라인이 항목을 그룹 단위로 처리하고, 공유 변환 단계를 위해 하나의 슬라이스로 평탄화한 다음, 루프 인덱스를 사용하여 각 출력 파일을 item-%04d.ext로 기록했습니다. 그러다 누군가 출력을 소유자별로 다시 그룹화해달라고 요청했습니다. 명백해 보이는 변경 사항을 적용하자 파일이 존재하고, 크기가 정확하며, 모든 스키마 검사를 통과했지만, 모든 단일 레코드에 대해 잘못된 데이터를 포함하는 파일이 생성되었습니다.

아무런 오류도 발생하지 않았습니다. 빌드가 통과하고, 테스트가 통과했으며, 아티팩트는 다운스트림 도구에서 올바른 구조로 열렸습니다. 페이로드만 잘못되었는데, 페이로드는 자동화된 검사가 보통 검사하지 않는 바로 그 부분입니다.

이것을 가능하게 하는 설정

세 가지 평범한 결정이 합쳐져 함정이 됩니다. 그 자체로는 각각 정당화될 수 있습니다.

첫째, 항목을 한 번에 일괄적으로 다운로드하고 변환하는 것이 그룹별로 하는 것보다 저렴하기 때문에 공유 처리 단계는 플랫 슬라이스를 사용합니다.

둘째, 해당 단계는 제자리 정렬을 합니다. 다운스트림 작업이 순서를 가정할 때 타임라인 위치, 키 또는 그 어떤 것으로든 정렬하는 것은 일반적입니다. 정렬은 올바르며 이를 설명하는 주석은 정확합니다.

셋째, 출력 파일 이름은 쓰기 시점의 루프 인덱스에서 가져옵니다.

for i := range items {
    items[i].LocalPath = filepath.Join(dir, fmt.Sprintf("item-%04d.src", i))
}
sortItems(items)                                     // in-place, reorders everything
for i := range items {
    out := filepath.Join(dir, fmt.Sprintf("item-%04d.out", i))
    convert(items[i].LocalPath, out)
    os.Remove(items[i].LocalPath)                    // source deleted, field not updated
}

자세히 읽어보세요. 입력은 정렬 전 인덱스로 이름이 지정됩니다. 출력은 정렬 후 인덱스로 이름이 지정됩니다. LocalPath 필드는 출력으로 업데이트되지 않으므로, 변환 후에는 더 이상 존재하지 않는 파일을 가리킵니다. 레코드와 변환된 파일 사이에 유일하게 남는 연결 고리는 정렬된 슬라이스에서의 위치입니다.

이는 하나의 루프가 매니페스트를 작성하는 한 작동합니다:

for i, item := range items {
    manifest = append(manifest, Entry{Path: fmt.Sprintf("item-%04d.out", i), ...})
}

같은 슬라이스, 같은 순서, 같은 인덱스. 우연히 맞았습니다.

문제를 일으키는 변경

이제 소유자별로 출력을 그룹화합니다. 일반적인 구현에서는 각 그룹을 순회하며 해당 파일에 번호를 매깁니다:

idx := 0
for _, group := range groups {
    for _, item := range group.Items {
        group.Entries = append(group.Entries, Entry{
            Path: fmt.Sprintf("item-%04d.out", idx),   // fresh numbering per group
        })
        idx++
    }
}

해당 매니페스트의 모든 경로는 디스크에 존재합니다. 모든 경로는 고유합니다. 모든 파일은 올바른 유형과 대략적으로 맞는 크기의 그럴듯한 콘텐츠를 가지고 있습니다. 그리고 플랫 슬라이스는 정렬되었고 그룹 순회는 정렬된 순서가 아니기 때문에, 그들 각각은 서로 다른 레코드에 속합니다.

이것이 바로 이름을 붙일 만한 실패 모드입니다: 버그는 누락된 파일이나 손상된 파일을 생성하지 않습니다. 잘못된 콘텐츠를 가진 유효한 파일을 생성합니다. 존재 여부 확인을 통과합니다. 크기 확인을 통과합니다. 형식 유효성 검사를 통과합니다. 스키마 유효성 검사를 통과합니다. 다운스트림 소비자는 문제없이 파일을 엽니다.

오디오 파이프라인에서는 클립이 올바른 타임라인 위치에 올바른 길이로 배치되지만 음성은 잘못된 것을 의미합니다. 문서 파이프라인에서는 페이지 수는 정확하지만 페이지 본문이 뒤바뀐 것을 의미합니다. 이미지 파이프라인에서는 크기는 정확하지만 피사체는 잘못됩니다. 증상은 항상 구조적이 아닌 의미론적이기 때문에, 여러분이 가진 모든 구조적 검사를 통과합니다.

뻔한 해결책이 충분하지 않은 이유

가장 먼저 드는 생각은 멤버십이 정렬 후에도 유지되도록 owner 필드를 추가하는 것입니다:

type Item struct {
    // ...
    GroupIdx int   // survives the in-place sort
}

그것은 필요한 조치이며, 그룹화를 복원하는 것도 맞습니다. 하지만 파일 바인딩 문제는 해결되지 않습니다. 바인딩은 그룹화가 아닌 위치에 의존했기 때문입니다. GroupIdx를 추가하면 그룹을 재구성하고 그 안에서 위치를 다시 생성할 수 있게 되는데, 이것이 바로 스왑을 유발하는 코드 경로입니다.

저는 첫 시도에서 이 실수를 저질렀습니다. 필드를 추가했고 그룹화도 작동했지만, 한 리뷰어가 매니페스트 빌더가 여전히 카운터에서 경로를 파생시킨다고 지적했습니다. 실제로 유효한 해결책은 경로 파생을 완전히 중단하는 것입니다.

// conversion updates the record to point at its own output
for i := start; i < end; i++ {
    os.Remove(items[i].LocalPath)
    items[i].LocalPath = outputs[i-start]     // the record carries its file
}

// manifest reads the field instead of computing an index
for _, item := range items {
    entries[item.GroupIdx] = append(entries[item.GroupIdx], Entry{
        Path: item.LocalPath,
    })
}

이제 레코드는 자체 아티팩트 참조를 가집니다. 재정렬, 재그룹화, 필터링, 병렬 처리가 모두 무해해집니다. 레코드가 이미 알고 있는 것을 어떤 것도 재계산하지 않기 때문입니다.

일반적인 규칙: 레코드와 그 아티팩트가 함께 있어야 할 때는 참조를 레코드에 저장하십시오. 인덱스는 위치이며, 위치는 모든 정렬, 필터 또는 파티션이 가장 먼저 파괴하는 것입니다. 두 개의 다른 루프가 모두 "N번째 파일"을 계산하는 순간, 아무도 강제하지 않는 불변식이 생깁니다.

이것을 테스트하는 것은 보기보다 어렵습니다

저는 바로 이 버그에 대한 회귀 테스트를 작성했습니다. 이 테스트는 정렬 키가 인터리브된 두 그룹을 설정하고, 파이프라인 단계를 실행했으며, 모든 항목이 자체 레코드의 파일을 가리키고 있음을 단언했습니다. 테스트는 통과했습니다.

수정 사항을 되돌렸을 때도 테스트는 통과했습니다.

프로덕션 함수가 네트워크 호출과 하위 프로세스를 필요로 하는 더 큰 루틴에 묻혀 있었기 때문에, 테스트는 테스트 파일 내에서 정렬과 매니페스트 구성을 다시 구현했습니다. 로직의 사본을 테스트하는 것은 그 사본이 올바르다는 것을 검증할 뿐입니다. 이는 실제 배포되는 코드에 대해서는 아무것도 알려주지 않습니다.

해결책은 순수한 부분을 추출하여 테스트가 직접 호출할 수 있도록 하는 것이었습니다:

func flattenWithGroupIdx(groups []Group) []Item      // tag membership
func sortItems(items []Item)                          // the actual sort, now callable
func groupIntoEntries(groups []Group, items []Item) []GroupEntries

세 가지 작은 추출이 있었고 동작 변경은 없었으며, 이제 테스트가 실제 출시 코드를 실행합니다. 그 후 버그가 있을 때 테스트가 실제로 실패하는지 확인했습니다:

$ # revert manifest to index-derived paths
$ go test -run TestKeepsBinding ./...
--- FAIL: TestKeepsBinding
    record a2 bound to wrong file: got item-0000.out, want /tmp/x/item-0000.out
    record a1 bound to wrong file: got item-0001.out, want /tmp/x/item-0001.out
    ... (5 records)
FAIL

그 2분짜리 확인이 바로 핵심입니다. 한 번도 실패하는 것을 본 적 없는 회귀 테스트는 보장이 아니라 가설일 뿐입니다. 만약 수정 사항을 되돌려도 테스트가 통과(green)한다면, 그 테스트는 다른 것을 테스트하고 있는 것이며, 그 다른 것이란 보통 여러분이 보호하려던 로직의 복사본입니다.

시맨틱 스왑을 잡아내는 어설션

구조적 검사는 구조상 이러한 종류의 버그를 놓치므로, 어설션은 형태가 아닌 동일성을 비교해야 합니다.

재정렬이 발생하기 전에 예상되는 매핑을 캡처한 다음, 이후에 비교합니다:

want := map[string]string{}
for _, item := range items {
    want[item.ID] = item.LocalPath      // snapshot before regrouping
}
// ... build manifest ...
for _, e := range entries {
    if e.Path != want[e.ID] {
        t.Errorf("%s bound to wrong file: got %s, want %s", e.ID, e.Path, want[e.ID])
    }
}

고유성을 명시적으로 확인하세요. 하나의 아티팩트를 가리키는 두 항목은 항목별 검사로는 볼 수 없는 교환입니다.

seen := map[string]string{}
for _, e := range entries {
    if prev, dup := seen[e.Path]; dup {
        t.Errorf("path %s shared by %s and %s", e.Path, prev, e.ID)
    }
    seen[e.Path] = e.ID
}

실제로 재정렬하는 정렬 키를 사용하세요. 항목이 이미 정렬된 픽스처는 아무것도 증명하지 못합니다. 정렬 시 레코드가 그룹 경계를 넘어가도록 그룹을 교차 배치하세요. 이것이 버그를 유발하는 조건입니다.

경로뿐만 아니라 확장자도 확인하세요. 원본 코드에서는 변환되지 않은 LocalPath가 삭제된 소스 파일을 계속 가리키고 있었습니다. 접미사를 검증하면 '변환 후 필드가 업데이트되지 않음' 유형의 실수를 모두 잡아낼 수 있습니다.

이 패턴이 숨어있는 다른 곳

저는 오디오 파이프라인에서 이 문제를 겪었지만, 그 형태는 일반적입니다. 다음 세 가지가 함께 나타나는 모든 곳에서 이 패턴을 찾아보세요.

  • 그룹별 호출보다 배치 API가 더 저렴할 때 흔히 사용되는 flatten-process-regroup 시퀀스.
  • 직접 작성하지 않은 헬퍼 내부에 숨겨진 것을 포함하여, 중간 어딘가에 있는 in-place 정렬 또는 필터.
  • 레코드에 저장되지 않고 루프 카운터에서 파생된 아티팩트 이름.

이 패턴이 나타나는 구체적인 예는 다음과 같습니다. 이미지를 일괄 처리한 다음 앨범별로 다시 그룹화하는 썸네일 생성, 한 번에 페이지를 렌더링한 다음 챕터별로 조립하는 보고서 빌더, 행을 대량으로 가져와 윈도우잉을 위해 타임스탬프로 정렬한 다음 테넌트별로 파티셔닝하는 ETL 작업, --output-%d 패턴을 사용하는 도구로 셸 아웃하는 모든 것.

"인덱스가 정렬된 순서와 일치함" 또는 "같은 루프, 같은 인덱스"와 같은 주석이 단서입니다. 그 주석은 강제되지 않는 불변성을 문서화하고 있습니다. 작성 시점에는 참이지만 다음 리팩토링 후에는 조용히 거짓이 되며, 테스트 스위트에서는 아무것도 감지하지 못할 것입니다.

일반적인 반론

"제자리 정렬(in-place sort)을 하지 마세요." 합리적인 말이지만, 정렬을 직접 제어하지 못하는 경우가 많습니다. 여기서는 순서에 의존하는 다른 파이프라인에서 사용하는 공유 헬퍼에 정렬이 있었습니다. 복사본을 반환하도록 변경하는 것은 그 자체로 위험이 따르는 더 광범위한 변경이었을 것이며, 위치에서 파생된 이름이라는 근본적인 문제를 해결하지도 못했을 것입니다.

"슬라이스 대신 ID를 키로 사용하는 맵을 사용하세요." 그 방법은 효과가 있고 위치를 완전히 제거합니다. 또한 할당 및 순서 제어 비용이 발생하는데, 다음 단계가 정렬된 순서로 스트리밍될 때는 이 점이 중요합니다. 레코드에 경로를 저장하면 슬라이스를 유지하면서도 동일한 안전성을 얻을 수 있습니다.

"나중에 출력을 검증하세요." 그럴 수 있지만, 의미론적 동일성을 검증하려면 보통 아티팩트를 디코딩하고 내용을 비교해야 하는데, 이는 비용이 많이 들고 단위 테스트에서는 불가능한 경우가 많습니다. 바인딩이 구조적으로 깨지는 것을 불가능하게 만드는 것이 깨진 것을 감지하는 것보다 저렴합니다.

"저희 타입은 불변(immutable)이라서 경로를 저장할 수 없습니다." 그렇다면 레코드와 아티팩트를 쌍으로 묶는 병렬 구조를 반환하고, 독립적으로 다시 인덱싱하는 두 개의 슬라이스 대신 그 구조를 전달하세요. 원칙은 변하지 않습니다. 하나의 값이 양쪽 절반을 모두 담는 것입니다.

기억해야 할 점

  1. "name inputs"와 "name outputs" 사이의 내부 정렬은 인덱스에서 파생된 파일 이름을 감지하기 어려운 정확성 버그로 만듭니다.
  2. 이 버그는 내용이 뒤바뀐 유효한 아티팩트를 생성하므로 존재, 크기, 형식, 스키마 검사가 모두 통과합니다.
  3. 소유권 필드를 추가하면 그룹화는 복원되지만 바인딩은 복원되지 않습니다. 레코드에 아티팩트 참조를 저장하세요.
  4. 테스트가 출시 코드를 호출하도록 순수 함수를 추출한 다음, 수정 사항을 한 번 되돌려 테스트가 실패하는지 확인하세요.
  5. 형태가 아닌 ID와 고유성을 검증하고, 정렬 순서가 실제로 레코드를 이동시키는 픽스처를 사용하세요.

이 모든 것 중 가장 간단한 방법은 수정 사항 되돌리기 확인입니다. 이번 분기에 회귀 테스트를 하나 작성한다면, 실패하는 것을 직접 목격한 테스트로 만드세요.

FAQ

현재 제 파이프라인에 이 버그가 있는지 어떻게 알 수 있나요? 루프 인덱스로 파일 이름의 형식을 지정하는 모든 곳을 찾으세요. 각각에 대해, 해당 라인과 파일을 다시 읽어오는 라인 사이에서 슬라이스가 재정렬될 수 있는지 확인해 보세요. 그 사이에 정렬, 필터링, 중복 제거 또는 병렬 분산(scatter)이 있다면, 버그가 발생할 수 있는 환경입니다. 그런 다음 위에 언급된 매핑 단언문을 작성하고 통과하는지 확인하세요.

정적 분석기가 이 버그를 잡아낼 수 있나요? 아니요. 모든 개별 라인은 정확합니다. 버그는 타입 검사기가 연결할 이유가 없는 두 루프 사이의 관계에 존재합니다. 이것이 완화 조치가 린트 규칙이 아닌 구조적인 이유입니다.

이것은 off-by-one 오류와 같은 것인가요? 아니요, 그리고 그 차이점은 디버깅에 중요합니다. off-by-one 오류는 보통 경계에서 충돌하거나 명백히 잘못된 요소를 하나 생성합니다. 이 버그는 완전한 순열을 생성합니다. 모든 요소가 잘못되었고, 누락된 요소는 없으며, 개수는 정확합니다. "모든 것이 미묘하게 잘못되었지만 망가진 것은 없다"는 내용의 보고를 처리하고 있다면, 산술 오류보다는 순열을 가설로 세우는 것이 더 좋습니다.

이것이 파일 파이프라인 외부에도 적용되나요? 예. 위치를 기준으로 두 컬렉션 간의 대응 관계를 유지하는 경우, 재정렬이 일어나면 이 관계가 깨집니다. 병렬 배열, 인덱스 키 캐시, 일괄 처리 API 클라이언트에서의 "N번째 응답이 N번째 요청과 일치한다"는 가정 모두 동일한 형태를 가집니다. 해결책도 동일합니다. 두 부분을 하나의 값으로 짝을 지어주세요.

관련 글