Home 트랜잭션과 잠금
Post
Cancel

트랜잭션과 잠금

트랜잭션과 락

트랜잭션은 작업의 완전성을 보장해주는 것이다. 작업을 모두 완벽하게 처리하거나, 처리하지 않고 원 상태로 복구하거나

트랜잭션은 유사해 보이지만 다르다.

락은 동시성제어하기 위한 기능이고,

트랜잭션은 데이터의 정합성보장하기 위한 기능이다.

락이 없다면 어떻게 되는걸까?

하나의 회원 정보 레코드여러 커넥션에서 동시에 변경할 때

락이 없다면 여러 커넥션에서 동시에 변경하게 된다. 따라서 데이터가 어떻게 변경되었는지 각 커넥션 마다 보장할 수 없다.

락은 여러 커넥션에서 동시에 동일한 레코드, 테이블을 요청할 경우 순서대로 변경할 수 있게끔 도와준다.

그리고 격리 수준이란, 하나의 트랜잭션 내에서 작업 내용을 공유/차단할 것인지 결정하는 레벨을 의미한다.


InnoDB와 MyISAM의 트랜잭션 차이점

AUTO_COMMIT을 사용한다고 가정했을 때

MyISAM 엔진은 데이터 변경 중 오류가 발생했지만 데이터가 롤백되지 않아 그대로 테이블에 오류가 발생한 데이터가 남아있게 된다. (Partial Update 발생!)

InnoDB는 롤백이 수행되어 테이블에 해당 데이터가 남아있지 않게 된다.

Memory 스토리지 엔진을 사용하는 테이블도 MyISAM과 같은 결과를 가진다. (Partial Update 발생!)


트랜잭션은 범위를 최소화하자

트랜잭션 범위 최소화 전

  1. 처리시작
    1. Connection 생성
    2. Tx 시작
  2. 사용자의 로그인 여부 확인
  3. 사용자의 글쓰기 내용의 오류 여부 확인
  4. 첨부로 업로드된 파일 확인 및 저장
  5. 사용자의 입력 내용을 DBMS에 저장
  6. 첨부 파일 정보를 DBMS에 저장
  7. 저장된 내용 또는 기타 정보를 DBMS에서 조회
  8. 게시물 등록에 대한 알림 메일 발송
  9. 알림 메일 발송 이력을 DBMS에 저장
    1. Tx 종료 (Commit)
    2. Connection 반납
  10. 완료

최소화 이후 흐름

  1. 처리시작
  2. 사용자의 로그인 여부 확인
  3. 사용자의 글쓰기 내용의 오류 여부 확인
  4. 첨부로 업로드된 파일 확인 및 저장
    1. Connection 생성
    2. Tx 시작
  5. 사용자의 입력 내용을 DBMS에 저장
  6. 첨부 파일 정보를 DBMS에 저장
    1. Tx 종료 (Commit)
  7. 저장된 내용 또는 기타 정보를 DBMS에서 조회
  8. 게시물 등록에 대한 알림 메일 발송
    1. Tx 시작
  9. 알림 메일 발송 이력을 DBMS에 저장
    1. Tx 종료 (Commit)
    2. Connection 반납
  10. 완료
  • 트랜잭션의 범위를 길게 잡아놓는다면 커넥션을 너무 오래 가지고 있다는 문제가 발생한다.

  • 또한 메일 전송 FTP 파일 전송 작업 등 네트워크를 통해 다른 서버와 통신하는 작업은 DBMS 트랜잭션과 엮이지 않는게 좋다. 프로그램이 실행되는 동안 외부 통신을 하는 과정에서 문제가 발생하면 DBMS까지 그 영향이 끼치게 되기 때문이다.


락의 종류

  • MySQL 엔진의 잠금
    • 글로벌 락
    • 테이블 락
    • 네임드 락
    • 메타데이터 락
  • 스토리지 엔진 레벨의 잠금
    • 레코드 락
    • 갭 락
    • 넥스트 키 락
    • 자동 증가 락
  • 인덱스 잠금

MySQL 엔진의 잠금

MySQL 서버의 스토리지 엔진을 제외한 나머지 부분을 가리키는 레벨이다.

글로벌 락

  • MySQL 서버 전체에 영향을 미치며 작업 대상 테이블/DB가 다르더라도 동일랗게 영향을 미친다.
  • 글로벌 락을 획득하면 SELECT를 제외한 대부분의 DDL, DML 문장의 경우 이 락이 해제될 때 까지 기다려야한다.

테이블 락

  • 테이블 단위로 설정되는 락이며 명시적/묵시적으로 사용 가능하다.
  • MyISAM 혹은 MEMORY 테이블에 데이터를 변경하는 쿼리를 실행하면 묵시적인 테이블 락이 걸린다.
  • 하지만, InnoDB테이블의 경우 스토리지 엔진 차원의 레코드 기반 잠금을 제공하기 때문에 묵시적 락이 걸리지 않는다고도 볼 수 있다.
  • 볼 수 있다는 말은, InnoDB 테이블에도 묵시적 락이 적용되긴 하지만 대부분 DML쿼리는 무시되고 DDL에만 적용되는 것이다.

네임드 락

  • 임의의 문자열에 대해 잠금을 설정한다.
  • DBMS가 여러 웹 서비스에 대응하면서 웹 서버가 어떤 정보를 동기화할 때 사용된다고 한다.

메타데이터 락

  • DB 객체(테이블, 뷰)등의 이름이나 구조를 변경하는 경우 획득하는 잠금이다.
  • 명시적으로 획득하는 락이 아닌 위 대상을 변경시 자동으로 획득하는 잠금이다.

스토리지 엔진 레벨

InnoDB 스토리지 엔진은 MySQL에서 제공하는 레벨의 잠금과는 별개의 락을 제공한다.

레코드 기반의 잠금 방식을 사용하여 MyISAM보다 훨씬 뛰어난 동시성 처리를 제공한다.

레코드 락

레코드 자체만을 잠근다. InnoDB 스토리지 엔진은 레코드 자체를 잠그는 것이 아닌 인덱스의 레코드를 잠근다.

갭 락

레코드 자체가 아니라 레코드와 바로 인접한 레코드 사이의 간격을 잠근다.

이 용도는, 특정 레코드와 특정 레코드 사이 간격에 INSERT가 되는 것을 제어한다.

넥스트 키 락

레코드 락과 갭 락을 합쳐놓은 형태의 락을 말한다.

자동 증가 락

AUTO_INCREMENT 컬럼이 사용된 테이블에 동시에 여러 레코드가 INSERT되는 경우 자동 증가 락이 적용되어

테이블 수준의 락을 걸어버린다. INSERT와 REPLACE와 같이 새로운 레코드를 저장하는 경우에만 락이 걸린다.


인덱스 잠금

레코드 락에서 레코드를 잠그는 것이 아닌 인덱스를 잠그는 것이라고 했다.

즉, 변경해야할 레코드를 찾기 위해 검색한 인덱스의 레코드를 모두 락을 거는 것이다.

만약 인덱스가 없다면 테이블을 풀 스캔하면서 UPDATE 작업을 수행하게 되는 것이다.

1
2
3
4
5
6
7
8
9
MySQL에서 스토리지 엔진이 인덱스 락을 거는 이유는 여러 가지가 있습니다.

첫째, 인덱스는 테이블에 비해 훨씬 작은 데이터 구조입니다. 따라서 인덱스 락을 걸면 레코드 락을 걸 경우보다 훨씬 더 적은 양의 데이터에 대해서만 락을 걸 수 있습니다. 이는 락 충돌의 가능성을 줄여주고, 시스템 전체적으로 더 효율적인 락 관리를 가능하게 합니다.

둘째, 인덱스는 데이터의 물리적인 위치와는 별개로 논리적인 구조를 제공합니다. 이는 인덱스를 사용하여 데이터를 검색하는 작업을 더 빠르고 효율적으로 수행할 수 있게 합니다. 그러나 이러한 이점을 제공하려면 인덱스의 무결성이 보장되어야 합니다. 락을 걸어 인덱스의 무결성을 유지할 수 있기 때문에 인덱스 락이 필요합니다.

셋째, 레코드를 걸면 인덱스에 대한 변경이나 새로운 인덱스 생성을 막을 수 있기 때문입니다. 반면 인덱스를 걸면 데이터 변경은 가능하지만, 해당 레코드에 대한 락이 없는 경우에도 인덱스를 검색할 수 있기 때문에 더 효율적입니다.

마지막으로, 인덱스가 없으면 테이블 풀 스캔을 통해 모든 레코드를 검색해야 하기 때문에 시스템 성능에 부담이 될 수 있습니다. 반면 인덱스를 락으로 걸면 해당 인덱스를 사용하는 쿼리만 락이 걸리고, 다른 쿼리는 영향을 받지 않습니다. 따라서 전반적으로 시스템 성능을 높일 수 있습니다.

격리 수준

격리 수준이란 여러 트랜잭션이 동시에 처리 될 때 특정 트랜잭션에서 다른 트랜잭션이 데이터를 조회/변경을 하는 것을 허용할지 말지 결정하는 것이다.

  • Read Uncommitted
  • Read Committed
  • Repeatable Read
  • Serializable

Read Uncommitted

  • Dirty Read 발생
  • Non-Repeatable Read 발생
  • Phantom Read 발생

이 격리 수준은 심각한 문제가 있다. 아래의 시나리오를 보자

  1. 유저 A가 mysql INSERT INTO user VALUES("KIM", 50000)이라는 쿼리를 보냈다.
  2. 유저 B가 SELECT * FROM USER WHERE id = 50000이라는 쿼리를 보냈다. (단, 위 유저 A가 보낸 쿼리는 커밋되지 않았다.)
    1. 유저 B는 이 내용을 가지고 추가적인 작업을한다.
  3. 유저 A의 INSERT 구문이 롤백되었다. 즉, 실제 테이블에 INSERT 되지 않은 채로 트랜잭션이 종료되었다.

위 상황이라면 B혼자 헛짓거리를 한게 되어버린다. 존재하지 않는 유저를 가지고 이것저것 처리를 한게 되었으니 말이다.

이처럼 어떤 트랜잭션에서 처리한 작업이 끝나지도 않았는데 다른 트랜잭션에서 볼 수 있는 현상을 Dirty Read라고 한다.

또한, Dirty Read는 Read Uncommitted 격리 수준에서만 발생하며 이 격리 수준은 RDBMS 표준에서도 눈길도 안주는 수준이다.

by GPT

1
2
이 격리 수준에서는 한 트랜잭션에서 변경 중인 데이터가 다른 트랜잭션에게도 보이게 됩니다. 
따라서 SELECT ... FOR UPDATE 구문이 사용되어도, 다른 트랜잭션은 해당 행을 읽을 수 있습니다.

Read Committed

  • Non-Repeatable Read 발생
  • Phantom Read 발생

이 격리 수준도 시나리오로 보자 이 수준은 꽤나 나쁘지 않다.

  1. 유저 A가 UPDATE user SET(first_name) = "SHIN" WHERE ID = 50000;이라는 쿼리를 보냈다.
  2. 유저 B가 SELECT * FROM USER WHERE id = 50000 이라는 쿼리를 보냈다. (단, 위 유저 A가 보낸 쿼리는 커밋되지 않았다.)
  3. 유저 A의 UPDATE 구문이 커밋되었다.

위 시나리오에서 B가 읽은 데이터는 무엇일까?

트랜잭션 내부를 까보면 데이터 변경이 있을 때 즉시 쓰기 방식이 아니라면 UNDO 로그를 남기게 되어있다.

즉, 유저 B가 읽은 데이터는 UNDO 로그에 남긴 UPDATE 쿼리가 적용되지 않는 이전의 데이터가 되는 것이다.

일단 트랜잭션이 끝난 뒤에야 읽을 수 있다고 했기 때문에 UNDO 영역에 백업된 레코드를 가져오는 것이 그 이유이다.

하지만 이 격리 수준도 문제가 있다. 다음 시나리오를 살펴보자

  1. 유저 A가 SELECT * FROM USER WHERE id = 50000 이라는 쿼리를 보냈다. 이 때 한 레코드가 조회가 되었고 트랜잭션은 종료되지 않았다.
  2. 유저 B가 UPDATE user SET(id) = 50000 WHERE ID = 50001; 이라는 쿼리를 보낸 후 커밋을 완료했다.
  3. 유저 A가 SELECT * FROM USER WHERE id = 50000 이라는 쿼리를 보냈다. 이 때 아무 레코드도 조회되지 않았다.

위 문제를 Repeatable Read 정합성에 어긋난다고 한다.

즉, 한 트랜잭션 안에서 똑같은 조회 쿼리를 날렸으나 다른 결과를 반환했기 때문이다.

별거 아닌 문제처럼 보이지만 Repeatable Read가 보장되지 않기 때문에 데이터의 정합성이 깨진 문제는 나중에 찾기도 힘들다.

1
2
3
이 격리 수준에서는 커밋된 데이터만 읽을 수 있습니다. 
따라서 SELECT ... FOR UPDATE 구문을 사용하면 해당 행을 선택하고 잠금을 설정하게 됩니다.
다른 트랜잭션이 해당 행을 선택하려고 하면, 해당 행이 잠겨있는 동안 대기하게 됩니다.

Repeatable Read

MySQL의 InnoDB 스토리지 엔진에서 기본으로 사용되는 격리 수준이다.

MySQL 서버는 바이너리 로그를 가졌기 때문에 이 격리 수준을 최소값으로 사용해야한다.

InnoDB 스토리지 엔진은 기본적으로 Rollback에 대비해 변경 전 레코드를 Undo 공간에 백업한 후 실제 레코드를 변경한다.

이 변경 방식을 MVCC 라고 하는데 앞서 보았던 Read Committed 방식도 이와 같은 방식이며 이 둘의 차이점Repeatable Read 방식은 Undo영역에 백업된 레코드의 여러 버전 중 어떤 것을 사용할 것이냐이다.

모든 InnoDB 트랜잭션은 고유한 Tx 번호를 가진다. Undo 영역에 백업된 레코드에는 Tx 번호를 가지고 있다.

시나리오로 Repeatable Read가 어떻게 동작하는지 보자

  1. 트랜잭션 번호 6번으로 테이블의 레코드가 2개 추가되었다.
  2. 사용자 B가 트랜잭션 번호 10으로 SELECT * FROM user WHERE id = 50000 쿼리를 날렸다.
  3. 사용자 A가 트랜잭션 번호 12UPDATE SET first_name = "SONG" WHERE id = 50000 쿼리를 날렸다.
    1. 이 때 Update 쿼리를 수행해서 레코드를 변경하기 전에, 기존 레코드 내용을 Undo 로그로 복사한다.
  4. 사용자 A가 날린 트랜잭션 12가 커밋 되었다.
  5. 사용자 B가 SELECT * FROM user WHERE id = 50000 쿼리를 또 날렸다.
  6. 이 때 결과는 Undo 로그에 저장된 아까 그 내용이 나온다.

이렇게 봐도 역시 Read Committed와 똑같아보인다.

결국 중요한건 누가 끼어들어서 Update Commit까지 마쳐도 그 전에 기록해둔 Undo로그를 읽어온다는 것이다.

일단 여기서도 Repeatable Read의 부정합이 발생할 수 있는 시나리오를 또 보자

  1. 트랜잭션 번호 6번으로 테이블의 레코드가 2개 추가되었다.
  2. 사용자 B가 트랜잭션 번호 10으로 SELECT * FROM user WHERE id >= 50000 FOR UPDATE 쿼리를 날렸다. (트랜잭션은 아직 살아있다.)
    1. 이 때 레코드 한 건이 조회가 되었다.
  3. 사용자 A가 트랜잭션 번호 12로 INSERT INTO user VALUES(50001, "KIM)을 날리고 커밋까지 완료했다.
  4. 사용자 B가 아까 진행중이던 트랜잭션 번호 내에서 또 똑같이 SELECT * FROM user WHERE id >= 50000 FOR UPDATE를 했다.
    1. 이번엔 레코드 두 건이 조회가 되었다.

마찬가지로 Repetable Read가 보장되지 않는 결과가 되어버렸다.

그리고 지금 처럼 다른 트랜잭션에서 수행한 변경으로 레코드가 보였다 안보였다 하는 현상을 Phantom Read라고 한다.

SELECT … FOR UPDATE 쿼리는 SELECT 하는 레코드에 쓰기 잠금을 걸어야하지만 Undo레코드에는 잠금을 걸 수 없다.

그래서 아까 정상적인 시나리오에서 봤던 것 처럼 Undo로그를 가져오는게 아니라 실제 레코드의 값을 가져오게 되는 것이다.

다시 정리하자면 다른 트랜잭션에 의해 변경이 있는 레코드일 경우 SELECT 시 Undo 로그를 읽어와야 하는데 실제 레코드를 읽어오는 Phantom Read 현상으로 인해 non-repeatable이 발생한 것이다.

InnoDB를 사용하면 스토리지 엔진에서 제공하는 Gap Lock, Next Key Lock 덕분에 Repeatable Read 수준에서도 Phantom Read가 발생하지 않는다.

1
2
3
이 격리 수준에서는 트랜잭션이 시작된 시점의 스냅샷을 사용하여 동일한 쿼리를 실행하더라도 항상 같은 결과를 보장합니다. 
SELECT ... FOR UPDATE 구문을 사용하면 해당 행에 대한 잠금을 설정하며 
다른 트랜잭션이 해당 행을 선택하려고 하면 잠금이 해제될 때까지 대기하게 됩니다.

Serializable

읽기 작업마저도 락을 걸어버리는 락의 최고봉 단계이다. 즉, 한 트랜잭션에서 읽고 쓰는 레코드는 다른 트랜잭션에서는 절대 접근할 수 없다.

1
2
3
4
이 격리 수준은 가장 엄격한 격리 수준으로, 동시성을 최소화하기 위해 강력한 잠금을 사용합니다. 
SELECT ... FOR UPDATE 구문을 사용하면 해당 행에 대한 잠금을 설정하며
다른 트랜잭션이 해당 행을 선택하려고 하면 잠금이 해제될 때까지 대기하게 됩니다. 
SERIALIZABLE 격리 수준은 다른 격리 수준보다 높은 수준의 데이터 일관성을 제공하지만, 동시성이 낮아질 수 있습니다.
This post is licensed under CC BY 4.0 by the author.