SQL 10회차 : 트랜잭션과 잠금 기초 (COMMIT/ROLLBACK, 동시성 이슈 맛보기, 안전한 주문 처리 실습)
트랜잭션은 “여러 SQL을 하나의 작업 단위로 묶어, 전부 성공하면 반영하고(Commit), 하나라도 문제가 생기면 되돌리는(Rollback)” 기능입니다. 잠금(Lock)은 여러 사람이 동시에 같은 데이터를 다룰 때, 서로의 작업이 충돌하지 않도록 “순서를 세우는 장치”입니다. 이번 글에서는 트랜잭션의 핵심 개념을 잡고, 더티 리드 같은 동시성 이슈를 개념 수준으로 맛본 뒤, 트랜잭션으로 안전한 주문 처리 흐름을 흉내 내는 실습을 진행합니다.
목차
- 핵심 포인트 한 번에 보기
- 트랜잭션이란 무엇인가
- COMMIT/ROLLBACK 기초
- 잠금(LOCK) 기초
- 동시성 이슈 맛보기(개념 수준)
- 실습: 트랜잭션으로 안전한 주문 처리 흐름
- 추가로 생각해볼 점
- 블로그 최적화 정보
핵심 포인트 한 번에 보기
- 트랜잭션은 “전부 아니면 전무(All or Nothing)”를 보장해 데이터 일관성을 지키는 기본 장치입니다.
COMMIT은 변경을 확정하고,ROLLBACK은 변경을 취소합니다(트랜잭션 시작 시점으로 되돌림).- 잠금(락)은 동시 작업에서 충돌을 막기 위한 “대기 줄”이며, 트랜잭션이 끝날 때(Commit/Rollback) 풀리는 경우가 많습니다.
- 주문 처리처럼 재고를 깎고 주문을 만들 때는 “재고 확인 → 재고 차감 → 주문 생성”을 한 트랜잭션으로 묶어야 안전합니다.
- 대표적으로
SELECT ... FOR UPDATE같은 방식으로 “해당 행을 잠그고” 재고를 확인하면 동시 주문으로 인한 초과 판매를 줄일 수 있습니다(DB/엔진별 동작 차이는 존재합니다).
트랜잭션이란 무엇인가
트랜잭션을 한 문장으로 정리하면 다음과 같습니다.
- 여러 개의 SQL 문을 하나로 묶어 “작업 단위”로 만들고, 성공하면 반영하고 실패하면 되돌리는 기능입니다.
예를 들어, “주문 생성”에는 보통 여러 단계가 포함됩니다.
- 재고 확인
- 재고 차감
- 주문 헤더(orders) 생성
- 주문 상세(order_items) 생성
이 과정에서 중간에 오류가 나면(예: 재고 차감은 됐는데 주문 저장이 실패) 데이터가 꼬입니다. 트랜잭션은 이런 상황에서 “중간 상태”를 없애고, 항상 일관된 상태만 남도록 돕습니다.
트랜잭션이 지키는 대표 성질(초보자용 요약)
| 성질 | 의미(쉬운 말) | 주문 처리에서의 예 |
|---|---|---|
| 원자성(Atomicity) | 전부 성공하거나, 전부 실패합니다. | 재고만 깎이고 주문이 없는 상태를 막습니다. |
| 일관성(Consistency) | 정해둔 규칙(제약조건 등)을 항상 만족합니다. | 재고가 음수가 되지 않도록 합니다(규칙 + 로직). |
| 격리성(Isolation) | 동시에 실행되어도 서로 간섭을 최소화합니다. | 두 사용자가 동시에 마지막 1개 재고를 사는 상황을 제어합니다. |
| 지속성(Durability) | 커밋된 결과는 장애가 나도 남습니다. | 결제 완료 주문이 사라지지 않도록 합니다. |
이번 글은 특히 원자성과 격리성(동시성)에 초점을 맞춰 실습합니다.
COMMIT/ROLLBACK 기초
트랜잭션은 보통 다음 흐름으로 사용합니다.
- 트랜잭션 시작(
BEGIN또는START TRANSACTION) - 여러 SQL 실행(조회/수정/삽입 등)
- 성공하면
COMMIT, 실패하면ROLLBACK
아래는 트랜잭션이 어떤 느낌인지 잡기 위한 가장 단순한 예시입니다.
-- (예시) 트랜잭션 기본 흐름
START TRANSACTION;
UPDATE accounts
SET balance = balance - 10000
WHERE account_id = 1;
UPDATE accounts
SET balance = balance + 10000
WHERE account_id = 2;
COMMIT; -- 여기서 두 UPDATE가 함께 확정됩니다.
만약 중간에 에러가 나거나, 조건에 맞는 데이터가 없어 이체가 성립하지 않는다면 아래처럼 되돌릴 수 있습니다.
START TRANSACTION;
UPDATE accounts
SET balance = balance - 10000
WHERE account_id = 1;
-- 여기서 문제가 생겼다고 가정
ROLLBACK; -- 트랜잭션 시작 이전 상태로 되돌립니다.
자주 헷갈리는 포인트: 자동 커밋(autocommit)
- 많은 DB/클라이언트는 기본적으로 “한 문장 실행 = 즉시 커밋”처럼 동작할 수 있습니다(환경에 따라 다름).
- 따라서 여러 SQL을 하나로 묶어 안전하게 처리하려면, 명시적으로
BEGIN/START TRANSACTION을 사용하고 마지막에COMMIT/ROLLBACK을 해야 합니다. - 실습에서 결과가 예상과 다르면, “지금 트랜잭션 안에서 실행 중인지”를 먼저 점검합니다.
잠금(LOCK) 기초
잠금은 “같은 데이터에 동시에 접근할 때 발생하는 충돌”을 줄이기 위한 장치입니다. 가장 쉬운 비유는 다음과 같습니다.
- 한 사람이 문서를 수정하는 동안, 다른 사람이 같은 줄을 동시에 바꾸면 내용이 꼬일 수 있습니다.
- 그래서 “지금은 A가 수정 중이니 B는 잠시 대기”를 만드는 것이 잠금입니다.
잠금이 생기는 대표 상황
- UPDATE/DELETE: 보통 변경 대상 행(또는 관련 범위)에 잠금이 걸립니다.
- SELECT ... FOR UPDATE: 조회하면서 “이 행을 앞으로 수정할 예정이니 잠그겠다”는 의도를 표현합니다.
- 인덱스/조건/엔진에 따라: 같은 SQL이라도 DB 종류, 스토리지 엔진, 격리 수준에 따라 잠금 범위가 달라질 수 있습니다.
초보자 단계에서는 다음 원칙만 먼저 잡아두면 충분합니다.
- 잠금은 “동시성 문제를 줄이기 위해” 쓰며, 너무 넓게 잡으면 “대기가 늘어 성능이 떨어질 수” 있습니다.
- 잠금은 대개 “트랜잭션이 끝날 때(Commit/Rollback)” 해제됩니다.
- 따라서 트랜잭션을 길게 잡아두면 그만큼 다른 작업이 기다릴 가능성이 커집니다.
동시성 이슈 맛보기(더티 리드 등은 개념 수준)
동시성 이슈는 “동시에 여러 트랜잭션이 실행될 때, 서로의 중간 상태를 보거나 덮어쓰면서 생기는 문제”입니다. 대표 개념을 너무 깊게 들어가지 않고, 실무에서 자주 듣는 용어 위주로 정리하면 다음과 같습니다.
| 이슈 | 무슨 뜻인가 | 현실 예시 | 대응 방향(초보자용) |
|---|---|---|---|
| 더티 리드(Dirty Read) | 커밋되지 않은 변경을 다른 트랜잭션이 읽음 | 재고가 잠깐 0처럼 보였는데 사실은 롤백됨 | 격리 수준을 너무 낮게 두지 않기 |
| 반복 불가능 읽기(Non-repeatable Read) | 같은 행을 두 번 읽었더니 값이 달라짐 | 장바구니 합계를 다시 계산했더니 금액이 바뀜 | 업무 규칙에 맞는 격리/잠금 사용 |
| 팬텀 리드(Phantom Read) | 조건으로 조회했더니 결과 “개수”가 달라짐 | 주문 목록을 다시 봤더니 새 주문이 끼어듦 | 격리 수준/인덱스/범위 잠금 고려 |
| 로스트 업데이트(Lost Update) | 서로의 업데이트가 덮여서 한쪽 변경이 사라짐 | 재고를 동시에 차감했는데 하나만 반영됨 | SELECT ... FOR UPDATE 또는 버전 컬럼(낙관적 락) |
여기서 중요한 결론은 단순합니다. “동시성이 있는 서비스에서 주문/결제/재고 같은 핵심 데이터는 트랜잭션과 잠금(또는 유사 전략)을 의식적으로 설계해야 한다”는 점입니다.
실습: 트랜잭션으로 안전한 주문 처리 흐름 흉내내기
실습은 “재고(stock)를 확인하고 차감한 뒤 주문을 만드는 흐름”을 하나의 트랜잭션으로 묶고, 동시 접근 상황에서 잠금이 어떤 역할을 하는지 관찰하는 방식입니다. 아래 예시는 이해를 돕기 위한 전형적인 패턴이며, DB 종류(MySQL, PostgreSQL 등)에 따라 문법/동작이 조금씩 달라질 수 있습니다.
1) 실습용 테이블 준비(간단 버전)
-- products: 재고를 가진 상품 테이블
CREATE TABLE products (
product_id INT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
stock INT NOT NULL,
price INT NOT NULL
);
-- orders: 주문 헤더
CREATE TABLE orders (
order_id INT PRIMARY KEY,
customer_id INT NOT NULL,
status VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL
);
-- order_items: 주문 상세
CREATE TABLE order_items (
order_id INT NOT NULL,
product_id INT NOT NULL,
qty INT NOT NULL,
unit_price INT NOT NULL,
PRIMARY KEY (order_id, product_id)
);
INSERT INTO products(product_id, name, stock, price)
VALUES (1, 'Keyboard', 5, 35000);
2) 안전한 주문 처리 트랜잭션(핵심 패턴)
아래 흐름의 핵심은 “재고를 읽을 때 잠금을 잡고(FOR UPDATE), 재고가 충분할 때만 차감하고 주문을 생성한 뒤 커밋”하는 것입니다.
-- 가정: customer_id=101, product_id=1, qty=3을 주문한다고 가정
-- (실습에서는 order_id를 임의로 부여합니다)
START TRANSACTION;
-- 1) 재고 확인 + 잠금 (동시 주문 충돌을 줄이기 위한 핵심)
SELECT stock, price
FROM products
WHERE product_id = 1
FOR UPDATE;
-- 2) 재고가 충분하면 차감 (예: stock >= 3일 때만 진행)
UPDATE products
SET stock = stock - 3
WHERE product_id = 1
AND stock >= 3;
-- 3) 실제 서비스라면 여기서 "영향 받은 행 수"를 확인해야 합니다.
-- (0행이면 재고 부족 또는 조건 불일치 -> ROLLBACK)
-- 4) 주문 생성
INSERT INTO orders(order_id, customer_id, status, created_at)
VALUES (1001, 101, 'CREATED', CURRENT_TIMESTAMP);
INSERT INTO order_items(order_id, product_id, qty, unit_price)
SELECT 1001, product_id, 3, price
FROM products
WHERE product_id = 1;
COMMIT;
위 SQL에서 초보자가 특히 체크해야 할 포인트는 다음입니다.
FOR UPDATE는 “이 행을 이후에 수정할 예정이니 잠그겠다”는 의미이며, 동시 주문 상황에서 순서를 만들어줍니다.UPDATE ... WHERE stock >= 3처럼 조건을 넣으면, 재고 부족일 때 “차감이 아예 되지 않게” 만들 수 있습니다(안전장치).- 업무 코드에서는
UPDATE결과가 1행인지 확인하고, 0행이면ROLLBACK후 “재고 부족”을 반환하는 방식이 일반적입니다.
3) 두 세션으로 잠금 체감하기(권장 실습)
DB 콘솔을 2개(세션 A, 세션 B) 열고, 아래 순서대로 실행해보면 잠금이 “대기”를 만드는 것을 확인할 수 있습니다.
세션 A
START TRANSACTION;
SELECT stock
FROM products
WHERE product_id = 1
FOR UPDATE;
-- 이 상태로 커밋/롤백하지 말고 잠시 멈춥니다(잠금이 유지되는지 확인 목적).
세션 B
-- 세션 A가 product_id=1을 잠근 상태에서 아래를 실행
UPDATE products
SET stock = stock - 1
WHERE product_id = 1;
세션 B의 UPDATE는 상황에 따라 “바로 실행되지 않고 대기”할 수 있습니다(잠금을 기다림). 이제 세션 A로 돌아가 다음 중 하나를 실행해보세요.
- 세션 A에서
COMMIT을 실행하면 잠금이 풀리고, 세션 B의 대기가 풀리면서UPDATE가 진행될 수 있습니다. - 세션 A에서
ROLLBACK을 실행해도 잠금이 풀리며, 세션 B가 진행될 수 있습니다(단, 세션 A의 변경은 취소됩니다).
4) 실패 시 롤백 흐름 만들기(재고 부족 시나리오)
재고가 5인데 6개를 주문하려고 하면, 안전한 트랜잭션은 “차감이 일어나지 않게” 만들고, 주문도 생성하지 않으며, 마지막에 롤백으로 끝내는 것이 자연스럽습니다.
START TRANSACTION;
SELECT stock
FROM products
WHERE product_id = 1
FOR UPDATE;
-- 재고가 5인데 6개를 차감하려고 시도
UPDATE products
SET stock = stock - 6
WHERE product_id = 1
AND stock >= 6;
-- 여기서 UPDATE 결과가 0행이라고 가정하면:
ROLLBACK;
이렇게 하면 “재고는 그대로, 주문도 없음”이라는 깔끔한 상태로 남습니다. 트랜잭션을 쓰는 가장 실질적인 이유가 바로 이 안정성입니다.
5) (선택) 격리 수준(Isolation Level) 맛보기
더티 리드 같은 현상은 “격리 수준”을 낮추면 나타날 수 있고, 높이면 줄어듭니다. 다만 격리 수준을 높일수록 대기(잠금)가 늘어 성능에 영향을 줄 수 있습니다. 초보자 단계에서는 “개념만” 잡고 넘어가도 충분합니다.
-- DB별 문법은 다를 수 있으니, 사용하는 DB의 문서를 확인하세요.
-- (예시) 트랜잭션 격리 수준을 세션/트랜잭션 단위로 설정하는 형태
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
-- ... SQL 실행 ...
COMMIT;
추가로 생각해볼 점
- 트랜잭션은 짧게: 트랜잭션이 길어질수록 잠금 유지 시간이 길어져 대기가 늘 수 있습니다. 네트워크 호출, 사용자 입력 대기 같은 작업을 트랜잭션 안에서 오래 끌지 않는 것이 일반적으로 유리합니다.
- 데드락(Deadlock): 서로가 서로의 잠금을 기다리며 교착 상태가 될 수 있습니다. DB는 보통 데드락을 감지해 한쪽 트랜잭션을 실패시키므로, 애플리케이션은 재시도 전략을 고려합니다.
- 조건부 UPDATE를 습관화: 재고 차감처럼 안전이 중요한 경우
UPDATE ... WHERE stock >= qty같은 방식으로 “DB가 안전장치” 역할을 하게 만들면 실수가 줄어듭니다. - 낙관적 락(Optimistic Lock): 잠금을 강하게 잡는 대신, 버전 컬럼(version)으로 충돌을 감지하는 방식도 있습니다. 트래픽과 충돌 빈도에 따라 선택지가 달라집니다.
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

0 댓글