home

Db 동시 수정 피하기

문제상황

  • 송금과 관련된 민감한 데이터의 동시성을 제어하고 일관성을 유지해야하는 요구사항이 생겼다.
transfer_id | transfer_status
-------------|----------------
1            | PENDING
  • transfer 이라는 테이블의 transfer_status의 상태에 따라서 송금 여부가 결정된다고 가정하자.
  • 만약 동일한 DB row에 대해서 여러 트랜잭션이 수정을 시도하는 일이 빈번하다면 이런 문제가 발생한다.
    • 트랜잭션 A: 송금을 처리하는 트랜잭션으로 transfer_id = 1의 transfer_status가 PENDING인 경우에 송금을 수행
    • 트랜잭션 B: 동시에 실행되는 또 다른 송금 처리 트랜잭션으로 동일한 transfer_id = 1의 송금 상태를 확인하고 송금을 수행
    • 트랜잭션 A가 transfer_id 1의 transfer_status을 조회하여 pending임을 확인하고 송금을 진행하는데, 그 사이에 트랜잭션 B가 transfer_id 1의 데이터를 조회한다.
    • 하지만 송금이 진행 중이라 여전히 transfer_id 1의 상태는 pending이고, 때문에 트랜잭션 A가 완료되기 전에 B도 송금을 수행한다.
    • 결국 중복으로 처리되어 받는 이는 돈을 두번 받게된다!
  • 이런 상황에서 동시성 제어를 위해 낙관적 락과 비관적 락이 존재하는데, 나의 경우는 실제 송금이 이루어지는 로직에 사용되는 테이블에 대한 락을 걸어야 했기에 조금더 보수적인 비관적 락을 선택하였다.
  • 그중에서도 행 수준 잠금을 택했는데 실제 테이블에는 transfer_status 말고도 이와함께 결정되는 다른 테이블들이 많았기 때문이다.
    • 가정을 하자면 만약 환불과 관련된 송금 건이라면 transfer_status가 변경되고 동시에 같은 row의 refund_status 도 한 트랜잭션에 변경되어야 한다.
    • refund_status만을 조회하는 다른 트랜잭션이 존재할 수 있기에, row 단위로 한 트랜잭션에서 데이터가 일관성있게 변경되어야 했다.

내가 문제를 해결한 방법

  • 행 수준 잠금은 DB 수준에서 쉽게 구현이 가능한데 바로 SELECT FOR이다.
  • 레디스를 활용한 방법도 있지만, 딱히 분산환경도 아니고 조금 성능이 떨어지더라도 확실한 선점을 하기 위해서다.

select for update

  • 행 단위로 동시 접근을 제어한다.
  • 먼저 락을 얻은 트랜잭션은 트랜잭션이 끝날 때까지 해당 행을 선점한다.
  • 락을 얻지 못한 트랜잭션은 락을 얻은 트랜잭션이 끝날 때(commit) 까지 대기한다.

내가 구현한 방법

  • 우선 송금을 위해 pending 중인 transfer 데이터를 불러올 때 select for을 사용한다.
.where(
    Transfer.transfer_status == TransferStatus.PENDING
    ).with_for_update()
  • 기존 송금 로직에서는 transfer_statuspending인 것을 success로 변경해주고 실제 송금을 진행한다. 앞선 코드에서 with_for_update()를 통해 pending인 transfer 데이터를 선점하고 로직을 수행한다.
  • 만약 한 로직에서 transfer_statuspending인 transfer 데이터를 불러오고, 같은 row에 환불을 위한 특정 컬럼을 업데이트 한 뒤 SQS나 SNS로 메시지를 보내야하는 요구사항이 있다고 가정하자.
  • 만약 송금 로직에서 row를 선점하고 있을 때, 위의 환불 로직에 의해 새로운 트랜잭션이 해당 row에 접근을 한다.
  • 환불 로직은 WHERE 조건에서 transfer_statuspending인 데이터만 불러오고, 다른 컬럼의 업데이트를 성공한 데이터만 메시지를 보내야하기 때문에 update시 rowcount를 검사한다.
def update_refund(self, dto):
        query = text("""
        UPDATE
            transfer       
        SET
            refund_status = 'pending',
            refund_amount = :refund_amount
        WHERE
            transfer_id = :transfer_id
            AND status = "pending"
        """).bindparams(**dto.model_dumps())
        result = self._db.execute(query)
        return result

result = uow.repo.update_refund(dto)
if result.rowcount > 0:
    event_data = SqsEvent(
        #...
    )
    try:
        send_to_sqs(event_data)
  • 이렇게 다른 트랜잭션에서의 선점을 고려해서 로직 실행 조건을 검사하거나 update가 됐는지 안됐는지 확인하는 로직이 필요하다.

SELECT FOR UPDATE의 주의점

  • select for update를 걸면 UPDATE는 트랜잭션은 대기해야 하지만, 일반적인 SELECT는 데이터를 읽을 수 있다.
  • 이는 읽기 작업이 쓰기 작업을 방해하지 않기 때문
  • 때문에 정합성이 중요한 로직에서 SELECT 시에는 SELECT FOR UPDATE를 사용하여 트랜잭션이 완료(COMMIT 또는 ROLLBACK)될 때까지 기다려야 한다.
  • 격리 수준 설정에 따라 일반 SELECT도 막을 수 있다. 예를 들어, READ COMMITTED 격리 수준에서는 SELECT FOR UPDATE가 선점하고 있을 때 일반 SELECT 문으로 읽기가 가능하지만 SERIALIZABLE 격리 수준에서는 읽지 못한다.