트랜잭션
트랜잭션이란 논리적인 작업 셋을 모두 완벽하게 처리하거나, 처리하지 못할 경우에는 원 상태로 복구해서 작업의 일부만 적용되는 현상이 발생하지 않게 만들어주는 기능이다.
트랜잭션의 성질
트랜잭션은 ACID라고 하는 원자성, 일관성, 격리성, 지속성을 보장해야 한다.
Atomicity - 원자성
원자성은 한 트랜잭션 내의 일련의 작업들은 모두 성공하거나 모두 실패해야 한다는 성질이다. (All or Nothing)
예를 들어 출금과 입금 작업을 하는 계좌이체 트랜잭션은 원자성을 보장해야 한다.
Consistency - 일관성
모든 트랜잭션은 일관성 있는 데이터베이스 상태를 유지해야 한다는 성질이다.
예를 들어 데이터베이스에서 정한 무결성 제약 조건을 항상 만족해야 한다.
Isolation - 격리성
동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않아야 한다는 성질이다.
예를 들어 동시에 같은 데이터를 수정하지 못하도록 해야 한다.
격리성은 동시성과 관련된 성능 이슈로 인해 격리 수준을 선택할 수 있다.
Durability - 지속성
하나의 트랜잭션이 성공적으로 수행되었다면, 그 결과가 항상 기록되어야 한다는 성질이다.
예를 들어 중간에 시스템에 문제가 발생해도 데이터베이스 로그 등을 사용해서 성공한 트랜잭션 내용을 복구할 수 있어야 한다.
트랜잭션 격리 수준
트랜잭션의 격리 수준이란 여러 트랜잭션이 동시에 처리될 때 특정 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있게 허용할지 말지를 결정하는 것이다.
격리 수준은 크게 READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE 4가지로 나뉘는데, 이 중 DIRTY READ 라고도 하는 READ UNCOMMITTED와 SERIALIZABLE은 거의 사용되지 않는다.
4개의 격리 수준에서 뒤로 갈수록 각 트랜잭션 간의 데이터 격리 정도가 높아지며, 동시 처리 성능도 떨어진다.
데이터베이스의 격리 수준을 이야기하면 항상 함께 언급되는 3가지 문제점들이 있는 이 문제점들은 격리 수준에 따라 발생할 수도 있고 발생하지 않을 수도 있다.
격리 수준 / 부정합 | DIRTY READ | NON-REPEATABLE READ | PHANTOM READ |
READ UNCOMMITTED | O | O | O |
READ COMMITTED | X | O | O |
REPEATABLE READ | X | X | O (InnoDB는 없음) |
SERIALIZABLE | X | X | X |
READ UNCOMMITTED
READ UNCOMMITTED 격리 수준에서는 각 트랜잭션에서의 변경 내용을 COMMIT이나 ROLLBACK 여부에 상관없이 다른 트랜잭션에서 데이터를 읽을 수 있다.
예를 들어 아래의 예시와 같이 트랜잭션 A가 데이터를 수정하고 있는데, COMMIT 하지 않아도 트랜잭션 B가 수정 중인 데이터를 조회할 수 있게 된다(DIRTY READ). 그 후 트랜잭션 B가 DIRTY READ한 데이터를 사용하는데, 트랜잭션 A가 ROLLBACK 되면 데이터 정합성에 심각한 문제가 발생할 수 있다.
DIRTY READ - 어떤 트랜잭션에서 처리한 작업이 완료되지 않았는데도 다른 트랜잭션에서 읽을 수 있는 현상
READ COMMITTED
READ COMMITTED 격리 수준에서는 어떤 트랜잭션에서 데이터를 변경했더라도 COMMIT된 데이터만 다른 트랜잭션에서 읽을 수 있다.
예를 들어 트랜잭션 A가 데이터를 변경한 뒤 COMMIT하기 전에 트랜잭션 B가 해당 데이터를 SELECT 하면 언두 영역의 백업된 레코드에서 변경 전 데이터를 읽어오게 된다.
따라서 DIRTY READ는 발생하지 않지만, 아래의 예시와 같이 하나의 트랜잭션 내에서 동일한 SELECT 쿼리를 실행했을 때 항상 같은 결과를 가져오지 못하는 NON-REPEATABLE READ가 발생할 수 있다.
REPEATABLE READ
REPEATABLE READ 격리 수준은 MySQL의 InnoDB 스토리지 엔진에서 기본으로 사용되는 격리 수준이다.
InnoDB 스토리지 엔진은 트랜잭션이 ROLLBACK 될 가능성에 대비해 변경되기 전 레코드를 언두 영역에 백업해두고 실제 레코드 값을 변경하는데, REPEATABLE READ는 언두 영역에 백업된 이전 데이터를 이용해 같은 트랜잭션 내에서는 동일한 결과를 보장해준다.
이러한 하나의 트랜잭션 내에서 동일한 결과 보장이 가능한 이유는 모든 InnoDB의 트랜잭션은 고유한 트랜잭션 번호(순차적으로 증가하는 값)를 가지며, 언두 영역에 백업된 모든 레코드에는 변경을 발생시킨 트랜잭션의 번호가 포함되어 있다. 그리고 SELECT 쿼리를 수행하려는 트랜잭션은 자신의 번호보다 작은 트랜잭션 번호에서 변경한 것만 보게 되므로 가능한 것이다.
하지만 이러한 REPEATABLE READ 격리 수준에서도 아래의 예시와 같이 반복 조회 시 결과 집합이 달라지는 PHANTOM READ가 발생할 수 있다.
SERIALIZABLE
SERIALIZABLE 격리 수준은 가장 단순한 격리 수준이면서 동시에 가장 엄격한 격리 수준으로, 읽기 작업도 공유 잠금을 획득해야 하고, 동시에 다른 트랜잭션은 해당 레코드를 변경하지 못하게 된다.
즉, 한 트랜잭션에서 읽고 쓰는 레코드를 다른 트랜잭션에서는 절대 접근할 수 없게 되는 것이다. 이로 인해 다른 격리 수준에서 일어나는 PHANTOM READ가 발생하지 않지만 동시성 처리 성능이 급격하게 떨어지게 된다.
트랜잭션 적용 시 주의사항
1. 트랜잭션의 범위는 프로그램 코드에서 최소화하는 것이 좋다. 일반적으로 데이터베이스 커넥션의 개수가 제한적이기 때문에 각 프로그램이 커넥션을 가지고 있는 시간이 길어질수록 사용 가능한 커넥션 수가 줄어들게 되고, 이로 인해 병목 현상이 발생할 수 있다.
2. 프로그램 코드에서 최소화된 트랜잭션의 범위도 분리할 수 있다면 분리하는 것이 좋다. 예를 들어 사용자가 게시물을 등록하는 일련의 과정이 아래와 같다고 가정해보도록 하자.
1. 처리 시작
===> DB 커넥션 생성
===> 트랜잭션 시작
2. 사용자의 로그인 여부 확인
3. 사용자가 입력한 게시물 내용을 DB에 저장
4. 게시물 등록에 대한 알림 메일 발송
5. 알림 메일 발송 이력을 DB에 저장
<=== 트랜잭션 종료(COMMIT)
<=== DB 커넥션 반납
6. 처리 완료
위 과정에서 트랜잭션의 범위를 최소화해보면, 다음과 같을 것이다.
1. 처리 시작
2. 사용자의 로그인 여부 확인
===> DB 커넥션 생성
===> 트랜잭션 시작
3. 사용자가 입력한 게시물 내용을 DB에 저장
4. 게시물 등록에 대한 알림 메일 발송
5. 알림 메일 발송 이력을 DB에 저장
<=== 트랜잭션 종료(COMMIT)
<=== DB 커넥션 반납
6. 처리 완료
이러한 하나의 트랜잭션을 조금 더 개선해보자면 아래와 같이 여러 개의 트랜잭션으로 분리해 트랜잭션의 범위를 더욱 최소화할 수 있다.
1. 처리 시작
2. 사용자의 로그인 여부 확인
===> DB 커넥션 생성
===> 트랜잭션 시작
3. 사용자가 입력한 게시물 내용을 DB에 저장
<=== 트랜잭션 종료(COMMIT)
4. 게시물 등록에 대한 알림 메일 발송
===> 트랜잭션 시작
5. 알림 메일 발송 이력을 DB에 저장
<=== 트랜잭션 종료(COMMIT)
<=== DB 커넥션 반납
6. 처리 완료
3. 메일 전송이나 FTP 파일 전송, 네트워크를 통해 원격 서버와 통신하는 등의 작업들은 트랜잭션 내에서 제거해야 한다. 프로그램이 실행되는 동안 해당 작업들이 실패하게 되면, 웹 서버뿐만 아니라 DB 서버까지 위험해질 수 있다.
부록. MySQL에서의 트랜잭션
MySQL에는 트랜잭션을 지원하지 않는 MyISAM, MEMORY 스토리지 엔진과 트랜잭션을 지원하는 InnoDB 스토리지 엔진이 존재한다.
트랜잭션을 지원하지 않는 MyISAM이나 MEMORY 스토리지 엔진의 경우, 일련의 작업이 수행되는 도중 오류가 발생하더라도 이전까지 수행되었던 부분들은 정상적으로 반영된다. 이러한 현상을 부분 업데이트(Partial Update) 라고 하며, Partial Update 현상이 발생하면 정상적으로 반영된 데이터들을 삭제하기 위한 복잡한 부가적인 작업이 필요해진다.
반면 트랜잭션을 지원하는 InnoDB 스토리지 엔진의 경우, 일련의 작업이 수행되는 도중 오류가 발생하게 되면 트랜잭션으로 묶인 일련의 작업들이 모두 ROLLBACK 된다. (All or Nothing)
그럼 트랜잭션을 지원하는 InnoDB 스토리지 엔진과 지원하지 않는 MyISAM 스토리지 엔진의 차이를 코드 레벨에서 간단히 살펴보도록 하자.
먼저 InnoDB와 MyISAM 스토리지 엔진을 사용하는 테이블을 만들고, 레코드를 하나씩 삽입해주었다.
mysql> CREATE TABLE innodb_table ( id INT NOT NULL, PRIMARY KEY(id) ) ENGINE=INNODB;
Query OK, 0 rows affected (0.05 sec)
mysql> INSERT INTO innodb_table (id) VALUES (3);
Query OK, 1 row affected (0.01 sec)
mysql> CREATE TABLE myisam_table ( id INT NOT NULL, PRIMARY KEY(id) ) ENGINE=MyISAM;
Query OK, 0 rows affected (0.02 sec)
mysql> INSERT INTO myisam_table (id) VALUES (3);
Query OK, 1 row affected (0.02 sec)
이 상태에서 아래와 같이 기본 키 제약조건에 위배되는 bulk insert 쿼리를 실행시켜보도록 하자.
mysql> INSERT INTO innodb_table (id) VALUES (1), (2), (3);
ERROR 1062 (23000): Duplicate entry '3' for key 'innodb_table.PRIMARY'
mysql> INSERT INTO myisam_table (id) VALUES (1), (2), (3);
ERROR 1062 (23000): Duplicate entry '3' for key 'myisam_table.PRIMARY'
mysql> SELECT * FROM innodb_table;
+----+
| id |
+----+
| 3 |
+----+
1 row in set (0.00 sec)
mysql> SELECT * FROM myisam_table;
+----+
| id |
+----+
| 1 |
| 2 |
| 3 |
+----+
3 rows in set (0.00 sec)
두 테이블 모두 기본 키 중복으로 오류가 발생하였는데, Innodb_table에는 INSERT INTO innodb_table (id) values (1), (2), (3); bulk insert 쿼리가 모두 반영되지 않았다는 것과, myisam_table에는 오류가 발생했음에도 ‘1’과 ‘2’가 반영되었다는 것을 알 수 있다.
이러한 결과를 분석해보자면, innodb_table은 트랜잭션을 지원하는 InnoDB 스토리지 엔진을 사용한다. 따라서 bulk insert 쿼리가 수행되기 전에 트랜잭션이 시작되고, 일련의 insert 작업이 수행되다가 ‘3’을 삽입하려고 하는 순간 기본 키 값이 중복되어 오류가 발생하면 트랜잭션이 COMMIT되지 못하고, ROLLBACK 된다. 이로 인해 정상적으로 수행되었던 '1'과 '2'의 삽입도 반영되지 않게 되는 것이다.
반면 myisam_table은 트랜잭션을 지원하지 않는 MyISAM 스토리지 엔진을 사용한다. 그렇기 때문에 bulk insert를 수행하면서 차례대로 ‘1’, ‘2’를 삽입하고, ‘3’을 삽입하는 과정에서 기본 키 중복 오류가 발생하게 되더라도 이전에 정상적으로 수행되었던 ‘1’, ‘2’에 대한 insert는 그대로 두고 쿼리 실행을 종료해 버린다. 즉, 쿼리 중 일부가 반영되는 Partial Update가 발생한 것을 알 수 있다.
Reference
'Computer Science' 카테고리의 다른 글
IP 주소 체계 알아보기 (0) | 2022.04.03 |
---|---|
프로세스 알아보기 (0) | 2022.02.15 |
데이터베이스 인덱스 (0) | 2022.02.12 |