[Spring] 스프링 DB 1편 - 데이터 접근 핵심 원리 섹션4 스프링과 문제 해결 - 트랜잭션
◼️ 문제점들
🟢 순수한 서비스 계층
가장 중요한 곳은 핵심 비즈니스 로직이 들어있는 서비스 계층이다. 비즈니스 로직은 최대한 변경없이 유지되어야 한다.
서비스 계층을 특정기술에 종속적이지 않게 개발해야 한다.
하지만 트랜잭션을 적용한 MemberServiceV2를 보면, 핵심 비즈니스 로직과 JDBC 기술이 섞여 있어서 유지보수 하기 어렵다.
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); // 커넥션을 모두 사용후 종료, 풀에 반납
}
}
🟢 현재 애플리케이션의 문제 정리
- 트랜잭션 문제
- JDBC 구현기술이 서비스 계층에 누수되고 있다.
- 트랜잭션 동기화 문제 - 커넥션을 파라미터로 넘겨야 한다.
- 트랜잭션 적용 코드를 보면 반복이 많다.
- 예외 누수 문제
- JPA나 다른 데이터 접근 기술을 사용하려면 다른 예외로 변경해야한다.
- JDBC 반복 문제
- try, catch, finally...
스프링은 위 문제를 해결하기 위한 다양한 방법,기술을 제공한다.
◼️ 트랜잭션 추상화
😥구현 기술마다 트랜잭션 사용 코드,방법이 다르다.
- JDBC : con.setAutoCommit(false)
- JPA : transaction.begin()
🟢트랜잭션 추상화
😉 이 문제를 해결하려면 트랜잭션 기능을 추상화하면된다.
public interface TxManager { // 트랜잭션 추상화 인터페이스
begin();
commit();
rollback();
}
🟢스프링의 트랜잭션 추상화
🪄 스프링 5.3부터는 JDBC 트랜잭션을 관리할 때 DataSourceTransactionManager 를 상속받아서 약간의 기능을 확장한 JdbcTransactionManager 를 제공한다. (둘의 기능 차이는 크지 않으므로 같은 것으로 이해)
// PlatformTransactionManager 인터페이스
public interface PlatformTransactionManager extends TransactionManager {
TransactionStatus getTransaction(@Nullable TransactionDefinition definition) throws TransactionException;
void commit(TransactionStatus status) throws TransactionException;
void rollback(TransactionStatus status) throws TransactionException;
}
◼️ 트랜잭션 동기화
🟢트랜잭션 매니저 역할
- 트랜잭션 추상화
- 리소스 동기화
- 트랜잭션을 유지하려면 같은 DB 커넥션을 유지해야한다.
- 동일한 트랜잭션 동안 데이터베이스 작업이 같은 커넥션을 사용하도록 보장한다.
- 트랜잭션을 유지하려면 트랜잭션 시작~끝까지 같은 DB 커넥션을 유지해야 함
- 파라미터로 커넥션을 전달하는 방법은 코드가 지저분해지고 코드 중복이 발생한다.
- 이를 위해 스프링은 트랜잭션 동기화 매니저를 제공한다. 이것은 쓰레드 로컬(ThreadLocal)을 사용해 커넥션을 동기화 해준다.
요약하자면, 트랜잭션 매니저는 트랜잭션의 시작, 커밋, 롤백을 담당한다.
트랜잭션 동기화 매니저는 현재 스레드에 트랜잭션 자원(커넥션)을 바인딩하고, 이를 통해 동일한 커넥션 사용을 보장한다.
🟢트랜잭션 동기화 매니저 동작 방식
- 트랜잭션 시작
- 트랜잭션 매니저가 DataSource를 통해 커넥션 생성 및 트랜잭션 시작
- 트랜잭션 동기화 매니저에 커넥션 보관
- 트랜잭션 매니저가 트랜잭션을 커밋 또는 롤백
- 트랜잭션 종료 후 트랜잭션 동기화 매니저에 보관된 커넥션 정리 및 닫음
쓰레드 로컬을 사용하면 각각의 쓰레드마다 별도의 저장소가 부여된다. 따라서 해당 쓰레드만 해당 데이터에 접근 할 수 있다.
예를들어 @Transactional 없이 동일한 커넥션을 유지하려면 다음과 같은 방법을 사용해야 한다.
🔶@Transactional 적용 전
public void transferMoney(Long fromAccountId, Long toAccountId, Double amount) {
Connection connection = null;
try {
connection = dataSource.getConnection();
connection.setAutoCommit(false); // 수동 트랜잭션 시작
Account fromAccount = findAccountById(connection, fromAccountId);
Account toAccount = findAccountById(connection, toAccountId);
fromAccount.withdraw(amount);
toAccount.deposit(amount);
updateAccount(connection, fromAccount);
updateAccount(connection, toAccount);
connection.commit(); // 트랜잭션 커밋
} catch (Exception e) {
if (connection != null) {
try {
connection.rollback(); // 트랜잭션 롤백
} catch (SQLException se) {
se.printStackTrace();
}
}
throw new RuntimeException(e);
} finally {
if (connection != null) {
try {
connection.close(); // 커넥션 반환
} catch (SQLException se) {
se.printStackTrace();
}
}
}
}
@Transactional을 사용하여 트랜잭션을 관리하면 코드가 훨씬 간단하고 안전하다. @Transactional 애노테이션을 메서드에 적용하여 스프링이 자동으로 트랜잭션을 시작하고 커밋하거나 롤백하도록 할 수 있다. 이를 통해 수동으로 커넥션을 관리할 필요가 없어진다.
🔶@Transactional 적용 후
@Service
public class BankService {
@Autowired
private AccountRepository accountRepository;
@Transactional
public void transferMoney(Long fromAccountId, Long toAccountId, Double amount) {
// 트랜잭션이 자동으로 시작됨
Account fromAccount = accountRepository.findById(fromAccountId).orElseThrow(() -> new RuntimeException("From account not found"));
Account toAccount = accountRepository.findById(toAccountId).orElseThrow(() -> new RuntimeException("To account not found"));
fromAccount.withdraw(amount);
toAccount.deposit(amount);
accountRepository.save(fromAccount);
accountRepository.save(toAccount);
// 트랜잭션이 자동으로 커밋되거나 예외 발생 시 롤백됨
}
}
◼️ 트랜잭션 문제 해결 - 트랜잭션 매니저1
이제 코드에서 커넥션을 파라미터로전달하지않고, 트랜잭션 매니저를 적용해보자.
/**
* 트랜잭션 - 트랜잭션 매니저
* DataSourceUtils.getConnection()
* DataSourceUtils.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(?, ?)";
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)
// public void update(String memberId, int money)
// public void delete(String memberId)
private void close(Connection con, Statement stmt, ResultSet rs){
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
//주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
DataSourceUtils.releaseConnection(con, dataSource);
}
private Connection getConnection() throws SQLException{
//주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
Connection con = DataSourceUtils.getConnection(dataSource);
log.info("get connection={} class={}", con, con.getClass());
return con;
}
}
DataSourceUtils.getConnection()
- 트랜잭션 동기화 매니저가 관리하는 커넥션이 있으면 해당 커넥션을 반환
- 트랜잭션 동기화 매니저가 관리하는 커넥션이 없으면, 새로운 커넥션을 생성해서 반환
DataSourceUtils.releaseConnection()
- 커넥션은 트랜잭션을 종료(커밋, 롤백)할 때 까지 살아있어야 한다.
- 트랜잭션을 사용하기 위해 동기화된 커넥션은 커넥션을 닫지 않고 그대로 유지해준다.
- 트랜잭션 동기화 매니저가 관리하는 커넥션이 없으면, 해당 커넥션을 닫는다.
아래에서 각 이체 기능을 테스트해보면 롤백 기능도 잘동작하고,모든 결과가 바르게 출력된다.
/**
* 트랜잭션 - 트랜잭션 매니저
* */
public class MemberServiceV3_1Test {
//...
@BeforeEach
void before() {
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
// JDBC 기술사용하므로 JDBC용 트랜잭션 매니저를 서비스에 주입
// 트랜잭션 매니저는 데이터소스를 통해 커넥션을 생성하므로 DataSource 가 필요하다.
PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
memberRepository = new MemberRepositoryV3(dataSource);
memberService = new MemberServiceV3_1(transactionManager,memberRepository);
}
//...
◼️ 트랜잭션 문제 해결 - 트랜잭션 매니저2
🟢트랜잭션 매니저 동작 흐름
- 트랜잭션 시작 - transactionManager.getTransaction()
- 트랜잭션 매니저는 내부 데이터소스 사용해 커넥션 생성
- 커넥션 수동 커밋 모드로 변경, 실제 DB에서 트랜잭션 시작
- 커넥션을 트랜잭션 동기화 매니저에 보관 (트랜잭션 동기화 매니저는 쓰레드 로컬에 커넥션 보관)
- 로직 실행
- 서비스는 비즈니스 로직을 실행하며 리포지토리 메서드 호출
- 리포지토리 메서드들은 트랜잭션이 시작된 커넥션이 필요
- 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용 - DataSourceUtils.getConnection()
- 획득한 커넥션을 사용해서 SQL을 데이터베이스에 전달,실행
트랜잭션 종료
- 트랜잭션 종료
- 비즈니스로직 끝. 트랜잭션 커밋,롤백시 종료
- 트랜잭션 동기화 매니저에서 동기화된 커넥션 획득
- 획득 커넥션을 통해 DB에 트랜잭션 커밋,롤백
- 리소스 정리 - 트랜잭션 동기화 매니저 정리, con.setAutoCommit(true), con.close() 커넥션 풀에 반환
◼️ 트랜잭션 문제 해결 - 트랜잭션 템플릿
템플릿 콜백 패턴을 활용해 트랜잭션 사용 로직의 코드 반복 문제를 해결할 수 있다.
//트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(new
DefaultTransactionDefinition());
try {
//비즈니스 로직
bizLogic(fromId, toId, money);
transactionManager.commit(status); //성공시 커밋
} catch (Exception e) {
transactionManager.rollback(status); //실패시 롤백
throw new IllegalStateException(e);
}
🔶TransactionTemplate
- 비즈니스 로직 정상 수행되면 커밋
- 언체크예외발생시 롤백 (체크예외는 커밋)
public class TransactionTemplate {
private PlatformTransactionManager transactionManager;
public <T> T execute(TransactionCallback<T> action){..} //응답값 있을때
void executeWithoutResult(Consumer<TransactionStatus> action){..} //응답값 없을때
}
트랜잭션 템플릿을 적용하자. 덕분에 트랜잭션을 시작하고, 커밋하거나 롤백하는 코드가 모두 제거되었다.
@Slf4j
public class MemberServiceV3_2 {
private final TransactionTemplate txTemplate;
private final MemberRepositoryV3 memberRepository;
public MemberServiceV3_2(PlatformTransactionManager transactionManager, MemberRepositoryV3 memberRepository) {
// transactionTemplate 을 사용하려면 transactionManager 가 필요
// transactionManager 를 주입 받으면서 TransactionTemplate 을 생성
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);
}
});
}
◼️ 트랜잭션 문제 해결 - 트랜잭션 AOP 이해
😥현재문제점: 비즈니스 로직과 트랜잭션을 처리하는 기술 로직이 한 곳에 있어 코드를 유지보수하기 어렵다.
😉 해결: 스프링 AOP를 통해 프록시를 도입, 트랜잭션을 편리하게 처리 가능
프록시를 도입하면 트랜잭션 시작 후 서비스를 대신 호출한다. 따라서 서비스 계층에는 순수 비즈니스 로직만 남는다.
필요한 곳에 @Transactional 애노테이션만 붙여주면, AOP는 이 애노테이션을 인식해서 트랜잭션 프록시를 적용해준다.
@Transactional 애노테이션은 메서드,클래스에 붙일 수 있다.
클래스에 붙이면 외부에서 호출 가능한 public 메서드가 AOP 적용 대상이 된다.
◼️ 트랜잭션 문제 해결 - 트랜잭션 AOP 적용
🔶트랜잭션 AOP 적용 후
@Transactional
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
bizLogic(fromId, toId, money);
}
🔶트랜잭션 AOP 테스트
/**
* 트랜잭션 - @Transactional AOP
*/
@Slf4j
@SpringBootTest // 스프링AOP 적용하려면 스프링 컨테이너가 필요하므로, 스프링부트를 통해 생성
class MemberServiceV3_3Test {
//...
@TestConfiguration // 스프링 빈 추가등록
static class TestConfig {
@Bean
DataSource dataSource() {
// 스프링에서 기본으로 사용할 데이터소스를 스프링 빈으로 등록 (트랜잭션 매니저에서도 사용)
return new DriverManagerDataSource(URL, USERNAME, PASSWORD);
}
@Bean
PlatformTransactionManager transactionManager() {
//트랜잭션 매니저를 스프링 빈으로 등록
// 트랜잭션 AOP는 트랜잭션 매니저를 스프링 빈으로 등록해두어야 한다.
return new DataSourceTransactionManager(dataSource());
} //...이후생략
◼️ 트랜잭션 문제 해결 - 트랜잭션 AOP 정리
선언적 트랜잭션 관리(Declarative Transaction Management): @Transactional 선언해 편리하게 트랜잭션 적용
프로그래밍 방식의 트랜잭션 관리(programmatic transaction management): 트랜잭션 관련 코드 직접 작성
◼️ 스프링 부트의 자동 리소스 등록
기존에는 아래처럼 데이터소스와 트랜잭션 매니저를 스프링 빈으로 직접 등록했었다.
@Bean
DataSource dataSource() {
return new DriverManagerDataSource(URL, USERNAME, PASSWORD);
}
@Bean
PlatformTransactionManager transactionManager() {
return new DataSourceTransactionManager(dataSource());
}
스프링부트는 DataSource를 스프링 빈에 자동등록한다. (개발자가 직접등록시 자동등록 X)
스프링 부트는 적절한 트랜잭션 매니저( PlatformTransactionManager )를 자동으로 스프링 빈에 등록한다.
application.properties에 아래속성을 등록하면 위 Test에서 데이터소스와 트랜잭션 매니저를 스프링 빈으로 등록하는 코드를 생략해도 자동생성해준다.
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=
/**
* 트랜잭션 - DataSource, transactionManager 자동 등록
*/
@Slf4j
@SpringBootTest
class MemberServiceV3_4Test {
//...
@TestConfiguration // 스프링 빈 추가등록
static class TestConfig {
private final DataSource dataSource;
TestConfig(DataSource dataSource) {
this.dataSource = dataSource; //데이터소스 빈을 주입 받을 수도 있다.
}
🟢정리
- 실무에서는 대부분 간편하고 실용적인 선언적 트랜잭션 관리를 사용한다.
- 데이터소스와 트랜잭션 매니저는 스프링 부트가 제공하는 자동 빈 등록 기능을 사용하는 것이 편리하다.
- 추가로 application.properties 를 통해 설정도 편리하게 할 수 있다.
'Spring' 카테고리의 다른 글
[Spring] 스프링 DB 2편 - 데이터 접근 활용 기술 섹션6 데이터 접근 기술 - Querydsl (0) | 2024.07.07 |
---|---|
[Spring] 스프링 DB 1편 - 데이터 접근 핵심 원리 섹션5,6 예외 & 스프링과 문제 해결 - 예외 처리, 반복 (0) | 2024.06.11 |
[Spring] 스프링 DB 1편 - 데이터 접근 핵심 원리 섹션3 트랜잭션 이해 (0) | 2024.06.02 |
[Spring] 스프링 DB 1편 - 데이터 접근 핵심 원리 섹션2 커넥션풀과 데이터소스 이해 (0) | 2024.05.29 |
[Spring] 스프링 DB 1편 - 데이터 접근 핵심 원리 섹션1 JDBC 이해 (0) | 2024.05.28 |