30개짜리 반려서, 그리고 공장을 가동한 날
목차
아침에 열어본 Resolution Center에 메일이 쌓여 있었다. 처음엔 하나둘 읽기 시작했는데 다섯 개쯤 됐을 때 세는 걸 그만뒀다. 전부 같은 이유였다. 5.6. 결국 하나씩 집계해보니 반려된 앱이 30개. 버전업 포함 기준이었고, 라이브 상태인 31개는 무사했다. 심사 진행 중인 6개는 추가 피격 가능성이 있다고 판단했다.
첫 한 시간을 감사 보고서 쓰는 데 썼다. report_20260809_1250_apple56audit.html. 앱 하나하나 들어가서 반려 사유 확인하고, 실결함인지 겉지문(fingerprint) 이슈인지 분류하고, 앱별 액션플랜 달았다. 이게 없으면 30개를 어떤 순서로 어떻게 고쳐야 하는지 혼란만 가중된다. 지도 없이 달리다가 같은 앱 두 번 고치는 게 더 비싸다.
W1이라고 이름 붙인 것들의 실체
5.6 복구 플랜을 W1부터 W5까지 나눴다. W1은 오늘 당장 해야 하는 기술 수정 전체다.
ad-SDK purpose string. 가장 많이 등장한 이슈였다. Info.plist의 광고 관련 목적 문자열들이 부정적 어감으로 쓰여 있었다. 사용자가 왜 이 권한이 필요한지 "긍정적으로" 설명해야 하는데, 그냥 "추적될 수 있습니다" 식으로 남아 있던 것들이 죄다 걸렸다. 문구 어감 하나로 30개짜리 청구서가 날아왔다는 게 처음엔 황당했다. 그러나 5.6이 이 지점을 명시적으로 잡겠다고 예고는 했었다. 미리 못 챙긴 거다.
"replace negative ad-SDK purpose strings with positive wording (5.6 W1 account-wide sweep)" — 이 커밋 메시지를 오늘 몇 번이나 썼는지 세기가 싫다. 앱이 다르고 파일이 달라도 메시지는 같다. 반복이 당연한 작업이었다.
iOS deployment target. 14.x로 설정된 앱들이 줄줄이 걸렸다. project.pbxproj에서 IPHONEOS_DEPLOYMENT_TARGET을 15.0으로 올리는 것. 어떤 앱은 Podfile도 같이 손봐야 했다. platform :ios, '15.0'이 빠진 경우에는 pod install 돌리고 Podfile.lock 업데이트까지 따라온다. 파일 한 줄 바꾸는 데 커밋이 두세 개 붙는 이유다.
ATT purpose string. 단일 문자열로만 선언돼 있던 앱들이 5.1.1 리스크로 같이 잡혔다. 4-element 형식으로 바꿔야 했다. en.lproj/InfoPlist.strings, ko.lproj/InfoPlist.strings, ja.lproj/InfoPlist.strings 각각 다 손봐야 하는 앱들이 있었고, 아예 ATT가 필요 없는 앱들은 purpose string 자체를 제거하는 게 맞았다. 앱마다 방향이 달랐다는 게 이 작업을 단순 치환으로 처리할 수 없게 만들었다.
ATS 와일드카드. NSAllowsArbitraryLoads를 전체 허용으로 열어둔 앱들. 보안 정책 위반이라기보다는 실제로 필요하지 않은데 옛날에 편의상 열어둔 것들이었다. 오늘 다 닫았다.
share origin. iOS에서 공유 시트 띄울 때 sharePositionOrigin을 하드코딩된 Rect로 넘기면 iPad에서 팝오버 위치가 틀린다. 실제 버튼의 RenderBox를 얻어서 넘기는 방식으로 앱마다 수정했다. settings_screen.dart, backup_service.dart, export_service.dart, backup_screen.dart, editor_screen.dart, viewer_screen.dart, home_screen.dart, 여러 도구 화면들. 이건 5.6 이슈가 아닌데 어차피 각 앱 코드를 열고 있는 김에 같이 정리했다. async gap에서 위젯이 detach된 경우 guard도 추가했고, detached RenderObject 케이스도 예외처리했다. 작은 것들이지만 이게 안 되면 iPad에서 공유 기능이 이상하게 동작한다.
location purpose string. CAS 광고 SDK가 location을 쓰는 앱들은 NSLocationAlwaysAndWhenInUseUsageDescription도 챙겨야 했다. 빠진 채로 올라가면 심사 중에 또 걸린다. 몇 개 앱에서 누락돼 있었다.
이 여섯 가지를 30개 앱에 각각 점검하고 수정하는 게 W1이었다. 어떤 앱은 다섯 가지 다 걸렸고, 어떤 앱은 두세 가지만. 일일이 확인해야 했다.
지문을 지운다는 게 무슨 의미인지
기술 수정만으로 5.6이 완전히 해결되지 않는다는 걸 이번에 배웠다. 애플 심사관이 비슷한 앱들을 묶어서 보는 방식이 있다. 아이콘이 닮아 있거나, 스크린샷 레이아웃이 동일하거나, 리스팅 문구가 판박이면 "동일 공장 대량 생산"으로 분류한다. 그 공장 제품들은 한꺼번에 눈에 불이 켜진다.
그래서 de-fingerprint가 필요했다. 각 앱을 눈에 띄게 다르게 만드는 것.
아이콘부터 시작했다. pitlog, inchwise, dawn, pulsora, 그 외 여러 앱들. 각각 새 아이콘을 받았다. 1024x1024 마스터 이미지와 adaptive icon용 foreground 레이어를 디자인하고, flutter_launcher_icons로 각 Android density(hdpi, mdpi, xhdpi, xxhdpi, xxxhdpi)와 iOS AppIcon까지 생성했다. 한 앱에서 커밋이 두세 개씩 나오는 이유가 이거다. 디자인 커밋 따로, 생성 커밋 따로 붙는다.
스크린샷 프레임 스크립트도 오늘 많이 만들었다. frame_filmic.py, frame_collage.py, frame_scatter.py, frame_edgeband.py, frame_minimal.py, frame_paper_thick.py, frame_fullbleed.py, frame_tilted.py, frame_tilt_right.py, frame_display_dark.py. 각각 컨셉이 다르다. 타이포그래피 우선 필름 느낌, 카드 콜라주, 팬 형태로 펼쳐지는 스캐터, 상단 전체를 가득 채우는 밴드, 얇은 보더의 미니멀, 두꺼운 보더의 따뜻한 종이 질감, 화면 가득 채우는 풀블리드, 기울어진 목업, 다크 배경 우측 정렬 타이포. 이걸 앱마다 다르게 조합하면 스크린샷이 겉으로 달라 보인다. 심사관이 "이 앱들 다 같은 레이아웃이네"라고 인식하지 않도록. Play용도 비율이 다르니까 frame_collage_play.py, frame_scatter_play.py 식으로 따로 만들었다.
리스팅 문구도 앱 하나씩 다시 썼다. "distinct voice"가 핵심이었다. 같은 카테고리 앱이라도 말투, 강조점, 문장 구조가 달라야 한다. 그러면서 em-dash도 전부 제거했고, Free/FREE 표현도 지웠다. 효능 주장성 ASO 키워드도 걷어냈다. 키워드 한도도 로케일별로 다시 맞췄다. 한국어는 91자, 영어는 97자.
이 작업이 지루하냐고? 솔직히 절반쯤 넘어가면 그냥 라인 타게 된다. 아이콘 생성하고, 리스팅 고치고, 커밋. 그런데 그게 오히려 나쁘지 않은 상태였다. 판단해야 할 게 없으니 뇌가 쉬는 느낌. 다음 복잡한 문제를 위한 충전 시간이랄까.
써놓고 열흘 묵혀야 하는 회신
Resolution Center 회신을 30개 앱 각각 작성했다. 처음엔 설명 위주로 썼더니 변명처럼 읽혔다. 다시 썼더니 이번엔 너무 납작하게 굽신거리는 것 같았다. 몇 번 더 고쳐서 "조치를 구체적으로 기술하되, 책임은 명확히 인정"하는 톤으로 정착했다. contrite하지만 자조적이지 않은. 커밋 히스토리에 "draft", "rewrite contrite tone", "rewrite accountable tone", "soften list-header into sentence"가 연속으로 붙은 건 그 고민의 흔적이다.
근데 이 회신을 8/18 전에 보내면 안 된다. 수정 빌드가 TestFlight에 올라가고 어느 정도 안착이 확인된 뒤에 보내야 한다. 회신을 먼저 보내면 심사관이 들여다봤을 때 빌드가 아직 없거나 이전 빌드 그대로인 상황이 된다. 그게 더 나쁘다.
다 써놓고 봉인해두는 상태가 이상하게 답답하다. 열흘이다.
서명 프로파일이 없었다
빌드를 TestFlight용 IPA로 뽑는 중에 몇 개 앱에서 서명 오류가 났다. 로컬 키체인에 기존 프로파일의 인증서가 없는 상황이었다. 팀 리드 승인 받고 새 프로파일로 교체했다. ExportOptions.plist 수정하고, store/asc_profile.py라는 ASC API 헬퍼 스크립트를 만들어서 앞으로는 프로파일을 스크립트로 내려받을 수 있게 해뒀다. store/asc.py도 같이 정리됐다.
CAS 등록이 아직 안 된 앱들에는 GMA sample app id를 플레이스홀더로 박았다. 크래시 가드 용도다. 실 등록 완료되면 casconfig.rb 한 번 돌리면 교체된다. 이 구조를 앱별로 잡아뒀다. casconfig.rb v2.1도 트래킹에 포함했다.
스크린샷 업로드도 자동화했다. store/asc_ss_upload.py를 만들고, 캡처가 중단된 경우 재개하는 store/resume_cap.sh도 붙였다. Play Console 쪽은 play_upload.py, play_listing.py를 billwise, camberpro, arrowwise, traytime에 각각 추가했다. Android 업로드 흐름이 이제 어느 정도 스크립트화됐다.
그 와중에 새로 만들어진 것들
오늘 5.6 대응만 한 건 아니다.
Dutymate가 오늘 초기 구현부터 출발했다. 원래 DutyWise였는데 스토어 동일명 충돌 때문에 오늘 개명했다. 번들 ID도 com.hedvion.dutymate로. casaproof 포크에서 도메인 전면 재작성. 관부가세 계산기. 화면 5종, 엔진 테스트, 8로케일, 아이콘, legal, listing 완성하고 시뮬 QA까지 통과한 상태에서 끝나지 않고, 같은 날 opus 리뷰 반영이 들어갔다. navKey 재생성 버그, 로케일 안전 금액파서, 개소세 과세표준 계산(관세 포함), 배제품목 소액면세 처리, 차트 시간축 오류, cart 인덱스 문제, 평점 3일+30일 게이트, 스트림 구독 순서, RTL 패딩, 보험료 입력. 그다음 리뷰 반영도 바로 들어왔다. 기록 영구삭제 제거, 재검증 2회 강등과 복원 15초, 결제 에러 표면화, 기록 재계산 프리필, 환율 stale 경고, iPad 적응형 그리드. 하루에 신규 앱 완성에서 리뷰 두 라운드까지 끝낸 셈이다.
billwise도 오늘 1.0.0이 들어왔다. 인보이스/견적서 메이커, 8로케일, Pro IAP. 곧바로 리뷰 반영이 따라왔다. 파일백업, 부분입금, 세금번호, 키워드 정리, 개선 7건. ASC에 최종 제출까지 완료된 상태다. billwise가 영어 기본값 사이트로 동작하도록 Next.js 쪽 nav label 처리도 수정했다.
traytime에서는 수면 타이머, 프로그램 기능, 이명 정보 화면, 청취 통계 화면이 들어갔다. 청취 시간 누적 정확도도 수정됐다. 소스 링크 404 나던 것들을 MedlinePlus(NIH)로 교체했다. 앱 내 출처 화면도 새로 만들었다. 클립보드 복사 폴백도 추가됐다. 그리고 프로그램 시작 시 기존 활성 사운드를 클리어하지 않아 생기는 버그, 슬립 타이머 레이스 컨디션도 잡았다.
슬랭디코더는 오프라인 시드 번들링이 들어갔다. 서버가 다운돼 있어도 첫 실행에 딕셔너리가 비어있지 않게. 시드 임포트 직후 첫 델타에서 new-slang 알림이 잘못 발화되는 버그도 수정됐다.
l10n 작업이 오늘 유독 많았다. ar, en, es, hi, id, ja 로케일 arb 파일들을 여러 앱에서 손댔다. 의료 면책 문구, 볼륨 경고 문자열, 청취 가이드 키, 위험 링크 교체, l10n 키 신규 추가. 그리고 flutter gen-l10n 돌려서 .dart 파일들 재생성. 이게 앱 수만큼 반복됐다. material i18n 서비스 추가로 재료 이름과 설명이 8개 로케일에 걸쳐 현지화된 앱도 있었다.
404_pjt는 오늘의 이방인
완전 다른 영역. Java 레거시 프로젝트에서 쿠폰 관련 기능 하나가 들어왔다. 재선물로 교체된 옛 교환번호를 입력했을 때 사용자한테 적절한 안내 메시지를 보여주는 기능. 내부 클래스 수정, MyBatis 쿼리 매퍼 추가, gift_ledger_old_pin_index를 추가하는 ALTER 구문.
솔직히 오늘 이 작업에서 맥락 전환 비용이 가장 컸다. iOS Info.plist랑 Flutter l10n을 수십 번 들여다보다가 Java 레거시 코드로 갑자기 전환하면 뇌가 살짝 버벅거린다. 그래도 유저 입장에서는 의미 있는 개선이다. 유효하지 않은 교환번호 입력했을 때 에러 코드만 뜨는 것보다 명확한 안내가 나오는 게 훨씬 낫다. 작은 것이다. 하지만 작은 것도 쌓인다.
마무리
오늘 하루를 한 단어로 표현하면 "공장". 창의적 판단은 아침 두 시간에 몰아서 했고, 나머지는 반복 작업을 오류 없이 소화하는 것이었다. 실수 하나가 30개 앱 중 하나를 또 망가뜨릴 수 있으니 집중은 유지해야 했다. 생각보다 그게 더 피곤하다. 판단 피로보다 실행 피로가 은근히 더 무겁다.
8/18까지 열흘이다. 그때까지 수정 빌드가 전부 TestFlight에 올라가 있어야 하고, 회신도 준비된 상태여야 한다. 그때 가서 판단해야 할 게 또 있을 수도 있다.
오늘 못 끝낸 앱들이 아직 몇 개 남아 있다. 내일 W1 마저 돌리고 빌드 큐 정리하는 게 첫 번째 할 일이다.
psy 사이트 오늘자 테스트 콘텐츠 3건은 자동 생성으로 정상 처리됐다.
댓글 0
첫 댓글 달아줘.