| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- CAS
- Lock
- 스프링
- iterator
- Atomic Type
- 데이터 타입
- 동시성
- 백엔드
- db
- foreach
- MVCC
- MySQL
- Synchronized
- jpa
- iterable
- 가비지 컬렉션
- reflection
- 가비지 컬렉터
- 자바
- Varchar
- Locking Read
- java
- text
- Di
- 동시성 문제
- gc
- Today
- Total
과정을 즐기자
트랜잭션 아웃 박스 패턴 알아보기 본문
이번 글에서는 트랜잭션 아웃 박스 패턴에 대해 알아보겠습니다.
트랜잭션 아웃박스 패턴은 로컬 트랜잭션 내에서 비즈니스 데이터와 이벤트를 함께 저장하여 분산 시스템에서의
데이터 일관성을 보장합니다.
라고만.. 설명하면 붕 뜨는 느낌이 있으니 필요한 배경부터 예제 코드까지 한 번 살펴보도록 하겠습니다.
📘 마이크로서비스 아키텍쳐(MSA) 에서 발생할 수 있는 문제
트랜잭션 아웃박스 패턴이 필요한 배경은 MSA 환경에서의 데이터 일관성과 메시지 송신의 원자성 보장 문제에서 출발합니다.
문제 상황을 생각해보겠습니다.
주문 관련 로직, 결제 관련 로직 각각이 복잡하여 별도의 서버로 분리하였습니다.
이때 주문을 생성하는 서버와 관련된 해당 주문에 대한 결제를 진행하는 서버가 다릅니다.
이때 크게 아래 2가지 문제가 발생할 수 있습니다.
1. 주문 DB에는 저장 성공, 메시지 브로커 전송 실패 (이벤트 미전송 → 결제 서버가 상태를 모름)
2. 메시지 브로커에는 전송 성공, 주문 DB 저장 실패 (잘못된 이벤트 발생 → 결제 서버가 존재하지 않는 주문을 참조)
위와 같은 문제를 막기위해 서로 다른 서버의 로직을 같은 하나의 트랜잭션으로 묶는다면 성능 문제가 발생할 수 있으며
굳이 서버를 분리한 이유가 사라지게 됩니다.

📕 트랜잭션 아웃 박스 패턴 적용해보기
위에서 발생할 수 있는 문제를 해결하기 위한 트랜잭션 아웃 박스 패턴 예제 코드를 작성해보겠습니다.
(예제는 Golang으로 작성하였습니다)
🌟 주문 & 이벤트 생성
1. 클라이언트 요청
클라이언트가 주문 생성 요청을 보냅니다.
서버는 Controller 에서 요청을 받고 처리합니다.
func (c *OrderController) CreateOrder(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req dto.CreateOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.ProductName == "" || req.Quantity < 1 || req.Price < 0 {
http.Error(w, "Invalid request: product_name, quantity (min 1), and price (min 0) are required", http.StatusBadRequest)
return
}
response, err := c.orderService.CreateOrder(&req)
if err != nil {
http.Error(w, "Failed to create order", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(response)
}
2. 트랜잭션 시작
OrderService에서 주문 생성에 대한 데이터베이스 트랜잭션을 시작합니다.
func (s *OrderService) CreateOrder(req *dto.CreateOrderRequest) (*dto.CreateOrderResponse, error) {
var response *dto.CreateOrderResponse
err := s.db.Transaction(func(tx *gorm.DB) error {
order := &models.Order{
ProductName: req.ProductName,
Quantity: req.Quantity,
Price: req.Price,
Status: OrderStatusPending,
}
if err := s.orderRepo.Create(tx, order); err != nil {
return fmt.Errorf("failed to create order: %w", err)
}
if err := s.createOrderCreatedEvent(tx, order); err != nil {
return fmt.Errorf("failed to create outbox event: %w", err)
}
response = &dto.CreateOrderResponse{
ID: order.ID,
ProductName: order.ProductName,
Quantity: order.Quantity,
Price: order.Price,
Status: order.Status,
}
return nil
})
if err != nil {
return nil, err
}
return response, nil
}
3. 주문 데이터 저장
2번 로직 중에서 Order를 생성하는 로직이 있습니다.
orderRepo는 gorm을 통해서 주문 테이블에 새로운 주문 정보를 INSERT합니다.
order := &models.Order{
ProductName: req.ProductName,
Quantity: req.Quantity,
Price: req.Price,
Status: OrderStatusPending,
}
if err := s.orderRepo.Create(tx, order); err != nil {
return fmt.Errorf("failed to create order: %w", err)
}
4. 아웃박스 이벤트 저장
3번과 동일한 트랜잭션 내에서 outbox_events 테이블에 이벤트를 INSERT합니다.
이벤트는 pending 상태로 저장되어 나중에 처리될 준비를 합니다.
if err := s.createOrderCreatedEvent(tx, order); err != nil {
return fmt.Errorf("failed to create outbox event: %w", err)
}
5. 트랜잭션 커밋
모든 작업이 성공하면 트랜잭션을 커밋합니다.
주문과 이벤트가 동시에 저장되어 데이터 일관성이 보장됩니다.
🌟 백그라운드 이벤트 발행
5초마다 한번씩 polling을 통해 OutboxProcessor가 동작합니다.
처리가 필요한 이벤트를 DB 에서 조회한 후 각 이벤트마다 아래 순서로 진행합니다.
- Kafka 메시지 생성 (Key, Value, Headers)
- 토픽으로 전송 (order-events)
- 응답 대기 (offset 확인)
- DB 상태 업데이트 (pending → processed)
실패 시 이벤트는 pending 상태 유지하고 다음 폴링에서 재시도할 수 있도록 합니다.
6. 아웃박스 폴링
백그라운드 프로세서 시작
func (p *OutboxProcessor) Start() {
ticker := time.NewTicker(p.interval)
defer ticker.Stop()
log.Println("Outbox processor started")
for {
select {
case <-ticker.C:
p.processOutboxEvents()
case <-p.stopCh:
log.Println("Outbox processor stopped")
return
}
}
}
대기 중인 이벤트 조회
func (p *OutboxProcessor) processOutboxEvents() {
events, err := p.outboxEventRepo.FindPendingEvents(p.batchSize)
if err != nil {
log.Printf("Error fetching outbox events: %v", err)
return
}
...
}
7. Kafka 메세지 전송
func (p *OutboxProcessor) processEvent(event *models.OutboxEvent) error {
log.Printf("Processing event: ID=%d, Type=%s, AggregateID=%s",
event.ID, event.EventType, event.AggregateID)
// Kafka로 이벤트 전송
topic := p.kafkaTopic
// 이벤트 타입에 따라 다른 토픽 사용 가능
if strings.HasPrefix(event.EventType, "Order") {
topic = "order-events"
}
deliveryChan := make(chan kafka.Event, 1)
err := p.kafkaProducer.Produce(&kafka.Message{
TopicPartition: kafka.TopicPartition{
Topic: &topic,
Partition: kafka.PartitionAny,
},
Key: []byte(event.AggregateID),
Value: []byte(event.Payload),
Headers: []kafka.Header{
{Key: "event_id", Value: []byte(fmt.Sprintf("%d", event.ID))},
{Key: "event_type", Value: []byte(event.EventType)},
{Key: "timestamp", Value: []byte(event.CreatedAt.Format(time.RFC3339))},
},
}, deliveryChan)
if err != nil {
return fmt.Errorf("failed to produce message to Kafka: %w", err)
}
// 전송 결과 대기
e := <-deliveryChan
m := e.(*kafka.Message)
if m.TopicPartition.Error != nil {
return fmt.Errorf("delivery failed: %w", m.TopicPartition.Error)
}
log.Printf("Event delivered to topic %s [%d] at offset %v",
*m.TopicPartition.Topic, m.TopicPartition.Partition, m.TopicPartition.Offset)
// 성공적으로 전송되면 processed로 표시
return p.outboxEventRepo.MarkAsProcessed(event)
}
📚 정리
이번 글에서는 MSA 환경에서 발생할 수 있는 데이터 일관성 문제를 해결하는 트랜잭션 아웃박스 패턴에 대해 알아보았습니다.
핵심은 로컬 트랜잭션 내에서 비즈니스 데이터와 이벤트를 함께 저장하여 해당 이벤트를 이용하여 다른 서버와의 데이터 일관성을
유지하는 것입니다.
이외에도 데이터 일관성 유지를 위해 SAGA 패턴 (분산 트랜잭션 보상 처리), CDC (Change Data Capture, DB 로그 기반 변경 감지)
등등이 존재합니다. 해당 내용에 대해서는 다음 글에서 정리해보도록 하겠습니다.
'Database' 카테고리의 다른 글
| MVCC 만으로 팬텀 리드를 막을 수 있을까? (0) | 2024.10.27 |
|---|---|
| 필터링 검색 기능은 어떻게 구현되는 것일까? (feat. inverted index) (1) | 2024.10.02 |
| RDB는 정말 유연한 설계에 대응하는 것이 어려울까? (0) | 2024.03.24 |
| Lock을 이용한 트랜잭션 격리와 MVCC가 나온 이유 (0) | 2023.10.02 |
| RDB에서 동시성 문제는 왜 발생하며 어떻게 해결해야 하나? (0) | 2023.09.30 |