448. 유령 읽기 (Phantom Read)
⚠️ 이 문서는 하나의 트랜잭션 안에서 똑같은 조건으로 검색을 두 번 했을 때, 값이 바뀌는 걸 막았더니 이번엔 내가 못 보던 새로운 데이터(행)가 갑자기 뿅 하고 나타나거나 사라져서 쿼리 결과의 '개수'가 달라지는 귀신 곡할 노릇인 '유령 읽기' 현상을 다룹니다.
핵심 인사이트 (3줄 요약)
- 본질: 한 트랜잭션 안에서 똑같은
SELECT ... WHERE쿼리를 두 번 날렸는데, 다른 트랜잭션의INSERT나DELETE때문에 첫 번째 결과 집합과 두 번째 결과 집합의 행(Row)의 개수가 달라지는 현상이다.- 차이점: Non-Repeatable Read(447번 문서)가 기존에 있던 데이터의 '값'이 바뀌는 거라면, Phantom Read는 아예 없던 데이터가 '추가'되거나 있던 게 '삭제'되는 현상이다.
- 해결책: 트랜잭션 격리 수준의 끝판왕인 **
Serializable**로 올리거나, MySQL(InnoDB)처럼 **갭 락(Gap Lock)**을 걸어 다른 사람이 데이터를 끼워 넣지 못하게 막아야 방어할 수 있다.
Ⅰ. 개요: 없던 학생이 생겨났다 (Context & Necessity)
"우리 반 학생이 총 몇 명인지 세어볼게."
- T1 (선생님): 반 학생 수를 세었다.
SELECT COUNT(*) FROM 반 WHERE 반번호 = 3$\rightarrow$ 결과: 30명. (이 트랜잭션은 아직 진행 중이다.) - T2 (교장 선생님): 그 찰나의 순간에 전학생 한 명을 3반에 등록했다.
INSERT INTO 반 VALUES ('박전학', 3)그리고COMMIT을 눌렀다. - T1 (선생님): 통계를 내려고 아까랑 똑같은 쿼리로 다시 학생 수를 세었다. 어라? 결과가 31명으로 늘어났다!
분명 30명이었는데 1초 만에 31명이 되었다. 없던 데이터가 유령(Phantom)처럼 나타나서 내 계산을 망쳐버렸다.
이처럼 다른 트랜잭션의 INSERT나 DELETE 때문에 내 트랜잭션의 결과 개수가 틀어지는 것을 Phantom Read라고 부른다.
📢 섹션 요약 비유: 팬텀 리드는 **'영화관 좌석 세기'**와 같습니다. 내가 앞줄부터 빈자리가 몇 개인지 세고 있는데(첫 번째 읽기), 뒷줄에서 어떤 사람이 몰래 문을 열고 들어와 빈자리에 앉아버렸습니다(INSERT). 내가 다시 처음부터 자리를 세어보니(두 번째 읽기) 아까랑 숫자가 다릅니다. 귀신이 곡할 노릇이죠.
Ⅱ. 팬텀 리드를 막는 가장 무식한 방법 (Serializable) ★
팬텀 리드는 방어하기 가장 까다로운 현상이다.
기존 데이터에 자물쇠(Lock)를 걸어봤자, 해커는 기존 데이터를 건드리는 게 아니라 **'새로운 빈 공간'에 데이터를 끼워 넣는 것(INSERT)**이기 때문이다.
이걸 원천 차단하려면 격리 수준을 최고 단계인 **Level 3 (Serializable)**로 올려야 한다.
- 원리: 3반 학생을 세는 동안, 아예 '3반이라는 그룹 전체(테이블 단위)'에 문을 쾅 닫고 쇠사슬을 채워버린다. 교장 선생님이 전학생을 넣으려고 하면 "선생님이 3반 세고 계셔! 기다려!" 하고 문전박대한다.
- 문제점: 이렇게 하면 완벽하지만, 삽입(
INSERT) 자체가 불가능해져서 동시성(속도)이 지옥으로 떨어진다. 실무에서는 쓸 수 없는 방법이다.
Ⅲ. 실무 팁: MySQL (InnoDB)의 천재적인 갭 락 (Gap Lock)
그럼 실무에서는 팬텀 리드를 어떻게 막을까?
놀랍게도 전 세계에서 가장 많이 쓰이는 MySQL의 기본 격리 수준은 Repeatable Read(레벨 2)인데, 팬텀 리드 현상이 발생하지 않는다!
그 비밀은 **갭 락(Gap Lock)**이라는 마법에 있다.
- 테이블 통째로 자물쇠를 거는 건 너무 낭비다.
- 그래서 MySQL은 **"내가 3반을 세고 있으면, 3반에 해당하는 데이터들 사이사이의 '빈 공간(Gap)'에만 보이지 않는 지뢰(자물쇠)를 깔아두자!"**라고 생각했다.
- 교장 선생님이 3반에 전학생을 넣으려고 빈 공간에 발을 들이밀면? 지뢰를 밟고
INSERT가 대기 상태로 빠진다. - 하지만 4반이나 5반에 전학생을 넣는 것은 지뢰가 없으므로 아주 빠르게 통과시킨다.
┌──────────────────────────────────────────────────────────────┐
│ 유령 읽기 (Phantom Read) 타임라인 시각화 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 시간 │ T1 (선생님: 학생 세기) │ T2 (교장: 전학생 등록) │
│ ────┼─────────────────────────┼─────────────────────────│
│ T1 │ SELECT COUNT(*) ──▶ 30명 │ │
│ T2 │ (아직 통계 내는 중...) │ INSERT (전학생) │
│ T3 │ │ COMMIT! (영구 확정) │
│ T4 │ SELECT COUNT(*) ──▶ 31명💥│ │
│ │
│ ★ 최종 결과: 1초 전에는 30명이었는데, 귀신(Phantom)처럼 1명이 추가되었다! │
└──────────────────────────────────────────────────────────────┘
Ⅳ. 결론
"완벽한 고립은 동시성의 적이다." Phantom Read는 일상적인 웹 서비스(회원 조회, 게시판)에서는 한두 명 늘어나고 줄어드는 게 큰 문제가 안 되므로 그냥 무시하고 넘어가도 되는 버그다. 하지만 회계 정산이나 재고 마감처럼 '정확히 1원, 딱 1개'가 중요한 롱 트랜잭션에서는 치명적인 결과를 낳는다. 따라서 자신이 쓰는 DB(Oracle, PostgreSQL, MySQL)가 팬텀 리드를 어떻게 처리하는지 명확히 알고, 중요한 로직에서는 갭 락(Gap Lock)을 쓰거나 쿼리 구조를 바꿔서 이 유령의 출입을 막아야 한다.
📌 관련 개념 맵
- 관련 특성: 고립성 (Isolation - 443번 문서)
- 방어막 (격리 수준):
SERIALIZABLE(완벽 방어), MySQL의 갭 락(Gap Lock) - 이전 단계의 이상 현상: Non-Repeatable Read (447번 문서 - 기존 데이터의 값이 바뀌는 현상)
- 주요 타겟 연산:
INSERT,DELETE(데이터의 행 자체가 추가/삭제될 때 발생)
👶 어린이를 위한 3줄 비유 설명
- 내가 냉장고를 열어보니 사과가 3개 있었어요 (첫 번째 읽기). 그래서 사과 3개를 그릴 준비를 했죠.
- 그사이에 엄마가 장을 보고 오셔서 사과 2개를 냉장고에 더 쑤셔 넣었어요 (INSERT).
- 내가 그림을 다 그리고 진짜 3개인지 다시 냉장고를 열었더니 사과가 5개가 되어있었어요! 귀신이 사과를 넣고 간 것처럼 숫자가 바뀌어버린(유령 읽기) 거랍니다!