실무 사고 사례 — 증상에서 원인까지
DB 사고는 대개 코드가 틀려서가 아니라, 데이터가 커지거나·동시성이 올라가거나·운영 설정이 어긋나며 터진다. 앞 장들의 개념이 실제 장애에서 어떻게 모습을 드러내는지 — 증상 → 원인 → 처방으로 모았다. *"어제까진 멀쩡했는데"*가 공통 서두다.
1. "어제까진 빨랐는데 오늘 갑자기 느려요" — 인덱스 누락
증상 — 특정 조회가 데이터가 늘면서 점점, 그러다 갑자기 느려진다. 원인 — 인덱스가 없어 *풀 스캔(Seq Scan/type: ALL)*인데, 행이 적을 땐 티가 안 나다가 임계를 넘자 폭발. 옵티마이저가 작은 테이블에선 풀스캔을 일부러 골랐던 게, 커지며 독이 됨.
-- before: Seq Scan, 1.2초
EXPLAIN ANALYZE SELECT * FROM orders WHERE member_id = 42;
-- Seq Scan on orders (actual time=0.2..1180 rows=20) ← 200만 행 전부 훑음
CREATE INDEX idx_orders_member ON orders(member_id);
-- after: Index Scan, 0.3ms
-- Index Scan using idx_orders_member (actual time=0.02..0.30 rows=20)
처방 — EXPLAIN ANALYZE로 풀스캔 확인 → 조건·정렬 칼럼에 인덱스([06]). 단, 낮은 선택도·함수 적용·앞 와일드카드면 인덱스가 안 먹으니 같이 점검.
2. "목록 한 번 여는데 쿼리가 수백 개" — N+1
증상 — 목록 1개 페이지에서 DB 쿼리가 수백~수천. APM에 같은 모양 쿼리 N번. 원인 — 목록을 1번 가져오고 각 항목의 연관을 지연 로딩으로 1번씩 → 1 + N. 처방 — 조인/IN 한 방으로 합치기. ORM이면 페치 조인·배치 페치([07]·[10]). 근본은 *"필요한 데이터를 몇 번의 쿼리로 가져올지"*를 의식하는 것.
3. "디스크가 계속 차요, DELETE 했는데도" — 긴 트랜잭션과 bloat
증상 — PostgreSQL에서 테이블·인덱스가 계속 부풀고 디스크가 찬다. VACUUM을 돌려도 공간이 안 준다. 원인 — 아주 오래 열린 트랜잭션(배치·안 닫은 커넥션·idle in transaction)이 오래된 스냅샷을 붙잡아, autovacuum이 죽은 튜플을 못 지운다([05]·[08]). 처방 — pg_stat_activity에서 긴 트랜잭션·idle in transaction 추적해 끊기, 트랜잭션을 짧게, idle_in_transaction_session_timeout 설정. (MySQL InnoDB도 긴 트랜잭션이 undo 히스토리를 키워 같은 양상.)
4. "데드락 에러가 쏟아져요" — 락 순서 불일치
증상 — deadlock detected/ERROR 1213가 간헐적으로. 원인 — 두 트랜잭션이 여러 행을 다른 순서로 잠금(A→B vs B→A)해 순환 대기([05]). 처방 — 항상 같은 순서로 잠그게 코드 정렬, 트랜잭션 축소·범위 축소, 적절한 인덱스로 락 범위 축소. 그리고 데드락은 정상 상황으로 보고 재시도 로직 추가.
5. "뒤쪽 페이지가 타임아웃" — 거대 OFFSET 페이지네이션
증상 — 1페이지는 빠른데 10000페이지가 느리다. 원인 — LIMIT 20 OFFSET 200000이 앞 20만 행을 읽고 버린다([07]). 처방 — 키셋 페이지네이션 WHERE id > :last_id ORDER BY id LIMIT 20 — 페이지가 뒤로 가도 일정하게 빠르다.
6. "간헐적으로 커넥션을 못 얻어요" — 풀 고갈
증상 — 평소 멀쩡하다 트래픽 몰리면 connection timeout. 원인 — 느린 쿼리/긴 트랜잭션이 커넥션을 오래 쥐어 풀이 마름. 외부 API 호출을 트랜잭션 안에 둔 것도 흔한 범인. 처방 — 느린 쿼리부터 잡기(풀 크기 키우기 전에), 트랜잭션 안 I/O 제거, 풀 크기는 DB 처리 능력에 맞춰(무작정 키우면 DB가 더 느려짐).
7. "마이그레이션 돌렸더니 서비스가 멈췄어요" — DDL 락
증상 — 큰 테이블에 칼럼 추가/인덱스 생성하자 그 테이블 쓰기가 전부 대기. 원인 — DDL이 테이블 락을 길게 잡음. 처방 — PostgreSQL은 CREATE INDEX CONCURRENTLY·ADD COLUMN(기본값 없는 추가는 빠름), MySQL은 ALGORITHM=INSTANT/INPLACE online DDL([09]). 큰 변경은 무중단 패턴(새 칼럼 추가→백필→전환)으로 쪼갠다.
8. "PK를 UUID로 바꿨더니 느려졌어요" — 랜덤 PK 페이지 분할
증상 — InnoDB에서 랜덤 UUID(v4) PK로 바꾸자 삽입이 느리고 단편화. 원인 — InnoDB는 PK가 클러스터형이라 PK 순서로 물리 저장하는데, 랜덤 값은 트리 곳곳에 삽입→페이지 분할([06]·[09]). 처방 — 단조 증가 PK(AUTO_INCREMENT) 또는 시간순 UUIDv7/ULID.
9. "방금 쓴 글이 안 보여요" — 복제 지연
증상 — 글을 쓰고 목록을 보면 내 글이 없다(새로고침하면 나타남). 원인 — 쓰기는 리더, 읽기는 비동기 팔로워인데 복제 지연으로 팔로워가 아직 과거([12]). 처방 — 방금 쓴 사용자에겐 일정 시간 리더에서 읽기(read-your-writes), 또는 쓰기 후 리더 라우팅.
10. "재고가 마이너스로 팔렸어요" — Lost Update
증상 — 동시 주문에 재고가 음수. 원인 — 재고 읽기 → 애플리케이션에서 -1 → 쓰기를 두 요청이 겹쳐 한쪽 차감이 덮임([04]). 처방 — DB가 원자적으로 UPDATE stock = stock - 1 WHERE id=? AND stock > 0(영향 행 0이면 품절), 또는 비관적 락(FOR UPDATE)·낙관적 락(version).
11. "통계가 낡아 옵티마이저가 헛다리" — 대량 적재 후
증상 — 대량 INSERT/배치 직후 평소 빠르던 쿼리가 엉뚱한 계획으로 느려짐. 원인 — 통계가 낡아 옵티마이저가 행 수를 크게 오추정([07]). 처방 — 대량 변경 후 ANALYZE(PG)/ANALYZE TABLE(MySQL)로 통계 갱신. EXPLAIN의 추정 vs 실제 괴리로 진단.
한 발 물러서서 — 공통 교훈
거의 모든 사고가 세 뿌리로 모인다 — (1) 인덱스·실행계획(읽기 성능), (2) 트랜잭션·락·격리(동시성·일관성), (3) 운영 설정(VACUUM·복제·풀·통계). 그래서 장애가 나면 EXPLAIN·활성 세션·락 대기·복제 지연·통계를 순서대로 본다. 그리고 대부분의 사고는 데이터가 작을 땐 안 보이다가 커지면 터지니 — 예상 규모로 미리 측정하는 습관이 최선의 예방이다.
한 줄 요약 — 사고는 코드보다 규모·동시성·운영에서 터진다. 인덱스 누락·N+1·긴 트랜잭션 bloat·데드락·큰 OFFSET·풀 고갈·DDL 락·랜덤 PK·복제 지연·lost update·낡은 통계 — 증상을 보면 어느 장의 개념이 깨졌는지가 보인다.
(출처: 앞 장들([04]~[09]·[12]) / Use The Index, Luke! / Kleppmann DDIA / PostgreSQL·MySQL 운영 문서.)