금융권 경쟁 조건 (Race Condition) 실전 시나리오

가이드 49 제3장-1: TOCTOU | 위험도: 5 (매우 높음)

⏱️ 경쟁 조건 타임라인

시나리오를 시작하세요

Thread 실행 순서가 여기 표시됩니다

💰 계좌 잔액 (실시간)

현재 잔액

1,000,000

Thread 1 출금액

-

Thread 2 출금액

-

공격 진행 단계

시나리오를 시작하세요...

실전 공격 시나리오

취약한 코드 (Race Condition)

TOCTOU
// Java - 취약한 출금 코드

@Service
public class AccountService {
    
    @Autowired
    private AccountRepository accountRepository;
    
    // ❌ 동기화 없음!
    public void withdraw(String accountId, BigDecimal amount) {
        
        Account account = accountRepository.findById(accountId);
        
        // ❌ TOCTOU: Time of Check
        if (account.getBalance().compareTo(amount) >= 0) {
            
            // ⚠️ 여기서 다른 Thread가 끼어들 수 있음!
            
            // ❌ TOCTOU: Time of Use
            account.setBalance(
                account.getBalance().subtract(amount)
            );
            
            accountRepository.save(account);
        } else {
            throw new InsufficientFundsException();
        }
    }
}

// 공격 시나리오

// 초기 잔액: 1,000,000원

Thread 1: withdraw(accountId, 800,000)
  → 잔액 확인: 1,000,000 >= 800,000 ✓
  
Thread 2: withdraw(accountId, 800,000)
  → 잔액 확인: 1,000,000 >= 800,000 ✓
  
Thread 1: 잔액 = 1,000,000 - 800,000 = 200,000
  → DB 저장: 200,000

Thread 2: 잔액 = 1,000,000 - 800,000 = 200,000
  → DB 저장: 200,000

// 최종 잔액: 200,000 (예상: -600,000)
// 손실: 1,400,000원!

// Python Flask - 취약한 송금

@app.route('/transfer', methods=['POST'])
def transfer():
    from_account = request.json['from_account']
    amount = request.json['amount']
    
    # ❌ Lock 없음!
    account = get_account(from_account)
    
    # TOCTOU: Check
    if account['balance'] >= amount:
        
        # ⚠️ 다른 요청이 끼어들 수 있음
        
        # TOCTOU: Use
        new_balance = account['balance'] - amount
        
        update_balance(from_account, new_balance)
        
        return jsonify({"status": "success"})
    else:
        return jsonify({"error": "잔액 부족"}), 400

// Node.js - 취약한 포인트 사용

app.post('/use-points', async (req, res) => {
    const { userId, points } = req.body;
    
    // ❌ 동시성 제어 없음
    const user = await User.findById(userId);
    
    // TOCTOU: Check
    if (user.points >= points) {
        
        // ⚠️ Race Condition!
        
        // TOCTOU: Use
        user.points -= points;
        await user.save();
        
        res.json({ status: 'success' });
    } else {
        res.status(400).json({ error: '포인트 부족' });
    }
});

🔵 서버 처리 로그

동시 요청 처리

[SERVER] Waiting...
서버 상태: RUNNING

안전한 코드 (동시성 제어)

해결책
// Java - 안전한 출금 (방법 1: synchronized)

@Service
public class SecureAccountService {
    
    @Autowired
    private AccountRepository accountRepository;
    
    // ✅ synchronized 키워드
    public synchronized void withdraw(
        String accountId, 
        BigDecimal amount
    ) {
        Account account = accountRepository
            .findById(accountId);
        
        if (account.getBalance().compareTo(amount) >= 0) {
            account.setBalance(
                account.getBalance().subtract(amount)
            );
            accountRepository.save(account);
        } else {
            throw new InsufficientFundsException();
        }
    }
}

// 방법 2: DB Lock (권장)

@Service
public class SecureAccountService {
    
    @Autowired
    private AccountRepository accountRepository;
    
    // ✅ SERIALIZABLE 격리 수준
    @Transactional(
        isolation = Isolation.SERIALIZABLE
    )
    public void withdraw(
        String accountId, 
        BigDecimal amount
    ) {
        // ✅ SELECT FOR UPDATE (비관적 락)
        Account account = accountRepository
            .findByIdForUpdate(accountId);
        
        if (account.getBalance().compareTo(amount) >= 0) {
            account.setBalance(
                account.getBalance().subtract(amount)
            );
            accountRepository.save(account);
        } else {
            throw new InsufficientFundsException();
        }
    }
}

// Repository - SELECT FOR UPDATE

@Repository
public interface AccountRepository 
    extends JpaRepository {
    
    // ✅ 비관적 락
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT a FROM Account a WHERE a.id = :id")
    Account findByIdForUpdate(@Param("id") String id);
}

// 방법 3: 낙관적 락

@Entity
public class Account {
    
    @Id
    private String id;
    
    private BigDecimal balance;
    
    // ✅ 버전 필드
    @Version
    private Long version;
    
    // getters/setters
}

@Service
public class OptimisticLockService {
    
    @Transactional
    public void withdraw(
        String accountId, 
        BigDecimal amount
    ) {
        try {
            Account account = accountRepository
                .findById(accountId);
            
            if (account.getBalance().compareTo(amount) >= 0) {
                account.setBalance(
                    account.getBalance().subtract(amount)
                );
                // 버전 불일치 시 예외 발생
                accountRepository.save(account);
            }
        } catch (OptimisticLockException e) {
            // ✅ 재시도
            throw new ConcurrentModificationException(
                "다시 시도해주세요"
            );
        }
    }
}

// 방법 4: ReentrantLock

@Service
public class LockBasedService {
    
    // ✅ 계좌별 Lock Map
    private final ConcurrentHashMap 
        locks = new ConcurrentHashMap<>();
    
    public void withdraw(
        String accountId, 
        BigDecimal amount
    ) {
        // 계좌별 Lock 획득
        ReentrantLock lock = locks.computeIfAbsent(
            accountId, 
            k -> new ReentrantLock()
        );
        
        lock.lock();
        try {
            Account account = accountRepository
                .findById(accountId);
            
            if (account.getBalance().compareTo(amount) >= 0) {
                account.setBalance(
                    account.getBalance().subtract(amount)
                );
                accountRepository.save(account);
            } else {
                throw new InsufficientFundsException();
            }
        } finally {
            // ✅ 반드시 unlock
            lock.unlock();
        }
    }
}

// Python Flask - 안전한 송금

from threading import Lock
from flask import Flask, request, jsonify

# ✅ 계좌별 Lock 딕셔너리
account_locks = {}

def get_lock(account_id):
    if account_id not in account_locks:
        account_locks[account_id] = Lock()
    return account_locks[account_id]

@app.route('/transfer', methods=['POST'])
def transfer():
    from_account = request.json['from_account']
    amount = request.json['amount']
    
    # ✅ Lock 획득
    lock = get_lock(from_account)
    
    with lock:
        account = get_account(from_account)
        
        if account['balance'] >= amount:
            new_balance = account['balance'] - amount
            update_balance(from_account, new_balance)
            
            return jsonify({"status": "success"})
        else:
            return jsonify({"error": "잔액 부족"}), 400

# Django - 트랜잭션 + SELECT FOR UPDATE

from django.db import transaction
from django.db.models import F

@transaction.atomic
def secure_withdraw(account_id, amount):
    
    # ✅ SELECT FOR UPDATE
    account = Account.objects.select_for_update().get(
        id=account_id
    )
    
    if account.balance >= amount:
        # ✅ F() 객체로 원자적 연산
        account.balance = F('balance') - amount
        account.save()
        
        # refresh로 최신 값 가져오기
        account.refresh_from_db()
        
        return account
    else:
        raise ValueError("잔액 부족")

// Node.js - Sequelize Lock

const { Sequelize, Op } = require('sequelize');

app.post('/use-points', async (req, res) => {
    const { userId, points } = req.body;
    
    // ✅ 트랜잭션 + Lock
    const t = await sequelize.transaction({
        isolationLevel: 
            Sequelize.Transaction.ISOLATION_LEVELS
                .SERIALIZABLE
    });
    
    try {
        // ✅ FOR UPDATE 락
        const user = await User.findByPk(userId, {
            lock: t.LOCK.UPDATE,
            transaction: t
        });
        
        if (user.points >= points) {
            user.points -= points;
            await user.save({ transaction: t });
            
            await t.commit();
            res.json({ status: 'success' });
        } else {
            await t.rollback();
            res.status(400).json({ error: '포인트 부족' });
        }
    } catch (error) {
        await t.rollback();
        res.status(500).json({ error: error.message });
    }
});

// MongoDB - Optimistic Locking

const pointSchema = new Schema({
    userId: String,
    points: Number,
    // ✅ 버전 필드
    __v: { type: Number, default: 0 }
});

async function usePoints(userId, points) {
    const maxRetries = 3;
    
    for (let i = 0; i < maxRetries; i++) {
        const user = await User.findOne({ userId });
        
        if (user.points >= points) {
            // ✅ 버전 체크 업데이트
            const result = await User.updateOne(
                { 
                    userId: userId, 
                    __v: user.__v  // 버전 확인
                },
                { 
                    $inc: { 
                        points: -points,
                        __v: 1  // 버전 증가
                    }
                }
            );
            
            if (result.modifiedCount > 0) {
                return { status: 'success' };
            }
            // 버전 불일치 -> 재시도
        } else {
            throw new Error('포인트 부족');
        }
    }
    
    throw new Error('동시성 충돌, 재시도 초과');
}

// Redis - 분산 락

const Redis = require('ioredis');
const redis = new Redis();

async function withdrawWithRedisLock(accountId, amount) {
    const lockKey = `lock:account:${accountId}`;
    const lockValue = Date.now() + 10000; // 10초 타임아웃
    
    // ✅ 분산 락 획득 시도
    const acquired = await redis.set(
        lockKey, 
        lockValue, 
        'PX', 10000,  // 10초 후 자동 만료
        'NX'          // 없을 때만 설정
    );
    
    if (!acquired) {
        throw new Error('다른 처리가 진행 중입니다');
    }
    
    try {
        // 출금 처리
        const account = await getAccount(accountId);
        
        if (account.balance >= amount) {
            account.balance -= amount;
            await saveAccount(account);
            return { status: 'success' };
        } else {
            throw new Error('잔액 부족');
        }
    } finally {
        // ✅ 락 해제
        await redis.del(lockKey);
    }
}