
GIF 출처 : https://sigridjin.medium.com/spring-transaction-관리에-대한-메모-f391fd2885b4
그림 자료 출처 : 김영한 - Spring DB 1 / 2 자료
1 ) 트랜잭션 이란?
2 ) 데이터베이스 연결 구조와 DB 세션
3 ) 수동 커밋과 자동 커밋
4 ) DB 락
5 ) 스프링에서 트랜잭션 적용하기
6 ) 트랜잭션 동기화 매니저
7 ) 트랜잭션 추상화
8 ) 트랜잭션 AOP
9 ) 자동 리소스 등록
트랜잭션 이란?
: 이름 그대로 ' 거래 '라는 의미이다. 개발에서 트랜잭션은 거래를 안전하게 처리하도록 ' 보장 ' 해주는 것을 뜻한다. 쉽게 이해하자면 사용자간의 데이터 변경에 대하여 오류 없이 안전하게 처리되는 것을 생각하면 된다.
트랜잭션을 잘 수행하기 위해서는 ACID라는 규칙 4가지를 준수해야 한다.
① 원자성 ( Atomicity )
: 트랜잭션 내에서 실행한 작업들은 마치 하나의 작업인 것 처럼 모두 성공하거나 실패해야한다.
② 일관성 ( Consistency )
: 모든 트랜잭션은 일관성 있는 데이터베이스 상태를 유지해야한다. 즉 , 데이터베이스에서 정한 무결성 제약 조건을 항상 만족해야한다.
③ 격리성 ( Isolation )
: 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리해야 한다. 즉, 동시에 같은 데이터를 수정하지 못하도록 해야한다는 규칙
④ 지속성 ( Durability )
: 트랜잭션을 성공적으로 끝내면 그 결과가 항상 기록되어야 한다는 것 . 트랜잭션과 관련된 데이터베이스 로그등을 통해 롤백되어야 할 상황에 트랜잭션 내용을 복구해야한다.
트랜잭션 전파 ( Transaction Propagation )
: 프랜잭션 전파란 트랜잭션의 경계에서 이미 진행 중인 트랜잭션이 있을 때 또는 없을 때 어떻게 동작할 것인가를 결정하는 방식을 의미한다.
해당 예시에서 A메소드의 트랜잭션이 시작한 이후 B메소드를 호출하였다.
이때 , B메소드의 경우 또한 트랜잭션을 담은 로직이고 트랜잭션의 경계 설정이 이루어졌다. 이후 B메소드의 트랜잭션은 커밋 혹은 롤백이 이루어질 것이고 해당 트랜잭션의 결과는 A메소드에 영향을 주지 않는다.
이와 같이 트랜잭션 경계를 가진 코드에 대해 이미 진행 중인 트랜잭션이 어떻게 영향을 미칠 수 있는가를 정의하는 것이 트랜잭션 전파 속성이라 한다.
- 트랜잭션 전파 속성 종류
① PROPAGATION_REQUIRED
② PROPAGATION_REQUIRES_NEW
③ PROPAGATION_NOT_SUPPORTED
격리 수준 ( Isolation Level )
: 여러 트랜잭션이 동시에 실행될 때 서로 얼마나 간섭할 수 있는지 결정하는 기준을 의미한다.
- 트랜잭션 격리 수준 종류
① READ UNCOMMITED ( 커밋되지 않은 읽기 )
② READ COMMITED ( 커밋된 읽기 )
③ REPEATABLE READ ( 반복 가능한 읽기 )
④ SERIALIZABLE ( 직렬화 가능 )
트랜잭션이 왜 잘 이루어져야 하는가 , 어떻게 이루어지는가를 이해하기 위해서는 먼저 데이터베이스의 연결구조와 세션에 대해 알아야한다.
먼저 사용자는 클라이언트에 통하여 DB 서버에 접근할 수 있다.앞서 언급한 커넥션을 통해 모든 요청을 수신 / 전송하게 되는데 이때 , 데이터베이스 서버는 내부에 세션이라는 것을 만든다.
이러한 세션을 통해 요청이 실행되게 되는데 세션은 트랜잭션을 시작하고 , 커밋 ( Commit ) 또는 롤백 ( Rollback ) 을 통해 트랜잭션을 종료한다.
그럼 다수의 세션이 하나의 데이터베이스 서버에 접근하는 상황을 생각해보자. 
그림의 경우 기존에 oldID 하나만 존재했던 상황에서 세션1이 2개의 새로운 데이터베이스 정보를 업데이트 하였다. 그러나 세션2의 경우 테이블을 조회했을 때 해당 업데이트 상황을 알 수 없다. 왜냐하면 세션1은 아직 ' 커밋 ( Commit ) ' 하지 않았기 때문이다.
커밋을 하게 될 경우 해당 변경사항이 다른 세션의 테이블에도 업데이트가 되며 전체적인 데이터베이스의 데이터가 변경된다. 
만약 해당 커밋이 잘못된 내용을 담은 업데이트인 경우가 존재할 수도 있다. 예를 들어 A라는 사람과 B라는 사람간의 인터넷 뱅킹을 생각해보자.
A는 10000원을 B에게 자신의 계좌에서 이체하는 상황이라고 생각해보자. 이때 , A의 계좌에서는 1만원이 빠져나갔으나 B의 계좌에는 1만원이 도착하지 않은걸로 확인되었을 때 , 이는 트랜잭션이 제대로 이루어지지 않은 상황임이 분명하다. 그렇기에 제대로된 트랜잭션을 수행하기 위해 앞서 언급한 ' 로그를 통해 트랜잭션을 복구 ' 하는 것이 필요하다. 이를 위해 사용되는 것이 바로 ' 롤백 ( Rollback ) ' 이다.
신규 데이터를 추가한 후 세션1이 롤백을 시행하였다고 해보자. 그럼 다음과 같은 결과로 변경된다. 
앞선 예시에서 트랜잭션이 이루어지기 전에 우리는 커밋을 수행했다. 이러한 커밋은 자동으로 이루어지는 ' 자동 커밋 ' 과 ' 수동 커밋 ' 이 존재한다.
- 자동 커밋
set autocommit true;
- 수동 커밋
set autocommit false;
그럼 여기서 이렇게 생각할 수도 있다. " 자동 커밋으로 해놓는게 편리한게 아닌가 ? " 물론 트랜잭션이 완전히 이루어진다는 보장아래에서는 맞는말일 수도 있다.
그러나 이전의 예시의 경우처럼 잘못된 트랜잭션이 발생한 경우에 커밋을 하게 될 경우 A 의 돈만 빠져나가는 상황이 발생하기 마련이다.
자동 커밋의 경우 DB에서 default 값으로 설정이 되어 있으므로 수동 커밋이 필요한 경우 수동 커밋으로 설정한 후 트랜잭션이 종료되었을 때 자동 커밋으로 다시 설정해놓을 필요가 있다.
그렇다면 앞선 롤백을 통해서 트랜잭션 과정에서 발생하는 모든 문제를 해결할 수 있을 까? 그렇지 않다. 만약 세션1과 2가 동시에 데이터를 수정하는 과정이 존재한다고 생각해보자.
세션 1과 세션2는 동시에 데이터를 수정하고 있다. 이때 , 세션 1은 자신의 변경점이 자신의 세션에서 잘 업데이트 되고 있음을 확인했다. 반대로 세션 2 또한 자신의 업데이트가 잘 이루어졌다고 판단했다. 만약 두사람의 업데이트가 동시에 커밋되면서 잘못된 업데이트가 발생한 경우도 분명 발생가능성이 있다.
이를 위해서 ' 락 ( Lock ) ' 을 통해서 해결할 수 있다. 락은 하나의 세션이 커밋하기 전까지는 다른 세션은 해당 데이터를 수정할 수 없게 막는다. 이는 운영체제에서 Mutex나 Semaphore을 떠올리면 된다.

다음 예시를 통해 살펴보자.
1st . 세션 1이 트랜잭션을 시작하면서 락을 획득했고 자신이 업데이트 하고자 하는 데이터를 변경했다.
2nd . 이후 세션2가 트랜잭션을 시작했고 락을 획득 시도했다.
3rd. 세션 1이 커밋한 후 세션2는 락을 획득하고 업데이트 후 커밋한 뒤에 락은 반납된다.
이때 , 세션2의 경우 락 의 획득에 대하여 대기 시간이 존재한다. 해당 대기 시간이 초가하게 될 경우 타임아웃 오류가 발생한다. 
해당 사진은 memberA 의 데이터 변경 10000 - 2000 을 수행하기 위해 락 획득을 대기하다 타임아웃으로 인해 오류가 발생한 케이스이다.
락은 데이터 변경 뿐만 아니라 데이터를 조회할 때도 락을 획득이 필요할 때가 존재한다. 만약 세션1이 데이터를 조회하기 위해 데이터베이스를 조회시도중에 다른 세션이 데이터를 변경할 가능성도 있기 때문이다. 이때 데이터를 조회하는 세션은 다음 구문을 사용하면 데이터를 조회하는 동시에 락을 설정할 수 있다.
select for update
그럼 트랜잭션을 실제 어플리케이션에 적용해볼 수 있다. 이를 위해 비즈니스 로직을 구현하여 적용해볼 필요가 있다. 해당 Service 클래스는 맴버간 계좌이체를 구현하였다.
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);
if(toMember.getMemberId().equals("ex")){
throw new IllegalStateException("이체중 예외 발생");
}
memberRepository.update(toId, toMember.getMoney()+-money);
validation(toMember);
memberRepository.update(toId,toMember.getMoney()+money);
// 커밋 , 롤백
}
private void validation(Member toMember){
if(toMember.getMemberId().equals("ex")){
throw new IllegalStateException("이체중 예외 발생");
}
}
}
해당 Service 코드를 바탕으로 작성한 테스트 코드에서 이를 실행해보자.
package hello.jdbc.service;
import hello.jdbc.connection.ConnectionConst;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV1;
import org.assertj.core.api.Assertions;
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;
/**
* 기본 동작 , 트랜잭션이 없어서 문제 발생
*/
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(ConnectionConst.URL,ConnectionConst.USERNAME,ConnectionConst.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);
memberRepository.save(memberA);
memberRepository.save(memberEx);
//when -> 예외가 터지는 경우에 대한 검증
Assertions.assertThatThrownBy(()-> memberService.accountTransfer(memberA.getMemberId(),memberEx.getMemberId(),2000))
.isInstanceOf(IllegalStateException.class);
//then
Member findMemberA = memberRepository.findById(memberA.getMemberId());
Member findMemberEx = memberRepository.findById(memberEx.getMemberId());
Assertions.assertThat(findMemberA.getMoney()).isEqualTo(8000);
Assertions.assertThat(findMemberEx.getMoney()).isEqualTo(10000);
}
}
해당 테스트코드에서와 마찬가지로 accountTransferEX()가 발생하게 되면 memberA의 돈만 2000이 감소하는 예외가 발생하게 된다. 트랜잭션 발생을 위해서 기존의 Repository에 커낵션을 파라미터로 받아오는 코드로 리팩토링하여 Service 코드를 수정할 필요가 있다.
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 (?, ?)"; // 넘겨줄 Query를 sql 에 작성
// SQL Injection 공격을 피할 수 있는 방법 -> PrepareStatement으로 해결
Connection con = null; // H2의 JDBC Connection 구현클래스의 객체
PreparedStatement pstmt = null;
try{
con = getConnection(); // Connection 실행
pstmt = con.prepareStatement(sql);
pstmt.setString(1,member.getMemberId());
pstmt.setInt(2,member.getMoney());
pstmt.executeUpdate(); // 작성한 Query가 실행된다. -> 데이터 베이스에 저장
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();
//rs.next()를 실행하면 실제 데이터가 존재하는 곳 부터 시작
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();
//rs.next()를 실행하면 실제 데이터가 존재하는 곳 부터 시작
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(); // Connection 실행
pstmt = con.prepareStatement(sql);
pstmt.setInt(1,money);
pstmt.setString(2,memberId);
pstmt.executeUpdate(); // 작성한 Query가 실행된다. -> 데이터 베이스에 저장
int resultSize = pstmt.executeUpdate();
log.info("resultSize={}",resultSize);
}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(); // 작성한 Query가 실행된다. -> 데이터 베이스에 저장
int resultSize = pstmt.executeUpdate();
log.info("resultSize={}",resultSize);
}catch (SQLException e) {
log.error("DB Error",e);
throw e;
} finally{
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(); // Connection 실행
pstmt = con.prepareStatement(sql);
pstmt.setString(1,memberId);
pstmt.executeUpdate(); // 작성한 Query가 실행된다. -> 데이터 베이스에 저장
}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;
}
}
이에 더해 Service 클래스의 수정또한 이루어져야 한다.
package hello.jdbc.service;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV1;
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;
/**
* 트랜잭션 - 파라미터 연동 , 풀을 고려한 종료
*/
@RequiredArgsConstructor
@Slf4j
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{
if(con!=null){
try{
con.setAutoCommit(true); // default 인 true 상태로 바꿔준다.
con.close();
}catch(Exception e){
log.info("error",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(fromId,fromMember.getMoney()- money);
if(toMember.getMemberId().equals("ex")){
throw new IllegalStateException("이체중 예외 발생");
}
memberRepository.update(toId, toMember.getMoney()+-money);
validation(toMember);
memberRepository.update(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);
}
}
}
}
해당 클래스에서는 커넥션을 받은 후 커넥션 실패시에 롤백을 수행하고 예외가 없을시에 수동 커밋을 세팅한 뒤에 해당 비지니스 로직을 수행하고 자동 커밋 ( 디폴트 ) 로 수정하는 매소드로 수정하였다.
이를 통해 앞서 작성한 Service 클래스에 커낵션을 부여하여 트랜잭션이 잘 이루어 지도록 구현하였다.
그러나 해당 코드의 경우 서비스 계층에 트랜잭션 코드가 추가 되면서 가독성이 매우 떨어지게 되었다. 또한 기본적인 MVC 구조에서 각 계층에서는 각 계층 마다의 역할과 기능을 수행해야한다. 이는 SOLID 5원칙의 SRP에 해당하는데 이를 위해서는 ' 트랜잭션을 담당하는 로직 ' 과 ' 비니지스 로직을 담당하는 로직 ' 으로의 분류 및 리팩토링이 필요하다.

" 그럼 왜 리팩토링이 필요할까 ? "
먼저 트랜잭션을 담당하는 로직의 경우 DataSource , Connection , SQLException 등 JDBC의 기술에 의존하고 있다. 이러한 의존 관계를 가지고 있는 로직이 비지니스 로직과 섞여 있는 상태임을 생각해보면 여러가지 문제가 생긴다.
비지니스 로직과 JDBC 기술이 섞여 있는 상황에서 만약 SQL Mapper 방식이 아닌 ORM 방식 중 JPA로 기술을 이전하는 상황이 생기는 것 처럼 데이터베이스 접근 방식이 바뀌는 경우 코드를 수정해야한다.
또한 JDBC 처럼 ' 구현체 ' 에 의존하였다는 점이 OCP 원칙에 어긋나기 때문에 이미 작성한 코드에는 여러 복합적이며 연속적인 문제가 발생하게 된다.
이러한 연쇄적인 문제를 해결하기 위해서는 리팩토링을 통해 로직의 분류 및 분리가 필요하다.
문제를 설명하는 서론이 길어 정리하면 다음과 같다.
< 정리 >
1. 트랜잭션 적용을 위해 구현 기술이 서비스 계층에서 누수되었다.
2. 마찬가지로 예외처리의 누수가 발생하였다.
3. JDBC 코드로 인해 가독성이 떨어진다.
4. 의존 기술 변경에 대하여 직접적인 코드 변경이 필요하다.
이러한 문제에 대한 해결책은 바로 ' 추상화 ' 이다. 트랜잭션 기능을 직접 의존하는 관계가 아닌 추상체에 의존한 뒤 의존성을 주입해주면 된다.
이러한 트랜잭션 추상화 인터페이스에 대하여 스프링은 ' PlatformTransactionManager ' 이라는 인터페이스를 제공하고 있으며 원하는 DB 접근 방식에 따라서 Config 파일에서 DI를 해주면 된다.
이와 관련된 부분은 밑에서 자세히 다룰 예정이다.
트랜잭션 관련 리팩토링에 대하여 의존성 관계도 정리가 필요하지만 코드의 간결화 또한 필요하다.
현재 커넥션을 전달하는 방식은 Connection 클래스의 con 객체를 파라미터로 받아 연결해주고 있다. 이러한 방식은 커넥션을 받고자 할 때 계속해서 파라미터로 넘겨주어야 하는 문제가 있다.
파라미터로 Connection 객체를 넘겨주게 될 경우 무슨 문제가 발생할까 ?
Connection 객체를 넘겨주는 로직의 경우 다단계에 걸쳐 복잡하게 얽혀있어 코드 변경시 많은 수정이 필요하다. 또한 멀티스레드 환경에서는 공유하는 인스턴스 변수에 스레드 별로 생성하는 정보를 저장하다가는 서로 덮어쓰는 문제가 발생하기 마련이다.
트랜잭션을 유지하기 위해 트랜잭션의 시작과 끝까지 같은 데이터베이스 커넥션을 유지해야함을 위해서 지금까지는 해당 파라미터를 받아와 유지하고 close하는 방식
으로 구현하였는데 이러한 부분까지 스프링에서는 ' 동기화 매니저 ' 를 통해 간단하게 처리하고 있다.

- 정의
: 쓰레드 로컬 ( ThreadLocal ) 을 사용하여 커넥션을 동기화 하여 트랜잭션이 일어나는 중에 커넥션을 유지해주는 역할
그럼 트랜잭션 동기화 매니저를 통해서 트랜잭션 동기화를 적용시켜보자.
해당 코드를 살펴보면 마지막 문단의 메소드에서 Connection을 받아오는 getConnection을 통해서 트랜잭션 동기화를 해주고 있으며 이를 통해 해당 Repository 클래스의 CRUD 기능에서는 Connection con 을 받아오지 않아도 된다.
또한 DataSourceUtils 객체의 releaseConnection 메소드를 통해서 사용을 완료한 커넥션을 반환한다. 해당 Repository 클래스를 사용하는 Service 클래스의 코드를 수정하면 된다.
package hello.jdbc.repository;
import hello.jdbc.domain.Member;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.datasource.DataSourceUtils;
import org.springframework.jdbc.support.JdbcUtils;
import javax.sql.DataSource;
import java.sql.*;
import java.util.NoSuchElementException;
/**
* 트랜잭션 - 트랜잭션 매니저
* DataSourceUtils.getConnection()
* DatSourceUtils.releaseConnection()
*/
@Slf4j
public class MemberRepositoryV3 {
private final DataSource dataSource;
public MemberRepositoryV3(DataSource dataSource) {
this.dataSource = dataSource;
} // 생성자를 통해서 의존성 주입
public Member save(Member member) throws SQLException{
String sql = "insert into member(member_id,money) values (?, ?)"; // 넘겨줄 Query를 sql 에 작성
// SQL Injection 공격을 피할 수 있는 방법 -> PrepareStatement으로 해결
Connection con = null; // H2의 JDBC Connection 구현클래스의 객체
PreparedStatement pstmt = null;
try{
con = getConnection(); // Connection 실행
pstmt = con.prepareStatement(sql);
pstmt.setString(1,member.getMemberId());
pstmt.setInt(2,member.getMoney());
pstmt.executeUpdate(); // 작성한 Query가 실행된다. -> 데이터 베이스에 저장
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();
//rs.next()를 실행하면 실제 데이터가 존재하는 곳 부터 시작
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 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(); // Connection 실행
pstmt = con.prepareStatement(sql);
pstmt.setInt(1,money);
pstmt.setString(2,memberId);
pstmt.executeUpdate(); // 작성한 Query가 실행된다. -> 데이터 베이스에 저장
int resultSize = pstmt.executeUpdate();
log.info("resultSize={}",resultSize);
}catch (SQLException e) {
log.error("DB Error",e);
throw e;
} finally{
close(con,pstmt,null);
}
}
// 삭제 기능
public void delete(String memberId) throws SQLException {
String sql = "delete from member where member_id=?";
Connection con = null;
PreparedStatement pstmt = null;
try{
con = getConnection(); // Connection 실행
pstmt = con.prepareStatement(sql);
pstmt.setString(1,memberId);
pstmt.executeUpdate(); // 작성한 Query가 실행된다. -> 데이터 베이스에 저장
}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);
// 주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야한다.
DataSourceUtils.releaseConnection(con,dataSource);
// JdbcUtils.closeConnection(con);
// releaseConnection을 통해서 con 과 dataSource객체의 연결을 해제한다.
}
private Connection getConnection() throws SQLException {
// 주의 ! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
Connection con = DataSourceUtils.getConnection(dataSource);
log.info("get Connection={},class = {}",con,con.getClass());
return con;
}
}
해당 코드의 경우 하나의 트랙잭션 안에엇 하나의 DB에 가공한 데이터를 저장하는 작업을 잘 수행할 것이다.
그러나 만약 하나의 트랜잭션 안에서 여러개의 DB에 데이터를 저장하는 작업이 필요할 수 있다. 로컬 트랜잭션으로는 여러개의 DB에 접근하는 방식은 불가능 하기 때문이다.
이를 위해 별도의 트랜잭션 관리자를 통해 트랜잭션을 관리하는 글로벌 트랜잭션 ( Global Transaction ) 방식을 통해 여러개의 DB가 참여하는 작업을 하나의 트래잭션으로 참여시킬 수 있다.
그러나 여기서 또다른 문제가 발생한다.
만약 JDBC 로컬 트랜잭션을 사용하는 경우에서 JTA를 이용하는 글로벌 트랜잭션으로 바꾸려 한다면 Service 클래스의 코드를 수정해야한다. 왜냐하면 DB에 접근하는 트랜잭션 코드는 데이터 액세스 기술마다 상이하기 때문이다.
다행히도 데이터 액세스 기술들은 대부분 트랜잭션 경계설정 방식에 있어 많은 공통점을 가지고 있다. 이를 트랜잭션 추상화 ( Transaction Abstraction ) 를 통해 특정 기술에 종속되지 않는 트랜잭션 경계코드를 생성할 수 있다.

따라서 Service 클래스에서는 스프링 트랜잭션 추상화에만 의존하면 되며 이를 DI를 통해 어떤 구현 클래스를 사용할 것인지 결정하면 된다.
package hello.jdbc.service;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV2;
import hello.jdbc.repository.MemberRepositoryV3;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
/**
* 트랜잭션 - 트랜잭션 매니저
*/
@RequiredArgsConstructor
@Slf4j
public class MemberServiceV3_1 {
// private final DataSource dataSource;
private final PlatformTransactionManager transactionManager;
private final MemberRepositoryV3 memberRepository;
// 시작
// 파라미터 제거
// PlatformTransactionManager 인터페이스의 객체를 받아옴 -> DI가 필요
public void accountTransfer(String fromId, String toId ,int money) throws SQLException {
// 트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
// 트랜잭션의 상태 정보를 포함하고 있어 커밋 , 롤백에 필요하다.
// DefaultTransactionDefinition() : 트랜잭션과 관련된 옵션을 지정할 수 있다.
try{
bizLogic(fromId, toId,money);
transactionManager.commit(status);
}catch(Exception e){
// 커넥션 실패시 롤백
transactionManager.rollback(status);
throw new IllegalStateException(e);
}
}
private void bizLogic(String fromId, String toId, int money) throws SQLException {
//비지니스 로직 수행
Member fromMember = memberRepository.findById(fromId);
Member toMember = memberRepository.findById(toId);
memberRepository.update(fromId,fromMember.getMoney()- money);
if(toMember.getMemberId().equals("ex")){
throw new IllegalStateException("이체중 예외 발생");
}
memberRepository.update(toId, toMember.getMoney()+-money);
validation(toMember);
memberRepository.update(toId,toMember.getMoney()+ money);
// 커넥션 커밋 -> 성공시 커밋
}
private void validation(Member toMember){
if(toMember.getMemberId().equals("ex")){
throw new IllegalStateException("이체중 예외 발생");
}
}
}
Service 클래스를 생성하는 개발자는 PlatformTransactionManager 인터페이스 객체를 생성한 뒤 해당 객체를 TransactionStatus 객체에 getTransaction 메소드를 통해 연결하여 트랜잭션을 시작하면 된다.
⭐️ 트랜잭션 특징 설정하기 ‼️
getTransaction 메소드의 파라미터에 new DefaultTransactionDefinition() 이라는 생성자 메소드가 들어간다.
여기서 DefaultTransactionDefinition 객체는 앞서 언급한 트랜잭션 전파 , 격리 , 제한시간 , 읽기전용 여부를 결정한다.
따라서 해당 특성들을 설정하고 싶은 경우 getTransaction 파라미터에 객체를 넣기 전 객체를 생성해준 후 특성을 설정하면 된다.
DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
// 1. 전파 방식 설정 (예: 항상 새 트랜잭션 시작)
definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
// 2. 격리 수준 설정 (예: 커밋된 데이터만 읽기)
definition.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
// 3. 읽기 전용 설정 (예: 성능 최적화용)
definition.setReadOnly(true);
// 4. 타임아웃 설정 (예: 30초 안에 완료되지 않으면 롤백)
definition.setTimeout(30);
TransactionStatus status = transactionManager.getTransaction(definition);
이제 테스트 코드를 통해 DataSource에 DriverManagerDataSource ( JDBC ) 와 PlatformTransactionManger에 DatasourceTransactionManger ( JDBC ) 를 주입한 코드를 실행해보자.
package hello.jdbc.service;
import hello.jdbc.connection.ConnectionConst;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV2;
import hello.jdbc.repository.MemberRepositoryV3;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
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.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.PlatformTransactionManager;
import java.sql.SQLException;
/**
* 트랜잭션 - 트랜잭션 매니저
*/
@Slf4j
public class MemberServiceV3_1Test {
public static final String MEMBER_A = "memberA";
public static final String MEMBER_B = "memberB";
public static final String MEMBER_EX= "ex";
private MemberRepositoryV3 memberRepository;
private MemberServiceV3_1 memberService;
@BeforeEach
void before(){
DriverManagerDataSource dataSource = new DriverManagerDataSource(ConnectionConst.URL,ConnectionConst.USERNAME,ConnectionConst.PASSWORD);
memberRepository = new MemberRepositoryV3(dataSource);
PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
memberService = new MemberServiceV3_1(transactionManager,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
log.info("START TX");
memberService.accountTransfer(memberA.getMemberId(),memberB.getMemberId(),2000);
log.info("END TX");
//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);
memberRepository.save(memberA);
memberRepository.save(memberEx);
//when -> 예외가 터지는 경우에 대한 검증
Assertions.assertThatThrownBy(()-> memberService.accountTransfer(memberA.getMemberId(),memberEx.getMemberId(),2000))
.isInstanceOf(IllegalStateException.class);
//then
Member findMemberA = memberRepository.findById(memberA.getMemberId());
Member findMemberEx = memberRepository.findById(memberEx.getMemberId());
Assertions.assertThat(findMemberA.getMoney()).isEqualTo(8000); // 예외가 터져서 rollback 발생
Assertions.assertThat(findMemberEx.getMoney()).isEqualTo(10000); // 실행시 예외가 발생함
}
}
결국 트랜잭션 동기화 매니저를 도입하므로써 코드의 간결화 , DI 문제를 해결하였다.
그러나 여전히 트랜잭션 동기화 매니저를 사용하면서 try-catch 문을 통해 예외 처리를 해주고 있다. 이러한 반복적인 코드 또한 해결할 필요가 있다.
이를 위해 스프링에서는 ' 템플릿 콜백 패턴 ' 을 제공한다. 탬플릿 콜백 패턴은 스프링에서 TransactionTemplate 라는 클래스를 통해 제공되고 있다.
- 탬플릿 콜백 패턴 이란?
: 탬플릿 콜백 패턴 ( Template Calbakc Pattern ) 은 반복적인 로직을 공통 로직으로 제공하면서 핵심 비지니스 로직을 호출자에게 맡기는 디자인 패턴을 의미한다.예를 들어 JDBC의 경우 try- catch를 통해 commit과 rollback여부를 가르는데 이를 JDBCTemplate를 통해 공통로직인 예외처리를 간소화한다.
템플릿 콜백 패턴의 경우 1 ) 클라이언트 , 2 ) 템플릿 , 3 ) 콜백 으로 구성된다.
클라이언트는 템플릿 ( 인터페이스 ) 를 new 키워드를 통해 생성하면 내부 콜백을 구현화 ( 익명 클래스 or 람다식 ) 을 통해 구현할 수 있다.
public class TransactionTemplate {
private PlatformTransactionManager transactionManager;
public <T> T execute(TransactionCallback<T> action){..}
// 응답 값이 있을 때 사용
void executeWithoutResult(Consumer<TransactionStatus> action){..}
// 응답 값이 없을 때 사용
}
그럼 트랜잭션 템플릿을 통해 앞서 작성한 Service 클래스를 리팩토링해보면 다음과 같다.
package hello.jdbc.service;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV3;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
import java.sql.Connection;
import java.sql.SQLException;
/**
* 트랜잭션 - 트랜잭션 탬플릿
*
*/
@RequiredArgsConstructor
@Slf4j
public class MemberServiceV3_2 {
// private final DataSource dataSource;
// private final PlatformTransactionManager transactionManager;
private final TransactionTemplate txTemplate;
//TransactionTemplate 클래스의 txTemplate 객체
private final MemberRepositoryV3 memberRepository;
public MemberServiceV3_2(PlatformTransactionManager transactionManager, MemberRepositoryV3 memberRepository) {
this.txTemplate = new TransactionTemplate(transactionManager);
this.memberRepository = memberRepository;
}
// 시작
public void accountTransfer(String fromId, String toId ,int money) throws SQLException {
txTemplate.executeWithoutResult((status)-> {
try {
bizLogic(fromId,toId,money); // 비지니스 로직이 잘 수행하면 커시
} catch (SQLException e) {
throw new IllegalStateException(e); // 예외의 경우 롤백
}
});
}
private void bizLogic(String fromId, String toId, int money) throws SQLException {
//비지니스 로직 수행
Member fromMember = memberRepository.findById(fromId);
Member toMember = memberRepository.findById(toId);
memberRepository.update(fromId,fromMember.getMoney()- money);
if(toMember.getMemberId().equals("ex")){
throw new IllegalStateException("이체중 예외 발생");
}
memberRepository.update(toId, toMember.getMoney()+-money);
validation(toMember);
memberRepository.update(toId,toMember.getMoney()+ money);
// 커넥션 커밋 -> 성공시 커밋
}
private void validation(Member toMember){
if(toMember.getMemberId().equals("ex")){
throw new IllegalStateException("이체중 예외 발생");
}
}
}
해당 코드의 경우 이전과 다르게 try-catch 부분을 굉장히 간소화 하였다. 그로인해 예외가 발생하면 Exception e 을 발생시키고 그렇지 않을 경우 바로 bizlogic 메소드를 실행한다.
이를 통해 코드 간소화 문제 까지 해결하였다. 아직 남은 문제인 SRP 문제를 해결해보자. 이를 위해 AOP를 도입하여 해결할 수 있다.
프록시를 도입하기 전에는 서비스 로직에서 트랜잭션을 직접 시작하는 문제가 있었다. 다음 그림과 같은 구조였기 때문에 SRP의 문제가 여전히 남아있었다. 
이를 보완하기 위해서는 Transaction Proxy - Service - Repository 구조를 통해서 트랜잭션을 실행하는 로직과 서비스의 로직을 분리한 ' 프록시 ' 를 도입이 필요하다.
스프링은 이에 대하여 AOP 기능을 제공하며 이를 통해서 프록시를 매우 편리하게 적용할 수 있다.
먼저 사용하고자 하는 Service 클래스의 메소드에 @Transactional 어노테이션을 사용하여 해당 메소드가 처리해야함을 명시한다.
그 후 해당 어노테이션이 붙은 메소드를 트랜잭션 처리하기 위해 Configuration파일 에서 어떤 DB접근 방식을 사용하느냐에 따른 빈 등록을 해준다.
이때 , @EnableTransactionManagement 어노테이션이 Configuration 파일에 명시되어야 트랜잭션이 정상적으로 동작한다.‼️
-> 최근 스프링부트에서는 생략해도 트랜잭션 자동설정이 되어있어서 쓰지 않아도 괜찮으나 다중 DB를 사용할때는 Config내부에서 설정해주어야 한다.
@Configuration
@EnableTransactionManagement // 수동 설정에서는 필수로 !!
public class JpaConfig {
@Bean
public PlatformTransactionManager transactionManager(EntityManagerFactory emf) {
return new JpaTransactionManager(emf);
}
@Bean
public LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) {
LocalContainerEntityManagerFactoryBean emf = new LocalContainerEntityManagerFactoryBean();
emf.setDataSource(dataSource);
emf.setPackagesToScan("com.example.domain");
emf.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
return emf;
}
@Bean
public DataSource dataSource() {
return new HikariDataSource(); // 예: HikariCP 설정
}
}
여기서 LocalContainerEntityManagerFactoryBean 에 관한 부분은 다음 포스팅을 참고 !
JPA 포스팅 ( 추후 제작 예정 )
@Transactional 어노테이션을 기존의 Service클래스에 적용한 코드는 다음과 같다.
package hello.jdbc.service;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV3;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import java.beans.Transient;
import java.sql.Connection;
import java.sql.SQLException;
/**
* 트랜잭션 - @Transactional AOP
*
*/
@RequiredArgsConstructor
@Slf4j
@Transactional
public class MemberServiceV3_3 {
private final MemberRepositoryV3 memberRepository;
// 시작
// 어노테이션 하나로 끝!
public void accountTransfer(String fromId, String toId ,int money) throws SQLException {
bizLogic(fromId,toId,money); // 비지니스 로직이 잘 수행하면 커밋
}
private void bizLogic(String fromId, String toId, int money) throws SQLException {
//비지니스 로직 수행
Member fromMember = memberRepository.findById(fromId);
Member toMember = memberRepository.findById(toId);
memberRepository.update(fromId,fromMember.getMoney()- money);
if(toMember.getMemberId().equals("ex")){
throw new IllegalStateException("이체중 예외 발생");
}
memberRepository.update(toId, toMember.getMoney()+-money);
validation(toMember);
memberRepository.update(toId,toMember.getMoney()+ money);
// 커넥션 커밋 -> 성공시 커밋
}
private void validation(Member toMember){
if(toMember.getMemberId().equals("ex")){
throw new IllegalStateException("이체중 예외 발생");
}
}
}
해당 코드를 살펴보면 기존에 트랜잭션을 처리하는 코드가 사라지고 @Transactional 어노테이션이 간단히 추가된 모습을 볼 수 있다. 이를 통해 SRP 문제를 해결하였고 순수한 비지니스 로직만을 남겨 가독성 또한 회복한 모습을 볼 수 있다.
해당 코드를 테스트하기 위해서는 기존의 @Test어노테이션이 아닌 스프링 AOP의 테스트 어노테이션인 @SpringBootTest 를 사용해야한다. 해당 어노테이션을 사용하게 되면 @Autowired를 통해서 스프링 컨테이너가 관리하는 빈을 사용할 수 있다.
package hello.jdbc.service;
import hello.jdbc.connection.ConnectionConst;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV3;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
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.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.PlatformTransactionManager;
import javax.sql.DataSource;
import java.sql.SQLException;
/**
* 트랜잭션 - @Transactional AOP
*/
@Slf4j
@SpringBootTest
public class MemberServiceV3_3Test {
public static final String MEMBER_A = "memberA";
public static final String MEMBER_B = "memberB";
public static final String MEMBER_EX= "ex";
@Autowired
private MemberRepositoryV3 memberRepository;
@Autowired
private MemberServiceV3_3 memberService;
@TestConfiguration
static class TestConfig{
@Bean
DataSource dataSource(){
return new DriverManagerDataSource(ConnectionConst.URL,ConnectionConst.USERNAME,ConnectionConst.PASSWORD);
}
@Bean
PlatformTransactionManager transactionManager(){
return new DataSourceTransactionManager(dataSource());
}
@Bean
MemberRepositoryV3 memberRepositoryV3(){
return new MemberRepositoryV3(dataSource());
}
@Bean
MemberServiceV3_3 memberServiceV3(){
return new MemberServiceV3_3(memberRepositoryV3());
}
}
@AfterEach
void after() throws SQLException {
memberRepository.delete(MEMBER_A);
memberRepository.delete(MEMBER_B);
memberRepository.delete(MEMBER_EX);
}
@Test
void AopCheck(){
log.info("memberService class={}",memberService.getClass());
log.info("memberService class={}",memberRepository.getClass());
}
@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
log.info("START TX");
memberService.accountTransfer(memberA.getMemberId(),memberB.getMemberId(),2000);
log.info("END TX");
//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);
memberRepository.save(memberA);
memberRepository.save(memberEx);
//when -> 예외가 터지는 경우에 대한 검증
Assertions.assertThatThrownBy(()-> memberService.accountTransfer(memberA.getMemberId(),memberEx.getMemberId(),2000))
.isInstanceOf(IllegalStateException.class);
//then
Member findMemberA = memberRepository.findById(memberA.getMemberId());
Member findMemberEx = memberRepository.findById(memberEx.getMemberId());
Assertions.assertThat(findMemberA.getMoney()).isEqualTo(10000); // 예외가 터져서 rollback 발생
Assertions.assertThat(findMemberEx.getMoney()).isEqualTo(10000); // 실행시 예외가 발생함
}
}
트랜잭션 AOP를 도입한 애플리케이션의 전체적인 흐름은 다음과 같다.
트랜잭션 인터셉터 ( TransactionInterceptor )
: 트랜잭션 어드바이저 ( Transaction Advisor ) 에서 사용되는 트랜잭션 어드바이스 ( Transaction Advice ) 는 RuntimeException 이 발생하는 경우에만 트랜잭션을 롤백시킨다.
즉 , 언체크 예외에 대해서는 롤백을 수행하나 체크 예외에 대해서는 커밋을 실행하게 된다. 언체크 예외중 커스텀하여 롤백을 수행할 수 있도록 하는 예외처리는 기본 원칙에 부합하지 않는다.
따라서 이를 위해 TransactionInterceptor를 사용할 수 있다.
앞선 목차들에서는 스프링 부트의 기능을 사용하는 방법이 아닌 직접 트랜잭션 매니저를 통해서 트랜잭션 관리를 했다. DriverManagerDataSource에 데이터베이스의 URL,USERNAME,PASSWORD를 받은 뒤에 이를 연결하는 방식으로 사용했는데 이러한 부분들을 스프링 부트를 통해서 자동화를 한 것이다.
application.properties 파일 내부에 데이터베이스 연결정보를 작성함으로써 이를 자동적으로 스프링 빈에 등록할 수 있게 된다.
기본적으로 스프링 부트는 데이터소스 ( DataSource ) 를 스프링 빈에 자동으로 등록하게 되는데 이는 커넥션 풀을 제공하는 HikariDataSource이다.
이렇게 자동으로 등록된 스프링 빈의 이름은 기본적으로 ~transactionManager이며 어떤 데이터베이스 접근 기술을 사용하는지에 따라서 빈에 등록 되는 이름이 다르다
- JPA : JpaTransactionManager
- JDBC : DataSourceTransactionManager
이전에 Config를 담당했던 코드를 비교해보며 어떠한 부분이 달라졌는지 살펴보자.
package hello.jdbc.service;
import hello.jdbc.connection.ConnectionConst;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV3;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
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.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.PlatformTransactionManager;
import javax.sql.DataSource;
import java.sql.SQLException;
/**
* 트랜잭션 - @Transactional AOP
*/
@Slf4j
@SpringBootTest
public class MemberServiceV3_3Test {
public static final String MEMBER_A = "memberA";
public static final String MEMBER_B = "memberB";
public static final String MEMBER_EX= "ex";
@Autowired
private MemberRepositoryV3 memberRepository;
@Autowired
private MemberServiceV3_3 memberService;
@TestConfiguration
static class TestConfig{
@Bean
DataSource dataSource(){
return new DriverManagerDataSource(ConnectionConst.URL,ConnectionConst.USERNAME,ConnectionConst.PASSWORD);
}
@Bean
PlatformTransactionManager transactionManager(){
return new DataSourceTransactionManager(dataSource());
}
@Bean
MemberRepositoryV3 memberRepositoryV3(){
return new MemberRepositoryV3(dataSource());
}
@Bean
MemberServiceV3_3 memberServiceV3(){
return new MemberServiceV3_3(memberRepositoryV3());
}
}
해당 코드의 경우 DriverManagerDataSource에 URL , USERNAME , PASSWORD를 직접 주입해주고 있다. 다음은 변경된 코드이다.
package hello.jdbc.service;
import hello.jdbc.connection.ConnectionConst;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV3;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.PlatformTransactionManager;
import javax.sql.DataSource;
import java.sql.SQLException;
/**
* 트랜잭션 - DataSource , transactionManager 자동등록
*/
@Slf4j
@SpringBootTest
public class MemberServiceV3_4Test {
public static final String MEMBER_A = "memberA";
public static final String MEMBER_B = "memberB";
public static final String MEMBER_EX= "ex";
@Autowired
private MemberRepositoryV3 memberRepository;
@Autowired
private MemberServiceV3_3 memberService;
@TestConfiguration
static class TestConfig{
private final DataSource dataSource;
public TestConfig(DataSource dataSource) {
this.dataSource = dataSource;
}
@Bean
MemberRepositoryV3 memberRepositoryV3(){
return new MemberRepositoryV3(dataSource);
}
@Bean
MemberServiceV3_3 memberServiceV3(){
return new MemberServiceV3_3(memberRepositoryV3());
}
}
해당 코드의 경우 DataSource 클래스의 dataSource객체를 불러온 후에 생성자를 통해 초기화를 한다. 이를 통해서 Service , Repository 객체를 초기화한다. 즉 , 의존성을 주입해주고 있다.