[Spring Database] Transaction / Lock
데이터를 저장할 때 메모장같은 파일이나 다른 저장소 대신 데이터베이스를 사용하는 가장 큰 이유는 데이터베이스가 트랜잭션을 제공하기 때문이다.
데이터베이스에서의 트랜잭션은 한 가지 거래를 안전하게 처리되도록 보장해주는 작업을 의미한다.
인터넷 뱅킹을 생각해보자.
A가 B에게 1000원을 이체하면 A의 잔고는 1000만큼 감소시키고, B의 잔고는 1000만큼 증가시켜야 한다.
만약 감소하는 작업은 성공했지만 증가하는 작업은 실패하는 치명적인 오류가 발생한다면?
아무도 그 은행을 사용하지 않을 것이다.
즉, 증가시키는 작업과 감소시키는 작업이 하나의 작업처럼 묶여서 동작해야 한다.
데이터베이스가 제공하는 트랜잭션 기능을 사용하면 두 가지 작업 모두 성공해야 데이터베이스에 값을 저장 (Commit) 하고, 중간에 하나라도 실패하면 거래 전의 상태로 돌아가도록(Rollback) 할 수 있다.
트랜잭션은 네 가지 속성을 보장해야 한다.
Atomicity (원자성) : 트랜잭션 내부에서 실행한 작업들은 하나의 작업처럼 모두 성공하거나 모두 실패해야 한다.
Consistency (정합성) : 모든 트랜잭션은 일관성 있는 데이터베이스 상태를 유지해야 한다.
Isolation (격리성) : 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리한다. (동시에 같은 데이터를 수정할 수 없다)
Durability (지속성) : 트랜잭션을 성공적으로 끝내면 그 결과가 항상 기록돼야 한다. (시스템 문제가 발생해도 복구할 수 있어야 한다)
원자성 일관성 지속성에는 문제가 없지만.. 격리성을 완벽히 보장하려면 트랜잭션을 거의 들어온 순서대로 처리해야 한다.
이 경우 동시 처리 성능이 매우 떨어지고, 이런 문제를 해결하기 위해 ANSI 표준은 트랜잭션의 격리성 수준을 4단계로 나눠 정의한다.
READ UNCOMMITED(커밋되지 않은 읽기)
READ COMMITTED(커밋된 읽기)
REPEATABLE READ(반복 가능한 읽기)
SERIALIZABLE(직렬화 가능)
위에서 내려올 수록 격리성이 낮고, 상황에 따라 다르지만 보통 격리 수준을 READ COMMITTED으로 설정한다.
클라이언트를 DB접근 툴로 정의하고 서버를 데이터베이스 서버로 정의하자.
클라이언트가 데이터베이스 서버와 커넥션을 맺으면 데이터베이스 서버는 내부에 세션을 만든다.
세션을 통해 클라이언트의 모든 요청을 처리하고, 클라이언트가 커넥션을 닫거나 DB관리자가 세션을 끊으면 세션이 종료된다.
커넥션 풀을 통해 10개의 커넥션을 생성하면 세션도 10개 만들어진다.
데이터를 변경하는 SQL을 실행하고 데이터베이스에 그 결과를 반영하려면 commit 명령어를 호출하고, 결과를 반영하고 싶지 않으면 rollback을 호출한다. (변경은 등록 / 수정 / 삭제를 의미한다)
즉, 커밋을 수행하기 전까지는 임시로 데이터를 저장한다.
해당 트랜잭션을 사용한 세션만 변경 데이터를 볼 수 있고, 다른 세션은 변경 데이터를 볼 수 없다.
물론 데이터베이스의 격리 수준에 따라 다르긴 하지만.. 보통 커밋 전에는 다른 세션이 작업한 데이터를 볼 수 없다.
이 부분은 데이터의 정합성 때문인데, 세션1이 변경하고 커밋하기 전인 데이터를 세션2가 볼 수 있다면 세션2는 데이터가 변경됐다고 가정하고 작업을 수행할텐데, 세션1이 커밋 대신 롤백을 해버린다면? .. 심각한 오류가 발생한다.
set autocommit true; //자동 커밋 모드 설정
insert into member(member_id, money) values ('data1',10000); //자동 커밋
insert into member(member_id, money) values ('data2',10000); //자동 커밋
자동 커밋으로 설정하면 각각의 쿼리가 실행된 직후에 커밋이 호출된다. (내부적으로 바로바로 트랜잭션이 실행된다고 생각하자)
트랜잭션을 제대로 사용하려면 이 기능을 사용하면 사용하면 안 된다.
set autocommit false; //수동 커밋 모드 설정
insert into member(member_id, money) values ('data3',10000);
insert into member(member_id, money) values ('data4',10000);
commit; //수동 커밋
보통 자동 커밋이 기본값으로 설정되어 있기 때문에 수동 커밋으로 설정하는 작업을 "트랜잭션을 시작한다"라고 표현한다.
수동 커밋을 사용할 때는 쿼리 실행 이후 꼭 commit 혹은 rollback을 호출해주자.
두 개의 세션이 같은 데이터에 접근하는 경우를 생각해보자.
세션1이 트랜잭션을 시작하고 데이터를 수정하는 동안 아직 커밋을 수행하지 않았는데 세션2에서 같은 데이터를 수정하게 되면 어떻게 될까?
네 가지 속성 중 하나인 원자성이 깨지게 된다.
여기서 세션1이 중간에 롤백까지 하면 세션2는 잘못된 데이터를 수정하게 되는 문제까지 발생한다.
이런 문제를 방지하려면, 하나의 세션이 트랜잭션을 시작하고 데이터를 수정하는 동안 다른 세션에서 해당 데이터에 접근하는걸 막아야 한다.
데이터베이스는 락(Lock)이라는 개념을 통해 문제를 해결한다.
세션1과 세션2가 동일한 데이터 행에 접근하는 경우, 우선 더 빨리 접근한 세션이 락을 획득한다.
락을 획득한 세션1은 해당 행에 update SQL을 수행할 수 있다.
이 때 세션2는 세션1보다 늦게 접근을 시도했기 때문에 락을 획득하지 못했다. 락이 들어올 때 까지 기다린다.
(락 대기 시간을 넘어가면 락 타임아웃 오류가 발생한다)
세션1이 커밋 혹은 롤백을 수행하면 락이 반납된다.
락을 획득한 세션2가 update SQL을 수행하고... 이후는 앞과 비슷한 양상으로 진행된다.
데이터베이스에 따라 다르지만, 보통 데이터를 조회 할 때는 락을 획득할 필요 없이 바로 조회할 수 있다.
(커밋 전 데이터를 조회한다)
하지만, 돈 계산처럼 데이터를 조회하는 동안 값이 변하면 안 되는 작업을 할 때는 락을 가져가야 한다.
이럴 때는 SELECT FOR UPDATE 구문을 사용하자.
set autocommit false;
select * from member where member_id='memberA' for update;
SELECT FOR UPDATE 구문을 사용하면 세션이 해당 데이터를 조회하는 동안 다른 세션이 조회하고 있는 데이터를 수정할 수 없다.
물론, 커밋이나 롤백 시 락을 반납해 다른 세션이 데이터를 수정할 수 있고, 락을 가져갈 수 없으면 대기해야 한다.
애플리케이션에서 트랜잭션을 사용할 때는 어떤 계층에서 적용해야 할까?
비즈니스 로직에서 오류가 발생하면 문제되는 부분을 함께 롤백하기 위해 트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작한다.
즉, 서비스 계층에서 커넥션을 만들고 트랜잭션 커밋 이후에 커넥션을 종료해야 한다.
하나의 트랜잭션이 진행될 때는 같은 세션을 사용해야 하고, 같은 커넥션을 유지해야 한다.
커넥션을 파라미터로 전달해서 같은 커넥션이 유지되도록 해 보자.
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);
}
}
서비스 계층에서 사용하는 리포지토리의 메서드를 교체했다.
con = getConnection() 메서드로 커넥션을 얻어오는 대신, 파라미터로 넘어온 커넥션을 사용한다.
서비스 로직이 끝날 때 까지 같은 커넥션을 사용하기 위해 리포지토리에서 커넥션을 닫지 않는다.
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 void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체중 예외 발생");
}
}
private void release(Connection con) {
if (con != null) {
try {
con.setAutoCommit(true); //커넥션 풀 고려
con.close();
} catch (Exception e) {
log.info("error", e);
}
}
}
}
서비스 계층에서는 트랜잭션을 시작한다.
리포지토리의 메서드를 사용할 때 서비스 계층에서 DataSource를 통해 주입받은 커넥션을 파라미터로 넘겨서 하나의 트랜잭션에서는 같은 커넥션을 유지하도록 한다.
finally문법을 사용해 커넥션을 모두 사용하고 나면 안전하게 종료한다.
여기서 con.close() 메서드는 커넥션을 종료하는게 아니라 커넥션 풀에 반납하는데, 트랜잭션을 위해 수동 커밋 모드로 설정했기 때문에 반납 전에 자동 커밋 모드로 변경한다. (일반적으로 자동 커밋을 사용한다)
비즈니스 로직이 정상적으로 수행되면 커밋, 로직 수행 도중 예외가 발생하면 롤백한다.
트랜잭션을 적용해 여러 작업들을 하나의 작업으로 묶어서 처리했는데..
비즈니스 로직을 처리하는 코드보다 트랜잭션을 처리하는 코드가 훨씬 더 많아져 코드가 지저분해졌다.
AOP는 이런 공통 관심사를 처리하기 위해 도입됐다.
'Spring > Spring Database' 카테고리의 다른 글
[Spring Database] JdbcTemplate (0) | 2022.09.06 |
---|---|
[Spring Database] 데이터베이스 예외 처리 (0) | 2022.09.05 |
[Spring Database] Transaction AOP (1) | 2022.09.01 |
[Spring Database] Connection Pool과 Data Source (0) | 2022.08.31 |
[Spring Database] JDBC (0) | 2022.08.30 |
댓글
이 글 공유하기
다른 글
-
[Spring Database] 데이터베이스 예외 처리
[Spring Database] 데이터베이스 예외 처리
2022.09.05 -
[Spring Database] Transaction AOP
[Spring Database] Transaction AOP
2022.09.01 -
[Spring Database] Connection Pool과 Data Source
[Spring Database] Connection Pool과 Data Source
2022.08.31 -
[Spring Database] JDBC
[Spring Database] JDBC
2022.08.30