동시성 문제가 발생할 수 있는 시나리오와 그에 맞는 해결 방안을 가정해본다
동시성 문제
여러 클라이언트(트랜잭션)가 동시에 데이터베이스에 접근하면서 발생할 수 있는 동시성 문제
락 경합: 여러 트랜잭션이 동일한 리소스를 동시에 수정하려고 할 때 발생하는 문제
- 일관성 깨짐(경쟁 상태): 트랜잭션 실행 결과에 따라 데이터의 값이 달라지거나 일관성/무결성이 깨지는 문제
- 데드락(교착 상태): 여러 트랜잭션이 서로 락을 기다리며 교착 상태에 빠지는 문제
성능 부하: 많은 트랜잭션에 대해 단일 데이터베이스 인스턴스가 모든 요청을 처리할 수 없게 되는 문제
한정 수량 이벤트 쿠폰 발급의 동시성 문제
다수의 사용자가 동시에 한정된 수량의 쿠폰을 발급받으려고 하는 상황
비단 쿠폰뿐만 아니라 좌석 예약, 상품 재고 관리 등 한정된 리소스를 다수의 사용자 요청으로 관리하는 기능/시스템에서 발생할 수 있는 문제이다
쿠폰은 데이터베이스의 remaining_quantity
컬럼을 통해 남은 수량을 관리하며 하나의 요청이 성공할 때마다 이를 감소시킨다
이 때 여러 사용자가 동시에 쿠폰 발급을 요청하면 데이터베이스에 불량한 잔여 수량 값이 업데이트될 수 있다 (데이터 무결성 손상)
트랜잭션 동시성 제어 문제와 경쟁 상태에 따른 데이터 무결성 손상
트랜잭션이 커밋되지 않은 상태에서 다른 스레드에서 쿠폰을 조회하면 실제 처리 수량과 다른 값으로 업데이트될 가능성이 높다
예시 상황
- A 스레드에서 쿠폰 발급 처리 진행 (커밋 X)
- A 스레드 -> remaining_quantity 조회 (현재 값: 5)
- A 스레드 -> remaining_quantity 수정 (현재 값: 4)
- B 스레드에서 쿠폰 발급 처리 진행 (커밋 X)
- B 스레드 -> remaining_quantity 조회 (undo 영역, 현재 값: 5)
- B 스레드 -> remaining_quantity 수정 (현재 값: 4)
-
A 스레드 커밋, B 스레드 커밋
- 각 스레드에서 요청을 처리한 뒤 업데이트된 컬럼 값
- 두 스레드에서 요청을 처리했기 때문에 컬럼의 값은 3이 돼야 하지만, 실제로는 4로 업데이트된다
- 마찬가지로 쿠폰이 하나 남았을 때 여러 요청 처리하다가 초과 발급을 할 수도 있다
동시성 제어 문제가 발생한 이유
일반적으로 비즈니스 로직(특정 스레드)을 처리할 때는 트랜잭션을 사용하여 데이터베이스와 상호작용한다
멀티 스레드 환경에서는 여러 트랜잭션이 동시다발적으로 데이터베이스와 상호작용하기 때문에 동시성 제어 문제가 발생할 수 있다
예시 상황에 따라 트랜잭션 A(스레드 A)와 B(스레드 B)가 쿠폰을 발급한다고 해보자
트랜잭션은 4가지의 격리 수준을 지원하며, 대부분의 데이터베이스는 READ COMMITTED 또는 REPEATABLE READ 수준을 지원한다
READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE
먼저 트랜잭션 A가 쿠폰을 발급한다 (아직 커밋하지 않은 상태)
보통 트랜잭션이 레코드의 값을 변경하면 그 즉시 값이 변경되고 원래의 값은 언두 로그에 보관한다
격리 수준에 따라 언두 로그에 저장하는 메커니즘이 다르지만, 이 문제에 대해선 결과적으로 동일한 오류가 발생하므로 READ COMMITTED 수준에서 설명한다
-- 수정 전 데이터베이스 --
쿠폰 레코드의 remaining_quantity 값: 5
-- 트랜잭션 A 비즈니스 로직 수행(커밋 X) --
트랜잭션 A 쿠폰 조회 -> 쿠폰 레코드 조회 (기존 값: 5)
트랜잭션 A 쿠폰 발급 -> 쿠폰 레코드 수정 (수정 값: 4)
-- 수정된 데이터베이스 --
쿠폰 레코드의 remaining_quantity 값: 4
언두 로그 - 쿠폰 레코드의 remaining_quantity 기존 값: 5
트랜잭션 B가 쿠폰을 발급하기 위해 쿠폰 레코드를 조회한다
아직 트랜잭션 A가 커밋하기 전의 시점이기 때문에 언두 로그에 저장되어 있는 레코드의 값을 참조한다
-- 트랜잭션 A가 수정한 이후의 데이터베이스 --
쿠폰 레코드의 remaining_quantity 값: 4
언두 로그 - 쿠폰 레코드의 remaining_quantity 기존 값: 5
-- 트랜잭션 B 비즈니스 로직 수행 (커밋 X) --
트랜잭션 B 쿠폰 조회 -> 언두 로그의 쿠폰 레코드 조회 (언두 로그값: 5)
트랜잭션 B 쿠폰 발급 -> 쿠폰 레코드 수정 (수정 값: 4)
-- 수정된 데이터베이스 --
쿠폰 레코드의 remaining_quantity 값: 4
언두 로그 - 쿠폰 레코드의 remaining_quantity 기존 값: 5
트랜잭션 A 커밋
트랜잭션 B 커밋
-- 커밋된 데이터베이스 --
쿠폰 레코드의 remaining_quantity 값: 4
이후 각각의 트랜잭션에서 커밋을 하여 변경사항을 반영하면 실제로는 두 개의 쿠폰이 발급됐지만 데이터베이스에는 하나의 잔여 수량만 차감된다
Serializable 격리 수준 선택
격리 수준을 Serializable로 선택하면 쿠폰 레코드에 대해 읽기 락(공유 락)을 획득할 수 있다
읽기 락을 획득한 쿠폰 레코드에 대해 값을 업데이트하려면 쓰기 락이 필요한데, 읽기 락과 쓰기 락은 양립할 수 없기 때문에 이 방법을 선택하면 데드락이 발생한다
따라서 격리 수준을 변경한다고 트랜잭션 간 동시성 제어 문제가 해결되지 않는다
트랜잭션 동시성 제어 문제 해결 방안
트랜잭션 동시성 제어 문제: 여러 트랜잭션이 동일한 데이터에 접근하고 수정하려 할 때 발생하는 문제
트랜잭션 격리 수준과 락 기반 동기화
- 공유 락/쓰기 락(Serialiazble 격리 수준): 일관된 읽기는 지원하지만 동시성으로 인한 데이터 손실은 보장하지 않는다
- 비관적 락, 낙관적 락
- 분산 락 (redis, zookeeper)
비동기 처리
데이터베이스 설계
- 데이터베이스 파티셔닝/샤딩
- 리더-팔로워 아키텍처 (데이터베이스에만 해당하는 개념은 아니다)
- 캐싱 (+ 데이터베이스 동기화)
분산 환경에서의 제어
동시성 테스트
- 자바: JMeter, Gatling