세미나 허브 결제서비스에 1000명이 동시에 세미나 신청한다면?

안녕하세요! 세미나허브 토이프로젝트를 진행하고 있는 PassionFruit200 입니다.

첫번쨰 글에 연속하여 두번쨰 글로 찾아뵙게 되었습니다!

 

첫번쨰 글에서는, 세미나 서비스 결제 기능의 기획을 정의하고, Spring Framework를 활용하여 DB 설계 과정에서 발생한 DB 비정규화에 대한 이야기, Spring AOP, Spring MVC에서의 생명주기 등등을 고려했던 부분을 나타내며 글을 작성했습니다.

 

이번 글에서는 트랜잭션의 격리 레벨 분석과 그 분석과정에서 필요했던 배타락의 필요성에 대한 이야기입니다  첫번째 기능에서 완성도를 붙이기 위한 작업이라고 볼 수 있습니다! 

사실상 이 부분부터가 더 중요한 내용이라고 생각됩니다.

 

글의 내용은 누구나 쉽게 이해할 수 있도록 이미지와 함께 글을 작성해보았으니 재밌게 읽어주시면 좋겠습니다!

목차

  1. 트랜잭션 본격적용준비
  2. Transaction Isolation 시나리오 선언과 실제 시나리오 테스팅
    1. Read UnCommitted :  Dirty Read 
      1. Scenario 시각화
      2. Scenario 테스트
    2. Read Committed  : Repeatable Read
      1. Scenario 시각화
      2. Scenario  테스트
    3. Repeatable Read : Phantom Read
      1. Scenario 시각화
      2. Scenario 테스트
    4. Serializable :
      1. 사용하지 않는 이유에 대하여
  3. 격리레벨에 배타락 걸기
  4. 마무리

본격적으로 트랜잭션 적용준비하기

지금까지 위에서 이번 트랜잭션을 적용하면서 왜 트랜잭션이 결제서비스에 필요한지, 트랜잭션과 MVCC가 무엇인지, 트랜잭션의 언두로그가 유지되는 기간, 트랜잭션의 최대양 및 언두로그 데이터 테이블 스페이스에 관하여 알아보았습니다.

이제는 본격적으로 각 격리레벨을 적용하여 올바르게 적용되는지 확인해볼 차례입니다.

 

기본적으로 각 격리레벨 수준에서 발생하는 부정합 문제 표입니다.

격리 수준 격리 강도 Dirty Read Non-Repeatable Read Phantom Read
Read Uncommited 낮다. O O O
Read Commited 중간. X O O
Repeatable Read 중간 X X O
Serializable 높다. X X X

적절한 격리레벨을 찾기 위해 4가지 격리 레벨 모두 시나리오를 만들어보고, 테스트를 진행해보겠습니다.

시작전 기본적인 정보

현재 제가 사용하는 DB버전은 Mysql 8.0.21 입니다.

mysql> select version();
+-----------+
| version() |
+-----------+
| 8.0.21    |
+-----------+
1 row in set (0.01 sec)

 

이제 트랜잭션을 바꾸면서 테스트해볼것이므로 아래의 명령어를 숙지합니다.

아래의 코드로 현재 세션의 트랜잭션 격리 레벨을 확인할 수 있습니다.

 현재 세션을 확인할경우
 SHOW VARIABLES LIKE '%isolation';  
 
 Mysql 8.0.21 에서 전체 설정을 확인할경우
 SELECT @@global.transaction_isolation;
 
 MariaDB 10.6 
 SELECT @@global.tx_isolation;

저는 전체 테이블 설정보다는 로컬테스트를 위해 트랜잭션 레벨을 현재 세션에서만 바꾸는 작업으로 진행합니다.

 

참고로 MariaDB 10.6 을 사용할 경우에는 Variable_name이 Mysql 8.0과 다른것을 알 수 있습니다.

 

Mysql 8.0.21 에서 현재 격리레벨 확인
MariaDB 10.6 에서 현재 격리 레벨 확인

 

아래에서도 추가로 다룰 것 이지만,  현재 세션의 격리레벨을 바꿀 수 있는 명령어입니다.

각 격리수준에서 사용할떄마다 커넥션에서 아래의 명령어를 실행합니다.

SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;

아래의 트랜잭션 시나리오 테스트를 위해 안다면 좋은것

1. 먼저 mysql 커넥션을 1개 추가로 더 킵니다.

그상태에서 현재 커넥션을 확인합니다.

mysql> show processlist;

현재 각 커넥션에서 어떤 작업을 진행하는 중인지 확인할 수 있습니다.

이렇게 두개의 트랜잭션을 키고서 하나의 커넥션으로는 서로 다른 커넥션에서 어떤 쿼리를 실행하고 있는지 확인하며 테스트 진행이 가능합니다.

1. 트랜잭션 격리 LEVEL 1 : READ_UNCOMMITTED 격리 수준에서 발생하는 Dirty Read 부작용 시나리오 작성

가장 낮은 격리 레벨인 READ_UNCOMMITED란 하나의 트랜잭션에서 아직 커밋되지 않은 값들도 읽을 수 있는 격리레벨입니다.

즉, 트랜잭션1에서 커밋되지 않은 값을 트랜잭션2가 읽을 수 있습니다.

만약, READ_UNCOMMITTED를 제 서비스에 적용시킨다면 어떻게 시나리오가 흘러갈까에 대한 시퀀스 다이어그램입니다.

  1. passionfruit200이 트랜잭션1을 시작하면서 일련의 과정을 거쳐 결제처리를 커밋 직전까지 완성했습니다.
  2. 트랜잭션2는 트랜잭션1이 커밋되기 전에 현재 Seminar의 500/500명이 모두 찬것을 보고 모든 세미나인원이 다 찼다는 UnChecked Exception, 즉 Runtime Exception SeminarRegistrationException을 발생시키며 트랜잭션을 롤백시킵니다.
  3. 하지만 트랜잭션1은 커밋 직전에 어떤 알지못하는 네트워크 오류로 갑자기 롤백되었습니다. 다시 500/500명에서 499/500명으로 줄어들게 되고, 사용자2는 참여할 수 있었는데도 참여하지 못하게 되었습니다.
  4. 이러한 사항들이 누적될경우 분명히 매출에 영향이 가고, 사용자 경험에 좋지 않습니다.
  5. 사실, READ_UNCOMMITTED의 Dirty Read 문제점은 많은 오류가 있습니다. 아래의 격리레벨을 더 다루면서 알아 보겠습니다.

1. 시나리오 테스트

아래의 흐름도가 진짜로 발생하는 상황인지 오류를 체크해볼려고 합니다.

DB 커넥션의 세션의 READ_UNCOMMITTED 격리 레벨로 수정합니다. ( 세션이므로 Global 설정은 바뀌지않습니다. )

SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

 

 

READ_UNCOMMITTED로 수정되었습니다.

 

테스트를 시작합니다. 빨간색 원은 순서를 의미합니다.

  1. 왼쪽 Transaction1 시작합니다.
  2. Transaction1에서 현재 세미나 정보를 확인해봅니다. 499명입니다.
  3. Transaction1에서 현재 세미나에 1명 추가합니다. 500/500명이 됩니다.
  4. 오른쪽 Transaction2에서 시작합니다.
  5. Transaction2에서 현재 세미나 정보를 확인해봅니다. 500명입니다. 
  6. 500명이므로 이미 모든 인원이 초과되었으므로 ROLLBACK 합니다.
  7. Transaction1에서 현재 세미나 정보를 확인해봅니다. 500명입니다. 
  8. 갑자기 Transaction1에서 네트워크 오류가 발생하여 롤백됩니다.

결론적으로 아무도 신청에 성공하지 못했습니다. 

이러한 서버 오류는 이후에 사용자가 확인했을떄 자리가 남았는데도 실패한것으로 보인다면 안좋은 영향을 주고 매출에도 영향을 줄 것 입니다.

예측한 시나리오와 일치합니다.

2. READ_COMMITTED 격리 수준에서 발생하는 REPEATABLE_READ 부작용 시나리오 작성.

READ_COMMITED란 트랜잭션에서 커밋된 값만 읽을 수 있는 격리레벨입니다.

REPEATABLE_READ 부작용이란 하나의 트랜잭션안에서 같은 쿼리가 다른 결과값을 조회하는 것 입니다.

아래 시퀀스 다이어그램에 대한 설명입니다.

  1. passionfruit200이 트랜잭션1을 시작하고, 현재 '스타크래프트 세미나'의 정보를 조회하여 499/500명인 것을 확인합니다.
  2. 트랜잭션1에서 참여를 위해 499 + 1 을 처리하여 500/500 으로 업데이트했습니다.
  3. 사용자2가 트랜잭션2를 시작합니다.
  4. 트랜잭션2이 현재 참여인원 499/500명을 반환받습니다.
  5. 트랜잭션1이 이 사이에 커밋을 진행했습니다.
  6. 트랜잭션2 또한 현재 참여인원 500/500명을 반환받으며 REPEATABLE_READ가 발생합니다.

6번 과정에서 하나의 쿼리 안에서 다른 결과값이 나오는 REPEATABLE_READ 부작용을 확인할 수 있습니다. 이러한 데이터 정합성 오류로 인하여 신청받을 수 있는 최대인원보다 더 많은 인원을 받게되어, 초과된 인원이 세미나에 등록됩니다.

2. 시나리오 테스트

현재 세션의 READ_UNCOMMITTED 격리 레벨로 수정합니다. ( 세션이므로 Global 설정은 바뀌지않습니다. )

MariaDB [seminar-hub]> SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
Query OK, 0 rows affected (0.000 sec)

MariaDB [seminar-hub]> SHOW VARIABLES LIKE '%isolation';
+---------------+----------------+
| Variable_name | Value          |
+---------------+----------------+
| tx_isolation  | READ-COMMITTED |
+---------------+----------------+
1 row in set (0.001 sec)

READ_UNCOMMITTED로 수정되었습니다.

  1. 왼쪽 Transaction1 시작합니다.
  2. Transaction1에서 현재 세미나 정보를 확인해봅니다. 499명입니다.
  3. 오른쪽 Transaction2에서 시작합니다.
  4. Transaction2에서 현재 세미나 정보를 확인해봅니다. 499명입니다. 
  5. Transaction1에서 현재 세미나에 1명 추가합니다. 500/500명이 됩니다.
  6. Transaction1이 커밋합니다.
  7. Transaction2에서 현재 세미나 정보를 확인해봅니다. 500명입니다.
  8. Transction2가 커밋됩니다.

하나의 Transaction2 안에서 같은 내용의 SELECT 쿼리가 다른 값을 받아옵니다. Repeatable_Read 부작용이 일어났습니다. 

Transaction2가 처음에 조회한 세미나의 

예측한 시나리오와 일치합니다.

3. REPEATABLE_READ 격리 수준

REPEATABLE READ 는 MySQL의 InnoDB 스토리지 엔진에서 기본으로 사용되는 격리 수준입니다. 해당 격리 수준에서는 READ COMMITTED 격리 수준에서 발생하는 "NON-REPEATABLE READ" 부정합이 발생되지 않습니다.  그대신에 "PHANTOM-READ" 부정합이 발생합니다. (아래 흐름도를 보면 알겠지만, 저희 로직에서는 "PHANTOM-READ"가 발생할 상황이 없었고, SELECT 시점으로 인한 다른 원인을 찾았습니다. )

  1. passionfruit200이 트랜잭션1을 시작하고, 현재 '스타크래프트 세미나'의 정보를 조회하여 499/500명인 것을 확인합니다.
  2. 사용자2가 트랜잭션2를 시작합니다.
  3. 트랜잭션2이 현재 참여인원 499/500명을 반환받습니다.
  4. 트랜잭션1에서 참여를 위해 499 + 1 을 처리하여 500/500 으로 업데이트했습니다.
  5. 트랜잭션2가 UPDATE 처리를 하려고하지만, REPEATABLE_READ 격리 수준에 의해 LOCK에 걸려 Waiting합니다. 운영체제 관점으로는 Blocking한다고 표현할 수 있습니다.
  6. 트랜잭션1이 모든 작업을 처리하고 커밋하는 순간, LOCK이 풀리면서 트랜잭션2가 이어서 작업을 진행합니다.
  7. 트랜잭션2는 현재 참여인원 501/500명을 반환받으며 초과 신청이 이루어지게 됩니다.

이렇게 테스트 시나리오를 작성하고나서 "PHANTOM-READ" 오류가 발생하는 것을 보고, 결제서비스 의 결제 API에서 REPEATABLE_READ의 격리레벨로도 해결할 수 없는건가? 라는 생각이 들었습니다. 이유는, 우리의 결제 API는 결제가 성공하여 "스타크래프트 세미나"에 등록되더라도 INSERT 하는것이 아닌 UPDATE 가 이루어지기 때문입니다. (1편의 비정규화 관련 파트를 보면, 왜 UPDATE가 이루어지는지 알 수 있습니다.) 그렇기에, "PHANTOM_READ" 는 애초에 발생하지 않습니다. 그렇다면, 이 에러상황은 어떤 부정합이기에 여전히 초과신청이 이루어지는 것일까요?

 

3-1. 시나리오 테스트

우선 시나리오 테스트를 진행한 후에 답을 써보겠습니다.

MariaDB [seminar-hub]> SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
Query OK, 0 rows affected (0.000 sec)

REPEATABLE READ 로 수정되었습니다.

 

 

REPEATABLE_READ에서는 Non-REPEATABLE_READ 부작용을 해결하기 위해, 즉 하나의 트랜잭션 안에서는 항상 같은 쿼리의 내용을 보장하기 위해 락을 제공합니다. 한 트랜잭션 내에서 특정 레코드에 대해서 수정작업이 이루어졌다면, 수정이 이루어지지 못하게 하는 쓰기락입니다.

  1. 왼쪽 Transaction1 시작합니다.
  2. Transaction1에서 현재 세미나 정보를 확인해봅니다. 499명입니다.
  3. 오른쪽 Transaction2에서 시작합니다.
  4. Transaction2에서 현재 세미나 정보를 확인해봅니다. 499명입니다. 
  5. Transaction1에서 현재 세미나에 1명 추가합니다. 500/500명이 됩니다.
  6. Transaction1이 올바르게 업데이트되었는지 조회합니다. 
  7. Transaction2에서 Update를 시도합니다. 현재 해당 레코드가 이미 트랜잭션1에서 점유하고 있는 레코드이기에 락이 걸립니다. 
  8. Transaction2에서 현재 세미나 정보를 확인해봅니다. 500명입니다.
  9. Transction2가 커밋됩니다.

3-2. REPEATABLE READ로 해결이 안되는 이유는 ?

이유는, SELECT 시점이 문제였습니다.

 

위의 시나리오 테스트에서도 보이듯이 

1. REPEATABLE READ 격리 수준에서는 는 중첩된 트랜잭션이 있는경우 한 트랜잭션이 수정하는 데이터는 다른 트랜잭션이 수정하거나 삭제할 수 없습니다.

즉, 현재 조회중인 데이터를 다른 트랜잭션이 "수정"하거나 "삭제"할수는 없을 경우에 이 격리수준이 의미가 있습니다.

결론 : 조회 순서에 의하여 이전 값을 여전히 사용하며 처리하지 못한다. 즉, 트랜잭션의 격리레벨과는 상관이 없는 문제였습니다. 

그렇다면 해결방법은 ?

READ LOCK을 걸어 해당 레코드에 읽기락(Read Lock)을 걸어주어야 합니다. 배타락(exclusive lock)이라고도 불립니다.

처음에 트랜잭션을 공부하고 진행하였을때는, 트랜잭션의 격리레벨로 해결할 수 있지 않을까라는 접근으로 생각했는데, READ LOCK을 통하여 결국은 일종의 SERIALIZABLE 처럼 작동시키는 것 밖에 해결방법이 없었습니다.

그렇다고, 트랜잭션의 격리레벨 중 SERIALIZABLE을 적용한다고 해도 SELECT 가 올바르게 작동할까요 ? 답은 아닙니다.

아래에서 테스트를 진행했습니다.

4. SERIALIZABLE 격리 수준

먼저 결과를 말씀드리자면, SERIALIZABLE 격리 수준을 사용하더라도 세미나 인원 부정합 문제를 해결할 수 없습니다.

 

처음에 SERIALIZABL의 의미인 직렬화라는 것을 알게되었을떄, 해당 API 내에서 트랜잭션이 완전히 SERIALIZE 되어 따로따로 실행되는 것이라고 이해했습니다. 즉, 모든 트랜잭션이 개별적으로 움직이는 것이라고 생각했습니다만, 더 공부해보면서, SERIALIZABLE은 배타 잠금(Exclusive Lock) 이 아닌 공유잠금(SHARED LOCK) 을 사용한다는 것을 알게되었습니다.  공유잠금(Shared Lock) 과 배타잠금(Exclusive Lock)의 이름만으로는 이해하기가 어렵습니다. 

 

공유 잠금(읽기 잠금)의 2가지 특성에 대해 이해하면 이해하는데 도움이 될 것 같습니다.

  1. 다중 동시 읽기 허용: 여러 트랜잭션이 동시에 동일한 데이터를 읽을 수 있습니다.
  2. 배타적 쓰기 방지: 읽기 잠금이 획득된 상태에서는 쓰기 잠금을 획득할 수 없습니다. 이로 인해 여러 트랜잭션이 동시에 읽기를 수행하면서도 쓰기 작업과의 충돌이 방지됩니다.

각 특성을 제 결제서비스와 연관지어 생각해보겠습니다.

첫번재 특성인  "다중 동시 읽기 허용" 특성에 의해, 여러 트랜잭션이 "스타크래프트 세미나"을 읽을 수 있습니다. 

두번째 특성인 "배타적 쓰기 방지" 특성에 의해,  한 트랜잭션에서 "스타크래프트 세미나"를 SELECT( 조회, 읽기 ) 하게 되면 베타잠금이 걸리기에, 다른 트랜잭션은 해당 레코드를 읽을 수는 있지만, 수정하지는 못합니다. 

 

즉, 그림으로 표현해보면 이런상황입니다.

하지만, 이러한 다중동시 읽기 허용에서 한 데이터를 여러 트랜잭션이 읽게 되면, 동시에 공유락을 얻게 되며 DeadLock 상황이 발생합니다. 

 

SERIALIZABLE의 공유잠금 사용시 발생할 수 있는 부분을 시나리오로 그려보았습니다.

  1. PassionFruit200이 트랜잭션1을 시작합니다.
  2. 사용자2가 트랜잭션2를 시작합니다.
  3. PassionFruit200이 "스타크래프트 세미나"를 조회하며, 해당 레코드에 대한 공유락을 획득합니다.
  4. 사용자2가 "스타크래프트 세미나"를 조회하며, 해당 레코드에 대한 공유락을 획득합니다.
  5. 트랜잭션 1이 "스타크래프트 세미나"를 업데이트하려고 하지만, "스타크래프트 세미나" 자원은 트랜잭션1과 트랜잭션2 둘다 에게 공유락이 걸려있기에, DeadLock이 발생합니다.
start transaction;

select * from seminar where seminar_name='스타크래프트 세미나';

update seminar set seminar_participants_cnt = seminar_participants_cnt + 1 where seminar_name='스타크래프트 세미나';

 

앞에서 말했듯, SERIALIZABLE에는 공유락(Shared Lock) 이 걸립니다. 처음에는 배타적 락이 걸릴 줄 알았으나, InnoDB의 스토리지 엔진에서는 "Non-Locking consistent read(잠금이 필요없는 일관된 읽기)" 라는 기본개념으로 트랜잭션을 제공하기에 최소한의 잠금으로 트랜잭션을 직렬화시킨것 같습니다. 

4. SERIALIZABLE 시나리오 테스트

우선 시나리오 테스트를 진행한 후에 답을 써보겠습니다.

mysql>  SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
Query OK, 0 rows affected (0.01 sec)

SERIALIZABLE 격리 수준이 완성되었습니다.

 

  1. 왼쪽 Transaction1 시작합니다.
  2. 오른쪽 Transaction2에서 시작합니다.
  3. Transaction1에서 현재 세미나 정보를 확인해봅니다. 499명입니다.
  4. Transaction2에서 현재 세미나 정보를 확인해봅니다. 499명입니다. 
  5. Transaction1에서 현재 세미나에 1명 추가합니다. 500/500명이 됩니다. 데드락이 발생합니다.

이 부분에서 Transaction2에서는 UPDATE 문을 하지도 않았는데? 라고 생각이 듭니다.

하지만, Transaction2에서 읽음으로써 공유잠금을 획득하고, 다른 곳에서 수정하지 못하도록 이미 잠금을 가지고 있는 상황인 것 입니다.

지금까지 트랜잭션의 격리레벨을 설정하더라도 SELECT의 조회시점을 조절하지 못한다면 해결할 수 없다는 것을 알게되었습니다.. 

5. 베타적 락(Exclusive Lock)을 활용하여 SELECT 조회시점을 해결해보자.

  1. PassionFruit200이 트랜잭션1을 시작합니다.
  2. 사용자2가 트랜잭션2를 시작합니다.
  3. PassionFruit200이 "스타크래프트 세미나"를 조회하며, 해당 레코드에 대한 배타락을 획득합니다.
  4. 사용자2가 "스타크래프트 세미나"를 조회하려하지만, 이미 해당 레코드에 배타락이 걸려있어 WAIT(대기)합니다.
  5. 트랜잭션1에서 참여를 위해 499 + 1 을 처리하여 500/500 으로 업데이트했습니다.
  6. 트랜잭션 1이 참여내역에 해당 정보를 삽입합니다. 그 외에도 결제금액 또한 저장합니다.
  7. 트랜잭션 1이 COMMIT 하며 세미나허브 DB에 반영합니다.
  8. 트랜잭션2가 대기하고 있었기에, 해당 레코드에 대한 배타락을 획득하고 작업을 이어나갑니다. 그림에서는, 500명 전체인원이 다 찼기에 중지시킵니다. 데이터 정합성 문제가 발생하지 않아, 초과 인원이 들어오지 않습니다.

5. 시나리오 테스트

  1. 왼쪽 Transaction1 시작합니다.
  2. 오른쪽 Transaction2에서 시작합니다.
  3. Transaction1에서 SELECT ... FOR UPDATE로 배타락을 획득하며 세미나 정보를 확인해봅니다. 499명입니다. 
  4. Transaction2에서 현재 세미나 정보를 확인해봅니다. 하지만 이미 Transaction1에서 해당 레코드에 대한 배타락을 획득하였기에 대기합니다.
  5. Transaction1에서 현재 세미나에 1명 추가합니다. 500/500명이 됩니다. 
  6. Transaction1에서 변경사항을 COMMIT하여, DB에 반영합니다.
  7. 11.82 sec 동안 대기했던 LOCK이 풀리면서, "스타크래프트 세미나"정보를 불러옵니다. 500/500 명이기에 초과입니다.
  8. RollBACK 시키며 Transaction2를 종료시킵니다.

6. 베타적 락(Exclusive Lock)과 알맞은 격리 레벨 알맞게 조합해서 사용해보기

이제 베타적 락(Exclusvie Lock)을 활용하여 세미나의 잔여 인원의 정합성을 올바르게 맞추었습니다.

이렇게 데이터 정합성 문제가 해결되었는데요. 트랜잭션 격리레벨은 무엇과 함꼐 조합하여 사용하면 좋을까요?

당연히 베타 락(Exclusive Lock)과 조합하며 데이터 정합성을 지킬 수 있는 격리 레벨을 선택하는 것이 좋을 것 입니다. 

 

위의 시나리오를 기반으로 결정하였을떄, READ_UNCOMMITTED를 사용해도 문제가 없다는 결론이 나왔습니다

그렇게 결정한 근거는, 배타적 락이 SELECT의 시점을 미뤄주기에 직렬화 시켜서 사용할 수 있기 때문입니다.

 

7. 실제로 배타락과 격리레벨을 코드에 적용시키기.

배타락은 조회시점을 조절하여 트랜잭션을 직렬 방식으로 바꿉니다.

격리레벨 READ_UNCOMMITTED를 사용해도 큰 문제가 없다고 판단되어 READ_UNCOMMITED를 적용합니다.

배타락을 코드로 적용시키는 부분은 Seminar를 호출하는 부분을 @Transaction 시작점 내에서 시작하도록 만들면 됩니다.

관련 코드를 테스트코드와 함께 작성해보았습니다.

 

7-1Repository Layer

7-1-1. [ com/seminarhub/repository/SeminarQuerydslRepository.java ] : 

@RequiredArgsConstructor
@Repository
public class SeminarQuerydslRepository {

    private final JPAQueryFactory queryFactory;

    public Optional<Seminar> findBySeminar_NameWithPessimisticLock(String seminar_name){
        QSeminar qSeminar = QSeminar.seminar;

        // 세미나 레코드를 PESSIMISTIC_WRITE 락으로 가져옵니다.
        Seminar seminarEntityLock = queryFactory.selectFrom(qSeminar)
                .where(qSeminar.seminar_name.eq(seminar_name)
                        .and(qSeminar.seminar.del_dt.isNull()))
                .setLockMode(LockModeType.PESSIMISTIC_WRITE) // 비관적 락 설정
                .fetchOne();
        return Optional.ofNullable(seminarEntityLock);
    }

}

7-1-2. [ com/seminarhub/repository/SeminarRepositoryTests.java ] : 테스트 코드

@SpringBootTest
public class SeminarRepositoryTests {
    @Autowired
    private SeminarRepository seminarRepository;

    @Autowired
    private SeminarQuerydslRepository seminarQuerydslRepository;
    
    private final String seminar_name= "SeminarTest";
    
    /**
     * [ 2024-03-09 passionfruit200 ]
     * Description :
     * Hibernate:
     *     select
     *         s1_0.seminar_no,
     *         s1_0.del_dt,
     *         s1_0.inst_dt,
     *         s1_0.seminar_explanation,
     *         s1_0.seminar_max_participants,
     *         s1_0.seminar_name,
     *         s1_0.seminar_participants_cnt,
     *         s1_0.seminar_price,
     *         s1_0.updt_dt
     *     from
     *         seminar s1_0
     *     where
     *         s1_0.seminar_name=?
     *         and s1_0.del_dt is null for update
     */
    @DisplayName("getBySeminar_NameWithPessimisticLock Test ")
    @Transactional
    @Test
    public void testGetBySeminar_NameWithPessimisticLock(){
        // given // when
        Optional<Seminar> seminar = seminarQuerydslRepository.findBySeminar_NameWithPessimisticLock("스타크래프트 세미나");

        // then
        assertNotNull(seminar.get());
    }

}

7-2 Service Layer

7-2. [ com/seminarhub/service/SeminarService.java ] : SeminarService 인터페이스 추가

public interface SeminarService {

	// ...
    
    SeminarDTO getWithPessimisticLock(String seminar_name);

	// ...
}

7-4. [ com/seminarhub/service/SeminarServiceImpl.java ] : SemianrService 인터페이스 구현부

@Service
@Log4j2
@RequiredArgsConstructor
public class SeminarServiceImpl implements  SeminarService{

    private final SeminarRepository seminarRepository;

    private final SeminarQuerydslRepository seminarQuerydslRepository;
    
    // ...
    // ...
    
    @Override
    public SeminarDTO getWithPessimisticLock(String seminar_name) {
        Optional<Seminar> result = seminarQuerydslRepository.findBySeminar_NameWithPessimisticLock(seminar_name);
        if(result.isPresent()){
            return entityToDTO(result.get());
        }
        return null;
    }
    
    // ...
    // ...

}

7-5. [ com/seminarhub/service/SeminarServiceTests.java ] : SeminarServiceTests 테스트 진행

//@SpringBootTest(classes = {SeminarServiceImpl.class, SeminarQuerydslRepository.class, LogAdvice.class})
@SpringBootTest
public class SeminarServiceTests {
    @MockBean
    private SeminarRepository seminarRepository;

    @MockBean
    private SeminarQuerydslRepository seminarQuerydslRepository;

    @Autowired
    private SeminarService seminarService;

    @DisplayName("Seminar Service getWithPessimisticLock Test")
    @Transactional
    @Test
    public void testGetWithPessimisticLockSeminar(){
        // Given
        Seminar existingSeminar = Seminar.builder()
                .seminar_no((long)123L)
                .seminar_name("SeminarTest")
                .seminar_explanation("SeminarExplanation")
                .build();
        Mockito.when(seminarQuerydslRepository.findBySeminar_NameWithPessimisticLock("SeminarTest")).thenReturn(Optional.of(existingSeminar));

        // when
        SeminarDTO seminarDTO = seminarService.getWithPessimisticLock("SeminarTest");

        // then
        Assertions.assertEquals(seminarDTO.getSeminar_no(), 123L);
        Assertions.assertEquals(seminarDTO.getSeminar_name(), "SeminarTest");
        Assertions.assertEquals(seminarDTO.getSeminar_explanation(), "SeminarExplanation");

        verify(seminarQuerydslRepository).findBySeminar_NameWithPessimisticLock("SeminarTest");
    }

}

7-6. [ com/seminarhub/service/Member_SeminarServiceImpl.java ] : Member_Seminar Service 구현

@Service
@Log4j2
@RequiredArgsConstructor
public class Member_SeminarServiceImpl implements Member_SeminarService{

    private final Member_SeminarRepository member_seminarRepository;

    private final Member_Seminar_Payment_HistoryRepository member_seminar_payment_historyRepository;

    private final SeminarQuerydslRepository seminarQuerydslRepository;

    private final SeminarService seminarService;
    private final MemberService memberService;

	// ...
    // ...
    
    @Transactional(isolation = Isolation.READ_UNCOMMITTED)
    @Override
    public Long registerForSeminar(MemberSeminarRegisterRequestDTO memberSeminarRegisterRequestDTO) throws SeminarRegistrationFullException {
        log.info(memberSeminarRegisterRequestDTO.toString());
        MemberDTO memberDTO = memberService.get(memberSeminarRegisterRequestDTO.getMember_id());
        SeminarDTO seminarDTO = seminarService.getWithPessimisticLock(memberSeminarRegisterRequestDTO.getSeminar_name());

        if (seminarDTO == null || memberDTO == null) {
            System.out.println(memberDTO.toString()+" "+seminarDTO.toString());
            // 예외 처리: 세미나나 멤버가 존재하지 않는 경우
            throw new SeminarRegistrationFullException("There are no Info Of Member || Seminar");
        }

        //신청하려는 Seminar가 아직 남아있는지 확인해야하고, Transactional로 격리상태를 유지해야합니다.
        Long currentParticipateCnt = seminarDTO.getSeminar_participants_cnt();
        if (seminarDTO.getSeminar_max_participants() <= (currentParticipateCnt)) {
            throw new SeminarRegistrationFullException("SeminarInfo:" + seminarDTO.getSeminar_name() + "is already " + currentParticipateCnt + "/" + seminarDTO.getSeminar_max_participants() + " full. Registration failed.");
        }
        //먼저 진행해야만, 다른 Repeatable_Read 격리 레벨에서 작동이 가능하다. (Repeatable_read에서는 현재 다른 트랜잭션이 조회하는 데이터는 수정하거나 삭제할 수 없기 때문이다.)
        seminarService.increaseParticipantsCnt(seminarDTO.getSeminar_no());

        Member_Seminar_Payment_History member_seminar_payment_history = Member_Seminar_Payment_History.builder()
                .member_seminar_payment_history_amount(seminarDTO.getSeminar_price())
                .build();
        member_seminar_payment_historyRepository.save(member_seminar_payment_history);

        Member_SeminarDTO member_seminarDTO = Member_SeminarDTO.builder()
                .member_no(memberDTO.getMember_no())
                .seminar_no(seminarDTO.getSeminar_no())
                .member_seminar_payment_history_no(member_seminar_payment_history.getMember_seminar_payment_history_no())
                .build();
        Member_Seminar member_seminar = dtoToEntity(member_seminarDTO);
        member_seminarRepository.save(member_seminar);
        return member_seminar.getMember_seminar_no();
    }

    // ...
    // ...
}

7-7. [ com/seminarhub/service/Member_SeminarServiceTests.java ] : 테스트 코드 구현

@SpringBootTest
public class Member_SeminarServiceTests {

    @MockBean
    private SeminarService seminarService;

    @MockBean
    private MemberService memberService;

    @MockBean
    private Member_SeminarRepository member_seminarRepository;

    @MockBean
    private SeminarQuerydslRepository seminarQuerydslRepository;

    @MockBean
    private Member_Seminar_Payment_HistoryRepository member_seminar_payment_historyRepository;


    @DisplayName("Member_Seminar Service RegisterForSeminar Test")
    @Test
    public void testRegisterForSeminars() throws SeminarRegistrationFullException {
        // given
        Member member = Member.builder()
                .member_id("passionfruit200@naver.com")
                .build();

        Seminar seminar = Seminar.builder()
                .seminar_name("SeminarTest")
                .build();

        MemberDTO memberDTO = MemberDTO.builder()
                .member_id("passionfruit200@naver.com")
                .build();

        SeminarDTO seminarDTO = SeminarDTO.builder()
                .seminar_name("SeminarTest")
                .seminar_no(3L)
                .seminar_max_participants(40L) //SET seminar_max_participants = 40;
                .seminar_participants_cnt(23L)
                .seminar_price(10000L)
                .build();

        Member_Seminar member_seminar = Member_Seminar.builder()
                .member_seminar_no(1L)
                .member(member)
                .seminar(seminar)
                .build();

        Member_Seminar_Payment_History member_seminar_payment_history = Member_Seminar_Payment_History.builder()
                .member_seminar_payment_history_no(4L)
                .member_seminar_payment_history_amount(seminarDTO.getSeminar_price())
                .build();

        MemberSeminarRegisterRequestDTO memberSeminarRegisterRequestDTO= MemberSeminarRegisterRequestDTO.builder()
                .member_id("passionfruit200@naver.com")
                .seminar_name("SeminarTest")
                .build();

        Mockito.when(memberService.get("passionfruit200@naver.com")).thenReturn(memberDTO);
        Mockito.when(seminarService.getWithPessimisticLock("SeminarTest")).thenReturn(seminarDTO);
        Mockito.when(member_seminar_payment_historyRepository.save(any(Member_Seminar_Payment_History.class))).thenReturn(member_seminar_payment_history);
        Mockito.doNothing().when(seminarService).increaseParticipantsCnt(seminarDTO.getSeminar_no());
        Mockito.when(member_seminarRepository.save(any(Member_Seminar.class))).thenReturn(member_seminar);

        // when
        Long result = memberSeminarService.registerForSeminar(memberSeminarRegisterRequestDTO);

        // then
        verify(memberService).get("passionfruit200@naver.com");
        verify(seminarService).getWithPessimisticLock("SeminarTest");
        verify(member_seminar_payment_historyRepository).save(any(Member_Seminar_Payment_History.class));
        verify(seminarService).increaseParticipantsCnt(seminarDTO.getSeminar_no());
        verify(member_seminarRepository).save(any(Member_Seminar.class));
    }

}

 

8. 실제로 Thread 100개 만들어서 테스트해보기

8. [ com/seminarhub/service/Member_SeminarServiceTests.java ]

@SpringBootTest
public class Member_SeminarServiceTests {

    @Autowired
    private SeminarService seminarService;

    @Autowired
    private MemberService memberService;

    @Autowired
    private Member_SeminarRepository member_seminarRepository;

    @Autowired
    private SeminarQuerydslRepository seminarQuerydslRepository;

    @Autowired
    private Member_Seminar_Payment_HistoryRepository member_seminar_payment_historyRepository;

    @Autowired
    private Member_SeminarService memberSeminarService;


    @DisplayName("Member_Seminar Service RegisterForSeminar Test")
    @Test
    public void testWithThreadsRegisterForSeminar() throws SeminarRegistrationFullException {
        String member_id = "passionfruit200@naver.com";
        String seminar_name = "스타크래프트 세미나";

        final int executeNumber = 5000;
        final ExecutorService executorService = Executors.newFixedThreadPool(500);
        final CountDownLatch countDownLatch = new CountDownLatch(executeNumber);

        for(int i=0;i<executeNumber; i++){
            executorService.execute( () -> {
                try{
                    memberSeminarService.registerForSeminar(new MemberSeminarRegisterRequestDTO(member_id, seminar_name));
                }catch(Exception e){
                    System.out.println(e.getMessage());
                }finally {
                    countDownLatch.countDown();
                }
            });
        }

        try {
            // 모든 스레드가 종료될 때까지 대기
            countDownLatch.await();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.out.println("Thread interrupted while waiting for completion.");
        } finally {
            // ExecutorService 종료
            executorService.shutdown();
        }

    }

}

 

Thread Pool에는 500개의 스레드를 설정해두고, 해당 스레드를 이용하여 총 5000번 호출하였습니다.

 

 

500개의 쓰레드가 총 5000번의 요청을 보냈음에도 안정적으로 최대인원(500명)을 초과하지 않고 500명을 신청한것을 확인할 수 있습니다.

마무리

이번글에서 트랜잭션을 적용해보며, 트랜잭션의 격리레벨만으로도 데이터 부정합을 체크해낼 수 있을것이라 생각하였는데, 해당사항은 불가능했습니다.

그 대신에, 베타락과 최소의 격리레벨을 사용하여 ACID 특징을 만족하고, 데이터의 부정합을 챙겨보게되었습니다.

또한, MySQL의 트랜잭션의 기능에 대해 공부해보며, 트랜잭션의 언두로그에 대해 알게되고, 트랜잭션의 실제 Source Code를 기반으로 가상메모리의 페이징 시스템과의 유사함을 생각해보며 언두슬롯과 페이지가 어떻게 연결되어있을지 생각해보아서 좋았습니다.

 

 

부록 (여기서부터는 위의 내용에 대하여 공부하면서 부가적으로 공부해본 내용들입니다.)

Transaction에 대해 공부하면서 왜 Transaction만으로는 배타락(Write Lock)을 제공하지 않는걸까? 하는 궁금증에 공부를 해보다가 MVCC, Undo Log, Transaction이 차지하는 용량과 같은 것들을 공부해보았습니다.

부록 목차

  1. 결제서비스에 트랜잭션을 적용하는 근본적 이유
  2. MySQL에서 InnoDB를 사용해야하는 이유 ( 트랜잭션과 MVCC(Multi Version Concurrency Control) 를 위해 )
  3. MVCC(Multi Version Concurrency Control)란 정확히 무엇을 의미하는가
  4. InnoDB 스토리지 엔진의 트랜잭션 내에서 사용되는 언두로그(Undo Log)에 대하여
    1. 언두로그가 사용하는 공간의 양
    2. 언두로그가 삭제되는 시점은 언제일까?
  5. InnoDB에서 현재 실행중인 트랜잭션의 개수를 확인방법
  6. 트랜잭션은 동시에 최대 몇개까지 실행될 수 있을까? 
  7. 언두 테이블 스페이스(Undo TableSpace)에 대하여
  8. 실제로 언두슬롯의 헤더 정보에는 어떤것이 담겨있을까?
  9. 만약 언두테이블 스페이스를 증가시키고 싶다면?
  10. MySQL 버전에 따른 언두테이블 스페이스의 변화
  11. (그외 추가정보) MySQL 8.0 에서 테이블 잠금 확인하는 방법

 

결제서비스에 트랜잭션을 적용하는 근본적인 이유

저희의 결제서비스에서 트랜잭션을 사용하는 이유는, 결제서비스 진행 중 발생하는 오류를 방지하는 것이 가장 큰 목적입니다. 사용자가 세미나를 구매하고 결제를 진행한뒤 결제 정보를 저희의 서비스에 저장할떄 그 찰나의 순간에 결제가 이루어지지 않으면 어떻게 될까요? 그 찰나의 순간에는 네트워크 에러로 인한 문제, 서버가 갑자기 중단되는 문제들이 있을 것 입니다. 이러한 복구작업은, 오류가 발생한다면 도저히 복구할 수 없는 수준의 복구작업이 발생할 것이고, 해당 회사의 서비스는 큰 신뢰감을 잃을 것 입니다.

 

이러한 문제를 원활히 해결하기 위해, 트랜잭션의 ACID 성질을 통해 안전성 문제를 해소하고자 트랜잭션을 사용하여야 합니다. 또한,이떄 트랜잭션을 사용할떄 여러 격리레벨이 존재하는데 이 서비스에 알맞은 최적의 격리 레벨 설정을 통하여 서비스에서 필요한 적절한 트랜잭션 격리레벨 선정을 통해 최대한의 성능을 내보고자입니다.  ( 이 부분에 대하여 흥미로운 부분을 알게 되어 인용합니다. ) Real Mysq. 8.0의 저자에 의하면

 4개의 격리 수준에서 순서대로 뒤로 갈수로 각 트랜잭션 간의 데이터 격리(고립)정도가 높아지며, 동시 처리 성능도 떨어지는 것이 일반적이라고 볼 수 있다. 격리 수준이 높아질수록 MySQL 서버의 처리 성능이 많이 떨어질 것으로 생각하는 사용자가 많은데, 사실 SERIALIZABLE 격리 수준이 아니라면 크게 성능의 개선이나 저하는 발생하지 않는다.

 

성능차이가 있지만 성능차이가 크게 나지 않는다고 합니다. 하지만, 조금의 성능개선이 큰 성능차이를 만들기에 적절한 격리 레벨을 찾는것이 좋을 것 입니다. (당연히 격리레벨에 따라 다른 로직이 적용되니 성능에는 차이가 있을 것 입니다.) 

MySQL에서 InnoDB를 사용해야하는 이유 ( 트랜잭션과 MVCC(Multi Version Concurrency Control) 를 위해 )

저희가 이번에 사용하는 결제서비스에서의 테이블은 모두 InnoDB를 사용합니다. 일반적인 MySQL 서비스를 사용하는 서비스라면, 대부분이 스토리지 엔진으로 InnoDB를 Default로 사용할 것 인데요, 이유는 MySQL 스토리지 엔진 중에 유일하게 레코드 기반의 잠금을 제공하기에 그렇습니다. 이러한 레코드 기반 잠금을 통해 높은 동시성을 제공합니다. 이것만 해도 InnoDB를 사용할 이유가 충분하고, 그 외에도 InnoDB는 트랜잭션과 MVCC(Mult Version Concurrency Control)을 지원합니다.

여기서 자꾸 MVCC가 등장하는데요, MVCC란 무엇일까요?

MVCC(Multi Version Concurrency Control)란 ??

MVCC 이라 함은 영어 이름 그대로 하나의 레코드에 대해 여러 개의 버전이 동시에 관리된다는 의미입니다.  이러한 MVCC를 통해 잠금을 걸지 않고 읽기 작업을 수행할 수도 있는 것 입니다. MVCC는 일반적으로 레코드 레벨의 트랜잭션을 지원하는 DBMS, Mysql에서는 InnoDB 스토리지 엔진이 지원하는 기능입니다. MVCC의 가장 큰 목적은 잠금을 사용하지 않는 일관된 읽기를 제공하는데 있습니다.(아쉽게도, 뒤에서 나오겠지만 제 시스템에서는 잠금을 사용했어야 했기에 MVCC를 활용하지는 못하였습니다.)  이 기능은 InnoDB 스토리지 엔진에서는 언두 로그(Undo Log)를 이용해 이 기능을 구현합니다. 트랜잭션에서 가장 중요한 기능인 롤백을 제공하고, MVCC를 제공하는데 가장 큰 도움을 주는 InnoDB에 대해서 더 알고싶어 추가로 알아보았습니다.

MVCC에서 언두로그에 데이터가 저장되는 방법 (세미나허브 코드와 함께)

예시와 함께 MVCC 작동방식에 대해 알아보겠습니다. 

아래의 테이블을 생성합니다.(제가 사용하는 세미나 테이블입니다.)

-- 테이블 생성 구문
CREATE TABLE seminar (
    seminar_no BIGINT AUTO_INCREMENT PRIMARY KEY,
    seminar_name VARCHAR(100) NOT NULL UNIQUE,
    seminar_explanation VARCHAR(500),
    seminar_price BIGINT,
    seminar_maxParticipants BIGINT,
    seminar_participants_cnt BIGINT,
    del_dt DATETIME,
    inst_dt DATETIME,
    updt_dt DATETIME,
    INDEX uix_seminar_name (seminar_name)
);


INSERT INTO seminar (
    seminar_name,
    seminar_explanation,
    seminar_price,
    seminar_maxParticipants,
    seminar_participants_cnt,
    del_dt,
    inst_dt,
    updt_dt
) VALUES (
    '스타크래프트 세미나',
    '저그 유저들을 위해서 강의가 열립니다!',
    90000,
    500,
    499, -- 이 부분은 member_seminar와 연관되지만 테스트를 위해 임의로 넣습니다.
    NULL,
    CURRENT_TIMESTAMP,
    CURRENT_TIMESTAMP
);

 

위의 CREATE문과 INSERT 문을 수행하면,  아래와 같은 MySQL 엔진에서 InnoDB 버퍼풀, 언두로그 테이블 스페이스, 데이터파일(디스크)로 이루어질 것 입니다.

이렇게 관계를 논리적으로 그릴 수 있습니다. 

 

이제 실제로 UPDATE를 해보겠습니다.

update seminar set seminar_participants_cnt = seminar_participants_cnt + 1 where seminar_name = '스타크래프트 세미나';

Update를 한 이후에는 어떻게 될까요?

  • 위의 그림에서 보다시피, 처음에 UPDATE 구문이 생성되면 InnoDB 버퍼풀에 값이 업데이트 되고, 이전 값은 언두로그에 저장됩니다.
  • 이러한 구조를 통해서 MVCC가 각 격리수준에 맞추어 적절한 값을 보여주는 것 입니다!
    • 만약, 격리레벨이 READ_UNCOMMITTED 라면 위에서 곧바로 InnoDB 버퍼풀에 저장된 값을 조회합니다.
    • 만약, 격리레벨이 READ_COMMITTED 라면 위에서 언두로그에 저장된 값을 조회합니다.
    • 만약, 격리레벨이 REPEATABLE_READ 라면 위에서 언두로그에 저장된 값을 조회합니다. 하지만 실제로 해당 레코드를 업데이트하기 위해서는 락이 걸립니다.
    • 만약, 격리레벨이 SERIALIZABLE 이라면 위에서 언두로그에 저장된 값을 조회합니다. 하지만 해당 조회한 레코드를 다음에 공유락을 얻은 트랜잭션은 수정하지 못합니다.

기본적으로 트랜잭션이 언두로그에 데이터를 저장하는 방식은 위와 같습니다.

 

이번에는, InnoDB의 언두로그(Undo Log) 자체가 어떻게 MySQL 엔진(메모리)에 구현되어있는지 확인해보겠습니다! 

InnoDB 스토리지 엔진의 트랜잭션 내에서 사용되는 언두로그(Undo Log)에 대하여

트랜잭션 내에서 언두로그(Undo Log)를 해석하면 "되돌리기 로그", "취소 로그" 라는 의미입니다.

이러한 언두로그가 사용되는 부분으로는 대표적으로 어디일까요?

  1. 트랜잭션 롤백에 사용될 수 있습니다.  즉, 롤백을 통해 트랜잭션 도중 변경된 데이터를 변경 전 데이터로 복구하는데 사용합니다. ( 변경 전 데이터 (원래 데이터)를 언두로그에 저장해놓는 것 입니다. ) 
  2. 격리 수준 보장(Isolation Level)을 위해서 사용할 수 있습니다. 특정 커넥션에서 데이터를 변경하는 도중에 다른 커넥션에서 데이터를 조회하면 트랜잭션 격리 수준에 맞게 변경중인 레코드를 읽지 않고 언두 로그에 백업해둔 데이터를 읽어서 반환합니다.

언두로그가 사용하는 공간의 양

언두로그가 사용할 수 있는 공간의 양은 어느정도일까요?

MySQL 5.5 버전 이전버전과 이후 버전으로 큰 차이가 나뉩니다.

1. 5.5 버전 이전의 InnoDB Storage Engine에서는 한번 증가한 언두 로그 공간은 다시 줄어들지 않았습니다.

2. 5.5 버전 이후, 8.0 버전 이후의 InnoDB Storage Engine에서는 필요한 언두 로그 공간만 할당하고, 이미 사용된 언두로그공간은 다시 줄어들거나와 같은 로직이 추가되었습니다.

언두로그가 삭제되는 시점은 언제일까?

먼저 결과를 말씀드리면, 언두로그는 동일한 데이터베이스에서 실행되는 여러 트랜잭션들이 모두 종료될때 삭제됩니다. 즉, 가장 먼저 시작한 트랜잭션이 아직 완료되지 않은 상태라면, 그 이후에 시작하고 이미 완료된 트랜잭션들이 생성한 언두 로그는 가장 먼저 시작한 트랜잭션이 종료될때까지 유지되어야만 합니다. 

 

 

간결하게 그 이유를 말씀드리면, Mysql이 MVCC(Multi Version Concurrency Control)  라는 방식을 사용하기 떄문입니다.

트랜잭션은 서로 다른 시점에 시작하고 종료할 수 있습니다.  이때 각 트랜잭션은 자신만의 언두 로그(undo log)를 가집니다. 이러한 언두로그가 생성될떄 비슷한 개념인 스냅샷이 함께 생겨납니다.  MVCC에서는 각 트랜잭션이 자신만의 '스냅샷'을 가지게 되는데, 이 스냅샷은 해당 트랜잭션이 시작된 시점의 데이터 상태를 의미합니다. 따라서 가장 먼저 시작된 트랜잭션 A가 완료되지 않은 상태에서는, 그 이후에 시작된 트랜잭션 B와 C의 언두 로그를 삭제하면, 트랜잭션 A의 스냅샷이 무효화될 수 있기 때문에 이를 방지하기 위해 언두 로그를 유지하게 됩니다. 기본적으로 데이터베이스는 레코드를 기준으로 하는 것이 아닌, 전체 DB 입장에서의 트랜잭션을 기준으로 합니다.

 

트랜잭션이 오랫동안 유지될 수 있는 작업은 무엇이 있을까요 ? 긴 시간 동안의 대량 데이터 처리, 배치 작업, 혹은 데이터 마이그레이션 등의 작업을 수행할 때는 트랜잭션을 일시적으로 오래 유지될 수 있습니다. 또한 만약에 어떤 API 호출이 트랜잭션이 실행된태로 무한대기에 들어가있다면 이후의 트랜잭션은 해당 API 호출이 무한대기에서 풀릴떄까지 쌓일 것 입니다. 

InnoDB에서 현재 실행중인 트랜잭션의 개수를 확인

위의 내용인 "언두로그가 삭제되는 시점은 언제일까?" 를 보면, 너무 많은 트랜잭션이 동시에 실행되면 언두로그 삭제시점에 영향을 주고, 당연히 많은 트랜잭션은 데이터 부하로 DB에 안좋은 영향을 끼칠 수 잇습니다.

그렇기 위해, 만약 DB가 심각하게 느려졌다면, DB에서 어떤 일이 일어나는지 알아봐야할 필요가 있는데요.

 

아래와 같은 명령어들로 현재 쌓인 트랜잭션의 개수를 확인할 수 있습니다.

mysql> show engine innodb status;

------------
TRANSACTIONS
------------
Trx id counter 1762
Purge done for trx's n:o < 1761 undo n:o < 0 state: running but idle
History list length 0
LIST OF TRANSACTIONS FOR EACH SESSION:
---TRANSACTION 283996890520928, not started
0 lock struct(s), heap size 1136, 0 row lock(s)
---TRANSACTION 283996890520096, not started
0 lock struct(s), heap size 1136, 0 row lock(s)
---TRANSACTION 283996890519264, not started
0 lock struct(s), heap size 1136, 0 row lock(s)

 

현재 활성화 상태인 트랜잭션의 개수를 information_schema의 innodb_metrics 테이블에서 확인할 수 있습니다. 현재 쌓인 트랜잭션의 개수가 곧 언두로그 건수라고 생각하면 됩니다. History list length 0 로 현재 서버의 언두 로그 건수는 0개인것을 알 수 있습니다.

mysql> SELECT COUNT
    -> FROM information_schema.innodb_metrics
    -> WHERE SUBSYSTEM='transaction' AND NAME='trx_rseg_history_len';
+-------+
| COUNT |
+-------+
|     0 |
+-------+
1 row in set (0.00 sec)

MySQL 8.0 버전부터는 information_schema에서 언두로그 개수를 확인할 수 있다는 것이 큰 장점입니다.

 

추가로 유의해야할점은, MySQL 서벙에서 INSERT 문장으로 인한 언두 로그와 UPDATE, DELETE 문장으로 인한 언두로그는 별도로 관리됩니다. UPDATE와 DELETE 문장으로 인한 언두로그는 MVCC와 데이터 복구(롤백 포함)에 모두 사용되지만, INSERT 문장으로 인한 언두로그는 MVCC를 위해서는 사용되지 않고 롤백이나 데이터 복구만을 위해서 사용되기 때문입니다. 위의 언두로그 건수에는 UPDATE와 DELETE 문장으로 인한 언두로그 개수만 보입니다.

 

트랜잭션은 동시에 최대 몇개까지 실행될 수 있을까? 

트랜잭션을 활용하여 결제 서비스를 진행하다보니, 최대 동시에 몇개의 트랜잭션까지 실행될 수 있을지가 중요합니다. 만약 인구가 많은 중국에서 국민앱이 폭발적으로 사용된다면, 실제로 엄청난 트랜잭션이 발생할 수 있습니다. (사견으로, 이러한 폭발적인 신청에는 앞서서 대기열을 활용하여  API 접속 자체를 막는 로직이 주로 사용됩니다. 대학교 수강신청을 하다보면, 접속대기열과 같은것을 볼 수 있습니다. 만약 하나의 API에 대해 폭발적인 접속이 이루어지는경우라면 잠금을 사용하여 그런일이 발생하지 않을 수 있지만, 만약 여러 API에서 동시에 여러 트랜잭션이 실행되는경우에는 문제가될 수 있습니다).

 

그런 연유에서 DB서버의 최대 트랜잭션량을 계산하는 것은 중요합니다. 한번 MySQL 에서는 어떤식으로 알아낼 수 있고 그 트랜잭션량을 증가시킬 수 있는지 확인해보겠습니다. 

 

전체 트랜잭션량은 언두 로그의 크기를 관리하는 언두 테이블 스페이스에 대하여 알아야 합니다. 트랜잭션이 위에서 살펴봤듯이 MVCC를 지원하기 위해 해당 시점의 데이터들을 언두로그에 계속 저장하고 있기 떄문입니다. 

그렇기에, System에서 전체 트랜잭션의 개수를 알기 위해서는 언두 테이블 스페이스(Undo Table Space)에 대하여 알아야합니다. 바로, 아래에서 언두 테이블스페이스(Undo TableSpace)에 대해 더 자세하게 알아보겠습니다.

언두 테이블 스페이스(Undo TableSpace)에 대하여

언두테이블 스페이스(Undo TableSpace)란 언두로그(Undo Log)가 저장되는 공간입니다.

저는 MySQL 8.0.21을 사용하고 있으므로 별도 로그파일에 기록되도록 한 방식으로 설명하였습니다.

 

언두테이블스페이스의 구조입니다.

먼저, 위의 그림이 그려진 조건은 아래와 같습니다.

1. InnoDB 페이지 크기(InnoDB Page Size)는 2^14 Bytes(16KB) 이다. 

2. 언두슬롯(Undo Slot) 1개당 헤더의 크기는 2^4 Bytes (16 B) 이다. (언두슬롯이 담을 수 있는 데이터가 아닌, 헤더를 의미한다. 언두슬롯 안의 Size가 표현할 수 있는 데이터 크기는 헤더가 어떻게 구성되어있는지 확인하기 어렵지만 표현할 수 있는 주소의 크기는 2^16 = (65,536) 까지 일 것 이다. )

3. 롤백세그먼트(Rollback Segment) 에서 언두슬롯(Undo slot) 개수 = ( InnoDB Page Size / Undo Slot Size ) = 2^14 / 2^4 = 2^10  (1024 개) 이다. ( 여기서 2^4 (16)으로 나눠지는 이유는 하나의 언두슬롯 크기를 16바이트로 가정합니다.)

4. 롤백 세그먼트의 개수는 1~128개의 숫자이다.

5. 하나의 트랜잭션은 각 INSERT, UPDATE, DELETE 문장의 특성에 따라 최대 4개까지 언두 슬롯을 사용한다. 일반적으로 트랜잭션이 임시 테이블을 사용하지 않으므로 하나의 트랜잭션은 대략 2개 정도의 언두슬롯을 필요로 한다고 가정한다. ( 이부분에 대해서 더 자세히 알고싶다면, https://dev.mysql.com/doc/refman/8.0/en/innodb-undo-logs.html   공식문서에 각 INSERT, DELETE, UPDATE 조건에 따른 트랜잭션 개수를 구하는 것이 상세하게 나와있습니다. )

위의 조건들을 합하여 언두테이블 스페이스를 표현하면, 아래와 같습니다.

 

최대 동시 트랜잭션 수 = (InnoDB 페이지 크기) / 16 * (롤백 세그먼트 개수) * (언두 테이블 스페이스 개수)  / (하나의 트랜잭션 당 필요한 언드슬롯 개수)

로 구할 수 있습니다.

위의 조건에서 InnoDB 페이지 크기는 2^14 Byte, 롤백 세그먼트 개수는 최대 128로 128로 설정합니다. 언두 테이블 스페이스 개수는 3개가 있다고 가정해보겠습니다. 하나의 트랜잭션 당 필요한 언두슬롯 개수는 2개로 설정합니다.

 

연산식 진행.

= ( 2 ^ 14 ) /(  (2^4) * (2^7) * (3) )  / 2

= 196608

= ( 2 ^ 14 ) /(  (2^4) * (2^7) * (3) )  / 2
= 196608

하나의 시스템에서 196608 개의 Transaction이 발생할 수 있습니다.

이 정도의 트랜잭션만 유지한다면, 시스템은 안전합니다. 다만, 앞에서 말했듯이 트랜잭션의 언두로그는 MVCC 에 따라 이전 번호의 트랜잭션 번호가 종료될때까지 언두로그가 유지된다는점을 유의해야 합니다.

실제로 언두슬롯의 헤더 정보에는 어떤것이 담겨있을까?

실제로 언두슬롯이 어떻게 디스크의 페이지 데이터를 가져오는지 알기 위해서는 언두 슬롯의 16 bytes짜리 헤더에 대해 몇몇부분만 알아보았습니다. 해당 디스크 연결에 대한 생각은 운영체제에서 나오는 가상메모리의 페이징 시스템과 유사한 부분들이 있어 그런부분들을 차용하여 생각해보았습니다. ( 구체적으로 따지게 된다면, 가상메모리의 페이징 테이블이 존재하지는 않지만, 같은 로직입니다. )

 

  • id :는 해당 롤백 세그먼트 내에서 언두슬롯의 번째를 의미합니다.
  • top_page_no 는 해당 롤백 세그먼트에 포함된 가장 최근의 페이지 번호를 의미합니다.
  • last_page_no 는 해당 롤백 세그먼트에 포함된 가장 오래된 페이지 번호를 의미합니다.
  • top_offset은 top_page_no 에서부터 순서대로의 offset을 나타냅니다.
  • trx_id는 트랜잭션 아이디를 나타냅니다.

아래의 소스코드에서 struct trx_undo_t 구조체 정보를 토대로 유추해보았습니다. 

https://github.com/mysql/mysql-server/blob/824e2b4064053f7daf17d7f3f84b7a3ed92e5fb4/storage/innobase/include/trx0undo.h#L193

(mysql server 공식 깃허브 : https://github.com/mysql/mysql-server )

 

그렇다면, 이제 실제로 언두테이블 스페이스에 저장될 Seminar Datagram의 Size를 구해보겠습니다. 

이떄 저장될 기준은 최대 Seminar Datagram을 그림으로 표현해봤습니다.

"Seminar" 테이블의 구조는 다음과 같습니다.

mysql> desc seminar;
+--------------------------+--------------+------+-----+---------+----------------+
| Field                    | Type         | Null | Key | Default | Extra          |
+--------------------------+--------------+------+-----+---------+----------------+
| seminar_no               | bigint       | NO   | PRI | NULL    | auto_increment |
| inst_dt                  | datetime(6)  | YES  |     | NULL    |                |
| updt_dt                  | datetime(6)  | YES  |     | NULL    |                |
| del_dt                   | datetime(6)  | YES  |     | NULL    |                |
| seminar_explanation      | varchar(500) | YES  |     | NULL    |                |
| seminar_max_participants | bigint       | YES  |     | NULL    |                |
| seminar_name             | varchar(100) | NO   |     | NULL    |                |
| seminar_participants_cnt | bigint       | YES  |     | NULL    |                |
| seminar_price            | bigint       | YES  |     | NULL    |                |
+--------------------------+--------------+------+-----+---------+----------------+

최악의 경우의 메모리 공간을 계산하겠습니다. varchar(500)은 고정된 데이터가 아니라 가변길이 입니다. 그렇기에 최악의 상황을 따로 가정하겠습니다.

  1. seminar_no는 bigint는 최대 8 bytes, 2^(64 - 1) 값을 표현할 수 있습니다. 양수 정수를 표현하므로 최대 표현값은 2^63  bytes일 것 입니다.
    • 최대 양수 값: 9,223,372,036,854,775,807 (2^63 - 1)
    • 최소 음수 값: -9,223,372,036,854,775,808 (-2^63)
  2. inst_dt, updt_dt, del_Dt 는 datetime(6) 으로 각 8 bytes를 의미합니다.
  3. seminar_explanation 은 varchar(500)이기에 가변이지만 최악의 경우 한글로 모두 꽉 차 있다고 가정하겠습니다. 그렇다면 500 bytes * 3 (UTF--8) 로 1500 bytes를 차지합니다.
  4. seminar_max_participants는 최대 8 bytes, 2^(64-1) 의 값을 표현할 수 있습니다.
  5. seminar_name  varchar(100) 이기에 가변이지만 최악의 경우 한글로 모두 꽉 차 있다고 가정하겠습니다. 그렇다면 100 * 3 (UTF -8) 로 300 bytes를 차지한다. 
  6. seminar_participants_cnt 는 최대 8 bytes, 2^(64-1) 의 값을 표현할 수 있습니다.
  7. seminar_price  최대 8 bytes, 2^(64-1) 의 값을 표현할 수 있습니다.

결론적으로 최악의 메모리 공간은 8 + 24 + 1500 + 8 + 300 + 8 + 8 = 1,856 bytes 가 나옵니다.

 

만약 언두테이블 스페이스를 증가시키고 싶다면?

저희는 위에서 직접 언두 테이블 스페이스 개수와 페이지 크기, 하나의 트랜잭션이 필요로 하는 언두 슬롯 개수 를 고려하여서 하나의 DB System에서 유지할 수 있는 트랜잭션의 개수를 연산하였습니다.

= ( 2 ^ 14 ) /(  (2^4) * (2^7) * (3) )  / 2
= 196608

하나의 시스템에서 196608 개의 Transaction이 발생할 수 있습니다.

만약, 서버 안정성을 위해 이보다 더 많은 트랜잭션 개수를 설정하고 싶다면 아래와 같이 설정할 수 있습니다.

 

조건은, MySQL 5.6 초과 버전이어야만 합니다. 즉 MySQL 8.0 버전부터는 동적으로 언두로그를 저장하는 언두테이블 스페이스를 관리하도록 설정합니다.

 

아래의 명령어들로 InnoDB undo tablespace를 조회/추가/삭제/비활성화/활성화 할 수 있습니다.

mysql> SELECT TABLESPACE_NAME, FILE_NAME
    -> FROM INFORMATION_SCHEMA.FILES
    -> WHERE FILE_TYPE LIKE 'UNDO LOG';
+-----------------+------------+
| TABLESPACE_NAME | FILE_NAME  |
+-----------------+------------+
| innodb_undo_001 | ./undo_001 |
| innodb_undo_002 | ./undo_002 |
+-----------------+------------+
2 rows in set (0.01 sec)

-- // tablespace를하나 더 추가할 수 있습니다.
mysql > CREATE UNDO TABLESPACE extra_undo_003 ADD DATAFILE '/data/undo_dir/undo_003.ibu';

-- // 언두테이블 스페이스를 비활성화합니다.
msql > ALTER UNDO TABLESPAC extra_undo_003 SET INACTIVE;

-- // 언두테이블 스페이스를 비활성화합니다.
msql > DROP UNDO TABLESPAC extra_undo_003;

실제로 이러한 언두로그파일들이 생겼는지 확인하고 싶으시다면,

mysql> show variables like 'datadir';

로 확인할 수 있습니다.

MySQL 버전에 따른 언두테이블 스페이스의 변화

위의 언두로그의 공간인 언두 테이블 스페이스는 MySQL 서버의 변화에 따라 큰 변화가 있었습니다. 해당 변화들은

첫번째, MySQL 5.6 이하 버전,

두번쨰, MySQL 5.6 초과 ~ MySQL 8.0.13 이하 버전,

세번째, MySQL 8.0.14 이후 버전으로 변화가 있었습니다.

 

첫번째, MySQL 5.6 이하 버전에서는 언두로그가 모두 시스템 테이블 스페이스(ibdata.ibd)에 저장되었습니다. 이 시스템 테이블 스페이스는 언두로그와 같은 모든 파일들이 저장될 수 있는 파일데이터 공간이라고 생각하면 됩니다.

 

두번쨰, MySQL 5.6 초과 ~ MySQL 8.0.13 이하 버전부터는 innodb_undo_tablespaces 라는 시스템변수 설정값을 통하여 언두로그를 시스템 테이블 스페이스에 저장할지 아니면 언두로그파일만을 저장하는 공간에 저장할지 사용할 수 있었습니다.

 

세번째, MySQL 8.0.14 이후 버전에서는 반드시  시스템 테이블스페이스 외부의 별도 로그 파일에 기록되도록 개선되었습니다. 이러한 작업 처리를 통해 시스템 테이블 스페이스의 크기에 영향을 받는것이 아닌 별도의 로그 파일 크기를 설정함으로써 언두로그 파일이 저장될 수 있는 공간을 따로 관리하려고 이렇게 변화한 것으로 생각할 수 있습니다.

(그외 추가정보) MySQL 8.0 에서 테이블 잠금 확인하는 방법

현재  MySQL 8.0 에서부터 본격적으로 performance_schema 테이블에서 잠금을 확인할 수 있게 도와주는데, 아래의 코드를 활용하여 확인할 수 있습니다.

mysql > SELECT
r.trx_id waiting_trx_id,
r.trx_mysql_thread_id waiting_thread,
r.trx_query waiting_query,
b.trx_id blocking_trx_id,
b.trx_mysql_thread_id blocking_thread,
b.trx_query blocking_query
FROM performance_schema.data_lock_waits w
INNER JOIN information_schema.innodb_trx b
ON b.trx_id = w.blocking_engine_transaction_id
INNER JOIN information_schema.innodb_trx r
ON r.trx_id = w.requesting_engine_transaction_id;

부록 마무리

이번 개발을 진행하면서

  • Transaction의 MVCC에 대해 알게되고, MVCC를 구현하기 위해 언두로그에 어떻게 데이터가 저장되는지 알게되었습니다.
  • MySQL 8.0 버전에서 언두로그의 데이터 저장 방식과 이루어져있는 구조에 대해 알게되었습니다.
  • 하나의 언두로그에 저장될 수 있는 최대 크기의 Seminar Datagram을 계산해보았습니다.
  • 하나의 MySQL 서버에서 수용할 수 있는 최대 트랜잭션 개수를 계산할 수 있게 되었습니다.
  • Transaction의 각 격리레벨에서 제공하는 격리레벨과 발생할 수 있는 데이터 부정합 문제에 대해 알게 되었습니다.
  • 배타락과 공유락의 차이점에 대해 알게되었습니다. 
  • 쓰레드를 활용하여 동시접속 테스트를 진행할 수 있게 되었습니다.
  • MySQL 8.0에서 트랜잭션에서 발생하는 락을 모니터링하는 방법에 대해 알게되었습니다.

+ Recent posts