개요

분산 환경에서 혹은 트랜잭션 경합이 심한 업무 환경에서 사용할수 있는 논리적인 트랜잭션 기법을 소개합니다.

트랜잭션 처리시 고려사항

분산 환경에서 느슨한 트랜잭션 처리나 어떤 예약 시스템에서 재고 수량을 동시에 수정해야 할 때, DBMS 내의 일반적인 트랜잭션 처리 방법은 어려울 수 있습니다.
트랜잭션의 시작과 끝은 변경된 데이터의 락(lock)을 의미하며, 여러 트랜잭션이 동시에 동일한 데이터를 변경하려고 할 때 한 트랜잭션이 대기해야 할 수밖에 없습니다.
그래서 트랜잭션 처리를 빠르게 하려면 SQL을 튜닝하거나 트랜잭션 단위를 더 작게 쪼개어 실행합니다.

애플리케이션 성능이 나오지 않는 경우

  • 동시에 동일한 데이터를 변경하려는 로직이 있는 경우 (데이터 경합으로 인한 락 발생)
  • 트랜잭션에 애플리케이션 로직이 포함된 경우 (락 점유 시간을 지연시킴)
  • 사용자 수가 급격히 증가하는 환경 (락 발생 가능성을 높임)

트랜잭션 관리가 어려운 업무 예시

트랜잭션 처리가 어려운 사례에 대해서 알아보겠습니다. 동일한 열을 수정하는 트랜잭션이나 오래 걸리는 경우가 있을 수 있습니다.

업무예시 #1 - 티켓 예약시스템

티켓 예약 시스템을 가정해봅시다. 핵심은 재고 관리입니다. 애플리케이션 처리 방법은 알 수 없지만, 데이터베이스 관점에서 여러 트랜잭션이 동시에 특정 행의 특정 열을 변경하려고 할 것입니다.
이런 경우 빠르게 처리해도 락을 회피하기 어려워 보입니다.

  • 예상 되는 로직
    1. 먼저 재고 수량을 확인합니다. (재고 수량이 변경되면 안 되므로 해당 상품의 재고 수량에 락을 점유(SELECT FOR UPDATE)하여 데이터 변경을 방지합니다.)
    2. 재고 수량에 주문한 수량을 반영합니다. (예: 재고가 100개이고 주문이 5개 들어오면, UPDATE SET Qty = Qty-5로 반영합니다.)
    3. 결제 서비스와 연동하여 완료되면 트랜잭션을 완료하기 위해 Commit을 합니다.

여러 트랜잭션이 동시에 같은 상품에 주문하면 빠르게 처리될까요? 재고 수량의 일관성을 유지하기 위해 여러 주문이 동시에 들어와도 처음 락을 선점한 주문이 실제 처리될 것입니다.

업무예시 #2 - MSA환경에서 트랜잭션처리

MSA 환경에서 트랜잭션 처리를 위해 SAGA 패턴을 사용했습니다. 주문 업무는 내부적으로 재고 서비스, 배송 서비스, 결제 서비스와 연결되어 있습니다.

  • 예상되는 로직
    1. 먼저 재고 서비스에서 재고 수량을 확인합니다.
    2. 재고가 있으면 주문한 수량만큼 차감합니다.
    3. 결제 서비스에 결제 요청을 합니다.
    4. 결제가 완료되면 모든 트랜잭션을 완료 처리하고 배송 서비스에 배송 요청을 합니다.

그러나 갑자기 결제 서비스에서 문제가 발생했습니다. 결제 요청 중에 Fail이 발생했습니다. 이 경우 주문 프로세스 중에 결제 서비스에서 문제가 생겼으므로 재고 서비스에 주문 수량을 취소해 달라고 보상 로직이 실행됩니다.

분산 환경에서는 각 서비스가 분리되어 있기 때문에 하나의 논리적인 트랜잭션으로 처리가 어려울 수 있습니다. 그 중 하나의 서비스에서 문제가 발생하면 이미 처리한 트랜잭션을 보상해주는 로직이 필요합니다.

ESCROW 트랜잭션 처리란?

앞서 언급한 두 가지 업무에서 트랜잭션 처리에 대해 고민 중입니다. DBMS로는 처리가 어려웠을 경우, 아마도 애플리케이션 레벨에서 로직을 활용하여 해결하셨을 것입니다.
이와 같은 상황에서 DB 내에서 로직을 활용하여 트랜잭션을 처리하는 방법을 소개하고자 합니다.

ESCROW(에스크로) 개념을 활용하여 트랜잭션을 처리하는 로직을 만들 수 있습니다.

ESCROW는 거래를 완료하는 과정에 있는 다른 두 당사자를 대신하여 제3자가 자산이나 금전을 보유하는 금융 계약을 설명하는 법적 개념입니다.(위키피디아 발췌)

ESCROW 개념을 활용하면 Lock을 회피하면서 트랜잭션을 처리할 수 있는 로직을 설계할 수 있습니다.
여러 트랜잭션에서 동일한 데이터 변경이 요청될 경우, 내부적으로 트랜잭션 목록(제3의 테이블=journal 테이블)을 관리하여 트랜잭션이 완료되기 전까지 실제 데이터를 변경하지 않습니다.
데이터가 변경되지 않았다는 의미는 Lock을 점유하지 않는다는 것이며, 따라서 여러 트랜잭션이 동시에 실행되어도 다른 트랜잭션 상태와 관계없이 처리가 가능하다는 것을 의미합니다.

트랜잭션 처리 방법 비교

전통적인 트랜잭션 처리 방법과 ESCROW로 명명한 트랜잭션 처리 기법을 비교해보겠습니다.

  • 전통적인 트랜잭션 처리 방법
    • 설명: 트랜잭션이 완료되기 전까지는 다른 트랜잭션에서 해당 컬럼의 값을 변경할 수 없고 대기해야 합니다.
    • 동작 방식: UPDATE 구문이 실행되면 해당 row에 Lock을 점유합니다. 변경된 값으로 업데이트하고 변경 전의 정보는 undo에 저장됩니다. 이 방식은 DBMS에 의해 동작하는 일반적인 방식으로, 트랜잭션이 완료될 때까지 계속해서 Lock을 유지합니다. 다른 트랜잭션이 동일한 row 값을 변경하려면 Lock을 기다려야 합니다.
    • 고려 사항: 트랜잭션이 길게 유지될 경우 문제가 될 수 있습니다. 동일한 row를 업데이트하는 트랜잭션이 발생하면 해당 트랜잭션은 Lock이 발생하여 업무 처리가 원활하지 않을 수 있습니다. 대부분의 일반적인 상황에서는 동일한 row를 변경하지 않도록 업무에서 제어합니다.
  • ESCROW 트랜잭션 처리 방법
    • 설명: 동일한 row의 특정 컬럼에 동시에 여러 사용자가 변경 가능합니다 (Lock 없음).
    • 동작 방식: UPDATE 구문이 실행되면 해당 row에 Lock을 점유하고, 해당 row에 값을 변경하는 것이 아니라 journal 테이블에 변경 요청한 데이터를 추가합니다. 해당 row에 값을 변경하지 않으므로 Lock을 점유하지 않습니다. 다른 트랜잭션에서는 동일한 row를 변경해도 Lock을 점유하지 않고 작동합니다. 트랜잭션이 커밋되는 순간에 해당 row에 데이터가 반영됩니다.
    • 고려 사항: 트랜잭션을 오래 유지해도 Lock 경합이 발생하지 않습니다. 그러나 특정 row의 값이 변경될 수 있는 범위를 지정해야 합니다 (예를 들어, 티켓 예약 시 재고가 없으면 안 되는 업무가 있기 때문입니다). 컬럼의 제약 조건을 파악할 때, journal 테이블에 있는 데이터와 현재 row의 데이터를 합산하여 계산하도록 로직을 추가해야 합니다. 전통적인 트랜잭션 처리에 비해 내부에서 처리하는 SQL의 개수가 많아지므로 (journal 테이블 관리, 현재 값을 계산하는 로직 실행) 단일 트랜잭션 속도는 저하될 수 있습니다.

ESCROW 트랜잭션 처리 방식에서는 데이터를 곧바로 수정하는 것이 아니라 트랜잭션 목록에서 수정될 값들을 관리하고, 잠재적으로 처리될 것으로 가정합니다.
단일 트랜잭션 처리 속도는 전통적인 트랜잭션 처리방법에 비해서 느릴수 있지만 동일한 데이터를 변경하려는 업무에는 Lock을 최소화하여 처리하므로 트랜잭션 처리량을 높일 수 있는 기법으로 이해하시면 좋습니다.

ESCROW 트랜잭션 처리시 고려사항

ESCROW는 트랜잭션 처리를 내부 테이블에서 관리된다고 이전에 언급했습니다. 내부 테이블에 트랜잭션 정보를 저장할 때는 특정 컬럼의 상대값으로 저장해야 합니다.
이에 따라 아래와 같은 제약사항을 만들고 관리하는 것이 좋습니다.

  • 동시에 변경 가능한 데이터 형식은 숫자여야 합니다. (journal 테이블에 이력을 저장하고 현재 값을 계산하기 위해 숫자 형식이어야 합니다. aggregate 함수를 사용하여 합산할 수 있기 때문입니다.)
  • UPDATE 문을 요청할 때는 SET qty = qty - 1과 같이 구문이 작성되어야 합니다. (journal 테이블에 데이터를 넣을때 -1 과 같은 상대값을 저장할수 있기 때문입니다. )

ESCROW 트랜잭션 처리 구현 방안

ESCROW 트랜잭션 처리의 동작 방식을 간락히 설명합니다.

  • 사전작업
    • 어떤 테이블의 어떤 컬럼을 Escrow로 처리할지 지정합니다. 이에 더해 해당 컬럼값의 범위를 지정합니다. (최대값 혹은 최소값을 지정합니다)
    • 이후에 테이블에 트리거(Trigger)를 생성합니다.
      • 로직 #1: 해당 컬럼의 한계에 도달했는지 확인합니다. (Journal 테이블의 데이터와 실제 데이터를 합산하여 한계값에 도달했는지 확인하는 로직을 추가합니다)
        • 한계값에 도달하면 업데이트는 실패로 에러를 발생시킵니다 (예외 처리).
      • 로직 #2: 업데이트가 수행되면 해당 컬럼의 값을 곧바로 변경하는 것이 아니라, Journal 테이블에 변경의 상대값을 저장하도록 설정합니다 (+10, -10 등으로 상대적인 값을 저장합니다).
  • 트랜잭션 실행
    • 업데이트 SQL 문장이 실행되어 완료되면, 일단 해당 컬럼의 일부분을 선점한 것으로 간주됩니다. 이후에 업무에 필요한 다른 로직을 수행하면 됩니다. (여러 개의 업데이트 구문이 동시에 수행되어도 동시에 처리 가능합니다).
  • 트랜잭션 완료(Commit)
    • Journal 테이블의 변경 정보를 원본 데이터에 실제로 값을 반영하고, Journal 테이블에 변경 정보를 삭제합니다.

오라클 23c에 추가된 Lock-Free Reservation 기능

이런 방식이 구현하기 어려우시겠죠?

하지만 오라클 데이터베이스에서는 이러한 처리를 가능하게 해주는 Lock Free Reservation 기능이 있습니다. 이 기능은 23c에서 새로 추가되었습니다.

이는 분산환경에서 동일한 로우의 컬럼 값을 여러 트랜잭션에서 동시에 변경하기 위해 만들어진 기능입니다. “ESCROW 트랜잭션 처리 구현 방안”과 같이 복잡한 구성이 필요하지 않습니다. 컬럼에 RESERABLE 속성을 지정하기만 하면 자동으로 ESCROW 방법으로 트랜잭션이 처리됩니다.

ALTER TABLE ticketsales MODIFY qty number RESERVABLE CONSTRAINT MINIMUM_CAPACITY CHECK (qty >= 10)

마무리

트랜잭션 중에 발생해서는 안 되는 상황 중 하나는 Deadlock입니다.(실패 하는 업무가 발생하기 때문입니다. ) 트랜잭션 처리 시에 가장 중요한 고려사항은 Lock을 점유하는 시간입니다. (다른 업무에 영향을 주기 때문입니다. )

트랜잭션 단위가 너무 길어서 불필요하게 Lock을 점유하고 있는지 업무 로직을 구현할 때 함께 고려하시는 것이 좋습니다. 업무의 특성에 따라 다양한 아이디어로 트랜잭션을 설계할 수 있습니다. 또한, 오라클 데이터베이스에서는 ESCROW 개념을 활용한 트랜잭션 기능을 제공하고 있으니 관심이 있다면 테스트 후 활용하시면 좋겠습니다.

댓글남기기