[MySQL] 트랜잭션과 잠금

뚜비·2023년 1월 28일
2

MySQL

목록 보기
1/1

1. 트랜잭션

트랜잭션(Transaction)이란?

  • 쪼갤 수 없는 업무 처리의 최소 단위, DB에서 논리적인 작업의 단위
  • 하나의 작업을 수행하는 데 필요한 연산(쿼리)들을 논리적으로 모아놓은 것 (하나 이상)
  • 데이터 베이스의 무결성일관성을 보장
    → 즉 논리적인 작업 셋을 모두 완벽하게 처리 하거나, 처리하지 못할 경우에는 모두 원 상태로 복구하여 일부만 실행되는 경우를 막는다. == 원자성
  • All or Nothing 형태를 의미

▶ 데이터의 무결성과 일관성
무결성 : 데이터의 정확성, 일관성, 유효성이 유지
일관성 : 원인과 결과의 의미가 연속적으로 보장되어 변하지 않는 상태



'부모님의 계좌에서 자식의 계좌로 백 만원을 계좌이체'하는 작업을 처리한다고 가정 해보자.

1.  부모님, 자식의 계좌의 잔액을 확인
2.  부모님 계좌에서 (잔액 - 백만원)으로 업데이트 
3.  자식 계좌에 (잔액 + 백만원)으로 업데이트 
4.  점검

만약 2번의 UPDATE 문이 실행되고 시스템에 장애가 발생하여 3번의 UPDATE 문이 실행되지 않는다면?
부모님의 백 만원은 사라져버리게 되는 모순된 상황이 발생하게 된다.


따라서 트랜잭션은 다음과 같은 연산 중 하나가 실행되어야 종료된다.

한 트랜잭션의 모든 쿼리문을 성공적으로 수행 -> 데이터를 최종적으로 데이터베이스에 영구적으로 반영 == commit 연산

작업 중 문제가 발생하여 한 연산이라도 실패하면 트랜잭션의 처리과정에서 발생한 변경사항을 취소 == rollback 연산


MySQL에서 트랜잭션

InnoDB 스토리지 엔진은 트랜잭션을 지원하고 MyISAM 이나 MEMORY 와 같은 스토리지 엔진은 트랜잭션을 지원하지 않는다고 한다. 두 스토리지 엔진을 비교해보자.

// MyISAM 테이블 생성 및 PK insert
mysql> create table tb_tab_myisam(col int primary key) ENGINE=MyISAM;
mysql> insert into tb_tab_myisam values (3);

// InnoDB 테이블 생성 및 PK insert
mysql> create table tb_tab_innodb(col int primary key) ENGINE=InnoDB;
mysql> insert into tb_tab_innodb values (3);

mysql> insert into tb_tab_myisam values(1),(2),(3);
Error Code: 1062. Duplicate entry '3' for key 'tb_tab_myisam.PRIMARY'

mysql> insert into tb_tab_innodb values(1),(2),(3);
Error Code: 1062. Duplicate entry '3' for key 'tb_tab_innodb.PRIMARY'

-- MyISAM
mysql> select * from tb_tab_myisam;
+-----+
| col |
+-----+
|   1 |
|   2 |
|   3 |
+-----+

-- InnoDB
mysql> select * from tb_tab_innodb;
+-----+
| col |
+-----+
|   3 |
+-----+
  • Insert 과정에서 2개의 구문 모두 1062 에러(PK 중복) 으로 에러가 발생

  • But 테이블을 조회하면 결과가 다르다.
    → MyISAM 테이블은 1,2가 INSERT된 상태, 즉 INSERT문 실행시 1,2를 저장하고 3에서 에러가 발생하여 쿼리 종료 == Partial Update
    → 데이터의 정합성(어떤 데이터들이 값이 서로 일치하는 상태)을 맞추는데 어렵게 된다.
    Partial Update_을 방지 하기 위해서 조건문으로 데이터에 대한 확인 및 클렌징 코드(Rollback) 까지를 준비..

  • InnoDB는 다음 로직이면 충분하다.

try {
  start transaction; 
  insert into tab_a ..;
  insert into tab_b ..;
  commit;
} catch(exception {
   rollback;
}

트랜잭션의 4가지 특성(ACID)

Atomicity(원자성)

  • 'all or nothing'
  • 즉, 트랜잭션의 모든 연산들이 정상적으로 수행 완료되거나 아니면 전혀 어떠한 연산도 수행되지 않은 상태를 보장
  • DBMS는 완료되지 않은 트랜잭션의 중간 상태를 데이터베이스에 반영해서는 안된다.

Consistency(일관성)

  • 트랜잭션 실행이 성공적으로 완료하면 언제나 일관성 있는 데이터베이스 상태로 유지하는 것
  • 즉 성공적으로 수행된 트랜잭션은 정당한 데이터들이 데이터베이스에 반영되었음을 의미

    ▶ DB에서 보존 되어야 하는 일관성
    DB의 상태, DB 내의 계층 관계, 컬럼의 속성, 제약조건 등등


Isolation(독립성/고립성)

  • 여러 트랜잭션이 동시에 수행 되더라도 각각의 트랜잭션은 다른 트랜잭션의 수행에 영향을 받지 않고 독립적으로 수행되어야 함을 의미
  • 즉 다수의 세션 또는 유저가 같은 시간에 같은 데이터에 접근하고 처리 중일 때 수행 중인 트랜잭션이 완료 될 때 까지 다른 트랜잭션이 끼어 들지 못하게 한다.
  • Isolation 이 없을 경우 다수의 사용자에 의해 dirty read 나 lost update 등이 발생될 수 있다. 만약 A와 B가 C의 계좌 잔액을 동시에 10000원으로 read했다면 C는 (잔액+20000원)이 update되지 않고 (잔액+10000원)으로 update 된다.

▶ Isolation level(격리수준)

  • Isolation level(격리수준) 설정에 따라 독립성의 높낮이 및 트랜잭션 진행간 발생될 수 있는 상황들도 달라지게 됩니다.
  • 각 DBMS 마다 기본으로 채택하고 있는 Isolation level(격리수준) 이 각각 다르며 MySQL 에서는 REPEATABLE READ 을 기본 값으로 하고 있습니다.
  • Isolation level 이 높다면?
    -> 트랜잭션의 격리성/독립성은 높아짐
    -> 동시 처리 성능이 떨어지며 시스템 자원도 많이 사용

Durability(지속성)

  • 트랜잭션이 성공적으로 완료되어 commit되었다면, 해당 트랜잭션에 의한 반영된 모든 변경은 향후에 어떤 소프트웨어나 하드웨어 장애가 발생 되더라도 보존되어야 함을 의미
  • 지속성을 위해 모든 트랜잭션은 로그(log)로 남겨진다.
  • 커밋 성능을 위해서 지속성 일부를 포기하는 설정 방식도 존재


2. 잠금(lock)

잠금

  • 잠금은 여러 세션(트랜잭션)이 동시에 동일한 레코드 나 테이블의 변경을 요청 할 경우 순서대로 한 시점에 하나의 세션(트랜잭션)만 변경할 수 있게 해주는 역할을 하게 됩니다.
  • 잠금(Lock) 과 트랜잭션은 서로 비슷한 개념 같지만 잠금(Lock)은 동시성 제어를 하기 위한 기능이고, 트랜잭션은 데이터의 정합성을 보장하기 위한 기능 입니다.

2-1. MySQL 엔진의 잠금

  • MySQL에서의 잠금은 스토리지 엔진 레벨과, MySQL 엔진 레벨로 나눌 수 있다.
  • MySQL 서버에서 스토리지 엔진 영역을 제외한 나머지 부분
  • MySQL 엔진 레벨의 잠금은 모든 스토리지 엔진에 영향을 미친다. 스토리지 엔진 레벨의 잠금은 스토리지 엔진 간 상호 영향을 미치지는 않는다.

글로벌 락(global lock)

  • MySQL에서 제공하는 잠금 중 가장 넓은 범위를 가지고 있는 잠금
    → FLUSH TABLES WITH READ LOCK 명령으로 획득
    → 영향을 미치는 범위는 해당 서버 전체
    → 작업 대상 테이블, 데이터베이스 상관 없이 동일하게 영향을 받는다.
  • 한 세션에서 글로벌 락을 획득하면 다른 세션에서는 해제 될 때 까지 SELECT를 제외한 대부분의 명령이 대기 상태가 된다.
  • 데이터베이스에 존재하는 MyISAM이나 MEMORY 테이블에 대해 mysqldump 유틸리티를 통해 일관된 백업을 받아야할 때 사용한다.
  • MySQL 8.0 부터 InnoDB 스토리지 엔진 기본 엔진으로 채택되면서 백업 시 조금 더 가벼운 백업 락을 사용한다. (Xtrabackup 또는 Enterprise Backup 과 같은 백업 툴에서 안정적인 실행을 위한 백업을 위한 Lock 이 추가)
-- GLOBAL LOCK
FLUSH TABLES WITH READ LOCK;
-- UNLOCK
UNLOCK TABLES;

-- BACKUP LOCK -> 새로운 백업 잠금 
LOCK INSTANCE FOR BACKUP;
-- UNLOCK
UNLOCK INSTANCE;

테이블 락(table lock)

  • 개별 테이블 단위로 설정되는 잠금
  • 명시적 또는 묵시적으로 특정 테이블의 락을 획득할 수 있다.
    명시적 : LOCK TABLES 테이블명 [read | write ] 명령 / UNLOCK TABLES 명령으로 잠금을 해제 / 특별한 경우가 아니라면 애플리케이션에서 사용할 필요 없음. 온라인 작업에서 상당한 영향
    묵시적 : MyISAM 이나 MEMORY 테이블에 데이터를 변경하는 쿼리를 실행하면 발생 / 쿼리가 실행되는 동안 자동으로 획득 되었다가 쿼리가 완료된 후 자동 해제
    → InnoDB 스토리지 엔진 테이블의 경우 대부분의 데이터 변경(DML) 은 레코드 기반의 잠금을 사용하고 테이블 레벨의 잠금은 스키마 변경을 막기 위해 사용
-- TABLE LOCK
LOCK TABLES table_name [ READ | WRITE ]
-- UNLOCK
UNLOCK TABLES;

네임드 락(named lock)

  • GET_LOCK() 함수를 이용하여 임의의 문자열에 대한 잠금을 설정할 수 있는 잠금
  • 유저 레벨 락으로도 불린다.
    → 단순히 사용자가 지정한 문자열에 대해 잠금을 획득하고 반납하는 잠금이다.
  • 자주 사용되지 않으나 배치 프로그램처럼 많은 레코드에 대해서 복잡한 요건으로 레코드를 변경하는 트랜잭션에 유용하게 사용된다.
    → 동일 데이터를 변경하거나 참조하는 프로그램끼리 분류해서 네임드 락을 걸고 쿼리를 실행해서 해결할 수 있다.
  • MySQL 8.0(공식문서에는 5.7버전) 부터는 네임드락을 중첩해서 사용할 수 있고, 동시에 모두 해제하는 기능도 추가
-- jade-1 이라는 문자열로 잠금을 획득하며
-- 이미 잠금이 있다면 5초간 대기
mysql> SELECT GET_LOCK('jade-1',5);
+----------------------+
| GET_LOCK('jade-1',5) |
+----------------------+
|                    1 |
+----------------------+


-- 문자열 'jade-1' 에 대해서 획득 가능한지를 확인
mysql> SELECT IS_FREE_LOCK('jade-1');
+------------------------+
| IS_FREE_LOCK('jade-1') |
+------------------------+
|                      0 |
+------------------------+


-- 문자열 jade-1 이 잠금이 설정되어 있는지 확인
mysql> SELECT IS_USED_LOCK('jade-1');
+------------------------+
| IS_USED_LOCK('jade-1') |
+------------------------+
|                      8 |
+------------------------+

-- 위 3개 함수 모두 정상적으로 락을 획득하거나 해제한 경우에 1을, 아니면 0을 반환한다.


-- connection id 확인
mysql> SELECT CONNECTION_ID();
+-----------------+
| CONNECTION_ID() |
+-----------------+
|               8 |
+-----------------+

-- 잠금해제
mysql> SELECT RELEASE_LOCK('jade-1');
+------------------------+
| RELEASE_LOCK('jade-1') |
+------------------------+
|                      1 |
+------------------------+

-- 모든 문자열에 대한 잠금을 해제한다. 해제된 잠금 수를 반환한다.
SELECT RELEASE_ALL_LOCKS();

메타데이터 락(metadata lock)

  • 데이터베이스 객체(테이블이나 뷰)의 이름이나 구조를 변경하는 경우 획득하는 잠금이다.
  • 명시적으로 획득 또는 해제 할 수 없지만 테이블의 이름을 변경하는 경우 자동으로 획득한다.
  • 트랜잭션을 명시적으로 시작 후에 데이터를 조회 한다면 다른 세션에서 테이블의 구조를 변경하게 되면 Metadata Lock 으로 대기
-- 배치 프로그램에서 별도의 임시 테이블에 서비스용 랭킹 데이터 생성 후 기존 테이블을 백업하는 경우
-- 아래 구문 실행 시 메타데이터 락을 자동으로 획득한다.
RENAME TABLE rank TO rank_backup, rank_new TO rank;

-- 트랜잭션을 명시적으로 시작 후에 데이터를 조회
-- 다른 세션에서 테이블의 구조를 변경하게 되면 Metadata Lock으로 대기 
-- Session 1
select CONNECTION_ID();
+-----------------+
| CONNECTION_ID() |
+-----------------+
|               4 |
+-----------------+
<-- Session 1의 Process ID  4  확인됨


-- 트랜잭션 시작
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)


mysql> select * from user_info;
+----+------+
| id | name |
+----+------+
|  1 | jade |
|  2 | Tom  |
+----+------+
2 rows in set (0.00 sec)


-- Session 2 : 컬럼 추가 시도
mysql> alter table user_info add column col2 varchar(100);
<!!---  획득 실패  락에 의해 대기중


-- Session 1 : Full processlist
mysql> show full processlist;
+----+------------+------+------+---------------------------------+----------------------------------------------------+
| Id | User       | db   | Time | State                           | Info                                               |
+----+------------+------+------+---------------------------------+----------------------------------------------------+
|  2 | system user| NULL | 1321 | Connecting to master            | NULL                                               |
|  4 | root       | npm  |    0 | starting                        | show full processlist                              |
|  8 | root       | npm  |   10 | Waiting for table metadata lock | alter table user_info add column col2 varchar(100) |
+----+------------+------+------+---------------------------------+----------------------------------------------------+
                          <!!-- Metadata lock 에 의해 Session 2가 대기 하는 상황


2-2. InnoDB 스토리지 엔진의 잠금

  • MySQL에서 제공하는 잠금과 별개스토리지 엔진 내부에서 레코드(테이블의 행)단위의 잠금을 지원
  • 레코드 기반의 잠금을 지원함으로 MyISAM 보다 훨씬 뛰어난 동시성 처리를 제공
  • 보통 명시적으로 잠금을 사용하는 경우는 드물고, 격리 수준에 따라 묵시적으로 잠금이 사용된다.
  • InnoDB는 비관적 잠금 방식을 사용한다.

    ▶ 비관적 동시성 제어(PCC, Pessimistic Concurrency Control)
    비관적 락이라고도 하며 트랜잭션이 충돌하는 가정하에 잠금을 거는 방식
    일반적으로 Shared Lock, Exclusive Lock을 통해 이를 구현한다.

  • InnoDB 스토리지 엔진에서는 Record lock 뿐 만아니라 Record 와 Record 사이의 간격을 잠그는 Gap lock이라는 것이 존재

레코드 락(Record Lock)

  • primary key, unique index로 조회해서 하나의 인덱스 레코드(=row)에만 lock을 거는 것을 의미
  • InnoDB 스토리지 엔진은 레코드 자체가 아니라 인덱스(특정 컬럼에 대해 데이터들이 정렬되어있는 자료구조)의 레코드를 잠근다.
  • InnoDB 테이블은 테이블 생성시 PK 인덱스를 명시적으로 지정하지 않아도 내부적으로 자동 생성된 클러스터 인덱스를 이용해서 잠금을 설정

갭 락(Gap Lock)

  • 레코드 자체가 아닌 레코드와 바로 인접한 레코드 사이의 간격만을 잠그는 락이다. 범위를 지정하기 위해 인덱스 레코드 사이 범위(gap)에 락을 거는 것
  • 레코드와 레코드 사이의 간격에 새로운 레코드가 생성(INSERT)되는 것을 제어하고, 넥스트 키 락의 일부로 사용된다.

넥스트 키 락(Next-Key Locks)

  • 레코드 락과 갭 락을 합쳐놓은 형태의 잠금으로 레코드와 그 레코드 앞의 갭 락을 포함한다.
  • 인덱스 레코드의 가장 처음 레코드의 이전, 그리고 마지막 레코드의 이후에 대해서도 gap 에도 잠금을 설정
  • REPEATABLE READ 격리 수준에서 팬텀 리드를 방지하기 위한 잠금이다.



3. MySQL의 격리 수준

격리 수준(isolation level)?

  • 하나의 트랜잭션 내에서 또는 여러 트랜잭션 간의 작업 내용을 어떻게 공유하고 차단할 것인지를 결정하는 레벨
  • 동시에 여러 트랜잭션이 처리될 때 특정 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있도록 허용할지 말지를 결정하게 됨.
  • 격리 수준은 크게 "READ UNCOMMITTED", "READ COMMITTED", "REPEATABLE READ", "SERIALIZABLE" 의 4가지로 나뉜다.

▶ 세 가지 부정합의 문제

  • 데이터베이스의 격리 수준을 이야기하면 항상 같이 언급되는 것으로 격리 수준의 레벨에 따라서 발생 여부가 달라 진다
  • DIRTY READ
    : 한 세션에서 진행 중인 트랜잭션이 완료되지 않았는데도 다른 세션에서 진행 중인 트랜잭션에서 해당 변경된 데이터를 볼 수 있는 현상
    : 즉 commit되지 않은 정보를 볼 수 있는 현상
  • NON-REPEATABLE READ
    : 한 트랜잭션에서 같은 쿼리를 두번 실행 했을 때 다른 값이 나오는 현상
    : 특정 데이터에 대한 수정이 발생하여 나타나는 현상이다.
  • PHANTOM READ
    : 다른 트랜잭션에서 수행한 변경 작업에 의해 레코드(테이블의 한 행)가 보였다가 안 보였다가 하는 현상
    : 결과 범위에 속하지 않은 레코드가 외부 작업에 의해 있을 수도 있고 없어질 수도 있다.
    : 한 트랜잭션 안에서 첫 번째 쿼리 수행 결과와 두 번째 쿼리 수행 결과가 다른 것, 이때 외부에 동시에 실행중인 트랜잭션의 INSERT 작업에 의해 발생하는 현상
    : 이를 방지하기 위해 쓰기 잠금을 걸어야 한다.

  • READ UNCOMMITTED은 일반적인 데이터베이스에서는 거의 사용하지 않는다.
  • SERIALIZABLE은 동시성이 중요한 데이터베이스에서는 거의 사용 되지 않는다. 왜냐하면 하나의 데이터 영역에서 READ가 발생할 때 어떤 DML도 발생할 수 없어 많은 문제점을 야기 시킨다.
  • 대부분의 RDBMS는 READ COMMITTED나 REPEATABLE READ로 고립성을 유지한다.

READ UNCOMMITTED

  • 각 트랜잭션에서의 변경 내용이 COMMIT이나 ROLLBACK 여부에 상관 없이 다른 트랜잭션에서 값을 읽을 수 있다.
  • 문제점
    : DIRTY READ 발생 → 데이터가 보였다가 사라졌다 하는 현상을 초래, 데이터 정합성에 큰 문제
    : 위의 그림처럼 Commit이 되지 않는 상태임에도 Update된 값을 다른 트랜잭션에서 읽을 수 있다.
  • MySQL 사용시 최소 READ COMMITTED 이상의 격리 수준 사용 권장(DBMS 표준에서 트랜잭션의 격리 수준 인정 안 함)

READ COMMITTED

  • Oracle Database에서 기본으로 사용되고 있는 격리 수준으로 온라인 서비스에서 가장 많이 선택되는 격리 수준이다.
  • Dirty Read와 같은 현상은 발생하지 않는다. → 한 세션의 트랜잭션에서 데이터를 변경하였을 때 COMMIT으로 트랜잭션이 완료되어야 다른 세션에서 변경된 데이터 조회 가능
  • MVCC
    : 하나의 데이터에 대해서 여러가지 버전이 존재하고 조회 하는 기능, Oracle과 MySQL 에서는 실제 테이블 값을 가져오는 것이 아니라 Undo 로그를 이용하여 구현
    : 위의 그림에서... 트랜잭션-1에서 데이터를 변경했는데 만약 트랜잭션-1이 COMMIT을 하지 않았을 경우, 트랜잭션-2에서는 이전 데이터 값(연두 테이블스페이스==언두 레코드==언두 세그먼트)을 계속 조회

  • 문제점 : NON-REPEATABLE READ 문제(REPEATABLE READ 가 불가능)

: 트랜잭션-1이 Commit한 이후 아직 끝나지 않는 트랜잭션-2가 다시 테이블 값을 읽으면 값이 BUSAN에서 JEJU로 변경됨을 알 수 있다.
: 하나의 트랜잭션내(BEGIN을 하여 트랜잭션-2를 시작한 이후)에서 똑같은 SELECT 쿼리를 실행했을 때는 항상 같은 결과를 가져와야 하는 REPEATABLE READ의 정합성에 어긋난다.
: 이러한 문제는 주로 입금, 출금 처리가 진행되는 금전적인 처리에서 주로 발생한다.
(ex. 만약 입출금 처리가 계속 진행될 때 다른 트랜잭션에서 오늘의 입금 총합을 조회한다고 하면 REPEATABLE READ가 보장되지 않아 해당 쿼리가 실행될 때마다 총합은 다른 결과가 나옴)


REPEATABLE READ

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

  • NON-REPEATABLE READ 부정합이 발생하지 않는다.

  • MVCC(Multi Version Concurrency Control)
    : 트랜잭션이 COMMIT을 하기 전에 다른 트랜잭션에서 해당 데이터를 조회시 Undo 로그 파일을 참조하여 이전 값을 보여주는 것
    : InnoDB 스토리지 엔진은 트랜잭션이 Rollback될 경우를 대비하여 Undo 공간에 백업해두고 실제 레코드 값을 변경한다.
    : Undo 영역에 백업된 이전 데이터를 이용해 동일 트랜잭션 내에서 동일한 결과를 계속 보여 줄수 있게 보장

  • MySQL에서는 트랜잭션마다 트랜잭션 ID를 부여하여 트랜잭션 ID보다 작은 트랜잭션 번호에서 변경한 것만 읽게 된다.

  • 백업된 데이터는 InnoDB 스토리지 엔진이 불필요하다고 판단하는 시점에 주기적으로 삭제한다. 이때 MVCC를 보장하기 위해서 실행 중인 트랜잭션 가운데 가장 오래된 트랜잭션 번호 보다 앞선 Undo 영역의 데이터는 삭제 할 수 없다.

  • 문제점
    : PHANTOM READ

: Undo에 백업된 레코드가 많아지면 MySQL 서버의 처리 성능이 떨어질 수 있다.
→ 한 사용자가 BEGIN 으로 트랜잭션을 시작하고 조회하고 장시간 트랜잭션을 종료 하지 않았다면 Undo 영역이 백업 된 데이터로 인하여 무한정 커질수도 있기 때문


SERIALIZABLE

  • 가장 단순한 격리 수준이지만 가장 엄격한 격리 수준
  • 성능 측면에서는 동시 처리성능이 가장 낮다.
  • SERIALIZABLE에서는 일반적인 DBMS에서 일어나는 PHANTOM READ가 발생하지 않는다. 그러나 읽기 작업도 공유 잠금(읽기 잠금)을 획득 해야만 하며, 동시에 다른 트랜잭션에서는 절대 접근 할 수 없는 상황이 됨.
  • InnoDB 스토리지 엔진에서 REPEATABLE READ 을 사용할 경우 갭 락과 넥스트 키 락 덕분에 "PHANTOM READ" 가 발생되지 않기 때문에 굳이 SERIALIZABLE 을 사용할 필요 없음


수정할 부분

lock에 대해 코드와 함께 더 수정


참고자료

헤시넷 위키_트랜잭션
블로그_트랜잭션과 잠금
블로그2_트랜잭션과 잠금
블로그3_트랜잭션의 격리 수준
블로그4_isolation level
블로그4_MySQL의 잠금
블로그5_InnoDB 스토리지 엔진 잠금
블로그6_Lock
유튜브_트랜잭션
유튜브2_트랜잭션

profile
SW Engineer 꿈나무 / 자의식이 있는 컴퓨터

1개의 댓글

comment-user-thumbnail
2023년 1월 29일

도움 많이 되었습니다!

답글 달기