본문으로 건너뛰기

이벤트 기반 아키텍처

Multi-Region Shopping Mall은 MSK Kafka를 중심으로 한 이벤트 기반 아키텍처(EDA)를 구현합니다. 이를 통해 서비스 간 느슨한 결합, 비동기 처리, 그리고 SAGA 패턴 기반의 분산 트랜잭션을 지원합니다.

MSK Kafka 토픽 구성

토픽 개요

35개의 Kafka 토픽이 도메인별로 구성되어 있습니다.

도메인별 토픽 상세

Order Domain (4 Topics)

토픽파티션ProducerConsumer설명
order-created6Order ServicePayment, Inventory, Notification주문 생성 이벤트
order-updated6Order ServiceAnalytics, Notification주문 상태 변경
order-cancelled3Order ServicePayment, Inventory, Notification주문 취소
order-completed3Order ServiceAnalytics, Recommendation주문 완료
// order-created 이벤트 스키마
{
"eventId": "evt-uuid-123",
"eventType": "ORDER_CREATED",
"timestamp": "2024-03-10T14:30:00Z",
"version": "1.0",
"source": "order-service",
"correlationId": "corr-uuid-456",
"payload": {
"orderId": "ORD-12345",
"userId": "USER-001",
"items": [
{
"productId": "PROD-001",
"sku": "S24U-256-BLK",
"quantity": 1,
"unitPrice": 1550000
}
],
"totalAmount": 1550000,
"currency": "KRW",
"shippingAddressId": "ADDR-001",
"paymentMethod": "CREDIT_CARD"
}
}

Payment Domain (4 Topics)

토픽파티션ProducerConsumer설명
payment-requested6Payment ServicePayment Processor결제 요청
payment-completed6Payment ServiceOrder, Inventory, Notification결제 완료
payment-failed3Payment ServiceOrder, Notification결제 실패
refund-processed3Payment ServiceOrder, Notification, Analytics환불 처리 완료

Inventory Domain (4 Topics)

토픽파티션ProducerConsumer설명
inventory-reserved6Inventory ServiceOrder재고 예약 완료
inventory-released3Inventory ServiceAnalytics재고 예약 해제
inventory-updated6Inventory ServiceProduct Catalog, Search재고 수량 변경
inventory-low-stock3Inventory ServiceNotification, Seller재고 부족 알림

Shipping Domain (4 Topics)

토픽파티션ProducerConsumer설명
shipment-created6Shipping ServiceOrder, Notification배송 생성
shipment-status-updated6Shipping ServiceOrder, Notification배송 상태 변경
shipment-delivered3Shipping ServiceOrder, Notification, Analytics배송 완료
shipment-failed3Shipping ServiceOrder, Notification, Returns배송 실패

Notification Domain (4 Topics)

토픽파티션ProducerConsumer설명
notification-requested6Various ServicesNotification Service알림 요청
notification-sent3Notification ServiceAnalytics알림 발송 완료
notification-failed3Notification ServiceAnalytics, Retry Handler알림 발송 실패
notification-scheduled3Notification ServiceScheduler예약 알림

User Domain (3 Topics)

토픽파티션ProducerConsumer설명
user-registered3User AccountNotification, Analytics회원 가입
user-profile-updated3User ProfileRecommendation프로필 변경
user-preferences-changed3User ProfileNotification, Recommendation설정 변경

Product Domain (4 Topics)

토픽파티션ProducerConsumer설명
product-created6Product CatalogSearch, Notification상품 등록
product-updated6Product CatalogSearch, Cache Invalidator상품 수정
product-price-changed6Pricing ServiceSearch, Notification, Wishlist가격 변경
product-discontinued3Product CatalogSearch, Wishlist, Cart판매 중단

Review Domain (2 Topics)

토픽파티션ProducerConsumer설명
review-created6Review ServiceProduct Catalog, Search, Notification리뷰 등록
review-moderated3Review ServiceNotification리뷰 검토 완료

Analytics Domain (3 Topics)

토픽파티션ProducerConsumer설명
analytics-page-view12API GatewayAnalytics페이지 조회
analytics-user-action12Various ServicesAnalytics사용자 행동
analytics-conversion6Order ServiceAnalytics전환 이벤트

Infrastructure Domain (2 Topics)

토픽파티션ProducerConsumer설명
system-health3All ServicesMonitoring헬스체크
dead-letter-queue6All ConsumersDLQ Handler처리 실패 이벤트

SAGA 패턴 - 주문 플로우

SAGA Orchestration

주문 생성은 여러 서비스의 협업이 필요한 분산 트랜잭션입니다. SAGA 패턴을 통해 이를 관리합니다.

보상 트랜잭션 (Compensating Transaction)

단계정상 액션보상 액션트리거 이벤트
1주문 생성주문 취소order-cancelled
2재고 예약재고 해제inventory-released
3결제 처리환불 처리refund-processed
4배송 생성배송 취소shipment-cancelled

CQRS 패턴

Command와 Query 분리

Write Model vs Read Model

측면Write ModelRead Model
목적비즈니스 규칙 적용빠른 조회
데이터정규화비정규화 (Denormalized)
저장소Aurora PostgreSQLElastiCache, OpenSearch
일관성StrongEventual
스키마트랜잭션 중심조회 패턴 최적화

예시: 상품 상세 조회

# Command Side - 상품 업데이트
@app.post("/products/{product_id}")
async def update_product(product_id: str, request: ProductUpdateRequest):
# 1. DocumentDB에 저장 (Source of Truth)
await docdb.products.update_one(
{"productId": product_id},
{"$set": request.dict()}
)

# 2. 이벤트 발행
await kafka.send("product-updated", {
"eventType": "PRODUCT_UPDATED",
"productId": product_id,
"changes": request.dict(),
"timestamp": datetime.utcnow().isoformat()
})

return {"status": "updated"}

# Event Consumer - Read Model 동기화
async def handle_product_updated(event):
product = await docdb.products.find_one({"productId": event["productId"]})

# 1. OpenSearch 업데이트 (검색용)
await opensearch.index(
index="products",
id=event["productId"],
body=transform_for_search(product)
)

# 2. ElastiCache 무효화 (캐시)
await cache.delete(f"product:{event['productId']}")

# 3. 가격 변경 시 위시리스트 사용자 알림
if "pricing" in event["changes"]:
await notify_wishlist_users(event["productId"], product["pricing"])

# Query Side - 상품 조회
@app.get("/products/{product_id}")
async def get_product(product_id: str):
# 1. 캐시 확인
cached = await cache.get(f"product:{product_id}")
if cached:
return json.loads(cached)

# 2. DocumentDB 조회
product = await docdb.products.find_one({"productId": product_id})

# 3. 캐시 저장
await cache.set(
f"product:{product_id}",
json.dumps(product),
ex=3600 # 1시간
)

return product

DocumentDB Change Stream → OpenSearch 동기화

아키텍처

구현

// Go - Change Stream Consumer
package main

import (
"context"
"encoding/json"
"log"

"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"github.com/opensearch-project/opensearch-go"
)

type ChangeStreamConsumer struct {
docdbClient *mongo.Client
osClient *opensearch.Client
}

func (c *ChangeStreamConsumer) WatchProducts(ctx context.Context) error {
collection := c.docdbClient.Database("mall").Collection("products")

pipeline := mongo.Pipeline{
{{"$match", bson.D{
{"operationType", bson.D{{"$in", bson.A{"insert", "update", "replace", "delete"}}}},
}}},
}

opts := options.ChangeStream().SetFullDocument(options.UpdateLookup)
stream, err := collection.Watch(ctx, pipeline, opts)
if err != nil {
return err
}
defer stream.Close(ctx)

for stream.Next(ctx) {
var change bson.M
if err := stream.Decode(&change); err != nil {
log.Printf("Error decoding change: %v", err)
continue
}

if err := c.processChange(ctx, change); err != nil {
log.Printf("Error processing change: %v", err)
// DLQ로 전송
c.sendToDLQ(change)
}
}

return stream.Err()
}

func (c *ChangeStreamConsumer) processChange(ctx context.Context, change bson.M) error {
operationType := change["operationType"].(string)

switch operationType {
case "insert", "update", "replace":
fullDoc := change["fullDocument"].(bson.M)
return c.indexProduct(ctx, fullDoc)
case "delete":
docKey := change["documentKey"].(bson.M)
productId := docKey["productId"].(string)
return c.deleteProduct(ctx, productId)
}

return nil
}

func (c *ChangeStreamConsumer) indexProduct(ctx context.Context, doc bson.M) error {
// OpenSearch 문서로 변환
searchDoc := map[string]interface{}{
"productId": doc["productId"],
"name": doc["name"],
"brand": doc["brand"],
"category": doc["category"],
"description": doc["description"].(bson.M)["short"],
"tags": doc["tags"],
"price": doc["pricing"].(bson.M)["listPrice"],
"salePrice": doc["pricing"].(bson.M)["salePrice"],
"rating": doc["ratings"].(bson.M)["average"],
"reviewCount": doc["ratings"].(bson.M)["count"],
"sellerId": doc["seller"].(bson.M)["sellerId"],
"status": doc["status"],
"updatedAt": doc["updatedAt"],
}

body, _ := json.Marshal(searchDoc)

_, err := c.osClient.Index(
"products",
bytes.NewReader(body),
c.osClient.Index.WithDocumentID(doc["productId"].(string)),
c.osClient.Index.WithRefresh("true"),
)

return err
}

Cross-Region MSK Replicator

구성

복제 토픽 설정

토픽복제 방향이유
order-*Primary → Secondary주문 이벤트는 Primary에서 생성
payment-*Primary → Secondary결제는 Primary에서만 처리
inventory-*Bidirectional재고 정보는 양쪽에서 필요
product-*Bidirectional상품 정보 동기화
notification-*Primary → Secondary알림은 Primary에서 조율
analytics-*Both → Primary분석 데이터는 Primary로 집계

Terraform 설정

resource "aws_msk_replicator" "cross_region" {
replicator_name = "cross-region-replicator"
description = "Replicate events between us-east-1 and us-west-2"

service_execution_role_arn = aws_iam_role.msk_replicator.arn

kafka_cluster {
amazon_msk_cluster {
msk_cluster_arn = aws_msk_cluster.use1.arn
}
vpc_config {
security_groups_to_add = [aws_security_group.msk_use1.id]
subnet_ids = aws_subnet.use1_private[*].id
}
}

kafka_cluster {
amazon_msk_cluster {
msk_cluster_arn = aws_msk_cluster.usw2.arn
}
vpc_config {
security_groups_to_add = [aws_security_group.msk_usw2.id]
subnet_ids = aws_subnet.usw2_private[*].id
}
}

replication_info_list {
source_kafka_cluster_arn = aws_msk_cluster.use1.arn
target_kafka_cluster_arn = aws_msk_cluster.usw2.arn

topic_replication {
topics_to_replicate = ["order-*", "payment-*", "product-*", "inventory-*"]
copy_topic_configurations = true
copy_access_control_lists_for_topics = true
detect_and_copy_new_topics = true
}

consumer_group_replication {
consumer_groups_to_replicate = [".*"]
synchronise_consumer_group_offsets = true
}

target_compression_type = "GZIP"
}
}

Dead Letter Queue (DLQ) 전략

DLQ 아키텍처

DLQ 메시지 스키마

{
"dlqId": "dlq-uuid-123",
"originalTopic": "order-created",
"originalKey": "ORD-12345",
"originalEvent": {
"eventId": "evt-uuid-123",
"eventType": "ORDER_CREATED",
"payload": { }
},
"error": {
"type": "ProcessingException",
"message": "Inventory service unavailable",
"stackTrace": "...",
"consumerGroup": "inventory-consumer"
},
"retryCount": 3,
"firstFailedAt": "2024-03-10T14:30:00Z",
"lastFailedAt": "2024-03-10T14:35:00Z",
"status": "PENDING" // PENDING, RETRYING, RESOLVED, DISCARDED
}

Consumer Group 설계

Consumer Group서비스구독 토픽인스턴스
order-payment-consumerPaymentorder-created3
order-inventory-consumerInventoryorder-created, order-cancelled3
order-notification-consumerNotificationorder-*2
payment-order-consumerOrderpayment-*3
shipment-order-consumerOrdershipment-*2
product-search-consumerSearchproduct-*3
analytics-consumerAnalyticsanalytics-*6

이벤트 처리 보장

At-Least-Once 처리

// Java Spring Kafka Consumer
@KafkaListener(
topics = "order-created",
groupId = "payment-order-consumer",
containerFactory = "kafkaListenerContainerFactory"
)
public void handleOrderCreated(
@Payload OrderCreatedEvent event,
@Header(KafkaHeaders.RECEIVED_KEY) String key,
Acknowledgment ack
) {
try {
// 멱등성 체크 (이미 처리된 이벤트인지 확인)
if (processedEventRepository.exists(event.getEventId())) {
log.info("Event already processed: {}", event.getEventId());
ack.acknowledge();
return;
}

// 비즈니스 로직 처리
paymentService.initiatePayment(event);

// 처리 완료 기록
processedEventRepository.save(new ProcessedEvent(
event.getEventId(),
Instant.now()
));

// 수동 커밋
ack.acknowledge();

} catch (RetryableException e) {
// 재시도 가능한 오류 - 커밋하지 않음
throw e;
} catch (Exception e) {
// 재시도 불가능한 오류 - DLQ로 전송
dlqProducer.send(event, e);
ack.acknowledge();
}
}

멱등성 (Idempotency) 보장

# Python - 멱등성 처리
class IdempotentEventHandler:
def __init__(self, redis_client, handler_func):
self.redis = redis_client
self.handler = handler_func

async def handle(self, event: dict):
event_id = event["eventId"]
lock_key = f"event_lock:{event_id}"
processed_key = f"event_processed:{event_id}"

# 1. 이미 처리된 이벤트인지 확인
if await self.redis.exists(processed_key):
logger.info(f"Event {event_id} already processed, skipping")
return

# 2. 분산 락 획득
lock_acquired = await self.redis.set(
lock_key, "1",
ex=30, # 30초 타임아웃
nx=True # 이미 존재하면 실패
)

if not lock_acquired:
logger.info(f"Event {event_id} is being processed by another instance")
return

try:
# 3. 이벤트 처리
await self.handler(event)

# 4. 처리 완료 기록 (7일 유지)
await self.redis.set(processed_key, "1", ex=604800)

finally:
# 5. 락 해제
await self.redis.delete(lock_key)

다음 단계