트랜잭션(Transaction)

임태영·2022년 2월 11일
post-thumbnail

우리는 일상생활에서 많은 작업들을 한다.
이러한 것들 중 완전히 완료되지 못하면 안 하느니만 못한 것들이 있다.
은행이 고객의 송금요청을 받았다. 본인 계좌에서 100만원을 다른 사람계좌로 보내달라고 한다.
은행은 먼저 고객의 계좌를 조회한다. 100만원 이상이 있는 것을 확인한다. 고객의 계좌에서 100만원을
인출한다. 이때 은행서버가 고장나고 거래내역이 날라갔다. 이런 경우 은행은 난감해진다.
고객에게 "송금중간에 서버가 망가져서 일을 부분적으로만 완료했습니다."라는 변명이 통할까?
이러한 상황에 대비하여 나온 개념이 트랜잭션이다.

트랜잭션이란?

트랜잭션은 하나의 단위로 처리되어야 하는 쿼리의 묶음이다.
이러한 트랜잭션은 4가지 속성을 가져야만 한다.

Atomicity(원자성)

원자성이랑 깨질 수 없음을 나타낸다. 트랜잭션은 하거나 말거나다. (All or Nothing)
부분적으로 완료되는 상황이란 존재하지 않는다.

Consistency(일관성)

트랜잭션은 모순이 없는 상태에서 모순이 없는 다음 상태로 가야한다. 은행계좌에서 인출된 100만원은 사라지면 안되고, 내 계좌로 돌아오거나 다른 사람의 계좌에 가야만 한다.

Isolation(격리)

하나의 트랜잭션의 결과는 완료되기 전까지는 다른 트랜잭션에서 보이면 안된다. 은행이 고객의 요청을 받아 송금을 위해 100만원을 빼놓은 상태라고 가정하자. 이때 마침 고객이 통신비를 내는 날이어서 통신사에서 고객계좌를 조회했다. 이때 통신사는 은행의 송금 트랜잭션이 완료되지 않았다면, 100만원이 빠지지 않은 계좌내역을 봐야 한다.

Durability(내구성)

트랜잭션의 결과는 영구적이어야 한다. 한 번 완료되면 그 상태는 영원히 가야한다.

트랜잭션의 범위

트랜잭션이 중간에 실패하여 롤백이 되는 상황은 자주 볼 수 있다. 이러한 상황에서 주의할 점이 있다.
바로 커넥션 단위로 롤백을 가진다는 점이다.

예를들어 고객이 같은 은행에서 계좌를 2개 가지고 있다고 생각해보자. 이때 계좌 1에서 계좌 2로 10만원 정도를 옮길 필요가 생겼다. 은행에게 "내 계좌 1에서 계좌 2로 10만원 옮겨줘"라는 트랜잭션을 요청한다. 단계를 다음과 같이 생각해 볼 수 있다.

DB커넥션을 얻어온다.

트랜잭션 시작!

조회한다.
인출한다.
계좌2에 입금();
에러발생.
COMMIT;

DB커넥션을 반납한다.

function 계좌2에 입금() {
	DB커넥션을 얻어온다.
    입금한다.
    DB커넥션을 반납한다.
};

이때 계좌2 입금 메소드를 실행완료하고 커밋하기 직전에 에러가 발생하여 롤백을 했다고 해보자.
우리는 계좌 2에서 10만원이 빠져서 계좌 1로 다시 들어오는 것을 원할 것이다. 하지만 이경우에는
뜻밖에도? 계좌2의 10만원 입금은 롤백되지 않는다. 커넥션이 다르기 때문이다.
이렇듯 트랙잭션을 다룰때 같은 커넥션에서만 다루는 것이 요구된다.

외부 API를 사용할때도 같은 맥락이다. 트랜잭션은 외부 API까지 롤백해주지 않는다. 이 부분은 어플리케이션단에서 처리해줘야 한다.

격리 수준

하나의 데이터에 두개 이상의 트랜잭션이 접근하는 경우는 자주 있다. 이러한 상황에 대비하여 트랜잭션은 보통 4가지의 격리수준을 제공한다.

READ UNCOMMITTED

실시간으로 데이터를 읽을 수 있다. 즉, 커밋되지 않은 데이터도 보여준다.

transaction 1 start;
update data1 10;
									transaction 2 start;
                                    select data1;  // 10
update data1 20;		
									select data1;  // 20
update data1 30;					
...									...
...								    ...	    
COMMIT;							    COMMIT;
									

이와 같이 읽을때 마다 데이터가 다르게 조회되는 문제가 발생한다. (dirty read)

READ COMMITTED

커밋된 데이터만 읽는다.

data1의 초기값은 5라고 가정.

transaction 1 start;
update data1 10;
									transaction 2 start;
                                    select data1;  // 5
update data1 20;		
									select data1;  // 5
...
...
update data1 30;					
								    select data1;  // 5
COMMIT;
									...
									select data1;  // 30 	
									COMMIT;

transaction1이 커밋하기 전까지는 transaction2에서 data1의 값은 5로 보인다.

그러나 transaction1이 커밋한 후에는 transaction2에서 data1의 값이 30으로 보이는 문제가 발생한다.

"READ UNCOMMITTED"와 "READ COMMITTED"는 하나의 트랜잭션에서 데이터의 일관성이 깨지는 문제가 발생한다.

REPEATABLE READ (Mysql의 기본 격리 레벨)

한 트랜잭션 내부에서는 반복적으로 읽어도 같은 값을 보게하는 격리 수준이다.

data1의 초기값은 5라고 가정.

transaction 1 start;
update data1 10;
									transaction 2 start;
                                    select data1;  // 5
update data1 20;		
									select data1;  // 5
...
...
update data1 30;					
								    select data1;  // 5
COMMIT;
									...
									select data1;  // 5 	
									COMMIT;

이전의 값을 보관하여 이를 보여준다.

그러나 하나의 데이터가 아닌 범위의 데이터를 조회하는 경우 문제가 발생한다.

SELECT COUNT(*) FROM STUDENTS; // 20이라 가정.

transaction 1 start;
...									transaction 2 start;	
...									SELECT COUNT(*) FROM STUDENTS; // 20
insert STUDENTS 학생1;						...
...									SELECT COUNT(*) FROM STUDENTS; // 21
COMMIT;								...
                                    COMMIT;

값은 보관하였으나 범위는 보관하지 못하여 발생하는 문제다. (Phantom Read)
이와 같은 문제를 MySql InnoDB에서는 MVCC로 해결한다.

MVCC란 MultiVersion Concurrency Ctroll의 약자다. 해석해보면 여러 버전으로 동시성 제어를 하겠다는 뜻이다. REPEATABLE READ의 격리 수준에서 발생하는 문제를 해결한다.
위의 상황을 MVCC로 해결하는 과정을 알아보자.

SELECT COUNT(*) FROM STUDENTS; // 20이라 가정. 이때의 데이터를 version1 이라 하자.

// transaction 1과 transaction 2는 모두 초기에는 version1의 데이터만 알고있다.

transaction 1 start;
...										transaction 2 start;	
...										SELECT COUNT(*) FROM STUDENTS; // v1 : 20
insert STUDENTS 학생1;  		
SELECT COUNT(*) FROM STUDENTS; // v2 : 21
										...
...										SELECT COUNT(*) FROM STUDENTS; // v1 : 20
COMMIT;									...
                                    	COMMIT;

transaction 1 에서는 초기에 20명이던 테이블(v1)에서 21명인 테이블(v2)가 생성되었다.
그러나 transaction 2는 계속하여 20명인 테이블(v1)을 참조한다.

트랜잭션이 시작할 때의 데이터 상태를 기억하여 트랜잭션이 진행되는 동안 데이터의 일관성을 보장할 수 있다.

그러나 MVCC에서도 문제가 발생한다. 바로 두 트랜잭션에서 같은 데이터를 수정할 때다.

v1 : 계좌 잔액 100만원

transaction 1 start;
select 잔액 from 계좌 // v1 : 100만원
...											transaction 2 start;
...                                         select 잔액 from 계좌; // v1 : 100만원
...											update 잔액 -= 70만원;    // v2 : 30만원 
... 										COMMIT;
...
update 잔액 -= 70만원; // v2 : 30만원
COMMIT;

두개의 트랜잭션은 자기의 버전에 맞게 행동을 했지만, 결과는 일관성이 깨진다. 따라서 한 트랜잭션은 롤백되어야 한다.

이를 해결하기 위해 명시적으로 행을 잠구는 방법을 사용한다.
"SELECT ... FOR UPDATE"

transaction 1 start;
select 잔액 from 계좌 FOR UPDATE; // 100만원
...											transaction 2 start;
...                                         select 잔액 from 계좌; // wait...
...											wait...
... 										wait...
...											wait...
update 잔액=30만원; // 30만원			  		wait...
COMMIT;										wait...
											// 30만원
                                            이후 작업...
                                          	COMMIT;
                                        

transaction 1이 명시적으로 행을 잠구었다. 따라서 transaction 2는 transaction 1이 커밋하지 전까지 대기해야 한다.

SERIALIZABLE(직렬화)

마지막 최고단계의 격리 수준이다. 모든 트랜잭션을 직렬화 한다. 한 번에 한 트랜잭션만 수행하는 것이다. 동시성으로 인한 문제는 발생하지 않지만, 처리량이 급격히 저하된다.

참고자료
https://www.youtube.com/watch?v=urpF7jwVNWs&list=PLwouWTPuIjUg0dmHoxgqNXyx3Acy7BNCz&index=5
https://www.youtube.com/watch?v=poyjLx-LOEU&list=PLwouWTPuIjUg0dmHoxgqNXyx3Acy7BNCz&index=6
https://books.google.co.kr/books?id=BL0NNoFPuAQC

profile
나 스스로를 깊게 알고싶은 사람입니다.

0개의 댓글