트랜잭션이란??
트랜잭션이란, 데이터베이스의 상태를 변경시키기 위해 수행하는 작업 단위 또는 한꺼번에 수행되어야 할 일련의 연산들을 의미합니다. 주로 UPDATE, INSERT, DELETE와 같은 데이터베이스 상태를 변환시키는 작업들이 트랜잭션 범위에 포함됩니다.
하나의 트랜잭션은 여러 단계로 이루어져 있으며, 모든 단계가 완료되면 커밋(commit)되어 데이터베이스에 변경사항이 저장됩니다.
그러나 중간에 어떤 문제가 발생하여 트랜잭션이 완료되지 못한 경우, 롤백(RollBack)이라는 동작을 통해 트랜잭션의 모든 변화를 원 상태로 복구시킵니다.
이처럼 트랜잭션은 데이터의 일관성을 보장하는 중요한 역할을 합니다. 트랜잭션의 모든 작업이 성공적으로 완료되지 않으면, 아무것도 변경되지 않은 것처럼 돌려놓음으로써 데이터의 무결성을 유지합니다.
예시를 살펴보자면, 은행의 계좌 이체를 예로 들 수 있습니다. 이체는 송금자 계좌의 돈을 줄이고 수신자 계좌의 돈을 늘리는 두 가지 동작을 포함합니다. 이러한 두가지 동작 모두 완료(송금자 돈 minus, 수신자 계좌 돈 plus)되어야만 이체 트랜잭션이 커밋될 수 있습니다.
만약 송금자 계좌에서 돈이 줄어 든 후, 어떤 이유로 수신자 계좌에 돈을 늘리는 동작이 실패한다면, 이체 트랜잭션은 롤백되어 송금자 계좌의 돈은 원래대로 돌아갑니다.
특징
트랜잭션의 특징은 일반적으로 ACID라는 약자로 설명되는 네가지 특성으로 정의됩니다!
- 원자성(Atomicity) : 트랜잭션에 포함된 작업들이 DB에 모두 반영되거나, 전혀 반영되지 않아야 한다는 특성입니다.(All or Nothing)
- 일관성(Consistency) : 트랜잭션 내의 모든 작업들이 모두 성공적으로 완료된 후에, 데이터베이스 상태가 일관성을 유지해야 한다는 뜻입니다.(만약 송금 상황에서 송금자의 돈만 줄었다거나, 수신자의 돈만 늘은 경우엔 일관성에 위배되는 것!)
- 격리성(Isolation) : 각각의 트린잭션은 서로에게 영향을 주지 않아야 한다는 특성입니다. 트랜잭션 수행 시 다른 트랜잭션이 동시에 접근 하지 못하도록 하는 특성으로, 이를 보장하기 위해 락과 같은 잠금 메커니즘이 사용됩니다.
- 지속성(Durability) : 트랜잭션이 성공적으로 완료되면, 그 결과는 영구적으로 반영되어야 한다는 특성입니다. 시스템이 문제를 일으키는 경우(ex: 시스템이 다운되는 경우)에도 완료된 트랜잭션의 결과는 손실되지 않아야 합니다.
그런데 이러한 ACID 특성을 완벽하게 보장하려면 한 번에 한개의 트랜잭션만 처리해야 하는데, 이는 효율성 측면에서 크게 떨어집니다. 따라서 실제 운영 환경에서는 데이터 일관성과 성능 사이의 적절한 균형을 찾아야 합니다.
이를 위해, 격리성(Isolatioin) 수준을 조정하여 여러 트랜잭션을 동시에 처리할 수 있게 합니다. 이렇게 하면 데이터 일관성을 일정 수준 이상으로 유지하면서도, 동시에 여러 트랜잭션을 처리하여 시스템의 성능과 효율성을 높일 수 있습니다. 하지만 이때문에 발생하는 문제점은, 격리성 수준을 낮추면서 동시에 처리되는 여러 트랜잭션들 사이에 충돌이 일어날 수 있습니다.
따라서 적절한 격리성 수준을 설정하고, 이에 따른 문제를 관리하는 것이 중요합니다 :)
일관성(Consistency)와 동시성(Concurrency)
트랜잭션의 특징 중 일관성이 완전히 보장될 경우에 여러 클라이언트의 요청을 받는 데이터베이스의 특성상 응답의 지연이 발생(동시성이 저해되는)현상이 발생할 수 있습니다.
왜냐하면, 트랜잭션이 데이터에 접근하는 순간, 그 데이터는 다른 트랜잭션으로 부터 잠기게 되고, 그로 인해 다른 트랜잭션들은 대기 상태에 놓이게 됩니다. 이렇게 되면 동시에 수행되는 트랜잭션의 수가 줄어들게 되고, 결과적으로 시스템의 처리 성능이 저하됩니다.
반대로, 동시성을 높이기 위해 여러 트랜잭션들이 동시에 데이터에 접근하게 한다면, 이는 데이터의 일관성을 해칠 수 있습니다.
서로 다른 트랜잭션들이 동시에 같은 데이터를 변경하려고 하면, 그 결과는 예측할 수 없게 되고, 이는 데이터의 일관성을 해칠 수 있는 것이죠!
따라서, 데이터베이스에서는 이러한 일관성과 동시성 사이에서 균형을 이루어야 합니다.
이를 위해 동시성 제어가 필요하며, 이는 동시에 실행되는 트랜잭션의 수를 최대화하면서 동시에 데이터의 일관성을 보장하는 역할을 수행합니다.
하지만, 동시성과 일관성은 서로 반대의 특성을 가지는 트레이드오프 관계(하나 증가시, 하나 감소)에 있기 때문에, 이 두가지를 모두 만족시키는 것은 쉽지 않습니다. 따라서 애플리케이션의 특성과 필요성에 따라 적절한 동시성 제어 방법을 선택하고, 그에 따라 일관성 수준을 설정하는 것이 중요합니다.
이러한 동시성은 낙관적 동시성 제어와 비관적 동시성 제어로 나뉩니다.
- 낙관적 동시성 제어(Optimistic Concurrency Control) : 같은 데이터를 동시에 수정하지 않을 것으로 가정합니다(낙관적). 이는 트랜잭션이 데이터를 읽는 시점에는 락을 설정하지 않습니다. 대신, 트랜잭션이 데이터를 실제로 수정하려 할때, 그 데이터가 자신이 읽은 이후에 변경되었는지 확인합니다. 이러한 방식은 데이터 충돌이 드문 경우에 효율적이며, 대기시간을 줄일 수 있습니다.
- 비관적 동시성 제어(Pessimistic Concurrency Control) : 같을 데이터를 동시에 수정할 것으로 가정합니다(비관적). 이는 특정 데이터에 대한 작업을 시작할 때 바로 락을 설정합니다. 그래서, 그 트랜잭션이 작업을 완료하고 락을 해제할 때까지, 다른 트랜잭션들은 그 데이터에 접근 할 수 없습니다. 이는 데이터의 일관성을 높이는데 도움은 되지만, 동시에 여러 트랜잭션이 동일한 데이터에 접근하려고 할 경우 대기시간이 길어집니다. 예를 들어, 온라인 상점에서 같은 상품을 동시에 여러 사람이 구매하려고 하는 경우, 각 구매 트랜잭션이 상품의 재고를 업데이트할때 마다 락을 설정하면, 그 사이에 다른 사람들은 대기 상태가 됩니다
이 두개념 다 락을 언제 어떻게 사용할지 정하는 전략이라고 생각하시면 됩니다!
락(Lock)
데이터베이스에서 락이란 특정 작업을 수행하는 동안 다른 트랜잭션의 접근으로부터 보호하는 방법을 가리킵니다.
락은 주로 데이터의 일관성을 보장하고, 동시에 수행되는 여러 트랜잭션간의 충돌을 방지하기 위해 사용됩니다.
일반적으로 트랜잭션이 걸린 Lock은 트랜잭션이 commit 되거나, rollback 될때 함께 Unlock 됩니다.
- 공유락(Shared Lock) : 공유락은 여러 트랜잭션이 동시에 같은 데이터를 읽을 수 있게 해주지만, 그 데이터에 대한 쓰기 작업은 허용하지 않습니다. 따라서 한 트랜잭션이 데이터를 읽는 동안 다른 트랜잭션은 해당 데이터를 변경할 수 없습니다.
- 배타적 락(Exclusive Lock) : 이 락은 한 트랜잭션만이 데이터를 읽고 쓸 수 있게 해주며, 이 트랜잭션이 락을 해제할 때 까지 다른 모든 트랜잭션의 접근을 차단합니다. 따라서 배타적 락이 설정된 데이터에 대한 다른 모든 요청은 대기 상태가 됩니다. (비관적 동시성 제어에서는 트랜잭션이 데이터를 읽고 쓸때마다 베타적 락을 설정)
격리 수준(Isolation Level)
격리수준이란 동시에 여러 트랜잭션이 처리될 때, 특정 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있도록 허용할지 말지를 결정하는 것입니다!
이러한 트랜잭션 격리 수준 종류는 다음과 같습니다
- READ UNCOMMITED
- READ COMMITED
- REPEATABLE READ
- SERIALIZABLE
READ UNCOMMITTED(가장 낮은 격리 수준)
각 트랜잭션에서의 변경 내용이 COMMIT이나 ROLLBACK 여부와 상관 없이 다른 트랜잭션에서 값을 읽을 수 있는 격리 수준입니다. 이는 정합성에 문제가 많은 격리 수준이기 때문에 사용하지 않는 것을 권장합니다.
아래 그림과 같이 Commit이 되지 않은 상태이지만 Update된 값을 다른 트랜잭션에서 읽을 수 있습니다.
이 격리 수준에서는 Dirty Read, Non-Repeatable Read, Phantom Read가 다 발생할 수 있습니다. 각 용어에 대해서는 밑에서 좀더 자세히 다루어 보겠습니다 :)
READ COMMITTED
커밋된 데이터만 읽을 수 있는 격리 상태로, 대부분의 RDB에서 기본적으로 사용되고 있는 격리 수준입니다.
이때 실제 테이블에서 값을 가져오는 것이 아니라 Undo 영역에 백업된 레코드에서 값을 가져옵니다.
이러한 Read Committed의 경우 Dirty Read의 문제가 발생하진 않지만 다음과 같은 문제점이 발생할 수 있습니다.
트랜잭션 1이 Commit 한 이후 아직 끝나지 않은 트랜잭션 2가 다시 테이블 값을 읽으면 값이 변경되어 있음을 확인할 수 있습니다. 이는 하나의 트랜잭션 내에서 똑같은 SELECT 쿼리를 실행했을 때는 항상 같은 결과를 가져와야 하는 REPEATABLE READ의 정합성에 어긋납니다. 이러한 문제는 주로 입금, 출금 처리가 진행되는 금전적인 처리에서 주로 발생합니다.
REPEATABLE READ(반복 읽기)
한 트랜잭션에서 내에서 같은 쿼리를 여러번 실행할 경우, 언제나 동일한 결과를 가져와야 함을 의미하는 격리 수준입니다.
이렇게 하기 위해, 트랜잭션 마다 고유한 트랜잭션 ID를 부여하고, 이 ID보다 작은 트랜잭션 번호에서 변경한 데이터만 읽게 됩니다.
이는 일종의 MVCC(Multi Version Concurrency Control)방기법으로, 하나의 데이터에 대해 여러 버전을 유지함으로써 동시성을 향상시키는 동시에, 일관성을 보장합니다.
MySQL의 경우 MVCC를 구현하기 위해 Undo Log라는 것을 사용합니다. Undo Log는 데이터의 이전 버전을 저장한느 공간으로, 트랜잭션 도중 데이터의 변경이 필요할 경우 이 공간에서 이전 데이터를 가져옵니다.
그러나 Undo Log가 계속 쌓이게 되면 MySQL 서버의 처리 성능이 떨어질 수 있기 때문에, 더 이상 필요하지 않다고 판단되는 시점에 주기적으로 삭제해야 합니다.
그렇다면 이런 REPEATABLE READ에는 문제가 없을까요??
이 경우에 Dirty Read, Non Repeatable Read 문제는 발생하지 않지만 Phantom Read 문제가 발생할 수 있습니다.
이러한 현상을 방지하기 위해서는 쓰기 잠금을 걸어야 합니다.
SERIALIZABLE
데이터 트랙잭션의 가장 높은 격리 레벨입니다. 이때는 트랜잭션이 순차적으로 실행되기 때문에 이 격리 수준에서는 어떠한 동시성 문제도 발생하지 않습니다. 물론 성능 측면에서 동시성이 가장 낮겠죠??
이러한 격리수준의 경우 Phantom Read 또한 발생하지 않지만, DB에서 거의 사용되지 않습니다.
각 레벨별 트랜잭션 적용 방법
// 별도로 정의하지 않으면 DB의 Isolation Level을 따름
@Transactional(isolation = Isolation.DEFAULT)
@Transactional(isolation = Isolation.READ_UNCOMMITTED)
@Transactional(isolation = Isolation.READ_COMMITTED)
@Transactional(isolation = Isolation.REPEATABLE_READ)
@Transactional(isolation = Isolation.SERIALIZABLE)
동시성 제어로 인한 문제점들(Dirty Read, Non Repeatable Read, Phantom Read)
Dirty Read
한 트랜잭션이 아직 커밋되지 않은 다른 트랜잭션의 변경을 읽는 것을 말합니다. 이때 해당 트랜잭션이 롤백될 경우 최종 결과값이 비 일관적으로 적용될 가능성이 있습니다.
만약 트랜잭션 A가 어떤 레코드를 변경했지만, 아직 커밋하지 않았는데, 이 시점에서 트랜잭션 B가 해당 레코드를 읽어버리면 Dirty Read가 발생합니다. 만약 트랜잭션 A가 롤백된다면??? 존재하지 않는 데이터를 읽는 셈이 되는 것이죠!
이해를 위해 예시를 한개 들어보겠습니다!
- A 트랜잭션에서 회원 a의 구매 목록을 추가하여 결제 금액이 4만원에서 5만원으로 변경
- 아직 커밋하지 않았는데 B 트랜잭션이 회원 a의 결제 금액을 조회
- 결제 금액이 5만원으로 조회됨(Dirty Read)
- A 트랜잭션에 문제가 생겨 롤백 처리게 되고, 이로 인해 결제 금액이 5만원에서 4만원으로 다시 변경
- B 트랜잭션은 조회한 5만원을 가지고 결제를 진행
보다시피 Dirty Read 문제가 발생하면 데이터의 일관성(consistency)이 깨질 수 있습니다!
Non Repeatable Read
한 트랜잭션 내에서 같은 쿼리를 두번 수행했을때, 두 쿼리의 결과가 서로 다른 경우를 말합니다. 이는 보통 다른 트랜잭션이 쿼리 사이에 데이터를 수정하거나 삭제할때 발생합니다. 예를 들어 트랜잭션 A가 어떤 레코드를 읽고 있는데, 트랜잭션 B가 그 사이에 해당 레코드를 수정하거나 삭제하고 커밋하면, 트랜잭션 A가 다시 같은 레코드를 읽었을때 결과가 다르게 되는 것이죠
이해를 위해 예시를 하나 살펴보겠습니다!
- B 트랜잭션에서 회원 a의 결제 금액을 조회
- 4만원이 조회됨
- A 트랜잭션에서 회원 a의 결제 금액을 5만원으로 변경하고 Commit
- B 트랜잭션에서 회원 a의 결제 금액을 다시 조회
- 5만원이 조회됨
하나의 트랜잭션 내에서 똑같이 Select를 수행했을때 같은 결과를 반환해야 한다는 Repeatable Read 정합성에 맞지 않는 결과를 가져오게 됩니다.
Phantom Read
Phantom Read는 한 트랜잭션 내에서 동일한 쿼리를 두 번 이상 실행할 때, 첫 번째 쿼리에서는 없던 레코드가 두 번째 쿼리에서 나타나거나, 반대로 사라지는 현상을 말합니다.
예를 들어, 트랜잭션 A가 어떤 조건을 만족하는 레코드들을 검색하는 쿼리를 실행하고, 그 사이에 트랜잭션 B가 그 조건을 만족하는 새로운 레코드를 추가하거나 삭제하고 커밋하면, 트랜잭션 A가 다시 같은 쿼리를 실행했을 때 검색 결과가 달라집니다.
이해를 위해 예시를 하나 살펴보겠습니다.
- 트랜잭션 A에서 결제 금액이 4만원 이상인 회원을 검색 -> 회원 A와 회원 B가 조회됨
- 트랜잭션 B에서 회원 C의 결제 금액을 5만원으로 변경하고 Commit
- 트랜잭션 A에서 다시 결제 금액이 4만원 이상인 회원을 검색 -> 회원 A,B,C가 조회됨
이럴 경우, 같은 쿼리를 두 번 수행했음에도 불구하고 두 번째 조회에서 없던 레코드(회원 C)가 나타나는 것이므로, Phantom Read 문제가 발생한 것입니다.
Wiki 내용을 발췌하자면, 데이터를 읽은 후 로직을 진행하는데 있어서, 다른 트랜잭션의 Update, Insert의 개입으로 다시 읽었을 때 데이터의 무결성이 깨진다라고 이해하시면 됩니다 :)
참고
https://wildeveloperetrain.tistory.com/m/123
https://nesoy.github.io/articles/2019-05/Database-Transaction-isolation
'데이터베이스' 카테고리의 다른 글
@Transactional 동작원리(+readOnly=true) (0) | 2023.08.08 |
---|