[Real MySQL1] MySQL의 트랜잭션과 잠금

MySQL의 트랜잭션과 잠금 — 왜 이 둘을 함께 이해해야 할까?

처음에는 트랜잭션과 잠금이 같은 개념인 줄 알았습니다. 둘 다 "데이터를 안전하게 지키는 것" 아닌가 싶었습니다.
하지만 공부할수록 이 둘은 목적 자체가 다르다는 걸 알게 되었습니다.
트랜잭션은 정합성을 위한 것이고, 잠금은 동시성을 제어하기 위한 것입니다.


1. 트랜잭션 — 전부 성공하거나, 전부 실패하거나

트랜잭션이 필요한 이유는 단순합니다. 여러 쿼리를 하나의 작업 단위로 묶어야 할 때가 있기 때문입니다.

예를 들어, 은행 이체를 생각해 보겠습니다. A 계좌에서 금액을 차감하고, B 계좌에 동일한 금액을 추가해야 합니다. 만약 차감은 성공했는데 추가 도중 오류가 발생한다면 어떻게 될까요? A의 돈은 사라졌는데 B에는 들어오지 않는 상황이 됩니다. 트랜잭션은 이런 상황을 막기 위해 존재합니다.

MyISAM vs InnoDB

두 스토리지 엔진의 차이를 보면 트랜잭션이 왜 중요한지 더 명확해집니다.

MyISAM (트랜잭션 미지원)은 쿼리 중간에 오류가 발생해도 오류 이전까지 실행된 쿼리는 그대로 DB에 반영됩니다. 이를 부분 업데이트(Partial Update) 라고 합니다. 결국 개발자가 직접 "이미 반영된 데이터를 원상복구"하는 재처리 로직을 if-else 분기로 작성해야 합니다. 작업이 복잡할수록 이 코드는 걷잡을 수 없이 늘어납니다.

InnoDB (트랜잭션 지원)는 다릅니다. 여러 쿼리를 하나의 트랜잭션으로 묶으면, 모두 성공했을 때만 COMMIT하고 중간에 하나라도 실패하면 ROLLBACK으로 전체를 되돌립니다. 복구 로직을 따로 작성할 필요 없이, DB가 일관성을 보장해 줍니다.

START TRANSACTION;

UPDATE accounts SET balance = balance - 10000 WHERE id = 'A';
UPDATE accounts SET balance = balance + 10000 WHERE id = 'B';

COMMIT; -- 둘 다 성공했을 때만 반영
-- 중간에 오류 발생 시 ROLLBACK → 전체 원상복구

트랜잭션 사용 시 주의사항

트랜잭션을 지원한다고 해서 무분별하게 범위를 넓히면 오히려 문제가 생깁니다.

핵심 원칙은 트랜잭션 범위를 최소화하는 것입니다.

DB 커넥션은 제한된 자원입니다. 트랜잭션이 열려 있는 동안 커넥션이 점유되므로, 범위가 길어질수록 다른 요청들이 사용할 수 있는 커넥션이 줄어들고 대기 시간이 늘어납니다.

특히 주의해야 할 것이 트랜잭션 안에서 외부 네트워크 통신을 하는 경우입니다. 외부 API 호출이나 메일 발송처럼 응답 시간을 예측하기 어려운 작업이 트랜잭션 범위에 포함되면, 외부 통신이 지연되거나 실패할 때 DB 커넥션도 함께 오랫동안 묶이게 됩니다.

❌ 나쁜 예
트랜잭션 시작
  → DB 조회
  → 외부 API 호출 (응답 지연 발생)
  → DB 변경
트랜잭션 종료

✅ 좋은 예
외부 API 호출 (트랜잭션 밖)
트랜잭션 시작
  → DB 조회
  → DB 변경
트랜잭션 종료

또한, 단순 SELECT만 하는 경우에는 트랜잭션이 꼭 필요하지 않습니다. 실제로 데이터를 변경하는 작업만 트랜잭션으로 묶는 것이 이상적입니다. 서로 다른 성격의 독립적인 작업이라면 트랜잭션을 분리하는 것도 좋은 방법입니다.


2. MySQL 엔진의 잠금

MySQL의 잠금은 두 계층으로 나뉩니다.

  • MySQL 엔진 레벨 잠금: 서버 전체에 영향을 주며, 모든 스토리지 엔진에 적용됩니다.
  • 스토리지 엔진 레벨 잠금: 각 스토리지 엔진이 자체적으로 관리하며, 엔진 간 상호 영향이 없습니다.

이번 챕터에서는 MySQL 엔진 레벨 잠금의 종류를 살펴보겠습니다.

글로벌 락

MySQL에서 제공하는 잠금 중 범위가 가장 넓습니다. 한 번 걸리면 MySQL 서버 전체에 영향을 미칩니다.

주로 mysqldump로 여러 DB의 MyISAM / MEMORY 테이블을 백업할 때 사용합니다. 백업 도중 데이터가 바뀌면 백업 일관성이 깨지기 때문에, 모든 테이블을 같은 시점 기준으로 백업하기 위해 변경을 막는 것입니다.

다만 InnoDB처럼 MVCC(Multi Version Concurrency Control) 를 지원하는 엔진은 이야기가 다릅니다. MVCC는 하나의 레코드에 대해 여러 버전을 유지하는 방식으로, 다른 트랜잭션이 변경 중인 레코드도 이전 커밋 버전을 통해 일관된 읽기(스냅샷 읽기)가 가능합니다. 그래서 InnoDB는 글로벌 락 없이도 일관된 백업이 가능합니다.

대신 InnoDB를 사용하는 환경에서는 백업 중 DDL(ALTER, DROP 등)이 실행될 경우 테이블 구조가 바뀌어 백업이 실패할 수 있으므로, 이를 막기 위한 Backup Lock을 별도로 제공합니다.

테이블 락

개별 테이블 단위로 설정되는 잠금입니다. 명시적으로 걸 수도 있고, 묵시적으로 걸리기도 합니다.

  • MyISAM / MEMORY: 데이터를 변경하는 쿼리 실행 시 테이블 전체에 묵시적으로 락이 걸립니다.
  • InnoDB: 레코드(row) 단위 잠금을 사용하므로 단순 데이터 변경 쿼리에서는 테이블 락이 발생하지 않습니다. 단, ALTER TABLE처럼 테이블 구조를 변경하는 DDL에서는 테이블 수준 잠금이 발생합니다.

네임드 락

테이블이나 레코드가 아닌, 사용자가 지정한 문자열을 기준으로 거는 락입니다.

여러 클라이언트가 같은 데이터를 동시에 처리해야 할 때, 동기화 수단으로 활용됩니다. 예를 들어 여러 조건을 확인하면서 많은 레코드를 수정하는 복잡한 트랜잭션을 같은 그룹으로 묶고, 네임드 락 획득 → 쿼리 실행 → 락 해제 순서로 처리하면, 같은 그룹의 작업이 동시에 실행되지 않아 데드락을 방지하기 쉬워집니다.

SELECT GET_LOCK('my_lock', 10);  -- 락 획득 (최대 10초 대기)
-- ... 작업 실행 ...
SELECT RELEASE_LOCK('my_lock'); -- 락 해제

메타데이터 락

테이블, 뷰 등의 이름이나 구조 같은 메타정보가 변경될 때 자동으로 획득되는 잠금입니다. RENAME TABLE을 실행하면 기존 이름과 새 이름 양쪽에 대한 락을 동시에 획득하여 처리합니다. 이름 변경 도중 다른 쿼리가 잘못된 상태를 보지 않도록 보호하는 역할을 합니다.

 

서비스 중 테이블 구조를 변경해야 한다면?

ALTER TABLE처럼 시간이 오래 걸리는 DDL을 서비스 운영 중에 직접 실행하면 테이블 전체가 오랫동안 잠깁니다. 이를 피하기 위해 다음과 같은 방식을 사용할 수 있습니다.

  1. 새 구조의 테이블을 따로 생성합니다. (access_log_new)
  2. 기존 테이블의 데이터를 여러 스레드로 미리 최대한 많이 복사합니다.
  3. 복사 중에도 서비스의 INSERT는 계속 발생하므로, 마지막에 잠깐 테이블을 잠급니다.
  4. 그 사이에 누락된 최근 데이터만 추가로 복사합니다.
  5. RENAME TABLE로 두 테이블을 교체합니다. (메타데이터 락 활용)
  6. 락 해제 후 기존 테이블을 삭제합니다.

대부분의 데이터는 미리 복사하고, 마지막 교체 순간에만 잠깐 락을 사용하는 것이 핵심입니다. 서비스 영향을 최소화하면서 구조를 변경할 수 있는 실용적인 패턴입니다.


3. InnoDB 스토리지 엔진 잠금

InnoDB는 MySQL 엔진 잠금과 별도로, 스토리지 엔진 내부에서 레코드 기반 잠금을 독자적으로 관리합니다. 덕분에 MyISAM보다 훨씬 세밀한 동시성 제어가 가능합니다.

다만 InnoDB 내부 잠금은 일반 MySQL 명령으로 파악하기 어렵습니다. SHOW ENGINE INNODB STATUS나 performance_schema, information_schema를 통해 트랜잭션, 잠금, 대기 관계를 조회할 수 있습니다.

잠금의 종류

잠금 설명

레코드 락 실제 존재하는 인덱스 레코드 하나를 잠금
갭 락 (Gap Lock) 레코드 사이의 빈 공간을 잠가 새로운 레코드 INSERT를 막음
넥스트 키 락 (Next-Key Lock) 레코드 락 + 갭 락의 결합. 범위 조회 시 INSERT 방지

PK나 유니크 인덱스로 정확히 한 건만 조회하면 레코드 락만 걸리지만, 범위 조회에서는 갭 락과 넥스트 키 락이 함께 사용됩니다.

AUTO_INCREMENT 락

여러 세션이 동시에 INSERT를 실행할 때 AUTO_INCREMENT 값이 중복되지 않도록 InnoDB가 내부적으로 사용하는 잠금입니다. 값을 배정하는 아주 짧은 순간에만 동작하며, INSERT와 REPLACE에서만 필요합니다.

중요한 점은 유일성 보장이 핵심이라는 것입니다. 완전한 연속성은 항상 보장되지 않을 수 있습니다. 예를 들어 INSERT가 실패해도 한 번 증가한 AUTO_INCREMENT 값은 되돌아가지 않습니다.

innodb_autoinc_lock_mode 설정으로 잠금 방식과 성능, 연속성 사이의 균형을 조정할 수 있습니다. MySQL 8.0부터는 ROW 기반 바이너리 로그가 기본이므로 innodb_autoinc_lock_mode=2가 기본값입니다.

인덱스와 잠금의 관계

InnoDB의 잠금에서 가장 중요하면서도 흔히 놓치기 쉬운 부분입니다.

InnoDB에서는 실제로 변경되는 레코드 수가 아니라, 검색 과정에서 인덱스를 얼마나 넓게 스캔했는지가 잠금 범위를 결정합니다.

 

InnoDB는 레코드 자체가 아닌, 검색에 사용한 인덱스 레코드를 기준으로 잠금을 겁니다.

예를 들어 employees 테이블에 first_name 인덱스만 있고, 다음 쿼리를 실행한다고 가정해 보겠습니다.

UPDATE employees
SET hire_date = NOW()
WHERE first_name = 'Georgi' AND last_name = 'Klassen';

실제 변경되는 레코드는 1건입니다. 하지만 last_name에 인덱스가 없으므로 MySQL은 first_name = 'Georgi'인 253건을 모두 인덱스로 스캔한 뒤 그중에서 last_name = 'Klassen'인 1건을 골라냅니다. 이 과정에서 253건 모두에 락이 걸립니다.

이것이 인덱스 설계가 단순한 조회 성능을 넘어 동시성 제어에도 직접적인 영향을 미치는 이유입니다. 조건절에 맞는 인덱스가 없으면 불필요하게 많은 레코드가 잠기고, 인덱스가 아예 없으면 풀스캔 과정에서 사실상 테이블 전체가 잠기는 상황까지 갈 수 있습니다.


4. 트랜잭션 격리 수준

여러 트랜잭션이 동시에 실행될 때, 한 트랜잭션이 다른 트랜잭션의 변경 내용을 어디까지 볼 수 있게 할지를 정하는 기준입니다.

격리 수준이 높을수록 데이터 정합성은 올라가지만, 동시 처리 성능은 낮아집니다. 상황에 맞는 수준을 선택하는 것이 중요합니다.

격리 수준을 이해하기 위해 먼저 발생할 수 있는 문제 유형을 알아야 합니다.

문제 의미

Dirty Read 커밋되지 않은 값을 읽음
Non-repeatable Read 같은 행을 다시 읽었을 때 값이 바뀜
Phantom Read 같은 조건으로 다시 조회했을 때 행의 수가 달라짐

1. READ UNCOMMITTED (가장 낮음)

다른 트랜잭션이 아직 커밋하지 않은 값도 읽을 수 있습니다. 상대 트랜잭션이 나중에 롤백하더라도 이미 그 값을 읽어버린 상태가 됩니다. Dirty Read가 발생하기 때문에 실무에서 거의 사용되지 않습니다.

 

2. READ COMMITTED

커밋된 값만 읽습니다. Dirty Read가 발생하지 않으며, Oracle의 기본 격리 수준입니다.

이 수준부터 MVCC가 활용됩니다. 다른 트랜잭션이 변경 중인 레코드를 조회하면, 변경 전 값을 언두 로그(Undo Log)에서 읽어옵니다. 덕분에 커밋 전 데이터를 보지 않아도 됩니다.

그러나 같은 트랜잭션 안에서 동일한 행을 두 번 조회하면 값이 달라질 수 있습니다. 첫 번째 조회 후 다른 트랜잭션이 해당 데이터를 변경하고 커밋하면, 두 번째 조회에서는 새로운 값이 보이기 때문입니다. (Non-repeatable Read)

 

3. REPEATABLE READ

MySQL InnoDB의 기본 격리 수준입니다. 한 트랜잭션 안에서 같은 행을 여러 번 조회해도 항상 같은 결과를 보장합니다.

READ COMMITTED와 마찬가지로 MVCC를 사용하지만, 차이는 어느 시점의 버전을 기준으로 삼느냐에 있습니다.

  • READ COMMITTED: 조회할 때마다 그 시점의 최신 커밋 버전을 읽습니다.
  • REPEATABLE READ: 트랜잭션이 시작된 시점의 버전을 계속 유지해서 읽습니다.

이 덕분에 같은 트랜잭션 안에서는 다른 트랜잭션이 데이터를 변경하고 커밋해도 영향을 받지 않습니다.

일반적으로 이 수준에서는 Phantom Read(범위 조회 시 결과 행 수가 달라지는 현상) 가 발생할 수 있습니다. 그런데 InnoDB는 REPEATABLE READ 수준에서도 Phantom Read가 발생하지 않습니다. 갭 락과 넥스트 키 락 덕분입니다.

넥스트 키 락은 레코드 락과 갭 락을 결합해, 범위 조회 시 해당 범위 안에 새로운 레코드가 INSERT되는 것을 막습니다. 이를 통해 같은 범위 조회를 반복해도 결과 행의 수가 달라지는 현상을 방지합니다.

4. SERIALIZABLE (가장 높음)

가장 엄격한 수준입니다. 한 트랜잭션에서 읽고 쓰는 레코드를 다른 트랜잭션에서 절대 접근할 수 없습니다. 심지어 읽기 작업에도 읽기 잠금을 획득해야 합니다.

Phantom Read를 포함한 모든 부정합 현상이 발생하지 않지만, 동시 처리 성능은 가장 불리합니다. InnoDB에서는 갭 락과 넥스트 키 락으로 REPEATABLE READ 수준에서도 Phantom Read를 방지하므로, 사실상 SERIALIZABLE을 사용할 필요성이 크게 줄어듭니다.


정리

격리 수준 Dirty Read Non-repeatable Read Phantom Read
READ UNCOMMITTED 발생 발생 발생
READ COMMITTED 없음 발생 발생
REPEATABLE READ 없음 없음 발생 (InnoDB는 없음)
SERIALIZABLE 없음 없음 없음

 

트랜잭션은 정합성을 위해, 잠금은 동시성을 위해 존재합니다. 그리고 격리 수준은 그 둘 사이의 균형을 어디에 둘지 결정하는 기준입니다. 세 가지가 어떻게 연결되는지 이해하면, 실제 서비스에서 발생하는 데이터 문제의 원인을 훨씬 빠르게 파악할 수 있습니다.

 


마치며

트랜잭션과 잠금을 처음 공부할 때는 단순히 "이런 게 있다" 수준으로 넘어가기 쉽습니다. 그런데 각 개념이 왜 필요한지, 어떤 문제를 해결하기 위해 설계되었는지를 이해하고 나니 훨씬 입체적으로 보이기 시작했습니다.

특히 "인덱스가 잠금 범위를 결정한다"는 사실은 인덱스를 조회 성능 도구로만 생각했던 시각을 완전히 바꿔놓았습니다. 인덱스 설계가 곧 동시성 설계라는 것, 그것이 이번 공부에서 가장 인상 깊었던 부분이었습니다.