framework/spring

3. 트랜잭션 이해

wooweee 2023. 9. 22. 23:47
728x90

1. 트랜잭션 - 개념 이해

 

  • data 저장시 file이 아닌 db에 저장하는 이유
    • db는 transaction이라는 개념을 지원하기 때문
  • transaction
    • 거래
    • 예시 : a와 b의 돈거래 : a 잔고 감소, b 잔고 증가
    • 핵심
      1. 2가지 작업이 합쳐져서 하나의 작업처럼 동작
      2. 모든 작업이 성공해서 db에 정상 반영하는 것 : commit
      3. 작업 중 1개라도 실패해서 거래 이전으로 되돌리는 것 : rollback

 

2. 트랜잭션 ACID

  • 원자성(atomictiy) : 여러작업이 하나의 작업인 것처럼 모두 성공 하거나 모두 실패
  • 일관성(consistency) : db 상태를 유지 - 무결성 제약 조건을 만족
  • 격리성(isolation) : 동시에 같은 데이터를 수정하지 못하도록 한다.
  • 지속성(durability) : commit이 끝난 후 결과가 항상 기록

 

2.1. 격리성

 

  • 격리성
    • 완벽히 보장시 동시 처리 성능이 매우 나빠짐
    • 격리성을 너무 완화시 db가 꼬이는 문제 발생

 

  • 트랜잭션 격리 수준
    • Read Uncommited (커밋 안된 것도 읽기)
    • read committed (커밋된 읽기 - 많이 사용)
    • repeatable read (반복 가능한 읽기)
    • serializable (직렬화)

 

3. db연결 구조와 DB 세션

  • transaction을 위한 기본 지식

 

 

  • 사용자는 was 나 db 접근 client를 사용해서 db server에 접근

  • 이때 db server는 내부에 session이라는 것을 만든다.
    - connection을 통한 모든 요청은 session을 통해서 실행

  • 개발자가 client를 통해 sql을 전달하면 connection을 통해 연결된 session이 sql을 실행

  • session
    • transaction 시작
    • commit, rollback 통해 transaction 종료
    • 사용자가 1) connection을 닫거나, DBA가 2) 세션을 강제 종료시 session 종료

 

 

  • connection pool 같은 경우 connection이 10개 생성되면 세션도 10개 만들어진다.

 

4. transaction - db 예제1 - 개념 이해

  • transaction 사용법
    • 변경 결과 저장 : commit
    • 변경 결과 원복 : rollback
    • commit과 rollback 수행 전까지는 임시로 data를 저장하는 것
      • 변경을 한 사용자의 session에서 변경 data가 보이고 다른 세션에게서는 변경 data가 보이지 않는다.
      • 변경 : insert, update, delete 로 표현

 

 

 

5. transaction - db 예제2 - 자동 커밋, 수동 커밋

  • 변경불가 자동 커밋
    • DDL : Create, Arter, Drop, Rename, Truncate, Comment
    • DCL : Grant, Revoke
  • 변경 가능 자동 커밋 - 수동 커밋으로 변경 가능
    • DML : Insert, Udapte, Delete

 

  • 자동 commit, 수동 commit 명령어
set autocommit true; // default

set autocommit false; // 수동 commit으로 전환
  • 설정 유지
    • 자동 commit, 수동 commit 설정 후 해당 세션 동안에는 계속 유지
    • 중간에 변경 가능

 

  • 수동 commit 
    • 수동 commit일 때 transaction을 시작한다고 표현을 할 수 있다.
    • 작업 수행 후 , commit, rollback을 호출 필요 == commit 과 rollback의 선택권을 가진다.
    • 둘다 호출 안할 시, db 자체의 timeout 설정 시간 초과 후 rollback 된다.

 

 

6. transaction - db 예제3 - transaction 실습

 

  • h2  db session 2개 연결하는 방법
    • http://localhost:8082 를 직접 입력해서 완전히 새로운 세션에서 연결
      • localhost:8082 뒤의 JSessionid 값이 다른 것을 확인하기

    • 주의
      • H2 데이터베이스 웹 콘솔 창을 2개 열때 기존 URL을 복사하면 안된다. 
      • URL을 복사하면 같은 세션( jsessionId )에서 실행되어서 원하는 결과가 나오지 않을 수 있다.

 

  • 계좌 이체 실패 시, commit 과 rollback 차이점
set autocommit false;
update member set money=10000 - 2000 where member_id = "memberA"; // success
update member set money=10000 + 2000 where member_idddd = "memberB"; // error

// error 문구
Column "MEMBER_IDDD" not found;
SQL statement: update member set money=10000 + 2000 where member_iddd = 'memberB'

 

 

  • 정리
    • 수동 커밋일 때는 최종적으로 아래 2개의 코드를 작성해야 그 동안 수정한 개별의 작업들이 실제로 저장 혹은 원복이 된다.
      commit;
      rollback;
    • 자동 commit도 transaction이 일어난다. 하지만 변경 하나하나 수행이 끝나자 마자 자동으로 commit;을 해버리기 때문에 transcation을 제대로 수행 할 수 없다.

 

7. DB 락 - 개념

 

  • 상황 : transaction을 시작하고 data를 수정하는 동안 아직 commit을 수행하지 않았는데, session에서 동시에 같은 data를 수정하게 되면 여러 문제 발생

  • 해당 문제를 방지하기 위해 session이 transaction을 시작하고 data를 수정하는 동안에는 commit이나 rollback 전까지 다른 session에서 해당 data를 수정할 수 없게 막아야 한다.

  • db가 제공하는 해결 방안
    • Lock이란 개념 제공

 

  • lock
    • 락이 없어서 대기 하고 있는 다른 session은 일정 시간동안만 대기하다가 일정 시간 초과 후 타임아웃 오류가 발생한다.
  • timeout 설정
set lock_timeout <milliseconds> // 락 timeout 시간 설정

// timeout 오류
Timeout trying to lock table {0}; 
SQL statement: update member set money=10000 - 2000 where member_id = 'memberA'

 

 

 

 

8. DB 락 - 조회

 

  • 일반적인 조회시는 lock과 상관없이 접근이 가능
    물론 commit 이전의 값을 보는 것

  • 조회시 lock이 필요한 경우
    • transaction 종료 시점까지 해당 data를 다른 곳에서 변경하지 못하도록 강제로 막아야 할 때 사용
    • select로 필요한 정보를 가져다가 service에 그 값으로 결과를 도출한 값을 반환 할 때 문제가 생기는 경우를 의미

 

  • 조회시에도 lock을 거는 방법 (select for update)
set autocommit false;
select * from member where member_id = 'memberA' for update;

// for update : 조회 시점 lock 획득 방법
  • 중요한 조회를 할테니깐 update 못하도록 막는 것
  • 이제 select lock이 된 시점에서 다른 session에서 값을 변경하는 작업은 lock이 없기 때문에 대기 상태에 들어간다.
  • 주의
    • 조회 이지만 작업이 끝난 후 commit을 작성해주어야지 session2가 lock을 받아서 사용가능해진다

 

9. transaction - 적용 1

  • 요약
    • transaction이 적용되지 않은 이전 repository 이용
    • repository에서 Datasource connection을 수행
    • service는 오로지 buiz logic만 수행
    • 예외 발생시, transaction이 적용되지 않았기 때문에 문제 발생

 

  • formId의 회원을 조회해서 toId의 회원에게 money만큼 돈을 계좌이체 하는 logic
package hello.jdbc.repository;

/**
 * JDBC - DriverManager 사용
 */
@Slf4j
public class MemberRepositoryV1 {

    // DataSource Interface 생성자 주입 받음
    // DI -> 이말은 설정 파일을 만들어서 그 안에 DataSource를 따로 보관하는 곳이 존재한다는 뜻
    private final DataSource dataSource;

    // 생성자 생성
    public MemberRepositoryV1(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    // 1. 등록
    public Member save(Member member) throws SQLException {
        String sql = "insert into member(member_id, money) values(? , ?)";

        Connection con = null;
        PreparedStatement pstmt = null; // sql params를 바인딩 받는 class

        try {
            con = getConnection(); // 1. connection 연결

            pstmt = con.prepareStatement(sql); // 2. db랑 소통
            pstmt.setString(1, member.getMemberId()); // sql string values의 첫번째 ?
            pstmt.setInt(2, member.getMoney()); // sql string values의 두번째 ?
            pstmt.executeUpdate(); // db에서 실행됨

            return member;

        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            close(con, pstmt, null); // close하는 과정에서 예외발생시 뒤에것도 close안되니깐 method로 분리
        }

    }

    // 2. 조회
    public Member findById(String memberId) throws SQLException {
        String sql = "select * from member where member_id = ?";

        Connection con = null; // db와 연결
        PreparedStatement pstmt = null; // sql문에 추가 값 넣고 실행
        ResultSet rs = null; // select query 문의 결과를 담고 있는 통

        try {
            con = getConnection();

            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, memberId);

            rs = pstmt.executeQuery(); // 조회시에는 Query() 사용,  등록시 update 사용

            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 {
            close(con, pstmt, rs);
        }
    }

    // 3. 수정
    public void update(String memberId, int money) throws SQLException {
        String sql = "update member set money=? where member_id = ?";

        Connection con = null; // db와 연결
        PreparedStatement pstmt = null; // sql문에 추가 값 넣고 실행

        try {
            con = getConnection();

            pstmt = con.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 {
            close(con, pstmt, null);
        }
    }

    // getConnection을 Datasource용으로 변경
    private Connection getConnection() throws SQLException{
        Connection con = dataSource.getConnection(); // 연결이 된다는 것이지 아직 연결된 db는 없다.
        log.info("get connection={}, class={}", con, con.getClass());
        return con;
    }

    // close() 예외 처리 - 더 간략한 방법으로 변경
    private void close(Connection con, Statement stmt, ResultSet rs) {
        // jdbc에서 제공하는 utils
        JdbcUtils.closeResultSet(rs);
        JdbcUtils.closeStatement(stmt);
        JdbcUtils.closeConnection(con); // hika 머시기는 hika constpool에 감싸져있어서 그 안으로 들어간다.
    }
}

 

package hello.jdbc.service;

@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); // 중간에 예외 발생

        memberRepository.update(toId, fromMember.getMoney() + money);

    }

    private static void validation(Member toMember) {
        if (toMember.getMemberId().equals("ex")){
            throw new IllegalStateException("이체중 예외 발생");
        }
    }
}

 

  • Test
    • 예외 발생시 , 자동 commit으로 인해서 memberA의 돈만 출금되고 송금 받는 인원이 없어졌다.
package hello.jdbc.service;

class MemberServiceV1Test {

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

    private MemberRepositoryV1 memberRepository;
    private MemberServiceV1 memberService;

    // 현재 repository 생성자에 들어갈 dataSource 설정이 안되어있어서 @BeforeEach에 직접 만들어서 넣어준다.
    @BeforeEach
    void before(){
        DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        memberRepository = new MemberRepositoryV1(dataSource);
        memberService = new MemberServiceV1(memberRepository);
    }

    // test 끝날때마다 원복 시켜주기
    @AfterEach
    void after() throws SQLException{
        memberRepository.delete(MEMBER_A);
        memberRepository.delete(MEMBER_B);
        memberRepository.delete(MEMBER_EX);
    }

    @Test
    void accountTransfer() throws SQLException {
        //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);
    }

    @Test
    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
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberEx = memberRepository.findById(memberEx.getMemberId());
        assertThat(findMemberA.getMoney()).isEqualTo(8000);
        assertThat(findMemberEx.getMoney()).isEqualTo(10000);
    }

}

 

 

10. transaction 적용2

  • service계층에서 connection을 관리함으로써, buiz logic에서 예외발생 시, rollback 수행 할 수 있게 된다.

10.1. 개념

 

  • transaction은 business logic이 있는 service 계층에서 시작해야 한다.
  • business logic이 잘못되면 해당 logic 자체를 rollback해야 하기 때문
  • transaction 동안 동일 connection을 유지해야하므로 같은 session을 유지 해야한다.
    • 그래서 business 계층에서 connection의 생성과 연결 끊기를 수행해야한다.
  • business에서 connection을 생성하므로 repository에서 해당 connection을 받을 수 있도록 param 추가가 필요

 

  • repository
  • 주의점
    1. connection 유지가 필요한 두 메서드는 params로 넘어온 connection을 사용해야한다.
      con = getConnection() 코드 제거 필요
    2. 컨넥션 유지가 필요하므로 서비스 로직이 끝나기 전까지 connection을 close하면 안되고 service.java에서 close()해줘야 한다.
package hello.jdbc.repository;

@Slf4j
public class MemberRepositoryV2 {

    // data source 생성
    private final DataSource dataSource;

    // 생성자 생성
    public MemberRepositoryV2(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    // 1. 등록
    public Member save(Member member) throws SQLException {...}

    // 2. 조회
    public Member findById(String memberId) throws SQLException {
        String sql = "select * from member where member_id = ?";

        Connection con = null; // db와 연결
        PreparedStatement pstmt = null; // sql문에 추가 값 넣고 실행
        ResultSet rs = null; // select query 문의 결과를 담고 있는 통

        try {
            con = getConnection();

            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, memberId);

            rs = pstmt.executeQuery(); // 조회시에는 Query() 사용,  등록시 update 사용

            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 {
            close(con, pstmt, rs);
        }
    }

    // 2. 조회 - transaction 용 findById 추가
    public Member findById(Connection con, String memberId) throws SQLException {
        String sql = "select * from member where member_id = ?";

        PreparedStatement pstmt = null; // sql문에 추가 값 넣고 실행
        ResultSet rs = null; // select query 문의 결과를 담고 있는 통

        try {
//            con = getConnection(); // 이 부분이 새로운 connection 만드는 부분

            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, memberId);

            rs = pstmt.executeQuery(); // 조회시에는 Query() 사용,  등록시 update 사용

            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 {
//            close(con, pstmt, rs);
            JdbcUtils.closeResultSet(rs);
            JdbcUtils.closeStatement(pstmt);
//            JdbcUtils.closeConnection(con); 이걸 닫으면 connection이 끝나버리기 때문에 절대 닫으면 안된다. - service 부분에서 닫아준다.
        }
    }



    // 3. 수정
    public void update(String memberId, int money) throws SQLException {
        String sql = "update member set money=? where member_id = ?";

        Connection con = null; // db와 연결
        PreparedStatement pstmt = null; // sql문에 추가 값 넣고 실행

        try {
            con = getConnection();

            pstmt = con.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 {
            close(con, pstmt, null);
        }
    }

    
    // 3. 수정 - transaction 용 findById 추가
    public void update(Connection con, String memberId, int money) throws SQLException {
        String sql = "update member set money=? where member_id = ?";

        PreparedStatement pstmt = null; // sql문에 추가 값 넣고 실행

        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.error("db error", e);
            throw e;
        } finally {
            JdbcUtils.closeStatement(pstmt);
        }
    }


    // 4. 삭제
    public void delete(String memberId) throws SQLException {...}


    // h2 db Connection
    private Connection getConnection() throws SQLException{
        Connection con = dataSource.getConnection(); // 연결이 된다는 것이지 아직 연결된 db는 없다.

        /** 연결된 설정 파일을 안만들었기 때문에 test할때 @BeforeEach에 dataSource를 만들어서 넣는다.
         *     @BeforeEach
         *     void before(){
         *         DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
         *         memberRepositoryV1 = new MemberRepositoryV1(dataSource);
         *     }
         */
        log.info("get connection={}, class={}", con, con.getClass());
        return con;
    }


    // close() 예외 처리
    private void close(Connection con, Statement stmt, ResultSet rs) {
        // jdbc에서 제공하는 util
        JdbcUtils.closeResultSet(rs);
        JdbcUtils.closeStatement(stmt);
        JdbcUtils.closeConnection(con); // hika는 hika constpool에 감싸져있어서 그 안으로 들어간다.
    }
}

 

  • service
    • 제일 중요한 transaction 가능한 logic이다.
    • 꼭 흐름을 파악할 것
    • close()를 하게 되면 connection이 종료되지 않고 pool에 반납되기 때문에 수동 commit mode를 꼭 default 값인 자동 commit 모드로 변경이 필요
package hello.jdbc.service;

/* *
 * 트랜잭션 - params 연동, pool을 고려한 close()
 * transaction == connection 시작 종료를 service에서 관리
 * service 핵심 로직에서 예외가 발생했을 경우 connection을 롤백을 해줘야 하기 때문
 * connection의 역할이 수동 commit 설정, commit, rollback
 *
 */
@Slf4j
@RequiredArgsConstructor
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); // transaction 시작

            bizLogic(con, fromId, toId, money); // 비즈니스 로직 최대한 분리

            con.commit(); // 성공시, commit

        }catch (Exception e){
            con.rollback(); // 실패시, rollback
            throw new IllegalStateException(e);
        }finally {
            release(con); // connection 끊기
        }
        // 이 부분에서 commit or rollback이 수행
    }

    /* *connection 종료 method
    * setAutoCommit(true) 원복
    * 1. 원래 autoCommit은 true가 default값
    * 2. DriverMangerDatasource와 같은 connection Pool을 사용하지 않는 connection은 close()시, connection이 완전 제거되어
    *    자동으로 autoCommit으로 돌아가서 신경 쓸 필요 X
    * 3. HikariCP같이 connectionPool을 사용하는 dataSource는 connection이 해제되지 않으므로 수동으로 autoCommit true를 설정 필
    * */
    private static void release(Connection con) {
        try{
            if (con != null){
                con.setAutoCommit(true); // 다음에 쓰는 사람을 위해서 true로 원복
                con.close();
            }
        } catch(Exception e){
          log.info("error", e);
        }
    }

    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, fromMember.getMoney() + money);
    }

    private static void validation(Member toMember) {
        if (toMember.getMemberId().equals("ex")){
            throw new IllegalStateException("이체중 예외 발생");
        }
    }
}

 

  • test
package hello.jdbc.service;

class MemberServiceV2Test {

    private MemberRepositoryV2 memberRepository;
    private MemberServiceV2 memberService;

    @BeforeEach
    void before(){
        DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        memberRepository = new MemberRepositoryV2(dataSource);
        memberService = new MemberServiceV2(dataSource, memberRepository); // transaction 사용 위해서 dataSource 넘김
    }

    @AfterEach
    void after() throws SQLException {
        memberRepository.delete("memberA");
        memberRepository.delete("memberB");
        memberRepository.delete("ex");
    }

    @Test
    void accountTransfer() throws SQLException {
        //given
        Member memberA = new Member("memberA", 10000);
        Member memberB = new Member("memberB", 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);
    }

    // transaction logic 수행
    @Test
    void accountTransferEx() throws SQLException {
        //given
        Member memberA = new Member("memberA", 10000);
        Member memberEx = new Member("ex", 10000);
        memberRepository.save(memberA);
        memberRepository.save(memberEx);

        //when
        // accountTransfer으 실제 내부적으로는 params를 4개를 받지만 그 중 하나인 connection은 이미 주입되어있는 상태이기 때문에 3개의 params만 받으면 된다.
        assertThatThrownBy(()->memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
                .isInstanceOf(IllegalStateException.class);


        //then
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberEx = memberRepository.findById(memberEx.getMemberId());
        assertThat(findMemberA.getMoney()).isEqualTo(10000);
        assertThat(findMemberEx.getMoney()).isEqualTo(10000);
    }
}