Troubleshooting: 외부 api 호출시 사용하는 보상트랜잭션 - takeoff-26/logistics-service GitHub Wiki
외부 api 호출시 사용하는 보상트랜잭션
@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 커넥션이 발생하면 메서드 시작점부터 종료점까지 커넥션이 유지된다.
위 코드에서 product와 stock이 원자적으로 생성되고 삭제되어야 한다.
MSA 환경에서는 한 곳의 장애가 다른 서비스로 전파될 수 있다.
이 트랜잭션 때문에 상품을 호출하는 외부 API까지 병목이 생길 수 있고, 이는 시스템 전체 장애로 확산될 가능성이 있다.
해결 방법
첫 번째 방법은 트랜잭션 분리이다.
하나의 트랜잭션으로 처리하던 작업을, 별도의 트랜잭션으로 처리하면 발생할 수 있는 문제는 데이터 일관성이 깨지는 것이다.
어떤 시점에 어떤 메서드를 호출하느냐에 따라 처리 방식이 달라진다.
다음 해결 방법은 Stock을 먼저 수행 후 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를 호출해야 한다. 이 과정에서도 네트워크 유실 가능성이 있기 때문에, 최소한의 호출로 처리하는 방향으로 개선이 필요했다.
@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);
}
@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 타임아웃 설정
해당 api를 외부에서 호출한다면 여전히 해당 메서드가 끝날때까지 기다려야하는 문제가 있다. 이를 방지하기 위해 FeignClient에 타임아웃을 적용할 수 있다.
ConnectTimeout - 외부 API에 대한 연결을 시도하는 시간 (예: 서버 다운 시 빠른 실패) ReadTimeout - 연결 성공 후 응답을 기다리는 시간 (예: 서비스 내부 처리 지연)
connection 타임아웃 설정은 짧고, readtimeout 설정은 비교적 길게 잡았는데, 아래와 같은 데이터 정합성 문제를 방지하기 위해서이다.
Product 생성시 stock을 생성하는데, 요청은 갔으나 재고서비스 내부의 문제로 인해 readtimeout 시간을 초과하게된다.
이렇게 api를 활용해 보상 트랜잭션을 적용해 리팩토링 했다.