개요

 MySQL의 인덱스를 공부하면 가장 처음 등장하는 내용이 바로 클러스터링 인덱스논 클러스터링 인덱스다. 필자는 인덱스를 공부하기 전 데이터가 적재되는 구조를 이해하고 싶었다. 이를 이해해야 인덱스를 설정했을 때 내부 구조, 성능과 같은 것들을 쉽게 유추할 수 있다고 생각했기 때문이다. 그리고 B-Tree 가 등장했다. 유투브 영상을보며 어떤 메커니즘으로 데이터들이 적재되고 구조화되는지 이해했다.

 마지막으로 MySQL의 인덱스 구조에 대한 아래 이미지를 보고 필자가 공부했던 B-Tree를 적용시켜 이해하려했다. 그런데 이게 웬걸? MySQL의 인덱스구조와 B-Tree의 구조가 매칭되지 않았다. 이번 글에서는 MySQL의 인덱스 구조와 실제 B-Tree의 구조가 다른 이유를 이해하고, 클러스터링 인덱스와 논 클러스터링 인덱스에 대해 알아보도록 하겠다.

이게 B-Tree 구조가 맞아?

 

 

 


MySQL의 데이터 적재 구조는 B-Tree 가 맞긴 해?

 다양한 DBMS들이 존재하고, 이들의 데이터 적재 구조도 모두 다르다. 이 중 MySQL은 B-Tree 방식의 데이터 구조를 채택했다고 하는데(실제 MySQL 공식문서에서도 말이다.) 위 이미지를 보면 '이게 B-Tree가 맞아?' 라는 생각이 든다.

 결론부터 말하면 B-Tree가 맞긴하다. 다만 필자가 공부했던 기본 형태의 B-Tree 가 아니다. 변형된 형태의 B-Tree 이다. MySQL에서는 변형된 B-Tree인 B+Tree 를 사용한다!!


B+Tree 란

B-Tree의 변형된 형태의 트리이다. 필자가 공부하고 느낀 4가지의 주요 특징들을 정리해보았다.

 

B+Tree

 

1. 실제 데이터는 리프 노드에만 저장된다. 

2. 리프 노드들은 포인터로 연결된 연결 리스트 형태이다.

3. 내부 노드는 오직 탐색을 위해 존재한다. 즉, 실제 데이터(== 레코드 데이터)가 없다.

4. 노드가 꽉 찰 경우 상위노드로 격상시킨다. 단, 복제된 ID를 격상시킨다.

 

위 특징을 이해하면 B+Tree 와 MySQL의 데이터 적재 구조를 이해할 수 있고, 더 나아가 인덱스와도 연계시킬 수 있다. 익숙한 B-Tree 를 보고 B+Tree와 뭐가 다른지 알아보자.

 

B-Tree 구조 살펴보기

4차 B-Tree에 1~10까지의 ID를 넣은 구조이다. 살펴보면, 루트 노드, 내부 노드, 리프 노드에 ID 값이 중복없이 들어있다. 만약 실제 DB였다면, ID 값과 함께 실제 데이터도 들어갔을것이다. 루트 노드, 내부 노드, 리프 노드에도 ID 와 함께 실제 데이터가 들어갔을것이란 말이다.

B-Tree 구조

 

 

 이제 MySQL 데이터 구조와 대조해보자. 먼저 눈에 띄는 건 ID이다. 루트 노드, 내부 노드, 리프 노드에 ID 값이 중복되어 들어있다. 하나 더있다. 루트 노드(or 내부 노드)의 데이터에는 실제 데이터가 아닌 리프노드의 주소 값이 들어있고, 리프 노드에 실제 데이터가 들어있다는 것이다.

 B-Tree는ID 중복을 허용하지 않고, 루트 노드(or 내부노드)에는 실제 데이터가 들어있던것과 상반되는 내용이다. 이를 통해 B-Tree 구조가 실제 데이터 페이지의 트리 구조와 다를 수 있다는 것을 직감할 수 있다.

 

MySQL 데이터 구조

 

 


B+Tree 구조 살펴보기

마찬가지로 4차 B+Tree에 1~10까지의 ID를 넣은 구조이다. B-Tree와 마찬가지로 루트 노드, 내부 노드, 리프 노드에 ID 값이 들어있다. 단, 중복을 허용하지 않았던 B-Tree와는 다르게 중복된 ID가 많다. 여기서 하나 짚고 넘어갈게 있다. B-Tree 와 B+Tree의 노드들이 분리된 이유는 뭘까? 바로 '격상' 때문이다. B-Tree는 균형잡힌 트리로써 균형을 맞추기 위해 값을 격상시킨다.

 그럼 B-Tree는 중복 ID가 없고, B+Tree는 있는 이유가 뭘까? 4번 특징인 '노드가 꽉 찰 경우 상위노드로 격상시킨다. 단, 복제된 ID를 격상시킨다.' 때문이다. 격상된 노드의 ID에 대한 데이터는 자신의 하위노드에 대한 포인터가 저장된다. 

B+Tree

 

 

 

격상된 노드에 포인터를 저장하는 이유는 뭘까? B+Tree의 3번 특징인 '내부 노드는 오직 탐색을 위해 존재한다. 즉, 실제 데이터(== 레코드 데이터)가 없다.' 때문이다. 이정표 역할만 하는것이다. 그럼 실제 데이터는 어디에 저장될까? 1번 특징에 따라 실제 데이터는 리프 노드에만 저장된다.

 

 마지막으로 리프 노드들은 모두 오른쪽 리프노드의 포인트를 갖는 것을 볼 수 있다. 이말은 뭘까? 특정 리프노드에서 다음으로 큰 ID를 가진 리프노드로 바로 이동할 수 있다는 뜻이다. 2번 특징인 '리프 노드들은 포인터로 연결된 연결 리스트 형태이다.'에 대한 내용이다. MySQL InnoDB 엔진에서는 이러한 특성을 살려 Next Key Lock을 걸 수 있고, 팬텀 리드 현상을 방지할 수 있다. 이에 대한 내용은 아래 글을 참고하면 좋다.

 

https://tlatmsrud.tistory.com/196

 

[MySQL] InnoDB 스토리지 엔진 잠금 / 레코드, 갭, 넥스트 키 락 / 팬텀리드를 방지할 수 있는 이유

개요 InnoDB 스토리지 엔진은 MySQL 엔진에서 제공하는 잠금과는 별개로 레코드 기반의 잠금 방식을 탑재하고 있다. 테이블 기반의 잠금 방식을 채택하고 있는 MyISAM 엔진보다 동시성 처리 측면에서

tlatmsrud.tistory.com

 

 

B+Tree 와 MySQL 데이터 구조 살펴보기

이제 다시 MySQL의 실제 데이터 구조를 보자. ID 값이 여러 노드에 중복되어 있고, 루트노드(or 내부노드)는 실제 데이터가 아닌 리프노드의 주소 값을 갖고 있다. 리프 노드는 실제 데이터를 갖고있으며, 이 이미지에 표시되지 않았지만, 101 번 노드는 102번 노드, 102번 노드는 103번 노드에 대한 포인터 정보도 갖고있다. 그렇다. 앞서 설명했던 B+Tree 구조와 MySQL의 실제 데이터 구조가 동일한 것을 느낄것이다. 

 이제 MySQL에서 데이터가 B+Tree 구조로 적재되는 것을 이해했다. 다음으로 클러스터링 인덱스와 논 클러스터링 인덱스를 이해해보자.

 

MySQL 데이터 트리

 

 


클러스터링이란 ?

 클러스터링은 "여러개를 하나로 묶는다"라는 의미로 사용된다. 인덱스의 클러스터링도 이 의미를 벗어나지 않는다. 클러스터링 인덱스는 PK가 비슷한 레코들끼리 묶어 저장시키는 인덱싱 방식을 말한다. 방금 B+Tree 구조를 보면 알 수 있듯이 실제 데이터가 저장된 리프노드에는 비슷한 ID를 가진 데이터들이 모여있는 것을 알 수 있다.  

 

클러스터링 인덱스란 뭔가요? 🤔

 클러스터링 인덱스는 '키'에 대해서만 적용되는 인덱스이다. 즉, '키'가 비슷한 레코드끼리 묶어서 저장하는 것을 클러스터링 인덱스라고 한다. 클러스터링 인덱스는 테이블에 PK가 존재할 경우 자동 적용된다. 즉, 테이블을 설계할 때 PK를 설정하면 클러스터링 인덱스가 적용될것이고, 실제 레코드가 저장될 때 PK가 비슷한 레코드끼리 묶여서 저장된다. 클러스터링 인덱스를 설정하면 인덱스로 설정한 컬럼을 키로 하는 B+Tree 구조로 데이터를 정렬 저장하겠다는 뜻이다.

 

키 값이 변경되면 어떻게되나요? 🤔

 PK가 비슷한 레코드들끼리 묶여 저장된다는 뜻은 PK가 비슷하지 않은 레코드들은 비교적 떨어져있다는 뜻이다. 키가 변경된다면, 그리고 그 키가 클러스터링 인덱스였다면 PK에 대한 레코드의 위치, 즉, 물리적인 저장 위치가 바뀌게된다.

 

InnoDB 엔진에서 클러스터링 인덱스는 반드시 있어야한다!

 B+Tree의 구조 상 B+Tree의 키가 되는 값이 반드시 필요하다. 그런데 PK를 설정하지 않는다며 어떻게될까? 이 경우 InnoDB 스토리지 엔진은 다음 우선순위에 따라 클러스터링 인덱스를 자동으로 설정한다.

 

1. PK가 있으면 기본적으로 PK를 클러스터링 인덱스 키로 설정

2. NOT NULL 옵션의 유니크 인덱스 중에서 첫 번째 인덱스를 클러스터링 키로 설정

3. 자동으로 유니크한 값을 가지도록 증가되는 컬럼을 내부적으로 추가 후 클러스터링 키로 설정

 

유니크 인덱스
저장되는 값들이 중복되지 않도록 강제하는 인덱스로, UNIQUE 제약 조건을 설정하면 내부적으로 유니크 인덱스가 생성된다.

 

정리하면, '프라이머리 키 > 유니크 키 > 자동 생성 값' 순서로 클러스터링 인덱스를 설정한다.

 

자동 생성 값은 쓸모 없지 않아요? 🙁

자동 생성 값은 사용자 입장에선 쓸모없는게 맞다. 사용자가 볼 수 없고, 이 컬럼을 조건절로 조회 쿼리를 날릴수도 없다. B+Tree 구조를 전혀 활용하지 못하는 상태이다. 하지만 이 값이 없다면 B+Tree 구조에서 사용할 ID가 없는 상황이 된것과 같다. 자동 생성 값은 이런 상황때문에 필요하다. 오직 InnoDB의 B+Tree 저장구조를 유지하기 위해 만들어진 것이다. 사용자에게는 쓸모없고 불필요해도 데이터 구조를 유지하기 위해서 반드시 필요한 값인것이다.

 


논 클러스터링 인덱스가 뭔가요?

 테이블의 기본 데이터 정렬 순서와 무관하게 별도의 B+Tree로 관리되는 인덱스이다. 여기서 말하는 기본 데이터 정렬 순서란 클러스터링 인덱스 기반으로 정렬된 데이터의 순서이다. 풀어 말하면 클러스터링 인덱스 기반으로 정렬된 데이터의 순서와 무관하게 별도로 관리되는 인덱스이다.

 논 클러스터링 인덱스는 실제 데이터 페이지의 물리적 위치를 이동시키지 않는다. 너무 당연한 말이다. 실제 데이터 페이지는 클러스터링 인덱스에 의해 생성된 B+Tree에 있고, 이와 별개로 논 클러스터링 인덱스는 별도의 B+Tree를 생성/관리하기 때문이다. 아까 클러스터링 인덱스의 리프노드에는 실제 레코드 데이터가 들어있다고 했다. 논 클러스터링 인덱스의 데이터에는 실제 레코드 데이터가 아닌 데이터 레코드의 주소가 들어있다.

 

 아래 이미지는 논 클러스터링 인덱스의 원리 설명을 위해 여러 블로그에서 많이 사용하는 이미지이다. 여기서 Index Page는 논 컬러스터링 인덱스 구조, Data Page는 클러스터링 인덱스 구조라고 생각한다면 몇가지 의문점이 들것이다.

 

논 클러스터링 인덱스 구조 설명 이미지

 

첫째, Index Page의 ID가 1 ~ 15, Data Page의 ID도 1 ~ 15로 동일하다.

 논 클러스터링 인덱스의 ID가 DataPage의 ID와 어떤 연관성이 있는걸까? 아니다! 이는 카디널리티가 1~15 인 age와 같은 컬럼을 논 클러스터링 인덱스로 설정했다고 생각하면 된다. 만약 name 컬럼을 인덱스로 설정했다면 IndexPage의 각 인덱스 ID는 andrew, bob, sean, tom과 같은 문자열 값이 들어갔을것이다.

 

둘째, Data Page 의 ID가 정렬되어 있지 않다.

 Data Page는 클러스터링 인덱스의 리프노드라고 생각할것이다. 일반적인 방식으로 테이블을 설계한다 치면 PK로 ID를 넣었을 것이고, 그 말은 ID 가 정렬되어 저장되는게 맞다. PK를 설정하지 않았다고 하더라도 유니크 인덱스 ID 혹은 자동 생성 ID 기반으로 정렬되어 있을것이다.

 이 이미지의 Data Page는 클러스터링 인덱스로 생성된 리프노드 아닌 것이다. INSERT 한 순서대로 데이터들이 적재된 데이터 페이지였다. 논 클러스터링 인덱스의 데이터 값에 데이터 페이지의 주소가 들어갔다는 것을 중점으로 설명하기 위한 예시로 이해하자.

 

위 두 내용을 인지하고 논 클러스터링 인덱스 구조 설명 이미지를 다시봐보자. 왜 저렇게 설명했는지를 이해할 수 있을것이다.


회고

이번 포스팅을 통해 MySQL 데이터 구조와 B+Tree , 인덱스 개념을 연관시켜 이해할 수 있었다. 다음에는 인덱스 기반으로 조회 쿼리를 날렸을 때 어떻게 데이터를 스캔하는지에 대해 알아보도록 하겠다.

반응형

개요

 InnoDB 스토리지 엔진은 MySQL 엔진에서 제공하는 잠금과는 별개로 레코드 기반의 잠금 방식을 탑재하고 있다. 테이블 기반의 잠금 방식을 채택하고 있는 MyISAM 엔진보다 동시성 처리 측면에서 뛰어나다.  레코드 락 뿐 아니라 레코드와 레코드 사이의 간격을 잠그는 갭 락(GAP LOCK), 이 두 락을 합쳐놓은 형태의 넥스트 키 락(NEXT KEY LOCK)도 지원한다. 이번 게시글에서는 이 세가지 락에 대해 이해하고, 팬텀 리드와 연관지어 생각해보도록 하자.

 

 아마 트랜잭션 격리 수준에 대해 공부했던 경험이 있다면 MySQL에서는 팬텀리드를 방지할수 있다는 얘기를 들어봤을것이다. 이를 넥스트 키락과 연계시켜 이해해볼것이다.

* 참고로 갭 락은 타겟 레코드를 잠그지 않는다. 레코드와 레코드 사이의 빈 공간(GAP)만을 잠근다.

 

 

InnoDB 엔진 락

 


레코드 락 (Record Lock)

 말 그대로 레코드에 대한 락이다. 특정 레코드를 수정하거나 잠금과 함께 조회할 때, 그 레코드만을 잠그고, 나머지 레코드에는 자유롭게 접근할 수 있다. 단, 일반적인 레코드 락과 다른점이 하나 있는데, 레코드 자체가 아닌 인덱스의 레코드를 잠근다는 것이다. 인덱스가 없는 테이블이더라도 자동 생성된 클러스터드 인덱스를 이용해 잠금을 설정한다. 아래 쿼리는 id = 1 인 프라이머리 키(인덱스)를 통해 레코드를 찾음과 동시에 인덱스의 레코드에 락을 거는 것이다.

 

SELECT * FROM [table] WHERE id = 1 FOR UPDATE;

 

클러스터드 인덱스의 레코드 락

 

 

인덱스 레코드를 잠근다? 🤔

MySQL의 데이터들은 B+트리 구조로 관리된다. 즉, 인덱스의 레코드는 리프 노드를 의미한다. 클러스터드 인덱스의 경우 리프 노드에 실제 레코드 데이터들이 들어가있다. 즉, 리프노드에 잠금을 건다는 건 실제 레코드에 락을 획득해 접근할 수 없도록 잠그는것과 같다.

 

논 클러스터드 인덱스라면? 🤔

마찬가지로 논 클러스터드 인덱스의 리프 노드를 잠근다. 단, 실제 데이터 레코드에 접근하기 위해 클러스터드 인덱스를 사용해야 하는 상황이 발생하면, 클러스터드 인덱스의 리프 노드 또한 잠근다. 아래와 같이 users 테이블이 있고, email 컬럼에 대해 논 클러스터드 인덱스를 설정한 상태에서 실제 레코드 인덱스가 잠기는지 테스트해보자.

CREATE TABLE users (
  id INT PRIMARY KEY, -- 클러스터드 인덱스
  name VARCHAR(100),
  email VARCHAR(100)
);

CREATE INDEX idx_email ON users(email); -- 논 클러스터드 인덱스

-- 데이터 추가
insert into users values (1,'andrew','andrew@tistory.com');
insert into users values (2,'test','test@tistory.com');
insert into users values (3,'maple','maple@tistory.com');
insert into users values (4,'andrew2','andrew@tistory.com');

 

start transaction; -- 트랜잭션 1 시작

SELECT name FROM users WHERE email = 'andrew@tistory.com' FOR UPDATE; -- 논클러스터드 인덱스를 조건으로 검색



start transaction; -- 트랜잭션 2 시작

update users set name = 'andrew.sim' where email = 'andrew@tistory.com'; -- 논 클러스터드 인덱스 잠금으로 인한 대기
update users set name = 'andrew.sim' where id = 1; -- 클러스터드 인덱스 잠금으로 인한 대기
update users set name = 'andrew.sim' where id = 2; -- 클러스터드 인덱스가 잠기지 않았으므로 실행 완료

 

그 이후 email 컬럼을 잠금과 함께 조회한다면, 논 클러스터드 인덱스의 리프노드가 잠길것이다. 그런데 조회하는 컬럼 name이므로 이를 조회하기 위해서는 클러스터드 인덱스를 사용하여 데이터 레코드에 접근해야한다. 이때 클러스터드 인덱스의 리프노드도 함께 잠기게 된다.

 

더보기

B Tree와 B+Tree 시뮬레이션 해보기

 

B Tree와 B+Tree 가 실제로 어떻게 동작하는지 확인할 수 있는 시뮬레이션 사이트이다. 

 

https://www.cs.usfca.edu/~galles/visualization/BTree.html

 

B-Tree Visualization

 

www.cs.usfca.edu

 

https://www.cs.usfca.edu/~galles/visualization/BPlusTree.html

 

B+ Tree Visualization

 

www.cs.usfca.edu

 


갭 락 (GAP LOCK)

레코드와 레코드 사이를 잠그는 락이다. 갭 락의 역할은 레코드와 레코드 사이에 새로운 레코드가 생성(INSERT)되는 것을 제어하는 것이다. 갭 락은 단독으로 거의 사용되지 않고 넥스트 키 락의 일부로 사용된다.

 

레코드 락은 인덱스 레코드로 잠그는데... 갭락은 뭘로 잠그죠?  🤔

 레코드 락은 인덱스 레코드를 잠그는 것이라고 했다. 하지만 갭 락은 실제로 존재하는 뭔가가 없다. 레코드와 레코드 사이는 텅 빈 값인데, 도대체 어떻게 이 범위에 잠금을 거는걸까? 바로 메타데이터를 활용해 잠금을 건다. 인덱스 레코드에 존재하는 메타데이터에는 자신과 인접한 인덱스 레코드의 정보도 포함되어 있다. 이 정보를 활용해 갭 락을 거는 것이다.

 

 만약 책장에 5번 책과 10책이 꽂혀있다고 해보자. 5번 책과와 10번 책 사이에 무언가를 끼우는 행위를 막으려면 10번 책에에 포스트잇을 붙여 '이전 책은 5번 책입니다.' 또는 5번 책에 '다음 책은 10번 책입니다' 라는 정보를 기재해주면 된다. 그럼 5번 책을 보면 다음 책은 10번 책이니 5 ~ 10 사이에는 갭이 있다는 것을 알 수 있다.

 


넥스트 키 락 (NEXT KEY LOCK)

 레코드 락과 갭 락을 합쳐 놓은 형태의 잠금으로 락 기반의 조회 쿼리(SELECT ... FOR UPDATE)범위 조건(<, >, BETWEEN)을 걸었을 때 설정되며, 해당 레코드 뿐 아니라 갭(GAP)에도 락이 걸리게 된다. 레코드 락과 갭 락이 함께 걸리는 이유를 이해하기 위해서는 InnoDB 엔진의 B+트리 구조를 이해해야한다. B+트리 구조에 대해서는 따로 설명하지 않겠다. 여기서는 B+트리 구조와 넥스크 키 락의 관계와 팬텀리드를 방지할 수 있는 이유를 알아볼것이다.

 

넥스트 키 락이 팬텀 리드를 방지할 수 있는 이유? 🤔

 MySQL에서는 넥스트 키 락에 의해 팬텀리드 현상을 방지한다. 이 넥스트 키락이 존재할 수 있게 하는 것이 B+트리이다. B+트리의 특성을 활용해 넥스트 키 락을 걸고, 이 락에 의해 팬텀 리드 현상을 막게 되는 것이다. B+트리의 가장 큰 특성 중 하나는 리프노드가 오른쪽 리프노드의 포인터를 갖는 연결 구조로 되어있다는 것이다. 이를 활용한다면 특정 리프 노드는 다음으로 큰 리프노드에 빠르게 접근할 수 있다.

 그럼 아래와 같이 10 단위의 ID 가 있는 B+ 트리 구조에서 특정 범위에 대한 SELECT ... FOR UPDATE 쿼리를 날리면 어떻게 될까?

 

B+트리 구조

 

SELECT * FROM USERS WHERE ID >= 10 AND ID <= 55 FOR UPDATE;

 

 먼저 ID가 10 이상인 인덱스 레코드(리프노드)를 찾고, 인덱스 레코드를 잠근다. 그 후 포인터를 통해 오른쪽 리프노드인 20으로 이동한다.

 그 후 현재 인덱스 레코드의 ID가 55 이하인지를 체크한다. 55 이하일 경우 마찬가지로 인덱스 레코드를 잠그고 오른쪽 리프노드로 이동하기를 반복해나간다. 그러다 ID가 60인 값을 만났을 때 55 이하라는 조건에 만족하지 않아 멈추게 된다.

 최종적으로 10, 20, 30, 40, 50 인덱스 레코드가 잠기고(레코드 락), 각 인덱스 레코드의 메타데이터를 통해 갭도 잠기게 된다.(갭 락) 레코드 락과 갭 락이 동시에 걸렸다. B+트리의 특성을 활용해 넥스트 키 락을 건 것이다.

 

이제 넥스트 키 락이 걸렸을 때, 팬텀 리드를 방지할 수 있을지를 생각해보자. 넥스트 키락은 레코드와 갭을 잠근다. 해당 범위에 인덱스 레코드를 삽입할 수 없다는 뜻이다. 그럼 너무나 당연하게도 팬텀리드 현상을 방지할 수 있게된다.

 

넥스트 키 락이 팬텀 리드를 방지할 수 있는 이유! 🤩

 정리하면, MySQL은 InnoDB 엔진을 사용하기에 B+트리 구조로 데이터가 저장되고, B+트리 구조에서 넥스트 키 락을 걸 수 있으며, 넥스트 키 락에 의해 팬텀 리드 현상이 방지되는 것이다. 

 

MySQL에서는 팬텀 리드가 발생하지 않는다고도 하던데... 🤔

이건 잘못된 말이다. MySQL에서도 팬텀 리드 현상이 발생할 수 있다. 다만, 잠금을 사용하여 조회한다면 B+트리를 사용하는 InnoDB엔진 특성 상 넥스트 키 락이 걸릴거고, 넥스트 키 락에 의해 레코드와 갭이 잠기면서 팬텀리드 현상을 방지하게 되는것이다. 잠금을 사용하여 조회하지 않는다면?? 팬텀리드 현상은 당연히 발생한다.

 


자동 증가 락 (AUTO INCREMENT LOCK)

 자동 증가하는 숫자 값을 채번하기 위한 잠금으로 테이블 락과 함께 동작한다. AUTO_INCREMENT 속성이 사용된 컬럼의 테이블에 레코드가 INSERT 될때 잠기며, UPDATE나 DELETE 쿼리에서는 잠금이 걸리지 않는다. AUTO_INCREMENT 락을 명시적으로 획득하는 방법은 없으며, 아주 짧은 시간동안 걸리는 잠금이기에 대부분의 경우 문제가 되지 않는다.

 MYSQL 5.1 이상부터는 innodb_autoinc_lock_mode 라는 시스템 변수를 사용해 자동 증가 락의 작동 방식을 변경할 수 있다.

 

1. innodb_autoinc_lock_mode = 0

테이블 락 기반의 기본적인 자동 증가 락을 사용한다.

 

2. innodb_autoinc_lock_mode = 1

여러 건의 레코드를 INSERT 하는 중, MySQL 서버가 INSERT 되는 레코드의 건수를 정확히 예측할 수 있을 때 자동 증가 락을 사용하지 않고, 훨씬 가볍고 빠른 래치(Latch)를 이용해 처리한다. 자동 증가 락보다 훨씬 짧은 시간동안만 잠금을 걸고, 필요한 자동 증가 값을 한번에 가져온다. 다만, INSERT ... SELECT 와 같이 MySQL 서버가 건수를 예측할 수 없을 때는 자동 증가 락이 사용된다.

 

3. innodb_autoinc_lock_mode = 2 (MySQL 8.0 기본 값)

자동 증가 락은 사용하지 않고 래치만을 사용한다. 이 방식은 연속된 자동 증가 값을 보장하지 않지만, INSERT ... SELECT와 같이 대량 INSERT 문이 설정되는 중에도 다른 커넥션에서 INSERT를 수행할 수 있다.

 

래치(Latch)가 뭔가요? 🤔

InnoDB 내부에서 데이터 구조를 보호하기 위해 사용하는 경량의 잠금이다. 자동 증가 락은 락의 범위가 테이블로, 자동 증가 값인 AUTO_INC 값을 가져올때INSERT가 완료될때까지 잠금을 유지한다. 여러 트랜잭션이 INSERT 시 잠금이 끝날때까지 대기하며 순차적으로 처리되는 구조이다.
 이에 반해 래치(Latch)는 AUTO_INC 값을 가져올때만 잠금을 유지한다. 여러 트랜잭션이 INSERT 시 병렬 INSERT 처리가 가능한 것이다.

 

반응형

개요

 MySQL의 잠금은 크게 두가지로 MySQL 엔진, InnoDB 스토리지 엔진 잠금이 있다. 이번 포스팅에서는 먼저 MySQL 엔진에 대한 잠금을 알아보자.

 


MySQL 엔진과 InnoDB 스토리지 엔진이 뭔가요?

MySQL 엔진과 InnoDB 스토리지 엔진은 MySQL 서버의 구성요소이다. MySQL 엔진은 쿼리 파싱, 최적화, 실행 뿐 아니라 캐시, 커넥션과 같은 부가 기능들을 관리하고, InnoDB 스토리지 엔진은 실제 데이터의 저장, 디스크 I/O, 인덱스 관리와 같은 하드웨어 처리를 담당한다. 즉, MySQL 엔진이 쿼리를 실행하면, InnoDB 스토리지 엔진은 데이터를 읽고, 쓰는 역할을 하는것이다.

 


MySQL 엔진 잠금

 

1.  글로벌 락

 글로벌 락은 SELECT를 제외한 DDL이나 DML 문장에 대한 락으로, MySQL 서버에 존재하는 모든 테이블과 DB에 영향을 미치는 락이다. FLUSH TABLES WITH READ LOCK 명령으로 락을 획득한다.

 다만, 이미 UPDATE와 같은 DML 트랜잭션이 진행중이라면 끝날때까지 대기해야하는데, 읽기 잠금을 획득해야하기 때문이다. 또한 다른 트랜잭션에서 장시간 SELECT 쿼리를 실행하는 상황에서도 대기해야한다.

 글로벌 락은 mysqldump와 같이 일관된 백업을 받아야할 때 사용하곤 한다.

 

DDL (Data Definition Language)
데이터 구조(스키마)를 정의/변경하는 명령어이다.

 

DML (Data Manipulation Language)
데이터 자체 조작하는 명령어로 SELECT, INSERT, UPDATE, DELETE 가 있다.

 

 

SELECT 쿼리를 기다려야 하는 이유?

FLUSH TABLES WITH READ LOCK을 끊어 읽어보면 FLUSH, READ LOCK 이라는 단어가 눈에 띈다. FLUSH는 버퍼 풀의 레코드를 디스크에 저장하는 작업을, READ LOCK은 읽기 잠금을 뜻한다. 읽기 잠금인데 다른 트랜잭션에서 발생하는 SELECT 쿼리에 영향을 받는 이유는 뭘까? 글로벌 락은 당연하게도 READ LOCK 뿐 아니라 METADATA LOCK을 획득해야 하고, 더 나아가 MySQL의 테이블 핸들이 닫혀있어야 한다. SELECT 쿼리는 테이블 핸들을 필요(열어야하는)로 하는 작업이다.

 

테이블 핸들 (Table Handle)
접근하는 테이블을 가리키는 내부 객체이다. 쿼리를 통해 테이블을 액세스 하면 테이블 핸들이 열리고, 트랜잭션이 끝나면 핸들이 닫힌다.

 

 

글로벌 락 테스트해보기

총 두가지를 테스트해볼 것이다. 첫번째는 글로벌 락이 걸렸을 때 읽기 잠금이 걸리는지, 두번째는 테이블 핸들이 열렸을 때 글로벌 락을 획득하지 못하고 대기하는지이다.

 

1) 글로벌 락이 걸렸을 때 읽기 잠금 

 

1번 트랜잭션에서 글로벌 락 획득

# transaction 1

start transaction; // 트랜잭션 시작

FLUSH TABLES WITH READ LOCK; // 글로벌 락

 

 

2번 트랜잭션에서 읽기 잠금 테스트

# transaction 2

start transaction;

SELECT * FROM TBL_USER; //성공 : 테이블 데이터 조회됨

UPDATE TBL_USER SET NAME = '승경' WHERE USER_ID = 1; // 실패 : 글로벌 락으로 인함

 

 

 

2) 테이블 핸들이 열렸을 때 글로벌 락 획득 대기

 

1번 트랜잭션에서 select sleep from [table] 쿼리를 통해 테이블 핸들 오픈

# transaction 1 

start transaction;

select sleep(10) from tbl_user; // tbl_user 에 로우 개수만큼 10초씩 sleep (테이블 핸들을 열기 위함)

 

2번 트랜잭션에서 글로벌 락 획득 시도

# transaction 2

start transaction;

FLUSH TABLES WITH READ LOCK; // 실패 또는 SELECT 작업이 끝난 후 성공

 

 

 

2.  테이블 락

 

테이블 락은 테이블 단위로 설정되는 잠금으로 명시적, 묵시적으로 락을 획득할 수 있다. 명시적 테이블 락은 'LOCK TABLES [TABLE NAME] [ READ or WRITE ]' 명령으로 획득할 수 있다. 단, 테이블 락은 어플리케이션에 상당한 영향을 끼치므로 실무에서는 거의 사용되지 않는다.

 묵시적 테이블 락은 DDL 쿼리 실행 시 발생하며, 읽기/쓰기를 모두 차단한다. DML 쿼리에 대해서는 스토리지 엔진에 따라 달리 동작하는데, MyISAM 또는 MEMORY 스토리지 엔진의 경우 자동으로 테이블 락이 발생한다. 데이터가 변경되는 테이블에 잠금을 설정하고 데이터를 변경한 후, 즉시 잠금이 해제되는 프로세스이다. InnoDB 스토리지 엔진은 엔진 차원에서 레코드 기반의 잠금을 제공하기 때문에 단순 DML 쿼리로 테이블 락이 설정되지 않는다.

 

DDL 쿼리도 트랜잭션처럼 Commit 해야 반영되나요?

맞다. Commit을 해야 실제로 반영된다. 다만, DDL 쿼리는 Auto Commit 으로 처리된다.

 

테이블 락  테스트해보기

테이블에 쓰기 잠금을 걸었을 때 테이블 데이터를 조회, 수정하는 쿼리를 날려보자.

 

1) 1번 트랜잭션에서 WRITE LOCK 실행

start transaction;

LOCK TABLES tbl_user WRITE; // tbl_user에 대한 Write Lock

 

 

2) 2번 트랜잭션에서 SELECT 및 UPDATE 쿼리 실행

start transaction;

select * from tbl_user; // 실패 : 테이블 Write Lock 으로 인함
update tbl_user set name = '승경' where user_id = 1; // 실패 : 테이블 Write Lock 으로 인함

 

 

 

3.  네임드 락

 네임드락은 임의의 문자열에 대한 잠금으로, GET_LOCK() 함수를 사용한다. 이 잠금은 테이블이나 레코드, 데이터 베이스 객체가 아닌 단순히 사용자가 지정한 문자열에 대한 잠금을 획득하고 반납하는 잠금이다. 반환은 RELEASE_LOCK() 함수를 사용한다.

 

 네임드 락을 사용하는 예는 다중 어플리케이션에서의 배치 실행이다. 다중 어플리케이션에서 한 번의 배치만 돌게 하기 위해 네임드 락을 사용하기도 한다.

SELECT GET_LOCK('batch_lock', 0); // batch_lock 획득 시도, 획득 대기시간 없음(0초)

 

 여러 어플리케이션에서 동시에 batch_lock 이라는 네임드 락 획득을 시도하면 최초 획득한 트랜잭션의 어플리케이션만 배치가 돌고, 나머지는 대기 시간이 없어 실패와 함께 배치 실행을 하지 않게 된다.

 

 네임드 락을 사용하는 또 다른 예는 비지니스 로직에 대한 잠금 범위를 지정하고 싶을때이다. 특정 로직이 실행되기 전 네임드 락을 획득하고, 로직이 끝난 후 네임드 락을 반환한다면, 멀티 스레드 환경에서 해당 로직이 순차적으로 처리될것이다.

// 비지니스 로직 시작
...
SELECT GET_LOCK('service_1', 100); // 네임드 락 획득
...
// 비지니스 로직 종료
...
SELECT RELEASE_LOCK('service_1'); // 네임드 락 반환

 

단, 락을 획득한 후 해제해주지 않으면 시스템이 멈추는 위험을 초래할 수 있고, 락 이름 충돌 가능성이 있으므로 구분할 수 있는 prefix를 붙여주는 것이 좋다.

 

 

4.  메타 데이터 락

 메타 데이터 락은 테이블이나 뷰 등 DB 객체의 이름이나 구조를 변경하는 경우에 획득하는 잠금이다. 이 락은 자동으로 획득/반환되는 묵시적 잠금이다. 앞서 설명한 글로벌 락, 테이블 락이 발생할 경우 메타 데이터 락도 함께 획득한다.

 

 

 

 

반응형

개요

슬로우 쿼리 개선을 위해 스프링 부트 환경에서 AOP 기반으로 슬로우 쿼리 로그를 남기는 인터셉터를 구현했다. 다만, JPA 환경이냐 Mybatis 환경이냐에 따라 다른 방식으로의 구현이 필요했고, Interceptor 타겟을 설정하는 부분에 따라 로깅을 남기는 구조였기 때문에 '정말 모든 쿼리를 다 체크하고 있을까?' 라는 의문이 들었다.

 

 그러던 중 Real MySQL의 슬로우 쿼리 관련 챕터를 읽게 되었고, MySQL 자체에 슬로우 쿼리 관련 설정이 있다는 것을 알게 되었다. 이 설정들을 적용해보고, 어떤 형태로 슬로우 쿼리가 추출되는지 확인해보았다.

 

MySQL 슬로우 쿼리 로깅 활성화

 

먼저 MySQL에 접속 후 여러 옵션값들을 확인하고 설정해야한다.

 

1) slow_query_log

슬로우 쿼리 로깅 활성화 여부를 나타내는 설정값이다. 기본값은 OFF이므로 ON 또는 1로 설정한다.

show variables like '%slow_query_log%'; // 슬로우쿼리 활성화 여부 확인

set global slow_query_log = 'ON'; // 슬로우쿼리 활성화

 

2) long_query_time

슬로우 쿼리에 대한 기준 시간을 나타내는 설정값이다. 기본값은 10초로 되어있어 1초로 수정하였다.

show variables like '%long_query_time%'; // 슬로우 쿼리 기준 시간 확인

set global long_query_time = 1; // 슬로우 쿼리 기준 시간을 1초로 변경

 

 

set global 명령어를 통해 변수들을 설정할 경우 MySQL이 재시작될 때 설정값들이 초기화된다. 영구 설정을 하고자하면 my.ini나 my.cnf 파일을 다음과 같이 수정하면 된다.

// my.ini 파일
[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/mysql-slow.log
long_query_time = 1

 

 

3) log_output

슬로우 쿼리를 남길 수 있는 방법은 두 가지로, 테이블 혹은 디스크이다. 온프레미스 환경에서는 디스크에 저장하는 것을 선호한다. 테이블로 설정할 경우 슬로우 쿼리가 발생할때마다 쓰기 작업이 발생하며, 데이터가 많아질 경우 부하가 발생할 수 있기 때문인데, 이를 방지하기 위해 슬로우 쿼리에 적재된 데이터를 주기적으로 삭제해주는 작업이 필요하다.

 AWS RDS 환경일 경우 파일 시스템에 직접 접근하지 못하므로 테이블에 저장하기도 한다. 참고로, 디스크로 설정한 후 RDS 설정에 SlowQuery Logging 을 활성화하면 Cloud Watch 의 로그 그룹에 저장된다. 필자의 경우 테이블에 적재되도록 설정하였다.

 

show variables like '%log_output%'; // 슬로우 쿼리 저장 타입 확인 (TABLE or FILE)

set global log_output = 'TABLE'; // 슬로우 쿼리를 테이블에 저장하도록 설정

 

 

슬로우 쿼리 테이블 확인

슬로우 쿼리 테이블은 mysql.slow_log 이다. 아래 명령어를 사용해 데이터가 쌓였는지 확인해보자. 아마 비어있을건데, 다음은 슬로우 쿼리를 직접 발생시켜 적재되는지를 확인해볼 것이다.

select * from mysql.slow_log order by start_time desc;

 

 

컬럼 설명
query_time 쿼리 실행 시간 (실질적 시간)
lock_time 테이블 락 시간
rows_sent 클라이언트에 전송된 행 수
rows_examined 스캔한 전체 행 수

* rows_examined 값이 매우 크면 인덱스가 제대로 안 쓰이고 있을 가능성이 높다.

 

슬로우 쿼리 발생시키기

 

슬로우 쿼리가 발생했을 때, mysql.slow_log 테이블에 적재되는지 확인하기 위해 select sleep(2) 쿼리를 날려보자.

 만약 슬로우 쿼리에 로그가 남지 않는다면, 아까 설정했던 global 설정 값들이 현재 세션에는 적용되지 않은 상태일 가능성이 있다. 이 경우 재접속하면 된다.

select sleep(2);
select * from mysql.slow_log order by start_time

mysql.slow_log 조회 결과

 

sql_text 값이 BLOB 형태로 출력된다면 아래 쿼리를 통해 조회하면 된다.

SELECT 
  start_time,
  user_host,
  CAST(sql_text AS CHAR) AS sql_text,
  query_time,
  lock_time,
  rows_sent,
  rows_examined
FROM mysql.slow_log;

 

 

 

 

 

 

반응형

 

1. 개요

 리두로그와 언두로그를 공부하려했더니 Buffer Pool 이라는 개념이 등장했다. 테이블을 메모리에 캐싱해두는 개념인데, 단순 개념만 알고있기에는 중요한 내용이기에 정리하였다.

 

2. InnoDB Buffer Pool이 뭐에요?

말 그대로 InnoDB 엔진에서 제공하는 버퍼 풀인데, 이녀석의 역할은 바로 캐싱이다.

테이블이나 인덱스를 캐싱한다고 한다. 그럼 정확히 어떤 형태로 캐싱을 하고, 실제 환경에서 어떻게 사용되는지 알아보자.

 

3. Buffer Pool 은 '페이지'들이 캐싱되어 있어요

 버퍼 풀에는 '페이지'들이 저장되어 있다. InnoDB 엔진에서의 페이지란 데이터베이스에서 데이터를 저장 단위이다. I/O를 통해 디스크로부터 페이지 단위로 데이터를 조회하면, Buffer Pool에 이를 그대로 저장한다. 페이지 하나의 크기는 16KB이다. (수정 가능하다)

 페이지들은 버퍼 풀에 차곡차곡 쌓이는게 아니다. 버퍼 풀은 데이터를 효율적으로 캐싱하기 위해 LRU 리스트, Flush 리스트, Free 리스트 라는 3개의 리스트로 구성되어 있으며, 이 중 LRU 리스트에 어떤 규칙에 따라 저장되게 된다.

 

3.1. Free 리스트

비어있는 페이지들의 목록이다. 사용자의 쿼리가 디스크의 데이터 페이지를 읽어와야 하는 경우 사용된다. 

 

3.2. Flush 리스트

디스크로 동기화되지 않은 데이터를 가진 데이터 페이지(== 더티 페이지)들의 목록이다. 즉, 디스크에서 데이터 페이지를 읽은 후 변경이 가해진 데이터 페이지들의 목록이다. 데이터가 변경되면 해당 페이지는 Flush 리스트에서 관리됨과 동시에 변경 내용을 리두 로그에 기록하고, 버퍼 풀에도 변경 내용을 반영한다. 그 후 특정 시점에 리두 로그가 디스크로 기록된다.

 

3.3. LRU (+MRU) 리스트

MySQL InnoDB 에서는 변형된 LRU 방식을 채택하고 있다. 바로 LRU와 MRU(Most Recently Used)가 결합된 하이브리드 방식이다. 참고로 LRU와 MRU는 캐시 알고리즘이다.

 

* LRU (Least Recently Used)
가장 최근에 사용되지 않은 페이지를 교체하는 알고리즘. 가장 오래된 캐시를 제거한다.

 

 

* MRU (Most Recently Used)
가장 최근에 사용된 페이지를 교체하는 알고리즘. 가장 최근에 사용된 캐시를 제거한다.

 

 

4. 가장 오래된 페이지와 가장 최근의 페이지를 제거한다고??

위 두 캐시 알고리즘이 버퍼 풀에 적용되어 있는 구조이다. 이해가 잘 되지 않는다면 어떻게 동작하는지에 대한 메커니즘을 그려나가보면 이해할 수 있을것이다.

 

아래는 변형된 LRU, 즉 LRU와 MRU가 결합된 형태의 리스트이다.

LRU+MRU 리스트

 

InnoDB에서의 페이지 캐시 메커니즘에 의해 New 서브리스트 자주 사용되는 페이지가 저장되는 영역, Old 서브리스트는 신규 혹은 자주 사용되지 않는 페이지가 저장되는 영역으로 사용될것이다. 편의상 New 서브 리스트가 존재하는 영역을 MRU 영역, Old 서브 리스트가 존재하는 영역을 LRU 영역이라 표현하겠다.

긴말 필요없이 바로 동작 메커니즘을 살펴보자

 

1) 필요한 레코드가 저장된 데이터 페이지가 버퍼 풀에 있는지 검사한다. [InnoDB 어댑티브 해시 인덱스 > 테이블 인덱스] 를 통해 페이지를 검색한다. 

* 어댑티브 해시 인덱스
B-Tree 의 성능 개선을 위해 InnoDB 에서 자동으로 추가하는 인덱스이다. 자주 읽히는 데이터 페이지의 키값을 이용해 해시 인덱스를 만들고, 필요할 때마다 해당 데이터 페이지로 즉시 찾아갈 수 있게 한다.
- LRU/MRU를 설명하는 글이므로 자세히 다루지 않겠다.

 

2) 버퍼 풀에 데이터 페이지가 있다면 MRU 영역 방향으로 승급한다. (MRU 리스트로 들어가는게 아닌 방향으로 승급이다.)

 

3) 버퍼 풀에 데이터가 없다면 디스크로부터 필요한 데이터 페이지를 버퍼 풀에 적재와 동시에 LRU 영역 Head 부분에 추가한다.

 

4) LRU 영역에 추가한 데이터 페이지가 조회되면 MRU 영역 방향으로 승급하고, 버퍼풀에 없다면 LRU Head 부분에 추가하는 과정을 반복한다.

 

5) 이때, 승급하는 데이터 페이지가 LRU 영역의 Head에 위치했을 경우, 바로 위인 MRU 영역의 Tail이 아닌 MRU 영역의 Head로 이동한다. (이부분이 핵심이다!)

 

6) 1,2,3,4,5 과정이 반복되면서 자주 사용되지 않는 데이터 페이지들은 자연스럽게 LRU 영역의 Tail 쪽으로 밀려난다. 끝까지 밀려난 데이터 페이지들은 버퍼 풀에서 제거된다.

 

7) 자주 사용되는 데이터 페이지의 경우 성능 향상을 위해 페이지의 인덱스 키를 '어댑티브 해시 인덱스'에 추가한다.

 

5. LRU, 오래된 데이터가 제거되는 것은 이해가는데.. MRU는?

LRU 알고리즘이 적용됐다는 것은 이해가 갈것이다. 그럼 최근에 사용된 데이터가 먼저 삭제된다는 MRU는 대체 어디에 적용된 것일까? 데이터 레코드를 버퍼 풀로 읽어오는 과정을 직접 그려보며 '최근 사용된 데이터가 먼저 삭제'되는 MRU 부분을 이해해보자.

 

1) 1시 30분, 디스크로부터 A 데이터 페이지를 읽어 버퍼 풀에 적재한다. 최초 버퍼 풀 적재시에는 LRU 영역 Head 부분에 저장된다.

A 데이터 레코드 조회

 

2) 1시 32분, 디스크로부터 B 데이터 페이지를 읽어 버퍼 풀에 적재한다. LRU 영역 Head 부분에 저장됨과 동시에 기존 A 데이터 페이지는 Tail 쪽으로 밀려난다.

 

B 데이터 레코드 조회

 

 

3) 2시 0분, 디스크로부터 C 데이터 페이지를 읽어 버퍼 풀에 적재한다. LRU 영역 Head 부분에 저장됨과 동시에 A, B 데이터 페이지는 Tail 쪽으로 밀려난다.

 

 

 

4) 2시 10분, 드디어 C 데이터 페이지를 찾는 쿼리가 실행됐고, C 데이터 페이지를 버퍼 풀에서 로드하게 된다. 이때 C 데이터 페이지는 LRU 영역의 Head에 위치했기 때문에 MRU 영역의 Head로 이동하게 된다.

 

 

5) 2시 20분, 디스크로부터 D 데이터 페이지를 읽어 버퍼 풀에 적재한다. LRU 영역 Head 부분에 저장됨과 동시에 A, B 데이터 페이지는 Tail 쪽으로 밀려난다.

 

 

6) 계속해서 신규 쿼리가 들어옴에 따라 디스크로부터 데이터 페이지를 읽어오게 되었다. E, F, G... X, Y, Z 까지, 다양한 데이터 페이지를 디스크로부터 로드하다보니 5시 30분이 되었다.

 

6. 최신의 데이터가 먼저 제거된다!

2시 10분에 저장한 C 데이터 페이지보다 최근에 사용했었던 E, F, G, H... 들의 데이터 페이지가 버퍼 풀에서 먼저 제거되었다. MRU 영역에서도 자주 사용되지 않은 데이터 페이지들은 Tail 쪽으로 밀려난다. 단, MRU 영역에 있는 C 데이터 페이지가 삭제되지 않은 이유는, 다른 데이터 페이지들이 여러번 재사용되지 않았고, 이로 인해 MRU 영역에서의 '경쟁'이 발생하지 않았기 때문이다. 

 

 '최근 사용된 데이터인 E,F,G,H 등의 데이터 페이지들이 먼저 삭제'된다는 점에서 MRU 알고리즘이 적용되었음을 알 수 있다.

 

Z 레코드까지 LRU 리스트에 저장됨

 

 

7. 정리하면... InnoDB의 LRU + MRU 버퍼 풀

 InnoDB 버퍼 풀은 하이브리드 방식을 사용함으로써 단순 오래된 데이터 페이지 뿐 아니라, 자주 사용되지 않은 최신의 데이터 페이지들도 제거하고 있다. 만약 MRU 알고리즘이 적용되지 않았다면 어떨까? 최근 액세스한 데이터 페이지 들 중 자주 사용되지 않아 사실상 필요없는 데이터 페이지가 메모리에서 공간만 차지할것이고, 버퍼 풀이 가득 차서야 제거될것이다. 캐시 메모리를 최대한 활용하기 위해서는 자주 사용하는 데이터를 많이 저장하고, 최신의 데이터라할지라도 자주 사용하지 않은 데이터는 제거하는 것이 좋지 않겠는가?!

 

오늘은 InnoDB Buffer Pool에 대한 기본 구조와 InnoDB의 LRU + MRU 리스트에 대해 알아보았다. 다음은 리두 로그와 언두 로그에 대해 공부해보도록 하겠다!

 

 

 

 

반응형

개요

InnoDB의 잠금에 대해 알아보기 전 비관적 잠금과 낙관적 잠금에 대해 알아보았다.

 

낙관적 잠금

각 트랜잭션이 같은 레코드를 변경할 가능성이 낮다고 낙관적으로 가정하는 상황에서의 잠금이다. 리소스를 잠그지 않고 데이터를 업데이트 한 후, 마지막 커밋 시 충돌을 확인하는 방식이다.

충돌이 감지되면(트랜잭션이 시작되고 끝나는 사이, 다른 트랜잭션이 데이터를 수정) 트랜잭션이 롤백되고 오류를 반환한다.

 

충돌을 어떻게 확인할까?

테이블에 수정 시점을 추적할 수 있는 버전 필드 또는 타임스탬프를 추가하는 것이다.

버전 필드 (Version Field)
낙관적 잠금에서 각 데이터 레코드에 추가되는 필드로, 해당 레코드가 마지막으로 수정된 시점을 추적하는 용도로 사용된다. 레코드가 업데이트 될 때마다 이 버전 번호가 증가하는 형태이다.

 

트랜잭션이 데이터를 읽을 때 버전 필드를 함께 읽고, 트랜잭션이 커밋할 때 버전 번호를 확인하여 일치하는지를 확인한다. 만약 버전 필드 값이 달라졌다면, 다른 트랜잭션에서 데이터를 수정한 것이라 판단하고 트랜잭션을 롤백 또는 재시작한다.

 타임스탬프를 사용할 경우 해당 레코드의 수정 시간을 기재한다. 마찬가지로 트랜잭션이 시작, 종료될 때 레코드의 수정 시간이 달라졌다면 롤백을, 같다면 커밋을 하는 방식이다.

 

-- 1. 데이터 읽을 때 버전 정보도 함께 읽음
SELECT product_id, product_name, price, version
FROM products
WHERE product_id = 1;

-- 2. 수정 시 버전 확인
UPDATE products
SET price = 200.00, version = version + 1
WHERE product_id = 1 AND version = 2;  -- '2'는 읽은 버전 번호

 

낙관적 잠금 장단점

자원에 대한 잠금이 없기때문에, 대기 시간이 짧고, 처리 속도가 향상될 수 있다. 특히 데이터 충돌이 드문 경우에는 트랜잭션이 빠르게 처리된다. 또한 여러 트랜잭션이 동시에 데이터를 읽고 수정할 수 있으므로, 데이터 충돌이 발생하는 상황이 적다면 그만큼 높은 동시성을 유지하게된다.

 

하지만 데이터 충돌이 발생할 경우 트랜잭션을 다시 시도해야 한다.  충돌이 자주 발생하거나 처리 비용이 높은 트랜잭션이라면, 재시도 비용도 증가하게 된다. 또한 충돌에 대한 예외 로직이 필요하다. 변경을 다시 시도하거나, 사용자에게 알림을 주는 등의 별도 처리가 필요하다. 이러한 예외 코드를 잘못 구현하기라도 한다면 데이터 무결성이 손상될수 있으므로 주의해야한다.

 

 

비관적 잠금

각 트랜잭션이 같은 레코드를 변경할 가능성이 높다고 비관적으로 가정하는 상황에서의 잠금이다.즉, 하나의 트랜잭션이 데이터를 조회하는 시점에 락을 걸고, 트랜잭션이 끝날때까지 유지한다.

조회 시점에 잠금을 획득하는 대표적인 방법은 SELECT FOR UPDATE 이다.

 

비관적 잠금의 장단점

트랜잭션의 동시 접근을 확실하게 방지할 수 있기에 데이터의 무결성이 보장된다.

 

하지만 동시성이 떨어져 처리 속도가 느리다. 트랜잭션이 각각의 자원을 점유하는 상태에서 서로의 자원을 요청한다면, 데드락이 발생하게 된다.

 

비관적 잠금 데드락 재현

users 테이블

 

1) 1번 트랜잭션이 users 테이블의 id = 1인 레코드에 대해 비관적 잠금을 설정한다. 트랜잭션은 끝나지 않고, 다른 작업들을 처리하고 있다.

 

select * from users where id = 1 FOR UPDATE;

 

2) 도중 2번 트랜잭션이 시작되었고, users 테이블의 id = 10인 레코드에 대해 비관적 잠금을 설정한다. 마찬가지로 트랜잭션은 끝나지 않고, 다른 작업들을 처리하고 있다.

 

select * from users where id = 10 FOR UPDATE;

 

3) 1번 트랜잭션의 작업 중 유저 정보 일괄 수정 위해 업데이트 쿼리가 나갔지만, 2번 트랜잭션에서 id = 10인 레코드에 대한 잠금으로 인해 '대기상태'에 들어간다.

 

update users set name = '이름';

 

4) 2번 트랜잭션에서도 마찬가지로 유저 정보 일괄 수정 위해 업데이트 쿼리가 나갔지만, 1번 트랜잭션에서 id = 1인 레코드에 대한 잠금으로 인해 '대기상태'에 들어간다.

 

5) 트랜잭션 1, 2번은 데드락 상태에 빠지게 된다.

 

하지만 실제 테스트를 해보니 무기한 데드락이 걸리지 않고 한쪽 트랜잭션을 롤백시키는 것을 볼 수 있는데, 이는 InnoDB에서 제공하는 자동 데드락 탐지 기능에 의해 한쪽 트랜잭션이 롤백되는 것이다.

데드락 상황을 인지하고, 트랜잭션 재시작

 

또한 락에 대한 타임아웃도 설정되어있기에, 락이 걸린 자원을 획득하지 못한다면 무한정 대기상태에 들어가지 않고, 에러 및 롤백 후 트랜잭션 재시작을 시도한다.

Lock wait timeout 초과 시 트랜잭션 재시작

 

잠금에 대한 대기 시간 변수는 innodb_lock_wait_timeout이며 , 기본 50초로 설정되어 있다.

innodb_lock_wait_timeout

 

 

반응형

1. 개요

 우리가 설치하고, 쿼리를 작성하는 MySQL 서버는 크게 두 엔진으로 구성된다. 하나는 MySQL 엔진, 하나는 스토리지 엔진. 스토리지 엔진은 데이터를 저장하는 하드웨어와 관련된 엔진이고, MySQL 엔진은 그 외 알쏭달쏭한 모든것이라고 생각하면 된다. 

MySQL 엔진과 스토리지 엔진

 

뭔 개소린가 싶겠지만 일단은 이렇게 이해하고 시작해보도록 하자. 이게 정신건강에는 좋은것같다.


2. MySQL 전체 구조 핥아보기

 상당히 친해보이는 두 엔진이 MySQL 어디에 속하는지 알아보자. 아래는 Real MySQL8.0 에 기재된 MySQL 서버 구조이다.

 

MySQL 서버의 전체 구조

 

 우리의 몸에 여러 기관들이 있듯이 하나의 엔진에도 여러 요소들이 존재한다. 각 요소들을 살펴보기 전에 그림에 나온 MySQL 서버의 전체 구조를 이해해보자.

 


3. MySQL 서버 전체 구조

MySQL 서버 전체 구조는 크게 프로그래밍 API, MySQL 서버, 운영체제 하드웨어로 나뉜다.

1) 프로그래밍 API 

MySQL 서버로 들어오는 요청에 대한 API를 말한다. Java 진영의 백엔드 개발자 입장에서는 JPA, MyBatis 를 사용해 MySQL 서버로 연동하는 부분이라 생각하면 된다.

2) MySQL 서버

우리가 배워야할 MySQL 엔진과 스토리지 엔진으로 구성된 서버이다. 생소한 용어들이 대부분이나 InnoDB는 뭔가 익숙하다. 일단 넘어가자.

3) 운영체제 하드웨어

DB 데이터는 운영체제 내 디스크에 저장된다. 단순 DB 데이터 뿐 아니라 로그파일도 저장된다.


4. MySQL 엔진 요소

1) 커넥션 핸들러 

 MySQL을 설치하고 가장 먼저 MySQL 서버에 접속한다. 접속에 성공하면 MySQL 서버와 접속한 클라이언트 간 커넥션이 생성된다. 커넥션 핸들러는 이러한 커넥션을 생성하고 클라이언트의 쿼리 요청, 즉, 커넥션에 관한 처리들을 핸들링한다.

 여기서 중요한건 '쿼리 요청을 핸들링'하는 것이다. 커넥션 핸들러가 쿼리를 분석하여 '처리'하진 않는다. 쿼리 요청이 오면 쿼리를 처리하는 어떤 녀석에게 "select 쿼리 처리해주세요!, insert 쿼리 처리해주세요!" 처럼 요청할 뿐이다.

 

2) SQL 인터페이스

DML, DDL, 프로시져, 뷰, 커서, 트리거, 함수 등의 지원을 위한 인터페이스를 말한다.

 

3) 쿼리 파서 (SQL 파서)

 커넥션 핸들러로부터 들어온 요청을 처리하며, 요청받은 쿼리 문장을 토큰(MySQL이 인식할 수 있는 최소 단위의 어휘나 기호)으로 분리해 트리 형태의 구조로 만들어준다. 이러한 트리형태의 데이터를 '파서 트리'라고 하며, 이 과정에서 문법 오류를 체크한다.

 

4) SQL 옵티마이저

 파서 트리를 효율적으로 처리하기 위한 실행 계획을 결정한다. 여기서 결정된 실행 계획에 따라 쿼리 처리 속도나 비용이 달라지게 된다. MySQL 뿐만 아니라 모든 DBMS에서도 쿼리의 실행계획을 수립하는 옵티마이저를 갖고 있다. 실행 계획에 대해 잘 이해하여 쿼리의 불합리한 부분을 찾아낸다면 옵티마이저의 최적화에 도움을 줄 수 있을것이다.

 추후 실행계획에 대해서 공부한 후 포스팅하도록 하겠다.

5) 캐시

MySQL 에서는 InnoDB Buffer Pool, Table Open Cache, Thread Cache 캐시가 사용된다. 캐시에 대한 자세한 내용은 다음에 다루도록 하겠다.

 

* 제거된 쿼리 캐시
 MySQL 8.0 부터 쿼리 캐시 기능이 제거됐다. 쿼리 캐시는 빠른 응답을 필요로 하는 웹 기반의 응용 프로그램에서 매우 중요한 역할을 담당했었다. SQL의 실행 결과를 메모리에 캐시하고, 동일 SQL 쿼리가 실행되면 테이블을 읽지 않고 즉시 결과를 반환하도록 동작했다. 하지만 테이블의 데이터가 변경되면 캐시에 저장된 결과 중에서 변경된 테이블과 관련된 것들을 모두 삭제해야 했다. 이는 심각한 동시 처리 성능 저하, 버그를 유발해 결국 제거됐다.

6) 버퍼

Join, Order By, Group By, Select 등의 쿼리 실행 시 사용되는 임시 메모리 공간을 의미한다. 종류로 Sort Buffer, Join Buffer, Read Buffer 가 있다. 각각에 대해 알아보자.

 

- Sort Buffer

 ORDER BY나 GROUP BY를 사용할 때 사용되는 버퍼로, 정렬에 필요한 데이터를 메모리에 저장하고, 이 안에서 정렬을 수행한다. 만약 정렬할 데이터가 Sort Buffer에 담기지 않을 경우 일부 데이터를 디스크의 임시 파일에 저장하고, 메모리와 디스크를 함께 사용해 정렬한다.

 

* ORDER BY 시 Select 절에는 꼭 필요한 컬럼만 기재하자
Order By 사용 시 Select 절 "*" 를 넣거나 굳이 필요없는 컬럼 추가한다면 Sort Buffer 의 사이즈를 초과할 수 있다. 이 경우 디스크에 임시 파일 형태로 데이터들이 저장될것이고 많은 I/O 비용을 들이는 메모리&디스크 정렬이 수행되기 때문이다.

- Join Buffer

인덱스가 없는 조인 작업을 수행할 때 사용하는 버퍼이다. 두 테이블을 조인할 때 보다 작은 테이블의 데이터를 Join Buffer에 저장하고, 큰 테이블의 데이터를 반복해서 비교하는 방식이다. 만약 테이블의 데이터가 Join Buffer에 담기지 않을 경우 여러번에 나누어 조인을 수행한다. 이는 성능 저하를 초래한다.

 

* Join Buffer에 의존하기보단 인덱스 기반 조인을 사용하자
일반적으로 Join Buffer에 의존한 조인보다 인덱스 기반의 조인이 성능면에서 우수하다. 키나 인덱스로 설정되지 않는 컬럼에 대한조인 시, 속도가 느리다면 Join Buffer를 추가하는것보다 인덱스 설정을 고려하자. 

- Read Buffer

 테이블의 데이터를 순차적(Full Scan)으로 읽을 때 사용되는 버퍼이다. 규모가 큰 테이블일수록 버퍼의 효율이 높아진다.

* 규모가 클수록 효율이 높은 이유
 Full Scan이 발생하면 MySQL은 디스크에서 테이블 데이터를 한번에 읽어오는게 아닌 조금씩 읽어온다. 이 과정에서 I/O 작업이 발생하는데, 읽어야할 테이블의 데이터가 많을 수록 I/O 작업은 늘어나게 된다. Buffer를 사용하게 된다면 조금씩이 아닌 버퍼 사이즈만큼 읽어올 수 있으므로 버퍼의 효율이 높은것이다. read_buffer_size 옵션으로 조절 가능하며 기본 값은 128KB이다.

5. MySQL 쿼리 실행 구조

스토리지 엔진을 알아보기 전 MySQL 엔진에서 일어나는 쿼리 실행 프로세스에 대해 알아보자. 여기서 사용되는 개념은 앞선 MySQL 서버 전체 구조의 요소에 중복된 내용이 있을 수 있으니 복습한다 생각하고 참고하도록 하자.

 

MySQL 쿼리 실행 구조

1) 클라이언트 접속

 클라이언트가 MySQL 서버에 접속하기 위해 ID/PW를 입력한다. 입력하는 시점에  MySQL 네이티브 프로토콜을 통해 MySQL 서버와 TCP/IP 기반의 커넥션이 맺어지게 된다. 이때 커넥션이 생성된다는 뜻인데, 정확히는 '임시 커넥션'이 생성되며, ID/PW 인증이 성공할 경우 완전한 커넥션이 맺어지게 된다.

 

2) 쿼리 요청 처리

 클라이언트가 쿼리를 입력하면 커넥션 핸들러가 쿼리 요청을 처리하기 위해 쿼리를 쿼리 파서에게 전달한다.

 

3) 파서 트리 생성 및 문법 오류 체크

 쿼리 파서는 받은 쿼리를 토큰으로 분리해 파서 트리를 생성한다. 추가로 이 과정에서 문법 오류를 체크한다. 문법에 이상이 없으면 전처리기에게 파서 트리를 전달한다.

 

4) 개체 검사 및 권한 체크

 전처리기는 파서 트리를 분석해 쿼리 문장에 구조적인 문제점이 있는지 확인한다. 각 토큰을 테이블 명, 컬럼 명, 내장 함수 명 등과 같은 개체에 매핑하여 유효성을 검사하고, 접근 권한을 확인한다. 실제 존재하지 않거나, 권한 상 접근할 수 있는 개체의 토큰은 이 단계에서 체크한다. 그 후 옵티마이저에게 전달한다.

 

5) 실행 계획 수립

 옵티마이저는 쿼리를 분석해 실행 계획을 수립한다. 하지만 계획을 할당하고, 이행하는 주체는 따로 있다. 실행 엔진과 핸들러인데, 이들의 관계를 회사로 비유하면 아래와 같다.

 

 - 옵티마이저 : 회사의 경영진 (회사의 계획 수립)

 - 실행 엔진 : 중간 관리자 (계획을 받아 업무를 할당)

 - 핸들러 : 실무자 (업무를 이행)

 

 이 단계에서는 옵티마이저가 실행 엔진에게 실행 계획을 전달한다.

 

6) 핸들러에게 처리 요청

 실행 엔진은 (옵티마이저가 만든) 계획대로 각 핸들러에게 요청해서 받은 결과를 또 다른 핸들러의 요청의 입력으로 연결하는 역할을 수행한다.

 

7) 핸들러의 작업 처리

 핸들러는 MySQL 서버의 가장 밑단에서 MySQL 실행 엔진의 요청에 따라 데이터를 디스크로 저장하거나 읽어 오는 역할을 담당한다. 즉, 핸들러는 스토리지 엔진을 의미하며, MyISAM 테이블을 조작하는 경우에는 핸들러가 MyISAM 스토리지 엔진이 되고, InnoDB 테이블을 조작하는 경우에는 핸들러가 InnoDB 스토리지 엔진이 된다.

반응형

1. 개요

 올해 6월부터 코로나 위기 단계가 하향 조정됨에 따라 7일 격리였던 격리 수준이 5일로 완화되었다. :) 어떤 단계에 따라 격리 수준 변경되고 있는데 우리가 사용하는 DB, 트랜잭션에도 격리 수준이란게 존재한다. 트랜잭션 격리수준이란 무엇인지 차근차근 이해해보자.

 

제공 : 질병관리청

 


2. 트랜잭션 격리수준이란?


2.1. 정의

트랜잭션 격리수준이란 동시에 여러 트랜잭션이 처리될 때, 트랜잭션끼리 얼마나 고립(격리)되어 있는가에 대한 수준을 말한다.

 


2.2. 트랜잭션은 원래 격리된거 아닌가?

 트랜잭션의 성질 중 격리성(Isolation)이 있다. 격리성이란 트랜잭션 수행 시 다른 트랜잭션이 끼어들이 못하도록 보장하는 것을 말한다. 이는  '트랜잭션은 서로 완전히 격리되어 있으니 다른 트랜잭션이 끼어들지 못합니다. 땅땅땅!' 이 아니다. 격리성을 강화시킨다면 다른 트랜잭션이 끼어들지 못하게 보장할 수 있고, 약화시킨다면 다른 트랜잭션도 중간에 끼어들 수 있다는 의미이다. 코로나처럼 7일간 무조건 집콕일수도 있고, 5일간 집콕 권고일수도 있다.

그럼 이렇게 격리수준을 나눈 이유는 뭘까? 먼저 트랜잭션의 성질을 살펴보자.

 


2.3. 트랜잭션 ACID

1) Atomicity (원자성)

 한 트랜잭션의 연산은 모두 성공하거나, 모두 실패해야한다. 예를들어 돈을 이체할 때 보내는 쪽에서 돈을 빼오는 작업만 성공하고, 받는 쪽에 돈을 넣는 작업은 실패하면 안된다.

 

2) Consistency (일관성, 정합성)

 트랜잭션 처리 후에도 데이터의 상태는 일관되어야 한다. '모든 계좌 정보에는 계좌 번호가 있어야 한다.'라는 제약 조건 즉, 상태가 걸려있을 때, 계좌번호를 삭제하거나 계좌번호가 없는 상태로 계좌정보를 추가하려 한다면 '모든 계좌 정보에는 계좌 번호가 있어야 한다' 에서 '모든 계좌 정보에는 계좌 번호가 없어도 된다' 라는 상태로 변경되게 된다. 때문에 상태를 변화시키는 트랜잭션은 실패하는것이다.

 

3) Isolation (격리성)

 트랜잭션 수행 시 다른 트랜잭션의 연산이 끼어들이 못하도록 보장한다. 하나의 트랜잭션이 다른 트랜잭션에게 영향을 주지 않도록 격리되어 수행되어야한다.

 

4) Durability (지속성)

 성공적으로 수행된 트랜잭션은 영원히 반영되어야 한다. 중간에 DB에 오류가 발생해도 다시 복구되어야 한다. 즉, 트랜잭션에 대한 로그가 반드시 남아야한다는 의미하기도 한다.

 


2.4. 트랜잭션의 Isolation

 여기서 격리성을 따져보자. 트랜잭션 수행 중간에 다른 트랜잭션이 끼어들수 없다면 어떻게될까? 모든 트랜잭션은 순차적으로 처리될 것이고, 데이터의 정확성은 보장될 것이다. 속도는 어떨까? 트랜잭션이 많아질수록 처리를 기다리는 트랜잭션은 쌓여간다. 앞에 있는 트랜잭션이 기다리는 시간만큼 대기 시간은 늘어난다. 결국 트랜잭션이 처리되는 속도가 느려지게 되고, 어플리케이션 운용에 심각한 문제가 발생할 수 있다.

 결국 준수한 처리 속도를 위해서는 트랜잭션의 완전한 격리가 아닌 완화된 수준의 격리가 필요하다. 이처럼 속도와 데이터 정확성에 대한 트레이드 오프를 고려하여 트랜잭션의 격리성 수준을 나눈것이 바로 트랜잭션의 격리수준이다.

 


 

3. 트랜잭션 격리수준 단계


3.1. DBMS마다 다른 격리수준

  트랜잭션 격리수준은 총 4단계로 Uncommitted Read, Committed Read, Repeatable Read, Serializable 로 구성된다. DBMS 마다 격리 수준에 대한 내용이 다를 수 있으니 보다 정확하게 알기 위해서는 공식 문서를 확인해야 한다. 필자는 MySQL 에서 제공하는 격리수준을 기준으로 하였으며, 필요에 따라서는 타 DBMS에서의 격리수준도 비교 분석하였다. 참고로 MySQL의 기본 격리수준은 Repeatable Read 이다.

 먼저 격리수준이 가장 낮은 Uncommitted Read부터 알아보자.

 


3.2. Uncommitted Read (커밋되지 않은 읽기)

 다른 트랜잭션에서 커밋되지 않은 데이터에 접근할 수 있게 하는 격리 수준이다. 가장 저수준의 격리수준이며, 일반적으로 사용하지 않는 격리수준이다.

 10번 트랜잭션이 '박기영'이라는 데이터를 '박경'으로 UPDATE 한 후 Commit 하지 않았을 때 13번 트랜잭션에서 접근하여 커밋되지 않은 데이터를 읽을 수 있다.

Uncommitted Read

 

 그런데 13번 트랜잭션이 데이터를 읽은 후 10번 트랜잭션에 문제가 발생하여 롤백된다면 데이터 부정합을 발생시킬 수 있다.

 

데이터 부정합은 어플리케이션에 치명적인 문제를 야기할 수 있다. 그래서인지 오라클에서는 이 수준을 아예 지원하지 않는다. 이처럼 커밋되지 않는 트랜잭션에 접근하여 부정합을 유발할 수 있는 데이터를 읽는 것을 더티읽기(Dirty Read)라고 한다. 

 

Dirty Read

 

 

 


3.2 Committed Read (커밋된 읽기)

 다른 트랜잭션에서 커밋된 데이터로만 접근할 수 있게 하는 격리 수준이다. MySQL을 제외하고 대부분 이를 기본 격리수준으로 사용한다.

 10번 트랜잭션이 '박기영'이라는 데이터를 '박경'으로 UPDATE 한 후 Commit 하지 않았을 때, 13번 트랜잭션에서 이를 조회할 경우 UPDATE 전 데이터인 '박기영' 이라는 값이 조회된다. Dirty Read 현상은 발생하지 않는다.

 그럼 어떻게  Read Committed는 UPDATE 전 값을 조회한걸까? 그 키는 바로 Undo 영역에 있다.

Committed Read


※ Undo 영역

 앞서 살펴본 트랜잭션의 성질 중 지속성(Durability)을 보면 다음과 같이 정의되어 있다.

 성공적으로 수행된 트랜잭션은 영원히 반영되어야 한다. 중간에 DB에 오류가 발생해도, 다시 복구되어야 한다. 즉, 트랜잭션에 대한 로그가 반드시 남아야한다는 성질을 의미하기도 한다.

 

 트랜잭션에 대한 로그가 반드시 남아있어야 한다. 즉, 복구는 로그를 기반으로 처리된다. 이 로그는 크게 두 가지가 있다. 오류에 의한 복구에 사용되는 Redo Log트랜잭션 롤백을 위해 사용되는 Undo Log이다.

 다시실행의 뜻을 갖는 Redo는 커밋된 트랜잭션에 대한 정보를 갖고 있고(왜? 복구하려면 다시 실행해줘야하니까), 실행 취소의 뜻을 갖는 Undo는 데이터베이스의 변경이 발생할 경우 변경되기 전 값과 이에 대한 PK 값을 갖고 있다(왜? 롤백하면 다시 되돌려야 하니까)

 

 그런데 Undo 영역이라고 말한 이유는 Undo Log가 Undo Log Buffer 형태로 메모리에 저장되고, 특정 시점에 디스크에 저장된 Undo Log File 에 I/O 작업으로 쓰여지기 때문이다. 추가로 이렇게 단계가 나눠지는 이유는 데이터에 변경사항이 생길때마다 Disk에 I/O 작업을 하는것보다 메모리 입력하고, 읽는것이 속도와 리소스 측면에서 유리하기 때문이다.

 

 정리하면, Undo 영역이란 변경 전 데이터가 저장된 영역이고, Commit 하기 전 데이터를 읽어올 수 있는 이유는 Undo 영역에 있는 데이터를 읽어오기 때문이다.

 


※ Non Repeatable Read(반복 가능하지 않은 읽기) 현상 발생

 Committed Read 수준에서는 Non Repeatable Read 현상이 발생한다. 이는 하나의 트랜잭션에서 동일한 SELECT 쿼리를 실행했을 때 다른 결과가 나타나는 것을 말한다.

 아래 그림을 보면 13번 트랜잭션이 동일한 SELECT 쿼리를 두 번 실행했을 때 결과가 다른 것을 볼 수 있는데, 10번 트랜잭션이 데이터 UPDATE 후 COMMIT 하기 전, 후에 SELECT 쿼리를 실행했었기 때문이다.

 

Non Repeatable Read

 


3.3. Repeatable Read (반복 가능한 읽기)

 Non Repeatable Read 문제를 해결하는 격리 수준으로, 커밋된 데이터만 읽을 수 있되 자신보다 낮은 트랜잭션 번호를 갖는 트랜잭션에서 커밋한 데이터만 읽을 수 있는 격리수준이다. 이게 가능한 이유는? 그렇다 Undo 로그때문이다. 또한 트랜잭션 ID를 통해 Undo 영역의 데이터를 스냅샷처럼 관리하여 동일한 데이터를 보장하는 것을 MVCC(Multi Version Concurrency Control) 라고 한다.

 

 아래 그림에서 10번 트랜잭션은 10번 보다 작은 트랜잭션에서 커밋한 데이터만 읽을 수 있으므로 13번 트랜잭션에서 변경한 내용은 조회할 수 없다. 같은 SELECT 쿼리가 두 번 실행됐을 때 같은 결과가 조회되므로 Non-Repeatable-Read 현상이 해결됨을 확인할 수 있다.

 

Repeatable Read

 


※ Repeatable Read를 지원하지 않는 오라클?!

 오라클은 Repeatable Read 수준을 지원하지 않는다. 그럼 Non Repeatable Read 문제를 해결할 수 없을까? 아니다!  해결할 수 있는 방법이 있다. 바로 Exclusive Lock을 사용하는 방법이다.

 


※ Exclusive Lock (배타적 잠금 / 쓰기 잠금)

 Exclusive Lock이란 특정 레코드나 테이블에 대해 다른 트랜잭션에서 읽기, 쓰기 작업을 할 수 없도록 하는 Lock 이다. SELECT ~ FOR UPDATE (업데이트 하려고 조회하는거에요~ 그러니까 다른 트랜잭션에서 접근못하도록 막아주세요~) 구문을 통해 사용할 수 있다.

 아래 참고 자료를 보자. SELECT ~ FOR UPDATE 를 사용하여 조회된 레코드에 대해 Exclusive Lock 을 걸면 다른 트랜잭션에서 해당 레코드에 대해 쓰기 작업 시 LOCK이 해제될때까지 대기하는 것을 볼 수 있다. 

 그런데 이상한 점이 하나 있다. Exclusive Lock은 읽기나 쓰기 작업을 할 수 없도록 한다고 했는데 아래 gif를 보니 다른 트랜잭션에서 SELECT를 통해 읽기 작업을 하고있다. 이건 바로 MVCC 기술을 통해 Undo 영역에서 읽어오는 것이다.

 

 

Exclusive Lock

 이제 이 과정을 그림으로 이해해보자. 10번 트랜잭션이 select id, name, from user for update 를 실행하여 레코드를 조회함과 동시에 Exclusive Lock이 건다. 다른 트랜잭션에서 접근 시 Lock이 풀릴때까지 대기하게 된다. 이후 10번 트랜잭션이 똑같은 쿼리를 실행해도 처음 조회했던 데이터와 같은 데이터가 조회되게 된다. Non-Repeatable Read 문제가 해결된것이다.

 

Oracle의 Exclusive Lock을 통한 Non Repeatable Read 문제 해결


※ Phantom Read (유령 읽기)

Repeatable Read와 Exclusive Lock 를 통해 Non Repeatable Read 문제를 해결했다. 그런데  새로운 문제가 발생한다. 바로 Phantom Read 현상이다.

 Phantom Read는 하나의 트랜잭션 내에서 여러번 실행되는 동일한 SELECT 쿼리에 대해 결과 레코드 수가 달라지는 현상을 말한다. Non Repeatable Read는 레코드의 데이터가 달라지는 것을 의미한다면 Phantom Read는 기존 조회했던 레코드의 데이터는 달라지지 않지만, 새로운 레코드가 나왔다가 사라졌다가 하는 것이다. 마치 유령처럼!! :(

 

 먼저 Exclusive Lock 을 보자. UPDATE, DELETE에 대한 Lock을 걸어 읽기, 쓰기 작업을 막을 수 있었지만, INSERT에 대한 LOCK은 걸 수 없다. 그 이유는 조회된 레코드에 대해서만 Exclusive Lock을 거는 것이지 조회되지 않은 레코드, 즉 나중에 추가할 레코드에 대해서는 Lock을 걸지 않기 때문이다.

 이는 Exclusive Lock을 사용해도 다른 트랜잭션에서 INSERT 작업이 가능하다는 뜻이고, 아래와 같이 처음엔 조회되지 않았던 레코드가 조회될 수 있다는 것이다. 마치 유령처럼!! :)

 

Oracle Exclusive Lock의 Phantom Read 현상

 


※ MySQL 에서는 발생하지 않는 Phantom Read

 InnoDB 엔진을 사용하는 MySQL 에서는 Repeatable Read 수준에서 Phantom Read 현상이 발생하지 않는다. 그 이유는 SELECT ~ FOR UPDATE를 통해 Lock을 걸때 Exclusive Lock이 아닌 Next Key Lock 방식을 사용하기 때문이다.


※ Next Key Lock

 Next Key Lock은 조회된 레코드에 대한 Lock 뿐 아니라 실행 쿼리에 대한 범위에 설정되는 Lock이다. 즉, Next Key Lock은 Record Lock, Gap Lock 이 조합된 Lock 이다. 

 예를 들어 SELECT * FROM USERS WHERE ID BETWEEN 0 AND 10 FOR UPDATE 쿼리를 실행시키면, 조회된 레코드에 대한 Record Lock과, 0 < ID <=10 에 해당하는 범위에 해당하는 Gap Lock이 걸린다. 이뿐 아니다! 마지막으로 조회된 레코드의 Index인 ID에 대해 그 다음 존재하는 ID 까지의 범위를 Gap Lock으로 설정한다. 만약 아래와 같이 2 이후 ID가 20인 레코드가 있다면 2 ~ 20 까지 Gap Lock을 건다.

 때문에 다른 트랜잭션에서 SELECT 쿼리를 통해 정해진 GAP에 해당하는 데이터를 INSERT 시도할 경우 Gap Lock으로 인해 대기상태에 들어가기 되고, 이는 기존 트랜잭션의 여러 동일 SELECT 쿼리에 대한 동일성이 보장되게 된다.

 

ID NAME
1 심승갱
2 박기영
20 홍길동

 

Mysql Next Key Lock

 

 

 참고로 SELECT * FROM USERS FOR UPDATE 쿼리를 실행한다면 조회된 모든 레코드에 대한 Lock과 모든 범위에 대한 GAP LOCK이 걸리게 된다.

 


3.4. Serializable

 가장 고수준의 격리수준으로 트랜잭션을 무조건 순차적으로 진행시킨다. 트랜잭션이 끼어들 수 없으니 데이터의 부정합 문제는 발생하지 않으나, 동시 처리가 불가능하여 처리 속도가 느려진다. 

 트랜잭션이 중간에 끼어들 수 없는 이유는 SELECT 쿼리 실행 시 Shared Lock(공유 잠금)을, INSERT, UPDATE, DELETE 쿼리 실행 시 Exclusive Lock (MySQL의 경우 Nexy Key Lock)을 걸어버리기 때문이다.

 


※ Shared Lock

 Shared Lock이란 다른 트랜잭션에서의 읽기 작업은 허용하지만, 쓰기 작업은 불가능하도록 한다. SELECT ~ FOR SHARE 문법을 통해 사용하는데, 키 포인트는 이 Lock의 경우 동시에 Exclusive Lock을 허용하지 않는다는 것이다.

 SELECT 쿼리를 실행하면 Shared Lock이 걸리게 되고, 다른 트랜잭션에서 UPDATE, DELETE, INSERT와 같은 쿼리 실행 시 Exclusive Lock, Next Key Lock을 얻어오려고 할텐데 Shared Lock 은 이를 허용하지 않아 대기 상태가 된다. 이러한 원리에 의해 트랜잭션들이 중간에 끼어들 수 없고 순차적으로 되는것이다.

 


4. 회고

 처음엔 트랜잭션의 격리 수준에 따라 트랜잭션 내에서 실행한 쿼리들이 어떻게 동작하는지만 이해하고 넘어가려 했지만, 이를 이해하기 위해서는 Commit, Rollback의 내부 동작 부터 시작해 Undo Log, Undo Log Buffer와 File, Cache Miss, Hit, MVCC, Lock 등.. DB에 대한 전반적인 흐름과 개념들을 알아야 했다. 또한 DBMS 마다 격리 수준의 내용이 달라지다보니 이를 비교 분석해야 했다.

 먼저 영상을 통해 기본 개념들을 학습했고, 여러 블로그 글들을 참고하여 정리해나갔다. chat gpt를 통해 이해한 내용을 검증하기도 했다. (근데 이녀석이 자꾸... 말을 바꾸네...??) 잘못된 내용을 바로잡을때마다 쓰고, 지우고를 반복했다. 그림을 갈아 엎어야할때는 마음이 너무 아팠지만, 그때 나사가 빠진(?) 부분이나 이해가 필요한 부분들을 시각적으로 찾을 수 있어서 매우 유익했다.

 이 글은 필자가 이해한 내용을 정리한거라 틀린 내용이 있을 수 있다. 독자분들께서 읽고 수정이 필요한 부분이나 추가 정보가 있다면 꼭! 댓글로 부탁드린다! :)

 


5. 참고자료

https://www.youtube.com/watch?v=bLLarZTrebU - 트랜잭션 격리수준

https://mangkyu.tistory.com/299 - 트랜잭션 격리수준

https://www.youtube.com/watch?v=0PScmeO3Fig - lock의 개념

https://www.youtube.com/watch?v=wiVvVanI3p4 - MVCC

https://www.youtube.com/watch?v=Ah9wAY8Hd9A - 데이터베이스 구조

https://www.youtube.com/watch?v=EhIFSh-qQgc&list=PLjXGlnSkrdzTUHikKihgVlW1jQCe-U35m&index=11 - 테이블 스페이스

 

반응형

1. 개요

 스프링 서버에서 DB 조회 결과를 내가 커스텀한 객체(DAO)로 가져오고 싶은 경우가 있다. 그럴 때 아래 예제처럼 resultType을 풀패키지 경로로 입력해야한다. 만약 입력한 BoardDAO 객체가 다른 쿼리에서도 사용된다면, 해당 쿼리의 resultType 또한 풀 패키지경로로 입력해야할 것이다. 추가로 패키지 경로가 바뀌거나 클래스 명이 바꿔버린다면? 모든 쿼리 설정파일을 바꿔줘야할 것이다.

 이러한 문제를 미연에 방지할 수 있는 방법이 바로 Mybatis의 typeAlias(별명) 설정이다.


2. typeAlias

 typeAlias는 패키지에 대한 별명을 지정할 수 있다. Mybatis 설정 파일에서 설정 가능하며, 아래 예제와 같이 typeAlias 태그의 type 값에  풀 패키지명을 입력하고, alias에는 별명을 지정한다.

typeAlias 설정

 


3. Mybatis 설정 파일 로드

 Mybatis 설정파일을 로드하는 방법은 스프링의 context 설정 중 sqlSessionFactory를 설정하는 부분에 configLocation 값으로 mybatis 설정파일 경로를 넣어주면 된다. 내 설정파일은 클래스파일 경로에 포함되어있기 때문에 다음과 같이 classpath 로 경로설정을 하였다.

 


4. 적용

 설정이 적용되면 resultType에 Alias로 값을 넣고 테스트해보자. 정상적으로 조회됨을 확인할 수 있을것이다.

 mybatis 설정 에러가 발생하는 경우가 있는데, 그럴때는 seetings라는 태그가 configuration태그 내 첫번째로 위치하는지 확인하자.

반응형

1. oracle 12c 설치

https://www.oracle.com/database/technologies/oracle-database-software-downloads.html

 

2. sqldeveloper 18.4 설치(JDK include ver)

https://www.oracle.com/technetwork/developer-tools/sql-developer/downloads/sqldev-downloads-184-5458710.html

 

3. 두 파일 모두 압축해제

 

4. oracle 설치 파일 실행(비밀번호 입력을 제외한 모든 항목 수정하지 않고 진행)

 

5. oracle 서비스 수동 전환(서비스가 자동 실행 되어있으면 oracle을 사용하지 않을 때에 컴퓨터가 버벅거릴 수 있음)

시작 - 서비스

 1) Oracle ~~ TNSListener - 수동으로 변경

 2) OracleServiceORCL - 수동으로 변경

 컴퓨터를 재부팅하면 해당 서비스는 자동으로 실행이 되지 않음. 때문에 오라클 프로그램에 필요한 서비스가 로드되지 않아 에러가 발생할 수 있음. 때문에 불필요한 메모리 낭비를 하기 싫다면 위의 설정을 따르고, 메모리 낭비가 상관없을 만큼 컴퓨터 사양이 좋다면 위의 설정은 하지 않아도 된다.

 서비스를 재실행시키려면 서비스 탭으로 들어가 서비스를 실행시키거나, bat파일을 만들어 서비스를 실행시키면 된다.

 

6. cmd 실행 후 sqlplus 입력

 

7. 사용자 명에 sys as sysdba를 입력하고 비밀번호는 입력하지 않고 엔터키를 누르면 설치한 Oracle Database 12c에 접속됨.

Oracle Database 12c 접속 성공

9. tablespace 생성

 * tablespace란 데이터를 관리하는 논리적인 저장 구조이다. 쉽게 말하면 테이블을 관리하는 공간을 의미한다. 테이블 데이터의 기본 용량을 설정할 수 있으며, 용량 초과 시 자동적으로 10 mbyte씩 증가한다.

 create tablespace [테이블 스페이스명] datafile 'orcl 경로\[database파일 명.dbf]' size 500m; 입력

 참고로 oracle 12c 버전의 orcl의 경로는 c드라이브의 app에 생성된다.

 

10. user 생성

 데이터베이스 user를 생성한다.

  create user [아이디] identified by [비밀번호] default tablespace [테이블 스페이스명] temporary tablespace temp;

유저 아이디 오류

 사용자 이름에 오류가 생겼다.

 12c 버전부터는 유저 아이디 앞에 c##이라는 문자열을 삽입해줘야 한다. 필자는 그게 귀찮기때문에 아래의 명령어를 추가하여 c##을 입력하지 않고 유저 아이디를 생성하였다.

 

c## 생략 가능하게 하는 명령어

11. user에게 권한 주기

connect, resource, dba 권한 부여

12. sql developer 실행

 경로 설정이 나올시 '아니요' 클릭.

 실행되면 아래 버튼 클릭

 

13. 접속 정보 입력

접속 정보 입력

접속 이름 : 임의의 이름

사용자 이름 : 생성한 유저 이름

비밀번호 : 생성한 유저의 비밀번호

호스트 이름 : 기본값

포트 : 기본값(1521)

SID : 데이터베이스의 전역 DB명. 기본값(orcl)

 

 * SID 확인 방법

 

입력 후 테스트 클릭. 성공 시 저장 후 접속.

성공

워크시트가 생성되면 성공

반응형

'DB > Oracle' 카테고리의 다른 글

[MyBaits] typeAlias 설정  (0) 2021.06.07

+ Recent posts