데이터를 데이터베이스에 저장 하는 이유가 무엇일까?
→ 데이터베이스는 트랜잭션이라는 개념을 지원하기 때문!
트랜잭션? : 데이터베이스에서 트랜잭션이란, 하나의 거래를 안전하게 처리하도록 보장해주는 것을 뜻한다.
→ 정상 처리 이후 : Commit
→ 비정상 처리 이후 : Rollback
트랜잭션 ACID :
→ 4가지를 트랜잭션에서 보장해야 한다.
트랜잭션 격리 수준 - Isolation level
위로 갈수록 격리 수준이 낮아진다. 그만큼 안전성이 떨어질 수 있지만, 직렬화로 갈수록 처리속도가 늦어지고, 여러 제한이 발생한다. 대부분의 데이터베이스는 “ READ COMMITED “ 수준의 격리 수준을 따른다.


트랜잭션 동작을 예제를 통해 확인해보자.
트랜잭션 사용법
commit; 을 호출하고, 결과를 반영하고 싶지 않다면, 롤백 명령어인 rollback; 을 호출하면 된다.예제에 사용되는 스키마
drop table member if exists;
create table member (
member_id varchar(10),
money integer not null default 0,
primary key (member_id)
);
자동 커밋 vs 수동 커밋
트랜잭션을 사용하려면 먼저 자동 커밋과 수동 커밋을 이해해야 한다.
자동 커밋?
자동 커밋 설정
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; //수동 커밋
보통 자동 커밋 모드가 기본으로 설정된 경우가 많기 때문에, 수동 커밋 모드로 설정하는 것을 트랜잭션을 시작한다고 할 수 있다.

데이터 초기화 SQL
//데이터 초기화
set autocommit true;
delete from member;
insert into member(member_id, money) values ('oldId',10000);

세션 1에서 신규 데이터 추가 SQL
//트랜잭션 시작
set autocommit false; //수동 커밋 모드
insert into member(member_id, money) values ('newId1',10000); insert into member(member_id, money) values ('newId2',10000);
실행 결과
Session1

Session 1 에서 데이터 조회

Session 2

세션1, 2 의 결과 이미지를 비교해보면, 아직 세션1 에서 커밋을 하지 않았기 때문에, 세션1에서는 입력한 데이터가 보이지만 세션2에서는 입력한 데이터가 보이지 않는 것을 확인할 수 있다.
세션1 신규 데이터 추가 후 commit 완료

세션1에서 commit; 실행
이후 세션2 에서 데이터가 반영되어 실제 데이터가 조회 됨을 알 수 있다.
데이터 초기화
//데이터 초기화
set autocommit true;
delete from member;
insert into member(member_id, money) values ('oldId',10000);

세션1 신규 데이터 추가
//트랜잭션 시작
set autocommit false; //수동 커밋 모드
insert into member(member_id, money) values ('newId1',10000); insert into member(member_id, money) values ('newId2',10000);
DB 조회
SELECT * FROM MEMBER;
세션1 조회 결과

세션2 조회 결과

세션1이 아직 커밋하지 않은 상태이기 때문에, 세션1에서는 데이터가 보이지만, 세션2에서는 입력한 데이터가 보이지 않는다.
세션1 신규 데이터 추가 후 Rollback
rollback;
세션1, 2 DB 조회

세션1, 2 모두 새로운 데이터가 DB에 반영되지 않은 것을 확인할 수 있다.
계좌이체 3가지 Case
기본 데이터 입력

set autocommit true;
delete from member;
insert into member(member_id, money) values ('memberA',10000);
insert into member(member_id, money) values ('memberB',10000);
계좌이체 실행
set autocommit false;
update member set money=10000 - 2000 where member_id = 'memberA';
update member set money=10000 + 2000 where member_id = 'memberB';

계좌이체 실행 SQL - 성공
세션1
commit;
세션1,2 DB 조회

정상 동작 한것을 확인 할 수 있다.
기본 데이터 입력
set autocommit true;
delete from member;
insert into member(member_id, money) values ('memberA',10000);
insert into member(member_id, money) values ('memberB',10000);
계좌이체 실행

실행 SQL - 오류
set autocommit false;
update member set money=10000 - 2000 where member_id = 'memberA'; //성공
update member set money=10000 + 2000 where member_iddd = 'memberB'; //쿼리 예외
3번째 줄 → member_iddd 로 인해 3번째줄은 쿼리 실행에서 예외된다.

세션1 commit
commit;
세션1, 2 DB 조회 결과

→ memberA 의 돈은 2000원이 사라졌고, memberB 의 돈은 2000원 증가해야 하지만, memberB의 돈은 그대로이다. 즉 2000원이 사라졌다!
다시 데이터를 삭제하고 기본 데이터를 입력하자.
set autocommit true;
delete from member;
insert into member(member_id, money) values ('memberA',10000);
insert into member(member_id, money) values ('memberB',10000);
계좌이체 실행 SQL - 오류
set autocommit false;
update member set money=10000 - 2000 where member_id = 'memberA'; //성공
update member set money=10000 + 2000 where member_iddd = 'memberB'; //쿼리 예외 발생
실행 결과

롤백
이럴때는 롤백을 호출해서 트랜잭션을 시작하기 전 단계로 데이터를 복구해야 한다.
세션1 롤백
rollback;
세션1, 2 DB 조회

정리
원자성 : 트랜잭션 내에서 실행한 작업들은 마치 하나의 작업인 것 처럼 모두 성공하거나, 모두 실패해야 한다.
이를 통해 여러 SQL 명령어를 마치 하나의 작업인 것 처럼 처리할 수 있다. 성공하면 한번에 반영하고, 중간에 실패해도 하나의 작업 처럼 되돌리는 것이 필요하다.
→ 이를 위해 트랜잭션을 수동 커밋 모드로 작업해야 하고, 작업의 처리에 따라 성공 시 commit, 실패 시 rollback 해야 한다.
세션1이 트랜잭션을 시작하고 데이터를 수정하는 동안 아직 커밋을 진행하지 않았는데, 세션2에서 동시에 같은 데이터를 수정하게 되면 여러 문제가 발생한다. → 트랜잭션의 원자성이 깨진다.
이를 방지하기 위해 세션이 트랜잭션을 시작하고 데이터를 수정하는 동안에는 커밋이나 롤백 전 까지 다른 세션에서 해당 데이터를 수정할 수 없게 막아야 한다.
실습
기본 데이터 입력 - SQL
set autocommit true;
delete from member;
insert into member(member_id, money) values ('memberA',10000);
세션1
set autocommit false;
update member set money=500 where member_id = 'memberA';
락1 실행 과정
세션2
SET LOCK_TIMEOUT 60000;
set autocommit false;
update member set money=1000 where member_id = 'memberA';
세션2 가 60초 내에 락을 획득하지 못하고 timeout 발생한 결과

세션1이 락을 반납하여 세션2에서 처리된 결과
세션1 - commit or rollback
commit;
// 또는,
//rollback;
세션2에서 락을 획득하여 데이터를 변경

실행 결과 - 세션1,2 DB 조회
세션2 에서 commit
commit;
실행 결과

일반적인 조회는 락을 사용하지 않는다.
→ 그럼에도 조회에 락을 사용해야 하는 case?
조회한 데이터로, 새로운 데이터를 만들어야 할 때가 그 예시로 들 수 있다.
예시 상황
매장 pos 의 정산 → 하루의 매출을 계산하여 정산집계표를 만들어야 하는데, 이 과정 중에 매출의 수정이 발생하면? → 새로운 정산집계표가 틀린 정보가 된다. 따라서 해당 테이블 (db 데이터) 를 수정하는 것이 아님에도 조회에 락이 필요한 상황이 된다.
앞서 데이터 설정과 수정은 알아봤으니, 어떻게 조회에 락을 걸 수 있는지에 대한 코드만 제시한다~
세션1 조회 - 락
set autocommit false;
select * from member where member_id='memberA' for update;
실제 애플리케이션에서 DB 트랜잭션을 사용해서 계좌이체 같이 중요한 비즈니스 로직을 어떻게 구현하는지 알아보자.
먼저 트랜잭션 없이 단순한 계좌이체 비즈니스 로직만 구현해보자.
MemberService1
package hello.jdbc.mycode.service;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV1;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.sql.SQLException;
@Slf4j
@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);
log.info("my log : from member 감소 ");
log.info("validation before");
// -> 오류 발생 시킬 것임.
validation(toMember);
log.info("validation after");
// -> toMember의 id 가 ex 이면 예외 발생됨.
memberRepository.update(toId, toMember.getMoney() + money);
log.info("my log : to member 증가 ");
}
public static void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체중 예외 발생");
}
}
}
MemberServiceV1Test
package hello.jdbc.mycode.service;
import com.zaxxer.hikari.HikariDataSource;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV1;
import hello.jdbc.service.MemberServiceV1;
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 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() {
//커넥션 풀링
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl(URL);
dataSource.setUsername(USERNAME);
dataSource.setPassword(PASSWORD);
memberRepository = new MemberRepositoryV1(dataSource);
memberService = new MemberServiceV1(memberRepository);
}
@AfterEach
void after() {
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
assertThatCode(() -> memberService.accountTransfer(memberA.getMemberId(), memberB.getMemberId(), 2000)).doesNotThrowAnyException();
//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 findMemberEx = memberRepository.findById(memberEx.getMemberId());
assertThat(findMemberA.getMoney()).isEqualTo(8000);
assertThat(findMemberEx.getMoney()).isEqualTo(10000);
}
}
→ MyCode : 성공 시 assertThatCode 사용하여 검증.
실행 과정
→ 같은 커넥션을 유지하려면, 커넥션을 파라미터로 전달해서 같은 커넥션을 사용하도록 하자.
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 Member save(Member member) throws SQLException {
String sql = "insert into member(member_id, money) values (?,?)";
Connection connection = null;
PreparedStatement preparedStatement = null;
try {
connection = getConnection();
preparedStatement = connection.prepareStatement(sql);
preparedStatement.setString(1, member.getMemberId());
preparedStatement.setInt(2, member.getMoney());
preparedStatement.executeUpdate();
return member;
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(connection, preparedStatement, null);
}
}
public Member findById(String memberId) throws SQLException {
String sql = "select * from member where member_id = ?";
Connection connection = null;
PreparedStatement preparedStatement = null;
ResultSet resultSet = null;
try {
connection = getConnection();
preparedStatement = connection.prepareStatement(sql);
preparedStatement.setString(1, memberId);
resultSet = preparedStatement.executeQuery();
if (resultSet.next()) {
Member member = new Member();
member.setMemberId(resultSet.getString("member_id"));
member.setMoney(resultSet.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(connection, preparedStatement, resultSet);
}
}
public Member findById(Connection con, String memberId) throws SQLException {
String sql = "select * from member where member_id = ?";
PreparedStatement preparedStatement = null;
ResultSet resultSet = null;
try {
preparedStatement = con.prepareStatement(sql);
preparedStatement.setString(1, memberId);
resultSet = preparedStatement.executeQuery();
if (resultSet.next()) {
Member member = new Member();
member.setMemberId(resultSet.getString("member_id"));
member.setMoney(resultSet.getInt("money"));
return member;
} else {
throw new NoSuchElementException("member not found memberId = " + memberId);
}
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
JdbcUtils.closeResultSet(resultSet);
JdbcUtils.closeStatement(preparedStatement);
// 커넥션은 여기서 닫지 않는다!
}
}
public void update(String memberId, int money) throws SQLException {
String sql = "update member set money=? where member_id=?";
Connection connection = null;
PreparedStatement preparedStatement = null;
try {
connection = getConnection();
preparedStatement = connection.prepareStatement(sql);
preparedStatement.setInt(1, money);
preparedStatement.setString(2, memberId);
int resultSize = preparedStatement.executeUpdate();
log.info("resultSize={}", resultSize);
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(connection, preparedStatement, null);
}
}
public void update(Connection con, String memberId, int money) throws SQLException {
String sql = "update member set money=? where member_id=?";
PreparedStatement preparedStatement = null;
try {
preparedStatement = con.prepareStatement(sql);
preparedStatement.setInt(1, money);
preparedStatement.setString(2, memberId);
int resultSize = preparedStatement.executeUpdate();
log.info("resultSize={}", resultSize);
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
JdbcUtils.closeStatement(preparedStatement);
// 커넥션은 여기서 닫지 않는다!
}
}
public void delete(String memberId) {
String sql = "delete from member where member_id = ?";
Connection connection = null;
PreparedStatement preparedStatement = null;
try{
connection = getConnection();
preparedStatement = connection.prepareStatement(sql);
preparedStatement.setString(1, memberId);
preparedStatement.executeUpdate();
log.info("delete memberId={}", memberId);
} catch (SQLException e) {
throw new RuntimeException(e);
} finally {
close(connection, preparedStatement, null);
}
}
private void close(Connection connection, Statement statement, ResultSet resultSet) {
JdbcUtils.closeResultSet(resultSet);
JdbcUtils.closeStatement(statement);
JdbcUtils.closeConnection(connection);
}
private Connection getConnection() throws SQLException {
Connection connection = dataSource.getConnection();
log.info("get connection ={}, class ={}", connection, connection.getClass());
return connection;
}
}
→ Repository 에서 커넥션 유지가 필요한 곳?
: findById, update
findById(Connection con, String memberId)
update(Connection con, String memberId, int money)
MemberService2
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, 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
package hello.jdbc.service;
import com.zaxxer.hikari.HikariDataSource;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV1;
import hello.jdbc.repository.MemberRepositoryV2;
import lombok.extern.slf4j.Slf4j;
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 MemberServiceV2Test {
private MemberRepositoryV2 memberRepository;
private MemberServiceV2 memberService;
public static final String MEMBER_A = "memberA";
public static final String MEMBER_B = "memberB";
public static final String MEMBER_EX = "ex";
@BeforeEach
void before() {
//커넥션 풀링
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl(URL);
dataSource.setUsername(USERNAME);
dataSource.setPassword(PASSWORD);
memberRepository = new MemberRepositoryV2(dataSource);
memberService = new MemberServiceV2(dataSource, memberRepository);
}
@AfterEach
void after() {
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
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());
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 findMemberEx = memberRepository.findById(memberEx.getMemberId());
assertThat(findMemberA.getMoney()).isEqualTo(10000);
assertThat(findMemberEx.getMoney()).isEqualTo(10000);
}
}
이체 중 예외 발생 case
→ 트랜잭션 적용으로 인해 계좌이체가 실패 시 롤백을 통해 모든 데이터가 정상적으로 초기화 됨!
🚨남은 문제!
애플리케이션에서 DB 트랜잭션을 적용하려면 서비스 계층이 지저분해지고 복잡한 코드가 필요하다. 또한 커넥션을 유지하도록 코드를 변경하는 것도 쉽지 않다. → 이를 스프링을 사용해 해결해 보자!