🗄️ 데이터베이스

트랜잭션

Transaction

데이터베이스에서 하나의 논리적 작업 단위로, 여러 연산을 묶어 모두 성공하거나 모두 실패(All or Nothing)하도록 보장합니다. ACID 속성을 통해 데이터 무결성과 일관성을 유지합니다.

📖 상세 설명

트랜잭션(Transaction)은 데이터베이스에서 수행되는 논리적 작업의 최소 단위입니다. 은행 계좌 이체를 예로 들면, "A 계좌에서 출금"과 "B 계좌로 입금"이라는 두 작업이 반드시 함께 성공하거나 함께 실패해야 합니다. 이것이 트랜잭션의 핵심 개념입니다.

트랜잭션의 생명주기는 BEGIN(시작), 작업 수행, COMMIT(확정) 또는 ROLLBACK(취소)으로 구성됩니다. COMMIT은 모든 변경사항을 영구적으로 저장하고, ROLLBACK은 트랜잭션 시작 시점으로 되돌립니다. 오류 발생 시 자동 또는 명시적으로 롤백을 수행합니다.

트랜잭션의 ACID 속성은 데이터 신뢰성의 근간입니다. 원자성(Atomicity)은 전부 또는 전무를, 일관성(Consistency)은 제약조건 준수를, 격리성(Isolation)은 동시 트랜잭션 간 간섭 방지를, 지속성(Durability)은 커밋된 데이터의 영구 보존을 보장합니다.

동시성 제어를 위해 데이터베이스는 락(Lock)과 MVCC(Multi-Version Concurrency Control) 기법을 사용합니다. 락은 다른 트랜잭션의 접근을 차단하고, MVCC는 데이터의 여러 버전을 유지하여 읽기와 쓰기가 서로 방해하지 않도록 합니다.

분산 트랜잭션은 여러 데이터베이스나 서비스에 걸친 트랜잭션입니다. 2PC(Two-Phase Commit), Saga 패턴 등을 통해 분산 환경에서도 일관성을 유지합니다. 마이크로서비스 환경에서는 Eventual Consistency와 보상 트랜잭션을 함께 고려해야 합니다.

💻 코드 예제

# 트랜잭션 관리 예제 - Python + PostgreSQL
import psycopg2
from contextlib import contextmanager
from decimal import Decimal

class TransactionManager:
    """트랜잭션 관리 클래스"""

    def __init__(self, connection_string: str):
        self.conn_string = connection_string

    @contextmanager
    def transaction(self):
        """
        컨텍스트 매니저를 이용한 트랜잭션 관리
        성공 시 자동 커밋, 실패 시 자동 롤백
        """
        conn = psycopg2.connect(self.conn_string)
        conn.autocommit = False  # 명시적 트랜잭션 모드

        try:
            yield conn
            conn.commit()  # 정상 완료 시 커밋
            print("✅ 트랜잭션 커밋됨")

        except Exception as e:
            conn.rollback()  # 오류 발생 시 롤백
            print(f"❌ 트랜잭션 롤백됨: {e}")
            raise

        finally:
            conn.close()


class OrderService:
    """주문 처리 서비스 - 트랜잭션 활용 예제"""

    def __init__(self, tx_manager: TransactionManager):
        self.tx_manager = tx_manager

    def create_order(self, user_id: int, items: list) -> int:
        """
        주문 생성 - 여러 테이블에 걸친 트랜잭션
        1. 재고 확인 및 차감
        2. 주문 생성
        3. 주문 상세 생성
        4. 포인트 적립
        """
        with self.tx_manager.transaction() as conn:
            with conn.cursor() as cur:
                # 1. 재고 확인 및 차감 (FOR UPDATE로 락)
                for item in items:
                    cur.execute("""
                        SELECT stock FROM products
                        WHERE id = %s FOR UPDATE
                    """, (item['product_id'],))

                    stock = cur.fetchone()
                    if not stock or stock[0] < item['quantity']:
                        raise ValueError(f"재고 부족: 상품 {item['product_id']}")

                    cur.execute("""
                        UPDATE products
                        SET stock = stock - %s
                        WHERE id = %s
                    """, (item['quantity'], item['product_id']))

                # 2. 주문 생성
                total_amount = sum(item['price'] * item['quantity'] for item in items)
                cur.execute("""
                    INSERT INTO orders (user_id, total_amount, status, created_at)
                    VALUES (%s, %s, 'pending', NOW())
                    RETURNING id
                """, (user_id, total_amount))
                order_id = cur.fetchone()[0]

                # 3. 주문 상세 생성
                for item in items:
                    cur.execute("""
                        INSERT INTO order_items (order_id, product_id, quantity, price)
                        VALUES (%s, %s, %s, %s)
                    """, (order_id, item['product_id'], item['quantity'], item['price']))

                # 4. 포인트 적립 (1%)
                points = int(total_amount * Decimal('0.01'))
                cur.execute("""
                    UPDATE users SET points = points + %s WHERE id = %s
                """, (points, user_id))

                return order_id


# Savepoint 활용 예제
def process_with_savepoint(conn):
    """
    Savepoint를 이용한 부분 롤백
    전체 트랜잭션을 유지하면서 일부만 취소 가능
    """
    with conn.cursor() as cur:
        cur.execute("BEGIN")

        try:
            # 첫 번째 작업
            cur.execute("INSERT INTO logs (msg) VALUES ('작업1 시작')")

            # Savepoint 생성
            cur.execute("SAVEPOINT sp1")

            try:
                # 위험한 작업 시도
                cur.execute("INSERT INTO risky_table VALUES (1, 'test')")
            except:
                # Savepoint로 롤백 (전체 트랜잭션은 유지)
                cur.execute("ROLLBACK TO SAVEPOINT sp1")
                print("⚠️ 부분 롤백됨 (Savepoint)")

            # 나머지 작업 계속
            cur.execute("INSERT INTO logs (msg) VALUES ('작업 완료')")
            cur.execute("COMMIT")

        except Exception as e:
            cur.execute("ROLLBACK")
            raise


# 사용 예시
if __name__ == "__main__":
    tx_manager = TransactionManager("postgresql://localhost/shop")
    order_service = OrderService(tx_manager)

    items = [
        {'product_id': 1, 'quantity': 2, 'price': Decimal('15000')},
        {'product_id': 2, 'quantity': 1, 'price': Decimal('25000')}
    ]

    try:
        order_id = order_service.create_order(user_id=123, items=items)
        print(f"주문 완료: #{order_id}")
    except ValueError as e:
        print(f"주문 실패: {e}")
// 트랜잭션 관리 예제 - Node.js + PostgreSQL
const { Pool } = require('pg');

class TransactionManager {
    constructor(connectionString) {
        this.pool = new Pool({ connectionString });
    }

    /**
     * 트랜잭션 래퍼 - 자동 커밋/롤백
     * @param {Function} callback - 트랜잭션 내에서 실행할 함수
     */
    async withTransaction(callback) {
        const client = await this.pool.connect();

        try {
            await client.query('BEGIN');
            const result = await callback(client);
            await client.query('COMMIT');
            console.log('✅ 트랜잭션 커밋됨');
            return result;

        } catch (error) {
            await client.query('ROLLBACK');
            console.error(`❌ 트랜잭션 롤백됨: ${error.message}`);
            throw error;

        } finally {
            client.release();
        }
    }
}

class OrderService {
    constructor(txManager) {
        this.txManager = txManager;
    }

    /**
     * 주문 생성 - 트랜잭션으로 여러 작업 묶기
     */
    async createOrder(userId, items) {
        return await this.txManager.withTransaction(async (client) => {
            // 1. 재고 확인 및 차감
            for (const item of items) {
                const { rows } = await client.query(
                    'SELECT stock FROM products WHERE id = $1 FOR UPDATE',
                    [item.productId]
                );

                if (rows.length === 0 || rows[0].stock < item.quantity) {
                    throw new Error(`재고 부족: 상품 ${item.productId}`);
                }

                await client.query(
                    'UPDATE products SET stock = stock - $1 WHERE id = $2',
                    [item.quantity, item.productId]
                );
            }

            // 2. 주문 생성
            const totalAmount = items.reduce(
                (sum, item) => sum + item.price * item.quantity, 0
            );

            const orderResult = await client.query(
                `INSERT INTO orders (user_id, total_amount, status, created_at)
                 VALUES ($1, $2, 'pending', NOW())
                 RETURNING id`,
                [userId, totalAmount]
            );
            const orderId = orderResult.rows[0].id;

            // 3. 주문 상세 생성
            for (const item of items) {
                await client.query(
                    `INSERT INTO order_items (order_id, product_id, quantity, price)
                     VALUES ($1, $2, $3, $4)`,
                    [orderId, item.productId, item.quantity, item.price]
                );
            }

            // 4. 포인트 적립
            const points = Math.floor(totalAmount * 0.01);
            await client.query(
                'UPDATE users SET points = points + $1 WHERE id = $2',
                [points, userId]
            );

            return orderId;
        });
    }
}

// Savepoint 활용
async function withSavepoint(client, name, callback) {
    await client.query(`SAVEPOINT ${name}`);

    try {
        return await callback();
    } catch (error) {
        await client.query(`ROLLBACK TO SAVEPOINT ${name}`);
        console.warn(`⚠️ Savepoint ${name}로 롤백됨`);
        throw error;
    }
}

// 재시도 로직이 포함된 트랜잭션
async function executeWithRetry(txManager, operation, maxRetries = 3) {
    let lastError;

    for (let attempt = 1; attempt <= maxRetries; attempt++) {
        try {
            return await txManager.withTransaction(operation);
        } catch (error) {
            lastError = error;

            // 직렬화 실패 또는 데드락인 경우만 재시도
            if (error.code === '40001' || error.code === '40P01') {
                console.log(`재시도 ${attempt}/${maxRetries}...`);
                await new Promise(r => setTimeout(r, 100 * attempt));
            } else {
                throw error;
            }
        }
    }

    throw lastError;
}

// 사용 예시
const txManager = new TransactionManager('postgresql://localhost/shop');
const orderService = new OrderService(txManager);

const items = [
    { productId: 1, quantity: 2, price: 15000 },
    { productId: 2, quantity: 1, price: 25000 }
];

orderService.createOrder(123, items)
    .then(orderId => console.log(`주문 완료: #${orderId}`))
    .catch(error => console.error(`주문 실패: ${error.message}`));
-- 트랜잭션 기본 사용법 (PostgreSQL/MySQL)

-- ============================================
-- 1. 기본 트랜잭션
-- ============================================
BEGIN;  -- 트랜잭션 시작 (START TRANSACTION도 가능)

-- 여러 작업 수행
UPDATE accounts SET balance = balance - 50000 WHERE id = 1;
UPDATE accounts SET balance = balance + 50000 WHERE id = 2;
INSERT INTO transfer_log (from_id, to_id, amount) VALUES (1, 2, 50000);

-- 모든 작업 성공 시 확정
COMMIT;

-- 또는 문제 발생 시 취소
-- ROLLBACK;


-- ============================================
-- 2. 자동 커밋 설정
-- ============================================
-- 자동 커밋 확인
SHOW autocommit;

-- 자동 커밋 끄기 (명시적 트랜잭션 필요)
SET autocommit = 0;

-- 작업 수행
INSERT INTO logs (message) VALUES ('테스트');

-- 명시적으로 커밋해야 저장됨
COMMIT;


-- ============================================
-- 3. Savepoint (부분 롤백)
-- ============================================
BEGIN;

INSERT INTO orders (user_id, total) VALUES (1, 10000);
-- 여기까지는 확실히 성공

SAVEPOINT before_items;

INSERT INTO order_items (order_id, product_id) VALUES (1, 100);
INSERT INTO order_items (order_id, product_id) VALUES (1, 999);  -- 실패 가정

-- 주문 상세만 롤백하고 주문은 유지
ROLLBACK TO SAVEPOINT before_items;

-- 대체 작업 수행
INSERT INTO order_items (order_id, product_id) VALUES (1, 101);

COMMIT;  -- 주문 + 대체 상세만 저장됨


-- ============================================
-- 4. 읽기 전용 트랜잭션
-- ============================================
-- 리포트 조회 시 유용 (일관된 스냅샷 보장)
BEGIN READ ONLY;

SELECT SUM(balance) FROM accounts;
SELECT COUNT(*) FROM transactions WHERE date = CURRENT_DATE;

COMMIT;


-- ============================================
-- 5. 트랜잭션 격리 수준 설정
-- ============================================
-- 현재 격리 수준 확인
SHOW TRANSACTION ISOLATION LEVEL;

-- 트랜잭션 시작 시 격리 수준 지정
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- 작업 수행
COMMIT;

-- 세션 전체 격리 수준 변경
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL READ COMMITTED;


-- ============================================
-- 6. 락을 이용한 동시성 제어
-- ============================================
BEGIN;

-- 행 레벨 배타적 락 (수정 예정)
SELECT * FROM products WHERE id = 1 FOR UPDATE;

-- 이 시점에 다른 트랜잭션은 이 행 수정 대기

UPDATE products SET stock = stock - 1 WHERE id = 1;

COMMIT;  -- 락 해제


-- 공유 락 (읽기만, 다른 트랜잭션도 읽기 가능)
SELECT * FROM products WHERE id = 1 FOR SHARE;


-- ============================================
-- 7. 트랜잭션 상태 확인 (PostgreSQL)
-- ============================================
-- 현재 실행 중인 트랜잭션 조회
SELECT pid, state, query, xact_start, query_start
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY xact_start;

-- 락 대기 상태 확인
SELECT blocked.pid AS blocked_pid,
       blocking.pid AS blocking_pid,
       blocked.query AS blocked_query
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
  ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE blocked.pid != blocking.pid;

🗣️ 실무에서 이렇게 말하세요

💬 결제 시스템 코드 리뷰에서
"이 결제 로직은 트랜잭션으로 감싸야 해요. 포인트 차감, 결제 기록, 주문 상태 업데이트가 하나의 트랜잭션 안에서 처리되어야 합니다. 중간에 실패하면 전체가 롤백되어야 포인트만 빠지고 결제 안 된 상황을 막을 수 있어요."
💬 성능 이슈 논의에서
"트랜잭션 범위가 너무 넓어요. 외부 API 호출을 트랜잭션 안에 넣으면 응답 대기하는 동안 락이 유지됩니다. API 호출은 트랜잭션 밖으로 빼고, 실패 시 보상 트랜잭션으로 처리하는 게 좋겠어요."
💬 장애 대응 회의에서
"롤백 로그 보니까 트랜잭션이 3분이나 열려 있었네요. 이러면 다른 요청들이 락 대기로 밀리고 결국 타임아웃 나요. 트랜잭션은 최대한 짧게 유지하고, 배치 작업은 청크 단위로 커밋하도록 수정합시다."

⚠️ 주의사항 & 베스트 프랙티스

트랜잭션 내 외부 호출 금지

HTTP 요청, 파일 I/O, 메시지 큐 발행 등을 트랜잭션 안에 넣으면 락이 오래 유지됩니다. 외부 호출은 커밋 후에 수행하세요.

커밋/롤백 누락 주의

예외 처리 로직에서 롤백을 누락하면 커넥션이 점유된 채로 남습니다. try-finally나 컨텍스트 매니저를 활용하세요.

대량 데이터 단일 트랜잭션 금지

수백만 건을 하나의 트랜잭션으로 처리하면 롤백 시간이 길어지고 메모리도 부족해집니다. 청크 단위로 나눠서 처리하세요.

트랜잭션 베스트 프랙티스

트랜잭션 범위 최소화, 락 순서 일관성 유지, 적절한 격리 수준 선택, 타임아웃 설정, 재시도 로직 구현, 모니터링 설정.

🔗 관련 용어

📚 더 배우기