데이터를 저장할 때 단순히 파일에 저장해도 되는데, 데이터베이스에 저장하는 이유는 여러 가지 이유가 있지만, 가장 대표적인 것은 바로 데이터베이스는 트랜잭션이라는 개념을 지원하기 때문입니다.
트랜잭션을 이름 그대로 번역하면 거래라는 뜻입니다. 즉, 데이터베이스에서 트랜잭션은 하나의 거래를 안전하게 처리하도록 보장해주는 것을 의미합니다. 예를 들어 A의 돈 10만원을 B에게 계좌이체 한다고 해 봅시다. 그럼 아래와 같이 동작해야 합니다.
1. A의 잔고를 10만원 감소
2. B의 잔고를 10만원 증가
1번과 2번 작업은 하나의 작업처럼 동작해야 합니다. 만약 1번은 성공했는데 2번에서 문제가 발생하여 계좌이체에 실패하면 아주 큰 문제가 발생하겠죠. 데이터베이스의 트랜잭션 기능을 사용하면 1번과 2번이 둘 다 성공하면 계좌이체에 성공하고, 2번에서 실패하면 계좌이체를 실패로 처리하고 거래 전의 상태로 돌아가도록 할 수 있습니다. 결과적으로 A의 잔고는 감소하지 않습니다.
모든 작업이 성공해서 데이터베이스에 정상 반영하는 것을 커밋(commit) 이라고 하고, 작업 중 하나라도 실패해서 거래 이전으로 되돌리는 것을 롤백(rollback) 이라고 합니다.
트랜잭션 ACID
트랜잭션은 ACID라는 원자성(Atomicity), 일관성(Consistency), 격리성(Isolation), 지속성(Durability)을 보장해야 합니다.
- 원자성 : 트랜잭션 내에서 실행한 작업들은 마치 하나의 작업인 것처럼 모두 성공하거나 모두 실패해야 합니다.
- 일관성 : 모든 트랜잭션은 일관성 있는 데이터베이스 상태를 유지해야 합니다.
- 격리성 : 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리합니다. 격리성은 동시에 같은 데이터를 수정하지 못하도록 하는 등의 동시성과 관련된 성능 이슈로 인해 트랜잭션 격리 수준(Isolation level)을 선택할 수 있습니다.
- 트랜잭션 격리 수준 - Isolation level
- READ UNCOMMITED(커밋되지 않은 읽기)
- READ COMMITTED(커밋된 읽기)
- REPEATABLE READ(반복 가능한 읽기)
- SERIALIZABLE(직렬화 가능)
- 트랜잭션 격리 수준 - Isolation level
- 지속성 : 트랜잭션을 성공적으로 끝내면 그 결과가 항상 기록되어야 합니다. 즉, 중간에 시스템에 문제가 발생해도 데이터베이스 로그 등을 사용해서 성공했던 트랜잭션 내용을 복구해야 합니다.
데이터베이스 연결 구조와 DB 세션
- 사용자는 웹 애플리케이션(WAS)나 DB접근 같은 클라이언트를 통해 데이터베이스 서버에 연결을 요청하고 커넥션을 맺게 됩니다.
- 커넥션 풀이 10개의 커넥션을 생성하면, 세션도 10개 만들어집니다.
- 이때 데이터베이스 서버는 내부에 세션이라는 것을 만듭니다. 그리고 앞으로 해당 커넥션을 통한 모든 요청은 이 세션을 통해서 실행하게 됩니다.
- 세션은 트랜잭션을 시작하고, 커밋 또는 롤백을 통해 트랜잭션을 종료합니다. 그리고 이후에 새로운 트랜잭션을 다시 시작할 수 있습니다.
- 사용자가 커넥션을 닫거나, 또는 DBA(DB 관리자)가 세션을 강제로 종료하면 세션은 종료됩니다.
Commit과 Rollback
데이터 변경 쿼리를 실행하고 데이터베이스에 그 결과를 반영하려면 커밋 명령어인 commit을 호출하고, 결과를 반영하고 싶지 않으면 롤백 명령어인 rollback 을 호출하면 됩니다.
커밋을 호출하기 전까지는 임시로 데이터를 저장하는 것이다. 따라서 해당 트랜잭션을 시작한 세션(사용자)에게만 변경 데이터가 보이고 다른 세션(사용자)에게는 변경 데이터가 보이지 않습니다.
예를 들어, 세션 1에서 신규 데이터를 저장하고 커밋을 호출하기 전에는 다른 세션에서는 신규 데이터를 조회할 수 없습니다.
세션 1에서 commit을 호출하면 신규 데이터가 실제 데이터베이스에 반영되므로 세션 2에서도 신규 데이터를 확인할 수 있습니다.
세션1에서 신규 데이터를 추가한 후 commit 이 아닌 rollback을 호출하면 세션1의 데이터베이스에 반영한 모든 데이터가 처음 상태로 복구됩니다. 수정, 삭제의 경우에도 동일하게 동작합니다.
자동 커밋과 수동 커밋
자동 커밋
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 을 호출해야 합니다. (안 하면 반영 안 되는 큰 사고가...)
참고로 수동 커밋 모드나 자동 커밋 모드는 한번 설정하면 해당 세션에서는 계속 유지되고, 중간에 변경도 가능합니다.
참고로, commit을 호출하지 않을 경우, DB마다 설정된 timeout 이 있어서 timeout만큼 시간이 지나면 자동으로 rollback 이 됩니다.
DB 락
세션1이 트랜잭션을 시작하고 데이터를 수정하는 동안 아직 커밋을 수행하지 않았는데, 세션 2에서 동시에 같은 데이터를 수정하게 되면 트랜잭션의 원자성이 깨지게 됩니다. 여기에 더해서 세션 1이 중간에 롤백을 하게 되면 세션 2는 잘못된 데이터를 수정하는 문제가 발생합니다.
이런 문제를 해결하기 위해 데이터베이스에서는 락(Lock) 이라는 개념을 제공합니다.
DB 락에 대한 개념을 (저장, 수정, 삭제) 즉 변경할 때와 조회할 때로 나누어서 설명하겠습니다.
DB 락 - 변경 시
- 세션 1이 트랜잭션을 시작합니다. 이때, 세션 1은 변경을 원하고자 하는 row의 락(Lock)을 먼저 획득합니다. 그 후 해당 row에 update sql을 수행합니다.
- 세션 2도 트랜잭션을 수행합니다. 세션 2도 동일한 row에 변경을 하고자 합니다. 하지만 획득할 수 있는 락(Lock)이 없기 때문에 락(Lock)이 돌아올 때까지 대기합니다.
- 참고로, 세션 2가 락(Lock)을 무한정 대기하는 것이 아닙니다. 설정한 락(Lock) 대기 시간을 넘어가면 타임아웃 오류가 발생합니다. 락(Lock) 대기시간은 아래 코드로 설정할 수 있습니다.
# SET LOCK_TIMEOUT <milliseconds>
SET LOCK_TIMEOUT 60000; # 60 초
- 세션1은 커밋을 수행합니다.. 커밋으로 트랜잭션이 종료되었으므로 락(Lock)도 반납합니다.
- 대기하던 세션 2가 락(Lock)을 획득하고 update sql을 수행합니다. 그 후 커밋을 수행하고 락(Lock)을 반납합니다.
DB 락 - 조회 시
일반적인 조회는 락을 사용하지 않습니다. 데이터베이스마다 다르지만, 보통 데이터를 조회할 때는 락을 획득하지 않고 바로 데이터를 조회할 수 있습니다. 예를 들어서 세션 1이 락을 획득하고 데이터를 변경하고 있어도, 세션 2에서 데이터를 조회는 할 수 있습니다.
하지만 데이터를 조회할 때도 락을 획득하고 싶을 때가 있는데, 이럴 때는 select for update 구문을 사용하면 됩니다. 이렇게 하면 세션1이 조회 시점에 락을 가져가버리기 때문에 세션 1에서 커밋을 하기 전까지 다른 세션에서 해당 데이터를 변경할 수 없습니다.
set autocommit false;
select * from member where member_id='memberA' for update; # 락 획득
# 조회 결과 프로세싱
commit;
이렇게 조회 시점에 락이 필요한 경우는 트랜잭션 종료 시점까지 해당 데이터를 다른 곳에서 변경하지 못하도록 강제로 막아야 할 경우입니다.
예를 들어, memberA의 금액을 조회한 다음에 이 금액 정보로 애플리케이션에서 어떤 계산을 수행하는데, 이 계산이 돈과 관련된 매우 중요한 계산이어서 계산을 완료할 때까지 memberA의 금액을 다른 곳에서 변경하면 안 되는 경우가 있을 것입니다. 이럴 때 조회 시점에 락을 획득합니다.
트랜잭션 적용해보기
실제로 코드에 트랜잭션을 적용해 보겠습니다.
위 그림에서 알 수 있듯이, 트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작해야 합니다. 비즈니스 로직에서 문제가 있어서 트랜잭션에 실패하면 롤백해야 하기 때문입니다.
그런데 트랜잭션을 시작하려면 커넥션이 필요합니다. 결국 서비스 계층에서 커넥션을 만들고, 트랜잭션 커밋 이후에 커넥션을 종료해야 합니다.
또한 애플리케이션에서 DB 트랜잭션을 사용하려면 트랜잭션을 사용하는 동안 같은 커넥션을 유지해야 같은 세션을 사용할 수 있습니다. (다른 커넥션을 사용하면 다른 세션에서 수행됩니다...)
구현할 코드는 accountA의 계좌에서 1만 원을 accountB로 이체하는 코드입니다.
Account 클래스
DB에 저장할 DTO 클래스를 정의하였습니다.
package hello.jdbc.domain;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class Account {
String account_id;
int money;
}
AccountRepository 클래스
DB에 CRUD 연산을 수행할 Repository 클래스입니다.
아래 코드에서 동일한 Connection을 받기 위해 메서드의 인자로 받고 있습니다. 이번 코드에는 트랜잭션을 사용할 메서드가 findById와 update 부분이라 이 두 개의 메서드에만 Connection을 인자로 받도록 수정하였습니다.
package hello.jdbc.repository.Account;
import hello.jdbc.domain.Account;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.support.JdbcUtils;
import javax.sql.DataSource;
import java.sql.*;
import java.util.NoSuchElementException;
@Slf4j
@AllArgsConstructor
public class AccountRepository {
private final DataSource dataSource;
// 같은 connection을 사용해야 하므로 connection을 인자로 받아야 한다!
public Account findById(Connection con, String id) throws SQLException {
String sql = "select * from account where account_id = ?";
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
// con = getConnection(); // 커넥션을 새로 만들면 안된다!
pstmt = con.prepareStatement(sql);
pstmt.setString(1, id);
rs = pstmt.executeQuery();
if (rs.next()) {
int money = rs.getInt("money");
return new Account(id, money);
} else {
throw new NoSuchElementException("account not found account_id = " + id);
}
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
//connection은 여기서 닫지 않는다.
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(pstmt);
}
}
public void update(Connection con, String id, int money) throws SQLException {
String sql = "update account set money=? where account_id=?";
PreparedStatement pstmt = null;
try {
// con = getConnection(); // 커넥션을 새로 만들면 안된다!
pstmt = con.prepareStatement(sql);
pstmt.setInt(1, money);
pstmt.setString(2, id);
pstmt.executeUpdate();
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
//connection은 여기서 닫지 않는다.
JdbcUtils.closeStatement(pstmt);
}
}
// 커넥션 유지가 필요없는 메소드
public Account save(Account account) throws SQLException {
String sql = "insert into account(account_id, money) values (?, ?)";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, account.getAccountId());
pstmt.setInt(2, account.getMoney());
pstmt.executeUpdate();
return account;
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, null);
}
}
public Account findById( String id) throws SQLException {
String sql = "select * from account where account_id = ?";
Connection con = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, id);
rs = pstmt.executeQuery();
if (rs.next()) {
int money = rs.getInt("money");
return new Account(id, money);
} else {
throw new NoSuchElementException("account not found account_id = " + id);
}
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, null);
}
}
public void delete(String accountId) throws SQLException {
String sql = "delete from account where account_id=?";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, accountId);
pstmt.executeUpdate();
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, null);
}
}
public 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("get connection={}, class={}", con, con.getClass());
return con;
}
}
AccountService 클래스
돈을 이체하는 기능을 구현한 서비스 클래스입니다.
package hello.jdbc.service;
import hello.jdbc.domain.Account;
import hello.jdbc.repository.Account.AccountRepository;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
import static hello.jdbc.common.Constants.TEST_ID_FOR_EXCEPTION;
@Slf4j
@AllArgsConstructor
public class AccountService {
private final DataSource dataSource;
private final AccountRepository repository;
public void transferMoney(String fromId, String toId, int money) throws SQLException {
Connection con = dataSource.getConnection(); // 커넥션 얻기
try {
con.setAutoCommit(false); // 트랜잭션 시작
// business logic
businessLogic(con, fromId, toId, money);
con.commit(); // 이체 성공 시 커밋
} catch (Exception e) {
con.rollback(); // 실패 시 롤백
throw new IllegalStateException(e);
} finally {
release(con);
}
}
private void businessLogic(Connection con, String fromId, String toId, int money) throws SQLException {
Account fromAccount = repository.findById(con, fromId);
Account toAccount = repository.findById(con, toId);
repository.update(con, fromId, fromAccount.getMoney() - money);
// exception occur (테스트용)
validation(toAccount);
repository.update(con, toId, toAccount.getMoney() + money);
}
private void validation(Account account) {
if (account.getAccountId().equals(TEST_ID_FOR_EXCEPTION)) {
throw new IllegalStateException("Exception while transferring!!");
}
}
private void release(Connection con) {
if (con != null) {
try {
con.setAutoCommit(true); // 풀에 반납하기 전에 default 값이 자동커밋모드로 바꿔주는 것이 좋다!!
con.close(); // 커넥션 종료가 아니라 풀에 반납한다는 의미!
} catch (SQLException e) {
log.error("error", e);
}
}
}
}
테스트 코드
이제 테스트 코드를 작성하여 트랜잭션이 잘 동작하는지 확인해 보겠습니다.
H2 데이터베이스에 아래 명령어로 ACCOUNT 테이블을 생성합니다.
create table account(
account_id varchar(20) not null,
money number not null
);
그 후 테스트 코드를 작성해 봅시다.
package hello.jdbc.service.account;
import hello.jdbc.domain.Account;
import hello.jdbc.repository.Account.AccountRepository;
import hello.jdbc.service.AccountService;
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.common.Constants.TEST_ID_FOR_EXCEPTION;
import static hello.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class AccountServiceTest {
private AccountRepository repository;
private AccountService service;
@BeforeEach
void setUp() {
// DataSource 정의
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
repository = new AccountRepository(dataSource);
service = new AccountService(dataSource, repository);
}
@AfterEach
// 테스트를 반복적으로 수행하기 위해서 테스트 후 매번 저장한 데이터 제거하는 코드
void tearDown() throws SQLException {
repository.delete("accountA");
repository.delete("accountB");
repository.delete(TEST_ID_FOR_EXCEPTION);
}
@Test
@DisplayName("정상 이체")
void transferMoney() throws SQLException {
Account accountA = new Account("accountA", 50000);
Account accountB = new Account("accountB", 10000);
repository.save(accountA);
repository.save(accountB);
service.transferMoney(accountA.getAccountId(), accountB.getAccountId(), 10000);
Account findAccountA = repository.findById(accountA.getAccountId());
Account findAccountB = repository.findById(accountB.getAccountId());
assertThat(findAccountA.getMoney()).isEqualTo(40000); // 이체 성공
assertThat(findAccountB.getMoney()).isEqualTo(20000); // 이체 성공
}
@Test
@DisplayName("이체중 예외 발생")
void transferException() throws SQLException {
Account accountA = new Account("accountA", 50000);
Account accountEx = new Account(TEST_ID_FOR_EXCEPTION, 10000);
repository.save(accountA);
repository.save(accountEx);
assertThatThrownBy(() -> service.transferMoney(accountA.getAccountId(), accountEx.getAccountId(), 10000)).isInstanceOf(IllegalStateException.class);
Account findAccountA = repository.findById(accountA.getAccountId());
Account findAccountEx = repository.findById(accountEx.getAccountId());
assertThat(findAccountA.getMoney()).isEqualTo(50000); // 이체 실패: 원래 데이터로 복구
assertThat(findAccountEx.getMoney()).isEqualTo(10000); // 이체 실패
}
}
- 이체 중 예외 발생 테스트에서 예외가 발생했으므로 트랜잭션을 롤백합니다. 계좌이체는 실패했으므로 롤백을 수행해서 accountA의 돈이 기존의 5만 원으로 복구되었고, accountEx 또한 기존의 액수와 동일합니다.
위와 같이 애플리케이션에 DB 트랜잭션을 적용하면 서비스 계층이 매우 복잡해집니다. 또 커넥션을 유지하도록 코드를 파라미터로 계속 넘겨주는 식으로 변경하는 것도 번거로운 일입니다. 다음 포스팅에는 스프링을 이용해 이런 문제들을 해결하는 방법에 대해서 작성해 보겠습니다.
'Spring Boot' 카테고리의 다른 글
[DB] JDBC Template 사용하기 (0) | 2024.08.04 |
---|---|
[DB] Spring 의 트랜잭션 추상화 (트랜잭션 매니저) (0) | 2024.08.04 |
[DB] JDBC 실습 (구현 및 테스트) (0) | 2024.08.03 |
[DB] JDBC란? (0) | 2024.07.29 |
MVC 패턴 (1) | 2024.01.03 |