1. 트랜잭션이란?
1.1. 트랜잭션의 기본 이해
트랜잭션의 사전적 정의는 "거래"다. 이때 데이터베이스에서 트랜잭션이란 하나의 거래를 안전하게 처리하도록 보장함을 의미한다. 근데 하나의 거래를 안전하게 처리하려면 고려할게 많다. 예를 들어, A의 돈을 B에게 이체한다고 하자. 그럼 A의 잔고 값을 감소시키고, B의 잔고 값은 증가시켜야한다. 만약 두 작업 중 하나는 성공하고, 하나는 실패하는 경우는 문제가 된다. 이런 경우 트랜잭션은 작업 1, 2가 모두 성공되어야 데이터를 저장한다. 하나라도 실패하면 거래 전의 상태로 되돌린다. 이때, 모든 작업이 성공하여 데이터베이스에 반영하는 것을 Commit이라고 부르고, 하나라도 실패하여 이전 상태로 되돌리는 것을 Rollback이라고 부른다.
1.2. ACID
트랜잭션은 ACID를 보장해야하며 각각의 의미는 다음과 같다.
- Atomicity: 성공 혹은 실패 두 가지 상태만 가진다. 즉, 트랜잭션 내에서 실행되는 모든 작업들은 모두 성공하거나 모두 실패하거나 두 가지 상태만을 가진다.
- Consistency: 데이터베이스의 상태는 일관되어야 한다. 하나의 트랜잭션 이전과 이후에 데이터베이스의 상태는 이전과 같이 유효해야 한다. 즉, 트랜잭션이 일어나도 데이터베이스의 기존 제약이나 규칙은 만족해야 한다.
- Isolation: 각각의 트랜잭션은 서로에게 영향을 미치지 않는다. (서로 동시에 같은 데이터를 수정하지 못하도록 한다.)
- Durability: 트랜잭션이 성공적으로 마무리된 경우, 변경된 내용이 데이터에 잘 적용되어야 한다. 시스템 오류가 발생하는 경우도 마찬가지다. (성공적으로 수행된 트랜잭션은 영원히 반영되어야 한다.)
하지만 트랜잭션 간 격리성을 완벽히 보장하려면 트랜잭션을 순서대로 실행해야한다. 이런 경우 성능이 좋지 않다. 따라서 트랜잭션의 Isolation level을 총 4가지로 나누게 된다. 보통 Read Committed를 주로 사용한다.
Isolation Level | 설명 |
Read Uncommitted | 다른 트랜잭션에서 값을 읽을 수 있다. Commit되지 않은 상태여도 update된 값을 다른 트랜잭션에서 읽을 수 있다. 보통 트랜잭션의 작업이 완료되지 않았는데도 다른 트랜잭션에서 볼 수 있는 현상을 Dirty Read라고 부르며 이것이 Read Uncommitted의 제다. |
Read Committed | 기본적으로 사용되는 Isolation Level이다. 실제 테이블 값을 가져오지 않고 Undo 영역에 백업된 레코드의 값을 가져온다. Dirty Read는 발생하지 않지만, 하나의 트랜잭션에서 똑같은 Select문을 실행했을 때 중간에 다른 트랜잭션의 변경사항이 Commit되면 다른 결과를 가져올 수 있는 문제가 발생한다. 이러한 문제를 Non-Repeatable Read라고 한다. (Phantom Read도 발생한다.) |
Repeatable Read | 트랜잭션마다 트랜잭션 ID를 부여하고, 트랜잭션 ID보다 작은 트랜잭션 번호에서 변경한 것만 읽는다. 하지만 이 방식도 Phantom Read의 문제가 발생할 수 있다. |
Serializable | 가장 엄격한 방식으로 거의 사용하지 않는다. |
- Phantom read: Non-Repeatable Read의 한 종류로 조회하던 결과의 행이 새로 생기거나 없어지는 현상이다.
2. 트랜잭션 예제
2.1. commit과 rollback의 개념
DML 쿼리들을 실행하고 해당 결과를 반영하려면 commit을 호출하고, 반영하고 싶지 않다면 rollback을 호출하면 된다. 이것이 기본적인 트랜잭션 사용법이다. 이때 커밋 호출 전까지는 임시로 데이터를 저장한다. 따라서 해당 트랜잭션을 실행한 사용자에게만 임시로 변경된 데이터가 보인다. (사용자들은 각각 DB로 커넥션을 연결하고 각자의 세션을 할당받는다.)
만약 커밋하지 않은 데이터를 다른 곳에서 조회하는 경우 다음과 같은 문제가 발생할 수 있다.
- 세션1에서 데이터를 추가하고 아직 커밋하지 않았다고 하자. 그런데 세션2에서 이런 데이터를 조회할 수 있다면, 해당 값을 가지고 어떤 로직을 수행할 수도 있다. 하지만 이후 세션1이 rollback을 수행하면 기존에 세션2가 수행한 연산은 아무런 의미가 없게 된다. (Read Uncommitted는 이런 데이터들을 볼 수 있다. 이런 방식은 성능면에서는 유리하지만 데이터 정합성에 문제가 많기 때문에 잘 사용하지 않는다.)
2.2. 자동 커밋 vs. 수동 커밋
자동 커밋은 각각의 쿼리 실행 이후 자동으로 커밋되는 방식이다. 우리가 일반적으로 DB에 Update나 Insert 등의 DML을 사용하면 바로 DB에 반영된다. 이런 방식은 쿼리를 하나 실행하면 자동으로 커밋되기 때문에 트랜잭션 기능을 제대로 활용할 수 없다.
SET autocommit true;
INSERT INTO member(id, moeney) VALUES('id1', 10000); // 자동 커밋된다.
INSERT INTO member(id, moeney) VALUES('id2', 5000); // 자동 커밋된다.
수동 커밋은 commit이나 rollback을 직접 호출하는 방식이다. 위 코드는 아래와 같이 변경될 수 있다. (SET autocommit fase는 트랜잭션의 시작을 의미한다.)
SET autocommit false;
INSERT INTO member(id, moeney) VALUES('id1', 10000);
INSERT INTO member(id, moeney) VALUES('id2', 5000);
commit;
h2에서 두 개의 세션을 만들어 수동 커밋 모드로 데이터를 수정해 보았다. 먼저 기존 테이블은 다음과 같다.
만약 한 쪽 세션에서 데이터를 수정했지만, 데이터를 커밋하지 않은 경우 해당 세션에서는 왼쪽과 같이 수정된 테이블이 보이지만, 다른 세션은 수정된 값이 없다.
만약 세션1에서 commit을 수행 후 조회하면 두 세션 모두 아래와 같이 동일한 결과를 얻을 수 있다.
3. Lock이란?
3.1. Lock의 개념
이전 예시에서는 세션1이 데이터를 수행하고 커밋하기 전에는 다른 세션에서는 해당 내용을 볼 수 없다는 사실을 알 수 있었다. 그런데 세션1이 특정 테이블을 수정하고 있는데 세션2가 동일한 테이블을 동시에 수정하면 어떤 일이 발생할까? 이때 동시성 문제가 발생한다. 이를 위해 DB Lock이라는 개념이 등장한다.
세션들은 특정 테이블을 수정하기 전에 먼저 Lock을 획득해야 한다. 만약 Lock을 획득했다면, 이후 수정작업을 수행한다. 만약 어떤 세션이 해당 테이블을 수정중이라면 Lock을 해당 세션이 가지고 있을 것이고, 만약 다른 세션이 이를 수정하려고 한다면 Lock을 얻을 수 없어 수정이 불가능하다.
먼저 세션1에서 member_A의 정보를 바꾸기 위해 아래와 같이 쿼리문을 작성하였고, commit은 수행하지 않았다.
만약 이 상태에서 다른 세션이 member_A의 정보를 바꾸려고 시도하면 Lock을 얻을 수 없어 아래와 같이 에러가 발생한다.
락은 기본적으로 락을 가진 트랜잭션이 commit 혹은 rollback할 때 반환한다. 만약 락을 얻기 위해 기다리는 시간을 지정하려면 "SET LOCK_TIMEOUT 60000"와 같이 SET LOCK_TIMEOUT문을 작성하면 된다.
3.2. Lock - 조회
보통 데이터 조회 시에는 락을 획득하지 않아도 조회가 가능하다. 단, 데이터 조회 시에 락을 획득하고 싶을 때가 있는데 이런 경우 "SELECT FOR UPDATE" 구문을 사용하면 된다. 이런 경우 다른 세션에서 락을 가지고 있을 때 조회가 불가능하다. 보통 트랜잭션 종료까지 해당 데이터를 다른 곳에서 변경하지 못하게 할 때 사용한다. 아래와 같이 사용할 수 있다.
SELECT * FROM member WHERE member_id='member_A' FOR UPDATE;
4. 트랜잭션 적용 - JDBC
우리가 DB에 어떤 데이터를 추가하고, 수정하는 작업은 아래와 같은 코드처럼 작성할 수 있다.
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);
memberRepository.update(toId, toMember.getMoney() + money);
}
그런데 위와 같은 방식은 중간에 에러가 발생하면, fromMember의 돈만 감소하고, toMember의 돈은 증가하지 않는 문제가 발생할 수 있다. 이런 상황을 예방하기 위해 비지니스 로직 시작 전에 트랜잭션을 시작해야 한다. 따라서 코드를 아래와 같이 변경해서 계좌 이체의 전체 비지니스 로직을 하나의 트랜잭션으로 묶어서 실행해야 한다.
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
Connection con = dataSource.getConnection();
try {
con.setAutoCommit(false);
bizLogic(fromId, toId, money, con);
con.commit();
} catch (Exception e) {
con.rollback();
throw new IllegalStateException(e);
} finally {
if (con != null) {
try {
con.setAutoCommit(true);
con.close();
} catch (Exception e) {
log.info("error", e);
}
}
}
}
private void bizLogic(String fromId, String toId, int money, Connection con) throws SQLException {
Member fromMember = memberRepository.findById(con, fromId);
Member toMember = memberRepository.findById(con, toId);
memberRepository.update(con, fromId, fromMember.getMoney() - money);
memberRepository.update(con, toId, toMember.getMoney() + money);
}
- 단, 트랜잭션은 하나의 세션에서 실행되어야 한다. 따라서 하나의 connection을 사용해야 한다.
출처: https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-db-1/dashboard
'BackEnd > Spring' 카테고리의 다른 글
[Spring DB-1] 문제해결 - 예외 처리, 반복 (0) | 2024.05.08 |
---|---|
[Spring DB-1] 문제 해결 - 트랜잭션 (0) | 2024.05.07 |
[Spring DB-1] Connection Pool과 DataSource (0) | 2023.11.24 |
[Spring DB-1] JDBC 이해 (0) | 2023.11.15 |
[Spring MVC-2] 메시지, 국제화 (0) | 2023.11.10 |