[스프링 DB 1편] 트랜잭션 이해
포스트
취소

[스프링 DB 1편] 트랜잭션 이해

트랜잭션 - 개념 이해

데이터를 데이터베이스에 저장하는 이유

  • 데이터를 저장할 때 왜 파일이 아니라 DB에 저장할까?
    • 그 이유는 DB는 트랙잭션이라는 개념을 지원한다.
  • 트랜잭션이란 직역하면 거래라는 뜻이다.
    • 즉, DB에서 하나의 거래를 안전하게 처리할 수 있도록 보장해주기 위해 생겨난 개념이다.

트랜잭션의 필요 이유

하나의 거래를 안전하게 처리하기 위해서는 고려해야할 사항들이 많다.

예시로 A가 B에게 5,000원을 계좌이체를 해야 한다고 가정해보자.
그렇다면 이 하나의 거래에서는 2가지 상황이 발생한다.

  1. A의 잔고는 5,000원 감소한다.
  2. B의 잔고는 5,000원 증가한다.

해당 케이스에서 정상적인 거래라함은 위에 있는 2가지 경우가 모두 성공한 경우를 가리킨다.
만약 1번은 성공했는데 2번은 실패한다면 어떻게 될까?
A는 5,000원이 감소했는데 B는 받은 돈이 없는 상황이 될 것이다.

이를 방지하기 위해 생긴 개념이 트랜잭션이다.
트랜잭션은 하나의 거래 안에서 발생하는 모든 동작이 성공하거나 실패했을 때에 대한 처리를 해준다.

모두 성공한다면 DB에 정상 반영할 것이다. 이는 커밋(Commit)이라고 부른다.
또한 하나라도 실패한다면 최초 상태로 원복을 해야할 것이다. 이는 롤백(Rollback)이라고 부른다.

트랜잭션 ACID

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

골치아픈 격리성

  • 격리성은 아까 설명했듯이 동시에 같은 데이터를 수정하지 못 하도록 막아야 하는 것같은 상황을 의미한다.
    • 그런데 트랜잭션 간에 격리성을 완벽히 보장하려면 트랜잭션을 거의 순서대로 실행해야 한다.
    • 문제는 그렇게 하면 동시 처리 성능이 매우 나빠진다.
  • 이를 위해 ANSI 표준은 트랜잭션의 격리 수준을 4가지 단계로 나누어 정의했다.
  • 종류
    • READ UNCOMMITED (커밋되지 않은 읽기)
    • READ COMMITTED (커밋된 읽기)
    • REPEATABLE REA D(반복 가능한 읽기)
    • SERIALIZABLE (직렬화 가능)
  • 일반적으로는 커밋된 읽기인 READ COMMITTED를 많이 사용한다.

데이터베이스 연결 구조와 DB 세션

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

DB 연결 구조

  • 사용자는 웹 애플리케이션 서버(WAS)나 DB 접근 툴 같은 클라이언트를 사용해서 데이터베이스 서버에 접근할 수 있다.
  • 클라이언트는 데이터베이스 서버에 연결을 요청하고 커넥션을 맺게 된다.
    • 이 때 데이터베이스 서버는 내부에 세션이라는 것을 만든다.
    • 해당 커넥션을 통한 모든 요청은 이 세션을 통해서 실행하게 된다.
  • 커넥션을 맺는다는 것은 개발자가 클라이언트를 통해 SQL을 전달하면 현재 커넥션에 연결된 세션이 SQL을 실행하는 것을 의미한다.
  • 세션은 트랜잭션을 시작하고, 커밋 또는 롤백을 통해 트랜잭션을 종료한다. 그리고 이후에 새로운 트랜잭션을 다시 시작할 수 있다.
  • 사용자가 커넥션을 닫거나, 또는 DBA(DB 관리자)가 세션을 강제로 종료하면 세션은 종료된다.
  • 커넥션 풀이 n개의 커넥션을 생성한다면 세션도 n개 만들어진다.

트랜잭션 - DB 예제1 - 개념 이해

  • 트랜잭션 개념을 설명할 때 말했듯이 커밋과 롤백이 있는데 DB에도 동일한 이름의 명령어가 있다.
    • commit;을 실행하면 커밋이 된다.
    • rollback;을 실행하면 롤백이 된다.
  • 커밋을 실행하기 전까지는 데이터를 임시로 저장한 상태가 된다.
    • 그래서 해당 트랜잭션을 시작한 세션에게만 변경된 데이터가 보이고, 다른 세션에서는 변경된 데이터가 보이지 않는다.
    • 세션이라함은 사용자를 의미한다고 봐도 무방하다.
  • 변경된 데이터는 등록, 수정, 삭제 모두를 의미한다.
    • 세 가지 경우 모두 같은 원리로 동작하기 때문이다.
  • 왜 다른 세션에서는 보이지 않는 걸까?
    • 세션1과 세션 2가 있다고 가정했을 때 세션1이 임시로 저장했는데 세션2가 그 데이터를 볼 수 있다고 가정해보자.
    • 세션2에서는 임시 데이터가 “아 이건 수정이 필요한 데이터다.”라고 생각해서 수정해뒀는데 세션1이 롤백하면 어떻게 될까?
    • 롤백하면 임시 데이터는 사라져버리기 때문에 세션2 입장에서는 데이터 정합성이 안 맞게 된다.

트랜잭션 - DB 예제2 - 자동 커밋, 수동 커밋

  • 커밋의 종류에는 2가지가 있다.
    • 자동 커밋
    • 수동 커밋
  • 자동 커밋의 경우에는 각각의 쿼리 실행 직후에 자동으로 커밋을 호출한다.
    • 그래서 커밋이나 롤백을 직접 호출하지 않아도 되서 편리하다.
    • 대신에 무조건 커밋되어 버리기 때문에 문제가 생기기 때문에 복구하기 어렵다.
    • set autocommit true;로 자동 커밋을 활성화한다.
  • 수동 커밋
    • 이름 그래도 수동으로 커밋하는 방법이다.
    • commit 명령어를 통해 DB에 반영한다.
    • set autocommit false;로 수동 커밋을 활성화한다.
  • 보통 자동 커밋이 활성화되어 있는 경우가 많다.
    • 그래서 수동 커밋 모드로 설정하는 것을 트랜잭션을 시작한다.라고 표현한다.
  • 수동 커밋을 활성화한다면 쿼리 실행 이후에 반드시 commit이나 rollback을 호출해줘야 한다.
  • 자동 커밋이든 수동 커밋이든 한 번 설정하면 해당 세션에서는 계속 유지된다.
    • 중간에 변경하는 것은 가능하다.

트랜잭션 - DB 예제3 - 트랜잭션 실습

콘솔 2개 열기

  • 비교를 위해 콘솔을 2개 열어주자.
    • jsessionid 값이 달라야 한다.
    • 2가지 브라우저를 열어서 각각 접속하면 된다.

데이터 맞추기

아래 쿼리를 실행해서 양쪽에서 볼 수 있는 데이터를 동일하게 맞춰두자.

//데이터 초기화
set autocommit true;
delete from member;
insert into member(member_id, money) values ('oldId',10000);

그 다음에 아래 쿼리를 양쪽 콘솔에서 실행해서 같은 데이터를 보여주는지 확인하자.

select * from member; 

세션1에서 데이터 추가

이제 세션1에서 데이터를 추가해보자.
트랜잭션의 동작 확인을 위해서 commit은 호출하지 않는다.

//트랜잭션 시작
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에서는 3개의 데이터 행이, 세션2에서는 1개의 데이터 행이 보인다.
즉, 트랜잭션이 잘 동작함을 알 수 있다.

세션1에서 커밋하기

이번에는 세션1에서 커밋을 진행해보자.

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

그 다음에 아래 쿼리를 양쪽 콘솔에서 실행해서 같은 데이터를 보여주는지 확인하자.

select * from member; 

실행해보면 분명 세션2에서는 데이터 행이 1건만 있었다.
그런데 세션1에서 추가한 2개의 데이터가 추가되서 총 3개의 데이터 행이 보인다.
이를 통해 트랜잭션의 동작을 확인할 수 있었다.

롤백

이번에는 롤백에 대해서 알아보기 위해 다시 데이터를 동일하게 맞춰보자.

//데이터 초기화
set autocommit true;
delete from member;
insert into member(member_id, money) values ('oldId',10000);

그 다음에 아래 쿼리를 양쪽 콘솔에서 실행해서 같은 데이터를 보여주는지 확인하자.

select * from member; 

이제 세션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에서는 3개의 데이터 행이, 세션2에서는 1개의 데이터 행이 보인다.
즉, 여전히 트랜잭션이 잘 동작함을 알 수 있다.

이번에는 세션1에서 롤백을 진행해보자.

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

그 다음에 아래 쿼리를 양쪽 콘솔에서 실행해서 같은 데이터를 보여주는지 확인하자.

select * from member; 

롤백으로 데이터가 DB에 반영되지 않은 것을 확인할 수 있다.

트랜잭션 - DB 예제4 - 계좌이체

  • 이번에는 계좌이체 예제를 통해 트랜잭션 사용 방식을 알아보자.
  • 3가지 상황을 가정해보자.
    • 계좌이체 정상
    • 계좌이체 문제 상황 - 커밋
    • 계좌이체 문제 상황 - 롤백
  • memberA가 memberB에게 2,000원을 지급하는 상황이다.
    • memberA와 memberB의 잔고는 동일하게 10,000원으로 시작한다.
    • memberA의 잔고는 2,000원 감소한다.
    • memberB의 잔고는 2,000원 증가한다.

계좌이체 정상

우선 양쪽 세션의 데이터를 맞춰주자.

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

이제 memberA가 memberB에게 계좌이체를 진행했다고 가정해보자.
아직 커밋은 하지 않았기에 다른 세션에서는 기존 데이터가 조회된다.

set autocommit false;
update member set money=10000 - 2000 where member_id = 'memberA';
update member set money=10000 + 2000 where member_id = 'memberB';

이제 커밋을 하면 변경사항이 DB에 반영된다.

commit;

데이터를 조회해보면 계좌이체가 정상적으로 이루어진 것을 확인할 수 있다.

select * from member;

계좌이체 문제 상황 - 커밋

이번에는 계좌이체 도중에 문제가 발생하는 상황을 알아보자.
우선 데이터를 다시 초기화한다.

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

문제가 발생한 상황을 만들기 위해
memberA의 잔고에서는 2,000원이 빠져나갔는데
memberB의 잔고에는 2,000원이 들어오지 않는 상황을 만들어보자.
memberB의 잔고를 변경하는 쿼리에 “_test”라는 유효하지 않은 문자열을 추가해서 쿼리를 일부러 실패시켜보자.

set autocommit false;
update member set money=10000 - 2000 where member_id = 'memberA'; //성공
update member set money=10000 + 2000 where member_id_test = 'memberB'; //쿼리 예외 발생

이 상황에서 강제로 커밋을 하게 되면 무슨 일이 발생할까?
계좌이체는 실패하고, memberA의 잔고만 줄어드는 아주 심각한 문제가 발생한다.

commit;

이제 조회 쿼리를 통해서 잔고를 확인해보자.

select * from member;

예상한 대로 memberA의 잔고만 2,000원이 줄어든 상황이 되어버렸다.
이렇게 중간에 문제가 발생했을 때는 커밋을 호출하면 안된다.
롤백을 호출해서 데이터를 트랜잭션 시작 시점으로 원복해야 한다.

계좌이체 문제 상황 - 롤백

중간에 문제가 발생했을 때 롤백을 호출해서 트랜잭션 시작 시점으로 데이터를 원복해보자. 우선 데이터를 다시 초기화하자.

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

아까랑 동일하게 잘못된 쿼리를 실행해서 강제로 SQL 오류를 발생시켜보자.

set autocommit false;
update member set money=10000 - 2000 where member_id = 'memberA'; //성공
update member set money=10000 + 2000 where member_id_test = 'memberB'; //쿼리 예외 발생

이 때 양쪽 콘솔에서 조회 쿼리를 통해서 잔고를 확인해보자.

select * from member;

예상했듯이 쿼리가 실행된 세션1에서는 memberA의 잔고만 줄어있다.
세션1에서 커밋은 하지 않았기 때문에 세션2에서는 memberA와 memberB 모두의 잔고가 10,000원으로 보인다.
이 때 세션1에서 롤백을 진행하자.

rollback;

이제 다시 양쪽 콘솔에서 조회 쿼리를 통해서 잔고를 확인해보자.

select * from member;

조회 쿼리를 통해서 세션1과 세션2에서 모두 memberA와 memberB의 잔고가 모두 10,000원인 것을 확인할 수 있다.

DB 락 - 개념 이해

세션1이 트랜잭션을 시작하고 데이터를 수정하는 동안 아직 커밋을 수행하지 않았는데,
세션2에서 동시에 같은 데이터를 수정하게 되면 여러가지 문제가 발생한다.
바로 트랜잭션의 원자성이 깨지는 것이다.

게다가 이 와중에 세션1이 중간에 롤백을 하게 되면 세션2는 잘못된 데이터를 수정하는 문제가 발생한다.
이런 문제를 방지하기 위해서 세션이 트랜잭션을 시작하고 데이터를 수정하는 동안에는
커밋이나 롤백 전까지 다른 세션에서 해당 데이터를 수정할 수 없게 막아야 한다.

그래서 데이터베이스에서 제공하는 개념이 락(Lock)이라는 개념이다.
락은 어떠한 세션이 특정 데이터에 대해서 변경을 진행한다면 다른 세션이 해당 데이터에 대해서 변경할 수 없게 한다.

락의 원리

만약에 memberC라는 회원이 있다고 가정해보자.
memberC는 10,000원의 잔고를 가지고 있다.
세션1은 memeberC의 잔고를 500원으로, 세션2는 memberC의 잔고를 1,000원으로 변경하려고 한다.
이 때 세션1이 세션2보다 먼저 데이터 변경 쿼리를 먼저 실행했다고 가정해보자.

  1. 세션1이 트랙잭션을 시작한다.
  2. 세션1이 memberC에 대한 데이터를 수정하려고 한다.
  3. memberC에 대한 락이 존재하기에 세션1을 memberC에 대한 락을 획득한다.
  4. 이 때 세션2가 memberC에 대한 데이터를 수정하기 위해 트랜잭션을 시작한다.
  5. 세션2가 memberC에 대한 데이터를 수정하려면 해당 데이터에 대한 락이 필요하니 락을 획득하려고 한다.
    • 하지만 이미 세션1이 memberC에 대한 락을 획득하였기 때문에 세션2는 락을 획득하기 위해 대기하게 된다.
  6. 세션1이 memberC의 잔고를 수정하는 쿼리를 실행한다.
    • 아직 커밋이나 롤백을 진행하지는 않았기에 락을 반납하지는 않는다.
  7. 세션1이 커밋을 진행해서 변경 결과를 DB에 저장한다.
    • 커밋을 진행해서 트랙잭션이 종료되었기 때문에 락을 DB에 반납한다.
  8. memberC에 대한 락을 획득하기 위한 세션2가 DB로부터 memberC에 대한 락을 획득한다.
  9. 세션2가 memberC의 잔고를 수정하는 쿼리를 실행한다.
  10. 세션2가 커밋을 진행해서 변경 결과를 DB에 저장한다.
    • 커밋을 진행해서 트랙잭션이 종료되었기 때문에 락을 DB에 반납한다.

그런데 세션2가 락을 획득하기 위해 대기하고 있을 때 무한정 대기하는 것은 아니다.
락 대기 시간을 넘어가면 락 타임아웃 오류가 발생한다.
락 대기 시간은 설정할 수 있다.

DB 락 - 변경

  • DB의 락을 대기하는 시간을 변경하는 방법은 간단하다.
    • SET LOCK_TIMEOUT 60000;처럼 실행하면 된다.
    • 시간 단위가 ms인 것을 참고하자.

아까의 상황이라면 아래와 같은 쿼리가 될 것이다.

SET LOCK_TIMEOUT 60000;
set autocommit false;
update member set money=1000 where member_id = 'memberC'; 

위와 같이 실행하면 쿼리를 실행한 세션에서는 트랙잭션을 시작하고
60초가 지났는데도 락을 획득하지 못 한다면 락 타임아웃 오류가 발생한다.
참고로 H2 DB에서는 설정한 시간보다 약간 더 걸린다.

락 타임아웃 오류가 발생한다면 아래와 같은 오류 메시지가 출력된다. Timeout trying to lock table {0}; SQL statement: xxx

DB 락 - 조회

일반적인 경우에는 조회할 때는 락이 필요하지는 않다.
물론 일반적인 경우에 그런 것이지 필요한 경우가 반드시 존재한다.
예를 들면 현실의 돈과 관련된 경우가 해당될 것이다.
조회할 때 락을 얻으려면 어떻게 해야할까?

단순히 select xxx 대신에 select xxx for update를 사용하면 된다. (xxx는 다른 쿼리를 나타낸다.)
이렇게 하면 쿼리를 실행한 세션에서 락을 가져가기 때문에 다른 세션에서는 해당 데이터를 변경할 수 없다.
중요한 것은 조회지만 결국은 락을 가져가는 것이기 때문에 반드시 commit을 실행해서 락을 반환해야 한다.

세션1에서 아래의 쿼리를 실행했다고 가정해보자.

select  * from member for update;

그런 다음에 세션2에서 아래의 쿼리를 실행하고 60초정도 기다려보면 아까처럼 오류가 발생한다.

SET LOCK_TIMEOUT 60000;
set autocommit false;
update member set money=1000 where member_id = 'memberC; 

Timeout trying to lock table "MEMBER"; SQL statement:xxx

이번에는 방금 세션2에서 실행한 쿼리를 다시 실행해보고, 세션1에서 commit을 호출해보자.
그런 다음에 다시 세션2를 확인해보면 쿼리가 실행됬다고 결과를 알려주는 것을 확인할 수 있다.
당연하지만 세션2에서 트랜잭션을 시작했으니 세션2에서도 commit을 실행해야 하는 것을 까먹지 말자.

트랜잭션 - 적용1

트랜잭션의 중요성을 알아보기 위해 이번에는 트랜잭션 없이 계좌이체를 진행하는 비즈니스 로직을 구현해보자.

비즈니스 로직 구현

package hello.jdbc.service;

import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV1;
import lombok.RequiredArgsConstructor;
import java.sql.SQLException;

@RequiredArgsConstructor
public class MemberServiceV1 {
    private final MemberRepositoryV1 memberRepository;
    
    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("계좌이체 중 예외 발생");
        }
    }
}

테스트

Ctrl + Shift + T를 통해서 테스트 클래스를 생성한 후 테스트 코드를 작성해보자. - 테스트를 진행하기 전에 아까 테스트한 데이터를 미리 지워주자.

package hello.jdbc.service;

import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV1;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.sql.SQLException;
import static hello.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.*;
import static org.assertj.core.api.Assertions.assertThat;

/**
 * 기본 동작, 트랜잭션이 없어서 문제 발생
 */
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);
    }
}

트랜잭션 - 적용2

이번에는 트랜잭션 통해서 계좌이체를 진행하는 비즈니스 로직을 구현해보자.

트랜잭션을 사용하는 것을 이해하기 위해 별도의 서비스를 만들어야 한다.
그 전에 트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작해야 함을 인지해야 한다.
해당 비즈니스 로직에서 문제가 발생하면 롤백을 해야 하기 때문이다.

그런데 트랜잭션을 시작하려면 DB 커넥션이 필요하다.
즉, 서비스 계층에서 커넥션을 만들고 트랜잭션 커밋 이후에 서비스 계층에서 커넥션을 종료해야 한다는 뜻이다.
애플리케이션에서 DB 트랜잭션을 사용하는 동안은 같은 커넥션을 유지해야 한다.
그래야지 같은 세션을 사용할 수 있기 때문이다.

애플리케이션에서 같은 커넥션을 유지하려면 어떻게 해야할까?
가장 단순한 방법은 커넥션을 파라미터로 전달해서 같은 커넥션이 사용되도록 유지하는 것이다.
먼저 파라미터를 통해 같은 커넥션을 유지할 수 있도록 파라미터를 추가한 리포지토리인 MemberRepositoryV2를 만들자.

리포지토리

  • MemberRepositoryV1과 많이 차이나는 것은 없다.
  • 대신에 아래의 두 메소드가 추가되었다.
    • findById(Connection con, String memberId)
    • update(Connection con, String memberId, int money)
  • 해당 두 메소드는 왜 추가되었을까?
    • 커넥션 유지가 필요한 두 메서드는 파라미터로 넘어온 커넥션을 사용해야 한다.
      • 그래서 con = getConnection() 코드가 있으면 안된다.
    • 커넥션 유지가 필요한 두 메서드는 리포지토리에서 커넥션을 닫으면 안된다.
      • 커넥션을 전달 받은 리포지토리 뿐만 아니라 이후에도 커넥션을 계속 이어서 사용한다.
      • 서비스 로직이 끝날 때에만 트랜잭션을 종료하고 닫아야 한다.
      • 그래서 PreparedStatementResultSet만 반납하고 Connection은 반납하지 않는다.
package hello.jdbc.repository;

import hello.jdbc.domain.Member;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.support.JdbcUtils;
import javax.sql.DataSource;
import java.sql.*;
import java.util.NoSuchElementException;

/**
 * JDBC - ConnectionParam
 */
@Slf4j
public class MemberRepositoryV2 {
    private final DataSource dataSource;

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

    //회원 정보 저장하기
    public Member save(Member member) throws SQLException {
        String sql = "insert into member(member_id, money) values(?, ?)";
        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, member.getMemberId());
            pstmt.setInt(2, member.getMoney());
            pstmt.executeUpdate();
            return member;
        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            close(con, pstmt, null);
        }
    }

    //회원 정보 조회하기
    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();
            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 {
            close(con, pstmt, rs);
        }
    }

    //회원 정보 조회하기
    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);
            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);
        }
    }

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

        try {
            con = getConnection();
            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 {
            close(con, pstmt, null);
        }
    }

    //회원 정보 수정하기
    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);
        }
    }

    //회원 정보 삭제하기
    public void delete(String memberId) throws SQLException {
        String sql = "delete from member where member_id=?";
        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, memberId);
            pstmt.executeUpdate();
        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            close(con, pstmt, null);
        }
    }

    //리소스 반납히기
    private void close(Connection con, Statement stmt, ResultSet rs) {
        JdbcUtils.closeResultSet(rs);
        JdbcUtils.closeStatement(stmt);
        JdbcUtils.closeConnection(con);
    }

    //커넥션 가져오기
    private Connection getConnection() throws SQLException {
        Connection con = dataSource.getConnection();
        log.info("get connection={} class={}", con, con.getClass());
        return con;
    }
}

서비스

  • 기존 MemberServiceV1을 복사해서 새로운 MemberServiceV2를 만들고 수정하자.
package hello.jdbc.service;

import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV2;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;

/**
 * 트랜잭션 - 파라미터 연동, 풀을 고려한 종료
 */
@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);
            }
        }
    }
}
  • Connection con = dataSource.getConnection();
    • 트랙잭션을 시작하기 위한 DB 커넥션을 가져온다.
  • con.setAutoCommit(false);
    • 트랜잭션을 시작하기 위해 자동 커밋 모드를 비활성화시켜서 수동 커밋 모드를 활성화시킨다.
  • bizLogic(con, fromId, toId, money);
    • 트랜잭션이 시작된 커넥션을 전달하면서 비즈니스 로직을 수행한다.
    • 트랜잭션을 관리하는 로직과 실제 비즈니스 로직을 구분한다.
  • con.commit();
    • 비즈니스 로직이 정상 수행되면 트랜잭션을 커밋한다.
  • con.rollback();
    • 비즈니스 로직 수행 도중에 예외가 발생하면 트랜잭션을 롤백한다.
  • release(con);
    • finally를 사용해서 커넥션을 모두 사용하고 나면 안전하게 종료한다.
    • 그런데 커넥션 풀을 사용하면 con.close()를 호출 했을 때 커넥션이 종료되는 것이 아니라 풀에 반납된다.
    • 현재 수동 커밋 모드로 동작하기 때문에 풀에 돌려주기 전에 기본 값인 자동 커밋 모드로 변경하는 것이 안전하다.

테스트

  • MemberServiceV1Test를 복사해서 MemberServiceV2Test를 만들자.
  • MemberRepositoryV1MemberRepositoryV2로 바꾸자.
  • MemberServiceV1MemberServiceV2로 바꾸자.
  • MemberServiceV2의 생성자는 MemberServiceV1와 달리 DataSource도 파라미터로 받으니 해당 부분도 바꿔주자.
  • MemberServiceV2TestaccountTransferEx에서 memberA의 결과 잔고를 10000으로 바꿔주자.
  • 그런 다음에 테스트를 진행해보면 정상적으로 트랜잭션이 동작해서 비즈니스 로직에서 예외가 발생하면 롤백이 발생하는 것을 확인할 수 있다.

출처

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.