일기 slecs

진상 다섯 번과 차단 루프, 그리고 쿼리 81개

목차

오늘 같은 날은 퇴근하면서 커밋 로그를 다시 열어봐야 믿어진다. PriceLens 심사 차단 항목 처리부터 kp-pay 인증 루프 막기, 대시보드 쿼리 뜯기까지. 두 프로젝트를 동시에 굴렸고 둘 다 불이 나 있었다. 오전에 뭘 했는지가 오후엔 기억이 안 날 정도로 컨텍스트 전환이 잦았다.

진상이라 부르기로 했다

PriceLens 심사 피드백 항목들을 팀 안에서 "진상"이라고 부르기 시작한 게 언제부터인지 기억도 안 난다. 번호가 ①에서 ⑤까지 붙었다는 건 그만큼 제출-차단-수정-재제출 사이클을 반복했다는 증거다. 오늘 처리한 것만 ①, ②, ⑤였는데, 각각이 단순하지 않았다.

진상①은 결제 활성화 대기 안내가 빠져 있다는 거였다. IAP 구매 직후 서버 검증 응답이 돌아오기 전에 사용자는 아무것도 모르고 앉아 있게 된다. 결제는 됐는데 앱이 반응을 안 하면 사용자 입장에선 결제가 실패한 건지 성공한 건지 모른다. 그 공백을 안 채웠더니 심사관이 잡았다. 실패한 스캔에서 크레딧이 차감되는 문제(I1)도 있었다. 선차감 후 실패 환급 구조였는데, 환급 타이밍이 틀려서 차감된 채로 남는 케이스가 있었다. 'no ads' 헤드라인 교체, 복원 무결과 안내까지. 이 커밋 하나로 건드린 arb 파일이 6개다. 진상① 하나를 반영하는 데 아랍어, 독일어, 영어, 스페인어, 프랑스어, 힌디어까지 각각 열어서 수정해야 한다. 11로케일을 지원한다는 건 텍스트 하나 바꿀 때마다 그 노동이 11배로 붙는다는 뜻이다.

진상②는 더 기본적인 게 빠져 있었다. 분석 중이라는 상태를 사용자한테 안 보여주고 있었다. AI가 사진을 받아서 처리하는 동안 화면이 그냥 멈춰 보이는 것이다. 더 심각한 건 실패했을 때 가짜 결과를 내주던 부분이었다. API가 에러를 뱉으면 뭔가 그럴듯한 값을 만들어서 보여주는 방어 로직인데, 그게 심사관 눈에 "잘못된 정보 제공"으로 읽혔다. 실패는 실패라고 정직하게 보여줘야 한다. 홈 화면에 남은 횟수를 표시하는 것, 동의화면 방침 링크를 제대로 연결하는 것도 이 커밋에 묶였다. 또 11개 파일.

진상⑤는 장부(ledger) 쪽이었다. mock 데이터에 표식이 없었고, DB 스키마 v2 마이그레이션 이슈로 총액 계산이 깨지는 케이스가 있었다. 식별 실패 시 무료 횟수를 환급해줘야 하는데 서버 단에서 처리가 안 되고 있었다. 이 커밋에만 server/app/main.py가 들어간다. 나머지는 클라이언트 픽스였는데 ⑤는 서버까지 내려가야 했다. 그게 처리하면서 제일 신경 쓰인 부분이었다 - 클라이언트만 고치면 끝나는 줄 알았는데 서버 환급 로직이 통째로 빠져 있었으니까.

그 전에 있던 "리뷰 차단 6건+중요 3건"짜리 커밋은 아마 오늘 전체에서 가장 무게감이 컸다. HMAC 엔타이틀먼트 검증, 페이월 3.1.2 고지, 온보딩 동의 강제, 크레딧 선차감, mock 배지, 스캔/IAP 계약 정합. HMAC은 IAP 영수증을 서버에서 검증할 때 서명 일치 여부를 제대로 확인 안 했다는 것이고, 페이월 3.1.2는 애플 가이드라인 3.1.2 항목, 즉 구독 정보를 결제 전에 명확히 고지해야 한다는 걸 못 지켰다는 것이다. mock 배지는... 개발 중 남긴 테스트 데이터 표식을 프로덕션 빌드에 그대로 올려버린 것이다. 부끄럽다. ios/Runner/Info.plist도 이 커밋에 들어 있다. gitignore도 수정했다는 건 그 과정에서 올리면 안 되는 파일이 추적되고 있던 게 있었다는 뜻이다.

QA 출시차단 4건은 별도다. 온보딩 setState Future 크래시는 비동기 처리 중에 위젯이 언마운트되는 전형적인 문제. 장부 revision 갱신이 안 되는 것, 사진 영구화가 안 되는 것. 빌드를 두 번 더 올렸다는 게 커밋 메시지에 나온다. 그 두 번이 얼마나 고통스러웠을지는 TestFlight 배포 사이클을 겪어본 사람은 안다.

빌드 번호가 적어주는 역사

1.0.0+6, 1.1.0+5, 1.1.2+13, 1.2.0+14. 오늘 기록에 찍힌 빌드 번호들이다.

+6에서 시작해서 +14까지 왔다. TestFlight 올리고 심사 냈다가 차단 먹고, 고쳐서 다시 내고, 또 차단 먹고. 버전 번호도 1.0.0에서 1.1.0, 1.1.2, 1.2.0으로 뛰었다. 그 점프마다 어떤 기능이 추가되거나 구조가 바뀌었다는 뜻이다.

iOS 최소 버전 13→15 작업이 두 커밋에 걸쳐 있다. 처음엔 "ITMS-90068 선반영, 다음 빌드부터"라고 주석을 달고 넘겼다. 그리고 실제로 제출하니 차단이 왔다. ios/Podfile, ios/Podfile.lock, ios/Runner.xcodeproj 전부 다시 손댔다. 한 번 더 빌드를 태운 것이다. 선반영을 제대로 마무리하지 않고 넘겼던 게 발목을 잡았다.

Android 릴리즈 업로드 키 서명 연결은 의외로 깔끔하게 됐다. build.gradle.kts에 릴리즈 키를 연결하고 gitignore에 넣고 OneDrive 백업. 교과서적인 처리다. 그게 오늘 스트레스가 제일 없었던 커밋이었을 것이다.

CAS.ai 광고망은 얽힌 게 많았다. casconfig.rb 스크립트로 설정 파일을 뽑는데, 출력 JSON 파일 이름이 빌드마다 바뀐다. cas_settings101.json, cas_settings104.json, cas_settings109.json. casconfig 돌릴 때마다 새 파일이 나오는 구조다. Info.plist에 위치 권한 키를 두 종류 추가한 것도 CAS가 요구하는 광고 관련 권한 때문이다. Podfile.lock이 여러 커밋에 걸쳐 변경된 건 CAS 관련 의존성이 계속 바뀌었다는 뜻이고, 그때마다 pod install 돌리고 xcworkspace를 다시 구워야 했다. iOS 빌드 사이클이 오래 걸리는 건 다들 알지만, 그게 하루에 열 번씩 반복되면 체감이 다르다.

콜드스타트 앱오픈 광고는 두 번 커밋됐다. 첫 번째는 lib/main.dart만, 두 번째는 ad_service.dart까지 추가됐다. 쿨다운 4시간 유지라는 조건이 명시된 걸 보면 콜드스타트마다 광고를 무조건 띄우는 게 아니라 마지막 노출 후 4시간이 지났을 때만 보여주는 방식이다. 그 판단 자체는 맞다. 앱 열 때마다 광고 보여주면 리텐션이 박살난다.

스캔 매직바이트, 작지만 오래가는 방어

pubspec.yaml이랑 server/app/main.py만 건드린 커밋인데 내용이 재미있다.

스캔 이미지 업로드 시 content-type을 클라이언트가 넘겨주는 대로 믿으면 간헐적으로 400이 났다. 클라이언트가 잘못된 MIME 타입을 올리거나, 이미지 라이브러리가 처리 중에 타입 정보를 날려버리는 경우가 있어서다.

매직바이트 스니핑은 파일 앞 몇 바이트를 직접 읽어서 실제 포맷을 확인하는 방어 기법이다. JPEG면 FF D8 FF, PNG면 89 50 4E 47로 시작한다. content-type 헤더를 믿지 말고 바이트를 봐라. 간헐적으로 나는 에러는 재현이 어려워서 디버그가 지옥인데, 이 패턴은 그런 종류의 문제를 근본에서 막는다.

같은 맥락에서 스캔 업로드 필드명 불일치도 있었다. 앱에서는 file로 올리는데 서버가 다른 이름을 기다리고 있었던 것. 개발 환경에서는 돌다가 특정 조건에서만 깨지는 불일치는 나중에 찾기 힘들어진다.

kp-pay: 루프를 막아야 했다

저쪽 프로젝트에서는 오늘 kp-pay 인증 관련 작업이 가장 묵직했다.

기가입 판정 강화부터. 기존엔 전화번호 하나만으로 기가입 여부를 판단했다. 번호가 일치하면 기가입이라고 보는 방식인데, 문제가 됐다. 번호 이동이나 번호 재사용 케이스에서 전혀 다른 사람이 기가입으로 분류되는 위험이 있다. 번호 + 이름 + 생년월일 세 개가 전부 일치해야 기가입으로 보도록 쿼리와 컨트롤러 로직을 고쳤다. 조건이 엄격해질수록 오탐은 줄고 미탐은 늘 수 있다. 여기서는 오탐의 피해, 즉 엉뚱한 사람의 계정으로 잘못 연결되는 것이 미탐의 피해보다 훨씬 크기 때문에 엄격하게 가는 게 맞다.

kp-pay에서 "등록 불가 고객" 응답이 올 때 기가입 회원이면 로그인 유도를 해주는 분기도 추가했다. 등록이 안 된다는 말만 던져두면 사용자가 왜 안 되는지 모른다. 이미 다른 계정으로 가입되어 있다면 그 계정으로 로그인하면 된다는 걸 알려줘야 CS 문의가 줄어든다. reauth.jspregister.jsp 양쪽에, 그리고 phone-auth.js에도 분기가 들어갔다.

재시도 루프 차단. 이게 오늘 kp-pay 작업에서 핵심이었다.

등록 불가 고객이라는 응답을 받고 나서도 페이지를 새로 고치면 다시 인증을 시도할 수 있는 구조였다. 사용자가 계속 반복해서 시도하고, 그때마다 kp-pay 쪽에 인증 요청이 날아가고, 실패 로그가 쌓인다. 의도치 않은 반복 요청 패턴이다. 24시간 차단 키를 박아서 한 번 등록 불가 판정을 받으면 24시간은 재시도 자체가 막히게 했다. 대리점 문의를 안내하는 것도 포함됐다. 스스로 해결할 수 없는 문제면 창구를 명확하게 보여줘야 한다.

차단 안내 UI도 함께 손봤다. kyc.jsp, profile.jsp, reauth.jsp, register.jsp, phone-auth.js 전부. 기존엔 "확인" 버튼이 있는 모달이었는데, 누르면 닫히고 다시 시도할 수 있는 구조였다. 닫기 불가 오버레이로 바꿨다. 페이지 자체를 떠나거나 다른 경로로 가야 하는 것이다. UX 관점에서 사용자를 가두는 건 나쁜 패턴이 맞다. 그런데 이 경우엔 가두는 게 맞다. 사용자가 답을 모르고 계속 시도하게 내버려두는 게 더 나쁜 경험이다.

AI 챗봇 연동 흐름

AI 챗봇 쪽에서 kp-pay 접수가 들어올 때 CS봇 명단 카드 방식으로 디스코드에 쏘는 작업을 했다. 처음엔 feat으로 회원정보를 디스코드 메모 카드 형식으로 보내는 것을 만들고, 그게 refactor로 CS봇 명단 카드 합류 방식으로 바뀌고, 거기서 fix가 붙었다. enabled='Y'일 때만 경고 메모를 기본 잠금 상태로 내보내도록.

세 커밋이 비교적 짧은 시간 안에 연달아 붙는 패턴은 구현하면서 구조가 바뀐 것이다. 처음 구현 방향이 틀렸거나, 만들다 보니 더 나은 방식이 보이거나. 이 경우엔 CS봇이 이미 명단 카드 규격을 쓰고 있었는데, 챗봇 접수를 다른 형식으로 만들었다가 통일이 안 되는 게 눈에 들어온 것 같다.

알림 전용 웹훅 분리가 구조적으로 중요한 부분이다. tb_discord_bot_configAI_CHAT 타입을 추가해서 AI 챗봇 상담 알림만 받는 채널과 웹훅을 따로 가져간다. 하나의 웹훅으로 전부 받다가 AI 챗봇 알림이 섞이면 CS 처리 흐름이 뒤엉킨다. 타입별로 채널을 분리하면 담당자가 각자 자기 채널만 보면 된다. 역할 격리 프롬프트라는 표현이 커밋에 있다. AI 챗봇이 회원 상담과 일반 상담을 다르게 처리하도록 프롬프트 레벨에서 분리했다는 뜻으로 보인다. 프롬프트로 분기를 만들려면 회원 여부 판단이 정확해야 하는데, 그게 위에서 기가입 판정 강화와 연결된다.

쿼리 81개를 봤을 때

대시보드 성능 작업은 오늘 가장 명쾌한 파트였다.

selectPartnerDashboardMetrics - 재귀 CTE가 19번 반복되고 있었다. 같은 테이블을 19번 읽는 구조다. WITH RECURSIVE 한 번에 계층을 다 펼치고, 테이블별로 단일패스로 집계하는 방식으로 재작성했더니 2.3초에서 1.1초로 줄었다. 절반 이하.

selectPlatformDashboardMetrics는 더 극적이었다. 서브쿼리가 81개였다. 화면에서 보여줄 숫자 컬럼마다 서브쿼리가 하나씩 박혀 있는 구조다. 집계를 한방에 돌리면 될 것을 컬럼 수만큼 쿼리가 반복된 것이다. 처음 열었을 때 그냥 닫고 싶다는 생각이 먼저 들었다. 테이블별 LEFT JOIN으로 묶어서 8개 패스로 재작성하니 1.4초에서 0.36초. 거의 4배다.

Redis 캐시 쪽 ELException은 성격이 다른 버그였다. Redis에서 읽어온 값이 캐시 히트일 때 dayDate 필드가 Date 타입이 아니라 String으로 역직렬화되는 케이스가 있었다. JSP에서 fmt:formatDate EL 태그로 날짜를 포맷하려는데 타입이 맞지 않으니 런타임에 터진다. 캐시 히트/미스 경로가 달라서 개발 환경에서 못 잡은 것이다. 서버 재시작 직후엔 캐시가 비어서 미스 경로만 타다가, 60초가 지나면 갑자기 500이 난다. 재현 조건을 맞추기가 까다로운 버그다. 서버 재시작하고 1분 기다려야 재현된다는 걸 파악하는 데 시간이 걸렸을 것이다.

SWR 전환은 이 기회에 캐시 전략을 아예 바꾼 것이다. 60초 하드 만료에서 신선도 60초 + 보관 30분으로. stale-while-revalidate 패턴이다. 60초가 지나도 보관된 값을 먼저 주고 백그라운드에서 갱신하며, single-flight으로 중복 갱신도 막았다. 대시보드는 실시간성이 1분 정도면 충분하고, 그 대신 응답은 항상 빨라야 한다. 타입 불일치 버그를 잡으면서 캐시 설계 자체도 맞는 기회에 정리했다.

파트너 포탈 자잘한 것들

400 에러 두 건은 partnerHeader.jsp 하나에서 나왔다. 전역검색에서 하위파트너 클릭, 쿠폰 결과 클릭 시 400. 검색어 파라미터 이름이 컨트롤러 기대값과 달랐거나 인코딩이 빠진 것이다. JSP에서 URL 파라미터를 조립하는 부분이 서버와 맞지 않으면 이렇게 난다. 전역검색은 사용 빈도가 높은 기능이라 이대로 두면 CS가 들어온다.

담당자 이메일 형식 검증은 저장 시점에 해야 하는데 안 하고 있었다. DB에 abc@ 같은 값이 들어가면 나중에 거래명세서 발송할 때 터진다. 발송 전 사전체크 추가랑 세트다. 파트너 수정 화면과 포탈 내 정보 두 군데 모두 컨트롤러를 손봤다.

정산검증 허위경고 제거도 있었다. 실제로 문제가 없는데 경고가 뜨면 담당자가 매번 들여다봐야 하고, 결국 경고 자체를 무시하는 습관이 생긴다. 그게 더 위험하다. 경고는 진짜 경고만 나와야 한다.

docs가 남긴 것

CLAUDE.md 커밋이 오늘 여러 개다. 심사 제출 상태, 함정 기록, 동기화 실경로 정정. 자동갱신 구독 첫 제출의 함정들이 문서에 박혔다. 가격 territory 없이 POST하면 거부당한다, IAP는 콘솔 경유가 선행조건이다, MISSING_METADATA는 정상이다. 이런 걸 실측해서 적어두지 않으면 다음 앱 심사 때 같은 데서 막힌다. 아니, 이미 이번에 막혔으니까 다음엔 안 막히도록 적은 것이다.

ASC 심사에추가 메뉴 자동화 실경로, MenuList__ItemInner 셀렉터를 실측으로 잡아서 클릭 무시 시 진단법까지 적었다. ASC 자동화는 DOM이 바뀌면 바로 깨지기 때문에 실측 셀렉터를 기록하는 게 맞다. 구독 MISSING_METADATA 마감 판단 근거도 남겼다. CAS app-ads.txt 소스를 GitHub 미러에서 cas.ai 정본으로 교체하면서 헤더 SSOT도 정리했다.

hedvion-ads 쪽에서 앱오픈 준비상태 공개 API 추가와 표시중 플래그 레이스 픽스가 있었다. 광고가 이미 표시 중인데 또 노출하려는 레이스 컨디션을 막는 것. 앱오픈 광고 같은 경우 콜드스타트와 포그라운드 전환이 거의 동시에 오는 경우가 있어서 플래그 없이 두면 중복 노출이 생긴다.

psy 쪽 자동생성 테스트 3건은 오늘도 찍혔다. celestial-omen, crisis-response-type, two-shadows-midnight. 일별 자동 생성이니 따로 건드린 건 없는데, 이름들이 오늘 하루 분위기랑 묘하게 맞는다.

커밋이 많을수록 뿌듯함보다 피로가 먼저 온다. 다 처리했다는 사실보다 이게 왜 이렇게 쌓여 있었냐는 생각이 더 크다. 기가입 판정 조건이 처음부터 세 필드 일치였어야 했고, 쿼리에 서브쿼리를 81개 박은 것도 누군가 한 번은 들여다봤어야 했다. 진상 다섯 번은 처음 제출 전에 더 꼼꼼하게 체크리스트를 돌렸더라면 두 번으로 끝났을 것이다. 다 핑계다. 오늘 다 닫았다는 게 중요하다.

댓글 0

첫 댓글 달아줘.