데이터를 저장할 때 단순히 파일에 저장해도 되지만, 데이터베이스를 사용하는 주요 이유 중 하나는 트랜잭션이라는 개념을 지원하기 때문이다. 트랜잭션은 말 그대로 "거래"를 뜻하며, 데이터베이스에서 트랜잭션은 하나의 논리적인 작업 단위를 안전하게 처리하도록 보장하는 기능을 의미한다.
모든 작업이 성공해서 데이터베이스에 정상 반영하는 것을 커밋(Commit)이라 하고, 작업 중 하나라도 실패해서 거래 이전으로 되돌리는 것을 롤백(Rollback)이라 한다.
원자성: 트랜잭션 내에서 실행한 작업들은 마치 하나의 작업인 것처럼 모두 성공 하거나 모두 실패해야 한다.
일관성: 모든 트랜잭션은 일관성 있는 데이터베이스 상태를 유지해야 한다. 예를 들어 데이터베이스에서 정한 무결성 제약 조건을 항상 만족해야 한다.
격리성: 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리한다.예를 들어 동시에 같은 데이터를 수정하지 못하도록 해야 한다. 격리성은 동시성과 관련된 성능 이슈로 인해 트랜잭션 격리 수준(Isolation level)을 선택할 수 있다.
지속성: 트랜잭션을 성공적으로 끝내면 그 결과가 항상 기록되어야 한다. 중간에 시스템에 문제가 발생해도 데이터베이스 로그 등을 사용해서 성공한 트랜잭션 내용을 복구해야 한다.
사용자는 웹 애플리케이션 서버(WAS)나 DB 접근 툴 같은 클라이언트를 사용해서 데이터베이스 서버에 접근하면 커넥션을 맺게 된다. 이때 데이터베이스 서버는 내부 세션을 만들고 모든 요청은 세션을 통해 이루어진다.(커넥션 풀이 10개의 커넥션을 생성하면, 세션도 10개 만들어진다.)
사용자가 데이터를 등록,수정과 같은 작업을 할때 트랜잭션이 시작된다. 커밋하기전 이루어진 작업은 임시 상태로 다른 세션에서 확인할수 없다. 커밋이 완료 후 정상 반영되며, rollback을 호출하면 수정하거나 삭제한 데이터도 모두 트랜잭션을 시작하기 직전의 상태로 복구된다.
자동 커밋
자동 커밋으로 설정하면 각각의 쿼리 실행 직후에 자동으로 커밋을 호출한다.
set autocommit true; //자동 커밋 모드 설정
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 or Rollback을 꼭 호출해야한다.
오토 커밋(Auto Commit) 모드로 동작할 경우, 각 쿼리가 실행될 때마다 자동으로 커밋이 이루어진다. 하지만 이 과정에서 중간에 문제가 발생하면 개별 쿼리가 각각 커밋되어 트랜잭션의 원자성이 깨질 수 있다.
따라서 트랜잭션 내에서는 수동 커밋(Manual Commit) 방식을 사용하는 것이 일반적이다. 이 방식에서는 모든 작업이 성공적으로 완료된 후에만 명시적으로 커밋을 실행하여 원자성을 보장한다.
세션1이 트랜잭션을 시작하고 데이터를 수정하는 동안 아직 커밋을 수행하지 않았는데, 세션2에서 동시에 같은 데이터를 수정하게 되면 여러가지 문제가 발생한다.
이런 문제를 방지하려면, 세션이 트랜잭션을 시작하고 데이터를 수정하는 동안에는 커밋이나 롤백 전까지 다른 세션에서 해당 데이터를 수정할 수 없게 막아야 한다. 순서는 먼저 신청한 세션이 Lock을 가져가고 다른 세션은 해당 데이터에 대한 수정,등록을 할수 없다. 이후 기존 세션에서 작업을 완료하고 Lock을 반납하면 대기하던 세션이 이어받고 작업을 진행한다.
실제 SQL 예제
세션1에서 먼저 member_id가 memberA인 row에 money를 500으로 설정한다. 이때 Lock을 소유하게 되고 세션2에서 동일한 row에 접근할때 Lock 소유를 위해 60초를 대기하고(Lock TimeOut 설정) 쿼리는 실행 대기 상태가 된다.세션1이 60초이내 commit을 완료하면 세션2에서 대기중인 쿼리를 실행하게 된다.
대부분 단순 조회는 Lock이 필요하지 않지만 memberA의 금액을 조회한 다음에 이 금액 정보로 애플리케이션에서 어떤 중요한 계산을 수행할때 이 값은 변경되면 안된다. 이 경우 조회 시점에 Lock을 획득하면 된다.
select for update(Lock)
set autocommit false;
select * from member where member_id='memberA' for update;
실제 어플리케이션에서 DB 트랜잭션을 사용하여 원자성이 중요한 로직을 구현해보자.
MemberServiceV1
@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,toMember.getMoney()+money);
}
private static void validation(Member toMember) {
if(toMember.getMemberId().equals("ex")){
throw new IllegalStateException("이체중 예외 발생");
}
}
}
멤버 아이디 2개와 금액을 순차적으로 입력하면 memberRepository를 통해 멤버 인스턴스의 금액을 변경한다. 만일 ex를 가진 회원에 금액을 이체하면 예외를 터트린다.
테스트
@Test
@DisplayName("정상 이체")
void accountTransfer() 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 findMemberB = memberRepository.findById(MemberEX.getMemberId());
assertThat(findMemberA.getMoney()).isEqualTo(8000);
assertThat(findMemberB.getMoney()).isEqualTo(10000);
}
해당 테스트는 중간에 예외가 발생하기 때문에 assertThatThrownBy가 통과되고 line by line으로 commit이 되기 때문에 A만 이체 금액이 차감된 결과를 확인할수 있다.
이번에 트랜잭션을 적용하여 원자성을 확보해보자.
애플리케이션에서 트랜잭션을 어떤 계층에 걸어야 할까? 쉽게 이야기해서 트랜잭션을 어디에서 시작하고, 어디에서 커밋해야할까?
트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작해야 한다. 비즈니스 로직이 잘못되면 해당 비즈니스 로직으로 인해 문제가 되는 부분을 함께 롤백해야 하기 때문이다.
애플리케이션에서 DB 트랜잭션을 사용하려면 트랜잭션을 사용하는 동안 같은 커넥션을 유지해야 한다. 이는 서비스 계층에서 커넥션을 생성하고 종료되어야 한다. (-> 다른 커넥션일 경우 다른 세션에서 동작)
같은 커넥션을 유지하기 위해서 파라미터로 커넥션을 받아 일관되게 처리할수 있도록 코드를 수정해보자.
MemberRepositoryV2
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();
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 {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(pstmt);
}
}
//update()도 마찬가지
파라미터로 connection을 받고 기존 getConnection()은 삭제하였다. 또한 리소스 정리에서 connection을 close하면 소멸하기 때문에 해당 코드를 삭제하였다.
MemberServiceV2
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV2 {
private final MemberRepositoryV2 memberRepository;
private final DataSource dataSource;
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 void release(Connection con) {
if(con !=null){
try {
con.setAutoCommit(true); // 커넥션 풀 고려
con.close();
}catch (Exception e){
log.info("error",e);
}
}
}
private void validation(Member toMember) {
if(toMember.getMemberId().equals("ex")){
throw new IllegalStateException("이체중 예외 발생");
}
}
}
DataSource를 HikariCP인 커넥션 풀을 사용한다면 Connection을 close하면 풀에 반납이된다. 하지만 위 코드에서 AutoCommit을 false로 했기 때문에 원상 복구하는 로직이 finally에서 필요하다.
테스트를 다시 진행해보면 중간에 예외가 터지고 Rollback되어 초기 금액이 변경되지 않는것을 볼수 있다.
애플리케이션 구조는 다음과 같은 3가지 구조로 크게 나뉜다.
1.프레젠테이션 계층
2.서비스 계층
3.데이터 접근 계층
여기서 가장 중요한 계층은 서비스 계층이며, 특정 기술에 종속적이지 않게 개발해야 변경에 강한 설계를 실현할수 있다. 종속적인 부분은 나머지 계층이 가져간다. 이전에 작성한 MemberServiceV2에서 DataSource,Connection,SQLException과 같이 JDBC 기술에 의존했다. 결과적으로 순수 자바 코드인 비지니스 로직보다 JDBC를 사용해 트랜잭션을 처리하는 코드가 더 많다. 문제점들을 정리해보면
비지니스 로직에서 트랜잭션 적용이 불가피 하여 종속적 기술(JDBC)이 적용되었다. ex.javax.sql.DataSource,java.sql.Connection,java.sql.SQLException 또한 같은 세션을 유지하기 위해 커넥션을 파라미터로 넘기는 번거로운 작업이 있다.
데이터 접근 계층의 예외가 서비스 계층으로 전파되어(throw SQLException) JDBC 기술이 서비스 계층에서 영향을 미친다.
서비스 계층에서 순수 자바 코드인 비지니스 로직의 비중이 적고 불필요한 코드(트랜잭션,try-catch,예외)가 많다.
이 문제를 해결해줄 스프링 트랜잭션을 다음 파트에서 사용해보자.