5 분 소요


트랜잭션 - 적용 1

먼저 트랜잭션 없이 단순하게 계좌이체 비즈니스 로직만 구현.

MemberServiceV1

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

    private validation(Member toMember) {
        if(toMember.getMemberId().eqauls("ex")) {
            throw new IllegalStateException("이체중 예외 발생");
        }
    }
}
  • formId의 회원을 조회해서 toId의 회원에게 money만큼의 돈을 계좌이체 하는 로직이다.
    • fromId 회원의 돈을 money만큼 감소시킨다. -> UPDATE SQL 실행
    • toId 회원의 돈을 money만큼 증가시킨다. -> UPDATE SQL 실행
  • 예외 상황을 테스트해보기 위해 toId"ex"인 경우 예외를 발생한다.

MemberServiceV1Test

// 기본 동작, 트랜잭션이 없어서 문제 발생
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() {
        DriverManagerDateSource dataSource = new DriverManagerDateSource(URL, USERNAME, PASSWORD);
        memberRepository = new MemberRepositoryV1(dataSource);
        memberService = new MemberServiceV1(memberRepository);
    }

    // @AfterEach - 테스트 데이터를 제거하는 과정 생략(memberRepository.delete(...))

    @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
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberEX = memberRepository.findById(memberEx.getMemberId());

        // memberA의 돈만 2000원 줄고, ex의 돈은 그대로
        assertThat(findMemberA.getMoney()).isEqualTo(8000);
        assertThat(findMemberEx.getMoney()).isEqualTo(10000);
    }
}
  • @BeforeEach : 각각의 테스트가 수행되기 전에 실행된다.
  • @AfterEach : 각각의 테스트가 실행되고 난 이후에 실행된다.

정리

이체 중 예외가 발생하면 memberA의 금액은 10000 -> 8000원으로 2000원 감소하지만, memberEx의 돈은 그대로 10000원으로 남아있다.


트랜잭션 - 적용 2

  • DB 트랜잭션을 사용해서 앞서 발생한 문제를 해결.
  • 애플리케이션에서 트랜잭션을 어떤 계층에 걸어야 하나?

비즈니스 로직과 트랜잭션
42

  • 트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작해야 한다.
    • 비즈니스 로직이 잘못되면 해당 비즈니스 로직으로 인해 문제가 되는 부분을 함께 롤백해야 하기 때문.
  • 트랜잭션을 시작하려면 커넥션이 필요하기 때문에, 서비스 계층에서 커넥션을 만들고, 트랜잭션 커밋 이후에 커넥션을 종료해야 한다.
  • 애플리케이션에서 DB 트랜잭션을 사용하려면 트랜잭션을 사용하는 동안 같은 커넥션을 유지해야 한다.
    • 그래야 같은 세션을 사용할 수 있다.

커넥션과 세션
43

애플리케이션에서 같은 커넥션을 유지하는 가장 단순한 방법은 커넥션을 파라미터로 전달해서 같은 커넥션이 사용되도록 유지하는 것이다.

MemberRepositoryV2

// JDBC - ConnectionParam
@Slf4j
public class MemberRepositoryV2 {

    private final DataSource dateSource;

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

    // 기존 save(), findById(), update(), delete() 생략..

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

        PreparedStatement pstmt = null;
        ResultSet rs = null;

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

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

    ...
}

MemberRepositoryV2는 기존 코드와 같고, 커넥션 유지가 필요한 두 메서드만 추가되었다. 두 메서드는 계좌이체 서비스 로직에서 호출하는 메서드이다.

  • findById(Connection con, String memberId)
  • update(Connection con, String memberId, int money)

주의 깊게 볼 코드!

  • 1.커넥션 유지가 필요한 두 메서드는 파라미터로 넘어온 커넥션을 사용해야 한다. 따라서, con = getConnection() 코드가 있으면 안된다.
  • 2.커넥션 유지가 필요한 두 메서드는 리포지토리에서 커넥션을 닫으면 안된다. 커넥션을 전달 받은 리포지토리 뿐만 아니라 이후에도 커넥션을 계속 이어서 사용하기 때문이다. 따라서, 서비스 로직이 끝날 때 트랜잭션을 종료하고 닫아야 한다.

MemberServiceV2

// 트랜잭션 - 파라미터 연동, 풀을 고려한 종료
@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, moeny);
            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.getConnection();
    • 트랜잭션을 시작하려면 커넥션이 필요.
  • con.setAutoCommit(false);
    • 트랜잭션을 시작하려면 자동 커밋 모드를 꺼야한다.
  • bizLogic(con, fromId, toId, moeny);
    • 트랜잭션이 시작된 커넥션을 전달하면서 비즈니스 로직을 수행한다.
    • 트랜잭션을 관리하는 로직과 실제 비즈니스 로직을 구분하기 위해 분리했다.
    • memberRepository.update(con, ...) : 리포지토리를 호출할 때 커넥션을 전달.
  • release(con);
    • finally {..}를 사용해서 커넥션을 모두 사용하고 나면 안전하게 종료한다.
    • 커넥션 풀을 사용하면 con.close()를 호출 했을 때 커넥션이 종료되는 것이 아니라 풀에 반납된다.
    • 현재 수동 커밋 모드로 동작하기 때문에 풀에 돌려주기 전에 기본 값인 자동 커밋 모드로 변경하는 것이 안전하다.

MemberServiceV2Test

// 트랜잭션 - 커넥션 파라미터 전달 방식 동기화
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 - 테스트 데이터를 제거하는 과정 생략(memberRepository.delete(...))

    // 정상이체 코드 동일..

    @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
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberEx = memberRepository.findById(memberEx.getMemberId());

        // memberA의 돈이 롤벡
        assertThat(findMemberA.getMoney()).isEqualTo(10000);
        assertThat(findMemberEx.getMoney()).isEqualTo(10000);
    }
}

트랜잭션 덕분에 계좌이체가 실패할 때 롤백을 수행해서 모든 데이터를 정상적으로 초기화 할 수 있게 되었다. 결과적으로 계좌이체를 수행하기 직전으로 돌아가게 된다.


남은 문제

애플리케이션에서 DB 트랜잭션을 적용하려면 서비스 계층이 매우 지저분해지고, 생각보다 복잡한 코드를 요구한다. 추가로 커넥션을 유지하도록 코드를 변경하는 것도 쉬운 일은 아니다.


<출처 : 인프런 - 스프링 DB 1편 : 데이터 접근 핵심 원리(김영한)>

댓글남기기