[SpringDB1] 트랜잭션 적용 예제
트랜잭션 - 적용 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 트랜잭션을 사용해서 앞서 발생한 문제를 해결.
- 애플리케이션에서 트랜잭션을 어떤 계층에 걸어야 하나?
비즈니스 로직과 트랜잭션
- 트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작해야 한다.
- 비즈니스 로직이 잘못되면 해당 비즈니스 로직으로 인해 문제가 되는 부분을 함께 롤백해야 하기 때문.
- 트랜잭션을 시작하려면 커넥션이 필요하기 때문에, 서비스 계층에서 커넥션을 만들고, 트랜잭션 커밋 이후에 커넥션을 종료해야 한다.
- 애플리케이션에서 DB 트랜잭션을 사용하려면 트랜잭션을 사용하는 동안 같은 커넥션을 유지해야 한다.
- 그래야 같은 세션을 사용할 수 있다.
커넥션과 세션
애플리케이션에서 같은 커넥션을 유지하는 가장 단순한 방법은 커넥션을 파라미터로 전달해서 같은 커넥션이 사용되도록 유지하는 것이다.
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 트랜잭션을 적용하려면 서비스 계층이 매우 지저분해지고, 생각보다 복잡한 코드를 요구한다. 추가로 커넥션을 유지하도록 코드를 변경하는 것도 쉬운 일은 아니다.
댓글남기기