본 포스팅은 데이터 중심 애플리케이션 설계의 7장 트랜잭션 단원을 참조하여 작성되었습니다.
또한 포스팅 내용 중 개인적인 의견이 들어간 부분도 있으니 자세한 내용은 책을 참조해주세요.
아무리 대단한 데이터 시스템이라도 언제나 아래와 같은 문제 상황이 발생할 수 있다.
- 데이터베이스의 소프트웨어 또는 하드웨어의 실패
- 예상치 못한 애플리케이션의 종료
- 네트워크 단절
- 여러 클라이언트의 동시 쓰기에 의한 덮어쓰기
- 데이터의 부분 갱신
- 클라이언트의 Race Condition에 의한 버그
뛰어난 시스템은 위와 같은 결함을 처리해서 전체적으로 시스템 장애로 이어지는 것을 막아야 한다.
이러한 결함을 다루는 기술은 트랜잭션이라는 매커니즘으로 사용되어 왔다.
트랜잭션은 애플리케이션의 몇 개의 읽기와 쓰기를 하나의 논리적 단위로 묶는 방법이다.
Application에서 발생한 많은 명령을 하나의 Transaction으로 묶어 Database에 전달하고
Database는 Transaction의 모든 명령이 실행될 수 있는지 확인하고 성공한다면 Commit을 진행하고
실패한다면 기존에 실행된 Transaction 명령을 Rollback하고 Application에 Abort를 반환한다.
이는 Applicatoin의 Database에서 발생할 수 있는 다양한 문제를 Transaction이라는 개념을 통해 단순화 한 것이다.
Transaction은 수치적으로 정해진 것이 아니기 때문에 각 Application의 특성에 맞게 조절하는 것이 중요하다.
그렇다면 Application이 어느정도의 Transaction Level이 필요한지 어떻게 확인할 수 있을까?
이를 위해선 Transaction Level에 따른 제공 기능을 확인해보고 결정할 수 있을 것이다.
주로 연관된 문제는 동시성 문제이며 일반적으로 Database는 아래의 3가지 정도의 Transaction Level을 제공한다.
- Read committed (커밋 후 읽기)
- Snapshot Isolation (스냅샷 격리)
- Serializability (직렬성)
Transaction을 지원하는 다양한 Database는 ACID라는 조건을 만족한다.
여기서 ACID는 원자성(Atomicity), 일관성(Consistency), 격리성(Isolation), 지속성(Duration)을 의미한다.
원자성이란 위에서 봤던 여러 명령을 가진 Transaction이 성공하면 commit되고 실패하면 전체가 rollback되는 것을 말한다.
즉, 하나의 Transaction은 쪼갤 수 없는 단위가 된다.
일관성은 데이터는 항상 진실이어야 한다는 개념이다.
다소 모호한 개념인데, 이는 Application에서 의미하는 데이터의 성질과 관계에 따른 일관성 보존을 의미한다.
예를들어 계좌 송금 시스템에서 송금된 금액은 반드시 목적지 계좌에 추가되며 송금된 계좌에서는 금액이 차감되는 일관성이 유지되어야 한다.
격리성은 동시에 실행되는 Transaction은 서로 격리되어야 하며 서로를 방해할 수 없다를 의미한다.
격리성을 보장하기 위한 방법은 다양한데 이는 Transaction Level에 따라 다르게 구현된다.
지속성은 Database에 저장된 데이터는 손실되지 않는 안전한 곳에 저장되어야 한다는 의미다.
ACID의 성질 중 Transaction과 직접적으로 연관되어 있는 것은 원자성과 격리성이다.
따라서 이 2개의 성질에 대해서 보장하는 방식을 알아보도록 하자.
문제점 1 - Dirty Read
특정 시간에 오픈되는 공연 티켓 예매 서비스가 있다고 생각해보자.
사용자는 관람하고 싶은 좌석 등급을 고르고 세부 좌석을 선택하고 금액을 지불해서 예매를 진행한다.
티켓 예매가 오픈되면 순간적인 트래픽이 급증하기 때문에 등급마다 남은 좌석 수를 별개의 테이블에 저장하고
사용자가 좌석 등급을 고를 때 해당 테이블에서 좌석 수를 읽어 보여준다.
그러면 특정 사용자가 좌석 예매를 완료했다고 하면 발생해야 하는 UPDATE는 2개가 된다.
UPDATE seats SET booked = true WHERE grade=S AND row=10 AND col=10
UPDATE seats_count SET count = count+1 WHERE grade=S
만약 첫번째 UPDATE가 실행되고 두번째 UPDATE가 실행되기 전에 다른 사용자가 S 등급의 좌석 수 조회를 요청한다면
실제 예매 가능한 좌석수와 조회되는 좌석 수는 다르게 나올 것이다.
이러한 현상을 격리성 위반, Dirty Read라고 한다.
즉, UPDATE를 2개 실행해야 하는 트랜잭션 A가 완료되기 전에 조회하는 트랜잭션 B가 커밋되지 않은 트랜잭션 A의 중간 결과를 읽을 수 있게 된 것이다.
문제점 2 - Dirty Write
두 사용자가 동일한 좌석을 예매하고 금액을 지불할 경우를 생각해보자.
# A 사용자
UPDATE seats SET booked = true WHERE grade=S AND row=10 AND col=10 AND booked=false # 1
UPDATE payment SET buyer='A' WHERE grade=S AND row=10 AND col=10 # 2
# B 사용자
UPDATE seats SET booked = true WHERE grade=S AND row=10 AND col=10 AND booked=false # 3
UPDATE payment SET buyer='B' WHERE grade=S AND row=10 AND col=10 # 4
굉장히 드문 경우겠지만, 두 사용자가 동시에 UPDATE seats를 했다면 두 사용자 모두 금액을 지불 할 수 있게 된다.
이렇게 되면 마지막에 금액을 지불한 사용자가 좌석의 buyer가 된다.
위처럼 seats table을 갱신하기 전에 다른 사용자 역시 갱신 요청을 하게 된다면 큰 문제를 유발시킬 수 있다.
문제 발생의 원인은 예매하려는 좌석에 대해 잠금을 하지 못해서 발생한 것으로 Dirty Write 문제라고 한다.
해결 방법 1 - Read Committed (커밋 후 읽기)
Drity Write 문제는 사실 MySQL과 같은 일반적인 RDBMS를 사용한다면 거의 겪지 않을 것이다.
RDBMS는 기본적으로 데이터베이스의 Row 수준의 잠금을 지원하기 때문이다.
따라서 트랜잭션이 특정 Row를 변경하고 싶다면 트랜잭션은 잠금을 획득해야하고 commit 또는 abort가 일어난 후에 다른 트랜잭션에서 해당 Row를 변경할 수 있게 된다.
그렇다면 Dirty Read의 경우는 어떨까?
어떤 트랜잭션이 변경을 위해 Row 잠금을 획득한 상태라면, 해당 Row를 읽기를 원하는 트랜잭션은 변경이 끝날 때까지 기다려야 할까?
대부분의 데이터베이스는 변경이 끝날때까지 기다리지 않고 변경 트랜잭션이 잠금을 획득하기 전에 있던 Row 값을 읽어 간다.
시간 | Row의 현재 값 | 액션 |
00:01 | X = 5 | Transaction A : Row 잠금 획득 |
00:02 | Transaction A : X = 6 변경 | |
00:03 | Transaction B : Row 읽기 X = 5 | |
00:04 | X = 6 | Transaction A : X = 6 쓰기 |
00:05 | Transactino A Row 잠금 해제 |
위와 같이 Transaction A가 변경을 위해 잠금을 획득하고 업데이트 중(00:01~00:05)이라고 하더라도 Transaction B는 00:03 시간에 X = 5인 변경 전의 값을 읽을 수 있다.
문제점 3 - Nonrepeatable Read
Read Committed가 보장되는 환경에서 사용자가 계좌 B(잔고 = 500원)에서 계좌 A(잔고 = 500원)로 돈(100원)을 전달하는 예제를 살펴보자.
시간 | 액션 | 결과 |
00:01 | 계좌 B에서 계좌 A로 100원 전달 요청 (계좌 A +100) (계좌 B -100) |
|
00:02 | 계좌 A 조회 | A 계좌 잔고 = 500원 |
00:03 | 전달 완료 | |
00:04 | 계좌 B 조회 | B 계좌 잔고 = 400원 |
Read Committed가 보장되기 때문에 계좌 B에서 계좌 A로 100원을 전달(00:01~00:03)하는 트랜잭션은 원자성과 격리성이 보장된다.
하지만 전달이 완료(트랜잭션 완료, 00:03)되기 전에 A 계좌를 조회(00:02)했다면 A 계좌는 전달되기 전의 잔고인 500원을 반환한다.
반면에 전달이 완료된 후 B 계좌를 조회(00:04)했다면 B 계좌는 전달된 후의 잔고인 400원을 반환한다.
이런 상황이 발생한다면 사용자는 1000원이 있지만 동시성 버그에 의해 900원이 있다고 조회될 것이다.
이러한 문제를 nonrepeatable read(비반복 읽기)라고 한다.
해결 방법 2 - Snapshot Isolation(Repeatable Read)
Snapshot Isolation은 nonrepeatable read를 해결할 수 있는 해결책이 된다.
각 트랜잭션은 데이터베이스의 일관된 Snapshot으로부터 데이터를 읽는다.
즉, 생성된 트랜잭션은 생성 당시의 데이터베이스에 commit되어 있는 현재의 모든 데이터를 본다.
따라서 트랜잭션 이후에 생성된 미래 데이터에 대해서는 볼 수 없고, 오직 과거에 커밋된 데이터만을 바라본다.
위의 nonrepeatable read의 문제였던 상황을 다시 확인해보자.
시간 | 트랜잭션 ID | 액션 | 결과 |
00:01 | 1 | 계좌 B에서 계좌 A로 100원 전달 요청 (계좌 A +100) (계좌 B -100) |
|
00:02 | 트랜잭션 2 생성 | ||
00:03 | 2 | 계좌 A 조회 | A 계좌 잔고 = 500원 |
00:04 | 전달 완료 | ||
00:05 | 2 | 계좌 B 조회 | B 계좌 잔고 = 500원 |
00:06 | 트랜잭션 3 생성 | ||
00:07 | 3 | 계좌 A 조회 | A 계좌 잔고 = 600원 |
00:08 | 3 | 계좌 B 조회 | B 계좌 잔고 = 400원 |
트랜잭션 2가 생성된 순간(00:02)은 아직 전달이 완료(commit)되지 않은 상태이기 때문에
각 계좌의 잔고를 조회(00:03, 00:05)했을 때 전달 전의 상태인 500원씩을 가지고 있다.
반면에 전달이 완료된 후 만들어진 트랜잭션 3은 전달이 완료된 금액인 600원/400원(00:07, 00:08)을 가진 것을 볼 수 있다.
이 같이 데이터베이스가 객체의 여러 버전을 함께 유지하는 기법을 다중 버전 동시성 제어(multi version concurrency control, MVCC)라고 한다.
문제점 4 - Lost Update
Lost Update(갱신 손실) 문제는 주로 read-modify-write 주기를 가졌을 때 발생한다.
# read
SELECT value from counters WHERE key = 'foo';
# modify
int new_value = value+1;
# write
UPDATE counters SET value = new_value WHERE key = 'foo';
트랜잭션이 row의 특정 column을 증가시킬 때 기존 값을 읽고(read), 증가시키고(modify), 증가된 값을 쓴다(write).
이 때, 2개의 트랜잭션이 동시에 read-modify-write를 한다면 같은 값에 대해 수행하기 때문에 실제로 2만큼이 증가되지 않고 1만 증가된다.
이러한 문제는 대부분의 관계형 데이터베이스에서 원자적 쓰기 연산을 제공하는데 이를 통해 해결할 수 있다.
UPDATE counters SET value = value+1 WHERE key = 'foo';
또한 명시적 잠금을 이용해 값을 업데이트하는 트랜잭션이 해당 row를 잠금해 다른 트랜잭션이 읽지 못하도록 만들 수 있다.
BEGIN TRANSACTION;
SELECT * FROM object WHERE key = 'foo' FOR UPDATE; # 명시적 잠금
UPDATE object SET value = 'update' WHERE key = 'foo';
COMMIT;
문제점 5 - Write Skew & Phantom Read
Dirty Write와 Lost Update는 서로 다른 트랜잭션이 같은 객체에 동시에 쓰려고 할 때 발생한 문제점들이다.
Write Skew는 쓰기에서 문제가 발생한다는 점은 같지만 서로 다른 객체에 쓸 경우 발생하는 문제점이다.
다른 트랜잭션이 다른 객체에 쓰는게 어떤 문제가 되는걸까? 아래의 예를 살펴보자.
Tom은 쇼핑몰에서 포인트를 이용해서 물건을 사려고 한다.
쇼핑몰은 사용자의 포인트가 물품의 가격보다 크면 물품을 구매하는 order table에 이를 업데이트한다.
BEGIN TRANSACTION;
SELECT price FROM products WHERE product_id = 'foo';
SELECT point FROM points WHERE user_id = 'tom';
if (point >= price) {
INSERT INTO order (user_id, product_id) VALUES ('tom', 'foo');
UPDATE points SET point=point-price WHERE user_id = 'tom';
}
COMMIT;
만약 Tom이 2개의 트랜잭션을 이용해 'foo' product와 'bar' product을 동시에 포인트로 구매하려고 하면 어떻게 될까?
Snapshot Isoloation(Repeatable Read)이라면 'foo'를 구매하는 트랜잭션이 진행 중이더라도
동시에 생성된 트랜잭션의 snapshot에서 point는 차감이 안되었기 때문에 'bar' 물품 역시 구매가 되고 point는 음의 값을 가지게 될 수 있다.
Read Committed도 역시 물품 구매 중 값을 읽을 수 있기 때문에 Snapshot Isolation과 같은 문제점이 발생한다.
동시에 같은 객체에 쓰기가 아니기 때문에 원자성이나 격리성에 위배되지 않지만 분명한 문제가 발생한다.
이러한 문제를 해결하는 가장 좋은 방법은 한번에 하나의 트랜잭션만 실행하는 것이다.
뒤에서 나올 Serializability 수준을 사용하면 가능하지만 처리 속도의 이유로 이를 사용할 수 없다면 앞에 Lost Update의 해결 방법 중 하나였던 명시적 잠금을 이용해 해결할 수 있다.
BEGIN TRANSACTION;
SELECT price FROM products WHERE product_id = 'foo';
SELECT point FROM points WHERE user_id = 'tom' FOR UPDATE;
if (point >= price) {
INSERT INTO order (user_id, product_id) VALUES ('tom', 'foo');
UPDATE points SET point=point-price WHERE user_id = 'tom';
}
COMMIT;
FOR UPDATE 를 사용해 points 테이블에서 user_id = 'tom'에 해당하는 row를 잠금하게 되면 해당 트랜잭션이 종료되기 전까지 다른 트랜잭션에서는 접근할 수 없게된다.
위와 같은 SELECT -> 조건 확인 -> UPDATE/INSERT와 같은 흐름에서 SELECT 부분에서 문제가 발생하는 경우를 Phantom Read라고 한다.
문제점 6 - Materializing Conflict (충돌 구체화)
위 Write Skew & Phantom Read는 명시적 잠금을 이용해 문제를 해결했다.
해결이 가능한 이유는 tom의 포인트 row와 같이 명시적 잠금을 할 대상이 정해져있기 때문이다.
하지만 조건 확인을 위한 SELECT에서 잠금할 대상이 없다면 Materializing Conflict(충돌 구체화) 문제가 발생한다.
예시를 위해 회의실 예약 시스템을 생각해보자.
BEGIN TRANSACTION;
SELECT count(*) FROM bookings
WHERE start_time < '2020-07-31 14:00'
AND end_time > '2020-07-31 15:00';
if (count == 0) {
INSERT INTO bookings (start_time, end_time) VALUES ('2020-07-31 14:00', '2020-07-31 15:00');
}
COMMIT;
14:00에서 15:00 사이에 회의가 없다면 회의실을 예약한다.
조건 확인을 위해 조회한 값은 예약 시간에 존재하는 예약 정보인데 이를 잠금하는건 충돌 문제를 해결하는데 도움이 되지 않는다.
회의실 예약을 위해서는 조회된 값이 없어야 하고 이 때, 예약을 위해 Write를 진행한다.
하지만 Write 중에 다른 트랜잭션에서 동일한 내용이 실행됐다면 그 어떤 잠금도 없기 때문에 충돌이 발생한다.
이러한 경우에는 Serializability(직렬성) 수준의 트랜잭션을 이용해 한 순간에 하나의 트랜잭션만 실행되도록 해야한다.
해결 방법 3 - Serializability(직렬성)
동시성 문제를 해결하는 가장 좋은 방법은 동시성을 없애는 것이다.
Serializability는 동시성을 없애고 병렬적으로 트랜잭션이 들어와도 한 순간에 오직 하나의 트랜잭션만 처리한다.
이렇게 되면 앞에서 발생했던 모든 문제를 해결 할 수 있다.
직렬성을 이용할 때는 트랜잭션에서 실행해야 하는 구문을 모두 Database에 미리 생성해 놓는 stored procedure를 주로 사용한다.
직렬성의 장점 :
- 동시성 문제(Dirty Write, Lost Update, Wirte Skew, Materializing Conflict)가 발생하지 않는다.
직렬성의 단점 :
- 하나의 thread만 이용하기 때문에 Database 단일 코어의 성능이 중요해진다.
- 하나의 트랜잭션만 처리하기 때문에 시간 당 처리량이 낮아진다.
정리
Database에서 지원하는 트랜잭션의 수준(Level)에 대해 알아봤다.
몇몇 NoSQL은 이러한 트랜잭션을 포기하고 높은 처리 속도와 분산 저장을 선택한 경우도 많다.
어떤 트랜잭션 수준을 사용하든 또는 아예 사용하지 않든 중요한 것은 애플리케이션에서 필요로 하는 정도의 수준에서
Best Practice를 파악하고 적용하는 것 같다.