Skip to content
ioob.dev
Go back

소프트웨어 아키텍처 5편 — CQRS와 이벤트 드리븐: 읽기/쓰기 분리에서 이벤트 기반까지

· 11분 읽기
Software Architecture 시리즈 (5/6)
  1. 소프트웨어 아키텍처 1편 — 아키텍처가 왜 필요한가
  2. 소프트웨어 아키텍처 2편 — 레이어드 아키텍처
  3. 소프트웨어 아키텍처 3편 — 헥사고날 아키텍처
  4. 소프트웨어 아키텍처 4편 — 클린 아키텍처와 어니언: 동심원 구조들의 공통점과 차이
  5. 소프트웨어 아키텍처 5편 — CQRS와 이벤트 드리븐: 읽기/쓰기 분리에서 이벤트 기반까지
  6. 소프트웨어 아키텍처 6편 — 모듈러 모놀리스: 마이크로서비스 전에 해볼 수 있는 것
Table of contents

Table of contents

읽기와 쓰기는 요구사항이 다르다

일반적인 웹 애플리케이션에서 읽기 요청과 쓰기 요청의 비율을 측정해보면, 대부분 읽기가 압도적으로 많다. 상품 목록 조회, 검색 결과, 상세 페이지 열람 같은 읽기 요청이 전체의 80~90%를 차지하고, 주문 생성, 리뷰 작성, 장바구니 수정 같은 쓰기 요청은 10~20% 정도다.

그런데 전통적인 아키텍처에서는 읽기와 쓰기가 같은 모델을 공유한다. 같은 엔티티, 같은 리포지토리, 같은 서비스 계층을 통과한다. 이 구조에서 문제가 드러나는 지점이 있다.

쓰기에 최적화된 데이터 모델은 읽기에 비효율적이다. 정규화된 테이블 구조는 데이터 무결성을 보장하지만, 복잡한 조회 화면을 렌더링하려면 여러 테이블을 JOIN해야 한다. 반대로 읽기에 최적화된 비정규화 구조는 쓰기 시 데이터 일관성을 유지하기 어렵다. 하나의 모델로 두 가지 요구사항을 동시에 만족시키려다 보면 양쪽 모두 타협하게 된다.

CQRS(Command Query Responsibility Segregation)는 이 문제를 정면으로 다룬다. 읽기와 쓰기를 별도의 모델로 분리하는 것이다.

CQS에서 CQRS로

CQRS를 이해하려면 먼저 CQS(Command Query Separation)를 알아야 한다. Bertrand Meyer가 제안한 이 원칙은 단순하다. 모든 메서드는 상태를 변경하는 Command이거나, 상태를 반환하는 Query이거나, 둘 중 하나여야 한다. 하나의 메서드가 상태를 바꾸면서 동시에 값을 반환하면 안 된다는 것이다.

// CQS를 따르는 설계
class ShoppingCart {
    private val items = mutableListOf<CartItem>()

    // Command — 상태를 변경하지만 아무것도 반환하지 않는다
    fun addItem(item: CartItem) {
        items.add(item)
    }

    // Query — 상태를 반환하지만 아무것도 변경하지 않는다
    fun totalPrice(): Money {
        return items.fold(Money.ZERO) { acc, item ->
            acc + item.price * item.quantity
        }
    }

    // CQS 위반 — 상태를 변경하면서 값을 반환한다
    // fun removeAndReturnLast(): CartItem { ... }
}

CQS는 메서드 수준의 원칙이다. Greg Young은 이 아이디어를 아키텍처 수준으로 확장했다. 메서드를 Command와 Query로 나누는 것이 아니라, 모델 자체를 Command 모델과 Query 모델로 분리하자는 것이 CQRS다.

CQRS의 구조

flowchart LR
    Client["클라이언트"]

    subgraph COMMAND["Command 경로"]
        direction TB
        CC["Command Controller"]
        CH["Command Handler"]
        WM["Write Model"]
        WDB[("Write DB")]
        CC --> CH
        CH --> WM
        WM --> WDB
    end

    subgraph QUERY["Query 경로"]
        direction TB
        QC["Query Controller"]
        QH["Query Handler"]
        RM["Read Model"]
        RDB[("Read DB")]
        QC --> QH
        QH --> RM
        RM --> RDB
    end

    Client -->|"POST, PUT, DELETE"| CC
    Client -->|"GET"| QC
    WDB -.->|"동기화"| RDB

    style COMMAND fill:#ff6b6b,color:#fff
    style QUERY fill:#4ecdc4,color:#fff

Command 경로와 Query 경로가 완전히 분리되어 있다. 쓰기 요청은 Command Handler를 통해 Write Model로 가고, 읽기 요청은 Query Handler를 통해 Read Model로 간다.

Command 측

Command는 시스템의 상태를 변경하는 의도를 담은 객체다. 이름은 동사형으로 짓고, 과거형이 아니라 명령형이다.

// Command — "이것을 해라"
data class PlaceOrderCommand(
    val customerId: String,
    val items: List<OrderItemDto>,
    val shippingAddress: Address
)

data class CancelOrderCommand(
    val orderId: String,
    val reason: String
)

Command Handler는 하나의 Command를 받아서 처리한다. 유효성 검증, 비즈니스 규칙 적용, 상태 저장까지가 역할이다.

@Service
class PlaceOrderCommandHandler(
    private val orderRepository: OrderRepository,
    private val inventoryService: InventoryService
) {
    @Transactional
    fun handle(command: PlaceOrderCommand) {
        // 1. 재고 확인
        command.items.forEach { item ->
            inventoryService.reserve(item.productId, item.quantity)
        }

        // 2. 주문 생성
        val order = Order.create(
            customerId = CustomerId(command.customerId),
            items = command.items.map { it.toDomain() },
            shippingAddress = command.shippingAddress
        )

        // 3. 저장
        orderRepository.save(order)
    }
}

Command Handler는 값을 반환하지 않는 것이 순수한 CQRS의 원칙이다. 물론 실무에서는 생성된 리소스의 ID 정도는 반환하는 경우가 흔하다. 원칙을 맹목적으로 지키기보다 실용성과의 균형을 찾는 편이 낫다.

Query 측

Query 모델은 화면에 필요한 형태로 미리 구성된 읽기 전용 모델이다. 정규화를 신경 쓸 필요가 없다. JOIN이 필요 없도록 비정규화해두면 조회 성능이 극적으로 올라간다.

// Query 모델 — 화면에 필요한 데이터를 그대로 담는다
data class OrderSummaryView(
    val orderId: String,
    val customerName: String,
    val itemCount: Int,
    val totalAmount: Long,
    val status: String,
    val orderedAt: LocalDateTime
)

// Query Handler — 읽기 전용 저장소에서 직접 조회
@Service
class OrderQueryHandler(
    private val orderReadRepository: OrderReadRepository
) {
    fun getOrderSummaries(customerId: String): List<OrderSummaryView> {
        return orderReadRepository.findSummariesByCustomerId(customerId)
    }

    fun getOrderDetail(orderId: String): OrderDetailView {
        return orderReadRepository.findDetailById(orderId)
            ?: throw OrderNotFoundException(orderId)
    }
}

Read Model은 도메인 엔티티가 아니다. 화면이 요구하는 형태의 DTO(Data Transfer Object)에 가깝다. 도메인 규칙이 들어갈 자리가 아니며, 오직 조회 성능에만 집중하면 된다.

단순 CQRS vs 이벤트 소싱 CQRS

CQRS를 이야기할 때 혼동되는 지점이 있다. CQRS와 이벤트 소싱(Event Sourcing)은 별개의 개념인데, 함께 언급되는 경우가 너무 많아서 마치 한 세트인 것처럼 인식되는 것이다.

단순 CQRS는 같은 데이터베이스를 공유하면서 코드 수준에서만 Command와 Query를 분리한다. 가장 도입 비용이 낮고, 대부분의 프로젝트에서는 이것만으로 충분하다.

// 단순 CQRS — 같은 DB, 다른 모델
@Service
class OrderCommandService(
    private val orderRepository: OrderRepository  // JPA 리포지토리
) {
    @Transactional
    fun placeOrder(command: PlaceOrderCommand) {
        val order = Order.create(...)
        orderRepository.save(order)
    }
}

@Service
class OrderQueryService(
    private val jdbcTemplate: JdbcTemplate  // 직접 SQL로 조회
) {
    fun getOrderList(customerId: String): List<OrderSummaryView> {
        return jdbcTemplate.query(
            """
            SELECT o.id, c.name, COUNT(oi.id), SUM(oi.price * oi.quantity), o.status, o.created_at
            FROM orders o
            JOIN customers c ON o.customer_id = c.id
            JOIN order_items oi ON o.id = oi.order_id
            WHERE o.customer_id = ?
            GROUP BY o.id, c.name, o.status, o.created_at
            """,
            OrderSummaryViewMapper(),
            customerId
        )
    }
}

Write 측은 JPA로 도메인 모델을 다루고, Read 측은 JdbcTemplate으로 최적화된 쿼리를 직접 날린다. DB는 하나지만 접근하는 코드 경로가 분리된 것이다.

이벤트 소싱 CQRS는 여기서 한 걸음 더 나간다. Write 측이 현재 상태를 저장하는 대신, 상태 변경 이벤트의 시퀀스를 저장한다. Read 측은 이 이벤트들을 구독해서 자체적으로 읽기 최적화된 뷰를 구성한다. 이벤트 소싱은 강력하지만 복잡성도 함께 가져온다. 도입 전에 신중한 판단이 필요하며, 단순 CQRS로 해결되는 문제에 이벤트 소싱까지 도입하면 과도한 엔지니어링이 된다.

이벤트 드리븐 아키텍처

CQRS와 자주 함께 등장하는 또 다른 아키텍처가 이벤트 드리븐 아키텍처(Event-Driven Architecture, EDA)다. 이벤트 드리븐은 CQRS와 독립적인 개념이다. CQRS 없이도 이벤트 드리븐을 쓸 수 있고, 이벤트 드리븐 없이도 CQRS를 쓸 수 있다. 다만 두 개를 조합하면 시너지가 생기기 때문에 함께 논의되는 것이다.

이벤트 드리븐 아키텍처의 핵심은 간단하다. 컴포넌트 간 통신을 직접 호출이 아니라 이벤트를 통해 수행한다. 주문 서비스가 결제 서비스를 직접 호출하는 대신, 주문이 생성됐다는 이벤트를 발행하면 결제 서비스가 그 이벤트를 수신해서 처리하는 구조다.

sequenceDiagram
    participant Client as 클라이언트
    participant Order as 주문 서비스
    participant Broker as 이벤트 브로커
    participant Payment as 결제 서비스
    participant Inventory as 재고 서비스
    participant Notification as 알림 서비스

    Client->>Order: 주문 요청
    Order->>Order: 주문 생성
    Order->>Broker: OrderCreated 이벤트 발행

    par 병렬 처리
        Broker->>Payment: OrderCreated 수신
        Payment->>Payment: 결제 처리
        Payment->>Broker: PaymentCompleted 발행
    and
        Broker->>Inventory: OrderCreated 수신
        Inventory->>Inventory: 재고 차감
    and
        Broker->>Notification: OrderCreated 수신
        Notification->>Notification: 주문 확인 메일 발송
    end

    Broker->>Order: PaymentCompleted 수신
    Order->>Order: 주문 상태 → 결제완료

이 다이어그램에서 주목할 점은 주문 서비스가 결제, 재고, 알림 서비스의 존재를 모른다는 것이다. 주문 서비스는 그저 주문이 생성되었다는 사실을 이벤트로 알릴 뿐이다. 누가 그 이벤트를 소비하는지는 관심 밖의 일이다.

이벤트 브로커

이벤트 생산자와 소비자 사이를 중계하는 것이 이벤트 브로커다. 대표적인 것이 Apache Kafka다. Kafka는 이벤트를 토픽(topic)에 저장하고, 소비자 그룹이 각자의 속도로 이벤트를 읽어갈 수 있게 해준다.

Spring에서 이벤트를 발행하고 소비하는 코드는 이렇게 생겼다.

이벤트 클래스를 먼저 정의한다.

data class OrderCreatedEvent(
    val orderId: String,
    val customerId: String,
    val items: List<OrderItemDto>,
    val totalAmount: Long,
    val occurredAt: Instant = Instant.now()
)

이벤트를 발행하는 쪽이다.

@Service
class OrderService(
    private val orderRepository: OrderRepository,
    private val eventPublisher: ApplicationEventPublisher
) {
    @Transactional
    fun placeOrder(command: PlaceOrderCommand) {
        val order = Order.create(
            customerId = CustomerId(command.customerId),
            items = command.items.map { it.toDomain() }
        )
        orderRepository.save(order)

        eventPublisher.publishEvent(
            OrderCreatedEvent(
                orderId = order.id.value,
                customerId = command.customerId,
                items = command.items,
                totalAmount = order.totalAmount().amount
            )
        )
    }
}

이벤트를 소비하는 쪽이다.

@Component
class PaymentEventHandler {
    @EventListener
    fun onOrderCreated(event: OrderCreatedEvent) {
        // 결제 처리 로직
        processPayment(event.orderId, event.totalAmount)
    }
}

@Component
class InventoryEventHandler {
    @EventListener
    fun onOrderCreated(event: OrderCreatedEvent) {
        // 재고 차감 로직
        event.items.forEach { item ->
            decreaseStock(item.productId, item.quantity)
        }
    }
}

위 예시는 Spring의 ApplicationEventPublisher를 사용한 프로세스 내부 이벤트다. 서비스가 다른 프로세스로 분리되면 Kafka나 RabbitMQ 같은 외부 브로커가 필요해진다.

느슨한 결합의 이점

이벤트 드리븐의 가장 큰 장점은 느슨한 결합(loose coupling)이다.

새 기능 추가가 쉽다. 주문 생성 시 포인트를 적립하는 기능을 추가한다고 하자. 동기 방식이면 OrderServicePointService 의존성을 추가하고 코드를 수정해야 한다. 이벤트 방식이면 PointEventHandler만 새로 만들어서 OrderCreatedEvent를 구독하게 하면 된다. 기존 코드는 한 줄도 바뀌지 않는다.

장애 격리가 가능하다. 알림 서비스가 죽어도 주문 처리에는 영향이 없다. 이벤트는 브로커에 쌓여 있다가 알림 서비스가 복구되면 다시 처리된다.

독립적인 확장이 가능하다. 읽기 트래픽이 많으면 Query 서비스만 스케일 아웃하고, 쓰기 부하가 높으면 Command 서비스만 확장하면 된다.

디버깅의 어려움

장점만 있는 것은 아니다. 이벤트 기반 시스템의 가장 큰 단점은 흐름 추적이 어렵다는 것이다.

동기 방식에서는 OrderService → PaymentService → InventoryService 호출 스택을 따라가면 전체 흐름이 보인다. 디버거에서 한 줄씩 따라갈 수도 있고, 로그에 메서드 호출 순서가 남는다.

이벤트 방식에서는 이벤트가 발행된 뒤 어떤 핸들러가 실행되는지를 코드에서 바로 알 수 없다. 이벤트를 발행하는 코드와 소비하는 코드가 물리적으로 떨어져 있기 때문이다. 장애가 발생하면 어디서 이벤트가 발행됐고, 누가 소비했고, 어디서 실패했는가를 추적해야 하는데, 이를 위해 분산 추적 시스템(Distributed Tracing)이 필요하다.

이벤트의 순서 보장도 쉽지 않다. 네트워크 지연, 리밸런싱, 재처리 등으로 인해 이벤트가 발행된 순서와 다른 순서로 소비될 수 있다. 멱등성(idempotency)을 보장하는 설계가 필수적이다.

CQRS + Event Sourcing

이벤트 소싱(Event Sourcing)은 현재 상태 대신 상태 변경 이벤트의 전체 이력을 저장하는 패턴이다. 은행 계좌의 잔액을 저장하는 대신, 입금/출금 이벤트의 목록을 저장하고 잔액이 필요하면 이벤트를 처음부터 재생해서 계산하는 방식이다.

CQRS와 이벤트 소싱을 조합하면 강력한 구조가 만들어진다. Command 측은 이벤트를 저장하고, 그 이벤트가 Read 측으로 전파되어 읽기 최적화된 뷰를 갱신한다.

// 이벤트 소싱 기반 Aggregate
class OrderAggregate {
    lateinit var id: OrderId
    var status: OrderStatus = OrderStatus.DRAFT
    private val items = mutableListOf<OrderItem>()
    private val pendingEvents = mutableListOf<DomainEvent>()

    fun place(customerId: CustomerId, items: List<OrderItem>) {
        // 상태를 직접 바꾸지 않고, 이벤트를 생성한다
        applyEvent(OrderPlacedEvent(
            orderId = OrderId.generate(),
            customerId = customerId,
            items = items
        ))
    }

    fun cancel(reason: String) {
        require(status == OrderStatus.PLACED) {
            "PLACED 상태에서만 취소할 수 있다"
        }
        applyEvent(OrderCancelledEvent(id, reason))
    }

    private fun applyEvent(event: DomainEvent) {
        handleEvent(event)  // 상태 반영
        pendingEvents.add(event)  // 이벤트 축적
    }

    private fun handleEvent(event: DomainEvent) {
        when (event) {
            is OrderPlacedEvent -> {
                id = event.orderId
                status = OrderStatus.PLACED
                items.addAll(event.items)
            }
            is OrderCancelledEvent -> {
                status = OrderStatus.CANCELLED
            }
        }
    }

    fun pendingEvents(): List<DomainEvent> = pendingEvents.toList()
}

이벤트 소싱은 완전한 감사 추적(audit trail)을 제공하고, 특정 시점의 상태를 복원할 수 있다는 강력한 이점이 있지만, 복잡성의 대가가 크다. 이벤트 스키마가 변경되면 마이그레이션이 까다롭고, 이벤트가 수백만 개 쌓이면 스냅샷(snapshot) 전략이 필요하며, 최종 일관성(eventual consistency)을 다루는 것이 직관적이지 않다.

언제 CQRS를 도입하는가

CQRS가 적합한 상황과 그렇지 않은 상황을 구분하는 것이 중요하다.

도입이 적합한 경우:

도입하지 말아야 할 경우:

Greg Young 본인도 말한 바 있다. CQRS는 시스템 전체에 적용하는 것이 아니라, 필요한 바운디드 컨텍스트에만 선택적으로 적용해야 한다고. 주문 처리처럼 복잡한 도메인에는 CQRS를 쓰고, 사용자 프로필처럼 단순한 도메인에는 전통적인 CRUD를 유지하는 것이 현실적인 접근이다.

정리

CQRS는 읽기와 쓰기의 요구사항이 다르다는 현실에서 출발한다. 이벤트 드리븐 아키텍처는 컴포넌트 간의 결합을 느슨하게 만들어서 변경과 확장을 쉽게 한다. 두 가지를 조합하면 강력하지만 복잡한 시스템이 만들어지고, 그 복잡성을 감당할 수 있는지가 도입 여부의 핵심 판단 기준이 된다.

다음 편에서는 시리즈의 마지막 주제인 모듈러 모놀리스를 다룬다. 마이크로서비스로 넘어가기 전에 시도할 수 있는 중간 지점이다.

6편: 모듈러 모놀리스


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
소프트웨어 아키텍처 4편 — 클린 아키텍처와 어니언: 동심원 구조들의 공통점과 차이
Next Post
소프트웨어 아키텍처 6편 — 모듈러 모놀리스: 마이크로서비스 전에 해볼 수 있는 것