개발자로 후회없는 삶 살기

spring PART.트랜잭션 이해 본문

[백엔드]/[spring | 학습기록]

spring PART.트랜잭션 이해

몽이장쥰 2023. 5. 3. 00:31

서론

※ 이 포스트는 다음 강의의 학습이 목표임을 밝힙니다.

https://www.inflearn.com/roadmaps/373

 

https://www.inflearn.com/roadmaps/373

 

www.inflearn.com

 

본론

- 트랜잭션 개념

데이터를 보관할 때 회원가입하면 회원 데이터를 보관할 때 db에 저장하는 이유는 db가 트랜잭션을 지원해서 그렇습니다. 트랜잭션은 이름 그래도 번역하면 거래라는 뜻으로 db에서 트랜잭션은 하나의 거래를 안전하게 처리하도록 보장해주는 의미입니다. 하나의 거래를 안전하게 처리하려면 고려할 게 많습니다.

 

예를들어서 단순하게 회원을 가입하면 insert하면 되지만 A의 5000원을 B에게 이체한다고 치면 A의 잔고는 5000원 감소하고 B는 증가해야하는 것입니다. 5000을 이체하는 것이 하나의 트랜잭션, 하나의 거래이고 사실 A의 증가와 B의 감소 2개로 이뤄진 것입니다.

 

이체라는 거래는 이렇게 2가지 작업이 합쳐져서 하나의 작업처럼 동작해야 합니다. 만약 1번은 성공했는데 2번에서 문제가 발생하면 이체는 실패하는 것입니다. 근데 1만 성공하고 2는 실패하면 A의 잔고만 5000원 감소하고 B는 증가하지 않고 끝나는 심각한 문제가 발생합니다.

 

> db가 제공하는 트랜잭션은 1, 2를 두개의 쿼리를 둘 다 함께 성공해야 둘 다 함께 저장하고, 중간에 하나라도 실패하면 거래 전의 상태로 돌아갈 수 있습니다. 롤백이란게 가능합니다. 만약 1번은 성공했는데 2번이 실패하면 이체 자체가 실패하고 거래 전의 상태로 완전히 돌아갈 수 있습니다. 결과적으로 이 경우 A의 잔고가 감소하지 않습니다.

 

모든 작업이 성공해서 데이터베이스에 정상 반영되는 것을 '커밋'이라고 하고 하나라도 실패해서 거래 이전으로 돌아가는 것을 '롤백'이라고 합니다.

 

-> 트랜잭션 ACID

트랜잭션은 ACID라는 원자성, 일관성, 격리성, 지속성도 보장해야합니다.

 

1) 원자성

트랜잭션 내에서 실행한 작업들은 마치 하나의 작업인 것처럼 모두 성공하거나 모두 실패해야 합니다. 위에서처럼 2가지 작업이지만 마치 하나의 작업처럼 묶어야합니다.

 

2) 일관성

모든 트랜잭션은 일관성있는 데이터 베이스 상태를 유지해야 합니다. 예를들어 db에서 정한 무결성 제약조건을 항상 만족해야합니다.

 

3) 격리성

동시에 실행하는 트랜잭션이 서로에게 영향을 미치지 않도록 격리해야합니다. 예를들어 여러 사람이 회원가입을 하거나 데이터를 동시에 수정하거나 웹 특성상 동시에 동작하면 동시에 같은 데이터를 수정하지 못하도록 격리합니다. 격리성은 동시에 트래픽이 들어오면 한번에 하나씩 처리하면 완벽히 격리가 되는데 그렇게 하면 성능 문제가 생겨서 성능 이슈로 인해 격리 수준을 선택할 수 있습니다.

 

4) 지속성

트랜잭션을 성공적으로 끝내면 그 결과가 항상 기록이 되어야합니다. 뭔가 트랜잭션을 커밋을 하면 중간에 시스템에 문제가 발생하더라고 db 로그들을 사용해서 성공한 트랜잭션 내용을 복구할 수 있어야합니다.

 

> 트랜잭션은 원자, 일관, 지속성은 '당연히' 보장해야하는데 문제는 격리성으로 완벽히 격리성을 보장하려면 트랜잭션을 거의 순서대로 실행해야 합니다. 예를들어 멀티 스레드로 동시에 100개의 요청이 오면 조금이라도 빨리온 요청먼저 처리하고 나머지는 기다려야합니다. 이렇게 되면 병렬처리가 안 되어서 굉장히 느려집니다. 그래서 격리 수준을 4단계로 나눕니다.

 

-> 수준

단계가 높아질 수록 성능은 낮아지고 동시에 못하도록 완벽히 막습니다. 직렬화 가능까지하면 너무 느려져서 보통은 READ COMMITTED를 많이 사용합니다. 성능은 READ UNCOMMITED가 제일 좋은데 이것은 데이터 변경이 완료(커밋)되기 전에 다른 요청이 그 데이터를 변경할 수 있어 보장이 안됩니다. READ COMMITTED은 누군가가 커밋한 데이터를 읽을 수 있는 단계입니다. 결론은 트랜잭션을 하나로 묶어서 처리하는 것과 한번에 커밋하고 한 번에 롤백하는 것입니다.

 

- db 연결 구조와 db 세션

트랜잭션을 더 자세히 이해하기 위해 db 서버 연결 구조와 db 세션을 알아야합니다.

 

-> 연결 구조

사용자가 was나 db 접근 툴(work bench)에 접근하면 db와 커낵션을 맺습니다. 그러면 db 내부에 세션을 만듭니다. 세션에서 실제 db의 동작을 합니다. 사용자는 db 접근 툴 같은 클라를 사용해서 db 서버에 접근할 수 있습니다. 앞으로는 해당 커낵션을 통한 모든 요청은 이 세션을 통해서 실행됩니다. 

> 다시 얘기하면 개발자가 was나 db 접근 툴을 이용하여 sql을 전달하면 현재 커낵션에 연결된 세션이 sql을 실행합니다. 세션은 트랜잭션을 시작하고, 커밋 또는 롤백을 통해 트랜잭션을 종료하고 이후 새로운 트랜을 다시 시작할 수 있습니다. 사용자가 커낵션을 닫으면 세션이 종료됩니다. 커넥션 풀이 10개의 커낵션을 생성하면, 세션도 10개 만들어집니다.

 

-> 정리

커낵션을 연결하면 db 내부에 세션이 생기고 새션을 통해서 트랜잭션도 시작하고 sql도 실행하는 것입니다. 커낵션마다 세션이 딱딱 연결이 되어있는 것입니다. 그냥 커낵션이 세션입니다.

 

- 트랜잭션 개념 이해

트랜잭션 동작을 예제를 통해 확인해보겠습니다. 

 

-> 트랜잭션 사용법

기본은 트랜을 시작하고 데이터를 막 변경한 후 커밋하거나 롤백하는 것입니다. 데이터 변경을 하고 결과를 반영하려면 커밋을 호출하고 반영하지 않으려면 롤백을 호출하면 됩니다.

 

> insert만 하면 db에 데이터가 커밋 안해도 들어가던데 보통 auto 커밋모드가 되어있어서 수동 커밋으로 바꾸고 해야합니다. 커밋을 호출하기 전까지는 임시로 데이터를 저장하는 것으로 회원 데이터를 바꾸거나 추가했으면 커밋 전에는 임시 저장으로 해당 트랜잭션을 시작한 세션(사용자)에게만 변경 데이터가 보이고 다른 세션(사용자)에게는 변경 데이터가 보이지 않습니다.

+ 위에서 커낵션을 연결하면 db 내부에 세션이 생기고 세션을 통해서 트랜잭션을 시작하고 sql한다고 했는데 커밋 전에는 해당 트랜잭션을 시작한 세션에게만 변경이 보이고 다른 세션에게는 보이지 않는 것입니다.(세션이 로컬이고 커밋하면 다른 사용자에게도 보이는 원격 저장소 같은 것입니다.) > 등록, 수정, 삭제 모두 같은 원리로 동작합니다. 

 

-> 그림

세션1, 2이 둘 다 조회 쿼리를 날리면 해당 데이터가 똑같이 보입니다.

 

근데 세션 1이 트랜잭션을 시작하고 신규 회원 1, 2를 추가합니다. 아직 커밋은 안 했습니다. (상태 임시입니다.) 이것은 데이터를 변경한 사람만 볼 수 있습니다. > 세션 1은 select하면 3행이 다 보입니다. 세션 2는 select하면 신규 회원은 아직 커밋안해서 안 보입니다. 

 

-> 커밋하지 않은 데이터가 보인다면?

커밋하지 않은 데이터가 보인다면 세션2가 신규 데이터가 다 보일 것이고 그러면 2의 입장에서는 2가 있다고 가정하고 어떤 그에 맞는 로직을 수행할 수 있습니다. 근데 1에서 롤백하면 신규가 다 지워지고 2가 작성한 로직이 오류가 발생하고 따라서 '데이터의 정합성'에 큰 문제가 발생합니다. 따라서 커밋 전 데이터는 다른 곳에서 보이면 안 됩니다. ※ 근데 격리 수준 1단계는 커밋하지 않은 데이터도 읽기 가능합니다.

 

-> 신규 데이터 추가 후 커밋

커밋을 하면 상태가 완료 처리가 되고 실제 db에 반영이 되고 2가 조회해도 됩니다.

 

-> 롤백

만약 커밋 안하고 롤백하면 신규 데이터가 다 날라갑니다. 수정하거나 삭제한 데이터도 롤백하면 다시 생깁니다.

 

- 자동 커밋

자동 커밋으로 하면 각각의 쿼리 실행 직후 자동으로 커밋되고 기본이 자동 커밋입니다. insert 2줄을 할 때마다 sql을 실행할 때마다 자동으로 커밋을 호출합니다. 하지만 쿼리를 하나하나 실행할 때마다 자동으로 되어서 트랜잭션을 할 수 없습니다. 5000원 이체에서 A는 감소하고 B는 실패해도 커밋이 자동으로 되어서 자동 커밋 모드는 이렇게 됩니다. 따라서 트랜잭션을 제대로 하려면 수동 커밋을 써야합니다. 자동커밋에서는 커밋하고 롤백해봐야 커밋 전으로 돌릴 수 없습니다.

 

- 수동커밋

 

set autocommit false하면 되고 꼭 마지막에 commit을 해야하고 안 그러면 이 insert 2줄이 반영이 안 되고 임시 상태로 남아있습니다. 수동 커밋 모드로 설정하는 것을 트랜을 시작한다고 표현할 수 있습니다. set autocommit false를 쓰면 아 이제 트랜잭션을 시작한다고 말하는 것과 같은 것입니다.

 

- 트랜잭션 실습

세션 2개를 만들어야합니다. url을 보면 세션 id가 있고 이것이 커낵션이 되면 생기는 사용자 세션입니다. 창을 새로 열어 새로 연결합니다. 왼쪽이 1번 오른쪽이 2번입니다.

 

1) 기본 데이터

먼저 기본 데이터를 맞춥니다. 

 

2) 세션 1에서 신규 데이터 추가

수동 커밋 모드로 하고 insert 쿼리를 2개 날립니다. 아직 커밋 안한 것입니다. 실행하면 1은 3행이고 2는 1행이 보여야합니다. 커밋 안하면 트랜잭션을 시작한 세션에서만 변화가 보인다고 했습니다.

 

커밋하면 2도 보입니다.

 

3) 롤백

이번에는 데이터를 기본 데이터로 다시 초기화하고 1에서만 2행을 추가한 그림을 만들고 롤백을 알아보겠습니다.

 

이 상태에서 1이 롤백하면 1행만 남습니다.

 

업데이트 쿼리를 1만원을 2만원으로 바꾸고 커밋 안하고 롤백하면 1만원입니다.

 

- 계좌이체 예제

계좌이체 3가지 경우로 트랜잭션을 자세히 알아보겠습니다. 1. 계좌이체 정상 수행, 2. 계좌이체 문제가 발생했을 때 커밋하는 것, 3. 문제 상황에서 롤백하는 것을 가정하고 보겠습니다.

 

1. 정상흐름
1) 초기 상태

A와 B가 둘다 만원씩 가지고 있다고 하겠습니다. A 2천원을 B에게 이체할 것입니다.

다음과 같이 2번의 update 쿼리로 A의 돈은 2000원 빼고 B의 돈은 2000원 추가하는 쿼리가 되어야합니다. 

 

근데 이 상태는 커밋을 안해서 임시 상태로 A에게만 보입니다.

 

 

2) 커밋

커밋하면 A, B가 둘 다 적용이 됩니다.

 

2. 문제 상황 커밋

문제가 발생했을 때 커밋하는 경우를 보겠습니다. 기본 데이터를 10000, 10000원 셋팅합니다.

 

1) 계좌 이체

개발자가 sql을 잘 못 짜서 이체 실행중 문제가 발생합니다. 그래서 A의 돈은 줄이는데 성공하지만 B는 증가에 실패합니다. sql도 절차지향이라서 그렇습니다. 첫번째 where는 id인데 두번째 where는 iddd로 이렇게 되면 쿼리에서 예외가 발생합니다. 

 

실행하면 컬럼을 찾을 수 없다는 예외가 발생합니다. 그러면 A의 돈은 성공해서 임시 상태로 반영이 됩니다. 근데 B는 돈이 증가하지 못했습니다. 이러면 세션 1이 조회하면 8000원 10000원이여야하고 2가 조회하면 커밋을 안해서 10000, 10000으로 보여야합니다. 

 

여기서 문제는 A는 줄었지만 B는 증가하지 않았다는 점입니다. 결과적으로 계좌이체를 실패하고 A의 돈만 2000원 준 상황입니다. 여기서 커밋을 하면 B도 8000, 10000원으로 되는 것입니다. 결과적으로는 이체에 실패한 것인데 A의 돈만 주는 심각한 문제가 발생한 것입니다. 

> ★ 이렇게 중간에 문제가 발생했을 때는 커밋을 호출하면 안 됩니다. 롤백을 해서 데이터를 트랜잭션을 시작하는 시점으로 원복해야합니다.

 

 

3. 문제 상황 롤백

다시 초기 상태로 만들고 똑같이 A의 돈만 주는 상황을 만듭니다. 아직 커밋 안해서 A에게만 보이고 B는 10000, 10000인 상황입니다. 

 

 

롤백을 해서 10000, 10000인 트랜잭션 시작 전의 상태로 돌려야합니다. 이렇게 해서 멤버 A의 돈만 나가는 일을 일어나게 하면 안됩니다.

 

-> 정리

1) 원자성 

트랜잭션 내에서 실행한 작업들은 마치 하나의 작업인 것처럼 모두 성공 하거나 모두 실패해야 합니다. 원자성 덕분에 여러 sql 명령어를 마치 하나의 작업인 것처럼 처리할 수 있었습니다. 성공하면 한 번에 반영하고, 중간에 실패해도 마치 하나의 작업을 되돌리는 것처럼 간단히 되돌릴 수 있습니다.

2) 오토커밋

만약 오토 커밋을 하면 이체 실패해도 바로 커밋이 되어서 A의 돈만 줄어드는 상황이 발생하고 이래서 이 경우 절대로 오토 커밋 모드를 쓰면 안됩니다. set autocommit false를 해서 트랜잭션을 수동 모드로 바꿔서 트랜잭션을 시작하면서 처리해야합니다. 

3) 트랜잭션 시작

이런 종류의 작업은 꼭 수동 커밋 모드를 사용해서 수동으로 커밋, 롤백 할 수 있도록 해야하고 보통 이렇게 자동 커밋 모드에서 수동 커밋 모드로 전환하는 것을 트랜잭션을 시작한다고 표현합니다.

 

- DB 락 개념

세션 1이 데이터를 수정하는 동안 커밋을 수행하지 않았는데 2가 동시에 같은 데이터를 수정하게 되면 여러 문제가 발생합니다. 이런 문제를 방지하려면 세션이 트랜잭션을 시작하고 데이터를 수정하는 동안에는 커밋이나 롤백하기 전까지 다른 세션에서 해당 데이터를 수정할 수 없게 막아야합니다.

 

-> 그림

예를들어 세션1이 A의 금액을 500원으로 바꾸고 싶고 2는 A의 금액을 1000원으로 변경하고 싶습니다. db는 이런 문제를 해결하기 위해 락이라는 개념을 제공합니다. 동시에 데이터를 수정하는 문제를 락으로 어떻게 해결하는지 알아보겠습니다.

 

조금이라도 빨리 들어오는 애가 우선권을 가집니다. 세션 1이 트랜을 시작하고 500원으로 변경 시도합니다. 이때 데이터를 변경하려면 데이터 변경 전에 해당 로우의 락을 먼저 획득해야합니다. 다른 곳에서 데이터를 수정하고 있지 않아서 해당 행의 락이 남아있으므로 세션 1이 락을 획득합니다. (세션 1이 세션 2보다 빨리 요청해서 그렇습니다.)

 

 

세션 1은 락을 획득했으므로 해당 로우에 update sql을 수행하고 반영이 됩니다. 락을 획득을 해야 데이터를 변경할 수 있는 것입니다. 세션1이 트랜을 시작해서 락을 들고 있는데 2가 트랜을 시작하고 2도 A의 돈을 변경하려고 시도합니다. 이때도 해당 로우의 락을 먼저 획득해야 쓸 수 있습니다. 

 

> 업데이트 쿼리를 수행해도 락이 없으면 쓰지 못하고 락이 돌아올 때까지 대기해야합니다. ( ※ 무한정 대기는 아니고 락 대시 시간을 설정하여 대기 시간을 넘어가면 락 타임아웃 오류가 발생합니다.)

 

지금 세션 1이 막 변경 중이고 2가 트랜잭션을 시작하고 lock을 확득하러 기다리고 있는 상황인데 1이 커밋을 수행하면 실제 A의 돈이 500원으로 바뀌고 모든게 db에 반영이 되고 커밋으로 트랜이 종료되었으므로 락도 반납합니다.

그러면 락을 획득하기 위해 대기하던 2가 세션을 락을 획득해서 update sql을 수행합니다. 2도 수행을 마치고 커밋하면 락을 반납합니다. 결과적으로 트랜잭션이 있는 동안 하나의 로우를 동시에 수정하는 것이 안됩니다.

 

- DB 락 데이터 변경

기본 데이터를 입력합니다. id는 A에 돈을 10000원 넣어둡니다. 

 

먼저 1이 500원으로 바꾸려고 합니다.

 

수동 커밋으로 하고 500원으로 업데이트하고 아직 커밋은 하지 않습니다. 락은 1이 트랜을 시작했기에 1이 가지고 있습니다.

 

이 상황에서 2가 락을 획득하고 업데이트하려는 시도를 합니다. set lock_timeout은 락을 획득하기까지 대기하는 시간으로 60초로 잡았습니다. 수동 커밋으로 트랜잭션을 시작하고(수동 커밋을 하는 게 트랜잭션 시작이라고 했습니다.) 1000으로 바꿔봅니다. 해보면 set 2개는 성공했는데 update 쿼리는 수행하지 못하고 돌고 있는 것입니다. 기다리고 있습니다.

 

세션 1이 트랜을 커밋하거나 롤백해서 종료하지 않았으므로 아직 세션1이 락을 가지고 있고 따라서 세션 2는 락을 획득하지 못하기 때문에 수정할 수 없습니다. 2는 락이 돌아올 때까지 대기하게 됩니다. 60초안에 락을 획득하지 못하면 예외가 발생합니다.

세션 1이 커밋을 하면 실제 데이터를 변경하고 트랜을 종료하고 락을 반납합니다. 1이 커밋하는 순가 2의 update가 수행됩니다.

 

아직 2도 커밋을 한 것은 아니라서 db는 500이고 2에게는 1000이 임시상태로 있는 것입니다. 2도 커밋하면 db가 1000이 됩니다.

 

-> 락 타임아웃 오류

 

1이 트랜을 시작하고 락을 가지고 수행하는데 2가 트랜을 시작하고 락을 가지려고 하고 락 타임아웃 설정을 10초로 주면 10초이내에 1이 커밋이나 롤백으로 락을 반납하지 않으면 오류가 발생합니다.

 

- DB 락 조회

일반적인 조회는 락을 사용하지 않습니다. 예를들어서 세션 1이 락을 획득하고 변경하고 있어도 세션 2에서 커밋 전 상태의 데이터를 마음 껏 조회할 수 있습니다.(임시 아니고 기존 데이터입니다.) 조회는 락을 획득하지 않아도 조회할 수 있습니다.

근데 데이터를 조회할 때도 락을 획득하고 싶을 때가 있습니다. 내가 데이터를 조회하는데 다른 데서 이 데이터를 변경하지 못하게 하고 싶을 때가 있습니다. select for update 구문을 사용하면 1이 조회 시점에 락을 가져가 버려서 다른 세션에서 해당 데이터를 변경할 수 없습니다. 물론 이 경우도 트랜잭션을 커밋하거나 롤백하면 락을 반납합니다.

 

-> 언제할까?

그러면 조회 시점에 락이 필요한 경우는 언제일까요? 트랜 종료까지 해당 데이터를 다른 곳에서 변경하지 못하도록 강제로 막아야할 때가 있습니다. 예를들어 로직에서 A를 select로 조회하고 이 정보로 어떤 중요한 계산을 수행하고 있습니다. 그런데 이 계산이 돈과 관련된 중요한 계산이라서 완료될 때까지 A의 금액을 다른 곳에서 변경하면 안되는 경우가 있습니다.

새벽에 토스에서 마감을 처리하느라 접근과 변경을 못하게 막는 것으로 예를 들을 수 있습니다. 그러면 다른 곳에서는 새션을 시작한 곳에서 select를 다 처리하고 락을 반납하기 전까지 db를 변경할 수 없습니다.

 

- 예제

 

기본 데이터를 초기화합니다. 1이 수동 커밋으로 트랜을 시작하고 쿼리 마지막에 for update를 붙입니다. 그러면 이 로우는 조회를 하는 동안에도 다른 곳에서 변경을 못합니다. 락을 가져가도 다른 곳에서 select는 됩니다.

 

 

그리고 1이 굉장히 복잡한 연산을 하고 있는 중에 2가 수동 커밋으로 세션을 시작해서 update를 하려고 합니다. 해보면 락이 없어서 set만 되고 update는 안됩니다. 락이 없어서 락을 받을 때까지 대기하게 됩니다.

 

 

여기서 1이 커밋을 하면 2의 update가 바로 됩니다. 1이 커밋을 한다고 해도 변경이 되는 것은 없지만 그 동안 다른 데서 변경도 못하는 것입니다. 결론은 select할 때 기본적으로는 락을 안 가져가는데 이렇게 가져갈 수 있다는 매커니즘입니다.

 

 

- 트랜잭션 적용

실제 어플에서 db 트랜잭션을 사용해서 계좌이체 같이 원자성이 중요한 비즈니스 로직을 어떻게 코드로 구현하는지 알아보겠습니다. 먼저 트랜잭션 없이 비즈니스 로직만 구현합니다.

 

-> 서비스

public class MemberService {
    private final MemberRepositoryV1 memberRepositoryV1;

    public MemberService(MemberRepositoryV1 memberRepositoryV1) {
        this.memberRepositoryV1 = memberRepositoryV1;
    }

    public void accountTransfer(String fromMemberId, String toMemberId, int money) throws SQLException {
        Member findMember = memberRepositoryV1.findById(fromMemberId);
        Member toMember = memberRepositoryV1.findById(toMemberId);

        memberRepositoryV1.update(fromMemberId, findMember.getMoney() - money);
        validation(findMember);
        memberRepositoryV1.update(toMemberId, findMember.getMoney() + money);
    }

    private static void validation(Member findMember) {
        if (findMember.getMemberId().equals("ex")) {
            throw new IllegalStateException("이체중 예외 발생");
        }
    }

계좌 이체 서비스 로직을 만듭니다. 내 돈을 빼서 다른 사람에게 돈을 주는 로직을 짜야합니다. 멤버 레포를 사용하고 계좌 이체 메서드 accountTransfer()를 만들고 fromid와 toId로 누가 누구에게 보낼지와 얼마 보낼지를 파라미터로 받습니다.

> 멤버 레포에서 from 회원을 꺼내고 to 회원을 꺼냅니다. 이때 checked 예외가 터지는데 일단은 던집니다. 원래는 던지지 않는데 예외는 이후에 알아봅니다. 이제 레포의 업데이트로 from의 돈을 얼마 보낼지를 뺍니다. 이체니깐 일단 from의 돈을 깍고 to의 돈을 올려야합니다.

+ 이렇게 하면 끝나는데 중간에 오류 케이스를 만들겠습니다. to 회원의 id가 ex면 이체를 실패하게 하고 메세지로 "이체 중 예외 발생"으로 합니다. 이렇게 하면 첫번째 update는 성공했는데 두번째 update는 실패하는 상황입니다.

 

지금 이 상황이 트랜잭션 개념에서 update를 2개 하는데 두 번째 update가 iddd라서 A의 돈은 감소하는데 B의 돈이 증가하지 않은 오류가 난 것입니다.

 

-> 테스트

이게 잘 동작하는제 테스트 해보겠습니다. 기본 동작을 수행하고 트랜잭션이 없어서 문제가 발생하게 할 것입니다. 트랜잭션이 없는 비즈니스 로직은 상상도 할 수 없습니다. 지금 트랜잭션이 없으니 예외가 발생하는 테스트를 해 볼 것입니다.

 

class MemberServiceTest {
    private final String member_A = "memberA";
    private final String member_B = "memberB";
    private final String member_EX = "ex";

    private MemberRepositoryV1 memberRepositoryV1;
    private MemberService memberService;

    @BeforeEach
    void beforeEach() {
        DataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        memberRepositoryV1 = new MemberRepositoryV1(dataSource);
        memberService = new MemberService(memberRepositoryV1);
    }

먼저 상수를 정의합니다. 회원 id 3개를 정의합니다. 레포와 서비스를 beforeeach로 주입받고 커낵션을 드라이버 매니저 데이터 소스를 사용할 것입니다. db를 사용한 후로부터는 레포에서 커낵션, stmt, rs를 사용하는데 이때 레포의 생성자에서 데이터 소스를 주입받고 레포에는 데이터 소스에만 의존하게 하고 getConnection에서 커낵션을 획득하게 했었습니다.

 

> 멤버 서비스도 생성하는데 얘는 멤버 레포가 필요하여 넣어줍니다. 서비스에서 레포의 update와 find를 했었습니다. 이것을 원래는 다 생성자 주입으로 할 것입니다.

 

-> 테스트 로직

1) 정상 케이스

@Test
void accountTransfer() throws SQLException {
    //given
    Member memberA = new Member("memberA", 10000);
    Member memberB = new Member("memberB", 10000);
    memberRepositoryV1.save(memberA);
    memberRepositoryV1.save(memberB);

    //when
    memberService.accountTransfer(member_A, member_B, 2000);

    //then
    Member findMember1 = memberRepositoryV1.findById("memberA");
    Member findMember2 = memberRepositoryV1.findById("memberB");
    assertThat(findMember1.getMoney()).isEqualTo("8000");
    assertThat(findMember2.getMoney()).isEqualTo("12000");
}

멤버A와 B를 만들고 A에 만원, B에 만원을 넣습니다. 이 멤버를 db에 레포.save로 저장합니다. 이제 A의 돈을 B에게 보낼 것이라서 from을 A to를 B로 하고 2000원을 이체합니다. 이때 멤버 서비스를 사용하는데 당연한 것이 클라이언트는 멤버 서비스와 레포가 있으면 무조건 비즈니스 로직인 서비스에 접근하는 것입니다. 이제 A의 돈이 8000이고 B의 돈이 12000인지 확인합니다. 이것은 정상적인 로직의 경우입니다.

 

 

2) 오류 케이스

 

@Test
@DisplayName("이체중 예외 발생")
void accountTransferEx() throws SQLException {
    //given
    Member memberA = new Member(MEMBER_A, 10000);
    Member memberEx = new Member(MEMBER_EX, 10000);
    memberRepositoryV1.save(memberA);
    memberRepositoryV1.save(memberEx);
    //when
    assertThatThrownBy(() -> memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
            .isInstanceOf(IllegalStateException.class);
    //then
    Member findMemberA = memberRepositoryV1.findById(memberA.getMemberId());
    Member findMemberEx = memberRepositoryV1.findById(memberEx.getMemberId());
    //memberA의 돈만 2000원 줄었고, ex의 돈은 10000원 그대로이다.
    assertThat(findMemberA.getMoney()).isEqualTo(8000);
    assertThat(findMemberEx.getMoney()).isEqualTo(10000);
}

정상 케이스는 확인이 됐습니다. 그러면 이제 오류 케이스를 보겠습니다. 이체 중에 예외가 발생하면 to 회원이 Ex인 경우입니다. when에서 이것을 그대로 수행하면 예외가 터지니 검증에서는 예외가 터지는 것이 참이도록 코드를 짜야합니다. 

 

> 그리고 then에서는 트랜잭션이 없으니 autocommit 모드로 돌아서 update에 실제 sql이 있으니 레포.update하나 수행할 때마다 커밋이 되는 것입니다. 근데 예외가 터져서 튕겨나가서 2번째 update는 수행이 안되고 결과적으로 A의 돈만 2000원 까이고 B의 돈은 변경이 없습니다.

 

-> 실행

실행해보면 예외가 터졌고 A의 돈만 까졌다는 것이 성공합니다. 

 

@AfterEach
void after() throws SQLException {
    memberRepositoryV1.delete(MEMBER_A);
    memberRepositoryV1.delete(MEMBER_B);
    memberRepositoryV1.delete(MEMBER_EX);
}

지금 연속으로 두번 테스트하는 것이 기본 키 때문에 계속 막힙니다. 그래서 AfterEach를 추가합니다. 각각의 테스트가 끝날 때 after가 호출됩니다. 근데 이보다 테스트에서 데이터를 제거하는 더 나은 방법으로는 트랜잭션을 활용하면 됩니다. 테스트 전에 트랜을 시작하고 테스트 이후에 롤백해버리면 데이터가 처음 상태로 돌아옵니다. 수동 커밋 모드로 해야할 것입니다.

 

- 트랜잭션 적용

지금 상황은 이체중 예외가 발생하면 A의 금액은 2000원 감소하고 B는 10000원이 그대로 남아있는 경우입니다. 결과적으로 A의 돈만 감소한 것입니다. 이 은행이 지금 완전히 문을 닫게 생긴 것입니다.

 

이제 db 트랜을 사용해서 앞서 발생한 문제점을 해결해볼 것입니다. 트랜을 쓰면 원자적으로 문제를 해결할 수 있습니다. 성공하면 모두 커밋하고 실패하면 모두 롤백하는 것입니다. 어플에서 트랜은 어떤 계층에 걸어야 할까요? 쉽게 얘기해서 트랜잭션을 어디서 시작하고 어디서 커밋하거나 롤백해야할까요?

 

- 비즈니스 로직과 트랜잭션

public void accountTransfer(String fromMemberId, String toMemberId, int money) throws SQLException {
    // 시작
    Member findMember = memberRepositoryV1.findById(fromMemberId);
    Member toMember = memberRepositoryV1.findById(toMemberId);

    memberRepositoryV1.update(fromMemberId, findMember.getMoney() - money);
    validation(toMember);
    memberRepositoryV1.update(toMemberId, findMember.getMoney() + money);
    // 커밋, 롤백
}

트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작해야합니다. 서비스에 보면 계좌 이체라는 로직이 다 들어있어서 여기서 시작해야합니다. 비즈니스 로직이 잘못되면 해당 비즈니스 로직으로 인해 문제가 되는 부분이 다 함께 롤백되야 하기 때문입니다.

 

> 그래서 accountTransfer 메서드가 원자적으로 커밋이 되거나 롤백이 되어야합니다. 그래서 비즈니스 로직 단위가 하나의 거래로 트랜잭션을 걸게 됩니다. 트랜잭션을 건다는 것은 비즈니스 로직이 시작할 때 트랜잭션을 시작하고 로직이 끝날 때 커밋하거나 롤백하는 것입니다.

 

그런데 트랜을 시작하려면 커낵션이 필요합니다. 트랜 시작이 set autocommit false인데 이게 db에 직접 명령을 날리는 것이니 커낵션이 필요합니다. 결국 서비스 계층에서 커낵션을 만들고 트랜잭션 커밋 이후 커낵션을 종료하도록 해야합니다.

> 그리고 어플에서 db 트랜을 사용하려면 트랜을 사용하는 동안 같은 커낵션을 유지해야합니다. 그래야 같은 세션을 사용할 수 있습니다.

 

ex) 예를들어서 accountTransfer에서 시작부터 끝까지 트랜잭션이 유지되는데 그러면 같은 같은 커낵션을 써야 같은 세션을 쓰고 다른 세션을 쓰게 되면 새로운 사용자, 새로운 트랜잭션이 되는 것입니다. 근데 현재 레포는 메서드를 호출할 때마다 get으로 새로운 커낵션을 풀에서 꺼내고 메서드가 끝나면 close합니다. 우리가 원하는 것은 같은 커낵션을 계속 유지해야합니다. "커낵션 > 세션 > 트랜잭션" 이런 방향입니다. 어플리케이션에서 같은 커낵션을 유지하려면 가장 단순한 방법은 커낵션을 파라미터로 전달해서 같은 커낵션이 사용되도록 하는 것 입니다.

 

-> 파라미터 방법

지금은 모든 레포의 메서드가 계속 커낵션을 get하니 모든 메서드에 커낵션을 다 파라미터로 넘길 것입니다. 레포지토리의 메서드를 변경할 것입니다.

 

// 이전
public Member findById(String memberId) throws SQLException {
    String sql = "select * from member where member_id = ?";

    Connection con = null;
    PreparedStatement pstmt = null;
    ResultSet rs = null;

    try {
        con = getConnection();
        pstmt = con.prepareStatement(sql);
        pstmt.setString(1, memberId);

        rs = pstmt.executeQuery();
        
        
// 이후
public Member findById(String memberId, Connection con) throws SQLException {
    String sql = "select * from member where member_id = ?";

    PreparedStatement pstmt = null;
    ResultSet rs = null;

    try {
        pstmt = con.prepareStatement(sql);
        pstmt.setString(1, memberId);

        rs = pstmt.executeQuery();

서비스에서 계좌 이체하는데 find와 update 메서드를 사용하니 이 2개에서 커낵션을 파라미터로 받을 수 있게 셋팅할 것입니다. 그래서 일반 find와 파라미터로 받는 find를 만듭니다. 실제로 옛날에는 이런 방식으로 했다고 합니다. 보면 getConnection이 있는데 이것이 커낵션 > 세션 > 트랜잭션으로 새로운 커낵션을 만드는 것이니 지워야합니다.

 

JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(pstmt);
//   JdbcUtils.closeConnection(con);

그리고 커낵션의 close를 쓰면 안 됩니다. 서비스 계층에서 트랜잭션이 끝날 때 이 커낵션을 종료해야 합니다. update도 이 방식으로 바꿉니다.

 

-> 서비스에 트랜잭션 적용

트랜은 서비스에 적용합니다. 비즈니스 로직에 트랜을 적용할 것입니다. 일단은 파라미터로 커낵션을 주는 방식을 할 것입니다.

 

@RequiredArgsConstructor
public class MemberServiceV2 {
    private final MemberRepositoryV1 memberRepositoryV1;
    private final DataSource dataSource;


    public void accountTransfer(String fromMemberId, String toMemberId, int money) throws SQLException {
        Connection con = dataSource.getConnection();

이전에는 레포에서 커낵션을 가지고 있어야해서 레포에서 데이터 소스가 필요했는데 이제는 서비스에서 커낵션을 여니 여기에 데이터 소스가 필요합니다.

 

public void accountTransfer(String fromMemberId, String toMemberId, int money) throws SQLException {
        Connection con = dataSource.getConnection();
        
        try {
            con.setAutoCommit(false);
            // 시작
            Member findMember = memberRepository.findById(fromMemberId, con);
            Member toMember = memberRepository.findById(toMemberId, con);

            memberRepository.update(fromMemberId, findMember.getMoney() - money, con);
            validation(toMember);
            memberRepository.update(toMemberId, findMember.getMoney() + money, con);
            con.commit();
            // 커밋, 롤백
        } catch (IllegalStateException e) {
            con.rollback();
            throw new IllegalStateException(e);
        } finally {
            if (con != null) {
                try {
                    con.setAutoCommit(true);
                    con.close();
                } catch (Exception e) {
                    log.error("error", e);
                }
            }
        }
    }

커낵션을 얻었으면 세션도 얻었습니다. 그리고 try로 con.setAutoCommit해서 수동 커밋으로 트랜잭션을 시작합니다. 이렇게 하면 실제로 커낵션을 통해서 pstmt = con.prepareStatement(sql); 한 거처럼 con에 setAutoCommit과 sql을 담아서 db에 sql을 날려주고 이제 여기에 로직을 수행합니다. 정상 수행이 되면 여기서 con.commit()을 해서 커밋 명령어가 db에  커밋이 전달되고 실행하게 됩니다.(정상이면 try에서 return하는 것과 같은 개념)

 

근데 예외가 발생하면 커낵션에서 rollback해줘야합니다. 롤백하고 예외를 던지겠습니다. 그리고 fin에서 con을 닫습니다. 커낵션을 연결하는 것과 닫는게 모두 서비스에 있어야합니다. jdbcUtils를 써도 되는데 한가지 고려할 점이 있어서 수동으로 합니다.

 

+ con이 null이 아니면 fin를 수행하고 여기에 con을 닫는 것을 넣어야 합니다. try에 지금 autocommit을 false로 했는데 이게 커낵션 풀에 돌아갈 것이라서 그냥 커넥션을 닫으면 풀에 돌아갑니다. 근데 돌아갔는데 수동 커밋이 유지된 상태로 돌아가서 누군가가 커낵션을 획득했을때 이게 false로 남아있는 것입니다. 기본값이 자동인데 수동으로 되어있으면 의도치 않게 동작할 수 있어서 닫을 때 자동 커밋으로 바꾸고 닫아야합니다. 그래서 자동으로 바뀌고 풀에 돌아갑니다. 풀을 사용하지 않는 경우는 true로 바꾸지 않아도 괜찮습니다.

 

try {
    con.setAutoCommit(false);
    // 시작
    bizLogic(con, toMemberId, money, fromMemberId);
    con.commit();
    // 커밋, 롤백
} catch (IllegalStateException e) {

}

private void bizLogic(Connection con, String toMemberId, int money, String fromMemberId) throws SQLException {
    Member findMember = memberRepository.findById(fromMemberId, con);
    Member toMember = memberRepository.findById(toMemberId, con);

    memberRepository.update(fromMemberId, findMember.getMoney() - money, con);
    validation(toMember);
    memberRepository.update(toMemberId, findMember.getMoney() + money, con);
}

개발을 할 때 레이어가 다른 부분은 분리를 하는 것이 좋은데 로직 코드를 보면 레포를 쓰지 않는 부분은 그냥 트랜잭션을 처리하기 위한 코드입니다. 그래서 비즈니스 로직에 커낵션을 넘기고 메소드 추출합니다. 트랜잭션을 관리하는 로직과 실제 비즈니스 로직을 구분하기 위함입니다. 이렇게 하면 로직이 서비스 계층해서 트랜을 시작한 하나의 커낵션으로 다 수행이 됩니다. 여기는 쿼리는 전부 다 같은 처음에 트랜을 만든 커낵션으로 수행이 되는 것입니다.

 

-> 테스트

memberRepository.save(memberA);
memberRepository.save(memberB);
//when
memberService.accountTransfer(memberA.getMemberId(),
        memberB.getMemberId(), 2000);
//then
Member findMemberA = memberRepository.findById(memberA.getMemberId());
Member findMemberB = memberRepository.findById(memberB.getMemberId());

테스트에서 주입받던 레포와 서비스를 다 바꾸고 실행해야합니다. 실행해보면 다른 커낵션을 사용하는데 이것은 accountTransfer가 아닌 레포에서 save와 findbyid할 때 사용한 커낵션이고

 

계좌 이체 내부에서는 동일한 커낵션을 사용합니다. 로그를 찍어보면 한번 만든 커낵션을 파라미터로 넘겨서 한번 만들고 생성하지 않습니다. 실행해보면 원래는 잘 안되던 예외 발생의 경우에 A와 B의 돈이 10000이 유지됩니다. 

 

"ex"를 한 경우 테스트를 실패합니다. 비즈니스 로직에서 예외가 터져서 롤백이 되어서 A, B 둘 다 다시 10000원이 되어서 그렇습니다.

 

비즈니스 로직에서 val을 하는데 예외가 발생해서 catch로 들어가고 catch에서 롤백을 해서 그렇습니다. 트랜잭션을 사용하지 않은 경우는 A만 2000원 깍기고 B는 아무것도 하지 않았는데 트랜잭션을 적용하니 예외가 터졌을 때 롤백을 해서 그렇습니다. 근데 이 코드는 정말 복잡합니다. 그리고 서비스 로직이면 비즈니스 로직이 있어야하는데 트랜잭션 코드가 비즈니스 로직 코드보다 많습니다. 추가로 커낵션을 파라미터로 넘기는 것도 일일히 하는 것이 문제입니다. 이제 이것을 정리해 볼 것입니다.

Comments