728x90
1. 트랜잭션 - 개념 이해
- data 저장시 file이 아닌 db에 저장하는 이유
- db는 transaction이라는 개념을 지원하기 때문
- transaction
- 거래
- 예시 : a와 b의 돈거래 : a 잔고 감소, b 잔고 증가
- 핵심
- 2가지 작업이 합쳐져서 하나의 작업처럼 동작
- 모든 작업이 성공해서 db에 정상 반영하는 것 : commit
- 작업 중 1개라도 실패해서 거래 이전으로 되돌리는 것 : rollback
2. 트랜잭션 ACID
- 원자성(atomictiy) : 여러작업이 하나의 작업인 것처럼 모두 성공 하거나 모두 실패
- 일관성(consistency) : db 상태를 유지 - 무결성 제약 조건을 만족
- 격리성(isolation) : 동시에 같은 데이터를 수정하지 못하도록 한다.
- 지속성(durability) : commit이 끝난 후 결과가 항상 기록
2.1. 격리성
- 격리성
- 완벽히 보장시 동시 처리 성능이 매우 나빠짐
- 격리성을 너무 완화시 db가 꼬이는 문제 발생
- 트랜잭션 격리 수준
- Read Uncommited (커밋 안된 것도 읽기)
- read committed (커밋된 읽기 - 많이 사용)
- repeatable read (반복 가능한 읽기)
- serializable (직렬화)
3. db연결 구조와 DB 세션
- transaction을 위한 기본 지식
- 사용자는 was 나 db 접근 client를 사용해서 db server에 접근
- 이때 db server는 내부에 session이라는 것을 만든다.
- connection을 통한 모든 요청은 session을 통해서 실행 - 개발자가 client를 통해 sql을 전달하면 connection을 통해 연결된 session이 sql을 실행
- session
- transaction 시작
- commit, rollback 통해 transaction 종료
- 사용자가 1) connection을 닫거나, DBA가 2) 세션을 강제 종료시 session 종료
- connection pool 같은 경우 connection이 10개 생성되면 세션도 10개 만들어진다.
4. transaction - db 예제1 - 개념 이해
- transaction 사용법
- 변경 결과 저장 : commit
- 변경 결과 원복 : rollback
- commit과 rollback 수행 전까지는 임시로 data를 저장하는 것
- 변경을 한 사용자의 session에서 변경 data가 보이고 다른 세션에게서는 변경 data가 보이지 않는다.
- 변경 : insert, update, delete 로 표현
5. transaction - db 예제2 - 자동 커밋, 수동 커밋
- 변경불가 자동 커밋
- DDL : Create, Arter, Drop, Rename, Truncate, Comment
- DCL : Grant, Revoke
- 변경 가능 자동 커밋 - 수동 커밋으로 변경 가능
- DML : Insert, Udapte, Delete
- 자동 commit, 수동 commit 명령어
set autocommit true; // default
set autocommit false; // 수동 commit으로 전환
- 설정 유지
- 자동 commit, 수동 commit 설정 후 해당 세션 동안에는 계속 유지
- 중간에 변경 가능
- 수동 commit
- 수동 commit일 때 transaction을 시작한다고 표현을 할 수 있다.
- 작업 수행 후 , commit, rollback을 호출 필요 == commit 과 rollback의 선택권을 가진다.
- 둘다 호출 안할 시, db 자체의 timeout 설정 시간 초과 후 rollback 된다.
6. transaction - db 예제3 - transaction 실습
- h2 db session 2개 연결하는 방법
- http://localhost:8082 를 직접 입력해서 완전히 새로운 세션에서 연결
- localhost:8082 뒤의 JSessionid 값이 다른 것을 확인하기
- localhost:8082 뒤의 JSessionid 값이 다른 것을 확인하기
- 주의
- H2 데이터베이스 웹 콘솔 창을 2개 열때 기존 URL을 복사하면 안된다.
- URL을 복사하면 같은 세션( jsessionId )에서 실행되어서 원하는 결과가 나오지 않을 수 있다.
- http://localhost:8082 를 직접 입력해서 완전히 새로운 세션에서 연결
- 계좌 이체 실패 시, commit 과 rollback 차이점
set autocommit false;
update member set money=10000 - 2000 where member_id = "memberA"; // success
update member set money=10000 + 2000 where member_idddd = "memberB"; // error
// error 문구
Column "MEMBER_IDDD" not found;
SQL statement: update member set money=10000 + 2000 where member_iddd = 'memberB'
- 정리
- 수동 커밋일 때는 최종적으로 아래 2개의 코드를 작성해야 그 동안 수정한 개별의 작업들이 실제로 저장 혹은 원복이 된다.
commit;
rollback; - 자동 commit도 transaction이 일어난다. 하지만 변경 하나하나 수행이 끝나자 마자 자동으로 commit;을 해버리기 때문에 transcation을 제대로 수행 할 수 없다.
- 수동 커밋일 때는 최종적으로 아래 2개의 코드를 작성해야 그 동안 수정한 개별의 작업들이 실제로 저장 혹은 원복이 된다.
7. DB 락 - 개념
- 상황 : transaction을 시작하고 data를 수정하는 동안 아직 commit을 수행하지 않았는데, session에서 동시에 같은 data를 수정하게 되면 여러 문제 발생
- 해당 문제를 방지하기 위해 session이 transaction을 시작하고 data를 수정하는 동안에는 commit이나 rollback 전까지 다른 session에서 해당 data를 수정할 수 없게 막아야 한다.
- db가 제공하는 해결 방안
- Lock이란 개념 제공
- lock
- 락이 없어서 대기 하고 있는 다른 session은 일정 시간동안만 대기하다가 일정 시간 초과 후 타임아웃 오류가 발생한다.
- timeout 설정
set lock_timeout <milliseconds> // 락 timeout 시간 설정
// timeout 오류
Timeout trying to lock table {0};
SQL statement: update member set money=10000 - 2000 where member_id = 'memberA'
8. DB 락 - 조회
- 일반적인 조회시는 lock과 상관없이 접근이 가능
물론 commit 이전의 값을 보는 것 - 조회시 lock이 필요한 경우
- transaction 종료 시점까지 해당 data를 다른 곳에서 변경하지 못하도록 강제로 막아야 할 때 사용
- select로 필요한 정보를 가져다가 service에 그 값으로 결과를 도출한 값을 반환 할 때 문제가 생기는 경우를 의미
- 조회시에도 lock을 거는 방법 (select for update)
set autocommit false;
select * from member where member_id = 'memberA' for update;
// for update : 조회 시점 lock 획득 방법
- 중요한 조회를 할테니깐 update 못하도록 막는 것
- 이제 select lock이 된 시점에서 다른 session에서 값을 변경하는 작업은 lock이 없기 때문에 대기 상태에 들어간다.
- 주의
- 조회 이지만 작업이 끝난 후 commit을 작성해주어야지 session2가 lock을 받아서 사용가능해진다
9. transaction - 적용 1
- 요약
- transaction이 적용되지 않은 이전 repository 이용
- repository에서 Datasource connection을 수행
- service는 오로지 buiz logic만 수행
- 예외 발생시, transaction이 적용되지 않았기 때문에 문제 발생
- formId의 회원을 조회해서 toId의 회원에게 money만큼 돈을 계좌이체 하는 logic
package hello.jdbc.repository;
/**
* JDBC - DriverManager 사용
*/
@Slf4j
public class MemberRepositoryV1 {
// DataSource Interface 생성자 주입 받음
// DI -> 이말은 설정 파일을 만들어서 그 안에 DataSource를 따로 보관하는 곳이 존재한다는 뜻
private final DataSource dataSource;
// 생성자 생성
public MemberRepositoryV1(DataSource dataSource) {
this.dataSource = dataSource;
}
// 1. 등록
public Member save(Member member) throws SQLException {
String sql = "insert into member(member_id, money) values(? , ?)";
Connection con = null;
PreparedStatement pstmt = null; // sql params를 바인딩 받는 class
try {
con = getConnection(); // 1. connection 연결
pstmt = con.prepareStatement(sql); // 2. db랑 소통
pstmt.setString(1, member.getMemberId()); // sql string values의 첫번째 ?
pstmt.setInt(2, member.getMoney()); // sql string values의 두번째 ?
pstmt.executeUpdate(); // db에서 실행됨
return member;
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, null); // close하는 과정에서 예외발생시 뒤에것도 close안되니깐 method로 분리
}
}
// 2. 조회
public Member findById(String memberId) throws SQLException {
String sql = "select * from member where member_id = ?";
Connection con = null; // db와 연결
PreparedStatement pstmt = null; // sql문에 추가 값 넣고 실행
ResultSet rs = null; // select query 문의 결과를 담고 있는 통
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
rs = pstmt.executeQuery(); // 조회시에는 Query() 사용, 등록시 update 사용
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);
}
}
// 3. 수정
public void update(String memberId, int money) throws SQLException {
String sql = "update member set money=? where member_id = ?";
Connection con = null; // db와 연결
PreparedStatement pstmt = null; // sql문에 추가 값 넣고 실행
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);
}
}
// getConnection을 Datasource용으로 변경
private Connection getConnection() throws SQLException{
Connection con = dataSource.getConnection(); // 연결이 된다는 것이지 아직 연결된 db는 없다.
log.info("get connection={}, class={}", con, con.getClass());
return con;
}
// close() 예외 처리 - 더 간략한 방법으로 변경
private void close(Connection con, Statement stmt, ResultSet rs) {
// jdbc에서 제공하는 utils
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
JdbcUtils.closeConnection(con); // hika 머시기는 hika constpool에 감싸져있어서 그 안으로 들어간다.
}
}
package hello.jdbc.service;
@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, fromMember.getMoney() + money);
}
private static void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")){
throw new IllegalStateException("이체중 예외 발생");
}
}
}
- Test
- 예외 발생시 , 자동 commit으로 인해서 memberA의 돈만 출금되고 송금 받는 인원이 없어졌다.
package hello.jdbc.service;
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;
// 현재 repository 생성자에 들어갈 dataSource 설정이 안되어있어서 @BeforeEach에 직접 만들어서 넣어준다.
@BeforeEach
void before(){
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
memberRepository = new MemberRepositoryV1(dataSource);
memberService = new MemberServiceV1(memberRepository);
}
// test 끝날때마다 원복 시켜주기
@AfterEach
void after() throws SQLException{
memberRepository.delete(MEMBER_A);
memberRepository.delete(MEMBER_B);
memberRepository.delete(MEMBER_EX);
}
@Test
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
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());
assertThat(findMemberA.getMoney()).isEqualTo(8000);
assertThat(findMemberEx.getMoney()).isEqualTo(10000);
}
}
10. transaction 적용2
- service계층에서 connection을 관리함으로써, buiz logic에서 예외발생 시, rollback 수행 할 수 있게 된다.
10.1. 개념
- transaction은 business logic이 있는 service 계층에서 시작해야 한다.
- business logic이 잘못되면 해당 logic 자체를 rollback해야 하기 때문
- transaction 동안 동일 connection을 유지해야하므로 같은 session을 유지 해야한다.
- 그래서 business 계층에서 connection의 생성과 연결 끊기를 수행해야한다.
- business에서 connection을 생성하므로 repository에서 해당 connection을 받을 수 있도록 param 추가가 필요
- repository
- 주의점
- connection 유지가 필요한 두 메서드는 params로 넘어온 connection을 사용해야한다.
con = getConnection() 코드 제거 필요 - 컨넥션 유지가 필요하므로 서비스 로직이 끝나기 전까지 connection을 close하면 안되고 service.java에서 close()해줘야 한다.
- connection 유지가 필요한 두 메서드는 params로 넘어온 connection을 사용해야한다.
package hello.jdbc.repository;
@Slf4j
public class MemberRepositoryV2 {
// data source 생성
private final DataSource dataSource;
// 생성자 생성
public MemberRepositoryV2(DataSource dataSource) {
this.dataSource = dataSource;
}
// 1. 등록
public Member save(Member member) throws SQLException {...}
// 2. 조회
public Member findById(String memberId) throws SQLException {
String sql = "select * from member where member_id = ?";
Connection con = null; // db와 연결
PreparedStatement pstmt = null; // sql문에 추가 값 넣고 실행
ResultSet rs = null; // select query 문의 결과를 담고 있는 통
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
rs = pstmt.executeQuery(); // 조회시에는 Query() 사용, 등록시 update 사용
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);
}
}
// 2. 조회 - transaction 용 findById 추가
public Member findById(Connection con, String memberId) throws SQLException {
String sql = "select * from member where member_id = ?";
PreparedStatement pstmt = null; // sql문에 추가 값 넣고 실행
ResultSet rs = null; // select query 문의 결과를 담고 있는 통
try {
// con = getConnection(); // 이 부분이 새로운 connection 만드는 부분
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
rs = pstmt.executeQuery(); // 조회시에는 Query() 사용, 등록시 update 사용
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);
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(pstmt);
// JdbcUtils.closeConnection(con); 이걸 닫으면 connection이 끝나버리기 때문에 절대 닫으면 안된다. - service 부분에서 닫아준다.
}
}
// 3. 수정
public void update(String memberId, int money) throws SQLException {
String sql = "update member set money=? where member_id = ?";
Connection con = null; // db와 연결
PreparedStatement pstmt = null; // sql문에 추가 값 넣고 실행
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);
}
}
// 3. 수정 - transaction 용 findById 추가
public void update(Connection con, String memberId, int money) throws SQLException {
String sql = "update member set money=? where member_id = ?";
PreparedStatement pstmt = null; // sql문에 추가 값 넣고 실행
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 {
JdbcUtils.closeStatement(pstmt);
}
}
// 4. 삭제
public void delete(String memberId) throws SQLException {...}
// h2 db Connection
private Connection getConnection() throws SQLException{
Connection con = dataSource.getConnection(); // 연결이 된다는 것이지 아직 연결된 db는 없다.
/** 연결된 설정 파일을 안만들었기 때문에 test할때 @BeforeEach에 dataSource를 만들어서 넣는다.
* @BeforeEach
* void before(){
* DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
* memberRepositoryV1 = new MemberRepositoryV1(dataSource);
* }
*/
log.info("get connection={}, class={}", con, con.getClass());
return con;
}
// close() 예외 처리
private void close(Connection con, Statement stmt, ResultSet rs) {
// jdbc에서 제공하는 util
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
JdbcUtils.closeConnection(con); // hika는 hika constpool에 감싸져있어서 그 안으로 들어간다.
}
}
- service
- 제일 중요한 transaction 가능한 logic이다.
- 꼭 흐름을 파악할 것
- close()를 하게 되면 connection이 종료되지 않고 pool에 반납되기 때문에 수동 commit mode를 꼭 default 값인 자동 commit 모드로 변경이 필요
package hello.jdbc.service;
/* *
* 트랜잭션 - params 연동, pool을 고려한 close()
* transaction == connection 시작 종료를 service에서 관리
* service 핵심 로직에서 예외가 발생했을 경우 connection을 롤백을 해줘야 하기 때문
* connection의 역할이 수동 commit 설정, commit, rollback
*
*/
@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); // transaction 시작
bizLogic(con, fromId, toId, money); // 비즈니스 로직 최대한 분리
con.commit(); // 성공시, commit
}catch (Exception e){
con.rollback(); // 실패시, rollback
throw new IllegalStateException(e);
}finally {
release(con); // connection 끊기
}
// 이 부분에서 commit or rollback이 수행
}
/* *connection 종료 method
* setAutoCommit(true) 원복
* 1. 원래 autoCommit은 true가 default값
* 2. DriverMangerDatasource와 같은 connection Pool을 사용하지 않는 connection은 close()시, connection이 완전 제거되어
* 자동으로 autoCommit으로 돌아가서 신경 쓸 필요 X
* 3. HikariCP같이 connectionPool을 사용하는 dataSource는 connection이 해제되지 않으므로 수동으로 autoCommit true를 설정 필
* */
private static void release(Connection con) {
try{
if (con != null){
con.setAutoCommit(true); // 다음에 쓰는 사람을 위해서 true로 원복
con.close();
}
} catch(Exception e){
log.info("error", e);
}
}
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, fromMember.getMoney() + money);
}
private static void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")){
throw new IllegalStateException("이체중 예외 발생");
}
}
}
- test
package hello.jdbc.service;
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); // transaction 사용 위해서 dataSource 넘김
}
@AfterEach
void after() throws SQLException {
memberRepository.delete("memberA");
memberRepository.delete("memberB");
memberRepository.delete("ex");
}
@Test
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);
}
// transaction logic 수행
@Test
void accountTransferEx() throws SQLException {
//given
Member memberA = new Member("memberA", 10000);
Member memberEx = new Member("ex", 10000);
memberRepository.save(memberA);
memberRepository.save(memberEx);
//when
// accountTransfer으 실제 내부적으로는 params를 4개를 받지만 그 중 하나인 connection은 이미 주입되어있는 상태이기 때문에 3개의 params만 받으면 된다.
assertThatThrownBy(()->memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
.isInstanceOf(IllegalStateException.class);
//then
Member findMemberA = memberRepository.findById(memberA.getMemberId());
Member findMemberEx = memberRepository.findById(memberEx.getMemberId());
assertThat(findMemberA.getMoney()).isEqualTo(10000);
assertThat(findMemberEx.getMoney()).isEqualTo(10000);
}
}