데이터를 파일이 아닌 데이터베이스에 저장하는 이유는, DB가 트랜잭션이라는 개념을 제공하기 때문이다.
트랜잭션 = 거래
데이터베이스에서 트랜잭션은 하나의 거래를 안전하게 처리하도록 보장해주는 것을 뜻한다.
주요 기능으로는, 하나의 로직에 묶인 모든 작업이 성공해서 데이터베이스에 정상 반영하는 커밋(Commit)과, 작업 중 하나라도 실패해서 거래 이전으로 되돌리는 롤백(Rollback)이 있다.
트랜잭션은 ACID라 하는 원자성(Atomicity), 일관성(Consistency), 격리성(Isolation), 지속성(Durability)을 보장해야 한다.
트랜잭션은 원자성, 일관성, 지속성을 보장한다. 문제는 격리성으로, 트랜잭션 간에 격리성을 완벽히 보장하려면 트랜잭션을 거의 순서대로 실행해야 한다. 이렇게 하면 동시 처리 성능이 매우 나빠진다.
따라서 ANSI 표준은 트랜잭션의 격리 수준을 4단계로 나누어 정의했다.
트랜잭션 격리 수준 - Isolation level
트랜잭션 사용법
commit
을 호출하고, 결과를 반영하고 싶지 않으면 롤백 명령어인 rollback
을 호출하면 된다.자동 커밋
set autocommit true; //자동 커밋 모드 설정
insert into member(member_id, money) values ('data1',10000); //자동 커밋
insert into member(member_id, money) values ('data2',10000); //자동 커밋
commit
, rollback
을 직접 호출하면서 트랜잭션 기능을 제대로 수행하려면 자동 커밋을 끄고 수동 커밋을 사용해야 한다.수동 커밋
set autocommit false; //수동 커밋 모드 설정
insert into member(member_id, money) values ('data3',10000);
insert into member(member_id, money) values ('data4',10000);
commit; //수동 커밋
commit
, rollback
을 호출해야 한다.set autocommit true;
delete from member;
insert into member(member_id, money) values ('oldId',10000);
select * from member;
name
필드는 이해를 돕기 위해 그린 것이고 실제로는 없다.)//트랜잭션 시작
set autocommit false; //수동 커밋 모드
insert into member(member_id, money) values ('newId1',10000);
insert into member(member_id, money) values ('newId2',10000);
select * from member;
select
쿼리를 실행해서 본인이 입력한 신규 회원1, 신규 회원2를 조회할 수 있다.select
쿼리를 실행해도 신규 회원들을 조회할 수 없다. 왜냐하면 세션1이 아직 커밋을 하지 않았기 때문이다.commit; //데이터베이스에 반영
commit
을 호출했다.commit
으로 새로운 데이터가 실제 데이터베이스에 반영된다. 데이터의 상태도 임시에서 완료로 변경되었다.select * from member;
// 데이터 초기화
set autocommit true;
delete from member;
insert into member(member_id, money) values ('oldId',10000);
// 트랜잭션 시작
set autocommit false; //수동 커밋 모드
insert into member(member_id, money) values ('newId1',10000);
insert into member(member_id, money) values ('newId2',10000);
rollback; //롤백으로 데이터베이스에 변경 사항을 반영하지 않는다.
commit
대신에 rollback
을 호출했다.rollback
을 호출하면 모두 트랜잭션을 시작하기 직전의 상태로 복구된다.select * from member;
세션1이 트랜잭션을 시작하고 데이터를 수정하는 동안 아직 커밋을 수행하지 않았는데, 세션2에서 동시에 같은 데이터를 수정하게 되면 여러가지 문제가 발생한다. 바로 트랜잭션의 원자성이 깨지는 것이다.
여기에 더해서 세션1이 중간에 롤백을 하게 되면 세션2는 잘못된 데이터를 수정하는 문제가 발생한다.이런 문제를 방지하려면, 세션이 트랜잭션을 시작하고 데이터를 수정하는 동안에는 커밋이나 롤백 전까지 다른 세션에서 해당 데이터를 수정할 수 없게 막아야 한다.
동작 방식은 간단하다. 락을 획득한 세션이 sql을 수행한다.
락 타임아웃 설정
SET LOCK_TIMEOUT <milliseconds>
: 락 타임아웃 시간을 설정한다.
select ~ for update
구문을 사용하면 된다.조회 시점에 락이 필요한 경우?
memberA
의 금액을 조회한 다음에 이 금액 정보로 애플리케이션에서 어떤 계산을 수행한다. 그런데 이 계산이 돈과 관련된 매우 중요한 계산이어서 계산을 완료할 때 까지 memberA
의 금액을 다른곳에서 변경하면 안된다. 이럴 때 조회 시점에 락을 획득하면 된다.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); // 여기서 예외가 터지면 memberA 의 계좌는 바뀌었는데, memberB의 계좌는 바뀌지 않아서 치명적인 문제가 생기는 것!!
memberRepository.update(toId, toMember.getMoney() + money);
}
// 예외 상황 테스트 하기 위해 id ex 인 경우 검증
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"
인 경우 예외를 발생한다.package hello.jdbc.service;
import hello.jdbc.connection.ConnectionConst;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV1;
import lombok.extern.slf4j.Slf4j;
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.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.*;
/**
* 기본 동작 확인, 트랜잭션 없어서 문제 발생할
*/
@Slf4j
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("정상 이체")
public void accountTransfer() throws Exception {
// 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());
Assertions.assertThat(findMemberA.getMoney()).isEqualTo(8000);
Assertions.assertThat(findMemberB.getMoney()).isEqualTo(12000);
}
@Test
@DisplayName("이체중 예외 발생")
public void accountTransferEx() throws Exception {
// 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); // 예외가 발생해서 B의 잔고 변동 X
}
}
주의! 테스트를 수행하기 전에 데이터베이스의 데이터를 삭제해야 한다.
delete from member;
정리
이체중 예외가 발생하게 되면 memberA
의 금액은 10000원 8000원으로 2000원 감소한다. 그런데 memberEx
의 돈은 그대로 10000원으로 남아있다. 결과적으로 memberA
의 돈만 2000원 감소했다!
아래에서 이러한 문제에 트랜잭션을 적용하여 해결해보자.
어플리케이션에서 트랜잭션을 어떤 계층에 걸어야 할까?
쉽게 이야기해서 트랜잭션을 어디에서 시작하고, 어디에서 커밋해야할까?
먼저 리포지토리가 파라미터를 통해 같은 커넥션을 유지하는 방법이다.
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 으로 parameter 로 받은 커넥션을 써야 같은 커넥션을 써서 일관성이 유지됨.
* 트랜잭션을 한 커넥션으로 유지하기 위한 옛날 방법(현재는 스프링으로)
* 서비스 계층이 지저분하고 복잡해짐
*/
@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;
try {
con = getConnection(); // connection 획득
pstmt = con.prepareStatement(sql);
pstmt.setString(1, member.getMemberId()); // 위에 sql 의 첫 번째 파라미터 바인딩 해준다.
pstmt.setInt(2, member.getMoney()); // 두 번째 파라미터 바인딩
pstmt.executeUpdate(); // 위에서 준비한 것들(쿼리)이 실제 실행되게 함.(insert 는 Update 문으로)
return member;
} catch (SQLException e) {
log.error("db error", e);
throw e; // 예외 던짐
} finally { // 예외가 발생하던, 하지 않던 항상 수행되어야 하는 부분(close) finally 에 작성.
// 커넥션(외부 리소스)은 실제 TCP,IP 커넥션에 걸려서 쓰는 것으로 안 닫으면 계속 유지됨. 역순으로 닫아줘야 함
// pstmt.close();
// con.close(); // 근데 위에서 예외가 터지면 닫히는 게 호출 자체가 안 될 수도 있어서, close() 메서드를 만들어서 try-catch 로 동작하게 함
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(); // select 는 Query 문으로. 이건 결과를 ResultSet(rs)에 담아서 반환해준다.
if (rs.next()) { // rs 는 내부에 커서같은 게 있어서, 한 번 호출을 해줘야 실제 데이터가 있는 곳부터 실행이 됨. next() 는 첫 번째 데이터가 있는지를 물어봐서 true 면 진행
Member member = new Member(); // 멤버 객체 만들어서 저장해주기
member.setMemberId(rs.getString("member_id"));
member.setMoney(rs.getInt("money"));
return member;
} else { // false 나와서~
throw new NoSuchElementException("member not found memberId=" + memberId); // 예외를 던질 땐 메시지를 잘 넣는 게 좋다. 문제 터졌을 때 해결하기 좋음
}
} catch (SQLException e) {
log.info("db error", e);
throw e;
} finally {
close(con, pstmt, rs);
}
}
// param 으로 connection 받기
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(); // select 는 Query 문으로. 이건 결과를 ResultSet(rs)에 담아서 반환해준다.
if (rs.next()) { // rs 는 내부에 커서같은 게 있어서, 한 번 호출을 해줘야 실제 데이터가 있는 곳부터 실행이 됨. next() 는 첫 번째 데이터가 있는지를 물어봐서 true 면 진행
Member member = new Member(); // 멤버 객체 만들어서 저장해주기
member.setMemberId(rs.getString("member_id"));
member.setMoney(rs.getInt("money"));
return member;
} else { // false 나와서~
throw new NoSuchElementException("member not found memberId=" + memberId); // 예외를 던질 땐 메시지를 잘 넣는 게 좋다. 문제 터졌을 때 해결하기 좋음
}
} catch (SQLException e) {
log.info("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);
}
}
// param 으로 connection 받기
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);
// JdbcUtils.closeConnection(con);
}
}
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);
}
}
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("getConnection={}, class={}", con, con.getClass());
return con;
}
}
con = getConnection()
코드가 있으면 안된다.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);
// 비즈니스 로직
bizLogic(con, money, fromId, toId);
// 성공 시 커밋
con.commit();
} catch (Exception e) {
// 실패 시 롤백
con.rollback();
throw new IllegalStateException(e);
} finally {
release(con);
}
}
private void bizLogic(Connection con, int money, String fromId, String toId) 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 release(Connection con) {
if (con != null) {
try {
// autoCommit 의 기본값이 true 이다.
// 커넥션 풀에서는 그냥 close 해버리면, autoCommit 의 값이 false 로 커넥션 풀에 그대로 남아 있음: con.close() 호출 시 커넥션이 종료되는 게 아니라, 풀에 반납되는 것이다. 쉽게 말해서 공공재인 커넥션 사용 후 원상태로 돌려줘야 하는 것.
con.setAutoCommit(true);
con.close();
} catch (Exception e) {
log.info("error", e);
}
}
}
// 예외 상황 테스트 하기 위해 id ex 인 경우 검증
private void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체중 예외 발생");
}
}
}
bizLogic(con, fromId, toId, money);
memberRepository.update(con..)
: 비즈니스 로직을 보면 리포지토리를 호출할 때 커넥션을 전달하는 것을 확인할 수 있다.con.commit(); //성공시 커밋
con.rollback(); //실패시 롤백
catch(Ex){..}
를 사용해서 비즈니스 로직 수행 도중에 예외가 발생하면 트랜잭션을 롤백한다.release(con);
finally {..}
를 사용해서 커넥션을 모두 사용하고 나면 안전하게 종료한다. 그런데 커넥션 풀을 사용하면 con.close()
를 호출 했을 때 커넥션이 종료되는 것이 아니라 풀에 반납된다. 현재 수동 커밋 모드로 동작하기 때문에 풀에 돌려주기 전에 기본 값인 자동 커밋 모드로 변경하는 것이 안전하다.package hello.jdbc.service;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV1;
import hello.jdbc.repository.MemberRepositoryV2;
import lombok.extern.slf4j.Slf4j;
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.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 트랜잭션 - 커넥션 파라미터 전달 방식으로 동기화
*/
@Slf4j
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("이체중 예외 발생")
public void accountTransferEx() throws Exception {
// 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(10000); // 예외 발생했기 때문에 rollback 됨
assertThat(findMemberB.getMoney()).isEqualTo(10000);
}
@Test
@DisplayName("정상 이체") // 기존 로직과 동일
public void accountTransfer() throws Exception {
// given
Member memberA = new Member(MEMBER_A, 10000);
Member memberB = new Member(MEMBER_B, 10000);
memberRepository.save(memberA);
memberRepository.save(memberB);
// when
log.info("START TX");
memberService.accountTransfer(memberA.getMemberId(), memberB.getMemberId(), 2000);
log.info("END TX");
// then
Member findMemberA = memberRepository.findById(memberA.getMemberId());
Member findMemberB = memberRepository.findById(memberB.getMemberId());
Assertions.assertThat(findMemberA.getMoney()).isEqualTo(8000);
Assertions.assertThat(findMemberB.getMoney()).isEqualTo(12000);
}
}
애플리케이션에서 DB 트랜잭션을 적용하려면 서비스 계층이 매우 지저분해지고, 생각보다 매우 복잡한 코드를 요구한다. 추가로 커넥션을 유지하도록 코드를 변경하는 것도 쉬운 일은 아니다.
다음에는 스프링을 사용해서 이런 문제들을 하나씩 해결한다.