ice rabbit programming

[회고] 업로드가 멈췄다 — 원인이 셋 겹친 사건 본문

Development/문제 진단 회고

[회고] 업로드가 멈췄다 — 원인이 셋 겹친 사건

판교토끼 2026. 9. 13. 12:29
728x90

들어가기 전에

AI 도구를 활용한 개발이 이제는 그 전이 까마득한 옛날이었던 것으로 느껴질만큼 상용화되었다. 다만 26년 중반을 넘어가는 현재 시점에서는 사용자의 능력에 따라서 이끌어낼 수 있는 결과물의 정도가 달라진다고 생각하고, 이슈가 있을 경우 이를 추론하고 판단하는 능력이 더욱 중요해졌다고 생각한다. 그렇기 때문에 6년 반 정도 개발자로 일하면서 자연스럽게 훈련된 프로세스인, 이슈를 찾고 원인을 파악해서 해결하는 과정을 조금씩 정리해보고자 한다.

업로드 중 동기화가 멈췄다 — 원인이 셋 겹친 사건

발단

정기 패키징 중 원격 동기화 단계가 특정 콘텐츠에서 에러도 없이 8분째 멈춰 있다는 제보가 들어왔다. 로그의 마지막 줄은 과거 버전의 매니페스트(메타데이터 파일을 LZMA로 압축한 해시명 zip)를 디코드하러 들어간 지점이었다. 동기화 단계는 원격 정리를 위해 이전 버전들의 매니페스트를 내려받아 푸는데, 거기서 돌아오지 않은 것이다.

파고들기 ① — 왜 도는가

그 파일 하나를 받아 로컬에서 돌려보니 바로 재현됐다. 진행 없이 도는 루프. 디코더를 열어보니 결함이 두 개 겹쳐 있었다.

첫째, .lzma 헤더의 8바이트 크기 필드 중 4바이트만 읽고 있었다. "크기 미상"을 뜻하는 sentinel(0xFF × 8)이 들어오면, 앞 4바이트만 읽은 코드는 이를 약 4GiB짜리 출력 크기로 오해한다.

둘째, 디코드 루프가 이랬다.

 
cpp
if (LZMA_STATUS_NOT_FINISHED == status) { continue; }  // 진행량 확인 없음
if (length == 0) { break; }

입력을 전부 소모하고도 "아직 4GiB 남았다"고 믿는 상태에서, 디코더는 NOT_FINISHED를 돌려주고 루프는 진행량 검사 없이 continue한다. 입력도 출력도 늘지 않는 반복이 영원히 돈다.

빌드 업로드 클라이언트가 만든 정상 매니페스트에서는 절대 걸리지 않는 경로다. 이쪽 인코더는 크기를 명시하고 종료 마커를 쓰지 않아서(writeEndMark = 0), 선언 크기를 다 뽑으면 MAYBE_FINISHED_WITHOUT_MARK로 깔끔히 끝난다.

파고들기 ② — 왜 이 파일만

그럼 특정 버전 매니페스트는 뭐가 다른가. 다른 버전들의 매니페스트는 평문 메타데이터 파일을 우리 인코더 로직으로 재압축하면 바이트 단위로 일치했다. 특정 버전만 달랐다. 크기 미상 헤더에 종료 마커까지 붙어 있었다. 우리 파이프라인이 만든 파일이 아니라는 뜻이다.

중간에 연휴가 있어서 제대로 기억을 하지 못 했었는데, 확인해 보니 운영자가 압축 도구로 수동 생성해서 올린 파일이었다. 마커가 있으면 예외 없이 안 걸리고 없으면 예외 없이 걸리는 구조라, 조사 범위에서 특정 버전 하나만 문제였다는 건 손으로 만든 게 그것 하나뿐이라는 뜻이기도 했다.

압축 도구가 잘못된 것은 아니었고, 압축 자체는 정상적으로 되었는데 디코딩하는 과정에서 로직에 오류가 있었다. 또한, 수동으로 복구한 여러 파일들 중 해당 파일 한 개에서만 오류가 발생하여 발견도 늦어졌다.

파고들기 ③ — 왜 수동 복구본이 존재하나

여기서 사건이 한 겹 더 벗겨졌다. 애초에 운영자는 왜 매니페스트를 손으로 복구했을까.

원격 정리(미러링 삭제)의 참조 집합에서 해시명 매니페스트가 누락되는 버그가 있었고, 정리 로직이 자기 매니페스트를 고아 파일로 오판해 지워버리고 있었던 문제가 있었다. 업로드 및 다운로드에 문제가 발생하지는 않지만 diff 파일을 생성하는 데에는 문제가 발생할 수 있기 때문에 복구가 필요했다. 이 과정에서 발생한 사이드 이펙트였다.

정리하면 삼중 구조다. 삭제 버그(원인) → 수동 복구(대응) → 그 복구본을 못 읽는 잠복 디코더 결함(발현). 어느 하나만 없었어도 이 장애는 없었다.

해결

수동 복구를 일으킨 원인은 기존에 발견 직후 수정했었기 때문에, 디코딩 로직만 수정했다.

디코더. 크기 필드 8바이트 전체를 읽고 sentinel은 명시적으로 거부. 루프에 no-progress 가드("진행이 없으면 에러") 추가. 디코드 후 남는 trailing 바이트를 로그로 노출. 이제 외부 산출물이 들어오면 도는 대신 한 줄로 정체를 밝힌다.

삭제 버그. 삭제 조건 수정으로 재발 차단.(이전에 이미 진행)

v52: raw=1,017,102 → zip=88,537   실제 오브젝트=88,537   일치
v53: raw=1,022,550 → zip=88,510   실제=88,510            일치
...
v58: raw=  842,835 → zip=65,557   실제=65,557            일치   (6건 전부)

재구성이 아니라 완전 복제다. 안전장치 3종을 걸고 진행했다 — 존재하는 버전만 대상으로, 매니페스트가 자기 식별자·버전을 스스로 선언하는지 검증하고, 만든 zip을 구 디코더로 재판정해 통과한 것만. 계획 파일을 만들어 업로드하고 재다운로드 검증까지 마쳤다. 새로 만드는 오브젝트라 CDN 무효화도 필요 없었다.

교훈

사고는 겹쳐서 온다. 버그 하나가 수동 개입을 부르고, 수동 개입이 잠복 결함을 깨운다. 수동 운영 개입은 "이 파이프라인의 산출물은 전부 우리 인코더가 만들었다"는 암묵적 가정을 깨뜨린다. 파서와 디코더는 외부 산출물이 들어온다고 가정하고 방어적으로 짜야 한다.

루프에는 상태 조건이 아니라 진행 조건을 걸어라. "아직 안 끝났다니 계속 돈다"는 무한 루프의 문법이다. "이번 반복에서 아무것도 진행되지 않았다"가 에러다. 그리고 포맷 필드는 전부 읽어라. 8바이트 중 4바이트만 읽으면 sentinel을 못 알아본다.

결정론적 압축은 공짜 백업이다. 입력과 설정이 남아 있으면 산출물을 바이트 단위로 재생할 수 있다. 복원 검증도 "크기가 비슷하다"가 아니라 바이트 일치로 해야 한다. 그래야 "복구했다"가 아니라 "원본이다"라고 말할 수 있다.

마치며

이전에 발견해서 수정 및 조치했던 부분이 사이드 이펙트로 발생했던 케이스였다. 발생하기 까다로운 케이스였고 로직이 잘못된 부분은 있었으나 정해진 파이프라인대로만 사용한다면 발생하지 않았기 때문에 로직에 대한 검증도 미흡했다.

하지만 논리적으로 이런 결과가 있었다면 반드시 선행되어야 하는 과정이 있고, 이를 잘 추론해내어 엣지 케이스 및 잘못된 로직에 대해 수정할 수 있었다. 추론의 결과를 확인하는 과정에서 AI 에이전트의 도움도 받았지만, 아직까지는 인간이 현재 상태와 이슈의 현황과 추론 방향 등을 잘 설정해줘야 더 빠르고 정확하게 찾을 수 있다고 생각한다.

이전까지 잘 사용하던 로직이라도 문제가 있을 수 있음을 다시 한 번 상기하는 경험이었다.

728x90