본문 바로가기

끄적끄적

[끄적끄적] 주문 Saga Isolation 부족 해결하기

Saga는 트랜잭션의 ACID특징중 Isolation을 보장하지 못하는것으로 알려져 있습니다.

실제로, Isolation이 부족하기 때문에 여러 문제가 발생할 수 있는데요.

이번 글 에서는 Isolation이 보장되지 않으면 발생할 수 있는 문제와, DAU 1500명 가량의 Gitanimals 의 주문 시스템에서는 어떤방식으로 해결했는지 작성해보도록 하겠습니다.

 

Isolation이 보장되지 않으면 발생할 수 있는 문제

Saga는 각 참여자들이 독립적으로 데이터를 커밋하고 Saga가 실패했을때 롤백 메시지를 받아 롤백을 진행합니다.

따라서, 전체 Saga가 끝나지 않았을때 Saga 참여자들의 각 트랜잭션은 이미 커밋 되었으므로, 데이터 변경이 외부에 노출되고, 다른 트랜잭션에서는 변경된, 하지만 롤백될지도 모르는 불완전한 데이터를 참조하게 됩니다.

 

Saga - Orchestration

 

이렇게, 언제든지 롤백될 수 있는 불완전한 데이터를 읽게되면 연쇄복귀, 부정합, 갱신손실 등의 문제가 발생할 수 있습니다. 또한 아래 그림과 같은 더욱 미묘한..? 문제도 발생할 수 있는데요. 

 

 

"상품 판매자 - 1" :  상품의 판매자는 자신의 상품을 상점에 등록합니다. 이때, 실수로 0을 하나 덜 입력해서 1,000원으로 상품을 올립니다.

이때, 이미 트랜잭션이 커밋되었기 때문에, 상품은 1,000원인 상태로 다른 트랜잭션에 노출됩니다.

 

"상품 구매자 - 1" : 상품의 구매자는 1,000원으로 올라온 상품을 확인하고 빠르게 구매를 시작합니다. 주문은 SUCCESS 상태로 변경되었고, 결제 서버로 요청을 전달해 다음 Saga를 진행합니다. 

 

"상품 판매자 - 2" : 그 사이, 판매자는 상품의 가격을 10,000으로 업데이트 합니다.

 

"상품 구매자 - 2" : 결제 서버는 결제를 진행하려고 하는데, 이때, 상품 가격은 10,000원으로 업데이트 되어있습니다. 따라서, 10,000원을 결제합니다.

 

"상품 구매자 - 3" : 상품을 전달합니다. 이때, 사용자는 영수증에 1,000원이 아닌 10,000원이 결제된것을 확인합니다. (

 

만약, 상품 구매자 트랜잭션이 Isolation을 보장했다면, 상품 구매자는 1,000원으로 물건을 구매할 수 있었을 것 입니다.

 

Isolation 부족 해결하기

실제로, Gitanimals에서도 똑같은 문제가 발생했는데요, 이러한 문제를 해결할 수 있는 방법은 아래와 같습니다.

 

1. 분산락 활용

분산락을 활용해서 Saga를 시작하기 전에 특정 id에 락을 획득한 Saga만 실행될 수 있도록 설계할 수 있습니다. 락을 획득하지 못한 Saga는 이미 락을 획득한 Saga가 락을 release 할때까지 대기합니다.

이 방식은 어떤 Saga도 동시에 실행할 수 없도록 막기때문에 최고로 고립되지만, 성능에 악영향을 미칠 수 있습니다. 또한, Lock을 획득한 Saga가 실행중 네트워크 지연등으로 느려지거나 다운되면, Lock을 기다리는 Saga는 무한정 대기하게 됩니다.

 

2. Saga 순서 조정

기존의 Saga가 1.주문 -> 2.결제 -> 3.재고차감 과 같이 되었다면, 1.재고차감 -> 2. 결제 -> 3. 주문 과 같이 Saga의 순서를 조정해서 회피하는 방법입니다. 모든 상황에 사용할수는 없고, 순서 조정으로 해결되는 상황에서만 사용할 수 있습니다. 

 

3. 상태 활용

Saga 과정중 변경되는 Data들에 상태를 부여하고, Saga가 종료 되었을때, 상태를 업데이트 해주는 방식입니다.

예를들어, 주문이 시작되자마자 Product의 상태를 SOLD_OUT으로 변경하는것이 아니라, Product 도메인의 상태를 ON_SALE에서 WAIT_SOLD_OUT으로 변경하고, Saga가 끝났을때, Product 도메인의 상태를 SOLD_OUT으로 변경시켜 주는 방식입니다.

만약, 판매자가 물건의 상태를 변경하려고 하는데, 상품이 WAIT_SOLD_OUT(누군가가 구매하는 중)이라면, 상태 변경을 취소하는등의 합의를 할 수 있습니다.

이 방식의 경우 상태가 많아지면 관리하기 힘들고 휴먼에러가 발생할 수 있습니다. Gitanimals 주문 시스템의 경우, 상태가 적고 상태에 따른 합의 알고리즘을 적용할 예정이라 이 방식을 응용해서 적용 했습니다.

 

구현

우선, 주문로직이 진행되는 과정에서 Product 도메인의 상태를 그려보면 아래와 같습니다.

주문 로직중 Product domain의 상태 변경

 

또한, 위 상태에 아래 3가지 조건을 추가해줬는데요, (구현 에서 설명되어 있지만, 갱신 손실 문제는 낙관적락을 이용했습니다.)

1. Product가 ON_SALE이 아니라면, WAIT_SOLD_OUT이 될 수 없다.

2. Product가 SOLD_OUT(판매된) 상태가 되기위해선, Product의 현재 상태가 WAIT_SOLD_OUT 상태여야 한다.

3. 어떠한 이유로 상품의 정보가 변경된다면, 무조건, ON_SALE 상태로 변경된다.

 

이 3가지 조건으로 인해서, 상품의 가격이 중간에 변경된다면 사용자의 트랜잭션은 롤백되게 됩니다. 따라서, 사용자는 변경된 상품의 가격을 다시 확인하고 주문할 수 있게 되어요. (판매자에게 우선권을 준 합의 방식입니다. 중간에 가격이 변경되서 주문을 실패할 경우 구매자는  변경된 가격을 다시한번 확인후 재주문 하면 되니까요.)

 

여기까지 읽으면 이해가 잘 안될 수 있는데요, 위에서 예시로 들었던 "1,000원이였던 상품의 가격이 주문 중간에 10,000 으로 변경되는 Saga" 에 상태를 추가해서 다시 그려보겠습니다.

 

(Gitanimals에서 중복된 상품은 존재할 수 없어서, 상품의 수량은 항상 1개입니다.)

 

위 그림을 보면 상품에 WAIT_SOLD_OUT 상태를 추가함으로써, Saga 중간에 다른 Saga가 끼어들었는지 판단할 수 있게 되었고, 다른 Saga가 끼어듦이 감지되면 롤백하게 구현함으로서, 사용자는 자신이 처음으로 확인한 금액과 똑같은 금액을 결제 할 수 있게 되었습니다. (롤백되면 가격을 다시 확인하고 결제하면 되니까요.)

 

아래부터는 실제 구현에 대한 설명입니다.

스프링 부트 3.2를 사용하고있고 jpa와 kotlin을 이용해서 구현했습니다.

Saga는 Redis-stream을 사용하기위해, Netx 프레임워크를 사용했습니다.

 

우선, Product 도메인에 ProductState를 만들어 줍니다.

이때, 갱신손실 문제등을 방지하기 위해서 낙관적락을 걸어주었습니다.

 

Product domain

 

ProductState에는 설명했던 ON_SALE, WAIT_SOLD_OUT, SOLD_OUT 상태를 만들어 줬습니다.

 

ProductState

 

 

Product class의 waitBuy와 buy 메소드를 보면 로직을 수행하기 전에 ProductState의 상태를 먼저 검사하는것을 볼 수 있습니다. 

아래서 보겠지만, waitBuy 메소드는 주문 Saga가 실행될때 호출되며, Product를 WAIT_SOLD_OUT상태로 변경합니다.

buy 메소드는 주문 Saga가 commit될때 호출되며, Product의 상태가 WAIT_SOLD_OUT 이라면, Product을 SOLD_OUT으로 변경하며, 아니라면 예외를 던집니다. 

 

(이때, 낙관적락을 사용하고 있기 때문에 중간에 다른 트랜잭션이 Product의 상태를 변경하면 버전이 올라가고 재시도 후 예외를 던집니다.)

 

Product class

 

 

Product 도메인을 사용하는 ProductService 클래스는 아래와 같습니다. Product를 조회 후 상태변경만 해줍니다. (이때 낙관적락을 사용하고 있기 때문에, Retry를 해줘야해요.)

 

ProductService class

 

다음은 가장 중요한 Saga를 진행하는 layer인데요, 저는 오케스트레이션 방식을 이용해서 구현했습니다.

코드가 약간 긴데, 전체 코드를 보여드리고, 블록으로 끊어서 설명해드리겠습니다. 주석을 달아놨으니, 코드로 이해하셔도 무방합니다.

 

(Orchestrator 안에있는 context는 Saga 전체에서 유지되는 데이터를 저장하는 저장소로 생각하시면 됩니다.)

 

package org.gitanimals.auction.app

import org.gitanimals.auction.domain.Product
import org.gitanimals.auction.domain.ProductService
import org.rooftop.netx.api.Orchestrator
import org.rooftop.netx.api.OrchestratorFactory
import org.springframework.stereotype.Service
import java.util.*

@Service
class BuyProductFacade(
    private val renderApi: RenderApi,
    private val identityApi: IdentityApi,
    private val productService: ProductService,
    orchestratorFactory: OrchestratorFactory,
) {

    private lateinit var orchestrator: Orchestrator<Long, Product>

    fun buyProduct(token: String, productId: Long): Product {
    	// orchestrator에 saga를 호출합니다.
        return orchestrator.sagaSync(
            productId,
            mapOf( // 이거는 하나의 Saga에서 전체적으로 유지되는 context에 값을 넣어주는 용도로 사용됩니다.
                "token" to token, // 구매자의 token과
                "idempotencyKey" to UUID.randomUUID().toString() // 멱등키를 넣어주고 있습니다.
            )
        ).decodeResultOrThrow(Product::class)
    }

    init {
        this.orchestrator = orchestratorFactory.create<Long>("buy product orchestrator")
            .startWithContext(
                contextOrchestrate = { context, productId -> // Saga를 시작합니다.
                    val buyer = identityApi.getUserByToken(context.decodeContext("token", String::class))
                    val product = productService.getProductById(productId)

					// 구매자의 포인트를 검증하고, 구매할 수 있다면 다음 로직을 수행합니다.
                    require(product.getPrice() <= buyer.points.toLong()) {
                        "Cannot buy product cause buyer does not have enough point \"${product.getPrice()}\" >= \"${buyer.points}\""
                    }

					// 여기서 Product의 상태를 WAIT_SOLD_OUT으로 변경합니다.
                    productService.waitBuyProduct(productId, buyer.id.toLong())
                },
                contextRollback = { _, productId -> productService.rollbackProduct(productId) } // Saga가 실패했을때, Product의 상태를 다시 ON_SALE로 변경하기 위해서, rollback 블록에 로직을 넣어줬습니다. 아래 join혹은 commit에서 예외가 던져지면 아래서부터 순차적으로 rollback 블록이 반드시 호출됩니다.
            )
            .joinWithContext(
                contextOrchestrate = { context, product ->
                    val token = context.decodeContext("token", String::class)
                    val idempotencyKey = context.decodeContext("idempotencyKey", String::class)
					
                    // 유저의 포인트를 차감합니다. 중복 차감을 방지하기 위해서 멱등키를 활용합니다.
                    identityApi.decreasePoint(token, idempotencyKey, product.getPrice().toString())

                    product // 다음 블록에서도 사용하기 위해, product를 전달해줍니다.
                },
                contextRollback = { context, product -> // 마찬가지로 Saga가 rollback 되었을때, 차감된 포인트를 재지급 하기 위해서 rollback 블록에 로직을 넣어줬습니다.
                    val token = context.decodeContext("token", String::class)
                    val idempotencyKey = context.decodeContext("idempotencyKey", String::class)

					// 여기서 중복 지급으  방지하기 위해서 멱등키를 활용합니다. 
                    identityApi.increasePoint(token, idempotencyKey, product.getPrice().toString())
                }
            )
            .joinWithContext(
                contextOrchestrate = { context, product ->
                    val token = context.decodeContext("token", String::class)
                    val idempotencyKey = context.decodeContext("idempotencyKey", String::class)

					// 유저에게 펫을 지급합니다. gitanimals에서는 render server의 유저 데이터에 펫을 넣어주면됩니다. 마찬가지로, 중복 지급을 방지하기위해서 멱등키를 활용하고 있습니다.
                    renderApi.addPersona(
                        token,
                        idempotencyKey,
                        product.persona.personaId,
                        product.persona.personaLevel,
                        product.persona.personaType,
                    )

                    product
                },
                contextRollback = { context, product -> // 마찬가지로 하위 블록에서 예외가 던져지면 롤백을 시켜줍니다. 
                    val token = context.decodeContext("token", String::class)
					
                    // 여기서는 지급한 펫을 다시 삭제합니다. 
                    renderApi.deletePersonaById(token, product.persona.personaId)
                }
            ).commit { product -> productService.buyProduct(product.id) } // Saga를 종료합니다. 이때, Product의 상태를 SOLD_OUT으로 변경합니다. 만약 실패하면 예외가 던져지며, 이 위 블록의 rollback부터 순차적으로 아래서부터 위로 올라가며 호출합니다.
    }
}

 

우선, Saga를 시작하는 부분입니다.

(BuyProductFacade -> ProductService 호출)

 

BuyProductFacade

 

로컬 트랜잭션 밖으로 분리하기 위해서, Facade클래스를 새로 만들어 주었구요, Saga를 시작하며, context(하나의 saga에서 유지되는 데이터 저장소)에 유저의 token과 멱등키(중복 결제 방지를 위해서..)를 넣어줍니다.

 

멱등키에 대한 내용이 궁금하시다면 이 포스팅을 참고해주세요.

 

orhcestrator 구현은 아래와 같습니다.

 

우선, orchestrator 시작부분에서 구매자가 충분한 돈을 갖고있는지 검증하고, 갖고있다면 product를 WAIT_SOLD_OUT 상태로 변경합니다. 

Saga가 실패할경우, product를 ON_SALE 상태로 롤백해주기 위해서, rollback(contextRollback 부분) 블록에 롤백시 호출될 로직을 구현해줬습니다. 

 

Start saga

 

다음은 Saga에 join하는 부분입니다.

orchestrate블록 에서는 identity 서버에 유저의 포인트 차감을 요청하고, rollback 블록에서는 saga가 실패했을때 포인트를 다시 지급하기 위해서, 차감된 포인트를 증가시킵니다. 이때, 포인트 중복 지급 혹은 포인트 중복 차감을 방지하기 위해서, 멱등키를 활용했습니다.

 

Join saga

 

그 다음 join 블록에서는 유저에게 pet을 지급합니다. (gitanimals같은 경우는 render server의 유저정보에 pet을 넣어주면 됩니다.)

Saga가 실패했을경우, 유저에게 지급된 pet을 다시 뺏는 로직을 rollback 블록에 넣어줬습니다.

 

Join saga - 2

 

마지막으로, commit을 호출하며 saga를 종료합니다.

여기서 WAIT_SOLD_OUT 상태가 아니라면 예외가 던져지고 이 위 블록의 rollback부터 모든 rollback 블록을 순차적으로 반드시 호출합니다.

commit saga

 

 

실제로 잘 동작하는지 테스트해본결과 모든 테스트가 성공하는것을 볼 수 있습니다.

 

 

테스트 코드는 여기서 확인할 수 있습니다.

 

마치며

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

 

실제 코드는 여기서 확인할 수 있습니다.

사용한 Saga 프레임워크는 여기서 확인할 수 있습니다.

깃 애니몰즈 프로젝트는 아래에서 확인할 수 있습니다.

 

Render server

 

GitHub - devxb/gitanimals: 🦆 깃허브 활동으로 펫을 키우세요 / Have pet in your github

🦆 깃허브 활동으로 펫을 키우세요 / Have pet in your github. Contribute to devxb/gitanimals development by creating an account on GitHub.

github.com

Api server

 

GitHub - devxb/gitanimals-api: gitanimals-api server

gitanimals-api server. Contribute to devxb/gitanimals-api development by creating an account on GitHub.

github.com