일반적으로 스프링 프로젝트는 아래와 같은 구조를 갖는다.
3가지 계층중에서 가장 중요한곳은 서비스 계층이다. 왜냐하면 프로젝트의 핵심 비지니스 로직이 들어있는 계층이기 때문이다.
시간이 흘러 UI와 데이터 저장 기술은 변경될 수 있어도 비지니스 로직은 변경하지 않고 유지되어야한다.(버그가 없다면!)
이를 위해서는 서비스 계층을 특정 기술에 종속적이지 않게 개발해야한다.
3가지 계층으로 나눈 이유도 서비스 계층을 최대한 순수하게 유지하기 위한 목적이 크다고 한다. 기술에 종속적인 부분은 프레젠테이션 계층, 데이터 접근 계층에서 가져간다.
서비스 계층이 특정 기술에 종속되지 않기 떄문에 비지니스 로직을 유지보수하기도 쉽고 테스트하기도 쉽다.
그리고 트랜잭션은 비지니스 로직이 존재하는 서비스 계층에서 시작을 해야한다. 왜냐하면 비지니스 로직이 수행되다가 실패한다면 그와 연관된 모든 로직을 rollback해야하기 때문이다.
이전 포스팅에서의 고전적인 트랜잭션 코드에서는 여러가지 문제점이 있었다.
이전 포스팅 : 이전 포스팅
@Slf4j
@RequiredArgsConstructor
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); //커넥션 풀 고려
con.close();
} catch (Exception e) {
log.info("error", e);
}
}
}
}
위 코드의 문제를 나열해보면 아래와 같다.
서비스 계층에서 트랜잭션을 시작하기 위해서 아래와 같은 JDBC 기술에 의존한다.
javax.sql.DataSource
java.sql.Connection
java.sql.SQLException
트랜잭션을 사용하기 위해서 JDBC 기술에 의존하며 비지니스 로직보다 JDBC를 이용해 트랜잭션을 처리하는 코드가 더 많다.
JDBC에서 JPA 같은 다른 기술로 바꾸어 사용하기 위해서는 서비스 코드를 모드 변경해야한다.(트랜잭션 적용 방법이 다르기 때문에)
핵심 비지니스 로직과 JDBC 기술이 섞여 있어 유지보수가 어렵다.
4가지 정도의 문제가 존재하는데 이를 좀더 상세하게 설명하면 아래와 같다.
JDBC 구현 기술의 누수
JDBC 구현 기술이 서비스 계층에 누수 된다. 서비스 계층은 순수한 자바 코드로 구성되어야 한다. 즉, 구현 기술을 변경 해도 서비스 계층 코드는 최대한 유지할 수 있어야 한다. 그래서 데이터 접근 계층에 JDBC 코드를 몰아둔다. 그런데 트랜잭션을 적용하면서 서비스 계층에 JDBC 구현 기술의 누수가 발생했다.
트랜잭션 동기화 문제
같은 트랜잭션을 유지하기 위해서 커넥션을 파라미터로 넘겨한다. 따라서 똑같은 기능도 트랜잭션용 기능과 트랜잭션을 유지하지 않아도 되는 기능으로 분리해야한다. (동일한 함수 2개를 만들어야 한다.)
트랜잭션 적용 반복 코드 문제
트랜잭션 적용 코드를 보면 반복된 코드가 매우 많다.
try,catch,finally와 같은 유사한 코드가 많이 반복된다.
커넥션을 열고, PreparedStatement를 사용하고 결과 맵핑 및 실행 등의 반복된 코드가 매우 중복된다.
지금까지 나열된 문제들은 스프링에서 모두 해결되었다!!!
서비스 계층은 트랜잭션을 사용하기 위해서는 JDBC 기술에 의존하고 있다. 향후 JDBC에서 JPA 같은 다른 데이터 접근 기술로 변경하기 위해서 서비스 계층의 트랜잭션 관련 코드도 모두 수정되어야 한다.
이 문제를 해결하려면 트랜잭션 기능을 추상화하면된다.
물론, 트랜잭션 추상화는 스프링이 이미 제공한다. 따라서 스프링이 제공하는 트랜잭션 추상화 기술을 사용하면 된다. 구현체도 이미 존재한다.
package org.springframework.transaction;
import org.springframework.lang.Nullable;
public interface PlatformTransactionManager extends TransactionManager {
TransactionStatus getTransaction(@Nullable TransactionDefinition definition)
throws TransactionException;
void commit(TransactionStatus status) throws TransactionException;
void rollback(TransactionStatus status) throws TransactionException;
}
스프링이 제공하는 트랜잭션 매니저는 크게 2가지 역할을 수행한다.
트랜잭션을 유지하려면 트랜잭션의 시작부터 끝까지 같은 데이터 베이스 커넥션을 유지해야한다. 결국 같은 커넥션을 동기화하기 위해서 이전에는 파라미터로 커넥션을 전달하는 방법을 사용했다.
파라미터로 커넥션을 전달하는 방법은 코드가 지저분해지는 것은 물론이고, 커넥션을 넘기는 메서드와 넘기지 않는 메서드를 중복해서 만들어야하는 단점들이 있다.
위 그림의 동작 방식은 아래와 같다.
1. 트랜잭션을 시작하려면 커넥션이 필요하다. 트랜잭션 매니저(트랜잭션 추상화)는 데이터 소스를 통해 커넥션을 만들고 트랜잭션을 시작한다.
2. 트랜잭션 매니저는 트랜잭션이 시작된 커넥션을 트랜잭션 동기화 매니저를에 보관한다.
3. 리포지토리는 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다. 따라서 파라미터로 커넥션을 전달하지 않아도 된다.
4. 트랜잭션이 종료되면 트랜잭션 매니저는 트랜잭션 동기화 매니저에 보관된 커넥션을 통해 트랜잭션을 종료하고 커넥션도 닫는다.
참고)
트랜잭션 매니저가 알아서 이전 코드들에서 수행했던 release 함수의 역할 까지 수행한다. 즉 커밋이나 롤백을 하는 경우 트랜잭션 매니저 내부에서 알아서 커넥션과 리소스를 정리해준다.
리포지토리 코드
@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) 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);
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();
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(dataSource);
DataSourceUtils.releaseConnection(con, dataSource);
DataSourceUtils.getConnection()은 아래와 같이 동작한다.
DataSourceUtils.releaseConnection()
아래의 코드는 트랜잭션 매니저를 이용한 서비스 계층 코드이다.
@Slf4j
@RequiredArgsConstructor
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("이체중 예외 발생");
}
}
}
테스트 코드
@BeforeEach
void before() {
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
memberRepository = new MemberRepositoryV3(dataSource);
PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
memberService = new MemberServiceV3_1(transactionManager, memberRepository);
}
위 테스트코드를 데이터소스 객체를 생성하고 트랜잭션 매니저는 DataSourceTransactionManager 구체 클래스로 생성하는걸 확인할 수 있다.
트랜잭션 매니저는 데이터소스를 이용해 커넥션을 생성하므로 DataSource가 필요하다.
트랜잭션 매니저의 전체 동작 흐름을 정리해보면 아래와 같다.
클라이언트의 요청으로 서비스 로직을 실행한다.
서비스 계층에서 transactionManager.getTransaction()을 호출해서 트랜잭션을 시작한다.
트랜잭션을 시작하려면 먼저 데이터베이스 커넥션이 필요하다. 트랜잭션 매니저는 내부에 데이터소스를 사용해서 커넥션을 생성한다.
커넥션을 수동 커밋모드로 변경해서 실제 데이터베이스 트랜잭션을 시작한다.
커넥션을 트랜잭션 동기화 매니저 보관한다.
트랜잭션 동기화 매니저는 쓰레드 로컬에 커넥션을 보관한다. 따라서 멀티 쓰레드 환경에서 안전하게 커넥션 보관이 가능하다.
서비스는 비지니스 로직을 실행하면서 리포지토리의 메서드들을 호출한다.이때 커넥션을 파라미터로 전달하지 않는다.
리포지토리 메서드들은 트랜잭션이 시작된 커넥션이 필요하다. 리포지토리는 DataSourceUtils.getConnection()을 사용해서 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다. 이 과정을 통해서 자연스럽게 같은 커넥션을 사용하고 트랜잭션도 유지된다.
획득한 커넥션을 사용해서 SQL을 데이터베이스에 전달해 실행한다.
비지니스 로직이 끝나고 트랜잭션을 종료한다. 트랜잭션은 커밋하거나 롤백하면 종료된다.
트랜잭션이 종료하려면 동기화된 커넥션이 필요하다. 트랜잭션 동기화 매니저를 통해 동기화된 커넥션을 획득한다.
획득한 커넥션을 통해 데이터베이스에 트랜잭션을 커밋하거나 롤백한다.
전체 리소르를 정리한다.
-> 트랜잭션 동기화 매니저를 정리한다.
-> con.setAutoCommit(true)로 되돌린다.
-> con.close()를 호출해서 커넥션을 종료한다. 커넥션풀을 사용하는 경우 con.close()를 호출하면 커넥션 풀에 반환된다.
트랜잭션 추상화를 사용해서 서비스 코드는 더이상 JDBC 기술에 의존하지 않는다.
트랜잭션 추상화를 사용하는 로직들을 보면 아직 같은 패턴이 반복된다.
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);
}
}
템플릿 콜백 패턴을 적용하려면 템플릿을 제공하는 클래스를 작성해야하는데, 스프링은 TransactionTemplate 라는 클래스를 제공한다.
참고)
템플릿 콜백 패턴은 말그대로 일정한 템플릿을 만들어놓고 템플릿 내부에서 호출하는 특정 함수만 각 구현체에서 구현하여 제공하는걸 의미한다.
public class TransactionTemplate {
private PlatformTransactionManager transactionManager;
public <T> T execute(TransactionCallback<T> action){..}
void executeWithoutResult(Consumer<TransactionStatus> action){..}
}
따라서 우리는 execute() 또는 executeWithoutResult()에서 수행될 비지니스 로직만 구현해주면 된다.
executeWithoutResult 또는 execute 함수안에서 트랜잭션이 시작되고 종료된다. 그래서 반복된 코드가 제거된다.(로직안에서 트랜잭션도 시작된다.)
트랜잭션 템플릿을 사용해서 반복코드를 제거해보면 아래와 같다.
/**
* 트랜잭션 - 트랜잭션 템플릿
*/
@Slf4j
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("이체중 예외 발생");
}
}
}
정리
1. 트랜잭션 템플릿으로 트랜잭션을 사용할때 반복되는 코드들을 제거할 수 있다.
2. 그러나, 서비스 로직인데 비지니스 로직 뿐만 아니라 트랜잭션을 처리하는 로직이 섞여있다.
지금까지 트랜잭션을 편리하게 사용하고 처리하기 위해서 트랜잭션 추상화(트랜잭션 매니저)와 반복저인 트랜잭션 로직을 해결하기 위해 트랜잭션 템플릿을 도입했다.
하지만, 아직 서비스 계층에서 트랜잭션 로직과 비지니스 로직이 섞여있는 문제가 있다.
스프링 AOP를 통해 프록시를 도입하면 이 문제도 해결할 수 있다.
아래의 2가지 그림은 프록시 도입을 통해서 비지니스 로직과 트랜잭션 로직을 분리하는 모습이다.
프록시를 사용하면 트랜잭션을 처리하는 객체와 비지니스 로직을 처리하는 서비스 객체를 명확하게 분리할 수 있다.
아래의 코드는 이해를 돕기 위한 트랜잭션 프록시 예시 코드다
// 프록시 코드
public class TransactionProxy {
private MemberService target;
public void logic() {
//트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(..);
try {
//실제 대상 호출
target.logic();
transactionManager.commit(status); //성공시 커밋
} catch (Exception e) {
transactionManager.rollback(status); //실패시 롤백
throw new IllegalStateException(e);
}
}
}
// 서비스 코드
public class Service {
public void logic() {
//트랜잭션 관련 코드 제거, 순수 비즈니스 로직만 남음
bizLogic(fromId, toId, money);
}
}
참고
스프링이 제공하는 AOP기능을 사용해서 프록시를 편리하게 만들 수 있다.
AOP 기능으로 트랜잭션을 처리해도 되지만, 트랜잭션을 전세계 누구나 사용하는 기능으로 스프링은 트랜잭션 AOP를 처리하기 위한 모든 기능을 제공해준다. 스프링부트를 사용하면 트랜잭션 AOP를 처리하기 위해 필요한 스프링빈들도 자동으로 등록해준다.
따라서, 개발자는 트랜잭션 처리가 필요한 곳에 @Transactional 애노테이션만 붙여주면 된다. 스프링의 트랜잭션 AOP는 이 애노테이션을 인식해서 트랜잭션 프록시를 적용해준다.
스프링 AOP를 적용하려면 어드바이저, 포인트컷, 어드바이스가 필요하다. 스프링은 트랜잭션 AOP 처리를
위해 다음 클래스를 제공한다. 스프링 부트를 사용하면 해당 빈들은 스프링 컨테이너에 자동으로 등록된다.어드바이저: BeanFactoryTransactionAttributeSourceAdvisor
포인트컷: TransactionAttributeSourcePointcut
어드바이스: TransactionInterceptor
아래의 코드는 트랜잭션 AOP를 활용한 예시 코드이다.
/**
* 트랜잭션 - @Transactional AOP
*/
@Slf4j
public class MemberServiceV3_3 {
private final MemberRepositoryV3 memberRepository;
public MemberServiceV3_3(MemberRepositoryV3 memberRepository) {
this.memberRepository = 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("이체중 예외 발생");
}
}
}
아래는 테스트 코드다.
@Autowired
private MemberRepositoryV3 memberRepository;
@Autowired
private MemberServiceV3_3 memberService;
@TestConfiguration
static class TestConfig {
@Bean
DataSource dataSource() {
return new DriverManagerDataSource(URL, USERNAME, PASSWORD);
}
@Bean
PlatformTransactionManager transactionManager() {
return new DataSourceTransactionManager(dataSource());
}
@Bean
MemberRepositoryV3 memberRepositoryV3() {
return new MemberRepositoryV3(dataSource());
}
@Bean
MemberServiceV3_3 memberServiceV3_3() {
return new MemberServiceV3_3(memberRepositoryV3());
}
}
트랜잭션 AOP를 사용하기 위해서는 필요한 재료들(DataSource, TransactionManager) 를 스프링빈으로 등록해놔야한다.
정리
트랜잭션 AOP를 통해서 비지니스 로직과 트랜잭션 로직을 분리할 수 있게 되었고, 개발자는 트랜잭션이 필요한곳에 @Transactional 애노테이션만 추가해주면 된다.
프록시는 스프링이 알아서 만들어준다. 그리고 스프링빈으로도 등록해준다.
트랜잭션 AOP를 사용하려면 스프링이 제공하는 시스템을 사용해야함. 즉 스프링빈으로 등록해야함.
@Transactional 애노테이션이 붙어있으면 프록시 클래스를 만들어서 스프링빈으로 등록함.
스프링 부트가 등장하기 이전에는 데이터소스와 트랜잭션 매니저를 개발자가 직접 스프링빈으로 등록해서 사용했다. 그런데 스프링부트로 개발을 시작한 개발자라면 데이터소스나 트랜잭션 매니저를 직접 등록해본적이 없을것이다.
왜냐하면 스프링부트에서 자동으로 등록해주기 때문이다!
이때 스프링부트는 다음과 같이 application.properties에 있는 속성을 사용해서 DataSource를 생성하고 스프링빈으로 등록한다.
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=
어떤 트랜잭션 매니저를 선택할지는 현재 등록된 라이브러리를 보고 판단하는데, JDBC를 기술을 사용하면
DataSourceTransactionManager 를 빈으로 등록하고, JPA를 사용하면 JpaTransactionManager 를
빈으로 등록한다. 둘다 사용하는 경우 JpaTransactionManager 를 등록한다. 참고로
JpaTransactionManager 는 DataSourceTransactionManager 가 제공하는 기능도 대부분 지원한다.
상세 docs : 자동등록
해당 포스트팅은 아래의 강의를 공부하여 정리한 내용입니다.
김영한님의 SpringDB1-스프링과문제해결-트랜잭션