447. 반복 불가능 읽기 (Non-Repeatable Read)

⚠️ 이 문서는 내가 하나의 트랜잭션 안에서 똑같은 쿼리를 두 번 날렸을 뿐인데, 그사이에 다른 사람이 데이터를 고치고 저장해 버리는 바람에 첫 번째 결과와 두 번째 결과가 달라져서 계산이 꼬여버리는 '반복 불가능 읽기' 현상을 다룹니다.

핵심 인사이트 (3줄 요약)

  1. 본질: 한 트랜잭션 내에서 같은 조건으로 데이터를 SELECT 했을 때, 다른 트랜잭션의 UPDATE 연산 때문에 데이터의 '값'이 중간에 바뀌어버리는 현상이다.
  2. 위험성: 통계 쿼리를 짤 때 치명적이다. 1단계로 총합을 구하고 2단계로 평균을 구하려고 했는데, 그사이에 값이 바뀌어버리면 총합과 평균이 앞뒤가 안 맞는 엉터리 보고서가 나온다.
  3. 해결책: 트랜잭션 격리 수준을 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줄 비유 설명

  1. 내가 냉장고 문을 열어보니 '초코우유'가 있었어요 (첫 번째 읽기). 그래서 우유랑 먹을 빵을 찾고 있었죠.
  2. 그사이에 동생이 냉장고를 열고 초코우유를 마셔버리고(UPDATE), 껍데기만 버렸어요.
  3. 내가 빵을 찾아서 다시 냉장고를 봤더니 우유가 사라져있었어요 (두 번째 읽기). 이게 바로 '반복해서 확인했더니 값이 달라진 현상'이랍니다!