Saga패턴을 이용한 분산 트랜잭션 제어(결제 프로세스 실습)

Pir·2022년 8월 15일
13
post-thumbnail

이번 포스팅은 msa에서 분산 트랜잭션을 제어하는 방법 중 하나인 saga-pattern을 활용해서 분산 서버에서 간단한 결제 프로세스를 구현해봅니다.

1. 분산 트랜잭션

💡 분산 트랜잭션을 왜 제어해야하는가?

  • 아래 그림은 결제 프로세스를 MSA 분산 환경으로 간략하게 나타냈습니다. 각 도메인은 각각의 DB를 바라보고 있으며, 결제 프로세스는 세 서비스와 DB를 거쳐야 완료됩니다.
  • 문제없이 항상 완료만 되는 상황이면 좋겠지만 Order 모듈에서 주문을 생성해서 DB에 저장한 후에, Stock에서 예외처리 혹은 장애가 나서 롤백해야한다면 어떻게 처리해야할까요?

2. Saga Pattern

Saga Pattern이란?

  • Saga Pattern은 마이크로 서비스에서 데이터 일관성을 관리하는 방법입니다.
  • 각 서비스는 로컬 트랜잭션을 가지고 있으며, 해당 서비스 데이터를 업데이트하며 메시지 또는 이벤트를 발행해서, 다음 단계 트랜잭션을 호출하게 됩니다.
  • 만약, 해당 프로세스가 실패하게 되면 데이터 정합성을 맞추기 위해 이전 트랜잭션에 대해 보상 트랜잭션을 실행합니다.
  • NoSQL 같이 분산 트랜잭션 처리를 지원하지 않거나, 각기 다른 서비스에서 다른 DB 밴더사를 이용할 경우에도 Saga Pattenrn을 이용해서 데이터 일관성을 보장 받을 수 있습니다.

간단히 정리하자면, 각기 다른 분산 서버에 다른 DB 밴더사를 사용하고 있어도, Saga Pattern을 사용하면 데이터 일관성을 보장받을 수 있다. 또한 트랜잭션 실패시, 보상 트랜잭션으로 데이터 정합성을 맞출 수 있다.


Choreography 방식이란?

Saga Pattern은 Orchestration 방식과 Choreography 방식이 존재하는데 이번 포스팅에서는 Choreography 방식만 소개합니다. Orchestration 방식을 알아보고 싶으시다면 마이크로소프트 공식 홈페이지를 참조해주세요

  • Choreography 방식은 서비스끼리 직접적으로 통신하지 않고, 이벤트 Pub/Sub을 활용해서 통신하는 방식입니다.
  • 프로세스를 진행하다가 여러 서비스를 거쳐 서비스(Stock, Payment)에서 실패(예외처리 혹은 장애)가 난다면 보상 트랜잭션 이벤트를 발행합니다.
  • 장점으론, 간단한 workflow에 적합하며 추가 서비스 구현 및 유지관리가 필요하지 않아서 간단하게 세팅할 수 있습니다.
  • 단점으론, 트랜잭션을 시뮬레이션하기 위해 모든 서비스를 실행해야하기 때문에 통합테스트와 디버깅이 어려운 점이 있습니다.

3. 결제 어플리케이션 구성

정상적인 분산 트랜잭션 프로세스


(1) 사용자 요청을 받은 Order 서비스에서 주문 번호를 생성해서 DB에 적재
(2) Kafka에 주문번호 생성 이벤트 발행
(3) Stock에서 주문번호 생성 이벤트를 구독해서 해당 재고 빼기
(4) Kafka에 재고 빼기 이벤트 발행
(5) Payment에서 재고 빼기 이벤트를 구독해서 결제 프로세스 진행

실패 분산 트랜잭션 프로세스

결제 분산 트랜잭션 진행 중, Payment 서비스에서 트랜잭션이 실패할 경우를 가정한 그림입니다. 빨간색 화살표는 Producer, 파란색 화살표는 Consumer를 뜻합니다.

(1) Payment 서비스에서 트랜잭션 실패
(2) Payment에서 재고 롤백 이벤트 발행
(3) Stock에서 재고 롤백 이벤트를 구독해서 해당 재고 플러스
(4) Stock에서 주문 롤백 이벤트 발행
(5) Order에서 주문 롤백 이벤트를 구독해서 해당 주문 삭제


4. 결제 어플리케이션 실습

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'
}

application.yml 설정

  • 카프카 Producer, Consumer 설정
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

docker-compose.yml 설정

  • 주키퍼, 카프카 컨테이너 설정
  • 카프카 토픽 설정 (주문번호 생성, 주문번호 롤백, 재고 빼기, 재고 롤백)
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

OrderService.java

@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() : 분산 트랜잭션 실패 시, 주문번호를 삭제하며 롤백을 위한 메소드

StockProducer.java

@Component
@RequiredArgsConstructor
public class StockProducer {
    private final KafkaTemplate kafkaTemplate;

    public void order(Long orderId){
        kafkaTemplate.send("order-create", orderId);
    }
}
  • order() : 주문번호 생성 이벤트로, order-create 토픽에 orderId를 보냄
  • Stock 서비스에선 order-create 토픽을 구독하며 해당 이벤트를 읽고 다음 트랜잭션을 진행

RollbackConsumer.java

@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. Payment 서비스에서 재고 롤백 이벤트 발행

StockDecreasedConsumer.java

PaymentService.java

StockProducer.java

1. errorPerHalf() 메서드로 50% 확률로 RuntimeException을 발생
2. 트랜잭션 실패로 인해 catch문의 rollbackDecreasedStock() 메서드 실행
3. 카프카 브로커에 stock-rollback 토픽에 orderId가 2인 메세지를 전달하면서 보상 트랜잭션 실행


✅ 2. Stock 서비스에서 재고 롤백 이벤트 구독 후 재고 롤백

  • 정상 트랜잭션 : 재고가 정상적으로 1개 줄여진 것을 확인
  • 보상 트랜잭션 : Payment 서비스에서 트랜잭션이 실패해서 orderId가 2인 상품 재고 롤백

✅ 3. Order 서비스에서 주문 롤백 이벤트 구독 후 주문 롤백

  • 정상 트랜잭션 : 주문 번호 생성
  • 보상 트랜잭션 : Payment 서비스에서 트랜잭션이 실패해서 orderId가 2인 주문 번호 삭제하면서 보상 트랜잭션 완료

결제 프로세스를 진행하면서 Payment 서비스에서 트랜잭션이 실패했기 때문에 Kafka를 통해서 이벤트 Pub/Sub으로 각각 서비스에 보상 트랜잭션이 발행돼서 모든 서비스가 데이터 일관성을 보장된 것을 확인할 수 있다.


실습 GitHub Repository

참고

profile
흉내내는 사람이 아닌, 이해하는 사람이 되자

0개의 댓글