트랜잭션은 데이터베이스의 상태를 변화시키기 위해서 수행하는
하나의 논리적 작업 단위
이다.
하나의 트랜잭션 내부에는 보통 여러 개의 SQL 연산이 포함된다. SELECT -> UPDATE -> DELETE 등 ..
각각의 쿼리들을 물리적인 작업
이라고 할 수 있고, 이들의 의미적으로 묶여 하나의 완결된 행위를 이루는 것이, 논리적인 작업 단위(=트랜잭션)
가 되는 것이다.
트랜잭션은 왜 필요할까? 각각의 쿼리를 묶지 않고 개별적으로 실행해서 반영하게 하면 왜 안되는 것일까?
아래의 예시를 통해서 살펴보자.
- A와 B가 있고, 각각 10만 원씩 소유하고 있다.
- A가 B에게 10만 원을 송금하려고 한다.
- 먼저 A의 잔고를 조회한 후, 10만 원을 차감한다.
- 이어서 B의 잔고를 조회한 후, 10만 원을 더하려고 한다.
- 그러나 4번 과정에서 오류가 발생하여 쿼리가 실행되지 않았다.
- 결과적으로 A의 잔고는 0원이 되었고, B의 잔고는 여전히 10만 원이다. 즉, 10만 원이 사라진 것이다.
이처럼 중간 단계에서 오류가 발생하면 데이터의 정합성
이 무너질 수 있다.
트랜잭션은 이러한 문제를 방지하고, “송금”이라는 논리적 행위가 완전하게 수행되도록 보장해준다.
데이터 정합성은, 데이터의 1) 정확성 2) 일관성 3) 신뢰성
을 유지하는 것을 의미한다.
- 데이터 정확성 : 데이터가 실세계와 일치하도록 보장한다.
- 데이터 일관성 : 데이터가 서로 모순되지 않도록 한다. (ex. 유저 PK와 게시글 FK가 정확히 매핑되어야 함)
- 데이터 신뢰성 : 데이터가 손상되거나 부정확하게 변형되지 않도록 보호한다.
데이터 정합성을 유지하는 방법에는 여러 가지가 존재하지만, 대표적으로 데이터베이스 제약 조건 & 트랜잭션
을 꼽을 수 있다.
과연 트랜잭션의 어떠한 특징 때문에 데이터 정합성을 보장할 수 있는 것일까?
이는 4가지 특징, ACID
로 살펴볼 수 있다.
원자성은 트랜잭션 내에서 실행되는 여러 쿼리들이
모두 성공하여 데이터베이스에 반영되거나, 하나라도 실패하면 전부 반영되지 않아야 함을 의미한다.
이를 All or Nothing
이라고도 부른다.
해당 특징 덕분에 앞서 예시로 들었던 송금 실패 시 A 계좌의 출금만 반영되는 문제를 방지할 수 있다.
즉, 트랜잭션이 실패하면 모든 변경 사항이 ROLLBACK
되어 데이터의 일관성이 유지된다.
일관성은 트랜잭션 수행 전과 후에 데이터베이스가
항상 정의된 규칙(제약 조건 등)을 만족하는 상태여야 함을 의미한다.
즉, 트랜잭션을 실행해도 데이터의 무결성과 비즈니스 규칙이 깨지면 안 된다.
- A와 B는 각각 10만 원의 잔고를 가지고 있다.
- 데이터베이스는 전체 잔고의 합이 20만 원이 되어야 한다는 규칙을 가진다고 가정하자.
- A가 B에게 10만 원을 송금하는 트랜잭션이 수행된다.
- 트랜잭션이 끝난 후, A는 0원, B는 20만 원을 갖게 된다면,
- 전체 잔고가 20만 원 → 20만 원으로 변하지 않았으므로, 일관성이 유지된 것이다.
❌ 하지만 만약 B의 계좌에 입금이 실패해서 B는 여전히 10만 원인데, A는 이미 출금된 상태라면?
전체 잔고가 20만 원 → 10만 원으로 변하게 되며, 일관성이 깨지게 된다.
그러므로 일관성은 정상적인 상태 -> 정상적인 상태
가 유지되도록 보장하는 특징이라 할 수 있다.
격리성은 둘 이상의 트랜잭션이 동시에 실행될 때,
각 트랜잭션이 독립적으로 수행되어야 함을 의미한다.
즉, 하나의 트랜잭션이 완료되기 전까지는 다른 트랜잭션이 그 결과를 볼 수 없고 간섭할 수 없어야 한다.
- A가 B에게 10만 원을 송금하는 트랜잭션을 실행한다.
- 동시에 다른 트랜잭션이 A의 잔액을 조회한다.
- 이때 출금 쿼리만 반영된 중간 상태를 조회하게 되면, A의 잔액은 0원으로 보일 수 있다.
- 1번 트랜잭션이 무사히 커밋된다면 큰 문제는 없지만, 실패하거나 ROLLBACK된다면 잘못된 정보가 외부에 노출된 셈이 된다. → 일관성이 깨질 수 있다.
단, 모든 경우에서 참조를 막지는 않는다. 이는 트랜잭션 격리 수준 (Isolation Level)에 따라서 달라질 수 있다.
지속성은 트랜잭션이 성공적으로 완료 (COMMIT)된 이후에는, 그 결과가 시스템에 영구적으로 반영되어야 함을 의미한다.
한 번 반영된 데이터는 시스템 오류나 장애가 발생하더라도 손실되지 않아야 하며, 디스크 등 영속적인 저장소에 기록되어 보존되어야 한다.
- A가 B에게 10만 원을 성공적으로 송금하고 트랜잭션 COMMIT된다.
- 서버가 갑자기 다운되어 시스템이 종료된다.
- 송금 성공 내역은 디스크 등에 기록되어 있어 사라지지 않는다.
- 즉, 성공한 트랜잭션의 결과는 반드시 지켜져야 한다.
지금까지 트랜잭션이 무엇인지, 그리고 트랜잭션의 4가지 특징(ACID)에 대해 살펴보았다.
그렇다면 이러한 트랜잭션의 성질을 실제로 제어할 수 있도록 해주는 것은 무엇일까?
COMMIT은 트랜잭션이 성공적으로 끝났음을 선언하고, 지금까지의 변경 내용을 데이터베이스에 영구 반영하는 명령어다.
BEGIN;
UPDATE account SET balance = balance - 100000 WHERE id = 'A';
UPDATE account SET balance = balance + 100000 WHERE id = 'B';
COMMIT;
이 명령을 수행하면, 위 두 쿼리는 실제로 디스크에 반영되어 다시 되돌릴 수 없는 상태가 된다.
트랜잭션 중간에 수행된 쿼리들은 실제 디스크가 아닌, DBMS 내부의 임시 저장소(버퍼 풀 등)에 반영된다.
즉, 디스크에 바로 쓰지 않고 메모리 영역(버퍼 풀, UNDO 로그 등)에 저장되는 것이며, 때문에 장애가 발생하면 ROLLBACK
을 통해 쉽게 되돌릴 수 있게 된다.
일부 DBMS는 이 과정을 위해 WAL(Write-Ahead Logging)을 이용해 로그부터 먼저 기록하고, 데이터는 나중에 실제 디스크에 반영하여 지속성을 보장한다.
디스크 I/O 연산을 줄이기 위해 DBMS가 사용하는 메모리 영역이다.
디스크에서 데이터를 읽으면, 먼저 버퍼 풀에 로딩되며, SQL 쿼리로 수정되는 데이터도 일단 여기에서 수정된다.
즉, 트랜잭션 도중의 모든 변경은 버퍼 풀에서 일어나며, 디스크에는 COMMIT 전까지 반영되지 않는다.
보통 MySQL InnoDB에서는 버퍼 풀, Oracle에서는 버퍼 캐시라고 부른다.
“데이터보다 로그를 먼저 쓴다”
라는 의미로, 변경 내용을 먼저 REDO/UNDO
로그로 디스크에 기록하고 나서, 실제 데이터를 디스크에 반영한다.
이 순서 덕분에 장애가 발생하더라도, 아래 작업이 가능해진다.
UNDO 로그
→ ROLLBACK 가능
REDO 로그
→ COMMIT 반영 가능
REDO 로그는 “커밋은 되었지만, 아직 디스크에 반영되지 않은 변경사항”
을 복구하기 위한 로그이다.
- 트랜잭션이 수행된다. (~ing)
- 버퍼 풀의 데이터가 변경된다. (~ing)
- REDO 로그가 작성된다. (~ing)
- 트랜잭션이 종료되어 COMMIT을 수행한다.
- 로그를 디스크에 먼저 flush한다. (WAL)
- 여기서 장애가 발생하여 시스템이 종료된다.
- 데이터 파일(.ibd 등)은 아직 디스크에 flush 되지 않아 실제 DB에 반영되지 않는다.
- 장애가 해결되고 시스템이 재시작한다.
- REDO 로그를 읽고, 해당 트랜잭션이 COMMIT 되었음을 인지한다.
- 디스크에 데이터 파일 flush 하여 다시 적용(REDO)한다.
이를 통해서 지속성(Durability)을 보장해 줄 수 있게 된다.
ROLLBACK은 트랜잭션 도중 오류가 발생했거나, 중간에 문제를 감지하여 지금까지의 모든 변경 작업을 취소하고 트랜잭션 이전 상태로 되돌리는 명령어다.
이는 UNDO
로그에 따라서 수행된다.
트랜잭션 도중 변경된 내용을 되돌릴 수 있도록 저장하는 로그로, ROLLBACK
이 호출되면 UNDO 로그를 기반으로 데이터를 트랜잭션 이전 상태로 복구하게 된다.
- A가 10만원을 B에게 송금한다.
- A의 잔고가 10만 원 → 0원이 된다.
- UNDO 로그는 역으로
“A: +10만 원”
정보를 기록한다.- 트랜잭션 중간에 문제가 발생한다.
- ROLLBACK 이 호출되고, 해당 로그를 통해서 A의 잔고를 0원 -> 10만원으로 원복한다.
이를 통해서 원자성(Atomicity)을 보장해 줄 수 있게 된다.
버퍼 풀, COMMIT, ROLLBACK, REDO, UNDO, flush
등 많은 용어들이 나왔다.
트랜잭션 내부 동작 순서를 정리하면 아래와 같다.
1. 버퍼 풀에 변경 내용을 반영한다. (메모리)
- SQL로 변경된 행(row)은 메모리에 있는 버퍼 풀에 먼저 기록됨
- 디스크 I/O 작업 최소화 -> 성능 향상
2. UNDO / REDO 로그 생성
- 동시에 해당 변경 내용을 로그 형태로 구성함
UNDO
→ ROLLBACK을 위한 이전 값REDO
→ COMMIT 후 디스크 재적용을 위한 값
3. 로그를 디스크에 먼저 기록 (WAL)
- COMMIT 이전에 반드시 로그를 디스크에 fsync() 같은 방식으로
flush
함- 로그 기록이 안전하게 끝났다면 COMMIT 수행하여 실제 데이터 파일을 DB에 flush함
4. 나중에 (비동기적으로) 버퍼 풀의 내용이 디스크로 반영됨
트랜잭션이 끝났다고 해서 모든 변경을 즉시 디스크에 반영하면 성능이 매우 저하되기 때문이다.
DB는 성능 향상을 위해 다음과 같은 전략을 쓴다.
📌 버퍼 풀 → 디스크 반영을 지연(flush delay)
- 버퍼 풀에 있는 변경 사항은 즉시 디스크에 쓰지 않고, 일정 시간이 지나거나, 메모리 압박, 체크포인트 시점에 맞춰 비동기적으로 flush 함
📌 왜 비동기로 처리하나?
- COMMIT 시마다 모든 데이터 페이지를 디스크에 쓰면 I/O 병목 발생 가능성 존재
- 대부분의 변경은 WAL 로그로 이미 복구 가능하므로, 실제 데이터 파일은 나중에 천천히 쓰더라도 문제가 없음
해당 트랜잭션은 COMMIT
되지 않은 것으로 간주된다.
왜냐하면 “COMMIT = 로그가 디스크에 안전하게 쓰였다는 보장까지 포함된 것”
이기 때문이다. 이는 WAL
의 중요한 원칙이다.
따라서 COMMIT 수행 중 로그 flush가 완료되지 않았다면, COMMIT은 실패한 것으로 처리된다.
장애 후 복구 시, 해당 트랜잭션의 COMMIT 레코드
가 디스크에 없으므로 실패 -> ROLLBACK
COMMIT 레코드
와 같은 REDO / UNDO 내부 동작 원리에 대해서는 따로 작성할 예정이다!
사소하긴 한데, 트랜잭션 중간에 DDL 작업을 진행하면 어떻게 될까?