← Ascendy

frontend

CI는 초록불인데 화면이 비었다 — 조용한 폴백이 만들어낸 오진 세 개

· Ascendy Engineering


TL;DR

이 글에 대하여. 프론트엔드 팀 인테이크를 정제한 회고다. 이미 해결·배포된 사건이다. 내부 엔드포인트 경로와 실제 응답 필드명, 내부 PR 번호와 커밋 참조는 일반화했고, 아래 JSON 예시는 모양만 남기고 키 이름을 중립적인 자리표시자로 바꿨다. 가장 가까운 사례는 정직하게 만들었더니 진짜 버그가 보였다이고, 같은 팀의 인접 사례로 같은 이름의 숫자가 두 정의를 가질 때관례를 절반만 지킨 대가가 있다.

증상 — 200인데 0건

소셜 탭에서 특정 친구를 열면 그 친구와 주고받은 사진이 떠야 하는데, “표시할 사진 없음”만 나왔다. 모바일도 웹도 같았다.

여기서 이미 이상하다. 서버는 200을 주고 있었고, 콘솔에는 에러가 없었으며, CI는 초록불이었다. 어디에도 “뭔가 잘못됐다”고 말해주는 신호가 없었다. 있는 건 하나 — 화면이 비어 있다는 사실뿐.

오진 셋 — 그리고 그중 둘은 진짜 버그였다

이 사건에서 가장 이야기할 만한 대목이 여기다.

오진 ① “데스크톱 렌더링 문제.” 가상 스크롤러가 중첩 flex 레이아웃 안에서 높이 0으로 렌더되는 버그를 발견했다. 진짜 버그였다. 고쳤다. 그런데 이건 데스크톱만의 문제였고, “모바일도 안 보인다”는 후속 제보로 원인이 아님이 드러났다.

오진 ② “백엔드에 데이터가 없다.” 모바일도 비었으니 데이터 쪽 아니냐고 넘겼다. 백엔드는 “코드상 정상, 실제 응답 캡처로 확정해달라”고 돌려줬다 — 맞는 판단이었다. 여기에 마침 배포 지연이 겹쳐서 구버전이 떠 있었고, 데이터가 잠깐 진짜로 비어 보였다. 재배포로 데이터는 돌아왔는데 화면은 여전히 비었다.

오진 ③ “프론트 상태 경쟁.” 로그를 보니 친구 사진을 표시 소스에 세팅한 직후 다른 목록 요청이 같은 store를 덮어써서, 사진이 잠깐 보였다 사라지는 경쟁이 있었다. 이것도 진짜 버그였다. 소유권 가드로 고쳤다. 그런데 운영자는 “깜빡이는 게 아니라 처음부터 계속 빈 상태”라고 했다. 또 아니었다.

여기까지 오는 동안 실재하는 버그를 두 개 고쳤는데, 사용자가 본 증상의 원인은 하나도 못 짚었다.

이게 조용한 실패의 진짜 비용이다. 흔히 “폴백이 버그를 숨긴다”고 말하는데, 실제로 벌어진 일은 그보다 나쁘다. 원인이 아무 신호도 내지 않으니, 진단은 신호를 내는 다른 것들로 몰려간다. 렌더러는 눈에 보이는 결함이 있었고, 스토어 경쟁은 로그에 흔적이 있었다. 둘 다 찾을 수 있었기 때문에 찾아졌다.

그리고 더 고약한 건, 그 둘이 진짜 버그였다는 사실 자체가 함정이었다는 점이다. 가짜 단서였다면 금방 버렸을 것이다. 진짜 버그를 고치고 나면 “이제 됐겠지”라고 믿게 되고, 안 고쳐지면 다음 그럴듯한 후보로 넘어간다. 조용한 실패는 자기를 숨길 뿐 아니라, 자기 대신 의심받을 후보들을 공급한다.

진짜 원인 — 배열인데 객체처럼 읽었다

돌파구는 운영자가 붙여준 실제 응답 body 한 덩어리였다. 모양은 이랬다(키 이름은 자리표시자).

{
  "sent":     [ /* 미디어 객체 배열 */ ],
  "received": [ /* 미디어 객체 배열 */ ],
  "sent_has_more":         true,
  "sent_next_offset":      30,
  "received_has_more":     true,
  "received_next_offset":  30
}

두 버킷이 직접 배열이고, 페이지네이션 메타는 최상위의 접미사 키로 붙어 있다.

그런데 코드는 이렇게 읽고 있었다.

const sent     = (data?.sent?.items ?? []).map(...)      // 배열엔 .items가 없다
const received = (data?.received?.items ?? []).map(...)

sent는 배열인데 코드는 sent.items를 읽는다. 배열에 그런 필드는 없으니 undefined고, ?? []가 그걸 조용히 빈 배열로 바꾼다.

예외도, 타입 에러도, 로그 한 줄도 없이 사진이 0장이 된다.

어떻게 들어왔나 — 스펙은 있었고 배포는 없었다

이력을 보면 명확하다. 며칠 전 한 커밋이 파싱을 바꿨다.

이 변경은 백엔드의 “breaking change” 스펙에 맞춘 것이었다. 페이지네이션을 위해 응답을 객체 형태로 바꾸겠다는 계획이 있었고, 프론트가 거기에 맞춰 갈아탔다.

문제는 그 스펙대로의 응답이 실제로 배포된 적이 없었다는 것이다. 프론트는 백엔드 PR의 스펙만 보고 하드 컷오버했고, 배포된 백엔드는 계속 배열을 주고 있었다. 전형적인 크로스레포 계약 desync다.

왜 CI도 리뷰도 못 잡았나

세 겹이었다.

① 조용한 폴백은 예외를 안 던진다. shape가 틀려도 빈 배열로 넘어간다. 타입체크·린트·테스트 전부 초록불, 화면만 빈다.

② 테스트가 코드의 가정을 그대로 복사한 목업을 썼다. 목업을 객체 형태로 만들어 두면 객체를 읽는 코드는 당연히 통과한다. 실제 응답과 대조하는 픽스처가 없었다. 이 상태의 테스트는 코드를 검증하는 게 아니라 자기 자신을 검증한다.

③ 단일 레포 정적 리뷰의 한계. 코드 리뷰는 — 사람이든 크로스-모델이든 — 코드가 내부적으로 일관되면 통과시킨다. 객체를 읽는 코드는 그 자체로 아무 모순이 없다. 이건 코드 결함이 아니라 런타임 크로스레포 계약 문제라, 실제 배포된 백엔드를 치지 않는 한 보이지 않는다. CI도 실 백엔드를 치지 않는다.

고친 방법 — 하드 컷오버 대신 관용 파싱

배포된 shape를 새 “정답”으로 삼아 다시 갈아타는 방법도 있었다. 그렇게 하지 않았다. 그건 같은 실수를 반대 방향으로 반복하는 것이기 때문이다.

대신 두 shape를 모두 받아들이게 했다.

// 알려진 두 모양만 인정한다.
const itemsOf = (bucket, field) => {
  if (Array.isArray(bucket))        return bucket        // 모양 ① 직접 배열
  if (Array.isArray(bucket?.items)) return bucket.items  // 모양 ② 객체 안의 목록

  // 여기가 핵심이다. 모르는 모양(null, 문자열, {}, {items: null} …)은
  // 조용히 빈 배열이 되지 않는다 — 반드시 신호를 남기고 넘어간다.
  // 단, 남기는 것은 값이 아니라 "모양"이다.
  reportUnknownShape({ field, shape: describeShape(bucket) })
  return []
}

// 응답 본문에는 사용자 식별 정보나 서명된 URL이 섞여 있을 수 있다.
// 그래서 진단에 필요한 구조만 남기고 값은 절대 싣지 않는다.
const describeShape = (v) =>
  Array.isArray(v)      ? `array(len=${v.length})` :
  v === null            ? "null" :
  typeof v === "object" ? `object(hasItems=${"items" in v}, itemsIsArray=${Array.isArray(v.items)})` :
                          typeof v

여기서 한 가지가 더 걸린다. 신호를 남긴다고 응답 본문을 통째로 로그에 실으면 안 된다. 이 응답에는 사용자 식별 정보와 서명된 스토리지 URL이 섞여 있었다 — 그대로 텔레메트리로 보내면 조용한 실패를 고치려다 정보를 흘리게 된다. 그래서 남기는 건 값이 아니라 모양이다: 필드 이름, 타입, items의 존재 여부와 그 타입, 길이 정도.

return []이 그대로 남아 있는 게 이상해 보일 수 있다. 의도한 것이다. 화면을 흰 화면으로 만들지 않으면서, 실패를 조용하게 두지 않는 것이 목표다. 여기서 결정적인 건 반환값이 아니라 바로 위 한 줄이다. 우리를 며칠 태운 건 결과가 빈 배열이었다는 사실이 아니라, 빈 배열이 되면서 아무 말도 하지 않았다는 사실이다.

그래서 구분해 둘 게 있다. 방어적 파싱과 관용적 파싱은 다르다. ?? []는 방어적이다 — 터지지 않게 막는다. 그런데 무엇을 막았는지 말하지 않는다. 관용적 파싱은 가능한 모양들을 명시적으로 열거하고, 그 목록 밖의 입력을 만나면 관측 가능한 신호로 표면화한다. 전환기에 필요한 건 후자다.

그리고 실제 응답 모양으로 behavioral 테스트를 잠갔다. 이제 누군가 다시 한쪽 shape로 하드 컷오버하면 그 테스트가 CI에서 빨간불이 된다. 백엔드에는 정식 응답 shape를 하나로 확정해달라고 요청했고, 확정될 때까지 프론트는 양쪽을 관용한다.

가져갈 것

며칠을 태운 건 어려운 버그여서가 아니었다. 틀렸다고 말해주는 문장이 어디에도 없었기 때문이다.


저작·인용: 이 글은 Ascendy Engineering이 작성했으며 출처 표기 시 재인용 가능합니다. 잘못된 정보를 발견하면 GitHub 이슈로 알려주세요.


Tags: debugging, api-contract, cross-repo, postmortem, defensive-parsing, testing, incident-prevention