티스토리 뷰

적은 리소스 적당한 효과

개발자는 종종 레거시 시스템이나 코드를 마주할 때가 있습니다. "레거시" 는 양면적이어서 잘 돌아가고 있는 시스템이면서도 앞으로의 요구사항 변경에 수용력이 부족해 보이는 시스템이기도 합니다. 이러한 레거시와의 공존은 필연적이면서도 효과적인 공존을 위한 부단한 아이디어들을 필요로 하기도 합니다.
보통 레거시 시스템을 대할 때면 이런 문장을 머리에 두고 생각합니다.

"적은 리소스만 들여서 적당한 효과를 얻을 방법이 없을까?"

항상 이런 접근만이 답이 되진 않지만 레거시 시스템은 우리에게 친절하지만은 않습니다. 조금 까탈스러운 제약이나 조건들을 가지고 있죠. 조금 지난 미션이지만 재미있게 해결했던 사례를 한번 소개하고자 합니다.

 

레거시 시스템 소개

우리 팀의 주요 역할은 주문 시스템의 개선 입니다. 그 와중에 재고를 다루기 위한 시스템이 주문과 엮여 의존적인 상태였습니다. 별도의 시스템을 갖추는 대신 "기능" 정도로 주문의 로직에 포함하고 있었는데 레거시 코드는 역할을 Stored Procedure를 사용하여 데이터베이스에서 수행토록 하고 있었습니다.
물론, 재고는 앞으로 더욱 주요한 기능이기에 사내에서는 이미 별도의 재고 시스템이 새로 개발을 거쳐 운영 중입니다. 하지만, 몇몇 새로운 재고 시스템으로 통합이 어려운 재고 관리를 위한 레거시 기능이 남아 있는 것 입니다.
재고는 앞으로 새 재고 시스템으로 점점 통합할 것이지만, 현재로서는 여전히 꽤 많은 재고 관리를 기존 레거시 코드로 운영할 필요가 있었습니다.

그런데 기존 로직은 몇 가지 고질적인 문제가 있었고 또한 트래픽에 병목지점으로 지적받고 있기도 하였습니다. 당장 진행을 앞둔 여러 비즈니스적 포인트들에서 높은 수익을 기대하기 위해서라도 이 기능에 대한 개선이 필요하지만 신규 시스템으로 통합을 진행하는 과정에서 많은 리소스를 들이기에는 적당하지 않은 상황이었습니다.
따라서, 아이디어가 필요했습니다. 가끔은 이런 미션이 의외의 재미를 만들어주기도 합니다. 해결할 상황은 다음과 같습니다.

  • 단일 테이블에서 재고를 관리한다.
  • 특정 상품의 이벤트는 동일 상품의 재고에 높은 트랜잭션 경쟁을 요구한다.
  • 재고 이하로 물건을 팔아서는 안된다.
  • 같이 주문한 여러 상품 중 한 상품이라도 재고가 부족하면 주문을 멈추고 재고를 원상복구 한다.

겪고 있던 상황은 몇 가지 문제들을 가지고 있었습니다.

 

쓰기 스큐

먼저 기존 로직을 단순화하여 소개하겠습니다.

재고를 차감할지를 결정하기 위해서는 주문에 포함한 모든 상품에 구매할 만큼의 재고가 남아 있는지를 파악해야 합니다. 다음과 같은 단순한 SELECT 문이면 (물론 단건으로 조회하진 않겠지만) 현재 재고를 확인할 수 있습니다.

SELECT ITEM_NO, REMAIN
  FROM STOCK
 WHERE ITEM_NO = @item_no

이제 애플리케이션에서 구매하려는 수량만큼을 모두 재고가 충분한지 검사한 후 만약 그렇지 못하다면 재고 차감을 멈추고, 충분하다면 역시 다음과 같이 UPDATE 하여 재고를 차감합니다.

UPDATE STOCK
   SET REMAIN = REMAIN - @amount
 WHERE ITEM_NO = @item_no

조금 단순하게 바꿨지만 뼈대는 유사한 로직입니다. 이 방식은 "재고 이하로 물건을 팔아서는 안된다."와 "같이 주문한 여러 상품 중 한 상품이라도 재고가 부족하면 주문을 멈추고 재고를 원상복구 한다." 도 수행합니다.
아니 수행할 것으로 생각했지만 사실 그렇지 않았습니다. 경합이 높지 않을 때는 잘 드러나지 않았지만 가끔 몰려드는 이벤트 상황에서 재고를 마이너스까지 차감하곤 하였습니다. 왜일까요?

이는 생각보다는 쉽게 추론이 가능합니다.

예를 들어 재고가 5개 남은 상품을 두 구매자가 구매하려고 합니다. A 구매자는 3개, B 구매자도 3개를 주문합니다. 매우 동시에 도착한 이 두 요청은 먼저 모두 재고를 조회합니다. 데이터베이스는 친절히 모든 구매 수행에 모두 5개의 재고를 알려줍니다. 아직 아무도 재고를 차감하지 않았으니까요.
애플리케이션은 모든 구매의 재고 차감이 가능하다고 판단하고 각각의 수량만큼의 재고를 차감합니다. 앗! 재고는 이제 -1 만큼 남았네요.

이는 쓰기 스큐라고 합니다.

이 쓰기 스큐를 해결하는 아주 간편한 방법이 있습니다. 각 수행에 포함한 트랜잭션의 격리 수준REPEATABLE READ 이상으로 올리는 것입니다. "특정 상품의 이벤트는 동일 상품의 재고에 높은 트랜잭션 경쟁을 요구한다."만 없다면요.

 

Compare And Set

격리 수준은 정합성과 성능의 트레이드오프를 갖습니다. 더 정합적인 격리 수준은 성능을 낮춥니다. REPEATABLE READ는 쓰기 스큐를 해결해 주지만 이 방식으로는 비즈니스 디렉터 분들께 선착순 이벤트 같은 건 꿈도 꾸지 말라고 해야 합니다.
그렇다면 적어도 READ COMMITTED 격리 수준 정도에서 쓰기 스큐를 해결할 방법이 없을까요? 일단 쓰기 스큐 자체를 발생하지 않는 데이터 시스템이나 아키텍처를 쓰는 방법이 있습니다. 다행히 신규 재고 시스템에는 이런 방법들을 고민하고 있습니다.
우리의 레거시 시스템에는? 너무 많은 비용의 해법 또한 유용하지 않습니다. 적은 노력만으로 적당히 어떻게 안될까요?

이런 경우 사용 가능한 방법 중 하나는 CAS (Compare And Set) 입니다. 조건을 비교하는 행위와 상태를 변경하는 걸 원자적으로 수행하는 것을 말하는데, 쿼리로 보면 다음과 같은 구조를 말합니다.

UPDATE STOCK
   SET REMAIN = REMAIN - @amount
 WHERE ITEM_NO = @item_no
   AND REMAIN >= @amount;

도메인 지식이 쿼리에 흘러들어 간 것이 조금 걸리지만 이 쿼리는 "재고 이하로 물건을 팔아서는 안된다."는 조건을 만족하면서 쓰기 스큐를 일으키지 않습니다. 이 정도 노력이면 들여볼 만할 듯합니다.
그런데 한 가지 걸리는 문제가 있습니다. "같이 주문한 여러 상품 중 한 상품이라도 재고가 부족하면 주문을 멈추고 재고를 원상복구 한다."를 하기에는 위 쿼리는 재고가 부족한 경우를 보고하지 않는다는 것입니다.
단지 변경할 대상이 없을 뿐이지 재고가 부족하다는 정보를 애플리케이션에 제공하지 않습니다.

 

낙관적 트랜잭션 제어

조금 더 가보기로 했습니다. 트랜잭션은 애플리케이션이 아니라도 데이터베이스에서 그 제어가 가능합니다. 우리는 Stored Procedure를 사용하여 트랜잭션 제어를 포함하고도 높은 성능을 기대할 수 있는 방법을 찾아보기로 하였습니다.
먼저, 같은 주문에서 다뤄야 할 상품과 그 수량을 모두 파라미터로 준비합니다. 실제로는 파라미터를 전달하기 위한 일종의 serialization - deserialization 과정이 있지만 이 글에서는 생략하겠습니다.

DECLARE @parameter TABLE
(
    ITEM_NO  int,
    AMOUNT   int
);

이 임시 테이블에 파라미터를 잘 전달받기로 합니다. 우리의 CAS 쿼리는 다음과 같이 변경하면 여러 상품에 대한 재고 처리를 같이 수행하는 쿼리로 사용할 수 있습니다.

UPDATE s
   SET s.REMAIN = s.REMAIN - p.AMOUNT
  FROM STOCK s
  JOIN @parameter p
        ON s.ITEM_NO = p.ITEM_NO
       AND s.REMAIN >= p.AMOUNT;

이를 사용하기 위해 일종의 낙관적인 트랜잭션 제어를 다음과 같이 사용합니다.

SELECT @count = COUNT(*) FROM @parameter;

-- 트랜잭션 시작
BEGIN TRAN

-- 무언가를 수행

-- 수행의 결과 영향이 생긴 변경 수가 기대한 수와 동일한지 비교
IF @@ROWCOUNT < @count
    BEGIN
        -- 기대한 변경 보다 적다면 수행을 취소
        ROLLBACK TRAN
    END
ELSE
    BEGIN
        -- 기대와 맞다면 커밋
        COMMIT TRAN
    END

낙관적인 제어라고 소개한 이유는 버전 정보 같은 단조 증가하는 명확한 기준을 기반으로 판단하고 있지는 않지만 현재 로직에서 충분히 그 수행 충돌을 확인할 수 있는 방법을 통해 일단 수행한 후 결과를 검토해 커밋/롤백을 판단하는 과정이 낙관적 잠금의 배경과 유사하기 때문입니다.
CAS와 낙관적인 트랜잭션 제어를 활용하여 Stored Procedure를 만들고 나니 이제 드디어 원하는 모든 요구사항을 적당히 만족하면서 변경이 간단한 방법을 찾은 것 같습니다. 전체 쿼리는 다음과 같습니다. 물론 단순화한 버전입니다.

SELECT @count = COUNT(*) FROM @parameter;

BEGIN TRAN

UPDATE s
   SET s.REMAIN = s.REMAIN - p.AMOUNT
  FROM STOCK s
  JOIN @parameter p
        ON s.ITEM_NO = p.ITEM_NO
       AND s.REMAIN >= p.AMOUNT;

IF @@ROWCOUNT < @count
    BEGIN
        ROLLBACK TRAN
    END
ELSE
    BEGIN
        COMMIT TRAN
    END

 

정리

이 글에서는 비즈니스 로직의 일부와 트랜잭션 제어를 애플리케이션 코드에서 Stored Procedure 로 옮기는 트레이드 오프를 통해 정합성과 성능을 적당히 잡을 수 있는 방법을 소개하였습니다. 비지니스 로직을 어플리케이션 코드 밖으로 유출하는 것은 조금 아쉽지만 얻을 수 있는 효과는 충분히 아쉬움을 상쇄할 수 있었습니다.
Compare And Set과 낙관적인 트랜잭션 제어는 쓰기 스큐를 방지하면서도 복잡한 비즈니스 요건을 포함하는 트랜잭션 설계를 성능을 많이 양보하지 않고도 달성할 수 있는 방법이었습니다.

개발자는 항상 해결할 문제 앞에 섭니다. 우리가 풀 문제는 항상 제약과 조건을 꼬리표로 달고 있죠. 특히 레거시라는 꼬리표가 잘 붙습니다. 그래서 한편으로는 이런 미션들은 기특한 아이디어를 필요로 하기도 합니다. 그중 하나가 Linger 로 오버헤드 줄이기 로 소개한 방법이기도 합니다.
소개한 방법도 레거시 재고 관리를 위한 아이디어였습니다. 우리는 새로운 재고 관리 시스템이 통합 수준을 충분히 높일 때까지 시간을 벌어줄 아이디어가 필요했으니까요.

긴 글 읽어주셔서 감사합니다.

Reference

  • 데이터 중심 애플리케이션 설계 신뢰할 수 있고 확장 가능하며 유지 보수하기 쉬운 시스템을 지탱하는 핵심 아이디어 (마틴 클레프만 / 2018.04.12)
댓글