Transaction

DB를 사용하는 가장 큰 목적 중의 하나는 바로 Concurrency Control이다. 트랜잭션을 이용해서 하나의 사용자에 대한 비즈니스 로직 처리를 한 작업 단위로 실행하는 것이 가능하다. 중간에 문제/오류가 생기는 경우, 트랜잭션 내의 전체 작업에 대해 수행을 취소함으로써, 하나의 작업 단위로 실행되도록 해준다.

가령, 계좌 이체를 예로 들면, A,B 각각의 통장에 5000원씩 있다고 가정할때, 만약 A가 B에게 1000원을 이체하면 A의 통장에는 4000원, B의 통장에는 6000원이 있어야한다. 하지만 중간에 오류가 생겨, A 통장에서는 빠져 나갔는데, B의 통장에 넣으려고 할때 에러가 발생하게 되었다하면 계좌 이체를 하기 이전 상태으로 돌려놓아야 한다. 하지만 그렇지 않게 되면 A의 통장에는 4000원, B의 통장에는 5000원이 남는 문제갑 발생할 수 있다. 따라서, 이렇게 하나의 작업 단위로 묶여서 실행되어야하는 부분은 트랜잭션으로 관리할 수 있다.

ACID

Atomicity

트랜잭션 내 실행한 작업에 대해서는 모두 성공하거나, 모두 실패하는 2가지 경우만 존재한다.

Concurrency

트랜잭션 실행 전과 실행 후에는 DB의 일관성을 만족해야한다. 키 제약 조건, 엔티티 무결성 제약조건,등의 제약조건을 만족하고 있는 valid한 상태를 유지하고 있어야한다.

Isolation

동시에 같은 데이터에 대해서 다룰 떄, 서로에 영향이 발생하지 않도록 해야한다. 그래서, 이러한 격리 수준에 따른 레벨이 나눠져있다. 레벨이 높을수록, 동시성 떨어지며, 성능 또한 낮아진다. 대개, Spring에서는 READ_UNCOMMITED 수준으로 실행된다.

Durablity

트랜잭션이 끝나게 되면, 결과가 항상 기록되야한다. 중간에 시스템 에러가 발생하더라도, 시스템 로그를 이용해서 복구를 할 수 있어야한다.

DB Session

db_connection_session

WAS을 통해 생성되는 DB connection은 실제, DB 서버의 세션과 매칭된다. 즉, connetion 과 session 1:1로 매칭된다. 또한, 모든 커넥션을 통한 모든 DB로의 요청은 세션을 통해 처리하게 된다. 같은 트랜잭션 내에서는 같은 세션을 통해 실행되며, 세션 단위로 커밋/롤백이 수행된다. 그렇기 때문에, 커밋을 수행하지 않은 데이터에 대해서는, 다른 커넥션이 해당 데이터를 볼 수 없는 이유가 이러한 세션을 통한 커넥션을 관리하기 때문이다.

Transaction Example

transaction1

위와 같이 DB가 구성되어 있고, 세션이 2개가 있다고 가정하자.

transaction2

이때, 세션 1에서 데이터를 추가하게 되면, 위와 같이 임시 데이터 형태로 DB에 저장되게 된다. 이렇게 임시 상태의 데이터로 존재하는 경우, 해당 데이터를 삽입한 세션을 제외한 나머지 세션에서는 해당 임시 데이터를 조회할 수 없다.

transaction3

위와 같이 commit을 수행한 경우, 임시 데이터는 완료 상태로 되며, 다른 세션에서도 조회가 가능하다.

transaction4

만약, 위와 같이 rollback을 수행하게 되면 트랜잭션을 실행하기 이전 상태로 되돌리게 된다.

autocommit

트랜잭션 단위로 작업을 수행하는 경우 autocommmit을 false 처리해야한다. 기본 설정은 autocommit이 true인 상태로, 모든 쿼리에 대해서 실행시 쿼리 단위로 자동으로 커밋이 이루어진다.

따라서, 트랜잭션 단위로 작업을 묶어서 처리하고자 하는 경우 autocommit을 false 처리해줘야한다.

set autocommit false

DB 락

세션 1에서 트랜잭션을 시작해서 데이터에 대한 작업을 하고 있는데, 만약 다른 세션에서 트랜잭션을 통해 해당 데이터를 접근하게 되면 문제가 발생한다. Atomicity, Isolation이 깨지는 문제가 발생하게 된다. 따라서, 트랜잭션에서 데이터를 수정할 때, 다른 세션에서 해당 데이터를 수정할 수 없도록 막아야 한다. 이를 위해 DB 락 이라는 개념을 활용한다.

db_lock

위와 같이 DB의 Row 단위로 락을 적용할 수 있는데, 세션에서 트랜잭션을 통해 데이터를 수정하게 되는 경우, DB락을 할당 받아서 처리해야한다. DB락이 이미 다른 세션에 할당되어 있는 경우 대기해야되는데, 무작정 대기하지 않고, 일정 시간이 지나게 되면 락 타임아웃 오류를 발생시킨다. 트랜잭션에서 커밋을 수행하게 되면 DB락을 반환하게 된다.

h2 db의 경우 아래와 같이 설정한다.

H2 DB lock_timeout

SET LOCK_TIMEOUT 60000;

보통은 DB에 대한 변경 작업을 수행하는 경우 DB의 락을 할당 받아서, 다른 세션에서 해당 데이터를 변경할 수 없도록한다. 하지만, 조회할때도 락을 획득하고 싶을 때가 있다. 가령, 특정 조회 작업 진행 과정에서 다른 곳에서 데이터를 수정하면 안되는 부분이 있을 수도 있다. 이럴때, select for update 구문을 활용한다.

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

Application에 Transaction 적용

service_transaction

트랜잭션은 실제 데이터를 수정하는 Service 계층에서 생성되고, 종료되야 한다. 또한 매번 같은 커넥션-세션을 이용해야 하므로, connection 객체를 인자로 전달해서 비즈니스 로직을 실행할 수 있도록 해야한다.

Service

public class MemberServiceV2 {
    private final DataSource dataSource;
    private final MemberRepositoryV2 memberRepositoryV2;

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

        try {
            //트랜잭션을 시작하기 위해 autocommit을 false 처리한다.
            conn.setAutoCommit(false);
            bizlogic(conn,fromId, toId, money);
            //트랜잭션이 성공하면 커밋
            conn.commit();
        } catch (Exception ex) {
            log.error("Transfer Failed");
            //실패하면 롤백 처리한다.
            conn.rollback();
            throw new IllegalStateException(ex);
        }
        finally{
            release(conn);
        }
    }
    //비즈니스 로직
    private void bizlogic(Connection conn,String fromId, String toId, int money) throws SQLException {
        Member fromMember = memberRepositoryV2.findById(conn, fromId);
        Member toMember = memberRepositoryV2.findById(conn, toId);

        memberRepositoryV2.update(conn,fromId, fromMember.getMoney() - money);
        validation(toMember);
        memberRepositoryV2.update(conn,toId, toMember.getMoney() + money);
    }
    //에러를 발생시키는 부분(트랜잭션 중간에 에러 상황 발생 가정) -->멤버 Id가 ex인경우 에러를 발생시킨다.
    private void validation(Member toMember) {
        if (toMember.getMemberId().equals("ex")) {
            throw new IllegalStateException("이체중 예외 발생");
        }
    }
    //Connection을 반환하는 메소드
    private void release(Connection conn) {
        if (conn != null) {
            try{
                conn.setAutoCommit(true);
                conn.close();
            }catch(SQLException ex){
                log.error("error", ex);
            }
        }

    }
}

Repository

public Member findById(Connection conn,String memberId) throws SQLException {
    String sql = "SELECT * FROM MEMBER WHERE MEMBER_ID =?";

    PreparedStatement pstmt = null;
    ResultSet rs = null;

    try{
        pstmt = conn.prepareStatement(sql);
        pstmt.setString(1, memberId);

        rs=pstmt.executeQuery();

        if (rs.next()) {
            Member member = new Member();
            member.setMemberId(rs.getString(1));
            member.setMoney(rs.getInt(2));
            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);
    }
}
public void update(Connection conn,String memberId,int money) throws SQLException {
    String sql = "UPDATE MEMBER SET MONEY=? WHERE MEMBER_ID =?";

    PreparedStatement pstmt = null;

    try{
        pstmt = conn.prepareStatement(sql);
        pstmt.setInt(1, money);
        pstmt.setString(2, memberId);
        int resultSize=pstmt.executeUpdate();
        log.info("resultSize={}", resultSize);
    } catch (SQLException e) {
        log.error("db error", e);
        throw e;
    }
    finally{
        JdbcUtils.closeStatement(pstmt);
    }
}

기존 설계한 repository와 달리 service 계층으로부터 Connection 객체를 받아서 수행하게 된다. 이렇게 하게 되면 같은 세션 내에서 작업을 처리하는 효과를 낼 수 있다.

Test

class MemberServiceV2Test {
    public static final String MEMBER_A = "memberA";
    public static final String MEMBER_B = "memberB";
    public static final String MEMBER_EX = "ex";

    private MemberRepositoryV2 memberRepositoryV2;
    private MemberServiceV2 memberServiceV2;

    @BeforeEach
    void before(){
        DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        memberRepositoryV2 = new MemberRepositoryV2(dataSource);
        memberServiceV2 = new MemberServiceV2(dataSource,memberRepositoryV2);
    }

    @AfterEach
    void after() throws SQLException {
        memberRepositoryV2.delete(MEMBER_A);
        memberRepositoryV2.delete(MEMBER_B);
        memberRepositoryV2.delete(MEMBER_EX);
    }

    @Test
    @DisplayName("정상 이체")
    void accountTransfer() throws SQLException {
        //given
        Member memberA = new Member(MEMBER_A, 10000);
        Member memberB = new Member(MEMBER_B, 10000);

        memberRepositoryV2.save(memberA);
        memberRepositoryV2.save(memberB);

        //when
        memberServiceV2.accountTransfer(memberA.getMemberId(), memberB.getMemberId(), 2000);

        //then
        Member findMemberA = memberRepositoryV2.findById(MEMBER_A);
        Member findMemberB = memberRepositoryV2.findById(MEMBER_B);

        assertThat(findMemberA.getMoney()).isEqualTo(8000);
        assertThat(findMemberB.getMoney()).isEqualTo(12000);
    }

    @Test
    @DisplayName("이체 오류")
    void accountTransferEx() throws SQLException {
        //given
        Member memberA = new Member(MEMBER_A, 10000);
        Member memberEx = new Member(MEMBER_EX, 10000);

        memberRepositoryV2.save(memberA);
        memberRepositoryV2.save(memberEx);

        //when
        //에러 상황 발생
        assertThatThrownBy(
                () -> memberServiceV2.accountTransfer(
                        memberA.getMemberId(), memberEx.getMemberId(), 2000))
                .isInstanceOf(IllegalStateException.class);


        //then
        Member findMemberA = memberRepositoryV2.findById(MEMBER_A);
        Member findMemberEx = memberRepositoryV2.findById(MEMBER_EX);

        //데이터가 롤백 되었음을 확인할 수 있다.
        assertThat(findMemberA.getMoney()).isEqualTo(10000);
        assertThat(findMemberEx.getMoney()).isEqualTo(10000);
    }
}

References

link: inflearn

link:springdb

댓글남기기