학습일지/MSA 분산 트랜잭션

[분산트랜잭션 맛보기] - TCC

Merge Log 2025. 10. 18. 23:27

TCC 패턴

  • TCC(Try-Confirm-Cancel) 는 분산 시스템에서 데이터 정합성을 보장하기 위해 사용하는 분산 트랜잭션 처리 방식
  • 전통적인 트랜잭션은 DB 의 Rollback, Commit 에 의존한다
  • TCC는 트랜잭션을 애플리케이션 레이어에서 논리적으로 관리한다
  • 세 단계로 나누어 트랜잭션을 관리한다
    • Try : 필요한 리소스를 점유할 수 있는지 검사하고 임시로 예약한다
    • Confirm : 실제 리소스를 확정 처리하여 DB에 반영한다
    • Cancel : 문제가 생긴 경우, 예약 상태를 취소하여 원복한다
  • Try, Confirm, Cancel 단계는 멱등하게 설계가 되어야 한다

주문 + 결제 요청 흐름시 OrderService 가 Coordinator 가 되는 상황



테스트

  • 주문(Order) / 상품(Product) / 포인트(Point) 가 존재한다
  • 주문이 요청되면 주문을 생성한다
  • 이후 클라이언트가 주문 + 결제 요청을 시도한다
  • OrderService 는 재고를 차감하도록 ProductService 에 예약한다
  • 이후 OrderService 는 포인트를 차감하도록 PointService 에 예약한다
  • 동시성 처리
    • Redis SETNX 를 통해 Lock 획득/해제
    • Optimistic Lock 처리

각각의 도메인 Entity 는 예약에 대한 Entity 를 관리해야 한다

예시) 상품(Product)

@Entity
@Table(name = "products")
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private Long quantity;

    private Long price;

    private Long reservedQuantity;

    @Version
    private Long version;
}
  • 해당 도메인의 핵심 도메인 엔티티
  • version 을 통해 낙관적 락 처리
  • reservedQuantity 컬럼을 가지고 있음
    • 1차 방어선 역할
    • 실제 재고 수량과 예약된 재고 두 가지 수량을 가지고 있으며 이를 통해 수량 예외 방어로직처리

@Entity
@Table(name = "product_reservations")
public class ProductReservation {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String requestId;

    private Long productId;

    private Long reservedQuantity;

    private Long reservedPrice;

    @Enumerated(EnumType.STRING)
    private ProductReservationStatus status;
}

public enum ProductReservationStatus {
    RESERVED,
    CONFIRMED,
    CANCELLED
}
  • 해당 도메인에 예약을 위해 존재하는 엔티티
  • Try, Confirm, Cancel 각 단계는 예약 엔티티를 통해 관리 및 수정

@Service
public class ProductService {

    private final ProductRepository productRepository;
    private final ProductReservationRepository productReservationRepository;

    public ProductService(ProductRepository productRepository, ProductReservationRepository productReservationRepository) {
        this.productRepository = productRepository;
        this.productReservationRepository = productReservationRepository;
    }

    @Transactional
    public ProductReserveResult tryReserve(ProductReserveCommand command) {
        List<ProductReservation> exists = productReservationRepository.findAllByRequestId(command.requestId());

        if (!exists.isEmpty()) {
            long totalPrice = exists.stream().mapToLong(ProductReservation::getReservedPrice).sum();

            return new ProductReserveResult(totalPrice);
        }

        Long totalPrice = 0L;
        for (ProductReserveCommand.ReserveItem item : command.items()) {
            Product product = productRepository.findById(item.productId()).orElseThrow();

            Long price = product.reserve(item.reserveQuantity());
            totalPrice += price;

            productRepository.save(product);
            productReservationRepository.save(
                    new ProductReservation(
                            command.requestId(),
                            item.productId(),
                            item.reserveQuantity(),
                            price
                    )
            );
        }

        return new ProductReserveResult(totalPrice);
    }

    ...
}
  • 위와 같이 핵심 도메인(Product) 생성 및 예약을 위한 엔티티(ProductReservation)을 생성한다

@Component
public class ProductFacadeService {

    private final ProductService productService;

    public ProductFacadeService(ProductService productService) {
        this.productService = productService;
    }

    public ProductReserveResult tryReserve(ProductReserveCommand command) {
        int tryCount = 0;

        while (tryCount < 3) {
            try {
                return productService.tryReserve(command);
            } catch (ObjectOptimisticLockingFailureException e) {
                tryCount++;
            }
        }

        throw new RuntimeException("예약에 실패하였습니다");
    }

    ...
}
  • Service 의 상위 레이어인 Facade 에서 낙관적 락에 대한 핸들링을 처리
  • 위 로직에서는 최대 3회 재시도

@RestController
public class ProductController {

    private final ProductFacadeService productFacadeService;
    private final RedisLockService redisLockService;

    public ProductController(ProductFacadeService productFacadeService, RedisLockService redisLockService) {
        this.productFacadeService = productFacadeService;
        this.redisLockService = redisLockService;
    }

    @PostMapping("/product/reserve")
    public ProductReserveResponse reserve(@RequestBody ProductReserveRequest request) {
        String key = "product:" + request.requestId();
        boolean acquiredLock = redisLockService.tryLock(key, request.requestId());

        if (!acquiredLock) {
            throw new RuntimeException("락 획득에 실패하였습니다.");
        }

        try {
            ProductReserveResult result = productFacadeService.tryReserve(request.toCommand());

            return new ProductReserveResponse(result.totalPrice());
        } finally {
            redisLockService.releaseLock(key);
        }
    }

    ...
 }
  • requestId 로 같은 요청에 대한 방어 로직 처리
    • 요청에 담긴 requestId 를 통해 Lock 획득 및 해제


장점

  • 확장성과 성능에 유리하다
    • 2PC 에 비해 DB Lock 점유시간이 짧다
    • 2PC 에 비해 Long Transaction 에 덜 취약하다
  • 장애 복구와 재시도 처리에 유연하다
    • 비즈니스 정책에 따라 전략을 정할 수 있다 → Confirm OR Cancel

단점

  • 기존 시스템에 비해 설계와 구현이 복잡하다
    • 모든 단계 (Try, Confirm, Cancel) 는 멱등적으로 설계되어야 한다
    • 네트워크 오류, 재시도 시나리오를 고려한 복잡한 로직 구현이 필요하다
  • Timeout 으로 인한 문제상황

테스트 코드

'학습일지 > MSA 분산 트랜잭션' 카테고리의 다른 글

[분산트랜잭션 맛보기] - Saga  (0) 2025.10.19
[분산트랜잭션 맛보기] - 2PC  (0) 2025.10.18