[Spring Database] Transaction AOP
트랜잭션을 적용해 여러 작업들을 하나의 작업으로 묶어서 처리하는건 성공했지만, 비즈니스 로직을 처리하는 코드보다 트랜잭션을 처리하는 코드가 훨씬 더 많아져 코드가 지저분해졌다.
좀 더 간단하게 처리하는 방법을 알아보자.
애플리케이션의 구조는 역할에 따라 세 가지 계층으로 나눌 수 있다.
프레젠테이션 계층 : UI와 관련된 처리를 담당하고, 클라이언트의 요청을 검증하고 응답하는 역할을 한다. (스프링 MVC, HTTP..)
서비스 계층 : 계좌이체와 같은 비즈니스 로직을 담당한다. (특정 기술에 의존하지 않고 자바 코드로 작성)
데이터 접근 계층 : 실제 데이터베이스에 접근하는 코드를 담당한다. (JDBC, JPA...)
모두 중요한 계층이지만, 그 중에서도 핵심 비즈니스 로직이 구현된 서비스 계층은 특히 더 중요하다.
프레젠테이션 계층과 데이터 접근 계층이 시간이 지남에 따라 다른 기술로 변경돼도, 비즈니스 로직은 최대한 변경 없이 유지되어야 한다.
따라서 서비스 계층을 개발할 때는 특정 기술에 종속적이지 않게 유지해야 하는 편이 좋다.
사실 애플리케이션을 세 가지 계층으로 구분하는 이유 중 하나가 서비스 계층을 순수하게 유지하기 위해서이다.
프레젠테이션 계층에서는 서비스 계층을 UI와 관련된 기술로부터 보호해주고, 데이터 접근 계층은 구체적인 데이터 접근 기술로부터 서비스 계층을 보호해준다. (인터페이스 사용)
이렇게 계층을 분리해 개발 시 향후 구현 기술이 변경되더라도 변경해야하는 부분을 줄일 수 있다.
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); // 커넥션 풀 고려. 보통 true를 사용한다.
con.close();
} catch (Exception e) {
log.info("error", e);
}
}
}
}
이전에 작성했던 트랜잭션을 구현한 코드이다.
트랜잭션을 사용하기 위해 javax.sql.DataSource / java.sql.Connection / java.sql.SQLException 같은 JDBC 기술에 의존해야했다.
때문에 비즈니스 로직보다 JDBC를 사용해서 트랜잭션을 처리하는 코드가 더 많아졌고, 추후 JDBC기술 대신 JPA같은 다른 기술로 바꿔 사용할 경우 서비스 계층과 데이터 접근 계층을 함께 변경해야 한다.
정리하면, 다음과 같은 문제가 발생했다.
1. JDBC 구현 기술이 서비스 계층에 누수되는 문제 - 서비스 계층에 비즈니스 로직과 JDBC기술이 섞여 있다.
2. 트랜잭션 동기화 문제 - 같은 기능도 트랜잭션을 사용하는 기능 / 트랜잭션을 사용하지 않는 기능으로 분리해야 한다.
3. 트랜잭션 적용 반복 문제 - try . catch . finally 부분이 반복된다.
4. 예외 누수 - JDBC 구현 기술의 예외가 서비스 계층으로 전파된다. (SQLException)
5. 트랜잭션 추상화 문제 - JDBC에서 JPA로 사용하는 기술을 변경하게 되면 서비스 계층의 코드도 바꿔야 한다.
그래도 트랜잭션을 포기할 수는 없는데...
문제를 하나하나 해결해보자.
트랜잭션 기능을 추상화해서 데이터 접근 기술을 바꿔도 서비스 계층에 영향이 없도록 설정하자.
public interface TxManager {
begin();
commit();
rollback();
}
서비스가 트랜잭션 기술에 의존하도록 하지 말고 추상화된 인터페이스를 만들어 인터페이스에 의존하도록 설계하자.
(트랜잭션 내부에서 DataSource를 통해 커넥션을 획득한다)
이제 원하는 구현체를 DI를 통해 주입하면 되고, OCP원칙을 지킬 수 있다.
스프링은 트랜잭션 추상화 기술을 제공하고, 데이터 접근 기술에 따른 트랜잭션 구현체도 대부분 만들어져 있어 개발자는 가져다 사용하기만 하면 된다.
public interface PlatformTransactionManager extends TransactionManager {
TransactionStatus getTransaction(@Nullable TransactionDefinition definition) throws TransactionException;
void commit(TransactionStatus status) throws TransactionException;
void rollback(TransactionStatus status) throws TransactionException;
}
getTransaction() : 트랜잭션을 시작한다.
commit(), rollback() 말 그대로..
이제 트랜잭션 동기화 문제를 해결해보자.
여기서 트랜잭션 매니저는 트랜잭션 인터페이스와 그 구현체를 의미한다.
트랜잭션 매니저는 내부에서 트랜잭션 동기화 매니저를 사용해 커넥션을 동기화한다.
이전처럼 파라미터로 커넥션을 동기화 하는 대신, 트랜잭션 동기화 매니저를 통해 커넥션을 획득하자.
(쓰레드로컬을 사용해 멀티쓰레드 환경에서 안전하다)
동작 과정은 다음과 같다.
1. 트랜잭션을 시작하려면 커넥션이 필요하다. 트랜잭션 매니저는 데이터소스를 통해 커넥션을 얻고 트랜잭션을 시작한다.
2. 트랜잭션이 시작된 커넥션들은 트랜잭션 동기화 매니저에 보관한다.
3. 리포지토리에서 커넥션을 사용할 때는 동기화 매니저에 있는 커넥션을 사용한다.
4. 트랜잭션이 종료되면 매니저에 보관된 커넥션을 꺼내 트랜잭션을 종료하고, 커넥션도 닫는다.
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) 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 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 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);
// 트랜잭션 동기화를 사용하려면 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() 메서드를 사용해 트랜잭션 동기화 매너지가 관리하는 커넥션을 반환받는다. (없으면 새로 만들어서 반환받는다)
con.close() 메서드 대신 DataSourceUtils.releaseConncetion() 메서드를 사용해 해당 커넥션이 동기화 매니저에서 가져온 커넥션인지 확인하고, 맞으면 커넥션을 닫지 않고 아니면 커넥션을 닫도록 한다.
public class MemberServiceV3_1 {
private final PlatformTransactionManager transactionManager; // 인터페이스로 추상화
private final MemberRepositoryV3 memberRepository;
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
//트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(new 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);
validation(toMember);
memberRepository.update(toId, toMember.getMoney() + money);
}
private void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체중 예외 발생");
}
}
}
서비스 계층에서는 데이터 접근 기술에 바로 의존하지 않고, 추상화된 인터페이스를 통해 접근한다.
PlatformTransactionManager의 구현체를 주입받자. (지금은 JDBC)
status 변수에는 현재 트랜잭션의 상태 정보가 포함되어있고, 커밋 / 롤백 시 사용된다.
트랜잭션을 적용할 때 반복되는 코드를 줄여보자.
트랜잭션을 시작하고, 비즈니스 로직을 실행한다.
비즈니스 로직이 정상적으로 실행되면 커밋, 예외가 발생해서 실패하면 롤백한다.
트랜잭션은 위의 코드를 try - catch - finally로 묶여서 구현하고, 각각의 트랜지션마다 달라지는 부분은 비즈니스 로직을 실행하는 부분 뿐이다.
이럴 때 템플릿 콜백 패턴을 사용해 반복되는 코드를 깔끔하게 처리할 수 있다.
public class TransactionTemplate {
private PlatformTransactionManager transactionManager;
public <T> T execute(TransactionCallback<T> action){..}
void executeWithoutResult(Consumer<TransactionStatus> action){..}
}
스프링은 템플릿 클래스 TransactionTemplate를 제공한다.
execute() : 응답 값이 있을 때 사용한다.
executeWithoutResult() : 응답 값이 없을 때 사용한다.
public class MemberServiceV3_2 {
private final 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);
validation(toMember);
memberRepository.update(toId, toMember.getMoney() + money);
}
private void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체중 예외 발생");
}
}
}
생성자에서 transactionManager를 주입받아 TransactionTemplate을 생성할 때 사용했다.
txTemplate.executeWithoutResult() 메서드 내부에서 비즈니스 로직을 실행하고, 정상 수행되면 커밋 / Unchecked 예외가 발생하면 롤백한다. (Checked 예외는 커밋)
해당 부분에서 try - catch 문법은 비즈니스 로직을 실행할 때 발생할 수 있는 SQLException을 처리하기 위해 사용됐다.
템플릿을 사용해 반복되는 코드를 제거할 수 있었다. (JDBC 반복 문제도 템플릿 콜백 패턴으로 해결할 수 있다)
이제 비즈니스 로직과 트랜잭션을 처리하는 기술 로직을 분리하고 싶은데.. 어떻게 해야 될까?
AOP를 통해 동적 프록시 기술을 도입하면 관심사를 분리할 수 있다.
프록시가 트랜잭션 처리 로직을 모두 가져가고 트랜잭션을 시작 후 실제 서비스를 대신 호출해 주고, 서비스 계층에는 비즈니스 로직만 남길 수 있다.
트랜잭션은 정말 많이 사용하는 기능이고 또 정말 중요한 기능이기에 스프링은 @Transactional 애너테이션을 제공하고, 개발자는 트랜잭션 처리가 필요한 부분에 해당 애너테이션만 붙여 주면 된다. (트랜잭션 AOP가 해당 애너테이션을 인식해 프록시를 적용한다)
public class MemberServiceV3_3 {
private final MemberRepositoryV3 memberRepository;
@Transactional
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);
validation(toMember);
memberRepository.update(toId, toMember.getMoney() + money);
}
private void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체중 예외 발생");
}
}
}
트랜잭션 관련 코드를 제거하고 비즈니스 관련 코드만 남겼다.
트랜잭션을 적용할 메서드에 @Transactional 애너테이션을 붙여줬다.
@Transactional 애너테이션을 클래스에 붙이면 외부에서 호출 할 수 있는 모든 public 메서드가 AOP의 적용 대상이 된다.
테스트 시 제대로 작동하는지 확인하려면 @SpringBootTest 애너테이션으로 테스트에서 스프링 컨테이너를 사용하고, @TestConfiguration 애너테이션으로 스프링 빈들을 등록해야 한다.
정리해보자.
@Transaction 애너테이션이 붙어있으면 스프링이 프록시를 만든다. (실제 서비스 대신 프록시가 의존관계를 주입받는다)
클라이언트는 프록시를 호출하고, 프록시 내부에서 트랜잭션을 시작하고 트랜잭션 매니저를 찾아서 호출한다.
트랜잭션 매니저는 데이터소스를 통해 커넥션을 생성하고, 트랜잭션 동기화 매니저에 넣어둔다.
프록시에서는 클라이언트의 요청을 처리하기 위해 서비스 계층을 호출하고 트랜잭션 동기화 매니저에 있는 커넥션을 사용해 요청을 처리한다.
메서드 실행이 완료되면 해당 커넥션은 DataSource에 자동으로 반환되니 conn.close() 메서드를 호출하지 않아도 된다.
'Spring > Spring Database' 카테고리의 다른 글
[Spring Database] JdbcTemplate (0) | 2022.09.06 |
---|---|
[Spring Database] 데이터베이스 예외 처리 (0) | 2022.09.05 |
[Spring Database] Transaction / Lock (1) | 2022.09.01 |
[Spring Database] Connection Pool과 Data Source (0) | 2022.08.31 |
[Spring Database] JDBC (0) | 2022.08.30 |
댓글
이 글 공유하기
다른 글
-
[Spring Database] JdbcTemplate
[Spring Database] JdbcTemplate
2022.09.06 -
[Spring Database] 데이터베이스 예외 처리
[Spring Database] 데이터베이스 예외 처리
2022.09.05 -
[Spring Database] Transaction / Lock
[Spring Database] Transaction / Lock
2022.09.01 -
[Spring Database] Connection Pool과 Data Source
[Spring Database] Connection Pool과 Data Source
2022.08.31