MySQL Lock의 종류와 Deadlock 해결 전략

2024-01-28 10:21:42
#mysql#database#lock#deadlock#innodb#transaction

Lock과 Transaction의 차이

Lock과 Transaction은 자주 혼동되는 개념입니다.

  • Transaction: 작업의 완전성을 보장합니다. 논리적인 작업을 모두 완벽하게 처리하거나, 처리하지 못할 경우 원 상태로 복구해서 Partial Update를 막습니다.
  • Lock: 동시성을 제어합니다. 하나의 레코드를 여러 Connection에서 동시에 변경하려 할 때, 순서를 보장합니다.

쉽게 말해 Transaction은 "데이터 정합성"을, Lock은 "동시 접근 제어"를 담당합니다.

Lock의 기본 개념: Shared Lock vs Exclusive Lock

MySQL에서 Lock은 크게 두 가지로 나뉩니다.

Lock 종류 약자 용도 호환성
Shared Lock S 읽기 전용 (SELECT ... FOR SHARE) S-Lock끼리 호환
Exclusive Lock X 쓰기 (UPDATE, DELETE) 어떤 Lock과도 비호환
-- Shared Lock 획득 (읽기용)
SELECT * FROM users WHERE id = 1 FOR SHARE;

-- Exclusive Lock 획득 (쓰기용)
SELECT * FROM users WHERE id = 1 FOR UPDATE;

Shared Lock은 여러 트랜잭션이 동시에 획득할 수 있지만, Exclusive Lock은 혼자만 획득할 수 있습니다. 이미 S-Lock이 걸린 상태에서 X-Lock을 획득하려면 S-Lock이 해제될 때까지 대기해야 합니다.

MySQL Lock 종류

1. Global Lock

한 세션에서 Global Lock을 획득하면 다른 세션에서는 SELECT를 제외한 DDL, DML 문장 실행이 불가능합니다.

-- Global Lock 획득
FLUSH TABLES WITH READ LOCK;

-- Global Lock 해제
UNLOCK TABLES;

주로 mysqldump로 백업할 때 사용됩니다. 전체 데이터베이스를 일관된 상태로 백업해야 하기 때문입니다.

2. Table Lock

개별 테이블 단위로 설정되는 Lock입니다.

-- 읽기 Lock (다른 세션도 읽기 가능, 쓰기 불가)
LOCK TABLES users READ;

-- 쓰기 Lock (다른 세션은 읽기/쓰기 모두 불가)
LOCK TABLES users WRITE;

-- Lock 해제
UNLOCK TABLES;

InnoDB 테이블의 경우 스토리지 엔진 차원에서 레코드 기반 잠금을 제공하기 때문에 DML에서는 묵시적으로 Table Lock이 걸리지 않습니다. 다만 DDL(ALTER TABLE 등)에서는 묵시적 Table Lock이 걸립니다.

3. Named Lock

GET_LOCK() 함수를 이용해 임의의 문자열에 대해 잠금을 설정할 수 있습니다.

-- "my_lock" 문자열에 대해 5초간 잠금 획득 시도
SELECT GET_LOCK("my_lock", 5);

-- 잠금 해제
SELECT RELEASE_LOCK("my_lock");

-- 모든 lock 해제
SELECT RELEASE_ALL_LOCKS();

잠금 대상이 데이터베이스 객체가 아니라 임의의 문자열이라는 점이 특징입니다. 여러 서버 인스턴스 간에 동기화가 필요한 작업에서 분산 락 대용으로 활용할 수 있습니다.

예를 들어 배치 작업이 여러 서버에서 동시에 실행되면 안 될 때:

-- 배치 시작 전
SELECT GET_LOCK("batch_daily_report", 0);
-- 0을 반환하면 이미 다른 서버에서 실행 중

4. Metadata Lock

데이터베이스 객체의 이름이나 구조를 변경하는 경우에 자동으로 획득되는 Lock입니다.

-- 아래 명령 실행 시 자동으로 Metadata Lock 획득
ALTER TABLE users ADD COLUMN email VARCHAR(255);
RENAME TABLE users TO members;

명시적으로 획득하거나 해제할 수 없습니다.

InnoDB 스토리지 엔진 잠금

InnoDB는 레코드 단위의 정교한 잠금을 제공합니다.

1. Record Lock

레코드 자체가 아닌 인덱스의 레코드를 잠급니다. 이 점이 중요합니다.

-- employees 테이블에 first_name 인덱스만 있는 경우
UPDATE employees SET hire_date = NOW()
WHERE first_name = 'Kim' AND last_name = 'CS';

위 쿼리에서 InnoDB는 first_name = 'Kim'모든 레코드에 Lock을 걸게 됩니다. last_name 조건은 인덱스가 없어서 필터링 용도로만 사용되기 때문입니다.

인덱스가 하나도 없는 테이블이라면? 내부적으로 생성된 클러스터 인덱스를 풀스캔하면서 모든 레코드에 Lock을 겁니다. 이래서 적절한 인덱스가 중요합니다.

2. Gap Lock

인접한 레코드 사이의 간격을 잠급니다. 레코드와 레코드 사이에 새로운 레코드가 INSERT되는 것을 방지합니다.

-- id가 10, 20, 30인 레코드가 있을 때
SELECT * FROM users WHERE id BETWEEN 15 AND 25 FOR UPDATE;
-- id 10~20 사이, 20~30 사이의 Gap에 Lock이 걸림
-- 이 범위에 INSERT 불가

Gap Lock은 Phantom Read를 방지하기 위해 존재합니다.

3. Next-Key Lock

Record Lock + Gap Lock을 합친 형태입니다. InnoDB의 기본 잠금 방식입니다.

4. Auto Increment Lock

AUTO_INCREMENT 컬럼의 값이 중복되지 않고 순서대로 증가함을 보장하기 위한 테이블 수준의 잠금입니다.

-- 두 개의 INSERT가 동시에 실행되면
INSERT INTO users (name) VALUES ('A');  -- id = 1
INSERT INTO users (name) VALUES ('B');  -- id = 2
-- 하나가 Lock을 획득하고, 다른 하나는 대기

INSERT에서만 걸리며, UPDATE/DELETE에서는 걸리지 않습니다. 명시적으로 획득하거나 해제할 수 없습니다.

Deadlock (교착 상태)

Deadlock은 두 개 이상의 트랜잭션이 서로가 가진 Lock을 기다리면서 무한정 대기하는 상태입니다.

Deadlock 발생 예시

시간    트랜잭션 A                    트랜잭션 B
────────────────────────────────────────────────────
T1     UPDATE users SET ...
       WHERE id = 1;
       (id=1에 X-Lock 획득)

T2                                    UPDATE orders SET ...
                                      WHERE id = 100;
                                      (id=100에 X-Lock 획득)

T3     UPDATE orders SET ...
       WHERE id = 100;
       (id=100 대기중...)

T4                                    UPDATE users SET ...
                                      WHERE id = 1;
                                      (id=1 대기중...)

       → Deadlock 발생!

InnoDB의 Deadlock 처리

InnoDB는 Deadlock을 자동으로 감지합니다. Deadlock이 발생하면 두 트랜잭션 중 하나를 강제로 롤백시킵니다. 일반적으로 언두 로그가 적은(작업량이 적은) 트랜잭션이 희생됩니다.

-- Deadlock 발생 시 에러 메시지
ERROR 1213 (40001): Deadlock found when trying to get lock;
try restarting transaction

Deadlock 확인 방법

-- 마지막 Deadlock 정보 확인
SHOW ENGINE INNODB STATUS;

-- 모든 Deadlock을 에러 로그에 기록 (디버깅용)
SET GLOBAL innodb_print_all_deadlocks = ON;

-- 현재 실행 중인 트랜잭션 확인
SELECT * FROM information_schema.INNODB_TRX;

-- 현재 Lock 대기 상태 확인 (MySQL 8.0+)
SELECT * FROM performance_schema.data_lock_waits;

Deadlock 예방 전략

1. 트랜잭션을 짧게 유지

// 나쁜 예: 트랜잭션 안에서 외부 API 호출
@Transactional
public void processOrder(Long orderId) {
    Order order = orderRepository.findById(orderId);
    paymentService.callExternalApi(order);  // 네트워크 지연!
    order.complete();
}

// 좋은 예: 외부 호출을 트랜잭션 밖으로
public void processOrder(Long orderId) {
    Order order = orderRepository.findById(orderId);
    paymentService.callExternalApi(order);

    orderService.complete(orderId);  // 여기서만 @Transactional
}

2. 일관된 순서로 Lock 획득

// 나쁜 예: 순서가 일관되지 않음
// Thread A: users -> orders
// Thread B: orders -> users

// 좋은 예: 항상 같은 순서로 접근
public void transfer(Long fromId, Long toId) {
    // ID가 작은 것부터 Lock
    Long firstId = Math.min(fromId, toId);
    Long secondId = Math.max(fromId, toId);

    Account first = accountRepository.findByIdForUpdate(firstId);
    Account second = accountRepository.findByIdForUpdate(secondId);
    // ...
}

3. 적절한 인덱스 사용

인덱스가 없으면 테이블 전체를 스캔하면서 불필요한 레코드까지 Lock을 겁니다.

-- 나쁜 예: last_name에 인덱스 없음
UPDATE users SET status = 'ACTIVE' WHERE last_name = 'Kim';
-- 인덱스 없이 전체 스캔 → 많은 레코드에 Lock

-- 좋은 예: last_name에 인덱스 추가
CREATE INDEX idx_last_name ON users(last_name);
UPDATE users SET status = 'ACTIVE' WHERE last_name = 'Kim';
-- 필요한 레코드만 Lock

4. 낮은 격리 수준 사용 고려

-- REPEATABLE READ 대신 READ COMMITTED 사용
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

Gap Lock이 줄어들어 Deadlock 가능성이 낮아집니다. 단, Phantom Read가 발생할 수 있으니 애플리케이션 레벨에서 처리해야 합니다.

5. 재시도 로직 구현

Deadlock은 완전히 피하기 어렵습니다. 애플리케이션에서 재시도 로직을 구현하는 것이 현실적입니다.

@Retryable(
    value = DeadlockLoserDataAccessException.class,
    maxAttempts = 3,
    backoff = @Backoff(delay = 100)
)
@Transactional
public void updateWithRetry(Long id) {
    // Deadlock 발생 시 최대 3번 재시도
}

JPA에서 Lock 사용하기

Spring Data JPA에서는 @Lock 어노테이션으로 Lock을 설정할 수 있습니다.

public interface UserRepository extends JpaRepository<User, Long> {

    // Pessimistic Write Lock (SELECT ... FOR UPDATE)
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT u FROM User u WHERE u.id = :id")
    Optional<User> findByIdForUpdate(@Param("id") Long id);

    // Pessimistic Read Lock (SELECT ... FOR SHARE)
    @Lock(LockModeType.PESSIMISTIC_READ)
    Optional<User> findWithLockById(Long id);
}
LockModeType SQL 용도
PESSIMISTIC_READ SELECT ... FOR SHARE 읽기 Lock (S-Lock)
PESSIMISTIC_WRITE SELECT ... FOR UPDATE 쓰기 Lock (X-Lock)
PESSIMISTIC_FORCE_INCREMENT FOR UPDATE + 버전 증가 낙관적+비관적 혼합
// 서비스 레이어에서 사용
@Transactional
public void updateUserBalance(Long userId, int amount) {
    User user = userRepository.findByIdForUpdate(userId)
        .orElseThrow(() -> new UserNotFoundException(userId));

    user.addBalance(amount);  // Lock이 걸린 상태에서 안전하게 수정
}

트랜잭션 격리 수준

Lock과 밀접하게 연관된 개념이 격리 수준입니다.

격리 수준 Dirty Read Non-Repeatable Read Phantom Read
READ UNCOMMITTED O O O
READ COMMITTED X O O
REPEATABLE READ X X O*
SERIALIZABLE X X X

*InnoDB의 REPEATABLE READ는 Gap Lock으로 Phantom Read도 방지합니다. 표준 SQL 정의와 다릅니다.

READ UNCOMMITTED

커밋되지 않은 데이터도 읽을 수 있습니다. Dirty Read가 발생하므로 실무에서 거의 사용하지 않습니다.

READ COMMITTED

Oracle의 기본 격리 수준입니다. 커밋된 데이터만 읽을 수 있습니다.

MySQL에서는 언두 로그를 활용합니다. 다른 트랜잭션이 데이터를 변경하면 언두 로그에 변경 전 데이터를 복사해두고, 커밋 전까지는 언두 로그의 데이터를 반환합니다.

REPEATABLE READ

MySQL InnoDB의 기본 격리 수준입니다. 트랜잭션 시작 시점의 스냅샷을 기준으로 일관된 읽기를 보장합니다.

InnoDB는 MVCC(Multi Version Concurrency Control)를 통해 구현합니다. 트랜잭션 번호를 기준으로 자신보다 나중에 시작된 트랜잭션의 변경은 무시합니다.

SERIALIZABLE

가장 엄격한 격리 수준입니다. 읽기 작업에도 Lock을 획득해야 합니다. 동시 처리 성능이 가장 떨어지지만, 데이터 정합성은 완벽하게 보장됩니다.

실무에서 Lock 문제 해결 팁

1. 느린 쿼리 확인

Lock 대기가 길어지면 느린 쿼리 로그에 기록됩니다.

-- 느린 쿼리 로그 활성화
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;  -- 1초 이상

2. Lock Wait Timeout 조정

-- 기본값: 50초
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';

-- 10초로 줄이기 (빠른 실패)
SET innodb_lock_wait_timeout = 10;

3. 현재 Lock 상태 모니터링

-- 어떤 트랜잭션이 어떤 Lock을 기다리는지
SELECT
    r.trx_id AS waiting_trx_id,
    r.trx_mysql_thread_id AS waiting_thread,
    r.trx_query AS waiting_query,
    b.trx_id AS blocking_trx_id,
    b.trx_mysql_thread_id AS blocking_thread,
    b.trx_query AS blocking_query
FROM performance_schema.data_lock_waits w
JOIN information_schema.innodb_trx b ON b.trx_id = w.BLOCKING_ENGINE_TRANSACTION_ID
JOIN information_schema.innodb_trx r ON r.trx_id = w.REQUESTING_ENGINE_TRANSACTION_ID;

Reference

프로필 이미지
@chani
바둑, 스타크래프트 등 고전 게임을 좋아하는 내향인 개발자입니다

댓글