BE-commerce 프로젝트
약 5분 분량 결제·정산

정산 배치에서 처리량과 가맹점 공정성 중 무엇을 우선할 것인가

목차

틱 용량을 1,000건으로 잡고 큰 가맹점 2,500건·작은 가맹점 10건을 시드해 돌려보니 작은 가맹점이 이틀을 기다렸습니다.

정산 배치는 한 틱에 최대 settlement-max-pages × read-chunk-size 건을 전역 id 오름차순으로 읽습니다. 그래서 가맹점 한 곳이 그 틱의 페이지를 다 채우면 뒤 가맹점의 항목은 이 틱에 안 읽히고 다음 틱으로 밀립니다. 처리량과 공정성 중 무엇을 포기할지 정해야 했습니다.

결론부터

  • id 순서는 남는 처리 용량을 가장 잘 쓰지만, 큰 가맹점 하나가 틱을 독점하면 작은 가맹점이 이틀 밀렸습니다.
  • 판매자별 상한만 두면 독점을 막을 수 있지만 판매자가 적은 날에는 처리 용량이 놀 수 있습니다.
  • 상한과 재분배를 함께 적용하면 첫 패스에서 모든 가맹점에 기회를 주면서 남는 용량도 재사용할 수 있었습니다.

이 글에서 말하는 공정성은 모든 가맹점을 같은 속도로 끝내는 것이 아니라, 작은 가맹점이 큰 가맹점의 물량 때문에 첫 처리 기회를 잃지 않게 하는 것입니다.


① 상한과 재분배: 처리량과 공정성은 같이 살 수 없었다

큰 가맹점에 큐를 몰아주면 작은 가맹점이 굶고, 공평하게 나누면 큰 가맹점 처리가 밀립니다. 정할 것은 “어떻게 하면 둘 다 좋은가”가 아니라 누구를 얼마나 굶길 것인가였습니다. 정책은 셋을 만들고 실측했습니다: id 순(기본) · 판매자별 상한 · 상한+재분배(상한+재분배가 구현한 정책이고 설정값은 round-robin입니다).

틱 용량 1,000건으로 재현

틱 용량을 작게 잡아 재현했습니다. 틱 용량 1,000건(max-pages 2장 × read-chunk-size 500건)으로 두고 큰 가맹점 2,500건(id 낮음)과 작은 가맹점 10건(id 높음)을 어제 날짜로 시드했습니다.

같은 시드를 두 정책으로 돌린 틱 1~3 배분 비교. id 순은 큰 가맹점이 1,000·1,000·500건을 가져가 작은 가맹점 10건이 셋째 틱에야 읽히고(2일 지연), 상한+재분배는 첫 틱에 큰 가맹점 500건과 작은 가맹점 10건이 함께 읽혀 작은 가맹점이 당일(0일) 끝나고 큰 가맹점이 500·1,000·1,000건으로 셋째 틱에 2,500건을 마친다

settle()은 그 날짜 이하의 미정산 항목을 id 오름차순으로 읽습니다. 그래서 작은 가맹점의 항목은 큰 가맹점의 재고가 다 빠질 때까지 뒤에 있습니다. 배치를 하루 한 틱씩(날짜를 하루씩 넘겨) 돌리며 세었습니다.

정책첫날 큰 가맹점이 가져간 몫작은 가맹점 전건 정산까지큰 가맹점 전건 완료
id 순(원래 동작)1,000건 = 틱의 100%2일 지연2일
판매자별 상한500건 = 틱의 50%0일(당일)2일
상한+재분배(구현, round-robin)500건 = 틱의 50%0일(당일)2일

작은 가맹점은 이틀을 기다렸습니다. 큰 가맹점이 그 틱의 페이지를 전부 채웠기 때문입니다. 물량이 작을 때는 드러나지 않고, 한 곳이 커지면 나타납니다.

기준과 비교, 고른 것

기준은 하나였습니다. 첫 패스에서 모든 가맹점이 최소 한 장은 읽혀야 합니다. 그래야 작은 가맹점이 다음 틱으로 밀리지 않습니다. 그리고 그 위에서 남는 용량을 버리지 않아야 합니다.

  • id 순(원래): 남는 용량을 안 버리고 처리량이 가장 높습니다. 대신 한 곳이 틱을 독차지하면 뒤가 굶습니다. 작은 가맹점이 며칠씩 밀립니다
  • 판매자별 상한: 가맹점마다 ceil(예산/가맹점수) 장으로 묶습니다. 독차지는 막히지만 물량이 적은 가맹점이 있으면 남는 용량이 놉니다
  • 상한+재분배(구현): 상한을 두고 그래도 용량이 남으면 재고가 있는 가맹점에게 한 장씩 더 줍니다. 독차지는 막으면서 용량도 안 버립니다

상한만 두고 재분배를 안 하면 작은 가맹점이 없는 날 큰 가맹점의 용량이 놀아 처리량이 떨어집니다. 그래서 상한과 재분배를 한 정책에 합쳤습니다. 미정산 재고가 있는 가맹점 목록을 먼저 얻고, ① 가맹점마다 상한까지 읽고 ② 남으면 재고가 있는 가맹점에 한 장씩 더 읽습니다.

한 가지 헷갈리기 쉬운 곳을 짚습니다. 상한 정책이 첫날 큰 가맹점을 500건(1장)으로 묶는 것은 상한이 가맹점 수로 나뉘기 때문입니다. perSellerPages = ceil(maxPages / 가맹점 수)이고, 첫날은 가맹점이 둘이라 각 1장(500건)씩 가져가 틱 예산 두 장이 다 소진됩니다(작은 가맹점은 10건뿐이라 그대로 끝). 작은 가맹점이 빠진 다음 날부터는 판매자가 큰 가맹점 하나뿐이라 ceil(2/1) = 2장을 혼자 써서 1,000건/일이 됩니다. 그래서 큰 가맹점은 500 → 1,000 → 1,000으로 세 번째 틱에 2,500건을 다 채웁니다(표의 “2일”은 이 실험이 완료를 틱 인덱스로 세는 값입니다. id 순도 같은 틱에 끝납니다). “상한 500건 × 5일”이 아니라, 첫날만 절반이고 그 뒤로는 혼자 틱을 다 쓴다는 것이 핵심입니다.

대가는 큰 가맹점이 냅니다. 첫날 하루 지급액이 1,000 → 500건으로 절반이 됩니다. 이 재현(가맹점 둘)에서는 판매자별 상한과 상한+재분배가 같은 값을 냅니다. 둘을 한 정책(상한+재분배)으로 합친 이유는 위에 적었습니다.


② 쓰기 전에 읽기를 끝낸다: 읽기와 쓰기를 가르지 않았다

읽기는 한 트랜잭션 안에서, 행 상태가 바뀌기 전에 전부 끝냅니다(settle()가 트랜잭션 하나이고, 페이지를 다 모은 뒤에야 쓰기로 넘어갑니다). 페이지마다 읽고 그때그때 쓰면, 앞 페이지를 쓰면서 상태가 바뀌어 offset 페이징이 흔들립니다. 같은 행이 두 장에 나오거나 아예 빠집니다. 그래서 읽기와 쓰기를 가르지 않았습니다. 이 갈림에서 잃은 것은 트랜잭션이 그만큼 길어지는 것입니다(읽는 페이지가 늘수록 커밋이 늦어집니다). id 순으로 한 장만 읽던 때에는 드러나지 않던 전제라, 여러 장을 읽게 되면서 다시 확인한 자리입니다.


③ 대가는 큰 가맹점이 낸다: 기본값은 id로 뒀다

공정성을 켜면 큰 가맹점이 손해를 보고, 끄면 작은 가맹점이 밀립니다. 즉 누구를 얼마나 굶길지의 문제입니다. 작은 가맹점의 대기일을 줄이는 대신, 큰 가맹점의 하루 지급액을 줄입니다. 대가는 큰 가맹점이 냅니다. 이 규모에서 대가는 완료일이 아니라 하루에 받는 금액으로 먼저 나타났습니다. 첫날 큰 가맹점이 가져가는 몫은 1,000 → 500건으로 절반이 되고 작은 가맹점이 그만큼 당겨집니다. 완료일은 이 규모에서는 같았습니다(둘 다 2일). 물량이 틱 용량의 여러 배가 되면 완료일 자체도 밀립니다.

정책을 고르는 것은 기술 결정이 아니라 사업 결정이었습니다. 가맹점에 대한 지급일 약속이 바뀌기 때문입니다. 그래서 정책은 app.settlement.fairness-policy로 만들되 기본값을 id(원래 동작)로 두었습니다. 켜지 않으면 읽기 경로가 100% 그대로입니다. 어느 쪽이 나은지는 가맹점과의 약속이 정하는 일이라, 필요한 순간이 오면 운영이 명시적으로 켭니다. 큰 가맹점 하나가 틱을 지배해 작은 가맹점 지급이 며칠씩 밀리는 사례가 실제로 생기면 그때 켭니다.

실험하다 걸린 것

이 실험을 돌리다 정산이 Data truncated for column 'status'로 실패했습니다. 정산 상태의 PAYOUT_HELD가 DB enum에 없던 잠복 버그였고, 판단을 재기 전에 스키마부터 맞춰야 했습니다. 코드와 스키마가 어긋난 자리는 트레이드오프 아홉 건 편에서 둘을 한 번에 다룹니다.


남은 구멍

  • 정책은 기본 id(off)다. 켜는 판단은 아직 사업이 하지 않았고 큰 가맹점 하나가 틱을 지배해 작은 가맹점 지급이 며칠씩 밀리는 사례가 실제로 생기는지가 그 조건입니다

페이지 예산 2장(max-pages 2)에 가맹점 3이면 가맹점마다 상한 1장씩이어도 그 틱에는 두 가맹점만 읽히고 셋째는 다음 틱으로 밀린다. 틱 1에서 가맹점 1·2는 1페이지씩 읽히고 가맹점 3은 0건, 틱 2에서 읽힌다

  • 가맹점 수가 틱 예산을 넘으면(가맹점 수 > max-pages) 상한이 1장씩으로 줄어 그 틱에 아예 못 읽는 가맹점이 생깁니다. 예산 2장·가맹점 3으로 돌려보니 둘만 읽히고 셋째는 다음 틱으로 밀렸다(SettlementFairnessTest.roundRobinCannotServeMoreSellersThanPagesBudget로 고정). 그 규모에서는 그룹핑이나 별도 배치 주기를 검토한다 (부하 곡선은 이 규모에서 안 쟀습니다)
  • 이 글의 수치는 로컬 단일 장비·합성 시드다. 가맹점 둘·고정 금액이라 실제 분포(가맹점 수·물량 편차)에 따라 값이 달라집니다
프로필 사진
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글

댓글 수정/삭제는 GitHub Discussions에서 가능합니다.