home
Real MySQL 8.0

[5장 트랜잭션과 잠금] 트랜잭션과 격리 수준

트랜잭션이 보장하는 작업의 완전성, 잠금과의 차이, 그리고 격리 수준이 실제로 무엇을 공유하고 차단하는지 정리한다.

트랜잭션이란

트랜잭션은 작업의 완전성을 보장하는 기능이다. 하나의 논리적인 작업 셋을 모두 완벽하게 처리하거나, 처리하지 못할 경우에는 원 상태로 복구한다. 작업의 일부만 적용되는 현상(Partial Update)이 발생하지 않게 만드는 것이 핵심이다.

이 차이는 스토리지 엔진 수준에서 드러난다. 같은 구조의 테이블을 각각 MyISAM과 InnoDB로 만들어 비교하면 확인할 수 있다. 두 테이블 모두 fdpk를 기본 키로 두고, 미리 값 3을 하나 넣어 둔다.

CREATE TABLE tab_myisam (fdpk INT NOT NULL, PRIMARY KEY (fdpk)) ENGINE=MyISAM;
INSERT INTO tab_myisam (fdpk) VALUES (3);

CREATE TABLE tab_innodb (fdpk INT NOT NULL, PRIMARY KEY (fdpk)) ENGINE=InnoDB;
INSERT INTO tab_innodb (fdpk) VALUES (3);

이제 두 테이블에 (1), (2), (3)을 한 문장으로 INSERT한다. 마지막 값 3이 이미 존재하는 기본 키와 충돌하므로 두 경우 모두 오류가 발생한다.

INSERT INTO tab_myisam (fdpk) VALUES (1), (2), (3);
-- ERROR 1062 (23000): Duplicate entry '3' for key 'tab_myisam.PRIMARY'

INSERT INTO tab_innodb (fdpk) VALUES (5), (6), (3);
-- ERROR 1062 (23000): Duplicate entry '3' for key 'tab_innodb.PRIMARY'

오류 메시지는 같지만, 오류 이후 남은 데이터는 다르다. MyISAM은 충돌하기 전에 처리한 12를 그대로 남긴다. 즉 문장이 실패했는데도 절반이 적용된 상태가 된다.

SELECT * FROM tab_myisam;
-- +------+
-- | fdpk |
-- +------+
-- |    1 |
-- |    2 |
-- |    3 |
-- +------+

반면 InnoDB는 문장 전체를 하나의 단위로 처리해서, 마지막 값에서 실패하자 앞서 넣은 56까지 되돌린다. 처음에 넣어 둔 3만 남는다.

SELECT * FROM tab_innodb;
-- +------+
-- | fdpk |
-- +------+
-- |    3 |
-- +------+

운영 관점에서 이 차이는 곧 장애 복구 비용이다. Partial Update가 남으면 어디까지 처리됐는지 애플리케이션이 다시 추적해야 하고, 그 보정 로직 자체가 또 다른 버그의 원인이 된다. 트랜잭션은 이 "어중간한 상태"를 아예 만들지 않는 것으로 그 비용을 없앤다.

잠금과 트랜잭션은 다른 문제를 푼다

잠금(Lock)과 트랜잭션은 자주 함께 등장해서 비슷한 개념처럼 보이지만, 해결하는 문제가 다르다.

  • 잠금: 동시성을 제어하기 위한 기능. 여러 커넥션이 같은 자원을 동시에 변경하려 할 때 순서를 강제해서 충돌을 막는다.
  • 트랜잭션: 데이터의 정합성을 보장하기 위한 기능. 작업 셋을 전부 적용하거나 전부 되돌린다.

즉 잠금은 "동시에 접근하는 것"을 다루고, 트랜잭션은 "부분적으로 적용되는 것"을 다룬다. 둘은 목적이 다르지만 실제 구현에서는 맞물려 동작한다. 트랜잭션이 정합성을 지키기 위해 필요한 만큼 잠금을 잡고, 그 잠금을 언제 얼마나 잡느냐가 다음에 나오는 격리 수준으로 이어진다.

격리 수준

격리 수준(Isolation Level)은 하나의 트랜잭션 내에서, 또는 여러 트랜잭션 간에 작업 내용을 어떻게 공유하고 차단할지를 결정하는 레벨이다. 격리를 강하게 걸수록 트랜잭션끼리 서로의 중간 상태를 볼 수 없게 되고, 그만큼 동시 처리 성능은 떨어진다. 정합성과 동시성 사이의 트레이드오프를 조절하는 다이얼이라고 보면 된다.

표준 SQL은 네 가지 격리 수준을 정의한다. 아래로 갈수록 격리가 강해진다.

격리 수준Dirty ReadNon-Repeatable ReadPhantom Read
READ UNCOMMITTED발생발생발생
READ COMMITTED없음발생발생
REPEATABLE READ없음없음발생 (InnoDB는 대부분 방지)
SERIALIZABLE없음없음없음

각 격리 수준이 허용하는 부정합은 세 가지 현상으로 설명된다.

  • Dirty Read: 아직 커밋되지 않은, 즉 롤백될 수도 있는 다른 트랜잭션의 변경을 읽는 것. 읽은 값이 실제로는 존재한 적 없는 값일 수 있다.
  • Non-Repeatable Read: 한 트랜잭션 안에서 같은 행을 두 번 조회했는데, 그 사이 다른 트랜잭션이 커밋해서 두 결과가 달라지는 것.
  • Phantom Read: 한 트랜잭션 안에서 같은 조건으로 여러 행을 조회했는데, 그 사이 다른 트랜잭션이 행을 추가·삭제해서 결과 집합의 행 수가 달라지는 것.

READ UNCOMMITTED는 커밋되지 않은, 즉 언제든 롤백될 수 있는 데이터까지 그대로 보여준다(Dirty Read). 읽은 값이 실제로는 존재한 적 없는 값일 수 있으므로, 그 값을 근거로 내린 판단은 언제든 틀릴 수 있다. 그래서 정합성이 조금이라도 중요한 곳에서는 쓰지 않는다. 예외적으로 선택하는 경우는 정확도보다 "막히지 않는 것"과 대략적인 수치가 더 중요할 때다. 대용량 배치의 진행 상황을 실시간으로 엿보거나, 바쁜 OLTP 테이블에 리포트성 조회를 걸면서 쓰기 트랜잭션을 잠금으로 막고 싶지 않을 때, 어느 정도의 부정확을 감수하고 쓰기도 한다.

SERIALIZABLE은 모든 부정합을 막는 대신, 단순 SELECT까지 잠금을 동반하는 읽기로 바꾼다. InnoDB에서는 일반 조회에도 공유 넥스트 키 락을 걸어 읽기가 쓰기를 막고 쓰기가 읽기를 막으므로, 동시성이 급격히 떨어지고 잠금 대기·데드락 위험이 커진다. 대부분의 서비스에는 과하다. 선택적으로 쓰는 경우는 정확성이 처리량보다 절대적으로 중요하면서 동시 접근이 적은 구간, 또는 REPEATABLE READ의 갭 락으로도 막지 못하는 특정 부정합(쓰기 스큐 등)을 애플리케이션 레벨에서 해결하기 어려울 때다. 이때도 보통 전체 설정을 바꾸지 않고 그 트랜잭션에만 한정해서 격리 수준을 올린다.

이렇게 양 끝의 두 수준은 특정 상황에서만 꺼내 쓰는 도구에 가깝고, 상시 기본값으로 두는 것은 READ COMMITTED와 REPEATABLE READ 두 가지다.

두 수준이 실무 기본값으로 살아남은 이유는 스냅샷을 잡는 범위가 다르기 때문이다. READ COMMITTED는 문장 단위로 스냅샷을 새로 잡는다. 같은 트랜잭션 안이라도 SELECT를 실행할 때마다 그 시점에 커밋된 최신 데이터를 읽는다. 그래서 다른 트랜잭션이 중간에 커밋한 변경이 곧바로 보이고, 같은 행을 두 번 읽으면 값이 달라질 수 있다(Non-Repeatable Read). 대신 오래된 버전을 오래 붙잡아 둘 필요가 없어 undo 유지 부담과 잠금 경합이 작다. 짧고 서로 독립적인 쿼리가 대부분인 일반적인 웹 트래픽에는 이 특성이 잘 맞아서, 많은 RDBMS가 이를 기본값으로 둔다.

REPEATABLE READ는 트랜잭션 단위로 스냅샷을 고정한다. 트랜잭션이 시작된 시점의 상태를 끝날 때까지 동일하게 읽으므로, 같은 조회를 몇 번 반복해도 결과가 변하지 않는다. 한 트랜잭션 안에서 여러 값을 읽고 그 값들 사이의 정합성이 중요한 작업 — 집계, 정산, 리포트 — 에 유리하다. 대신 트랜잭션이 열려 있는 동안 그 스냅샷을 계속 유지해야 하므로 비용이 더 든다.

그래서 둘 중 무엇을 고를지는 "한 트랜잭션 안에서 읽기 일관성을 얼마나 강하게 보장할 것인가"와 "그 대가로 감수할 undo·잠금 비용" 사이의 절충으로 정해진다. 양 끝인 READ UNCOMMITTED와 SERIALIZABLE은 이 절충에서 한쪽으로 지나치게 치우쳐 있어 실무 기본값이 되지 못한다.

MySQL에서의 기본 격리 수준

InnoDB의 기본 격리 수준은 REPEATABLE READ다. Oracle 등이 READ COMMITTED를 기본으로 쓰는 것과 대비된다. MySQL에서 격리 수준을 따로 지정하지 않으면, 트랜잭션이 시작된 시점의 상태를 기준으로 읽어 같은 조회를 반복해도 결과가 바뀌지 않는 것이 기본으로 적용된다.

댓글

아직 댓글이 없습니다.