서버가 두 개이상 존재하고 있을 때 DB 트랜잭션이 같은 동작이 두 번 수행될 경우가 생길 수 있습니다.
예를 들면 콘서트 좌석 예매를 생각해봅시다.
좌석 예매는 예매버튼만 누르면 끝나지 않고, 이런 과정들을 거쳐야 합니다.
1. 로그인을 한 후 몇 자리를 예매할지 선택합니다.
2. 좌석을 고를 '열'을 선택하고 '열'안에 있는 예매가능한 좌석번호 중 인원에 맞춰 선택합니다.
3. 결제방식과 티켓의 수령방법(당일티켓부스 수령/택배수령)을 선택하고 결제를 진행합니다.
자 그럼 여기서 이런 가정을 해봅시다.
아주 우연히 두 명의 사람(민호와 중식)이 거의 동시에 A열 11번이란 좌석을 선택합니다. 앞서 말씀드린 것 같이 좌석예매는 여러과정을 거치기 때문에 중식이 열 안에 있는 예매가능한 좌석번호 중 하나를 선택하고 결제방식을 고르는 사이에 민호가 똑같은 자리를 선택해 결제 선택화면으로 넘어갔습니다.
이런 트랜잭션을 간단하게 쿼리문으로 표현하면 이렇게 되겠네요
SELECT is_reservable(예매가능여부)
FROM CONCERT_SEAT(콘서트좌석테이블)
WHERE seat_id = A-11
-- 선택한 좌석의 예매가능여부를 조회한다.
UPDATE CONCERT_SEAT(콘서트좌석테이블)
SET is_reservable(예매가능여부) = FALSE
WHERE seat_id = A-11(좌석번호)
-- 결제가 완료되면 예매가능여부를 False로 바꾼다.
거의 동시에 데이터에 접근했기 때문에 똑같은 쿼리문은 두 번 실행되게 되고,
두 명은 결제대금을 지불하고 똑같은 좌석 티켓을 받게되는 문제가 발생합니다!
콘서트 당일날에 대참사가 벌어지게 되겠지요.
다른 비행기나 업그레이드 같은 보상옵션이 준비되어있는 비행기 좌석 같은 경우는 노쇼를 대비해서 오버부킹을 일부로 열어놓은 경우도 있지만, 보상이 힘든 콘서트 같은 곳에서 이런일이 발생한다면 구매자는 굉장히 기분이 안 좋을 겁니다.
어찌됬건 간에 이런 동시성 문제를 줄이기 위해서는 트랜잭션 락 이라는 기능을 활용하는것이 좋습니다.
우선 RDBMS의 내장된 기능을 활용하는 방법이 있습니다.
MySQL은 Transaction Isolation Level(트랜잭션 격리레벨)은 총 네 가지가 존재합니다.
READ UNCOMMITTED
1) COMMIT 되지 않은 데이터에 다른 트랜잭션에서 접근할수 있다.
2) INSERT, UPDATE, DELETE 후 COMMIT 이나 ROLLBACK에 상관없이 현재의 데이터를 읽어온다.
3) ROLLBACK이 될 데이터도 읽어올 수 있으므로 주의가 필요하다.
4) LOCK이 발생하지 않는다.
READ COMMIITED
1) COMMIT 된 데이터에 다른 트랜잭션에서 접근할 수 있다.
2) 구현 방식이 차이 때문에 Query를 수행한 시점의 데이터와 정확하게 일치하지 않을 수 있다.
3) LOCK이 발생하지 않는다.
4) MySQL에서 많은 양의 데이터를 복제하거나 이동할 때 이 LEVEL을 추천한다.
REPEATABLE READ
1) Default LEVEL이다.
2) SELECT시 현재 시점의 스냅샷을 만들고 스냅샷을 조회한다.
3) 동일 트랜잭션 내에서 일관성을 보장한다.
4) record lock과 gap lock이 발생한다.
5) CREATE SELECT, INSERT SELECT시 lock이 발생한다.
SERIALIZE
1) 가장 강력한 LEVEL이다.
2) SELECT 문에 사용하는 모든 테이블에 shared lock이 발생한다.
이중 MySQL의 default isolation level은 3번째에 속하는 REPEATABLE READ
입니다.
이를 Serialize로 레벨업을 해놓은다면 모든 SELECT구문에서 Lock발생하게 됩니다.
중복 트랜잭션 가능성을 DB단위에서 아예 원천차단하는 셈이죠.
하지만 이는 빈대 잡자고 초가삼간 태우는 굉장히 비효율적인 트랜잭션 락 방법입니다.
웹사이트에서는 끊임없는 데이터 로드는 이뤄지고 셀수없이 많은 SELECT 쿼리문이 수행됩니다.
이를 일일히 락을 건다면 당연히 디비에는 버퍼가 걸리고, 이는 서버와 웹서버로 그대로 연결됩니다.
현실성이 매우 떨어지는 방법입니다.
좀 더 간단한 해답은 SELECT FOR UPDATE
입니다.
SELECT is_reservable(예매가능여부)
FROM CONCERT_SEAT(콘서트좌석테이블)
WHERE seat_id = A-11
FOR UPDATE
다음과 같이 쿼리문에 FOR UPDATE를 추가하는 순간 지정된 row는 SELECT는 트랜잭션이 종료될 때까지LOCK(잠금상태)가 되어 다른 세션(다른 사용자)접근자체가 불가능해집니다.(UPDATE/DELETE/SELECT모두막힘) 그러므로 만약 이런 트랜잭션락이 걸려있다면 조금 더 먼저 좌석선택을 한 중식이만 티켓팅에 성공하게 됩니다.
다만 몇 가지 주의해야할 사항이 있습니다.
1. AUTOCOMMIT 옵션을 해제해야합니다. AUTOCOMMIT이 켜져있다면, 해당 쿼리문이 끝나는 순간 트랜잭션이 끝나기 때문에 큰 의미가 없어집니다.
SHOW FULL PROCESS
라는 명령어를 통해 락이 걸려있는 아이디을 확인하고,KILL [삭제할 아이디]
구문을 통해 강제로 락을 종료시켜줘야합니다.SELECT는 단순한 읽기/참조일 뿐인데 수정(UPDATE)이나 삭제(DELETE)만 막으면 되지 않을까?
라는 생각을 가지고 계신다면 FOR UPDATE
말고 LOCK IN SHAREMODE
를 활용하시면 됩니다.
FOR UPDATE
와 거의 똑같지만 다른 세션의 SELECT 쿼리는 허용해줍니다.
SELECT is_reservable(예매가능여부)
FROM CONCERT_SEAT(콘서트좌석테이블)
WHERE seat_id = A-11
LOCK IN SHAREMODE
하지만 이 경우 트랜잭션 모순성이라는 문제가 발생할 수 있으니, 이를 생각해야 합니다.