Spring

[인프런/스프링 DB 1편] 3. 트랜잭션

주니어주니 2023. 6. 20. 17:12

 

트랜잭션 

 

1. 개념 

 

하나의 거래를 안전하게 처리하도록 보장해주는 것 

 

커밋 : 모든 작업이 성공해서 데이터베이스에 정상 반영 

롤백 : 하나라도 실패해서 거래 이전으로 되돌리는 것 

 

 

트랜잭션 ACID 

  • 원자성(Atomicity) : 트랜잭션 내에서 실행하는 작업들은 하나의 작업인 것처럼 모두 성공하거나 모두 실패해야 함
  • 일관성(Consistency) : 일관성 있는 데이터베이스를 유지해야 함. (무결성 제약 조건 항상 만족) 
  • 격리성(Isolation) : 동시에 실행되는 트랜잭션들이 서로 영향을 미치지 않도록 격리해야 함. (동시에 같은 데이터 수정하지 못하도록) 격리성은 동시성과 관련된 성능 이슈로 인해 트랜잭션 격리 수준을 선택할 수 있음
  • 지속성(Durability) : 트랜잭션을 성공적으로 끝내면 그 결과가 항상 기록되어야 함. 중간에 시스템에 문제가 발생해도 데이터베이스 로그 등을 사용해서 성공항 트랜잭션 내용을 복구해야 함

 

 

트랜잭션 격리 수준 - Isolation level 

트랜잭션 간에 격리성을 완전히 보장하려면 트랜잭션을 거의 순서대로 실행해야 함. 이렇게 하면 동시 처리 성능이 매우 나빠짐. -> 트랜잭션 격리 수준을 4단계로 나누어 정의 

  • READ UNCOMMITED (커밋되지 않은 읽기) -> 성능은 빠르지만, 커밋하지 않은 데이터를 다른 곳에서 볼 수 있음 -> 데이터 정합성 문제 
  • READ COMMITTED (커밋된 읽기) ----> 일반적으로 많이 사용
  • REPEATABLE READ (반복 가능한 읽기)
  • SERIALIZABLE (직렬화 가능) 

 

단계 높을수록 격리성 보장, 성능 느림 

 

 


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

트랜잭션을 이해하기 위한 데이터베이스 서버 연결 구조와 DB 세션

 

  • 사용자는 웹 애플리케이션 서버(WAS)나 DB 접근 툴 같은 클라이언트를 사용해서 데이터베이스 서버에 접근
    클라이언트는 데이터베이스 서버에 연결 요청, 커넥션 맺음
    이때 데이터베이스 서버는 내부에 세션을 만듦. 커넥션을 통한 모든 요청은 세션을 통해 실행
  • 개발자 -> 클라이언트를 통해 SQL 전달 -> 커넥션에 연결된 세션이 SQL 실행 
  • 세션 - 트랜잭션 시작, 커밋/롤백을 통해 트랜잭션 종료, 새로운 트랜잭션 시작
  • 사용자가 커넥션을 닫거나, DB 관리자가 세션을 강제로 종료하면 세션 종료

 

  • 커넥션 풀이 10개의 커넥션 생성 -> 세션 10개 생성

 


 

 

3. 트랜잭션 DB 예제 

 

1) 자동 커밋, 수동 커밋 

 

(1) 자동 커밋 설정

-  기본 자동 커밋

- 쿼리를 실행할 때마다 자동으로 커밋되기 때문에 원하는 트랜잭션 기능을 제대로 사용할 수 X

-> 커밋, 롤백 호출하면서 트랜잭션 기능을 제대로 수행하려면 수동 커밋 사용

set autocommit true; //자동 커밋 모드 설정
insert into member(member_id, money) values ('data1',10000); //자동 커밋
insert into member(member_id, money) values ('data2',10000); //자동 커밋

 

 

(2) 수동 커밋 설정

- 보통 수동 커밋 모드로 설정 (set autocommit false) : 트랜잭션을 시작한다고 봄 

- 수동 커밋 설정 후에는 commit, rollback 필수 ! 

set autocommit false; //수동 커밋 모드 설정
insert into member(member_id, money) values ('data3',10000);
insert into member(member_id, money) values ('data4',10000);
commit; //수동 커밋

 

 

 

2) 계좌이체 예제

 

(1) 계좌이체 정상 흐름 

 

기본 데이터 생성 -> 자동 커밋

 

 

계좌이체 실행 -> 수동 커밋 설정 -> 아직 커밋 안함 

 

 

커밋 후 -> 데이터베이스에 결과 반영

 

 

 

(2) 계좌이체 문제 상황 

 

기본 데이터 입력 

 

 

🚨 문제 상황 발생

오타로 인해 오류 발생 -> memberA의 돈은 줄었는데, memberB의 돈은 증가하지 않음 ! 

 

 

 

이때 커밋을 해버리면 ? 

계좌이체 실패 + memberA의 돈만 줄어드는 심각한 문제 발생 

-> 커밋 X 

 

 

롤백

트랜잭션 시작 시점으로 데이터 원복

 

 

* 정리 

원자성 : 트랜잭션 내의 작업들은 하나의 작업인 것처럼 모두 성공하거나 모두 실패해야 함 
원자성 덕분에 여러 SQL 명령어를 하나의 작업인 것처럼 처리 가능

오토 커밋 : 오토 커밋으로 동작하면 쿼리 실행 때마다 바로 커밋되기 때문에 문제 발생 -> 수동 커밋

트랜잭션 시작 : 자동 커밋 모드에서 수동 커밋 모드로 전환하는 것을 트랜잭션의 시작으로 봄

 

 


4. DB 락

 

1) 개념 

 

트랜잭션을 실행하는 동안, 동시에 같은 데이터를 수정할 수 없도록 락을 걸어둠 

 

 

1. 서로 같은 데이터를 수정하고 싶을 때, 좀더 빨리 요청한 세션 1이 트랜잭션 시작 

2. 해당 로우의 락 획득 

3. update 쿼리 실행 

4. 세션 2가 트랜잭션 실행 -> 해당 로우의 락이 없으므로 대기 (무한정 대기 X, 락 대기 시간 넘어가면 타임아웃 오류)

5. 세션 1이 커밋 수행 -> 트랜잭션 종료 -> 락 반납 

 

6. 세션 2가 락 획득 

7. 세션2가 update 쿼리 실행 

8. 세션 2가 커밋 수행 -> 트랜잭션 종료 -> 락 반납 

 

 

 

2) 데이터 변경 시 DB 락

 

기본 데이터

 

 

세션 1이 트랜잭션 시작 -> memberA 로우의 락 획득 -> 데이터 업데이트 -> 커밋 아직 안함

 

 

세션 1의 트랜잭션 실행 중인데 세션 2가 수정하려고 함 

세션 2는 락을 아직 획득하지 못했으므로 실행 대기 ( 락 획득 시간 60초로 설정 - 60초 안에 락 얻지 못하면 락 타임아웃 오류 발생 )

 

 

 

세션 1이 커밋 -> 트랜잭션 종료 -> 곧바로 세션 2가 락 획득 -> 세션 2의 업데이트 쿼리 실행

 

 

 

 

3) 데이터 조회 시 DB 락

 

일반적으로 조회할 때는 락을 사용하지 않음

세션 1이 락을 획득하고 데이터를 변경하고 있어도, 세션 2에서 데이터를 조회할 수는 있음

 

세션 1이 데이터를 조회하고 있을 때, 세션 2에서 데이터를 변경해버리는 경우 

-> 조회할 때도 락을 획득

 

 

 

기본 데이터 

 

 

세션 1이 데이터 조회 + 락 획득 ( select for update

세션 2가 데이터 변경하려고 함 -> 락 획득할 때까지 대기

 

 

세션 1 커밋 -> 락 반납 -> 세션 2가 락 획득 -> 세션 2의 변경 쿼리 실행 

 

세션 2 커밋 -> 데이터베이스에 변경 사항 반영 

 

 


 

 

5. 트랜잭션 적용 

 

1) 트랜잭션 없이 계좌이체 로직 (정상동작할 때, 이체중 예외 발생할 때)

 

* MemberRepositoryV1 

- 원래 리포지토리에서 각각 메소드마다 커넥션 획득해서 사용

package hello.jdbc.repository;

import hello.jdbc.domain.Member;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.support.JdbcUtils;

import javax.sql.DataSource;
import java.sql.*;
import java.util.NoSuchElementException;

/**
 * JDBC - DataSource 사용, JdbcUtils 사용
 */
@Slf4j
public class MemberRepositoryV1 {

    private final DataSource dataSource;

    public MemberRepositoryV1(DataSource dataSource) {
        this.dataSource = dataSource;
    }

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

        Connection con = null;                      // finally에서도 호출해야 하기 때문에 밖에 따로 선언
        PreparedStatement pstmt = null;

        try {
            con = getConnection();                  // 커넥션 획득 -> 메소드로 추출
            pstmt = con.prepareStatement(sql);      // sql에 파라미터 바인딩
            pstmt.setString(1, member.getMemberId());   // sql의 첫번째 ?에 문자 저장
            pstmt.setInt(2, member.getMoney());         // sql의 두번째 ?에 숫자 저장
            pstmt.executeUpdate();                  // 실행 (sql을 커넥션을 통해 데이터베이스에 전달)
            return member;
        } catch (SQLException e) {
            log.error("db error", e);               // 로그를 찍기 위해 예외 잡음
            throw e;
        } finally {
            close(con, pstmt, null);
        }
    }

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

        Connection con = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null;

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

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

        Connection con = null;
        PreparedStatement pstmt = null;

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

    public void delete(String memberId) throws SQLException {
        String sql = "delete member where member_id=?";

        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, memberId);
            pstmt.executeUpdate();
        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            close(con, pstmt, null);
        }
    }

    private void close(Connection con, Statement stmt, ResultSet rs) {
        JdbcUtils.closeResultSet(rs);
        JdbcUtils.closeStatement(stmt);
        JdbcUtils.closeConnection(con);
    }

    private Connection getConnection() throws SQLException {
        Connection con = dataSource.getConnection();
        log.info("get connection={}, class{}", con, con.getClass());
        return con;
    }
}

 

 

* MemberServiceV1 - 계좌이체 로직 구현 

package hello.jdbc.service;

import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV1;
import lombok.RequiredArgsConstructor;

import java.sql.SQLException;

@RequiredArgsConstructor
public class MemberServiceV1 {

    private final MemberRepositoryV1 memberRepositoryV1;

    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
        Member fromMember = memberRepositoryV1.findById(fromId);
        Member toMember = memberRepositoryV1.findById(toId);

        memberRepositoryV1.update(fromId, fromMember.getMoney() - money);
        validation(toMember);
        memberRepositoryV1.update(toId, toMember.getMoney() + money);
    }

    private static void validation(Member toMember) {
        if (toMember.getMemberId().equals("ex")) {
            throw new IllegalStateException("이체중 예외 발생");
        }
    }
}
  • @RequiredArgsConstructor : final 붙은 변수에 대해 생성자 생성
    • MemberRepository는 스프링 빈에 등록돼서 주입받는게 X, 테스트할 때 new로 생성해서 사용함

 

 

* MemberServiceV1Test - 트랜잭션 없이 기본동작, 예외발생 테스트

package hello.jdbc.service;

import hello.jdbc.connection.ConnectionConst;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV1;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.datasource.DriverManagerDataSource;

import java.sql.SQLException;

import static hello.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;

/**
 * 기본 동작, 트랜잭션이 없어서 문제 발생
 */
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;

    @BeforeEach
    void before() {
        DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        memberRepository = new MemberRepositoryV1(dataSource);
        memberService = new MemberServiceV1(memberRepository);
    }

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

    // 정상 이체
    @Test
    @DisplayName("정상 이체")
    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
    @DisplayName("이체중 예외 발생")
    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 (A의 잔고는 줄고, EX의 잔고는 그대로)
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberB = memberRepository.findById(memberEx.getMemberId());
        assertThat(findMemberA.getMoney()).isEqualTo(8000);
        assertThat(findMemberB.getMoney()).isEqualTo(10000);
    }

}

 

  • @AfterEach - 테스트 데이터 제거 
    • 테스트 실행 후마다 데이터 제거 -> 다음 테스트에서 데이터 중복 오류 방지

 

 

(1) 정상 이체 - accountTransfer() 

 

memberA 금액 : 10000 -> 8000

memberB 금액 : 10000 -> 12000

 

 

(2) 이체중 예외 발생 - accountTransferEx() 

 

shift + f6 : memberB -> memberEx로 모두 rename

 

서비스 로직에서 member의 ID가 ex일 때 예외 발생하도록 설정했음 

-> memberA의 금액은 감소했는데, 받는 member의 ID가 없어서 이체 오류 

-> memberA 금액 : 10000 -> 8000 

    memberEx 금액 : 10000 그대로

-> 엉망진창 이렇게 되어버리면 안됨 !!

 

 

 

 

2) 트랜잭션 적용

 

  • 트랜잭션비즈니스 로직이 있는 서비스 계층에서 시작해야 함
    ( 비즈니스 로직이 잘못되면 해당 로직으로 인해 문제되는 부분을 함께 롤백해야 하기 때문 )
  • 트랜잭션을 시작하려면 커넥션 필요
    -> 서비스 계층에서 커넥션 획득 -> 트랜잭션 커밋 후 커넥션 종료
  • 트랜잭션을 사용하는 동안에는 같은 커넥션 유지해야 함
    -> 그래야 같은 세션 사용
    -> 같은 커넥션을 유지하려면 ? 
    -> 단순한 방법으로는, 커넥션을 파라미터로 전달해서 같은 커넥션 사용되도록 유지

 

 

* MemberRepositoryV2 - 회원 등록, 조회, 수정, 삭제

/**
 * JDBC - ConnectionParam
 */
@Slf4j
public class MemberRepositoryV2 {

    private final DataSource dataSource;

    public MemberRepositoryV2(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public Member save(Member member) throws SQLException {
        ...
    }

    public Member findById(Connection con, String memberId) throws SQLException {
        String sql = "select * from member where member_id = ?";

        PreparedStatement pstmt = null;
        ResultSet rs = null;

        try {
            // 새로운 커넥션을 get하지 않고 넘겨받아야 함 !
            // con = getConnection();
            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 {
            JdbcUtils.closeResultSet(rs);
            JdbcUtils.closeStatement(pstmt);
            // connection을 여기서 닫으면 안됨 ! 서비스에서 종료해야 함
            // JdbcUtils.closeConnection(con);
        }
    }


    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.error("db error", e);
            throw e;
        } finally {
            // connection을 여기서 닫지 않는다.
            JdbcUtils.closeStatement(pstmt);
        }
    }

    public void delete(String memberId) throws SQLException {
        ...
    }

    private void close(Connection con, Statement stmt, ResultSet rs) {
        JdbcUtils.closeResultSet(rs);
        JdbcUtils.closeStatement(stmt);
        JdbcUtils.closeConnection(con);
    }

    private Connection getConnection() throws SQLException {
        Connection con = dataSource.getConnection();
        log.info("get connection={}, class{}", con, con.getClass());
        return con;
    }
}

- 서비스 로직에서 트랜잭션 적용 필요한 부분 : 회원 조회, 수정

- findById(Connection con, String memberId)

- update(Connection con, String memberId, int money)

 

  • 커넥션 유지가 필요한 두 메소드는 커넥션 생성 X , 파라미터로 넘어온 커넥션 사용 !
  • 커넥션 유지가 필요한 두 메소드는 리포지토리에서 커넥션을 닫으면 X
    이후에도 커넥션을 계속 이어서 사용하기 때문
    -> 서비스 로직이 끝날 때 트랜잭션 종료해야 함

 

 

* MemberServiceV2 - 계좌 이체 로직

package hello.jdbc.service;

import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV2;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;

/**
 * 트랜잭션 - 파라미터 연동, 풀을 고려한 종료
 */
@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);   // 트랜잭션 시작

            // 비즈니스 로직
            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 static void release(Connection con) {
        if (con != null) {
            try {
                con.setAutoCommit(true);    // 커넥션 풀 고려
                con.close();
            } catch (Exception e) {
                log.info("error", e);
            }
        }
    }

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

 

  • Connection con = dataSource.getConnection();
    • 트랜잭션 시작하려면 커넥션 필요

  • con.setAutoCommit(false);
    • 수동 커밋 모드 -> 트랜잭션 시작
    • 세션에 set autocommit false 전달

  • bizLogic(con, fromId, toId, money);
    • 트랜잭션이 시작된 커넥션을 전달하면서 비즈니스 로직 수행
    • 메소드를 따로 추출한 이유 - 트랜잭션 관리 로직과 실제 비즈니스 로직 구분하기 위함
    • bizLoginc 내의 각 메소드를 호출할 때도 커넥션을 전달함 ( update(con, ...) )
  • con.commit();
    • 비즈니스 로직이 정상 수행되면 트랜잭션 커밋
  • con.rollback();
    • 비즈니스 로직 수행 중 예외가 발생하면 catch -> 트랜잭션 롤백

  • release(con); 
    • 커넥션을 모두 사용하고 나면 안전하게 종료 (커넥션 풀 사용시에는 풀에 커넥션 반납)
    • 풀에 돌려주기 전에 수동 커밋 모드를 기본 값인 자동 커밋 모드로 변경해줌

 

* MemberServiceV2Test - 트랜잭션 적용 테스트 

package hello.jdbc.service;

import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV1;
import hello.jdbc.repository.MemberRepositoryV2;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.datasource.DriverManagerDataSource;

import java.sql.SQLException;

import static hello.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

/**
 * 트랜잭션 - 커넥션 파라미터 전달 방식 동기화
 */
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);
    }

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

    @Test
    @DisplayName("정상 이체")
    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);
    }

    @Test
    @DisplayName("이체중 예외 발생")
    void accountTransferEx() throws SQLException {
        // given 이렇게 주어졌을 때
        Member memberA = new Member("memberA", 10000);
        Member memberEx = new Member("ex", 10000);
        memberRepository.save(memberA);
        memberRepository.save(memberEx);

        // when (예외가 던져져야 함)
        assertThatThrownBy(() -> memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
                .isInstanceOf(IllegalStateException.class);


        // then (A의 잔고는 줄고, EX의 잔고는 그대로)
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberB = memberRepository.findById(memberEx.getMemberId());
        assertThat(findMemberA.getMoney()).isEqualTo(10000);
        assertThat(findMemberB.getMoney()).isEqualTo(10000);
    }

}

 

(1) 정상이체

기존 로직과 같음

 

 

(2) 이체중 예외 발생 

  • memberService.accountTransfer() 실행
  • 커넥션 생성 후 트랜잭션 시작
  • memberA의 금액 2000원 감소
  • memberEx의 회원 ID는 ex이므로 예외 발생
  • 예외 잡아서 트랜잭션 롤백
  • 계좌이체 실패 -> 롤백 -> memberA의 돈이 기존 10000원으로 복구
  • 정상적으로 초기화

 

 

-> 근데 비즈니스 로직 코드 < 트랜잭션 코드 -> 복잡하고 지저분한 문제