결제가 완료되면 판매자에게 돈을 줘야 한다. 그런데 결제 즉시 지급하지는 않는다. 실제 커머스에서 정산은 보통 이렇게 흐른다.
결제 완료
→ 정산 생성 (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만 바꾸면 된다.
결제가 완료되면 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가 동작하지 않음
DefaultErrorHandler와 DeadLetterPublishingRecoverer를 설정했는데 실패한 메시지가 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(...)
}
매일 자정에 전날 생성된 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) }
관리자가 정산 지급을 승인하면 상태가 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 프록시를 우회해서 트랜잭션이 적용되지 않는다.
판매자가 자신의 정산 현황을 조회할 때 상태별 건수와 합계를 보여줘야 한다.
처음엔 전체 레코드를 가져와서 애플리케이션에서 집계했다. 판매자의 거래량이 많아지면 메모리에 수만 건을 올려야 한다.
// ❌ 전체를 메모리에 올려서 집계
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