다중 사용자가 동시에 DB의 데이터를 읽고 수정하는 환경에서 발생하는 중요한 문제 중 하나는 동시성 문제가 아닐까 합니다.
백엔드 개발자로서 동시성 문제는 한 번쯤 고민해 봤을 굉장히 흥미로운 주제일 텐데요.
여러 사용자가 동시에 단일 데이터에 접근하여 조회하고 수정할 때, MySQL에서는 이를 어떻게 관리할까요?
공유 잠금(Shared Lock), 배타적 잠금(Exclusive Lock)
MySQL은 동시성 문제를 잠금(Lock) 메커니즘을 통해 제어하며, 대표적인 방법으로 공유 잠금(Shared Lock)과 배타적 잠금(Exclusive Lock)을 사용합니다.
일반적으로 공유 잠금은 읽기 잠금, 배타적 잠금은 쓰기 잠금 으로 불리고 있어요.
이 두 잠금 방식의 차이는 동일한 레코드에 여러 세션이 접근할 때 동작 방식에 따라 구분할 수 있습니다.
공유 잠금(Shared Lock), 배타적 잠금(Exclusive Lock)의 동작 방식
1. 공유 잠금(Shared Lock)
- 읽기 잠금으로 사용됩니다.
- 공유 잠금을 가진 트랜잭션은 데이터를 읽을 수 있으며, 다른 트랜잭션들도 데이터를 읽을 수 있습니다.
- 즉, 여러 트랜잭션에서 동시에 공유 잠금을 획득할 수 있기에 동시 읽기가 가능합니다.
- 예를 들어,
- 🅰️ 트랜잭션이 공유 잠금을 먼저 가져간 경우, 🅱️ 트랜잭션이 공유 잠금을 획득하더라도 데이터는 일관성을 보장하므로, 🅱️ 트랜잭션도 또 다른 공유 잠금을 획득하면서 동시에 데이터를 읽을 수 있습니다.
- 다만 공유 잠금을 나중에 가져간 🅱️ 트랜잭션이 🅰️ 트랜잭션 보다 먼저 데이터를 쓰는 경우, 먼저 데이터에 접근한 🅰️ 트랜잭션과 데이터가 달라질 수 있으므로 🅰️ 트랜잭션 종료까지 대기해야합니다.
2. 배타적 잠금(Exclusive Lock)
- 쓰기 잠금으로 사용됩니다.
- 오직 배타적 잠금을 가진 트랜잭션만 데이터의 쓰기 작업을 수행할 수 있기에, 다른 트랜잭션은 이 데이터를 읽거나 수정할 수 없습니다.
- 즉, 다른 트랜잭션들은 배타적 잠금을 가진 트랜잭션이 종료될 때 까지 대기해야합니다.
- 예를 들어,
- 🅰️ 트랜잭션이 배타적 잠금을 먼저 가져간 경우, 🅰️ 트랜잭션에서 데이터가 어떻게 변경될 지 모르기 때문에 🅱️ 트랜잭션은 데이터를 읽기 조차할 수 없으며 🅰️ 트랜잭션 종료까지 대기해야합니다.
정리를 해보자면
여러 트랜잭션이 특정 레코드에 몰리는 읽기 작업과 쓰기 작업이 서로 방해를 일으키기 때문에 동시성 문제가 발생하고, 이를 해결하기 위해 잠금을 더 오랫동안 유지하게되서 동시성 문제가 해결되더라도 성능 저하가 발생하게 되는거죠.
Non-Locking Consistent Read, MVCC(다중 버전 동시성 제어)
MySQL은 이러한 동시 처리 효율을 높이고 잠금으로 인한 성능 저하를 줄이기 위해 잠금 없는 일관된 읽기(Non- Locking Consistent Read, MVCC) 를 도입하게 되었어요.
잠금 없는 일관된 읽기 는 말 그대로 데이터를 조회할 때, 대상 레코드에 대해 잠금을 걸지 않고 읽을 수 있다는 것입니다.
그런데, 여기서 한 가지 의문이 들 수 있을 것 같아요.
나는 동시성을 생각하지 않고 그냥 SELECT 를 사용했는데 어떻게 문제없이 동작했던거야?
MySQL은 데이터가 변경될 때, 변경 전 레코드를 💡Undo 라는 공간에 백업한 후 실제 데이터를 수정합니다.
그리고 다른 트랜잭션이 변경 중인 데이터를 읽으려 하면, 변경된 레코드가 아닌 💡Undo 영역에 백업된 데이터를 반환하는 방식으로 동작해요.
이 과정에서 순수 SELECT 쿼리는 어떤 잠금도 걸리지 않고, 다른 세션의 잠금으로부터 제약을 받지 않기 때문에 우리는 동시성을 고려 없이 사용할 수 있는 것 이였어요.
그리고 이 과정을 Non-Locking Consistent Read 라고 합니다.
이때, 트랜잭션 격리 수준에 따라 반환되는 레코드의 버전이 달라질 수 있습니다.
트랜잭션 격리 수준에 따른 차이
- Repeatable Read:
- 트랜잭션이 시작된 시점의 데이터인, 💡Undo 영역에 저장된 데이터를 반환
- 즉, 동일한 SELECT 문을 여러 번 실행해도 항상 같은 결과가 반환 → 반복 가능한 읽기
- Read Committed:
- 💡Undo 영역에 저장된 데이터가 아닌 가장 최근에 커밋된 데이터를 반환
- 즉, 트랜잭션이 실행 중이고 종료되지 않더라도, 다른 트랜잭션에서 커밋한 데이터는 읽을 수 있음
트랜잭션 격리 수준이 Read Committed 인 경우, 가장 최근에 커밋된 데이터를 반환하는 반면, Repeatable Read 인 경우 트랜잭션이 시작된 시점의 💡Undo 영역에 저장된 데이터를 반환하므로, 동일한 SELECT 문을 여러 번 실행해도 항상 같은 결과가 반환됩니다.
이러한 특성 때문에 이 격리 수준을 Repeatable Read 라고 부르는 것이죠!
앞서 설명한 대로, MySQL은 공유 잠금과 배타적 잠금을 통해 동시성 문제를 관리하고
데이터 동시 읽기 작업의 경우 Non-Locking Consistent Read, MVCC 기법을 활용하여 성능 저하를 줄이고 일관된 데이터를 보장하고 있었습니다.
하지만, 데이터가 동시에 수정되는 상황에서는 여전히 동시성 문제를 완전히 방지하기 어려운 경우가 있습니다.
특히, 여러 트랜잭션이 동일한 데이터를 수정하려고 할 때 충돌을 방지하는 것이 중요한데, 이를 해결하기 위한 방법이 바로 잠금 기반의 SELECT 입니다.
잠금 기반의 SELECT FOR UPDATE, SELECT FOR SHARE
지금까지 다뤘던 Repeatable Read 격리 수준에서의 SELECT 쿼리는 트랜잭션이 시작된 시점의 데이터를 반환합니다.
하지만 SELECT FOR UPDATE 또는 SELECT FOR SHARE와 같은 잠금 기반의 SELECT 구문은 격리 수준에 관계없이 트랜잭션이 시작된 시점의 데이터가 아니라, 가장 최근에 커밋된 데이터를 반환한다는 점에서 차이가 있습니다.
잠금 SELECT vs 일반 비잠금 SELECT
아래의 쿼리가 순차적으로 실행되었다고 가정해봅시다.
SESSION 1 – 트랜잭션 시작 후 데이터 조회
BEGIN;
SELECT * FROM user WHERE id = 1;
+------+-------------+
| id | created_date|
+------+-------------+
| 1 | 2025-08-26 |
+------+-------------+
SESSION 2 – 데이터 업데이트
UPDATE user
SET created_date = '2026-03-26'
WHERE id = 1;
COMMIT;
이제 SESSION 1 에서 SELECT FOR UPDATE 또는 일반 SELECT 를 실행했을 때 결과를 비교해볼게요.
SESSION 1 – SELECT FOR UPDATE 사용
SELECT * FROM user WHERE id = 1 FOR UPDATE;
+------+-------------+
| id | created_date|
+------+-------------+
| 1 | 2026-03-26 | -- SESSION 2 에서 변경한 최신 데이터
+------+-------------+
- ✅ 최근 커밋된 최신 데이터가 반환됨
SESSION 1 – 일반 SELECT 사용
SELECT * FROM user WHERE id = 1;
+------+-------------+
| id | created_date|
+------+-------------+
| 1 | 2025-08-26 | -- SESSION 1에서 트랜잭션 시작 당시의 데이터
+------+-------------+
- ✅ 트랜잭션이 시작된 시점의 데이터가 반환됨
잠금 기반의 SELECT 를 사용한 경우와 단순 SELECT 를 사용한 경우의 결과가 다르다는 점을 확인할 수 있습니다.
SELECT FOR SHARE나 SELECT FOR UPDATE는 잠금을 걸어야 하기 때문에, 가장 최근에 커밋된 데이터를 조회하고 그 데이터에 잠금을 설정합니다.
SELECT FOR UPDATE로 행을 잠그면 해당 행에 배타적 잠금이 설정되고, 다른 트랜잭션은 이 행을 수정하거나 삭제할 수 없게 됩니다.
하지만 이 상태에서 비잠금 SELECT 쿼리를 사용하여 데이터를 조회하면, 조회 시에는 잠금이 발생하지 않지만 해당 행에 대한 잠금이 해제될 때까지 다른 트랜잭션이 수정할 수 없게 되고, 여러 트랜잭션이 서로 잠금을 기다리게 되면 결국 교착 상태(Deadlock)가 발생할 수 있습니다.
따라서 REPEATABLE READ 격리 수준에서 SELECT FOR UPDATE와 같은 잠금 기반 SELECT와 비잠금 SELECT 쿼리를 함께 사용하는 것은 권장되지 않습니다.
비관적락(Pessimistic Lock) 과 낙관적락(Optimistic Lock)
보통 동시성 문제를 해결하는 방법에는 비관적락과 낙관적락이 많이 불리게 됩니다.
비관적락(Pessimistic Lock)
비관적 락은 데이터를 수정하려는 트랜잭션에는 배타적 잠금(Exclusive Lock) 을, 데이터를 읽으려는 트랜잭션에는 공유 잠금(Shared Lock) 을 사용하여 데이터를 보호하는 방식입니다.
잠금을 걸어두고 데이터를 수정하는 방식이라, 다른 트랜잭션이 이 데이터를 수정할 수 없게 차단할 수 있어요.
하지만 아래와 같은 문제점이 있습니다.
- 성능 저하: 잠금을 걸어두면 다른 트랜잭션들이 대기하게 돼서, 동시 처리 성능이 많이 떨어질 수 있음
- 교착 상태(Deadlock): 여러 트랜잭션이 서로 다른 자원을 잠그고 대기하는 상황에서, 트랜잭션들이 서로의 잠금을 해제하기 위해 무한 대기 상태에 빠지면 교착 상태가 발생할 수 있음
낙관적 락(Optimistic Lock)
낙관적 락은 데이터를 수정할 때 잠금을 사용하지 않고, 대신 버전 컬럼을 활용해 충돌을 감지하고 처리하는 방식입니다.
직접적으로 잠금을 사용하지 않아서 성능 저하가 적고, 대기 시간이 줄어드는 장점이 있습니다.
낙관적 락을 구현할 때는 버전 컬럼(version column) 을 추가하는 방법이 일반적이에요. 이 버전 컬럼은 데이터가 수정될 때마다 값을 증가시키는 방식으로 동작합니다.
예를 들어, user 테이블에 version 이라는 컬럼을 추가하고, 데이터를 수정할 때마다 이 값을 업데이트 해준다면
트랜잭션이 데이터를 수정하려 할 시점에, 현재 버전 값을 확인하고, 수정 시점의 버전 값과 현재 버전 값이 일치할 때 업데이트가 가능하도록 처리하는 거죠.
만약 두 버전 값이 일치하지 않으면, 다른 트랜잭션이 데이터를 수정한 걸로 보고 충돌이 발생한 것이므로 롤백하고 다시 시도해야 해요.
그래서 낙관적 락은 아래와 같은 문제점이 있습니다.
- 롤백 및 재수행 로직을 직접 구현: 여러 트랜잭션이 동시에 동일한 데이터를 수정하려해 충돌이 발생할 경우 트랜잭션을 롤백하고 다시 시도해야 해서, 충돌 처리 로직을 직접 구현해줘야 함. →코드가 다소 복잡해질 수 있음.
낙관적 락을 사용할지, 비관적 락을 사용할지
아래와 같은 경우에는 낙관적 락을 사용하는 게 적합하다고 생각합니다.
- 트랜잭션 간 충돌이 드물고, 데이터 수정이 빈번하지 않은 경우
- 성능을 중요시해야 하고, 잠금 대기 시간을 줄여야 하는 상황
- 충돌이 발생하면 쉽게 처리할 수 있는 경우
반면 이런 경우에는 비관적 락을 사용하는 게 적합할 것 같아요.
- 데이터 수정이 빈번하게 발생할 경우
- 트랜잭션 간 충돌이 자주 발생할 가능성이 높고, 충돌을 미리 방지하는 게 중요한 경우
- 성능보다는 데이터의 일관성이 중요한 경우
잠금을 사용할 때는 항상 교착 상태가 발생하지 않도록 주의하는게 중요한 것 같습니다.
이를 위해 잠금이 적용되는 범위와 트랜잭션 격리 수준을 충분히 이해해야할 것 같아요.
특히, SELECT FOR UPDATE와 같은 잠금 기반 쿼리와 비잠금 SELECT 쿼리를 함께 사용하는 상황은 최소화 하도록 합시다 💪
'DB' 카테고리의 다른 글
LIMIT OFFSET 페이징 쿼리를 개선할 수 있는 방법 (3) | 2025.03.09 |
---|
댓글