트랜잭션
여러 개의 SQL 구문을 실행하는 경우, 중간 구문이 오류로 인해 반영이 안 되는 경우가 있다.
만약 계좌 송금을 진행할 때 A 계좌에서 금액이 빠지는 구문은 실행되었지만, B 계좌에 금액을 넣는 구문이 실패한다면 큰 문제이다!
트랜잭션은 이런 경우를 막기 위해 두 구문이 모두 성공하면 커밋으로 실제 DB에 반영하고, 실패하면 롤백을 통해 실행 전으로 되돌리는 기능이다.
데이터베이스 연결구조
WAS는 커넥션을 사용해 DB서버에 접근할 수 있다. 커넥션을 통해 WAS(클라이언트)와 DB가 연결되면 데이터베이스 서버는 내부에 세션이라는 것을 만든다. 또 해당 커넥션을 통해 오는 모든 요청은 세션을 통해 실행하게 된다.
이때 세션은 트랜잭션을 시작하고, 커밋 또는 롤백을 통해 트랜잭션을 종료한다. 보통 자동 커밋으로 설정되어 있어 쿼리를 하나 실행하면 커밋까지 자동으로 진행된다.
DB 락
2개의 세션이 DB에서 작업을 진행 중일 때,
세션 1에서 트랜잭션을 시작하고 데이터를 수정하는 동안 세션 2에서 동일한 데이터에 접근해 수정을 한다면 큰 문제가 발생할 것이다.
따라서 세션이 트랜잭션을 시작하고 데이터를 수정하는 동안에는 커밋이나 롤백 전까지 다른 세션이 접근할 수 없도록 DB락을 가지고 있는다.
세션은 DB를 수정하기 전 락을 먼저 획득하고 수정을 진행한다.
만약 락이 다른 세션에서 사용 중이라면 대기상태에 들어가고, 일정 시간 동안 락을 획득하지 못하면 락 타임아웃 오류가 발생한다!
수정에는 락을 사용하지만, 보통 조회 시엔 락을 사용하지 않는다.
트랜잭션 적용하기
트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작해야 한다! 비스니스 로직에서 문제 부분을 함께 롤백해야 하기 때문.
그런데 트랜잭션을 시작하려면 커넥션이 필요하다..
따라서 비즈니스 로직에서 커넥션을 생성한 후 리포지토리로 넘겨야 하는 상황이 발생한다..!
따라서 리포지토리 로직에선 파라미터로 커넥션을 받아야 한다.
// Repository
public Member findById(Connection con, String memberId) throw 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();
// result 받기 생략..
return member;
//try catch 생략..
}
public void update(Connection con, String memberId, int money) throw SQLException {
// 돈을 송금하는 로직 생략..
}
이를 사용하는 서비스 로직은 다음과 같게 된다.
//Service
public class MemberServiceV2 {
private final DataSource dadaSource;
private final MemberRepositoryV2 memberRepository;
public void accountTransfer(String formId, String toId, int money) throw SQLException {
Connection con = dataSource.getConnection();
con.setAutoCommit(false); // 트랜잭션 시작
try {
// 비즈니스 로직 (돈 송금)!
Member fromMember = memberRepository.findById(con, fromId);
Member toMembet = memberRepository.findById(con, toId);
// 비즈니스 로직 생략..
con.commit(); // 성공시 커밋
} catch (Exception e) {
con.rollback(); // 실패시 롤백
throw new IllegalStateException(e);
} finally {
release(con); // 오토커밋 true로 바꾼 후 릴리즈 필요!
}
}
위 코드는 몇 가지 문제점이 있다.
1. 서비스 계층이 순수하지 않다
- 서비스 계층은 핵심 비즈니스 로직이 있는 가장 중요한 부분이다. 그런데 DataSource, Connection, SQLException 등 JDBC 기술에 의존하고 있다.
2. 트랜잭션 동기화 문제
- 트랜잭션에서 커넥션을 동일하게 유지하기 위하 파라미터로 커넥션을 넘겼는데, 동일한 기능을 트랜잭션용과 아닌 것으로 분리해 구현해야 했다.
3. 트랜잭션 적용 반복
- 트랜잭션을 적용하기 위해 너무 많은 코드가 반복된다. (try, catch, finally...)
4. 예외 누수
SQLException은 체크 예외 이기 때문에 catch 나 throw 가 필요한데, 이는 JDBC 의존 기술로 서비스계층을 순수하지 않게 한다.
5. JDBC 반복
트랜잭션과 별개로, Repository에서 유사한 코드 (con, pstmt, rs)가 반복된다.
이런 문제점을 스프링에서 해결해 준다!!!
1. 트랜잭션 추상화 (서비스 계층을 순수하게 만들어주기)
현재 서비스 계층이 트랜잭션을 사용하기 위해 JDBC 기술에 의존한다. 만약 JDBC에서 JPA로 데이터 접근 기술을 바꾼다면 코드를 전부 다 바꿔야 한다
- JDBC Transaction : con.setAutoCommit(false);
- JPA Transaction : transaction.begin();
이를 해결하기 위해 TxManager라는 추상화된 인터페이스를 만들고, 서비스 계층에서 이를 의존하도록 한다. 이후 데이터 접근 기술에 따라 JdbcTxManager 혹은 JpaTxManager를 주입하면 해결할 수 있다.
스프링에선 이를 PlatformTransactionManager라는 인터페이스로 구현했다. 서비스 계층에선 이를 의존하면 된다.
* PlatformTransactionManager
- getTransaction() : 트랜잭션 시작
- commit() : 트랜잭션 커밋
- rollback() : 트랜잭션 롤백
2. 트랜잭션 동기화 (트랜잭션간 커넥션 동일하게 유지해 주기)
위에서 트랜잭션이 트랜잭션 매니저 (PlatformTransactionManager)를 통해 실행된다고 했다.
추가적으로 트랜잭션이 진행되는 동안 커넥션이 동일하게 유지되어야 하는데, 트랜잭션 매니저는 트랜잭션 동기화 매니저를 사용해 커넥션을 동기화해 준다. 즉 커넥션이 필요하면 트랜잭션 동기화 매니저를 통해 커넥션을 획득한다.
이는 쓰레드 로컬을 사용한다. 방식을 조금 더 자세히 설명하면
1. 트랜잭션 매니저 (PlatformTransactionManager)가 트랜잭션을 시작하기 위해 dataSource를 통해 커넥션을 생성한(가져온)다.
2. 해당 커넥션을 트랜잭션 동기화 매니저에 저장한다. 이때 쓰레드마다 부여된 별도 저장소에 저장하므로 해당 쓰레드 (결국 해당 트랜잭션)만 접근이 가능하다.
3. 리포지토리는 해당 트랜잭션 동기화 매니저에서 커넥션을 꺼내 사용(CRUD)하고, 트랜잭션 매니저도 동일한 커낵션을 꺼내 사용해 종료(커밋 or 롤백)한다.
!주의사항!
우린 지금까지 dataSource를 통해 직접 커넥션을 만들어왔는데, 만약 트랜잭션 동기화를 사용하려면
다시 말해 트랜잭션 동기화 매니저를 통해 커넥션을 받으려면 DataSourceUtils를 사용해 커넥션을 받아야 한다.
이는 트랜잭션 동기화 매니저에 커넥션이 있으면 그걸 받아오고,
없으면 새로운 커넥션을 생성해(가져와) 반환한다.
// Repository
// 위 작성했던 Connection을 파라미터로 받는 함수는 삭제 가능!
private void close(Connection con, Statement stmt, ResultSet rs) {
JdbcUtils.colseResultSet(rs);
JdbcUtils.closeStatement(stmt);
// 커넥션을 다시 트랜잭션 동기화 매니저로 전달하기 위해선 DataSourceUtils 사용해야함
DataSourceUtils.releaseConnection(con, dataSource);
}
private Connection getConnection() throws SQLException {
// 트랜잭션 동기화를 사용하려면 DataSourceUtils 를 사용해야함!
Connection con = DataSourceUtils.getConnection(dataSource);
return con;
}
서비스 계층에선 더 이상 Connection, DataSource에 의존하지 않는다! (SQLException 은 나중에 다룰 예정)
// Service
private final PlatformTransactionManager transactionManager;
private final MemberRepository repository;
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
// 비즈니스 로직
transactionManager.commit(status) // 성공시 커밋
} catch (Exception e) {
transactionManaget.rollback(status) // 실패시 롤백
throw new IllegalStateException(e);
}
}
JDBC를 사용하는 경우, transactionManager에 DataSourceTransactionManager를 주입받으면 된다.
JPA를 사용하는 경우, transactionManager에 JpaTransactionManager를 주입받으면 된다.
getTransaction()으로 TransactionStatus 가 반환되며, 이는 커밋 롤백에 필요하다.
* DefaultTransactionDefinition() --> 트랜잭션과 관련된 옵션을 지정할 수 있다.
* 트랜잭션 매니저는 DataSource를 통해 커넥션을 만드므로, 주입 시 DataSource를 넣은 후 주입해야 한다.
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
PlatformTransactionManager txMan = new DataSourceTransactionManager(dataSource);
MemberRepository memberRepository = new MemberRepository(dataSource);
MemberService memberService = new MemberServiceV3_1(transactionMamager, memberRepository);
3. 트랜잭션 템플릿 (트랜잭션 반복 코드 해결하기)
트랜잭션을 진행하는 템플릿은 동일하다. 성공 시 커밋, 실패 시 롤백.
이를 템플릿화 하는 TransactionTemplate가 있다. 물론 이는 트랜잭션을 실행하므로 TransactionManager를 주입받는다.
이 클래스는 크게 두 가지 함수가 있다.
- execute() : 응답 값이 있을 때 사용
- executeWithoutResult() : 응답값이 없을 때 사용
public class MemberServiceV3_2 {
private final TransactionTemplate txTemplate;
private final MemberRepository memberRepository;
// 생성자
public MemberServiceV3_1 (TransactionManager txManager, MemberRepository memberRepository) {
this.txTemplate = new TransactionTemplate(txManager);
this.memberRepository = memberRepository;
}
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
txTemplate.executeWithoutResult ((status) -> {
try {
bizLogic // 비즈니스 로직 생략..
} catch (SQLException e) {
throw new IllegalStateException(e);
}
});
}
커밋, 롤백 구문이 모두 삭제되었다.
이런 해결책을 통해 서비스 계층에서 많은 의존이 삭제되었지만, 여전히 트랜잭션 관련 로직은 남아있다.
AOP 를사용하면 최종적으로 서비스 계층을 깔끔히 비즈니스 로직만 남도록 할 수 있다.
서비스를 호출하기 전 Transaction 프록시를 호출해 트랜잭션을 시작하고, 이후 프록시 안에 있는 서비스를 통해 비즈니스로직을 수행하는 것이다.
// 트랜잭션 프록시 코드 예시
public class TransactionProxy {
priate TransactionManager = transactionManager;
private MemberService target;
public void accountTransfer(...) {
TransactionStatus status = transactionManager.getTransaction(...);
try {
target.accountTransfer(...);
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
throw new IllegalSateException(e);
}
}
}
스프링 트랜잭션 AOP 기능을 사용하기 위해선 해당 서비스 또는 함수에 @Transactional 어노테이션을 붙이면 된다..
// MemberService
@Transactional
public void accountTransfer(String fromId, String toId, int money) throw SQLException {
bizLogic(fromId, toId, money);
}'코딩 > JAVA 공부' 카테고리의 다른 글
| [Spring] DB의 이해, 예외 추상화와 JDBC Template (1) | 2023.06.26 |
|---|---|
| [Spring] DB의 이해, Connection Pool 과 DataSource (0) | 2023.06.24 |
| [Spring] DB의 이해, JDBC란? (0) | 2023.06.23 |
| [Spring] JSP 와 Servlet, JSP 를 활용한 MVC 구조 (0) | 2023.06.04 |
| [Spring] 웹애플리케이션 이해 - Servlet (0) | 2023.06.03 |