447. 반복 불가능 읽기 (Non-Repeatable Read)
⚠️ 이 문서는 내가 하나의 트랜잭션 안에서 똑같은 쿼리를 두 번 날렸을 뿐인데, 그사이에 다른 사람이 데이터를 고치고 저장해 버리는 바람에 첫 번째 결과와 두 번째 결과가 달라져서 계산이 꼬여버리는 '반복 불가능 읽기' 현상을 다룹니다.
핵심 인사이트 (3줄 요약)
- 본질: 한 트랜잭션 내에서 같은 조건으로 데이터를
SELECT했을 때, 다른 트랜잭션의UPDATE연산 때문에 데이터의 '값'이 중간에 바뀌어버리는 현상이다.- 위험성: 통계 쿼리를 짤 때 치명적이다. 1단계로 총합을 구하고 2단계로 평균을 구하려고 했는데, 그사이에 값이 바뀌어버리면 총합과 평균이 앞뒤가 안 맞는 엉터리 보고서가 나온다.
- 해결책: 트랜잭션 격리 수준을
Repeatable Read이상으로 올리면 방어된다. (현대 MySQL/InnoDB의 기본 격리 수준이다.)
Ⅰ. 개요: 1초 사이에 바뀐 잔고 (Context & Necessity)
은행에서 이런 일이 일어났다고 가정해보자.
- T1 (은행원): 오늘 VIP 고객의 이자를 계산하려고 A의 통장 잔고를 조회했다. 잔고가
100만 원이었다. 이 트랜잭션은 아직 안 끝났다. 이자 계산 로직이 돌아가는 중이다. - T2 (A 고객): 딱 그 순간, A가 자기 폰으로
50만 원을 출금해 버렸다(UPDATE 완료, COMMIT 됨). 이제 실제 DB 잔고는50만 원이다. - T1 (은행원): 방금 계산한 이자가 맞는지 확인하려고, 아까랑 똑같은 쿼리로 잔고를 한 번 더 조회했다. 어? 잔고가
50만 원으로 바뀌어 있다!
결과: 은행원은 100만 원을 기준으로 이자를 줬는데, 최종 잔고는 50만 원으로 찍혀있다. 한 트랜잭션 안에서 앞뒤가 안 맞는(Inconsistent) 상태가 되어버렸다.
이처럼 내가 읽는 동안 남이 그 값을 바꿔버리는 현상이 바로 **Non-Repeatable Read(반복 불가능 읽기)**다. (Dirty Read와 달리 T2가 COMMIT을 완료한 정상적인 데이터임에도 내 로직이 꼬인다는 점이 다르다.)
📢 섹션 요약 비유: 이 현상은 **'메뉴판 가격표'**와 같습니다. 내가 식당에 들어올 때 짜장면이 5,000원인 걸 보고(첫 번째 읽기) 지갑을 열고 있는데, 그 찰나에 주인이 짜장면 가격표를 6,000원으로 매직으로 고쳐 썼습니다. 내가 돈을 내려고 다시 메뉴판을 보니(두 번째 읽기) 가격이 바뀌어 있어서 계산이 꼬이는 상황입니다.
Ⅱ. Non-Repeatable Read 방어 (Repeatable Read) ★
이 버그를 막으려면 격리 수준을 **Level 2 (Repeatable Read)**로 올려야 한다. 어떻게 방어할까?
1. 스냅샷 (MVCC - 다중 버전 동시성 제어)
- "남들이 자꾸 가격표를 고치네? 그럼 아예 **나만의 전용 메뉴판 사진(스냅샷)**을 찍어놓자!"
- T1이 트랜잭션을 시작하는 순간, DB 엔진은 현재 데이터베이스의 상태를 사진 찍듯 얼려둔다.
- 나중에 T2가 원본 데이터를 50만 원으로 고치든 말든 상관없다. T1이 두 번째 조회를 할 때는 진짜 원본 데이터를 안 보고 아까 찍어둔 내 사진(100만 원)을 계속 본다.
- 결과: T1이 끝날 때까지는 백 번을 조회해도 무조건 100만 원이 나온다! (반복 읽기 쌉가능)
2. 읽기 락 (Shared Lock)의 유지
- 예전 방식이다. 내가 한 번 읽은 데이터(100만 원)에 자물쇠를 걸어서, 내 트랜잭션이 끝날 때까지 남들이 절대
UPDATE를 못 하게 막아버린다. - 이렇게 하면 방어는 되지만 시스템이 너무 느려지기 때문에, 현대의 DB는 대부분 1번(MVCC 스냅샷) 방식을 사용한다.
Ⅲ. 실무 꿀팁: MySQL vs Oracle
현업에서 가장 많이 쓰는 두 데이터베이스는 이 부분을 다루는 철학이 다르다.
- Oracle (오라클)
- 기본 격리 수준:
Read Committed - 특징: Non-Repeatable Read를 기본적으로 허용한다. 은행에서 A 트랜잭션이 돌아가는 중이라도, 최신 데이터(B가 수정한 데이터)를 보여주는 것이 더 중요하다고 생각하기 때문이다. 정 원하면 쿼리에
FOR UPDATE를 걸어 수동으로 막아야 한다.
- 기본 격리 수준:
- MySQL / MariaDB (InnoDB 엔진)
- 기본 격리 수준:
Repeatable Read - 특징: 내 트랜잭션이 시작되면 완벽한 나만의 스냅샷을 만들어준다. 데이터의 앞뒤가 안 맞는 현상을 원천 차단하여 개발자가 편하게 코딩할 수 있게 돕는다.
- 기본 격리 수준:
┌──────────────────────────────────────────────────────────────┐
│ 반복 불가능 읽기 (Non-Repeatable Read) 타임라인 시각화 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 시간 │ T1 (은행원: 이자 계산) │ T2 (고객: 출금) │
│ ────┼─────────────────────────┼─────────────────────────│
│ T1 │ SELECT 잔고 ──▶ 100만 원 │ │
│ T2 │ (100만 원 기준으로 이자 계산 중) │ UPDATE 잔고 = 50만 원 │
│ T3 │ │ COMMIT! (50만 원 영구 확정) │
│ T4 │ SELECT 잔고 ──▶ 50만 원 💥!! │ │
│ │
│ ★ 최종 결과: T1은 한 번의 작업 안에서 서로 다른 잔고 2개를 보는 환상에 빠짐.│
└──────────────────────────────────────────────────────────────┘
Ⅳ. 결론
"어제 본 데이터가 오늘도 똑같을 것이라 믿지 마라." Non-Repeatable Read는 Dirty Read처럼 남이 쓰다만 쓰레기 데이터를 읽는 것은 아니다. 정상적인 남의 데이터를 읽었는데 타이밍이 엇갈려 '나의 맥락'이 부서지는 현상이다. 특히 재무, 정산, 배치(Batch) 처리 등 수만 건의 데이터를 길게 읽어가며 계산하는 롱 트랜잭션(Long Transaction)에서는 치명적인 논리적 오류를 낳는다. 내가 다루고 있는 데이터베이스가 MySQL인지 Oracle인지에 따라 이 격리 수준의 기본값이 다르다는 것을 인지하는 것이 백엔드 엔지니어의 필수 상식이다.
📌 관련 개념 맵
- 관련 특성: 고립성 (Isolation - 443번 문서)
- 방어막 (격리 수준):
REPEATABLE READ(MySQL 기본값) - 보조 기술: MVCC (Multi-Version Concurrency Control - 스냅샷 제공 기술)
- 다음 단계의 이상 현상: Phantom Read (448번 문서 - 값이 아니라 '개수'가 바뀌는 현상)
👶 어린이를 위한 3줄 비유 설명
- 내가 냉장고 문을 열어보니 '초코우유'가 있었어요 (첫 번째 읽기). 그래서 우유랑 먹을 빵을 찾고 있었죠.
- 그사이에 동생이 냉장고를 열고 초코우유를 마셔버리고(UPDATE), 껍데기만 버렸어요.
- 내가 빵을 찾아서 다시 냉장고를 봤더니 우유가 사라져있었어요 (두 번째 읽기). 이게 바로 '반복해서 확인했더니 값이 달라진 현상'이랍니다!