민원 홀드 엔진을 하루 만에 착지시키다
목차
오늘 커밋 수를 세봤더니 스물다섯 개가 넘었다. 열어보기 전부터 안다. 뭔가 크게 치고 들어가는 날이라는 걸. 그리고 실제로 그랬다.
아침은 CS로 시작했다
정확히는 어젯밤 새벽부터 CS가 들어오기 시작했다. 충전이 안 된다는 것이다. 은행 점검 시간대라는 건 알고 있었다. 그런데 화면에 표시된 점검 안내 문구가 실제 점검 창과 맞지 않았다. 코드에 박혀 있던 점검 시작 시각이 틀렸다. 문서에 적힌 걸 그대로 믿었고, 실측을 안 했다.
아침 첫 커밋이 결국 CS 대응이 됐다. 은행 점검 시작 시각을 직접 확인해서 23:20으로 정정하고, 충전 화면 두 군데에 점검 안내 배너를 달았다. charge.jsp, charges.jsp 양쪽에 배너가 들어갔고, wallet.css와 wallet.scss까지 손댔으니 스타일도 새로 잡은 거다. 충전을 시도하면 "현재 점검 시간입니다" 류의 안내가 뜨도록.
기분이 좋지 않았다. 운영에서 터진 뒤에야 고치는 흐름이 탐탁지 않다. 적힌 것을 믿으면 안 된다는 걸 알면서도, CS가 먼저 들어오고 나서야 움직이는 게 계속 반복되는 것 같아서. CS 처리를 끝내고 나서야 오늘의 본작업이 기다리고 있었다.
민원 홀드 엔진을 짜다
민원이 접수되면 해당 파트너의 출금 가능 잔액 일부를 묶어두는 기능이다. 분쟁이 확정되기 전에 돈이 출금돼버리면 회수할 방법이 없으니, 선제적으로 홀드를 걸어서 민원이 해소되면 풀고 확정되면 차감하는 구조다.
테이블을 네 개 신설했다. 홀드 자체를 기록하는 테이블, 상태 변이 이력 테이블, 캐스케이드 판정 테이블, 공휴일 마스터. 핵심 로직은 ComplaintHoldUtil이라는 유틸 클래스가 가져가고, 민원 확정 시 훅이 물리고, 배치 잡이 만기 판정과 차감을 처리한다.
공휴일 마스터(tb_holiday_master)에 2026~2027년 시드 데이터도 오늘 밀어넣었다. 홀드 만기를 영업일 기준으로 계산하는데, 공휴일 데이터가 없으면 토요일이나 공휴일이 만기일로 잡혀버린다. 개발 DB 적재는 오늘 완료했고, 운영 DB는 배포 때 별도 처리가 필요하다. INSERT_20260818_holiday_seed.sql 파일로 만들어져 있다.
GREATEST 바닥값 보정이라는 표현이 커밋 메시지에 있다. 홀드 금액 계산 시 음수로 떨어지는 케이스를 막기 위해 SQL 레벨에서 GREATEST(계산값, 0)을 씌우는 것이다. 잔액보다 많은 금액을 홀드하려 하거나, 차감 후 음수가 나오는 상황을 방어한다.
초안이 나오자마자 코드 리뷰를 돌렸다. 그게 오늘 하루의 진짜 중심이 됐다.
Opus 리뷰 세 번
첫 번째 결과가 P0 두 건, P1 다섯 건이었다.
P0 첫 번째는 래칫 로직 부재였다. 래칫은 홀드 금액이 한 번 올라가면 그 밑으로 내려가지 않게 하는 것이다. 파트너 잔액이 늘어날 때마다 홀드 기준을 재계산해서 금액이 줄어드는 상황이 생기면 안 된다. 민원 홀드는 보수적으로 유지돼야 한다. P0 두 번째는 forfeit 클램프였다. 홀드된 금액을 차감할 때 원래 홀드액을 초과해서 깎이는 것을 막는 처리인데, 이게 없으면 특정 조건에서 파트너 잔액이 마이너스로 들어간다.
이 두 개를 첫 버전에서 놓쳤다. 돈 다루는 로직에서는 기본 중에 기본인 것들이다. 초안을 빠르게 짜면서 경계값 처리를 나중으로 미루는 나쁜 습관이 또 나온 것이다. 변명은 안 한다.
P1 다섯 건도 만만치 않았다. 판정 쿼리에서 순액 기준으로 잡아야 할 컬럼이 총액으로 잡혀 있었고, 시스템 ID 바인딩이 일부 빠져 있었고, 락 범위가 필요 이상으로 넓었다. 여러 트랜잭션이 동시에 들어올 때 데드락 가능성을 키우는 구조였다. 자동 캐스케이드 A모드도 이 첫 번째 반영 커밋에 같이 붙었다. 캐스케이드는 파트너 본인 잔액으로 홀드 목표를 채우지 못할 때 상위 파트너 잔액에서 나머지를 채우는 로직이다.
두 번째 리뷰 사이클에서 P2가 나왔다. 만기 계산 fail-closed 처리, hold_amount 절대대입 클램프, 포탈 쿼리에서 sysId 바인딩 누락, toInt 가드.
toInt 가드는 쿼리 결과가 null이거나 예상치 않은 타입으로 반환될 때 Integer 캐스팅에서 NPE가 터지는 걸 방어하는 것이다. 만기 계산 fail-closed는 공휴일 데이터가 없거나 계산 자체가 실패할 때 가장 짧은 기간으로 잡아버리는 것. 홀드는 항상 보수적 방향으로. hold_amount 절대대입 클램프는 홀드 금액을 설정할 때 상한을 절대값으로 박는 것이다.
그리고 자동 캐스케이드 hold_until 24시간 컷오프가 단독 커밋으로 하나 더 나왔다. codex 재검증에서 FAIL이 났다는 표현이 있다. 캐스케이드 판정을 할 때 24시간 이내에 생성된 홀드만 대상으로 삼아야 한다는 것이다. 이 컷오프가 없으면 오래된 홀드까지 캐스케이드 판정에 끌려 들어가서, 이미 처리됐어야 할 건들이 계속 상위 파트너 잔액을 건드린다. 파트너 입장에서는 갑자기 이유 모르게 금액이 묶이는 것처럼 보이게 된다.
세 차례 리뷰 사이클을 하루 안에 전부 처리했다. 피곤하긴 했지만 이렇게 하는 게 맞다는 생각은 든다. 돈이 움직이는 로직은 버그 하나가 운영에서 수습이 어렵다.
화면 레이어가 한꺼번에 따라왔다
엔진이 어느 정도 안정되자 화면을 붙이는 작업이 쏟아졌다.
관리자 쪽 /admin/complaint-case 화면이 새로 생겼다. list.jsp, create.jsp, detail.jsp 세 개가 모두 만들어졌고 엑셀 다운로드도 붙었다. ExcelConfigRegistry.java가 두 커밋에 걸쳐 수정된 걸 보면 컬럼 구성이 중간에 한 번 바뀐 것이다. 엑셀은 업무팀이 직접 쓰는 거라 컬럼 순서나 레이블이 잘못 들어가면 나중에 수정 요청이 온다. 처음에 잘 잡아야 한다.
파트너 포탈에는 /partner/complaint 화면이 생겼다. 본인 파트너에 걸린 홀드 목록과 각 홀드의 현재 상태가 보인다. 홀드 상태를 파트너가 이해할 수 있는 언어로 표시해야 한다는 게 까다로운 지점이었다. 내부 상태 코드를 그대로 보여주면 안 되니까.
대시보드 두 군데도 수정했다. 관리자 전체요약 대시보드에는 민원 홀드 합계 카드가 머천트 가용 잔액 카드 옆에 붙었다. complaint_hold.jsp라는 조각 파일이 따로 만들어져서 main.jsp와 total-summary.jsp에서 include하는 구조다.
포탈 파트너 대시보드의 출금가능 카드도 바꿨다. 기존에는 출금 가능 금액을 단일 숫자로 보여줬는데, 이제는 잡힌 금액과 잠금 대기 금액을 분해해서 표시한다. 출금 가능 금액이 예상보다 적을 때 파트너가 이유를 모르면 CS가 들어온다. 분해 표기가 그 CS를 선제적으로 막는다. 민원 페이지 링크도 카드 안에 함께 달았다.
관리자 파트너 상세 페이지에도 홀드 분해 표기를 넣었다. 잡힌 액, 잠금 대기 금액, 민원 목록 링크. 운영팀이 특정 파트너 상세로 들어갔을 때 민원 홀드 현황을 바로 파악할 수 있어야 한다.
0원짜리도 표시해야 한다
대시보드 민원 홀드 fix 커밋이 하나 따로 있다. 잡힌 돈이 0원인 케이스를 처음에는 숨겼는데, 보여주는 방향으로 바꿨다.
잔액이 0인 파트너에게 홀드가 걸리면 "잡힌 액 = 0원, 잠금 대기 = 목표 전액"이 된다. 이 상태를 0이라는 이유로 숨기면, 운영자 입장에서는 홀드가 아예 안 걸린 것처럼 보인다. 실제로는 홀드가 걸렸고 상위 캐스케이드 대기 중인 상태인데. 목표 금액과 현재 부족분을 표시해야 상황이 파악된다.
JSP, SQL 쿼리 매퍼, 파트너 Java 컨트롤러 세 군데를 수정했다. 이런 케이스가 초기에 잘 안 잡히는 이유는 항상 같다. 테스트를 잔액이 충분한 파트너로만 한다. 잔액 0짜리 파트너로 민원 홀드를 걸어보는 시나리오를 처음부터 넣었으면 됐을 일이다.
민원인 이름 정리와 memo 필드의 여정
민원인 필드가 오늘 정리됐다. 처음에는 별도 입력 컬럼으로 뒀다가, 민원인은 항상 민원 대상 회원이므로 member_sn으로 회원 테이블을 조인해서 이름을 가져오는 구조로 바꿨다. 별도 컬럼을 제거하는 ALTER가 ALTER_20260818_complaint_complainant_name.sql이다. 먼저 fix로 데이터 모델을 정리하고, 이어서 feat으로 등록/수정 폼, 관리자 목록/상세/엑셀, 포탈 본인/하위 민원 리스트에 이름 표시를 붙였다.
memo 필드는 하루에 타입이 두 번 바뀌었다. 처음 VARCHAR(500)으로 설계됐는데 운영 등록에서 "Data too long"이 터졌다. 일단 TEXT로 올렸다. 그랬더니 사용자가 VARCHAR(2000)으로 확정해달라고 했다. TEXT보다는 길이가 명시된 VARCHAR를 선호한다는 이유였다.
ALTER_20260818_complaint_memo_text.sql 파일이 두 커밋에 걸쳐 수정됐다. VARCHAR(500) → TEXT → VARCHAR(2000). 처음 설계 시 실사용 케이스를 더 꼼꼼하게 잡았으면 한 번에 끝났을 일이다. 민원 비고는 상황 설명이 길게 들어올 수 있는 필드다. 500이 처음부터 너무 작았다.
알림, 동결, UX 마무리
파트너 알림 로직도 붙었다. 민원 홀드 설정 시, 해제 시, 차감 시 각각 인앱 통지가 발송된다. 유틸 클래스에서 알림 발송을 처리하고 쿼리 매퍼에서 대상 토큰을 조회하는 구조다.
민원 대상 회원 자동 동결도 오늘 들어갔다. 민원이 접수되면 해당 회원 계정이 자동으로 동결된다. 분쟁 진행 중에 해당 회원이 계속 서비스를 이용하는 것을 막는 조치다. 포탈 하위 민원 상시 노출과 같은 커밋에 묶여 있다.
등록 UX fix는 세 가지가 하나로 묶였다. 민원 대상 회원 검색 자동완성, 파트너 필드 공백 검색, 하위민원 섹션 조건부 표시. 공백 검색은 파트너 필드에 아무것도 입력하지 않고 검색을 눌렀을 때 전체 리스트가 나오게 하는 것이다. 처음에는 공백으로 검색하면 결과가 아예 안 나왔다. 하위민원 섹션은 상위 파트너 체계가 없는 케이스에서 섹션 자체를 숨기는 처리다. 필요 없는 섹션이 항상 보이면 폼이 불필요하게 복잡해진다.
운영 배포 준비 SQL
UPDATE_20260818_complaint_menu_enable_prod.sql. 민원 메뉴를 운영 DB에 use_yn=N으로 선반영해두는 SQL이다. DB 항목을 코드보다 먼저 넣되 메뉴가 실제로 보이지는 않게 한다. 코드 배포가 완료된 뒤에 이 SQL을 실행해서 use_yn=Y로 전환하면 메뉴가 활성화되는 구조다.
배포 순서를 분리해서 관리하는 방식이다. 코드와 DB가 동시에 올라가야 하는 상황에서, DB를 먼저 준비해두고 코드 배포 타이밍에 맞춰 활성화하는 것이다. 배포 담당자가 이 SQL 파일의 존재를 알고 있어야 코드 올린 뒤 잊지 않고 실행하게 된다. 커밋 히스토리에 남아 있으니 추적은 되는데, 배포 체크리스트에도 따로 명시해두는 게 안전하다.
iOS 쪽도 챙겼다
메인 작업과 별개로 iOS도 건드렸다. casconfig.rb 사본이 추가됐다. CAS 미온보딩 대기군이라는 표현이 커밋 메시지에 있는데, 온보딩 절차가 완료된 뒤에 실행할 예정인 설정 파일이다. 지금은 준비만 해두는 상태.
Podfile.lock에 실사용 pod 열한 개를 반영하고, 고아가 된 SPM Package.resolved 파일 두 개를 제거했다. CocoaPods과 Swift Package Manager가 섞여 있을 때 한쪽에서 관리하지 않는 잔재 파일이 생기는 경우가 있다. 빌드 과정에서는 무시되더라도 저장소에 불필요한 파일이 남아 있으면 나중에 누군가 보다가 혼선이 생긴다.
빌드 넘버는 1.0.3+9. positive purpose strings 포함 재빌드라는 표현이 있는데 앱스토어 심사 관련 문자열이 포함된 빌드다. pubspec.yaml만 변경됐다.
psy 쪽에서는 자동 생성 테스트 세 종이 들어갔다. family-camping-type, midnight-laundromat, secondhand-trade-type. webp 커버 이미지와 JSON 파일이 같이. 이건 별도 스케줄로 돌아가는 자동화라 오늘 직접 손댄 것은 아니다.
하루를 마감하며 커밋 목록을 다시 훑는다. 민원 홀드 시스템 하나를 처음부터 끝까지 땄다. 엔진 설계, 리뷰 사이클 세 번, 화면 레이어, 알림, 동결 로직, 대시보드 통합, 민원인 이름 정리, DB ALTER 여러 차례, 운영 배포 준비까지. 거기에 새벽 CS 처리, iOS 정리도 끼어 있다.
P0 두 건이 여전히 걸린다. 래칫과 클램프는 금융 로직 기본인데 초안에서 빠졌다. 리뷰 사이클이 세 번 돌아간 게 시간을 많이 먹었고, 그 시간은 다른 무언가를 밀어낸다. 다음에 비슷한 구조를 짤 때는 경계값 처리를 체크리스트에 처음부터 넣어두는 수밖에 없다.
memo가 두 번 바뀐 것도 마찬가지다. 사전에 요구사항을 더 구체적으로 잡았으면 한 번에 끝났다.
내일 코드 배포 후 메뉴 활성화 SQL까지 실행되면 민원 홀드가 운영에 올라간다. 리뷰를 세 차례 돌았으니 잡을 것은 어느 정도 잡았다는 믿음은 있다. 다만 0원 케이스나 캐스케이드 경계에서 예상 못 한 케이스가 운영에서 나올 가능성은 완전히 없다고 말하기 어렵다.
댓글 0
첫 댓글 달아줘.