"DB 격리 수준... 그거 전공 시간에 배웠던 것 같은데...
READ COMMITTED
랑REPEATABLE READ
... 뭐 그런 거였나?"많은 개발자분들이 데이터베이스 트랜잭션 격리 수준(Isolation Level)에 대해 이론적으로는 알고 있지만, 막상 실제 서비스 개발 중에 이로 인한 문제를 겪거나 그 중요성을 깊이 체감하기는 쉽지 않습니다. 구글링을 해보면 각 격리 수준의 정의와 간단한 SQL 콘솔 예제는 넘쳐나지만, "그래서 이게 실제 코드에서 어떤 문제를 일으키는데?"라는 질문에는 명쾌한 답을 찾기 어려울 때가 많죠.
저 역시 그랬습니다. 각 격리 수준의 차이점을 단순 암기하고, 콘솔에서 몇 번 재현해 보는 정도로는 실무에서 마주하는 복잡한 동시성 문제 앞에서 속수무책이었습니다. 이 글에서는 제가 최근 신규 결제 시스템을 개발하면서 겪었던 DB 격리 수준 관련 이슈와 그 해결 과정을 공유하며, 이 다소 딱딱한 주제를 좀 더 현실적이고 친숙하게 풀어보고자 합니다.
(잠깐! 이 글은 DB 트랜잭션 격리 수준에 대한 기본적인 이해가 있다는 전제하에 진행됩니다. 혹시 개념이 가물가물하시다면, 가볍게 복습하고 오시는 것을 추천드립니다!)
새로운 결제 시스템을 개발하면서, 기존 Oracle 기반 환경에서 MySQL로 데이터베이스를 전환하는 작업을 진행했습니다. 시스템의 핵심 기능 중 하나는 사용자의 결제가 완료된 후, 해당 사용자의 잔액을 정확하게 업데이트하는 것이었습니다.
// 초기 결제 처리 로직 (간략화된 의사 코드)
@Transactional
fun processPayment(userId: Long, paymentAmount: Long) {
// ... (결제 요청 유효성 검사 등 선행 로직) ...
val user = userRepository.findById(userId) // 사용자 정보 조회
val currentBalance = user.balance
if (currentBalance < paymentAmount) {
throw InsufficientBalanceException("잔액이 부족합니다.")
}
val balanceToUpdate = currentBalance - paymentAmount
// 🤞 핵심: 업데이트 전 잔액(optimistic lock 역할)이 일치할 때만 업데이트
val updatedRows = userBalanceRepository.updateUserBalance(
userId = userId,
newBalance = balanceToUpdate,
expectedOriginalBalance = currentBalance // 낙관적 락 또는 조건부 업데이트
)
if (updatedRows == 0) {
throw ConcurrencyException("동시 결제 처리 중 문제가 발생했습니다. 다시 시도해주세요.")
}
println("Processing payment for user: ${user.id}, New Balance: $balanceToUpdate")
// ... (결제 완료 후 추가 로직) ...
}
// updateUserBalance 쿼리 예시
// UPDATE user_balance
// SET balance = :newBalance
// WHERE user_id = :userId AND balance = :expectedOriginalBalance;
결제 시스템의 특성상 동일한 사용자에게 여러 결제 요청이 거의 동시에 들어올 수 있으므로, 동시성 이슈는 항상 최우선으로 고려해야 할 부분이었습니다. 그래서 위 코드처럼 업데이트 시점의 잔액이 최초 조회 시점의 잔액(expectedOriginalBalance
)과 일치할 때만 업데이트하는 일종의 낙관적 락(Optimistic Lock) 또는 조건부 업데이트 방식을 사용했습니다.
한 건씩 순차적으로 요청이 들어올 때는 당연히 문제가 없었습니다. 하지만 여러 요청이 동시에 발생할 경우를 대비하여, 트랜잭션 시작 부분에서 해당 사용자의 잔액 레코드에 배타적 락(Exclusive Lock)을 거는 방식을 추가했습니다. (예: SELECT ... FOR UPDATE
또는 특정 컬럼을 UPDATE
하여 락 획득)
// DB 락을 추가한 결제 처리 로직
@Transactional
fun processPaymentWithLock(userId: Long, paymentAmount: Long) {
// 🥇 트랜잭션 시작 시 DB 락 획득 시도
// 동일 사용자에 대한 두 번째 이후 요청은 여기서 첫 번째 요청의 트랜잭션이 끝날 때까지 대기
val user = userBalanceRepository.findByIdForUpdate(userId) // 예: SELECT ... FOR UPDATE
// 또는 특정 컬럼 UPDATE로 락
val currentBalance = user.balance
// ... (이하 잔액 비교 및 업데이트 로직은 위와 유사) ...
updateUserBalance(userId, newBalance, currentBalance)
}
// lockUserById 쿼리 예시 (특정 컬럼 업데이트 방식)
// UPDATE user_balance
// SET last_locked_at = NOW() // 실제 의미 없는 컬럼을 업데이트하여 락 획득
// WHERE user_id = :userIdToLock;
// 또는 SELECT ... FOR UPDATE user_balance WHERE user_id = :userIdToLock;
여기까지는 괜찮았습니다. 그런데, 락을 잡기 직전에 "사용자의 잔액 정보가 없으면 새로 생성하는" 로직이 추가되면서 문제가 발생하기 시작했습니다.
// 문제가 발생한 로직
@Transactional
fun processPaymentWithProblem(userId: Long, paymentAmount: Long) {
// 😱 문제의 로직: 락을 잡기 전에 잔액 정보를 읽고, 없으면 생성
ensureUserBalanceExists(userId) // 내부에서 SELECT 후 INSERT 실행 가능성
// 트랜잭션 시작 시 DB 락 획득 시도
val user = userBalanceRepository.findByIdForUpdate(userId)
val currentBalance = user.balance // 이 시점의 currentBalance가 문제!
// ... (이하 잔액 비교 및 업데이트 로직) ...
// 마지막 업데이트 시, expectedOriginalBalance로 currentBalance를 사용하는데,
// 이 값이 다른 트랜잭션의 커밋을 반영하지 못함
updateUserBalance(userId, newBalance, currentBalance)
}
fun ensureUserBalanceExists(userId: Long) {
val balance = userBalanceRepository.findByUserId(userId) // SELECT 실행
if (balance == null) {
userBalanceRepository.save(UserBalance(userId = userId, amount = 0L)) // INSERT 실행
println("User balance created for userId: $userId")
} else {
println("User balance exists for userId: $userId, amount: ${balance.amount}")
}
}
처음에는 이 ensureUserBalanceExists
로직이 문제를 일으킬 것이라고는 전혀 생각하지 못했습니다. 단순한 조회 및 생성 로직이라고만 여겼고, 이전 Oracle 기반 시스템에서는 유사한 구조에서도 특별한 이슈가 없었기 때문입니다. 온갖 계산 로직을 뒤지고, 동시성 테스트 코드를 수십 번 돌리며 헛발질만 계속했습니다.
차분히 디버깅을 진행하며 문제 상황을 재현해 보았습니다.
ensureUserBalanceExists
메서드 내에서 사용자 X의 잔액을 조회합니다. (이때 둘 다 10,000원을 읽음)userBalanceRepository.findByIdForUpdate(userId)
를 통해 DB 락을 획득합니다.user.balance
값을 읽을 때, 요청 A가 커밋한 최신 잔액(5,000원)을 읽어올 것으로 예상했지만, 여전히 처음 ensureUserBalanceExists
에서 읽었던 10,000원을 기준으로 잔액 계산을 시도합니다.updateUserBalance
) 시 expectedOriginalBalance
조건(10,000원이어야 함)이 실제 DB의 잔액(5,000원)과 일치하지 않아 업데이트에 실패하게 됩니다.(다이어그램: 요청 A/B 동시 처리 시 잔액 읽기 문제 시각화)
[여기에 요청 A와 B가 동시에 들어와 잔액을 읽는 시점, A의 커밋, B의 잘못된 잔액 참조로 인한 업데이트 실패 과정을 나타내는 다이어그램 삽입]
Tx1 (Request A) Tx2 (Request B)
------------------------------------------------------------------------------------
BEGIN BEGIN
ensureUserBalanceExists() ensureUserBalanceExists()
SELECT balance FROM user_balance WHERE id=X; SELECT balance FROM user_balance WHERE id=X;
(balance = 10000) (balance = 10000)
findByIdForUpdate(X) [LOCK 획득]
(currentBalance = 10000)
findByIdForUpdate(X) [LOCK 대기]
... A 결제 처리 ...
UPDATE user_balance SET balance=5000
WHERE id=X AND balance=10000;
COMMIT
[LOCK 획득]
(currentBalance = 10000) <- 문제 지점! A의 커밋 반영 못함
... B 결제 처리 (10000원 기준으로) ...
UPDATE user_balance SET balance=...(새 잔액)
WHERE id=X AND balance=10000; <- 실패! (실제 DB는 5000)
ROLLBACK
이때부터 "아! 이거 DB 격리 수준 문제구나!"하는 직감이 들었습니다.
신규 시스템은 MySQL을 기반으로 개발 중이었고, MySQL의 기본 트랜잭션 격리 수준은 REPEATABLE READ
입니다. REPEATABLE READ
격리 수준의 주요 특징 중 하나는, 하나의 트랜잭션 내에서 처음 읽은 데이터는 해당 트랜잭션이 종료될 때까지 일관성을 유지하기 위해 다시 읽어도 동일한 값을 반환한다는 것입니다. (이를 위해 MVCC - Multi-Version Concurrency Control 와 스냅샷 개념이 사용됩니다.)
즉, 요청 B의 트랜잭션은 시작 시점(정확히는 첫 SELECT 시점)의 "스냅샷"을 기준으로 데이터를 읽기 때문에, 요청 A가 중간에 데이터를 변경하고 커밋했더라도 요청 B는 그 변경 사항을 보지 못하고 처음 읽었던 10,000원을 계속해서 참조했던 것입니다.
🤔 꼬리 질문:
REPEATABLE READ
에서 발생하는 이러한 현상을 "Phantom Read는 아니지만 Non-Repeatable Read는 방지한다"고 설명하는데, 여기서 Phantom Read와 Non-Repeatable Read의 차이점은 무엇일까요? 이 시나리오가 Phantom Read에 해당하지 않는 이유는 무엇일까요?
이 문제를 해결하기 위한 몇 가지 방법을 생각해 볼 수 있습니다.
방법 1: DB 락을 획득한 후, 트랜잭션 내에서 다시 잔액 정보 조회/생성
가장 간단하고 확실한 방법은 ensureUserBalanceExists
로직의 실행 순서를 변경하는 것입니다. 즉, 배타적 락을 먼저 획득한 후에 잔액을 조회하고 필요시 생성하도록 하는 것입니다.
@Transactional
fun processPaymentSolution1(userId: Long, paymentAmount: Long) {
// 🥇 트랜잭션 시작 시 DB 락 획득 시도
val user = userBalanceRepository.findByIdForUpdate(userId)
// ✅ 락을 획득한 후에 잔액 정보를 읽고, 없으면 생성
ensureUserBalanceExistsAfterLock(userId, user) // user 객체를 넘겨 재조회 방지 또는 내부에서 FOR UPDATE로 조회
val currentBalance = user.balance // 이제 이 값은 락 이후의 최신 값(또는 새로 생성된 값)
// ... (이하 잔액 비교 및 업데이트 로직) ...
updateUserBalance(userId, newBalance, currentBalance)
}
이렇게 하면, 요청 B는 요청 A가 모든 작업을 마치고 커밋한 후에 락을 획득하고, 그 시점의 최신 잔액 정보를 읽어오므로 문제가 해결됩니다.
방법 2: 해당 트랜잭션의 격리 수준을 READ COMMITTED
로 변경
MySQL의 REPEATABLE READ
대신, 해당 결제 처리 트랜잭션에 대해서만 격리 수준을 READ COMMITTED
로 낮추는 방법입니다. READ COMMITTED
격리 수준은 다른 트랜잭션에서 커밋된 변경 사항은 현재 트랜잭션에서도 즉시 볼 수 있도록 허용합니다. (Non-Repeatable Read 발생 가능)
// Spring @Transactional 어노테이션 사용 시
@Transactional(isolation = Isolation.READ_COMMITTED)
fun processPaymentSolution2(userId: Long, paymentAmount: Long) {
// 😱 문제의 로직 (그대로 유지)
ensureUserBalanceExists(userId)
// 트랜잭션 시작 시 DB 락 획득 시도
val user = userBalanceRepository.findByIdForUpdate(userId)
// ✅ READ_COMMITTED 이므로, 락 획득 후 SELECT 시 다른 트랜잭션의 커밋된 변경 사항을 읽어옴
val currentBalance = userBalanceRepository.findById(userId).balance // 다시 조회 필요 (또는 프레임워크 특성 확인)
// ... (이하 잔액 비교 및 업데이트 로직) ...
updateUserBalance(userId, newBalance, currentBalance)
}
이 방법을 사용하면, 요청 B가 락을 획득한 후 user.balance
를 다시 조회할 때(또는 프레임워크/JPA가 1차 캐시를 어떻게 관리하느냐에 따라 동작이 다를 수 있으므로 주의 깊은 테스트 필요), 요청 A가 커밋한 최신 잔액을 읽어올 수 있게 됩니다.
방법 3: 데이터베이스 전체의 기본 격리 수준을 READ COMMITTED
로 변경
만약 서비스의 전반적인 특성이 REPEATABLE READ
보다 READ COMMITTED
에 더 적합하다고 판단된다면, 데이터베이스 자체의 기본 격리 수준을 변경하는 것도 고려할 수 있습니다. (DBA와의 충분한 협의가 필요합니다!)
왜 이전 Oracle 시스템에서는 문제가 없었을까?
이전에 사용했던 Oracle 데이터베이스의 기본 트랜잭션 격리 수준은 READ COMMITTED
였기 때문입니다. 그래서 동일한 로직 구조에서도 한 트랜잭션이 읽은 데이터라도 다른 트랜잭션에서 커밋된 변경이 발생하면, 다음 SELECT 시 그 변경된 값을 읽어왔던 것입니다. 데이터베이스 시스템마다 기본 격리 수준과 각 격리 수준의 동작 방식(특히 MVCC 구현)에 미묘한 차이가 있을 수 있음을 항상 인지해야 합니다.
🤔 꼬리 질문: 만약
READ COMMITTED
격리 수준을 사용한다면, "한 트랜잭션 내에서 동일한 데이터를 여러 번 조회했을 때 다른 결과가 나올 수 있는 현상(Non-Repeatable Read)"이 발생할 수 있습니다. 이러한 현상이 우리 서비스에 어떤 영향을 미칠 수 있을지, 그리고 이를 허용해도 괜찮은 상황은 어떤 경우일까요?
REPEATABLE READ
는 항상 정답일까? 격리 수준 선택의 기준그렇다면 REPEATABLE READ
는 항상 피해야 하는 격리 수준일까요? 꼭 그렇지는 않습니다.
예를 들어, 사용자가 온라인 쇼핑몰에서 상품을 장바구니에 담고 결제를 진행하는 시나리오를 생각해 봅시다. 사용자가 처음 장바구니에서 상품 가격(예: 1,000원)을 확인하고 결제 단계로 넘어갔는데, 그 사이에 판매자가 상품 가격을 1,500원으로 인상했다고 가정해 보겠습니다.
READ COMMITTED
환경: 사용자가 결제 최종 확인 화면에서 다시 상품 정보를 조회하면, 변경된 가격(1,500원)이 보일 수 있습니다. 이는 사용자에게 혼란을 주고, 잔액 부족 등으로 결제 실패를 유발할 수 있습니다.REPEATABLE READ
환경: 사용자의 결제 트랜잭션 동안에는 처음 확인했던 가격(1,000원)이 계속 유지되므로, 일관된 사용자 경험을 제공할 수 있습니다. (물론, 실제 결제 처리 시점에는 다시 한번 가격 검증 로직이 필요할 수 있습니다.)이처럼, READ COMMITTED
가 다른 트랜잭션에서 커밋된 데이터를 즉시 읽어와서 오히려 시스템의 예측 가능성이나 사용자 경험을 저해하는 경우도 있습니다. 반대로, REPEATABLE READ
가 격리 수준이 더 높다고 해서 모든 상황에서 데이터 정합성을 완벽하게 보장해 주는 것도 아닙니다. (예: Phantom Read는 막지 못할 수 있음 - MySQL InnoDB의 경우 Next-Key Lock으로 일부 방지)
결국, 데이터베이스 트랜잭션 격리 수준은 정답이 있는 것이 아니라, 개발하려는 서비스의 특성, 데이터의 중요도, 동시성 처리 요구사항, 그리고 허용 가능한 데이터 불일치 수준 등을 종합적으로 고려하여 "더 옳은 방향"으로 신중하게 결정해야 합니다.
또한, 각 RDBMS(MySQL, PostgreSQL, Oracle, SQL Server 등)마다 기본 격리 수준이 다르고, 지원하는 격리 수준의 종류나 각 수준의 실제 동작 방식에도 차이가 있을 수 있습니다. 심지어 사용하는 ORM 프레임워크(JPA/Hibernate 등)나 트랜잭션 관리 설정값도 주의 깊게 살펴봐야 합니다.
이번 이슈를 해결하는 과정에서 트랜잭션 격리 수준에 따라 데이터의 일관성과 동시성 처리 방식이 얼마나 극명하게 달라질 수 있는지, 그리고 단순한 설정 하나나 로직 한 줄 추가가 실제 시스템 동작에 어떤 예측 불가능한 영향을 미칠 수 있는지를 뼈저리게 느꼈습니다.
단순히 이론으로만 알던 지식을 실제 문제 해결에 적용해 본 경험은 앞으로 유사한 구조를 설계하거나 디버깅할 때 매우 중요한 기준점이 될 것이라고 생각합니다. 혹시 비슷한 동시성 문제나 데이터 정합성 이슈로 어려움을 겪고 계신 분이 있다면, 이 글이 작은 실마리가 되었으면 합니다.
항상 코드 한 줄, 설정 하나에도 그 이면에 숨겨진 동작 원리를 고민하는 개발자가 되도록 노력해야겠습니다.