스코프 하나가 열두 군데로 번진 하루
목차
오늘은 딱 잘라 말하기 어려운 날이었다. 파트너 포탈만 판 것도 아니고, 즐거운 대량환불만 만진 것도 아니고, 모바일 앱 제출 마무리도 끼어있었다. 세 개 레인이 동시에 달렸고, 어떤 레인은 깔끔하게 마무리됐고, 어떤 레인은 블로커에 처박혀 멈췄다. 그 와중에 봇 인프라도 두꺼워졌다.
커밋 타임라인을 보면 오늘 가장 많은 에너지가 들어간 건 파트너 포탈이었다. 거기서 시작하자.
담당 대리점 스코프, 팔수록 나오는 구멍
영업대행사 관리자가 여럿이고, 계정마다 담당하는 대리점이 다르다. 이 설명은 한 줄로 끝나지만 구현하려고 들어가면 전혀 다른 얘기다. "당연히 자기 담당 대리점 데이터만 보여야 한다"는 요건이 코드베이스 어디까지 퍼져있는지 파악하는 것 자체가 일이다.
출발은 유틸 하나였다. 로그인한 계정의 담당 대리점 목록을 꺼내오는 메서드. 이걸 만들어서 쿼리 매퍼에 IN (담당대리점ID) 조건을 붙이는 작업. 회원 목록 쿼리부터 시작했다. 그런데 이 로직이 원래 관리자 쪽에 있었다. 파트너 포탈에서 쓰려면 이전이 필요했다. 담당 대리점 조회 스코프를 관리자→파트너 포탈로 이전 커밋이 단순 복사가 아니었던 이유가 여기 있다. 컨트롤러 레이어, cmm 공통 쪽, 그리고 .claude/rules/admin-patterns.md까지 건드렸다. 패턴 자체를 어디에 두어야 하는지 정리가 필요했던 거다.
이전 직후 거래명세서에도 스코프를 붙였다. 회원 목록만 필터링하면 끝이 아니라 거래 내역 조회도 담당 대리점 기준으로 잘려야 한다. 컨트롤러 하나에 쿼리 매퍼 여러 개가 동시에 들어갔다.
잠깐 숨 쉬고 돌아보니 누락이 보였다. fix(partner): 담당 대리점 스코프 누락분 보강 - 계약·자동승인 5곳. 계약 관련 엔드포인트 두 곳, 자동승인 세 곳. 전부 손으로 훑으면서 조건을 추가했다. 이런 누락은 첫 번에 완전히 잡기가 어렵다. 메서드 이름이나 파라미터만 보고는 스코프 필터가 필요한지 맥락을 읽어야 알 수 있는 케이스가 있다. 실제 데이터 흐름을 따라가야 나온다.
대시보드는 조금 다른 양상이었다. 스코프 적용은 기본이고, "본인돈 카드 숨김"이 추가됐다. 영업 관리자 포탈에서는 본인의 재무 카드를 보여줄 필요가 없다는 요건이다. 이게 처음 들으면 헷갈리는데, "당신 돈을 당신한테 숨긴다"는 게 아니라 관리자 포탈이라는 컨텍스트에서 재무 카드 노출이 부적절한 거다. dashboard.jsp랑 partnerSidebar.jsp 둘 다 건드렸다.
그러고 나서 codex 리뷰 피드백이 들어왔다. fix(partner): 대시보드 스코프 codex 지적 2건 보강 - 사이드바 판매·확정수익. 판매 집계랑 확정수익 집계에 스코프 필터가 빠져있었다. 쿼리 매퍼 고치고 컨트롤러도 같이 수정했다. 잡아준 건 고마운데 동시에 "내가 아까 놓쳤다"는 얘기이기도 하다. 기분이 반반이다.
폴링 쪽도 따로 있었다. fix(partner): 스코프 매니저 balance 폴링 응답에도 본인 재무 마스킹. 잔액을 폴링으로 받아오는 엔드포인트가 별도로 있는데 거기서는 마스킹이 안 됐던 거다. 컨트롤러 파일 하나 더.
수익 집계에서는 다른 종류의 버그가 나왔다. PAY_COMMISSION 타입이 집계 쿼리에서 빠진 채로 계산이 돌고 있었다. 결제수수료가 파트너 수익에 반영이 안 됐던 거다. 언제부터였는지 알 수 없다. 조용히 살아있었을 가능성이 높다. 발견해서 다행이라는 말밖에. 쿼리 매퍼 고치고 subordinates.jsp에도 반영했다.
파트너 삭제 플로우는 오늘 중 가장 설계 판단이 들어간 부분이었다. feat(partner): 파트너 삭제를 하위 포함 캐스케이드 논리삭제로 전환 + 잔액 가드. 파트너를 삭제하면 하위 대리점이 줄줄이 따라가야 하는데, 물리 삭제가 아니라 논리 삭제다. 데이터는 남고 플래그만 바뀐다. 거기다 잔액 가드가 붙었다. 돈을 들고 있는 파트너는 삭제 불가. 컨트롤러, settlement 컨트롤러, 쿼리 매퍼, detail.jsp, settlement/partner-detail.jsp까지 다섯 파일이 한 커밋에 들어갔다. 이런 건 부분만 커밋하면 나중에 찾기 더 어려워진다. 다 잡고 올려야 한다.
발송포탈 처리도 있었다. 발송포탈을 매칭 판매포탈 계보로 취급 (잠금 동행·계층 표시). 발송포탈이 매칭된 판매포탈 하위 계보로 묶이게 되는 구조 변경이다. 판매포탈이 잠기면 발송포탈도 같이 잠겨야 한다. 쿼리 매퍼가 세 개 들어갔다. 같은 비즈니스 규칙을 여러 쿼리에 각각 박아야 해서 파일 수가 늘어난 거다. suspend 쪽도 같이 수정됐다.
회원등록 모달에서는 스크롤 버그를 잡았다. members.jsp에서 담당 대리점 칸이 스크롤이 안 됐다. 기능과 관계없는 UI 잡일이 끼어들면 리듬이 끊긴다. 빨리 고치고 넘어가는 수밖에 없다.
본인인증 뱃지 건. 웰컴 플로우를 통해 인증한 이력이 있는 회원과 없는 회원을 뱃지에서 구분해야 한다는 요건이다. 단순 "인증됨/미인증"이 아니라 인증 경로까지 표시한다. 쿼리도 같이 바꿨고 members.jsp에 분기가 들어갔다.
파트너 목록 계층 뷰는 가독성 개선을 먼저 넣었다가 강조 톤이 너무 강했는지 바로 다음 커밋에서 톤다운됐다. list.jsp를 두 번 건드렸다. 이런 스타일 조정은 눈으로 봐야 결정되는 거라서 한 번에 맞기 어렵다. 넣어보고 "음, 이건 좀 과해" 싶으면 줄이는 거다.
파트너 포탈만 보면 오늘 커밋이 열다섯 개다. 그 중 절반이 fix다. feat 하나 넣으면 주변에서 fix가 두세 개 따라오는 패턴이었다. 스코프 작업이 특히 그렇다. 전체 코드베이스를 손으로 훑어야 누락을 잡을 수 있는데, 한 번에 다 잡는 건 사실상 불가능하다.
ERR003이라는 이름의 블로커
즐거운 대량환불은 오늘 첫 실제 테스트를 시도했다.
ERR003. 즐거운 API가 반환한 에러코드다. "IP 미등록". dev 환경에서 pg_config API를 호출했더니 이게 나왔다. 처음엔 원인이 두 가지였다. 토큰 만료이거나 IP 미등록이거나. 커밋이 "테스트 착수 결과 기록"과 "ERR003 재검증 - 토큰 유효·IP 미등록 확정"으로 나뉜 걸 보면, 한 번 더 확인해서 원인을 특정했다는 거다. 토큰은 살아있다. IP가 문제다. 즐거운 측에 dev 서버 IP 등록을 요청해야 한다. 그 전까지 실제 API 통신 테스트는 막혀있다.
기다리는 동안 코드단에서 할 수 있는 건 다 해뒀다. ZlgoonBulkRefundClient.java에 최종 규격서(20260629) 기준 실값을 전부 반영했다. pg_config 산출 로직에 법인계좌를 넣는 작업이 있었는데, 이건 운영DB tb_relay_fee_config를 직접 실측해서 넣은 거다. 설계서에 적힌 값을 그냥 쓰는 게 아니라 실제 운영 데이터를 확인했다는 게 중요하다. 스테이지 값이랑 운영 값이 다를 수 있기 때문이다. SQL INSERT 파일도 커밋에 들어갔다. 테스트 쿠폰 파일도 .claude/pdf/zlgoon/에 보관했다.
설계 메모 커밋이 네 개다. 오늘 뭘 시도했고, 뭐가 막혔고, 확인된 사실이 무엇인지. IP 등록 기다리면서 한 달 후의 내가 컨텍스트를 잃지 않으려는 거다. 블로커 상태에서 남길 수 있는 가장 좋은 것은 기록이다.
TrayTime, 심사에 들어갔다
치아교정 착용 시간 추적 앱 TrayTime이 오늘 App Store 심사에 올라갔다.
이 프로젝트가 오늘만 한 건 아니었겠지만 오늘 커밋이 잔뜩 찍혀있다. 시작은 chore: fork unpouched -> traytime. 기존 프로젝트를 fork해서 rename sweep 돌리고, console ID 중립화하고, AdMob 테스트 fallback 설정. fork 출발이라서 여러 파일에 이전 앱 흔적이 남아있어 일괄 수정이 필요했다.
로컬리제이션은 8개 언어로 새로 작성했다. arb 파일을 6개 올렸다는 걸 보면 영어를 포함해 아랍어, 스페인어, 힌디어, 인도네시아어, 일본어 등이다. 치아교정 도메인 특화 텍스트를 새로 써야 하니까 그냥 번역이 아니라 도메인 용어를 선택하는 작업이다. 어떤 언어는 "aligner"를 직역하면 어색하다.
도메인 모델도 처음부터 만들었다. aligner_plan, wear_session, progress_photo, 그리고 착용 수학 계산 로직 wear_math. 단위 테스트도 같이 작성했다. 이게 잘한 부분이다. wear_math 같은 계산 로직은 테스트 없으면 나중에 수정할 때 겁난다.
AdMob 실 iOS ID 연결 과정에서 fix(release) 커밋이 꽤 묵직했다. Info.plist에 실 ID 넣고, 사진과 카메라 사용 문자열을 각 로케일로 넣고, CFBundleLocalizations 8개 로케일 선언하고, listing copy도 과한 표현 걷어냈다. 한 커밋에 여러 사안이 묶였다.
스크린샷은 80장이었다. iPad와 iPhone 여러 사이즈에 8개 언어를 곱하면 금방 그 숫자가 된다. 여기서 아랍어가 문제였다. GeezaPro 폰트가 라틴 문자 'Pro'를 렌더링하지 못하고 두부(□)를 냈다. 캡션 전체를 아랍어로 바꿔서 해결했다. fix(store): ar screenshot 5 caption all-Arabic. 이런 건 시뮬레이터에서 직접 로케일 바꿔보기 전까지 잡기 어렵다. 영어 환경에서는 전혀 안 보이는 버그다.
iPad paywall shot도 문제가 있었다. 와이드 레이아웃에서 paywall이 가운데 정렬이 안 됐다. width-constraint도 추가했다. 스크린샷 파이프라인은 이런 것들이 쌓인다.
IAP 심사용 스크린샷을 따로 찍어서 올렸다. paywall 화면에 $4.99 가격이 보여야 한다. 업로더에도 hardening이 들어갔다. 이전에 업로드가 불안정했거나 retry가 없었던 것 같다.
시뮬레이터 erase도 파이프라인에 박았다. chore(store,notif): erase sim before capture. pending system prompt가 앱 삭제 후에도 시뮬레이터에 남는다는 걸 이번 작업 중에 확인했다. 그래서 캡처 전에 시뮬레이터를 지운다는 룰이 docs에도 올라갔다.
통합 테스트에서 finder ambiguity 문제도 있었다. 테스트가 여러 요소를 동시에 잡아서 에러가 났던 거다. scroll target 수정도 같이 들어갔다. pubspec에서 SPM 옵션도 껐다.
docs: TrayTime full submission complete (version 1.0 + IAP WAITING_FOR_REVIEW). 이 한 줄 커밋이 오늘 가장 깔끔한 마무리다.
Unpouched도 비슷한 여정을 걸었다. chore: quitcaffeine 스캐폴드 포크 + 무력화에서 시작해서 니코틴 파우치 금연 도메인으로 전면 rewrite했다. 앱 아이콘은 PIL 지오메트릭으로 민트 리밋 링 모양을 직접 그렸다. 형제 앱과 시각적으로 구분해야 한다는 판단이었다. 법적 문서 4종은 legal.hedvion.com/unpouched에 올려서 라이브 검증까지 마쳤다. 사진 경로 버그도 있었다. 절대 경로로 저장하면 기기 이전 시 깨진다. 상대 경로로 바꿨다.
리스팅에서는 'free' 단어 리터럴이 0건이어야 했다. fix: listing free-단어 리터럴 0건 (pouch-free→no-pouch). App Store 가이드라인에서 설명에 'free'를 쓰면 리젝 사유가 될 수 있다. pouch-free를 no-pouch로 전부 바꿨다. Play 스토어 closed testing도 제출 완료됐다.
봇 세 개가 하루에 형태를 갖췄다
ASO 트래커가 오늘 처음 만들어지고, 같은 날 여러 차례 고쳐졌다. 처음엔 iTunes Search API로 37개 앱의 키워드 순위와 평점을 긁어서 디스코드에 올리는 것. 그다음 버전에서 Google Play 스크랩을 추가했다. 포맷이 문제였다. 처음엔 아마 모든 앱 상태를 길게 늘어놨을 텐데, 랭크인된 것만 순위순으로 상세하게, 미노출이나 전멸은 한 줄로 압축하는 방식으로 바꿨다. 앱별 베스트 랭크 1개랑 전일 변동만 보여주는 방향으로 한 번 더 압축이 들어갔다. Unpouched도 추적 목록에 넣었다. ASC ID도 기록됐다.
AdMob 일일 수익 리포트는 09:10 KST cron으로 #일일-admob에 올라간다. 처음엔 top6만 보여줬는데 활동 전체 앱을 랭킹순으로 보여주는 방향으로 바꿨다. 플랫폼 태그도 달고, 1024자 필드 분할 처리도 했다. 디스코드 embed 필드 제한 때문이다.
niche-scout은 오늘 신설됐다. 매일 앱 니치 아이디어를 제안하는 봇인데, iTunes 실측까지 돌리고 반박 검증 단계를 거친다. 첫 실행에서 메트로놈, 튜너, 뽀모도로 같은 커머디티 유틸이 후보로 나와서 기각됐다. 그래서 제외 목록에 바로 추가했다. 첫 실행에서 기각 행 후보로 토큰 낭비하는 걸 막으려는 거다. 루프 로직도 바꿨다. 생존자가 나올 때까지 최대 4후보를 시도한다. 기각은 컴팩트 1장, 생존은 풀카드로 보여주고 기각사유가 피드백으로 들어가서 다음 제안에 반영된다.
문서가 쌓이는 이유
docs(mobile) 커밋이 다섯 개였다. 이건 단순 정리가 아니다.
"진상 손님 5명" adversarial 리뷰 게이트가 출시 파이프라인 문서에 들어갔다. 제출 전에 5명의 적대적 페르소나가 앱을 쓴다고 가정하고 리뷰를 시뮬레이션해야 한다. 만들고 나서 userDirective로 강화 커밋이 바로 왔다. "all hostile, no 'looks clean' escape". 하나라도 "깔끔해 보인다"고 통과하면 안 된다는 거다. 이 강화 커밋이 따로 있다는 게 의미심장하다. 처음에 만든 게이트가 너무 물렸던 것 같다.
Health & Fitness 앱은 IAP 제출 전 regulated medical device 선언이 필요하다는 것도 기록됐다. TrayTime이 해당된다. 치아교정 착용 시간 추적 앱이 의료기기 카테고리에 걸리는지 판단이 필요했던 거다.
simctl erase 룰은 traytime 작업 중에 직접 발견한 거다. pending system prompt가 앱 삭제 후에도 시뮬레이터에 살아있다는 게 확인됐다. 이제 캡처 전에 지우는 게 룰이 됐다.
출시 파이프라인 무인 진행 3룰. "unpouched 10h 스톨 사고"에서 나온 거다. 10시간. 장시간 자동화가 스톨 상태로 조용히 멈춰있었던 경험이다. 그 사건에서 배운 것들이 룰로 정리됐다.
Play 리스팅 키워드 채우기 룰도 추가됐다. 제목 30자, 짧은 설명 80자에 근접하게 채워야 하고, 긴 설명에 자연스럽게 배치해야 한다.
chore(psy): daily auto-generated tests는 그냥 봇이 돌아간 거다. inner-paradox-type, midnight-restroom-knock, sacred-geometry-fate 세 개. 커버 이미지까지 생성됐다. 수동으로 건드린 건 없고 파이프라인 결과물이 버전관리에 들어간 것이다.
오늘 feat보다 fix가 많았다. 처음부터 완전하게 만든 게 아니라는 소리다. 나는 이게 나쁜 패턴이라고 생각하지 않는다. 실제 개발이 그렇다. 스코프를 넣고, 구멍을 메우고, 또 구멍이 나오면 또 메운다. 파트너 포탈은 내일 또 구멍이 나올 수 있다. 즐거운은 IP 등록 기다리면 열린다.
TrayTime 심사관 손에 들어갔다.
댓글 0
첫 댓글 달아줘.