데이터베이스

[MySQL] MVCC(다중 버전 동시성 제어)와 데이터베이스가 트랜잭션을 지원하는 방법과 동작 과정

망나니개발자 2023. 2. 21. 10:00
반응형

이번에는 데이터베이스가 트랜잭션을 지원하는 방법과 동작 과정에 대해 살펴보도록 하겠습니다. 아래의 내용은 RealMySQL과 MySQL 공식 문서 등을 참고하여 작성한 내용입니다.

 

 

 

 

1. MVCC(다중 버전 동시성 제어)와 언두 로그(Undo Log), 리두 로그(Redo Log)


[ MVCC(다중 버전 동시성 제어)란? ]

데이터베이스를 사용하는 가장 큰 이유 중 하나는 바로 트랜잭션 때문이다. 하지만 모든 DBMS가 트랜잭션 기능을 제공하지는 않는데, 대표적으로 MySQL의 스토리지 엔진 중 하나인 MyISAM이 그렇다. 대신 MySQL의 InnoDB는 레코드 단위까지 트랜잭션을 지원해준다.

레코드 수준의 트랜잭션을 지원하기 위해서 InnoDB는 MVCC를 사용한다. MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어)란 데이터베이스가 동시성을 제어하기 위해 사용하는 방법 중 하나로, 스냅샷을 이용한다. 여기서 멀티 버전이라는 것은 스냅샷을 통해 하나의 레코드에 대해 여러 버전이 관리된다는 것을 의미하며, 이를 통해 데이터에 대한 변경이 완료(commit)되기 전 까지의 변경 사항은 다른 사용자가 볼 수 없도록 하는 등의 제어가 가능하다.

MVCC를 이용하는 이유는 락을 사용하지 않기 위해서이다. 동시성 제어를 위한 가장 쉬운 방법이 락이지만, 락을 사용하면 동시 요청 시에 처리 속도가 상당히 떨어진다. 따라서 MySQL은 MVCC를 사용하고 있으며, 스냅샷으로 언두 로그(Undo Log)를 활용한다.

자세한 내용은 MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어) 관련 포스팅을 참고하도록 하자.

 

 

 

[ 언두 로그(Undo Log)란? ]

언두 로그란 MySQL이 트랜잭션과 격리 수준을 보장하기 위해 백업해둔 변경 전의 데이터이다.

  • 트랜잭션 보장: 트랜잭션이 롤백되면 변경된 데이터를 백업된 이전 버전으로 복구시킨다.
  • 격리 수준 보장: 특정 커넥션에서 데이터를 변경하는 도중에 다른 커넥션에서 데이터를 조회하면 격리 수준에 맞는 데이터를 반환한다.

 

언두 로그는 상당히 중요하게 사용되지만 그렇다고 항상 사용되지는 않는다. 예를 들어 트랜잭션이 롤백되지 않는다면 사용되지 않을 수 있다. 그래서 상당히 중요한만큼 관리도 많이 필요하다.

언두 로그가 실제로 남는 과정을 살펴보도록 하자. 예를 들어 아래와 같은 UPDATE 쿼리를 실행했다고 하자.

UPDATE member SET area = "경기" WHERE member_id = 1;

 

 

그러면 데이터베이스는 우선 커밋 여부와 무관하게 실제 데이터와 버퍼풀(메모리)의 내용을 변경한다. 그리고 언두 영역에는 변경 전의 값을 백업해두다가 커밋되면 현재 상태를 유지하고, 롤백되면 백업 데이터로 복구한다.

이러한 언두 로그의 동작 방식 때문에 아래와 같은 상황들은 좋지 않다.

  • 대량의 데이터를 변경 또는 삭제하는 경우
  • 트랜잭션을 오래 유지하는 경우

 

예를 들어 1억 건의 레코드가 저장된 100GB의 테이블을 DELETE 한다고 하자. 그러면 테이블에서 모든 레코드가 삭제되지만 동시에 언두 로그로도 복사가 되면서 저장 공간을 100GB 차지하게 되어 문제가 생길 수 있다. MySQL 5.5 부터는 언두 로그를 자동으로 줄여주긴 하지만, 그렇다고 해서 트랜잭션 종류 후에 바로 언두 로그가 삭제되지 않으므로 대량의 데이터를 한 번에 처리하는 것은 위험하다.

트랜잭션을 오래 유지하는 것도 위험하다. 트랜잭션이 완료되지 않고 계속 방치된다면 데이터베이스는 해당 트랜잭션이 커밋될지 롤백될지 모르므로 언두 로그를 계속해서 쌓는다. 쌓인 양은 많지 않겠지만, 문제는 해당 레코드를 조회하는 쿼리가 실행되면 언두 로그를 뒤져서 변경 전의 레코드를 찾아야 하므로 쿼리 성능이 떨어지게 된다. 그러므로 트랜잭션은 가능한 짧게 유지하는 것이 좋다. 그리고 이를 위해 네트워크 요청과 관련된 부분은 가능하다면 반드시 트랜잭션 범위에서 제외시켜야 한다.

 

 

 

[ 리두 로그(Redo Log)란? ]

리두 로그는 트랜잭션의 4가지 요소(ACID) 중에서 D(Durable)에 가장 밀접하게 연관돼있다. MySQL은 언두 로그와 마찬가지로 데이터 변경 내용을 리두 로그로 기록하고, 이를 통해 MySQL 서버가 비정상적으로 종료됐을 때 일관된 데이터를 갖도록 도와준다. MySQL 서버가 비정상 종료되면 다음과 같은 문제가 생길 수 있는데, 이때 리두 로그를 활용하면 문제를 해결할 수 있다.

  • 커밋됐지만 데이터 파일에 기록되지 않은 데이터
  • 롤백됐지만 데이터 파일에 이미 기록된 데이터

 

커밋됐지만 메모리에만 저장되고 데이터 파일로 기록되지 않은 경우에는 리두 로그의 내용을 복사하기만 하면 된다.

하지만 롤백된 경우라면 리두 로그 만으로 해결할 수 없다. 그래서 변경 전의 데이터를 갖는 언두 로그의 내용을 복사해야 한다. 그럼에도 불구하고 리두 로그 역시 필요한데, 해당 트랜잭션이 커밋됐는지 혹은 롤백됐는지 아니면 실행 중인 상태였는지 확인하기 위해서이다.

참고로 리두 로그도 버퍼(메모리) 공간이 존재한다. 리두 로그의 내용을 반영하려면 랜덤 I/O를 통해 데이터가 저장될 위치를 찾아야 한다. 하지만 매번 디스크에 바로 기록하면 랜덤 I/O 때문에 성능이 떨어지므로, 일정 주기로 버퍼의 내용을 디스크에 동기화하도록 되어 있다.

 

 

 

 

[ 버퍼풀(Buffer Pool)이란? ]

버퍼풀은 스토리지 엔진의 핵심 부분으로, 디스크에 저장된 테이블과 인덱스 정보(엄밀히는 페이지)를 캐시해두는 공간이다. 이러한 버퍼 풀은 크게 2가지 역할을 갖는다.

  • 캐싱을 통한 읽기 성능 향상
  • 쓰기 지연을 통한 쓰기 성능 향상

 

버퍼풀은 데이터를 임시 저장하기 위한 메모리 공간이므로, 읽기 작업 시에 디스크 읽기 횟수를 줄여 성능을 높일 수 있다. 또한 쓰기 작업을 지연시켜 여러 건의 디스크 쓰기를 한 번에 처리할 수 있는 버퍼 역할도 같이 한다. 쓰기 작업을 매번 따로 수행하면 데이터를 처리할 위치를 찾아야 하는 랜덤 I/O가 발생하지만 버퍼풀을 사용하면 일괄 처리하므로 디스크 I/O를 줄여 성능을 높일 수 있다.

그러므로 버퍼풀은 성능에 상당한 영향을 준다. 그래서 쿼리 요청이 빈번한 서버를 재시작하면 캐싱된 내역이 없어 쿼리 성능이 1/10도 안되게 떨어지곤 한다. 그래서 MySQL은 5.6부터 서버가 셧다운 되기 직전에 버퍼풀을 백업하고, 자동으로 복구할 수 있는 기능을 제공해준다. 이렇게 디스크의 데이터가 버퍼풀에 적재된 상태를 워밍업(warming up)이라고 한다.

 

 

 

 

 

 

2. 데이터베이스가 트랜잭션을 지원하는 방법과 동작 과정


[ 트랜잭션(Transaction)이란? ]

잠금이 존재하는 이유는 동시성을 제어하기 위함이다. 락을 통해 동일한 레코드를 하나의 커넥션만 변경할 수 있는 것이다.

반면에 트랜잭션이 존재하는 이유는 데이터의 정합성을 보장하기 위함이다. 트랜잭션은 계좌 이체처럼 여러 작업들로 묶이는 하나의 논리적인 작업을 완벽하게 처리하고, 만약 문제가 있다면 일부 작업만 적용되는 부분 업데이트(Partial Update) 문제를 해결해준다.

그렇다고 하여 트랜잭션이 꼭 여러 개의 변경 작업을 수행하는 쿼리가 있을 때만 의미있는 것은 아니다. 트랜잭션은 하나의 논리적인 작업 자체가 완전히 적용되거나(Commit) 아무것도 적용되지 않음(Rollback)을 보장해주는 것이다.

예를 들어 다음과 같이 간단히 ID만 갖는 테이블에 일부 데이터가 준비된 상황이라고 하자.

CREATE TABLE my_id (id INT NOT NULL, PRIMARY KEY (id)) ENGINE=INNODB;
INSERT INTO my_id (id) VALUES(3);

 

 

아래의 쿼리는 1개의 쿼리임에도 불구하고 PK가 중복되어 데이터의 정합성이 깨지게 된다. 이처럼 트랜잭션은 꼭 여러 개의 변경 작업에만 의미있는 것이 아니다.

INSERT INTO my_id (id) VALUES(1), (2), (3);

 

 

 

 

[ 트랜잭션의 동작 과정 예시 ]

MySQL이 격리 수준(Isolation Level)이 READ_COMMITTED인 경우 데이터의 변경을 어떻게 처리하는지 살펴보도록 하자.

예를 들어 우리가 아래와 같이 INSERT 문을 통해 데이터를 추가했다고 하자.

INSERT INTO member(id, name, area) VALUES (1, "MangKyu", "서울");

 

 

INSERT 문이 실행되면 데이터베이스의 메모리(버퍼풀)과 디스크 각각에 데이터가 저장된다.

 

 

 

그리고 다음과 같은 UPDATE 문을 실행해 데이터를 갱신한다고 하자.

START TRANSACTION;
UPDATE member SET area = "경기" WHERE id = 1;

 

 

UPDATE 문이 실행되면 커밋 여부와 관계없이 일단 버퍼풀의 값은 갱신되고 언두 로그에는 변경 전의 데이터가 복사된다. 디스크에도 수정한 값으로 갱신돼야 하는데, 이는 백그라운드 쓰레드에 의해서 처리가 되므로 시점에 따라 아직 생신되지 않았을 수 있다. 하지만 MySQL은 ACID를 보장하므로 일반적으로 버퍼풀과 데이터 파일은 동일한 상태라고 가정해도 된다.

 

 

 

 

MVCC를 통해 동일한 레코드가 여러 버전으로 관리되고 있고, 이를 통해 커밋 이전의 데이터 읽기, 데이터 롤백 등이 가능한 것이다. 그렇다면 아직 커밋 또는 롤백이 되지 않은 상태에서 다른 사용자가 레코드를 조회하면 어떤 데이터가 조회될까?

SELECT * FROM member WHERE id = 1;

 

 

진행중인 작업 내역을 보여줄지 또는 백업된 데이터를 보여줄지는 격리 수준(Isolation Level)에 따라 다르다. 데이터베이스는 이렇게 하나의 트랜잭션 내에서 또는 여러 트랜잭션 간의 어떤 작업 내용을 공유할 지 결정을 해야하는데, 이를 격리 수준이라고 한다.

만약 격리 수준이 커밋되지 않은 데이터의 읽기를 허용하는 수준(READ_UNCOMMITED)이라면 커밋 여부와 무관하게 버퍼풀의 데이터를 읽어서 반환한다. 반대로 커밋된 데이터를 원하는 격리수준(READ_COMMITED, REPEATABLE_READ, SERIALIZABLE) 이라면 백업된 언두 영역의 데이터를 반환한다.

그러다가 커밋 또는 롤백이 호출되었다고 하자.

COMMIT;
ROLLBACK;

 

 

만약 COMMIT을 하면 더 이상의 변경 없이 지금의 상태를 영구적으로 만든다. 커밋이 된다고 언두 영역의 데이터가 항상 바로 삭제되지는 않는다. 언두 영역을 필요로 하는 트랜잭션이 더는 없을 때 비로소 삭제된다.

ROLLBACK을 하면 언두 영역에 백업된 데이터를 버퍼 풀로 다시 복구하고, 언두 영역의 내용은 삭제해버린다.

 

 

 

 

[ 잠금 없는 일관된 읽기(Consistent Non-Locking Read) ]

MySQL은 MVCC를 통해 락 없이 읽기 작업을 수행할 수 있다. 그래서 격리 수준이 SERIALIZABLE 만 아니라면 순수한 읽기 작업은 다른 트랜잭션과 무관하게 항상 잠금 대기 없이 바로 실행된다.

특정 사용자가 레코드를 변경하고 아직 커밋하지 않았더라도 데이터를 변경하는 트랜잭션이 다른 트랜잭션의 읽기 작업을 방해하지 않는다. 이를 잠금 없는 일관된 읽기(Consistent Non-Locking Read)라고 표현하며, 앞서 살펴보았듯 이는 언두 로그를 통해 가능하다.

 

 

 

 

[ 트랜잭션 주의 사항 ]

트랜잭션은 DBMS 커넥션과 마찬가지로 꼭 필요한 최소한의 코드에만 적용하는 것이 좋다. 즉, 트랜잭션의 범위를 최소화하여 꼭 필요한 곳에만 한정하는 것이 좋다.

예를 들어 사용자가 글을 작성한 후 저장 버튼을 클릭했을 때 다음과 같은 작업들이 처리된다고 하자. 다음의 절차들 중에서 DBMS의 트랜잭션 처리에 좋지 않은 부분을 찾아보도록 하자.

1) 처리 시작
  => 커넥션 풀에서 커넥션 객체 조회
  => 트랜잭션 시작
2) 사용자의 로그인 여부 확인
3) 사용자의 글쓰기 내용의 오류 여부 확인
4) 첨부로 업로드된 파일 확인 및 저장
5) 사용자의 입력 내용을 DBMS에 저장
6) 첨부 파일 정보를 DBMS에 저장
7) 저장된 내용 또는 기타 정보를 DBMS에서 조회
8) 게시물 등록에 대한 알림 메일 발송
9) 알림 메일 발송 이력을 DBMS에 저장
<= 트랜잭션 종료(Commit)
<= 커넥션 반납
10) 처리 완료

 

 

DBMS에 좋지 않은 영향을 주는 부분은 다음과 같다.

  • 불필요하게 트랜잭션이 빨리 시작되고 있음
  • 트랜잭션 범위에 네트워크 작업이 포함됨
  • 묶여도 되지 않는 트랜잭션이 불필요하게 묶여 있음

 

가장 먼저 트랜잭션이 불필요하게 빨리 시작된다는 문제가 있다. 실제로 많은 개발자들이 커넥션을 가져오는 코드를 1~2번 사이에 구현하지만 실제로 트랜잭션이 필요한 순간은 5번부터이므로 4번까지는 트랜잭션이 필요 없다. 사용 가능한 커넥션의 개수는 제한적이다. 만약 요청이 많아져서 사용할 수 있는 커넥션이 커넥션 풀에 없다면, 커넥션 풀이 반납되고 이를 가져오기 위한 대기 작업이 발생할 수 있다.

그 다음으로 트랜잭션 범위에 네트워크 작업이 포함된다는 문제가 있다. 만약 일시적으로 외부 서버와 통신할 수 없는 등의 상태가 된다면 커넥션을 계속 물고 있으면서 서버 뿐만 아니라 데이터베이스까지 위험해질 수 있다. 그러므로 오랜 시간 소요될 수 있는 메일 전송이나 FTP 파일 전송 등의 네트워크 통신 작업은 어떻게든 트랜잭션 범위에서 제거하는 것이 좋다.

마지막으로 묶여도 되지 않는 트랜잭션이 불필요하게 묶여 있다는 문제가 있다. 사용자가 입력한 정보를 저장하는 5번과 6번 작업은 세트이므로 반드시 하나의 트랜잭션으로 묶여야 한다. 하지만 7번 작업은 단순 조회이므로 트랜잭션에 포함될 필요가 없다. 또한 9번 작업 역시 성격이 다르므로 이전 트랜잭션(5번과 6번)에 묶일 필요가 없고 별도의 트랜잭션으로 분리하는 것이 좋다.

 

 

 

 

 

[ 트랜잭션 범위 안의 읽기와 트랜잭션 범위 밖의 읽기 ]

가끔 트랜잭션 내에서 실행되는 SELECT와 트랜잭션 없이 실행되는 SELECT의 차이를 혼동하는 경우가 있다. READ COMMITTED 수준에서는 트랜잭션 내에서 실행되는 SELECT와 트랜잭션 밖에서 실행되는 SELECT의 차이가 별로 없다. 하지만 REPEATABLE READ 격리 수준에서는 기본적으로 SELECT 문장도 트랜잭션 범위 내에서만 작동한다.

즉, START TRANSACTION (또는 BEGIN) 명령으로 트랜잭션을 시작한 상태에서 온종일 동일한 쿼리를 반복해서 실행해봐도 동일한 결과만 보게 된다. 아무리 다른 트랜잭션에서 그 데이터를 변경하고 COMMIT 해도 말이다. 별로 중요하지 않은 차이처럼 보이지만, 이런 문제로 데이터의 정합성이 깨지고 이로 인해 애플리케이션 버그가 발생하면 찾기가 쉽지 않다.

 

 

 

 

반응형