[Real MySQL 8.0 (1권)] 5장 - 트랜잭션과 잠금
Real MySQL 8.0 1권 5장의 내용을 정리합니다.
5장에서 던지는 질문
- 트랜잭션은 단순히 ‘여러 쿼리의 묶음’일까, 아니면 그 이상의 의미가 있을까?
- MySQL 엔진 락과 InnoDB 스토리지 엔진 락은 어떻게 상호작용하는가?
- 왜 인덱스 설계가 잠금의 범위와 서비스 성능에 직접적인 영향을 미치는가?
- 격리 수준(Isolation Level)에 따른 부정합 현상을 InnoDB는 어떻게 해결하는가?
MySQL의 전반적인 잠금 구조와 InnoDB 스토리지 엔진의 내부 동작 원리를 정리한 내용입니다.
트랜잭션 (Transaction)
트랜잭션은 작업의 완전성(Completeness)을 보장하는 것입니다. 즉, 데이터의 정합성을 위해 쿼리 세트가 100% 적용되거나(Commit), 아예 적용되지 않아야(Rollback) 함을 보장하는 메커니즘입니다.
- MyISAM: 트랜잭션을 지원하지 않습니다.
- InnoDB: 트랜잭션을 지원합니다.
트랜잭션 설정 시 주의사항
애플리케이션 개발 시 트랜잭션 범위를 최소화하는 것이 중요합니다.
- 커넥션 점유 최소화: 트랜잭션 내에 네트워크 통신(API 호출, 메일 발송)이나 파일 I/O가 포함되면 DB 커넥션을 오래 붙잡게 되어 커넥션 풀 고갈의 원인이 됩니다.
- 로직 분리: 단순 조회 작업이나 외부 연동 로직은 트랜잭션 밖으로 빼고, 실제 쓰기 작업이 일어나는 DB 조작 단계만 트랜잭션으로 묶어야 합니다.
MySQL 엔진의 잠금
MySQL의 잠금은 크게 MySQL 엔진 레벨과 스토리지 엔진 레벨로 나뉩니다. 엔진 레벨 잠금은 모든 스토리지 엔진에 영향을 미칩니다.
글로벌 락 (Global Lock)
- 명령어:
FLUSH TABLES WITH READ LOCK으로 획득하며 모든 테이블에 읽기 잠금을 겁니다. - 범위: MySQL 서버 전체입니다.
SELECT를 제외한 대부분의 DDL, DML 문장이 대기 상태가 됩니다. - 백업 락 (Backup Lock): 8.0에서 도입된 가벼운 락으로, 일반적인 데이터 변경은 허용하되 스키마 변경(DDL)이나 사용자 권한 변경만 제한합니다. 서비스 중단을 최소화하며 일관된 백업을 수행할 때 사용됩니다.
테이블 락 (Table Lock)
특정 테이블 단위로 설정되는 잠금입니다.
- 명시적 락:
LOCK TABLES table_name [READ | WRITE]명령으로 획득하며, 실무에서는 성능 저하 우려로 거의 사용하지 않습니다. - 묵시적 락: MyISAM이나 MEMORY 엔진에서 데이터 변경 시 자동 발생합니다. InnoDB는 레코드 기반 잠금을 지원하므로 DML 시에는 발생하지 않고 주로 DDL(구조 변경) 시에만 발생합니다.
- 엔진별 차이: MyISAM은 대부분 테이블 단위 락을 쓰지만, InnoDB는 레코드 기반 잠금을 쓰므로 DDL(ALTER TABLE 등) 시에만 테이블 락이 발생합니다.
네임드 락 (Named Lock)
- 명령어:
GET_LOCK()함수를 통해 사용자가 지정한 문자열에 대해 잠금을 설정합니다. - 활용: 여러 웹 서버가 배치 작업 시 상호 동기화가 필요하거나, 동일 데이터를 대량 수정하여 데드락 위험이 클 때 애플리케이션 레벨의 분산 락처럼 활용할 수 있습니다.
메타데이터 락 (Metadata Lock)
- 명령어: 테이블 구조 변경(DDL) 시 자동으로 획득하며 명시적으로 획득할 수 없습니다.
RENAME TABLE처럼 여러 테이블의 이름을 한꺼번에 바꿀 때 원자성을 보장하기 위해 사용됩니다.
InnoDB 스토리지 엔진 잠금
InnoDB는 레코드 기반 잠금을 제공하며, 잠금 정보가 아주 작은 단위로 관리되어 락 에스컬레이션이 발생하지 않는 것이 특징입니다.
잠금 종류
- 레코드 락 (Record Lock): 레코드 자체를 잠급니다. InnoDB는 특이하게 인덱스의 레코드를 잠급니다.
- 갭 락 (Gap Lock): 레코드 사이의 빈 공간을 잠가 새로운 생성을 제어하며 팬텀 리드를 방지합니다.
- 예:
BETWEEN 2 AND 4 FOR UPDATE시 2, 3, 4 사이 공간에 INSERT 대기 발생.
- 예:
- 넥스트 키 락 (Next-Key Lock): 레코드 락 + 앞쪽 갭 락. 팬텀 리드 방지와 복제 정합성 유지가 목적입니다.
- 자동 증가 락 (AUTO_INCREMENT Lock):
INSERT시 중복 없는 번호 할당을 위한 테이블 수준 잠금입니다.- 모드 1(기본): 성능과 안전성 균형. 모드 2(교차): 락 없이 병렬 수행하여 성능은 좋으나 복제 시 주의가 필요합니다.
인덱스와 잠금 (매우 중요)
InnoDB 잠금의 핵심은 “레코드가 아니라 인덱스를 잠근다”는 점입니다.
- 락 발생 흐름: 보조 인덱스 탐색 → 해당 인덱스 엔트리에 락 → PK를 통해 클러스터 인덱스(실제 레코드) 접근 및 락.
- 주의: WHERE 절에 인덱스가 적절히 설정되지 않으면 잠금 범위가 불필요하게 커져 동시성이 저하됩니다. 인덱스가 없으면 풀 스캔하며 모든 레코드를 잠급니다.
예시 상황:
first_name에만 인덱스가 있고last_name에는 없을 때
1 2 UPDATE employees SET hire_date = NOW() WHERE first_name = 'Georgi' AND last_name = 'Klassen';
first_name = 'Georgi'인 레코드가 300건이고 실제 조건에 맞는 레코드가 1건이라도, 300건의 인덱스 레코드 전체에 잠금이 걸립니다.
인덱스가 전혀 없다면 테이블 풀 스캔을 수행하며 모든 레코드를 잠그게 되므로, 잠금 범위를 최소화하기 위한 인덱스 설계가 매우 중요합니다.
그렇다면 복합 인덱스 순서가 잠금 범위에 미치는 영향은?
- 잠금 범위 결정: InnoDB는 쿼리 조건을 만족하기 위해 스캔한 모든 인덱스 레코드에 락을 겁니다.
- 최적화 전략: 복합 인덱스의 첫 번째 컬럼을 필터링 효율이 좋은 컬럼으로 배치하여 스캔 범위를 최소화해야 불필요한 레코드 잠금을 막을 수 있습니다.
이렇게 여러 인덱스와 레코드에 락이 걸리다 보면, 트랜잭션 간 교착 상태는 피할 수 없게 됩니다.
InnoDB는 어떻게 교착 상태를 감지(데드락 탐지)하고 해제하나요?
- 탐지 스레드: InnoDB는 잠금 대기 목록을 그래프(Wait-for Graph)로 관리하며 주기적으로 사이클 발생 여부를 체크합니다.
- 해제 과정: 데드락 감지 시 Undo 로그 양이 가장 적은 트랜잭션을 우선적으로 롤백(Victim 선정)시켜 비용을 최소화합니다.
MySQL의 격리 수준 (Isolation Level)
격리 수준은 동시에 처리되는 트랜잭션 간에 데이터를 어떻게 공유할지 결정하는 설정입니다.
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|
| READ UNCOMMITTED | 발생 | 발생 | 발생 |
| READ COMMITTED | - | 발생 | 발생 |
| REPEATABLE READ | - | - | 발생 (InnoDB는 차단) |
| SERIALIZABLE | - | - | - |
| 격리 수준 | 읽기 일관성 방식 | Undo 로그 사용 | 팬텀 리드 방지 | Gap Lock 사용 | 주요 특징 |
|---|---|---|---|---|---|
| READ UNCOMMITTED | 없음 (Dirty Read 허용) | 사용 안 함 | 방지 안 됨 | 사용 안 함 | 커밋되지 않은 값까지 읽음. 성능은 좋지만 정합성이 매우 낮음 |
| READ COMMITTED | 커밋된 시점 기준 읽기 | 사용함 | 방지 안 됨 | 사용 안 함 | 커밋된 데이터만 읽음. 반복 쿼리 시 결과가 다를 수 있음 (Non-Repeatable Read 발생) |
| REPEATABLE READ (기본값) | 트랜잭션 시작 시점 스냅샷 | 사용함 | 방지됨 | 사용함 | 동일 트랜잭션 내 쿼리 결과 보장. 팬텀 리드는 갭 락을 통해 차단 |
| SERIALIZABLE | 공유 락 기반 직렬화 | 사용함 | 완벽히 방지 | 강제 사용 | 모든 SELECT에도 락을 획득. 동시성이 가장 낮지만 정합성은 최상 |
여기서 중요한 질문이 하나 생깁니다. 쓰기 중인 데이터를 읽으면서도 어떻게 일관성을 보장할 수 있을까요?
MVCC와 Undo 로그: 잠금 없는 일관된 읽기(Non-locking Consistent Read)의 원리는?
- 핵심 원리: 데이터를 변경할 때 기존 데이터를 Undo 로그 영역에 복사해 둡니다.
- 작동 방식: 조회 시점에 자신의 트랜잭션 ID보다 나중에 커밋된 데이터라면 Undo 로그의 이전 버전을 읽어 일관성을 유지합니다.
- 이점: 읽기 작업이 쓰기 작업을 기다리지 않아 동시 처리 성능이 극대화됩니다.
Undo 로그의 역할
- MVCC: 다중 버전 일관성 유지를 통해 읽기 작업이 쓰기를 방해하지 않게 합니다.
- 롤백/지연 삭제: 트랜잭션 실패 시 복구 및 참조 중인 데이터 보존을 담당합니다.
주요 특징
- READ COMMITTED: 커밋된 데이터만 읽지만, 한 트랜잭션 내에서 동일 조회 결과가 달라지는
Non-Repeatable Read가 발생할 수 있습니다. - REPEATABLE READ: InnoDB의 기본값입니다. MVCC를 이용해 트랜잭션 시작 시점의 스냅샷을 읽어 일관성을 보장하며, 갭 락 덕분에
Phantom Read도 거의 발생하지 않습니다. - SERIALIZABLE: 읽기 작업에도 공유 잠금을 획득해야 하므로 동시 처리 성능이 크게 떨어집니다.
운영 및 설계 관점에서의 심화 분석
시간/공간 복잡도 분석
- 시간 복잡도: 인덱스가 잘 설계된 경우 잠금 탐색은 $O(\log N)$이나, 인덱스가 없으면 $O(N)$의 잠금 범위가 발생하여 시스템 전체 지연을 초래합니다.
- 공간 복잡도: 락 자체의 메모리 점유는 적으나, 긴 트랜잭션 유지 시 Undo 로그가 누적되어 디스크 공간 점유가 $O(\Delta \text{Data} \times \text{Time})$으로 증가하며 Purge 스레드 지연을 일으킵니다.
주의사항 및 실무 대안
- 데드락 방지: 레코드 접근 순서를 일치시키거나, 배치 작업 시 동일 데이터를 참조하는 프로그램끼리 분류하여 네임드 락을 사용하는 것을 고려해볼 수 있습니다. 또한 여러 분산 서버 간의 동기화가 필요한 경우, DB 레코드 잠금 대신 네임드 락(Named Lock)을 활용하여 원자적 작업을 논리적으로 격리하는 것도 훌륭한 대안입니다.
- 복제 방식 고려: 넥스트 키 락이나 갭 락으로 인한 데드락이 빈번하다면, 바이너리 로그 포맷(Binlog Format)을
STATEMENT에서ROW로 변경하는 것을 검토해보면 좋습니다. 이를 통해 잠금 범위를 최소화하고 동시 처리 성능을 개선할 수 있습니다. - 트랜잭션 분리 (Event-driven): 외부 API 연동 등은 트랜잭션 밖에서 수행하십시오. 강한 정합성이 필요 없는 작업은 Kafka/Redis를 활용한 이벤트 기반 설계를 통해 DB 점유 시간을 최소화하는 것을 고려해볼 수 있습니다.
