이번 포스팅은 msa에서 분산 트랜잭션을 제어하는 방법 중 하나인 saga-pattern을 활용해서 분산 서버에서 간단한 결제 프로세스를 구현해봅니다.
간단히 정리하자면, 각기 다른 분산 서버에 다른 DB 밴더사를 사용하고 있어도, Saga Pattern을 사용하면 데이터 일관성을 보장받을 수 있다. 또한 트랜잭션 실패시, 보상 트랜잭션으로 데이터 정합성을 맞출 수 있다.
Saga Pattern은 Orchestration 방식과 Choreography 방식이 존재하는데 이번 포스팅에서는 Choreography 방식만 소개합니다. Orchestration 방식을 알아보고 싶으시다면 마이크로소프트 공식 홈페이지를 참조해주세요
(1) 사용자 요청을 받은 Order 서비스에서 주문 번호를 생성해서 DB에 적재
(2) Kafka에 주문번호 생성 이벤트 발행
(3) Stock에서 주문번호 생성 이벤트를 구독해서 해당 재고 빼기
(4) Kafka에 재고 빼기 이벤트 발행
(5) Payment에서 재고 빼기 이벤트를 구독해서 결제 프로세스 진행
결제 분산 트랜잭션 진행 중, Payment 서비스에서 트랜잭션이 실패할 경우를 가정한 그림입니다. 빨간색 화살표는 Producer, 파란색 화살표는 Consumer를 뜻합니다.
(1) Payment 서비스에서 트랜잭션 실패
(2) Payment에서 재고 롤백 이벤트 발행
(3) Stock에서 재고 롤백 이벤트를 구독해서 해당 재고 플러스
(4) Stock에서 주문 롤백 이벤트 발행
(5) Order에서 주문 롤백 이벤트를 구독해서 해당 주문 삭제
order, stock, payment 세 서비스 코드가 비슷하기 때문에 order 서비스 코드로만 포스팅하겠습니다. 코드가 궁금하신 분은 GitHub을 참고해주세요. 실제 데이터는 order 서비스에서만 저장하고 stock, payment 서비스에서는 로그 출력으로 대신합니다. 또한 카프카와 주키퍼는 docker-compose로 띄우며 실습 환경 설정합니다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.kafka:spring-kafka'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2:2.1.214'
}
spring:
kafka:
consumer:
bootstrap-servers: localhost:9092
group-id: group-01
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.apache.kafka.common.serialization.LongDeserializer
producer:
bootstrap-servers: localhost:9092
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.LongSerializer
version: "3"
services:
zookeeper:
image: wurstmeister/zookeeper
ports:
- "2181:2181"
kafka:
image: wurstmeister/kafka
ports:
- "9092:9092"
environment:
KAFKA_ADVERTISED_HOST_NAME: 127.0.0.1
KAFKA_CREATE_TOPICS: "order-create:1:1, order-rollback:1:1, stock-decrease:1:1, stock-rollback:1:1"
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
volumes:
- /var/run/docker.sock:/var/run/docker.sock
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final StockProducer stockProducer;
public void order(String productId){
final Order order = new Order(productId);
final Order newOrder = orderRepository.save(order);
stockProducer.order(newOrder.getId());
}
public void delete(Long orderId){
orderRepository.deleteById(orderId);
log.info("{}번 주문번호 삭제", orderId);
}
}
order()
: 주문 번호를 생성하고 주문번호 생성
이벤트 호출delete()
: 분산 트랜잭션 실패 시, 주문번호를 삭제하며 롤백을 위한 메소드@Component
@RequiredArgsConstructor
public class StockProducer {
private final KafkaTemplate kafkaTemplate;
public void order(Long orderId){
kafkaTemplate.send("order-create", orderId);
}
}
order()
: 주문번호 생성 이벤트로, order-create
토픽에 orderId를 보냄order-create
토픽을 구독하며 해당 이벤트를 읽고 다음 트랜잭션을 진행@Slf4j
@Component
@RequiredArgsConstructor
public class RollbackConsumer {
private final OrderService orderService;
@KafkaListener(topics = "order-rollback", groupId = "group-01")
public void rollbackOrder(Long orderId){
log.error("======== [Rollback] order-rollback, orderId :{}======== ", orderId);
orderService.delete(orderId);
}
}
rollbackOrder()
: 보상 트랜잭션으로, order-rollback
토픽을 구독하며 데이터 일관성을 보장하기 위해 해당 주문을 삭제Payment 서비스에서 트랜잭션이 실패해서 보상 트랜잭션 발생상황을 가정합니다
기대 (1) : Payment 서비스에서 재고 롤백
이벤트 발행
기대 (2) : Stock 서비스에서 재고 롤백
이벤트 구독 후 재고 롤백
기대 (3) : Stock 서비스에서 주문 롤백
이벤트 발행
기대 (4) : Order 서비스에서 주문 롤백
이벤트 구독 후 주문 롤백
1. errorPerHalf()
메서드로 50% 확률로 RuntimeException을 발생
2. 트랜잭션 실패로 인해 catch문의 rollbackDecreasedStock()
메서드 실행
3. 카프카 브로커에 stock-rollback
토픽에 orderId가 2인 메세지를 전달하면서 보상 트랜잭션 실행
결제 프로세스를 진행하면서 Payment 서비스에서 트랜잭션이 실패했기 때문에 Kafka를 통해서 이벤트 Pub/Sub으로 각각 서비스에 보상 트랜잭션이 발행돼서 모든 서비스가 데이터 일관성을 보장된 것을 확인할 수 있다.