끄적끄적

[MySQL] Gap Lock 과 Dead Lock (+ 여러 Lock들) 본문

개발/데이터베이스

[MySQL] Gap Lock 과 Dead Lock (+ 여러 Lock들)

코리이 2023. 5. 21. 18:18

운영중인 서비스에서 Dead Lock 이 발생한다는 알람을 받은 적이 있었다. 트래픽이 없을 때는 문제 없었는데 최근 이벤트를 진행하면서 순간 트래픽이 몰리다보니 Gap Lock 에 의해 Dead Lock 이 발생하면서 예상치 못한 에러가 발생한 것이였다. 그래서 Lock 에 대해 다시 한번 공부하는 기회가 되어서 이에 대한 간단한 이야기와 회사에서 실제로 Dead Lock 을 어떤 방법을 활용해서 해결했는지 예시와 함께 정리해볼까 한다. 포스팅은 MySQL(InnoDB) 기준으로 작성하였고 PostgreSQL 의 경우 Lock 방식이 다르므로 적용되지 않는 다는 점은 기억하면 좋겠다.

들어가기 전에

포스팅에서 예시를 들기 위해 아래 ERD 의 구조를 사용하려 한다. 채팅방과 유저가 존재하며, 채팅방 참여 정보를 "채팅방에 참여한 유저" 로 네이밍을 정했다. 실제로 채팅방에 참여한 유저 테이블에서는 참여정보 뿐 아니라 채팅방 알람 on/off 등에 대한 정보를 가지고 있을 수도 있다.

Shared and Exclusive Lock & Intention Lock

Lock 종류에 대해 이야기 하기 전에 Shared Lock 과 Exclusive Lock 에 대해 이해할 필요가 있다. 어떻게 보면 이 둘도 Lock 의 종류라고 할 수 있지만 아래에서 설명할 Row-Level Lock 과 Record Lock 등 모두에 적용되는 개념이기 때문에 따로 설명할 필요가 있어 보인다. 

우선 Shared Lock 이란 Read, 즉 Select 쿼리를 하기 위해 필요한 Lock 이며 S 락 이라고 표현한다. 첫번째 트랜잭션에서 S 락이 걸리면 다른 트랜잭션에서 수행 시 조회는 허용하지만 쓰기는 허용하지 않겠다는 뜻이다. 

반면 Exclusive Lock 이란 write 에 대한 Lock 으로 Update, Delete 에 대한 Lock 이며 X 락이라고 표현한다. S 락과는 다르게 쓰기 뿐 아니라 조회도 허용하지 않는다는 뜻이다. 이 X 락은 기본적인 Write 쿼리 실행시 발생할 수 있다.

그리고 Intention Lock 이라는 용어도 있는데 이는 아래에서 이야기할 Row-Level 락 과 Table 락 등 여러 Lock 을 동시에 제어하기 위해 나온 개념이다. 만약 Tx1 이 row1 에 Row-Level 락을 걸고 하나의 row 에 관한 작업만을 하려고 하는데 Tx2 에서 Table 에 작업을 하기 위해서 Table 락을 걸려고 한다고 생각해보자. 그때 Table 락을 걸기 위해서는 특정 row 에 락이 존재하면 안 될 것이다. 이를 판단하기 위해 모든 row 에 락을 걸면 비효율적일 것이므로 우선 Intention Lock 을 걸어 Table 락은 이것만 확인 후 락을 걸지 말지 판단하게 된다. 이때 Intension 을 붙혀 약어로 IX, IS 락이라고 부르며 아래에서 예시로 설명할 Lock 들은 IX → X 락 혹은 IS → S 락 을 차례로 걸고 수행하게 된다.

 

S 락을 걸고 싶다면 트랜잭션 내에서 Select 에 LOCK IN SHARE MODE(FOR SHARE) 를 추가해서 사용한다. 그러면 다른 트랜잭션에서 S 락은 동시에 걸 수 있지만 X 락은 대기 상태에 걸리게 된다.

# Tx 1
START TRANSACTION;
SELECT * FROM blog_users WHERE id = '1' LOCK IN SHARE MODE;

# TX 2 (통과)
START TRANSACTION;
SELECT * FROM blog_users WHERE id = '1' LOCK IN SHARE MODE;

# TX 3 (대기)
START TRANSACTION;
UPDATE blog_users SET name = 'Pawmi' WHERE id = '1';

X 락을 걸고 싶다면 트랜잭션 내에서 SELECT 에 FOR UPDATE 를 추가해서 사용한다. 그러면 Update 뿐 아니라 Select 시에도 락이 걸리는 것을 확인할 수 있다. 다만 일반적인 Select 실행시에는 Lock 이 걸리지 않으므로 다른 트랜잭션에서 Select 시 S 락이나 X 락을 거는 경우 발생한다는 것을 기억해야 한다.

# TX 1
START TRANSACTION;
SELECT * FROM blog_users WHERE id = '1' FOR UPDATE ;

# TX 2 (대기)
START TRANSACTION;
SELECT * FROM blog_users WHERE id = '1' LOCK IN SHARE MODE;

# TX 3 (대기)
START TRANSACTION;
SELECT * FROM blog_users WHERE id = '1' FOR UPDATE ;

# TX 4 (대기)
START TRANSACTION;
UPDATE blog_users SET name = 'Pawmi' WHERE id = '1';

결론적으로

  1. S 락을 걸면 X 락을 허용하지 않는다.
  2. X 락을 걸면 S 락, X 락 모두 허용하지 않는다.

정도만 기억해도 무방할 것 같다.

InnoDB Lock 종류

InnoDB 의 lock 에는 크게 Row-Level Lock, Record Lock 이 존재하며 특수한 경우 Gap Lock 과 Next-Key 락 또한 발생할 수 있다. 그리고 Gap Lock 의 한 종류이자 insert 명령시에 특수하게 발생할 수 있는 Insert Intention Lock 이 존재한다.

Row-Level Lock

Row-Level Lock 이 가장 기본적인 Lock 이라고 할 수 있다. RDB 의 table 은 는 Row 를 기준으로 데이터가 삽입되어 진다. 즉, 하나의 데이터 집합에 대해서만 Lock 을 건다. 예를 들어 user1 에 lock 을 걸었다면 user2 에는 lock 이 걸리지 않는다. 예시로 설명하면 아래와 같다. 아래는 id 가 1,2 인 서로 다른 row 에 각각 lock 을 걸었으므로 동시에 수행될 수 있다. 하지만 다른 트랜잭션에서 id 가 1 인 row 에 락을 획득하려 하면 대기해야 한다.

# Tx 1
START TRANSACTION;
SELECT * FROM blog_users WHERE id = '1' FOR UPDATE;

# TX 2 (통과)
START TRANSACTION;
SELECT * FROM blog_users WHERE id = '2' FOR UPDATE;

# TX 3 (대기)
START TRANSACTION;
SELECT * FROM blog_users WHERE id = '1' FOR UPDATE;

Record Lock

이름이 Record Lock 이라 혼동되지만 Index Record 에 걸리는 락이다. 쉽게 이야기 하면 앞에서는 단순히 id 를 가지고 Row 별로 락을 걸었다면 이는 Index 범위로 락을 걸게 된다. 예시를 들면 채팅방에 유저가 참여한다고 해보자. 그리고 아래와 같은 row 형태를 띈다고 가정해보자.

id user_id room_id
joined1 1 room1
joined2 2 room1
joined3 2 room2
joined4 3 room2

이 때 room_id=room1 으로 두 개의 row (joined1, joined2) 에 관해 lock 을 걸 수 있다. 결국 여러 row 에 락을 건다고 생각하면 된다. 이 때 record 락이 여러개인데 중복 row 가 존재한다면 조심해야 한다. 예를 들어 room_id=room1 으로 락을 걸었는데 user_id=2 으로 락을 걸려고 하면 joined3 row 에는 중복으로 락이 걸리므로 이전 트랜잭션이 종료될때까지 대기해야만 한다.

# Tx 1
START TRANSACTION;
SELECT * FROM blog_room_joined_users WHERE room_id = 'room1' FOR UPDATE ;

# Tx 2 (대기)
START TRANSACTION;
SELECT * FROM blog_room_joined_users WHERE user_id = '2' FOR UPDATE ;

# Tx 3 (대기)
START TRANSACTION;
SELECT * FROM blog_room_joined_users WHERE id = 'joined2' FOR UPDATE ;

# Tx 4 (통과)
START TRANSACTION;
SELECT * FROM blog_room_joined_users WHERE user_id = '3' FOR UPDATE ;

Gap Lock 과 Next-Key Lock

MySQL 을 사용하다 보면 가장 조심해서 사용해야 할 부분이 Gap Lock 이다. Gap Lock 이란 Record Lock 을 걸게 될 때 실제 Row(Record) 가 존재하지 않는 경우 그 범위에 Lock 을 거는 행위를 의미한다. 흔히들 예시로 드는 것이 아래 표처럼 데이터가 있다고 생각해보자

id (PK) name
1 Alice
2 Bob
9 John
10 Tom

이 때 id Between 1 AND 14 으로 락을 걸게 되면 실제 id 는 1,2,9,10 만 존재하게 되는데 id 가 3~8, 11~14 에도 락이 걸릴 수 있다는 뜻이다. 실제 존재하는 Record 에만 락을 거는것이 아니라 실제 쿼리로 주어진 부분 모든 곳에 락을 건다고 생각하면 될 것 같다. 

이 때 Next-Key Lock 이라는 것도 존재하는데 이는 Gap Lock + Record Lock 을 합친 개념이다. 즉, 실제 존재하는 레코드에 락이 걸린것을 Record Lock, 실제 존재하지 않지만 명령에 의해 락이 걸린 것을 Gap Lock 이라고 부르며 대부분의 경우 인덱스를 기준으로 락을 걸면  Next-Key Lock 이 걸리게 된다. 아래 그림은 어떻게 락이 걸리는지에 대해 이야기하고 있다.

Insert Intention (Gap) Lock

인덱스 범위로 Lock 을 걸면 Gap Lock 이 생긴다는 것을 알 것이다. 하지만 InnoDB 에는 Insert 시에만 존재하는 특별한 Lock 이 존재하는데 공식 문서에는 일종의 Gap Lock 이라고 정의하고 있다. 앞에서 봤지만 Gap Lock 을 활용하면 넓은 범위의 락을 걸 수 있다. 하지만 단순 Insert 시에 범위로 락이 걸려 성능을 저해할 수 있기 때문에 Insert 시에 서로 동일 인덱스로 접근하는 것이 아니라면 락에 의해 기다리지 않아도 되게끔 설계되어 있다. 예를 들면 아래와 같은 경우 id 가 2~4 는 Insert Instention Gap Lock 이 걸리게 되지만 그 다음 트랜잭션에서는 Gap Lock 에 의해 대기하지 않고 Insert 작업을 할 수 있다는 것을 의미한다.

# TX1
START TRANSACTION;
INSERT INTO samples (id, name) VALUES (1, 'LEE'), (5, 'KIM');

# TX2
START TRANSACTION;
INSERT INTO samples (id, name) VALUES (2, 'LEE'), (4, 'KIM');

Dead Lock 에 관하여

이제 대부분의 Lock 에 대한 개념에 대해 알아보았다. 그렇다면 Dead Lock 은 무엇일까? 이는 Lock 의 한 종류라기 보다는 Lock 에 의해 발생하는 오류라고 보면 될 것 같다. 위키피디아 정의를 살펴보면 다음과 같이 정의하고 있다.

두 개 이상의 작업이 서로 상대방의 작업이 끝나기 만을 기다리고 있기 때문에 결과적으로 아무것도 완료되지 못하는 상태

 

즉, 두 가지 락이 발생해서 서로가 서로의 락을 해제할때까지 기다리고 있는 상태라는 것이다. 예를 들면 아래와 같은 상태라고 할 수 있다.

Gap Lock 에 의한 Dead Lock 발생

사실 위에서 설명한 Dead Lock 예시의 경우 실행 순서를 잘 조절하거나 Transaction 범위를 잘 좁혀서 사용하면 쉽게 해결할 수 있다. 또한 실제 앱에서 id=1 을 조회했는데 id=2 를 업데이트 하려고 하는 경우는 거의 존재하지 않을 것이다. 하지만 모든 Gap Lock 은 S Lock 이고 X Lock 이 존재하지 않는다는 Gap Lock 의 특수한 특징 때문에 Dead Lock 이 발생할 수 있다. 필자의 경우에도 이 때문에 Dead Lock 이 발생했다.

앞에서 이야기한 예시를 활용해서 유저가 채팅방에 참여하는 로직을 개발한다고 생각해보자. 그럼 도메인 모델 패턴을 활용하면 아래와 같은 코드 형식을 사용할 수 있을 것이다. 이 때 repository 의 getOne 은 채팅방 참여 정보를 가져오는 로직을 X Lock 과 함께 추상화해서 사용하고 있다.

async joinRoom(params: { userId: string; roomId: string }) {
    const { userId, roomId } = params;
    await this.dataSource.transaction(async (entityManager) => {
        // 채팅 참여 정보가 없다면 생성한다.
        const roomJoinedUser = await this.repository(entityManager).getOne({ userId, roomId })
            || new RoomJoinedUser({ user: userId,room: roomId });

        roomJoinedUser.join();

        await this.repository(entityManager).save(roomJoinedUser);
    });
}

코드만 봤을때는 큰 문제가 없어 보인다. 실제로 테스트코드에서도 동시에 진행하지 않고 하나씩 진행하다 보니 스무스하게 통과하는 모습도 보여줬다. 하지만 만약 특정 유저의 참여정보가 없는데 두 채팅방에 동시에 참여하려고 한다고 가정해 보자. 웹에서 하는 채팅의 경우 한 유저가 여러 채팅방에 동시에 참여하는 경우는 흔히 볼 수 있을 것이다. 이 때 아래 그림과 같은 순서로 쿼리가 발생할 수 있다.

즉 개발자 입장에서는 X Lock  을 걸었으니 TX2 의 SELECT 쿼리는 실행되지 않는다고 예측했었지만 실제로는 Gap Lock, 즉 S Lock 이 걸리게 되어 TX1, TX2 둘다 SELECT 까지 동시에 진행되어 버린 것이다. 그래서 두 트랜잭션 모두 INSERT 진행이 되지 않고 Dead Lock 이 발생해 알람이 울렸던 것이였다. 물론 이러한 Dead Lock 의 경우는 MySQL 에서 감지해서 알아서 Rollback 시켜주기 때문에 에러는 발생해도 운영상에는 문제가 없긴 하다. 하지만 유저 입장에서는 채팅방에 참여가 잘 되지 않는 문제가 발생한다.

해결방안

원인을 알았으니 해결 방안은 간단했다. 우선 FOR UPDATE(X Lock) 로직을 왜 썼는지부터 파악해 보아야 한다. 그 이유는 "채팅방을 나갈때" 문제가 생길 가능성이 있기 때문이였다. 예를 들어 이미 채팅방에 참여한 유저가 아래와 같은 작업을 한다고 가정해 보자. 참고로 앞에서 참여된 채팅방에서 알람조절을 한다고 설명한 바 있다.

 

  1. 유저는 채팅방 알람을 켠다.
  2. 유저는 채팅방을 나간다.

이 경우에 아래와 같이 쿼리가 발생해 채팅방을 나갔는데 알람이 켜지는 이상한 경우가 발생할 가능성이 있다.

그렇기 때문에 채팅방 나가기 처리 트랜잭션에 X LOCK 을 걸고 작업을 하게 되면 채팅방 알람을 켜는 트랜잭션의 경우 SELECT 쿼리도 대기하게 되므로 채팅방이 나가진 후 알람켜기 작업은 진행할 수 없게 된다. 즉, 앱 로직에서는 채팅방에서 나가진게 체크가 되므로 알람켜기 작업 요청이 들어오면 에러를 반환하면 된다. 이렇게 나가기/알람켜기 같은 경우에는 이미 레코드가 존재하므로 Record Lock(X Lock) 이 걸려 앞에서 확인한 것과 같은 Dead Lock 은 발생하지 않는다. 반면 채팅방 참여의 경우에는 레코드가 존재할 수도 있고 존재하지 않을 수도 있는 상황이 되므로 Record Lock 이 걸릴지 Gap Lock 이 걸릴지 알 수 없는 상황이다.

하지만 채팅방 참여의 경우 고려해 보면

 

  1. 채팅방 나가기 & 채팅방 참여하기
  2. 채팅방 알림 켜기 & 채팅방 참여하기

등 실제로 Lock 을 걸어야 할 부분은 존재하지 않았다. 그 이유는 로직의 순서가 반대로 실행되어도 유저 입장에서는 후순위의 액션을 한번 더 요청하는게 어려운 일이 아니기 때문이다. 결론적으로 채팅방 참여하기에는 X Lock 을 제거하는 방향으로 결정했는데 크게 문제없이 운영되고 있다.

결론

처음 Dead Lock 알람이 울렸을 때는 그 이유를 잘 몰랐다. 앱을 디버깅 했을 때 분명 X Lock 을 걸었는데 SELECT 가 되는 것을 확인할 수 있어서 그 때부터 이것저것 다시 공부하기 시작했다. 결론적으로는 Gap Lock 에는 X Lock 이 없다는 점을 처음 알게 된 계기가 되었던 것 같다. 하지만 생각해보면 도메인 모델 패턴으로 entity 에서 변경점을 찾아서 수정하도록 추상화 하는 것이 아니라 직접 DB 에 update 쿼리를 실행시키도록 수정한다면 이런 고민은 필요 없어질 것 같기도 했다. 물론 안티패턴이라 지양하고 있기는 하지만 DB 를 무조건적으로 추상화시켜서 해결하는 것만이 만능은 아닌 것 같다는 생각을 많이 하게 된 계기가 되기도 했다.

참고

https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html

https://ko.wikipedia.org/wiki/%EA%B5%90%EC%B0%A9_%EC%83%81%ED%83%9C

https://product.kyobobook.co.kr/detail/S000001514319

https://kukuta.tistory.com/215

https://suhwan.dev/2019/06/09/transaction-isolation-level-and-lock/