정산 시스템 설계 — Kafka 이벤트, Spring Batch, 분산락까지

정영범·2026년 5월 12일

토이프로젝트

목록 보기
10/11

정산이란

결제가 완료되면 판매자에게 돈을 줘야 한다. 그런데 결제 즉시 지급하지는 않는다. 실제 커머스에서 정산은 보통 이렇게 흐른다.

결제 완료
  → 정산 생성 (PENDING)         ← 오늘 팔렸다는 기록
  → 익일 배치로 확정 (CONFIRMED) ← 취소/환불 정리 후 확정
  → 관리자 승인으로 지급 (PAID)  ← 실제 계좌 이체

취소나 환불이 생길 수 있기 때문에 당일 바로 지급하지 않는다. 하루가 지나 정산을 확정한 후 지급하는 구조다. 이 프로젝트도 이 흐름을 따랐다.


정산 엔티티 — 상태 전이를 도메인 안에

@Entity
class Settlement(
    val paymentId: UUID,
    val orderId: UUID,
    val sellerId: UUID,
    val userId: UUID,
    val totalAmount: Long,
    val platformFee: Long,    // 플랫폼 수수료
    val sellerAmount: Long,   // 판매자 수령액

    var status: SettlementStatus = SettlementStatus.PENDING,
    var confirmedAt: Instant? = null,
    var paidAt: Instant? = null,
) {
    fun confirm() {
        require(status == SettlementStatus.PENDING) { "PENDING 상태만 확정할 수 있습니다" }
        status = SettlementStatus.CONFIRMED
        confirmedAt = Instant.now()
    }

    fun pay() {
        require(status == SettlementStatus.CONFIRMED) { "CONFIRMED 상태만 지급 완료 처리할 수 있습니다" }
        status = SettlementStatus.PAID
        paidAt = Instant.now()
    }
}

상태 전이 메서드를 엔티티 안에 넣었다. confirm()PENDING에서만, pay()CONFIRMED에서만 호출할 수 있다. 잘못된 상태에서 호출하면 require가 예외를 던진다. 서비스 레이어에서 상태를 직접 바꾸는 코드가 없다.

수수료 계산은 설정 파일로 관리한다.

@ConfigurationProperties(prefix = "settlement")
data class SettlementConfig(
    var platformFeeRate: Double = 0.10  // 10%
) {
    @PostConstruct
    fun validate() {
        require(platformFeeRate in 0.0..1.0) { "수수료율은 0.0~1.0 사이여야 합니다" }
    }

    fun calculatePlatformFee(amount: Long): Long = (amount * platformFeeRate).toLong()
    fun calculateSellerAmount(amount: Long): Long = amount - calculatePlatformFee(amount)
}

수수료율이 바뀌어도 코드를 수정하지 않아도 된다. application.yml에서 settlement.platform-fee-rate만 바꾸면 된다.


1단계: 정산 생성 — Kafka 이벤트 기반

결제가 완료되면 payment-service가 PAYMENT_COMPLETED 이벤트를 Kafka로 발행한다. settlement-service의 Consumer가 이 이벤트를 받아서 정산을 생성한다.

@KafkaListener(topics = ["payment-events"], groupId = "settlement-service-group")
fun consume(message: String) {
    val event = objectMapper.readTree(message)
    val eventType = event["eventType"]?.asText() ?: return

    if (eventType != "PAYMENT_COMPLETED") return

    val eventId = UUID.fromString(event["eventId"].asText())

    idempotencyHandler.executeIdempotent(eventId) {
        val payload = objectMapper.readTree(event["payload"].asText())

        settlementService.createSettlement(
            paymentId = UUID.fromString(payload["paymentId"].asText()),
            orderId = UUID.fromString(payload["orderId"].asText()),
            sellerId = UUID.fromString(payload["sellerId"].asText()),
            userId = UUID.fromString(payload["userId"].asText()),
            totalAmount = payload["amount"].asLong()
        )
    }
}

idempotencyHandler.executeIdempotent()로 감쌌다. 4편에서 다룬 멱등성 처리 패턴이 그대로 적용된다. Kafka at-least-once 특성상 같은 이벤트가 두 번 올 수 있는데, eventId 기반으로 중복 처리를 막는다.

> 트러블슈팅: DLQ가 동작하지 않음

DefaultErrorHandlerDeadLetterPublishingRecoverer를 설정했는데 실패한 메시지가 DLQ로 가지 않았다. 로그도 없이 조용히 사라졌다.

원인은 try-catch였다. Consumer 메서드 안에서 예외를 잡아 삼키면 리스너 컨테이너는 "정상 처리된 것"으로 인식한다. DefaultErrorHandler는 리스너 컨테이너 레벨에서 예외를 감지하기 때문에 메서드 안에서 catch되면 아예 실행되지 않는다.

// ❌ 예외를 삼킴 → DLQ 동작 안 함
fun consume(message: String) {
    try {
        settlementService.createSettlement(...)
    } catch (e: Exception) {
        log.error { "처리 실패: ${e.message}" }
    }
}

// ✅ 예외를 전파 → DefaultErrorHandler가 감지 → 재시도 → DLQ
fun consume(message: String) {
    settlementService.createSettlement(...)
}

2단계: 정산 확정 — Spring Batch 일별 배치

매일 자정에 전날 생성된 PENDING 정산을 CONFIRMED로 일괄 전환한다.

@Scheduled(cron = "\${settlement.batch-cron}")  // 기본값: 매일 자정
fun dailyConfirmation() {
    val targetDate = LocalDate.now().minusDays(1).toString()  // 전날

    val jobParameters = JobParametersBuilder()
        .addString("targetDate", targetDate)
        .addLong("timestamp", System.currentTimeMillis())  // 재실행 허용
        .toJobParameters()

    jobLauncher.run(dailySettlementJob, jobParameters)
}

timestamp를 JobParameters에 추가한 이유가 있다. Spring Batch는 동일한 JobParameters로 실행된 Job을 재실행하지 않는다. 배치가 중간에 실패해서 재실행해야 할 때 timestamp가 없으면 "이미 실행됐다"고 거부한다. timestamp를 넣어두면 같은 targetDate라도 새 JobInstance로 처리된다.

배치 처리는 Chunk 기반으로 구성했다.

// Reader: 전날 생성된 PENDING 정산 조회 (1000개씩)
JpaPagingItemReaderBuilder<Settlement>()
    .queryString(
        "SELECT s FROM Settlement s " +
        "WHERE s.status = :status AND s.createdAt < :targetDate " +
        "ORDER BY s.createdAt ASC"
    )
    .pageSize(CHUNK_SIZE)  // 1000

// Processor: PENDING → CONFIRMED
ItemProcessor { settlement ->
    settlement.confirm()
    settlement
}

// Writer: 저장
ItemWriter { items -> settlementRepository.saveAll(items) }

3단계: 정산 지급 — 분산락으로 중복 지급 방지

관리자가 정산 지급을 승인하면 상태가 CONFIRMED → PAID로 전이된다. 동시에 같은 정산에 대해 지급 요청이 두 번 들어오면 중복 지급이 발생할 수 있다. Redisson 분산락으로 막았다.

fun pay(settlementId: UUID): SettlementResponse {
    val lock = redissonClient.getLock("settlement:pay:$settlementId")

    if (!lock.tryLock(5, 10, TimeUnit.SECONDS)) {
        throw IllegalStateException("현재 처리 중인 요청이 있습니다. 잠시 후 다시 시도해주세요.")
    }

    return try {
        settlementPayExecutor.execute(settlementId)  // @Transactional
    } finally {
        lock.unlock()
    }
}

락 담당(SettlementService)과 트랜잭션 담당(SettlementPayExecutor)을 분리했다. 8편에서 다룬 Self-invocation 문제 때문이다. 같은 클래스 안에서 @Transactional 메서드를 호출하면 Spring AOP 프록시를 우회해서 트랜잭션이 적용되지 않는다.


판매자 정산 현황 조회 — DB 집계 쿼리

판매자가 자신의 정산 현황을 조회할 때 상태별 건수와 합계를 보여줘야 한다.

처음엔 전체 레코드를 가져와서 애플리케이션에서 집계했다. 판매자의 거래량이 많아지면 메모리에 수만 건을 올려야 한다.

// ❌ 전체를 메모리에 올려서 집계
val all = settlementRepository.findBySellerId(sellerId)
val pending = all.filter { it.status == PENDING }.sumOf { it.sellerAmount }

DB에서 바로 집계하도록 바꿨다.

@Query("""
    SELECT s.status as status, COUNT(s) as count, SUM(s.sellerAmount) as totalAmount
    FROM Settlement s
    WHERE s.sellerId = :sellerId
    GROUP BY s.status
""")
fun findSummaryBySellerId(sellerId: UUID): List<SettlementSummaryRow>

상태별로 GROUP BY해서 건수와 합계만 가져온다. 거래량이 아무리 많아도 쿼리 결과는 항상 상태 수(최대 3행)만 반환된다.


정산 흐름 전체 정리

결제 완료
  → PAYMENT_COMPLETED 이벤트 (Kafka)
  → settlement-service Consumer 수신
  → 멱등성 체크 (eventId 기반)
  → 정산 생성 (PENDING)
       ├── totalAmount: 결제 금액
       ├── platformFee: 수수료 (설정값 기반)
       └── sellerAmount: 판매자 수령액

매일 자정 Spring Batch
  → 전날 PENDING 정산 1000건씩 조회
  → confirm() → CONFIRMED

관리자 지급 승인
  → Redisson 분산락 획득
  → pay() → PAID
profile
벨로그 좋은것만 드려요

0개의 댓글