일기 slecs

명세서 뜯어고치는 사이 앱 심사가 또 걸렸다

목차

이런 날이 있다. 아침에 딱 하나만 끝내자 싶었는데 커밋이 서른 개를 넘기는 날. 프로젝트가 동시에 네 개 돌아가고 있으니 당연하다면 당연한 건데, 그래도 매번 이 느낌이 익숙해지지 않는다. 오전에 명세서 작업 붙잡고 있는데 Xcode가 ITMS 에러 뱉고, 그걸 잡으러 갔더니 파트너 포탈 쪽 슬랙 알림 들어오고. 쿼리는 돌고 배치는 돌고. 서로 다른 컨텍스트 사이를 번갈아 스위칭하면서 버티는 하루였다.

오늘의 메인 — 거래명세서에 매입 산값 집어넣기

이게 오늘 가장 크고 무거운 작업이었다. 거래명세서에 상품권 매입 산값을 추가한다, 명목은 세무 증빙용. 요구사항 들었을 때는 "쿼리 하나 추가하고 JSP에 컬럼 붙이면 되겠지" 수준으로 봤다. 근데 파고들면 파고들수록 전처리가 필요한 것들이 줄줄이 딸려 나왔다.

첫 번째 걸림돌은 집계 쿼리 자체였다. 매입 집계에 발송포탈 발송 건, DISPATCH가 섞여 있었다. DISPATCH는 파트너가 직접 매입한 게 아니라 발송용으로 소진된 거라 명세서 매입 행에 들어가면 안 된다. 거기다 SENDER 타입도 명세서 대상 자체가 아닌데 쿼리가 조건 없이 다 가져오고 있었다. 세무 증빙이라는 용도를 생각하면 이 상태로 내보냈다가는 숫자가 틀리게 찍힌다. UI 버그가 아니라 세무 데이터 오류다. 쿼리 매퍼에 들어가서 DISPATCH 제외 조건을 추가하고, SENDER도 집계 대상에서 걷어냈다.

두 번째가 EL 비교 문제. JSP에서 매입 0원 케이스를 처리할 때, 값이 0이면 0원 대신 -로 표시하게 돼 있었는데 실제로는 그냥 0원이 그대로 나오고 있었다. 들여다보니 EL 표현식 안에서 숫자를 문자열과 비교하는 구조라 조건이 성립을 못 했다. 파트너 발행 명세서, 정산 명세서 상세, 목록까지 세 파일 전부 같은 패턴. 각각 들어가서 비교 조건 수정했다. 막상 원인 파악하면 실제 수정은 짧은데, 원인 파악까지 가는 길이 길었다. EL은 런타임에 타입 오류를 조용히 삼켜서 눈에 잘 안 띈다.

세 번째로 엑셀 매핑. ExcelConfigRegistry.java에 새 컬럼 등록하고, 유틸 내부 클래스에서 매입 산값을 엑셀 행에 매핑하는 로직 추가. 이 부분은 기존 패턴이 정리돼 있어서 빠르게 붙였다. 기존 패턴이 있다는 게 얼마나 편한지, 없을 때랑 비교하면 체감이 다르다.

이렇게 선행 수정 두 개가 정리된 다음에야 feat(statement) 커밋이 나왔다. 피처 하나 내보내기 위해 픽스 두 개가 선행됐다는 게 오늘 명세서 작업의 구조였다. 기능 추가 자체보다 그 밑바닥을 정리하는 데 시간이 더 많이 들었다.

과거 명세서 소급 처리 얘기도 중간에 나왔다. 이미 발행된 명세서들에도 매입 산값이 없으니 소급해서 채워야 한다는 요구였는데, 오늘 당장 손대기엔 위험하다고 판단했다. 대상 범위가 어디서 어디까지인지, 산값 계산 기준이 어떻게 되는지, 실행 순서가 어떠해야 하는지 — 이것들이 정리되지 않은 상태에서 UPDATE를 날렸다가 틀리면 세무 데이터가 오염된다. 되돌리기도 쉽지 않다. 그래서 가이드 문서(statement-purchase-cost-backfill.md)로 작업 흐름을 기록하고, TODO에 항목 등재하고 보류했다. 지금 못 하는 것과 지금 하면 안 되는 것을 구분하는 게 오늘의 판단이었다.

에러 메시지가 이렇게 많을 줄이야

명세서 작업 정리하고 넘어갔더니 금액 표기 통일 이슈가 기다리고 있었다.

잔액 부족, 출금 불가 같은 에러 메시지에서 금액이 숫자만 달랑 찍히는 케이스들. 100000이 아니라 100,000원이 나와야 하는데, 콤마가 없거나 단위가 없거나 둘 다 없거나, 제각각이었다. 건드린 파일이 쿠폰 외부 연동, 파트너 웹, 정산 웹, 배치 정산, 주문 웹, 유틸 클래스까지 여섯 군데. 기능별로 각자 메시지를 만들다 보니 표기 기준이 통일이 안 된 것이다. 누가 잘못한 게 아니라 처음부터 공통 포맷터를 강제하지 않은 구조의 문제다.

출금 가능 금액 부족 에러는 유틸 클래스 한 군데라 따로 커밋 끊었다.

귀찮은 작업이다. 근데 안 하면 계속 부분적으로 고치게 된다. 파트너가 출금 시도하다 에러 만났을 때 50000이라고만 나오면 읽기도 불편하고, 특히 큰 금액이면 자릿수 파악이 바로 안 된다. 오늘 한 번에 전수 잡고 끝냈다.

예치금 부족 안내 문구도 있었다. 쿠폰 발급이 차단될 때 나가는 메시지가 사업자 기준으로 돼 있어야 하는데 회원 기준으로 나가고 있었다. 파트너한테 가는 안내인데 맥락이 틀리니까 받는 사람 입장에서 혼란스럽다. 고치면서 DispatchFailReason.java에 차단 사유 로그도 보강했다. 왜 막혔는지 나중에 추적할 일이 꼭 생기니까.

파트너 포탈 AI 격리 — 구조를 바꾸는 작업이었다

파트너 포탈이 오늘 꽤 많은 커밋을 받았다.

채팅 위젯이 파트너 채팅 대신 관리자 채팅으로 넘어가는 버그가 있었다. 파트너 포탈에서 채팅 버튼 눌렀는데 관리자용 채널이 열리는 거다. 신규 화면 policy.jsp, sitemap.jsp를 추가하면서 레이아웃 서브 설정이 꼬인 것 같았다. 폰트랑 아이콘 정합도 같이 정리했다.

관리자가 파트너 포탈에 대리진입할 때 AI 상담이 관리자 세션 기준으로 답변하던 문제. 대리진입이면 파트너 컨텍스트로 동작해야 하는데, AI 웹 쪽에서 세션 사용자 정보를 가져올 때 관리자 정보를 그대로 쓰고 있었다. 파트너 정보를 넘기도록 수정했다.

AI 학습 데이터 등급 격리는 오늘 구조적으로 의미 있는 변경이었다. AI 상담이 학습한 데이터를 질문자 등급과 관계없이 답변에 활용하면 안 된다는 요구사항이다. 파트너 전용으로 등록된 학습 데이터가 일반 회원 질문에 대한 답변으로 나가면 안 되고, 반대도 마찬가지다. ALTER_20260806_ai_training_target_level.sql — 학습 데이터 테이블에 대상 등급 컬럼을 추가했다. 쿼리 매퍼에 등급 필터 조건이 들어갔고, AI 학습 목록 화면에서도 등급이 표시되게 했다. DDL도 갱신됐다.

AI 자동 학습 저장할 때 질문자 등급을 함께 기록하게 한 것도 같은 맥락이다. 등급 정보가 없으면 나중에 필터가 의미 있게 동작하지 못한다. 파트너 화면에서는 학습저장 버튼을 숨겼다. 파트너가 AI 학습을 직접 트리거하는 구조는 처음부터 의도된 게 아니었다.

파트너 전용 약관·사이트맵 신설, 발급내역 핀번호 조회 팝업에 슈퍼관리자만 보이게 하고 감사로그 추가, 예치 현황 화면에 요구 예치금 산정식 직접 노출. 이것들이 오늘 하루 사이에 다 들어갔다. 핀번호 조회 감사로그는 특히 빠지면 안 되는 것 — 민감 정보 조회 이력이 없으면 나중에 문제 생겼을 때 추적이 안 된다.

대시보드 TTFB 4초 — 드디어 잡았다

perf 커밋 하나가 있었다. 대시보드/전체요약 SSR 렌더에 Redis 캐시 누적 계층 우선 조회를 붙인 것.

첫 진입 TTFB가 4초였다. 화면 열면 4초 동안 빈 화면을 보고 있어야 한다는 거다. 진작에 잡혔어야 하는 건데 계속 미뤄지다 오늘 처리했다. 캐시 히트 시에는 즉시 응답. 첫 진입은 여전히 무겁지만 대시보드 특성상 같은 데이터를 반복 조회하는 패턴이라 히트율이 올라가면 체감이 달라진다.

코드 변경 자체보다 "드디어 했다"는 감정이 강했다. 4초짜리 TTFB를 알면서 두고 있었다는 게 좀 찝찝했으니까.

App Store 심사가 오늘 정말 물고 늘어졌다

이 얘기를 안 하면 오늘 회고가 완성이 안 된다.

커밋 로그에 build 3build 4build 11이 있다. 빌드 번호가 하루 만에 이렇게 뛰는 날은 로컬 테스트 빌드가 섞였거나, 반려 대응을 여러 번 반복했거나, 둘 다다. 오늘은 둘 다였다.

흐름을 따라가면: 처음에 ITMS-90683이 나왔다. NSLocationAlwaysAndWhenInUse 키가 없다는 거다. 광고 SDK가 필요로 한다는데, 위치 권한을 실제로 사용하지 않는 앱에서 이 키를 요구하는 게 직관에 반하지만, 이유를 따지기보다 추가했다.

그다음 App Review 5.1.1. NSUserTrackingUsageDescription이 너무 일반적이라는 것. 구체적인 목적 텍스트를 써야 통과된다는 거다. 8개 로케일, ar, en, es, hi, id, de, fr에 추가 언어까지 전부 재작성했다. 각 로케일 파일 열어서 문구 고치는 게 지루하기는 한데, 그보다 "또 이거"라는 피로감이 더 컸다. ATT 문구로 심사 반려 맞은 게 처음이 아니었으니까.

그다음이 좀 꼬였다. ATT 프롬프트를 무조건 띄우게 했더니 App Review 2.1로 또 반려. "광고 없는 앱에서 ATT 왜 씁니까." 맞는 지적이다. 론칭 시점에 실제로 광고를 서빙하지 않는 상태라면 ATT를 쓸 이유가 없다. CAS 같은 광고 SDK가 연동돼 있어도 마찬가지다. 실제 광고 서빙이 없으면 ATT가 없어야 한다.

build 3에서 ATT 전체를 걷어냈다. Info.plist에서 ATT 관련 키 제거, 관련 SDK 설정 정리, 독일어·영어·스페인어·프랑스어·인도네시아어 로케일 파일에서 추적 관련 문구 삭제, Podfile.lock과 xcscheme도 건드렸다. 하나 제거하면 연쇄적으로 따라오는 게 있다.

이 과정에서 docs/mobile-app-launch.md에 "반려 대응 순서 강제" 항목을 명문화했다. 심사 회신을 먼저 확인하고, 재제출은 그다음. 욱해서 바로 수정하고 올리면 회신 읽기도 전에 다른 버전이 올라가 있는 꼴이 된다. 심사팀 입장에서는 응답도 안 했는데 새 빌드가 들어온 것이고, 그러면 리뷰가 처음부터 다시 시작된다. ITMS-90683과 ATT 대응 교훈도 같이 묻어뒀다. 위치 purpose string 두 종류와 ATT 통과 문구 템플릿까지. 다음 앱 낼 때 이 문서 한 번만 보면 같은 실수는 피할 수 있다.

submit_107.py, 자동제출 워처도 오늘 만들었다. TestFlight 통과 대기하다 제출 버튼 직접 누르는 과정을 자동화한 것이다. 반려 대응을 여러 번 반복하다 보면 이런 자동화가 얼마나 필요한지 뼈로 느끼게 된다.

CAS 통합도 오늘 들어갔다. casconfig.rb 실행, cas_settings103.json 생성, AdMob 설정, 1.0.4 릴리즈용 asc_release_104.py 추가, store 리스팅 marketingUrl 처리 확인. App Store 관련 커밋이 오늘 하루 기준으로 가장 많은 비중을 차지했다. 심사 대응이 그만큼 많은 라운드를 탔다는 뜻이다.

DryWave, 수익화 구조 전체를 하루에 심었다

DryWave는 스피커 방수 토출용 진동을 내는 앱인데, 오늘 수익화 구조가 한 번에 들어갔다.

feat 하나가 두꺼웠다. 하루 5회 무료, 이후 Pro 결제 유도하는 소프트 페이월. 채널 선택, 반복, 강도 조절, Siri 연동이 Pro 기능으로 게이트되고, 이어폰 모드와 Apple Watch 앱도 새로 추가됐다. GatePrefs.kt, MainActivity.kt(안드로이드), WatchEjectEngine.swift, WatchGate.swift, ContentView.swift(Watch) 등이 이 커밋에 몰려 있다. Watch 앱까지 들어간 커밋이라 파일 목록이 짧지 않다.

기존 사용자 grandfather 처리는 빠지면 안 됐다. 이미 자유롭게 쓰던 사람한테 갑자기 하루 5회 제한이 생기면 리뷰가 터진다. 결제 없이 계속 쓸 수 있게 기존 사용자를 exemption 처리했다.

바로 뒤에 fix 커밋이 붙었다. consume-on-complete, ATT defer, app-open drain 방지, pending 구매 UX, 이어폰 캡 실시간 적용, Siri 현지화, whatsnew 텍스트. 커밋 제목만 봐도 얼마나 많은 엣지 케이스를 한 번에 정비한 건지 짐작이 간다. 결제 관련 로직은 특히 엣지 케이스가 많다. 구매가 pending 상태로 걸려 있는 경우, 완료 전에 소비 처리가 먼저 되는 경우, 앱 오픈 시 ATT가 너무 일찍 뜨는 경우 — 하나씩 다 생각해야 한다.

Watch target에 SKIP_INSTALL=YES가 빠져 있어서 아카이브할 때 에러가 났고, 바로 후속 수정 커밋이 따라붙었다. 아카이브 에러는 실제로 빌드 올리기 전까지 잘 안 보이는 류의 문제라 항상 이 부분에서 시간을 한 번씩 쓰게 된다.

1.2.0+12. 버전 숫자 하나 올렸는데 내용물은 수익화 구조 전체다.

쿠폰 동기화 sell_yn, 자동화 하나 추가

공급사에서 상품 판매중지 처리를 하면 동기화 시 우리 쪽 DB에 sell_yn='N'이 자동으로 반영되게 했다. SQL 매퍼와 자바 로직 두 군데 수정.

이게 없으면 공급사가 팔지 말라고 한 상품이 우리 시스템에서 계속 판매 중으로 노출된다. 그동안 수동으로 처리하던 걸 자동화한 셈인데, 단순해 보이지만 빠지면 운영 부담이 계속 쌓인다.

psy 자동 테스트 3종

cinema-type, family-ott-type, midnight-train-ghost. 매일 자동으로 돌아가는 테스트 콘텐츠 생성이라 별도로 손댄 건 없다. 커버 이미지와 JSON 데이터가 정상 생성됐다. 확인하고 넘어가는 정도.


오늘 서른 개 넘는 커밋. 세무 증빙용 명세서 매입값 추가가 메인이었고, App Store 심사 3연타 대응, DryWave 수익화 구조 전체, 파트너 포탈 AI 등급 격리, 대시보드 캐시, 에러 메시지 전수 통일, 쿠폰 판매중지 자동화까지. 각각이 별개 프로젝트고 별개 맥락이다.

명세서 작업은 EL 비교 오류 같은 잔 버그부터 집계 쿼리 정비까지 실을 따라가는 느낌이었고, App Store 쪽은 ATT를 넣었다 뺐다 하며 심사팀과 핑퐁 치는 느낌이었다. 소진되는 방식이 달랐는데 같은 날 동시에 돌아가니까 컨텍스트 스위칭 비용이 꽤 들었다.

명세서 소급 처리가 TODO에 묻혀 있다. 내일 대상 범위 파악부터 시작해야 할 것 같다. 미룰 수 있는 성질의 작업이 아니다.

댓글 0

첫 댓글 달아줘.