트랜잭션

도토리·2023년 5월 3일
0

스프링 DB 접근

목록 보기
3/6
post-thumbnail
  • 데이터를 파일이 아닌 DB에 저장하는 대표적 이유: DB는 트랜잭션 기능을 제공하기 때문
    DB에서 transaction(=거래)은 하나의 거래를 안전하게 처리하도록 보장해준다는 뜻
  • 트랜잭션 기능을 사용하면, 하나의 거래(ex. 계좌이체) 내의 모든 작업이 성공해야 DB에 저장하고, 중간에 하나라도 실패하면 거래 전의 상태로 돌아갈 수 있다.
    commit: 모든 작업이 성공해서 DB에 정상 반영하는 것
    rollback: 작업 중 하나라도 실패해서 거래 이전으로 되돌리는 것

DB 연결 구조, DB 세션

  • 클라이언트(DB 접근 툴, WAS 등)와 DB 서버가 커넥션 맺으면, DB 서버는 내부에 세션을 생성한다. 각 커넥션마다 세션이 연결되어 있다.
  • 세션이 트랜잭션 시작, SQL 실행, 트랜잭션 종료를 수행한다.
  • 사용자가 커넥션을 닫으면 세션 종료
  • 예를 들어, 커넥션 풀이 10개의 커넥션 생성 -> 세션도 10개 존재

트랜잭션 동작

  • commit을 호출하기 전까지는 데이터를 임시로 저장하는 것이다. 즉, 데이터를 DB에 저장하는(실제로 DB에 반영하는) 것이 아니다.
  • 해당 트랜잭션을 시작한 세션에게만 변경(insert, delete, update) 데이터가 보이고, 다른 세션에게는 변경 데이터가 보이지 않는다. (트랜잭션 격리 수준 READ COMMITTED 기준)
  • commit을 통해 데이터의 상태가 '임시' -> '완료'로 변경된다.
    (oracle에서는 각 세션마다 임시 저장소를 가지고 있고, commit을 통해 임시 저장소에 저장된 데이터가 실제 DB에 저장되었음)
  • rollback을 통해 트랜잭션 시작 직전의 상태로 복구된다.
    (oracle에서는 rollback을 통해 임시 저장소에 저장된 데이터가 원복되었음)

자동 커밋, 수동 커밋


자동 커밋

  • 각 쿼리 실행 직후, 자동으로 commit을 호출
    예를 들어, update1 -> update2인 경우, update1 -> commit -> update2 -> commit
  • 내가 직접 commit 또는 rollback 호출할 필요가 없다는 편리함이 있지만, 쿼리 하나 실행할 때마다 자동 커밋되기 때문에 트랜잭션 기능을 제대로 사용할 수 없다.
  • 보통 자동 커밋 모드가 기본이고, 트랜잭션 기능을 제대로 사용하기 위해서는 수동 커밋을 사용해야 한다.
set autocommit true; //자동 커밋 모드 설정, 이게 기본임
  • 자동 커밋 모드에서도 내부적으로 트랜잭션이 일어나는데, SQL문 1개 단위로 트랜잭션이 일어난다. 예를 들어, 트랜잭션 시작 -> update -> commit

수동 커밋

  • 트랜잭션 기능을 제대로 사용하려면 수동 커밋을 사용하자.
set autocommit false; //수동 커밋 모드 설정
  • 보통 자동 커밋 모드가 기본으로 설정된 경우가 많기 때문에, 관례상 수동 커밋 모드로 설정하는 것 == 트랜잭션을 시작한다고 표현한다.
  • 수동 커밋 모드로 설정하면, 이후에 commit 또는 rollback을 반드시 호출해야 한다. 만약 호출하지 않으면, DB에서 설정한 시간이 지나면 자동으로 롤백된다.
  • 참고) 자동 커밋 모드 또는 수동 커밋 모드는 한 번 설정하면, 해당 세션에서는 계속 유지된다.

DB lock - 수정

  • 세션이 트랜잭션 시작하고 데이터 수정하는 동안 즉, commit 또는 rollback 전까지는 다른 세션에서 해당 데이터를 수정할 수 없도록 막아야 한다.
  • row를 수정하기 위해서는 해당 row의 lock을 우선 획득해야 한다. 즉, 특정 row의 lock을 획득해야 해당 row를 수정할 수 있음
  • 특정 row의 lock을 획득하지 못하면, lock을 획득할 때까지 대기한다. 즉, row를 수정할 수 없다. 단, lock 대기를 무한정하는 것은 아니고, lock 대기 시간을 넘어가면 lock timeout 오류가 발생한다.
set lock_timeout 60000; //단위는 ms, 60s

위 코드가 필수는 아니고, DB마다 설정된 기본 lock 대기 시간이 있다.

  • 트랜잭션 종료(commit 또는 rollback)하면, lock을 반납한다.

DB lock - 조회

  • DB마다 다르지만, 보통 데이터를 조회할 때는 lock을 획득하지 않아도 바로 데이터를 조회할 수 있다. 예를 들어, 세션1이 lock을 획득하고 데이터를 변경하고 있더라도, 세션2는 데이터를 조회할 수 있다. 물론, 임시 데이터 전의 데이터 조회

  • lock을 획득해야 데이터 조회할 수 있도록: 'select for update' 구문
    어떤 세션에서 위 코드를 실행하면, 조회 시점에 lock을 가져가버리고, 따라서 다른 세션에서는 해당 row를 변경할 수 없다. (다른 세션에서 조회할 때 select for update 구문을 사용하지 않는 이상 조회는 lock을 획득하지 않아도 당연히 가능함) 물론, 트랜잭션 종료하면 lock이 반납된다.

  • 조회 시점에 lock 필요한 경우: 트랜잭션 종료 시점까지 해당 row를 다른 세션에서 변경하지 못하도록 강제로 막아야 할 때
    예를 들면, 자정에 있는 은행 점검 시간 -> 금액을 조회하고, 관련 계산을 수행하는데, 계산을 완료할 때까지 해당 금액을 다른 곳에서 변경하면 안 된다.

※ 트랜잭션, lock은 DB마다 동작하는 방식이 조금씩 다르기 때문에, 해당 DB가 의도한대로 동작하는지 테스트한 이후에 사용하도록 하자.


애플리케이션에 트랜잭션 적용

  • 트랜잭션은 비지니스 로직이 있는 service layer에서 시작한다.
  • 트랜잭션을 시작하기 위해서는 커넥션이 필요하다. 결국, service layer에서 커넥션 생성하고, 트랜잭션 종료 이후에 커넥션을 종료해야 한다.
  • 트랜잭션을 사용하는 동안, 같은 커넥션을 유지해야 한다.★

참고) 트랜잭션 시작, 트랜잭션 종료의 의미

DB에서,

  • 트랜잭션 시작 == set autocommit false;
  • 트랜잭션 종료 == commit; 또는 rollback;

애플리케이션에서,

  • 트랜잭션 시작 == con.setAutocommit(false); == DB에 set autocommit false; SQL을 전달하는 것
  • 트랜잭션 종료 == con.commit(); 또는 con.rollback(); == DB에 commit; 또는 rollback; SQL을 전달하는 것

v1

@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); //IllegalStateException o/x
        memberRepository.update(toId, toMember.getMoney() + money);
    }

    private void validation(Member toMember) {
        if (toMember.getMemberId().equals("ex")) {
            throw new IllegalStateException("이체 중 예외 발생");
        }
    }
}
  • 애플리케이션 입장
    update(): 커넥션 획득 -> UPDATE SQL 전달 -> DB 결과 응답 -> 커넥션 반납
  • DB 입장
    자동 커밋 모드가 기본이기 때문에, 트랜잭션 시작 -> UPDATE SQL 실행 -> commit;
  • 두 update()는 서로 다른 커넥션, 서로 다른 트랜잭션

v2

service의 accountTransfer()를 트랜잭션 단위로 잡을 것인데, findById(), findById(), update(), update()는 각각 커넥션 풀에서 커넥션 획득, 커넥션을 반납하고 있다. 그런데, 하나의 트랜잭션이므로 동일한 커넥션을 사용해야 한다.

Repository(변화1~3 위주로 볼 것)

public Member findById(Connection conn, String memberId) throws SQLException { //변화1. 매개변수에 Connection 추가

    String sql = "select * from member where member_id = ?";

    PreparedStatement pstmt = null;
    ResultSet rs = null;

    try {
    	//변화2. conn = dataSource.getConnection(); 코드 없음
        pstmt = conn.prepareStatement(sql);       
        ...
    } catch (SQLException e) {
        ...
    } finally {
    	//변화3. conn 반납 x
        close(null, pstmt,  rs);
    }
}

public void update(Connection conn, String memberId, int money) throws SQLException { //변화1. 매개변수에 Connection 추가

    String sql = "update member set money = ? where member_id = ?";

    PreparedStatement pstmt = null;

    try {
    	//변화2. conn = dataSource.getConnection(); 코드 없음
        pstmt = conn.prepareStatement(sql);
        ...
    } catch (SQLException e) {
        ...
    } finally {
    	//변화3. conn 반납 x
        close(null, pstmt, null);
    }
}
  • 커넥션 풀에서 커넥션 획득 x, 주어진 커넥션(파라미터로 전달된 커넥션) 사용
  • 커넥션 반납 x

Service

public void accountTransfer(String fromId, String toId, int money) throws SQLException {

    Connection con = dataSource.getConnection(); //커넥션 획득
    con.setAutoCommit(false); // 트랜잭션 시작
    try {
        bizLogic(con, fromId, toId, money); //비지니스 로직: SQLException, IllegalStateException
        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 (SQLException e) {
            log.info("error", e);
        }
    }
}
  • 트랜잭션을 시작하기 위해서는 커넥션이 필요하다.
    (DB에 set autocomit false; SQL을 전달해야 하므로)
  • con.setAutoCommit(false); == DB에 set autocommit false; SQL 전달 == DB 트랜잭션 시작
  • 비지니스 처리 로직을 bizLogic() 메소드로 따로 뽑은 이유: 트랜잭션 처리 로직, 비지니스 로직을 구분하려고
  • DB 입장에서는,

ⅰ.

set autocommit false;
  select
  select
  update
  update
commit;

ⅱ.

set autocommit false;
  select
  select
  update
rollback;
  • release()에서 con.setAutocommit(true) 코드 실행하는 이유:
    커넥션 풀을 사용하는 경우 con.close()하면 커넥션이 커넥션 풀로 돌아간다. 커넥션에 setAutocommit(false) 설정이 되어 있는 채로 커넥션 풀로 돌아가게 된다. 이후 다른 사람이 이 커넥션을 획득했을 때는 당연히 autocommit이 true라고 생각하고 사용할 것이기에, 커넥션 원상복구는 필수!

0개의 댓글