트랜잭션의 기본 개념
여러 테이블을 조작하는 개발 작업에서는 데이터 일관성을 보장하기 위해 트랜잭션과 그 특성을 반드시 고려해야 합니다. 트랜잭션은 원자성과 데이터 무결성을 보장하는 핵심 메커니즘입니다. 이 글에서는 실제 시스템 결제 로직에서 발생할 수 있는 동시성 문제를 통해 트랜잭션 격리 수준에 대해 심도 있게 탐구해 보겠습니다.
ACID 특성
- 원자성 (Atomicity): 트랜잭션 내의 모든 작업은 하나의 단위로 처리됩니다. 즉, 모든 작업이 성공적으로 완료되거나, 하나라도 실패하면 전체 작업이 취소(rollback)되어 트랜잭션이 시작되기 전 상태로 되돌아갑니다.
- 일관성 (Consistency): 트랜잭션이 성공적으로 완료되면 데이터베이스는 항상 일관된 상태를 유지해야 합니다. 이는 데이터의 정확성, 무결성 규칙을 준수하는 것을 의미합니다.
- 격리성 (Isolation): 두 개 이상의 트랜잭션이 동시에 데이터베이스의 동일한 데이터에 접근할 때, 각 트랜잭션이 다른 트랜잭션의 작업에 영향을 받지 않도록 격리됩니다. 격리 수준은 읽기 미완료(Read Uncommitted), 읽기 완료(Read Committed), 반복 읽기(Repeatable Read), 직렬화(Serializable) 등 여러 단계로 나뉩니다.
- 내구성 (Durability): 트랜잭션이 성공적으로 커밋되면, 해당 변경 사항은 영구적으로 데이터베이스에 저장되어 시스템 장애가 발생해도 유지됩니다.
네 가지 트랜잭션 격리 수준
- 읽기 미완료 (Read Uncommitted): 가장 낮은 격리 수준입니다. 트랜잭션이 다른 트랜잭션에서 아직 커밋되지 않은 변경 내용(더티 데이터)을 읽을 수 있습니다.
- 읽기 완료 (Read Committed): 트랜잭션이 다른 트랜잭션에서 커밋된 데이터만 읽을 수 있도록 보장합니다. 하지만 한 트랜잭션 내에서 동일한 데이터를 두 번 조회했을 때 결과가 다를 수 있습니다(반복 불가능한 읽기).
- 반복 읽기 (Repeatable Read): 트랜잭션 내에서 동일한 데이터를 여러 번 조회해도 항상 동일한 결과를 반환합니다. 하지만 새로운 행이 삽입되는 경우(팬텀 리드)는 방지하지 못할 수 있습니다.
- 직렬화 (Serializable): 가장 높은 격리 수준입니다. 트랜잭션을 순차적으로 실행하는 것과 동일한 결과를 보장하여 모든 동시성 문제(더티 리드, 반복 불가능한 읽기, 팬텀 리드)를 방지합니다.
주요 읽기 현상 (Anomalies)
- 더티 리드 (Dirty Read): 트랜잭션이 아직 커밋되지 않은 다른 트랜잭션의 변경 내용을 읽는 현상입니다.
- 반복 불가능한 읽기 (Non-repeatable Read): 한 트랜잭션 내에서 동일한 데이터를 두 번 조회했을 때, 중간에 다른 트랜잭션이 해당 데이터를 수정하고 커밋하여 결과가 달라지는 현상입니다.
- 팬텀 리드 (Phantom Read): 한 트랜잭션 내에서 동일한 쿼리를 두 번 실행했을 때, 중간에 다른 트랜잭션이 새로운 데이터를 삽입하여 두 번째 쿼리 결과에 처음에는 없던 새로운 행이 나타나는 현상입니다.
실제 비즈니스 로직: 사용자 출금 처리
사용자가 자신의 계좌에서 돈을 출금하는 간단한 비즈니스 로직을 통해 격리 수준의 중요성을 살펴보겠습니다. 이 로직은 다음과 같은 단계를 포함합니다.
- 현재 계좌 잔액 조회
- 출금 기록 저장
- 계좌 상세 내역 저장
- 계좌 잔액 업데이트
이러한 다중 테이블 작업은 원자성을 보장하기 위해 하나의 트랜잭션으로 묶어야 합니다. 데이터베이스를 분산하여 분산 트랜잭션을 피하기 위해, 동일 사용자의 데이터는 동일 데이터베이스에 저장하는 전략(사용자 ID 기준 분산)을 사용할 수 있습니다.
초기 구현 및 문제점
아래는 `SERIALIZABLE` 격리 수준을 설정한 Java 코드입니다. 이 코드는 동시에 여러 사용자가 출금을 시도할 때 예상치 못한 결과를 초래할 수 있습니다.
// 격리 수준: SERIALIZABLE
public Boolean processWithdrawal(WithdrawalRequest request) {
// ①. 현재 계좌 정보 조회
UserAccount userAccount = queryUserAccount(request.getUserId());
// ②. 잔액 확인
if (request.getAmount() > userAccount.getBalance()) {
throw new InsufficientBalanceException("잔액이 부족합니다.");
}
// ③. 출금 기록 저장
saveWithdrawalRecord(request);
// ④. 계좌 상세 내역 저장
saveAccountDetail(request);
// ⑤. 계좌 잔액 업데이트
updateUserAccountBalance(request.getUserId(), request.getAmount());
return true;
}
문제 분석: `SERIALIZABLE` 격리 수준을 사용했음에도 불구하고, MySQL에서 `SELECT` 문은 기본적으로 `LOCK IN SHARE MODE`를 사용하여 공유 락(Shared Lock)을 획득합니다. 이는 다른 트랜잭션이 동일한 데이터를 읽는 것을 막지 않습니다. 따라서 두 개의 출금 트랜잭션이 동시에 실행되면, 둘 다 초기 잔액(예: 5000원)을 읽고 각각 3000원, 4000원을 출금하려 할 경우, 최종 잔액이 음수가 될 수 있습니다.
동시성 문제 해결 방안
이 문제를 해결하기 위해 두 가지 주요 전략을 적용할 수 있습니다.
방안 1: 업데이트 후 재조회 검증
업데이트 작업 후, 예상 잔액과 실제 데이터베이스의 잔액을 비교하여 불일치 시 트랜잭션을 롤백하는 방식입니다.
public Boolean processWithdrawal(WithdrawalRequest request) {
// ①. 현재 계좌 정보 조회
UserAccount userAccount = queryUserAccount(request.getUserId());
// ②. 잔액 확인
if (request.getAmount() > userAccount.getBalance()) {
throw new InsufficientBalanceException("잔액이 부족합니다.");
}
// ③. 출금 기록 저장
saveWithdrawalRecord(request);
// ④. 계좌 상세 내역 저장
saveAccountDetail(request);
// ⑤. 계좌 잔액 업데이트
updateUserAccountBalance(request.getUserId(), request.getAmount());
// ⑥. 잔액 검증
long expectedBalance = userAccount.getBalance() - request.getAmount();
UserAccount updatedAccount = queryUserAccount(request.getUserId());
long actualBalance = updatedAccount.getBalance();
if (expectedBalance != actualBalance) {
// 다른 트랜잭션에 의해 잔액이 변경된 경우, 예외 발생 및 트랜잭션 롤백
throw new ConcurrencyException("동시에 다른 트랜잭션에서 잔액이 변경되었습니다.");
}
return true;
}
방안 2: 낙관적 락 (Optimistic Locking) / CAS (Compare-And-Set)
더 효율적인 방법은 `UPDATE` 쿼리에 현재 잔액을 조건으로 추가하는 것입니다. 이는 CAS(비교 후 설정) 패턴의 일종으로, 업데이트가 성공했는지 확인하여 실패 시 트랜잭션을 롤백합니다.
public Boolean processWithdrawal(WithdrawalRequest request) {
// ①. 현재 계좌 정보 조회
UserAccount userAccount = queryUserAccount(request.getUserId());
// ②. 잔액 확인
if (request.getAmount() > userAccount.getBalance()) {
throw new InsufficientBalanceException("잔액이 부족합니다.");
}
// ③. 출금 기록 저장
saveWithdrawalRecord(request);
// ④. 계좌 상세 내역 저장
saveAccountDetail(request);
// ⑤. 계좌 잔액 업데이트 (CAS 방식)
// SQL 예시: UPDATE accounts SET balance = :newBalance WHERE id = :id AND balance = :oldBalance
int updatedRows = updateUserAccountBalanceWithCondition(
request.getUserId(),
userAccount.getBalance() - request.getAmount(),
userAccount.getBalance()
);
if (updatedRows == 0) {
// 다른 트랜잭션에 의해 잔액이 변경되어 업데이트 실패한 경우
throw new ConcurrencyException("동시에 다른 트랜잭션에서 잔액이 변경되었습니다.");
}
return true;
}
이 방식은 데이터베이스 레벨에서 충돌을 감지하고 처리하므로, 애플리케이션 로직이 더 간결해지고 성능상 이점이 있습니다. 하지만 사용자 계좌 작업이 매우 빈번한 경우, 트랜잭션 실패 확률이 증가할 수 있습니다. 이러한 경우, 메시지 큐와 같은 비동기 처리 아키텍처를 도입하여 사용자 요청을 순차적으로 처리하는 것이 더 나은 해결책이 될 수 있습니다.