개발/항해99

mariadb 쿼리 성능 개선 및 MSA 환경에서의 트랜잭션

IamBD 2024. 11. 15. 01:25

현재 항해에서 진행중인 E-커머스 프로젝트에서 대용량 트래픽 & 데이터 처리를 위해 아래와 같은 작업을 진행하려 합니다.

 

  1. 자주 사용되거나 복잡한 쿼리에 인덱스 적용
  2. 트랜잭션 범위 대한 이해와 서비스 확장 및 분리시의 트랜잭션의 한계 파악
  3. 기존 로직에 영향도를 주지 않고 이벤트기반 메시지 발행

 

 

쿼리 성능 테스트

 

현재 제 프로젝트는 아주 간단한 기능만이 구현되어 있어 복잡한 쿼리는 없고 자주 사용되더라도 그 데이터의 양이 적어 의미있는 성능개선 지표를 얻기가 힘든 상태이기에 테스트를 위한 더미 데이터를 삽입하여 진행하겠습니다.

 

대용량까지는 아니지만 유의미한 성능 측정은 가능한 1건의 더미 상품 정보를 생성하였습니다.

DELIMITER //

CREATE PROCEDURE insert_products()
BEGIN
    DECLARE i INT DEFAULT 1;
    WHILE i <= 1000 DO
            INSERT INTO PRODUCTS (PRICE, NAME, STOCK)
            VALUES (10, CONCAT('Product ', i), 5);
            SET i = i + 1;
        END WHILE;
END //

DELIMITER ;

call insert_products();

 

 

 

이제 인덱스가 적용되지 않은 상태에서 특정 가격과 재고를 조건문으로 하는 쿼리를 실행해보겠습니다.

EXPLAIN SELECT id, name, price, stock 
FROM products 
WHERE price >= 10 AND stock > 10
ORDER BY stock DESC;

 

 

실행계획을 보면 type에 ALL로 나옴으로서 별도 인덱스 지정이 되지않아 full-scan을 하고 있으며 key 또한 null로 인덱스를 타지 않는 것을 확인할 수 있으며 인덱스를 사용하지 않기 때문에 filesort 작업에 시간이 0.16초정도 걸리는 것을 확인할 수 있습니다.

 

 

애초에 복잡한 쿼리는 아니었으니 이제 where절과 order by에 사용되는 price와 stock에 index를 설정해보겠습니다.

create index idx_product_price
    on products (price);

create index idx_product_stock
    on products (stock);

 

실행 계획 및 성능입니다.

 

아까와는 다르게 type도 full-scan에서 range로 바뀐 모습을 볼 수 있고 key 또한 적절히 사용하고 있습니다. 또한 인덱스가 없어 filesort에만 0.16초 정도가 소모되었으나 전체 작업 자체가 0.0004초로 단순 계산상 400배 이상 빨라졌습니다.

 

 

무척 단순한 성능 테스트였으나 수치상 빨라진 것을 확인하였으니 이를 JPA에 적용해보겠습니다.

@Entity
@Table(name = "products", indexes = {
    @Index(name = "idx_product_price", columnList = "price"),
    @Index(name = "idx_product_stock", columnList = "stock")
})
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private Long price;
    private Long stock;
}

 

 

 

트랜잭션 범위에 대한 이해와 MSA

 

현재 제 코드에서의 도메인은 cart, order, product, user로 나누어져 있습니다. 만약 서비스를 나눈 후 카트에 물건을 담는 코드인 CartFacde 내 addToCart 메서드를 보겠습니다.

@Transactional
public void addToCart(CartCommand.AddToCartRequest command) {
    var product = productService.getProduct(command.getProductId()); --------- 1
    productService.checkStock(product.getId(), command.getQuantity());

    cartService.addToCart(command); ------------------------------------------ 2
}

 

1번에서는 productService를 호출하고 있고 그 데이터를 가지고 2번에 cartService로 넘기고 있습니다. 현재의 모놀리틱 환경에서는 데이터베이스를 같이 사용하고 있기 때문에 하나의 트랜잭션으로 엮어 사용할 수 있지만 MSA로 분리된다면 구성에 따라 다르겠지만 각자 독립적인 데이터베이스를 사용하게 되며 기존처럼 트랜잭션을 공유할 수 없습니다.

 

트랜잭션을 공유할 수 없다면 A~D의 작업중 C에서의 실패가 발생했을 때 A,B의 롤백을 처리하기 어렵게 되는것입니다.

 

이러한 문제를 해결하기 위한 방법으로는 대표적으로 SAGA 패턴이나 분산락을 활용할 수 있습니다. 분산락의 적용은 이전에 작성했던 Redisson을 활용한 방법에서 간단히 살펴보았으니 SAGA 패턴만 알아보겠습니다.

 

먼저 위 코드에서 Facade단에 트랜잭션이 있는 이유는 복합적으로 여러 도메인의 서비스를 호출하고 이를 조합하여 일관성있는 로직을 처리하기 위함입니다. 하지만 MSA로의 서비스 분리시 트랜잭션을 공유할 수 없다는 문제점을 확인했으니 Facade가 아닌 각 서비스로 트랜잭션을 내려주면 분리가 되더라도 트랜잭션은 독립적으로 동작할 수 있게됩니다.

 

하지만 아직 유기적으로 동작하지 않으니 이를 엮어주어야 하는데 오케스트레이션이 없는 Choreography 방식과 있는 Orchestration 방식이 있습니다.

 

만약 createOrder라는 Facade 코드가 있다고 가정해보겠습니다.

public class OrderFacde {
	
	OrderService orderService;
	CustomerService customerSerivce;
	PayService payService;
    
	@Transactional
	createOrder() {
		orderService.createOrder(); // 1
		customerService.valid();    // 2
		payService.pay();           // 3
		orderService.complete();    // 4
	}
    
}

1. 주문을 대기상태로 생성합니다.

2. 충분한 신용을 갖고 있는지 확인합니다.

3. 신용으로 결제합니다.

4. 주문을 완료상태로 수정합니다.

 

createOrder는 @Transactional로 엮여있기 때문에 하나의 작업으로 묶여 여러 서비스들 중 실패가 발생하면 알아서 rollback 하게 됩니다. 하지만 저희는 MSA로 서비스를 분리할 예정이기 때문에 더이상 하나의 트랜잭션으로 묶어 사용할 수 없습니다.

 

Choreography 

중앙 제어자 없이 분리되어 있는 트랜잭션들을 이벤트 기반으로 엮는 방법입니다.

 

출처 : https://microservices.io/

 

1. OrderService는 POST /orders 요청에 의해 주문 생성

2. 주문 생성 이벤트를 발생

3. CustomerService의 이벤트 처리자가 크레딧 예약을 시도

4. 크레딧 예약 이벤트를 발생

5. OrderService의 이벤트 처리자가 Order 여부 결정

 

조금 자세히 설명하자면 1번에서 OrderService는 Order events chanel을 통해 이벤트를 비동기로 전달하며 해당 채널은 CustomerService에서 구독하고 있습니다. 새 이벤트를 수신한 CustomerService는 신용 한도(잔고)를 확인하여 Reserved나 Limit Exceeded 이벤트를 발생시키며 이 역시 Customer events channel을 통해 비동기로 OrderService에게로 다시 전달됩니다. CustomerService로부터 이벤트를 전달받은 OrderService는 주문을 확정 혹은 취소 상태로 변경합니다.

 

 

Orchestration

choreography와는 달리 트랜잭션을 중앙에서 모두 컨트롤하는 방식입니다.

 

출처 : https://microservices.io/

 

1. OrderService로부터 주문 생성 요청을 수신하고 CreateOrder 오케스트레이터를 생성합니다.

2. CreateOrder 오케스트레이터는 PENDING 상태의 주문을 생성합니다.

3. Message Broker를 통해 CustomerService로 Reserve Credit 명령을 보냅니다.

4. Reserve Credit 명령을 받은 CustomerService는 reserve를 시도합니다.

5. reserve 시도 결과를 Message Broker를 통해 다시 Order에게 응답합니다.

6. Create Order 오케스트레이터는 응답에 따라 주문을 승인 혹은 거절합니다.

 

 

 

위 두 방법은 모두 각자 다른 데이터베이스를 사용하며 철저히 독립적이어도 중간 매개체를 통해 마치 하나의 트랜잭션인 거서럼 로직을 수행할 수 있습니다. 다만 간단히 예시를 든 위 Facade처럼 자동 롤백 기능을 사용할 수 없음으로 각자 이벤트 결과에 따라 스스로 이전 데이터로 돌아가는 보상 트랜잭션을 설계해야 합니다.

 

 

 

참고자료

 

https://microservices.io/patterns/data/saga.html