배포와 인프라9 분 읽기

손으로 넣은 앱이 목록에서 사라졌는데, 개수는 그대로였습니다

앱 46개짜리 정본 목록에 빠진 앱 하나를 손으로 채워 넣었습니다. 이틀 뒤 그 파일을 재생성했더니 그 앱이 없어지고 대신 다른 앱이 들어와 있었습니다. 여전히 46개였습니다 — 개수를 세는 검사는 이런 교체를 영원히 통과시킵니다.

#shipping-infra#automation-pipeline#gotchas#reality-check#seo
왼쪽: 생성물에 손으로 넣은 항목이 다음 재생성 때 조용히 교체된다. 오른쪽: 교체 전후 개수가 46으로 같아서 개수 검사로는 못 잡는다
하나가 빠지고 하나가 들어왔습니다. 총계는 안 움직였습니다.

웹앱을 마흔 개 넘게 운영합니다. 서로 추천 카드로 이어져 있고, 그 카드들이 읽는 정본 목록 파일이 하나 있습니다. 허브 사이트, 포털, 각 앱의 추천 위젯이 전부 이 파일 하나를 봅니다.

이틀 전에 그 목록에서 앱 하나가 빠져 있는 걸 발견했습니다. 라이브인데 목록엔 없었습니다. 그래서 항목을 손으로 써 넣고 커밋했습니다.

chore: 앱 목록 정본을 추적 대상으로 + birthday 편입 (45앱 -> 46앱)

오늘 새 앱을 하나 더 배선하려고 그 파일을 재생성했습니다. 그리고 결과를 예전 파일과 비교했습니다.

$ diff <(keys old) <(keys new)
8d7
< birthday
25a25
> gyeonjeok

하나가 빠지고 하나가 들어왔습니다. 46개 그대로입니다.

이 파일은 제가 고칠 수 있는 파일이 아니었습니다

README를 열어보니 첫 줄에 적혀 있었습니다.

재생성: python3 ootssu-apps.manifest.build.py (허브 카드가 정본 데이터 소스이므로, 허브 갱신 후 재실행하면 동기화)

이 파일은 소스가 아니라 산출물입니다. 진짜 소스는 허브 사이트의 카드 HTML이고, 스크립트가 그걸 긁어서 이 JSON을 만듭니다. 제가 이틀 전에 손댄 건 산출물 쪽이었습니다.

그러면 그 수정은 처음부터 다음 재생성까지만 사는 수정이었던 겁니다. 커밋 메시지에는 "편입"이라고 적어뒀지만 실제로 편입된 건 아무것도 없었습니다. 버전 관리가 돼 있었고, 리뷰를 통과했고, 이틀 뒤에 사라졌습니다.

질문 하나 드리겠습니다. 당신이 방금 고친 그 파일, 누가 그 파일을 만듭니까?

무서운 건 사라진 게 아니라 안 들킨 겁니다

빠졌다는 걸 알아챈 건 우연입니다. 오늘 다른 작업을 하다가 재생성 전후를 비교해봤을 뿐입니다.

만약 비교를 안 했다면? 허브·포털·앱 마흔 개의 추천 카드가 그 앱을 다시는 안 보여줍니다. 앱은 멀쩡히 살아 있고, 사이트도 200을 주고, 어떤 봇도 실패하지 않습니다. 그냥 어느 목록에도 안 들어가는 앱이 되는 겁니다. 이건 감시 봇이 앱 44개 중 21개만 보고 있던 일과 같은 종류의 조용함입니다.

그리고 이 경우엔 개수 검사도 소용이 없습니다.

before   46 apps
after    46 apps

-1 +1은 총계를 안 건드립니다. "앱이 줄었으면 알려줘" 같은 가드는 교체를 영원히 통과시킵니다. 크기를 비교하면 안 되고 집합을 비교해야 합니다.

고친 방법: 산출물이 아니라 소스에 넣기

당신이라면 어느 쪽을 고르시겠습니까?

  1. 생성 스크립트에 "손으로 넣은 항목 보존" 기능을 붙인다. 예외 목록을 하나 만들어 재생성 때 합쳐준다.
  2. 허브 카드(진짜 소스)에 그 앱을 추가하고, 산출물은 그냥 다시 만든다.

1번이 유혹적입니다. 코드 열 줄이면 되고, 이미 손으로 넣은 항목도 살아납니다. 그런데 그 순간 이 파일은 두 개의 소스를 갖게 됩니다. 허브 카드와 예외 목록. 다음 사람은 어느 쪽을 고쳐야 하는지 모릅니다. 저는 버전 하나가 세 군데에 살고 있던 일을 이미 한 번 치렀습니다.

2번으로 갔습니다. 허브 ko/en/ja 홈과 해당 카테고리 페이지에 카드를 한 장씩 넣고, 재생성했습니다. 47개가 나왔고 둘 다 들어 있습니다.

소스를 열었더니 셋이 더 나왔습니다

정본 쪽으로 들어가니 그동안 산출물만 보고는 못 볼 것들이 보였습니다.

하나. 앱 하나가 en·ja·zh 세 로케일 모두에서 카드는 있는데 구조화 데이터(ItemList)에는 빠져 있었습니다. 카드 34개, ListItem 33개. 사람 눈엔 보이고 검색엔진 쪽 목록에선 없는 상태로 얼마나 있었는지는 모릅니다.

둘. 생성 스크립트에 앱별 지원 로케일 표가 있는데, 표에 없는 앱은 조용히 ["ko"]로 떨어지고 있었습니다. 경고는 찍혔습니다.

로케일 미지정(ko 기본 처리): ['birthday', 'gyeonjeok', 'bungeo', 'hwaksin', 'plotta']

다섯 개 중 둘은 실제로 여러 로케일을 지원합니다. 전부 눌러서 확인했습니다 — HTTP 코드와 <html lang>으로요. 기본값은 때때로 맞기 때문에 위험합니다. 맞는 날엔 경고가 거짓말처럼 보이고, 틀린 날엔 아무도 안 봅니다.

셋. 산출물에 generated 필드가 있는데 값이 문자열로 박혀 있었습니다.

"generated": "2026-08-21",

재생성을 몇 번을 해도 이 날짜는 안 움직입니다. "이 파일이 언제 만들어졌나"를 답하라고 있는 필드가 3주째 같은 답을 하고 있었습니다. 그리고 이틀 전 제 손편집 커밋은 이 값을 손으로 고쳐뒀습니다 — 그래서 사본들의 날짜가 서로 안 맞는데도 아무도 이상하게 안 봤습니다.

자가진단 3개

  • 방금 고친 파일 맨 위에 "generated by" 주석이 있습니까? 없다면, 정말 없는 게 맞습니까?
  • 그 목록을 지키는 가드가 개수를 봅니까, 집합을 봅니까?
  • 파이프라인이 뱉는 경고 중에 "기본값으로 처리했다"는 줄이 있습니까? 그 기본값이 맞는지 마지막으로 확인한 게 언제입니까?

결론

이틀 전 커밋은 잘못된 코드가 아니었습니다. 잘못된 파일이었습니다. 파생물에 손을 대면 그 수정은 다음 생성까지만 삽니다. 그리고 그 죽음은 조용합니다 — 빌드도 통과하고, 테스트도 통과하고, 개수도 그대로입니다.

정말로 배운 건 이겁니다. 목록에서 무언가 사라지는 사고를 막고 싶으면, 총계를 지키지 말고 이름을 지켜야 합니다.

지금 딱 하나만 해보세요. 생성물인 걸 알면서 쓰고 있는 파일 하나를 골라, 생성 스크립트를 한 번 돌려보고 결과를 현재 파일과 diff 떠보시는 겁니다. 아무것도 안 나오면 3초 걸린 겁니다.

관련 글