SQL · 기본
데이터베이스 개론
트랜잭션과 ACID - 주문과 결제를 한 덩어리로
트랜잭션 정의, 원자성·일관성·고립성·지속성, COMMIT·ROLLBACK, 주문·재고·결제를 함께 바꿔야 하는 이유, 상태 전이 그림
개발자KR · 원고 갱신
이 장에서 배우는 것
회원이 책을 주문하면 데이터베이스 안에서는 한 가지 일만 일어나지 않는다. orders에 주문이 생기고, order_item에 담은 책이 기록되고, inventory의 재고가 줄고, payment에 결제 내역이 남는다. 이 네 가지 변경은 따로 반영되면 안 된다. 이번 장에서는 이렇게 여러 변경을 하나로 묶는 단위인 트랜잭션(transaction)과, 그 트랜잭션이 지켜야 할 네 가지 약속인 ACID를 SQL 연구소의 온라인 서점 데이터로 확인한다.
- 트랜잭션이 왜 필요한지, 주문·재고·결제 예제로 설명할 수 있다
- 원자성·일관성·고립성·지속성이 각각 무엇을 보장하는지 구분할 수 있다
- BEGIN, COMMIT, ROLLBACK으로 트랜잭션을 직접 작성할 수 있다
- 트랜잭션의 상태가 활성 상태에서 커밋 또는 철회로 어떻게 옮겨가는지 그림으로 설명할 수 있다
문제 상황
SQL 연구소 서점의 운영팀에 문의가 하나 들어왔다고 하자. 어떤 회원이 책을 주문했는데 마이페이지에는 주문 내역이 보이지만 결제 내역은 남아 있지 않다는 것이다. 로그를 보니 원인은 단순했다. 서버 애플리케이션이 orders에 주문을 저장하고, inventory의 재고를 한 권 줄이고, 마지막으로 payment에 결제 기록을 남기는 세 단계를 순서대로 실행했는데, 두 번째와 세 번째 단계 사이에 결제 대행사 응답이 지연되며 애플리케이션 프로세스가 재시작된 것이다. 그 결과 주문은 존재하고 재고는 이미 줄었는데, 결제 기록은 영원히 만들어지지 않는 상태로 남았다. 회원은 책값을 내지 않았는데 서점은 재고를 하나 잃은 셈이다. 이런 사고를 막으려면 "주문을 만든다"는 하나의 업무가 실제로는 여러 개의 SQL 문으로 이루어져 있다는 점과, 그 여러 문장을 하나의 단위로 묶어 전부 반영하거나 전부 취소하는 방법을 알아야 한다.
트랜잭션이란 무엇인가
트랜잭션은 하나의 논리적인 업무를 이루는 SQL 문의 묶음이다. "회원 1이 책 1을 2권 주문한다"는 업무 하나는 orders에 한 행을 넣고, order_item에 한 행을 넣고, inventory의 재고를 줄이고, payment에 한 행을 넣는 네 개의 SQL 문으로 나뉜다. 이 네 문장을 트랜잭션으로 묶지 않으면 데이터베이스는 각 문장을 별개의 작업으로 취급해서, 문제 상황에서 본 것처럼 중간에 멈춰도 이미 실행된 문장의 결과는 그대로 남는다.
표준 SQL과 SQLite에서는 BEGIN으로 트랜잭션을 열고, 그 안에서 원하는 만큼 문장을 실행한 다음, COMMIT으로 모든 변경을 확정하거나 ROLLBACK으로 BEGIN 시점 이전 상태로 되돌린다. SQLite는 BEGIN 없이 실행한 문장은 문장 하나하나를 각각 자동으로 커밋하므로, 여러 테이블을 함께 바꾸려면 반드시 BEGIN을 먼저 실행해야 한다.
ACID: 트랜잭션이 지켜야 할 네 가지 성질
데이터베이스는 트랜잭션이라는 개념만 제공하는 게 아니라, 그 트랜잭션이 지켜야 할 네 가지 성질을 보장한다. 이를 앞 글자를 따서 ACID라고 부른다.
원자성(atomicity)
트랜잭션 안의 변경은 전부 반영되거나 전부 취소된다. 주문 예제에서 재고 차감까지는 성공했는데 결제 기록 삽입에서 오류가 나면, 이미 실행된 재고 차감도 함께 취소되어야 한다. 부분적으로만 반영된 상태는 존재하지 않는다.
일관성(consistency)
트랜잭션이 끝난 뒤 데이터베이스는 정해진 제약 조건을 만족하는 상태여야 한다. inventory.stock은 0 이상이어야 한다는 제약, order_item.qty는 0보다 커야 한다는 제약이 그 예다. 트랜잭션 도중에는 일시적으로 값이 이상해 보여도, 커밋이 끝난 시점에는 모든 제약을 만족해야 한다.
고립성(isolation)
여러 회원이 같은 순간에 주문을 넣어도, 각 트랜잭션은 서로 다른 트랜잭션이 아직 커밋하지 않은 중간 상태를 보지 않는다. 예를 들어 두 회원이 재고가 1권 남은 책을 동시에 주문했을 때, 한 트랜잭션이 재고를 차감하는 도중의 값을 다른 트랜잭션이 읽어서 재고가 남은 것처럼 착각하는 일이 없어야 한다. 여러 트랜잭션이 실제로 겹칠 때 무슨 일이 생기는지는 동시성 제어를 다루는 다음 장에서 자세히 살펴본다.
지속성(durability)
COMMIT이 끝난 변경은 그 뒤에 정전이나 서버 재시작이 일어나도 사라지지 않는다. 회원이 결제를 마치고 주문 완료 화면을 본 순간, 그 주문은 데이터베이스 장애와 무관하게 남아 있어야 한다.
| 성질 | 영어 표기 | 보장하는 것 | 이번 장 예제에서 확인한 지점 |
|---|---|---|---|
| 원자성 | atomicity | 전부 반영 또는 전부 취소 | 재고 부족 시 주문 전체를 ROLLBACK |
| 일관성 | consistency | 커밋 후 제약 조건 만족 | stock >= 0 제약이 항상 유지됨 |
| 고립성 | isolation | 다른 트랜잭션의 중간 상태 차단 | 다음 장에서 상세히 다룸 |
| 지속성 | durability | 커밋 후 변경 영구 보존 | COMMIT 이후 재고 3으로 유지 |
COMMIT과 ROLLBACK, 그리고 상태 전이
트랜잭션은 BEGIN 직후부터 활성 상태에 들어간다. 그 안에서 문장을 하나씩 실행하다가 마지막 문장까지 오류 없이 끝나면 부분 커밋 상태를 거쳐 COMMIT으로 커밋됨 상태에 이른다. 이 상태부터는 지속성이 적용되어 변경이 영구히 남는다. 반대로 도중에 제약 위반 같은 오류가 나거나, 애플리케이션이 조건을 확인한 뒤 반영하지 않기로 판단하면 실패 상태를 거쳐 ROLLBACK으로 철회됨 상태에 이른다. 철회됨 상태에서는 BEGIN 이후 실행한 모든 변경이 마치 처음부터 없었던 것처럼 되돌아간다.
여기서 중요한 점은 COMMIT과 ROLLBACK이 대칭이 아니라는 것이다. COMMIT은 한 번 끝나면 되돌릴 수 없고, 그 시점 이후에 잘못을 발견했다면 새로운 트랜잭션으로 값을 보정해야 한다. 반면 ROLLBACK은 COMMIT 전까지는 언제든 호출해서 지금까지의 변경을 전부 지울 수 있다.
| 상태 | 의미 | 다음 상태로 넘어가는 조건 |
|---|---|---|
| 활성 상태 | BEGIN 이후 문장을 순서대로 실행하는 중 | 마지막 문장까지 성공하면 부분 커밋, 오류가 나면 실패 |
| 부분 커밋 | 모든 문장 실행은 끝났고 COMMIT을 기다리는 중 | COMMIT을 호출하면 커밋됨 |
| 커밋됨 | 변경이 지속적으로 저장된 상태 | 더 이상 되돌릴 수 없음, 보정은 새 트랜잭션으로 |
| 철회됨 | ROLLBACK으로 BEGIN 이전 상태로 돌아간 상태 | 없음, 처음부터 다시 시작 |
완성 코드
bookstore_tx.sql
.headers on
PRAGMA foreign_keys = ON;
CREATE TABLE member (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
grade TEXT NOT NULL CHECK (grade IN ('BASIC','SILVER','GOLD','VIP')),
region TEXT,
joined_on TEXT NOT NULL
);
CREATE TABLE book (
id INTEGER PRIMARY KEY,
isbn TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
publisher_id INTEGER,
category_id INTEGER,
price INTEGER NOT NULL,
published_on TEXT,
pages INTEGER
);
CREATE TABLE inventory (
book_id INTEGER PRIMARY KEY REFERENCES book(id),
stock INTEGER NOT NULL CHECK (stock >= 0),
updated_at TEXT NOT NULL
);
CREATE TABLE coupon (
id INTEGER PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
discount_rate REAL NOT NULL,
valid_until TEXT NOT NULL
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
member_id INTEGER NOT NULL REFERENCES member(id),
ordered_at TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('PAID','SHIPPED','DELIVERED','CANCELLED','REFUNDED')),
coupon_id INTEGER REFERENCES coupon(id)
);
CREATE TABLE order_item (
order_id INTEGER NOT NULL REFERENCES orders(id),
line_no INTEGER NOT NULL,
book_id INTEGER NOT NULL REFERENCES book(id),
qty INTEGER NOT NULL CHECK (qty > 0),
unit_price INTEGER NOT NULL,
PRIMARY KEY (order_id, line_no)
);
CREATE TABLE payment (
id INTEGER PRIMARY KEY,
order_id INTEGER NOT NULL REFERENCES orders(id),
method TEXT NOT NULL CHECK (method IN ('CARD','BANK','POINT')),
amount INTEGER NOT NULL,
paid_at TEXT NOT NULL
);
INSERT INTO member (id, email, name, grade, region, joined_on)
VALUES (1, 'jieun@example.com', '김지은', 'GOLD', '서울', '2024-03-02');
INSERT INTO book (id, isbn, title, publisher_id, category_id, price, published_on, pages)
VALUES (1, '979-11-00-000001', 'SQL 연구소 이야기', NULL, NULL, 15000, '2025-01-10', 320);
INSERT INTO inventory (book_id, stock, updated_at)
VALUES (1, 5, '2026-09-29 09:00:00');
-- 트랜잭션 1: 주문·재고·결제를 한 덩어리로 묶어서 커밋한다
BEGIN;
INSERT INTO orders (id, member_id, ordered_at, status, coupon_id)
VALUES (1001, 1, '2026-09-29 10:00:00', 'PAID', NULL);
INSERT INTO order_item (order_id, line_no, book_id, qty, unit_price)
VALUES (1001, 1, 1, 2, 15000);
UPDATE inventory
SET stock = stock - 2, updated_at = '2026-09-29 10:00:00'
WHERE book_id = 1;
INSERT INTO payment (id, order_id, method, amount, paid_at)
VALUES (5001, 1001, 'CARD', 30000, '2026-09-29 10:00:01');
COMMIT;
-- 다음 주문을 받기 전, 애플리케이션이 남은 재고를 먼저 확인한다
SELECT stock FROM inventory WHERE book_id = 1;
-- 트랜잭션 2: 남은 재고보다 많은 수량을 주문해 롤백으로 되돌린다
BEGIN;
INSERT INTO orders (id, member_id, ordered_at, status, coupon_id)
VALUES (1002, 1, '2026-09-29 11:00:00', 'PAID', NULL);
INSERT INTO order_item (order_id, line_no, book_id, qty, unit_price)
VALUES (1002, 1, 1, 10, 15000);
ROLLBACK;
-- 최종 상태 확인
SELECT id, status FROM orders ORDER BY id;
SELECT book_id, stock FROM inventory;
줄별 해설
.headers on은 sqlite3 CLI에게 SELECT 결과 앞에 열 이름을 함께 출력하라는 지시다. 이 줄은 SQL 문이 아니라 CLI 도구에게 보내는 명령이라 세미콜론을 붙이지 않는다.
PRAGMA foreign_keys = ON;은 외래 키 제약을 활성화한다. SQLite는 이 설정을 켜지 않으면 orders.member_id가 존재하지 않는 회원을 가리켜도 오류를 내지 않으므로, 서점 데이터처럼 테이블 사이 참조가 많은 스키마에서는 켜 두는 것이 안전하다.
CREATE TABLE inventory의 CHECK (stock >= 0)은 일관성 제약이다. 재고를 마이너스로 만드는 UPDATE는 이 시점에서 바로 거부된다.
첫 번째 BEGIN;부터 COMMIT;까지가 하나의 트랜잭션이다. 그 사이의 네 문장은 orders, order_item, inventory, payment 네 테이블을 차례로 바꾸지만, 데이터베이스 입장에서는 COMMIT이 실행되기 전까지 어느 것도 다른 연결에서 보이지 않는다.
UPDATE inventory SET stock = stock - 2 ...는 재고 5에서 2를 빼서 3으로 만든다. 이 값이 CHECK 제약을 어기면 이 문장에서 즉시 실패하고, 아직 COMMIT하지 않았으므로 앞의 두 INSERT도 함께 되돌릴 수 있는 상태로 남는다.
두 번째 BEGIN;부터 ROLLBACK;까지는 재고보다 많은 10권을 주문하는 상황을 흉내 낸다. 실제 서비스라면 애플리케이션이 UPDATE를 실행하기 전에 재고를 확인해서, 부족하면 UPDATE와 결제 INSERT를 아예 시도하지 않고 ROLLBACK을 호출한다. 이 스크립트에서는 이미 SELECT stock ...로 재고가 3임을 확인했으므로, orders와 order_item에 잠시 써 두었던 두 행을 ROLLBACK으로 모두 취소한다.
마지막 두 SELECT 문은 최종 상태를 확인한다. 주문 1001은 커밋되어 남아 있고, 주문 1002는 철회되어 존재하지 않으며, 재고는 5에서 2만 줄어든 3으로 남는다.
실행 결과
$ sqlite3 bookstore.db < bookstore_tx.sql
stock
3
id|status
1001|PAID
book_id|stock
1|3
실무에서 자주 틀리는 것
여러 테이블 변경을 트랜잭션으로 묶지 않는다
BEGIN 없이 문장을 하나씩 실행하면 SQLite는 각 문장을 즉시 커밋한다. 재고 차감과 결제 기록 사이에 오류가 나면 재고만 줄고 결제 기록은 없는 상태가 그대로 남는다.
INSERT INTO orders (id, member_id, ordered_at, status, coupon_id)
VALUES (1003, 1, '2026-09-29 12:00:00', 'PAID', NULL);
UPDATE inventory SET stock = stock - 1 WHERE book_id = 1;
INSERT INTO payment (id, order_id, method, amount, paid_at)
VALUES (5003, 1003, 'CARD', 15000, '2026-09-29 12:00:05');
세 문장을 BEGIN과 COMMIT으로 감싸면 이 세 문장은 하나의 단위가 되어 전부 반영되거나 전부 취소된다.
BEGIN;
INSERT INTO orders (id, member_id, ordered_at, status, coupon_id)
VALUES (1003, 1, '2026-09-29 12:00:00', 'PAID', NULL);
UPDATE inventory SET stock = stock - 1 WHERE book_id = 1;
INSERT INTO payment (id, order_id, method, amount, paid_at)
VALUES (5003, 1003, 'CARD', 15000, '2026-09-29 12:00:05');
COMMIT;
오류가 나도 ROLLBACK을 호출하지 않는다
UPDATE가 CHECK 제약 위반으로 실패해도 트랜잭션 자체가 자동으로 취소되지는 않는다. 애플리케이션이 오류를 무시하고 넘어가면 트랜잭션이 열린 채로 남아, 앞서 써 둔 주문과 상세 항목이 이도 저도 아닌 상태로 대기하게 된다.
BEGIN;
INSERT INTO orders (id, member_id, ordered_at, status, coupon_id)
VALUES (1004, 1, '2026-09-29 13:00:00', 'PAID', NULL);
INSERT INTO order_item (order_id, line_no, book_id, qty, unit_price)
VALUES (1004, 1, 1, 999, 15000);
UPDATE inventory SET stock = stock - 999 WHERE book_id = 1;
-- CHECK 제약 위반으로 이 문장만 실패하고 트랜잭션은 열린 채로 남는다
오류를 확인한 즉시 ROLLBACK을 호출해서 이미 써 둔 변경까지 모두 되돌려야 한다.
BEGIN;
INSERT INTO orders (id, member_id, ordered_at, status, coupon_id)
VALUES (1004, 1, '2026-09-29 13:00:00', 'PAID', NULL);
INSERT INTO order_item (order_id, line_no, book_id, qty, unit_price)
VALUES (1004, 1, 1, 999, 15000);
UPDATE inventory SET stock = stock - 999 WHERE book_id = 1;
-- 오류가 발생하면 애플리케이션은 반드시 ROLLBACK을 호출한다
ROLLBACK;
COMMIT이 끝난 뒤 ROLLBACK으로 되돌리려 한다
COMMIT은 지속성을 확정하는 명령이라 그 이후에는 ROLLBACK이 되돌릴 대상이 없다. 아래처럼 COMMIT 다음에 ROLLBACK을 호출해도 이미 확정된 재고 차감은 그대로 남는다.
BEGIN;
UPDATE inventory SET stock = stock - 1 WHERE book_id = 1;
COMMIT;
-- 커밋 후 재고 차감이 잘못됐다는 걸 알고 되돌리려 한다
ROLLBACK;
이미 커밋된 값을 고치려면 그 값을 되돌리는 새 트랜잭션을 따로 실행해야 한다.
BEGIN;
UPDATE inventory SET stock = stock + 1 WHERE book_id = 1;
COMMIT;
한눈에 보기
| 개념 | 한 줄 요약 |
|---|---|
| 트랜잭션 | 여러 SQL 문을 하나의 작업 단위로 묶은 것 |
| 원자성 | 트랜잭션 안의 변경은 전부 반영되거나 전부 취소된다 |
| 일관성 | 커밋이 끝난 뒤에는 항상 제약 조건을 만족하는 상태다 |
| 고립성 | 다른 트랜잭션의 커밋 전 중간 상태는 보이지 않는다 |
| 지속성 | 커밋된 변경은 이후 장애가 나도 사라지지 않는다 |
| COMMIT | 트랜잭션의 변경을 모두 반영하고 확정한다 |
| ROLLBACK | BEGIN 이후의 모든 변경을 이전 상태로 되돌린다 |
연습 문제
SQL 연구소의 서점 데이터에서 다음 과제를 직접 트랜잭션으로 작성해 보자.
- 회원 1이 책 1을 3권 주문하고 카드로 결제하는 전체 과정을 하나의 트랜잭션으로 작성하라. 주문 생성, 상세 항목 삽입, 재고 차감, 결제 기록 순서로 작성하고 마지막에 COMMIT한다.
- 책 1의 재고가 0이라고 가정하자. 재고를 확인한 뒤 1권짜리 주문을 시도했다가, 재고가 부족하므로 아무 것도 반영되지 않도록 ROLLBACK으로 마무리하는 트랜잭션을 작성하라.
orders,order_item,inventory,payment네 테이블 중 하나라도 반영되지 않으면 안 되는 이유를 원자성 개념으로 설명하고, 이 네 테이블 갱신을 트랜잭션으로 묶지 않았을 때 생길 수 있는 구체적인 불일치 사례를 하나 들어라.- COMMIT과 ROLLBACK의 차이를 트랜잭션 상태 전이 관점에서 설명하라.
정답과 해설
1번 네 문장을 BEGIN과 COMMIT으로 감싸면 된다.
BEGIN;
INSERT INTO orders (id, member_id, ordered_at, status, coupon_id)
VALUES (2001, 1, '2026-09-29 15:00:00', 'PAID', NULL);
INSERT INTO order_item (order_id, line_no, book_id, qty, unit_price)
VALUES (2001, 1, 1, 3, 15000);
UPDATE inventory
SET stock = stock - 3, updated_at = '2026-09-29 15:00:00'
WHERE book_id = 1;
INSERT INTO payment (id, order_id, method, amount, paid_at)
VALUES (6001, 2001, 'CARD', 45000, '2026-09-29 15:00:05');
COMMIT;
2번 재고를 먼저 확인하고, 부족하면 UPDATE와 결제 INSERT 없이 ROLLBACK만 호출한다.
SELECT stock FROM inventory WHERE book_id = 1;
-- 결과: 0
BEGIN;
INSERT INTO orders (id, member_id, ordered_at, status, coupon_id)
VALUES (2002, 1, '2026-09-29 15:10:00', 'PAID', NULL);
INSERT INTO order_item (order_id, line_no, book_id, qty, unit_price)
VALUES (2002, 1, 1, 1, 15000);
-- 재고가 0이므로 이 주문은 반영하지 않는다
ROLLBACK;
3번 네 테이블은 "주문 하나"라는 같은 업무를 서로 다른 각도에서 기록한다. 그중 하나만 빠지면 데이터가 가리키는 현실과 실제 재고·결제 상태가 어긋난다. 예를 들어 재고 차감은 반영됐는데 결제 INSERT가 빠지면, 서점은 책을 내줄 준비를 했지만 대금을 받은 기록이 없는 상태가 남는다. 반대로 결제는 기록됐는데 재고 차감이 빠지면 실제 보유 재고보다 시스템상 재고가 많이 남은 것처럼 보여 이미 없는 책을 또 주문받을 수 있다. 원자성은 이런 어긋남을 막기 위해 네 변경을 하나의 단위로 취급해서 전부 반영하거나 전부 취소한다.
4번 활성 상태에서 시작한 트랜잭션은 마지막 문장까지 성공하면 부분 커밋을 거쳐 COMMIT으로 커밋됨 상태에 도달하고, 그 순간부터 변경은 지속성의 보호를 받아 영구히 남는다. 반면 도중에 오류가 나거나 애플리케이션이 반영하지 않기로 판단하면 실패 상태를 거쳐 ROLLBACK으로 철회됨 상태에 도달하고, BEGIN 이후의 모든 변경은 처음부터 없었던 것처럼 사라진다. 즉 COMMIT은 앞으로 나아가 확정하는 명령이고 ROLLBACK은 뒤로 돌아가 지우는 명령이며, 둘 다 COMMIT이 끝나기 전까지만 선택할 수 있다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.