관리 메뉴

나구리의 개발공부기록

트랜잭션 이해, 데이터베이스 연결 구조와 DB 세션, 트랜잭션 - DB예제, DB 락, 트랜잭션 적용 본문

인프런 - 스프링 완전정복 코스 로드맵/스프링 DB 1편 - 데이터 접근 핵심 원리

트랜잭션 이해, 데이터베이스 연결 구조와 DB 세션, 트랜잭션 - DB예제, DB 락, 트랜잭션 적용

소소한나구리 2024. 9. 12. 15:40

  출처 : 인프런 - 스프링 DB 1편 데이터 접근 핵심 원리 (유료) / 김영한님  
  유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용  

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


1. 트랜잭션 이해

1) 데이터 베이스에 저장하는 이유

  • 데이터를 저장할 때 단순히 파일에 저장하는 것이 아닌 데이터베이스에 저장하는 이유 중 가장 대표적인 이유는 데이터베이스는 트랜잭션이라는 개념을 지원하기 때문임
  • 트랜잭션은 이름 그대로 번역하자면 거래라는 뜻인데, 데이터베이스에서 트랜잭션은 하나의 거래를 안전하게 처리하도록 보장해주는 것을 뜻함
  • 하나의 거래를 안전하게 처리하려면 생각보다 고려해야할 점이 많은데, 예를들어 계좌이체의 경우 A의 5000원을 B에게 계좌이체 한다고 했을 때, A의 잔고를 5000원 감소하고 B의 잔고를 5000원을 증가해야하는 것처럼 2가지 작업이 하나의 작업처럼 동작해야 함
  • 만약 A의 잔고의 5000원을 감소하는 코드만 성공하고, B의 잔고를 5000원 증가하는 코드는 실패하게 된다면 A의 잔고만 5000원 감소하는 심각한 문제가 발생됨
  • 트랜잭션 기능을 사용하면 증가, 감소 둘다 함께 성공해야 저장하고 중간에 하나라도 실패하면 거래 전의 상태로 돌아갈 수 있음
  • 모든 작업이 성공해서 데이터베이스에 정상 반영하는 것을 커밋(Commit)이라고 하고, 작업 중 하나라도 실패해서 거래 이전으로 되돌리는 것을 롤백(Rollback)이라고 함

2) 트랜잭션 ACID

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

3) 트랜잭션 격리 수준 - Isolation level

  • 트랜잭션 간에 격리성을 완벽히 보장하려면 트랜잭션을 거의 순서대로 실행해야하는데 이렇게 하면 동시 처리 성능이 매우 나빠짐
  • 이런 문제로 ANSI 표준은 트랜잭션의 격리 수준을 4단계로 나누어 정의함
  • READ UNCOMMITED(커밋되지 않은 읽기)
  • READ COMMITED(커밋된 읽기)
  • REPEATABLE READ(반복 가능한 읽기)
  • SERIALIZABLE(직렬화 가능)
  • 주로 READ COMMITED, REPEATABLE READ 둘중 하나를 사용하며, READ UNCOMMITED는 DB입장에서는 가장 성능은 좋지만 커밋되지 않은 변경 중인 데이터가 보이게 되는 문제가 발생되기 때문에 사용하지 않음

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

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

데이터 베이스 연결 구조


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

  • 트랜잭션의 동작 개념의 전체 그림을 이해하는 시간
  • 트랙잭션 개념의 이해를 돕기 위해 예시로 설명하는 것이며 구체적인 실제 구현방식은 데이터베이스마다 다름

1) 트랜잭션 사용법

  • 데이터 변경 쿼리를 실행하고 데이터 베이스에 그 결과를 반영하려면 커밋 명령어인 commit을 호출하고 결과를 반영하고 싶지 않으면 rollback을 호출하면 됨
  • 커밋을 호출하기 전까지는 임시로 데이터를 저장하는 것
  • 따라서 해당 트랜잭션을 시작한 세션(사용자)에게만 변경 데이터가 보이고 다른 세션(사용자)에게는 변경 데이터가 보이지 않으며 등록, 수정, 삭제 모두 같은 원리로 동작함

2) 트랜잭션 동작

(1) 기본데이터 -> 세션1 신규 데이터 추가

기본 데이터 -> 세션1에 신규 데이터 추가

  1. 세션1, 세션2 둘다 가운데 있는 기본 테이블을 조회하면 해당 데이터가 그대로 조회됨
  2. 세션1은 트랜잭션을 시작하고 신규 회원1, 신규회원 2를 DB에 추가하고 아직 커밋은 하지 않으면 새로운 데이터는 임시 상태로 저장됨
  3. 세션1은 select 쿼리를 실행해서 본인이 입력한 신규 회원1, 신규회원 2를 조회할 수 있지만 세션2는 select 쿼리를 실행해도 신규 회원들을 조회할 수 없음

(2) 커밋하지 않은 데이터를 다른 곳에서 조회할 수 있으면 어떤 문제가 발생하는가

  • 세션2는 데이터를 조회 했을 때 회원 1, 2가 보이게 되는데, 세션2는 신규 회원1, 신규회원 2가 있다고 가정하고 어떤 로직을 수행할 수 있음
  • 그런데 세션 1이 롤백을 수행하면 신규 회원1, 신규 회원2의 데이터가 사라지게 되면서 데이터의 정합성에 큰 문제가 발생함
  • 세션2가 회원 1, 2가 있다고 가정하고 비용을 지불하는 로직을 하였는데 세션1이 롤백을 해버리게 되면 심각한 문제가 발생할 수 있어 커밋 전의 데이터는 다른 세션에서 보이지 않는다
  • 물론 위에서 설명한대로 격리성 level을 조절할 순 있지만 해당 옵션은 사용하지 않음

(3) 신규 데이터 추가후 commit / rollback

  1. 세션1이 신규 데이터를 추가한 후에 commit을 호출하면 새로운 데이터가 실제 데이터베이스에 반영되며 데이터의 상태도 임시 -> 완료로 변경되고 다른 세션에서도 회원 테이블을 조회하면 신규 회원을 확인할 수 있음
  2. 세션 1이 신규 데이터를 추가한 후에 rollback을 호출하면 세션1이 데이터베이스에 반영한 모든 데이터가 처음 상태로 복구되며 수정하거나 삭제한 데이터도 rollback을 호출하면 모두 트랜잭션을 시작하지 직전의 상태로 복구됨

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

1) 예제에 사용되는 스키마

  • 해당 스키마를 H2 데이터베이스에 실행해서 테이블을 생성
drop table member if exists;
create table member (
   member_id varchar(10),
   money integer not null default 0,
   primary key (member_id)
);

2) 자동 커밋 설정 - 기본값

  • 트랜잭션을 사용하려면 자동 커밋과 수동 커밋을 이해해야 하는데, 자동 커밋으로 설정하면 각각의 쿼리 실행 직후에 자동으로 커밋을 호출함
  • 커밋이나 롤백을 직접 호출하지 않아도 되는 편리함이 있으나 쿼리를 하나하나 실행할 때마다 자동으로 커밋이 되어버리기 때문에 우리가 원하는 트랜잭션 기능을 제대로 사용할 수 없음
  • 물론 자동커밋도 한줄한줄 트랜잭션이 발생 되는 것임
set autocommit true; //자동 커밋 모드 설정
insert into member(member_id, money) values ('data1',10000); //자동 커밋
insert into member(member_id, money) values ('data2',10000); //자동 커밋

 

3) 수동 커밋 설정

  • commit, rollback을 직접 호출하면서 트랜잭션 기능을 제대로 수행하려면 자동 커밋을 끄고 수동 커밋을 사용해야 함
  • 보통 자동 커밋 모드가 기본으로 설정된 경우가 많기 때문에 수동 커밋 모드로 설정하는 것을 트랜잭션을 시작한다고 표현 할수 있음
  • 수동 커밋을 설정하면 이후에 꼭 commit, rollback을 호출 해야함
  • 수동 커밋 모드나 자동 커밋 모드는 한번 설정하면 해당 세션에서는 계속 유지되며 중간에 변경하는 것은 가능함

 

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

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

1) 기본 데이터 입력

  • 세션을 두개 생성해야 하므로 H2 콘솔 창을 2개 열고 실습
  • 웹 브라우저의 URL을 그대로 복사하면 세션자체가 복사되므로 터미널을 하나 열어서 h2.sh를 하나더 실행하거나 웹브라우저를 켜서 localhost:8082로 접속하면 새로운 세션을 받음
  • 접속 했을 때 URL에 jsessionid값이 서로 달라야함 (같으면 같은 세션에 접근하게 됨)
  • 해당 쿼리를 실행해서 데이터를 초기화 하고 세션1, 세션2에서 select * from member; 쿼리로 동일한지 결과를 확인
//데이터 초기화
set autocommit true;
delete from member;
insert into member(member_id, money) values ('oldId',10000);

2) 신규 데이터 추가 - 커밋 전

  • 세션 1에 아래의 쿼리를 실행해서 수동 커밋으로 신규 데이터를 추가
  • 추가 후 세션1, 세션2에서 select * from member; 쿼리를 실행해보면 세션1에는 입력한 데이터가 보이지만 세션2에서는 입력한 데이터가 보이지 않음
//트랜잭션 시작
set autocommit false; //수동 커밋 모드
insert into member(member_id, money) values ('newId1',10000);
insert into member(member_id, money) values ('newId2',10000);

3) 커밋 - commit

  • 세션1에서 commit;을 해주고 세션2에서 select 문을 날려보면 데이터베이스에 실제 데이터가 반영 되었기 때문에 데이터가 조회되는 것을 조회할 수 있음

4) 롤백 - rollback

  • 롤백 테스트를위헤 1), 2) 번까지 진행하여 데이터 초기화 및 세션1에 신규 데이터를 추가하고 각 세션에서 select 문으로 데이터를 조회해보면 기존 2번의 결과와 마찬가지로 세션1에는 데이터3개 세션1에는 데이터 1개가 조회됨
  • 세션 1에서 rollback;을 해주고 세션1, 세션2에서 select 문을 날려보면 데이터가 DB에 반영되지 않은 것을 확인할 수 있음  

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

  • 계좌이체 예제를 통한 트랜잭션 알아보는 실습

1) 계좌이체의 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) 계좌이체 정상

계좌이체 실행 -> 커밋

set autocommit false;
update member set money=10000 - 2000 where member_id = 'memberA';
update member set money=10000 + 2000 where member_id = 'memberB';
  1. set autocommit false로 설정후 memberA의 돈을 memberB에게 2000원 계좌이체하는 update 쿼리를 실행
  2. autocommit이 false 이므로 임시 상태로 되어 다른 세션에서 조회하면 기존의 데이터가 보이지만, 쿼리를 실행한 세션에서는 임시 상태의 결과가 보여짐
  3. commit을 하고 데이터를 확인해보면 데이터베이스에 결과가 반영되면서 memberA의 MONEY는 8000원이 되고 memberB의 MONEY는 12000원이 되어 모든 세션에서 데이터베이스의 결과가 정상적으로 보여짐

2) 계좌이체 문제 상황 - 커밋

set autocommit false;
update member set money=10000 - 2000 where member_id = 'memberA'; //성공
update member set money=10000 + 2000 where member_iddd = 'memberB'; //쿼리 예외 발생
  1. 다시 기본 데이터로 세팅 후 계좌이체를 실행 중 문제가 발생하도록 두번째 SQL의 조건을 member_iddd로 작성하여 쿼리를 실행
  2. member_iddd는 찾을 수 없다는 오류가 나오면서 오류 메세지가 뜨면서 쿼리를 실행한 세션에서 select 쿼리로 조회해 보면 memberA의 돈을 2000원 줄이는 데는 성공했지만 memberB의 돈을 2000원 증가 시키는 것 실패한 것을 확인 수 있음
  3. 커밋을 실행 후 각 세션에서 조회를 해보면 2개의 쿼리중 한개만 성공한 내용이 데이터베이스에 반영되어 결과가 보여짐
  4. 이렇게 모든 쿼리가 성공하지 않은 상태에서 강제로 commit을 실행하게 되면 계좌이체의 전체 거래는 실패하게되고, memberA의 돈만 2000원 줄어드는 아주 심각한 문제가 발생하므로 커밋을 호출하면 안되고, 롤백을 호출해서 데이터를 트랜잭션 시작 시점으로 원복 해야 함

3) 계좌이체 문제 상황 - 롤백

  • 아까의 문제의 상황에서 롤백을 하기 위해 데이터 초기화 -> 오류 발생시점까지 다시 진행해서 쿼리를 똑같이 정상 쿼리 하나는 실행되어 임시 상태가 되고 비정상 쿼리는 실패해서 수행 안된상태로 진행 후 select 쿼리로 결과를 확인
  • 트랜잭션이 실패 되었으므로 update 쿼리를 진행한 세션에서 rollback을 진행하면 트랜잭션 시작시점 되돌아감
  • select로 각 세션에서 조회해보면 잔고가 모두 10000으로 돌아간 것을 확인할 수 있음

4) 정리

(1) 원자성

  • 트랜잭션 내에서 실행한 적음들은 마치 하나의 작업인 것처럼 모두 성공하거나 모두 실패해야 함, 트랜잭션의 원자성 덕분에 여러 SQL 명령어를 마치 하나의 작업인 것 처럼 성공하면 한번에 반영하고 중간에 실패해도 모두 간단히 되돌릴 수 있었음

 

(2) 오토커밋과 트랜잭션 시작

  • 오토 커밋 모드로 동작하는데 계좌이체 중간에 실패하면 쿼리를 하나 실행할 때 바로 커밋이 되어버리기 때문에 쿼리가 하나만 실행되어 정상적인 결과가 보여지지 않는 심각한 오류가 발생함(돈만 빠져나가고 증가가 안되는 현상 등)
  • 이런 종류의 작업은 꼭 수동 커밋 모드를 사용해서 수동으로 커밋, 롤백을 할 수 있도록 해야하며 보통 이렇게 자동 커밋 모드에서 수동 커밋 모드로 전환하는 것을 트랜잭션을 시작한다고 표현함

7. DB 락 - 개념 이해

  • 세션1이 트랜잭션을 시작하고 데이터를 수정하는 동안 아직 커밋을 수행하지 않았는데, 세션 2에서 동시에 같은 데이터를 수정하게 되면 트랜잭션의 원자성이 깨지게 됨
  • 여기에 더해서 세션1이 중간에 롤백하게 되면 세션2는 잘못된 데이터를 수정하는 문제가 발생됨
  • 이런 문제를 방지하려면 세션이 트랜잭션을 시작하고 데이터를 수정하는 동안에는 커밋이나 롤백 전까지 다른 세션에서 해당 데이터를 수정할 수 없게 막아야 함

1) 락 개념이 도입된 트랜잭션 예시

그림 순서 0 -> 6까지

  1. 데이터 베이스는 세션1이 memberA의 금액을 500원으로 변경하고 싶고 세션2는 같은 memberA의 금액을 1000원으로 변경하려고 할 때 동시에 데이터를 수정하는 문제를 해결하기 위해 락(Lock)이라는 개념을 제공함
  2. 둘중 조금이라고 먼저 도착한 세션이 우선권을 가지게 되는데, 여기서는 세션1이 먼저 도착했다고 가정하면 세션1이 트랜잭션을 시작하면 해당 로우의 락을 먼저 획득을 하고 memberA의 money를 500으로 변경하는 sql을 실행함
  3. 세션2도 트랜잭션을 시작하는데 memberA의 money를 변경하려는 시도를 하지만 변경하려면 로우의 락을 먼저 획득해야 하는데 없으므로 락이 돌아올 때까지 대기함 (무한정 대기하는 것은 아니고 락 대기 시간을 넘어가면 타임아웃 오류가 발생함, 락 대기시간은 설정할 수 있음)
  4. 세션1이 커밋을 수행하면 트랜잭션이 종료되고 락도 반납하고 락을 획득하기 위해 대기하던 세션2가 락을 획득함
  5. 세션2가 update sql쿼리를 수행하고 커밋으로 트랙잭션이 종료되면 락을 반납함

8. DB 락 - 변경

  • 실습을 위해 기본 데이터를 입력
set autocommit true;
delete from member;
insert into member(member_id, money) values ('memberA',10000);

1) 변경과 락

(1) 세션1 트랜잭션 시작

 set autocommit false;
 update member set money=500 where member_id = 'memberA';
  • 세션1이 트랜잭션을 시작
  • memberA의 데이터를 500원으로 업데이트 하고 아직 커밋은 하지 않았음, memberA 로우의 락은 세션1이 가지게 됨

(2) 세션2 트랙잭션 시도

SET LOCK_TIMEOUT 60000;
set autocommit false;
update member set money=1000 where member_id = 'memberA';
  • SET LOCK_TIMEOUT 60000 : 락 획득 대기시간을 60초로 설정, 60초 안에 락을 얻지 못하면 예외가 발생함(H2 데이터 베이스는 딱 60초에 예외가 발생하지않고 시간이 조금더 걸림, 데이터베이스마다 내부 메커니즘에 의해 조금씩 다른듯 함)
  • 해당 쿼리를 실행하면 세션2는 memberA의 데이터를 1000원으로 수정하려고 하지만 세션1이 트랜잭션을 커밋하거나 롤백하지 않았으므로 세션1이 락을 들고 있어 세션2는 데이터를 수정할 수 없고 락이 돌아올 때까지 대기함
  • 세션2의 결과 창을 보면 쿼리 3개중 2개만 실행되고 나머지 한개는 실행이 안된 모습을 볼 수 있음
  • 이때 세션2에서 다른 쿼리를 계속 실행해도 락이 획득 되기 전까지 모든 트랜잭션은 수행이 안됨

(3) 세션1 트랜잭션 종료

  • 세션1에서 커밋을 수행하면 트랙잭션이 종료되어 락을 반납하고 세션2가 락을 획득하여sql을 수행
  • 세션1에서 커밋을 수행하자마자 세션2의 결과창에서 sql이 수행된 내역을 확인할 수 있음
  • 이때 만약 세션2에서 여러가지 쿼리를 수행해놓은 상태였다면 세션1이 커밋하는 순간 모든 쿼리가 순차적으로 실행되는 것을 확인할 수 있음

(4) 세션2 트랜잭션 종료

  • 세션2에서 commit을 수행하면 트랜잭션이 종료되고 락이 반납됨

2) 락 타임아웃

  • SET LOCK_TIMEOUT 시간을 짧게 조정해서 락 대기시간동안 락을 획득하지 못하면 이미지와 락 타임아웃 오류를 발생시킴

** 참고

  • 실습시 락이 꼬이는 문제가 발생할 수 있는데, 이럴때는 H2 서버를 완전 종료(브라우저를 내리는 것이아닌 터미널에서 실행한 h2.sh를 꺼야함)후 실습하면 됨

9. DB 락 - 조회

  • 데이터베이스마다 다르지만 일반적인 조회는 락을 획득하지 않고 바로 데이터를 조회할 수 있음
  • 세션1이 락을 획득하고 데이터를 변경하고 있어도 다른 세션에서 데이터를 조회는 할 수 있음

1) 조회와 락

  • 데이터를 조회할 때도 락을 획득하고 싶을 때가 있는데 이럴 때는 select for update 구문을 사용하면됨
  • 그러면 조회 시점에 락을 가져가버리기 때문에 다른 세션에서 해당 데이터를 변경할 수 없게됨
  • 마찬가지로 commit 하면 락을 반납함

2) 조회 시점에 락이 필요한 경우는?

  • 트랜잭션 종료 시점까지 해당 데이터를 가른 곳에서 변경하지 못하도록 강제로 막아야 할 때 사용함
  • 애플리케이션 로직에서 금액을 조회후 해당 금액정보로 애플리케이션에서 어떤 계산을 수행하는데, 이 계산이 돈과 관련된 매우 중요한 계산이여서 계산을 완료할 때까지 다른곳에서 변경하면 안되는 상황, 하루 전체 금액을 정산을 해야하는데 다른데서 해당 데이터를 변경하지 못하게 일정시간 막아두어야 하는 상황 같을 때 조회 시점에 락을 획득 하면 됨

3) 실습

  • 기본 데이터는 기존과 동일한 데이터를 사용하여 초기화 진행

(1) 세션1 - 조회 락을 가져감

  • select 시 구문 마지막에 for update로 해서 락을 획득함(만약 락이 없다면 대기해야함)
  • 락을 가져갔기 때문에 다른 세션에서 변경할 수 없음(for update 구문 없는 select는 가능)
set autocommit false;
select * from member where member_id='memberA' for update;

 

(2) 세션2 - 데이터 변경시도

  • 세션1이 락을 가져갔으므로 autucommit을 false 변경하는 커밋만 수행되고 update쿼리는 수행되지 않음
  • 여기서도 마찬가지로 설정한 락 대기시간을 초과하게 되면 락 타임아웃 오류가 발생함
  • 락 타임아웃 오류가 발생하기 전에 세션1에서 commit을 수행해주면 대기하고있던 세션2가 반납된 락을 획득하고 sql을 실행함
set autocommit false;
update member set money=500 where member_id = 'memberA';

 

** 참고

  • 트랜잭션과 락은 데이터베이스마다 실제 동작하는 방식이 조금씩 다르기 때문에 해당 데이터베이스 메뉴얼을 확인해보고 의도한대로 동작하는지 테스트한 이후에 사용
  • 트랜잭션과 락에대한 더 깊이있는 내용은 검색이나 JPA책 참고

10. 트랜잭션 적용 1 - 트랜잭션 없이 계좌이체 비즈니스 로직 구현해보기

1) MemberServiceV1

  • fromId의 회원을 조회해서 toId의 회원에게 money 만큼 돈을 계좌이체하는 로직
  • 예외 상황을 테스트 해보기 위해 toId가 "ex"인 경우 예외를 발생시킴
package hello.jdbc.service;

@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) {
        // 받는 사람의 계좌id가 ex일 경우 오류가 나도록 설정 -> 트랜잭션예제를 위함
        if (toMember.getMemberId().equals("ex")) {
            throw new IllegalStateException("이체 중 예외발생");
        }
    }
}

2) MemberServiceV1Test

  • 테스트를 실행하기전 DB에 데이터가 남아 있지 않도록 모두 삭제 후 테스트를 실행해야 함
package hello.jdbc.service;

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

        Assertions.assertThat(findMemberA.getMoney()).isEqualTo(8000);
        Assertions.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);  // memberId가 ex 일경우 예외 발생

        memberRepository.save(memberA);
        memberRepository.save(memberEx);

        // when
        // accountTransfer() 작동 중 예외가 터질것을 검증
        assertThatThrownBy(
                () ->memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
                .isInstanceOf(IllegalStateException.class);

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

        // 메서드 실행 중 예외가 터졌으므로 MemberA의 Money만 8000이 되고, MemberB의 Money는 변화가 없음
        Assertions.assertThat(findMemberA.getMoney()).isEqualTo(8000);
        Assertions.assertThat(findMemberB.getMoney()).isEqualTo(10000);
    }
}

 

(1) accountTransfer() - 정상이체 테스트

 

  • given - 테스트 준비
    • memberA, memberB에 각 10000원씩 저장
  • when - 로직 실행
    • memberA -> memberB로 2000원을 보내는 계좌이체 로직을 실행
  • then - 검증
    • 계좌이체가 정상적으로 동작할 것을 예측하기 때문에 memberA의 Money는 8000, memberB의 Money는 12000원이 되는지 검증
  • 결과 확인

 

(2) accountTransferEx() - 이체중 예외 발생 테스트

 

  • given - 테스트 준비
    • memberA, memberEX에 각 10000원씩 저장
  • when - 로직 실행
    • memberA -> memberEX로 2000원을 보내는 계좌이체 로직을 실행
    • 여기서 memberEx의 ID는 "ex" 이므로 예외가 발생함
  • then - 검증
    • 예외가 발생하여 memberA의 Money만 2000원 감소할 것을 예측하기 때문에 memberA의 Money는 8000, memberEx의 Money는 10000인지 검증
  • 결과 확인

 

(3) 테스트 데이터 제거

  • 테스트가 끝나면 다음 테스트에 영향을 주지 않기 위해 @afterEach에서 테스트에 사용한 데이터를 모두 삭제
    • @BeforeEach로 테스트 전에 커넥션을 생성하도록 메서드를 작성
    • @AfterEach로 반복 테스트 실행을 위해 테스트 후 DB의 데이터를 초기화 시켜주는 메서드를 작성
  • 테스트 데이터를 제거하는 과정이 불편하지만 다음 테스트에 영향을 주지 않으려면(중복 오류 등이 발생할 수 있음) 테스트에서 사용한 데이터를 모두 제거해야함
  • 테스트 데이터를 제거하는 더 나은방법으로는 트랜잭션을 활용하면되는데, 테스트 전에 트랜잭션을 시작하고 테스트 이후에 트랜잭션을 롤백해버리면 데이터가 처음 상태로 돌아옴

(4) 정리

  • 트랜잭션을 적용하지 않으니 실제 코드에서 구현 시 메서드의 중간에 예외가 발생했을때 계좌이체 로직이 정상적으로 동작하지 않는 것을 확인할 수 있음

11. 트랜잭션 적용 2 - 트랜잭션을 사용하여 문제 해결

  • 정말 과거에는 이런식으로 적용해서 코드를 짰다고 함

1) 트랜잭션은 어떤 계층에 걸어야 할까?

좌) 비즈니스 로직과 트랜잭션 / 우) 커넥션과 세션

  • 비즈니스 로직이 잘못되면 해당 비즈니스 로직으로 인해 문제가 되는 부분을 함께 롤백 해야 하므로 트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작해야함
  • 트랜잭션을 시작하려면 커넥션이 필요한데, 결국 서비스계층에서 커넥션을 만들고 트랜잭션 커밋 이후에 커넥션을 종료해야함
  • 애플리케이션에서 DB 트랜잭션을 사용하려면 트랜잭션을 사용하는 동안 같은 커넥션을 유지해야 같은 세션을 사용할 수 있음
  • 가장 단순한 방법으로는 커넥션을 파라미터로 전달해서 같은 커넥션이 사용하도록 유지하는 방법으로 애플리케이션이 같은 커넥션을 유지하도록 할 수 있음

2) MemberRepositoryV2

  • 실제 서비스 로직에서 사용되는 findById()와 update()를 복사하여 Connection을 파라미터로 받을 수 있도록 메서드를 오버로딩
  • 커넥션 유지를 위해 파라미터로 받은 커넥션으로 메서드를 수행후 해당 커넥션을 MemberRepository에서 받으면 안됨
  • 리포지토리뿐 아니라 이후에도 커넥션을 계속 이어서 사용하기 때문에 이후 서비스 로직이 완전히 끝날 때 트랜잭션을 종료(커밋, 롤백)하고 닫아야 함
package hello.jdbc.repository;

// JDBC - ConnectionParam(커넥션을 파라미터로 넘김)
@Slf4j
public class MemberRepositoryV2 {

    // ... 기존 코드들

    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);   // 해당 인덱스에 값 저장
            int resultSize = pstmt.executeUpdate();       // 실행
            log.info("resultSize={}", resultSize);
        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally { // 꼭 finally 연결을 닫아줘야함 -> 안그럼 연결이 계속 유지됨
            // connection은 여기서 닫지 않음 -> 서비스에서 종료
            JdbcUtils.closeStatement(pstmt);
        }
    }

    // update()를 복사하여 Connection을 파라미터로 전달하는 메서드 오버로딩
    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);   // 해당 인덱스에 값 저장
            int resultSize = pstmt.executeUpdate();       // 실행
            log.info("resultSize={}", resultSize);
        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally { // 꼭 finally 연결을 닫아줘야함 -> 안그럼 연결이 계속 유지됨
            close(con, pstmt, null);
        }
    }

    // findById()를 복사하여 Connection을 파라미터로 전달하는 메서드 오버로딩
    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()) {            // 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);
//            JdbcUtils.closeConnection(con);
        }
    }
    
    // ... 기존 코드들
    
}

3) MemberServiceV2

(1)  Connection con = dataSource.getConnection()

  • 트랜잭션 시작을 위한 커넥션 생성

(2) con.setAutoCommit(false)

  • 트랜잭션을 시작하려면 자동 커밋 모드를 종료해야함(수동 커밋으로 변경)

(3) bizLogic(con, fromId, toId, money)

  • try - catch 문에 트랜잭션을 관리하는 로직과 실제 비즈니스 로직이 같이 섞여있어 구분을 하기위해 비즈니스 로직만 따로 메서드 추출로 빼서 작성
  • 비즈니스 로직 메서드에 생성한 내부 코드들에 Connection을 받아서 사용하도록 수정

(4) 트랜잭션 종료

  • 로직이 성공 시 con.commit() 으로 트랜잭션 성공
  • 예외가 발생 시(실패) con.rollback()으로 트랜잭션을 롤백

(5) release(con)

  • finally{..}를 사용해서 커넥션을 모두 사용하고 나면 커넥션을 종료해야 하는데 커넥션 풀을 사용하게 되면 con.close()를 호출 했을 때 커넥션을 종료하는 것이 아니라 풀에 반납함
  • 풀에 돌려주기 전에 데이터베이스는 오토커밋이 기본값이므로 오토커밋을 전제로하고 코드를 작성하는 경우가 많기 때문에 오토 커밋 모드로 변경해주고 반납 하는 것이 안전함
  • 해당 로직도 조금 길어서 메서드 추출로 빼서 작성
package hello.jdbc.service;

@Slf4j
@RequiredArgsConstructor
public class MemberServiceV2 {

    private final DataSource 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();   // 정상적으로 수행 되었을 경우 commit 수행
        } catch (Exception e) {
            con.rollback(); // 예외가 발생했을 경우 rollback 수행
            throw new IllegalStateException(e); // 기존 예외를 감싸서 던짐
        } finally {
            // 커넥션을 종료하는 메서드 - 오토커밋을 활성화하고 커넥션을 반납
            release(con);
        }
    }

    private void release(Connection con) {
        if (con != null) {
            try {
                con.setAutoCommit(true);    // AutoCommit 모드를 true로 복구시킴
                con.close();                // 제일 마지막에 커넥션을 close()
            } catch (Exception e) {
                log.info("error", e);   // 예외 log는 {} 없이 exception 정보만 파라미터에 넣어주면 됨
            }
        }
    }

    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) {
        // 받는 사람의 계좌id가 ex일 경우 오류가 나도록 설정 -> 트랜잭션예제를 위함
        if (toMember.getMemberId().equals("ex")) {
            throw new IllegalStateException("이체 중 예외발생");
        }
    }
}

3) MemberServiceV2Test

(1) 정상이체 - 생략

 

(2) 이체중 예외 발생

  • 트랜잭션 사용 전에는 예외가 발생하면 memberA의 돈만 감소했는데, 트랜잭션을 사용함으로써 예외가 발생하면 con.rollback()가 호출되어 트랜잭션이 롤백됨
  • 계좌이체는 실패했지만 롤백을 수행해서 memberA, memberB의 돈이 기존 10000으로 복구됨
package hello.jdbc.service;

/**
 * 트랜잭션 - 커넥션 파라미터 전달 방식으로 동기화
 */
class MemberServiceV2Test {

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

    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);  // dataSource 추가
    }

    @AfterEach // 테스트 끝난 후 호출
    void after() throws SQLException {
        memberRepository.delete(MEMBER_A);
        memberRepository.delete(MEMBER_B);
        memberRepository.delete(MEMBER_EX);
    }
    
    // ... 정상로직은 당연히 성공하므로 생략

    @Test
    @DisplayName("이체중 예외 발생")   // 테스트 이름
    void accountTransferEx() throws SQLException {
        // given
        Member memberA = new Member(MEMBER_A, 10000);
        Member memberEx = new Member(MEMBER_EX, 10000);  // memberId가 ex 일경우 예외 발생

        memberRepository.save(memberA);
        memberRepository.save(memberEx);

        // when
        // accountTransfer() 작동 중 예외가 터질것을 검증
        assertThatThrownBy(
                () ->memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
                .isInstanceOf(IllegalStateException.class);

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

        // 메서드 실행 중 예외가 터졌으므로 트랜잭션이 롤백이 적용되어 둘다 초기상태인 10000이 되야함
        Assertions.assertThat(findMemberA.getMoney()).isEqualTo(10000);
        Assertions.assertThat(findMemberB.getMoney()).isEqualTo(10000);
    }
}

4)정리

  • 트랜잭션의 적용으로 인해 계좌이체가 실패할 때 롤백을 수행해서 모든 데이터를 정상적으로 초기화가 가능해짐
  • 하지만 지금 코드는 DB 트랜잭션을 적용하려면 서비스 계층이 매우 지저분해지고 매우 복잡한 코드를 요구하게됨
  • 추가로 커넥션을 유지하도록 코드를 변경해야하는 작업도 쉬운일이 아닌데 이런 문제를 스프링으로 해결할 수 있음