스프링부트 실무 가이드 3편: 이벤트 드리븐 아키텍처

스프링부트 실무 가이드 3편: 이벤트 드리븐 아키텍처


시리즈 네비게이션

이전현재다음
2편: 캐싱 전략3편: 이벤트 드리븐4편: Resilience 패턴

서론

마이크로서비스 환경에서 서비스 간 통신은 시스템의 성능과 안정성을 좌우한다. 동기 방식의 직접 호출은 강한 결합과 장애 전파 문제를 야기하며, 이를 해결하기 위해 이벤트 드리븐 아키텍처 가 널리 사용된다.

3편에서 다루는 내용:

  • 동기 방식의 한계와 이벤트 드리븐의 장점
  • 메시지 큐의 역할과 Kafka 기본 개념
  • Outbox 패턴으로 이벤트 발행 신뢰성 확보
  • Consumer 멱등성 구현 전략

목차


1. 이벤트 드리븐 아키텍처 (EDA)

1.1 전통적인 동기 방식의 한계

직접 호출 방식 (Request-Response)

주문 서비스가 모든 후속 작업을 직접 호출:

[OrderService]

     ├──▶ [InventoryService].decreaseStock()    (50ms)
     ├──▶ [PaymentService].processPayment()     (200ms)
     ├──▶ [EmailService].sendConfirmation()     (500ms)
     ├──▶ [NotificationService].sendPush()      (300ms)
     └──▶ [AnalyticsService].recordOrder()      (100ms)

총 응답 시간: 1,150ms

문제점:

문제설명
강한 결합OrderService가 5개 서비스에 의존
긴 응답 시간모든 작업이 끝나야 응답
장애 전파EmailService 장애 → 주문 실패
확장 어려움새 기능 추가 시 OrderService 수정 필요

1.2 이벤트 드리븐 방식

“무슨 일이 일어났다”를 알리고, 관심 있는 서비스가 반응

[OrderService]

     └──▶ "주문이 생성되었다" (이벤트 발행)

              ├──▶ [InventoryService] "재고 차감할게"
              ├──▶ [PaymentService] "결제 처리할게"
              ├──▶ [EmailService] "이메일 보낼게"
              ├──▶ [NotificationService] "푸시 보낼게"
              └──▶ [AnalyticsService] "기록할게"

OrderService 응답 시간: 50ms (이벤트 발행만)
나머지는 비동기로 각자 처리

1.3 핵심 개념

┌─────────────────────────────────────────────────────────────────┐
│                    이벤트 드리븐 아키텍처                         │
│                                                                  │
│   ┌───────────┐         ┌───────────┐         ┌───────────┐    │
│   │ Producer  │ ──────▶ │  Message  │ ──────▶ │ Consumer  │    │
│   │ (발행자)   │  Event  │   Queue   │  Event  │ (소비자)   │    │
│   └───────────┘         │  (브로커)  │         └───────────┘    │
│                         └───────────┘                           │
│        │                      │                      │          │
│   "일이 일어났다"         메시지 저장/전달        "내가 처리할게"   │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘
용어설명프로젝트 예시
Event시스템에서 발생한 사실”주문이 생성됨”
Producer이벤트를 발행하는 서비스OrderService
Consumer이벤트를 소비하는 서비스EmailService
Message Queue이벤트를 저장하고 전달Kafka

2. 메시지 큐

2.1 메시지 큐란?

비유: 우체국 시스템

[동기 방식] - 직접 전달
발신자 ──────────────────────▶ 수신자
       발신자가 수신자 집까지 가서
       직접 전달 (수신자 없으면 대기)

[메시지 큐] - 우체국 경유
발신자 ──▶ [우체국] ──▶ 수신자

           ├── 메시지 보관
           ├── 수신자 부재 시 보관
           └── 수신자가 원할 때 수령

2.2 메시지 큐의 장점

장점설명예시
비동기 처리응답 기다리지 않음주문 후 즉시 응답, 이메일은 나중에
디커플링서비스 간 직접 의존 제거OrderService는 EmailService 몰라도 됨
버퍼링트래픽 급증 시 완충초당 1000건 → 큐에 쌓고 천천히 처리
신뢰성메시지 유실 방지Consumer 죽어도 메시지 보존
확장성Consumer 수평 확장처리 느리면 Consumer 추가

2.3 메시지 큐 종류

종류특징사용 사례
Kafka고성능, 영구 저장, 재처리 가능대용량 이벤트 스트리밍
RabbitMQ유연한 라우팅, 다양한 프로토콜복잡한 라우팅 필요 시
AWS SQS관리형, 간편한 설정AWS 환경
Redis Pub/Sub초경량, 메모리 기반실시간 알림 (비영구적)

2.4 Point-to-Point vs Pub/Sub

Point-to-Point (1:1)

Producer ──▶ [Queue] ──▶ Consumer

              └── 하나의 Consumer만 메시지 수신

Publish/Subscribe (1:N) - Kafka 방식

Producer ──▶ [Topic] ──┬──▶ Consumer Group A
                       ├──▶ Consumer Group B
                       └──▶ Consumer Group C

              └── 모든 그룹이 같은 메시지 수신

3. 이벤트 설계

3.1 좋은 이벤트의 특징

1. 과거형으로 명명 (이미 일어난 사실)

좋음: OrderCreated, PaymentCompleted, ItemShipped
나쁨: CreateOrder, ProcessPayment, ShipItem

2. 불변성 (Immutable)

// 이벤트는 발생한 사실이므로 변경 불가
data class OrderCreatedEvent(
    val orderId: Long,
    val buyerId: Long,
    val totalAmount: BigDecimal,
    val occurredAt: LocalDateTime = LocalDateTime.now()
)

3. 자기 완결성 (Self-contained)

// 나쁨: Consumer가 추가 조회 필요
data class OrderCreatedEvent(
    val orderId: Long  // 이것만으론 정보 부족
)

// 좋음: 필요한 정보 포함
data class OrderCreatedEvent(
    val orderId: Long,
    val orderNumber: String,
    val buyerId: Long,
    val buyerEmail: String,
    val items: List<OrderItemDto>,
    val totalAmount: BigDecimal
)

3.2 프로젝트의 이벤트 예시

// 주문 생성 이벤트
payload = mapOf(
    "orderId" to savedOrder.id,
    "buyerId" to buyerId,
    "sellerIds" to sellerIds.toList(),
    "totalAmount" to savedOrder.totalAmount,
    "orderNumber" to savedOrder.orderNumber
)

// 주문 상태 변경 이벤트
payload = mapOf(
    "orderId" to savedOrder.id,
    "buyerId" to order.buyer.id,
    "sellerId" to sellerId,
    "status" to newStatus.name,
    "orderNumber" to savedOrder.orderNumber
)

4. 이벤트 발행의 신뢰성 문제

4.1 이중 쓰기 문제 (Dual Write Problem)

@Transactional
fun createOrder(request: CreateOrderRequest) {
    // 1. DB에 주문 저장
    val order = orderRepository.save(Order(...))

    // 2. 메시지 큐에 이벤트 발행
    messageQueue.send(OrderCreatedEvent(order.id))  // ← 문제!
}

왜 문제인가?

DB와 메시지 큐는 별개의 시스템 (서로 다른 트랜잭션)

Case 1: DB 커밋 성공, 메시지 발행 실패
┌─────────┐     ┌─────────┐
│   DB    │     │  Queue  │
│ ✅ 저장  │     │ ❌ 실패  │
└─────────┘     └─────────┘
→ 주문은 있는데 이벤트 없음

Case 2: 메시지 발행 후 DB 롤백
┌─────────┐     ┌─────────┐
│   DB    │     │  Queue  │
│ ❌ 롤백  │     │ ✅ 발행  │
└─────────┘     └─────────┘
→ 주문은 없는데 이벤트 있음

4.2 해결책: Outbox 패턴

핵심 아이디어: 이벤트를 메시지 큐 대신 같은 DB에 저장

┌─────────────────────────────────────────────────────────────────┐
│                   하나의 DB 트랜잭션                              │
│                                                                  │
│   ┌─────────────────┐     ┌─────────────────┐                   │
│   │   orders 테이블  │     │ outbox_events   │                   │
│   │                 │     │     테이블       │                   │
│   │  INSERT 주문    │     │  INSERT 이벤트  │                   │
│   └─────────────────┘     └─────────────────┘                   │
│                                                                  │
│            둘 다 성공하거나 둘 다 실패 (원자성)                    │
└─────────────────────────────────────────────────────────────────┘

                            (별도 프로세스)


                         ┌─────────────────────┐
                         │   Message Queue     │
                         │   (Kafka 등)        │
                         └─────────────────────┘

4.3 Outbox 패턴의 보장

보장설명
At-least-once이벤트가 최소 한 번은 발행됨
순서 보장같은 aggregate는 순서대로 발행
장애 복구앱 재시작 후 PENDING 이벤트 재발행

주의: 중복 발행 가능 → Consumer에서 멱등성 필요


5. Outbox 패턴 구현

5.1 Outbox 테이블

CREATE TABLE outbox_events (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    aggregate_type VARCHAR(100) NOT NULL,   -- "Order"
    aggregate_id VARCHAR(100) NOT NULL,     -- "123"
    event_type VARCHAR(100) NOT NULL,       -- "OrderCreated"
    payload TEXT NOT NULL,                  -- JSON 데이터
    status VARCHAR(20) DEFAULT 'PENDING',   -- PENDING/PROCESSED/FAILED
    created_at DATETIME DEFAULT NOW(),
    processed_at DATETIME,
    retry_count INT DEFAULT 0
);

5.2 트랜잭션 내에서 이벤트 저장

@Transactional
fun createOrder(buyerId: Long, req: CreateOrderRequest): OrderResponse {
    // 1. 주문 저장 (같은 트랜잭션)
    val savedOrder = orderJpaRepository.save(order)

    // 2. Outbox에 이벤트 저장 (같은 트랜잭션)
    outboxEventService.saveEvent(
        aggregateType = "Order",
        aggregateId = savedOrder.id.toString(),
        eventType = "OrderCreated",
        payload = mapOf(
            "orderId" to savedOrder.id,
            "buyerId" to buyerId,
            "sellerIds" to sellerIds.toList(),
            "totalAmount" to savedOrder.totalAmount
        )
    )
    // 커밋 시 둘 다 저장되거나 둘 다 롤백

    return OrderResponse.from(savedOrder)
}

5.3 별도 프로세스가 메시지 큐로 발행

// OutboxPublisher.kt
@Component
@Profile("docker", "prod")
class OutboxPublisher(
    private val outboxJpaRepository: OutboxJpaRepository,
    private val kafkaTemplate: KafkaTemplate<String, Any>
) {
    @Scheduled(fixedDelay = 1000)  // 1초마다
    @Transactional
    fun publishPendingEvents() {
        val pendingEvents = outboxJpaRepository.findRetryableEvents()

        pendingEvents.forEach { event ->
            try {
                val topic = determineTopicForEvent(event.eventType)
                kafkaTemplate.send(topic, event.aggregateId, event.payload)
                event.markAsProcessed()
            } catch (e: Exception) {
                event.markAsFailed(e.message ?: "Unknown error")
            }
            outboxJpaRepository.save(event)
        }
    }

    // 7일 지난 처리 완료 이벤트 정리
    @Scheduled(cron = "0 0 * * * *")
    fun cleanupProcessedEvents() {
        val cutoff = LocalDateTime.now().minusDays(7)
        outboxJpaRepository.deleteProcessedEventsBefore(PROCESSED, cutoff)
    }
}

5.4 Outbox 엔티티

// OutboxEvent.kt
@Entity
@Table(name = "outbox_events")
class OutboxEvent(
    val aggregateType: String,    // "Order"
    val aggregateId: String,      // "123"
    val eventType: String,        // "OrderCreated"
    val payload: String,          // JSON

    var status: OutboxStatus = OutboxStatus.PENDING,
    var retryCount: Int = 0
) {
    fun markAsProcessed() {
        this.status = OutboxStatus.PROCESSED
        this.processedAt = LocalDateTime.now()
    }

    fun markAsFailed(error: String) {
        this.retryCount++
        if (this.retryCount >= MAX_RETRY_COUNT) {
            this.status = OutboxStatus.FAILED
        }
    }
}

6. 중복 발행과 멱등성

6.1 중복 발행이 발생하는 케이스

Outbox 패턴은 At-least-once 를 보장하므로 중복 발행이 발생할 수 있습니다.

Case 1: Kafka 발행 성공, DB 업데이트 전 장애

OutboxPublisher 실행 흐름:

1. PENDING 이벤트 조회          ✅
2. Kafka로 발행                 ✅ (메시지 전송됨)
3. status = PROCESSED 업데이트  ❌ (서버 크래시!)
   ─────────────────────────────────
   │  이 시점에 앱이 죽으면?      │
   └─────────────────────────────────

재시작 후:
- DB에는 여전히 status = PENDING
- OutboxPublisher가 같은 이벤트를 다시 발행
- Consumer는 같은 메시지를 2번 받음

Case 2: 네트워크 타임아웃

1. Kafka로 발행 요청            ✅
2. Kafka가 메시지 저장          ✅
3. 응답 반환 중 네트워크 타임아웃  ❌
   ─────────────────────────────────
   │  Producer는 실패로 인식      │
   │  실제로는 Kafka에 저장됨     │
   └─────────────────────────────────

4. 재시도 로직에 의해 다시 발행
5. Kafka에 같은 메시지가 2개

Case 3: 여러 인스턴스에서 동시 처리

┌─────────────────┐     ┌─────────────────┐
│   Instance A    │     │   Instance B    │
│                 │     │                 │
│ 1. 이벤트 조회   │     │ 1. 이벤트 조회   │
│    (id=1, PENDING)    │    (id=1, PENDING)
│                 │     │                 │
│ 2. Kafka 발행   │     │ 2. Kafka 발행   │
│                 │     │                 │
│ 3. PROCESSED    │     │ 3. PROCESSED    │
└─────────────────┘     └─────────────────┘

→ 같은 이벤트가 2번 발행됨

6.2 중복 처리 전략

전략 1: Consumer에서 멱등성 보장 (권장)

@Component
class OrderEventConsumer(
    private val processedEventRepository: ProcessedEventRepository,
    private val emailService: EmailService
) {
    @KafkaListener(topics = ["marketplace.order.created"])
    fun handleOrderCreated(payload: Map<String, Any>, ack: Acknowledgment) {
        val eventId = payload["eventId"] as String

        // 1. 이미 처리한 이벤트인지 확인
        if (processedEventRepository.existsById(eventId)) {
            log.info("이미 처리된 이벤트, 스킵: $eventId")
            ack.acknowledge()
            return
        }

        // 2. 비즈니스 로직 처리
        emailService.sendOrderConfirmation(payload)

        // 3. 처리 완료 기록
        processedEventRepository.save(ProcessedEvent(eventId))

        ack.acknowledge()
    }
}

전략 2: DB 유니크 제약으로 중복 방지

// 이메일 발송 기록 테이블
@Entity
@Table(
    uniqueConstraints = [
        UniqueConstraint(columnNames = ["order_id", "email_type"])
    ]
)
class EmailSentRecord(
    val orderId: Long,
    val emailType: String,  // "ORDER_CONFIRMATION"
    val sentAt: LocalDateTime = LocalDateTime.now()
)

// Consumer에서 사용
fun sendOrderConfirmation(orderId: Long) {
    try {
        emailSentRecordRepository.save(
            EmailSentRecord(orderId, "ORDER_CONFIRMATION")
        )
        // 저장 성공 = 첫 번째 처리 → 이메일 발송
        emailService.send(...)
    } catch (e: DataIntegrityViolationException) {
        // 유니크 제약 위반 = 이미 처리됨 → 스킵
        log.info("이미 발송된 이메일, 스킵: orderId=$orderId")
    }
}

전략 3: 분산 락으로 동시 처리 방지

@Component
class OutboxPublisher(
    private val redissonClient: RedissonClient
) {
    @Scheduled(fixedDelay = 1000)
    fun publishPendingEvents() {
        val lock = redissonClient.getLock("outbox-publisher-lock")

        // 하나의 인스턴스만 실행
        if (lock.tryLock(0, 5, TimeUnit.SECONDS)) {
            try {
                doPublish()
            } finally {
                lock.unlock()
            }
        }
    }
}

전략 4: SELECT FOR UPDATE로 동시 조회 방지

interface OutboxJpaRepository : JpaRepository<OutboxEvent, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT e FROM OutboxEvent e WHERE e.status = :status ORDER BY e.createdAt")
    fun findPendingEventsWithLock(status: OutboxStatus): List<OutboxEvent>
}

6.3 멱등성 설계 원칙

원칙설명예시
고유 식별자모든 이벤트에 unique ID 포함eventId: UUID
처리 기록처리한 이벤트 ID 저장processed_events 테이블
비즈니스 키자연 키로 중복 체크orderId + emailType
결과 동일성N번 실행해도 결과 같음UPDATE ... SET status = 'SENT'
멱등한 연산 예시:

✅ 멱등: UPDATE status = 'SENT' WHERE id = 123
         (여러 번 실행해도 결과 동일)

❌ 비멱등: INSERT INTO emails (order_id, ...)
          (여러 번 실행하면 중복 데이터)

❌ 비멱등: UPDATE point = point + 100
          (여러 번 실행하면 계속 증가)

✅ 멱등: UPDATE point = 1100 WHERE id = 123 AND point = 1000
         (조건부 업데이트로 멱등성 확보)

7. Kafka 기본 개념

7.1 Kafka 아키텍처

┌─────────────────────────────────────────────────────────────────┐
│                        Kafka Cluster                             │
│                                                                  │
│   ┌─────────────────────────────────────────────────────────┐   │
│   │              Topic: marketplace.order.created            │   │
│   │                                                          │   │
│   │   Partition 0: [msg0] [msg3] [msg6] ───▶ offset         │   │
│   │   Partition 1: [msg1] [msg4] [msg7] ───▶ offset         │   │
│   │   Partition 2: [msg2] [msg5] [msg8] ───▶ offset         │   │
│   │                                                          │   │
│   └─────────────────────────────────────────────────────────┘   │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

핵심 용어

용어설명
Topic메시지 카테고리 (테이블과 유사)
Partition토픽을 나눈 단위 (병렬 처리용)
Offset파티션 내 메시지 위치
Consumer Group함께 메시지를 소비하는 Consumer 집합

7.2 파티션과 병렬 처리

Topic: orders (3 partitions)

[Producer]

    ├── key=order-123 ──▶ Partition 0
    ├── key=order-456 ──▶ Partition 1
    └── key=order-789 ──▶ Partition 2

[Consumer Group: email-service]

    ├── Consumer 1 ◀── Partition 0
    ├── Consumer 2 ◀── Partition 1
    └── Consumer 3 ◀── Partition 2

→ 3개의 Consumer가 병렬로 처리
→ 같은 key(orderId)는 같은 파티션 → 순서 보장

7.3 Consumer Group

같은 메시지를 다른 용도로 처리:

Topic: marketplace.order.created

              ├──▶ Consumer Group: email-service
              │         └── 주문 확인 이메일 발송

              ├──▶ Consumer Group: notification-service
              │         └── 푸시 알림 발송

              └──▶ Consumer Group: analytics-service
                        └── 주문 통계 집계

각 그룹은 모든 메시지를 받음 (독립적 처리)

7.4 메시지 보존 정책 (Retention)

Kafka의 특징: 소비해도 메시지를 삭제하지 않음

전통적인 메시지 큐 (RabbitMQ 등):
┌─────────────────────────────────────────┐
│ Queue: [msg1] [msg2] [msg3]             │
│              ↓                          │
│        Consumer가 msg1 소비             │
│              ↓                          │
│ Queue: [msg2] [msg3]  ← msg1 삭제됨     │
└─────────────────────────────────────────┘

Kafka:
┌─────────────────────────────────────────┐
│ Topic: [msg1] [msg2] [msg3]             │
│              ↓                          │
│        Consumer가 msg1 소비             │
│              ↓                          │
│ Topic: [msg1] [msg2] [msg3]  ← 그대로!  │
│         ↑                               │
│    Consumer의 offset만 이동 (0 → 1)     │
└─────────────────────────────────────────┘

왜 삭제하지 않는가?

이유설명
다중 Consumer Group각 그룹이 독립적으로 같은 메시지 소비
재처리 (Replay)offset을 되돌려 과거 메시지 재처리 가능
장애 복구Consumer 재시작 시 놓친 메시지 처리
감사 로그과거 이벤트 추적 가능

Retention 설정

# Kafka Topic 설정 예시
retention.ms: 604800000     # 7일 (밀리초)
retention.bytes: -1         # 크기 제한 없음 (-1)

# 또는
retention.hours: 168        # 7일 (시간)

Consumer Offset 관리

Partition 0: [msg0] [msg1] [msg2] [msg3] [msg4] [msg5]
              offset: 0     1      2      3      4      5

Consumer Group A: offset = 3  (msg0~2 처리 완료, msg3부터 처리)
Consumer Group B: offset = 1  (msg0 처리 완료, msg1부터 처리)
Consumer Group C: offset = 5  (모두 처리 완료)

→ 각 그룹이 자신의 진행 상황(offset)을 별도로 관리
→ 메시지 자체는 retention 정책에 따라 삭제

재처리 (Replay) 활용

활용 사례:

  • 버그 수정 후 과거 데이터 재처리
  • 새 Consumer Group 추가 시 과거 이벤트 처리
  • 데이터 분석을 위한 이벤트 재조회

8. Kafka 설정

8.1 프로젝트의 Kafka Config

// KafkaConfig.kt
@Configuration
@Profile("docker", "prod")
class KafkaConfig {

    companion object {
        const val ORDER_CREATED_TOPIC = "marketplace.order.created"
        const val ORDER_STATUS_CHANGED_TOPIC = "marketplace.order.status-changed"
    }

    @Bean
    fun producerFactory(): ProducerFactory<String, Any> {
        return DefaultKafkaProducerFactory(mapOf(
            ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers,
            ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java,
            ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to JsonSerializer::class.java,
            ProducerConfig.ACKS_CONFIG to "all",        // 모든 복제본 확인
            ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG to true  // 중복 방지
        ))
    }
}

8.2 Consumer 구현 예시

@Component
class OrderEventConsumer(
    private val emailService: EmailService
) {
    @KafkaListener(
        topics = ["marketplace.order.created"],
        groupId = "email-service"
    )
    fun handleOrderCreated(
        payload: Map<String, Any>,
        ack: Acknowledgment
    ) {
        val orderId = payload["orderId"] as Long
        val buyerId = payload["buyerId"] as Long

        // 멱등성 체크 (중복 처리 방지)
        if (emailService.alreadySent(orderId)) {
            ack.acknowledge()
            return
        }

        emailService.sendOrderConfirmation(orderId, buyerId)
        ack.acknowledge()  // 처리 완료 후 커밋
    }
}

9. 전체 흐름 요약

┌─────────────────────────────────────────────────────────────────┐
│  1. 주문 생성 요청                                               │
│     POST /api/v1/orders                                         │
└─────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│  2. OrderService.createOrder() - 하나의 트랜잭션                 │
│                                                                  │
│     orders 테이블          outbox_events 테이블                  │
│     ┌──────────────┐       ┌──────────────────┐                 │
│     │ INSERT 주문  │       │ INSERT 이벤트    │                 │
│     │ id=123       │       │ type=OrderCreated│                 │
│     │ status=PENDING│       │ status=PENDING   │                 │
│     └──────────────┘       └──────────────────┘                 │
│                                                                  │
│     → 응답 반환 (빠름)                                           │
└─────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│  3. OutboxPublisher (1초마다 실행)                               │
│                                                                  │
│     outbox_events 조회 (status=PENDING)                         │
│              │                                                   │
│              ▼                                                   │
│     Kafka로 발행 ──▶ marketplace.order.created                  │
│              │                                                   │
│              ▼                                                   │
│     status = PROCESSED로 변경                                    │
└─────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│  4. Kafka Consumers (각자 독립적으로 처리)                        │
│                                                                  │
│     ┌─────────────────┐  ┌─────────────────┐                    │
│     │ EmailService    │  │ NotificationSvc │                    │
│     │ 주문 확인 메일   │  │ 푸시 알림       │                    │
│     └─────────────────┘  └─────────────────┘                    │
└─────────────────────────────────────────────────────────────────┘

10. FAQ (면접 대비)

Q1. 이벤트 드리븐 아키텍처의 장단점은?

장점:

  • 서비스 간 느슨한 결합 (디커플링)
  • 비동기 처리로 응답 시간 단축
  • 개별 서비스 독립적 확장 가능
  • 장애 격리 (한 서비스 장애가 전파되지 않음)

단점:

  • 디버깅 어려움 (흐름 추적이 복잡)
  • 최종 일관성 (Eventual Consistency)
  • 메시지 순서, 중복 처리 고려 필요
  • 인프라 복잡도 증가

Q2. Outbox 패턴을 사용하는 이유는?

DB 저장과 메시지 발행은 서로 다른 트랜잭션이라 둘 중 하나만 성공할 수 있습니다 (이중 쓰기 문제). Outbox 패턴은 이벤트를 같은 DB 트랜잭션에 저장하여 원자성을 보장하고, 별도 프로세스가 나중에 메시지 큐로 발행합니다.

Q3. At-least-once vs At-most-once vs Exactly-once?

보장 수준의미구현
At-most-once최대 한 번 (유실 가능)처리 전 커밋
At-least-once최소 한 번 (중복 가능)처리 후 커밋
Exactly-once정확히 한 번트랜잭션 + 멱등성

일반적으로 At-least-once + Consumer 멱등성 이 현실적인 선택입니다.

Q4. Consumer 멱등성은 어떻게 구현하나요?

fun handleEvent(event: OrderCreatedEvent, ack: Acknowledgment) {
    // 1. 이미 처리했는지 확인
    if (processedEventRepository.exists(event.eventId)) {
        ack.acknowledge()
        return
    }

    // 2. 비즈니스 로직 처리
    processOrder(event)

    // 3. 처리 완료 기록
    processedEventRepository.save(event.eventId)

    ack.acknowledge()
}

Q5. 메시지 순서가 중요하면 어떻게 하나요?

같은 키(예: orderId)를 가진 메시지는 같은 파티션으로 가므로 순서가 보장됩니다.

// 같은 주문의 이벤트는 같은 파티션으로
kafkaTemplate.send(topic, orderId.toString(), payload)

Q6. Kafka vs RabbitMQ 선택 기준은?

기준KafkaRabbitMQ
처리량높음 (수백만 msg/s)중간
메시지 보존영구 저장소비 후 삭제
재처리가능 (offset 이동)어려움
라우팅단순복잡한 라우팅 가능
사용 사례이벤트 스트리밍, 로그작업 큐, RPC

11. 프로젝트 파일 구조

marketplace/
├── marketplace-domain/
│   └── src/main/kotlin/.../outbox/
│       └── OutboxEvent.kt              # Outbox 엔티티

├── marketplace-infra/
│   └── src/main/kotlin/.../outbox/
│       └── OutboxJpaRepository.kt      # Repository

├── marketplace-api/
│   └── src/main/kotlin/.../
│       ├── config/
│       │   └── KafkaConfig.kt          # Kafka 설정
│       ├── outbox/
│       │   └── OutboxPublisher.kt      # 발행 스케줄러
│       └── order/
│           └── OrderService.kt         # 이벤트 저장

└── docker-compose.yml                  # Kafka, Zookeeper

정리

핵심 패턴 비교

패턴목적핵심
이벤트 드리븐서비스 간 느슨한 결합Producer → Queue → Consumer
Outbox 패턴이벤트 발행 신뢰성DB와 이벤트를 같은 트랜잭션에 저장
멱등성중복 메시지 처리같은 메시지 N번 처리해도 결과 동일

메시지 보장 수준

보장 수준의미구현
At-most-once최대 한 번 (유실 가능)처리 전 커밋
At-least-once최소 한 번 (중복 가능)처리 후 커밋
Exactly-once정확히 한 번트랜잭션 + 멱등성

Quick Checklist

  • 이벤트를 DB와 같은 트랜잭션에 저장하는가? (Outbox 패턴)
  • Consumer에서 멱등성을 보장하는가?
  • 이벤트 ID로 중복 처리를 체크하는가?
  • 같은 키의 메시지가 순서를 보장받는가? (파티션 키)
  • 처리 실패 시 재시도/DLQ 전략이 있는가?
  • 이벤트가 과거형으로 명명되었는가? (OrderCreated)
  • 이벤트에 필요한 정보가 포함되었는가? (자기 완결성)

다음 편에서는 Resilience 패턴 (Circuit Breaker, Rate Limiter) 에 대해 다룹니다.

👉 다음: 4편 - Resilience 패턴

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.