Transaction 사용하기: #3 Django에서 transaction 과 동시성 처리

정재혁·2022년 11월 29일
1

이번 포스트에서는 transaction을 사용했음에도, 데이터 무결성이 보장되지 않는 상태의 토이 프로젝트를 개선해볼 것이다.

토이 프로젝트 구조

토이프로젝트 구조는 2편에 설명하였던 것과 동일하다.
project와 audio는 1:N 관계이다.
각각의 audio는 text값을 가지고 있고, 이를 tts를 통해 mp3파일로 제공하는 프로젝트이다.

mp3파일 생성에는 시간이 오래 걸리고, project를 생성할 때 문장 갯수에는 제한이 없다.
따라서, mp3 파일 생성은 request시에 처리하지 않고, 파일 생성이 필요하다는 flag 컬럼 값을 true로 만들어두기만 한다.

background scheduler는 audio중 파일 생성이 필요한 audio를 조회한 후 mp3파일을 생성하고, flag값을 flase로 업데이트한다.

트랜잭션과 동시성 처리 문제

위와 같은 구조에서 한가지 문제점이 발생한다.
예를 들어, A라는 audio의 mp3 파일을 생성하고 있을 때, A audio의 텍스트를 수정했다고 가정해보자.
REPEATABLE READ level에서는 각각의 트랜잭션은 독립된 스냅샷을 가지고 작업을 수행하고, 서로 다른 transaction이 서로에게 영향을 주는 것을 허용하지 않는다.
따라서, mp3파일을 생성하는 작업 중간에, 해당 audio의 텍스트가 수정된다면 race condition이 발생하게 된다.

audio A에 대한 text update 요청이 되어, 수정이 완료되고, 파일 생성 필요 flag를 true로 update 했어도.
TTS Provider가 잘못된 text로 mp3파일을 생성한 뒤, flag를 false로 update한다.

결과적으로, audio A의 텍스트는 "어머니가 방에 들어가신다." 로 수정되었으나, mp3 파일은 "아버지가 방에 들어가신다."로 생성된 상태로 저장될 것이다.

독립된 transaction의 관점으로 봤을 때는 문제가 없다.

  • TTS Provider:
    mp3파일 생성이 필요한 audio를 가져와서, mp3파일을 문제없이 생성하고, 파일을 생성했음을 flagging 하였다. 이 transaction의 snapshot에서 audio A의 텍스트는 "아버지가 방에 들어가신다."이고, 각 transaction간에는 영향을 끼칠 수 없기 때문에, audio A의 텍스트에 대한 수정 요청이 있음을 알 수 없다.

  • API의 update 요청:
    text와 mp3 파일 생성 필요 flag를 true로 잘 수정하였다.

각각의 비지니스 로직을 논리적으로 transaction으로 묶을 뿐만 아니라, 로직간의 동시성 역시 고려해야한다.

문제해결

django에는 이러한 문제를 해결하기 위해, ORM에서 select_for_update란 기능을 제공한다.
사용방법은 간단하다.

def find_for_update(self, limit: int, skip_locked: bool = True) -> list[dict]:
        """
        update를 위해서 select 합니다.
        다른 transaction에 의해 lock이 걸려있을 경우 skip 하는 것을 default로 합니다.
        """
        return AudioSerializer(
            Audio.objects.select_for_update(
                skip_locked=skip_locked, nowait=not skip_locked
            )
            .filter(is_audio_required=True)
            .order_by("updated_at")[:limit],
            many=True,
        ).data

select_for_update를 사용하게 되면, transaction이 완료될 때 까지 select 한 row들에 lock을 걸어준다. 반대로, 이미 lock이 걸려있으면 기다린다. 이러한 행동들을 파라매터로 제어할 수 있다.

  • no_wait:
    • lock이 걸렸을 때, 기다릴지 말지 결정한다.
    • 기본값은 False로 lock이 풀릴 때 까지 기다린다.
    • True는 lock이 걸린 경우 error를 발생시킨다.
  • skip_locked:
    • lock이 걸렸을 때, skip 할지 결정한다.
    • 기본값은 false로 lock이 풀릴 때 까지 기다린다.

MySQL 8.0.1+ 부터, no_wait과 skip_locked를 지원한다.

현재, 토이 프로젝트에서는 background worker가 데이터 조작이 필요한 N개의 row를 조회하고 있다.
이는 특정 row에 대하여 작업해야 하는 것이 아니고, 음성 파일 생성이 필요한 row들을 조회하면 된다.
따라서, skip_locked 옵션을 사용하여 lock이 걸린 row는 넘기고 select하는 방법이 타당하다고 볼 수 있다.

select_for_update의 작동방식

위의 코드에서 raw SQL문을 뽑아내면 아래와 같다.

SELECT 
  `audio`.`id`, 
  `audio`.`created_at`, 
  `audio`.`updated_at`, 
  `audio`.`project_id`, 
  `audio`.`user_id`, 
  `audio`.`index`, 
  `audio`.`text`, 
  `audio`.`speed`, 
  `audio`.`path`, 
  `audio`.`is_audio_required` 
FROM 
  `audio` 
WHERE 
  `audio`.`is_audio_required` = True 
ORDER BY 
  `audio`.`updated_at` ASC 
 FOR UPDATE 
  SKIP LOCKED

주목할 부분은 맨 아래의 FOR UPDATE SKIP LOCKED 이다.
당연하게도, MySQL의 SKIP LOCKED 명령을 사용하고 있다.

MySQL 공식문서의 SKIP LOCKED과 NO WAIT에 대한 설명을 요약하면 아래와 같다.

SKIP LOCKED은 결과중에 lock이 걸린 row를 skip하고 그 다음 row를 가져옵니다.
FOR UPDATE는 exclusive lock을 겁니다. (Read/Write 모두 불가능)

NO WAIT은 LOCK이 풀리길 기다리거나, SKIP 하는 것을 원하지 않을 때 사용합니다.
LOCK이 걸린 ROW가 오랜시간 동안 점유될 것으로 예상된다면, 기다리는 것 보다, 빠르게 error를 발생시키는 것이 좋을 수도 있습니다.

  • SKIP LOCKED은 multi-threaded 워커가 데이터 조작이 필요한 N개의 row를 조회할 때 매우 간편합니다.
  • Lock이 걸릴 것이 예상되지 않거나, 비지니스 로직에 부합하지 않는 Lock이라면 NO WAIT을 사용하면 좋습니다.
  • SKIP LOCKED와 NO WAIT은 같은 테이블에 적용 시키지 않는 한, 하나의 쿼리에서 사용할 수 있습니다.

결론

  • transaction을 건다고 해서 무조건 비지니스 로직에 부합하는 데이터를 보장할 수 없다.
  • 동시성 처리를 위해, FOR UPDATE 명령을 사용하여, lock에 대응하는 방법을 지정할 수 있다.

참고자료

select_for_update: 장고공식문서

LOCKED SKIP: MySQL 공식문서

profile
궁금한 것을 궁금한 것으로 두지 말자.

0개의 댓글