데이터베이스 락의 모든 것
일반적으로 데이터베이스가 여러 트랜잭션의 동시 접근을 제어하기 위해 사용하는 락을 의미한다.
처음 락에 대해 학습한다면 Java에서 사용하는 락과 혼용되기 쉬운데, 기본적으로 Java 락은 서로 다른 스레드가 같은 메모리 데이터를 동시에 수정하지 못하게 하는, JVM 내부에서 동작하는 락이다. Java 락은 기본적으로 다른 서버의 스레드의 동시성까지 해결할 수 없다. 즉, 데이터베이스에 접근하는 어플리케이션 서버가 2대 있다고 가정할 때, 서버1에서 synchronized로 어플리케이션 내부 락을 건다고 하더라도, 서버2가 데이터베이스에 접근하는 것을 막을 수 없다. 즉, synchronized만으로는 멀티 서버 환경의 동시성 문제를 해결할 수 없다.
심지어 하나의 서버에서 다른 트랜잭션이 DB에 접근하는 경우에도 마찬가지다. 예를 들어, 트랜잭션A가 JVM락을 획득하고 작업을 완료한 뒤, DB에 커밋하기 이전에 JVM 락을 해제했다고 가정하자. 이때 커밋하기 이전에 다른 트랜잭션이 접근해서 JVM락을 획득하고 작업하는 경우에도 동시성이 깨질 수 있다. 결국, 자바 락은 어플리케이션 내부 메모리의 리소스 동시 접근을 제어하는 락, DB락은 DB 데이터 동시 접근을 제어하는 락으로 이해하면 된다.
대표적인 데이터베이스 락들을 살펴보자. 아래의 락들은 서로 다른 기준으로 분류한 개념들이기 때문에, 같은 레벨에서 비교하면 혼동이 올 수 있다. 예를 들어, Row Lock은 비관적 락을 구현하기 위해 InnoDB가 사용하는 구체적인 락 종류 이다.
결국, 나누는 기준으로 락을 이해하는 것이 중요하다.
분류도
- 동시성 제어
- JVM
- synchronized
- ReentrantLock
- DB
- 낙관적 락
- @Version (JPA)
- 비관적 락
- Shared Lock
- Exclusive Lock
- (구분선)
- Row Lock
- Gap Lock
- Next-Key Lock
- 분산 락
- 낙관적 락
- JVM
1. 어디서 동시성을 제어하는가를 기준
어플리케이션 락과 데이터락
앞서 말했다시피, JVM의 메모리를 보호하기 위해 사용하는 어플리케이션 락(예: Java락)과 DB의 데이터를 보호하기 위한 데이터락으로 분류할 수 있다.
2. 동시성 제어 전략을 기준
1. 낙관적 락 (Optimistic Lock)
충돌이 거의 없다고 가정한 제어 전략으로, 실제 락을 걸지는 않는다.
트랜잭션A가 수정할 때 내가 먼저 이 값을 수정했다고 명시하여, 다른 사람이 동일한 조건으로 값을 수정할 수 없게 하는 방식이다.
즉, 실제로 물리적인 락을 거는 방식이 아닌, 쓰기 시점에 문제가 있는지 확인하는 동시성 제어 전략이다. 이제 아래 예시를 통해 구체적으로 어떻게 동시성을 제어하는지 살펴보자.
(한가지 주의할 점은 해당 락은 DB에서 제공해주는 물리적 락 제어가 아닌 어플리케이션 레벨에서 잡아주는 락이라는 점이다.)
예시로 살펴보기
트랜잭션A와 B가 둘다 id = 1인 레코드의 name을 수정한다고 가정하자. 낙관적 락 전략을 사용하면 다음과 같이 동시성이 제어된다.
- 트랜잭션 A와 B 둘다 동시에 아래 쿼리로 member 정보 조회
SELECT *
FROM member
WHERE id = 1;
위 쿼리로 데이터를 조회하면 다음과 같은 응답값이 온다. 여기서 주의 깊게 확인해야할 필드는 version 필드이다. 즉, 조회 시점의 데이터 version을 표현한다.
id=1
name=jun
version=5
- 트랜잭션A가 id = 1 이면서 version=5인 member의 이름을 sangjunA로 수정
UPDATE member
SET
name = 'sangjunA',
version = version + 1
WHERE
id = 1
AND version = 5;
조회시점에 가져온 version = 5와 id = 1에 해당하는 레코드에 대해 쓰기 작업을 진행하고, 버전을 하나 올려 수정작업이 진행되었음을 명시한다. 즉, version = 5 시점에 조회했던 다른 트랜잭션들은 더이상 수정 작업을 할 수 없도록 동시성을 제어할 수 있다.
- 트랜잭션B가 id = 1 이면서 version=5인 member의 이름을 sangjunB로 수정
UPDATE member
SET
name = 'sangjunB',
version = version + 1
WHERE
id = 1
AND version = 5;
이미 id = 1의 version은 6으로 수정되었기 때문에 해당 쿼리는 실패하기에 동시성을 제어할 수 있다.
실제 사용 방법
대표적으로 JPA의 @Version 어노테이션을 사용하면 낙관적 락을 구현할 수 있다.
@Entity
public class Member {
@Id
private Long id;
@Version
private Long version;
}
이와 같이 어노테이션을 붙이면 JPA가 내부적으로 아래와 비슷한 SQL을 만든다.
UPDATE member
SET
name = ?,
version = ?
WHERE
id = ?
AND version = ?;
낙관적 락 핵심 정리
낙관적 락 이해에서 중요한 내용을 다시 한번 정리해보자.
즉, 낙관적 락을 한줄로 정리하자면, 조회 시점의 version을 기억해두고, Update 시점에 아직도 해당 version이 맞는가를 검사하는 방식으로, 조회 시점 이후 데이터가 변동되었는지를 체크하여 동시성을 제어하는 방식이다.
실제 락이 아닌, 별도의 컬럼을 추가하여 충돌적인 업데이트를 막는 방식이다. 사실상 버전 기반 충돌 감지 전략이다. 예시의 version뿐 아니라 hashcode나 timestamp를 사용하기도 한다.
DB에서 제공해주는 물리적 락 제어가 아닌 어플리케이션 레벨에서 잡아주는 락이다.
2. 비관적 락 (pessimistic lock)
비관적 락은 트랜잭션이 시작될 때 Shared Lock(조회 시) 또는 Exclusive Lock(쓰기 시)을 걸고 시작하는 방법이다. 즉, 이미 조회 혹은 쓰기 시점에 트랜잭션은 해당 물리적인 락을 획득하는 방식으로 동시성을 제어한다.
공유 락과 배타 락과 관련된 구체적인 내용은 아래 작성되어 있다. 현재 예시가 이해되지 않는다면, 이를 먼저 읽고 오는 것을 추천한다.
예시로 살펴보기
아래 시퀀스는 요청의 시간 순이다.
- **트랜잭션A가 쓰기 요청을 위한 SELECT를 요청한다. 이때 트랜잭션A는 베타 락을 획득한다. **
- (여기서
FOR UPDATE;는 조회하면서 동시에 배타 락을 거는 SQL 문법이다. 즉, 조회하는 순간 다른 트랜잭션이 해당 행을 수정하지 못하게 하기 위함이다. Lost Update 문제를 해결할 수 있다.)
- (여기서
SELECT *
FROM member
WHERE id = 2
FOR UPDATE;
- 트랜잭션B가 id = 2인 member의 name을 sangjunB로 수정하려고 한다.
UPDATE member
SET name='sangjunB'
WHERE id = 2;
- 쓰기 작업이라 배타 락이 필요한데 이미 트랜잭션A가 갖고 있기 때문에 대기 상태에 들어간다.
- 트랜잭션A가 모든 작업을 마무리하고 Commit된다. 이때 Lock이 해제되고 대기 상태였던 트랜잭션B가 락을 획득하고 실행된다.
즉, 비관적 락은 Transaction을 이용하여 충돌을 예방하는 방식으로 동시성을 제어한다.
3. 낙관적 락 VS 비관적 락 차이점
두 전략은 언제 충돌이 발견되는지 차이가 있다. 낙관적 락은 조회 시점이 아닌 쓰기 시점에 충돌을 감지하는 전략이다. 반면에 비관적락은 조회 시점에 이미 락을 획득한다.
그렇기 때문에 낙관적 락은 충돌을 막는 것이 아니라 충돌을 감지하는 전략임을 인지해야 한다. 충돌이 발견되면 OptimisticLockException을 던지고 재시도하거나, 사용자에게 다른 사람이 이미 수정했음을 알려주는 등 후속 동작이 필요하다. 이 부분이 충돌을 미리 방지하는 비관적락과 가장 큰 차이점이다.
3. 비관적 락을 구현하는 실제 락
1. Shared Lock(S Lock 혹은 공유 락)
데이터를 읽을 때 획득하는 락으로 여러 트랜잭션이 동시에 획득 가능하다. 다만, 다른 트랜잭션의 수정은 막는다.
주의할 점은 아래와 같은 일반적인 SELETE는 InnoDB에서 Shared Lock을 잡지 않고, MVCC를 사용한다.
SELECT *
FROM member
WHERE id = 2;
즉, 읽기 시점에 S락을 획득하고 싶다면, 다음과 같이 쿼리에 명시해줘야 한다.
SELECT *
FROM member
WHERE id = 2
FOR SHARE;
2. Exclusive Lock (X Lock 혹은 배타 락)
데이터를 수정할 때 획득하는 락으로 다른 트랜잭션의 읽기와 쓰기를 모두 차단한다.
가장 대표적인 예시로 FOR UPDATE 키워드로 트랜잭션은 배타 락을 획득할 수 있다.
SELECT ... FOR UPDATE
3. Row Lock (행 락)
특정 행만 잠그는 방식으로 MySQL InnoDB는 id = 1 행만 잠근다. 따라서 다른 행은 수정 가능하다.
MySQL의 InnoDB 스토리지 엔진은 기본적으로 Row Lock(행 락)을 사용한다.
InnoDB는 행 단위 잠금(Row-Level Locking) 을 지원하며, 조건에 따라 Gap Lock, Next-Key Lock, Intent Lock 등을 함께 사용합니다.
실무에서는 대부분 이 Row Lock(행 락)을 사용한다.
4. Table Lock (테이블 락)
테이블 전체를 잠그는 방식으로, 해당 테이블의 전체 읽기, 쓰기가 제한된다. 따라서 성능이 좋지 않아 실무에서는 거의 사용하지 않는다. SQL문 예시는 다음과 같다.
LOCK TABLE member WRITE;
-- member 전체 읽기, 쓰기 제한
5. Intent Lock (의도 락)
개발자가 직접 설정하거나 신경쓰는 락이 아닌, DB 내부에서 자동으로 사용하는 락이다. 다음 쿼리를 예로 내부 의도 락이 어떻게, 왜 사용되는지 알아보자.
UPDATE member
SET point = 100
WHERE id = 1;
다음과 같은 SQL문이 실행되면 DB 내부에서는 실제로 다음과 같이 락이 걸린다.
- 테이블에 Intent Exclusive Lock
- 행에 Exclusive Lock
다음과 같이 테이블에 의도 락을 걸어주는 이유는, 다른 트랜잭션에게 이 테이블 안의 일부 행을 수정할 예정임을 알려주기 위함이다. 앞서 말했듯이 DB 내부에서 자동으로 사용되는 락으로 개발자는 보통 신경쓰지 않아도 된다.
6. Gap Lock
MySQL InnoDB에서 사용하는 락으로, 행 자체가 아닌 행과 행 사이의 빈 공간에 거는 락이다.
예를 들어, 현재 DB에 데이터 1,5,10가 저장되어 있다고 가정하자. 이때 다음과 같은 SQL이 실행되면 1~10 구간 전체가 잠길 수도 있다.
-- 트랜잭션 A
SELECT *
FROM member
WHERE id BETWEEN 1 AND 10
FOR UPDATE;
이때 다른 트랜잭션 B에서 7에 대해 아래 쓰기 쿼리를 실행한다면 실패하거나 대기에 들어갈 수 있다.
-- 트랜잭션 B
INSERT INTO member(id)
VALUES(7);
Gap락을 사용하는 이유는 Phantom Read를 방지하기 위함이다.
7. Next-Key Lock
MySQL InnoDB의 핵심 락으로 Row Lock과 Gap Lock의 조합이다. 다음과 같은 쿼리가 실행된다면,
SELECT *
FROM member
WHERE id >= 5
FOR UPDATE;
MySQL InnoDB는 행과 주변의 Gap을 한번에 잠글 수 있다. 이런 쿼리는 단순 조회가 아니라 현재 읽은 범위가 바뀌면 안되는 작업이기 때문에, InnoDB는 행 뿐만 아니라 그 사이의 Gap까지 잠가서 phantom read를 막으려고 한다.
SELECT *
FROM member
WHERE id >= 5;
이런 SQL은 보통 consistent read이기 때문에, Next-Key Lock을 걸지 않는다. 이는 MVCC 스냅샷을 읽는 방식이기 때문이다.
MySQL InnoDB 기본 격리 수준인 REPEATABLE READ에서는 phantom 방지를 위해 Next-Key Lock이 더 적극적으로 사용되고, READ COMMITTED에서는 Gap Lock 사용이 줄어드는 편이다.
이 부분은 DB 엔진의 구현체(InnoDB) 가 어떻게 구현되었는가의 영역이다. 따라서 InnoDB가 REPEATABLE READ를 구현하는 방법 중 하나가 Next-Key Lock임을 인지하자. 그리고 MySQL의 REPEATABLE READ가 Phantom Read를 막을 수 있는 이유이다.
번외
1. MySQL InnoDB의 기본 락은 무엇일까?
InnoDB는 기본적으로 Row-Level Lock을 사용한다. 다만 내부적으로는 Intent Lock, Gap Lock, Next-Key Lock 등을 함께 사용하여 동시성과 정합성을 보장한다.
2. InnoDB 기준, SQL별 락 설정 기준
- 일반 SELECT → 락 없음 (MVCC 사용)
- SELECT ... FOR UPDATE → Row Lock
- UPDATE / DELETE → Row Lock
- REPEATABLE READ → Next-Key Lock 사용 가능
동시성 문제 종류
1. Lost Update
두개 이상의 트랜잭션이 조회 시점에 락을 걸지 않아, 수정 시점의 정합성이 맞지 않는 문제. 예를 들어, 트랜잭션 A와 B가 계좌 잔액이 10000원인 시점을 읽고, A가 5000원을 출금하고 B가 3000원을 출금했는데 잔액이 2000원이 아닌 7000원이 되는 문제.
1. 트랜잭션A 10000원 조회
2. 트랜잭션B 10000원 조회
3. 트랜잭션A 10000원 - 5000원을 계산하여, 계좌에 5000원으로 UPDATE
4. 트랜잭션B 10000원 - 3000원을 계산하여, 계좌에 7000원으로 UPDATE
결과: 계좌 잔액이 2000원이어야 하는데, 7000원으로 반영
- For Update를 걸어 트랜잭션A가 조회 시점에 베타락을 획득하도록 설정하여 해결할 수 있다.