네이버페이 개발자가 Oracle에서 MySQL로 옮긴 결제 시스템에서 겪은 동시성 버그를 통해 DB 격리 수준을 실무 관점에서 풀어낸다. 잔액 검증 로직이 자꾸 실패했는데 원인은 코드가 아니라 MySQL의 기본 격리 수준 REPEATABLE READ였다. 같은 트랜잭션에서 한 번 읽은 값이 고정되는 특성이 lock 이전의 select와 만나 버그를 냈다.
핵심 포인트- 결제는 '업데이트 전 잔액 == 결제 이전 잔액'일 때만 갱신하는 낙관적 방식이라 동시성 제어가 필수였다.
- 동시 요청을 막으려 트랜잭션 앞에서 잔액 컬럼을 UPDATE해 DB lock을 잡았다.
- lock을 잡기 전에 '잔액 정보 없으면 insert'하는 select 로직을 추가한 순간, 두 번째 이후 요청의 갱신이 실패했다.
- 원인은 MySQL 기본 격리 수준 REPEATABLE READ다. 트랜잭션 내에서 처음 읽은 잔액이 커밋 변화와 무관하게 고정된다.
- Oracle은 기본이 READ COMMITTED라 같은 코드가 문제없었다. 격리 수준은 상황·정책에 맞춰 골라야 한다.
상세 정리- 문제 코드: processPayment가 결제 후 'balance = originalBalance'일 때만 잔액을 UPDATE한다. 순차 요청은 정상이었다.
- lock 도입: 트랜잭션 시작부에서 lockUserById로 잔액 컬럼을 UPDATE해 lock을 잡아, 두 번째 요청은 첫 요청이 끝날 때까지 대기하게 했다.
- 회귀: lock 직전에 readUserMethod(잔액 없으면 기본값 insert)를 추가하자 두 번째 이후 요청이 잔액 갱신에 실패했다.
- 증상: A와 B가 동시에 들어오면 A가 먼저 lock을 잡고 B는 대기한다. 기대와 달리 B는 A가 커밋한 잔액이 아니라 커밋 이전 값을 계속 읽었다.
- 원인 분석: readUserMethod가 lock 이전에 잔액을 select하고, REPEATABLE READ가 그 값을 트랜잭션 끝까지 고정한다. A·B 모두 10,000원을 읽은 뒤 A가 5,000원을 커밋해도 B는 10,000원으로 결제를 이어갔다.
- 해결책 1: 잔액 읽기를 lockUserById 뒤로 옮겨 lock 안에서 커밋된 데이터만 읽게 한다.
- 해결책 2: 해당 트랜잭션만 @Transactional(isolation = Isolation.READ_COMMITTED)로 낮춘다.
- 해결책 3: DB 기본 격리 수준을 READ COMMITTED로 바꾼다(DBA 협의).
- Oracle과의 차이: 구 시스템이 멀쩡했던 건 Oracle 기본이 READ COMMITTED라 중간에 커밋된 값을 다시 읽어왔기 때문이다. MySQL은 REPEATABLE READ라 문제가 드러났다.
- 트레이드오프: 결제 도중 아이템 가격이 1,000→1,500원으로 바뀌면 READ COMMITTED는 그 변경을 읽어 잔액부족 결제 실패를 낼 수 있다. REPEATABLE READ가 높은 격리라 해서 모든 상황의 정합성을 보장하진 않는다.
- 교훈: RDBMS마다 기본·지원 격리 수준이 다르고(Oracle은 REPEATABLE READ 미지원) 프레임워크 설정값도 함께 살펴야 한다.
왜 읽나격리 수준을 암기만 했던 개발자가 실제 결제 동시성 버그로 REPEATABLE READ와 READ COMMITTED의 차이를 체감하고 싶을 때 유용하다.