배포와 인프라10 분 읽기

데일리 퍼즐인데 기기마다 정답이 달랐습니다

한 모드만 클라이언트가 자기 카탈로그에서 정답을 뽑고 있었습니다. 카탈로그가 갱신되면 풀이 바뀌고, 기기별 pinning이 그 서로 다른 답을 붙들어 뒀습니다. 공유 가능한 데일리가 아니라 개인 퍼즐이었습니다.

#reality-check#verification#methodology#gotchas
개념 도식: 같은 날짜에 기기마다 다른 정답이 뽑히는 구조와 해석 순서 수정
글 내용을 요약한 개념 도식.

하루에 한 문제를 푸는 음악 맞히기 게임입니다. 모드가 몇 개 있고, 대부분은 서버가 만든 일정표를 읽어 그날의 정답을 정합니다.

그중 한 모드만 클라이언트가 자기 카탈로그에서 "최근 발매 40곡"을 뽑아 골랐습니다.

질문 하나 드리겠습니다. 당신의 "데일리" 콘텐츠는 어디서 결정됩니까? 그 결정이 기기 안에서 일어난다면, 그건 데일리가 아닙니다.

1. 그 풀은 카탈로그가 갱신될 때마다 바뀝니다

두 판본으로 실측했습니다.

하루 차이의 두 카탈로그 판본 → 풀 40곡 중 13곡이 교체
연속된 다섯 날짜의 정답이 전부 다름

공유 가능한 데일리가 아니라 기기별 개인 퍼즐이었습니다. 결과 그리드를 친구와 비교할 수 없고, 친구가 올린 퍼즐을 내가 풀 수도 없습니다. 데일리 게임의 존재 이유가 그건데 말입니다.

2. 좋은 의도의 캐시가 결함을 영속화했습니다

⚠️ 기기별 pinning이 문제를 고정시키고 있었습니다.

"하루 중에 카탈로그가 갱신돼도 답이 안 바뀌게" 하려고 넣은 장치였습니다. 의도는 옳습니다. 그런데 그게 바로 기기마다 다른 답을 붙들어 두는 장치였습니다.

pinning이 없었다면 적어도 같은 날 갱신 이후에는 모두가 같은 답을 봤을 수도 있습니다. 캐시가 불일치를 안정적으로 만들어 준 것입니다.

3. 포팅 결함이 아니라 원설계 결함이었습니다

iOS와 안드로이드가 같은 설계였습니다. 한쪽에서 실수로 다르게 짠 게 아닙니다.

그래서 한쪽만 고치면 더 나빠집니다. 두 플랫폼이 서로 다른 답을 내게 되니까요. 이 결함은 양쪽을 같은 커밋 묶음에서 고쳐야 하는 종류입니다.

4. 그리고 원격 파이프라인이 로컬보다 낡아 있었습니다

일정표를 만드는 스크립트를 열어 봤습니다. 로컬에는 있던 후보 자격 필터와 최소 곡 수 기준(20)이 원격 사본에는 아예 없었습니다. 고친 것이 푸시되지 않았던 것입니다. 결과가 라이브에 남아 있었습니다.

어떤 그룹: 곡 7개 · 데일리 18건  → 같은 곡이 평균 2.6회
다른 그룹: 곡 15개 · 11건 / 곡 8개 · 13건 (미래 날짜 5건 포함)

그룹 모드는 자동완성이 그 그룹으로 좁혀지므로 곡이 7개면 사실상 답을 알려 줍니다.

⚠️ 사본이 여러 개인 프로젝트에서는 "고쳤다"와 "돌고 있다"가 다릅니다. 크롤러가 하드코딩된 카피를 읽던 이야기배포된 페이지가 다른 앱이던 이야기가 같은 함정입니다.

판정은 원격 본을 API로 내려받아 로컬 HEAD와 diff 하는 것입니다. 그런데 여기에 함정이 하나 더 있습니다. git show HEAD:<경로>의 경로를 틀리면 양쪽이 빈 파일로 나와 "동일"로 오판합니다. 실제로 한 번 그렇게 읽었습니다. 빈 것과 빈 것을 비교하면 언제나 같습니다.

5. 당신이라면 해석 순서를 어떻게 정하시겠습니까

세 가지 소스가 있습니다. 서버 일정표, 기기에 고정된 값, 로컬 뽑기.

  • (a) 기기 고정값 → 서버 일정 → 로컬 뽑기
  • (b) 서버 일정 → 기기 고정값 → 로컬 뽑기
  • (c) 서버 일정만 쓰고 나머지는 없앤다

(a)는 지금 상태와 같습니다. 한 번 고정되면 서버가 뭘 하든 안 바뀝니다. (c)는 서버가 정하지 않은 날짜에 게임이 통째로 사라집니다.

답은 (b)이고, 여기에 규칙 셋이 붙습니다.

6. 해석 순서가 규칙이고, 두 플랫폼이 같아야 합니다

서버 일정 → 기기 고정값 → 로컬 뽑기
  • 서버 일정이 고정값을 이깁니다.
  • 서버가 카탈로그에 없는 곡을 가리키면(정정 중일 때) 다음 단계로 내려갑니다. 못 찾았다고 모드를 닫으면 그날 게임이 사라집니다.
  • 서버가 정하지 않은 날짜만 고정합니다. 그냥 고정해 두면 사라진 곡을 붙들게 됩니다.
  • 옛 카탈로그에는 그 키가 없습니다. 한쪽은 decodeIfPresent, 다른 쪽은 기본값 빈 맵.

판정 함수를 순수 함수로 떼어(resolve(...)) 두 플랫폼이 같은 테스트 표를 갖게 했습니다. 설계가 같아서 생긴 결함이니, 정본도 같은 표여야 합니다.

7. 같은 날 나온 자매 결함 하나

자동완성 후보의 정렬이 플랫폼마다 달랐습니다. 한쪽은 동점을 식별자로 갈랐는데 다른 쪽엔 그 처리가 없어 순서가 카탈로그 배열 순서에 좌우됐습니다.

목록이 8개로 잘리므로 순서가 곧 어떤 후보가 보이는지입니다. 카탈로그를 뒤집자 8개 중 4개가 다른 곡이 됐습니다. 식별자로 고정했습니다.

정렬은 "보기 좋게"의 문제로 취급되기 쉬운데, 목록이 잘리는 순간 정렬은 필터가 됩니다.

아직 되돌릴 수 없는 것

  • 이미 나간 날짜들의 기록은 되돌릴 수 없습니다. 지난 며칠간 사람마다 다른 답을 풀었다는 사실은 그대로 남습니다.
  • 몇 명이 공유 그리드를 비교하려다 실패했는지는 계측에 없습니다. 그래서 이 결함의 비용을 숫자로 못 씁니다.

자가진단 3가지

  1. "데일리"를 정하는 코드가 클라이언트에 있습니까? 있다면 그 답은 기기 상태의 함수입니다.
  2. 일관성을 위해 넣은 캐시가 무엇을 고정하고 있는지 확인하셨습니까? 캐시는 옳은 값도 틀린 값도 똑같이 잘 붙듭니다.
  3. 원격에서 도는 스크립트와 로컬 HEAD를 실제로 diff 해 보셨습니까? 두 파일이 모두 비어 있으면 "동일"로 보입니다. 파일 크기부터 찍으십시오.

솔직한 부분

가장 뼈아픈 건 pinning입니다. 그건 제가 일관성을 지키려고 넣은 코드입니다. 실제로는 불일치를 안정화하고 있었습니다.

의도가 좋은 코드는 의심을 덜 받습니다. "이건 답이 바뀌지 않게 하는 장치"라고 이름 붙여 두면, 그게 무엇을 바뀌지 않게 하는지 다시 묻지 않게 됩니다.

오늘 딱 하나만 해 보세요. 당신 앱의 "오늘의 콘텐츠"를 정하는 코드가 서버 응답만으로 결정되는지, 아니면 기기에 있는 무언가를 읽는지 한 번만 따라가 보십시오. 읽는 게 있으면 그 값이 기기마다 같은지 물어보시면 됩니다.

관련 글