외부 api 호출시 사용하는 보상트랜잭션

hyezuu·2025년 3월 25일

시작하며

@transactional 내에서 외부 연결이 이루어지면, 해당 호출이 완료될 때까지 DB 커넥션이 잡혀있게 된다. 만약, 외부 API 호출(client 호출)이 느려지거나 장애가 나면, DB 커넥션을 불필요하게 오랜 시간 점유하여 성능 저하로 이어질 수 있다. 이를 개선하기 위해 적용한 방법들을 공유해 보려고 한다.

문제가 되는 곳

    @Override
	@Transactional
	public PostProductResponseDto saveProduct(PostProductRequestDto requestDto) {

		validateRequest(requestDto.hubId(), requestDto.companyId());
        //물리트랜잭션 시작(db 의 커넥션을 물고있는 상태) (1)
        Product product = productRepository.save(Product.create(requestDto.toCommand()));
		//동기적인 동작을 위해 외부호출을 트랜잭션 내에서 호출한다.(2)
        PostStockResponseDto responseDto = stockClient
			.saveStock(PostStockRequestDto.from(product.getId(), requestDto));

		return PostProductResponseDto.from(product, responseDto);
	}

     // 검증을 수행하는 외부 호출, 물리적 트랜잭션은 시작되지 않는다.
	private void validateRequest(UUID hubId, UUID companyId) {
		hubClient.findByHubId(hubId);
		companyClient.findByCompanyId(companyId);
	}
     ...
}

@Transactional이 존재하면 최초의 DB 커넥션이 발생하는 시점부터 메서드 종료 시점까지 커넥션이 유지된다.
위 코드의 의도는 cascade 관계라고 생각한 product와 stock이 원자적으로 생성되고 삭제되어야 한다는 점에서 비롯되었다.

그런데 MSA 환경에서는 한 곳의 장애가 다른 서비스로 전파될 수 있다. 이 트랜잭션 때문에 상품을 호출하는 외부 API까지 병목이 생길 수 있고, 이는 시스템 전체 장애로 확산될 가능성이 있다.

MSA 환경에서 최종적 일관성(Eventual Consistency)을 강조하는 이유가 여기에 있지 않나 싶다.

최종적일관성이란 ?
데이터가 일시적으로 불일치할 수 있지만, 일정 시간이 지나면 결국 일관된 상태로 수렴하는 특성을 의미한다.

해결방법

첫 번째 방법은 트랜잭션 분리이다.
하나의 트랜잭션으로 처리하던 작업을, 별도의 트랜잭션으로 처리하면 발생할 수 있는 문제는 데이터 일관성이 깨지는 것이다.

어떤 시점에 어떤 메서드를 호출하느냐에 따라 처리 방식이 달라지는데, 처음에는 두 번째 방법을 적용했다가 결국 첫 번째 방법으로 진행했다.


초기 해결 방법

두번째 방법의 코드

  • 오래걸리는 saveStock을 먼저 수행 후 product를 저장하는 방식
	@Override
	public PostProductResponseDto saveProduct(
		PostProductRequestDto requestDto, UserInfoDto userInfo) {
		validateRequest(requestDto.hubId(), requestDto.companyId());
		validateAccessToCompany(requestDto.companyId(), userInfo);
		Product product = Product.create(requestDto.toCommand());
		PostStockResponseDto savedStock = getSavedStock(product.getId(), requestDto);
		return PostProductResponseDto.from(getSavedProduct(product), savedStock);
	}

	private void validateRequest(UUID hubId, UUID companyId) {
		hubClient.findByHubId(hubId);
		companyClient.findByCompanyId(companyId);
	}
	private void validateAccessToCompany(UUID resourceId, UserInfoDto userInfo) {
		boolean isCompanyManager = userInfo.role() == UserRole.COMPANY_MANAGER;
		if (isCompanyManager && !getCompanyId(userInfo).equals(resourceId)) {
			throw ProductBusinessException.from(ACCESS_DENIED);
		}
	}

	private Product getSavedProduct(Product product) {
		try {
			return productRepository.save(product);
		} catch (Exception e) {// 제품 생성 실패시 고아 재고 삭제 요청
			stockClient.deleteStock(product.getId());
			throw ProductBusinessException.from(PRODUCT_SAVE_FAILED);
		} 
	}

	private PostStockResponseDto getSavedStock(
		UUID productId, PostProductRequestDto requestDto) {
		return stockClient.saveStock(PostStockRequestDto.from(productId, requestDto));
        }
}

이 방식에서는 save를 늦게 호출하기 때문에, JPA에서 자동 생성해주는 UUID를 받을 수 없다.
그래서 아래처럼 랜덤한 UUID를 생성하는 책임을 직접 가져가야 했다.

	public static Product create(CreateProduct command){
		return new Product(command.name(), command.companyId());
	}

	private Product(String name, UUID companyId) {
		this.id = UUID.randomUUID();
		this.name = name;
		this.companyId = companyId;
	}

재고를 먼저 생성하면, 삭제를 위해 또다시 외부 API를 호출해야 한다.
이 과정에서도 네트워크 유실 가능성이 있기 때문에, 최소한의 호출로 처리하는 방향으로 개선이 필요했다.

최종적으로 적용한 방법 (첫 번째 방법 적용)

  • product 저장saveStock 호출 실패삭제를 진행하는 방법
    @Override
	public PostProductResponseDto saveProduct(
		PostProductRequestDto requestDto, UserInfoDto userInfo) {
		validateRequest(requestDto.hubId(), requestDto.companyId());
		validateAccessToCompany(requestDto.companyId(), userInfo);
		Product savedProduct = getSavedProduct(requestDto);
		PostStockResponseDto savedStock = getSavedStock(requestDto, savedProduct);
		return PostProductResponseDto.from(savedProduct, savedStock);
	}

	private void validateRequest(UUID hubId, UUID companyId) {
		hubClient.findByHubId(hubId);
		companyClient.findByCompanyId(companyId);
	}
	private void validateAccessToCompany(UUID resourceId, UserInfoDto userInfo) {
		boolean isCompanyManager = userInfo.role() == UserRole.COMPANY_MANAGER;
		if (isCompanyManager && !getCompanyId(userInfo).equals(resourceId)) {
			throw ProductBusinessException.from(ACCESS_DENIED);
		}
	}

	private Product getSavedProduct(PostProductRequestDto requestDto) {
		return productRepository.save(Product.create(requestDto.toCommand()));
	}

	private PostStockResponseDto getSavedStock(
		PostProductRequestDto requestDto, Product savedProduct) {
		try {
			return stockClient.saveStock(
				PostStockRequestDto.from(savedProduct.getId(), requestDto));
		} catch (Exception e) {
			log.error(e.getMessage());
			productRepository.delete(savedProduct);
			throw ProductBusinessException.from(PRODUCT_SAVE_FAILED);
		}

}

위와 같이 먼저 product를 생성 및 저장하고, 외부api를 호출시 실패하게되면 보상트랜잭션을 적용하여 최종적 일관성에 도달하도록 했다.

FeignClient 타임아웃 설정

여기까지는 하나의 서버의 db 커넥션 관점에서의 성능을 기준으로 작성되었다. 해당 api를 외부에서 호출한다면 여전히 해당 메서드가 끝날때까지 기다려야하는 문제가 있다.
이를 방지하기 위해 FeignClient에 타임아웃을 적용할 수 있다.

설정값설명
ConnectTimeout외부 API에 대한 연결을 시도하는 시간 (예: 서버 다운 시 빠른 실패)
ReadTimeout연결 성공 후 응답을 기다리는 시간 (예: 서비스 내부 처리 지연)
spring:
  cloud:
    openfeign:
      client:
        config:
          default:
            readTimeout: 10000
            connect-timeout: 3000

connection 타임아웃 설정은 짧고, readtimeout 설정은 비교적 길게 잡았는데,
아래와 같은 데이터 정합성 문제를 방지하기 위해서이다.

Product 생성시 stock을 생성하는데, 요청은 갔으나 재고서비스 내부의 문제로 인해 readtimeout 시간을 초과하게된다.

  • 위 사례는 sleep(5000)으로 테스트했으나, 실제로는 db connection 을 얻지 못해 병목되는 문제가 있겠다

product 입장에서는 Read Timeout 이 적용되어 타임아웃 예외가 발생하여 product 삭제를 수행하지만, stock 은 병목이 끝난 후 정상적으로 처리될 수 있다.
이는 stockstock을 호출하는 주체(product) 의 상태를 모르기때문에 발생하는 문제이다.
이때문에 정합성을 추가적으로 맞춰주기 위한 상태를읽는 api가 필요하게된다.

그렇게 발생한 추가적인 고민포인트..

재고쪽에서 상품을 조회하고, 존재하지 않는 상품을 참조하고있는 재고가 있다면 일괄 삭제하는 배치처리가 필요하겠다. 재고같은 경우는 재고 수량의 변동이 잦아 비관적 락을 적용해뒀는데, 커넥션이 부족해지진 않을까 ?
그렇다면 초기 api를 두번 요청해야하는 수고를 감수하는것이 오히려 더 나은 결과를 내진 않을까? (상품은 비교적 삽입,삭제,수정 요청이 적은 도메인이니까, 재고가 없는 상품을 삭제하는것은 비교적 병목이 덜 할지도 모른다)

마무리

데이터베이스 커넥션을 지키는 것부터 시작해서, 결국 동기 방식의 한계점까지 발견하게 되었다. 해당 기능을 이벤트 기반 비동기 방식으로 개선해 나가면 더 좋은 결과를 낼 수 있지 않을까? 하나의 숙제를 남기고 글을 마무리한다!

profile
기록

0개의 댓글