쿼리문이 아니라 아래처럼 자바코드로 계좌이체하는 로직을 했을 때, 돈을 빼는 건 성공했는데 돈을 옮기기전에 오류가 나면 어떻게 될까??

@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 static void validation(Member toMember) {
        if(toMember.getMemberId().equals("ex")){
            throw new IllegalStateException("예외");
        }
    }

}

그렇다. A의 계좌에서만 돈이빠지고, B의 계좌는 그대로인 것을 보니, 오토커밋모드를 제공하는 것을 알 수 있다. 하나의 쿼리문이 끝나면 커밋을 자동으로 해줘서 원자성을 지키려는 자바의 좋은 조치이긴 하나, 계좌이체 같이 연속적으로 일어나는 것은 트랜잭션의 범위를 어떻게 해야할까..?

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

정확히는 애플리케이션에서 트랜잭션을 어떤 계층에 걸어야 할까? 쉽게 이야기해서 트랜잭션을 어디에서 시작하고, 어디에서 커밋해야할까? 라는 고민을 항상 갖고 있자.

결국은 아래 사진처럼 “비즈니스 로직”이 있는 서비스 계층에서 시작해야하고 거기서 끝나야 한다. 한 비즈니스 로직에서 잘못되면 여기서 롤백되고 끝나야 한다!!

image.png

아래 로직을 보자…


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

con.setAutoCommit(false);//트랜잭션 시작 으로 표현하고 있다. 이렇게 자동 커밋 모드를 수동 커밋 모드로 변경하는 것을 트랜잭션을 시작한(연다)다고 보통 표현한다. 바로바로 커밋되면 안되므로!!

그리고 같은 connect를 쓰기 위해 매개변수에 모두 con을 넘겨주고 있는 것을 볼 수 있다!!