먼저 들어가기 전에, 이 글은 망나니 개발자님의 블로그를 보고 정리한 글임을 알립니다.
그럼 가장 먼저, 동시성 제어(Concurrency-Controll)에 대해 알아보겠다.
DBMS가 다수의 사용자 사이에서 동시에 작용하는 다중 트랜젝션의 상호간섭 작용에서 Database를 보호하는 것을 의미한다.
일반적으로 동시성을 높이면 일관성이 낮아지게 되며, 이들은 반비례 관계이다.
다수 사용자의 동시 접속을 위해 DBMS는 동시성 제어를 할 수 있도록 Lock기능과 SET TRANSACTION 명령어를 사용해 트랜젝션의 격리성 수준을 조정할 수 있는 기능도 제공한다. 격리성 수준에 관해서는 좀 있다 살펴보고, 먼저 동시성을 제어하는 방법을 알아보자. 여기엔 크게 두 가지 방법이 존재한다.
동시성 제어의 목표는 동시에 실행되는 트랜젝션 수를 최대화 하면서 입력, 수정, 삭제, 검색 시 데이터의 무결성을 유지하는데 있다. 따라서, 동시 업데이트가 거의 없는 경우라면 낙관적 잠금을 사용하면 되지만, 그렇지 않다면 비관적 제어를 사용해야 한다.
하지만 우리가 사용하는 OLTP는 대부분 동시 업데이트가 있으며, 따라서 비관적 제어를 많이 사용하게 된다. 그러면 이제 비관적 동시성 제어를 위한 대표적인 방법인 공유락과 배타락에 대해 알아보자.
간단하게 설명하면, 공유락은 읽기 잠금, 배타락은 쓰기 잠금이다.
만약 동일한 레코드에 대해 각각 공유락과 배타락을 가져간 경우, 다음과 같이 동작한다.
참고로, 획득한 락을 해제하는 방법은 결국 커밋과 롤백밖에 없다.
이러한 방식의 일반적인 Locking 매커니즘은 구현이 간단한 반면, 아래와 같은 문제점을 가지고 있다.
이러한 문제점들을 해결하기 위해 MVCC(Multi-Version Concurrency Control)이 나오게 되었다.
동시 접근을 허용하는 데이터베이스에서 동시성을 제어하기 위해 사용하는 방법 중 하나이다.
원본의 데이터와 변경중인 데이터를 동시에 유지하는 방식으로, 원본 데이터에 대한 Snapshot을 백업하여 보관한다. 만약 두 가지 버전의 데이터가 존재하는 상황에서 새로운 사용자가 데이터에 접근하면 데이터베이스의 Snapshot을 읽는다. 그러다가 변경이 취소되면 원본 Snapshot을 바탕으로 데이터를 복구하고, 만약 변경이 완료되면 최종적으로 디스크에 반영하는 방식으로 동작한다.
결국 MVCC는 스냅샷을 이용하는 방식으로, 기존의 데이터를 덮어 씌우는게 아니라 기존의 데이터를 바탕으로 이전 버전의 데이터와 비교해서 변경된 내용을 기록한다. 이렇게 해서 하나의 데이터에 대해 여러 버전의 데이터가 존재하기 되고, 사용자는 마지막 버전의 데이터를 읽게 된다. 이러한 구조를 지닌 MVCC의 특징을 정리하면 아래와 같다.
MVCC의 접근 방식은 잠금을 필요로 하지 않기 때문에 일반적인 RDBMS보다 매우 빠르게 작동한다. 또한 데이터를 읽기 시작할 때, 다른 사람이 그 데이터를 삭제하거나 수정하더라도 영향을 받지 않고 데이터를 사용할 수 있다. 대신 사용하지 않는 데이터가 계속 쌓이게 되므로 데이터를 정리하는 시스템이 필요하다. MVCC모델은 하나의 데이터에 대한 여러 버전의 데이터를 허용하기 때문에 데이터 버전이 충돌될 수 있으므로 어플리케이션 영역에서 이를 해결해야 한다. 또한 UNDO 블록 I/O, CR Copy 생성 등의 부가적인 오버헤드 또한 발생한다. 하지만 이러한 구조의 MVCC는 문장수준과 트랜젝션 수준의 읽기 일관성이 존재한다.
MySQL의 InnoDB에서는 Undo Log를 활용해 MVCC 기능을 구현한다. 이해를 위해 실제 쿼리문 예시를 가지고 살펴보도록 하자. 만약 아래와 같은 CREATE 쿼리문이 실행되었다고 하자.
CREATE TABLE member (
id INT NOT NULL,
name VARCHAR(20) NOT NULL,
area VARCHAR(100) NOT NULL,
PRIMARY KEY(id),
INDEX idx_area(area)
)
INSERT INTO member(id, name, area) VALUES (1, "Sang", "Daegu");
그러면 데이터는 다음과 같은 상태로 저장이 된다. 메모리와 디스크에 모드 해당 데이터가 동일하게 저장되는 것이다.

그리고 다음과 같이 UPDATE문을 실행시켰다고 하자.
UPDATE member SET area = "Busan" WHERE id = 1;
UPDATE 문이 실행된 결과를 그림으로 표현하면 다음과 같다. 먼저 COMMIT 실행 여부와 무관하게 InnoDB 버퍼 풀은 새로운 값으로 갱신된다. 그리고 Undo 로그에는 변경 전의 값들만 복사된다. 그리고 InnoDB 버퍼 풀의 내용은 백그라운드 쓰레드를 통해 디스크에 기록되는데, 디스크에도 반영되었는지 여부는 시점에 따라 다를 수 있다고 한다. 이는 InnoDB가 성능을 위해 버퍼 풀의 변경 내용을 즉시 데이터 파일에 반영하지 않고, 먼저 변경 이력은 Redo Log에 저장 후 나중에 여유가 있을 때 업데이트하는 방식이기 때문이다.

그럼 여기서 COMMIT이나 ROLLBACK이 호출되지 않은 상대에서 다른 사용자가 아래와 같은 쿼리로 데이터를 조회하면 어떤 결과가 반환될까?
SELECT * FROM member WHERE id = 1;
그 결과는 트랜젝션의 격리 수준에 따라 다르다. 만약 커밋되지 않은 내용도 조회하도록 해주는 READ_UNCOMMITTED라면 버퍼 풀의 데이터를 읽어서 반환하며, 이는 커밋 여부와 무관하게 변경된 데이터를 읽어 반환하는 것이다.
만약 READ_COMMITED 이나 그 이상의 격리 수준(REPEATABLE_READ, SERIALIZABLE)이라면 변경되기 이전의 Undo 로그 영역의 데이터를 반환하게 된다. 이것이 가능한 이유는 하나의 데이터에 대해 여러 버전을 관리하는 MVCC 덕분이다.
여기서 Undo Log 영역의 데이터는 커밋 혹은 롤백을 호출하여 InnoDB 버퍼풀도 이전의 데이터로 복구되고, 더 이상 언두 영역을 필요로 하는 트랜잭션이 더는 없을 때 비로소 삭제된다.
PostgreSQL은 다중 버전 행 저장 방식(Append-only)을 사용한다. 이를 이해하기 위해 UPDATE가 발생했을 때 내부 시스템 컬럼인 xmin(생성 트랜잭션 ID)과 xmax(삭제/업데이트 트랜잭션 ID)가 어떻게 변하는지 살펴보자.
PostgreSQL에서도 다음 INSERT문이 실행되었다고 하자.
INSERT INTO member(id, name, area) VALUES (1, 'Sang', 'Daegu'); -- 트랜잭션 ID: 100
그럼 테이블(Heap)에는 다음과 같은 컬럼이 생성된다.

여기서 xmin은 이 행을 생성한 트랜잭션 번호이고, xmax는 이 행을 삭제하거나 업데이트한 트랜잭션 번호이며 0은 현재 유효한 데이터임을 의미한다.
그럼 여기서 다음 UPDATE 컬럼이 들어온다면?
UPDATE member SET area = 'Busan' WHERE id = 1; -- 트랜잭션 ID: 101
PostgreSQL은 기존 데이터를 덮어쓰지 않고 새로운 행을 추가한다.

그럼 이러한 상태가 되고, 이때 커밋되지 않은 시점에서 다른 사용자가 조회하면 어떻게 될까?
PostgreSQL은 SELECT를 할 때 현재 커밋된 트랜잭션 목록이 담긴 스냅샷을 찍는다. 이를 통해 각 행의 xmin, xmax와 대조하고, 어떤 행을 보여줄지 결정한다.
만약 트랜젝션 101번이 진행 중이었다면, 다른 사용자의 스냅샷에겐 101번은 작업중이라고 표시되기 때문에 Daegu 값이 조회된다.
이러한 방식은 간단해 보이지만, 쓸모없는 레코드가 많아지므로, 이를 정리할 작업이 필요하며 이는 PostgreSQL에서 VACUUM이라는 작업으로 이뤄지고 있다.
VACUUM이 실행되면 엔진은 테이블의 각 페이지를 밑바닥부터 훑으며 다음 작업을 수행한다.
Dead Tuple 찾기
xmax가 이미 커밋된 트랜잭션 번호이고, 현재 이 데이터를 보고 있는 다른 활성 트랜잭션이 아무도 없다면 이를 Dead Tuple로 간주한다.
FSM(Free Space Map) 업데이트
가장 핵심이 되는 작업이며, Daegu가 있던 자리를 물리적으로 파일에서 삭제하는 게 아니라, 대신 FSM이라는 별도의 파일에 사용 가능한 공간이라고 적는다. 이후 insert작업이 들어오면 FSM을 참고해 저장한다.
Index 정리
테이블만 치운다고 끝이 아니며, 인덱스 또한 죽은 데이터를 가리키고 있기 때문에, 인덱스 페이지에서도 해당 항목을 정리하여 인덱스 효율을 높인다.