REST 기반의 간단한 분산 트랜잭션 구현 - 2편 TCC Cancel, Timeout



지난 글에서는 분산된 REST 시스템들 간의 트랜잭션을 HTTP와 REST 원칙으로 접근하여 해결하는 방법으로 TCCTry-Confirm/Cancel를 소개하였고 온라인 쇼핑몰에서 일어날 수 있는 '주문' 시나리오를 예시로 설명하였다.

TCC는 2단계 커밋 프로토콜Two-phase commit protocol 처럼 1단계로 Try(2)(3)로 사용할 리소스(여기서는 재고, 결제)를 예약하고 정상적인 경우 2단계로 예약한 리소스를 Confirm(5)(6)함으로써 분산 트랜잭션을 구현한다.

그림. TCC REST 시스템 시나리오

그림. TCC REST 시스템 시나리오

이번 글에서는 TCC에서 일어날 수 있는 예외 흐름 중 하나인 'Try 후 Confirm 하기 전에 실패하는 경우'에 대해 다룬다.

예외 시나리오

클라이언트가 OrderService로 주문 요청하고(1), OrderService는 StockService와 PaymentService로 Try 한다(2)(3). 하지만 OrderService에서 내부 오류 등으로 인해 Confirm을 못했다(4).

이런 상황에서 어떻게 일관성을 유지할 수 있을까?

그림. 예외 시나리오

그림. 예외 시나리오

예약한 리소스 문제

Try는 리소스를 사용하기 전에 예약하는 것이다. OrderService는 Confirm 하지 못하고 Try만 하고 실패했기 때문에 StockService와 PaymentService까지 Confirm이 전달되지 않았다. 따라서 StockService와 PaymentService에서 관리하는 재고 차감이나 결제는 처리되지 않았으며 예약만 된 상태이다.

예약된 상태는 특정 리소스를 점유하고 있다는 의미이고, 리소스를 점유하고 있는 동안에는 다른 API Consumer에서 해당 리소스를 사용하는 것은 제한된다. 따라서 OrderService가 Try만 하고 실패하는 경우 예약한 리소스까지 해제해야 한다.

분산된 환경에서 예약된 리소스를 해제하는 것은 쉬운 문제가 아닌데 TCC  메커니즘에서는  'Cancel'과 'Timeout' 두 가지 방법으로 예약된 리소스를 해제한다.

출처 : http://www.inf.usi.ch/faculty/pautasso/talks/2012/soa-cloud-rest-tcc/rest-tcc.html#/tcc

출처 : http://www.inf.usi.ch/faculty/pautasso/talks/2012/soa-cloud-rest-tcc/rest-tcc.html#/tcc

TCC - Cancel

OrderService가 오류를 감지하면 명시적으로 StockService/PaymentService에 Cancel 요청을 하여 Try로 예약한 리소스의 해제 요청을 한다. Cancel은 예약한 주체(OrderService)가 명시적으로 해제 요청하는 방식이다.

그림. TCC Cancel

그림. TCC Cancel

REST 커뮤니케이션 관점에서 상세하게 보면 TCC REST API Consumer(OrderService)가 Try 요청 시에 TCC REST API Provider(StockService,PaymentService)는 응답으로 Confirm 하거나 Cancel 할 수 있는 URI을 반환하고 이를 사용하여 API Consumer는  DELETE HTTP Method로 예약한 리소스 대한 해제 요청한다.

그림. TCC Cancel REST 커뮤니케이션

그림. TCC Cancel REST 커뮤니케이션

아래는 API Consumer인 OrderService 코드의 일부이다.

예외 시나리오를 임의적으로 만들기 위해 OrderService에서 요청한 주문의 상품 아이디가 'prd-0004'이면 내부 오류가 발생한 것으로 가정하였다. OrderService는 내부 오류가 발생하면 TccRestAdapter로 cancelAll 메소드를 호출하여 doTry 메소드로 예약한 리소스를 명시적으로 해제 요청한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Service
public class OrderServiceImpl implements OrderService {
    private TccRestAdapter tccRestAdapter;
    ...
    @Override
    public void placeOrder(final Order order) {
        // ...
        // TCC - Try
        List<ParticipantLink> participantLinks  =
                tccRestAdapter.doTry(Arrays.asList(stockParticipationRequest, paymentParticipationRequest));
        // Exception Path
        if(order.getProductId().equals("prd-0004")) {
            // TCC - Cancel
            tccRestAdapter.cancelAll(participantLinks);
            throw new RuntimeException("Error Before Confirm...");
        }
        //...
    }
}

TccRestAdapter cancelAll 메소드는 Spring RestTemplate를 사용하여 HTTP DELETE 요청한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Component
public class TccRestAdapterImpl implements TccRestAdapter {
    private RestTemplate restTemplate = new RestTemplate();
    // ...
    @Override
    public void cancelAll(List<ParticipantLink> participantLinks) {
        participantLinks.forEach(participantLink -> {
            try {
                restTemplate.delete(participantLink.getUri());
            } catch (RestClientException e) {
                log.error(String.format("TCC - Cancel Error[URI : %s]", participantLink.getUri().toString()), e);
            }
        });
    }
}

아래는 API Provider 중 하나인 StockService 코드의 일부이다.

Spring Controller에서 DeleteMapping 사용하여 HTTP DELETE 메소드를 매핑하였고 요청 아이디를 받아 Spring Service로 위임한다.

1
2
3
4
5
6
7
8
9
10
11
@RestController
@RequestMapping("/api/v1/stocks")
public class StockRestController {
    private StockService stockService;
    // ...
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> cancelStockAdjustment(@PathVariable Long id) {
        stockService.cancelStock(id);
        return new ResponseEntity<>(HttpStatus.NO_CONTENT);
    }
}

Spring Service는 아이디로 예약한 리소스를 조회하고 상태를 'Cancel'로 변경하여 저장한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
public class StockServiceImpl implements StockService {
    private ReservedStockRepository reservedStockRepository;
    // ...
    @Transactional
    @Override
    public void cancelStock(final Long id) {
        ReservedStock reservedStock = reservedStockRepository.getOne(id);
        reservedStock.setStatus(Status.CANCEL);
        reservedStockRepository.save(reservedStock);
        log.info("Cancel Stock :" + id);
    }
}

데이터베이스에는 아래와 같이 저장된다.

그림. 예약한 리소스가 'Cancel'된 상태

그림. 예약한 리소스가 'Cancel'된 상태

가용한 재고를 산정한다고 해보자. 실제 재고인 STOCK 테이블과 예약된 재고 RESERVED_STOCK 테이블을 참조하여 가용 재고를 산정할 것이다. 이때 재고에서 RESERVED_STOCK 테이블의 'Cancel' 상태는 제외한다.

리소스를 해제한다는 것은 결국 RESERVED_STOCK 테이블에서 제외하는 것이다.

TCC - Timeout

OrderService에서 예기치 않는 상황이 발생하여 명시적으로 Cancel 요청을 못 할 수도 있다. Timeout은 StockService/PaymentService가 예약한 리소스에 대한 만료 시간을 두어 예약한 리소스가 만료되면 Timeout 처리하여 해제하는 방식이다.

OrderService가 Cancel 요청을 하지 못한 상황이라도 결과적으로 리소스는 해제되어야 한다.

그림. TCC Timeout

그림. TCC Timeout

아래는 API Provider 중 하나인 StockService 코드의 일부이다.

Spring RestContoller에서 Spring Service로부터 받은 ReservedStock의 getExpires 메소드를 사용하여 HTTP 응답 엔터티Response Entity인 ParticipantLink를 생성하고 이를 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@RestController
@RequestMapping("/api/v1/stocks")
public class StockRestController {
    private StockService stockService;
    @Autowired
    public void setStockService(StockService stockService) {
        this.stockService = stockService;
    }
    @PostMapping
    public ResponseEntity<ParticipantLink> tryStockAdjustment(@RequestBody StockAdjustment stockAdjustment) {
        final ReservedStock reservedStock = stockService.reserveStock(stockAdjustment);
        final ParticipantLink participantLink = buildParticipantLink(reservedStock.getId(), reservedStock.getExpires());
        return new ResponseEntity<>(participantLink, HttpStatus.CREATED);
    }
    private ParticipantLink buildParticipantLink(final Long id, final LocalDateTime expired) {
        URI location = ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(id).toUri();
        return new ParticipantLink(location, expired);
    }
    // ...
}
public class ParticipantLink {
    private URI uri;
    private LocalDateTime expires;
    public ParticipantLink(URI uri, LocalDateTime expires) {
        this.uri = uri;
        this.expires = expires;
    }
    public URI getUri() {
        return uri;
    }
    public LocalDateTime getExpires() {
        return expires;
    }
}

 Spring Service는 ReservedStock(JPA Entity)을 생성하고 JPAJava Persistence API로 데이터베이스에 저장한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Service
public class StockServiceImpl implements StockService {
    private ReservedStockRepository reservedStockRepository;
    @Autowired
    public void setReservedStockRepository(ReservedStockRepository reservedStockRepository) {
        this.reservedStockRepository = reservedStockRepository;
    }
    @Override
    public ReservedStock reserveStock(final StockAdjustment stockAdjustment) {
        ReservedStock reservedStock = new ReservedStock(stockAdjustment);
        reservedStockRepository.save(reservedStock);
        return reservedStock;
    }
    //...
}

expires Timestamp은 ReservedStock이 생성자에서 결정한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Entity
public class ReservedStock {
    // 3초 타임 아웃
    private static final long TIMEOUT = 3L;
    // ...
    private LocalDateTime expires;
    public ReservedStock(StockAdjustment stockAdjustment) {
        // ...
        this.created = LocalDateTime.now();
        this.expires = created.plus(TIMEOUT, ChronoUnit.SECONDS);
    }
    //...
}

데이터베이스에는 아래와 같이 저장된다.

그림. 예약된 리소스에 대한 만료 Timestamp

그림. 예약된 리소스에 대한 만료 Timestamp

그림. TCC Try HTTP 응답

그림. TCC Try HTTP 응답

다시 가용한 재고를 산정한다고 해보자. 재고에서 RESERVED_STOCK 테이블의 expires 컬럼을 참조하여 재고에서 제외한다.

결국 Cancel과 동일하게 RESERVED_STOCK 테이블에서 제외하는 것이다.

HTTP 404 - NOT FOUND

API Consumer가 유효하지 않은 리소스(Cancel/Timeout/존재하지 않은 리소스 아이디)를 Confirm 요청을 하는 경우 API Provider는 404-Not Found HTTP 응답 코드를 반환하여 API Consumer에게 유효하지 않은 리소스임을 알려준다.

그림. TCC Confirm REST 커뮤니케이션

그림. TCC Confirm REST 커뮤니케이션

아래 코드는 StockService 코드 일부이다.

Spring RestController는 요청 아이디를 받아 Spring Service로 위임한다. Spring Service에서 IllegalArgumentException 발생하면 HTTP 응답 코드를 404(HttpStatus.NOT_FOUND)로 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
@RequestMapping("/api/v1/stocks")
public class StockRestController {
    private StockService stockService;
    // ...
    @PutMapping("/{id}")
    public ResponseEntity<Void> confirmStockAdjustment(@PathVariable Long id) {
        try {
            stockService.confirmStock(id);
        } catch(IllegalArgumentException e) {
            return new ResponseEntity<>(HttpStatus.NOT_FOUND);
        }
        return new ResponseEntity<>(HttpStatus.NO_CONTENT);
    }
}

Spring Service는 id를 사용하여 ReservedStock을 조회하고 ReservedStock가 유효함에 대한 판단은 ReservedStock에게 위임한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
public class StockServiceImpl implements StockService {
    // ...
    @Transactional
    @Override
    public void confirmStock(Long id) {
        ReservedStock reservedStock = reservedStockRepository.getOne(id);
        if(reservedStock == null) {
            throw new IllegalArgumentException("Not found");
        }
        reservedStock.validate();
        // ...
    }
}

ReservedStock은 리소스의 상태와 expires를 비교하여 유효함을 판단한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Entity
public class ReservedStock {
    // ...
    private LocalDateTime expires;
    // ...
    public void validate() {
        validateStatus();
        validateExpired();
    }
    private void validateStatus() {
        if(this.getStatus() == Status.CANCEL || this.getStatus() == Status.CONFIRMED) {
            throw new IllegalArgumentException("Invalidate Status");
        }
    }
    private void validateExpired() {
        if(LocalDateTime.now().isAfter(this.expires)) {
            throw new IllegalArgumentException("Expired");
        }
    }
}

실제 운영환경에서는 예약 테이블(ReservedStock, ReservedPayment)의 크기가 엄청 커질 수 있는데....

예약 테이블의 데이터는 상태가 Cancel 혹은 Confirmed 되거나 Timeout 되면 더 이상 사용하지 않기 때문에 임시적이다. 시스템을 운영하면서 임시 데이터의 증가는 문제가 될 수 있다. 그래서 더 이상 사용하지 않는 데이터는 지워주어야 한다.

StockService와 같이 가용 재고 같은 조회할 때 예약 테이블을 함께 참조해야 하는 경우에는 데이터베이스나 애플리케이션 스케줄러 사용하여 주기적으로 지워주는 방법이 있을 수 있으며, PaymentService 같이 예약 테이블을 함께 참조하지 않는 경우라면 ReservedPayment를 Redis 같은 곳에 EXPIRE를 함께 사용하여 저장하는 방법이 있다.

완벽한 것은 없다

분산 환경에서 완벽하게 일관성을 유지하는 것은 매우 어려운 일이다. 예컨대 Confirm 중에 리소스가 Timeout 되는 경우 일관성이 깨지고 만다.

그림. Confirm 중 Timeout

그림. Confirm 중 Timeout

2단계 커밋 프로토콜에서는 휴리스틱 예외Heuristic Exceptions라고 정의한다.

휴리스틱 예외는 제거할 수는 없지만 위의 경우 적절한 Timeout 시간을 설정하여 발생 가능성을 줄일 수 있다. 그래도 발생하는 경우에는 오류 로그를 파일이나 큐Queue로 큐잉하여 리포팅한다. 리포팅된 로그는 시스템 관리자(혹은 시스템)에 의해 해결함으로써 결과적으로 일관성을 유지한다.

이런 방식은 단기적으로 일관성을 잃더라도(클라이언트 입장에서 주문을 성공했다고 받았지만 결제는 되지 않을 수 있다) 결과적으로는 일관성을 유지하는 모델을 결과적 일관성Eventual consistency이라고 한다.

마치며

이번 글은 Confirm 하기 전에 실패하는 경우 일관성을 유지하는 방법을 다루었다.  다음 글은 위에서 잠시 언급했던 결과적 일관성을 좀 더 상세하게 설명하고 이를 사용하여 'Confirm 중 실패하는 경우' 일관성 유지하는 방법을 다룬다.

GitHub

전체 코드는 필자의 GitHub 저장소에서 확인할 수 있다.

참고 자료


Popit은 페이스북 댓글만 사용하고 있습니다. 페이스북 로그인 후 글을 보시면 댓글이 나타납니다.