개발자 관점에서 DB 트랜잭션 이해하기

2021-05-25

.

Data_Engineering_TIL(20210524)

[학습자료]

youtube “최범균” 채널 “프로그래밍 초식 : DB 트랜잭션 조금 이해하기 01”와 “프로그래밍 초식 : DB 트랜잭션 조금 이해하기 02 격리” 영상을 공부하고 정리한 내용입니다.

** 영상 URL

  • 프로그래밍 초식 : DB 트랜잭션 조금 이해하기 01 : https://www.youtube.com/watch?v=urpF7jwVNWs

  • 프로그래밍 초식 : DB 트랜잭션 조금 이해하기 02 격리 : https://www.youtube.com/watch?v=poyjLx-LOEU

[학습내용]

  • 트랜잭션이란

개발자 입장에서 트랜잭션은 “여러 읽기/쓰기를 논리적으로 하나로 묶어주는것”을 말한다.

이 트랜잭션을 시작하고, 여러 쿼리를 실행하고, 그리고 마지막에 커밋이나 롤백하는 일련의 세단계로 구성이된다.

트랜잭션 시작 –> 여러가지 쿼리 실행 –> 커밋 또는 롤백

커밋이라는 행위를 하게되면 트랜잭션 시작과 커밋사이에 했던 모든 쿼리를 디비에 반영한다. 반대로 롤백을 하게되면 모두 반영하지 않게 된다.

이렇게 여러개의 읽기나 쓰기 쿼리를 논리적으로 하나로 묶어주는 것을 트랜잭션이라고 할 수 있다.

2

  • 트랜잭션이라는게 없으면 ..

예를 들어서 아래 그림과 같이 “1.2:update”를 성공하고, “1.3.:insert”를 하는데 이 과정에서 실패가 났다고 하자. 그러면 어플리케이션 개발자는 “1.2:update”를 어떤식으로던지 취소할 수 있는 코드를 구현해야 한다. 그러나 디비 트랜잭션이라는 개념이 있기 때문에 롤백하는 코드 한줄로 마무리 지을 수 있다. 따라서 디비 트랜잭션이라는 것은 어플리케이션 개발자가 데이터에 대해서 고민해야하는 것을 상당부분 없애준다.

3

  • 트랜잭션 범위는 커넥션을 기준으로 한다.

이 사실을 잘 인지하지 못하면 코드를 잘못 만들 수 있다. 예를 들어서 아래 그림과 같이 app1이라는게 커넥션1을 만들었다. 그다음에 2번에서 트랜잭션을 시작했다. 그 다음에 3번에서 뭔가 쿼리를 실행하고, 4번에서 svc라는 객체의 메서드를 호출했다. 그런다음에 5번에서 다시 실행을 했는데 뭔가 문제가 있다는 것을 인지해서 6번 롤백을 실행했다. 그러면 이 상황에서 기대를 하는 것은 3번과 5번에서 실행한 쿼리를 롤백하는 것 뿐만 아니라 4.3에서 실행한 쿼리도 롤백을 하고 싶은데 만약에 4번의 메소드를 실행하는 과정에서 4.1처럼 새로운 커넥션을 만들고 그 커넥션에 트랜잭션을 시작하고 커밋을 했다면 4.3은 이미 커밋이 된 상태이다. 그래서 롤백되지 않는다. 트랜잭션의 범위가 커넥션을 기준으로 한다는 것을 모른다면 의도하지 않게 어플리케이션이 동작하는 실수를 할 수 있다. 이 얘기는 뭐냐 반대로 말하면 여러 메소드를 호출할때 다양한 메소드를 하나의 트랜잭션으로 묶고 싶다면 여러 메소드에서 하나의 커넥션을 사용할 수 있는 방안이 필요하다는 것을 뜻한다.

4

  • 그래서 나온 개념이 트랜잭션 전파라는 것이다.

트랜잭션 전파는 여러 메소드 호출이 한 트랜잭션에 묶이도록 해준다. 일반적으로 개발자가 이거를 직접 구현하지는 않는다. 프레임워크가 트랜잭션 전파 기능을 제공해준다.

예를 들어서 아래 그림과 같이 스프링에서는 “@Transactional”이라는 것을 붙이면 이거를 붙인 메소드와 그 메소드에서 호출하는 또다른 메소드가 모두 하나의 트랜잭션으로 묶이도록 해준다. 아래 예시에서는 create라는 메소드가 checkDuplicate 메소드를 호출하고 checkDuplicate는 스프링에서 제공하는 jdbc 템플릿을 이용해서 뭔가 쿼리를 실행할 것이다. 그리고 create 메소드에서 insert 메소드를 호출하는데 그 insert라는 메소드도 역시 스프링에서 제공하는 jdbc 템플릿을 이용해서 뭔가 쿼리를 실행할 것이다. 이 경우에 create 메소드는 스프링에서 제공하는 전파기능을 활성화하고 checkDuplicate이나 insert 메소드에서 사용하는 jdbc 템플릿을 create 메소드를 실행할때 생성된 트랜젝션을 같이 공유해서 쿼리를 실행한다.

이런식으로 트랜잭션 전파기능을 사용하면 메서드 간에 커넥션 객체를 전달하지 않아도 여러 메소드 호출을 한 트랜잭션으로 묶어서 실행할 수 있게된다. 개발자 입장에서 커넥션을 일일히 전파하는것은 괴로운 일이다.

5

  • 트랜잭션과 외부 연동

트랜잭션 범위 안에 외부연동이 섞여 있으면 롤백처리에 주의해야 한다.

아래 왼쪽 그림에서는 트랜잭션을 시작하고 업데이트, 인서트 한다음에 외부 api를 호출했는데 호출을 실패했다. 그러면 롤백을 할 것이다. 이 과정에서 롤백을 하면 2번과 3번이 같이 롤백되기 때문에 큰 문제는 없어보인다. 아래 오른쪽 그림은 반면에 2번에서 업데이트를 치고, 3번에서 외부 api를 호출하는데 성공했다. 그런다음에 4번에서 insert를 하는 과정에서 문제가 발생했다. 그래서 롤백을 했다. 롤백할때 2번은 롤백이 되겠지만 3번에서 외부 api 호출은 이미 성공했고 이거는 돌이킬 수 없는 프로세스다. 따라서 이와 같은 상황에서 롤백을 하더라도 외부 api도 상태를 복구할 수 있는 어떤 과정이 추가적으로 필요하다.

왼쪽 그림이라고 해서 오른쪽과 같은 상황이 안일어날거라는 보장도 없다. 예를 들어서 왼쪽에 4번 과정에서 외부 api를 호출했는데 그 외부 api에서 리턴은 성공했는데 그 순간에 갑자기 네트워크 문제가 발생해서 타임아웃이 났다던가하는 일이 발생할 수 있다. 그러면 롤백이 발생할 것이다. 그러면 외부 api 호출은 성공했는데 어플리케이션에서 롤백이 발생해버리면 결국에는 오른쪽 그림과 같은 상황이 발생하는 것이다.

따라서 어플리케이션에서 롤백한 다음에 외부 api 시스템에 대한 상태를 원복하는 방법에 대해서 고민을 잘 해야한다.

6

  • 글로벌 트랜잭션

글로벌 트랜잭션은 두개 이상의 자원(디비, 메세징큐 등)을 하나의 트랜잭션으로 묶어주는 개념이다. 예를 들어서 서로 다른 디비에 대해서 하나의 트랜잭션으로 묶어서 처리할 수 있다. 또는 디비에 인서트를 하고, 메세지를 보내라고 큐에다가 append를 하는 것을 하나의 트랜잭션으로 할 수 있다는 것이다. 따라서 디비에 인서트하는게 실패했다면 메세지 보내는 것도 롤백이 된다거나 혹은 메세지 보내는게 롤백이 된다면 디비 인서트도 롤백이 된다는 것이다.

하지만 단점도 있는데 단일자원에 대해서 트랜잭션 처리할때와 비교했을때 성능이 떨어진다. 또 서비스와 마이크로 서비스로 개발을 하다보면 아키텍처적으로 이거를 사용할 수 없는 구조인 경우가 있다. 그래서 결론적으로 글로벌 트랜잭션은 거의 사용하지 않는다.

일반적으로는 여러 자원에 대해서 데이터 처리가 필요한 경우에는 글로벌 트랜잭션을 사용하기 보다는 이벤트나 비동기 메세징과 같은 다른 수단을 고려한다.

  • 트랜잭션의 원자성

all or nothing : 다 처리를 하거나 다 처리를 하지 않거나

  • 트랜잭션의 범위는 중요하다. 왜냐하면 어플리케이션에 문제가 발생했을때 롤백해야하는 범위를 정하는 기준이 되기 때문이다.

  • 같은 데이터에 동시에 접근하는 동시성은 문제가 될 수 있다.

** 아래 그림에서 A : 검정얼굴, B : 하얀얼굴

예를 들어서 당직 담당자를 최소 1명 유지해야 한다는 규칙이 있다고 가정하자. 그런데 오늘따라 유난히 담당자가 몸이 안좋아서 당직을 할수없는 상황이 발생했다. 이럴때 A가 먼저 담당자가 둘이 있는지 쿼리를 날려서 확인했다. 확인을 했는데 마침 둘이어서 자기는 쉬어도 될거 같다는 판단을 했다. 그래서 곧이어 업데이트 쿼리를 날려서 본인을 담당자에서 제외를 시켰다. 그리고 커밋을 했다. 그런데 운이 없게도 그와 거의 유사한 시점에 B도 오늘 약속이 있어서 담당자를 할 수 없는 상황이었다. 그래서 마찬가지로 select 쿼리를 날렸는데 담당자가 둘이라는 것을 확인한 다음에 역시 본인을 담당자에서 제외하는 쿼리를 날렸다. 그리고 커밋을 했다. A와 B는 고의로 그런건 아니지만 남은 담당자가 0이 되버렸다. 이런식으로 데이터를 동시에 접근할때 문제가 발생할 수 있다.

7

  • 경쟁상태(race condition)

여러 클라이언트가 같은 데이터에 접근하는 상태를 말한다. 이런 경쟁상태일때 데이터가 원하는 상태가 되지 않는 문제가 발생할 수 있다. 이 문제를 해결하기 위해서 트랜잭션을 격리하는 방법을 사용한다. 트랜잭션 격리는 다른 트랜잭션 때문에 내가 진행하고 있는 트랜잭션이 영향을 받지 않도록 하는 것을 말한다. 격리의 가장 기본적인 방법은 모든 트랜잭션을 줄을 세워서 순서대로 실행하는 것이다. 그러나 한번에 한개의 트랜잭션만 처리하게 되면 전반적인 처리성능이 매우 떨어지는 문제가 있다. 그래서 일반적으로는 상황에 따라 여러가지 격리방법을 적용하는 편이다. 여기서 여러가지 격리방법은 크게 4가지가 있다. Read Uncommited, Read commited, Repeatable Read, Serializable. 여기서 Read Uncommited는 사실상 사용하지 않는 방법이다.

  • 동시성과 관련한 여러 문제들

1) 커밋되지 않은 데이터 읽기

2) 커밋되지 않은 데이터 덮어쓰기

3) 읽는 동안 데이터 변경

4) 변경 유실

5) 기타등등

  • 커밋되지 않은 데이터 읽기 (dirty read)

stock이라는 테이블에 레코드가 하나가 있고, stock cnt라는 테이블에 cnt값이 1로 저장되어 있다고 하자. 윗쪽에 있는 사용자가 stock 테이블에 인서트를 하나 했다. 그러면 그 시점에 stcok 레코드는 두개로 바뀐다. 그리고 레코드를 하나 넣었으니까 stockcnt에서 cnt를 1에서 2를 바꾼다. 그런데 위에 사용자가 이 두쿼리를 실행하는 사이에 아래에 사용자가 stock 테이블의 데이터를 조회를 했더니 2가 나왔는데 stockcnt에서 cnt를 조회했는데 1이 나왔다. 올바르게 데이터가 조회된 상황이 아닌것이다. 이렇게 커밋되지 않은 데이터를 읽을때 조회한 데이터가 손상되는 문제가 발생하게 된다.

8

  • 커밋되지 않은 데이터 덮어쓰기 (dirty write)

커밋되지 않은 데이터를 덮어쓸때도 문제가 있다. 윗쪽에 있는 사용자가 asset이라는 테이블에 R1인 행의 값의 오너를 A로 바꾸었다. 그런 다음에 asset이라는 테이블에 R2인 행의 값의 오너를 A로 바꾸었다. 결론적으로 R1과 R2의 오너를 A로 바꾼것이다. 그런데 아래에 있는 사용자가 절묘하게 위에 사용자가 두개의 쿼리를 날리는 그 사이에 asset이라는 테이블에 R1인 행의 값의 오너를 B로 바꾸고 그런 다음에 R2인 행의 값의 오너를 B로 바꿔버렸다. 그러면 DB에는 R1의 오너는 B일 것이고, R2의 오너는 A가 될 것이다.

9

  • dirty read와 dirty write가 발생하지 않도록 하기위해 DB는 Read commited 격리개념을 제공한다.

커밋된 데이터만 읽는 개념이다. 그리고 커밋된 데이터만 덮어쓰기를 한다. 커밋되지 않은 데이터에 대해서는 읽기나 덮어쓰기 자체를 하지 않는다. 어떻게 이를 구현하냐면 디비는 커밋된 값과 트랜잭션 진행중인 값을 따로 보관한다. 그래서 트랜잭션 진행중인 값은 읽지 않는 처리를 한다. 그리고 커밋된 데이터만 덮어쓰기는 디비에서 어떻게 구현했냐면 레코드(행) 단위로 잠금을 사용한다. 그래서 같은 데이터를 수정하는 트랜잭션이 끝날때까지 대기하고 있다가 그 다음에 데이터를 수정한다.

  • 읽는 동안에 데이터가 바뀌면 또 문제가 될 수 있다. (read skew)

데이터를 읽는 시점에 따라 데이터가 유효하거나 유효하지 않을 수 있다. 아래 그림과 같이 DB에 points라는 테이블에 A는 10, B는 10이라는 데이터가 저장되어 있다고 가정하자. 이때 위에 있는 사용자가 A의 포인트 값을 조회했다. 그러면 A는 10이 조회될 것이다. 그리고나서 아래의 사용자가 A의 값을 10에서 11로 증가시켰다. 그러면 아직 커밋은 하지 않았지만 아래 사용자 입장에서는 A는 11이 될 것이다. 그런 상태에서 아래의 사용자가 B의 값을 10에서 9로 1감소시켰다. 이 상태에서 커밋을 하면 디비에는 A는 11, B는 9가 저장될 것이다. 이후에 위에 사용자가 B의 포인트를 조회했다. 그러면 9가 조회될 것이다. 이렇게 되면 위에 사용자 입장에서는 B를 변경하지 않았는데 B의 데이터가 변경되는 의도하지 않는 데이터 손실 문제가 발생할 수 있다.

10

  • 트랜잭션이 진행되는 동안에 변경된 데이터를 읽으면서 발생하는 앞선 문제는 Repeatable Read 이라는 격리 개념으로 해결한다.

트랜잭션이 진행되는 동안에는 데이터가 변경되더라도 같은 데이터를 읽게 해주는 것이다. 이는 아래 그림과 같이 데이터를 버저닝해서 읽는 시점에 특정 버전에 해당하는 데이터만 읽도록 한다.

12

  • Read commited과 Repeatable Read 개념을 적용해도 또 다른 문제가 발생할 수 있다. 변경한 데이터가 유실될 수 있는 문제가 있다. 이를 Lost Update라고 한다. 일반적으로 같은 데이터를 업데이트를 하려고 할때 발생한다. 예를 들어보자. 위에 사용자가 id가 1인 아티클을 조회했다. 이때 readcnt가 1였다. 그리고 바로 그 다음에 아래의 사용자도 똑같은 쿼리를 날려서 readcnt가 1인 것을 확인했다. 그 다음에 위에 사용자가 id가 1인 아티클의 readcnt를 2로 업데이트 했다. 그리고 아래 사용자 역시 readcnt값을 2로 업데이트 하려고 한다. 물론 같은 데이터를 업데이트 치기 때문에 디비의 잠금 개념 때문에 아래의 사용자는 윗쪽 사용자가 커밋을 할때까지 업데이트 쿼리가 지연이 된다. 그러면 위에 사용자가 커밋을 완료하면 대기하고 있던 아래사용자의 업데이트 쿼리가 실행이 될텐데 그리고 아래 사용자가 커밋을 날렸다. 이렇게 되면 아래의 사용자가 기대하는 것은 2가 아니라 3이 되야 하는데 실제로는 2가 될 것이다.

13

변경유실에 대한 몇가지 처리방법이 있는데 일반적으로는 아래의 세가지 방법을 사용한다.

1) 원자적 연산 사용

디비가 지원하는 원자적 연산을 사용하는 것이다. 그러면 디비가 동시에 데이터를 수정하는 요청에 대해서 순차적으로 처리한다.

ex) update article set readcnt = readcnt+1 where id = 1

2) 명시적인 잠금

데이터를 조회할때 수정할 행을 명시적으로 미리 잠궈버리는 것이다. 그런 다음에 데이터를 수정하고 마지막에 데이터를 커밋하거나 롤백할때 이 잠금을 해제하는 것이다. 따라서 데이터가 잠겨져있는 동안에는 다른 트랜잭션은 데이터를 select 조차 하지 못하게 막아버린다.

ex) select … for update

14

3) CAS(Compare And Set(비교한 다음에 업데이트 치는 개념))

데이터를 수정할때 값이 같은지 비교해서 같은 경우에만 실제 변경이 발생하도록 하는 방식이다.

15

  • 읽는 동안에 데이터가 바뀌면 또 문제가 될 수 있다. (read skew와는 다른 케이스)

하나의 트랜잭션의 결과가 다른 트랜잭션의 쿼리결과에 영향을 주는 경우

물리적으로 같은 데이터를 쓰지 않지 않지만 논리적으로는 당직자가 1명이 되면 안되는데 둘다 당직을 안한다고 false했으니 경쟁상태라고 할 수 있다.

16

이런 문제를 해결할 수 있는 격리 기법이 Serializable이다. 트랜잭션을 순서대로 처리한다는 개념인데 일반적으로 인덱스 기반의 잠금이나 where 절의 조건 기반의 잠금을 사용해서 구현한다. 인덱스 기반의 잠금으로 Serializable을 구현했다고 가정하자. 그리고 위에 사용자가 select count 쿼리를 날리는데 이때 calldate가 인덱스로 잡혀있다고 가정하자. 그리고 위쪽 사용자가 calldate==’2021-03-20’ 인덱스에 대해서 업데이트 처리를 하였다. 그러면 해당 인덱스에 대한 잠금을 얻은 것이다. 이와 거의 동시에 아래의 사용자가 calldate==’2021-03-20’ 인덱스에 대해서 업데이트를 치려고 하는데 이미 위쪽 사용자가 잠금을 얻었기 때문에 아래 사용자의 쿼리는 잠금에 실패한다. 그래서 업데이트 쿼리를 실행할 수 없게 된다. 그리고 위에 사용자는 커밋을 날려서 쿼리를 반영하게 된다. 반면에 아래 사용자는 쿼리의 잠금이 실패했기 때문에 해당 쿼리는 실패가 뜨게되고 롤백을 하거나 쿼리가 실패한채로 커밋을 할 것이다.

17

  • 동시성 문제를 다룰 때면 따라서 사용하는 디비가 어떤 격리 개념을 제공하는지 확인을 반드시 해야한다. 그리고 가능하면 잠금시간은 최소화 하는 것이 좋다. 왜냐하면 잠금 시간이 길어질수록 데이터 처리량이 떨어지기 때문이다.