본문 바로가기
Java/트러블 슈팅

상품 구매 트랜잭션으로 보는 부정합 문제

by oneny 2023. 11. 13.

트랜잭션 이상 현상에 따른 정합성 문제

 

트랜잭션과 잠금

트랜잭션과 잠금 트랜잭션 트랜잭션은 꼭 여러 개의 변경 작업을 수행하는 쿼리가 조합됐을 때만 의미 있는 개념은 아니다. 트랜잭션은 하나의 논리적인 작업 셋에 하나의 쿼리가 있든 두 개 이

oneny.tistory.com

위 트랜잭션과 잠금에 대한 이론을 공부하고 프로젝트에 이 트랜잭션과 잠금을 어떻게 적용할 수 있을지 테스트해보려고 한다.

 

상품 구매 상황에서 발생 가능한 문제

두 명의 사용자가 동시에 하나의 상품을 구매했다고 가정해보면 다음과 같은 로직의 순서가 이루어진다.

  1. 구매하려는 상품 조회해서 재고가 있는지 확인
  2. 사용자의 주문 데이터 생성
  3. 구매하려는 상품 재고 차감

물론 결제 로직도 추가해야하지만 그것보다는 사용자가 상품 구매하는 상황에서 발생할 수 있는 동시성 이슈가 무엇인지에 초점을 맞추자. 위 그림을 보면 transaction_a와 transaction_b가 동시에 재고 확인을 했을 때 해당 상품은 재고가 10이고, tranction_b에서 재고를 차감한 후 commit을 하면서 트랜잭션이 종료가 되었다. 그리고 동시에 진행되고 있었던 transaction_a에서는 transaction_b가 수행했었다는 사실을 모르고, 재고가 10인 것으로 알고있다. 그래서 10에서 하나를 차감한 9로 업데이트를 하게 되는데 그러면 transaction_b가 수행했던 내용이 사라지게 된다. 이러한 현상을 Lost update라고 한다. 이렇게 Nonserial Schedule에서 transaction들이 어떤 형태로 겹쳐서 실행되는지에 따라 이상한 결과가 나올 수 있고 이를 어떻게 해결할 수 있을지 살펴보자.

 

트랜잭션 이상 현상

트랜잭션들이 동시에 실행될 때 발생 가능한 이상 현상들에 대해서 살펴보자.

 

Dirty Read

READ UNCOMMITED 격리 수준에서는 Dirty Read를 허용한다. 이는 트랜잭션 A(왼쪽)가 어떤 데이터를 수정하려고 하지만 커밋하지 않은 상태에서 트랜잭션 B(오른쪽)가 버퍼풀에 캐시된 아직 커밋되지 않은 트랜잭션 A의 변경 내용을 읽으려고 시도하기 때문에 발생하는 문제이다. 즉, 버퍼풀의 데이터가 디스크에 기록되기 전에 읽힐 경우, 아직 커밋되지 않은 변경 사항이 포함된 데이터를 읽게 된다.

이는 격리 수준을 READ COMMITED 이상의 격리 수준을 사용하여 해결할 수 있다.

 

Non-repeatable Read

Non-repeatable Read은 READ UNCOMMITED 격리 수준과 READ COMMITED 격리 수준에서 발생할 수 있는 이상 현상이다. 이 현상은 동일한 트랜잭션 내에서 같은 데이터를 두 번 읽을 때 발생하며, 첫 번째 읽은 값과 두 번째 읽은 값이 다르게 나타나는 경우를 가리킨다. 위 상황을 살펴보자.

  1. 트랜잭션 B(오른쪽)가 데이터를 읽음
  2. 트랜잭션 A(왼쪽)가 데이터를 수정 후 커밋
  3. 트랜잭션 B(오른쪽)가 동일한 데이터를 다시 읽음

이 때 3번 트랜잭션 B가 동일한 데이터를 다시 읽을 때 값이 변경되었기 때문에 Non-repeatable Read가 발생하게 된다. Non-repeatble Read를 방지하려면 보다 높은 격리 수준인 REPEATABLE READ 또는 SERIALIZABLE 격리 수준을 사용할 수 있다. 이러한 격리 수준에서는 트랜잭션이 읽은 데이터를 다른 트랜잭션이 수정할 수 없도록 락을 걸어두어, 읽은 데이터가 일관된 상태를 유지하게 된다.  아래 결과를 보면 트랜잭션 A가 COMMIT했음에도 불구하고 일관된 데이터를 읽는 것을 확인할 수 있다.

 

REPEATABLE READ 격리 수준과 READ COMMITED 격리 수준 모두 언두 영역에 백업된 이전 데이터를 보여 주지만, 두 격리 수준에는 언두 영역을 활용하는 방식이 다르다. 즉, REPEATABLE READ 격리 수준은 언두 영역에 백업된 레코드의 여러 버전 가운데 몇 번째 버전을 보여 주냐에 차이가 있어 Non-repeatable Read 문제를 해결할 수 있다.

즉, 언두 영역에 백업된 모든 데이터에는 변경을 발생한 트랜잭션의 번호가 포함되어 있는데, REPEATABLE READ 격리 수준에서는 실행 중인 트랜잭션보다 작은 트랜잭션에서 변경한 데이터만 보게 하여 Non-repeatable 문제를 해결한다.

 

당일 주문 총액 조회 상황으로 보는 NON-REPEATABLE READ 문제

당일 주문 총합을 조회하려는 transaction_b 트랜잭션이 시작되었다고 가정해보자. 트랜잭션이 시작한 시간은 22:17:53이다.

 

그리고 다른 transaction_a 트랜잭션은 사용자가 주문하려는 트랜잭션이고, 시작한 시간은 22:18:04 이다. transaction_b 트랜잭션이 현재 조회하는 주문 총합은 210,000원인 것을 확인할 수 있다.

 

하지만 transaction_a가 먼저 트랜잭션이 종료되었을 때, transaction_b에서 다시 당일 주문 총합을 조회하려고 했을 때는 다른 트랜잭션에 의해 반영된 레코드가 추가로 조회되는 것을 확인할 수 있다.

이러한 부정합 현상은 하나의 트랜잭션에서 동일한 데이터를 여러 번 읽고 변경하는 작업이 금전적인 처리와 연결되면 문제가 될 수 있다. 위 상황처럼 다른 트랜잭션에서 주문 처리가 계속 진행될 때 다른 트랜잭션에서 오늘 주문된 금액의 총합을 조회하려고 하면 REPEATABLE READ가 보장되지 않기 때문에 총합을 계산하는 SELECT 쿼리는 실행될 때마다 다른 결과를 가져올 것이다. 중요한 것은 내가 트랜잭션을 시작했을 때 사용 중인 트랜잭션의 격리 수준에 의해 실행하는 SQL 문장이 어떤 결과를 가져오게 되는지 정확히 예측할 수 있어야 한다는 것이다.

READ COMMITED 격리 수준에서 트랜잭션 내에서 실행되는 SELECT 문장과 트랜잭션 외부에서 실행되는 SELECT 문장의 차이가 별로 없기 때문에 SELECT으로 조회할 때마다  다른 결과를 가져와 잘못된 정보를 전달하고, 데이터 정합성이 깨지는 문제가 발생할 수 있다. 하지만 REPEATABLE READ 격리 수준에서는 기본적으로 SELECT 문장도 트랜잭션 범위 내에서만 작동한다. 즉, START TRANSACTION 명령으로 트랜잭션 시작한 상태에서 온종일 동일한 쿼리를 반복해서 실행해도 동일한 결과만 보여준다. 

 

Phantom Read

Phantom Read는 SELECT ... FOR UPDATE 쿼리와 같은 쓰기 잠금을 거는 경우 다른 트랜잭션에서 수행한 변경 작업에 의해 트랜잭션이 동일한 쿼리를 실행했을 때, 같은 결과 집합을 얻지 못하는 현상을 말한다. 위 상황의 순서를 다음과 같다.

  1. 트랜잭션 B(오른쪽 트랜잭션)가 범위를 읽음
  2. 트랜잭션 A(왼쪽)가 새로운 행을 삽입
  3. 트랜잭션 B(오른쪽)가 같은 범위를 다시 읽음

3번 트랜잭션 B가 동일한 범위를 다시 읽었을 때, 데이터가 새로운 행이 추가되었기 때문에 두 번째 SELECT ... FOR UPDATE에서 이전과 다른 결과를 얻는 것을 확인할 수 있다. 그 이유는 SELECT ... FOR UPDATE 쿼리의 경우 SELECT하는 레코드에 쓰기 잠금을 걸어야 하는데, 언두 영역에는 잠금을 걸 수 없기 때문이다. 따라서 어쩔 수 없이 SELECT ... FOR UPDATE나 SELECT ... LOCK IN SHARE MODE로 조회되는 레코드는 언두 영역의 변경 전 데이터를 가져오는 것이 아니라 현재 레코드의 값을 가져온다. 이러한 Phantom Read는 방지하기 위해서는 InnoDB인 테이블인 경우에는 REPEATABLE READ나 SERIALIZABLE 격리 수준으로 설정하면 된다.

InnoDB 스토리지 엔진은 레코드 락과 갭 락을 합친 넥스트 키 락을 사용한다. t 테이블에 c1 = 13, c = 17인 두 레코드가 있다고 가정하자. 이때, SELECT c1 FROM t WHERE c1 WHERE c1 BETWEEN 10 AND 20 FOR UPDATE 쿼리를 수행하면, 10 <= c1 <=12, 14 <= c1 <= 16, 18 <= c1 <= 20인 영역은 전부 갭 락에 의해 락이 걸려서 해당 영역에 레코드를 삽입할 수 없다. 또한 c = 13, c = 17인 영역도 레코드 락에 의해 해당 영역에 레코드를 삽입할 수 없다. 이러한 방식으로 InnoDB 스토리지 엔진은 넥스트 키 락을 이용하여 PHANTOM READ 문제를 해결한다.

 

2024.02.11일에 업데이트된 내용인데 REPEATABLE READ로 격리 수준을 사용하는 경우에는 stock에 대한 넥스트 키 락으로 인해 다른 트랜잭션에서 쓰기 작업이 진행되지 못하는 것을 확인할 수 있다.

 

Lock을 활용한 Concurrency Control

Dirty Write, Lost Update 등의 상황에서 Lock을 활용하여 여러 트랜잭션이 DB에 접근해서 데이터 수정하려고 할 때 일어나는 충돌을 해결할 수 있다.

 

비관적 락(Pessimistic Lock)

호환성 Shared Lock(Read Lock) Exclusive Lock(Write Lock)
Shared Lock(Read Lock) O X
Exclusive Lock(Write Lock) X X

 

비관적 락은 REPEATABLE READ 또는 SERIALIZABLE 격리 수준에서 제공한다. 트랜잭션이 시작될 때 Shared Lock(read lock) 또는 Exclusive Lock(write lock)을 걸고 시작한다.

  • 공유락(Shared Lock): Read Lock이라고도 불리는 공유락은 트랜잭션이 읽기를 할 때 사용하는 락이며, 데이터를 읽기만하기 때문에 같은 공유락끼리는 동시에 접근이 가능하지만, write(insert, update, delete) 작업은 막는다.
  • 배타락(Exclusive Lock): Write Lock이라고도 불리며, 데이터를 변경할 때 사용하는 락이다. 트랜잭션이 완료될 때까지 유지되며, 배타락이 끝나기 전까지 read/write를 모두 막는다.

 

Dirty Write

위 사용자가 동시에 상품을 구매하는 트랜잭션 중 하나의 순서는 위와 같을 것이다. InnoDB 테이블을 사용하는 테이블에서는 UPDATE된 레코드에 대해서 배타락(Exclusive Lock)이 걸려 다른 트랜잭션에서 동시에 수행하지 못하도록 하여 commit되지 않은 데이터를 write하는 Dirty write 상황을 방지한다.

 

Lost Update

맨처음에 봤던 사용자가 동시에 상품을 구매하는 트랜잭션에서 정합성 문제가 발생할 수 있는 상황이다. 트랜잭션이 동시에 실행됐을 때 두 트랜잭션에서 모두 product_a 재고가 10으로 재고가 있는 것을 확인할 수 있다. 하지만 트랜잭션 B(오른쪽)에서 재고를 3개 차감하여 7개로 업데이트했지만 트랜잭션 A(왼쪽)에서 재고를 9개로 업데이트가 진행되면서 트랜잭션 B가 진행했던 내용을 사라져버린 것이다. 이렇게 업데이트를 덮어쓰는 현상을 Lost Update라고 한다.

 

이를 비관적 락을 사용하여 해결하기 위해서는 SELECT ... FOR UPDATE;를 통해서 읽기 락(Shared Lock)이 아닌 쓰기 락(Exclusive Lock)을 사용하도록 하면 특정 행이나 테이블의 데이터를 읽을 때 쓰기 락을 획득한다. 이를 통해 해당 데이터에 대한 다른 트랜잭션의 읽기(다른 트랜잭션에서도 SELECT ... FOR UPDATE이어야 함) 또는 쓰기 작업을 방지하고, 현재 트랜잭션이 데이터를 수정할 수 있게 한다.

하지만 이러한 직접 락을 걸어서 하나의 작업만 처리되게끔 보장하는 비관적 락은 데이터 자체에 락을 걸기 때문에 동시성이 떨어져 성능이 많이 저하되며, 서로의 자원이 필요한 경우에는 데드락이 일어날 가능성이 있다는 문제가 있다.

 

낙관적 락(Optimistic Lock)

낙관적 락은 자원에 락을 걸지 않고, 동시성 문제가 발생하면 그때 처리한다. 즉, DB에서 제공해주는 특징을 이용하는 것이 아닌 어떤 행의 현재 상태를 체크하고, 해당 행이 수정되었는지 여부를 확인하는 방식으로 Application Level에서 잡아주는 Lock이다.

 

위 그림을 보는 것처럼 같은 레코드에 대해 각기 다른 2개의 수정 요청이 있었지만 1개가 업데이트됨에 따라 version이 변경되었기 때문에 뒤의 수정 요청은 반영되지 않게 되었다. 이렇게 낙관적 락은 version과 별도의 컬럼을 추가하여 충돌적인 업데이트를 막는다. 또는 커밋할 때 다시 해당 행의 version을 확인하여 업데이트를 충돌을 막기도 하는데 컬럼은 version 뿐만 아니라 timestamp를 이용하기도 한다.

 

 

출처

[데이터베이스] MySQL 트랜잭션 격리 수준

[Database] 낙관적 락 / 비관적 락

(2부) DB MVCC 이어서 설명합니다! MySQL & PostgreSQL 예제와 함께 확인해 보세요!(feat. select ... for update)