MSA 환경에서 서비스 간의 데이터 일관성을 보장하는 것은 복잡한 문제입니다.
특히 여러 서비스에 각각 다른 데이터베이스가 존재하며 트랜잭션을 유지해야하는 상황에서 ACID 원칙을 지키는 것은 쉽지 않습니다.
이 기술적 문제를 해결하기 위하여 SAGA 패턴을 적용하여 Kafka 기반의 분산 트랜잭션을 구현하는 미니 프로젝트를 진행하고 글로 정리하였습니다.
프로젝트 전체 코드는 아래 github을 통해 확인할 수 있습니다.
https://github.com/kwj2435/spring-redis-stock-manager
SAGA 패턴
SAGA는 '이야기' 혹은 '서사'라는 뜻을 가진 영어단어입니다. 단어의 뜻과 같이 SAGA 패턴의 분산 트랜잭션은 2PC(Two-Phase Commit)와 같은 전통적인 트랜잭션 관리 방식과 다르게, 서비스 간 느슨한 결합과 분산 환경에서의 장애 내성을 목표로 설계되었습니다.
간략히 설명하면 SAGA 패턴은 하나의 트랜잭션을 여러 작은 로컬 트랜잭션으로 나누고, 각 단계에서 작업이 성공적으로 처리되면 다음 단계로 진행합니다. 각 단계에서 실패 발생시 보상 트랜잭션을 실행하여 데이터를 원상복구합니다.
SAGA 패턴은 크게 코레오그래피(Choreography) 방식과 오케스트레이션(Orchestration) 방식, 두가지로 나뉩니다.
코레오그래피(choreography) 방식
코레오 그래피 방식은 중앙 관리자가 없는 이벤트 기반 아키텍처입니다.
각 서비스가 작업 완료 후 이벤트를 발행하여 다음 서비스를 트리거 하는 구조입니다.
코레오 그래피는 서비스간 이벤트로 분산 제어하기 때문에 느슨한 결합상태를 유지할 수 있고 이를 통해 높은 확장성을 가질 수 있습니다.
다만 이벤트 설계가 상대적으로 복잡하고 이벤트 흐름에 따른 장애 추적이 어렵다는 단점이 있습니다.
오케스트레이션(Orchestration) 방식
오케스트레이션은 중앙 관리자인 오케스트레이터가 전체 트랜잭션 흐름을 제어합니다.
오케스트레이터가 각 서비스 호출 및 보상 트랜잭션 실행을 지시합니다.
오케스트레이션은 오케스트레이터를 중앙 관리자로 지정하여 동작하기 때문에 중앙 로직 설계가 복잡하고, 중앙 관리자 자체가 단일 장애점이 될 수 있습니다.
이번 프로젝트에서는 코레오그래피 방식으로 주문 - 결제 - 재고처리 시나리오를 가정하여 SAGA 패턴을 기반으로한 분산 트랜잭션을 구현하고 정리해보았습니다.
1. 프로젝트 세팅
이번 프로젝트의 주요 내용은 Kafka, 멀티모듈 세팅들이 아니기에 간략히 정리하였습니다.
프로젝트 세팅과 관련된 자세한 코드는 Github을 통해 확인하실 수 있습니다.
1.1 멀티 모듈 구성
주문, 결제, 재고 처리 모듈은 멀티 모듈로 구성하여 하나의 프로젝트에서 도커 컴포즈를 이용하여 동작되도록 구성하였습니다.
멀티 모듈 구조는 다음과 같습니다.
├── inventory-service // 재고 관련 모듈
├── order-service // 주문 관련 모듈
├── payment-service // 결제 관련 모듈
├── common-service // 공통 모듈 (Kafka, 로깅 등)
├── gradle.build
1.2 인프라 구성
1.3 Docker Compose 세팅
SAGA 패턴 기반의 분산 트랜잭션을 구현하기 위해 각 모듈은 개별 DB를 바라보도록 구성하였습니다.
카프카를 비롯한 서비스 모듈, 서비스 모듈에 연동될 DB와 같은 인프라 구성은 도커 컴포즈를 통해 구성되도록 세팅하였습니다.
도커 컴포즈 전체 코드를 확인하시려면 아래 더보기를 눌러주세요.
version: '3.8'
services:
# Kafka
zookeeper:
image: confluentinc/cp-zookeeper:latest
environment:
ZOOKEEPER_CLIENT_PORT: 2181
networks:
- backend
kafka:
image: confluentinc/cp-kafka:latest
ports:
- 9092:9092
environment:
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,PLAINTEXT_HOST://0.0.0.0:29092
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,PLAINTEXT_HOST://localhost:29092
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
KAFKA_DEFAULT_REPLICATION_FACTOR: 1
KAFKA_MIN_INSYNC_REPLICAS: 1
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
depends_on:
- zookeeper
networks:
- backend
# 주문 서비스
order-service:
build:
context: ./order-service
ports:
- "8081:8080"
depends_on:
- order-mysql-db
networks:
- backend
platform: linux/amd64
order-mysql-db:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: order!2#
MYSQL_DATABASE: order
ports:
- "3306:3306"
networks:
- backend
volumes:
- ./init/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
- ./init/order.sql:/docker-entrypoint-initdb.d/order.sql
# 결제 서비스
payment-service:
build:
context: ./payment-service
ports:
- "8082:8080"
depends_on:
- payment-mysql-db
networks:
- backend
platform: linux/amd64
payment-mysql-db:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: payment!2#
MYSQL_DATABASE: payment
ports:
- "3307:3306"
networks:
- backend
volumes:
- ./init/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
- ./init/payment.sql:/docker-entrypoint-initdb.d/payment.sql
# 재고 서비스
inventory-service:
build:
context: ./inventory-service
ports:
- "8083:8080"
environment:
- SPRING_PROFILES_ACTIVE=docker
depends_on:
- inventory-mysql-db
networks:
- backend
platform: linux/amd64
inventory-mysql-db:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: inventory!2#
MYSQL_DATABASE: inventory
ports:
- "3308:3306"
networks:
- backend
volumes:
- ./init/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
- ./init/inventory.sql:/docker-entrypoint-initdb.d/inventory.sql
#Docker 네트워크 설정
networks:
backend:
name: backend
driver: bridge
1.2 Kafka 세팅
SAGA 패턴은 독립된 로컬 트랜잭션의 작업의 후처리와 롤백이 이벤트 기반으로 동작하는 것이 핵심입니다.
이벤트 기반의 처리를 위해 메시지 큐 도입이 필요하다 판단하였고, 범용적으로 사용되며 높은 트래픽 상황에 유연하며, 큐에 적재된 메시지가 삭제되지 않도록 하기위해 Kafka 사용이 적합하다 판단하였습니다.
Kafka는 Docker 컨테이너 기반 단일 노드로 동작하도록 세팅하였으며, Common 모듈에 포함하여 개발하였습니다.
모듈별 application.yml 설정
spring:
# kafka 설정
kafka:
bootstrap-servers: kafka:9092
consumer:
group-id: order-group
auto-offset-reset: earliest
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.springframework.kafka.support.serializer.JsonSerializer
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
2. 서비스 모듈 개발과 이벤트 처리
프로젝트에서 사용할 모듈은 각각 주문(Order Service), 결제(Payment Service), 재고(Inventory Service)로 구성했습니다.
SAGA 패턴의 코레오그래피를 기반으로한 모듈별 호출구조는 다음과 같습니다.
프로젝트에서는 모듈별 선형구조로 호출과 보상트랜잭션 처리가 이루어지고 있으나 실제 서비스에서는 이벤트 호출과 보상 트랜잭션이 복잡한 그래프 형태로 구성되게 됩니다.
각 모듈의 간단한 API와 보상 트랜잭션 처리는 다음과 같습니다.
주문(Order Service)
주문 모듈은 사용자의 주문 상품 ID 리스트를 포함한 요청 수신
서버에서는 상품 ID를 통해 상품 정보를 조회한 후, 주문 정보를 보류(PENDING)상태로 DB에 저장
이후 재고 정보 확인을 위해 재고 모듈을 대상으로 주문정보를 담은 재고 확인 요청 이벤트 발행
재고(Inventory Service)
재고 모듈은 주문 요청 토픽에 발행된 이벤트를 확인하여, 주문 요청 상품의 재고가 충분한지 확인
재고 충분 -> 재고에 대한 임시 차감을 해두고 결제 모듈에 결제 요청 이벤트 발행
재고 불충분 ->주문 모듈로 보상트랜잭션 요청 이벤트 발행
결제(Payment Service)
결제 모듈은 결제 요청 토픽에 발행된 이벤트를 확인하여, 결제 처리
결제 성공 -> 주문 모듈과 재고 모듈에 결제 성공 이벤트를 발행하여 주문 상태 변경(APPROVED)와 재고 임시 차감을 확정
결제 실패 -> 주문 모듈과 재고 모듈에 결제 실패 이벤트를 발행하여 주문 요청을 취소하고, 재고 임시 차감을 롤백
3. 추가 개선 포인트
1. 이벤트 순서 보장
모듈간 이벤트 순서를 보장하기 위해 파티션 키를 이용하여 이벤트 순서를 보장
2. 이벤트 중복 처리
동일한 이벤트가 중복 처리되지 않도록 IdemPotency Key 적용 처리
3. 보상 트랜잭션 충돌
결제 실패시 재고 롤백 과정에서 이미 재고가 다른 주문에 배정된 경우를 고려한 롤백 로직 추가
4. 타임아웃 처리
결제나 재고 확인 단계에서 일정 시간 내에 응답이 없으면 자동으로 주문을 취소하는 로직 추가