# DDD 실전: 결제 환율 고정으로 네 개의 치명적 버그를 해결한 방법
결제 시스템에서 환율 계산 오류는 재무 불일치와 신뢰 상실로 이어집니다. 실제 Go 사례 연구에서 애그리게이트에 고정 환율을 포함한 도메인 모델이 교과서 DDD 예제에서 드러나지 않는 문제를 해결하는 방법을 보여줍니다.
한 결제에 세 개의 환율 문제
기존 아키텍처는 여러 소스에서 환율을 가져왔습니다: checkout-api는 Redis에서 데이터를 가져오고, payment-service는 HTTP를 통해 fx-rate-service에서, ledger-service는 Postgres 스냅샷에서 가져왔습니다. 이로 인해 불일치가 발생했습니다: 고객은 12,430.00 KZT를 보았고, 은행은 12,435.27 KZT를 청구했으며, 관리 패널의 지원팀은 12,428.91 KZT를 보았습니다. 각 서비스는 기술적으로 맞았지만, 시스템 전체적으로 일관되지 않은 데이터를 생성했습니다.
임시 수정 사항—대규모 결제에서 checkout-api 캐시 비활성화—은 p95 지연 시간을 120 ms에서 500 ms로 악화시켰습니다. 영구 해결책은 환율, 금액, 유효 기간을 비즈니스 계약으로 고정하는 도메인 엔티티 Quote를 도입했습니다. 이제 결제는 동적 데이터 대신 quote_id를 통해 생성됩니다. 이로 인해 청구나 장부 입력 중 환율 재계산 가능성이 사라졌습니다.
type Quote struct {
ID string
UserID string
From Money
To Money
Rate RateSnapshot
CreatedAt time.Time
}
func NewPayment(quote Quote, idemKey string) (*Payment, error) {
if time.Now().After(quote.Rate.ExpiresAt) {
return nil, ErrQuoteExpired
}
// ...
}
payments 테이블은 비정규화되어(환율과 금액 복사본 저장)졌지만, 감사 시 데이터 원자성을 보장합니다. 이제 단일 행에 사건 분석에 필요한 모든 정보가 포함됩니다.
반올림 오류: float64 vs decimal.Decimal
두 번째 치명적 버그는 하룻밤에 8,000행에 걸쳐 누적되는 불일치로 나타났습니다. 문제는 일관되지 않은 반올림에서 비롯되었습니다: payment-service는 math.Round를 사용하고, billing-service는 decimal.NewFromFloat를, 보고서는 SQL의 round() 함수를 사용했습니다. float64를 NewFromFloat를 통해 decimal로 변환하면 정밀도 손실이 발생했습니다.
임시 수정은 불일치를 최대 1 KZT까지 누적하는 야간 작업 rounding_adjustments였습니다. 최종 해결책은 다음과 같습니다:
- 도메인 구조체에서
float64금지 decimal.NewFromString을 통해 문자열로 금액 수락Money.RoundByCurrency메서드에서 중앙화된 반올림- 정밀도를 통화에 맞춤 (예: JPY는 정수로 반올림)
func (m Money) RoundByCurrency() Money {
scale := map[Currency]int32{
"USD": 2,
"JPY": 0,
}[m.currency]
return Money{
amount: m.amount.Round(scale),
currency: m.currency,
}
}
배치 작업 성능은 200k 행에서 1.8초에서 6.4초로 하락했지만, 백오피스 프로세스에는 허용 가능합니다. 정확성이 속도보다 우선했습니다.
멱등성 vs 외부 API 타임아웃
세 번째 문제는 결제 제공자에서 발생했습니다: acquirer-api의 3초 타임아웃으로 중복 청구가 일어났습니다. context deadline exceeded 시 시스템이 재시도했지만, 첫 번째 요청은 제공자에서 여전히 처리되었습니다. 결과: 동일 금액의 두 배 청구.
첫 번째 대처—자동 재시도 비활성화—는 unknown 상태에 멈춘 결제 수를 증가시켰습니다. 최종 해결책:
(merchant_id, idempotency_key)에 고유 인덱스가 있는 필수idempotency_key- 현재 결제 상태를 확인하는 도메인 메서드
Authorize - 멈춘 작업을 위한 조정 워커
create unique index payments_idem_uq
on payments (merchant_id, idempotency_key);
func (p *Payment) Authorize(acquirerID string) error {
switch p.Status {
case PaymentAuthorized:
return nil
case PaymentCreated, PaymentUnknown:
p.Status = PaymentAuthorized
// ...
}
}
PaymentUnknown 상태는 외부 API 불확실성을 반영하는 명시적 시스템 상태가 되었습니다. 조정 워커는 90초 이상 된 결제를 제공자에게 상태를 쿼리하여 처리합니다.
읽기 모델 지연: 지원을 위한 실시간 데이터
Quote 구현 후 새로운 문제가 나타났습니다: Kafka 지연(3-4분)으로 지원 관리 패널에 오래된 데이터가 표시되었습니다. 결제가 정확했음에도 운영자가 이전 금액을 보고 고객 패닉이 발생했습니다.
빠른 수정:
- 관리 패널에
quote_id와rate_version표시 - 15분 이내 결제에 대해
payments테이블 직접 읽기 - 지연 경고 임계값을 10초에서 60초로 낮춤
최종 해결책은 데이터 소스를 분리했습니다:
- 최근 결제:
payments테이블 - 과거 데이터: 장부 기반 읽기 모델
이로 인해 관리 패널 복잡성이 증가했지만, 중요한 시나리오에서 데이터 신선도를 보장합니다.
독단 없는 DDD: 애그리게이트를 도메인 핵심으로
프로젝트 성공은 domain 폴더가 아니라 환율을 비즈니스 계약으로 재고하는 데 있었습니다. 이제 Payment 애그리게이트는:
- 생성 시
Quote만료 검증 - 확인 후 금액 고정
- 도메인 작업에서
float64제외 - 멱등 청구 보장
unknown불확실성 적절 처리
애플리케이션 계층은 얇아졌습니다: 단지 단계를 조율하며 비즈니스 로직을 애그리게이트에 위임합니다. 사용 예시:
func (uc *UseCase) CreatePayment(ctx context.Context, cmd CreatePaymentCommand) (*Payment, error) {
quote, err := uc.quotes.Get(ctx, cmd.QuoteID)
if err != nil {
return nil, err
}
payment, err := NewPayment(*quote, cmd.IdempotencyKey)
// ...
}
핵심 교훈: 도메인 규칙이 모델에 내장될 때 DDD가 작동하며, 기술 세부 사항으로 취급되지 않습니다.
주요 교훈
- 애그리게이트 내 고정 환율은 서비스 간 불일치를 제거합니다. 환율은 동적 매개변수가 아니라 결제 계약의 일부여야 합니다.
- 정밀한 화폐 타입. 중앙화된 반올림과 함께
decimal.Decimal사용; 테스트에서도float64피함. - 명시적
unknown상태는 외부 시스템 불확실성을 솔직히 반영합니다. 수동 해결을 위한 조정 워커 추가. - 데이터 소스 분리는 지원 운영에 필수적입니다. 최근 작업은 읽기 모델이 아닌 테이블에서 직접 읽음.
— Editorial Team
아직 댓글이 없습니다.