데이터베이스 | 동시성 제어 - 트랜잭션 격리 수준

Jihun Kim·2022년 1월 30일
3

데이터베이스

목록 보기
4/7
post-thumbnail

DB Concurrency 문제는 DB에 두 명 이상의 유저가 동시에 접근할 때 발생할 수 있는 문제이다. 즉, Concurrency는 transaction이 순차적으로 실행되는 것이 아니라 순서에 상관없이 동시에 실행되는 것을 말한다.

예를 들어, 두 유저가 동시에 계좌의 잔고에 변경이 일어나는 작업을 한다고 가정해 보자. 한 명은 해당 계좌에 저축을 하고 동시에 다른 한 명은 해당 계좌에서 다른 계좌로 이체를 한다. 이 경우 적절한 concurrency control이 없다면 어느 한 명은 계좌의 잔고가 내가 예상한 것과 다르다는 것을 보게될 것이다.

이처럼 여러 유저의 동시 접속 문제는 모든 서비스에서 당연히 일어날 수밖에 없는데, DBMS는 동시성을 제어할 수 있도록 Lock 기능과 SET TRANSACTION 명령어를 이용해서 트랜잭션의 격리성 수준을 조정할 수 있도록 제공하고 있다.

그러나 Locking은 읽기 작업과 쓰기 작업이 서로 방해를 하며 동시성 문제가 발생하고 또한 데이터 일관성에 문제가 생기기도 한다. 또한, Lock이 걸리는 시간이 있어 성능 저하가 발생하기도 한다. 이러한 문제를 해결하기 위한 방법을 MVCC(multi-version concurrency control)이라 하는데, 이에 대해서는 추후에 추가적으로 다루어 보겠다.



Transaction 격리 수준

Transaction 격리 수준이란 여러 transaction이 동시에 처리될 때 transaction끼리 얼마나 고립되어 있는 지를 나타내는 것으로, RDBMS가 처리하는 격리 수준을 말한다.

격리 수준에 따라 특정 트랜잭션이 다른 트랜잭션에서 변경한 데이터를 볼 수 있도록 허용할 지 말 지를 결정할 수 있다.

이 때, 위에서 설명했던 것과 같이 두 유저가 동시에 DB에 접근하는 것을 막기 위해 전부 lock을 걸어 버리면 되지 않을까 생각할 수도 있지만 그렇게 되면 동시에 수행되는 많은 트랜잭션들은 순차적으로 처리할 수밖에 없게 되는데 그러면 데이터베이스의 성능은 떨어지게 된다. 따라서, 가장 효율적인 Locking 방법을 고민할 필요가 있다.


Lock의 종류와 특징
Lock에는 두 가지 종류가 있다.

  • 공유 락(shared lock)
    • 데이터를 읽을 때 사용되는 락으로, 공유 락끼리 동시에 접근이 가능하다.
  • 배타 락(exclusive lock)
    • 데이터를 변경할 때 사용되는 락으로, 트랜잭션이 완료될 때까지 유지되며 해당 락이 해제되기 전까지 다른 트랜잭션이 접근할 수 없다.


Read uncommitted(Level 0)

특징

  • 말 그대로 트랜잭션 처리중에 읽는 것이 허용된다는 뜻으로, 아직 commit 되지 않은 데이터를 다른 트랜잭션이 읽을 수 있다.
  • 데이터를 읽는 동안 해당 데이터에 공유 락이 걸리지 않는 계층이다.

발생 가능한 문제점: Dirty Read

  • 데이터 정합성에 문제가 생길 수 있다.
  • 만약 1번 트랜잭션에서 A 계좌로 송금을 하는 중이며 아직 커밋하지 않은 상태일 때, 2번 트랜잭션에서는 A계좌의 송금 후 금액을 조회하게 된다.
    • 그런데 이 때, 계좌 송금 중 네트워크 오류 등의 문제로 롤백이 일어나면 1번 트랜잭션에서는 여전히 이전 금액이지만 B 트랜잭션에서는 송금 후의 금액을 조회하게 된다.

Read committed(Level 1)

특징

  • 한 트랜잭션이 처리되는 동안 다른 트랜잭션이 접근할 수 없다.
    • 따라서, 커밋이 이루어진 후에만 다른 트랜잭션이 조회할 수 있다.
  • Read uncommitted에서와는 달리 데이터를 읽는 동안 해당 데이터에 공유 락이 걸리는 계층이다.
  • Oracle에서 기본으로 사용하는 격리 수준이다.

발생 가능한 문제점: Non-repeatable read

  • 하나의 트랜잭션 내에서 똑같은 SELECT 쿼리를 실행했을 때 항상 같은 결과를 가져와야 하는 REPEATABLE READ의 정합성에 어긋난다.
    • 즉, 한 트랜잭션이 처리되는 동안 특정 값을 조회할 때 다른 값이 조회되는 현상이다.
  • 실제 테이블 값을 가져오는 것이 아니라, Undo 영역에 백업된 레코드에서 값을 가져온다.
  • 읽기 재현이 안된다는 뜻으로, 예를 들어 트랜잭션 1이 계좌를 조회했을 때 잔고가 10000원이었다고 하자. 그런데 트랜잭션 2에서 A계좌로 송금을 완료해 커밋하여 잔고가 10000 → 20000원이 되었을 때 트랜잭션 1이 다시 잔고를 조회하면 10000원이 아니라 20000원이 된다.

Repeatable Read(Level 2)

특징

  • 트랜잭션이 처리되는 동안 한 번 조회한 데이터의 내용이 항상 동일함을 보장한다.
    • 즉, 자신의 트랜잭션 번호보다 아래의 트랜잭션 번호에서 커밋된 내용만 보게 된다.
    • 따라서, 다른 트랜잭션은 자신의 트랜잭션 번호보다 낮은 트랜잭션 번호의 처리가 끝나기 전까지 해당 데이터를 수정할 수 없다.
  • 한 트랜잭션이 완료될 때까지 SELECT 쿼리가 사용되는 모든 데이터에 공유 락이 걸리는 계층이다.
  • 데이터를 수정할 때는 Undo 공간에 백업해 두고 실제 레코드 값을 변경한다.
    • 백업 데이터는 불필요 하다고 판단되는 시점에 주기적으로 삭제한다.
    • 백업 레코드가 많아지면 성능이 저하될 수 있다.
    • 이러한 변경 방식을 MVCC(Multi Vercion Concurrency Control)이라 부른다.
  • 단점은 INSERT/DELETE문에 대한 정합성이 보장되지 않는다는 점이다(참고 stack overflow).

발생할 수 있는 문제점: Phantom Read

  • 이름 그대로, 사용자는 환상을 보게 될 수 있다.
    • 즉, 원래 있던 데이터가 사라지거나 혹은 생기는 현상이 발생할 수 있다.
    • 왜냐하면, 1번 트랜잭션이 특정 계좌에서 2억 원을 조회했었는데 2번 트랜잭션이 롤백하면 해당 데이터는 사라지기 때문이다.

Serializable(Level 3)

특징

  • 가장 엄격한 격리 수준을 갖는다.
  • 한 트랜잭션이 SELECT 쿼리를 실행해 테이블을 읽으면 다른 트랜잭션은 해당 테이블에 대한 추가, 변경 또는 삭제를 할 수 없다.

발생할 수 있는 문제점: 성능 저하

  • Level 0~2에서 겪었던 문제점은 전부 해결되지만 동시 처리 성능은 매우 떨어진다.


Non-repeatable read와 Phantom read의 차이점

  • Non-repeatable read: 하나의 row에 대해 변경 사항이 생길 경우 발생할 수 있는 문제점
    - UPDATE 구문 실행시 발생
  • Phantom read: 레코드의 범위 내에서 발생할 수 있는 문제점
    - INSERT/DELETE 구문 실행시 발생


동시성 문제를 해결할 수 있는 방법

비관적 동시성 제어와 낙관적 동시성 제어가 있다.


1. 비관적 동시성 제어

  • 사용자들이 같은 데이터를 동시에 수정할 것이라 가정 한다.
  • 데이터를 읽는 시점에 Lock을 걸고, 트랜잭션이 완료될 때까지 Lock을 유지한다.
  • SELECT 시점에 Lock을 걸기 때문에 심각한 성능 저하를 초래할 수 있어 wait 또는 nowait 옵션과 함께 사용할 필요가 있다.

DB에 설정된 transaction 격리 수준 수정

위에서 언급했듯이, 각 DBMS마다 격리 수준이 다르게 설정되어 있다.

django에서 사용할 postgreSQL의 격리 수준을 조회해 보면 다음과 같다.

SHOW TRANSACTION ISOLATION LEVEL;
# read committed

이렇게 설정되어 있는 격리 수준을 더 높은 수준에 해당하는 repeatable read, serializable 중 하나로 수정하면 된다.


select_for_update 사용하기

select_for_update는 트랜잭션이 끝날 때까지 row에 락을 걸 쿼리셋을 반환한다.

이에 해당하는 SQL 구문은 SELECT ... FOR UPDATE 이다.

SELECT *
FROM entries
WHERE id=10
FOR UPDATE;

select_for_update(nowait=Falseskip_locked=Falseof=()no_key=False)

공식 문서에 따르면 아래와 같이 사용할 수 있다.

from django.db import transaction

entries = Entry.objects.select_for_update().filter(author=request.user)
with transaction.atomic():
    for entry in entries:
	       entry.left -= 1
				 entry.save()

entries 쿼리셋에 대해 “for entry in entries” 반복문을 도는 동안 entries에 매칭 되는 row는 모두 해당 transaction이 끝날 때까지 락이 잡힌다. 따라서 다른 트랜잭션은 읽기 작업 외에는 해당 row에 변경을 가하는 어떤 작업도 할 수 없다.

파라미터

  • nowait과 skip_locked는 배타적인 관계로, 둘 다 True면 ValueError가 발생한다(기다리지 않는다는 설정과 무시한다는 설정은 상충 관계).
    • nowait
      • default는 False: 이 경우 row lock이 잡혀 있으면 대기한다.
      • True일 경우 이미 lock이 잡혀 있다면 DatabaseError를 발생시킨다.
    • skip_locked
      • default는 False
      • True인 경우 락이 잡혀 있다면 이를 무시한다.
  • of
    • selec_for_update()select_related를 통해 join된 테이블의 row도 함께 락을 잡는다. 만약 이를 원하지 않는다면, of 파라미터를 이용해 lock을 잡을 테이블만 명시할 수 있다.

Redis를 이용해 application 단에서 lock을 잡기

django는 django-redis 라이브러리를 이용해 redis를 사용할 수 있는데, Redis는 제한적이긴 하지만 SETNXINCR 명령을 통해 Redis atomic operation을 지원한다.

SETNXset()의 파라미터 nx를 통해 사용할 수 있다. SETNX는 “set not exist”라는 의미로, “락이 존재하지 않으면 락을 획득한다”는 연산을 atomic하게 할 수 있도록 만들어 준다.

from django.core.cache import cache

movie_ticket_key = f'movie_ticket_key:{user_id}:{movie_id}'
if cache.set('movie_ticket_key', '1', nx=True):
    movie = Movie.objects.get(id=movie_id)
    movie.remaining_seat -= 1
    movie.save()

따라서, 이를 통해 매 row마다 락이 잡혀 있지 않다면 락을 획득할 수 있다.



2. 낙관적 동시성 제어

  • 사용자들이 같은 데이터를 동시에 수정하지 않을 것이라 가정 한다.
  • 데이터를 읽는 시점에 Lock을 걸지 않으며, 수정 시점에 값이 변경 됐는지를 검사한다.

version 혹은 updated_at 컬럼 이용하기

이 방법은 데이터를 수정할 때마다 version을 1 증가시키거나 updated_at을 현재 시각으로 갱신하는 방법이다.

그러면 다른 트랜잭션에서 동일한 버전으로 수정하려고 한다면 다른 트랜잭션은 version 충돌이 일어나 실패하게 된다.

def is_reservation_available(self):
    updated = Movie.objects.filter(
        id=self.id,
        version=self.version,
    ).update(
        remaining_seat=remaining_seat-1,
        version=self.version+1,
    )
    if updated > 0:
			return False
		return True

이는 모델에 version 혹은 updated_at 필드를 추가하여 구현할 수 있다. 이 함수에서는 updated > 0일 경우 예약이 불가하므로 False를 반환하는 함수인데 해당 쿼리에는 version 필드가 들어가 있다. 한 트랜잭션이 해당 쿼리를 실행 중일 때 version을 수정하게 되며 이 때 다른 필드가 동일한 버전으로 수정하려 한다면 version 충돌이 일어나 트랜잭션 롤백이 수행된다.

이 글에서는 django-concurrency 라이브러리를 이용해 여기서 제공하는 version field를 사용하는 것도 알려주었다.



PostgreSQL의 격리 수준

  • PostgreSQL의 격리 수준은 READ COMMITTED이다.
  • 트랜잭션 격리 수준에는 READ COMMITTED, READ UNCOMMITTED, REPEATABLE READ, SERIALIZABLE이 있는데 PostgreSQL에서 READ COMMITTEDREAD UNCOMMITTED는 동일하게 취급 된다. 따라서, PostgreSQL에는 3개의 격리 수준이 있는 것과 다름 없다.

격리 수준 변경

PostgreSQL의 트랜잭션 격리 수준 변경은 트랜잭션 블럭 내에서만 실행할 수 있다. 따라서, 다음과 같이 실행하면 된다.

<실행>

BEGIN;
SET transaction isolation level read committed;

<확인>

SHOW transaction isolation level;
--------------
read uncommitted    


참고

1) 요기요 기술 블로그

2) DB Conccurrency 개념 설명

3) 격리 수준 설명

4) 데이터 무결성 및 정합성

5) select_for_update()에 대한 장고 공식문서

profile
쿄쿄

0개의 댓글