Real MySQL 8.0 – 5장 트랜잭션과 잠금
0. 핵심 개념 정리
본격적으로 들어가기 전에, 이 장에서 계속 반복해서 등장하는 두 개념부터 정리하고 가자.
트랜잭션 (Transaction)
하나의 논리적 작업 단위를 전부 성공(COMMIT)하거나 전부 취소(ROLLBACK)하여, 중간에 일부만 반영되는 부분 업데이트(Partial Update)가 발생하지 않도록 보장하는 기능이다. 즉, 데이터의 정합성(Consistency)을 책임지는 장치이다.
잠금 (Lock)
여러 세션이 동시에 동일한 데이터에 접근할 때, 동시에 변경되는 것을 방지하여 정합성과 동시성을 제어하는 메커니즘이다. 트랜잭션이 “무엇을 할지”에 대한 규칙이라면, 잠금은 “여럿이 동시에 접근할 때 어떻게 조율할지”에 대한 규칙이다.
5.1 트랜잭션
5.1.1 MySQL에서의 트랜잭션
트랜잭션은 “쿼리가 몇 개 실행됐는가”가 아니라, 하나의 논리적 작업 단위 전체가 성공하거나 전체가 실패하는지를 보장하는 기능이다.
중간에 오류가 발생하면, 그 시점에서 멈춰 버리는 것이 아니라 애초에 아무 일도 없었던 것처럼 롤백되어야 한다.
MyISAM vs InnoDB 예시
CREATE TABLE tab_myisam (
fdpk INT NOT NULL,
PRIMARY KEY (fdpk)
) ENGINE = MyISAM;
CREATE TABLE tab_innodb (
fdpk INT NOT NULL,
PRIMARY KEY (fdpk)
) ENGINE = InnoDB;
INSERT INTO tab_myisam (fdpk) VALUES (3);
INSERT INTO tab_innodb (fdpk) VALUES (3);
INSERT INTO tab_myisam (fdpk) VALUES (1), (2), (3); -- 에러지만 1, 2는 반영됨
INSERT INTO tab_innodb (fdpk) VALUES (1), (2), (3); -- 에러 → 전체 롤백
MyISAM
트랜잭션을 지원하지 않기 때문에, 3에서 PK 중복 에러가 나도 1, 2는 이미 반영된 상태로 남는다. 즉, 부분 업데이트가 발생하며, 이후 정합성 문제를 애플리케이션에서 직접 처리해야 한다.
InnoDB
트랜잭션을 지원하므로, 3에서 에러가 발생하면 1, 2까지 포함해서 전체가 롤백된다. 하나의 작업 셋이 “모두 또는 전혀” 반영되도록 DB가 보장해 준다.
MyISAM / MEMORY 같은 트랜잭션 미지원 엔진은 정합성 문제를 애플리케이션에서 떠안게 만든다. InnoDB는 이 부담을 DB 레벨에서 처리해 주기 때문에, 거의 모든 실무 서비스에서 InnoDB를 사용한다.
5.1.2 트랜잭션 사용 시 주의사항
1) 트랜잭션 범위는 “최소한”으로
DB 커넥션 수는 제한적이다. 트랜잭션이 열려 있는 시간 = 커넥션이 점유되는 시간이기 때문에, 트랜잭션 범위는 가능한 짧게, 최소한의 코드만 포함하도록 설계해야 한다.
- 트랜잭션이 오래 열려 있으면 그만큼 다른 요청들이 대기하게 된다.
- 단순 조회(SELECT)라면 굳이 트랜잭션에 묶지 않아도 된다.
Spring / JPA를 쓸 때는?
위 내용은 순수 SQL / MyBatis 관점에서는 정답이지만, Spring Data JPA 같이 ORM을 사용할 때는 예외가 있다.
- 지연 로딩(Lazy Loading)은 “영속성 컨텍스트 + 트랜잭션”을 전제로 동작한다.
- 그래서 단순 조회라도, JPA에서는 보통
@Transactional(readOnly = true)를 사용한다.
순수 SQL / MyBatis 환경: 단순 조회는 트랜잭션에서 빼는 것이 일반적이다.
Spring Data JPA 환경: Lazy Loading을 고려해 조회 메서드에도 트랜잭션을 거는 경우가 많다.
2) 외부 I/O는 트랜잭션 밖에서
예: 파일 업로드, 메일 발송, HTTP API 호출, FTP 전송 등
- 이런 작업을 트랜잭션 내부에 넣으면, 그 시간 동안 커넥션과 잠금이 모두 유지된다.
- 응답 지연, 타임아웃, 네트워크 오류가 DB까지 전파되는 문제가 생길 수 있다.
따라서 보통은 다음과 같이 설계한다.
- DB에 저장해야 하는 핵심 로직만
BEGIN ~ COMMIT / ROLLBACK구간에 둔다. - 외부 시스템과의 통신은 트랜잭션 밖에서 처리한다.
5.2 MySQL 엔진 레벨 잠금
5.2.0 잠금의 큰 분류
MySQL 엔진 레벨 잠금
- 글로벌 락 (Global Lock)
- 테이블 락 (Table Lock)
- 메타데이터 락 (Metadata Lock)
- 네임드 락 (Named Lock)
InnoDB 스토리지 엔진 레벨 잠금
- 레코드 락 (Record Lock)
- 갭 락 (Gap Lock)
- 넥스트 키 락 (Next-Key Lock)
엔진 레벨 잠금은 MySQL 서버 전체 관점의 잠금이고, InnoDB 잠금은 “해당 테이블의 스토리지 엔진 내부”에서 동작하는 잠금이다.
5.2.1 글로벌 락 (Global Lock)
FLUSH TABLES WITH READ LOCK;
- 서버 전체에 영향을 미치는 가장 범위가 넓은 락이다.
- 락이 잡히면, 다른 세션에서 SELECT를 제외한 대부분의 DDL/DML이 대기 상태가 된다.
백업 락 (MySQL 8.0~)
InnoDB + MVCC 덕분에 모든 변경을 멈추는 전통적인 글로벌 락 대신, 조금 더 가벼운 “백업을 위한 락”이 도입되었다.
- DDL, 계정/권한 변경 등은 차단된다.
- DML(INSERT/UPDATE/DELETE)은 허용된다.
→ 서비스는 계속 돌아가면서, 스키마 변경과 계정 변경만 막고 백업을 안정적으로 수행할 수 있다.
5.2.2 테이블 락 (Table Lock)
1) 명시적 테이블 락
LOCK TABLES table_name READ;
LOCK TABLES table_name WRITE;
UNLOCK TABLES;
서비스 전체에 영향을 많이 주기 때문에, 일반적인 운영 환경에서는 거의 사용하지 않는다. (마이그레이션, 특정 유지 보수 작업 등 아주 제한적인 상황에서만 사용.)
2) 묵시적 테이블 락
- MyISAM / MEMORY 테이블에서 DML이 실행될 때, 쿼리 동안 자동으로 테이블 락이 걸렸다가 해제된다.
- InnoDB는 DML에서 테이블 락 대신 레코드 단위 잠금을 사용하므로, DDL(ALTER TABLE 등)에서만 테이블 락의 영향을 주로 받는다.
5.2.3 네임드 락 (Named Lock)
임의의 문자열에 대해 잠금을 설정할 수 있는 기능이다.
SELECT GET_LOCK('mylock', 2); -- 'mylock'에 대한 락 획득 (최대 2초 대기)
SELECT IS_FREE_LOCK('mylock'); -- 사용 가능 여부 확인
SELECT RELEASE_LOCK('mylock'); -- 락 해제
- 테이블 / 레코드 같은 DB 객체가 아니라, 단순 “키 문자열”에 대한 잠금이다.
- 여러 서버가 하나의 작업을 중복 수행하면 안 되는 상황에서 유용하다.
- 예: 배치 작업이 동시에 두 번 이상 돌지 않게 막을 때.
5.2.4 메타데이터 락 (Metadata Lock, MDL)
테이블 / 뷰의 이름, 구조를 변경하는 순간 자동으로 걸리는 잠금이다.
ALTER TABLE,RENAME TABLE등 DDL 실행 시 내부적으로 획득된다.- 개발자가 직접 잡거나 해제하는 것이 아니라, 서버가 자동 관리한다.
- 테이블 이름 변경을 여러 스텝으로 나누면, 중간에 다른 세션이 끼어들어 “Table not found” 같은 오류가 날 수 있다.
가능하면 구조 변경은 하나의 DDL로 원자적으로 처리하는 것이 안전하다.
5.3 InnoDB 스토리지 엔진 잠금
5.3.0 특징
- InnoDB는 스토리지 엔진 내부에서 레코드 기반 잠금을 제공한다.
- 잠금 에스컬레이션이 없으므로, 레코드 잠금이 테이블 전체 잠금으로 자동 확대되지 않는다.
- MyISAM보다 훨씬 높은 동시성을 제공한다.
5.3.1 InnoDB 잠금의 종류
5.3.1.1 레코드 락 (Record Lock)
- 특정 레코드(정확히는 인덱스 엔트리)에 대한 잠금이다.
- InnoDB는 “레코드 자체”가 아니라 인덱스 엔트리를 잠근다.
- 어떤 인덱스로 검색하느냐에 따라 잠금 범위가 달라진다.
5.3.1.2 갭 락 (Gap Lock)
- 레코드와 레코드 사이의 “간격”을 잠그는 락이다.
- 해당 구간에 새로운 레코드가 INSERT 되는 것을 막는다.
- 일반적으로는 넥스트 키 락의 일부로 사용된다.
갭 락이 단독으로 쓰이는 전형적인 경우
- PK 또는 유니크 인덱스로 “존재하지 않는 값”을 Locking Read 할 때.
SELECT * FROM users WHERE id = 9999 FOR UPDATE;
id = 9999가 존재하지 않으면, “9999가 들어갈 수 있는 위치”에 해당하는 인덱스 갭만 잠긴다. 즉, 실제 레코드는 없지만, 그 자리를 위한 공간만 잠그는 것이다.
5.3.1.3 넥스트 키 락 (Next-Key Lock)
- 레코드 락 + 갭 락의 조합이다.
- 특정 인덱스 값과 그 앞 구간까지 함께 잠근다.
주요 목적
- REPEATABLE READ에서 Phantom Read를 방지한다.
- Statement 기반 복제에서 소스 / 레플리카 결과를 일치시키는 데 도움을 준다.
단점
- 데드락, 장기 대기의 원인이 되기도 한다.
- ROW 기반 바이너리 로그를 사용하면 넥스트 키 락 사용을 줄일 수 있다.
5.3.1.4 자동 증가 락 (AUTO_INCREMENT Lock)
AUTO_INCREMENT컬럼의 값이 중복되지 않도록 보장하는 잠금이다.- INSERT / REPLACE와 같이 새로운 레코드를 추가하는 쿼리에서만 사용된다.
- MySQL 8.0에서 기본 설정(innodb_autoinc_lock_mode = 2)일 때는, 아주 짧은 시간만 잠금이 걸렸다가 해제된다.
5.3.2 인덱스와 잠금의 상관관계 (핵심)
이 챕터의 핵심 문장은 다음 한 줄로 요약할 수 있다.
InnoDB는 “레코드”가 아니라 “인덱스 엔트리”를 잠근다.
따라서 어떤 인덱스를 타고 검색하느냐가 잠금 범위, 동시성, 데드락 발생 가능성을 결정한다.
1) 잠금 범위는 “검색된 인덱스 범위”이다
PK 인덱스로 단건 업데이트
UPDATE users
SET age = 20
WHERE id = 5;
- PK 인덱스로 id = 5 한 건만 정확히 찾는다.
- 그 인덱스 엔트리 하나만 잠긴다.
- 불필요한 잠금이 없으므로 동시성이 매우 좋다.
보조 인덱스 / 범위 검색
UPDATE users
SET age = age + 1
WHERE email LIKE 'kim%';
- email 인덱스가 있다면, 'kim%' 범위에 해당하는 모든 인덱스 엔트리에 잠금이 걸릴 수 있다.
- 넥스트 키 락, 갭 락이 함께 걸리면서 다른 트랜잭션을 오래 기다리게 만들 수 있다.
인덱스가 아예 없을 때
UPDATE users
SET age = 20
WHERE name = 'choi';
- name 컬럼에 인덱스가 없으면, 결국 클러스터 인덱스 전체를 스캔해야 한다.
- 스캔 과정에서 많은 인덱스 엔트리를 잠그게 되어, 사실상 테이블 대부분이 잠긴 것과 비슷한 효과가 난다.
인덱스를 어떻게 설계했느냐에 따라 “쿼리 성능”뿐 아니라 “잠금 범위 · 동시성 · 데드락 위험도”까지 결정된다.
5.3.3 레코드 수준 잠금 확인 및 해제
잠금 상태 확인
SHOW PROCESSLIST;
현재 어떤 세션이 무엇을 실행 중이고, 잠금 대기 중인지 확인할 수 있다.
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;
레코드 잠금과 잠금 대기 관계를 상세히 볼 수 있다.
잠금 해제
KILL <thread_id>;
문제를 일으키는 세션을 강제로 종료하면 해당 트랜잭션이 롤백되고, 잠금도 함께 해제된다.
5.4 트랜잭션 격리 수준
5.4.0 개요
격리 수준은 “여러 트랜잭션이 동시에 수행될 때, 서로의 변경 내용을 어느 시점부터 볼 수 있게 허용할 것인가”를 결정하는 규칙이다.
- READ UNCOMMITTED
- READ COMMITTED
- REPEATABLE READ (MySQL / InnoDB 기본)
- SERIALIZABLE
5.4.0.1 대표적인 부정합 현상
- Dirty Read : 커밋되지 않은 데이터를 다른 트랜잭션이 읽는 현상
- Non-Repeatable Read : 같은 트랜잭션에서 같은 조건으로 두 번 조회했는데 값이 바뀌는 현상
- Phantom Read : 같은 조건으로 두 번 조회했는데 레코드 “개수”가 달라지는 현상
5.4.0.2 격리 수준 비교
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|
| READ UNCOMMITTED | 발생 | 발생 | 발생 |
| READ COMMITTED | 없음 | 발생 | 발생 |
| REPEATABLE READ | 없음 | 없음 | 이론상 발생 (InnoDB는 대부분 방지) |
| SERIALIZABLE | 없음 | 없음 | 없음 |
5.4.1 READ UNCOMMITTED
- 가장 낮은 격리 수준이다.
- 커밋되지 않은 데이터를 다른 트랜잭션이 읽을 수 있어 Dirty Read가 발생한다.
- 정합성 문제가 심각하기 때문에 실제로는 거의 사용하지 않는다.
5.4.2 READ COMMITTED
- Oracle의 기본 격리 수준이다.
- 커밋된 데이터만 읽을 수 있어 Dirty Read는 발생하지 않는다.
- 하지만 같은 트랜잭션에서 두 번 조회했을 때 값이 달라질 수 있는 Non-Repeatable Read가 발생한다.
5.4.3 REPEATABLE READ
- MySQL InnoDB의 기본 격리 수준이다.
- 한 트랜잭션 내에서 동일 조건으로 여러 번 조회해도 항상 같은 결과를 보장한다.
- 언두 로그 기반 MVCC 스냅샷을 사용한다.
Consistent Read vs Locking Read
- Consistent Read (일관된 읽기)
일반 SELECT는 언두 로그를 이용해 “트랜잭션 시작 시점 기준 스냅샷”을 조회한다. 이 경우 Phantom Read가 발생하지 않는다. - Locking Read (잠금 읽기)
SELECT ... FOR UPDATE,SELECT ... LOCK IN SHARE MODE는 현재 레코드를 기준으로 읽으면서 잠금을 건다. 이 경우 이론적으로 Phantom Read가 가능하지만, InnoDB의 갭 락 / 넥스트 키 락 덕분에 대부분의 상황에서는 방지된다.
5.4.4 SERIALIZABLE
- 가장 엄격한 격리 수준이다.
- 트랜잭션들이 순차적으로 실행되는 것과 비슷한 효과를 낸다.
- Dirty Read, Non-Repeatable Read, Phantom Read가 모두 발생하지 않는다.
- 동시성 성능이 크게 떨어지기 때문에 일반적인 웹 서비스에서는 거의 사용되지 않는다.
5장 전체 요약
- 트랜잭션은 “작업 단위 전체의 성공 / 실패”를 보장해 데이터 정합성을 지킨다.
- 잠금은 “여러 트랜잭션이 동시에 같은 데이터를 만질 때” 충돌을 조정한다.
- InnoDB는 레코드가 아니라 인덱스 엔트리를 잠그므로, 인덱스 설계가 잠금 범위와 동시성에 직결된다.
- 그래서 인덱스 설계는 단순 조회 성능 문제를 넘어, 잠금 · 데드락 · 격리 수준 동작까지 함께 고려해야 한다.
- MySQL의 기본 격리 수준인 REPEATABLE READ는 MVCC + 갭 락 + 넥스트 키 락을 활용해, 성능과 정합성을 모두 잡는 방향으로 설계되어 있다.
'책 > Real MySQL 8.0' 카테고리의 다른 글
| [Real MySQL 8.0] 10장. 실행 계획 (3) | 2026.01.11 |
|---|---|
| [Real MySQL 8.0] 9장. 옵티마이저와 힌트 (1) | 2025.12.30 |
| [Real MySQL 8.0] 6장. 데이터 압축 & 7장. 데이터 암호화 (2) | 2025.12.18 |
| [Real MySQL 8.0] 8장. 인덱스 (1) | 2025.12.09 |
| [Real MySQL 8.0] 4장. 아키텍처 (5) | 2025.11.21 |