Study 7화 - Transaction #2

yeolyeol·2024년 8월 20일
0

til

목록 보기
15/27
post-thumbnail

우리같은 응애 개발 어린이들은 프로젝트나 헤커톤을 할 때 데이터베이스의 동시성을 딱히 신경쓰지 않는다.
왜냐!
어짜피 트레픽도 얼마 없을 것이고, 동시에 접근하는 것 자체가 흔지 않은 일이기 때문이다.

하지만, 우린 필수로 알아야 한다.

데이터베이스의 동시성은 실무에서 반드시 고민되고 사용되는 개념이며, 사용자가 많은 만큼 철저한 트랜잭션 격리 수준을 신경써야 한다.

예를 들어, 한 유저가 스마트폰으로 A 계좌에서 돈을 조회하는 작업과 컴퓨터로 A 계좌에서 돈을 출금하는 작업이 동시에 일어났다고 가정해보자.
이러한 상황에 대한 마땅한 동시성 제어가 없다면, 계좌의 잔고가 생각한 것과 다를 수 있게 된다.

이를 해결하는 방법으로, Lock 기능과 SET TRANSACTION 명령어를 이용하여 트랜잭션의 격리성 수준을 조정할 수 있다.

하지만, Locking은 Read와 Write가 서로 방해를 하며 동시성 문제가 발생하고 데이터 일관성에 문제가 생기기도 한다. 또한 Lock이 걸리는 시간이 있어 성능 저하가 발생하기도 한다.
이러한 문제를 해결하기 위해 MVCC(Multi-Version Concurrency Control)라는 방법을 사용하는데, 추후 다뤄보도록 하겠다.

Transaction Isolation Level

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

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

트랜잭션과 Lock에 대해 제대로 알고 있지 않았던 나는 "동시성 문제, 그냥 전부 Lock 걸면 되는거 아냐?" 와 같은 응애스러운 생각을 했었다.
조금만 생각해보면 수행되는 모든 트랜잭션이 동시에 처리해도 문제가 되지 않는 부분까지 무조건 순차적으로 처리하게 되므로 데이터베이스의 성능은 떨어지게 된다.

따라서, 적절한 Locking과 Transaction Isolation level을 지정할 필요가 있다.

트랜잭션 격리 수준은 격리 수준이 높은 순서대로,
SERIALIZABLE, REPEATABLE READ, READ COMMITTED, READ UNCOMMITED가 존재한다.
참고로 아래의 예제들은 모두 자동 커밋(AUTO COMMIT)이 false인 상태에서만 발생한다.

들어가기 전에,
Lock의 종류와 특징에 대해 간략하게 알고 넘어가자.

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

Serializable(Lv 3)

이름 그대로 트랜잭션을 순차적으로 진행시키는 격리 수준.
여러 트랜잭션이 동일한 레코드에 동시 접근이 불가능하므로, 어떠한 데이터 부정합 문제도 발생하지 않는다. 대신, 트랜잭션이 순차적으로 처리되어야 하기에 데이터베이스 동시 처리의 성능이 매우 떨어진다.
Serializable 격리 수준에서는 순수한 SELECT 작업에도 대상 레코드에 넥스트 키 락공유 락으로 건다. 따라서, 한 트랜잭션에서 넥스트 키 락이 걸린 레코드를 다른 트랜잭션에서는 절대 추가/수정/삭제할 수 없다.

문제점 - 성능 저하

SERIALIZABLE은 가장 안전하지만 가장 성능이 떨어지므로, 극단적으로 안전한 작업이 필요한 경우가 아니라면 동시 접속이 많은 실무에서 데이터베이스 동시성 목적으로 사용해서는 안된다.

Repeatable Read(Lv 2)

일반적인 RDBMS는 변경 전의 레코드를 UNDO 공간에 백업해둔다. 그러면 변경 전/후 데이터가 모두 존재할 수 있으며, 동일한 레코드에 대해 여러 번전의 데이터가 존재한다고 볼 수 있다. 이를 MVCC 다중 동시성 제어라고 한다. (추후에 자세하게 다뤄보도록 하고, 지금은 백업 레코드에 어느 트랜잭션에 의해 백업되었는지 트랜잭션 번호를 함께 저장한다는 점만 알고 넘어가자.)
REPEATABLE READ는 MVCC를 이용해 한 트랜잭션 내에서 동일한 결과를 보장하지만, 새로운 레코드가 추가되는 경우에 부정합이 생길 수 있다. 이러한 REPEATABLE READ의 동작 방식을 자세히 살펴보도록 하자.

예시

아래와 같이, 사용자 B에 의해 트랜잭션(T-ID = 10)이 시작되고 id가 20인 레코드를 조회하면 1건 조회되는 상황이라고 하자.

그리고 이때 다른 사용자 A의 트랜잭션(T-ID = 12)에서 id=20인 레코드를 갱신하는 상황이라고 하자. 그러면 MVCC를 통해 기존 데이터는 변경되지만, 백업된 데이터가 변경이 발생하기 전 트랜잭션 ID와 함께 언두 로그에 남게 된다.

사용자 B가 다시 한번 동일한 SELECT 문을 실행하면 어떻게 될까? 그 결과는 다음과 같다.

사용자 A의 트랜잭션(T-ID = 12)이 시작되고 커밋까지 되었지만, 해당 트랜잭션은 사용자 B의 트랜잭션(T-ID = 10)보다 나중에 실행되었기 때문에 조회 결과로 T-ID가 10보다 작은 6의 레코드를 가져오면서 기존과 동일한 데이터를 얻게 된다. 즉, REPEATABLE READ는 어떤 트랜잭션이 읽은 데이터를 다른 트랜잭션이 수정하더라도 동일한 결과를 반환할 것을 보장해준다.

문제점 - Phantom Read

SELECT로 조회한 경우 트랜잭션이 끝나기 전에 다른 트랜잭션에 의해 추가된 레코드가 발견되는 현상. 즉, 사용자 B의 트랜잭션에 새로운 레코드가 보였다가 안보였다가 하는 것.
MVCC가 없는 REPEATABLE READ수준에서는 UNDO 로그를 통한 이전 버전을 따로 관리할 수 없기에 SELECT를 제외한 다른 DML 작업에서 발생할 수 있다.

하지만, MVCC로 UNDO 로그를 관리하고 있기에 나중에 실행된 트랜잭션에서 레코드가 추가되어도 해당 레코드를 무시하면 된다.

그럼, 어떠한 상황에 Phantom Read가 발생할까?
바로, 잠금이 사용되는 경우에 Phantom Read가 발생한다.
MySQL을 제외한 다른 RDMBS를 먼저 살펴보자.

사용자B가 먼저 데이터를 조회하는데, 이번에는 SELECT FOR UPDATE를 이용해 쓰기 잠금(배타적 잠금)을 걸었다. 그리고 사용자 A가 새로운 데이터를 INSERT하는 상황이라고 하자.
일반적인 DBMS에서는 갭락이 존재하지 않으므로 id = 20인 레코드만 잠금이 걸린 상태이고, 사용자 A의 요청은 ID가 21인 새로운 레코드 추가로 잠금 없이 즉시 실행된다. 

이로 인해, 사용자 B가 다시 조회를 하게 되면 총 2건이 조회되면서 Phantom Read가 발생한다

처음 Repeatable Read 예시를 들었을 때와 조금 다른 점이 보인다.
바로, UNDO 로그에서 T-ID를 고려하지 않은 것. 그 이유는 배타적 잠금 때문에 잠금 장치가 없는 append only형태의 UNDO 로그가 아닌 테이블에서 수행되기 때문이다.

MySQL의 Gap Lock

MySQL은 다른 일반적인 RDBMS와 다르게 Gap Lock이 존재한다.
사용자 B가 SELECT FOR UPDATE(배타적 잠금)로 데이터를 조회한 경우에 MySQL은 id가 20인 레코드에는 레코드 락, id가 20보다 큰 범위에는 갭 락으로 넥스트 키 락을 건다. 따라서 사용자 A가 id가 21인 user를 INSERT 시도한다면, B의 트랜잭션이 종료(커밋 또는 롤백)될 때 까지 기다리다가, 대기를 지나치게 오래 하면 락 타임아웃이 발생하게 된다.

따라서, 일반적으로 MySQL의 REAPEATABLE READ에서는 Phantom Read가 발생하지 않는다.

그럼 MySQL에는 언제 Phantom Read가 발생할까?

바로, B 트랜잭션의 처음 잠금 없는 SELECT를 수행하고 A의 INSERT 이후 두 번째 SELECT에 잠금을 건 SELECT를 하게 되면 Phantom Read가 발생한다. 위에서 설명했던 것과 같이 잠금있는 SELECT를 하게 되면, UNDO 로그가 아닌 테이블에서 조회하기 때문에 A 트랜잭션에 의해 추가된 데이터 결과를 조회하게 된다.

Read committed(Lv 1)

이름 그대로, 커밋된 데이터는 조회할 수 있다.

예시

A 트랜잭션이 시작된 후 어떤 데이터를 변경하였고, 아직 커밋은 하지 않은 상태라고 하자.
그러면 테이블은 먼저 갱신되고, UNDO 로그로 변경 이전의 데이터가 백업된다.

이때 사용자 B가 데이터를 조회하려고 하면, READ COMMITTED에서는 커밋된 데이터만 조회할 수 있으므로, REPEATABLE READ와 마찬가지로 UNDO 로그에서 변경 전의 데이터를 찾아서 반환하게 된다.
최종적으로 A 트랜잭션에서 커밋하면 그때부터 다른 트랜잭션에서도 새롭게 변경된 값을 참조할 수 있는 것이다.

문제점 - Non-repeatable read

하지만 READ COMMITTED는 Non-Repeatable Read(반복 읽기 불가능) 문제가 발생할 수 있다.
즉, 아직 끝나지 않은 트랜잭션에서 같은 SELECT를 반복 사용할 때마다 다른 트랜잭션의 UPDATE로 인해 다른 데이터가 조회되는 것이다.

그림으로 쉽게 알아보자.

위 그림처럼 B 트랜잭션이 시작되고 ID = 20인 레코드를 조회했다고 하자. 해당 조건을 만족하는 "Yeol"을 반환한다.
이후, 트랜잭션 A에서 UPDATE 문을 수행하여 ID = 20인 레코드를 변경했다고 하자. 그리고 트랜잭션 A는 커밋까지 완료되어 종료된 상태이다. 이때 트랜잭션 B에서 다시 동일한 조건으로 레코드를 조회하면 어떻게 될까?

READ COMMITTED 는 커밋된 데이터는 조회할 수 있도록 허용하므로 "Yeol"이 아닌 "Dong"이라는 결과가 나오게 된다.

Phantom Read와의 차이점

둘 다 동일한 쿼리를 날렸을 때, 이전과 다른 결과를 얻는다는 데이터 일관성에 영향을 미친다.
그래서 Phantom Read와 Non-repeatable Read와 비슷해 보일 수 있지만, 두 문제점에는 명확한 차이가 있다.
Phantom Read는 데이터의 추가나 삭제데이터가 없어지거나 생겨나는 현상이고, Non-repeatable Read는 데이터 갱신이전 데이터와 달라지는 현상을 말한다.

실생활 예시

나는 오늘 어떤 트랜잭션에서 이번 달 동안 입금된 총 합을 계산하면서 가계부를 작성하고 있는데, 다른 트랜잭션에서 계속해서 입금 도는 출금을 COMMIT하는 상황이라고 하자. 그러면 READ COMMITTED에서는 같은 트랜잭션일지라도 조회할 때마다 계좌 내역이 달라지므로 가계부 정리에 문제가 발생한다.

따라서 격리 수준이 어떻게 동작하는지, 그리고 격리 수준에 따라 어떠한 결과가 나오는지 예측할 수 있어야 한다.

Read uncommitted(Lv 0)

말 그대로 트랜잭션 처리중에 읽는 것이 허용된다는 뜻으로, 아직 commit 되지 않은 데이터를 다른 트랜잭션이 읽을 수 있다.

예시

사용자 A의 트랜잭션에서 INSERT를 통해 데이터를 추가했다고 하자. 아직 커밋 또는 롤백이 되지 않은 상태임에도 불구하고 READ UNCOMMITTED는 변경된 데이터에 접근할 수 있다.

문제점 - Dirty Read

위 그림과 같은 상황에서 DML 명령어 수행시 문제가 발생하여 ROLLBACK이 수행해야 된다고 가정하자. 이후 B 트랜잭션에서 다시 조회하게 되면, 다른 결과를 조회하게 된다.

이 뿐만 아니라 트랜잭션이 종료되기 전까지 DML 명령어는 몇 개든 수행될 수 있고, 그 사이마다 다른 트랜잭션은 같은 SELECT 문이라도 계속 다른 결과를 얻게 된다.

그래서 READ UNCOMMITTED는 RDBMS 표준에서 인정하지 않을 정도로 정합성에 문제가 많은 격리 수준이다. 따라서 MySQL을 사용한다면 최소한 READ COMMITTED 이상의 격리 수준을 사용해야 한다.

결론

READ UNCOMMITTED는 부정합 문제가 지나치게 발생하고, SERIALIZABLE은 동시성이 상당히 떨어지므로 READ COMMITTED 또는 REPEATABLE READ를 사용하면 된다. 참고로 오라클에서는 READ COMMITTED를 기본으로 사용하며, MySQL에서는 REPEATABLE READ를 기본으로 사용한다. 

격리 수준이 높아질수록 MySQL 서버의 처리 성능이 많이 떨어질 것으로 생각하는데, 사실 SERIALIZABLE이 아니라면 크게 성능 개선 및 저하는 발생하지 않는다. 그 이유는 결국 언두 로그를 통해 레코드를 참조하는 과정이 거의 동일하기 때문이다. 따라서 MySQL은 갭 락을 통해 Phantom Read까지 거의 발생하지 않고, READ COMMITTED보다는 정합성은 뛰어난 REPEATABLE READ를 사용한다.

참고

https://mangkyu.tistory.com/299
https://velog.io/@combi_jihoon/%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%B2%A0%EC%9D%B4%EC%8A%A4-DB-Concurrency
https://velog.io/@daehoon12/MySQL-%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%A7%80-%EC%97%94%EC%A7%84-%EC%88%98%EC%A4%80%EC%9D%98-%EB%9D%BD

profile
한 걸음씩 꾸준히

0개의 댓글

관련 채용 정보