트랜잭션 격리 수준과 전파 수준

허준현·2022년 7월 27일
2

Oracle

목록 보기
6/11
post-thumbnail

오늘은 트랜잭션의 전파 수준과 격리 수준에 대해서 이야기해보고자 한다.

트랜잭션에서의 격리는 한 트랜잭션에서 데이터가 수정되는 과정이 다른 트랜잭션과는 독립적으로 진행되어야 한다는 특성이다. 이 때 독립되는 수준을 4가지로 나눌수 있으며 각각에 대해 알아보자

트랜잭션 격리 수준 혹은 트랜잭션 읽기 일관성은 아래의 4가지와 같다.

  • READ UNCOMMITTED
  • READ COMMITTED
  • REPEATABLE READ
  • SERIALIZABLE

우선적으로 트랜잭션 격리 수준을 정할 때에는 동시성정합성이 반비례 한다는 것이다.
트랜잭션의 성능이 좋으면 특정 데이터에 대해서 일관성, 정합성 이 좋지 않지만 동시성이 좋지 않으면 그만큼 일관성 있는 데이터를 제공한다.

들어가기에 앞서 해당 격리 수준마다 발생하는 문제에 대해서 간단하게 정리하고자 한다.

  • 더티 리드 (Dirty read) : 생성, 갱신, 혹은 삭제 중에 커밋 되지 않은 데이터 조회를 허용함으로서, 트랜잭션이 종료되면 더 이상 존재하지 않거나, 롤백되었거나, 저장 위치가 바뀌었을 수도 있는 데이터를 읽어들이는 현상이다.
  • 반복 가능하지 않은 조회 (Non-repeatable read) : 한 트랜잭션 내에서 같은 행이 두 번 이상 조회됐는데 그 값이 다른 경우를 말한다.
  • 팬텀 리드 (Phantom read) : 한 트랜잭션 내에서 같은 쿼리문이 실행되었음에도 불구하고 조회 결과가 다른 경우를 뜻한다. (추가적인 레코드가 생기는 경우)

이제 트랜잭션의 격리 수준을 하나하나 살펴 보도록 하자.

READ UNCOMMITTED

해당 격리 수준은 커밋 전의 트랜잭션의 데이터 변경 내용을 다른 트랜잭션이 읽는 것을 허용한다.
각 트랜잭션의 변경 내용이 COMMIT이 되거나 ROLLBACK 이 되어도 상관 없는 격리 수준을 말한다.

위의 사진처럼 트랜잭션 작업이 완료되지 않은 상태임에도 다른 트랜잭션이 볼 수 있는
더티 리드가 발생하였다.
만일 위의 그림에서는 commit을 하였지만 rollback을 하게 된다면 해당 데이터는 엉뚱한 데이터를 읽어오기 때문이다. 따라서 무결성이나 일관성을 보장해주지 않기 때문에 사용을 지양한다.

하지만 데이터베이스나 페이지 레코드 단위로 잠금을 유지하는데 드는 비용이 제일 적고 교착 상태에 빠지지 않고 성능이 빠르기 때문에 변경이 자주 안되는 데이터를 집계하는데 사용한다.

READ COMMITED

커밋이 완료된 데이터에 대해서만 조회를 허용하는 방식이며 오라클에서 기본적으로 채택하고 있는 방식이며 온라인 서비스에서 가장 많이 선택되는 격리수준이다.

가져오고자 하는 데이터가 쿼리 시작 전에 변경되었다면 실제 버퍼 캐시, DB에 저장된 값을 가져오는 것이 아닌 UNDO 영역에 변경 전 데이터를 가져온다.

UNDO 버퍼가 어떤 역할을 하는지 까먹었다면 아래 링크를 참고하자.

UNDO 버퍼란?
결론적으로 undo 버퍼를 활용하여 트랜잭션의 롤백도 대비함과 동시에 격리 수준을 유지하면서 높은 동시성을 제공한다.

위의 사진은 한 트랜잭션 안에서 한 트랜잭션 안에서 하나의 레코드에 대해서 조회를 여러 번 했을 경우에 데이터 값이 다르게 나오는 Non-repeatable read 오류가 발생한 사진이다. 이는 undo 영역의 데이터를 가져왔지만 해당 데이터를 조작한 트랜잭션이 commit이 되면서 정합성이 불일치 하게 된 것이다.

추가적으로 Oracle 에서는 문장 수준의 읽기 일관성을 보장하고 다른 RDMS는 LOCK을 통한 트랜잭션 읽기 일관성을 제공한다.

MYSQL의 INNO DBsemi-consistent read를 사용하여 잠겨 있지 않은 행에 대해서는 여러 세션에서 다른 부분을 수정할 수 있게 한다.
같은 말로는 인덱스 레코드만 잠그고 갭락을 실행하지 않고 로우락을 사용하여 팬텀 리드 혹은 non-reaptable read가 발생한다.
(문장 수준의 읽기 일관성와 LOCK은 다음 포스트에서 자세히 알아보자)

REPEATABLE READ

트랜잭션마다 트랜잭션 ID를 부여하는데 해당 격리 수준을 사용하면 자신의 ID보다 작은 번호를 받은 트랜잭션에 대해서만 읽게 된며 mysql 에서 기본적으로 사양하는 격리 수준이다.
쉽게 말해서 자신의 트랜잭션이 시작되기 전의 커밋된 내용에 대해서만 조회 할 수 있는 것이다.


위에 상황은 아주 이상적인 상황이지만 만일 좌측 트랜잭션ID가 12가 아닌 9인 경우에는 그 전에 시작한 트랜잭션 이므로 insert가 발생했을 시에 범위 SELECT에 새로운 데이터가 생기는 PHANTOM READ가 발생한다 .

하지만 아이러니 하게도 InnoDBMVCC를 사용하여 해당 격리 수준에서 phantom read가 발생하지 않는다.

MVCC (MUITI VERSION CONCERENCY CONTROL) 란?
하나의 logical한 대상에 대해서 여러개의 physical한 버젼을 유지하고 있는 기법

쉽게 말하면 잠금을 사용하지 않은 일관된 읽기를 제공하는 기법이며 MVCC 전에는 레코드가 수정중이라면 읽기를 LOCK하여 일관성을 제공하였지만 UNDO LOG에 저장된 스냅샷을 활용하여 LOCK을 사용하지 않아도 되고 동시성을 높일 수 있다.

하지만 mysql에서 update 혹은 delete가 발생하였을 때는 log를 사용하기 보다는 해당 레코드 주변 레코드까지 롤백 혹은 커밋 전까지 잠그는 lock을 사용한다. 이런 락을 테이블 락 혹은 넥스트 키 락을 사용한다. 이는 read-commited 의 semi-consistent read와 대조를 이루는 부분이다.
semi-consistent read

처음에는 update에 대하여 undo 로그에 적재하기 때문에 그 전에 실행 되었던 트랜잭션에서 insert한 데이터에 대해서 phantom read를 막을 수 없다고 생각했다. 하지만 undo log에 스냅샷을 적재할 때 정합성을 높이기 위해 update 한 데이터만 가지는 것이 아닌 해당 주변 레코드도 저장하는 것 해서 정합성을 보장하는 것 같다.

😁추가적으로 postSQL Oracle의 MVCC는 버전의 생성과 관리방법이 다르며 자세한 사항은 아래의 블로그에 잘 설명되어 있다.
MVCC Oracle postSQL

update 부정합

REPEATABLE READ에서 undo log를 활용하여 일관성을 제공하기 위해서 부정합이 나타나는 경우도 있다. 아래의 경우를 확인해보자

START TRANSACTION; -- transaction id : 1
SELECT * FROM Member WHERE name='junyoung';

    START TRANSACTION; -- transaction id : 2
    SELECT * FROM Member WHERE name = 'junyoung';
    UPDATE Member SET name = 'joont' WHERE name = 'junyoung';
    COMMIT;

UPDATE Member SET name = 'zion.t' WHERE name = 'junyoung'; -- 0 row(s) affected (1)
COMMIT;

위의 경우에 최종적으로 name = joont가 된다.
2번 트랜잭션에서 이름을 joont로 바꾸고 commit 시에 junyoung을 undo 로그에 적재하게 된다.
이후 zion't 로 업데이트 하려고 해도 해당 데이터는 실제 테이블에 적재되어 있는 junyoung 이 아닌 undo 로그 안에 있는 데이터 이기 때문에 해당 데이터를 업데이트 할 수 없게 된다.
이를 update 부정합이라고 하며 더 자세한 설명은 아래 블로그를 참고하자
update 부정합

SERIALIZABLE

가장 단순한 격리 수준이지만 가장 엄격한 격리 수준을 말한다. 일반적으로 SELECT으로 데이터 블럭을 가져올 때 해당 블럭에 대해서 어떤 LOCK을 걸지 않은데 해당 격리 수준을 사용하게 되면 읽기 작업에도 SHARED LOCK 을 하게 된다.
성능 측면에서는 동시 처리성능이 가장 낮아서 데이터베이스에서 거의 사용되지 않는다.

오라클의 격리 수준에 대한 필자 생각

위에서 살펴본 것 처럼 오라클에서 기본적으로 사용하는 read-commited에서 여러가지 문제점이 발생한다는 것을 알았다.
하지만 현재 필자가 속해있는 프로젝트에서도 read-commited를 사용하는데 update부정합이나 팬텀리드와 이 외에도 dirty wirte, wirte skew와 같은 문제점을 어떻게 해결하는 것일까?
우선 오라클에서는 repeatable-read , serialize 단계를 곧 snapshot isoloation 단계라고도 하는데 이는 해당 건에 대해 내가 커밋을 했을 때 기존의 해당 컬럼에 대해 다른 트랜잭션이 커밋하여 값이 달라진다면 후순위 트랜잭션을 Rollback하는 것을 말한다. (참고로 mysql, postgresql에서의 serialize는 select만 해도 뒤에 for share 로 작동한다.)
그래서 오라클에서는 동일한 컬럼에 대해서 작업하는 경우에는 후순위의 트랜잭션을 롤백한다.

하지만 난 금융어플을 쓰면서 다시 보내라는 것을 본 적이 없는데..?

해당 건에 대해 롤백하게 되면 백앤드 단에서는 exception이 발생할 것이고 대규모 트래픽이 발생하는 어플에서는 이런 경우를 느낀 적이 없었다. 이런 경우에는 어떻게 처리하는 걸까?
이번에 카카오 서버에 장애가 발생했을 때 서버를 재부팅하게 되어도 기존에 exception이 발생한 요청에 대해서 계속 요청이 들어와 재부팅하는 동시에 뻗는 다는 동기의 말을 들은 적 있다.
따라서 필자가 생각하기로는 후행 트랜잭션이 데이터를 업데이트하는 데 롤백이 되는 경우 다시 기다리고 다시 요청을 하는 것 같다.

지금까지 트랜잭션 수준의 읽기 일관성 4가지에 대해 알아보았다.
추가적으로 스프링에서 트랜잭션에서 옵션으로 줄 수 있는 전파수준에 대해서 말하고자 한다.

7가지 전파 수준

1. REQUIRED

@Transactional(propagation = Propagation.REQUIRED)
public void getUsrIDSeq() {
    ... //이하 다른 전파수준도 동일한 틀을 지니고 있다.
}

default 속성으로써 모든 트랜잭션 매니저가 지원한다.
시작된 트랜잭션이 있으면 참여하고 없으면 새로 시작하며 동일한 메소드를 호출하면 같은 트랜잭션으로 묶이게 되며 예외가 발생하면 해당 메소드 호출 한곳에 롤백이 전파된다.

만일 대용량 트래픽에서 Seq 처럼 1씩 증가하는 메소드에 접근하게 된다면 1,2,3,4 로 증가하는 것이 아닌 1,1,2,2 처럼 동일하게 처리된다. 해당 문제는 아래의 required_new 로 해결할 수 있다.

2. REQUIRES_NEW

REQUIRES_NEW는 항상 새로운 트랜잭션을 시작해야 하는 경우에 사용할 수 있다. 해당 전파 수준을 사용하게 된다면 REQUIRES의 문제점을 보완 할 수 있다.

이 외에도 격리 수준을 SERIALIZABLE로 올린다거나 , 뮤텍스처럼 한 번에 하나의 프로세스만 들어오게 하는 임계 공간을 설정해 주는 것도 방법이다.

따라서 2개의 트랜잭션이 독립적으로 행동하며 예외가 발생해도 호출 한 곳에 콜백이 전파되지 않는다는게 REQUIRES와의 차이점이다.

하지만 JTA 트랜잭션 매니저를 사용한다면 서버의 트랜잭션 매니저에 트랜잭션 보류가 가능하도록 설정되어 있어야 한다.

3. SUPPORTS

SUPPORTS는 이미 시작 트랜잭션이 있으면 참여하고, 아니면 트랜잭션 없이 진행한다.
트랜잭션이 없기는 하지만 해당 경계 안에서 Connection 객체나 하이버네이트의 Session 등은 공유를 할 수 있다.
간단하게 LOL에서 바텀 라인전에서 서포터 혼자 할 수 없다! 라는 식으로 이해하면 편하다 💩

4. NOT_SUPPORTED

NOT_SUPPORTED는 이미 진행중인 트랜잭션이 있으면 이를 보류시키고, 트랜잭션을 사용하지 않도록 한다. 기본적으로 SUPPORTED 가 들어간 부분은 트랜잭션을 사용하지 않는다.

5. MANDATORY

MANDATORY는 한국어로 필수적인 이라는 뜻을 가지고 있다. 즉 시작된 트랜잭션이 있으면 참여하지만 트랜잭션이 시작된 것이 없으면 새로 시작하는 대신 예외를 발생시킨다.
즉, MANDATORY는 혼자서 독립적으로 트랜잭션을 진행하면 안되는 경우에 사용할 수 있다.

6. NEVER

NEVER는 이미 진행중인 트랜잭션이 있으면 예외를 발생시키며, 트랜잭션을 사용하지 않도록 강제한다.

7.NESTED

NESTED는 이미 진행중인 트랜잭션이 있으면 중첩 트랜잭션을 시작한다.

중첩 트랜잭션은 트랜잭션 안에 다시 트랜잭션을 만드는 것으로, 독립적인 트랜잭션을 만드는 REQUIRES_NEW와는 다르다.

NESTED에 의한 중첩 트랜잭션은 먼저 시작된 부모 트랜잭션의 커밋과 롤백에는 영향을 받지만, 자신의 커밋과 롤백은 부모 트랜잭션에게 영향을 주지 않는다.

예를 들어 메인 작업을 진행하며 이와 관련된 로그를 DB에 저장해야 한다고 할 때
로그를 저장하는 작업이 실패하더라도 메인 작업의 트랜잭션은 롤백되지 말아야 한다.
이렇듯 부모의 트랜잭션은 자식의 작업에 영향을 줘야하지만 자식의 트랜잭션은 부모에 영향을 주지 않아야 할 때 NESTED 전파 속성을 이용할 수 있다.

하지만 NESTED는 모든 트랜잭션 매니저에 적용 가능하지 않으므로 사용하는 트랜잭션 매니저와 드라이버 또는 WAS 등을 확인해야 한다.

참고 블로그

https://n1tjrgns.tistory.com/266

https://happyer16.tistory.com/entry/%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EC%A0%84%ED%8C%8C-%EC%86%8D%EC%84%B1-propagation-%EB%A1%A4%EB%B0%B1-%EC%98%88%EC%99%B8

https://developyo.tistory.com/250

전파, 격리 수준을 마치고 나서😃

처음에는 전파 및 격리 수준에 대해서 유투브로 처음 접하고 공부하게 되었다.
하지만 semi-consistent를 접하고 나서 트랜잭션 수준의 읽기 일관성문장 수준의 읽기 일관성 에 대해 알아보고 그와 연관된 다양한 LOCK에 대해서도 배우게 되었다. 따라서 다음 포스트는 이 LOCK과 읽기 일관성에 대해서 알아보자

profile
best of best

0개의 댓글