JPA 는 Transaction 을 쉽게 제어할 수 있도록 @Transactional 어노테이션을 제공한다. 쉽게 사용할 수 있지만, 깊은 이해를 위해서는 그 이면을 살펴봐야한다.


Transaction 이란?

trans·ac·tion

  1. 거래, 매매
  2. 처리 (과정)

사전적 의미로 거래나 매매, 그리고 처리(과정) 라는 의미로 풀이되는데, 컴퓨터공학에서 표현하는 의미로는 처리(과정) 이 적합한 듯 하다.

컴퓨터공학에서 Transaction 이란, 하나로 묶은 DB 작업의 단위 이다. 하나로 묶는 범위는 작업자가 임의대로 지정할 수 있다.

영화 예매를 처리하는 과정을 생각해보자.

  1. 현재 상영관의 빈 자리 목록을 요청한다.
  2. 좌석을 선택하여 결제한다.
  3. 빈 자리 목록에 예매에 성공한 자리가 노출되지 않는다.

위 과정은 각각 DB에 서로 다른 요청을 보낸다. 1번은 빈 자리 목록을 불러오는 SELECT 요청을 진행할 것이고, 2번과 3번은 결제 및 예매 내역에 대한 INSERT 혹은 UPDATE 명령을 수행 할 것으로 보인다.

예매를 성공하기 위해서 위 과정 중 어느 하나라도 잘못되어서는 안된다. 즉, 하나의 단위로 관리되어야 한다.

이렇게 DB Command 는 여러가지로 나누어지지만 개념적으로 하나의 단위로 묶어야 하는 처리 과정을 Transaction 이라고 한다. 그렇다면, Transaction 은 어떤 특성이 있을까? ACID 라는 약자로 Transaction 이 가져야하는 특징을 설명할 수 있다.


Atomicity (원자성)

위의 3가지 과정은 예매를 하기 위한 필수 과정이다. 더 이상 나눌 수 없고, 부분적으로 수행할 수 없다. 예매를 위한 최소 단위이다.

Consistency (일관성)

70개의 빈 좌석과 30개의 예매된 좌석이 있을 때, 새로운 Transaction 의 결과가 어떻게 처리되던 간에 데이터의 총 값을 일관되게 보장해야 한다. 가령 예매를 하고 난 이후 빈 좌석이 69개가 되었다면, 예매된 좌석은 31개가 되어야 한다.

Isolation (독립성)

어느 사용자의 예매가 진행 중이라면, 그 과정은 다른 사용자들의 예매 과정과 독립적이어야 한다. 다시 말해, 내가 선택한 좌석을 결제하려고 할 때 이 과정에 다른 사용자가 참여하여 가로채는 일이 없다는 것을 보장하는 성질이다.

Durability (지속성)

예매가 완료되고 난 후 그 결과가 영구적으로 지속되는 것을 의미한다. 예매 이후에 시스템 장애가 발생하더라도, 예매가 완료되었다는 사실을 확인할 수 있어야 한다.


Transaction 이 ACID 를 보장하는 방법 이해하기

Transaction 은 애플리케이션 개발을 할 때 개발자가 비즈니스 로직에 지정한 논리적인 단위 묶음이다. Transaction 을 시작하고 싶은 곳에 시작을 알리고, 종료하고 싶은 곳에 종료를 알린다. 그렇게 설정된 논리적인 단위 묶음 내에서 발생하는 데이터베이스 연산은 Transaction 이 정한 규칙에 따라 이루어지게 된다.


DB Lock

실제로 데이터의 ACID 를 보장하기 위해서는 데이터베이스가 데이터를 다루는 방식을 조정해야 한다. Transaction 은 ACID 를 보장하기 위하여 데이터베이스가 지원하는 동시성 관리 메커니즘(lock)을 이용한다. 따라서 DB 의 lock 에 대하여 공부를 하면, Transaction 이 어떻게 ACID 를 보장하도록 만드는지 알 수 있다.

InnoDB 는 많은 종류의 lock 타입 을 지원하고 있다. 대표적인 lock 에 대하여 알아보자.

Row level locks

가장 기본적으로 InnoDB 는 2 가지 타입의 표준적인 lock 을 구현하고 있다. 첫번째는 Shared lock (s lock) 이고, 두번째는 Exclusive lock (x lock) 이다. InnoDB 는 이 두가지 동시성 관리 메커니즘을 가지고 인덱스가 걸린 Record, 지정된 범위 내에 존재하는 Record 등에 lock 을 수행한다.

Shared lock

SELECT * FROM example WHERE example.id = 1 FOR SHARE;

일반적인 SQL SELECT 문은 읽기 연산 이후에 해당 row 에 접근하는 것에 아무런 제약을 걸지 않지만, s lock 을 수행하는 명령어인 FOR SHARE 을 입력하면 해당 row 의 Transaction 이 종료되기 전까지 다른 Transaction 에서 수행되는 DB write 연산이 수행될 수 없다. 단, read 연산에 대해서는 제약을 걸지 않는다.

Exclusive lock

SELECT * FROM example WHERE example.id = 1 FOR UPDATE;

s lock 이 read 연산에 대해서 제약을 걸지 않는 반면, x lock 을 수행하는 명령어인 FOR UPDATE 를 입력하면 해당 row 의 Transaction 이 종료되기 전까지 다른 Transaction 에서 수행되는 모든 DB 연산이 수행될 수 없다.

Transaction (T1) 이 데이터베이스의 한 row 에 대하여 s lock 을 걸었다고 해보자. 이 때, 다른 Transaction (T2) 는 해당 row 에 대하여

  • s lock 을 거는 것이 허용된다. 결과적으로 하나의 row 에 대하여 T1 과 T2 모두 s lock 을 걸 수 있다. 즉, T1 이 끝나기 전에도 해당 row 를 다른 Transaction 이 읽을 수 있다.
  • x lock 을 거는 것이 허용되지 않는다. 즉, T1 이 끝나기 전에는 해당 row 를 다른 Transaction 이 수정하거나 삭제할 수 없다.

InnoDB 는 이렇게 2 가지 종류의 lock 전략으로, lock 을 거는 대상을 달리하는 여러가지 종류의 lock type 을 소개하고 있다.

공식문서 에 Row level locks 와 기타 다른 lock 이 함께 소개되어 있는데, Row level locks 는 다른 lock 에서 사용될 lock 의 전략을 설명하는 것이다. Row level locks 를 이용하여 기타 다른 lock 전략 (Intension locks, Record locks, Gap locks …) 을 만든다.


Record locks

Record locks 는 index 에 거는 lock 이다. 예를 들어서, SELECT c1 FROM t WHERE c1 = 10 FOR UPDATE; 라는 구문이 있다고 하자. c1 컬럼이 인덱스로 지정된 컬럼일 때, c1 의 값이 10 인 인덱스 Record 에 lock 이 걸린다. 즉, c1 이 10인 Record 는 insert, update, delete 가 불가능하다.

Record locks 는 테이블에 정의된 index 가 존재하지 않더라도 수행되게 된다. InnoDB 가 primary key 를 정의하지 않은 테이블에 대해 clustered index 를 임의로 생성하기 때문이다.


Gap locks

Gap locks 는 쿼리로 검색된 index Record 들의 사이 간격에 존재하는 범위에 걸리는 lock 이다. 예를 들어서 SELECT c1 FROM t WHERE c1 BETWEEN 10 and 20 FOR UPDATE 는 10과 20 사이의 값이 c1 컬럼에 insert 되는 것을 막는다.


Isolation Level

Transaction 은 ACID 를 보장하기 위하여 데이터베이스가 지원하는 동시성 관리 매커니즘(lock) 을 이용한다고 했다. 이 때, 얼마나 철저히 ACID 를 보장할 것인지 혹은 어떤 방식으로 ACID 를 보장할 것인지에 대한 수준을 설정할 수가 있다. 이 것이 바로 Isolation Level 이다.

Isolaction Level 을 설정하는 것은 여러 Transaction 이 동시에 변경을 수행하고 쿼리를 수행할 때 성능과 안정성, 일관성 및 결과 재현성 간의 균형을 조정하는 것이다. Isolation Level 을 최대치로 올려서 ACID 를 강력하게 보장하겠다는 것은 곧 동시성을 위해 성능을 포기하겠다는 말이 된다. 즉, 성능과 안정성 사이에는 trade-off 가 존재함을 유념하여 Isolation Level 을 설정할 필요가 있음을 미리 알아두자.

MySql 은 SQL 표준으로 정의되어 있는 4가지 Isolation Level 을 모두 지원한다.

  1. READ UNCOMMITTED
  2. READ COMMITTED
  3. REPEATABLE READ
  4. SERIALIZABLE

위와 같은 순서로 ACID 가 보장되는 정도를 달리한다. 1순위가 가장 덜 보장하는 것이고, 4순위가 가장 엄격하게 ACID 를 보장하고 있다고 생각하면 된다.

https://mydbops.wordpress.com/2018/06/22/back-to-basics-isolation-levels-in-mysql

이제 한 가지씩 세부적으로 알아보도록 하자.


READ UNCOMMITTED

READ UNCOMMITTED 는 커밋되지 않은 Record 를 읽을 수 있는 수준의 Isolation Level 이다. 여러 Transaction 사이에 존재하는 isolation 이 없는 것이므로, lock 을 사용하지 않는다. lock 을 유지하기 위한 overhead 가 필요없기 때문에 높은 성능을 보장한다. 하지만 DIRTY READ 가 발생할 수 있음에 유념해야 한다.

DIRTY READ

https://mydbops.wordpress.com/2018/06/22/back-to-basics-isolation-levels-in-mysql

위의 그림에서 보듯이, Tread 1 이 데이터를 var = 50 의 데이터를 var = 100 으로 변경한 후 UPDATE 쿼리를 실행했다. 이 때, Tread 2 가 해당 Record 에 접근하면 READ UNCOMMITTED Isolation Level 에서는 Transaction 간에 Isolation 을 설정하지 않기 때문에 데이터에 접근할 수 있다. 따라서 var = 100 데이터를 가지고 올 수 있다. 그런데 여기서 Tread 1 에서 실행했던 Transaction 이 commit (종료) 되지 않고, roll back 되었다면 var 는 100 이 아니라 초기의 값인 50 으로 되돌아 간다. Tread 2 에서는 이 사실을 알 수 없고 var = 100 기준으로 작업을 수행하게 된다. 이것이 바로 DIRTY READ 이다.


READ COMMITTED

READ COMMITTED 는 커밋된 Record 를 읽을 수 있는 수준의 Isolation Level 이다. 커밋된 데이터만 읽을 수 있기 때문에, READ UNCOMMITTED 에서 발생하는 DIRTY READ 를 피할 수 있다. READ COMMITTED 는 MySql 을 제외한 많은 RDBMS 소프트웨어에서 기본 설정으로 사용된다. MySql 은 곧 살펴볼 REPEATABLE READ 를 기본 설정으로 사용하고 있다.

다시 돌아와서, READ COMMITTED 는 각각의 SELECT 쿼리에서 커밋된 데이터만을 읽어오기 위해 해당 SELECT 쿼리가 실행되기 전 마지막 커밋된 데이터의 스냅샷을 읽어온다. 여기서 한 가지 문제점이 발생할 수 있다. 만약 하나의 Transaction 내에서 여러개의 SELECT 쿼리가 실행되었다고 해보자. 그렇다면 실행된 각각의 SELECT 쿼리는 쿼리가 실행되기 전 마지막 커밋된 데이터의 스냅샷을 읽어오기 때문에, 다른 결과를 보여줄 수 있다.

이러한 현상을 NON REPEATABLE READS 라고 한다.


REPEATABLE READ

REPEATABLE READ 는 InnoDB 의 기본 Isolation level 이다. Transaction 내에 첫번째 SELECT 쿼리에 대한 Snapshot 결과를 반복적으로 사용하여 해당 Transaction 내에서는 동일한 결과를 읽어올 수 있다. Transaction 이 실행되고 있는 동안에는 다른 Transaction 에 의한 UPDATE, DELETE 쿼리의 결과가 Commit 되더라도 수행되고 있는 Transaction 에는 그 결과가 반영되지 않는다. 이와 같이 일관된 읽기를 보장하기 위해서 Snapshot 을 유지해야 하기 때문에 추가적인 overhead 가 다소 발생할 수 있다.

REPEATABLE READ 는 Row level locks(FOR UPDATE, FOR SHARE)를 사용한 READ 연산과 UPDATE, DELETE 문에 대해서 lock 을 걸게 된다. lock 을 거는 방식은 SELECT 문에서 사용한 검색 조건에 따라 달라진다.

  • 단일한 검색 조건을 사용한 검색의 경우, InnoDB 는 오직 검색된 index record 에 대해서만 lock 을 건다. 이 경우 테이블에 대한 INSERT 자체를 막는 것은 아니기 때문에 side-effect (PHANTOM READ) 가 발생할 수 있다.
  • 다른 검색 조건에 대해서는, InnoDB 는 검색된 인덱스 범위에 gap locks 혹은 next-key locks 를 사용하여 lock 을 건다. 해당 범위 내에 INSERT 가 발생하는 것을 막는다.

위에서 잠시 언급했지만 REPEATABLE READ 는 단일 검색조건의 경우 INSERT 문에 대한 lock 을 걸지 않기 때문에 PHANTOM READ 라는 문제점을 일으킬 가능성이 있다.

PHANTOM READ

Transaction 1 이 단일한 검색 조건을 사용하였고, 그에 대한 결과가 다른 Transaction 내에서 실행되는 INSERT 에 의해서 결과가 달라지는 경우를 PHANTOM READ 라고 한다.


SERIALIZABLE

SERIALIZABLE 은 REPEATABLE READ 와 유사하지만, autocommit 조건이 비활성화된 경우에 암묵적으로 모든 SELECT 문에 FOR SHARE 를 수행한다. 만일 autocommit 이 활성화되어 있다면, SELECT 문은 그 자체가 Transaction 이 된다. 모든 SELECT 문에 FOR SHARE 를 강제함으로써 다른 Transaction 에 의한 변경 및 추가를 막는다.

가장 엄격한 Transaction isolation level 이며, 그만큼 데이터 동시 처리 능력이 떨어진다.


Spring JPA 에서 Transaction isolation level

2 편에서 계속.



References

  • https://dev.mysql.com/doc/refman/8.0/en
  • https://mydbops.wordpress.com/2018/06/22/back-to-basics-isolation-levels-in-mysql