Spring DB : 트랜잭션 적용

    이 글은 인프런 김영한님의 강의를 복습하며 작성한 글입니다.

     

    비즈니스 로직 with 트랜잭션 구현

    앞선 게시글에서는 "set autocommit false"를 이용해서 트랜잭션을 구현했다. 이번 포스팅에서는 실제 어플리케이션에서 어떻게 트랜잭션을 사용해 원자성이 중요한 비즈니스 로직을 구현하는지 확인해본다. 먼저 트랜잭션 없이 단순하게 계좌이체 비즈니스 로직만 구현해보자. 

     

    MemberSerivce 코드 구현(비즈니스 로직) 

    @RequiredArgsConstructor
    public class MemberService1 {
    
        private final MemberRepositoryV1 memberRepository;
    
    
        public void accountTransfer(String fromId, String toId, int money) {
            Member fromMember = memberRepository.findById(fromId);
            Member toMember = memberRepository.findById(toId);
    
            //from의 돈을 깎고 내 돈을 올린다.
            memberRepository.update(fromId, fromMember.getMoney() - money);
    
            // 중간에 오류 케이스를 만들어준다 (트랜잭션 실패하는 로직)
            validate(toMember);
    
            memberRepository.update(toId, toMember.getMoney() + money);
        }
    
        private void validate(Member toMember) {
            if (toMember.getId().equals("ex")) {
                throw new IllegalStateException("이체 중 예외 발생 ");
            }
        }
    
    
    }
    • from id의 회원을 조회해서 toId의 회원에게 money만큼의 돈을 계좌이체 하는 로직이다. 
      • from id 회원의 돈을 money만큼 감소한다 → update sql 실행
      • to id 회원의 돈을 money만큼 증가한다 → update sql 실행
    • 예외 상황을 테스트해보기 위해 to Id가 "ex"인 경우 예외를 발생한다. 이것은 트랜잭션의 원자성을 확인하는데 사용될 것이다. 

     

    정상이체 테스트

    @Test
    @DisplayName("정상 이체")
    void accountTransfer() {
        //given
    
        Member memberA = new Member(MEMBER_A, 10000);
        Member memberB = new Member(MEMBER_B,  10000);
    
        memberRepository.save(memberA);
        memberRepository.save(memberB);
    
        //when
        memberService.accountTransfer(memberA.getMemberId(), memberB.getMemberId(), 2000);
    
        //then
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberB = memberRepository.findById(memberB.getMemberId());
        assertThat(findMemberA.getMoney()).isEqualTo(8000);
        assertThat(findMemberB.getMoney()).isEqualTo(12000);
    }
    • 정상 이체 테스트 코드다.
    • MemberA, MemberB로 되어있기 때문에 이체 과정에서 의도적인 예외가 발생하지 않는다. (이체 예외는 MemberEX에서만 발생함) 
    • 따라서 MemberA → MemberB로 2,000원이 정상 이체된다. 이것을 DB에서도 확인 가능하다.

     

    이체 실패 테스트 (트랜잭션 문제 확인)

     

     

    @Test
    @DisplayName("이체 중 예외 발생")
    void accountTransferEx() {
        //given
    
        Member memberA = new Member(MEMBER_A, 10000);
        Member memberEx = new Member(MEMBER_EX,  10000);
    
        memberRepository.save(memberA);
        memberRepository.save(memberEx);
    
        //when : 예외 발생을 검증함. 
        assertThatThrownBy(() -> memberService.accountTransfer(memberA.getMemberId(),
                memberEx.getMemberId(), 2000)).isInstanceOf(IllegalStateException.class);
        
    
        //then : 트랜잭션 없다. 그냥 한 건씩 처리한다. 즉, 자동 커밋 상태라고 보면 된다.
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberB = memberRepository.findById(memberEx.getMemberId());
        assertThat(findMemberA.getMoney()).isEqualTo(8000);
        assertThat(findMemberB.getMoney()).isEqualTo(10000);
    }
    • 이체 중 예외가 발생하면 memberA의 금액은 감소, memberB의 금액은 증가한다. 계좌이체라는 '하나의 작업 단위'로 처리된 것이 아니라, 하나의 작업은 성공, 하나의 작업은 실패하게 된다.
    • 위 문제를 하나씩 뜯어서 살펴보자.
      1. MemberEx는 이체 시 반드시 예외가 발생하도록 코드가 작성되어있다.
      2. memberA에서 memberEx로 계좌 이체를 하는 비즈니스 로직을 실행했다. 이 때, memberA에서 돈이 빠지고,  memberB의 돈이 올라가는 작업이다. 돈을 빼고 넘겨주는 두 개의 작업이 하나의 작업처럼 진행되도록 메서드로 뺐다.
      3. memberA에서 돈을 차감했다. 이 때는 2,000원이 정상적으로 DB에서 빠지는 것을 알 수 있다. 왜냐하면 현재 트랜잭션을 이용하는 것이 아니라 자동 커밋 모드를 사용했기 때문에 DB에 바로바로 반영이 되기 때문이다.
      4. memberEx에 돈을 올리려는 순간, Exception이 발생한다. 그리고 이 Exception은 AssertThatThrownBy에서 확인된다.
      5. 결론은 memberA의 돈만 감소하게 된다.

    위 작업의 가장 큰 문제점은 하나의 트랜잭션 내에서 작업이 이루어지지 않았다는 점이다. 두 개의 작업을 하나처럼 하기 위해 메서드로 뽑아냈는데, 실제 DB에서 동작하는 것은 자동 커밋 때문에 두 개의 작업이 각각 커밋처리 된다. 따라서 하나의 작업처럼 처리가 될 수 없게 된 것이다. 

    위의 사례에서 볼 수 있듯이, 트랜잭션이 없다면 여러 가지 작업을 하나의 작업처럼 처리하려고 했을 때 원자성이 깨져 여러 문제점이 발생할 수 있다는 것을 이해할 수 있다. 

     

    트랜잭션은 어느 계층에 걸어야할까? 

    어플리케이션을 개발할 때 트랜잭션을 어디에서 시작 / 커밋 / 롤백 처리를 해야할까? 

    • 트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작을 해야한다. 계좌이체라는 로직이 서비스에 다 들어있기 때문이다. 예를 들어 비즈니스 로직이 잘못되면 해당 비즈니스 로직으로 인해 문제가 되는 부분을 모두 롤백을 해야한다. 즉, 비즈니스 로직이 '원자적'으로 처리가 되어야 한다. 
    • 트랜잭션을 시작하려면 커넥션이 필요하다. 트랜잭션을 시작하는 것은 SQL로 생각해보면 수동 커밋을 모드를 설정하는 것이다. 정리하면 서비스 계층에서 커넥션을 만들고, 서비스 계층에서 트랜잭션 커밋 이후에 커넥션을 종료해야한다.
    • 어플리케이션에서 DB 트랜잭션을 사용하려면 "트랜잭션을 사용하는 동안 같은 커넥션을 유지"해야한다. 그래야 같은 세션을 사용할 수 있다.
      • 예를 들어 다른 커넥션을 사용한다고 해보자. 다른 커넥션을 사용한다는 것은 다른 세션을 사용한다는 것이다. 세션은 각각 커밋되지 않은 변경점을 임시적으로 자기 자신에게만 보이게 한다. 따라서 다른 커넥션을 사용하는 경우, 각 세션에 변경점이 각각 생긴다는 것을 의미한다. 즉, 원자적으로 처리가 되는 것이 아니라 각각 처리가 되게 된다. 따라서 같은 커넥션을 유지해야한다. 

     

    커넥션과 세션

    앞서 이야기한 것처럼 트랜잭션 내에서는 같은 커넥션을 유지해야한다. 커넥션이 바뀌면 DB 세션이 바뀌는 것이기 때문에 한 트랜잭션 내에서 진행되지 않는 개념이기 때문이다. 어플리케이션에서 같은 커넥션을 유지하려면 어떻게 해야할까? 가장 단순한 방법은 커넥션을 파라미터로 전달해서, 같은 커넥션이 사용되도록 유지하는 것이다. 

     

     

    커넥션과 세션 실습

    기존의 MemberRepositoryV1을 MemberRepositoryV2로 만든다. 이 때, 각 메서드의 파라메터에 Connection을 넘겨줄 수 있도록 한다. 

    1. 커넥션 유지가 필요한 두 메서드는 서로 커넥션을 공유해서 사용해야한다. 따라서 두 메서드 내에는 con = getConnection()이라는 코드가 존재하면 안된다. 이 코드는 DataSource를 이용해 새로 Connection을 얻어주는 코드기 때문이다. 
    2. 커넥션 유지가 필요한 두 메서드는 리포지토리에서 커넥션을 닫으면 안된다. 커넥션을 전달 받은 리포지토리뿐만 아니라, 커넥션을 전달해준 서비스 계층에서도 커넥션을 계속 이어서 사용하기 때문이다. 그래야 트랜잭션이 유지된다. 커넥션은 서비스 로직이 끝날 때, 트랜잭션을 종료하고 닫게 된다. 

     

    MemberRepositoryV1의 FindbyId 코드 

    public Member findById(String memberId) throws SQLException {
        String sql = "select * from member where member_id = ?";
    
        Connection conn = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null;
    
        try {
            conn = dataSource.getConnection();
            pstmt = conn.prepareStatement(sql);
            pstmt.setString(1, memberId);
    
            rs = pstmt.executeQuery();
            log.info("Connection = {}, class = {}", conn,conn.getClass());
    
            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("error", e);
            throw e;
        }finally {
            close(conn, pstmt, rs);
        }
    }
    • 이 Repository 코드는 이전에 사용하던 코드다. 이 때는 트랜잭션이 아닌 DataSource를 이용해 어떤 DB를 사용하건 상관없이 JDBC 인터페이스를 이용해 커넥션을 얻도록 리팩토링 된 코드다.
    • 이 코드는 MemberService에서 Connection을 넘겨주는 것이 아니다. 따라서 비즈니스 로직을 진행하는 Service 계층 관점에서 바라본다면, 자동 커밋 모드가 활성화 된 상황으로 이해할 수 있다. 

     

    MemberRepositoryV2의 findById 코드

    public Member findById(Connection connection, String memberId) throws SQLException {
        String sql = "select * from member where member_id = ?";
    
    
        PreparedStatement pstmt = null;
        ResultSet rs = null;
    
        try {
            Connection conn = connection;
            pstmt = conn.prepareStatement(sql);
            pstmt.setString(1, memberId);
    
            rs = pstmt.executeQuery();
            log.info("Connection = {}, class = {}", conn,conn.getClass());
    
            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("error", e);
            throw e;
        }finally {
            JdbcUtils.closeResultSet(rs);
            JdbcUtils.closeStatement(pstmt);
        }
    }
    • findById에 Connection 객체를 넘겨주는 코드를 작성했다. 
      • getConnection()은 사라지게 되었고, 파라메터로 전달받은 Connection으로 DB와 통신을 한다
      • JdbcUtils.closeConnection() 메서드를 사용하지 않는다. 왜냐하면 커넥션은 비즈니스 로직인 서비스 계층에서 닫혀야 하기 때문이다. 
    • update 코드도 동일하게 Connection을 파라메터로 넘겨주었다. 

     

     

    MemberSerivceV1 (No Transaction)

    @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);
    
            // 오류케이스
            validate(toMember);
    
            memberRepository.update(toId, toMember.getMoney() + money);
        }
    }
    • MemberServiceV1의 코드는 트랜잭션 개념이 적용되지 않은 코드였다.
    • 따라서 계좌이체 도중 Exception이 발생하면 정상적으로 코드가 수행되지 않는다. 

     

    MemberSerivceV2 (Transaction 적용)

    // 계좌이체 로직
    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
    
        Connection conn = dataSource.getConnection();
    
        try {
            // 트랜잭션 시작
            conn.setAutoCommit(false);
    
            // 핵심 로직
            bizLogic(fromId, toId, money, conn);
    
            conn.commit();
    
        } catch (Exception e) {
            System.out.println("here");
            conn.rollback();
            throw new IllegalStateException(e);
        } finally{
    
            if (conn != null) {
                try {
                    conn.setAutoCommit(true);
                    conn.close();
                } catch (Exception e) {
                    log.info("error mesage = {}", e.getMessage(),e);
                }
            }
        }
    }
    
    private void bizLogic(String fromId, String toId, int money, Connection conn) throws SQLException {
        Member fromMember = memberRepository.findById(conn, fromId);
        Member toMember = memberRepository.findById(conn, toId);
    
        memberRepository.update(conn, fromId, fromMember.getMoney() - money);
        validate(toMember);
        memberRepository.update(conn, toId, toMember.getMoney() + money);
    }
    • 서비스 계층에서 트랜잭션을 연동하기 위해 Connection이 필요하다. 이 때, Connection은 커넥션 풀을 이용해 얻게 된다.
    • 서비스 계층에서 비즈니스 로직이 끝나야 트랜잭션이 Commit 된다. 따라서 Connection은 Repository 계층이 아닌 Service 계층에서 닫도록 한다. 
    • 트랜잭션을 시작하는 방법
      • 수동 커밋 모드를 설정하는 것을 트랜잭션을 시작한다고 표현한다 → Conn.setAutocommit(false)
    • 트랜잭션용 관심사 / 비즈니스용 관심사를 분리했다. 관심사가 다르기 때문에 분리했고 추후 템플릿 메서드 패턴 / 프록시 개념을 이용해 관심사의 분리를 통해 코드 개선을 할 수 있다. 
    • bizLogic()에서는 Connection을 넘겨준다.
      • findById() / update() 메서드에서 Connection이 넘어가는 것을 볼 수 있다.
      • 메서드 간 Connection이 연결된다. 따라서, 트랜잭션이 유지되는 것으로 이해할 수 있다. 
    • Finally를 사용해 Connection을 모두 사용하고 나면 안전하게 종료한다. con.close()를 호출하면 커넥션이 종료된 것이 아니라 풀에 반납된다. 커넥션은 파괴되지 않고 풀에서 재사용 되기 때문에 초기 설정을 복구해주는 것이 필요하다. 따라서 setAutoCommit(True)를 설정한다. 

     

    MemberServiceV2 + MemberRepositoryV2를 이용한 테스트 코드 

    @Test
    @DisplayName("이체 중 예외 발생")
    void accountTransferEx() throws SQLException {
        //given
        Member memberA = new Member(MEMBER_A, 10000);
        Member memberEX = new Member(MEMBER_EX, 10000);
    
        memberRepository.save(memberA);
        memberRepository.save(memberEX);
    
        //when
        assertThatThrownBy(() -> memberService.accountTransfer(memberA.getMemberId(), memberEX.getMemberId(), 2000))
                .isInstanceOf(IllegalStateException.class);
    
        //then
        DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        Connection connection = dataSource.getConnection();
    
        Member findMemberA = memberRepository.findById(connection,MEMBER_A);
        Member findMemberEX = memberRepository.findById(connection, MEMBER_EX);
        assertThat(findMemberA.getMoney()).isEqualTo(10000);
        assertThat(findMemberEX.getMoney()).isEqualTo(10000);
    }
    • accounTransfer() 메서드에서 원자적으로 계좌이체가 실행된다.
    • 이 때, 동일 Connection으로 동일 트랜잭션에서 처리가 되기 때문에 같이 성공하거나 같이 실패하게 된다.
    • MEMBER_EX라는 이름을 가진 Member는 계좌이체 과정에서 실패가 하기 때문에 트랜잭션이 롤백된다.

    위의 상황을 정리하면 다음과 같다.

    1. 계좌 이체 로직을 실행한다.
    2. 커넥션을 생성 + 트랜잭션 시작한다. MemberA → MemberEx로 2000원 계좌 이체를 시도한다.
    3. MemberA의 금액이 2,000원 감소한다. 
    4. MemberEx에서 예외가 발생한다.
    5. 예외가 발생했기 때문에 Catch문에서 예외를 처리해주고, 트랜잭션은 RollBack 된다. 
    6. RollBack 되었기 때문에 MemberA가 가지는 값은 10,000으로 돌아가게 된다. 

     

    남은 문제

    1. 어플리케이션에서 DB 트랜잭션을 적용하기 위해서는 Try / Catch / Finally 구문이 들어가면서 각각의 커넥션을 셋팅하고 회수해주는 절차를 해야한다. 그런데 이 트랜잭션 적용을 위한 코드가 매우 복잡하고, 커넥션도 각 메서드에 직접 파라메터로 넘겨줘야한다. 핵심 기능은 짧은데, 부가 기능이 긴 상황이다.
    2. 커넥션을 유지 / 유지하지 않는 메서드를 구별하는 것도 어려워진다. 트랜잭션 내에서 처리하고 싶은 메서드도 있다. 그렇지만 트랜잭션 내에서 처리하고 싶지 않은 메서드도 존재한다. 예를 들어 회원 저장을 단건으로 처리할 때, 굳이 트랜잭션을 유지할 필요가 없다. 따라서 이런 경우에는 커넥션을 넘기지 않는 메서드를 사용해야 한다. 이런 경우에 코드를 다시 만들어야 하는데, 시간이 필요해진다. 

    정리를 해보면 어플리케이션 로직을 위한 코드에 지저분한 코드(부가 관심사)가 많이 들어간다는 것이 있고, 트랜잭션 관리를 위한 코드가 너무 복잡해진다는 것을 알 수 있다. 스프링이 어떻게 트랜잭션을 처리하는지를 확인하고자 한다. 

     

    정리

    1. 트랜잭션을 사용하면 릴리즈 하는 작업까지 완료를 해줘야한다. 그렇지 않을 경우 락이 잡혀있게 된다. 
    2. 트랜잭션을 사용하기 위해서는 트랜잭션 범위 내에서 동일한 DB Connection을 이용해야한다. 
    3. 서비스 계층에서 트랜잭션이 시작되고 끝난다. 따라서 Repository에서는 사용한 PreparedStatement, ResultSet을 종료하는 역할만한다. Connection은 서비스 계층에서 회수한다.

     

    댓글

    Designed by JB FACTORY