본문에서 지운 이름이 URL에 그대로 남아 있었다
블로그와 내부 문서에서 식별자 두 개를 가리는 작업을 했습니다. 문장 속 이름만 고치면 되는 일인 줄 알았는데, 하나는 글 파일명, 곧 URL 슬러그 안에 들어 있었습니다. 이름을 바꾸면 URL과 OG 이미지, 문서 속 목록까지 같이 바뀌어야 했습니다.
작은 제품들을 계속 살리며 겪은 배포, 캐시, 색인, 로컬 자동화 기록.
블로그와 내부 문서에서 식별자 두 개를 가리는 작업을 했습니다. 문장 속 이름만 고치면 되는 일인 줄 알았는데, 하나는 글 파일명, 곧 URL 슬러그 안에 들어 있었습니다. 이름을 바꾸면 URL과 OG 이미지, 문서 속 목록까지 같이 바뀌어야 했습니다.
진동은 화면에 안 보이고 에뮬레이터에서는 느낄 수도 없습니다. 시스템 로그로 판정하는 방법을 세우다, 우리 호출이 기기 설정 하나에 통째로 꺼질 수 있다는 걸 알았습니다.
로그인 실패 수백 건의 사유가 '취소'였습니다. 라벨을 믿지 않고 경과 시간을 같이 재보니, 같은 이름 뒤에 완전히 다른 두 현실이 있었습니다.
워치 앱이 있는 앱 8개의 폰↔워치 연동을 대조했습니다. 7개는 실배선이었고, 1개는 화면만 있고 받는 쪽이 없는 껍데기였습니다.
판정 하나가 오염되는 걸 막으려고 새로 늘린 앱을 대조군에서 뺐습니다. 맞는 조치였습니다. 그런데 그 앱은 다른 어느 판정에도 안 들어가 있었습니다 — 제외는 오염을 막는 동시에 감시 구멍을 하나 팝니다. 제외할 때마다 자기 행을 만들어야 했습니다.
진동이 전혀 안 온다는 신고를 받고 세 곳을 고쳤습니다. 사용자는 '이제 느껴진다'고 했지만, 실제로는 신호 사다리의 두 칸만 고쳐져 있었습니다.
앱을 켠 468대 중 164대만 걷기를 시작했습니다. 최대 이탈 구간은 로그인이었고, 취소 이벤트가 설치당 여섯 번씩 찍혀 있었습니다.
앱 46개짜리 정본 목록에 빠진 앱 하나를 손으로 채워 넣었습니다. 이틀 뒤 그 파일을 재생성했더니 그 앱이 없어지고 대신 다른 앱이 들어와 있었습니다. 여전히 46개였습니다 — 개수를 세는 검사는 이런 교체를 영원히 통과시킵니다.
UA를 바꿔 찔러보는 차단 테스트는 모든 조건에서 403을 돌려줍니다. 엣지가 UA가 아니라 IP로 검증하기 때문입니다. 판정은 원본 access log에서 해야 합니다.
감시판은 잡마다 '기대 주기'를 갖고 산출물 신선도로 판정합니다. 그 주기가 실제 스케줄과 다르면, 안 도는 날마다 지연 경보가 켜집니다.
각 함수는 정상이었고 테스트도 통과했습니다. 결함은 어느 함수에 어느 라벨이 붙었는지였습니다. 영어로는 Stop과 End로 겨우 갈렸고, 충돌은 번역에서 태어났습니다.
한 모드만 클라이언트가 자기 카탈로그에서 정답을 뽑고 있었습니다. 카탈로그가 갱신되면 풀이 바뀌고, 기기별 pinning이 그 서로 다른 답을 붙들어 뒀습니다. 공유 가능한 데일리가 아니라 개인 퍼즐이었습니다.
Next.js에서 dynamicParams는 하위 세그먼트로 상속된다. 부모가 false면 자식이 true라고 선언해도 진다. 매일 발행하던 날짜 URL이 사흘 뒤 죽고 있었다.
저장소 40개의 버전 거짓말을 고치러 갔습니다. 고치는 코드는 40줄인데, 함정은 두 개였습니다: 빌드가 읽는 파일과 재생성이 읽는 파일이 다르고, '스토어가 진실'이라는 가정도 심사 중인 앱에서는 틀립니다.
심사에서 같은 사유로 두 번 거부됐습니다. 첨부된 스크린샷에는 통화 화면이 정상으로 떠 있었습니다. 타이머도 돌고 종료 버튼도 살아 있는데, 서버에서 오는 이벤트가 0건이었습니다. 에러는 하나도 없었습니다.
널 허용 컬럼 하나가 신규 사용자의 프로필 로드를 통째로 터뜨렸습니다. 기존 사용자는 멀쩡했으니 신고가 한 건도 없었습니다. 그리고 그 오류를 표시할 자리가 스플래시 화면에는 없었습니다.
기능을 다 만들고 빌드도 테스트도 통과했습니다. 마지막에 시뮬레이터 스크린샷을 찍었는데, 화면에 제 코드에 없는 문구가 있었습니다. 저장소 전체 grep 0건. 그 한 줄 때문에 하루치 작업을 전부 폐기했습니다.
프로덕션 앱의 결제 화면은 '연결을 확인하라'고 했고, 다른 앱의 로그인 화면은 '문제가 발생했다'고 했습니다. 폰은 정상이었습니다. 진짜 원인은 빈 채로 컴파일된 시크릿 하나와, 콘솔에 등록되지 않은 서명 지문 하나였습니다.
앱스토어 리스팅에서 심사 없이 즉시 고칠 수 있는 필드는 promotionalText 하나뿐입니다. 그 필드는 새 버전을 낼 때마다 스스로 비워집니다. 한 앱에서 손으로 채운 지 사흘 만에 다시 비었고, 계정 전체를 세어보니 앱 40개 648칸이 빈 값이었습니다.
음성 대화 앱 세 개에서 '가끔 끼어들고 반복한다'는 신고가 왔습니다. 에코 제거는 켜져 있었지만 완벽하지 않았고, 남은 잔여가 서버의 발화 감지 임계를 넘어 앱이 자기 목소리에 끊기고 있었습니다. 실기기 없이 적응형 임계로 맞춘 과정입니다.
한 번도 출시된 적 없는 앱을 프로덕션에 올리려는데 미리보기에서 저장 버튼이 죽어 있었습니다. 같은 날 다른 앱에서 본 선언 폼은 2단계였는데 이 앱은 3단계였고, 차이는 매니페스트의 권한 두 줄이었습니다.
같은 날 앱 두 개가 가격 확인 요청으로 거부됐습니다. 원장엔 '이 유형은 회신만 하면 된다'고 적혀 있었는데, 회신 후 두 시간 동안 상태는 그대로였습니다. 실제 경로는 회신 다음의 수동 재제출이었습니다.
주간 보안 메일은 무해한 항목 하나만 알려줬습니다. API로 전량 214건을 받아보니, 진짜 구멍은 한 단계 낮은 등급 무더기 속에 있었습니다 — 시크릿을 평문으로 돌려주는 내부 함수가 익명 역할에 열려 있었고, 마이그레이션 파일엔 회수 구문이 정확히 적혀 있었는데도요.
릴리스 도구가 xcodebuild에 버전을 커맨드라인으로 넘기고, 저장소에는 되쓰지 않습니다. 스토어엔 1.9.4가 올라가 있는데 project 파일은 1.0인 채로 40번 릴리스됐습니다. 한 번도 안 터진 이유와, 딱 한 번 터진 자리.
앱 두 개가 콘솔 목록에서 며칠째 'Not yet sent for review'였습니다. 같은 날 같은 스크립트로 올린 다른 일곱 개는 전부 'In review'로 바뀌었는데요. 그래서 릴리스를 다시 만들어 재제출하려고 계획을 적어 뒀습니다. 두 화면이 서로 다른 말을 하고 있었고, 제가 믿은 쪽이 틀린 쪽이었습니다.
repo 전체 미러 업로드가 공용 스타일시트를 넉 달 전 파일로 덮었습니다. 파일은 있었고, 크기만 2.5KB였습니다. 잃어버린 원본은 Chrome 디스크 캐시에서 복구했습니다.
브라우저에서는 새 문구가 보이는데 검색 결과와 미리보기에는 옛날 문구가 떴습니다. 원인은 하드코딩된 폴백 한 줄과 그와 갈라진 i18n 데이터였습니다.
같은 로직을 Swift에서 Kotlin으로 그대로 포팅했는데 Android에서만, 그것도 일부 계정만 로그인이 크래시했습니다. 원인은 코드가 아니라 두 언어가 '없는 필드'를 다루는 방식이었습니다.
주간 보안 메일엔 이슈 1건만 왔습니다. API로 전체를 받아보니 227건이었고, 진짜 구멍은 메일에 없던 그 안에 있었습니다 — 관리자 삭제 함수에 상수로 박힌 해시 하나면 누구 글이든 지울 수 있었습니다.
한 앱의 privacy.html에서 헤더가 6번 나오는데 </html>은 1개였습니다. 확인해 보니 41개 앱, 123개 파일 전부 같은 증상이었습니다. "라이브 받아서 고쳐 되올리기"를 반복한 배포 절차가 매 사이클마다 한 겹씩 쌓은 것이었습니다.
보안 마이그레이션 끝에 넣은 기능 시험이 진짜 유저 행을 건드렸습니다. 되돌리는 코드도 같이 넣었는데, 그 함수가 실제로 바꾸는 필드 세 개 중 하나를 빼먹었습니다.
앱 스토어 개인정보 라벨에서 건강·이메일·User ID가 전부 "연결되지 않음"으로 신고돼 있었습니다. 스키마에는 모든 로그 테이블에 계정 외래키가 걸려 있습니다. 결정적 증거는 Apple 자신의 마법사 문구였습니다.
마켓플레이스 API가 total 4를 주면서 items는 빈 배열을 줬습니다. 저는 그걸 '아직 게시가 안 됐다'로 읽고 로고를 만들고 있었습니다. 실제로는 이미 색인돼 있었고, 기본 응답에서 조용히 걸러지고 있었을 뿐입니다.
마켓플레이스에 도구 하나 올리는 데 플랫폼이 네 번 거절했습니다. 전부 명확한 에러 메시지를 줬으니 싼 문제였습니다. 정작 위험했던 다섯 번째는 아무것도 실패시키지 않았습니다 — 무료 공개 도구에 내 API 키를 얹었다는 사실 자체였습니다.
8초짜리 영상 하나를 만드는 데 25.8초가 걸렸습니다. 해상도를 1080×1920에서 720×1280으로 낮췄더니 25.4초가 나왔습니다. 그 0.4초가 병목이 어디 있는지 알려줬습니다.
안드로이드에서 공유 시트를 띄운 직후 임시 파일을 정리했습니다. 깔끔한 코드처럼 보였고, 프로덕션에 나갔고, 영상 공유는 전부 조용히 실패하고 있었습니다.
스토어에 뜨는 개발자 이름을 사업자명으로 바꾸려고 했습니다. 바꾸려던 필드는 처음부터 열려 있었고, 잠겨 있던 건 옆에 있던 다른 버튼이었습니다. 잠금 조건은 화면 본문 어디에도 없고 물음표 아이콘 툴팁 한 줄에만 적혀 있었습니다. 조건은 몇 년째 갖고 있던 도메인이었습니다.
영상은 실제로 만들어졌습니다. 두 플랫폼에서 mp4 바이트까지 확인했고 웹은 이미 프로덕션에 올렸습니다. 그런데 iOS에서 자랑하기를 누르면 아무 일도 일어나지 않았습니다.
미지원 로케일 경로에서 404가 아니라 500이 났습니다. 제가 열어보는 경로는 전부 200이라 1년 가까이 몰랐고, 한 앱은 하필 /ko/와 /en/이 깨진 쪽이었습니다. 43개 호스트를 전수 스윕해서 정확히 몇 개인지 셌습니다.
무료 한도 75% 경고가 왔습니다. 엣지 헤더를 훑어 범인을 지목했는데, 대시보드를 보니 제가 지목한 앱은 목록에 없었습니다. 진짜 원인은 루트 레이아웃의 headers() 한 번이었고, 그것 때문에 239개 페이지가 요청마다 미국에서 렌더되고 있었습니다.
4.3(b) 포화 카테고리로 리젝됐습니다. 앱 첫 화면은 이미 작명이었지만 스토어 키워드는 여전히 astrology·fortune teller였고 5개 로케일 설명 첫 문장은 '운세 허브'였습니다. 고친 건 코드가 아니라 텍스트였습니다.
구독자가 앱이 비어 있다고 신고했습니다. 결제·영수증·권한은 전부 정상이었습니다. 원인은 가입 트리거가 만든 '완성처럼 보이는 빈 행'과 앱의 `!= nil` 한 줄이었습니다. 실측 결과 가입자 651명 중 646명이 온보딩을 건너뛴 상태였습니다.
테스트 통과, 빌드 통과, 커스텀 도메인 TLS 200, 계측 이벤트 적재까지 전부 확인했습니다. 다음 날 외부 방문자를 세어보니 0명이었습니다. grep 두 번으로 원인이 나왔습니다. 게임 품질이 아니었습니다.
공공 관광 데이터 공모전에 앱을 냈습니다. 제안서에 쓴 시간대별 혼잡도 데이터는 개방 API에 없었고, 앱에는 이미 그 차트가 Math.random()으로 그려지고 있었습니다. 제안서에서 구현, 심사, 제출까지 4개월 반 동안 나온 함정 전부.
주간 수집 봇이 매주 성공했습니다. 제품 60개가 늘었고 페이지 692개가 빌드됐습니다. 그런데 라이브 사이트는 07-27에 멈춰 있었습니다.
제출 5분 뒤, 아직 공개되지 않은 버전에서 두 대의 기기가 앱을 열었습니다. 심사 기기는 신규 유저와 구별되지 않고, 하필 측정하려던 지표를 아래로만 끌어내립니다. 걸러내는 대신 드러내기로 했습니다.
리뷰 DB가 7주 멈춰 있었습니다. 수집기 고장이라고 보고했다가 틀렸고, 애플이 27건을 제거했다고 보고했다가 또 틀렸습니다. 브라우저를 한 번 열어보니 리뷰는 전부 그대로 있었습니다. API가 자기 데이터를 안 돌려주고 있었을 뿐입니다.
첫 세션 퍼널 계측을 붙이고 184개 테스트를 통과시켰습니다. 확인 삼아 서버 테이블을 조회하니 배포도 안 한 계측이 프로덕션에 6행을 써놓았습니다. 테스트를 통과시키는 행위 자체가 측정 대상을 바꾸고 있었습니다.
서브도메인 일곱 개를 한 경로로 통일하는 30분짜리 정리 작업이었습니다. 그 과정에서 백업으로 살려 낸 사이트가 알고 보니 이미 다른 경로에 정본으로 살아 있었고, 제가 올린 건 25배 작은 구버전이었습니다.
사이트 7개를 한 경로로 합치고 서브도메인은 301만 남겼습니다. 배포도 검증도 깨끗했는데, 며칠 뒤 Search Console이 '소유권 확인 실패'를 띄웠습니다. 확인 메타태그가 제가 리다이렉트해 버린 그 페이지에 살고 있었습니다.
한국어로 설정된 휴대폰에서 dev.ootssu.com의 첫 화면이 영어로 열렸습니다. 글 번역 문제가 아니라 라우팅, 문서 lang, 모바일 전환 버튼이 동시에 만든 i18n 운영 버그였습니다. Accept-Language를 제대로 파싱하고, /ko 문서는 lang=ko로 렌더하고, 모바일에서도 언어 전환 버튼을 보이게 고쳤습니다.
AppleScript로 브라우저 세션을 조종하던 주문 모니터를 공식 커머스API로 옮겼습니다. bcrypt 서명 인증, launchd 상시 운영, 그리고 환불·반품을 끝까지 자동 승인하지 않은 이유.
구글 서치콘솔이 'Not found (404)' 메일을 수십 통 쏟아냈습니다. 사이트를 고치려다, 대시보드 대신 서버 로그를 봤습니다. 사이트맵 149개 전부 멀쩡, 내부 링크도 멀쩡, 검색봇이 실제로 404를 낸 URL은 전 로그 통틀어 딱 하나였습니다. 나머지는 전부 외부 유령이었습니다.
Android 릴리스를 API로 자동화하고 'completed 100%'면 배포 완료로 기록했습니다. 어느 날 리젝 메일이 왔는데 트랙 상태는 여전히 completed였습니다. API는 '트랙에 배정됐다'를 말할 뿐 '사람들이 받고 있다'를 말하지 않습니다. 데이터 보안 양식은 아예 API로 손댈 수도 없습니다.
TestFlight에서만 안 뜨는 배너를 4시간 쫓았습니다. Debug/Release도, 서명도, 실기기도 범인이 아니었습니다. 범인은 제가 원인을 보려고 넣은 진단 코드 그 자체였습니다. 관측 장치가 관측 대상을 바꾸고 있었습니다.
채널 토큰이 하루에 하나씩 순차로 죽었습니다. 재발급 지옥(PKCE 불일치, 똑같은 이름의 브랜드 계정 여섯 개)을 지나 1원칙으로 근본을 찾으니, '재발급하면 됨'은 값싼 수정처럼 보이는 함정이었습니다. 진짜 원인은 OAuth 동의 화면의 게시 상태였습니다.
지역 비교 카드가 통째로 사라졌습니다. 에러도 없고, RPC는 200이고, 데이터도 들어 있는데 화면만 비었습니다. 표시에 쓰지도 않는 date 필드 하나의 디코딩 실패가 decode 전체를 throw시켰고, catch가 빈 배열로 떨어졌습니다. 어느 단계에서도 빨간불이 안 켜집니다.
영수증도 정상, 서버도 정상, 에러도 없는데 앱만 계속 free였습니다. 범인은 이름이었습니다. RevenueCat이 앱 이름 기반으로 entitlement를 자동 생성하는데, 코드는 'pro' 하나만 보고 있었습니다. 그리고 이 버그는 예외도 로그도 없이 조용히 실패합니다.
Android release 빌드가 세 버전에 걸쳐 같은 NPE로 죽었습니다. keep rule을 계속 넓혔고, 한 번은 잠잠해졌다가 다시 회귀했습니다. keep rule이 안 먹은 게 아니라, keep이 걸린 위치와 R8이 자르는 위치가 달랐습니다. 정답은 keep이 아니라 패턴을 바꾸는 것이었습니다.
심사 리젝 후 상품 현지화를 고치려는데 409 'version is not editable'가 났습니다. 그래서 버전을 편집 가능하게 만들었더니 같은 409. 에러가 말하는 'version'은 appStoreVersion이 아니라, 아직 열려 있는 reviewSubmission 껍데기였습니다. App Store Connect API 함정 시리즈 1편.
지도를 축소하면 다른 유저의 영토가 안 보이고 버벅였습니다. 로딩이 느린 것처럼 보였는데, 기다려도 안 채워졌습니다. 조회 범위가 고정 bbox였습니다 — 화면은 줌마다 4배가 되는데 요청 범위는 그대로라, 어느 줌 아래로는 화면 가장자리가 서버에 요청조차 되지 않았습니다.
일본어 쇼츠 영상 속에 404 화면이 박제돼 있었다. 파고들었더니 33개 앱 중 23개가 /ja에서 깨져 있었다. 자동화가 스스로를 검증하지 않으면 조용히 거짓말을 한다는 이야기.
배포한 지 한참인데 맨 URL이 stale HTML을 서빙했다. 원인은 HTML 엣지 캐싱, 해결은 no-cache 규칙 + Cloudflare가 검증 도구를 막는다는 사실을 아는 것.
배치 병렬 배포, sourceless 배포 스크립트, 그리고 새 프로젝트 첫 배포가 실패하지 않게 하는 vercel.json 한 줄.
루트 layout에 canonical '/'를 박으면 모든 페이지가 이를 상속한다 — 모든 페이지가 구글에 '나는 홈의 중복'이라고 말하는 셈. 함정과 해결.
BSD 도구, 한국어 로케일, launchd의 최소 환경이 각각 예약 작업을 깨뜨렸고 에러 메시지는 진짜 원인을 설명하지 않았다. 함정 다섯과 해결.