[업무일지] 지급 차단 배치를 대상·검사 두 축으로 나눈 단계적 롤아웃
매일 새벽 셀러 상태를 크롤링해서, 계좌가 바뀌었거나 부도가 났으면 다음 지급을 막는 배치입니다. 잘못 막으면 멀쩡한 셀러의 정산금이 묶이니까, 전부 한 번에 켜지 않고 일부 대상에만 켜서 단계적으로 넓혔습니다. 좁히는 축을 둘로 나눈 게 핵심이었습니다. 어떤 대상에 도느냐와, 그 대상에 어떤 검사를 도느냐.
셀러에게 정산금을 미리 지급하고 나중에 쇼핑몰 정산일에 회수하는 서비스입니다. 미리 줬는데 그 사이 셀러가 부도가 나거나 정산 계좌가 바뀌면 회수를 못 합니다. 그래서 매일 새벽 셀러 상태를 크롤링해서, 위험 신호가 잡히면 블랙리스트에 올려 다음 지급을 막습니다. 이 상태체크 배치를 새로 세워서 켜는 작업이었습니다. Java 21, Spring Boot 3.3.x, 매일 새벽 도는 스케줄드 배치 환경입니다.
이 배치가 틀리면 방향이 나쁘다
이 배치는 틀리는 방향이 안 좋습니다. 안 막아야 할 셀러를 막으면, 즉 오탐이 나면, 멀쩡한 셀러가 돈을 못 받습니다. 크롤링이 일시적으로 실패하거나, 판정 기준이 조금 세거나, 크롤한 값을 우리 도메인에 잘못 앉히거나, 이 중 어디 하나만 어긋나도 그건 곧바로 멀쩡한 셀러의 정산금이 묶이는 걸로 이어지고, 한번 묶이면 셀러가 문의를 넣고 우리가 확인해서 풀어줄 때까지 그 돈은 계속 잠겨 있습니다.
대상은 하루에 수천 건입니다. 여기에 새 판정 로직을 전부 한 번에 켜는 건 겁이 났습니다. 버그가 있으면 그날 아침 수천 명이 한꺼번에 막히는 거니까요. 그래서 일부 대상에만 켜서 며칠 지켜보고, 괜찮으면 조금 넓히기로 했습니다.
좁히는 축을 둘로 나눴다
여기서 판단이 하나 들어갑니다. 좁히는 방법이 한 가지가 아니라는 겁니다.
첫째 축은 대상입니다. 대상마다 구간 번호(seq)가 붙어 있어서, 처음엔 한 구간만 켜고 나머지 구간은 새 로직을 아예 안 돌렸는데, 그마저도 그 구간 전부가 아니라 개수를 50건으로 잘라서 며칠 지켜보다가 이상이 없으면 무작위로 섞어 200건으로 늘리는 식으로, 한 구간 안에서도 한꺼번에 다 돌리지 않고 표본만 조금씩 넓혀 갔습니다.
둘째 축은 검사 종류입니다. 블랙리스트 사유는 여럿입니다. 계좌 유효성, 부도, 대표자 변경, 다른 대출 여부 같은 것들이죠. 이걸 사유별 핸들러로 나눠 두고 있었습니다. 여기서 확신이 제일 높은 둘, 계좌 유효성과 부도만 처음부터 전 대상에 돌리고, 나머지 핸들러는 게이트로 묶었습니다.
1
2
3
4
5
6
7
8
9
// Java 21, Spring Boot 3.3.x
// 블랙리스트 매니저: 대상 구간이 낮으면 핵심 핸들러만 실행한다
Long seq = context.getSeq();
if (seq != null
&& seq < ACTIVE_THRESHOLD
&& !(handler instanceof InvalidAccountHandler
|| handler instanceof BankruptcyHandler)) {
return; // 안정화 기간: 확신 높은 핸들러만 전 대상에 돈다
}
축을 왜 둘로 나눴냐면, 한 축만 쓰면 곤란해서였습니다. 대상만 좁히면, 켠 구간에서는 모든 검사가 돌지만 안 켠 구간에서는 계좌 유효성 같은 핵심 검사조차 안 돕니다. 그러면 새 로직을 안정화하는 동안 넓은 대상이 기본 보호도 못 받는 셈이 됩니다. 그래서 핵심 검사 둘은 대상과 무관하게 처음부터 켜두고, 나머지 검사를 대상과 함께 천천히 넓혔습니다. 위험한 건 좁게, 확실한 건 넓게. 이 둘을 따로 조절할 수 있게 된 겁니다.
임계값을 조금씩 내려서 넓혔다
그다음은 지루한 반복입니다. 며칠 지켜보고, 로그와 블랙리스트 등록 건수가 이상 없으면 임계값을 한 칸 내립니다.
핸들러 게이트는 처음에 seq 10 미만을 걸어 두었다가 며칠 간격으로 7 미만, 5 미만으로 낮췄습니다. 이 숫자가 내려갈수록 전체 핸들러를 받는 대상이 넓어지는 구조고, 완료 처리 쪽도 한 구간에서 시작해 seq 6 초과, 4 초과로 나란히 풀었습니다. 1월 중순에 한 구간으로 켠 배치가 2월 하순에 전 대상이 되기까지, 6주 남짓 이 조절만 반복했습니다.
한 번에 크게 넓히지 않은 건 되돌릴 여지를 남기려는 거였습니다. 한 칸 넓혔는데 이상하면 그 한 칸만 되돌리면 되니까요. 크게 넓혔다가 문제가 나면, 어디까지가 새로 켠 대상 탓인지 가려내는 것부터 일이 됩니다.
한 번은 실행을 통째로 껐다
중간에 한 번 멈칫한 적이 있습니다. 켜둔 구간에서 뭔가 예상과 달라서, 블랙리스트 실행을 아예 주석으로 막아버렸습니다. 원인을 확인하는 동안 새로 막히는 셀러가 나오면 안 되니까요. 확인하고 나서 게이트를 다시 걸고 되살렸습니다.
지나고 보면 이 판단이 롤아웃보다 중요했습니다. 일부만 켜서 천천히 넓히는 것보다, 이상하면 즉시 전부 끌 수 있게 해두는 것이 안전장치였거든요. 넓히는 손잡이와 끄는 손잡이가 둘 다 손 닿는 곳에 있어야 합니다.
임시 게이트를 손으로 걷어냈다
전 대상이 안정화된 뒤, 그동안 심어둔 임시 코드를 걷어냈습니다. 매니저의 seq 게이트도, 완료 처리의 seq 조건도 지웠습니다. 이제 대상 구분 없이 모든 검사가 모든 대상에 돕니다.
이 임시 코드에는 ‘안정화 기간을 거친 후 제거 예정’이라는 주석이 붙어 있었습니다. 날짜로 스스로 없어지는 코드가 아니라, 사람이 “이제 됐다” 하고 지워야 하는 코드였죠. 켜는 손잡이를 심었으면 걷어내는 것까지가 한 세트인데, 그 마지막은 결국 사람 기억에 맡긴 셈입니다.
맺음말
두 축으로 나눈 건 잘한 선택이었습니다. 핵심 검사를 처음부터 전 대상에 두면서도 위험한 검사는 좁게 시작할 수 있었으니까요. 한 축만 있었으면 이 둘 중 하나를 포기해야 했을 겁니다. 넓히는 동안 blast radius 를 작게 유지한 것도 이 구조 덕이었습니다.
포기한 것과 남은 한계가 있습니다. 안정화됐다는 판정을 수치가 아니라 눈으로 했습니다. 매일 아침 로그와 블랙리스트 등록 건수, 사유별 분포를 봤는데, 오탐률 같은 지표를 자동으로 뽑아 임계선을 넘으면 멈추는 장치는 안 만들었습니다. 대상이 넓어질수록 눈으로 보는 것도 한계가 있었고요. 새 로직과 옛 로직의 판정을 나란히 비교하는 것도 안 했습니다. 옛 로직이 이미 걷혀 있어서 비교할 대상이 없었거든요.
그리고 임시 게이트가 seq 조건으로 두 클래스에 흩어져 있었습니다. 한쪽은 핸들러를 거르는 조건이고 다른 쪽은 완료 처리를 거르는 조건이라, 지울 때 한 군데라도 빠뜨리면 그 대상만 조용히 다르게 도는 거였죠. 걷어낼 때 양쪽을 같이 봐야 했습니다. 결국 이 배치의 롤아웃에서 제가 실제로 설계한 건 판정 로직이 아니라, 그걸 켜고 끄고 넓히는 손잡이였습니다.