
트랜잭션: 작업의 원자적 단위
데이터베이스 트랜잭션의 개념과 ACID 특성을 경험을 통해 이해한 과정

데이터베이스 트랜잭션의 개념과 ACID 특성을 경험을 통해 이해한 과정
데이터베이스 커넥션 풀의 개념과 성능 최적화를 경험을 통해 이해한 과정

데이터베이스 복제를 통한 고가용성과 읽기 성능 향상을 경험을 통해 이해한 과정

데이터베이스 샤딩의 개념과 대규모 트래픽 처리를 경험을 통해 이해한 과정

벡터 데이터베이스의 동작 원리와 활용 방법을 프로젝트 경험을 통해 이해한 과정

서비스를 운영하다 보면 데이터 정합성 문제를 피할 수 없습니다. 저도 처음 결제 시스템을 만들 때 이 문제를 정면으로 마주했습니다. 사용자가 포인트를 차감하고 상품을 구매하는 과정에서, 포인트는 차감됐는데 상품 구매는 실패하는 황당한 상황이 발생했습니다. 고객 문의가 쏟아졌고, 수동으로 데이터를 복구하느라 밤을 새웠습니다.
그때 선배 개발자가 던진 한마디가 있었습니다. "트랜잭션 써봤어?" 저는 그때까지 트랜잭션이 뭔지도 몰랐습니다. SQL 쿼리만 열심히 짜면 되는 줄 알았죠. 하지만 실제로는 단순히 쿼리를 실행하는 것만으로는 부족했습니다. 여러 작업을 하나의 묶음으로 처리하고, 중간에 문제가 생기면 전부 되돌려야 하는 상황이 너무나 많았습니다.
트랜잭션을 제대로 이해하고 나서야, 제 서비스의 데이터 정합성 문제가 대부분 해결됐습니다. 이 글은 그때의 경험을 바탕으로, 트랜잭션이 무엇이고 왜 필요한지, 그리고 어떻게 사용해야 하는지를 정리한 노트입니다.
트랜잭션을 처음 접했을 때 가장 혼란스러웠던 부분은 "원자적(Atomic)"이라는 개념이었습니다. 화학 시간에 배운 원자를 떠올리며 "더 이상 쪼갤 수 없다"는 뜻인 건 알겠는데, 그게 데이터베이스에서 무슨 의미인지 와닿지 않았습니다.
제가 작성한 코드는 이랬습니다:
// 포인트 차감
await db.query('UPDATE users SET points = points - 1000 WHERE id = 1');
// 상품 구매 기록
await db.query('INSERT INTO purchases (user_id, product_id) VALUES (1, 100)');
이 코드의 문제는 첫 번째 쿼리는 성공하고 두 번째 쿼리가 실패하면, 포인트만 차감되고 구매는 안 되는 상황이 발생한다는 것입니다. 네트워크 문제, 서버 재시작, DB 연결 끊김 등 수많은 이유로 중간에 실패할 수 있었습니다.
또 다른 혼란은 "격리(Isolation)"라는 개념이었습니다. 여러 사용자가 동시에 같은 데이터를 수정하면 어떻게 되는지, 왜 가끔 이상한 값이 읽히는지 이해하기 어려웠습니다. 특히 재고 관리 시스템을 만들 때, 두 사용자가 동시에 마지막 1개 상품을 구매하는 상황에서 둘 다 구매 성공으로 처리되는 버그를 경험했습니다.
트랜잭션을 이해하는 데 결정적이었던 비유는 "은행 계좌 이체"였습니다.
A 계좌에서 B 계좌로 10만 원을 이체한다고 생각해보세요. 이 과정은 두 단계로 이루어집니다:
만약 1번은 성공하고 2번이 실패하면? A의 돈은 사라지고 B는 받지 못합니다. 돈이 증발하는 겁니다. 반대로 2번만 성공하면? 돈이 공짜로 생깁니다. 둘 다 있어서는 안 되는 상황이죠.
트랜잭션은 바로 이 두 작업을 하나로 묶어서, 둘 다 성공하거나 둘 다 실패하도록 보장하는 메커니즘입니다. 이 비유를 듣자마자 무릎을 쳤습니다. 아, 그래서 "원자적"이라고 하는구나. 쪼갤 수 없는 하나의 작업 단위라는 뜻이었습니다.
코드로 표현하면 이렇습니다:
// 트랜잭션 시작
await db.query('BEGIN');
try {
// A 계좌 차감
await db.query('UPDATE accounts SET balance = balance - 100000 WHERE id = 1');
// B 계좌 추가
await db.query('UPDATE accounts SET balance = balance + 100000 WHERE id = 2');
// 모두 성공하면 커밋
await db.query('COMMIT');
} catch (error) {
// 하나라도 실패하면 롤백
await db.query('ROLLBACK');
throw error;
}
BEGIN으로 트랜잭션을 시작하고, 모든 작업이 성공하면 COMMIT으로 확정하고, 중간에 에러가 발생하면 ROLLBACK으로 모든 변경사항을 취소합니다. 이 패턴을 이해하고 나니, 제 서비스의 수많은 데이터 정합성 문제가 해결됐습니다.
트랜잭션을 제대로 이해하려면 ACID라는 4가지 특성을 알아야 합니다. 처음엔 이론적으로만 느껴졌지만, 실제로 하나하나 부딪히며 그 중요성을 깨달았습니다.
"All or Nothing" - 전부 아니면 전무.
트랜잭션 내의 모든 작업은 완전히 실행되거나, 전혀 실행되지 않아야 합니다. 앞서 설명한 계좌 이체 예시가 바로 원자성입니다.
제가 겪은 실제 사례는 주문 시스템이었습니다:
BEGIN;
INSERT INTO orders (user_id, total) VALUES (1, 50000);
UPDATE products SET stock = stock - 1 WHERE id = 100;
INSERT INTO order_items (order_id, product_id) VALUES (LAST_INSERT_ID(), 100);
COMMIT;
이 세 개의 쿼리는 하나의 논리적 작업입니다. 주문 생성, 재고 차감, 주문 상세 기록. 하나라도 실패하면 전부 취소돼야 합니다. 원자성이 보장되지 않으면 주문은 생성됐는데 재고는 그대로이거나, 재고는 차감됐는데 주문 기록이 없는 상황이 발생합니다.
트랜잭션이 완료된 후에도 데이터베이스의 모든 제약 조건과 규칙이 지켜져야 합니다.
예를 들어, 계좌 잔액은 음수가 될 수 없다는 제약이 있다면:
-- 이 트랜잭션은 실패해야 함
BEGIN;
UPDATE accounts SET balance = balance - 100000 WHERE id = 1;
-- 만약 balance가 50000이었다면? -50000이 되면 안 됨
ROLLBACK; -- 자동으로 롤백되거나 CHECK 제약으로 막힘
제 경우, 포인트 시스템에서 CHECK (points >= 0) 제약을 걸어두니, 포인트가 부족한 상태에서 차감하려는 시도가 자동으로 막혔습니다. 일관성 덕분에 데이터가 항상 비즈니스 규칙을 만족하는 상태로 유지됐습니다.
이 부분이 가장 복잡하고, 실제로 가장 많은 버그를 만들어냈습니다.
제가 만든 티켓 예매 시스템에서 이런 상황이 발생했습니다:
// 사용자 A와 B가 동시에 마지막 1장을 예매 시도
// 사용자 A
const ticket = await db.query('SELECT remaining FROM tickets WHERE id = 1');
// remaining = 1
// 사용자 B (거의 동시에)
const ticket = await db.query('SELECT remaining FROM tickets WHERE id = 1');
// remaining = 1 (아직 A가 업데이트 안 함)
// 사용자 A
await db.query('UPDATE tickets SET remaining = 0 WHERE id = 1');
// 사용자 B
await db.query('UPDATE tickets SET remaining = 0 WHERE id = 1');
// 둘 다 성공! 티켓 2장이 판매됨
이 문제를 해결하려면 격리 수준(Isolation Level)을 조정하거나, 비관적 락(Pessimistic Lock)을 사용해야 합니다:
BEGIN;
SELECT remaining FROM tickets WHERE id = 1 FOR UPDATE; -- 락 걸기
-- 다른 트랜잭션은 이 행을 읽을 수 없음
UPDATE tickets SET remaining = remaining - 1 WHERE id = 1;
COMMIT;
FOR UPDATE를 사용하면 해당 행에 락이 걸려서, 다른 트랜잭션은 이 트랜잭션이 끝날 때까지 기다려야 합니다. 이렇게 하니 동시성 문제가 해결됐습니다.
트랜잭션이 성공적으로 커밋되면, 시스템이 고장나더라도 그 결과는 유지돼야 합니다.
이건 데이터베이스가 알아서 해주는 부분이라 개발자가 직접 신경 쓸 일은 적습니다. 하지만 한 번은 서버가 갑자기 재시작됐는데, 커밋 직전의 트랜잭션들이 사라진 적이 있었습니다. 알고 보니 DB 설정에서 fsync가 꺼져 있어서, 디스크에 실제로 쓰기 전에 메모리에만 있던 데이터가 날아간 것이었습니다.
지속성을 보장하려면 DB가 WAL(Write-Ahead Logging) 같은 메커니즘을 사용해서, 커밋 시점에 반드시 디스크에 기록하도록 해야 합니다.
격리성을 완벽하게 보장하면 성능이 떨어집니다. 모든 트랜잭션이 순차적으로 실행되면 동시성이 0이 되니까요. 그래서 실제로는 상황에 맞는 격리 수준을 선택해야 합니다.
가장 낮은 격리 수준. 커밋되지 않은 데이터도 읽을 수 있습니다. Dirty Read가 발생합니다.
-- 트랜잭션 A
BEGIN;
UPDATE accounts SET balance = 1000000 WHERE id = 1;
-- 아직 커밋 안 함
-- 트랜잭션 B (동시에)
SELECT balance FROM accounts WHERE id = 1;
-- 1000000을 읽음 (커밋 안 된 데이터!)
-- 트랜잭션 A
ROLLBACK; -- 취소됨
-- 트랜잭션 B는 존재하지 않는 데이터를 읽은 것
실제로 이 수준을 쓸 일은 거의 없습니다. 데이터 정합성이 너무 위험하니까요.
커밋된 데이터만 읽을 수 있습니다. Dirty Read는 방지되지만, Non-Repeatable Read가 발생할 수 있습니다.
-- 트랜잭션 A
BEGIN;
SELECT balance FROM accounts WHERE id = 1;
-- 100000
-- 트랜잭션 B (동시에)
BEGIN;
UPDATE accounts SET balance = 200000 WHERE id = 1;
COMMIT;
-- 트랜잭션 A (다시 읽기)
SELECT balance FROM accounts WHERE id = 1;
-- 200000 (값이 바뀜!)
같은 트랜잭션 내에서 같은 데이터를 두 번 읽었는데 값이 다릅니다. 대부분의 경우 문제없지만, 통계나 리포트를 생성할 때는 문제가 될 수 있습니다.
같은 트랜잭션 내에서는 같은 데이터를 읽으면 항상 같은 값이 나옵니다. 하지만 Phantom Read가 발생할 수 있습니다.
-- 트랜잭션 A
BEGIN;
SELECT COUNT(*) FROM orders WHERE user_id = 1;
-- 10개
-- 트랜잭션 B
INSERT INTO orders (user_id, total) VALUES (1, 50000);
COMMIT;
-- 트랜잭션 A
SELECT COUNT(*) FROM orders WHERE user_id = 1;
-- 11개 (새로운 행이 추가됨!)
기존 행의 값은 변하지 않지만, 새로운 행이 추가되거나 삭제될 수 있습니다. 제 경우 대부분의 서비스에서 이 수준을 사용합니다.
가장 높은 격리 수준. 트랜잭션들이 완전히 순차적으로 실행되는 것처럼 동작합니다. 모든 문제가 해결되지만, 성능이 크게 떨어집니다.
금융 시스템처럼 데이터 정합성이 절대적으로 중요한 경우에만 사용합니다. 제 경우 결제 관련 핵심 로직에만 이 수준을 적용했습니다.
요즘은 대부분 ORM을 사용하니까, ORM에서 트랜잭션을 어떻게 쓰는지가 중요합니다.
Prisma 예시:await prisma.$transaction(async (tx) => {
// 포인트 차감
await tx.user.update({
where: { id: userId },
data: { points: { decrement: 1000 } }
});
// 구매 기록
await tx.purchase.create({
data: {
userId: userId,
productId: productId,
amount: 1000
}
});
});
$transaction 안의 모든 작업은 하나의 트랜잭션으로 묶입니다. 중간에 에러가 나면 자동으로 롤백됩니다.
const t = await sequelize.transaction();
try {
await User.update(
{ points: sequelize.literal('points - 1000') },
{ where: { id: userId }, transaction: t }
);
await Purchase.create({
userId: userId,
productId: productId
}, { transaction: t });
await t.commit();
} catch (error) {
await t.rollback();
throw error;
}
트랜잭션은 DB 락을 유발하므로, 범위를 최소화해야 합니다.
❌ 나쁜 예:
await prisma.$transaction(async (tx) => {
const user = await tx.user.findUnique({ where: { id: userId } });
// 외부 API 호출 (느림!)
const paymentResult = await externalPaymentAPI.charge(user.cardToken, 1000);
await tx.purchase.create({ data: { userId, amount: 1000 } });
});
외부 API 호출이 느리면 트랜잭션이 오래 열려 있어서 다른 요청들이 블로킹됩니다.
✅ 좋은 예:
// 트랜잭션 밖에서 외부 API 호출
const paymentResult = await externalPaymentAPI.charge(cardToken, 1000);
// 성공하면 트랜잭션으로 DB만 빠르게 처리
await prisma.$transaction(async (tx) => {
await tx.purchase.create({ data: { userId, amount: 1000 } });
await tx.user.update({
where: { id: userId },
data: { points: { increment: 100 } }
});
});
여러 트랜잭션이 서로의 락을 기다리면 데드락이 발생합니다.
// 트랜잭션 A
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- 1번 락
UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- 2번 락 대기
// 트랜잭션 B (동시에)
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 2; -- 2번 락
UPDATE accounts SET balance = balance + 50 WHERE id = 1; -- 1번 락 대기
-- 서로 기다리는 데드락 발생!
해결책은 항상 같은 순서로 락을 획득하는 것입니다:
// 항상 id가 작은 것부터 락 획득
const [smallerId, largerId] = [id1, id2].sort();
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = smallerId;
UPDATE accounts SET balance = balance + 100 WHERE id = largerId;
COMMIT;
가장 중요한 부분입니다. 결제는 절대 실패해서는 안 되니까요.
async function processPayment(userId: number, amount: number) {
return await prisma.$transaction(async (tx) => {
// 1. 사용자 포인트 확인 및 차감
const user = await tx.user.update({
where: { id: userId },
data: { points: { decrement: amount } }
});
if (user.points < 0) {
throw new Error('Insufficient points');
}
// 2. 결제 기록 생성
const payment = await tx.payment.create({
data: {
userId: userId,
amount: amount,
status: 'COMPLETED'
}
});
// 3. 구매 내역 생성
await tx.purchase.create({
data: {
userId: userId,
paymentId: payment.id,
productId: productId
}
});
return payment;
}, {
isolationLevel: 'Serializable' // 최고 수준 격리
});
}
동시성 문제가 가장 많이 발생하는 부분입니다.
async function purchaseProduct(productId: number, quantity: number) {
return await prisma.$transaction(async (tx) => {
// 비관적 락으로 재고 확인
const product = await tx.$queryRaw`
SELECT * FROM products
WHERE id = ${productId}
FOR UPDATE
`;
if (product.stock < quantity) {
throw new Error('Out of stock');
}
// 재고 차감
await tx.product.update({
where: { id: productId },
data: { stock: { decrement: quantity } }
});
return product;
});
}
대량의 데이터를 처리할 때는 트랜잭션을 작게 나눠야 합니다.
// ❌ 나쁜 예: 10만 건을 하나의 트랜잭션으로
await prisma.$transaction(async (tx) => {
for (const user of allUsers) { // 10만 명
await tx.user.update({ where: { id: user.id }, data: { ... } });
}
});
// ✅ 좋은 예: 1000건씩 나눠서 처리
for (let i = 0; i < allUsers.length; i += 1000) {
const batch = allUsers.slice(i, i + 1000);
await prisma.$transaction(async (tx) => {
for (const user of batch) {
await tx.user.update({ where: { id: user.id }, data: { ... } });
}
});
}
트랜잭션은 여러 DB 작업을 하나의 원자적 단위로 묶어서, 전부 성공하거나 전부 실패하도록 보장하는 메커니즘입니다. ACID 특성을 이해하고, 적절한 격리 수준을 선택하며, 트랜잭션 범위를 최소화하면, 데이터 정합성 문제의 대부분을 해결할 수 있습니다. 실제로 결제, 재고, 포인트 같은 중요한 데이터를 다룰 때는 반드시 트랜잭션을 사용해야 합니다.