-
[김영한 스프링] 12. 트랜잭션 이해 - 트랜잭션 적용Spring/스프링 DB 1편 - 데이터 접근 핵심 원리 2023. 12. 28. 23:23
트랜잭션 - 적용1
실제 애플리케이션에서 DB 트랜잭션을 사용해서 계좌이체 같이 원자성이 중요한 비즈니스 로직을 어떻게 구현하는지 알아보자.
먼저 트랜잭션 없이 단순하게 계좌이체 비즈니스 로직만 구현해보자.
MemberServiceV1
main/java/hello/jdbc/service/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 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 void validation(Member toMember) { if (toMember.getMemberId().equals("ex")) { throw new IllegalStateException("이체 중 예외 발생"); } } }
- formId의 회원을 조회해서 toId의 회원에게 money 만큼의 돈을 계좌이체 하는 로직이다.
- fromId 회원의 돈을 money 만큼 감소한다. -> UPDATE SQL 실행
- toId 회원의 돈을 money 만큼 증가한다. -> UPDATE SQL 실행
- 예외 상황을 테스트해보기 위해 toId가 "ex"인 경우 예외를 발생한다.
MemberServiceV1Test - 정상이체
test/java/hello/jdbc/service/MemberServiceV1Test 생성
package hello.jdbc.service; import hello.jdbc.domain.Member; import hello.jdbc.repository.MemberRepositoryV1; 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.*; /** * 기본 동작, 트랜잭션이 없어서 문제 발생 */ 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); } @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); } }
- given : 다음 데이터를 저장해서 테스트를 준비한다.
- memberA 10000원
- memberB 10000원
- when : 계좌이체 로직을 실행한다.
- memberService.accountTransfer()를 실행한다.
- memberA -> memberB로 2000원 계좌이체 한다.
- memberA 금액이 2000원 감소한다.
- memberB의 금액이 2000원 증가한다.
- then : 계좌이체가 정상 수행되었는지 검증한다.
- memberA 8000원 - 2000원 감소
- memberB 12000원 - 2000원 증가
결과
MemberServiceV1Test - 이체 중 예외 발생
package hello.jdbc.service; import hello.jdbc.domain.Member; import hello.jdbc.repository.MemberRepositoryV1; 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.*; /** * 기본 동작, 트랜잭션이 없어서 문제 발생 */ 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); } @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 findMemberB = memberRepository.findById(memberEx.getMemberId()); assertThat(findMemberA.getMoney()).isEqualTo(8000); assertThat(findMemberB.getMoney()).isEqualTo(10000); } }
- given : 다음 데이터를 저장해서 테스트를 준비한다.
- memberA 10000원
- memberEx 10000원
- when : 계좌이체 로직을 실행한다.
- memberService.accountTransfer()를 실행한다.
- memberA -> memberEx로 2000원 계좌이체 한다.
- memberA의 금액이 2000원 감소한다.
- memberEx 회원의 ID는 ex이므로 중간에 예외가 발생한다. -> 이 부분이 중요하다.
- then : 계좌이체는 실패한다. memberA의 돈만 2000원 줄어든다.
- memberA 8000원 - 2000원 감소
- memberEx 10000원 - 중간에 실패로 로직이 수행되지 않았다. 따라서 그대로 10000원으로 남아있게 된다.
결과
정리
이체 중 예외가 발생하게 되면 memberA의 금액은 10000원 -> 8000원으로 2000원 감소한다. 그런데 memberEx의 돈은 그대로 10000원으로 남아있다. 결과적으로 memberA의 돈만 2000원 감소한 것이다!
MemberServiceV1Test - 전체 실행
package hello.jdbc.service; import hello.jdbc.domain.Member; import hello.jdbc.repository.MemberRepositoryV1; 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.*; /** * 기본 동작, 트랜잭션이 없어서 문제 발생 */ 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 Member findMemberA = memberRepository.findById(memberA.getMemberId()); Member findMemberB = memberRepository.findById(memberEx.getMemberId()); assertThat(findMemberA.getMoney()).isEqualTo(8000); assertThat(findMemberB.getMoney()).isEqualTo(10000); } }
테스트 데이터 제거
테스트가 끝나면 다음 테스트에 영향을 주지 않기 위해 @AfterEach에서 테스트에 사용한 데이터를 모두 삭제한다.
- @BeforeEach : 각각의 테스트가 수행되기 전에 실행된다.
- @AfterEach : 각각의 테스트가 실행되고 난 이후에 실행된다.
- 테스트 데이터를 제거하는 과정이 불편하지만, 다음 테스트에 영향을 주지 않으려면 테스트에서 사용한 데이터를 모두 제거해야 한다. 그렇지 않으면 이번 테스트에서 사용한 데이터 때문에 다음 테스트에서 데이터 중복으로 오류가 발생할 수 있다.
- 테스트에서 사용한 데이터를 제거하는 더 나은 방법으로는 트랜잭션을 활용하면 된다. 테스트 전에 트랜잭션을 시작하고, 테스트 이후에 트랜잭션을 롤백해버리면 데이터가 처음 상태로 돌아온다. 이 방법은 이후에 설명하겠다.
로그
트랜잭션 - 적용2
- 이번에는 DB 트랜잭션을 사용해서 앞서 발생한 문제점을 해결해보자.
- 애플리케이션에서 트랜잭션을 어떤 계층에 걸어야 할까? 쉽게 이야기해서 트랜잭션을 어디에서 시작하고, 어디에서 커밋해야할까?
비즈니스 로직과 트랜잭션
- 트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작해야 한다. 비즈니스 로직이 잘못되면 해당 비즈니스 로직으로 인해 문제가 되는 부분을 함께 롤백해야 하기 때문이다.
- 그런데 트랜잭션을 시작하려면 커넥션이 필요하다. 결국 서비스 계층에서 커넥션을 만들고, 트랜잭션 커밋 이후에 커넥션을 종료해야 한다.
- 애플리케이션에서 DB 트랜잭션을 사용하려면 트랜잭션을 사용하는 동안 같은 커넥션을 유지해야한다. 그래야 같은 세션을 사용할 수 있다.
커넥션과 세션
애플리케이션에서 같은 커넥션을 유지하려면 어떻게 해야할까? 가장 단순한 방법은 커넥션을 파라미터로 전달해서 같은 커넥션이 사용되도록 유지하는 것이다.
먼저 리포지토리가 파라미터를 통해 같은 커넥션을 유지할 수 있도록 파라미터를 추가하자.
코드 유지를 위해 MemberRepositoryV1은 남겨두고 MemberRepositoryV2를 만들자.
MemberRepositoryV2
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 - ConnectionParam */ @Slf4j public class MemberRepositoryV2 { private final DataSource dataSource; public MemberRepositoryV2(DataSource dataSource) { this.dataSource = dataSource; } public void delete (String memberId) throws SQLException { String sql = "delete from 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); } } 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 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 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; } } public Member findById(Connection con, String memberId) throws SQLException { String sql = "select * from member where member_id = ?"; PreparedStatement pstmt = null; ResultSet rs = null; try { 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 Member save(Member member) throws SQLException { String sql = "insert into member(member_id, money) values (?, ?)"; Connection con = null; PreparedStatement pstmt = null; try { con = getConnection(); pstmt = con.prepareStatement(sql); pstmt.setString(1, member.getMemberId()); pstmt.setInt(2, member.getMoney()); pstmt.executeUpdate(); return member; } 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; } }
MemberRepositoryV2는 기존 코드와 같고 커넥션 유지가 필요한 다음 두 메서드가 추가되었다. 참고로 다음 두 메서드는 계좌이체 서비스 로직에서 호출하는 메서드이다.
- findById(Connection con, String memberId)
- update(Connection con, String memberId, int money)
주의 - 코드에서 다음 부분을 주의해서 보자!
- 1. 커넥션 유지가 필요한 두 메서드는 파라미터로 넘어온 커넥션을 사용해야 한다. 따라서 con = getConnection() 코드가 있으면 안 된다.
- 2. 커넥션 유지가 필요한 두 메서드는 리포지토리에서 커넥션을 닫으면 안 된다. 커넥션을 전달받은 리포지토리뿐만 아니라 이후에도 커넥션을 계속 이어서 사용하기 때문이다. 이후 서비스 로직이 끝날 때 트랜잭션을 종료하고 닫아야 한다.
이제 가장 중요한 트랜잭션 연동 로직을 작성해보자.
기존 MemberServiceV1을 복사해서 새로운 MemberServiceV2를 만들고 수정하자.
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); // 트랜잭션 시작 bizLogin(con, fromId, toId, money); con.commit(); // 성공 시 커밋 } catch (Exception e) { con.rollback(); // 실패 시 롤백 throw new IllegalStateException(e); } finally { release(con); } } private void bizLogin(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); //트랜잭션 시작
- 트랜잭션을 시작하려면 자동 커밋 모드를 꺼야 한다. 이렇게 하면 커넥션을 통해 세션에 set autocommit false 가 전달되고, 이후부터는 수동 커밋 모드로 동작한다. 이렇게 자동 커밋 모드를 수동 커밋 모드로 변경하는 것을 트랜잭션을 시작한다고 보통 표현한다.
- bizLogic(con, fromId, toId, money);
- 트랜잭션이 시작된 커넥션을 전달하면서 비즈니스 로직을 수행한다.
- 이렇게 분리한 이유는 트랜잭션을 관리하는 로직과 실제 비즈니스 로직을 구분하기 위함이다.
- memberRepository.update(con..) : 비즈니스 로직을 보면 리포지토리를 호출할 때 커넥션을 전달하는 것을 확인할 수 있다.
- con.commit(); //성공 시 커밋
- 비즈니스 로직이 정상 수행되면 트랜잭션을 커밋한다.
- con.rollback(); //실패 시 롤백
- catch(Ex){..}를 사용해서 비즈니스 로직 수행 도중에 예외가 발생하면 트랜잭션을 롤백한다.
- release(con);
- finally {..}를 사용해서 커넥션을 모두 사용하고 나면 안전하게 종료한다. 그런데 커넥션 풀을 사용하면 con.close()를 호출했을 때 커넥션이 종료되는 것이 아니라 풀에 반납된다. 현재 수동 커밋모드로 동작하기 때문에 풀에 돌려주기 전에 기본 값인 자동 커밋 모드로 변경하는 것이 안전하다.
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; import static org.junit.jupiter.api.Assertions.*; /** * 트랜잭션 - 커넥션 파라미터 전달 방식 동기화 */ class MemberServiceV2Test { public static final String MEMBER_A = "memberA"; public static final String MEMBER_B = "memberB"; public static final String MEMBER_EX = "ex"; 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(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 Member findMemberA = memberRepository.findById(memberA.getMemberId()); Member findMemberB = memberRepository.findById(memberEx.getMemberId()); assertThat(findMemberA.getMoney()).isEqualTo(8000); assertThat(findMemberB.getMoney()).isEqualTo(10000); } }
정상이체 - accountTransfer()
기존 로직과 같아서 생략한다.
이체 중 예외 발생 - accountTransferEx()
- 다음 데이터를 저장해서 테스트를 준비한다.
- memberA 10000원
- memberEx 10000원
- 계좌이체 로직을 실행한다.
- memberService.accountTransfer()를 실행한다. 커넥션을 생성하고 트랜잭션을 시작한다.
- memberA memberEx로 2000원 계좌이체 한다.
- memberA의 금액이 2000원 감소한다.
- memberEx 회원의 ID는 ex이므로 중간에 예외가 발생한다.
- 예외가 발생했으므로 트랜잭션을 롤백한다.
- 계좌이체는 실패했다. 롤백을 수행해서 memberA의 돈이 기존 10000원으로 복구되었다.
- memberA 10000원 - 트랜잭션 롤백으로 복구된다.
- memberEx 10000원 - 중간에 실패로 로직이 수행되지 않았다. 따라서 그대로 10000원으로 남아있게 된다.
트랜잭션 덕분에 계좌이체가 실패할 때 롤백을 수행해서 모든 데이터를 정상적으로 초기화할 수 있게 되었다. 결과적으로 계좌이체를 수행하기 직전으로 돌아가게 된다.
출처 : https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-db-1
'Spring > 스프링 DB 1편 - 데이터 접근 핵심 원리' 카테고리의 다른 글
[김영한 스프링] 14. 스프링과 문제 해결(트랜잭션) - 트랜잭션 매니저 (0) 2023.12.29 [김영한 스프링] 13. 스프링과 문제 해결(트랜잭션) - 문제점들 & 추상화 & 동기화 (1) 2023.12.29 [김영한 스프링] 11. 트랜잭션 이해 - DB 락 (1) 2023.10.19 [김영한 스프링] 10. 트랜잭션 이해 - 트랜잭션 DB 예제 (1) 2023.10.19 [김영한 스프링] 09. 트랜잭션 이해 - 개념 이해 & 데이터베이스 연결 구조와 DB 세션 (1) 2023.10.19 - formId의 회원을 조회해서 toId의 회원에게 money 만큼의 돈을 계좌이체 하는 로직이다.