데이터를 DataBase에 저장하는 가장 대표적인 이유는 DB가 가진 transaction기능 때문이다. 직역하면 "거래"라는 뜻의 트랜잭션은 하나의 거래를 안전하게 처리하도록 보장해준다. 하나의 거래가 성공적으로 처리되려면 생각보다 고려해야할 점이 많다.
"kim이 song에게 50000원의 송금 거래"
위의 거래 행위에서 kim의 통장잔고의 총액에서 50000원이 빠져야 할 것(1)이며 song의 통장잔고 총액에서는 50000원이 늘어나야 할 것(2)이다. 이 거래에서 (1), (2)는 AND로 성공해야한다. 어느 하나라도 실패할 경우가 그대로 데이터베이스에 적용된다면 인터넷뱅킹 시스템은 큰 문제를 겪게 될 것이다.
모든 작업이 성공(거래 성공)해서 데이터베이스에 이를 정상 반영하는 것을 Commit(커밋)이라 하고, 작업 도중 문제가 발생하여 거래 이전으로 되돌리는 것을 Rollback(롤백)이라 한다.
트랜잭션은 원자성(Atomicity), 일관성(Consistency), 격리성(Isolation), 지속성(Durability)를 보장해야만 한다.
원자성 : 트랜잭션 내에서 실행한 작업들은 마치 하나의 작업인 것처럼 모두 성공 하거나 모두 실패해야 한다.일관성 : 모든 트랜잭션은 무결성 만족과 같은 일관성 있는 데이터베이스 상태를 유지해야한다.(데이터베이스에 설정된 제약을 준수해야한다.)격리성 : 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않아야 한다.지속성 : 트랜잭션을 성공적으로 마친다면 그 결과가 데이터베이스에 반영되어야 한다. 성공적으로 마쳤지만 반영에 있어 지연된다면 데이터베이스 로그 등을 사용하여 성공한 트랜잭션 내용을 복구하여 반영해야한다.트랜잭션은 원자성, 일관성, 지속성은 확실히 보장하지만 격리성은 성능을 고려하여 격리 수준을 나누어 적용할 수 있다.(이번 챕터에서는 이를 다루지 않는다.)
클라이언트(애플리케이션 서버)가 DB에 커넥션 요청을 하여 커넥션을 맺게 된다면 데이터베이스 서버는 자신의 내부에 세션을 만든다. 그리고 하나의 커넥션에 대해 이를 통해 들어오는 모든 요청은 이 세션을 통해 실행한다.(커넥션 풀에 커넥션이 10개라면 DB에서 세션또한 10개가 된다.)
트랜잭션을 적용하기 위해서 sql 쿼리로는 commit;, 롤백을 실행하려면 rollback;을 호출하면 된다. 커밋과 롤백이 호출되기 전 까지 DB는 임시로 데이터들을 보관하고 있는다. 해당 트랜잭션을 시작한 세션에게만 임시로 데이터들을 보관하고 있음을 확인할 수 있으며 다른 세션에게는 변경 데이터가 커밋이나 롤백 이전에는 보이지 않는다.(트랜잭션 이전 상황이 보인다.)
만약 커밋하지 않은 데이터를 다른 세션에서 조회하여 사용할 수 있다면, 조회하여 사용했지만 커밋을 하지 않고 롤백이 진행되었다면 활용해서는 안될 데이터를 다른 세션을 통해 활용하게 되는 것이다. 그러므로 커밋 이전에 다른 세션이 이를 조회해서 사용하는 것은 불가능해야한다.
기본적으로 데이터베이스(H2 사용중)는 자동 커밋 상태이다.
insert into member(member_id, money) values ('data1',10000); //자동 커밋
insert into member(member_id, money) values ('data2',10000); //자동 커밋
위와 같은 두 insert 명령어는 한 줄씩 실행되며 한 줄씩 커밋이 자동으로 진행된다. 우리는 이에 트랜잭션을 걸기 위해 set autocommit false;를 호출해야한다.
set autocommit false; //수동 커밋 모드 설정
insert into member(member_id, money) values ('data1',10000); //자동 커밋
insert into member(member_id, money) values ('data2',10000); //자동 커밋
이렇게 명령을 준다면 commit;을 하지 않는 이상 데이터베이스에서 두 인서트문은 적용되지 않을 것이다. 그리고 위의 내용으로 하여금 알 수 있는 점은 이 명령을 수행한 세션에서는 인서트에 대한 수정내용을 보기 위해 데이터베이스 조회시 두 데이터가 추가된 것을 볼 수 있고 다른 세션에서 commit 이전 조회시 추가되지 않은 모습(이전의 모습)을 볼 수 있다.
세션1에서 트랜잭션을 시도하고 있을 때(Commit되기 이전) 세션2에서 트랜잭션이 진행되는 데이터에 접근 시 데이터가 보이지 않는다고 했다. 만약 세션1의 트랜잭션 도중 세션2에서 트랜잭션에서 진행되고 있는 데이터를 변경한다면 세션1은 변경된 데이터를 처리할 것이다. 이는 세션1이 목표한 것과 다른 결과를 가져올 수 있으므로 데이터베이스는 이를 막을 Lock기능을 제공한다.

만약 특정 레코드가 특정 세션에서 트랜잭션의 대상이 된다면 해당 세션은 해당 레코드에 대한 Lock을 얻는다. 다른 세션에서 트랜잭션 도중 이 레코드에 대한 트랜잭션을 하려고하지만 Lock을 빼앗긴 상황이기 때문에 트랜잭션을 진행할 수 없게 된다. 다른 세션의 트랜잭션은 잠시 대기 상태를 가진다. 이 대기 상태는 무한정 대기하지 않고 락 대기시간을 가지고 대기시간동안 기다리게 된다. 대기시간을 초과할 경우 락 타임오류가 발생하며 이러한 락 대기시간은 직접 설정도 가능하다.(SET LOCK_TIMEOUT <milliseconds>)
위의 내용에서 세션1이 트랜잭션 진행중(commit이전) 세션2에서 select를 통해 조회가 가능했다. 물론 트랜잭션 진행사항이나 완료사항은 commit이전이기 때문에 전혀 보이지 않는다. 트랜잭션 이전 상황에 대한 조회접근이 가능했다.
데이터 조회에 대해서도 락을 획득할 수 있다. 이는 세션2에서 단순히 select로 접근할 것이 아니라 select for update로 하여금 이 레코드가 락이 없다면 조회가 불가능한 호출을 사용할 수 있다.
예를 들어 대규모 트랜잭션이 일어나고 있다고 가정해보자. 중요한 작업이기에 모든 로직은 이 트랜잭션이 완료된 후 작업이 허용된다. 만약 다른 세션에서 이전의 데이터로 무언가 계산하려고 시도하고 성공한다면 이는 오류가 된다. 이러한 논리에서 조회에 대해서도 Lock 상태를 반영하는 것이 필요할 수 있다.
select * from member where member_id='memberA' for update;와 같이 쿼리문을 만들 수 있다.
// 코드 일부분 발췌
// 계좌이체 로직
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);
}
}
기존에는 리포지토리의 각 기능에서 connection을 직접 가져왔지만 트랜잭션을 적용하기 위해(하나의 트랜잭션 내에서 하나의 connection만 사용을 위함) 트랜잭션 이전에 connection객체를 불러와 모든 리포지토리 기능에서 하나의 connection객체만을 사용하기 위해 각각의 기능마다 connection을 주입해서 사용한다.
서비스 클래스에서 나타낸 코드이지만 트랜잭션을 구상하기 위해 복잡한 코딩이 이루어지고 있다. 물론 현재까지 하는 실습은 예전의 방식이며 이렇게 복잡한 트랜잭션을 쉽게 처리하기위한 최신 기술들이 존재한다.