뿌리 깊은 시스템은 외부바람에 흔들리지 않는다

묘니·2025년 8월 22일
0

분산 환경에도 흔들리지 않는 주문/결제 시스템 설계하기

외부 시스템 장애에도 끄떡없는 주문/결제 시스템을 만드는 방법

현대의 복잡한 비즈니스 환경에서 시스템은 더 이상 독립적으로 동작하지 않는다. 특히 PG(Payment Gateway)와 같은 외부 서비스가 필수적으로 끼어들면서 개발자의 고민은 깊어졌다.

문제는 이 외부 서비스가 우리의 통제 밖에 있으며 예측 불가능하다는 점이다. 잘못은 쟤가 했지만 뒷수습은 결국 우리 몫이다. 그렇다고 외부 서비스가 드러눕는다고 우리의 서비스가 무너질 수는 없기 때문이다.

그렇다면 어떻게 해야 시스템을 더 견고하고 회복탄력성 있게 만들 수 있을까?

우리의 시스템은 어떤 문제가 있었나

처음엔 장애 발생 시 롤백이 용이하도록 주문과 결제를 하나의 원자적 트랜잭션으로 묶었다.

그러나 외부 시스템이 개입하는 순간 이야기는 달라졌다.

트랜잭션이 지나치게 길어져 병목의 위험이 커졌고, 외부 응답 지연/실패에 직접적으로 영향 받는 문제가 있었기에 결국 주문과 결제를 분리할 수밖에 없었다.

그렇게 결심을 하고 주문 파사드 쪽의 코드를 보니 그만 다시 눈을 감고 싶어졌다.

@Transactional(rollbackFor = Exception.class)
public OrderResult.Detail placeOrder(OrderCriteria.Create criteria) {
	// 상품 정보 조회 및 검증
	List<Long> productIds = criteria.toProductIds();
	List<ProductInfo.Basic> products = productService.getBasics(productIds);

	// 요청한 상품 수와 조회된 상품 수 비교
	if (products.size() != productIds.size()) {
		throw new CoreException(
				com.loopers.support.error.ErrorType.NOT_FOUND, 
				"상품을 찾을 수 없습니다."
		);
	}

	Map<Long, ProductInfo.Basic> productMap = products.stream()
			.collect(java.util.stream.Collectors.toMap(ProductInfo.Basic::id, p -> p));

	// 주문 아이템 목록 생성 및 가격 검증
	List<OrderItem> orderItems = criteria.toOrderItems(productMap);
	BigDecimal itemsTotal = orderItems.stream()
			.map(OrderItem::getTotalPrice)
			.reduce(BigDecimal.ZERO, BigDecimal::add);

	// 쿠폰 할인 계산 및 사용 처리 (전체 금액에 대해 할인, 남은 금액 반환)
	BigDecimal remainingAmountAfterCoupon = couponService.discountProducts(criteria.userId(), criteria.couponIds(), itemsTotal);
	BigDecimal couponDiscountAmount = itemsTotal.subtract(remainingAmountAfterCoupon);

	// 포인트 차감
	BigDecimal finalAmount = remainingAmountAfterCoupon;
	if (criteria.pointAmount() != null && criteria.pointAmount().compareTo(BigDecimal.ZERO) > 0) {
		pointService.deduct(new PointCommand.Deduct(criteria.userId(), criteria.pointAmount().longValue()));
		finalAmount = remainingAmountAfterCoupon.subtract(criteria.pointAmount()).max(BigDecimal.ZERO);
	}

	// 결제 처리
	PaymentInfo.Detail paymentInfo = paymentService.processPayment(criteria.toPaymentCommand(finalAmount));

	// 재고 차감
	stockService.validateAndReduceStocks(criteria.toStockReduceCommands());

	// 주문 생성
	OrderInfo.Detail orderInfo = orderService.createOrder(criteria.toCommand(paymentInfo.id(), finalAmount), orderItems);

	return OrderResult.Detail.from(orderInfo);
}

이 어지러운 코드를 대체 누가 저질렀죠...? 저요...

가뜩이나 고민할 것도 많은데 리팩토링이나 기존 로직을 뒤엎어야 하는 양이 많았다. 내가 저지른 코드 업보니 누굴 탓 할 수도 없고... 눈물을 흘리면서 많은 내용을 수정했다.

주문과 결제의 API를 완전히 분리하고, 비동기로 동작하는 로직에 대비해 주문 검증 및 생성, 결제 요청, 결제 완료 후 주문 완료, 이 세개의 트랜잭션으로 분리했다.

그렇게 수정된 주문 파사드는 다음과 같다.

 @Transactional(rollbackFor = Exception.class)
public OrderResult.Detail placeOrder(OrderCriteria.Create criteria) {
	// 포인트 확인
	pointService.validatePoint(criteria.userId(), criteria.pointAmount());
	// 상품 조회 및 확인
	List<ProductInfo.Basic> products = productService.getBasics(criteria.toProductIds());
	stockService.validateStocks(criteria.toStockCommands(products));
	// 주문 상품 가격 합계 계산
	BigDecimal totalAmount = orderService.calculateTotalAmount(criteria.toOrderItems(products));
	// 쿠폰 할인 금액 계산
	BigDecimal orderAmount = couponService.discountAmount(criteria.userId(), criteria.couponIds(), totalAmount);
	// 주문 생성
	OrderInfo.Detail orderInfo = orderService.createOrder(criteria.toCommand(products, orderAmount));

	return OrderResult.Detail.from(orderInfo);
}

@Transactional(rollbackFor = Exception.class)
public void completeOrder(OrderCriteria.Complete criteria) {
	// 주문 조회
	OrderInfo.Detail orderInfo = orderService.getOrder(criteria.orderId());
	// 포인트 차감
	pointService.deduct(criteria.toPointDeductCommand(orderInfo.pointAmount()));
	// 쿠폰 사용
	couponService.useCoupons(criteria.userId(), criteria.couponIds());
	// 재고 차감
	stockService.validateAndReduceStocks(criteria.toStockReduceCommands(orderInfo.items()));
	// 주문 상태 변경
	orderService.completeOrder(criteria.orderId());
}

외부 시스템의 트랜잭션이 우리 시스템의 주문 로직에 직접적인 영향을 미치지 않게 되면서 시스템의 안정성이 향상됐다!

그럼 이제 우리는 완벽히 분리된 결제 트랜잭션에서 결제를 외부 시스템에 맡기고 비동기 콜백(Callback)만 기다리면 된다.

하지만 우리의 서비스는 항상 해피 케이스에서 작동하지 않는다.

콜백 유실, 서버 오류를 대비한 폴링(Polling)

  • 만약 PG 서비스가 보낸 결제 성공 콜백이 네트워크 문제로 인해 유실되면?
  • PG 서비스의 상태가 불안정하여 결제 실패가 자주 일어난다면?
  • PG 서비스가 요청에 대한 응답을 아주 늦게 주고, 그 사이에 콜백이 도착한다면?
  • 우리 서버가 일시적으로 중단된 사이에 콜백이 도착한다면?

외부 콜백은 본질적으로 신뢰할 수 없는 알림이다. 따라서 결제 응답의 신뢰도를 높이기 위한 별도의 설계가 필요했다.

결제 요청 후 일정 시간이 지나도 콜백이 도착하지 않은 주문에 대해서는 주기적인 스케줄러로 직접 최신 결제 상태를 조회하도록 했다. 이렇게 하면 단발성 응답에 의존하는 것보다 훨씬 정확한 결과를 얻을 수 있다.

이 방식은 “콜백을 별도로 저장한다” 같은 복잡한 처리 과정을 피하면서도, 현실적으로 가장 단순하고 효과적인 해결책이 된다.

물론 응답 하나하나를 세밀하게 관리하거나 특정 타이밍에 재요청하는 방법도 가능하다. 하지만 이는 결국 예외 상황을 처리하기 위한 보조 수단일 뿐이다.

그래서 여러 복잡한 기법을 도입하기보다는, 폴링(polling)을 통한 확인으로 충분히 목적을 달성했다. 비록 조금 느리더라도, 단순하고 확실하게 언젠가는 도달하는 최종적 일관성(Eventual Consistency)을 확보할 수 있기 때문이다.

적절한 폴링 주기?

현재는 콜백이 1~5초 범위에서 랜덤하게 도착하기 때문에 폴링 주기를 3초 정수 간격으로 설정했다.(사실 시간없어서 아직 백오프 적용을 못 했다.)
하지만 정수간격으로 폴링하는 것 보단 지수 백오프(Exponential Backoff) 방식으로 하면 불필요한 요청을 점점 줄여나갈 수 있다. 실제로 현업에서도 실시간 소켓통신의 경우 백오프 방식으로 서버 재접속을 시도하고 있다.

CAP와 나무꾼개발자

분산 시스템에서 C(Consistency), A(Availability), P(Partition Tolerance) 세 가지를 동시에 만족할 수 없으며, 항상 두 가지만 선택해야 한다

모든 것에 완벽한 시스템을 만들 수 있다면 얼마나 좋을까?
하지만 익히 알다시피, 에릭 브루어가 말했듯 우리는 CAP 삼박자를 모두 가져갈 수는 없다. 나무꾼도 결국 상으로 금은동 도끼를 가졌는데, 세 개의 금도끼를 모두 가지려는 건 욕심이라는 거다.

그럼 CAP 중에서 딱 2개를 달성할 수 있는데, 애석하게도 P라는 금도끼는 무조건 받아야 한다. 안 원했는데 그렇게됐다.

그럼 이제 CA라는 두 개의 금도끼 중 뭘 가져야할지 아주 신중하게 고민해야하는 거다.

하지만 외부 PG가 있는 이상 엄격한 일관성을 달성하기란 어렵다. 데이터 일관성을 위해 모든 시스템을 대기시킨다? 어디서 시스템 가용성 떨어지는 소리가 들린다...

결국 우리는 언제나 최종적 일관성(Eventual Consistency)을 목표로 하고 가용성을 극대화하는 쪽을 택해야한다.

그렇다면 여기서 또 의문이 생긴다.
정말 '될 때까지' 요청하는 방법이 좋은 방법일까?

타임아웃과 서킷브레이커

외부 API에 대한 요청이 실패하거나 무한정 지연될 때, 무작정 기다리거나 계속 재시도하는 것은 불필요한 리소스 낭비로 이어진다. 그렇기에 시스템의 안정성을 위해서는 실패를 예측하고, 이를 효율적으로 처리할 방안이 필요하다.

타임아웃: 무한 대기 방지

외부 API의 응답이 오지 않아 무한 대기에 빠지는 상황을 막기 위해 타임아웃(Timeout)을 설정해야 한다. 여기서 중요한 점은 타임아웃이 로직의 실패가 아니라는 점이다.

내가 너무 급해서 먼저 떠난게 상대방의 잘못이라고 할 수 없다. 타임아웃은 우리 시스템이 정한 시간 안에 응답을 받지 못했음을 선언하는 전략적 결정이고, 우리 시스템의 가용성과 사용자 경험을 보호하기 위한 임시 조치일 뿐이다.

서킷브레이커: 무의미한 재시도 막기

재시도를 한다 해도, 외부 서비스가 완전히 다운되어 응답을 줄 수 없는 상황이라면 계속 재시도하는 것은 무의미하다. 이때 필요한 것이 바로 서킷브레이커(Circuit Breaker)다.
마치 전기 회로의 차단기처럼, 특정 서비스의 실패율이 임계치를 넘으면 더 이상의 요청을 막아준다. 이로써 우리 시스템은 불안정한 외부 서비스로부터 격리되어 불필요한 리소스 낭비를 막고 스스로를 보호할 수 있게된다.

그렇다면 이렇게 설계한 시스템이 정말 잘 동작할까?

투비 컨티뉴........................

0개의 댓글