스프링 DB접근 1편 - 트랜잭션

이성준·2022년 9월 28일
0

Spring

목록 보기
10/11

김영한님 스프링 DB접근 1편 - 트랜잭션 부분을 보고 정리한 내용입니다.

트랜잭션 - 적용1

트랜잭션 미적용

@RequiredArgsConstructor
public class MemberServiceV1 {

    private final MemberRepositoryV1 memberRepository;

    public void accountTransfer(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("이체중 예외 발생");
        }
    }
}

fromId의 회원을 조회해서 toId의 회원에게 money만큼의 돈을 계좌 이체하는 로직인데, 현재는 트랜잭션이 적용되어있지않아서, 이체중 예외가 발생할 경우 fromMember는 돈이 줄고, toMember는 돈이 안주는 참사가 생긴다.

트랜잭션 - 적용2

  • 트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작해야한다 -> 해당 로직이 잘못되면 그 로직때문에 문제 되는 부분을 모두 롤백해야하기 때문에
  • db 트랜잭션에서 같은 커넥션을 유지해야 같은 세션을 유지 할 수 있다.

트랜잭션을 적용하고 커넥션을 파라미터로 넘겨서 동기화함

@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 release(Connection con) {
        if(con !=null){
            try{
                con.setAutoCommit(true); //커넥션 풀 고려
                con.close();
            }
            catch(Exception e){
                log.info("error", e);
            }
        }
    }

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

같은 커넥션을 유지하는 가장 단순한 방법은, 바로 파라미터에 커넥션을 넣어서 같은 커넥션이 유지될 수 있도록 하는것이다.

커넥션을 파라미터로 넘긴 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 memberId " + memberId);
            }
        } catch (SQLException e) {
            log.error("db error", e );
            throw e;
        }
        finally {
            JdbcUtils.closeResultSet(rs);
            JdbcUtils.closeStatement(pstmt);
        }
    }

repository 코드중 일부분인데, 여기보면은 con을 계속 넘겨가지고 같은 커넥션을 유지하고 있다.

  • 커넥션 유지를 위해 파라미터로 넘어온 커넥션을 사용
  • 서비스 로직에서 커넥션을 계속 이어서 사용하기때문에 커넥션을 닫는 코드가 없다.

트랜잭션이 적용된 MemberService

@Slf4j
@RequiredArgsConstructor
public class MemberServiceV2 {
    private final DataSource dataSource;
    private final MemberRepositoryV2 memberRepository;

    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
      //1.
      Connection con = dataSource.getConnection();

        try{
        //2.
            con.setAutoCommit(false); // 트랜잭션 시작
            //3. 시작
            bizLogic(con, fromId, toId, money);
            con.commit(); //4. 성공시 커밋
        }
        catch(Exception e){
        //5. 롤백
            con.rollback();
            throw new IllegalStateException(e);
        }
        finally{
        //6.
            release(con);
        }

    }

MemberRepositoryV2에 맞게 서비스 코드를 고쳐주었고,
전체 흐름은 다음과 같다.
1. 트랜잭션을 시작하려면 커넥션이 필요하니 데이터 소스에서 가져와준다.
2. 자동 커밋 모드를 끈다.
3. 비즈니스 로직을 실행하고
4. 성공시 커밋하고
5. 실패시 롤백한다
6. 커넥션을 모두 사용했으면 풀에 반납해준다.

문제점 : 트랜잭션을 적용하기는 했지만 아직 서비스 계층이 너무 지저분하고, 커넥션을 파라미터로 넘기는 부분도 아쉽다.

스프링과 문제해결

보통 우리가 웹 어플리케이션을 만들때 이렇게 프레젠테이션 계층과 서비스 계층, 데이터 접근 계층으로 나누어서 만든다.
계층 마다 역할을 확실히 해서 프레젠테이션 계층은 웹과 관련된 책임, 서비스 계층은 비즈니스 로직에 관한 책임, DB 접근 계층은 DB와 관련된 책임만 부여해서 설계 하는게 좋다.
1. 근데 지금은 트랜잭션을 사용하기 위해서 jdbc 기술에 의존하고 있고, jdbc 기술과 비즈니스 로직이 섞여있다.
2. 같은 커넥션을 만들기위해 계속 커넥션을 파라미터로 넘겨주고 있다.
3. 계속 반복되는 코드
3. JDBC 구현 기술 예외가 서비스 계층으로 전파되고있다.
4. try catch 같은 코드가 반복되고있다.

1번 문제 해결

현재 우리 서비스 계층이 JDBC의 트랜잭션에 의존하고 있다, 근데 JPA로 바뀌면? JDBC 코드를 전부 바꿔야한다. 그래서 스프링에서는 트랜잭션 자체를 추상화해서 제공한다.

그래서 클라이언트는 이 스프링 트랜잭션 추상화에 의존하고 구현체를 DI를 통해 주입하면 돼서, OCP 원칙을 지킬수있게 되었다.

스프링이 추상화한 트랜잭션

2번 문제 해결

트랜잭션을 유지하기위해 시작부터 끝까지 같은 데이터베이스 커넥션을 유지해야하는데 이것을 해결하기위해 스프링에서는 트랜잭션 동기화 매니저를 제공한다.

트랜잭션 동기화 매니져는 해당 쓰레드만 접근할 수 있는 특별한 저장소인 쓰레드로컬을 사용해서 커넥션을 보관한다. 그러면 파라미터를 넘기는 대신 저 동기화 매니저에서 커넥션을 계속 가져오는 방식으로 동기화를 할 수 있다.

트랜잭션 동기화 매니저 적용


	  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);
        }

    }
    
      private Connection getConnection() throws SQLException {
       //1. 
        Connection con = DataSourceUtils.getConnection(dataSource);
        log.info("get connection = {}, class={}", con, con.getClass());
        return con;
    }

 	  private void close(Connection con, Statement stmt, ResultSet rs) {

        JdbcUtils.closeResultSet(rs);
        JdbcUtils.closeStatement(stmt);
        //2. 트랜잭션 동기화를 사용하려면 dataSourceUtils를 사용해야 한다.
        DataSourceUtils.releaseConnection(con,dataSource);


    }
  1. DataSourceUtils.getConnection()을 이용해서 트랜잭션 동기화 매니져가 관리하는 커넥션을 가져와준다.
  2. DataSourceUtils.releaseConnection()을 이용해서 동기화된 커넥션은 닫지 않고 그대로 유지한다.

1번,2번 문제를 해결한 코드

@Slf4j
@RequiredArgsConstructor
public class MemberServiceV3_1 {
//    private final DataSource dataSource;
    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);
        }

트랜잭션 매니저로 트랜잭션을 시작하고 성공하면 커밋, 아니면 롤백을 시켜서 꽤 간단해졌지만, 저 bizLogic 부분 빼고는 계속 반복 된다. 그래서 이걸 이런 반복을 해결하기위해 템플릿 콜백 패턴을 활용하면 좋다.

3번 문제 해결 v1


스프링은 TransactionTemplate이라는 클래스를 제공함으로써, 반복문제를 해결할수 있는 방법1을 제공한다. execute는 응답 값이 있을때 사용하면 되고, executeWithoutResult는 응답값이 없을때 사용하면 된다.

TransactionTemplate 적용

@Slf4j
public class MemberServiceV3_2 {
//    private final DataSource dataSource;
//    private final PlatformTransactionManager transactionManager;
    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);
          }
      });

    }

해결된거같깉한데, 조금 찝찝하다. 왜냐하면 아직도 서비스 계층에 트랜잭션을 처리하는 기술로직이 함께 포함되어있기 때문이다.

3번문제해결 v2

생각해보면 비즈니스로직은 핵심기능이고 트랜잭션은 부가기능이다. aop를 활용하면 될거 같다, 그리고 스프링은 @Transactional로 트랜잭션 aop를 완벽하게 지원한다.

그리고 전체 흐름은 다음과 같다.

  1. 프록시를 호출한다.
  2. 프록시에서 스프링 컨테이너를 통해 트랜잭션 매니저를 획득하고
  3. 트랜잭션 매니저는 getTransaction()으로 트랜잭션을 시작한다.
  4. 그리고 프록시는 실제서비스를 호출해 비즈니스 로직을 실행하고.
  5. 트랜잭션 매니저는 트랜잭션을 종료시킨다.

@Transactional 적용

@Slf4j
public class MemberServiceV3_3 {
//    private final DataSource dataSource;
//    private final PlatformTransactionManager transactionManager;

    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);
    }

트랜잭션을 적용하고 싶은곳에 @Transactional만 붙여주면 된다

0개의 댓글