본문 바로가기

Web Sever 개발과 CS 기초/스프링

JDBC Trasaction문제 Spring으로 해결하기

관련 내용

해당 프로젝트 깃허브

 

<JDBC, Spring - Transaction 처리에 대한 완벽한 이해>

  • 순수 JDBC만을 사용한 Transaction 사용 방법 알아보기

JDBC를 사용한 Trasaction 처리와 이해

  • (현재 글)순수 JDBC-Transaction 문제 Spring TransactionManger로 해결하기 - 트랜잭션 템플릿, 트랜잭션 AOP 사용

JDBC Trasaction문제 Spring으로 해결하기

  • Spring Transaction AOP 동작 과정과 주의 사항 알아보기

스프링 Trasaction AOP 동작 과정과 주의 사항

  • Spring Transaction 전파 원리 이해하기

Spring Transaction 전파 이해하기

  • Spring을 사용한 JDBC 예외 누수 문제 해결하기

Spring을 사용한 JDBC 예외 누수 문제 해결하기

  • 자바 예외에 대한 이해

자바 예외에 대한 이해

개요와 목적

순수 JDBC로 Transaction처리를 했을 때의 문점들을 파악하고,

스프링 기술을 사용하여 문제들을 해결하는 방법에 대해서 알아보자

문제들 파악하기

JDBC를 사용한 Trasaction 처리와 이해 에서 알아본 JDBC Transaction 처리 코드는 순수한 서비스 계층을 유지 못한다는 문제가 있다.

그 문제들을 코드를 통해서 확인해보자.

@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을 사용하기 위해서 서비스 코드 내부에서 connection 획득
        //repository에 해당 connection 동일하게 전달
        Connection con = dataSource.getConnection();
        try {
            //setAutoCommit(false)로 트랜잭션 시작
            con.setAutoCommit(false);

            //계좌 이체 비지니스 로직
            Member fromMember = memberRepository.findById(con, fromId);
            Member toMember = memberRepository.findById(con, toId);
            memberRepository.update(con, fromId, fromMember.getMoney() - money);
            //에러 발생 상황 연출
            if (toMember.getMemberId().equals("ex")) {
                throw new IllegalStateException("이체중 예외 발생");
            }
            memberRepository.update(con, toId, toMember.getMoney() + money);

            //성공시 커밋
            con.commit();
        } catch (Exception e) {
            //에러발생 - 실패시 롤백
            con.rollback();
            throw new IllegalStateException(e);
        } finally {
            release(con);
        }
    }

문제 1. 예외 누수 문제

SQLException 은 JDBC 전용 기술이다. 향후 JPA나 다른 데이터 접근 기술을 사용하면, 그에 맞는 다른 예외로 변경해야 하고, 결국 서비스 코드도 수정해야 한다. 해당 문제 해결은 아래 블로그 글에서 확인할 수 있다.

Spring을 사용한 JDBC 예외 누수 문제 해결하기

문제 2. JDBC 구현 기술이 서비스 계층에 누수되는 문제

트랜잭션을 적용하기 위해 JDBC 구현 기술이 서비스 계층에 누수되었다(서비스 계층이 JDBC 기술에 의존 되었다). 향후 JDBC에서 JPA 같은 다른 기술이 바뀌면 JDBC 의존성 코드 때문에 서비스 코드도 모두 함께 변경해야 한다. (JPA는 트랜잭션을 사용하는 코드가 JDBC와 다르다.)

문제 3. 트랜잭션 적용 반복 문제

트랜잭션에서 JDBC 트랜잭션을 사용할 때 try catch finnally 반복되는 문제이다.

문제 4. 트랜잭션 동기화 문제

현재는, 트랜잭션을 유지하기 위해 커넥션을 파라미터로 계속해서 넘겨야 한다. 트랜잭션 설정과 사용을 분리해야 한다.

트랜잭션 추상화 (문제2 해결)

JDBC와 JPA 트랜잭션을 사용하는 코드가 다르다.(JDBC : con.setAutoCommit(false) JPA : transaction.begin())

그래서 어떤 기술을 쓰든 같은 코드를 써서 트랜잭션을 처리할 수 있도록 트랜잭션 인터페이스를 만들어서 추상화 작업이 필요하다.

그 방법으로는 스프링이 제공하는 트랜잭션 추상화 기술, PlatformTransactionManager 인터페이스를 사용하면 된다.

//PlatformTransactionManager 인터페이스
package org.springframework.transaction;
		
public interface PlatformTransactionManager extends TransactionManager {
		//트랜잭션을 시작한다. 
		//시작 메소드 이름이 getTransaction()인 이유는 
		//이미 진행중인 트랜잭션에도 참여할 수 있기 때문이다.
		TransactionStatus getTransaction(@Nullable TransactionDefinition definition)
					throws TransactionException;
		
		void commit(TransactionStatus status) throws TransactionException;
		
		void rollback(TransactionStatus status) throws TransactionException;
}

참고
스프링 5.3부터는 JDBC 트랜잭션을 관리할 때 DataSourceTransactionManager 를 상속받아서 약간의 기능을 확장한 JdbcTransactionManager 를 제공한다. 둘의 기능 차이는 크지 않으므로 같은 것으로 이해하면 된다.

트랜잭션 동기화 (문제4 해결)

스프링이 제공하는 트랜잭션 매니저는 추상화 외에도 리소스 동기화라는 기능을 제공한다.

리소스 동기화란, 트랜잭션을 유지하려면 같은 커넥션을 유지해야 하는데, 지저분한 파라미터로 커넥션을 넘기는 방식이 아니라, 트랜잭션 동기화 매니저를 사용한다.

  • 트랜잭션 동기화 매니저는 ThreadLocal을 사용하고, 커넥션을 보관한다. 그래서 멀티 쓰레드 상황에서도 안전하게 커넥션을 보관할 수 있다.
  • 트랜잭션 매니저는 트랜잭션이 시작된 커넥션을 트랜잭션 동기화 매니저에 보관한다.
  • 이제 레포지토리 로직을 실행 할 때, 파라미터가 아닌 트랜잭션 동기화 매니저에서 커넥션을 획득한다.
  • 트랜잭션이 종료되면 트랜잭션 매니저는 트랜잭션 동기화 매니저에 보관된 커넥션을 통해 트랜잭션을 종료하고 커넥션을 닫는다.

1탄 트랜잭션 매니저 사용하기 (문제2, 4 실제 해결)

문제 2,4 번을 해결하는 트랜잭션 매니저 추상화 기술을 실제 코드로 적용해보자. 적용 전과 적용 후 코드를 비교해보자.

변경된 레포지토리 로직

커넥션 리소스 파라미터 방식이 아닌, 리소스 동기화 방식을 사용하기.

KIMHWANG\jdbc\src\main\java\hello\jdbc\repository\MemberRepositoryV2.java

MemberRepositoryV3.java

트랜잭션 매니저를 사용해서 변한 핵심 코드만 가져왔다.

//<적용전>
//MemberRepositoryV2
//파라미터로 con을 받는다
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();
        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            //connection은 여기서 닫지 않는다. 서비스에서 마무리한다.
            JdbcUtils.closeStatement(pstmt);
        }
    }

//<적용후>
//MemberRepositoryV3
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 {
	    //파라미터로 받지 않고 아래 DataSourceUtils를 사용한다.
            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 {
		//커넥션을 반환할 때도 아래 DataSourceUtils를 사용한다.
            close(con, pstmt, null);
        }
    }
private void close(Connection con, Statement stmt, ResultSet rs) {
        JdbcUtils.closeResultSet(rs);
        JdbcUtils.closeStatement(stmt);
        //주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
	//con.close() 를 사용해서 직접 닫아버리면 커넥션이 유지되지 않는 문제가 발생한다
        //releaseConnection() 을 사용하면 커넥션을 바로 닫는 것이 아니라,
        //트랜잭션을 사용하기 위해 동기화된 커넥션은 커넥션을 닫지 않고 그대로 유지해준다.
        //트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 해당 커넥션을 닫는다
        DataSourceUtils.releaseConnection(con, dataSource);
    }

    private Connection getConnection() throws SQLException {
        //주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
	//dataSource는 의존성 주입된 DataSource 객체이다.
	//.getConnection() 는 다음과 같이 동작한다
        //1. 트랜잭션 동기화 매니저가 관리하는 커넥션이 있으면 해당 커넥션을 반환한다.
        //2. 트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 새로운 커넥션을 생성해서 반환한다.
        Connection con = DataSourceUtils.getConnection(dataSource);
        log.info("get connection={} class={}", con, con.getClass());
        return con;
    }

변경된 서비스 로직

서비스 영역에서 기술에 의존성 없이 트랜잭션 사용해보자.

KIMHWANG\jdbc\src\main\java\hello\jdbc\service\MemberServiceV2.java

MemberServiceV3_1.java

(테스트 코드는 깃 허브에서 확인 할 수 있다.)

//<적용전>
//MemberServiceV2.java
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 {
            //setAutoCommit(false)로 트랜잭션 시작
            con.setAutoCommit(false);
            //비즈니스 로직
	    bizLogic(fromId, toId, money);
            //성공시 커밋
            con.commit();
        } catch (Exception e) {
            //에러발생 - 실패시 롤백
            con.rollback();
            throw new IllegalStateException(e);
        } finally {
            release(con);
        }
    }

//<적용후>
//MemberServiceV3_1.java
public class MemberServiceV3_1 {

    //setAutoCommit(false)가 아닌 PlatformTransactionManager 사용
    private final PlatformTransactionManager transactionManager;
    private final MemberRepositoryV3 memberRepository;

    public void accountTransfer(String fromId, String toId, int money) throws
        SQLException {
        //트랜잭션 시작
        //지금은 JDBC 기술을 사용하기 때문에 
	//DataSourceTransactionManager 구현체를 주입 받아야 한다.
        //JPA 같은 기술로 변경되면 JpaTransactionManager 를 주입 받으면 된다.
        TransactionStatus status = transactionManager.getTransaction(new
            DefaultTransactionDefinition());
        try {
            //비즈니스 로직
            bizLogic(fromId, toId, money); 
            //성공시 커밋
            transactionManager.commit(status); 
        } catch (Exception e) {
            //실패시 롤백
            transactionManager.rollback(status); 
            throw new IllegalStateException(e);
        }
    }

KIMHWANG\jdbc\src\main\java\hello\jdbc\service\MemberServiceV3_1.java

PlatformTransactionManager 객체 만드는 과정

@BeforeEach
void before() {
    DriverManagerDataSource dataSource = new DriverManagerDataSource(URL,
        USERNAME, PASSWORD);
		
    //트랜잭션 매니저는 데이터소스를 통해 커넥션을 생성하므로 DataSource가 필요하다.
    PlatformTransactionManager transactionManager = new
        DataSourceTransactionManager(dataSource);

    memberRepository = new MemberRepositoryV3(dataSource);
    memberService = new MemberServiceV3_1(transactionManager,
        memberRepository);
}

트랜잭션 매니저 작동 흐름 파악하기

트랜잭션 인터페이스가 어떤 방식으로 작동하는 지 알아보자.

<트랜잭션 시작>

클라이언트의 요청으로 서비스 로직을 실행한다.

  1. 서비스 계층에서 transactionManager.getTransaction() 을 호출해서 트랜잭션을 시작한다.
  2. 트랜잭션을 시작하려면 먼저 데이터베이스 커넥션이 필요하다. 트랜잭션 매니저는 내부에서 데이터소스를 사용해서 커넥션을 생성한다.
  3. 커넥션을 수동 커밋 모드로 변경해서 실제 데이터베이스 트랜잭션을 시작한다.
  4. 커넥션을 트랜잭션 동기화 매니저에 보관한다.
  5. 트랜잭션 동기화 매니저는 쓰레드 로컬에 커넥션을 보관한다. 따라서 멀티 쓰레드 환경에 안전하게 커넥션을 보관할 수 있다.

<로직 실행>

  1. 서비스는 비즈니스 로직을 실행하면서 리포지토리의 메서드들을 호출한다. 이때 커넥션을 파라미터로 전달하지 않는다.
  2. 리포지토리 메서드들은 트랜잭션이 시작된 커넥션이 필요하다. 리포지토리는DataSourceUtils.getConnection() 을 사용해서 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다. 이 과정을 통해서 자연스럽게 같은 커넥션을 사용하고, 트랜잭션도 유지된다.
  3. 획득한 커넥션을 사용해서 SQL을 데이터베이스에 전달해서 실행한다.

<트랜잭션 종료>

  1. 비즈니스 로직이 끝나고 트랜잭션을 종료한다. 트랜잭션은 커밋하거나 롤백하면 종료된다.
  2. 트랜잭션을 종료하려면 동기화된 커넥션이 필요하다. 트랜잭션 동기화 매니저를 통해 동기화된 커넥션을 획득한다.
  3. 획득한 커넥션을 통해 데이터베이스에 트랜잭션을 커밋하거나 롤백한다.
  4. 전체 리소스를 정리한다. 트랜잭션 동기화 매니저를 정리한다. 쓰레드 로컬은 사용후 꼭 정리해야 한다. con.setAutoCommit(true) 로 되돌린다. 커넥션 풀을 고려해야 한다. con.close() 를 호출해셔 커넥션을 종료한다. 커넥션 풀을 사용하는 경우 con.close() 를 호출하면 커넥션 풀에 반환된다.

트랜잭션 템플릿 (문제3 해결)

PlatformTransactionManager 인터페이스 사용 시 반복되는 패턴을 TransactionTemplate을 사용하여 해결을 해보자.

public class MemberServiceV3_2 {
    private final TransactionTemplate txTemplate;
    private final MemberRepositoryV3 memberRepository;
    public MemberServiceV3_2(PlatformTransactionManager transactionManager,
        MemberRepositoryV3 memberRepository) {
        //TransactionTemplate을  transaction을 주입받아서 생성
        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);
            }
        });
    }
}

2탄 트랜잭션 AOP를 사용해서 문제 해결하기

PlatformTransactionManager 인터페이스를 사용하는 두번 째 방법은 @Transactional 어노테이션을 선언하는 것이다.

스프링 AOP를 통해 프록시를 도입하여, TransactionTemplate도 없는 비지니스 로직만 남겨보자.

실무에선 트랜잭션 매니저 또는 트랜잭션 템플릿를 직접 작성하는 것보다. @Transactional 애노테이션 하나만 추가하여 나머지는 스프링 트랜잭션 AOP가 자동으로 처리해주는 방식을 주로 사용한다.

스프링 트랜잭션 AOP의 간단한 동작 과정 이해

public class Service {
    @Transacsional
    public void logic() {
    //트랜잭션 관련 코드 제거, 순수 비즈니스 로직만 남음
    bizLogic(fromId, toId, money);
    }
}

@Transactional 어노테이션을 붙여주면, 스프링 AOP 기술이 확인해서, 트랜잭션 프록시를 적용해준다. 아래가 코드가 가상의 트랜잭션 처리를 대신 해주는 프록시 클래스이다.

//스프링 AOP가 만들어준 트랜잭션 프록시
//실제 비지니스 로직을 가진 서비스를 target을 가져와서
//트랜잭션 프록시 처리 코드 사이에 끼워 넣는다.
//그렇게 되면, 서비스계층에는 트랜잭션 코드를 완전히 제거할 수 있다.
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);
		 }
	 }
}

트랜잭션 AOP 적용해보기

지금까지 만든 코드에 트랜잭션 AOP를 사용하는 서비스 클래스를 만들어보자.

KIMHWANG\jdbc\src\main\java\hello\jdbc\service\MemberServiceV3_3.java

@Slf4j
@RequiredArgsConstructor
public class MemberServiceV3_3 {
    
	private final MemberRepositoryV3 memberRepository;
    
	//트랜잭션 어노테이션만 붙여주서, 트랜잭션 코드를 완전히 제거할 수 있다.
	@Transactional
    public void accountTransfer(String fromId, String toId, int money) throws
        SQLException {
        bizLogic(fromId, toId, money);
    }
}

참고

@Transactional 애노테이션은 메서드에 붙여도 되고, 클래스에 붙여도 된다. 클래스에 붙이면 외부에서 호출 가능한 public 메서드가 AOP 적용 대상이 된다. 왜냐하면, 트랜잭션 프록시 클래스에 해당 서비스 클래스 import해서 메소드를 호출해야 하는데, private 메소드는 호출할 수 없기 때문이다.

<해당 서비스 코드를 테스트 해볼 config 설정하기>

KIMHWANG\jdbc\src\main\java\hello\jdbc\service\MemberServiceV3_3.java

@Slf4j
@SpringBootTest
class MemberServiceV3_3Test {

    @Autowired
    MemberRepositoryV3 memberRepository;
    @Autowired
    MemberServiceV3_3 memberService;

    @TestConfiguration
    static class TestConfig {
				
	//프링에서 기본으로 사용할 데이터소스를 스프링 빈으로 등록한다.
	//추가로 트랜잭션 매니저에서도 사용한다
        @Bean
        DataSource dataSource() {
            return new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        }
	//스프링이 제공하는 트랜잭션 AOP는 
	//스프링 빈에 등록된 트랜잭션 매니저를 찾아서 사용하기
	//때문에 트랜잭션 매니저를 스프링 빈으로 등록해두어야 한다.
        @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 사용의 전체적인 흐름

  1. 트랜잭션 AOP 프록시 호출
  2. 프록시에서 스프링 컨테이너를 통해 트랜잭션 매니저 획득
  3. 트랜잭션 매니저에서 getTransaction()으로 (어플리케이션 상)트랜잭션을 시작
  4. 트랜잭션 매니저는 주입되어 있는 DataSource에서 커넥션 생성
  5. 해당 커넥션이 setAutoCommit(false)를 통해 실질적인 DB 트랜잭션 시작
  6. 트랜잭션 동기화 매니저 쓰레드 로컬에 커넥션을 보관
  7. 실제 비지니스 로직 실행
  8. 비지니스 로직 안에 있는 리포지토리 로직 실행
  9. 리포지토리 로직 실행을 위한 트랜잭션 동기화 매니저에서 커넥션 획득 후 실행

트랜잭션 매니저 자동 리소스 등록

데이터소스와 트랜잭션 매니저를 직접 스프링 빈으로 등록하는 것이 아닌, 스프링 부트의 application.properties 에 있는 속성을 사용해서 빈을 자동 등록해보자.

DataSource 자동 등록

아래 코드를 통해 스프릉 부트는 데이터소스( DataSource )를 스프링 빈에 자동으로 등록한다. 자동으로 등록되는 스프링 빈 이름: dataSource이다.

스프링 부트가 기본으로 생성하는 데이터소스는 커넥션풀을 제공하는 HikariDataSource 이다. spring.datasource.url 속성이 없으면 내장 데이터베이스(메모리 DB)를 생성하려고 시도한다.

//application.properties
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=

트랜잭션 매니저 - 자동 등록

스프링 부트는 적절한 트랜잭션 매니저( PlatformTransactionManager )를 자동으로 스프링 빈에 등록한다. 자동으로 등록되는 스프링 빈 이름은 transactionManager이다.

어떤 트랜잭션 매니저를 선택할지는 현재 등록된 라이브러리를 보고 판단하는데, JDBC를 기술을 사용하면 DataSourceTransactionManager 를 빈으로 등록하고, JPA를 사용하면 JpaTransactionManager 를 빈으로 등록한다.

둘 다 사용하는 경우 JpaTransactionManager 를 등록한다. 참고로 JpaTransactionManager 는 DataSourceTransactionManager 가 제공하는 기능도 대부분 지원한다.

<자동 등록이 아닌, 직접 등록하는 방식의 코드>

@Slf4j
@SpringBootTest
class MemberServiceV3_3Test {

    @Autowired
    MemberRepositoryV3 memberRepository;
    @Autowired
    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());
        }
    }
}

<자동 등록 방식의 코드>

@Slf4j
@SpringBootTest
class MemberServiceV3_4Test {

    @Autowired
    MemberRepositoryV3 memberRepository;
    @Autowired
    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_3() {
            return new MemberServiceV3_3(memberRepositoryV3());
        }
    }
}