일기 slecs

발송포탈 전면 확장에 앱 출시 준비까지, 욕심부린 날

목차

커밋 로그를 닫고 나서 멍하니 있었다. 40개 가까운 커밋이 오늘 하루에 쌓였다. 두 개의 완전히 다른 시스템, 완전히 다른 기술 스택에서 동시에 작업이 돌아갔다. 한쪽에서는 JSP와 MyBatis 쿼리 매퍼, 다른 한쪽에서는 Flutter와 iOS 프로비저닝 프로파일. 이 두 세계가 오늘 하루 안에서 교차했다.

욕심을 부렸냐고 하면, 솔직히 어쩔 수 없는 상황이었다고 하고 싶은데, 사실 일정을 좀 더 잘 분리했어야 했다.

권종이 어디 있는지 물어보기 전까지는 아무도 몰랐다

발송포탈 작업은 아침 일찍 시작됐다. 시작점은 단순했다. 대량발송 예약 목록에 쿠폰명이 표시가 안 된다는 것. 발송을 예약해놨는데 목록에 어떤 쿠폰인지가 안 나온다. 조회 쿼리를 열어보니 예약 발송 건을 가져오는 SELECT에 브랜드 명칭과 쿠폰명 필드가 빠져 있었다. 권종 테이블과의 JOIN 자체가 없었다.

쿼리 매퍼에 JOIN을 추가하고, JSP에 컬럼을 추가했다. 30분도 안 걸리는 작업이었는데, 거기서 질문이 생겼다. 예약 목록에 쿠폰명이 생겼으면, 발송이 완료된 이후 내역에는? 상세 화면에는?

보러 갔더니 역시 없었다. 당연히 없었다. 처음 만들 때 권종 표시가 요구사항에 없었거나, 있었어도 빠진 거겠지. sender-dispatch-history.jspsender-dispatch-detail.jsp 두 화면에 똑같이 쿼리 수정을 반복했다. 그리고 대시보드, 거래명세서로 이어졌다. dashboard-sender.jsp, sender-transaction-statement.jsp.

결국 오전 초반의 상당한 시간을 권종 정보 하나를 관련 화면 전체에 심는 작업으로 보냈다. 단순 반복처럼 보이지만 화면마다 쿼리 구조가 달랐다. 어떤 화면의 조회 쿼리는 이미 서브쿼리가 여러 겹으로 쌓여 있어서 JOIN 위치를 어디에 끼워 넣을지를 먼저 파악해야 했다. 잘못된 위치에 JOIN을 추가하면 카테시안이 나거나 의도하지 않은 행이 중복된다. 쿼리마다 확인하면서 하나씩 넣었다.

이걸 처음부터 일관되게 만들었으면 오늘 작업이 없었을 텐데. 그런 생각은 항상 나중에 한다.

coupon-issue.jsp에 여섯 번 손을 댔다

오늘 coupon-issue.jsp 하나에 여섯 개의 별도 커밋이 붙었다. 보통 한 파일을 여러 번 건드리면 하나의 커밋으로 묶는 게 낫지만, 오늘은 각각의 작업이 독립적인 맥락을 갖고 있어서 분리했다.

구분자 확장은 텍스트 입력에서 쉼표만 허용하던 걸 탭, 슬래시, +까지 허용하도록 바꾼 것. 엑셀에서 복붙하면 탭으로 붙여지는 경우가 있고, 슬래시 구분 파일을 그대로 들고 오는 경우도 있다는 피드백이었다. +는 국제 전화번호 포맷 때문에. 정규식 하나 고치는 거지만, 구분자가 늘어나면서 파싱 로직도 같이 검토해야 했다. 그리고 안내 문구가 "쉼표로 구분해서 입력하세요"라고 명시돼 있어서 그것도 고쳐야 했다.

탭 전환 버그는 구분자 수정 과정에서 테스트하다가 발견했다. 번호검사 탭에서 직접입력 탭으로 넘어가면 수신자 수가 0으로 표시된다. 실제 데이터가 날아간 게 아니라 탭 전환 이벤트 핸들러 안에서 카운터 렌더링이 리셋되는 문제였다. 표시는 0인데 내부 배열엔 수신자가 살아 있는 상태.

사용자 입장에서 수신자 수 표시가 0이 되면 데이터가 날아갔다고 받아들인다. 아무도 "표시만 0이고 실제로는 있습니다"라고 생각하지 않는다. 다시 입력하게 된다. UX 버그인데 심각도는 데이터 유실 버그급이다.

원인은 명확했지만 찾는 데 시간을 좀 썼다. coupon-issue.jsp 안에 스크립트 블록이 길고, 탭 전환 관련 이벤트 핸들러가 흩어져 있어서 추적하는 게 쉽지 않았다. 관련 변수와 함수를 다 찾아서 타이밍을 맞추는 방식으로 해결했다.

번호검사 회원대조 표 반응형 이탈은 CSS 문제였다. 테이블이 모바일 화면 너비를 벗어나서 삐져나오는 전형적인 케이스. 테이블 바깥을 감싸는 overflow-x: auto 래퍼를 추가하는 게 정석인데, 기존 마크업 구조상 어느 element에 감쌀지가 조금 생각해야 했다. 기존 구조를 많이 건드리지 않는 선에서 처리.

수신자 1회성 선택 버그 두 개는 서로 다른 원인이었다. 삭제 후 재선택 불가 - 수신자를 선택 목록에서 제거했을 때 내부 상태에서 "선택됨" 플래그가 완전히 클리어되지 않아서, 같은 수신자를 다시 선택하려 하면 이미 선택된 것으로 처리되는 문제. 발송 완료 후 재발송 불가 - 발송 플로우가 완료된 이후 특정 상태값이 초기화되지 않아서 발송 버튼이 영구적으로 비활성 상태에 갇히는 문제.

이 두 개가 동시에 터지는 상황을 상상하면, "쿠폰 보내고 나서 추가로 더 보내자"는 자연스러운 워크플로우가 완전히 막혀버린다. 같은 수신자를 다시 선택하려 해도 안 되고, 발송 버튼도 안 눌린다. 고치고 나서 보니 원인이 모두 명확해서 수정 자체는 어렵지 않았는데, 이게 지금까지 살아 있었다는 게 좀 의아했다.

권종 세트 장바구니 발송은 오늘 발송포탈 기능 중 제일 큰 것이다. 여러 권종을 장바구니에 담아서 수신자 전원에게 한 번에 보내는 방식. 기존엔 권종을 하나 선택해서 발송하는 단일 권종 구조였다. 이걸 바꾸면 발송 단위가 달라지기 때문에 JS의 상태 관리 방식을 바꾸고, 발송 요청 페이로드 구조도 수정하고, 서버 측 처리와 맞춰야 했다. JSP 파일 하나의 커밋으로 기록됐지만 실제론 프론트-백 양쪽에 걸친 작업이었다.

dedup 충돌과 그 뒤처리

오늘 중에 가장 신경 쓰인 건 쿠폰예치금 복원 dedup 충돌이었다.

발급 실패 시 예치금을 자동 복원하는 로직이 있다. 예를 들어 대량 발송 중 일부 건이 외부 모듈사에서 거절되면, 해당 건에 쓰인 예치금은 자동으로 복원돼야 한다. 이 복원 이력을 쌓을 때 중복 방지를 위한 dedup 처리가 있는데, dedup 키 구성 방식에 충돌이 있어서 동일한 복원 건이 두 번 쌓이는 경우가 생겼다.

버그 자체를 고치는 건 Java 코드에서 dedup 키 생성 로직을 수정하면 됐다. 문제는 이미 운영 중에 중복 이력이 쌓인 것들이다. 그래서 CORRECTION_20260801_day_sender_deposit_restore.sql 파일이 함께 커밋됐다. 잘못 쌓인 중복 이력을 정리하는 보정 SQL.

이런 보정 SQL 작업은 항상 조심스럽다. SELECT로 영향 범위를 먼저 확인하고, 조건이 맞는지 여러 번 검토한 다음에 실행한다. 쿼리 하나 잘못 건드리면 복원돼야 할 예치금 이력이 날아갈 수 있다. 실행하고 나서도 한동안 이력 목록을 지켜보게 된다.

이 과정에서 복원 이력 문구도 통일했다. 이전에 "자동복원", "쿠폰 예치금 복원", "복원(발송 실패)" 등으로 제각각이던 게 '쿠폰예치금 복원(자동)'으로 일원화됐다. 예치금 이력 목록 화면에서도 이 라벨이 표시되도록 쿼리와 JSP를 함께 수정.

외부 공급사 판매중지 자동 전이 기능도 같은 흐름에서 나왔다. 외부 모듈사에서 쿠프 86 오류(판매중지상품 발급 거절)를 반환하면, 해당 상품을 내부에서도 자동으로 판매중지 상태로 전환한다. 이게 없으면 이미 판매 못하는 상품에 계속 발급을 시도해서 실패 이력만 쌓인다. 오류 코드 86을 특정해서 내부 상품 상태를 변경하는 연동 로직. 모듈사마다 오류 코드 체계가 다를 수 있어서 확장 지점을 열어두는 방식으로 구현했다.

정산 시스템, 레이어가 많다

수익 대시보드와 정산 시스템 쪽 작업이 오늘 꽤 무거웠다. 커밋 수로도 많지만 각각의 커밋이 비즈니스 로직과 데이터 모델, UI가 동시에 얽혀 있어서 단순 UI 작업보다 인지 비용이 높다.

발행사 매입대금(부채성) 카드를 수익 대시보드에 추가하는 작업이 두 커밋에 걸쳐 진행됐다. 처음에 별도 카드로 붙였는데, 봤더니 맥락상 수익 구성 표 안에 한 행으로 들어가는 게 더 자연스럽다는 판단이었다. 위치를 재배치했다. 매입대금 집계 조건도 처음엔 전체 쿠폰을 대상으로 하다가 coupon_source=EXTERNAL인 외부 매입 쿠폰만 집계하도록 좁혔다. 내부 발행 쿠폰을 여기에 섞으면 수치가 틀어진다.

정산 출금 A안. PG 정산분 제외를 기본 한도로 하고, PG 포함 선택지를 옵션으로 제공하는 구조. 이 부분은 코드만 봐서는 짤 수 없다. PG 정산분이 실제로 언제 들어오는지, 출금 가능 잔액 계산 시점에 포함되면 어떤 문제가 생기는지, 정산 사이클 안에서 타이밍이 어떻게 맞아야 하는지를 이해해야 한다. 비즈니스 로직과 현금 흐름 흐름을 같이 알고 있어야 하는 영역. 쿼리 매퍼에서 출금 가능 한도를 계산하는 쿼리를 수정하고, _merchant-withdraw-partial.jsp에 PG 포함/제외 선택 UI를 붙였다.

전액 출금 초과분 처리도 수정했다. 전액 출금 시 잔액보다 초과되는 부분은 마감 정리분으로 별도 표기하고, 이후 달의 수익에서 자동으로 차감하지 않도록 했다. 이게 중요한 이유는 초과분을 다음 달 수익에서 자동 차감하면 운영자 입장에서 수익이 갑자기 줄어드는 것처럼 보이는 혼란이 생기기 때문이다.

월별 수익 원장 개편은 오늘 작업 중 UI 면에서 가장 공이 들어간 것이다. 기존에 용어가 내부 시스템 용어 그대로여서 화면을 봐도 숫자의 의미를 해석해야 했다. 이걸 운영자가 직관적으로 읽을 수 있도록 쉬운 말로 바꾸고, 달별 이월 잔액 열을 추가했다. 이월 잔액 열이 있어야 "지난달에서 얼마가 넘어왔고, 이번 달에 얼마가 발생해서, 다음 달로 얼마를 넘기는가"를 한 줄로 볼 수 있다. 설명 박스도 추가했다. '오늘 돈 흐름' 카드 순서도 복구.

FIFO 소진 표시도 붙었다. 플랫폼 출금이 월별 수익 원장에서 FIFO 방식으로 소진될 때 어떤 달의 수익부터 쓰이는지가 표시된다.

신규 대리점 기본 예치 룰 10% 자동 생성과 미설정 12곳 백필. 이게 오늘 데이터 영향 범위가 있는 작업 중 가장 넓다. 예치 룰이 없는 대리점은 정산 계산이 맞지 않는다. 신규 대리점 등록 시 기본값 10%를 자동으로 넣도록 로직을 추가하고, 이미 등록됐지만 룰이 없는 12개 대리점에는 백필. 대리점 관련 Java 클래스가 여럿 수정됐다.

스코프 매니저 하위 환불 허용도 있었다. 스코프 매니저 권한을 가진 사용자가 담당 대리점 건에 한정해서 환불 처리를 할 수 있도록. 권한 범위를 명확히 제한하는 게 핵심이었다. 담당 대리점 건이 아닌 경우에는 여전히 막혀야 한다.

엑셀 컬럼이 다 빈 값인 이유

파트너포탈 정산내역 엑셀 버그. 금액 앞에 오는 컬럼들이 전부 빈 값으로 나왔다. ExcelConfigRegistry.java를 봤더니 컬럼 매핑 인덱스가 어긋나 있었다. 엑셀 생성 시 몇 번째 컬럼에 어떤 데이터를 넣는지 정의하는 부분인데, 중간에 컬럼이 추가되거나 순서가 바뀌면서 인덱스가 틀어진 것으로 보였다.

이런 버그가 귀찮은 이유는 런타임 에러가 없다는 것이다. 코드는 정상 실행되고, 엑셀 파일도 생성된다. 그런데 열어보면 값들이 엉뚱한 위치에 있거나 비어 있다. 로그로는 안 잡힌다.

ATT 키가 없으면 광고 없는 앱도 막힌다

TankLog 쪽 작업으로 넘어오면, 오늘 두 개의 중요한 체크포인트가 있었다. iOS 스토어 제출과 Android Play 비공개 테스트 게시.

iOS 심사 제출 과정에서 ITMS-90683이 터졌다. NSUserTrackingUsageDescription 키가 Info.plist에 없다는 오류. 트래킹 관련 키인데, 광고를 전혀 안 하는 앱에도 이 키가 있어야 심사 제출이 된다는 걸 이번에 다시 확인했다. 논리적으로는 이상하다. 광고 트래킹이 없으면 ATT 동의 팝업도 안 띄우고, 그러면 이 키가 왜 필요한가. 그런데 Apple의 검증 로직은 키 존재 여부만 본다.

Info.plistNSUserTrackingUsageDescription을 추가하고, 빌드 번호를 1.0.0+2로 올렸다. docs/mobile-app-launch.md에 이 함정을 기록했다. 광고 없는 앱인데도 ATT 키 없으면 409 응답으로 제출이 막힌다고.

iOS 실배포 프로파일 발급도 오늘 했다. tanklog AppStore 20260801. ExportOptions.plist에 정확한 팀 ID와 프로비저닝 프로파일 UUID를 맞추고, project.pbxproj에서 코드 사이닝 설정을 교체했다. .mobileprovision 파일도 교체. 이 작업이 귀찮은 건 여러 파일에서 UUID가 각각 맞아야 하는데, 하나라도 안 맞으면 빌드 서명 단계에서 실패한다. 그리고 오류 메시지가 항상 직관적이지는 않다.

Play 쪽은 비공개 테스트를 게시 완료했다. 가격은 $6.99. Play 콘솔에서도 함정 2건을 기록했다. Play 콘솔 함정은 iOS와 별도로 있다. 국가별 콘텐츠 등급 설정, 타겟팅 설정 등에서 비공개 테스트 게시 전에 먼저 처리해야 하는 것들이 있다.

스샷 50컷과 tofu 수정

스크린샷 작업이 오늘 TankLog에서 꽤 많은 시간을 먹었다. iOS용으로 만든 원본을 재합성해서 Play용으로 50컷을 뽑았다. Play는 iOS와 해상도와 비율이 달라서 단순히 같은 파일을 쓸 수 없다. 피처 그래픽과 512 아이콘도 별도로.

그리고 프레임 헤드라인 tofu 문제. tofu는 해당 언어의 글리프를 지원하지 않는 폰트를 썼을 때 나오는 빈 블록(□) 현상이다. 독일어, 프랑스어, 스페인어, 이탈리아어 헤드라인에 특수문자가 들어가는데, 프레임에 쓰던 폰트가 그 글리프를 커버하지 못했다. 예를 들어 독일어 ü, ö, 프랑스어 é, à 같은 것들.

로케일별로 폰트를 따로 지정하도록 바꾸고, 렌더링 전에 글리프 커버리지를 확인하는 게이트를 추가했다. 지원하지 않는 글리프가 있으면 폴백 폰트를 쓰도록. 이런 문제는 실제로 각 로케일에서 렌더링해보기 전에는 잘 안 보인다. 한국어나 영어로만 확인하면 절대 발견 못 하는 버그.

리스팅 텍스트는 8개 로케일로 준비했다. ko, en, de, fr, es, it, ja 에 더해 pt까지. 앱 이름 로컬라이징도 했다. ASC PATCH로 로케일별로 각각 반영하는 방식. reef/ammoniac 오타 수정, 하드코딩된 가격 제거도 함께. 리스팅에 "$6.99" 같은 가격을 직접 박아두면 나중에 가격 변경 시 모든 로케일 리스팅을 일일이 바꿔야 한다. 스토어 자체에서 가격을 관리하게 두는 게 맞다.

IAP 심사 스샷도 준비했다. paywall_screen.dartpurchase_service.dart, screenshot_harness.dart가 같이 커밋됐다. 인앱결제가 있는 앱은 Paywall 화면 스크린샷을 심사 메모에 첨부해야 통과되는 경우가 많다. 미리 준비한 것.

리뷰 지적들을 다 받아내면서

코드리뷰 결과도 오늘 여러 배치로 반영됐다. 출시차단 3건, 권고 6건. 1.1에서는 P0 2건, P1 3건, P2 7건. AndroidManifest.xml과 Info.plist 수정, l10n 파일들 수정이 포함됐다.

Pro 기능 4종도 1.1에서 추가됐다. 사이클 코치, 유지보수, 목표범위, 사진. 각각 6개 언어 리소스 파일에 스트링이 들어갔다.

기록이 0인 탱크 카드에 행동 유도 문구를 추가한 것도 있다. 6개 로케일. 빈 화면이 그냥 비어 있으면 사용자가 어디서 시작해야 하는지 모른다. 한 줄의 문구가 전환율에 실제로 영향을 준다.

이 뒤로 남는 것들

포트폴리오에 TankLog 앱 카드를 추가했다. index.html에 카드 추가하고 썸네일 이미지 커밋. 새 앱이 출시 준비를 마치면 하는 루틴 작업이다.

psy 쪽은 일별 자동 생성 테스트 2종이 올라왔다. candle-flame-oraclecompliment-reaction-type. 이건 자동화된 파이프라인이 커밋한 것이라 오늘 내가 직접 작업한 게 아니다.

오늘을 정리하면 coupon-issue.jsp에만 여섯 번 손이 갔고, 두 개의 완전히 다른 시스템이 동시에 돌아갔으며, 데이터 보정 SQL이 하나 나갔다. 가장 찝찝한 건 역시 dedup 보정 SQL이다. 코드는 고쳤지만 운영 데이터를 직접 건드리는 작업은 며칠을 더 지켜봐야 마음이 놓인다. TankLog는 양쪽 스토어 모두 체크포인트를 찍었다는 것 자체는 진전이다. iOS 심사 결과를 기다리는 시간이 이제 시작됐다.

댓글 0

첫 댓글 달아줘.