[트랜잭션과 ACID]

만약 A가 100만원이 있고 B가 200만원이 있다고 가정하자
A는 B한테 20만원을 송금한다 하였을때 결과론 적으로는 B의 계좌에는 220만원이 있어야 할것이다.
그럴라면 아래와 같은 SQL문이 동작할 것이다.
BEGIN;
-- A의 잔액에서 20만원 차감
UPDATE account
SET balance = balance - 200000
WHERE account_id = 'A';
-- B의 잔액에 20만원 추가
UPDATE account
SET balance = balance + 200000
WHERE account_id = 'B';
COMMIT;
이렇게 정상적인 결과를 얻기 위해서는 두개의 업데이트문이 모두 이루어져야 한다.
이렇듯 이러한 여러개의 작업을 어떠한 하나의 온전한 결과를 위해 하나로 묶여져야 한다. 이런것이 Transaction이다.
Transaction
단일한 논리적인 작업 단위이며
의미론적으로 혹은 논리적으로 여러 SQL 작업들을 단일 작업으로 묶어서 나누어질 수 없게 만든 것이다.
일부작업만 성공해서 반영되는 일은 허용하지 않는다.
autocommit
각 SQL 문장을 자동으로 COMMIT할지 여부를 결정하는 설정
mysql에서는 기본값이 ON이다.
INSERT, UPDATE, DELETE 문을 실행하면 자동으로 COMMIT됨 → 즉, 트랜잭션을 따로 시작하지 않아도 바로 반영됨
autocommit = OFF
→ 수동으로 BEGIN, COMMIT, ROLLBACK을 사용해야 변경 내용이 확정됨
-- 트랜잭션 시작
START TRANSACTION; -- 또는 BEGIN;
-- 작업 1
UPDATE account SET balance = balance - 200000 WHERE account_id = 'A';
-- 작업 2
UPDATE account SET balance = balance + 200000 WHERE account_id = 'B';
-- 성공적으로 끝났으면
COMMIT;
-- 문제가 생기면
-- ROLLBACK;
위와 같이 트랜잭션을 명시적으로 적어서 실행하게 된다면 autocommit 기능은 자동 비활성화가 된다.
이러한 트랜잭션문은 하나의 작업이라도 실패한다면 다시 롤백되게 된다.
자바에서는 아래와 같이 트랜잭션을 구현할 수 있다.
import java.sql.*;
public class TransferExample {
public static void main(String[] args) {
String url = "jdbc:mysql://localhost:3306/mydb"; // DB 접속 URL
String user = "root";
String password = "1234";
Connection conn = null;
try {
conn = DriverManager.getConnection(url, user, password);
conn.setAutoCommit(false); // 트랜잭션 시작
// A의 잔액 감소
PreparedStatement stmt1 = conn.prepareStatement(
"UPDATE account SET balance = balance - ? WHERE account_id = ?"
);
stmt1.setInt(1, 200000);
stmt1.setString(2, "A");
stmt1.executeUpdate();
// B의 잔액 증가
PreparedStatement stmt2 = conn.prepareStatement(
"UPDATE account SET balance = balance + ? WHERE account_id = ?"
);
stmt2.setInt(1, 200000);
stmt2.setString(2, "B");
stmt2.executeUpdate();
conn.commit(); // 둘 다 성공 시 커밋
System.out.println("송금 성공");
} catch (Exception e) {
System.out.println("에러 발생, 롤백합니다: " + e.getMessage());
try {
if (conn != null) conn.rollback(); // 에러 시 롤백
} catch (SQLException rollbackEx) {
rollbackEx.printStackTrace();
}
} finally {
try {
if (conn != null) conn.setAutoCommit(true); // 원래대로 복구
if (conn != null) conn.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}
좋은 코드이다. 하지만 조금 문제가 있다. 너무 길고 뭔가 복잡해서 읽기가 싫고 관리가 힘들다.
그래서 이러한 트랜잭션 코드는 따로 빼서 관리하고 비지니스 로직만 담을 수 있도록 Spring에서 제공해주는 어노테이션이 @Transactional 이다.
@Service
public class TransferService {
@Autowired
private AccountRepository accountRepository;
@Transactional // 트랜잭션 자동 적용
public void transfer(String fromId, String toId, int amount) {
accountRepository.decreaseBalance(fromId, amount);
accountRepository.increaseBalance(toId, amount);
}
}
위와 같이 어노테이션하나로 코드가 말끔해졌다.
이러한 트랜잭션과 관련한 중요한 개념이 있다. ACID이다.
ACID
트랜잭션이 정확하고 신뢰성 있게 처리되기 위해 지켜야 할 원칙 4가지
1. A - 원자성 (Atomicity)
트랜잭션은 모두 실행되거나 전부 실패해야 한다. 중간은 없다.
예: A가 B한테 송금할 때
A 계좌에서 20만원 빠지고 → B 계좌에 20만원 들어가야 함
→ 둘 중 하나라도 실패하면 전체 취소돼야 함
2. C - 일관성 (Consistency)
트랜잭션 실행 전후로 데이터 무결성이 보장되어야 한다.
예: 총 은행 자산이 1억이었으면, 트랜잭션 이후에도 1억이어야 함
무결성 제약 조건(예: 외래키, 제약조건 등)을 깨지 않도록 함
3. I - 독립성 (Isolation)
동시에 실행되는 트랜잭션이 서로 간섭없이 혼자 실행되는 것처럼 격리수준을 지키며 동작하게 하는 것
예: A가 송금하는 중에 B가 잔액을 조회했을 때,
→ 중간 상태를 보면 안 되고, 완료된 결과만 보여야 함
4. D - 지속성 (Durability)
트랜잭션이 성공적으로 COMMIT되면,
시스템이 꺼져도 결과는 영구히 보존돼야 함
근데 격리수준은 좀 더 자세히 알아 볼 필요가 있다.
동시에 트랜잭션이 실행되면 **이상한 현상들(트랜잭션 간 간섭)**이 발생할 수 있음.
그걸 막아주는 게 바로 Isolation Level, 즉 격리 수준
트랜잭션에서 발생할 수 있는 3가지 결과이다.
| Dirty Read | 다른 트랜잭션이 커밋하지 않은 데이터를 읽음 | A가 잔액 수정 중인데 B가 그걸 읽어버림 |
| Non-repeatable Read | 한 트랜잭션 중 같은 SELECT 결과가 달라짐 | SELECT 한 번 했는데, 다시 하니까 값이 바뀜 |
| Phantom Read | 같은 조건으로 SELECT했는데 행 개수가 달라짐 | WHERE age > 20 했는데 다음엔 더 나옴 |
MySQL 격리 수준 4단계
낮은 격리수준 -> 높은 격리 수준 순이다.
| 격리 수준 | Dirty Read | Non-repeatable Read | Phantom Read |
| READ UNCOMMITTED | ❌ 발생함 | 발생함 | 발생함 |
| READ COMMITTED | ✅ 방지됨 | ❌ 발생함 | 발생함 |
| REPEATABLE READ (MySQL 기본값) | ✅ | ✅ | ❌ (MySQL은 인덱스 기반으로 방지 가능) |
| SERIALIZABLE | ✅ | ✅ | ✅ 완전 방지 (락 기반) |
mysql기본 격리수준은 REPEATABLE READ이다.
✔ READ UNCOMMITTED :
가장 낮은 격리 수준
커밋되지 않은 데이터도 읽을 수 있음 → Dirty Read 발생 가능
매우 빠르지만 신뢰도가 낮다.
✔ READ COMMITTED :
커밋된 데이터만 읽기
커밋된 데이터만 읽음 → Dirty Read 방지
하지만 동일한 SELECT를 두 번 하면 다른 결과가 나올 수 있음
예시:
- 트랜잭션 A에서 SELECT
- 트랜잭션 B에서 UPDATE 후 COMMIT
- 트랜잭션 A에서 같은 SELECT → 값이 바뀜
✔ REPEATABLE READ :
반복 읽기 보장
트랜잭션 내에서 같은 SELECT는 항상 같은 결과 반환
즉, Non-repeatable Read 방지
MySQL에선 MVCC 덕분에 Phantom Read도 방지
요약:
- MySQL InnoDB 기본 격리 수준
- 대부분의 정합성 요구 충족
- MVCC 기반 스냅샷 읽기 덕분에 성능도 좋음
✔ SERIALIZABLE :
가장 엄격한 수준
모든 트랜잭션을 직렬적으로 실행하는 것처럼 동작
모든 읽기/쓰기 요청에 락을 걸기 때문에 성능 저하 큼
- 모든 SELECT 시에도 공유 락 걸림
- 동시성 낮고 성능 느림
- 정합성 최우선일 때만 사용 (예: 금융, 회계, 송금)
* MVCC (Multi-Version Concurrency Control)
→ 다중 버전 동시성 제어
데이터의 Lock 없이, 트랜잭션 마다 자기만의 "과거 시점 스냅샷"을 읽게 해주는 기술이다.
- > 트랜잭션이 시작되면, 그 순간의 데이터 상태만 보는 것
- 다른 트랜잭션이 데이터를 수정하고 있어도,
- 내 트랜잭션에서는 처음에 보던 값 그대로 읽힘
- 마치 스냅샷 사진을 찍어두고 그걸 계속 보는 것처럼