트랜잭션
트랜잭션은 DB가 수행하는 작업의 최소 단위이다. 즉 DB는 트랜잭션 단위로 작업을 수행한다. 트랜잭션에 대한 자세한 내용은 아래 글을 참고하자.
https://gunjoon.tistory.com/96
DB 세션
클라이언트가 DB 서버와 커넥션을 맺게 되면 DB 서버는 세션을 만들고 커넥션을 통한 모든 요청은 생성된 세션을 통해 실행한다. 세션은 트랜잭션을 시작하고, 커밋 또는 롤백을 통해 트랜잭션을 종료한다. 그 후 새로운 트랜잭션을 다시 시작할 수 있다. 사용자가 커넥션을 닫거나, 또는 DBA(DB 관리자)가 세션을 강제로 종료하면 세션은 종료된다.
트랜잭션 사용법
트랜잭션을 작성할 때는 아래 두 가지에 유의하면 된다.
- 트랜잭션 마지막에는 commit 연산 또는 rollback 연산을 수행해 트랜잭션을 종료한다. 트랜잭션 실행이 성공하면 commit 연산을 수행해 트랜잭션 실행 결과를 DB에 영구적으로 반영시키고 트랜잭션 실행에 실패했으면 rollback 연산을 호출해 DB의 상태를 트랜잭션 수행 전으로 되돌린다. 즉 commit과 rollback 연산으로 트랜잭션의 원자성을 만족시킨다.
- 트랜잭션은 병행(concurrently)하게 실행되므로 동시성 문제가 발생하지 않도록 작성한다. 트랜잭션, 트랜잭션 격리 수준, 락 동작 방식은 DB 마다 조금씩 다르기에 해당 DB의 매뉴얼을 숙지하고 있어야 한다.
이 글에서는 트랜잭션 격리 수준이 READ COMMITTED이라고 가정한다. 커밋을 호출하기 전까지는 트랜잭션 실행 결과가 임시로 반영된다. 따라서 해당 트랜잭션을 시작한 세션(사용자)에게만 실행 결과가 보이고 다른 세션(사용자)에게는 보이지 않는다.
자동 커밋, 수동 커밋
세션은 보통 자동 커밋 모드가 디폴트로 설정되어 있다. 자동 커밋 모드는 하나의 쿼리 문을 수행하고 정상적으로 수행되면 커밋을, 수행에 실패하면 롤백을 자동으로 실행하는 모드이다. 트랜잭션은 보통 여러 개의 쿼리 문으로 구성되기에 자동 커밋 모드가 아닌 수동 커밋 모드에서 실행하는 것이 적합하다.
set autocommit true; //자동 커밋 모드 설정
insert into member(member_id, money) values ('data1',10000); //자동 커밋
insert into member(member_id, money) values ('data2',10000); //자동 커밋
수동 커밋 모드는 클라이언트가 직접 commit 또는 rollback 연산을 실행하는 모드이다. 보통 세션을 수동 커밋 모드로 설정하는 것을 트랜잭션을 시작한다고 표현한다. 수동 커밋 모드에서는 반드시 commit 또는 rollback 연산을 실행해 트랜잭션을 종료해야 한다. 참고로 수동 커밋 모드나 자동 커밋 모드는 한번 설정하면 해당 세션에서는 계속 유지되며 중간에 변경하는 것이 가능하다.
set autocommit false; //수동 커밋 모드 설정(트랜잭션 시작)
insert into member(member_id, money) values ('data3',10000);
insert into member(member_id, money) values ('data4',10000);
commit; // 수동 커밋(트랜잭션 종료)
트랜잭션 적용
애플리케이션에서 계좌 이체 트랜잭션을 작성해 보자. 계좌 이체 로직(비즈니스 로직)은 하나의 트랜잭션으로 구성되어야 한다. 그래야 계좌 이체 로직이 정상적으로 수행되면 수행 결과가 DB에 반영되고 실패하면 롤백되어 DB에 반영되지 않기 때문이다. 아래 그림처럼 트랜잭션이 서비스 계층의 계좌 이체 로직을 감싸게 된다. 이때 중요한 점은 트랜잭션은 세션 단위로 유지되기 때문에 세션을 만든 커넥션을 트랜잭션 종료까지 유지해야 한다.(쉽게 커넥션이 세션이라 생각하면 편하다.)
Repository 코드
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;
@Slf4j
public class MemberRepositoryV2 {
private final DataSource dataSource;
public MemberRepositoryV2(DataSource dataSource) {
this.dataSource = dataSource;
}
private Connection getConnection() throws SQLException {
Connection con = dataSource.getConnection();
log.info("get connection = {}, class = {}", con, con.getClass());
return con;
}
public Member save(Member member) throws SQLException {
String sql = "insert into member(member_id, money) values (?,?)";
Connection conn = null;
PreparedStatement pstmt = null;
try {
conn = getConnection();
pstmt = conn.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(conn, pstmt, null);
}
}
public Member findById(String memberId) throws SQLException {
String sql = "select * from member where member_id = ?";
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
conn = getConnection();
pstmt = conn.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(conn, pstmt, rs);
}
}
public Member findById(Connection conn, String memberId) throws SQLException {
String sql = "select * from member where member_id = ?";
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
pstmt = conn.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(String memberId, int money) throws SQLException {
String sql = "update member set money = ? where member_id = ?";
Connection conn = null;
PreparedStatement pstmt = null;
try {
conn = getConnection();
pstmt = conn.prepareStatement(sql);
pstmt.setInt(1, money);
pstmt.setString(2, memberId);
pstmt.executeUpdate();
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(conn, pstmt, null);
}
}
public void update(Connection conn, String memberId, int money) throws SQLException {
String sql = "update member set money = ? where member_id = ?";
PreparedStatement pstmt = null;
try {
pstmt = conn.prepareStatement(sql);
pstmt.setInt(1, money);
pstmt.setString(2, memberId);
pstmt.executeUpdate();
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
JdbcUtils.closeStatement(pstmt);
}
}
public void delete(String memberId) throws SQLException {
String sql = "delete from member where member_id=?";
Connection conn = null;
PreparedStatement pstmt = null;
try {
conn = getConnection();
pstmt = conn.prepareStatement(sql);
pstmt.setString(1, memberId);
pstmt.executeUpdate();
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(conn, pstmt, null);
}
}
private void close(Connection conn, Statement stmt, ResultSet rs) {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
JdbcUtils.closeConnection(conn);
}
}
서비스 계층의 계좌 이체 로직(트랜잭션)은 Repository의 커넥션을 인자로 받는 findById와 update 메서드를 사용한다. 이 두개의 메서드는 다른 메서드와 달리 커넥션을 인자로 받으며 마지막에 커넥션을 정리하지 않는다. 이는 트랜잭션 종료까지 같은 커넥션을 유지하기 위함이다.
계좌 이체 로직
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 conn = dataSource.getConnection();
try {
//트랜잭션 시작
conn.setAutoCommit(false);
//비즈니스 로직 시작
bizLogic(conn, fromId, toId, money);
//커밋 (트랜잭션 종료)
conn.commit();
} catch (Exception e) {
//롤백 (트랜잭션 종료)
conn.rollback();
throw new IllegalStateException(e);
} finally {
//자원 해제
if(conn != null) {
try {
conn.setAutoCommit(true);
conn.close();
} catch (Exception e) {
log.info("error", e);
}
}
}
}
private void bizLogic(Connection conn, String fromId, String toId, int money) throws SQLException {
Member fromMember = memberRepository.findById(conn, fromId);
Member toMember = memberRepository.findById(conn, toId);
memberRepository.update(conn, fromId, fromMember.getMoney() - money);
memberRepository.update(toId, toMember.getMoney() + money);
}
}
- con.setAutoCommit(false)으로 수동 커밋 모드를 설정한다. 보통 수동 커밋 모드로 변경하는 것을 트랜잭션이 시작한다고 표현한다.
- 다음으로 비즈니스 로직(bizLogic 메서드)이 수행된다. 비즈니스 로직을 메서드로 구분한 이유는 트랜잭션 코드와 비즈니스 로직을 분리하기 위함이다. 트랜잭션 수행 중 같은 커넥션을 계속 유지한다.
- 트랜잭션이 성공적으로 수행되면 commit 하고 실패하면 rollback 한다.
- 마지막에는 자원(커넥션)을 해제한다. 커넥션을 생성했으면 닫아주고 커넥션 풀을 사용했다면 커넥션 풀에 반납한다. 그런데 그냥 반납할 경우 커넥션의 세션은 수동 커밋 모드로 설정된 채로 반납된다. 우리는 세션이 기본적으로 자동 커밋 모드로 설정되어 있다고 생각하기에 자동 커밋 모드로 변경 후 반납하는 것이 좋다.
아래는 테스트 코드이다.
package hello.jdbc.service;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV2;
import org.assertj.core.api.Assertions;
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.*;
/**
* 트랜잭션 - 커넥션 파라미터 전달 방식 동기화
*/
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
memberA = memberRepository.findById(memberA.getMemberId());
memberB = memberRepository.findById(memberB.getMemberId());
Assertions.assertThat(memberA.getMoney()).isEqualTo(8000);
Assertions.assertThat(memberB.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
Member finalMemberA = memberA;
Member finalMemberEx = memberEx;
Assertions.assertThatThrownBy(()->
memberService.accountTransfer(finalMemberA.getMemberId(), finalMemberEx.getMemberId(), 2000))
.isInstanceOf(IllegalStateException.class);
//then
memberA = memberRepository.findById(memberA.getMemberId());
memberEx = memberRepository.findById(memberEx.getMemberId());
Assertions.assertThat(memberA.getMoney()).isEqualTo(10000);
Assertions.assertThat(memberEx.getMoney()).isEqualTo(10000);
}
}