REST 기반의 간단한 분산 트랜잭션 구현 - 1편

안영회 님은  마이크로 서비스 공부하게 책 하나 추천해주세요 글에서 마이크로 서비스 간 일관성을 유지하는 방법 중 하나로 TCCTry-Confirm/Cancel를 언급했다.

How to become eventually consistent.
  • 오호… 지옥(?)에 오신 것을 환영한다. 내 모듈에 처리된 내용이 다른 모듈과 일관성을 유지하려면 어떻게 해야 하나? 알려진 방법으로 TCCTry-Confirm/Cancel 같은 것이 있고, 크리스 리차드슨처럼 상태를 데이터로 저장하지 않고, 이벤트를 저장하는 방법이 있다. 방법은 설명할 수 없으니 왜 이렇게 하는지만 간단히 설명해본다. 여러분이 여행자를 위한 앱을 만든다 생각해보자. 항공권과 렌트카 혹은 호텔까지 묶어서 예약을 해주고 싶다. 항공사와 연결하고, 렌트카 시스템과 연결하고, 호텔 시스템과 연결해야 한다. 이들을 모두 데이터베이스 트랜잭션Transcation으로 처리할 수는 없다.[6]

이 글은 필자가 스프링 부트Spring Boot로 TCC를 구현해 본 것으로 부제를 달자면 '스프링 부트로 구현하는 TCC'이다.

TCC 이외에 이벤트를 사용하여 구현하는 방법은 김형준 님의 글 대용량 환경에서 그럭저럭 돌아가는 서비스 만들기에 일부가 나와 있으니 참고하길 바란다.

나의 REST 시스템 시나리오

마이크로 서비스로 만들어진 온라인 쇼핑몰에서 아래와 같은 순서로 주문이 처리된다고 가정해보자.[1]

  • 1 단계 : 클라이언트는 주문 서비스(OrderService)에 주문을 요청한다.
  • 2 단계 : 주문 서비스는 재고 서비스(StockService)에 재고 차감을 요청한다.
  • 3 단계 : 주문 서비스는 결제 서비스(PaymentService)에 결제 요청한다.
  • 4 단계 : 주문 서비스는 구매 주문을 생성한다.

나의-REST-시나리오 (1)

주문을 처리하는 과정에서 재고를 차감(2단계)하고 결제 처리(3단계)는 성공했지만 구매 주문 생성(4단계)하다가 실패했다면 어떻게 될까? 모두 롤백Rollback 되지 않으면 일관성이 깨지고 만다.

분산 형태로 처리되는 주문 처리 단계가 모두 성공하거나 하나라도 실패하게 된다면 모두 롤백 되어야 한다(all-or-nothing). 주문처리 단계에 대해 흔히 말하는 트랜잭션[2] 처리에 대한 보장이 필요하다.

데이터베이스 트랜잭션의 한계

모노리틱 아키텍처Monolithic Architecture에서는 일반적으로 데이터베이스 트랜잭션에 의존한다.

출처 : http://microservices.io/patterns/monolithic.html

출처 : http://microservices.io/patterns/monolithic.html

하지만 마이크로 서비스의 경우 각 서비스마다 다른 데이터베이스를 사용하는 것이 일반적이고 이를 하나의 데이터 베이스 트랜잭션으로 처리하는 것은 기술적으로 어렵고(이 기종 데이터베이스일 수도 있고) 처리한다 해도 긴 트랜잭션(long trasaction)이 발생하기 때문에 효용도 적다.[3]

TCCTry-Confirm/Cancel

TCC는 DZone에 올라온 Transactions for the REST of Us 글에서 나온 것으로 분산된 REST 시스템들 간의 트랜잭션을 HTTP와 REST 원칙으로 접근하여 해결하는 방법이다.

관계형 데이터베이스 경우 SQL 트랜잭션 구문을 사용하여 트랜잭션을 제어한다.

1
2
3
4
START TRANSACTION;
SELECT @A:=SUM(salary) FROM table1 WHERE type=1;
UPDATE table2 SET summary=@A WHERE type=1;
COMMIT;

START TRANSACTION 키워드로 트랜잭션을 시작하고 정상적으로 작업이 끝나는 경우 COMMIT 키워드를 그렇지 않은 경우 ROLLBACK 키워드를 사용한다.

TCC에서 트랜잭션을 제어하는 방법은 관계형 데이터베이스에서 트랜잭션을 제어하는 방법과 유사하다.

앞서 '나의 REST 시스템 시나리오'에서 주문 처리를 TCC 방식으로 변경하게 되면 아래처럼 된다.

나의-REST-시나리오-TCC 적용

REST API 호출(2단계, 3단계)은 한 번에 끝내는 것이 아니라 2번(Try, Confirm)에 걸쳐 하게 된다.

트랜잭션의 all-or-nothing을 TCC는 REST API를 호출을 시도(Try)하고 전부 확정(Confirm)하거나 전부 취소(Cancel) 하는 것으로 구현한다.

본격적으로 코드를 보자.

TCC REST API Consumer : OrderService

API Consumer인 OrderService는 placeOrder 메서드로 주문을 처리한다. 여기가 바로 트랜잭션 지점이다.

다른 서비스(StockService, PaymentService)와의 TCC REST 커뮤니케이션 책임은 TccRestAdapter가 가진다.

먼저 TccRestAdapter로 재고 차감과 결제 요청을 Try하고 오류가 없는 경우 Confirm 요청한다.

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
@Service
public class OrderServiceImpl implements OrderService {
    private TccRestAdapter tccRestAdapter;
    @Autowired
    public void setTccRestAdapter(TccRestAdapter tccRestAdapter) {
        this.tccRestAdapter = tccRestAdapter;
    }
    @Override
    public void placeOrder(final Order order) {
        // 재고 차감(Try)
        ParticipantLink stockParticipantLink = reduceStock(order);
        // 결제 요청(Try)
        ParticipantLink paymentParticipantLink = payOrder(order);
        // ...
        // 확정(Confirm)
        tccRestAdapter.confirmAll(stockParticipantLink.getUri(), paymentParticipantLink.getUri());
    }
    private ParticipantLink reduceStock(final Order order) {
        final String requestURL = "http://localhost:8081/api/v1/stocks";
        Map<String, Object> requestBody = new HashMap<>();
        requestBody.put("adjustmentType", "REDUCE");
        requestBody.put("productId", order.getProductId());
        requestBody.put("qty", order.getQty());
        return tccRestAdapter.doTry(requestURL, requestBody);
    }
    private ParticipantLink payOrder(final Order order) {
        final String requestURL = "http://localhost:8082/api/v1/payments";
        Map<String, Object> requestBody = new HashMap<>();
        requestBody.put("orderId", order.getOrderId());
        requestBody.put("paymentAmt", order.getPaymentAmt());
        return tccRestAdapter.doTry(requestURL, requestBody);
    }
}

TCC REST 커뮤니케이션을 HTTP 관점에서 상세하게 표현하면 아래와 같다.

image2018-5-3_9-58-51

TccRestAdapter doTry 메서드는 Spring RestTemplate을 사용하여 HTTP 요청(POST)을 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Component
public class TccRestAdapterImpl implements TccRestAdapter {
    private RestTemplate restTemplate = new RestTemplate();
    @Override
    public ParticipantLink doTry(final String requestURL, final Map<String, Object> requestBody) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        ResponseEntity<ParticipantLink> response = restTemplate.postForEntity(requestURL, new HttpEntity(requestBody, headers), ParticipantLink.class);
        if(response.getStatusCode() != HttpStatus.CREATED) {
            throw new RuntimeException(String.format("TRY Error[URI : %s][HTTP Status : %s]",
                    requestURL, response.getStatusCode().name()));
        }
        return response.getBody();
    }
    //...
}

Try 요청의 경우 정상적인 HTTP 응답(HttpStatus.CREATED) 받으면 HTTP BODY에는 JSON 형태로 Confirm 하거나 Cancel할 수 있는 URI이 담겨 있다. 이를 ParticipantLink로 변환하여 반환한다.

image2018-5-14_9-8-9

1
2
3
4
5
public class ParticipantLink {
    private URI uri;
    private Date expires;
    // ...
}

TccRestAdapter confirmAll 메서드는 Try 요청 시 받았던 ParticipantLink의 URI로 Confirm HTTP 요청(PUT)을 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Component
public class TccRestAdapterImpl implements TccRestAdapter {
    private RestTemplate restTemplate = new RestTemplate();
    @Override
    public void confirmAll(final URI... uris) {
        for (URI uri : uris) {
            try {
                restTemplate.put(uri, null);
            } catch (RestClientException e) {
                cancelAll(uris);
                throw new RuntimeException(String.format("Confirm Error[URI : %s]",
                        uri.toString()), e);
            }
        }
    }
    //...
}

TCC REST API Provider : StockService/PaymentService

StockService를 대표로 API Provider 입장에서 TCC 처리 과정을 살펴보자.

TCC - Try

Spring Rest Controller를 사용하여 HTTP POST Method와 연결하였다. Controller는 Spring Service로 처리를 위임한다.

Spring Service에서 반환받은 ReservedStock의 id를 사용하여 Confirm 하거나 Cancel할 수 있는 URI을 만들고(buildParticipantLink) 이를 HTTP 응답 BODY로 반환 한다.

여기서 중요한 것은 실제로 재고를 차감하는 것이 아니라는 점이다. 재고 차감은 API Consumer가 Confirm 요청 시 처리 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@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.getCreated());
        return new ResponseEntity<>(participantLink, HttpStatus.CREATED);
    }
    private ParticipantLink buildParticipantLink(final Long id, final Date created) {
        URI location = ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(id).toUri();
        final long expires = created.getTime() + TIMEOUT;
        return new ParticipantLink(location, new Date(expires));
    }
    // ...
}

Spring Service의 reserveStock 메서드는 StockAdjustment을 생성자로 전달하여 ReservedStock 엔티티를 생성하고 JPAJava Persistence API를 사용하여 데이터베이스에 저장한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@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);
        log.info("Reserved Stock :" + reservedStock.getId());
        return reservedStock;
    }
    // ...
}
1
2
public interface ReservedStockRepository extends JpaRepository<ReservedStock, Long> {
}

ReservedStock은 Try 시 요청 내용(HTTP Request Body)을 JSON 문자열로 직렬화하여 resources에 필드에 저장한다.[4]

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
@Entity
public class ReservedStock {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;
    private String resources;
    @Enumerated(EnumType.STRING)
    private Status status;
    @Temporal(TemporalType.TIMESTAMP)
    private Date created;
    public ReservedStock() {
    }
    public ReservedStock(StockAdjustment stockAdjustment) {
        ObjectMapper objectMapper = new ObjectMapper();
        try {
            this.resources = objectMapper.writeValueAsString(stockAdjustment);
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }
        this.created = new Date();
    }
    public StockAdjustment getResources() {
        ObjectMapper objectMapper = new ObjectMapper();
        try {
            return objectMapper.readValue(this.resources, StockAdjustment.class);
        } catch (IOException e) {
            e.printStackTrace();
            return null;
        }
    }
    // ...
}

Try 처리가 끝나면 데이터베이스에는 아래처럼 저장 된다.

image2018-5-8_9-12-33

이렇게 저장된 데이터의 id는 ParticipantLink의 URI로 API Consumer에게 전달된다.

그리고 API Consumer가 Confirm 요청 시에 id를 이용하여 Try 시 요청했던 데이터를 조회 후 실제 재고 차감을 처리하게 된다.

TCC - Confirm

Spring Controller에서는 @PutMapping을 사용하여 HTTP PUT Method를 연결하였다. PathVariable로 id를 매개변수로 받아 처리를 Spring Service로 위임한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RestController
@RequestMapping("/api/v1/stocks")
public class StockRestController {
    private StockService stockService;
    @Autowired
    public void setStockService(StockService stockService) {
        this.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를 사용하여 Try시 저장된 ReservedStock을 조회한다.

ReservedStock의 resources 필드(JSON 문자열)를 역직렬화(reservedStock.getResources())) 하고 이를 사용하여 실제로 재고 차감 처리를 한다.

마지막으로 재처리 되지 않도록 ReservedStock 상태를 변경한다.

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
@Service
public class StockServiceImpl implements StockService {
    private ReservedStockRepository reservedStockRepository;
    private StockRepository stockRepository;
    @Autowired
    public void setReservedStockRepository(ReservedStockRepository reservedStockRepository) {
        this.reservedStockRepository = reservedStockRepository;
    }
    @Autowired
    public void setStockRepository(StockRepository stockRepository) {
        this.stockRepository = stockRepository;
    }
    @Transactional
    @Override
    public void confirmStock(Long id) {
        ReservedStock reservedStock = reservedStockRepository.findOne(id);
        validateReservedStock(reservedStock);
        if(reservedStock.getResources().getAdjustmentType().equals("REDUCE")) {
            // 상품 재고 조회
            Stock stock = stockRepository.findByProductId(reservedStock.getResources().getProductId());
            // 재고 차감
            stock.decrease(reservedStock.getResources().getQty());
            stockRepository.save(stock);
        }
        // ReservedStock 상태 변경
        reservedStock.setStatus(Status.CONFIRMED);
        reservedStockRepository.save(reservedStock);
        log.info("Confirm Stock :" + id);
    }
    // ...
}

Confirm 처리가 끝나면 최종적으로 데이터베이스에는 아래와 같이 저장된다.

image2018-5-8_9-39-37

image2018-5-8_9-42-12

마치며

이번 글은 TCC 개념과 기본적인 흐름에 대해 다루었다. TCC를 적용하다 보면 예외적으로 아래와 같은 상황이 발생할 수 있다.

  • Try 후 Confirm 하기 전에 실패하는 경우
  • Confirm 중 실패하는 경우

다음 글에서는 이런 상황에 대해 다룬다.

GitHub

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

주석

[1] 실무에서는 훨씬 많은 단계가 있다. 이 글에서는 이해를 돕기 위해 간단하게 표현 하였다.

[2] (소프트웨어) 트랜잭션은 다음과 같은 ACID 속성을 가진다.

  • 원자성Atomicity : 트랜잭션의 경계 안에서 수행되는 각 작업의 단계는 모두 성공적으로 완료되거나 롤백돼야 한다. 부분 완료는 트랜잭션의 개념이 아니다.
  • 일관성Consistency : 시스템의 자원은 트랙잭션의 시작과 완료 시점에 모두 일관성 있고 손상되지 않은 상태여야 한다.
  • 격리성Isolcation : 개별 트랜잭션의 결과는 트랙잰션이 성공적으로 커밋하기 전까지 다른 열려 있는 트랙잭션에서 볼 수 없어야 한다.
  • 지속성Durability : 커밋된 트랜잭션의 결과는 영구적이어야 한다.
출처 : Patterns of Enterprise Application Architecture

[3] 처리량을 극대화하기 위해 최신 트랜잭션 시스템은 트랜잭션을 최대한 짧게 유지하도록 설계된다. 따라서 여러 요청에 걸친 트랜잭션을 만들지 말아야 한다. - Patterns of Enterprise Application Architecture

[4] JSON의 경우 데이터 구조가 중첩 될 수 있으며 가변적이기 때문이다.

참고 자료


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