본문 바로가기
CS/데이터베이스

[DB] 트랜잭션 이해

by 2245 2023. 10. 8.

트랜잭션 개념

트랜잭션을 이름 그대로 번역하면 거래라는 뜻입니다. 이것을 쉽게 풀어서 이야기하면, 데이터베이스에서 트랜잭션은 하나의 거래를 안전하게 처리하도록 보장해주는 기능입니다. 하나의 거래를 안전하게 처리하려면 생각보다 고려해야 할 점이 많습니다.

예를 들어, A의 5000원을 B에게 계좌이체한다고 생각해봅시다.

 

5000원 계좌이체

  1. A의 잔고를 5000원 감소
  2. B의 잔고를 5000원 증가

 

만약, A의 잔고를 5000원 감소시킨 후, B의 잔고를 5000원 증가시킬 차례에 서버가 먹통이 되었다면 어떻게 될까요?

B의 잔고는 아무런 변화없이 그대로인데, A의 잔고의 5000원만 날아간셈입니다. 아주 심각한 문제가 발생합니다.

이렇듯, 계좌이체라는 거래는 이 두 작업이 하나의 작업처럼 동작해야 합니다. 

A의 잔고가 감소했으면, B의 잔고가 증가하던지 / B의 잔고가 증가하지 않았다면, A의 잔고도 감소하면 안 됩니다.

전자의 경우인, 모든 작업이 성공해서 데이터베이스에 정상 반영하는 것을 커밋(Commit)이라 합니다. 

후자의 경우인, 작업 중 하나라도 실패해서 거래 이전으로 되돌리는 것을 롤백(Rollback)이라 합니다.

 

트랜잭션 ACID

트랜잭션은 ACID를 보장해야 합니다.

 

  • 원자성(Atomicity) : 트랜잭션 내에서 실행한 작업들은, 마치 하나의 작업인 것처럼 모두 성공하거나 실패해야 한다. (마치 더이상 쪼개지지 않는 원자처럼..)
  • 일관성(Consistency) : 모든 트랜잭션은 일관성있는 데이터베이스 상태를 유지해야 한다. 예를 들어, 데이터베이스에서 정한 무결성 제약 조건을 항상 만족해야 한다.
  • 격리성(Isolation) : 동시에 실행되는 트랜잭션들은 서로에게 영향을 미치지 않도록 격리시켜야 한다. 즉, 동시에 같은 데이터를 수정하지 못하도록 막아야 한다. 격리성은 동시성과 관련된 성능 이슈를 일으키므로, 트랜잭션 격리 수준(Isolation Level)을 선택할 수 있다. 
  • 지속성(Durability) : 트랜잭션을 성공적으로 끝냈다면, 그 결과가 항상 기록되어 있어야 한다. 중간에 시스템에 문제가 발생해도, 데이터베이스 로그 등을 사용하여 성공한 트랜잭션 내용을 복구해야 한다.

 

트랜잭션은 원자성, 일관성, 지속성을 보장합니다.

문제는 격리성인데, 트랜잭션 간의 격리성을 완전히 보장하려면 트랜잭션을 순차적으로 실행해야 합니다. 이럴 경우 동시 처리 성능이 매우 나빠집니다. 이 문제로 인해 ANSI 표준은 트랜잭션 격리 수준을 4단계로 나누어 정의했습니다.

 

트랜잭션 격리 수준 - Isolation Level

  • READ UNCOMMITED(커밋되지 않은 읽기)
  • READ COMMITTED(커밋된 읽기)
  • REPEATABLE READ(반복 가능한 읽기)
  • SERIALIZABLE(직렬화 가능)
참고 강의에서는 일반적으로 많이 사용하는 READ COMMITTED(커밋된 읽기) 트랜잭션 격리 수준을 기준으로 설명합니다. 
트랜잭션 격리 수준에 대해 더 자세한 내용은 데이터베이스 메뉴얼이나 JPA 책 16.1 트랜잭션과 락을 참고합시다.
참고  →  https://mangkyu.tistory.com/299

 

 

 


데이터베이스 연결 구조

트랜잭션을 더 자세히 이해하기 위해 데이터베이스 서버 연결 구조에 대해 알아봅시다. 

DB 세션

  • 클라이언트는 서버나 DB 접근 툴을 사용해 데이터베이스 서버에 접근할 수 있습니다. 
  • 이때, 클라이언트는 데이터베이스 서버에 연결을 요청하여 커넥션을 획득합니다. 
  • 그리고 데이터베이스는 내부에 세션을 만듭니다. 앞으로 해당 커넥션을 통한 요청은 모두 이 세션을 통해서 실행됩니다. 
  • 쉽게 이야기하면, 개발자가 클라이언트를 통해 SQL을 전달하면, 현재 커넥션에 연결된 세션이 트랜잭션을 시작하고, SQL을 실행합니다. 그리고 커밋 또는 롤백으로 트랜잭션을 종료합니다.
  • 또 다른 SQL이 전달되면, 새 트랜잭션을 다시 시작하여 같은 과정을 반복합니다.
  • 사용자가 커넥션을 닫거나, DBA(DB 관리자)가 세션을 강제로 종료하면 세션은 종료됩니다.

 

커넥션 풀과 DB 세션

커넥션 풀이 10개의 커넥션을 생성하면, 세션도 10개 만들어집니다. 

 

 

 


트랜잭션 이해 - 1. 커밋과 롤백

참고
지금부터 설명하는 내용은 트랜잭션의 개념 이해를 돕기 위해 예시로 설명하는 것입니다. 구체적인 실제 구현 방식은 데이터베이스마다 다릅니다.

트랜잭션 사용법

  • 데이터베이스 변경 쿼리를 실행한 후, 해당 결과를 데이터베이스에 반영하려면 커밋 명령인 commit을 호출하고, 반영하고 싶지 않으면 롤백 명령어인 rollback을 호출하면 됩니다.
  • 커밋을 호출하기 전까지 실행한 쿼리들은 모두 임시로 데이터를 저장한 것입니다.
    따라서, 해당 트랜잭션을 시작한 세션(사용자A)에게만 변경된 데이터가 보이고, 다른 세션(사용자B)에게는 변경 데이터가 보이지 않습니다. 
  • 등록, 수정, 삭제 모두 같은 원리로 동작합니다. 세 개의 동작을 모두 앞으로 변경이라고 표현하겠습니다.  

 

예제와 실습을 통해 알아보도록 하겠습니다.

기본 데이터

  • 세션 1, 세션 2 두 명의 사용자가 있습니다.
  • 현재 테이블을 조회하면 둘 모두에게 해당 데이터가 조회됩니다.  

 

세션 1 - 트랜잭션 시작, 신규 데이터 추가

  • 세션 1이 트랜잭션을 시작한 후, 신규 회원 1과 신규 회원 2를 쿼리를 통해 테이블에 추가했습니다. 
  • 아직 커밋은 하지 않았습니다. 
  • 해당 데이터는 테이블에 임시 상태로 저장이 됩니다.
  • 세션 1은 Select 쿼리를 통해 본인이 삽입한 신규 회원 1, 신규 회원 2를 조회할 수 있습니다.
  • 반면, 세션 2는 Select 쿼리를 실행해도 새로 추가된 데이터들을 조회할 수 없습니다. 세션 1이 아직 커밋을 하지 않았기 때문입니다.

 

✔️만약, 커밋하지 않은 데이터를 다른 세션에서 조회할 수 있다면?

세션 1에서 아직 커밋하지 않는 데이터가 세션 2에게 보인다면, 세션 2는 조회한 해당 데이터로 다른 로직을 수행할 수 있습니다. 하지만, 세션 1이 롤백을 한다면 해당 데이터가 사라지게 됩니다. 따라서 데이터 정합성에 큰 문제가 발생하고 이는 심각한 문제로 이어집니다. 따라서, 커밋 전의 데이터는 다른 세션에서 보이지 않습니다. 

 

 

경우 1 . 세션 1 커밋

  • 세션 1에서 커밋을 호출했습니다.
  • 임시 데이터가 테이블에 실제 데이터로 반영이 되었습니다.
  • 다른 세션에서 테이블을 조회하면 신규 회원들을 확인할 수 있습니다.

 

경우 2. 세션 1 롤백

  • 세션 1이 커밋 대신 롤백을 호출했습니다.
  • 세션 1이 테이블에 반영한 모든 데이터가 처음 상태로 복구됩니다.
  • 수정하거나 삭제한 데이터도 rollback을 호출하면 모두 트랜잭션 시작하기 전의 상태로 복구됩니다. 

 

자동 커밋, 수동 커밋

자동 커밋

자동 커밋은 말 그대로, 개발자가 직접 커밋을 호출하지 않아도, 쿼리가 실행될 때마다 커밋이 호출됩니다.

즉, 쿼리가 실행될 때마다 테이블에 그대로 반영이 됩니다. 커밋을 직접 호출하지 않아도 되는 편리함이 있지만, 실행할 때마다 자동으로 커밋이 되기 때문에 트랜잭션 기능을 사용할 수 없습니다.

set autocommit true; //자동 커밋 모드 설정
insert into member(member_id, money) values ('data1',10000); //자동 커밋
insert into member(member_id, money) values ('data2',10000); //자동 커밋

수동 커밋

따라서 commit과 rollback을 직접 호출하면서 트랜잭션 기능을 제대로 수행하려면 수동 커밋 모드를 사용해야 합니다.

set autocommit false; //수동 커밋 모드 설정
insert into member(member_id, money) values ('data3',10000);
insert into member(member_id, money) values ('data4',10000);
commit; //수동 커밋

 

주의 트랜잭션의 시작
보통 자동 커밋 모드가 기본으로 설정된 경우가 많기 때문에, 수동 커밋 모드로 설정하는 것이 트랜잭션의 시작이라고 표현합니다. 
수동 커밋 설정 이후엔 꼭 commit 또는 rollback을 호출해야 합니다.
또한, 수동 커밋 모드나 자동 커밋 모드는 한 번 설정하면 해당 세션에서 계속 유지됩니다. 중간에 변경이 불가능합니다.  

 

 

커밋과 롤백 실습

1. 준비

  • 실습을 위해 H2 데이터베이스 웹 콘솔 창을 2개 열어둡시다.
주의 
H2 데이터베이스 웹 콘솔 창을 2개 열때, 기존 URL을 복사하면 안 됩니다. 꼭 http://localhost:8082 를 직접 입력하거나, 새 창에서 연결 버튼을 눌러 완전히 새로운 세션에서 연결해야 합니다. URL을 복사하면 같은 세션(jsessionId)에서 실행되어서 원하는 결과가 나오지 않을 수 있습니다. 즉, 주소창에 적힌 jsessionid가 달라야 다른 세션으로 인식됩니다. 

 

 

2. 테이블 생성 및 기본 데이터 입력

drop table member if exists;

create table member (
    member_id varchar(10),
    money integer not null default 0,
    primary key (member_id)
);
//데이터 초기화
set autocommit true;
delete from member;
insert into member(member_id, money) values ('oldId',10000);
  • 현재는 자동 커밋 모드이기 때문에 별도의 커밋을 호출하지 않습니다. 
  • 참고로 이미지의 name 필드는 이해를 돕기 위해 추가한 것이고, 실제 테이블에는 없는 것이 맞습니다.

 

3. 세션 1 - 신규 데이터 추가 (커밋 전)

//트랜잭션 시작
set autocommit false; //수동 커밋 모드
insert into member(member_id, money) values ('newId1',10000);
insert into member(member_id, money) values ('newId2',10000);

 

실행

select * from member;

세션 1

 

신규 데이터 조회 가능

세션 2

신규 데이터 조회 불가능

 

 

경우 1 . 세션 1 커밋

commit; //데이터베이스에 반영

실행

세션 1과 세션 2 모두 select * from member; 를 하면 새 데이터가 조회됩니다.

 

 

경우 2. 세션 1 롤백

rollback; //롤백으로 데이터베이스에 변경 사항을 반영하지 않는다.

실행

롤백으로 인해 새 데이터가 테이블에 반영되지 않아 세션 1과 세션 2 모두 select * from member; 를 해도 새 데이터가 조회되지 않습니다. 

 

 

 

 


트랜잭션 이해 - 2. 계좌이체 예제

트랜잭션을 더욱 이해하기 위해 다음 3가지 상황을 준비했습니다.

 

  • 계좌이체 정상
  • 계좌이체 문제 발생 → 커밋
  • 계좌이체 문제 발생 → 롤백

 

계좌이체 정상

1. 기본 데이터 입력

set autocommit true;
delete from member;
insert into member(member_id, money) values ('memberA',10000);
insert into member(member_id, money) values ('memberB',10000);

 

 

2. 계좌이체 실행

  • memberA가 memberB에게 2000원을 계좌이체합니다.
set autocommit false;
update member set money=10000 - 2000 where member_id = 'memberA';
update member set money=10000 + 2000 where member_id = 'memberB';

 

 

3. 커밋

commit;
  • 정상적으로 memberA의 금액은 8000원으로 줄어들고, memberB의 금액은 12000원으로 증가한 것을 확인할 수 있습니다. 

 

 

문제 상황 발생 → 커밋 or 롤백

이번에는 계좌에체 도중 문제가 발생했는데, 커밋한 상황을 알아봅시다.

 

1. 기본 데이터 입력

set autocommit true;
delete from member;
insert into member(member_id, money) values ('memberA',10000);
insert into member(member_id, money) values ('memberB',10000);

 

2. 계좌이체 실행, 문제 발생

  • 계좌이체 도중 문제가 발생해 memberA의 돈은 2000원 줄이는 것에 성공했지만, memberB의 돈을 2000원 줄이는데 실패합니다.
set autocommit false;
update member set money=10000 - 2000 where member_id = 'memberA'; //성공
update member set money=10000 + 2000 where member_iddd = 'memberB'; //쿼리 예외 발생

실행

 

여기서 문제는 memberA의 돈이 2000원 줄어들었지만, memberB의 돈은 2000원 증가하지 않았다는 점입니다. 

계좌이체는 실패하고 memberA만 2000원 손해보았습니다.

 

 

경우 1. 강제 커밋

현재 시점에 커밋을 통해 테이블에 반영하면, 우려했던 문제 상황이 실제로 발생합니다. 

commit;

 

따라서 이렇게 문제가 발생했을 땐 커밋을 호출하면 안 됩니다. 롤백을 호출하여 트랜잭션 시작 시점으로 복구해야 합니다.

경우 2. 롤백

rollback;

똑같이 계좌이체가 실패해서 문제가 발생했지만, 롤백을 사용하여 트랜잭션 시작 이전 상태로 되돌아왔습니다. 

memberA의 돈과 memberB의 돈이 모두 그대로 원래 상태로 돌아왔습니다.

 

 

 

정리

  • 원자성 : 트랜잭션 내에서 수행되는 작업들은 마치 하나의 작업인 것처럼 모두 성공하거나 실패해야 합니다.
  • 오토 커밋 : 만약, 오토 커밋 모드로 동작하는데, 계좌이체 로직 도중에 문제가 발생하면 어떻게 될까요? 쿼리를 수행하는 즉시 테이블에 반영이 되기 때문에, 예시에서 본 것처럼 memberA의 돈만 2000원 줄어드는 심각한 문제가 발생할 수 있습니다.
  • 트랜잭션 시작 : 따라서, 이런 종류의 작업은 꼭 수동 커밋 모드를 사용하여 수동으로 커밋 또는 롤백을 수행해야 합니다. 이렇게 자동 커밋 모드에서 수동 커밋 모드로 전환하는 시점을 트랜잭션의 시작이라고 표현합니다.

 

 

 


트랜잭션 이해 - 3. DB 락 이해

만약, 세션 1이 트랜잭션을 시작한 후 데이터를 수정하고 아직 커밋은 하지 않았습니다. 이때, 세션 2에서 같은 데이터를 수정하면 여러가지 문제가 발생합니다. 바로 트랜잭션의 원자성이 깨지게 됩니다. 

이 문제를 방지하려면, 세션이 트랜잭션을 시작하고 데이터를 수정하는 동안 커밋 또는 롤백으로 해당 트랜잭션을 종료하기 전까지는, 다른 세션이 해당 데이터를 수정하지 못하도록 해야 합니다. 이를 락(Lock)이라고 합니다.

 

1. 기본 데이터 입력 및 상황

set autocommit true;
delete from member;
insert into member(member_id, money) values ('memberA',10000);
  •  현재, 세션 1은 memberA의 돈을 500원으로 변경하고 싶습니다. 
  • 동시에 세션 2는 같은 memberA의 돈을 1000원으로 변경하고 싶습니다. 
  • 다른 세션에서 같은 데이터에 접근하면 여러 문제가 발생하기 때문에 데이터베이스는 락(Lock)이라는 개념을 제공합니다.
  • 세션 1이 세션 2 보다 아주 미세하게 먼저 트랜잭션을 시작했다고 가정해봅시다. 그리고 세션1이 트랜잭션을 끝마치기 전 세션 2가 memberA를 수정하려고 시도해봅시다.

 

2. 세션 1 트랜잭션 시작

set autocommit false;
update member set money=500 where member_id = 'memberA';
  • 세션 1이 트랜잭션을 시작합니다.
  • memberA의 돈을 500으로 변경을 시도합니다.
  • 먼저, 해당 로우의 락을 먼저 획득해야 합니다. 락이 남아있으므로, 세션 1은 락을 획득합니다.
  • 획득한 락으로 세션 1이 Update SQL을 수행합니다.
  • 아직 커밋 또는 롤백은 하지 않았습니다. 

 

3. 세션 2 트랜잭션 시작

set autocommit false;
update member set money=1000 where member_id = 'memberA';
  • 세션 2가 트랜잭션을 시작합니다. 
  • 세션 2가 memberA의 money 데이터를 변경하기 위해 해당 로우의 락을 획득을 시도하지만, 없으므로 락이 획득될 때까지 대기합니다. 
참고 타임아웃
SET LOCK_TIMEOUT 60000;
set autocommit false;
update member set money=1000 where member_id = 'memberA';​​
해당 쿼리 이전에 SET LOCK_TIMEOUT(60000)을 통해 락 획득 대기 시간을 60초로 설정할 수 있습니다. 
60초안에 락을 획득하지 못하면 타임아웃 예외가 발생합니다. (참고로, H2 데이터베이스는 정확히 60초 후에 예외가 발생하지 않고 조금 더 많이 걸릴 수 있습니다.)
H2 데이터베이스는 SET LOCK_TIMEOUT을 설정하지 않으면, 기본 값이 0초입니다.

 

설정한 대기 시간동안 락을 획득하지 못해 타임아웃 발생

 

 

4. 세션 1 커밋

commit;
  • 세션 1이 커밋을 수행합니다. 
  • 트랜잭션이 종료되었으므로, 락을 반납합니다.

 

5. 세션 2 락 획득

  • 락 획득을 대기하고 있던 세션 2가 락을 획득합니다.
  • 대기하고 있던 Update SQL을 실행합니다. 
commit;
  • 커밋을 수행하고, 트랜잭션이 종료되었으므로 락을 반납합니다. 

 

 

 


DB 락 - 조회

조회는 락을 사용하지 않는다. but...

데이터베이스마다 다르지만, 보통 데이터를 조회할 때는 락을 획득하지 않습니다. 

세션 1이 트랜잭션을 시작하고 select 쿼리를 날리고 커밋을 하지 않아도, 세션 2가 해당 데이터에 접근해 update 쿼리를 날릴 수 있습니다. 

하지만, 데이터를 조회할 때도 락을 획득하고 싶을 때가 있습니다. 이럴 때 사용하는 것이 select for update 구문입니다.

Select for update

예를 들어, 애플리케이션 로직 중에 memberA의 금액을 조회한 다음 이 금액의 정보를 사용해 애플리케이션의 중요한 계산을 수행한다고 하면 어떨까요? 

따라서 이 계산을 수행하는 도중에는 memberA의 금액을 다른 곳에서 변경하면 안 됩니다. 

이럴 때 조회 시점에 락을 획득하여 처리할 수 있습니다. 

ex) 마감을 한 후 하루 매출 정산을 할 때, 해당 데이터를 가져와 계산을 수행하는 대략 30분 동안은 해당 데이터가 변경되면 안 된다. 

 

 

실습

1. 기본 데이터 입력

set autocommit true;
delete from member;
insert into member(member_id, money) values ('memberA',10000);

 

2. 세션 1 - select for update 구문 사용

set autocommit false;
select * from member where member_id='memberA' for update;
  • select for update 구문을 사용해 조회와 동시에 선택한 로우의 락을 획득합니다. (물론, 락이 없다면 락을 획득할 때까지 대기해야 합니다.)
  • 세션 1은 트랜잭션을 종료할 때까지 memberA의 로우의 락을 보유합니다. 
  • 참고로, 세션 1이 락을 소유해도 세션 2에서 select (조회)는 가능합니다.

 

3. 세션 2 - 변경 시도 

set autocommit false;
update member set money=500 where member_id = 'memberA';
  • 세션 2가 데이터를 변경하려고 합니다. 그러기 위해선 락이 필요합니다.
  • 세션 1이 현재 memberA 로우의 락을 획득하고 있기 때문에 세션 2는 락을 획득할 때까지 대기합니다. 
  • 이후에 세션 1이 커밋을 수행하는 동시에 대기하고 있던 세션 2가 락을 획득하고 update 쿼리를 수행합니다. 
    만약, 락 타임아웃 시간이 지나면 락 타임아웃 예외가 발생합니다. 

 

4. 세션 1 - 커밋

commit;
  • 세션 1이 락을 반환합니다. 

 

5. 세션 2 - 커밋

commit;
  • 세션 2도 커밋하여 락을 반환합니다. 

 

 

참고
트랜잭션과 락은 데이터베이스마다 실제 동작하는 방식이 다릅니다. 따라서, 해당 데이터베이스 메뉴얼을 확인해보고 의도한대로 동작하는지 테스트한 이후에 사용하도록 합시다. 
트랜잭션과 락에 관한 더 깊이있는 내용은 JPA 책 16.1 트랜잭션과 락을 참고합시다. 

 

 

 


트랜잭션 애플리케이션 적용

실제 애플리케이션을 개발할 때 트랜잭션을 사용하여 계좌이체와 같이 원자성이 중요한 비지니스 로직을 어떻게 처리하는지 알아봅시다. 

먼저, 트랜잭션 없이 구현하여 어떤 문제점이 발생하는지 확인해봅시다.

 

 

트랜잭션 없이 계좌이체 로직 구현

MemberService - 비지니스 로직 수행 중 문제 발생

package hello.jdbc.service;

...

@RequiredArgsConstructor
public class MemberServiceV1 {

    private final MemberRepositoryV1 memberRepository;

    /**
     * fromId의 회원을 조회하여, toId의 회원에게 money 만큼의 돈을 계좌이체하는 로직 수행
     * 로직 수행 도중 예외 발생을 위해 toId가 "ex"인 경우, validation() 함수를 통해 IllegalStateException 발생
     */
    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
        Member fromMember = memberRepository.findById(fromId);
        Member toMember = memberRepository.findById(toId);

        memberRepository.update(fromId, fromMember.getMoney() - money);
        validation(toMember);
        memberRepository.update(toId, toMember.getMoney() + money);
    }

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

 

MemberServiceTest - 계좌이체 실행

package hello.jdbc.service;

...

/**
 * 기본 동작, 트랜잭션이 없으므로 데이터 정합성의 문제 발생
 */
public class MemberServiceV1Test {

    public static final String MEMBER_A = "memberA";
    public static final String MEMBER_B = "memberB";
    public static final String MEMBER_EX = "ex";

    private MemberRepositoryV1 memberRepository;
    private MemberServiceV1 memberService;

    @BeforeEach
    void before() {
        DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        memberRepository = new MemberRepositoryV1(dataSource);
        memberService = new MemberServiceV1(memberRepository);
    }

    /**
     * 테스트에 영향을 주지 않기 위해 테스트 종료 때마다 사용한 데이터 제거
     */
    @AfterEach
    void after() throws SQLException {
        memberRepository.delete(MEMBER_A);
        memberRepository.delete(MEMBER_B);
        memberRepository.delete(MEMBER_EX);
    }

    @Test
    @DisplayName("정상 이체")
    void accountTransfer() throws SQLException {
        //given
        Member memberA = new Member(MEMBER_A, 10000);
        Member memberB = new Member(MEMBER_B, 10000);
        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());
        assertThat(findMemberA.getMoney()).isEqualTo(8000);
        assertThat(findMemberB.getMoney()).isEqualTo(12000);
    }

    @Test
    @DisplayName("이체중 예외 발생")
    void accountTransferEx() throws SQLException {
        //given
        Member memberA = new Member(MEMBER_A, 10000);
        Member memberEx = new Member(MEMBER_EX, 10000);
        memberRepository.save(memberA);
        memberRepository.save(memberEx);

        //when
        assertThatThrownBy(() -> memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
                .isInstanceOf(IllegalStateException.class);

        //then
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberEx = memberRepository.findById(memberEx.getMemberId());

        //memberA의 돈만 2000원 줄었고, ex의 돈은 10000원 그대로이다.
        assertThat(findMemberA.getMoney()).isEqualTo(8000);
        assertThat(findMemberEx.getMoney()).isEqualTo(10000);
    }


}

 

결과

정상 이체의 경우, memberA가 memberB에게 2000원 이체하면 memberA의 돈은 8000원이 되고 memberB의 돈은 12000원이 됩니다.

하지만, 중간에 예외가 발생할 경우 memberA의 돈은 8000원으로 2000원 감소했지만 ex의 돈은 10000인 것을 확인할 수 있습니다.

 

 

 

트랜잭션을 사용하여 계좌이체 로직 구현

이처럼 트랜잭션을 사용하지 않고 계좌이체 같은 로직을 구현하면 문제가 발생한다는 것을 확인했습니다.

그렇다면, 트랜잭션을 어느 시점에 시작해야 할까요? 다시 말해 트랜잭션을 시작하는 코드를 Service와 Repository 둘 중 어디에 넣고, 커밋 또는 롤백하는 코드는 어느 계층에 넣어야 할까요?

 

트랜잭션 시작과 종료 위치

트랜잭션은 비지니스 로직이 있는 서비스 계층에서 시작해야 합니다. 

그래야 비지니스 로직을 시작하기 전에 트랜잭션을 시작하고, 비지니스 로직이 모두 끝난 후 트랜잭션을 종료할 수 있기 때문입니다. 

 

하지만 여기엔 지금 한 가지 걸림돌이 있습니다.

애플리케이션에서 트랜잭션을 사용하려면 비지니스 로직을 수행하는 쿼리들은 모두 같은 커넥션을 유지해야 합니다.

그래야 같은 세션을 사용하여 한 트랜잭션 내의 로직이라는 것을 인식할 수 있습니다.

(memberA의 돈을 2000원 감소할 때도, memberB의 돈을 2000원 증가시킬 때도 같은 커넥션을 사용해야 합니다.)

 

트랜잭션과 세션

 

어떻게 같은 커넥션을 유지할 수 있을까요?

방법은 단순합니다. 리포지토리에서 쿼리를 수행할 때, 커넥션 객체를 파라미터로 전달하여 같은 커넥션을 사용하도록 하는 것입니다. 

 

MemberRepository

  • 아래의 코드에서 중요한 점은 getConnection()을 통해 커넥션를 새로 생성하는 것이 아니라, 파라미터로 넘어온 커넥션 객체를 사용합니다. 
  • 커넥션을 현재 시점에 닫지 않습니다. 모든 트랜잭션 내의 로직이 수행된 후에 서비스 계층에서 닫습니다. 
package hello.jdbc.repository;

...

/**
 * jDBC - ConnectionParam
 */
@Slf4j
public class MemberRepositoryV2 {

    private final DataSource dataSource;

    public MemberRepositoryV2(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    //save...
    //findById...

    /**
     * 트랜잭션 내의 쿼리들은 같은 커넥션을 사용하기 위해 파라미터로 커넥션 객체 전달
     */
    public Member findById(Connection con, String memberId) throws SQLException {
        String sql = "select * from member where member_id = ?";

        PreparedStatement pstmt = null;
        ResultSet rs = null;

        try {
            pstmt = con.prepareStatement(sql);      //getConnection이 아닌 파라미터로 넘어온 커넥션 사용
            pstmt.setString(1, memberId);

            rs = pstmt.executeQuery();

            if(rs.next()) {
                Member member = new Member();
                member.setMemberId(rs.getString("member_id"));
                member.setMoney(rs.getInt("money"));
                return member;
            } else {
                throw new NoSuchElementException("member not found memberId=" + memberId);
            }
        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            //connection은 여기서 닫지 않는다.
            JdbcUtils.closeResultSet(rs);
            JdbcUtils.closeStatement(pstmt);
        }
    }

    //update...

    public void update(Connection con, String memberId, int money) throws SQLException {
        String sql = "update member set money=? where member_id=?";

        PreparedStatement pstmt = null;

        try {
            pstmt = con.prepareStatement(sql);
            pstmt.setInt(1, money);
            pstmt.setString(2, memberId);
            pstmt.executeUpdate();
        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            //connection은 여기서 닫지 않는다.
            JdbcUtils.closeStatement(pstmt);
        }
    }

    //delete...
    //close...
    //getConnection...
}

 

MemberService

package hello.jdbc.service;

...

/**
 * 트랜잭션 - 커넥션 파라미터 연동, 풀을 고려한 종료
 */
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV2 {

    private final DataSource dataSource;
    private final MemberRepositoryV2 memberRepository;

    /**
     * 트랜잭션을 시작하기 위해선 커넥션이 필요합니다.
     * 비지니스 로직이 성공 시 커밋합니다.
     * 비지니스 로직이 실패 시 롤백합니다.
     * 트랜잭션이 모두 종료되면, 커넥션을 닫습니다.
     */
    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
        Connection con = dataSource.getConnection();

        try {
            con.setAutoCommit(false);   //트랜잭션 시작
            //비지니스 로직
            bizLogic(con, fromId, toId, money);     //커넥션 객체 함께 전달
            con.commit();   //성공시 커밋
        } catch (Exception e) {
            con.rollback(); //실패시 롤백
            throw new IllegalStateException(e);
        } finally {
            release(con);   //커넥션 반환
        }
    }

    private void bizLogic(Connection con, String fromId, String toId, int money) throws SQLException {
        Member fromMember = memberRepository.findById(con, fromId);
        Member toMember = memberRepository.findById(con, toId);

        memberRepository.update(con, fromId, fromMember.getMoney() - money);
        validation(toMember);
        memberRepository.update(con, toId, toMember.getMoney() + money);
    }

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

    /**
     * 커넥션 반환
     * 커넥션을 커넥션 풀에 반환할 때는 자동 커밋 모드로 변경한 후 반환해야합니다.
     * 기본적인 동작 방식은 자동 커밋이기 때문에, 재사용되는 커넥션을 다른 쓰레드가 사용할 경우 수동 커밋을 자동 커밋으로 착각하고 로직을 수행할 수 있습니다. 
     */
    private void release(Connection con) {
        if(con != null) {
            try {
                con.setAutoCommit(true);    //커넥션 풀 고려
                con.close();
            } catch (Exception e) {
                log.info("error", e);
            }
        }
    }
}

 

MemberServiceTest - 실행

package hello.jdbc.service;

...

/**
 * 트랜잭션 - 커넥션 파라미터 전달 방식의 세션 동기화
 */
public class MemberServiceV2Test {
    private MemberRepositoryV2 memberRepository;
    private MemberServiceV2 memberService;

    @BeforeEach
    void before() {
        DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        memberRepository = new MemberRepositoryV2(dataSource);
        memberService = new MemberServiceV2(dataSource, memberRepository);
    }

    @AfterEach
    void after() throws SQLException {
        memberRepository.delete("memberA");
        memberRepository.delete("memberB");
        memberRepository.delete("ex");
    }

    @Test
    @DisplayName("정상 이체")
    void accountTransfer() throws SQLException {
        //given
        Member memberA = new Member("memberA", 10000);
        Member memberB = new Member("memberB", 10000);
        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());
        assertThat(findMemberA.getMoney()).isEqualTo(8000);
        assertThat(findMemberB.getMoney()).isEqualTo(12000);
    }

    @Test
    @DisplayName("이체중 예외 발생")
    void accountTransferEx() throws SQLException {
        //given
        Member memberA = new Member("memberA", 10000);
        Member memberEx = new Member("ex", 10000);
        memberRepository.save(memberA);
        memberRepository.save(memberEx);

        //when
        assertThatThrownBy(() -> memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
                .isInstanceOf(IllegalStateException.class);

        //then
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberEx = memberRepository.findById(memberEx.getMemberId());

        //memberA의 돈이 롤백 되어야 함
        assertThat(findMemberA.getMoney()).isEqualTo(10000);
        assertThat(findMemberEx.getMoney()).isEqualTo(10000);
    }

}

 

결과

트랜잭션없이 계좌이체 로직을 수행했던 전의 코드와 달리, 크랜잭션을 사용하여 memberA가 ex 에게 2000원을 계좌이체하는 도중 예외가 발생할 경우 롤백되어 memberA와 ex의 돈이 10000원으로 복구된 것을 확인할 수 있습니다.

 

 

 

 


아직 남은 문제들

  1. 서비스 계층에 섞인 트랜잭션 코드
    : 서비스 계층은 의존성없는 순수한 계층으로 남아있어야 나중에 DB 교체와 다른 계층의 변경이 있어도 서비스 계층은 코드 수정을 하지 않을 수 있습니다. 현재는 DB와 관련된 트랜잭션 코드가 비지니스 로직 수행 코드보다 많을 뿐더러 throw SQLExcpetion와 같이 특정 JDBC에 종속된 예외 코드가 남아있습니다. 
  2. 커넥션 객체를 파라미터로 전달
    : 트랜잭션을 사용하기 위해 커넥션 객체를 리포지토리에 파라미터로 넘겼습니다. 따라서 리포지토리에는 커넥션 파라미터가 있는 메서드와 없는 메서드가 생겼습니다. 같은 기능의 2개의 메서드로 인해 실수할 확률도 늘고 매번 2개의 메서드를 작성해야 하는 번거러움도 생겼습니다.

 

해당 문제들은 스프링이 제공하는 기능을 통해 해결할 수 있습니다.

미리 작성해보면 트랜잭션 AOP, 트랜잭션 매니저를 통해 해결할 수 있습니다.

다음 글에서 작성하도록 하겠습니다.

 

 

 


출처

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-db-1/dashboard

 

스프링 DB 1편 - 데이터 접근 핵심 원리 - 인프런 | 강의

백엔드 개발에 필요한 DB 데이터 접근 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 DB 접근 기술의 원리와 구조를 이해하고, 더 깊이있는 백엔드 개발자로 성장할 수 있습니다., 백엔

www.inflearn.com