minghxx.blog
  • [Spring] 스프링 DB 1편 3) 트랜잭션 이해(2)
    2023년 11월 17일 09시 34분 26초에 업로드 된 글입니다.
    작성자: 민발자
    728x90

    스프링 DB 1편 데이터 접근 핵심 원리

    Session 3 트랜잭션 이해

    7. DB 락 - 개념이해

    1) DB 락

    세션 1이 트랜잭션을 시작하고 데이터를 수정하는 동안 커밋을 수행하지 않았을 때 세션 2에서 동시에 같은 데이터를 수정하게 되면 트랜잭션의 원자성이 깨지게 된다.

    이 문제를 해결하기 위해 세션이 트랜잭션을 시작하고 데이터를 수정하는 동안 커밋이나 롤백을 수행하기 전까지 다른 세션에서 해당 데이터를 수정할 수 없게 막기 위해 데이터베이스는 락이라는 개념을 제공

     

    2) DB 락 동작

    1. 세션 1이 memberA의 금액을 500원으로 변경하고 싶고 세션 2는 memberA의 금액을 1000원으로 변경하고자 함(세션 1이 먼저 시작)

    2. 세션 1이 트랜잭션을 시작하면서 memberA의 금액을 500원으로 변경 시도, 이때 해당 로우의 락을 먼저 획득한다.

    3. 세션 1은 락을 획득해 해당 로우에 update sql을 수행

    4. 세션 2 트랜잭션 시작하면서 memberA의 금액을 변경하려고 시도, 해당 로우의 락을 이미 세션 1이 가지고 있어 락이 돌아올때까지 대기(락 대기 시간을 넘어가면 락 타임아웃 오류 발생)

    5. 세션 1이 커밋을 수행하고 트랜잭션이 종료됨에 따라 락을 반납

    6. 대기 중이던 세션 2가 락을 획득

    7. 세션 2는 update sql을 수행 후 커밋을 수행하면서 트랜잭션이 종료되고 락을 반납한다.


    8. DB 락 - 변경

    1) 락 타임아웃

    SET LOCK_TIMEOUT 60000;

    락 획득 시간을 60초로 설정

    60초 안에 락을 얻지 못하면 예외가 발생

    Timeout trying to lock table {0}; SQL statement:
    update member set money=10000 - 2000 where member_id = 'memberA' [50200-200] HYT00/50200

    설정해 둔 시간안에 락을 얻지 못하면 해당 예외가 발생


    9. DB 락 - 조회

    1) 조회와 락

    일반적으로 데이터를 조회할 때는 락을 사용하지 않지만 데이터를 조회할 때도 락을 사용하고 싶으면 select for update 구문을 사용

    조회 시점에서 락을 획득하고 트랜잭션을 커밋하면 락을 반납

    select for update
    // 예시
    select * from member where member_id='memberA' for update;

    트랜잭션과 락은 데이터베이스마다 실제 동작하는 방식이 조금씩 다르기 때문에 해당 데이터베이스 메뉴얼을 확인해 보고 테스트 후 사용

     

    2) 조회 락을 사용하는 경우

    트랜잭션 종료 시점까지 해당 데이터를 다른 곳에서 변경하지 못하도록 강제로 막아야할 때 사용

    memberA의 금액을 조회한 다음 이 금액 정보로 계산을 수행할 때 계산이 완료될 때까지 memberA의 금액을 다른 곳에서 변경하면 안 된다.


    10. 트랜잭션 적용 1

    1) 트랜잭션 없이 계좌이체 구현

    - memberServiceV1

    romId의 회원을 조회해 toId 회원에게 money만큼의 돈을 계좌이체

    예외 상황을 테스트하기 위해 toId가 ex인 경우 예외 발생

     

    - memberServiceV1Test 정상 이체

    given : 회원 A와 회원 B에 10000원씩 저장

    when : 회원 A에서 회원 B로 2000원 이체

    then : 회원 A는 8000원 회원 B는 12000원 잔고 확인

     

    - memberServiceV1Test 예외 발생

    given : 회원 A와 회원 Ex에 10000원씩 저장

    when : 회원A에서 회원 Ex로 2000원 이체, 회원 A의 잔고 변경은 완료되었으나 toId가 ex이므로 중간에 예외 발생

    then : 회원A는 8000원 회원 Ex는 10000원


    11. 트랜잭션 적용 2

    1) 비즈니스 로직과 트랜잭션

    트랜잭션은 비즈니스 로직이 있는 서비스 계층부터 시작  비즈니스 로직으로 인해 문제가 되는 부분을 함께 롤백해야 하기 때문

    트랜잭션을 시작하기 위해선 커넥션이 필요 → 서비스 계층에서 커넥션을 만들고 트랜잭션을 시작하고 종료해야 한다.

    애플리케이션에서 DB 트랜잭션을 사용하기 위해 트랜잭션을 사용하는 동안 같은 커넥션을 유지해야 함, 그래야 같은 세션을 사용할 수 있다.

     

    2) 커넥션과 세션

    애플리케이션에서 같은 커넥션을 유지하기 위한 가장 간단한 방법은 커넥션을 파라미터로 전달 

     

    3) 트랜잭션 적용한 계좌이체 구현

    - MemberRepositoryV2

    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.info("db error", e);
            throw e;
        } finally {
            // connection은 여기서 닫지 않는다.
            JdbcUtils.closeResultSet(rs);
            JdbcUtils.closeStatement(pstmt);
        }
    }
    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();
            log.info("resultSize = {}", resultSize);
        } catch (SQLException e) {
            log.info("db error", e);
            throw e;
        } finally {
            // connection은 여기서 닫지 않는다.
            JdbcUtils.closeStatement(pstmt);
        }
    }

    커넥션을 유지하는 메서드 추가

    커넥션 유지가 필요한 두 메서드는 파라미터로 넘어온 커넥션을 사용 → getConnection() 사용하면 안 된다.

    리포지토리에서 커넥션을 닫으면 안 된다  컨넥션을 전달받은 리포지토리 이후에도 계속 이어서 사용하기 때문에 서비스 로직에서 종료하고 닫아야 함

     

    - MemeberServiceV2

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

    dataSource.getConnection() → 트랜잭션을 시작하기 위해 커넥션 생성

    con.setAutoCommit(false) → 수동 커밋모드로 설정, 트랜잭션 시작

    bizLogic() → 트랜잭션이 시작된 커넥션을 전달하면서 비즈니스 로직 수행, 트랜잭션 관리 로직과 비즈니스 로직을 구분하기 위해 메서드로 만듦

    con.commit() → 비즈니스 로직이 정상 수행되면 트랜잭션 커밋

    con.rollback() → 비즈니스 로직 수행 도중 예외가 발생하면 트랜잭션 롤백

    release() → 커넥션을 모두 사용하면 나면 안전하게 종료, 커넥션 풀을 사용하면 풀에 반납이 되기 때문에 자동 커밋모드로 설정 후 반환

     

    - memberServiceV2Test 예외 발생

    given : 회원 A와 회원 Ex에 10000원씩 저장

    when : 회원A에서 회원 Ex로 2000원 이체, 회원 A의 잔고 변경은 완료되었으나 toId가 ex이므로 중간에 예외 발생되고 트랜잭션 롤백

    then : 회원A는 10000원 회원 Ex는 10000원

    트랜잭션 덕분에 계좌이체가 실패할 때 롤백을 수행하면서 모든 데이터를 정상적으로 초기화, 계좌이체 수행 전으로 돌아가게 된다.

     

     

    728x90
    댓글