가이드 49 제3장-1: TOCTOU | 위험도: 5 (매우 높음)
시나리오를 시작하세요
Thread 실행 순서가 여기 표시됩니다
현재 잔액
1,000,000 원
Thread 1 출금액
-
Thread 2 출금액
-
// 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: '포인트 부족' });
}
});
동시 요청 처리
// 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);
}
}