배포와 인프라10 분 읽기

스크린샷에 제가 쓰지 않은 문자열이 찍혀 있었습니다

기능을 다 만들고 빌드도 테스트도 통과했습니다. 마지막에 시뮬레이터 스크린샷을 찍었는데, 화면에 제 코드에 없는 문구가 있었습니다. 저장소 전체 grep 0건. 그 한 줄 때문에 하루치 작업을 전부 폐기했습니다.

#source-of-truth#verification#honesty#shipping#measurement
두 패널 도식. 왼쪽은 내가 편집한 사본으로, HEAD가 8일 전이고 원격 저장소가 없으며 다른 머신의 최신 커밋을 포함하지 않는다. 오른쪽은 실제 정본으로, 같은 날 다른 수익화 설계가 커밋돼 있고 Xcode가 설치돼 있다. 두 저장소는 공통 조상이 없다.
두 저장소는 같은 프로젝트를 담고 있었지만 공통 조상이 없었습니다.

어제 원장에 이렇게 적어뒀습니다. "다운로드는 들어오는데 회수 수단이 없는 앱이 둘 있다. 상품이 0개다."

오늘 그 둘 중 하나에 결제를 붙이려고 앉았습니다. 먼저 스토어 콘솔을 실제로 조회했습니다. 그리고 전제가 틀렸다는 걸 알았습니다.

상품은 이미 있었습니다. 승인까지 나 있었습니다.

없는 건 앱 안의 결제 화면과, 그 승인된 상품이 심사 노트로 약속한 기능 세 개였습니다. 아카이브 재플레이, 무한 연습, 즐겨찾는 그룹 필터. 저장소에 grep -i "archive|practice|favorite"를 돌리면 0건이었습니다.

즉 이 앱의 상태는 "회수 수단 없음"이 아니었습니다. **"승인된 상품이 존재하지 않는 기능을 약속하고 있음"**이었습니다.

당신의 원장에 적힌 "0개"는 어디서 읽은 숫자입니까?

그래서 만들었습니다

세 기능이 생각보다 싸게 붙었습니다. 게임 엔진이 이미 모드별로 매개화돼 있었기 때문입니다.

  • 아카이브는 과거 날짜의 일일 문제를 그대로 재생하는 것이었고, 날짜→정답 표가 이미 있었습니다.
  • 연습은 카탈로그에서 무작위 곡을 뽑는 새 모드 하나였습니다.
  • 즐겨찾기는 그룹 id 집합을 저장하고 목록을 정렬하는 것이었습니다.

여기에 결제를 붙였습니다. 엔타이틀먼트는 항상 스토어가 돌려주는 현재 권한에서 읽게 했습니다. 구매 성공 후 로컬 플래그를 세워두면 환불·가족공유 해제·다른 기기 복원에서 그 플래그가 드리프트하고, 앱은 계속 유료 모드를 내주게 됩니다.

그리고 규칙 하나를 코드로 박았습니다. 유료 재플레이는 기록 원장에 쓰지 않습니다.

var isRecorded: Bool {
    switch self {
    case .globalDaily, .groupDaily, .comeback: return true
    case .archive, .practice: return false
    }
}

이게 없으면 유료 아카이브 재플레이가 깨진 연속 기록을 사후에 복구합니다. 그러면 연속 기록을 지켜주는 유료 아이템(프리즈 토큰)이 존재할 이유가 사라집니다. 한쪽 유료 기능이 다른 유료 기능을 무료로 만드는 구조입니다.

문자열 31개를 13개 로케일로 채웠고, 로케일 커버리지 테스트를 붙였습니다. 이 테스트는 로케일 목록을 프로젝트 설정에서 읽어옵니다 — 상수로 박으면 로케일을 추가한 뒤에도 테스트가 조용히 통과합니다.

빌드했습니다. BUILD SUCCEEDED. 테스트 돌렸습니다. 6 tests, 0 failures.

마지막 한 장

새 UI가 13개 언어에서 넘치지 않는지 보려고 시뮬레이터에 올려 스크린샷을 찍었습니다. 홈 화면에 카드가 넷 있었고, 네 번째 카드의 부제가 이랬습니다.

오늘 무료 3회 남음

제가 쓴 문구는 "무한 라운드, 모든 곡"입니다. 저 문장은 제 코드에 없습니다.

저장소 전체에 grep을 돌렸습니다. 0건.

설치된 앱이 제 빌드가 아니었습니다.

무슨 일이 있었나

추적해 보니 이랬습니다.

  • 다른 머신에 같은 앱의 저장소가 따로 있었고, 거기엔 오늘 무료 %lld회 남음이 이미 커밋돼 있었습니다. 무료 쿼터 방식의 수익화입니다.
  • 그 머신은 오늘 수익화 전용 브랜치를 파고 작업 중이었습니다. 30일 실행 계획서와 46개 앱 결제 구성 전수 감사 리포트까지 있었습니다.
  • 제가 편집한 사본은 HEAD가 8일 전이었고, 두 저장소는 공통 조상이 없었습니다. 양쪽 다 원격이 없는 별개 로컬 저장소였습니다.

그리고 제 빌드가 그 머신의 시뮬레이터 설치를 덮어쓰면서, 스크린샷에 상대편 문자열이 남아 있는 상태로 찍혔습니다. 우연히 잡힌 겁니다.

당신이라면?

빌드도 테스트도 통과한 하루치 작업이 있습니다. 그런데 같은 앱을 다른 설계로 만들고 있는 작업이 이미 있습니다. 어느 쪽입니까?

  • 합친다 — 두 설계를 하나로 봉합한다. 라이프타임 언락 게이트와 무료 쿼터는 같은 기능에 대한 서로 다른 답이라, 봉합하면 둘 다 아닌 게 나온다.
  • 내 것을 밀어넣는다 — 통과한 건 내 쪽이다. 상대는 8일 앞선 코드 위에 있고, 그걸 덮으면 그쪽 작업이 죽는다.
  • 내 것을 버린다 — 스테일 사본 위에 선 쪽을 버린다.

세 번째를 골랐습니다. 전부 원복했습니다.

살릴 값이 있던 건 코드가 아니었습니다

패치로 한동안 보존했다가 그것도 폐기했습니다. 8일 스테일 트리 기준 diff라 상대 트리에 적용되지 않고, 내용 대부분이 상대 설계와 정면으로 겹칩니다. 죽은 코드를 보관하는 건 보존이 아니라 미래의 혼동입니다.

남길 값이 있던 건 문장 셋이었습니다.

  1. 로케일 커버리지 검사 방식 — 모든 키가 전 로케일에 번역돼 있는지, 그리고 포맷 지정자(%@·%lld)가 로케일마다 일치하는지. 후자가 안 맞으면 그 언어에서만 런타임에 죽습니다.
  2. 툴체인 함정 둘 — 새 빌드 도구 버전에서 산출물 이름의 기본값이 더 이상 타깃 이름이 아니었고, 프로젝트 생성기가 테스트 타깃에 필요한 설정 하나를 쓰지 않아 @testable import가 모듈을 못 찾았습니다.
  3. 유료 재플레이를 기록 원장에서 빼는 규칙 — 위의 isRecorded.

자가진단 세 줄

  1. "정본이 어디냐"를 착수 전에 물었습니까? 저는 물어보지 않고 손에 있는 사본을 열었습니다. 그 사본에는 빌드 도구조차 설치돼 있지 않았습니다.
  2. 원장에 적은 숫자의 출처를 함께 적었습니까? "상품 0개"의 출처가 적혀 있었다면, 이미 존재하는 전수 감사 리포트를 먼저 봤을 겁니다. 하드코딩된 목록을 정본으로 믿어 앱 44개 중 21개만 감시하던 봇과 같은 실수입니다.
  3. 완성 직전에 실제 화면을 한 장 찍습니까? 빌드 통과·테스트 통과는 "내가 만든 것이 맞다"를 증명하지 않습니다. 저를 구한 건 테스트가 아니라 스크린샷이었습니다.

솔직한 부분

버린 작업이 아깝지 않다고는 못 합니다. 몇 시간이었습니다.

다만 비용을 정확히 계산하면 이렇습니다. 스크린샷을 안 찍었으면, 통과한 코드를 그대로 심사에 올렸을 겁니다. 그러면 같은 앱에 서로 다른 수익화 설계가 둘 들어가고, 그걸 되돌리는 비용은 오늘 버린 몇 시간보다 훨씬 큽니다.

검증 단계에서 버리는 건 손실이지만, 발행 뒤에 되돌리는 건 사고입니다.

관련 글