3. 트랜잭션 이해
1. 트랜잭션
DB에서의 트랜잭션은 하나의 거래를 안전하게 처리하도록 보장해주는 것을 뜻하는데 예를 들어 A의 5000원을 B에게 계좌이체를 한다고 하면 A의 잔고를 5000원 감소하고 B의 잔고를 5000원 증가해야하는 것과 같이 고려해야할 점이 많다. 계좌이체는 이렇게 2가지 작업이 합쳐져 하나의 작업처럼 동작을 해야한다.
만약 A의 잔고 감소에 성공했는데 B의 잔고를 증가시키지 못했다면 계좌이체는 실패하고 A 잔고만 감소하는 문제가 생긴다.
트랜잭션을 사용하게 되면 감소와 증가를 둘 다 함께 성공해야 저장하고 하나라도 실패하면 거래 전 상태로 되돌아갈 수 있다. 모든 작업에 성공하여 DB에 정상 반영하는 것을 커밋(Commit)이라고 하고 거래 전으로 되돌리는 것을 롤백(Rollback)이라 한다.
2. 데이터베이스 연결 구조와 DB 세션
사용자는 웹 애플리케이션 서버(WAS)나 DB접근 툴과 같은 클라이언트를 사용하여 데이터베이스 서버에 접근할 수 있는데 클라이언트는 데이터베이스 서버에 연결을 요청하고 커넥션을 맺게 된다. 이때 데이터베이스는 내부에 세션을 만드는데 앞으로 해당 커넥션을 통한 모든 요청은 세션을 통해서 실행하게 된다.
즉, 개발자가 클라이언트를 통하여 SQL을 전달하면 현재 커넥션에 연결된 세션이 SQL을 실행하게 되는 것이다.
세션은 트랜잭션을 시작하고 커밋 또는 롤백을 통하여 트랜잭션을 종료한다. 이후 새로운 트랜잭션을 다시 시작할 수 있다.
만약 사용자가 커넥션을 닫거나 DB관리자가 세션을 강제로 종료하면 세션은 종료된다.
커넥션 풀이 10개의 커넥션을 생성하면 세션도 똑같이 10개가 만들어진다.
3. 트랜잭션 개념 이해
트랜잭션 사용법
데이터 변경 쿼리를 실행하고 데이터베이스에 그 결과를 반영하려면 커밋 명령어인 commit을 호출하고, 결과 반영하고 싶지 않으면 rollback을 호출하면 된다.
커밋을 호출하기 전까지는 임시로 데이터를 저장하는 것이므로 트랜잭션을 시작한 세션(사용자)에게만 변경 데이터가 보이고 다른 세션(사용자)에게는 변경 데이터가 보이지 않는다.
세션 1, 2 둘 다 가운데 있는 기본 테이블을 조회하면 해당 데이터가 그대로 조회된다.
세션 1은 트랜잭션을 시작하고 신규 회원1, 2를 DB에 추가했고 아직은 커밋을 하지 않은 상태이다(임시 저장)
세션 1은 select 쿼리를 실행하여 본인이 입력한 신규 회원을 조회할 수 있지만 세션 2는 조회할 수 없다. (세션1이 커밋을 하지 않았기 때문)
세션 1이 신규 데이터 추가한 후 commit을 호출 하면 실제 데이터베이스에도 반영이 되어 데이터 상태도 임시에서 완료로 변경된다.
이제 세션 2에서도 신규 회원 조회가 가능하다.
세션 1이 신규 데이터 추가 후에 commit대신 rollback을 호출하면 데이터베이스에 반영한 모든 데이터가 처음 상태로 복구된다.
수정하거나 삭제한 데이터도 rollback을 호출하면 모두 트랜잭션 시작하기 직전 상태로 복구된다.
자동 커밋, 수동 커밋
자동 커밋으로 설정하면 쿼리 실행 직후에 자동으로 커밋을 호출해줘서 편리하지만 쿼리를 실행할 때 마다 자동으로 커밋이 되어버리기 때문에 트랜잭션 기능을 제대로 사용할 수 없다.
따라서 commit, rollback을 직접 호출하면서 트랜잭션 기능을 제대로 수행하려면 자동 커밋을 끄고 수동 커밋을 사용해야한다.
set autocommit true; //자동 커밋 모드 설정
insert into member(member_id, money) values ('data1',10000); //자동 커밋
insert into member(member_id, money) values ('data2',10000); //자동 커밋
set autocommit false; //수동 커밋 모드 설정
insert into member(member_id, money) values ('data3',10000);
insert into member(member_id, money) values ('data4',10000);
commit; //수동 커밋
보통 자동 커밋 모드가 기본으로 설정되어 있는 경우가 많아서 수동 커밋 모드로 설정하는 것을 트랜잭션을 시작한다고 표현할 수 있다.
4. DB락
세션 1이 트랜잭션 시작하고 데이터 수정하는 동안 커밋을 수행하지 않았는데 세션 2에서도 동시에 같은 데이터를 수정하게 되면 여러 문제가 발생하게 된다. (원자성 문제)
이러한 문제를 방지하기 위해 락을 통해 세션이 트랜잭션을 시작하고 데이터 수정하는 동안에는 커밋이나 롤백 전까지 다른 세션에서 해당 데이터를 수정할 수 없게 막아야 한다.
세션 1이 트랜잭션을 시작하고, member A의 데이터를 500원으로 업데이트 하고 커밋을 하지 않았을 때 member A 로우의 락은 세션 1이 갖게 된다.
이후 세션 2는 member A 의 데이터를 1000원으로 수정하려 하는데 세션 1이 커밋이나 롤백해서 종료하지 않았으므로 세션 2는 락을 획득하지 못해 아직 데이터를 수정할 수 없다. -> 세션 2는 락이 돌아올 때까지 대기해야 한다.
SET LOCK_TIMEOUT 6000 : 60초 안에 락을 얻지 못하면 예외 발생
세션 1이 커밋되면서 락을 반납하고 이후 대기하던 세션 2가 락 획득 후 세션 2의 업데이트가 반영된다. 이후에 세션 2도 커밋을 호출하여 락을 반납해야 한다.
5. 트랜잭션 적용1
먼저 트랜잭션 없이 단순 계좌이체 비즈니스 로직만 구현해보고자 한다.
[MemberServiceV1.java]
@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("이체중 예외 발생");
}
}
}
fromId의 회원을 조회해서 toId의 회원에게 money 만큼의 돈을 계좌 이체하는 로직이다.
fromId 회원의 돈을 money 만큼 감소 -> UPDATE SQL 실행
toId 회원의 돈을 money 만큼 증가 -> UPDATE SQL 실행
예외 상황 테스트를 위해 toId가 "ex"인 경우 예외를 발생시킨다.
[MemberServiceV1Test.java]
/*
* 기본 동작, 트랜젝션 없어서 문제 발생
* */
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가 dataSource 필요로함
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);
/*
memberA -> memberB로 2000원 계좌 이체
*/
//then : 계좌 이체가 정상 수행되었는지 검증
Member findMemberA = memberRepository.findById(memberA.getMemberId());
Member findMemberB = memberRepository.findById(memberB.getMemberId());
assertThat(findMemberA.getMoney()).isEqualTo(8000); //2000원 감소
assertThat(findMemberB.getMoney()).isEqualTo(12000); //2000원 증가
}
@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);
//!!!!!!memberEx 회원의 ID는 ex이므로 중간에 예외 발생!!!!!!
//then : 계좌이체 실패하고 memberA의 돈만 2000원 감소
Member findMemberA = memberRepository.findById(memberA.getMemberId());
Member findMemberEx =
memberRepository.findById(memberEx.getMemberId());
//memberA의 돈만 2000원 줄었고, ex의 돈은 10000원 그대로이다.
assertThat(findMemberA.getMoney()).isEqualTo(8000);
assertThat(findMemberEx.getMoney()).isEqualTo(10000);
}
}
테스트에서 사용한 데이터를 제거하는 방법으로는 트랜잭션을 활용하여 롤백해버리는 게 더 나은 방법이다.
6. 트랜잭션 적용2
DB 트랜잭션을 사용하여 문제점을 해결해 보는데 애플리케이션에서 트랜잭션은 어디에서 시작하고 어디에서 커밋을 해야할까라는 의문점이 생긴다.
트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작해야 한다. 왜냐면 비즈니스 로직이 잘못되기라도 한다면 해당 비즈니스 로직으로 인하여 문제가 되는 부분을 함께 롤백해야 하기 때문이다.
트랜잭션을 시작하려면 커넥션이 필요로한데 그러면 결국 서비스 계층에서 커넥션을 만들고 트랜잭션 커밋 이후 커넥션을 종료해야 한다.
또한 DB 트랜잭션을 사용하기 위해선 트랜잭션 사용하는 동안 같은 커넥션을 유지해야한다. (같은 세션 사용하기 위해)
애플리케이션에서 같은 커넥션을 유지하기 위한 가장 단순한 방법은 커넥션을 파라미터로 전달해 같은 커넥션이 사용되도록 유지하는 것이다.
repository가 파라미터를 통해 같은 커넥션을 유지할 수 있게 파라미터를 추가한다.
[MemberRepositoryV2.java]
/*
* JDBC - ConnectionParam
* */
@Slf4j
public class MemberRepositoryV2 {
//의존 관계 주입
private final DataSource dataSource;
public MemberRepositoryV2(DataSource dataSource) {
this.dataSource = dataSource;
}
public Member save(Member member) throws SQLException {
String sql = "insert into member(member_id, money) values(?,?)";
Connection con = null;
PreparedStatement pstmt = null;
/*
* PreparedStatement는 SQL Injection을 방어할 수 있다. -> ?로 바인딩
* */
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, member.getMemberId()); //sql에 대한 파라미터 바인딩
pstmt.setInt(2, member.getMoney());
pstmt.executeUpdate();
return member;
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally{
//시작과 역순으로 close
/*
pstmt.close(); //만약 Exception이 터지면 다음 코드도 호출 안 될 수 있음
con.close();
*/
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);
}
}
//!!!!!!추가 1!!!!!!
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);
//JdbcUtils.closeConnection(con);
}
}
/*
* 회원 수정
* */
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);
}
}
/*
* 회원 수정
* */
//!!!!!!추가 2!!!!!!
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 {
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);
}
}
//해제
/*
* 해제는 rs -> pstmt -> con 으로
* */
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();
//dataSource에서 커넥션 획득
log.info("get connection={}, class={}",con, con.getClass());
return con;
}
}
이 코드에서는 기존 MemberRepositoryV1 코드와 같은데 커넥션 유지에 필요한 두 메서드만 추가 되었다. (계좌이체 서비스 로직에서 호출하는 메서드)
커넥션 유지가 필요한 두 메서드는 파라미터로 넘어온 커넥션을 사용해야해서 con=getConnection()코드가 있으면 안된다.
또한 커넥션 유지가 필요하니까 Repository에서 커넥션을 닫으면 안된다. 커넥션을 전달 받은 Repository 뿐만 아니라 이후에 커넥션을 게속 이어서 사용해야하기 때문이다. 서비스 로직이 끝날 때 트랜잭션을 종료하고 닫아야한다.
다음은 가장 중요한 트랜잭션 연동 로직이다.
[MemberServiceV2.java]
/*
* 트랜잭션 - 파라미터 연동, 풀을 고려한 종료
* */
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV2 {
private final MemberRepositoryV2 memberRepository;
private final DataSource dataSource;
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("이체중 예외 발생");
}
}
}
[MemberServiceV2Test.java]
/*
* 트랜잭션 -커넥션 파라미터 전달 방식 동기화
* */
class MemberServiceV2Test {
private MemberRepositoryV2 memberRepository;
private MemberServiceV2 memberService;
@BeforeEach
void before() {
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
//memberRepository가 dataSource 필요로함
memberRepository = new MemberRepositoryV2(dataSource);
memberService = new MemberServiceV2(memberRepository, dataSource);
}
@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
Member findMemberA = memberRepository.findById(memberA.getMemberId());//memberA 금액 2000원 감소
Member findMemberEx = memberRepository.findById(memberEx.getMemberId());//회원 ID가 ex이므로 예외 발생
//memberA의 돈이 롤백 되어야함
assertThat(findMemberA.getMoney()).isEqualTo(10000); //트랜잭션 롤백을 복구
assertThat(findMemberEx.getMoney()).isEqualTo(10000);
}
}
트랜잭션 덕분에 계좌이체가 실패할 경우 롤백을 통해 데이터 초기화를 할 수 있게 되었다. 다음엔 이제 스프링을 통해 코드를 간결하게 변경을 해보려한다.
'Spring > Spring boot' 카테고리의 다른 글
검증 1(validation) (0) | 2023.08.17 |
---|---|
[JDBC] 트랜잭션 2 (0) | 2023.08.17 |
[JDBC] 커넥션풀, 데이터 소스 (1) | 2023.08.10 |
[JDBC] (0) | 2023.08.10 |
@PathVariable 어노테이션 (0) | 2023.05.31 |