본문 바로가기

Spring

JDBC와 트랜잭션 문제, 스프링의 해결책

  • 이번 포스팅에서는 스프링에서 트랜잭션을 적용하기 위해서 어떠한 과정을 거쳐왔는지를 예제를 통해 학습해보자.
  • 예제는 간단하게 계좌이체를 하는 코드이다.
@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 release(Connection con) {
        if (con != null) {
            try {
                con.setAutoCommit(true); //connection pool을 고려
                con.close();
            } catch (Exception e) {
                log.info("error", e);
            }
        }
    }

    private void validation(Member toMember) {
        if (toMember.getMemberId().equals("ex")) {
            throw new IllegalStateException("이체중 예외 발생");
        }
    }

  • 첫 번쨰 함수는 accountTransfer() 이다.
    • 이 함수는 파라미터로 보내는 사람, 받는 사람, 금액 등을 받아서 계좌이체를 실행한다.
    • 계좌이체를 하는 로직은 bizLogic 으로 넘기고 호출한다.
    • 중요한 bizLogic 보다는 데이터베이스 커넥션과 트랜잭션과 관련된 코드가 훨씬 많음을 알 수 있다.
    • SQLException 체크 예외가 발생할 수 있어서 선언되어있다.
    • 다음은 Repository 함수이다.
    public Member findById(Connection con, String memberId) throws 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();
            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 member_id=" + memberId);
            }
        } catch (SQLException e) {
            log.error("error", e);
            throw e;
        } finally {
            JdbcUtils.closeResultSet(rs);
            JdbcUtils.closeStatement(pstmt);
//            JdbcUtils.closeConnection(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);
	        int resultSize = pstmt.executeUpdate(); //영향받은 row 수를 반환
	        log.info("resultSize={}", resultSize);
	    } catch (SQLException e) {
	        log.error("DB error");
	        throw e;
	    } finally {
	        JdbcUtils.closeStatement(pstmt);
	    }
	}

  • 여기서도 dataSource 로부터 커넥션을 가져오고 이 커넥션으로 PreparedStatement 를 각각 만들어서 이를 실행한다.

문제점들

  • 간단하게 만들어 보았는데 코드에는 여러 문제가 있음을 알 수 있다.
  1. 트랜잭션문제
    1. JDBC 구현 기술이 서비스에 누수 : 서비스 계층은 순수해야한다. JDBC 관련 코드가 서비스에까지 전파되었는데 만약 JDBC가 JPA로 변경되면 대부분의 코드를 수정해야한다.
    2. 트랜잭션 동기화 문제 : 계좌이체 로직은 하나의 트랜잭션으로 실행되어야 한다. 따라서 같은 트랜잭션을 유지하기 위해 같은 Connection을 유지해야하는데 그러기 위해 Connection을 매개변수로 전달한다.
    3. 트랜잭션 적용 반복 문제 : 트랜잭션과 관련된 코드가 너무 많다. try, catch, finally ...
  2. 예외 누수 문제 : SQLException은 JDBC 전용 체크 예외이다. JDBC => JPA로 변경되면 예외도 변경된다. 기술이 변경 되면 서비스코드까지 수정해야하는 문제가 발생한다.
  3. JDBC 반복 문제 : 데이터 접근계층에서도 try, catch, finally 등 JDBC 관련 코드가 너무 중복된다.

스프링이 위의 문제들을 어떻게 해결해나가는지 예제 코드를 통해서 살펴보자

트랜잭션 추상화

  • 현재 서비스 계층에서 트랜잭션을 사용할 때 JDBC 기술에 의존하고 있다. 이 때 JDBC => JPA로 기술변경이 발생하면 트랜잭션 관련 코드를 모두 수정해야한다.
  • 이 방법을 해결하기 위해 서비스가 의존하는 대상을 트랜잭션의 추상화(인터페이스)에만 의존하게 만든다.
public interface TxManager{
	begin();
	commit();
	rollback();
}

  • 의존성 주입으로 트랜잭션 관리 기술을 넣어주면 서비스 코드이 변경없이 트랜잭션 기술을 변경할 수 있다. 

스프링의 트랜잭션 추상화

  • 스프링은 이미 트랜잭션 추상화 기술을 만들어두었다.
    • PlatformTransactionManager
    • 그리고 이에 대한 구현체도 각 데이터 접근 기술마다 제공한다. 

  • 우리는 그냥 가져다 사용하면 된다!!

트랜잭션 동기화

  • 다음은 트랜잭션 동기화 문제이다. 이전 코드에서는 하나의 커넥션을 유지하기 위해 커넥션을 매개변수로 전달했고 그 커넥션으로 트랜잭션을 관리했다.
  • 스프링이 제공해주는 트랜잭션 메니저는 트랜잭션 추상화 기능 뿐만아니라 트랜잭션 리소스 동기화까지 해준다.
  • 리소스동기화
    • 커넥션을 유지하여 하나의 트랜잭션을 유지해준다.
  • 트랜잭션 동기화 매니저는 쓰레드로컬을 사용해서 커넥션을 동기화한다.
  • 트랜잭션이 시작되면 트랜잭션 매니저는 커넥션을 생성하고 해당 커넥션을 동기화매니저(TransactionSynchronizationManager)에 보관한다.
  • 필요할떄 동기화 매니저르 통해서 꺼내서 커넥션을 가져와서 사용한다.

트랜잭션 동기화 - 리소스 획득과 반환 예제 코드

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 {
    // transactinosynch
    Connection con = DataSourceUtils.getConnection(dataSource);
    log.info("get connection={}, class={}", con, con.getClass());
    return con;
}

  • DataSourceUtils.getConnection()
    • 트랜잭션 동기화 매니저에 커넥션이 있으면 해당 커넥션을 반환
    • 없으면 만들어서 반환
  • DataSourceUtils.releaseConnection()
    • 커넥션을 close 하면 커넥션을 유지할 수 없다.
    • releaseConnection()을 사용하면 커넥션을 닫지 않는다.
    • transactionManager.commit() or rollback 할때 커넥션으로 커밋 롤백하고
    • 동기화매니저의 리소스를 정리한다.

트랜잭션 문제 해결 - 트랜잭션 템플릿

  • 트랜잭션 로직은 반복되는 패턴이 있음을 볼 수 있다.
    • 트랜잭션 시작
    • try catch
    • commit rollback 등등..
  • 달라지는 부분은 비즈니스 로직뿐이다.

이러한 문제는 템플릿 콜백 패턴으로 해결할 수 있다.

txTemplate.executeWithoutResult((status)-> {
    try {
        bizLogic(fromId, toId, money);
    } catch (SQLException e) {
        throw new IllegalStateException(e);
    }
});

  • 트랜잭션 템플릿 덕분에 트랜잭션을 시작하고 커밋 롤백코드르 제거할 수 있다.
  • 트랜잭션 템플릿의 기본 동작
    • 비즈니스 로직이 정상 수행되면 커밋한다.
    • 언체크 예외가 발생하면 롤백한다. 그 외의 경우 커밋한다.
  • 코드를 많이 제거할 수 있었지만 그럼에도 트랜잭션이라는 부가기능을 제공하는 코드가 아직 남아있다.
  • 이 문제를 어떻게 해결할 수 있을까?

트랜잭션 문제 해결 - 트랜잭션 AOP 이해

  • 프록시 도입 전
    • 서비스에 비즈니스 로직과 트랜잭션 로직이 섞여있다.

  • 프록시 도입 후
    • 트랜잭션 프록시가 트랜잭션 처리로직을 가져간다.
    • 트랜잭션을 시작한 후에 비즈니스 로직을 실행한다. 

트랜잭션 문제 해결 - 트랜잭션 AOP 적용

@Transactional
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
	bizLogic(fromId, toId, money);
}

트랜잭션 문제 해결 - 트랜잭션 AOP 정리

![[Pasted image 20220617225521.png]]

  • 선언적 트랜잭션 관리 vs 프로그래밍 방식 트랜잭션 관리
  • @Transactional vs 트랜잭션 코드 작성
    • 선언적 트랜잭션 관리가 프로그래밍 방식에 비해 매우 편하고 실용적이다.
    • 프로그래밍 방식의 트랜잭션 관리는 스프링 컨테이너나 AOP 기술이 없이 간단히 사용할 수 있지만 실무에서는 대부분 선언적 트랜잭션 관리를 사용한다.

스프링 부트의 자동 리소스 등록

  • 스프링 부트는 데이터 접근과 트랜잭션 관련하여 리소스를 자동등록한다.
  • 데이터 소스 자동등록
    • 스프링부트는 다음과 같은 application.properties(yml) 파일의 정보를 읽어서 데이터소스를 자동으로 등록한다.
      • spring.datasource.url=jdbc:h2:tcp://localhost/~/test
      • spring.datasource.username=sa
      • spring.datasource.password=
    • 디폴트는 히카리
  • 트랜잭션 매니저 자동등록
    • 스프링 부트는 적절한 트랜잭션 매니저(PlatformTransactionManager)를 자동으로 스프링 빈에 등록해준다
    • 어떤 트랜잭션 매니저를 사용할지는 등록된 라이브러리를 보고 판단하게된다.

Reference

  • 스프링 DB 1편 - 데이터 접근 핵심 원리 by 김영한