정산 시스템 설계 — 수천 셀러에게 1원 오차 없이 정산하는 법
한 줄 요약: 정산 시스템의 핵심은 정확성과 감사 가능성(Auditability)이다. float 한 줄, 배치 한 번의 실수가 수천 셀러에게 1원씩 빠지는 사고로 이어진다. 모든 돈의 흐름에는 검증 가능한 흔적이 있어야 한다.
실제 사고: 정산 시스템이 무너지면 어떤 일이 벌어지나
2022년 한 대형 이커머스 플랫폼에서 정산 배치 버그로 3,400명의 셀러에게 7영업일간 정산이 누락됐습니다. 원인은 단순했습니다. 환불 건을 차감 처리하는 로직에서 double 타입으로 금액을 더했고, 부동소수점 오차가 누적되어 특정 셀러의 정산 금액이 음수가 됐습니다. 음수 정산을 필터링하는 방어 코드가 해당 셀러 전체 정산을 SKIP하도록 동작했습니다.
네이버페이 파트너사 정산에서는 수수료율 테이블 마이그레이션 오류로 구 요율이 적용되어 셀러 수천 곳에 과다 수수료가 청구된 사례가 있었습니다. 배민에서는 배달 수수료와 서비스 수수료를 합산하는 SQL에서 LEFT JOIN 방향이 바뀌면서 수수료가 두 번 차감되는 버그가 배포됐습니다.
이 세 사고의 공통점은 단 하나입니다. 정산 로직을 변경할 때 “실행 전 시뮬레이션”과 “실행 후 교차 검증”이 없었습니다. 정산 시스템은 일반적인 CRUD와 다릅니다. 잘못된 INSERT는 롤백하면 되지만, 잘못 지급된 돈은 돌려받기가 어렵고, 덜 지급된 돈은 셀러 이탈로 이어집니다.
이 글은 이 세 가지 실패를 전부 방어할 수 있는 정산 시스템을 WHY 중심으로 설계합니다.
1. 설계 의사결정 로드맵
정산 시스템을 설계할 때 반드시 결정해야 하는 다섯 가지 선택지가 있습니다. 각 결정은 이후 아키텍처 전체를 규정하므로, 근거 없이 고르면 면접에서 즉시 탈락합니다.
1-1. 정산 주기: 실시간 vs 일배치 vs 월배치
| 후보 | 장점 | 단점 | 언제 적합한가 |
|---|---|---|---|
| 실시간 (이벤트 기반) | 셀러 자금 회전 빠름, 즉각 지급 | 취소/환불 역전 처리 복잡, PG사 즉시정산 수수료 높음 | 크리에이터 플랫폼, 배달 라이더 당일 지급 |
| 일배치 (T+1/T+2) | 취소 환불을 당일 묶어서 상계, 구현 단순 | 셀러 자금 하루 묶임 | 이커머스 일반 (쿠팡, 스마트스토어) |
| 월배치 | 정산 서버 부하 최소 | 셀러 자금 한 달 묶임, 이탈 위험 | B2B 계약 기반, 월정액 SaaS |
우리의 선택: 일배치 (T+2)
셀러 입장에서 자금 회전이 중요하지만, 취소·환불이 당일 집중 발생하는 커머스 구조에서는 실시간 정산이 역전 처리를 지나치게 복잡하게 만듭니다. 주문이 완료(DELIVERED) 상태로 확정된 건만 묶어서 익익영업일(T+2)에 지급하면, 당일 취소 건은 자연스럽게 제외됩니다. 배치 창이 확실해야 “이 배치로 지급된 금액의 합 = PG사 정산 수령액 - 수수료”라는 교차검증도 가능합니다.
1-2. 금액 계산 정밀도: float vs BigDecimal vs 정수 센트
| 후보 | 장점 | 단점 | 언제 적합한가 |
|---|---|---|---|
| float/double | 빠름, 코드 간결 | 부동소수점 오차 누적 → 금액 오차 | 절대 금융에 사용 금지 |
| BigDecimal | 정확한 십진수 연산 | 느림, 코드 장황, 스케일 관리 필요 | 금액 중간 계산, 수수료율 적용 |
| 정수 센트 (원화: 정수 원) | 가장 빠름, 오차 없음 | 소수점 이하 버림 정책 명시 필요 | 최종 지급 금액 저장, DB 컬럼 |
우리의 선택: 중간 계산은 BigDecimal, 저장·지급은 정수 원
수수료율(예: 3.5%)을 곱하면 소수점이 나옵니다. 이 과정은 BigDecimal로 처리하고, 최종 지급 금액은 원 단위 절사(floor)하여 정수로 저장합니다. 절사로 남은 잔전(fractional amount)은 별도 컬럼에 누적해두었다가 월말에 플랫폼이 흡수합니다. 이 정책을 문서화해두지 않으면 “왜 1원이 빠지냐”는 민원이 반드시 들어옵니다.
1-3. 정산 상태 관리: FSM vs 플래그
| 후보 | 장점 | 단점 | 언제 적합한가 |
|---|---|---|---|
| Boolean 플래그 | 구현 단순 | is_paid=true, is_disputed=true 조합 불가능 상태 발생 |
상태가 2개 이하인 단순 시스템 |
| String 상태 컬럼 (무규칙) | 자유로운 상태 추가 | 허용되지 않는 전이 차단 불가 | 프로토타입 |
| FSM (유한 상태 기계) | 허용된 전이만 가능, 감사 이력 명확 | 구현 비용 있음 | 정산처럼 상태 전이 규칙이 중요한 도메인 |
우리의 선택: FSM
정산 상태를 무규칙 String으로 관리하는 것은 신호등 없는 교차로와 같습니다. 사고가 언제 날지 모를 뿐, 사고는 반드시 납니다.
정산은 PENDING → CALCULATING → READY → PAID 또는 READY → DISPUTED → RESOLVED → PAID 같은 명확한 흐름이 있습니다. FSM을 적용하면 PAID → CALCULATING 같은 역주행을 코드 레벨에서 원천 차단할 수 있고, 모든 상태 전이를 이력 테이블에 기록하여 “이 정산이 왜 DISPUTED 상태인가”를 즉시 추적할 수 있습니다.
1-4. 수수료 계산 엔진: 하드코딩 vs 룰 엔진
| 후보 | 장점 | 단점 | 언제 적합한가 |
|---|---|---|---|
| 하드코딩 | 빠른 개발 | 수수료율 변경마다 배포 필요, 셀러별 다른 요율 불가 | 수수료율이 단일하고 고정적인 경우 |
| DB 기반 요율 테이블 | 배포 없이 요율 변경, 셀러/카테고리별 차등 | 테이블 설계 복잡 | 셀러 등급별 수수료가 다른 플랫폼 |
| 룰 엔진 (Drools 등) | 복잡한 조건 분기 표현 가능 | 운영 복잡, 디버깅 어려움 | 수수료 조건이 수십 가지 이상 |
우리의 선택: DB 기반 요율 테이블 + 버전 관리
셀러마다 카테고리마다 수수료율이 다르고, 정책 변경이 잦은 플랫폼에서는 하드코딩이 유지보수 지옥을 만듭니다. 수수료율 테이블에 effective_from, effective_to 컬럼을 두어 과거 정산을 재계산할 때도 그 시점의 요율을 정확히 가져올 수 있게 합니다. 이것이 없으면 “과거 정산 재현”이 불가능해집니다.
1-5. 세금 처리: 내부 구현 vs 외부 서비스
| 후보 | 장점 | 단점 | 언제 적합한가 |
|---|---|---|---|
| 내부 구현 | 빠름, 외부 의존 없음 | 세법 변경 시 직접 대응, 국가별 세율 관리 복잡 | 단일 국가, 세율 단순 |
| 외부 세금 서비스 (Avalara, TaxJar) | 최신 세율 자동 반영, 다국가 지원 | 비용, 외부 의존성 | 글로벌 이커머스, 세금 복잡도 높음 |
우리의 선택: 초기엔 내부 구현 (부가세 10% 고정), 글로벌화 시 외부 서비스 연동
국내 단일 사업자 기준이라면 부가세 10%를 내부 로직으로 처리하는 것이 단순합니다. 단, 세금 계산 로직을 인터페이스로 추상화해두어야 나중에 외부 서비스로 교체할 때 비용이 줄어듭니다.
2. 요구사항 분석 및 규모 추정
2-1. 기능 요구사항
| 기능 | 상세 | 면접에서 확인할 것 |
|---|---|---|
| 정산 집계 | 일별 주문·취소·환불을 셀러별로 합산 | 부분 취소, 부분 환불 처리 방식은? |
| 수수료 계산 | 카테고리·셀러 등급별 요율 적용 | 수수료 산정 기준 시점은 주문 완료 vs 배송 완료? |
| 세금 처리 | 부가세 분리 계산 및 세금계산서 발행 | 면세 상품 혼합 주문 처리 방식은? |
| 정산서 생성 | 셀러별 정산 명세서 PDF/API 제공 | 셀러가 직접 조회할 포털이 필요한가? |
| 지급 연동 | 은행 가상계좌, PG사 API 통해 자동 이체 | 이체 실패 시 재시도 정책은? |
| 이의 제기 | 셀러가 오정산 신고, 검토 후 조정 | 이의 제기 처리 SLA는 몇 영업일? |
| 감사 이력 | 모든 상태 전이, 금액 계산 근거 보관 | 법적 보관 기간은? (통상 5년) |
2-2. 비기능 요구사항
| 항목 | 목표 | 근거 |
|---|---|---|
| 정확성 | 1원 오차 없음 | 오차 발생 시 셀러 신뢰 붕괴 |
| 완결성 | 대상 주문 100% 처리 | 누락 건 발생 시 셀러 민원 |
| 처리 시간 | 10만 건 배치 1시간 내 | T+2 지급 데드라인 준수 |
| 감사 가능성 | 5년 이력 보관 | 세법·공정거래법 요구사항 |
| 멱등성 | 동일 배치 재실행 시 결과 동일 | 배치 실패 후 재실행 시 중복 지급 방지 |
| 가용성 | 99.9% (배치 윈도우 기준) | 배치 창 내 장애 시 T+2 지급 불가 |
2-3. 규모 추정
가정: 셀러 50,000명, 일 주문 500,000건, 취소율 5%
일 정산 대상 주문 = 500,000 × 95% = 475,000건
셀러당 평균 일 주문 = 475,000 / 50,000 = 9.5건
정산서 생성 = 50,000건/일 (셀러당 1건)
지급 이체 = 50,000건/일 (영업일 기준)
정산 데이터 크기 = 건당 약 1KB × 475,000 = 475MB/일
연간 누적 = 475MB × 250영업일 ≈ 119GB/년 (압축 후 약 30GB)
피크 배치 부하: 자정 배치 시작 → 1시간 내 완료 목표
→ 초당 처리 = 475,000 / 3,600 ≈ 132건/초
→ DB write IOPS = 132 × 3 (정산레코드+이력+집계) ≈ 400 IOPS
규모 추정의 핵심은 “132건/초”라는 숫자가 나왔을 때, 단순 MySQL 단일 인스턴스로도 충분히 처리된다는 판단입니다. 정산 배치는 Read-heavy가 아니라 Write-heavy인데, 400 IOPS는 일반 SSD 기반 RDS가 충분히 소화합니다. 샤딩이나 NoSQL이 필요하지 않습니다.
3. 고수준 아키텍처
정산 시스템은 회계 부서와 같습니다. 영업팀(주문 시스템)에서 매출 데이터를 받아, 수수료·세금·환불을 정확히 계산해 셀러(직원)에게 월급을 지급합니다. 회계 부서가 틀리면 신뢰가 무너집니다.
graph LR
A["주문 DB"] --> B["정산 배치 엔진"]
B --> C["수수료 계산기"]
C --> D["정산서 생성기"]
D --> E["지급 연동"]
B --> F["감사 로그 DB"]
| 컴포넌트 | 역할 |
|---|---|
| 주문 DB | 완료·취소·환불 상태의 주문 원본 데이터 제공 |
| 정산 배치 엔진 | 일배치로 정산 대상 주문 집계, FSM 상태 관리 |
| 수수료 계산기 | 셀러/카테고리별 요율 테이블 조회, BigDecimal 계산 |
| 정산서 생성기 | 셀러별 명세서 생성, 세금계산서 발행 |
| 지급 연동 | 은행 가상계좌 이체 API 호출, 실패 재시도 |
| 감사 로그 DB | 모든 상태 전이와 금액 계산 근거를 불변 기록 |
4. 핵심 컴포넌트 상세 설계
4-1. 정산 배치 엔진: 주문 → 정산 집계
정산 배치 엔진은 하루치 영수증을 밤새 정리하는 북키퍼입니다. 빠짐없이, 중복 없이, 그리고 나중에 다시 확인할 수 있게 기록해야 합니다.
배치 엔진의 가장 중요한 설계 원칙은 멱등성입니다. 자정에 배치를 실행하다 새벽 2시에 서버가 죽었다면, 복구 후 같은 배치를 재실행해도 결과가 동일해야 합니다. 이를 위해 배치를 날짜 기준 파티션으로 나누고, 각 파티션에 처리 완료 여부를 기록합니다.
| 단계 | 동작 | 실패 시 대응 |
|---|---|---|
| 대상 추출 | settlement_date = T-2, 상태 DELIVERED 주문 SELECT |
재실행 시 동일 SELECT 결과 보장 |
| 셀러별 집계 | SUM(주문금액) - SUM(환불금액) = 매출총액 | Checksum으로 집계 전후 금액 검증 |
| 수수료 차감 | 매출총액 × 요율 = 수수료, BigDecimal 처리 | 요율 테이블 스냅샷을 정산 레코드에 저장 |
| 세금 계산 | (매출총액 - 수수료) 중 과세분 × 10% | 면세 상품 목록 별도 관리 |
| 정산 레코드 생성 | INSERT INTO settlements (멱등 키: seller_id + settlement_date) | UPSERT로 중복 방지 |
@Service
public class SettlementBatchEngine {
@Transactional
public SettlementResult calculateSettlement(Long sellerId, LocalDate settlementDate) {
// 멱등 키로 기존 처리 여부 확인
Optional<Settlement> existing = settlementRepository
.findBySellerIdAndSettlementDate(sellerId, settlementDate);
if (existing.isPresent() && existing.get().getStatus() != SettlementStatus.FAILED) {
return SettlementResult.alreadyProcessed(existing.get());
}
// 대상 주문 집계 (BigDecimal 사용)
List<Order> orders = orderRepository
.findDeliveredOrdersForSettlement(sellerId, settlementDate);
BigDecimal grossRevenue = orders.stream()
.map(Order::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal refundAmount = refundRepository
.findRefundsForSettlement(sellerId, settlementDate)
.stream()
.map(Refund::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal netRevenue = grossRevenue.subtract(refundAmount);
// 수수료 계산 — 요율 스냅샷 저장
FeeRate feeRate = feeRateRepository.findApplicableRate(sellerId, settlementDate);
BigDecimal fee = netRevenue
.multiply(feeRate.getRate())
.setScale(0, RoundingMode.FLOOR); // 원 단위 절사
BigDecimal settlementAmount = netRevenue.subtract(fee);
Settlement settlement = Settlement.builder()
.sellerId(sellerId)
.settlementDate(settlementDate)
.grossRevenue(grossRevenue.longValue())
.refundAmount(refundAmount.longValue())
.netRevenue(netRevenue.longValue())
.feeRateSnapshot(feeRate.getRate().toPlainString()) // 요율 스냅샷
.feeAmount(fee.longValue())
.settlementAmount(settlementAmount.longValue())
.status(SettlementStatus.READY)
.build();
return SettlementResult.success(settlementRepository.save(settlement));
}
}
여기서 주목해야 할 설계 포인트는 두 가지입니다. 첫째, feeRateSnapshot에 적용된 요율을 문자열로 저장합니다. 나중에 “왜 이 금액으로 정산됐는가”를 재현할 때 당시 요율 테이블이 변경되어도 추적이 가능합니다. 둘째, 모든 금액은 long 타입의 원 단위로 저장하고, BigDecimal은 계산 중간 과정에서만 사용합니다.
⚠️ 한계:
@Transactional하나로 주문 조회 + 정산 레코드 INSERT를 묶으면 셀러 수가 많을 때 트랜잭션이 수 분간 열려 있어 DB 커넥션이 고갈됩니다. 또한 주문 DB와 정산 DB가 분리된 마이크로서비스 구조에서는 분산 트랜잭션이 불가능합니다.
🔄 탈출 전략: 청크 단위로 트랜잭션을 분리하고(Spring Batch
chunk(1000)), 주문 DB 조회와 정산 DB 쓰기를 별도 트랜잭션으로 처리합니다. 주문 DB 조회는 읽기 전용 트랜잭션(@Transactional(readOnly = true))으로 분리하면 커넥션 점유 시간을 절반으로 줄입니다. 분산 환경이라면 Outbox Pattern으로 주문 이벤트를 받아 정산 DB에만 씁니다.
4-2. 수수료/할인 분배 계산기
수수료 계산기는 케이크를 자르는 규칙입니다. 플랫폼, 셀러, 쿠폰 발행자가 각자 얼마를 가져가는지 정확한 비율로 나눠야 합니다.
플랫폼 수수료 외에도 쿠폰 비용 분담이 복잡합니다. 플랫폼이 발행한 쿠폰과 셀러가 발행한 쿠폰의 비용 부담 주체가 다릅니다.
| 비용 항목 | 부담 주체 | 계산 방식 |
|---|---|---|
| 플랫폼 수수료 | 셀러 부담 | 매출총액 × 카테고리 요율 |
| 플랫폼 쿠폰 할인 | 플랫폼 부담 | 할인액 전액을 플랫폼이 흡수 |
| 셀러 쿠폰 할인 | 셀러 부담 | 할인액을 셀러 정산에서 차감 |
| 배송비 쿠폰 | 주체별 협약에 따름 | 협약 테이블 참조 |
| 포인트 사용 | 플랫폼 부담 | 셀러는 정가 기준으로 정산 |
포인트 처리는 특히 주의가 필요합니다. 고객이 1만원 상품에 2천 포인트를 사용해 8천원을 결제했을 때, 셀러는 1만원 기준으로 정산받아야 합니다. 그렇지 않으면 셀러가 포인트 프로모션을 기피합니다. 이 비용은 플랫폼이 부담합니다.
⚠️ 한계: 쿠폰 비용 분담 협약 테이블이 복잡해질수록 정산 계산 코드가 비즈니스 규칙과 뒤섞입니다. 협약 유형이 20가지를 넘으면 코드가 거대한 switch-case 덩어리가 됩니다.
🔄 탈출 전략: 쿠폰 비용 분담 로직을 전략 패턴(Strategy Pattern)으로 분리합니다. 협약 유형별로
CouponCostStrategy구현체를 만들고, DB 협약 테이블에서 전략 이름을 읽어 동적으로 선택합니다. 새로운 협약 유형이 생겨도 기존 코드를 수정하지 않고 구현체 하나만 추가합니다.
4-3. BigDecimal 기반 금액 처리 원칙
public final class MoneyCalculator {
// 수수료율 적용: 절사(FLOOR) 정책
public static long applyFeeRate(long amount, BigDecimal rate) {
return BigDecimal.valueOf(amount)
.multiply(rate)
.setScale(0, RoundingMode.FLOOR)
.longValue();
}
// 금액 분배: 나머지는 첫 번째 항목에 합산
public static long[] distribute(long total, int[] ratios) {
int ratioSum = Arrays.stream(ratios).sum();
long[] result = new long[ratios.length];
long allocated = 0;
for (int i = 1; i < ratios.length; i++) {
result[i] = BigDecimal.valueOf(total)
.multiply(BigDecimal.valueOf(ratios[i]))
.divide(BigDecimal.valueOf(ratioSum), 0, RoundingMode.FLOOR)
.longValue();
allocated += result[i];
}
result[0] = total - allocated; // 나머지는 첫 항목에
return result;
}
}
금액 분배에서 발생하는 잔전(1원 미만 잔액)의 귀속 정책을 명문화해야 합니다. 위 코드처럼 나머지를 첫 번째 항목(주로 플랫폼)에 귀속하는 방식이 일반적입니다. 이 정책이 없으면 분배 합계가 원래 금액과 다를 수 있습니다.
⚠️ 한계:
MoneyCalculator를 static 유틸리티 클래스로 만들면 테스트는 쉽지만, 반올림 정책(FLOOR, HALF_UP 등)이 요구사항 변경으로 바뀔 때 모든 호출 지점을 수정해야 합니다. 또한 다통화 환경에서는 통화별로 최소 단위가 달라(JPY는 정수, KRW는 정수, USD는 센트)distribute()인터페이스가 맞지 않습니다.
🔄 탈출 전략: 반올림 정책을
RoundingPolicy인터페이스로 주입받는 구조로 전환합니다. 통화별 최소 단위를Currencyenum에 담아MoneyCalculator가 통화를 인자로 받도록 확장합니다. 단, 현재 단일 통화(KRW)라면 이 변경은 오버엔지니어링입니다. 다통화 요구사항이 구체화될 때 리팩토링합니다.
4-4. 정산서 생성 + 지급 연동
정산서는 영수증이자 계약서입니다. 나중에 분쟁이 생겼을 때 “우리는 이 계산대로 지급했다”는 법적 증거가 됩니다.
지급 연동에서 가장 위험한 시나리오는 이중 지급입니다. 이체 API를 호출했는데 응답이 없어서 재시도했을 때, 실제로는 첫 번째 호출이 처리됐다면 두 번 지급됩니다.
@Service
public class PaymentDispatcher {
@Transactional
public void dispatch(Settlement settlement) {
// 지급 시도 전 상태 검증 (FSM)
if (settlement.getStatus() != SettlementStatus.READY) {
throw new InvalidStateTransitionException(
settlement.getStatus(), SettlementStatus.PAYING);
}
// 상태를 PAYING으로 먼저 변경 (재진입 방지)
settlement.transitionTo(SettlementStatus.PAYING);
settlementRepository.save(settlement);
try {
// 멱등 키로 이체 요청 (은행 API에 idempotency key 전달)
String idempotencyKey = "settlement:" + settlement.getId();
TransferResult result = bankApiClient.transfer(
settlement.getSellerBankAccount(),
settlement.getSettlementAmount(),
idempotencyKey
);
settlement.transitionTo(SettlementStatus.PAID);
settlement.setTransactionId(result.getTransactionId());
} catch (Exception e) {
settlement.transitionTo(SettlementStatus.PAY_FAILED);
// 재시도 큐에 등록
retryQueue.enqueue(settlement.getId());
}
settlementRepository.save(settlement);
auditLogService.record(settlement); // 불변 감사 이력 기록
}
}
상태를 PAYING으로 먼저 변경하고 이체를 시도하는 순서가 핵심입니다. 이체 API 호출 중 서버가 죽어도 PAYING 상태에서 재시작하면 은행 API에 “이 idempotency key로 이미 처리됐는가”를 조회하여 중복 지급을 방지합니다.
⚠️ 한계: 위 코드의
@Transactional안에서 은행 API 외부 HTTP 호출을 합니다. HTTP 호출이 30초 타임아웃으로 길어지면 트랜잭션이 30초간 열린 채로 DB 커넥션을 점유합니다. 50,000건을 순차 처리하면 커넥션 풀 고갈이 발생할 수 있습니다.
🔄 탈출 전략: DB 상태 변경(
PAYING)과 외부 API 호출을 트랜잭션에서 분리합니다. 상태를PAYING으로 커밋한 뒤 트랜잭션을 종료하고, 별도 스레드(또는 비동기 큐)에서 은행 API를 호출합니다. 결과가 오면 다시 새 트랜잭션을 열어PAID또는PAY_FAILED로 전이합니다. 커넥션 점유 시간을 API 호출 시간(수백 ms)에서 DB 쓰기 시간(수 ms)으로 줄입니다.
4-5. 이의 제기(Dispute) 처리
이의 제기 시스템은 회사의 고충 처리 창구입니다. 셀러가 틀렸다고 생각할 때 어디에 어떻게 이의를 제기하고, 며칠 안에 답을 받을 수 있는지 명확해야 합니다.
graph LR
A["PAID 정산"] --> B["Dispute 접수"]
B --> C["내부 검토"]
C --> D["조정 승인"]
C --> E["기각"]
D --> F["재정산 실행"]
| FSM 상태 | 진입 조건 | 허용 전이 |
|---|---|---|
| DISPUTED | 셀러가 이의 제기 신청 | → UNDER_REVIEW |
| UNDER_REVIEW | 운영팀이 검토 시작 | → APPROVED, REJECTED |
| APPROVED | 오정산 확인 | → ADJUSTED |
| REJECTED | 정산이 정확 | → CLOSED |
| ADJUSTED | 차액 추가 지급 완료 | → CLOSED |
이의 제기가 승인되면 차액만 별도 지급하고, 원래 정산 레코드는 수정하지 않습니다. 불변 원칙을 지켜야 감사 추적이 유지됩니다. 차액 레코드를 별도 생성하고 원본 정산과 연결하는 방식을 사용합니다.
⚠️ 한계: 이의 제기 FSM이 운영팀의 수동 검토(
UNDER_REVIEW)에 의존합니다. 셀러가 10만 명이고 이의 제기율이 0.1%만 되어도 하루 100건입니다. 운영팀이 수작업으로 처리하면 SLA 3영업일을 지키기 어렵습니다.
🔄 탈출 전략: 이의 제기 금액이 소액(예: 1만원 이하)이고 계산 근거가 명백한 경우(요율 오적용 등)는 자동 승인 규칙을 적용합니다. 자동 판정이 어려운 복잡한 건만 운영팀에 에스컬레이션합니다. 자동 처리율 목표를 70% 이상으로 설정하면 운영 부담을 크게 줄입니다.
5. 이 설계의 한계와 대안
설계를 자랑하는 것은 쉽습니다. 설계가 실패하는 순간을 먼저 생각하는 것이 시니어의 관점입니다.
5-1. 오버엔지니어링 경고: 규모에 맞는 설계를 선택하라
정산 시스템 설계에서 가장 흔한 실수는 셀러 100명짜리 시스템에 Kafka와 Debezium을 들이붓는 것입니다. 복잡한 파이프라인은 버그 표면을 넓히고, 운영 인력이 없으면 오히려 정확성을 낮춥니다. 아래 기준을 먼저 확인하세요.
| 규모 | 적합한 설계 | Kafka가 필요한가 |
|---|---|---|
| 셀러 100명 이하 | 스프레드시트 + 수동 이체 | 전혀 불필요. 엑셀로 충분합니다 |
| 셀러 1,000명 이하 | 단일 MySQL + Spring Batch 일배치 | 불필요. DB 폴링으로 충분 |
| 셀러 10,000명 이하 | 청크 병렬 배치 + Aurora | 선택 사항. 없어도 됩니다 |
| 셀러 10만명+, 다국가 | 이벤트 기반 스트리밍 파이프라인 | 비로소 필요 |
“정산은 정확성이 최우선, 성능은 그 다음입니다. 빠른 정산보다 틀린 정산이 100배 위험합니다.”
이 원칙을 면접관에게 먼저 이야기하면 신뢰를 얻습니다. Kafka를 자랑하기 전에 “우리 규모에서 Kafka가 필요한가”를 묻는 사람이 좋은 엔지니어입니다.
5-2. 배치 정산이 실패하면? — 체크포인트와 재시작
배치가 중간에 죽는 것은 이상 상황이 아닙니다. 매일 실행하는 배치는 반드시 언젠가 죽습니다. 따라서 “죽어도 안전한 설계”가 처음부터 내장되어야 합니다.
graph LR
A["배치 시작"] --> B["체크포인트 조회"]
B --> C["미처리 청크만 선택"]
C --> D["청크 처리"]
D --> E["체크포인트 저장"]
E --> F["다음 청크"]
체크포인트 테이블 설계:
CREATE TABLE batch_checkpoint (
job_name VARCHAR(100) NOT NULL,
settlement_date DATE NOT NULL,
last_seller_id BIGINT NOT NULL DEFAULT 0,
processed_count INT NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'RUNNING',
updated_at DATETIME NOT NULL,
PRIMARY KEY (job_name, settlement_date)
);
배치는 1,000명 단위 청크로 나누고, 각 청크 완료 시 last_seller_id를 갱신합니다. 재시작 시 last_seller_id 이후부터만 처리합니다. 이 구조면 새벽 2시에 서버가 죽어도 재시작 후 30초 만에 이어받을 수 있습니다.
멱등성 보장의 핵심 3가지:
(seller_id, settlement_date)UNIQUE 제약 — DB 레벨에서 중복 삽입 원천 차단- UPSERT(
INSERT ... ON DUPLICATE KEY UPDATE) — 재실행 시 같은 결과로 덮어쓰기 - 청크 단위 독립 트랜잭션 — 하나의 거대 트랜잭션은 재시작 불가능한 롤백을 유발
5-3. BigDecimal이 병목이 될 때 — 정수 센트 전략
BigDecimal은 정확하지만 느립니다. JMH 벤치마크 기준 long 연산 대비 약 10~30배 느립니다. 셀러 50만 명 규모에서 수수료 계산 루프가 BigDecimal로만 구성되면 배치 시간이 수 시간으로 늘어날 수 있습니다.
“언제 BigDecimal이 과한가” 판단 기준:
| 상황 | BigDecimal 필요성 | 대안 |
|---|---|---|
| 최종 저장·지급 금액 | 불필요 | long 원 단위 정수 |
| 수수료율(3.5%) 곱셈 | 필요 | BigDecimal로 계산 후 long으로 변환 |
| 다통화 환율 적용 | 필요 | BigDecimal 필수 |
| 단순 정수 덧셈·뺄셈 | 불필요 | long 덧셈으로 충분 |
// 느린 방식: 모든 단계에서 BigDecimal
BigDecimal gross = BigDecimal.ZERO;
for (Order o : orders) {
gross = gross.add(o.getAmountAsBigDecimal()); // 수십만 번 반복 시 병목
}
// 빠른 방식: 집계는 long, 비율 계산만 BigDecimal
long grossLong = orders.stream().mapToLong(Order::getAmountLong).sum(); // long 덧셈
long fee = BigDecimal.valueOf(grossLong)
.multiply(feeRate)
.setScale(0, RoundingMode.FLOOR)
.longValue(); // BigDecimal은 딱 이 한 줄만
정수 센트 전략: 환율이 복잡한 다통화 환경에서는 모든 금액을 “최소 화폐 단위의 정수”로 저장합니다. 원화는 원 단위 long, USD는 센트 단위 long으로 저장하고, 화면 표시 시에만 소수점을 붙입니다. 이 방식이면 BigDecimal 없이 모든 금액 연산을 정수로 처리할 수 있습니다.
5-4. 정산 금액 불일치 발생 시 — 대조(Reconciliation) 프로세스
정산 금액 불일치는 “발생하면 큰일”이 아니라 “반드시 발생하므로 대조 프로세스가 있어야 한다”는 관점으로 접근해야 합니다.
항공사는 비행기 추락을 막는 것이 아니라, 추락 직전에 반드시 잡히는 체크리스트를 설계합니다. 정산 대조도 마찬가지입니다.
graph LR
A["내부 정산 합계"] --> C["대조 엔진"]
B["PG사 정산 수령액"] --> C
C --> D["일치: 완료"]
C --> E["불일치: 차이 레코드 생성"]
E --> F["운영팀 알림"]
T+1 대조 배치 설계:
-- 내부 정산 합계
SELECT SUM(settlement_amount) AS internal_total
FROM settlements
WHERE settlement_date = '2025-01-15' AND status = 'PAID';
-- PG사 정산 수령액 (전일 PG 정산 API로 적재)
SELECT SUM(received_amount) AS pg_total
FROM pg_settlements
WHERE settlement_date = '2025-01-15';
-- 불일치 탐지
-- 허용 오차: 절사 잔전 × 셀러 수 (최대 몇 천 원) 초과 시 알림
대조 결과 불일치가 발생하면 즉시 경보를 발송하고, 차이 금액과 원인을 reconciliation_exceptions 테이블에 기록합니다. 주요 불일치 원인별 대응:
| 불일치 원인 | 탐지 방법 | 대응 |
|---|---|---|
| 환불 반영 타이밍 차이 | 다음 날 재대조 시 해소 여부 확인 | 1~2일 대기 후 자동 재대조 |
| 수수료율 오적용 | 셀러 평균 수수료율 이상 감지 | 즉시 배치 중단 + 재계산 |
| PG사 집계 오류 | PG사와 건별 대조 | 수동 조정 후 기록 |
| 타임존 경계 누락·중복 | 일별 총 주문 수 대조 | 쿼리 수정 + 재실행 |
5-5. 은행 API 장애 시 — 지급 큐 + Circuit Breaker + 수동 Fallback
은행 API는 연간 수십 번 장애가 납니다. 은행 점검 시간(통상 자정~오전 6시)과 정산 배치 시간이 겹치면 대규모 지급 실패가 발생합니다. 이를 대비한 3중 방어 구조가 필요합니다.
graph LR
A["지급 요청"] --> B["Circuit Breaker"]
B --> C["은행 API"]
B --> D["OPEN: 즉시 실패"]
D --> E["지급 대기 큐"]
E --> F["복구 후 재시도"]
Circuit Breaker 상태 전환 기준:
@CircuitBreaker(
failureRateThreshold = 50, // 50% 실패 시 OPEN
waitDurationInOpenState = 60000, // 60초 후 HALF_OPEN
slidingWindowSize = 10 // 최근 10회 기준
)
public TransferResult transfer(BankAccount account, long amount, String idempotencyKey) {
return bankApiClient.transfer(account, amount, idempotencyKey);
}
Circuit Breaker가 OPEN되면 즉시 은행 API 호출을 중단하고 지급 요청을 큐에 적재합니다. 은행 API가 복구되면 큐에서 꺼내 순서대로 재시도합니다. idempotency key 덕분에 재시도는 항상 안전합니다.
수동 지급 Fallback: 은행 API가 수 시간 이상 복구되지 않을 때를 대비해, 운영팀이 지급 대기 목록을 CSV로 다운로드해 은행 인터넷뱅킹에 일괄 이체할 수 있는 관리자 기능이 필요합니다. 자동화가 모든 상황을 커버할 수 없습니다.
5-6. Kafka 메시지 유실 시 — Outbox Pattern과 Exactly-Once의 현실
Kafka를 사용할 때 가장 흔한 오해는 “Kafka를 쓰면 메시지가 유실되지 않는다”입니다. Kafka 자체는 내구성이 높지만, 메시지를 Kafka에 보내는 시점과 DB에 쓰는 시점이 달라서 유실이 발생합니다.
창고(DB)에 물건을 넣었다는 메모(Kafka 메시지)를 먼저 보냈는데, 창고 문이 잠겨 있었습니다. 메모는 보냈지만 물건은 없는 상태입니다. 이것이 이중 쓰기 문제입니다.
이중 쓰기 문제:
// 위험한 방식: DB 저장과 Kafka 발행이 분리됨
@Transactional
public void completeSettlement(Settlement settlement) {
settlementRepository.save(settlement); // DB 저장 성공
kafkaTemplate.send("settlement-ready", settlement); // Kafka 발행 실패 → 유실!
}
Outbox Pattern으로 해결:
@Transactional
public void completeSettlement(Settlement settlement) {
settlementRepository.save(settlement);
// Kafka 직접 발행 대신 같은 트랜잭션 내 Outbox 테이블에 저장
outboxRepository.save(OutboxEvent.of("settlement-ready", settlement));
// 트랜잭션이 커밋되면 DB에 둘 다 저장됨 → 원자성 보장
}
// 별도 Outbox Relay가 Outbox 테이블을 폴링해서 Kafka로 발행
graph LR
A["정산 서비스"] --> B["settlements 테이블"]
A --> C["outbox 테이블"]
D["Outbox Relay"] --> C
D --> E["Kafka"]
Debezium CDC 방식: Outbox Relay를 직접 구현하는 대신 Debezium이 MySQL binlog를 감시해서 outbox 테이블의 INSERT를 자동으로 Kafka에 발행하는 방식도 있습니다. 코드 없이 CDC로 연동되지만, Debezium 자체가 운영 복잡도를 추가합니다.
Exactly-Once Semantics의 현실: Kafka의 exactly-once는 “Kafka 내부에서는 정확히 한 번”을 의미합니다. Consumer가 메시지를 받아서 외부 시스템(DB, 은행 API)에 반영하는 과정까지 exactly-once를 보장하려면 idempotent consumer 구현이 필수입니다. Kafka의 exactly-once 설정만으로는 충분하지 않습니다.
5-7. 동시성과 락 — 정산 배치 중복 실행 방지
정산 배치가 동시에 두 번 실행되면 어떻게 될까요? 멱등성이 있어도 중간 상태에서 두 인스턴스가 같은 레코드를 수정하면 데이터 정합성이 깨집니다.
중복 실행 방지 전략 비교:
| 전략 | 장점 | 단점 | 추천 규모 |
|---|---|---|---|
| DB Advisory Lock | 구현 단순, 추가 인프라 없음 | DB 연결 유지 필요, 타임아웃 관리 | 소규모~중규모 |
| Redis 분산 락 (Redisson) | 빠름, TTL 자동 만료 | Redis 의존성 추가, 네트워크 단절 시 Lock 오판 | 대규모 |
| Kafka Consumer Group | 파티션 단위 자연스러운 독점 | Kafka 인프라 필요 | Kafka 이미 사용 시 |
// DB Advisory Lock 방식 (MySQL)
@Transactional
public void runBatch(LocalDate settlementDate) {
// 같은 날짜 배치가 이미 실행 중이면 락 획득 실패
Integer lockResult = jdbcTemplate.queryForObject(
"SELECT GET_LOCK(?, 0)", Integer.class,
"settlement_batch_" + settlementDate
);
if (lockResult == 0) {
log.warn("배치 이미 실행 중: {}", settlementDate);
return;
}
try {
executeBatch(settlementDate);
} finally {
jdbcTemplate.execute("SELECT RELEASE_LOCK('settlement_batch_" + settlementDate + "')");
}
}
Redis 락이 과한 이유: 정산 배치는 초당 수천 요청을 처리하는 API 서버가 아닙니다. 하루 한 번, 밤에 조용히 실행되는 배치입니다. Redis 분산 락은 여러 인스턴스가 밀리초 단위로 경쟁하는 상황에서 빛을 발합니다. 배치 중복 실행 방지에는 DB Advisory Lock이나 Quartz 스케줄러의 클러스터링 기능으로 충분합니다.
같은 셀러 정산이 동시에 두 번 돌면: (seller_id, settlement_date) UNIQUE 제약이 DB 레벨 최후 방어선입니다. 두 번째 INSERT 시도는 DuplicateKeyException으로 즉시 차단됩니다. 락은 1차 방어, UNIQUE 제약은 2차 방어로 이중 보호합니다.
5-8. Kafka vs DB 폴링 — 언제 무엇을 선택하나
“정산 이벤트를 Kafka로 받아야 한다”는 말은 규모에 따라 오버엔지니어링일 수 있습니다.
DB 폴링이 충분한 규모:
-- 단순 DB 폴링: 5분마다 실행
SELECT * FROM orders
WHERE status = 'DELIVERED'
AND settlement_status = 'PENDING'
AND created_at >= DATE_SUB(NOW(), INTERVAL 2 DAY)
LIMIT 1000;
일 주문 10만 건 이하라면 DB 폴링으로 충분합니다. 인덱스가 잘 걸려 있으면 5분마다 1,000건 조회는 MySQL이 50ms 이내에 처리합니다. Kafka 클러스터, Schema Registry, Consumer Group 관리 없이도 됩니다.
Kafka가 필요한 임계점:
| 조건 | DB 폴링 한계 |
|---|---|
| 일 주문 100만 건 이상 | 폴링 쿼리 자체가 DB 부하를 일으킴 |
| 정산 시스템이 여러 마이크로서비스 | 각 서비스가 주문 DB에 직접 연결하면 결합도 증가 |
| 준실시간 정산 (<10분 지연) | 폴링 간격 단축보다 이벤트 드리븐이 효율적 |
| 주문 DB와 정산 DB가 다른 팀 소유 | 직접 연결 불가, 이벤트로 통신해야 함 |
Kafka 사용 시 파티션 키 설계: 파티션 키를 seller_id로 설정하면 같은 셀러의 이벤트가 항상 같은 파티션으로 가고, 같은 Consumer가 처리합니다. 셀러 단위 순서 보장이 자연스럽게 됩니다.
kafkaTemplate.send(
MessageBuilder.withPayload(event)
.setHeader(KafkaHeaders.TOPIC, "order-completed")
.setHeader(KafkaHeaders.KEY, String.valueOf(event.getSellerId())) // 파티션 키 = 셀러ID
.build()
);
Consumer Lag 모니터링도 필수입니다. Lag가 늘어나면 정산 배치가 실행될 시점에 아직 처리 안 된 이벤트가 남아서 당일 정산 누락이 발생합니다. 배치 실행 전 “Consumer Lag = 0” 확인을 선행 조건으로 걸어야 합니다.
6. 극한 시나리오 3개
시나리오 1: 정산 배치 도중 서버 크래시 — 3,200건 처리 중 절반만 완료
새벽 2시, 배치 엔진이 셀러 3,200명 중 1,600명을 처리하고 OOM으로 죽었습니다. 남은 1,600명은 PENDING 상태입니다. T+2 지급 데드라인까지 6시간이 남았습니다.
graph LR
A["배치 재시작"] --> B["PENDING 셀러 조회"]
B --> C["멱등 체크"]
C --> D["미처리만 재실행"]
D --> E["완료 검증"]
멱등성 설계가 이 시나리오를 무력화합니다. 배치가 재시작되면 settlement_date = T이고 상태가 PENDING인 레코드만 골라서 재처리합니다. 이미 READY 상태인 1,600건은 건드리지 않습니다. 이를 위해 배치를 “셀러 단위 청크”로 나누고 각 청크를 독립 트랜잭션으로 처리해야 합니다. 하나의 거대한 트랜잭션으로 묶으면 중간 실패 시 전체 롤백이 발생하여 멱등 복구가 불가능합니다.
숫자로 검증: 1,600건 재처리 = 132건/초 × 12초 = 1,584건. 6시간 데드라인에 비해 여유가 충분합니다. 단, 재시작 후 “처리 완료 건수 = 전체 셀러 수” 검증을 자동화해야 배치 완결성을 보장합니다.
시나리오 2: 수수료율 테이블 잘못 마이그레이션 — 8,000명 셀러에게 3.5% 대신 35% 적용
배포 직후 수수료율 테이블에 소수점 오류가 생겼습니다. 일배치가 이미 실행됐고 8,000명의 정산이 READY 상태입니다. 지급은 아직 시작 전입니다.
graph LR
A["이상 탐지"] --> B["배치 긴급 정지"]
B --> C["READY→FAILED 롤백"]
C --> D["요율 수정 배포"]
D --> E["배치 재실행"]
지급 전이라면 READY 상태의 레코드를 FAILED로 전이시키고 배치를 재실행합니다. 이것이 가능한 이유가 FSM입니다. 플래그 기반 설계였다면 is_calculated=true 레코드를 어떤 기준으로 재처리할지 로직이 없습니다.
핵심 방어선: 배치 완료 후 “셀러 평균 수수료율이 정상 범위(2~10%)에 있는가”를 자동 검증하는 Sanity Check를 파이프라인에 내장해야 합니다. 이 수치가 20%를 넘으면 자동으로 배치를 일시 정지하고 알림을 보내야 합니다. 이번 사례처럼 35%였다면 이 검증에서 즉시 걸립니다.
시나리오 3: 은행 API 장애 — 50,000건 이체 중 40% 타임아웃
오전 9시 이체 시작 후 은행 API가 간헐적 타임아웃을 반환합니다. 20,000건이 PAY_FAILED 상태입니다. 셀러들에게 “오늘 정산이 왜 안 왔냐”는 문의가 폭주합니다.
graph LR
A["PAY_FAILED 감지"] --> B["재시도 큐 적재"]
B --> C["지수 백오프 재시도"]
C --> D["이체 성공"]
C --> E["임계 초과 알림"]
은행 API 장애는 재시도로 해결하되, 지수 백오프(1분 → 2분 → 4분)를 적용해 은행 서버를 더 압박하지 않아야 합니다. 재시도는 idempotency key 덕분에 안전합니다. 은행이 이미 처리한 건은 “이미 처리됨” 응답을 반환하므로 중복 지급이 발생하지 않습니다.
셀러 대응: 지급이 오늘 내 완료되지 않을 것으로 판단되면, 셀러 포털에 “은행 시스템 점검으로 인해 오늘 오후 N시까지 지급 완료 예정”이라는 안내를 자동 게시합니다. 이 자동화가 없으면 CS팀이 수작업으로 같은 내용을 복붙하는 상황이 벌어집니다.
7. 실무 실수 Top 5
| 순위 | 실수 | 증상 | 방어 방법 |
|---|---|---|---|
| 1위 | float/double로 금액 계산 | 특정 셀러 정산 1~2원 오차 누적 | 계산은 BigDecimal, 저장은 정수 원 |
| 2위 | 요율 테이블 버전 관리 없음 | 과거 정산 재현 불가, 소급 분쟁 불가 | effective_from/to 컬럼 필수 |
| 3위 | 배치 멱등성 미보장 | 재실행 시 중복 정산 레코드 생성 | 멱등 키(seller_id + date) UNIQUE 제약 |
| 4위 | 이체 중복 지급 방지 없음 | API 타임아웃 재시도로 2배 지급 | idempotency key를 은행 API에 전달 |
| 5위 | Sanity Check 없는 배치 파이프라인 | 수수료율 버그가 지급 전까지 미발견 | 배치 완료 후 자동 통계 검증 |
8. Phase 1→4 진화
Phase 1: MVP — 단일 배치, 단순 수수료 (월 비용 약 50만원)
셀러 1,000명 이하, 수수료율 단일(3.5%). Spring Batch + MySQL 단일 인스턴스로 일배치 구현합니다. 정산서는 CSV 파일로 이메일 발송. 운영 인프라: EC2 t3.medium 1대 + RDS t3.small 1대.
이 단계에서는 멱등성과 감사 로그를 반드시 구현해야 합니다. 나중에 추가하려면 기존 데이터 마이그레이션이 필요합니다.
Phase 2: 다수 셀러 + 다등급 수수료 (월 비용 약 150만원)
셀러 10,000명, 수수료율 카테고리/등급별 차등. 요율 테이블 버전 관리 도입. 배치를 청크 단위로 병렬화(Partitioned Step). 셀러 포털에서 정산서 직접 조회 기능 추가. 인프라: EC2 t3.large 2대(배치 전용) + RDS t3.medium + ElastiCache(요율 캐시).
Phase 3: 실시간 조회 + 이의 제기 포털 (월 비용 약 400만원)
셀러 50,000명. 배치 처리량 확보를 위해 Aurora로 업그레이드. 이의 제기 FSM 구현. 배치 완료 즉시 셀러에게 카카오 알림톡 발송. Sanity Check 자동화. 정산 데이터 S3 아카이브(5년 보관). 인프라: ECS Fargate(배치 워커 오토스케일) + Aurora 2노드 + S3.
Phase 4: 글로벌 + 세금 서비스 연동 (월 비용 약 1,500만원)
다국가 셀러, 다통화 정산. Avalara/TaxJar 연동으로 국가별 세율 자동 반영. 배치를 Kafka 기반 이벤트 파이프라인으로 전환(스트리밍 집계). Debezium CDC로 주문 DB 변경을 실시간 캡처. 정산 데이터 분석 레이어(Redshift/BigQuery) 추가.
9. 핵심 메트릭
| 메트릭 | 목표값 | 경보 임계값 | 이유 |
|---|---|---|---|
| 배치 완료율 | 100% | < 99.9% | 미처리 셀러 = 정산 누락 |
| 금액 오차율 | 0% | > 0건 | 1원 오차도 즉시 알림 |
| 배치 처리 시간 | 1시간 이내 | > 2시간 | T+2 지급 데드라인 준수 |
| 이체 성공률 | > 99.5% | < 98% | 은행 API 장애 조기 감지 |
| 이의 제기율 | < 0.1% | > 0.5% | 정산 정확도 지표 |
| 이의 제기 처리 시간 | 3영업일 이내 | > 5영업일 | SLA 위반 방지 |
| 감사 로그 적재 지연 | < 1분 | > 5분 | 실시간 추적 가능성 보장 |
10. 실제 장애 사례
사례 1: 환불 상태 동기화 지연 (국내 이커머스 A사)
주문 시스템과 정산 시스템이 분리된 구조에서, 환불 이벤트가 Kafka를 통해 정산 시스템에 전달됐습니다. 그런데 Kafka 컨슈머 지연으로 당일 환불 건이 다음날 배치에 반영됐습니다. 결과적으로 환불된 주문이 정산에 포함되어 셀러가 취소된 주문 금액까지 받았다가, 다음 배치에서 차감됐습니다. 셀러 입장에서는 “갑자기 정산이 줄었다”는 민원이 발생했습니다.
해결책: 정산 배치가 실행되기 전 “환불 이벤트 컨슈머 지연이 N분 이내”임을 확인하는 전제 조건 체크를 추가했습니다. 지연이 임계를 초과하면 배치를 시작하지 않고 알림을 보냅니다.
사례 2: 타임존 버그로 경계 날짜 주문 이중 집계
서버 타임존이 UTC였고, 정산 쿼리의 날짜 조건이 UTC 자정을 기준으로 동작했습니다. 한국 기준 12월 31일 오후 11시에 접수된 주문이 UTC로는 1월 1일 0시여서, 12월 31일 정산과 1월 1일 정산 양쪽에 모두 포함됐습니다. 두 배 정산이 발생했습니다.
해결책: 모든 날짜 경계를 KST 기준으로 명시적으로 지정하고, 정산 쿼리에 created_at_kst 컬럼을 별도 관리합니다. 타임존 관련 로직은 유틸리티 메서드 하나로 중앙화합니다.
11. 확장 포인트
1. 실시간 정산 대시보드
셀러가 “오늘 내 예상 정산액은 얼마인가”를 실시간으로 확인할 수 있는 기능입니다. 주문 완료 이벤트를 소비해서 셀러별 당일 누계를 Redis에 유지합니다. 이것은 확정 정산이 아니라 예상치이므로, 취소·환불이 발생하면 함께 반영됩니다. 셀러 포털 트래픽을 고려해 Redis Sorted Set으로 셀러 ID를 키로 관리합니다.
2. 정산 예측 엔진
과거 정산 데이터를 기반으로 다음 주 예상 정산액을 예측합니다. 셀러가 자금 계획을 세우는 데 도움이 됩니다. 간단하게는 최근 4주 평균을 제공하고, 고도화 시 요일별 패턴, 시즌 계수를 적용합니다.
3. 정산 데이터 분석 레이어
정산 데이터는 플랫폼의 수익 분석에 핵심 데이터입니다. 카테고리별 수수료 수익, 셀러 등급별 매출 분포, 환불율 추이 등을 Redshift에 적재해 BI 도구로 분석합니다. 운영 DB에서 직접 분석 쿼리를 실행하면 배치 성능에 영향을 줍니다.
4. 다통화 정산
글로벌 셀러를 받으면 USD, JPY, EUR 등 다통화 정산이 필요합니다. 환율 스냅샷을 정산 레코드에 함께 저장하고, 환율 확정 기준 시점을 계약서에 명시해야 합니다. 환율 기준 시점이 불명확하면 환율 변동으로 인한 분쟁이 발생합니다.
댓글