CQRS 패턴으로 데이터베이스 성능 문제를 해결해보자

“왜 우리 시스템은 사용자가 몇 명만 늘어나도 이렇게 느려지는 걸까?”

이런 고민을 해본 적이 있다면, 아마도 테이블 락(Table Lock) 문제를 겪고 있을 가능성이 높습니다. 오늘은 CQRS라는 패턴이 어떻게 이런 문제를 해결할 수 있는지 차근차근 알아보겠습니다.

CQRS가 뭔가요?

CQRS(Command Query Responsibility Segregation)는 간단히 말해 “데이터를 읽는 것”과 “데이터를 변경하는 것”을 분리하는 패턴입니다.

  • Command (명령): 데이터를 생성, 수정, 삭제하는 작업
  • Query (조회): 데이터를 읽어오는 작업

기존에는 하나의 코드에서 읽기와 쓰기를 모두 처리했다면, CQRS에서는 이 두 가지를 완전히 분리합니다.


테이블 락이 뭐고, 왜 문제가 될까요?

테이블 락이란?

데이터베이스에서 동시에 여러 작업이 같은 데이터에 접근할 때, 데이터 일관성을 보장하기 위해 “잠시 기다려!”라고 말하는 메커니즘입니다.

실제 상황으로 이해해보기

온라인 쇼핑몰을 운영한다고 가정해봅시다:

오후 2시: 관리자가 전체 상품 재고 현황 보고서를 조회합니다 (30초 소요)
오후 2시 5초: 고객 A가 상품을 주문합니다
오후 2시 7초: 고객 B가 같은 상품을 주문합니다
오후 2시 10초: 고객 C가 또 다른 상품을 주문합니다

문제 상황: 재고 현황 보고서 조회가 30초 동안 실행되는 동안, 모든 주문 처리가 대기 상태가 됩니다. 고객들은 “주문하기” 버튼을 눌러도 아무 반응이 없어 답답해합니다.

왜 이런 일이 발생하나요?

기존 방식에서는 다음과 같은 작업들이 모두 같은 테이블에 락을 걸기 때문입니다:

-- 관리자의 복잡한 보고서 쿼리 (30초 소요)
SELECT
    p.product_name,
    p.price,
    i.current_stock,
    s.supplier_name,
    c.category_name,
    AVG(r.rating) as avg_rating
FROM products p
JOIN inventory i ON p.id = i.product_id
JOIN suppliers s ON p.supplier_id = s.id
JOIN categories c ON p.category_id = c.id
LEFT JOIN reviews r ON p.id = r.product_id
GROUP BY p.id, p.product_name, p.price, i.current_stock, s.supplier_name, c.category_name;

-- 고객의 주문 처리 (1초면 충분한데 위 쿼리 때문에 30초 대기)
UPDATE inventory SET current_stock = current_stock - 1 WHERE product_id = 123;
INSERT INTO orders (customer_id, product_id, quantity) VALUES (1, 123, 1);

CQRS가 이 문제를 어떻게 해결하나요?

해결의 핵심 아이디어

CQRS는 읽기와 쓰기를 완전히 분리해서, 읽기 작업이 쓰기 작업을 방해하지 않도록 만듭니다.

1. 읽기 전용 모델 (Query Model)

// 기존 방식 - 읽기와 쓰기가 섞여있음
@Service
public class ProductService {
    public ProductDto getProduct(Long id) {
        // 복잡한 조회 로직
    }

    public void updateStock(Long id, int quantity) {
        // 재고 업데이트 로직
    }
}
// CQRS 적용 - 읽기만 담당
@Service
public class ProductQueryService {
    public ProductDto getProduct(Long id) {
        // 조회에만 최적화된 로직
        // 데이터를 변경하지 않음을 명시
    }

    public List<ProductReportDto> getInventoryReport() {
        // 복잡한 보고서도 읽기 전용으로 처리
    }
}

2. 쓰기 전용 모델 (Command Model)

// CQRS 적용 - 쓰기만 담당
@Service
public class ProductCommandService {
    public void updateStock(UpdateStockCommand command) {
        // 재고 변경에만 집중
        // 최소한의 데이터만 업데이트
    }

    public void createOrder(CreateOrderCommand command) {
        // 주문 생성에만 집중
    }
}

CQRS의 구체적인 이점들

이점 1: 읽기 작업이 쓰기를 방해하지 않음

기존 방식:

[복잡한 보고서 조회] ──── 테이블 락 ──── [간단한 주문 처리 대기 😢]
     30초 소요                               30초 대기

CQRS 적용 후:

[복잡한 보고서 조회] ──── 읽기 전용 ──── [락 없음]
     30초 소요

[간단한 주문 처리] ──── 쓰기 전용 ──── [즉시 처리 😊]
     1초 소요

이점 2: 각각의 목적에 최적화된 코드

읽기 최적화:

-- 보고서용 전용 뷰 또는 테이블
SELECT * FROM product_report_view
WHERE stock_level = 'LOW';
-- 미리 계산된 데이터로 빠른 조회

쓰기 최적화:

-- 필요한 컬럼만 업데이트
UPDATE inventory
SET current_stock = current_stock - ?
WHERE product_id = ?;
-- 최소한의 작업으로 빠른 처리

이점 3: 트랜잭션 최적화로 동시 처리 능력 대폭 향상

시나리오: 100명의 사용자가 동시에 접속

기존 방식의 트랜잭션 처리:

@Transactional
public void handleRequest(Request request) {
    // 😱 모든 요청이 하나의 긴 트랜잭션으로 처리
    if (request.isRead()) {
        processComplexQuery(); // 30초
    } else {
        processSimpleUpdate(); // 0.1초
    }
    // 결과: 1명이 보고서 보면 99명이 30초 대기
}

CQRS 적용 후 트랜잭션 처리:

// 읽기 전용 - 락 없는 동시 처리
@Transactional(readOnly = true)
public void handleQuery() {
    processComplexQuery(); // 30초이지만 락 없음
    // ✅ 100명이 동시에 보고서 조회 가능
}

// 쓰기 전용 - 짧은 락으로 빠른 처리
@Transactional
public void handleCommand() {
    processSimpleUpdate(); // 0.1초 락
    // ✅ 서로 다른 데이터면 100명이 동시에 업데이트 가능
}

결과 비교:

  • 기존: 순차 처리 (100 × 30초 = 50분)
  • CQRS: 동시 처리 (최대 30초)

이점 4: 코드 복잡도 감소

// 기존 방식 - 하나의 메서드에서 모든 걸 처리
public class ProductService {
    public ProductResponse handleProduct(ProductRequest request) {
        if (request.isReadOperation()) {
            // 복잡한 조회 로직
            // + 권한 체크
            // + 캐싱 로직
            // + 데이터 변환 로직
        } else {
            // 복잡한 쓰기 로직
            // + 유효성 검증
            // + 비즈니스 규칙 적용
            // + 트랜잭션 처리
        }
        // 이 메서드는 너무 많은 책임을 가짐
    }
}
// CQRS 적용 - 각자의 책임에만 집중
public class ProductQueryService {
    public ProductDto getProduct(Long id) {
        // 조회와 데이터 변환에만 집중
        // 코드가 단순하고 이해하기 쉬움
    }
}

public class ProductCommandService {
    public void updateProduct(UpdateProductCommand command) {
        // 데이터 변경에만 집중
        // 비즈니스 로직이 명확함
    }
}

실제 적용해보기

단계 1: 현재 코드 분석하기

// 현재 이런 코드가 있다면?
@Service
public class OrderService {
    public OrderDto getOrder(Long id) { ... }           // 읽기
    public List<OrderDto> getOrderList() { ... }        // 읽기
    public void createOrder(OrderDto dto) { ... }       // 쓰기
    public void updateOrder(OrderDto dto) { ... }       // 쓰기
    public void cancelOrder(Long id) { ... }            // 쓰기
}

단계 2: 읽기와 쓰기 분리하기

// 읽기 전용 서비스
@Service
public class OrderQueryService {
    public OrderDto getOrder(Long id) { ... }
    public List<OrderDto> getOrderList() { ... }
    // 읽기만 하고 데이터를 변경하지 않음
}

// 쓰기 전용 서비스
@Service
public class OrderCommandService {
    public void createOrder(CreateOrderCommand command) { ... }
    public void updateOrder(UpdateOrderCommand command) { ... }
    public void cancelOrder(CancelOrderCommand command) { ... }
    // 쓰기만 하고 복잡한 조회는 하지 않음
}

단계 3: 컨트롤러에서 적절히 사용하기

@RestController
public class OrderController {
    private final OrderQueryService queryService;
    private final OrderCommandService commandService;

    @GetMapping("/orders/{id}")
    public OrderDto getOrder(@PathVariable Long id) {
        return queryService.getOrder(id);  // 읽기 전용
    }

    @PostMapping("/orders")
    public void createOrder(@RequestBody CreateOrderCommand command) {
        commandService.createOrder(command);  // 쓰기 전용
    }
}

주의사항과 팁

언제 CQRS를 써야 할까?

  • 복잡한 조회 쿼리가 많은 시스템
  • 동시 사용자가 많은 시스템
  • 읽기와 쓰기 패턴이 다른 시스템

언제 CQRS가 오버엔지니어링일까?

  • 사용자가 적고 단순한 시스템
  • 읽기와 쓰기가 1:1 매칭되는 단순한 CRUD
  • 성능 문제가 없는 시스템

마무리

CQRS는 복잡해 보이지만, 핵심은 단순합니다. “읽는 것”과 “변경하는 것”을 분리하여 서로 방해하지 않게 만드는 것입니다.

테이블 락 때문에 성능 문제를 겪고 있다면, CQRS 패턴을 적용해보세요. 사용자들이 더 이상 “왜 이렇게 느려?”라고 묻지 않을 것입니다.

시작은 작은 기능 하나부터! 가장 성능 문제가 심한 부분부터 CQRS를 적용해보시면 됩니다.




    Enjoy Reading This Article?

    Here are some more articles you might like to read next:

  • 무료 프록시, 크롤러 실패의 지름길, 유료 프록시가 필수인 7가지 기술적 이유
  • 파이썬 웹 크롤링 완벽 가이드 - 현업 데이터 엔지니어의 실전 노하우
  • AI 글쓰기 품질을 높이는 프롬프트 엔지니어링 8단계 (실전 템플릿 포함)
  • AI 시대, 경쟁력 있는 사람이 되는 법, 효과적인 프롬프트 작성 가이드
  • AI를 믿을 수 있을까? 인간이 할루시네이션을 구분할줄 알아야 한다.