Spring에서의 고전적인 트랜잭션1

hoyong.eom·2023년 8월 12일
0

스프링

목록 보기
29/59
post-thumbnail

Spring

트랜잭션

데이터를 저장하는데 있어서 파일을 사용하지 않고 데이터베이스를 사용하는 이유는 여러 이유가 있겠지만, 그중 한가지는 데이터베이스의 트랜잭션 기능 때문이라고 한다.(트랜잭션 기능을 통해서 데이터 관련 작업이 안전하게 수행되기 때문이다.)

참고)
트랜잭션에서 모든 작업이 성공해서 데이터베이스에 정상 반영하는 것을 커밋(Commit)이라고 하며, 작업 중 하나라도 실패해서 거래 이전으로 되돌리는것을 롤백(rollback)이라고 한다.

데이터베이스 연결 구조와 DB 세션

  • 사용자는 웹 애플리케이션서버(WAS)나 DB 접근 툴 같은 클라이언트를 사용해서 데이터베이스 서버에 접근할 수 있다. 클라이언트는 데이터베이스 서버에 연결을 요청하고 커넥션을 맺게 된다. 이때 데이터베이스 서버는 내부에 세션이라는 것을 만든다. 그리고 앞으로는 해당 커넥션을 통한 모든 요청은 이 세션을 통해서 실행하게 된다.
  • 개발자가 클라이언트를 통해서 SQL을 전달하면 현재 커넥션에 연결된 세션이 SQL을 실행한다.
  • 세션은 트랜잭션을 시작하고, 커밋 또는 롤백을 통해 트랜잭션을 종료한다. 그리고 이후에 새로운 트랜잭션을 다시 시작할 수 있다.
  • 사용자가 커넥션을 닫거나 세션을 강제 종료하면 세션은 종료된다.

트랜잭션 - 개념 이해

데이터 변경 쿼리를 실행하고 데이터베이스에 그 결과를 반영하려면 커밋 명령어인 commit을 호출하고 결과를 반영하고 싶지 않으면 롤백 명령어인 rollback 을 호출하면 된다.

커밋을 호출하기 전까지는 임시로 데이터를 저장한다. 따라서 해당 트랜잭션을 시작한 세션(사용자)에게만 변경 데이터가 보이고 다른 세션(사용자)에게는 변경 데이터가 조회되지 않는다.

트랜잭션 자동 커밋, 수동 커밋

자동 커밋으로 설정하면 각각의 쿼리 실행 직후에 자동으로 커밋을 호출한다.
따라서 커밋이나 롤백을 직접 호출하지 않아도 되는 편리함이 있다. 하지만 쿼리를 하나하나 실행 할때 마다 자동으로 커밋되어 버리기 떄문에 우리가 원하는 트랜잭션 기능을 제대로 사용할 수 없다.

자동 커밋 설정
set autocommit true; // 자동 커밋 모드 설정

따라서, commit과 rollback을 직접 호출 하면서 트랜잭션 기능을 제대로 수행하려면 자동 커밋을 끄고 수동 커밋을 사용해야 한다.

수동 커밋 설정
set autocmmit false; // 수동 커밋 모드 설정

보통은 자동 커밋 모드가 기본으로 설정된 경우가 많기 때문에 수동 커밋 모드로 설정하는 것을 트랜잭션 시작한다고 표현할 수 있다.

수동 커밋 설정을 하면 이후에 꼭 commit과 rollback을 호출해야한다.
참고로 수동 커밋 모드나 자동 커밋 모드는 한번 설정하면 해당 세션에서는 계속 유지된다. 중간에 변경하는것은 가능하다.

DB 락

만약 세션1에서 트랜잭션을 시작하고 데이터를 수정하는 동안 아직 커밋을 수행하지 않았는데, 세션2에서 동시에 같은 데이터를 수정하게 되면 여러 가지 문제가 발생한다. 바로 트랜잭션의 원자성이 꺠지는것이다. 여기에 더해서 세션1이 중간에 롤백을 하게 되면 세션2는 잘못된 데이터를 수정하는 문제가 발생한다.

이런 문제를 방지하려면, 세션이 트랜잭션을 시작하고 수정하는 동안에는 커밋이나 롤백 전까지 다른 세션에서 해당 데이터를 수정할 수 없게 막아야 한다.

DB 락은 트랜잭션이 커밋하거나 롤백하면 락을 반환하게 되어 다른 트랜잭션이 해당 테이블의 ROW 데이터에 대해서 작업을 수행할 수 있게 된다.

DB 락 조회

일반적으로 조회시(select)에는 락을 사용하지 않는다.
데이터베이스마다 다르지만, 보통 데이터를 조회할 때는 락을 획득하지 않고 데이터를 바로 조회할 수 있따.

다만, 데이터를 조회할 때도 락을 획득하고 싶을 떄가 있다. 이럴댸는 select for update 구문을 사용하면 된다.
이렇게 하면 세션1이 조회시점에 락을 가져가 버리기 때문에 다른 세션에서 해당 데이터를 변경할 수 없다.
이 경우에도 트랜잭션을 커밋하고 락을 반납한다.

예시
set autocommit false;
select * from member where member_id='memberA' for update;

트랜잭션 적용

애플리케이션에서 트랜잭션을 어떤 계층에 적용시켜야할까?
서비스? 리포지토리? 컨트롤러?

  • 트랜잭션을 비지니스 로직이 있는 서비스 계층에서 시작해야한다. 왜냐하면 비지니스 로직이 잘못되면 해당 비지니스 로직으로 인해 문제가 되는 부분을 함께 롤백해야 하기 때문이다.(비지니스 로직은 서비스 계층에 있다.)
  • 트랜잭션을 시작하기 위해서는 커넥션이 필요하다(커넥션을 이용해서 전달된 명령이 세션에서 수행되기 때문에) 결국 서비스 계층에서 커넥션을 만들고, 트랜잭션 커밋 이후에 커넥션을 종료해야한다.(리포지토리 계층에서 해야할게 서비스 계층으로 넘어오는 문제가 있다.)
  • 애플리케이션에서 DB 트랜잭션을 사용하려면 트랜잭션을 사용하는 동안 같은 커넥션을 유지해야한다. 그래야 같은 세션을 사용할 수 있다. 같은 커넥션을 유지하기 위해서는 커넥션을 파라미터로 넘기는 방법을 사용할 수 있다.

트랜잭셔 적용 코드

    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 {
            //connection은 여기서 닫지 않는다.
            JdbcUtils.closeResultSet(rs);
            JdbcUtils.closeStatement(pstmt);
        }

    }

위 코드는 트랜잭션을 적용한 repository 코드이다.

  • update(Connection con, String memberId, int money); 함수에서 동일한 커넥션을 사용하기 위해서 파라미터로 전달한다.
  • 커넥션 유지가 필요하기 때문에 리포지토리 계층에서 커넥션을 닫으면 안된다. 케넥션을 전달 받은 리포지토리 뿐만 아니라 이후에도 동일한 커넥션을 계속 사용해야하기 떄문이다. 이후 서비스 로직이 완전히 종료된 이후에 트랜잭션을 종료하고나서 커넥션을 닫아야한다.

아래의 코드는 서비스 계층이 코드이다.

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

위 코드에 대해서 약간의 분석을 해보면 아래와 같다

  • 트랜잭션을 시작하는 부분은 서비스 계층이므로 커넥션을 서비스 계층에서 가져온다. Connection con = dataSource.getConnect()

  • 트랜잭션을 시작하려면 자동 커밋 모드를 꺼야한다. con.setAutoCommit(false);

  • 트랜잭션이 시작된 커넥션을 전달하면서 비지니스 로직을 수행한다.
    위 코드에서 함수로 분리되어 있는데 트랜잭션을 관리하는 로직과 실제 비니스로직을 구분하기 위함이라고 한다. 그리고 리포지토리 계층으로 con 객체를 넘겨서 사용한다. bizLogic(con, fromId, toId, money0

  • 비지니스 로직이 정상 수행되면 트랜잭션을 커밋한다.
    con.commit();

  • 비지니스 로직 수행 도중에 예외가 발생하면 트랜잭션을 롤백한다.
    con.rollback()

  • 커넥션을 모두 사용하고 나면 안전하게 종료한다. 그런데 커넥션 풀을 사용하면 con.close()를 호출 했을때 커넥션이 종료되는것이 아니라 풀에 반납된다. 현재 수동 커밋 모드로 동작하기 때문에 풀에 돌려주기 전에 기본값인 자동 커밋 모드로 변경한다.
    finally{...}

문제점

고전적인 트랜잭션 사용 코드에는 여러 문제가 존재한다.
1) 서비스 계층에 트랜잭션 코드를 적용하기 때문에 서비스 계층이 비지니스 로직과 트랜잭션 로직으로 오염된다.
2) 커넥션을 유지하기 위한 코드가 매우 복잡하다.

참고

해당포스팅은 아래의 강의를 공부하여 정리한 내용입니다.
김영한님의 SpringDB1-트랜잭션

0개의 댓글