[E-commerce] 트랜잭셔널 아웃박스 패턴을 활용한 이벤트 기반 주문 처리 시스템 설계 및 구현(With Kafka)

 

현재 서비스의 트랜잭션 범위에 대한 이해

상품 주문 결제 트랜잭션

Tx -> 사용자 조회 -> 장바구니 조회 -> 주문 및 주문 아이템 생성 -> 재고 차감 -> 결제 -> 주문 정보 전송 -> commit

현재 트랜잭션 범위의 문제점

  1. 긴 트랜잭션 : 하나의 트랜잭션에서 주문, 결제 같이 여러 작업을 처리하게 될 경우 트랜잭션의 작업 시간이 길어져, 다른 트랜잭션에서 커넥션이 필요할 때 대기시간이 길어져 사용성이 떨어진다.
    -> 트랜잭션의 사용시간이 길어 생기는 문제
  2. 전체 롤백 문제 : 주문 정보를 외부 데이터 플랫폼에 전송 도중 오류로 인해 전송에 실패하면 이전 작업(주문 생성, 재고 차감, 결제)들이 모두 롤백된다. 상품 주문 시 핵심적인 비즈니스 로직이 부가적인 주문 정보 전송 로직에 의해 롤백되고 있기 때문에 트랜잭션 분리가 필요하다.
  3. 동시성 관리 문제: 긴 트랜잭션은 동시성을 저하시킬 수 있다. 다수의 사용자가 동시에 동일한 상품의 재고를 차감하려고 할 때 서로의 작업을 기다려야 할 수 있으며, 이는 사용자 경험을 저하시킬 수 있다.

+ 데드락 발생 가능성

현재 주문 시스템에서는 상품 재고 차감 시 동시성 문제를 해결하기 위해 각 상품의 재고별로 배타락을 걸고 개별 트랜잭션으로 처리하고 있다.
상품 재고 차감 시 각 상품 재고 row에 락을 걸기 때문에 두개 이상의 트랜잭션에서 서로 다른 상품에 락을 걸게 될 시 데드락이 발생할 수 있다.

데드락 발생 상황
Tx 1 : 상품 1, 2 의 재고를 차감하려고 한다.
Tx 2 : 상품 2, 1 의 재고를 차감하려고 한다.

Tx 1 -> 상품 1의 재고 row에 비관적 락을 건다.
Tx 2 -> 상품 2의 재고 row에 비관적 락을 건다.
Tx 1 -> 상품 2의 재고 row에 비관적 락을 걸려고 하지만 이미 Tx 2에서 락을 걸고 있기 때문에 대기 상태에 빠진다.
Tx 2 -> 상품 1의 재고 row에 비관적 락을 걸려고 하지만 이미 Tx 1에서 락을 걸고 있기 때문에 Tx 2도 대기 상태에 빠진다.

Tx 1과 2는 서로 락이 걸린 자원에 접근하려고 하기 때문에 데드락 상태에 빠진다.

 

해결방안

각 트랜잭션이 서로 락이 걸린 자원에 접근하지 않기 위해 자원에 접근하는 순서를 일관되게 유지할 수 있다. 
상품 id를 정렬해주면 모든 트랜잭션이 동일한 순서로 상품 재고 테이블의 자원에 접근하기 때문에 데드락 상황을 피할 수 있다.

e.g.
Tx 1 : 상품 1, 2 의 재고를 차감하려고 한다.
Tx 2 : 상품 2, 1 의 재고를 차감하려고 한다.

각 트랜잭션에서 재고를 차감하기 전 상품의 id별로 정렬을 해준다.

Tx 1 : 상품 1, 2 의 재고를 차감하려고 한다.
Tx 2 : 상품 1, 2 의 재고를 차감하려고 한다.

Tx 1 -> 상품 1의 재고 row에 비관적 락을 건다.
Tx 2 -> 상품 1의 재고 row에 비관적 락을 걸려고 하지만 이미 Tx 1에서 락을 걸고 있기 때문에 대기 상태에 빠진다.
Tx 1 -> 상품 2의 재고 row에 비관적 락을 걸고 상품 1의 락을 해제한다.
Tx 2 -> 상품 1의 재고 row에 대한 락이 해제되었기 때문에 락을 획득한다.

 

현재 실시간 주문 정보를 데이터 플랫폼에 전달할 때 발생하는 문제와 해결방안

문제점

주문 - 결제 - 주문 정보 전송 로직이 하나의 트랜잭션 범위에 있기 때문에 주문과 결제가 정상적으로 처리되었어도 주문 정보를 외부 데이터 플랫폼으로 전송하는 로직에 에러가 발생할 경우 정상적으로 처리되어야 할 주문과 결제가 롤백된다.
(주문 정보를 데이터 플랫폼으로 전송하는 로직은 주문 및 결제에 영향을 미치면 안 된다.)

 

해결방안

주문 결제 트랜잭션과 주문 정보를 전송하는 트랜잭션을 분리하여 주문 정보 전송에 실패하여도 주문 결제 트랜잭션에는 영향이 미치지 않도록 한다.


Spring의 ApplicationEventPublisher를 사용하여 주문 완료 시 주문 이벤트를 생성하여 주문 결제 로직과 주문 정보를 데이터 플랫폼으로 전송하는 로직을 분리할 수 있다.
그리고 주문 정보를 데이터 플랫폼으로 전송하는 로직에 @Transactional의 옵션 중 Propagation.REQUIRES_NEW 옵션을 이용해 주문 메서드의 트랜잭션과 분리된 별도의 트랜잭션을 시작할 수 있다.

 

주의 사항

주문 결제 트랜잭션이 정상적으로 커밋되었을 경우에만 주문 정보를 전송하는 트랜잭션이 시작되도록 해주어야 한다. EventListener에서 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)를 사용하여 해당 이벤트를 발행한 트랜잭션이 성공적으로 commit 된 경우 이벤트를 실행하도록 설정할 수 있다.

@Transactional(propagation = Propagation.REQUIRES_NEW)  
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)  
public void orderEventHandler(OrderCreatedEvent event) {  
	// 외부 데이터 플랫폼으로 주문 정보 전달
}

 

서비스의 규모가 확장된다면 서비스들을 어떻게 분리하고 그 분리에 따른 트랜잭션 처리의 한계와 해결방안

규모 확장 시 서비스 분리

규모가 확장되면 주문 시스템, 재고 시스템, 결제 시스템 각각의 시스템으로 분리하여 관리하는 것이 더 효율적일 수 있다.

그래서 이벤트 기반으로 주문 시스템, 재고 시스템, 결제 시스템의 트랜잭션을 분리하고 각 시스템 간 결합을 느슨하게 유지하여 관리할 수 있다.

 

이전에 보았던 거대했던 상품 주문 결제 트랜잭션 범위를 Spring Event를 활용하여 짧게 처리할 수 있다.

주문은 주문만 하고 이벤트 발행 -> 재고 차감은 재고 차감만 하고 이벤트 발행 -> 결제는 결제만 하고 이벤트 발행 이런 흐름으로 각각의 시스템을 한 트랜잭션 내에서 동작하도록 범위를 설정할 수 있다.

 

관심사의 분리와 함께 트랜잭션 범위도 짧게 설정함으로써 이전에 언급했던 긴 트랜잭션 문제와 전체 롤백 문제를 해결할 수 있다. (사실 결제 실패했을 때, 보상 트랜잭션을 구현해줘야 하기 때문에 더 복잡하긴 하다..)

 

이벤트 기반의 주문 결제 시스템 전체적인 흐름

 

트랜잭션 처리의 한계

이벤트 기반으로 트랜잭션을 분리했을 때의 문제점

대표적으로 이벤트 유실 가능성이 존재한다.

 

예를 들어 주문을 생성하는 트랜잭션 내에서 주문 생성 이후 db에 저장하고 커밋된 이후 주문 생성 이벤트를 발행하는 중 예외가 발생했다면 이후 재고 차감 -> 결제 프로세스는 진행이 되지 않는다.

그럼 DB에는 주문이 생성되었지만 재고 차감과 결제가 반영이 되지 않는 문제가 존재한다.

 

결과적으로, 주문 정보는 존재하지만 재고가 차감되지 않았고 결제도 이루어지지 않는 일관성 불일치 문제가 발생한다.

 

해결 방안

Eventual Consistency(결과적 일관성)을 보장하기 위해 대표적으로 Transactional Outbox Pattern을 도입하여 해결할 수 있다.

 

Outbox Pattern

Transactional Outbox Pattern은 DB와 메시지 큐 간 데이터의 일관성을 보장하기 위해 사용되는 패턴이다.

 

예를 들어 아래와 같은 문제가 있다고 가정하자.

이벤트 기반의 이커머스 서비스에서 주문을 하는 상황에서 주문은 DB에 생성되었지만 주문 이벤트를 발행하는 행위가 실패하여 메시지를 발행하지 못하였을 때, 결제가 이루어지지 않아 일관성이 깨질 수 있다.

 

이러한 이벤트 유실 상황을 방지하기 위해 Outbox pattern을 사용할 수 있다.

 

카프카와 Outbox pattern을 적용하여 주문 시스템 리팩터링

 

 

1. 주문 생성 이후 주문에 대한 정보를 카프카를 이용하여 메시지를 전달하고, 이벤트 발행에 대한 실패했을 때를 대비하여 Outbox Pattern을 적용하여 주문 생성 시 outbox 객체도 같이 생성(Outbox status는 INIT으로 생성)하여 db에 저장하였다.

@Transactional
public Order order(Long userId, OrderRequest request) throws JsonProcessingException {
	Cart cart = cartService.getCart(userId);
	Order order = orderService.order(userId, cart, request);

	outboxService.recordOrderOutbox(order);
	orderEventPublisher.publishEvent(new OrderCreatedEvent(order));
	return order;
}

 

2. 카프카에서 발행된 메시지를 소비하는 2개의 컨슈머(outbox-consumer, stock-consumer)가 존재한다.

2-1. outbox-consumer는 카프카에서 메시지를 가져와 주문 생성 시 만들었던 outbox 객체의 상태를 Published 상태로 변경해 주어 이벤트가 발행되었음을 마킹해 준다.

@Transactional(propagation = Propagation.REQUIRES_NEW)
@KafkaListener(id = "outbox-container", topics = TOPIC_ORDER)
public void orderEventHandler(String message) throws JsonProcessingException {
	OutboxEntity outbox = objectMapper.readValue(message, OutboxEntity.class);

	outboxService.updateStatus(outbox, OutboxStatus.PUBLISHED);
}

 

2-2. stock-consumer는 주문 메시지를 바탕으로 재고를 감소하는데, 이벤트가 중복 발행되었을 때를 처리하기 위해 Processed 객체를 활용한다.

만일 카프카에서 가져온 메시지에 대한 processed 객체가 존재하면 이미 이전에 처리되었던 이벤트이기 때문에 재고 차감을 하지 않고, 없으면 재고 차감을 실행하고 processed 객체를 생성하여 db에 저장한다.

@Transactional(propagation = Propagation.REQUIRES_NEW)
@KafkaListener(id = "stock-container", topics = TOPIC_ORDER)
public void orderEventHandler(String message) throws JsonProcessingException {
	OutboxEntity outbox = objectMapper.readValue(message, OutboxEntity.class);

	Long orderId = outbox.getAggregateId();
	Order order = orderService.getOrder(orderId);

	// 이미 처리된 이벤트인지 확인
	if (!processedService.checkExistedProcessed(orderId)) {
		stockService.updateStockQuantityForOrder(order);
		// 이벤트 처리 기록
		processedService.record(orderId);
	}
}

 

 

3. 이벤트 발행이 실패된 이벤트들을 재처리하기 위해 스케쥴러를 이용하여 outbox객체의 상태가 INIT인 것들을 DB에서 가져와 다시 카프카에 전송해 준다.

@Scheduled(fixedRate = 600000)
public void processFailedOutboxMessages() throws JsonProcessingException {
	List<OutboxEntity> initOutboxMessages = outboxService.getInitOutbox();
	for (OutboxEntity outbox : initOutboxMessages) {
		orderProducer.sendOrder(outbox);
	}
}

 

4. 재고 차감 이후는 스프링 이벤트를 활용하여 결제 로직을 진행한다.

 

 

정리

처음으로 카프카를 이용해서 메시지 발행도 해보고 아웃박스 패턴도 적용해 보았다.(잘 적용한 건지는 미지수..)

아무튼 적용해 보면서 느낀 점은 이벤트 기반 아키텍처의 장점이 각 시스템이 독립적으로 구성되어 있기 때문에 시스템 간 결합도를 낮춘것이라고 생각을 했는데, 단점도 이벤트로 시스템간 결합도를 끊었기 때문에 코드를 파악하는 게 매우 힘들었다.

내가 작성했지만 이 이벤트가 발행되었을 때 어떤 클래스로 이동해야 되는지 생각하는 비용이 좀 들어 고생을 했었다.

 

로컬에서 카프카 띄우고 주문했을 때 정상적으로 처리는 되지만 아직 통합 테스트 코드를 작성하지 못해 동시성 테스트를 해보지 못한 것이 아쉽다.

빠른 시일 내에 카프카를 적용했을 때, 통합테스트 하는 방법을 알아보고 적용해 보자.