프롤로그: 그날, DB가 탔다
금요일 오후 5시 47분. 퇴근을 13분 앞두고 슬랙에 메시지가 뜬다.
🔥 [ALERT] DB CPU 98% — Connection Pool Exhausted — 응답시간 47초
모니터링 대시보드를 열었다. 그래프가 수직으로 치솟아 있다. 쿼리 큐에 3,000개가 쌓여 있고, 커넥션 풀은 진작에 바닥났다. Slow Query 로그가 폭포처럼 쏟아진다. API 서버는 504 Gateway Timeout을 뱉고 있고, 프론트엔드에는 "잠시 후 다시 시도해주세요"가 도배되어 있다.
DB가 탄 거다.
"탄다"는 과장이 아니다. 서버실 온도가 올라가고, 디스크 I/O가 포화되고, 커넥션이 고갈되면 물리적으로도 뜨거워진다. 그리고 그 열기는 고스란히 개발자의 등골을 타고 올라온다.
이 글은 그 순간에 대한 이야기다. DB가 타기 전에 무엇을 준비해야 하는지, 타는 순간에 무엇을 해야 하는지, 그리고 타고 난 뒤에 무엇을 바꿔야 하는지.
1장. 불이 나기 전 — 방화벽을 세워라
불이 나면 소방차를 부르지만, 소방차가 와도 이미 탄 건 돌아오지 않는다. DB가 타기 전에 세워두는 방화벽이 있다.
인덱스: 불쏘시개를 치워라
DB가 타는 가장 흔한 원인 1위. 인덱스가 없는 테이블에 Full Table Scan이 걸리는 것. 100만 건짜리 테이블을 매 요청마다 처음부터 끝까지 훑으면, 그건 장작 위에 휘발유를 붓는 거다.
-- 이게 매 요청마다 실행되고 있다면, 불이 안 나는 게 이상하다
SELECT * FROM orders WHERE user_id = ? AND status = 'PENDING'
ORDER BY created_at DESC;
-- 이 인덱스 하나로 Full Scan → Index Seek
CREATE INDEX idx_orders_user_status_created
ON orders(user_id, status, created_at DESC);
EXPLAIN ANALYZE를 습관처럼 찍어라. Seq Scan이 보이면 그건 아직 안 터진 시한폭탄이다.
실전 체크리스트:
- WHERE 절에 자주 등장하는 컬럼에 인덱스가 있는가?
- 복합 인덱스의 컬럼 순서가 쿼리 패턴과 일치하는가?
- 안 쓰는 인덱스가 쌓여서 오히려 INSERT/UPDATE를 느리게 만들고 있지 않은가?
- SELECT * 를 쓰고 있지 않은가? 필요한 컬럼만 명시하라
커넥션 풀: 소방 호스의 수를 정하라
커넥션 풀은 DB와 애플리케이션 사이의 파이프다. 파이프가 부족하면 요청이 대기열에 쌓이고, 대기열이 넘치면 타임아웃이 연쇄 발생한다. 그렇다고 파이프를 무한정 늘리면 DB가 컨텍스트 스위칭에 허덕인다.
PostgreSQL 공식 권장 공식:
max_connections = (core_count * 2) + effective_spindle_count
4코어 서버에 SSD 1개라면? (4 * 2) + 1 = 9. 아홉 개. 생각보다 적다.
HikariCP의 기본값이 10인 데는 이유가 있다. 커넥션 100개를 열어놓는 것이 능사가 아니다. 적은 커넥션으로 빠르게 반환하는 것이 핵심이다.
# HikariCP 설정 예시
spring:
datasource:
hikari:
maximum-pool-size: 10
minimum-idle: 5
connection-timeout: 3000 # 3초 안에 못 받으면 실패
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 5000 # 5초 이상 미반환 시 경고
leak-detection-threshold는 반드시 설정하라. 커넥션을 빌려가서 안 돌려주는 코드가 있으면, 풀이 서서히 말라간다. 로그에는 아무 에러도 안 뜨는데 어느 날 갑자기 타는 경우가 이거다.
캐시: 불 자체를 예방하라
가장 확실한 방어는 DB에 불이 안 가게 하는 것이다. 변하지 않는 데이터, 자주 읽히는 데이터, 계산 비용이 높은 데이터는 캐시에 올려라.
// Redis 캐시 패턴 (Cache-Aside)
async function getUserProfile(userId: string): Promise<UserProfile> {
// 1. 캐시에서 먼저 찾는다
const cached = await redis.get(`user:${userId}`);
if (cached) return JSON.parse(cached);
// 2. 캐시에 없으면 DB 조회
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
// 3. 결과를 캐시에 저장 (TTL 5분)
await redis.set(`user:${userId}`, JSON.stringify(user), 'EX', 300);
return user;
}
캐시 적중률(Hit Rate)이 90%면, DB로 가는 트래픽이 1/10로 줄어든다. 이것만으로 "탈 뻔한 DB"가 "여유로운 DB"로 바뀐다.
주의할 점:
- Cache Invalidation은 컴퓨터 과학에서 가장 어려운 문제 중 하나다. 데이터가 변경되었을 때 캐시를 확실히 무효화하라
- 캐시 자체가 날아가면(Redis 재시작 등) DB에 트래픽이 한꺼번에 몰리는 **캐시 스탬피드(Cache Stampede)**가 발생한다. TTL에 랜덤 지터(jitter)를 추가하라
- 캐시에 잘못된 데이터가 올라가면 "데이터가 이상해요" 문의가 폭주한다. 캐시가 해결책인 동시에 새로운 문제의 원인이 될 수 있다
2장. 불이 난 순간 — 진화 매뉴얼
0분~2분: 상황 파악 — "어디서 타고 있는가"
불이 나면 가장 먼저 할 일은 불이 어디서 시작됐는지 파악하는 것이다. DB가 탄다고 DB가 원인이 아닐 수 있다.
확인 순서:
# 1. 현재 실행 중인 쿼리 확인 (PostgreSQL)
SELECT pid, now() - pg_stat_activity.query_start AS duration,
query, state
FROM pg_stat_activity
WHERE (now() - pg_stat_activity.query_start) > interval '5 seconds'
ORDER BY duration DESC;
# 2. 커넥션 상태 확인
SELECT state, count(*)
FROM pg_stat_activity
GROUP BY state;
# 3. 락 대기 확인
SELECT blocked_locks.pid AS blocked_pid,
blocking_locks.pid AS blocking_pid,
blocked_activity.query AS blocked_query
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_locks blocking_locks ON ...
빈번한 원인 Top 5:
- 인덱스 없는 쿼리가 갑자기 호출량 증가 — 마케팅팀이 프로모션 푸시를 보냄
- 배치 작업이 트랜잭션 시간대에 실행됨 — 크론잡 설정 실수
- N+1 쿼리가 트래픽 증가와 함께 폭발 — 평소에는 10건이라 안 보였는데 10,000건이 되니 터짐
- 락 경합 — 한 트랜잭션이 테이블 락을 잡고 안 놓아줌
- 디스크 I/O 포화 — 로그 파일이 디스크를 가득 채움
2분~5분: 1차 진화 — "출혈을 멈춰라"
원인을 파악했으면 즉시 출혈을 멈춘다. 완벽한 해결이 아니라 피해 확산 방지가 목표다.
킬 쿼리: 문제가 되는 장시간 실행 쿼리를 강제 종료한다.
-- 특정 쿼리 강제 종료
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE duration > interval '30 seconds'
AND query LIKE '%problematic_table%';
서킷 브레이커 발동: 장애가 전파되지 않도록 문제 엔드포인트를 차단한다.
// 서킷 브레이커 패턴
class CircuitBreaker {
private failures = 0;
private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';
private readonly threshold = 5;
private readonly resetTimeout = 30000; // 30초
async execute<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === 'OPEN') {
throw new Error('Circuit is OPEN — 서비스 일시 중단');
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
private onFailure() {
this.failures++;
if (this.failures >= this.threshold) {
this.state = 'OPEN';
setTimeout(() => { this.state = 'HALF_OPEN'; }, this.resetTimeout);
}
}
private onSuccess() {
this.failures = 0;
this.state = 'CLOSED';
}
}
읽기 트래픽 분산: Read Replica가 있다면, 읽기 쿼리를 레플리카로 돌린다.
비핵심 기능 차단: 추천 알고리즘, 통계 집계, 로그 적재 같은 비핵심 기능을 일시적으로 끈다. 핵심 비즈니스 로직(결제, 인증)만 살린다.
5분~30분: 2차 진화 — "원인을 제거하라"
출혈이 멈추면 근본 원인을 처리한다.
인덱스가 원인이라면: 긴급 인덱스 생성. PostgreSQL의 CREATE INDEX CONCURRENTLY는 테이블 락 없이 인덱스를 생성한다.
-- 운영 중에도 안전하게 인덱스 생성
CREATE INDEX CONCURRENTLY idx_emergency_fix
ON orders(user_id, created_at DESC);
N+1이 원인이라면: 즉시 핫픽스. JOIN이나 배치 쿼리로 교체.
// Before: N+1 — 유저 100명이면 쿼리 101번
const users = await userRepo.findAll();
for (const user of users) {
user.orders = await orderRepo.findByUserId(user.id); // N번 실행
}
// After: JOIN으로 1번
const usersWithOrders = await userRepo
.createQueryBuilder('user')
.leftJoinAndSelect('user.orders', 'order')
.getMany();
배치 작업이 원인이라면: 해당 크론잡을 중지하고, 트래픽이 적은 시간대로 재스케줄링.
3장. 불이 꺼진 뒤 — 화재 보고서를 쓰라
Postmortem: 같은 불이 두 번 나면 그건 방화다
장애가 복구되면 반드시 사후 분석(Postmortem) 문서를 작성한다. 이건 책임 추궁이 아니라 학습을 위한 것이다.
## 장애 보고서 — 2026-03-30 DB 과부하
### 타임라인
- 17:47 — 모니터링 알림 발생 (DB CPU 98%)
- 17:49 — 원인 파악: 프로모션 푸시로 인한 트래픽 300% 급증
- 17:52 — 장시간 실행 쿼리 강제 종료
- 17:55 — 비핵심 API 엔드포인트 일시 차단
- 18:10 — 긴급 인덱스 생성 완료
- 18:15 — 정상 복구 확인
### 근본 원인
- orders 테이블의 user_id + status 복합 인덱스 부재
- 마케팅팀 프로모션 일정이 개발팀에 공유되지 않음
### 재발 방지
- [ ] orders 테이블 인덱스 추가 (완료)
- [ ] 마케팅 프로모션 일정 공유 프로세스 수립
- [ ] Slow Query 임계값 알림 추가 (5초 → 2초로 하향)
- [ ] 부하 테스트 시나리오에 프로모션 트래픽 패턴 추가
부하 테스트: 불을 미리 질러보라
"이 시스템이 트래픽 10배를 견딜 수 있는가?"를 아는 유일한 방법은 실제로 10배를 때려보는 것이다.
// k6 부하 테스트 스크립트 예시
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '2m', target: 100 }, // 워밍업
{ duration: '5m', target: 500 }, // 평소 트래픽
{ duration: '2m', target: 2000 }, // 프로모션 시나리오
{ duration: '5m', target: 2000 }, // 유지
{ duration: '2m', target: 0 }, // 쿨다운
],
};
export default function () {
const res = http.get('https://api.example.com/orders?status=PENDING');
check(res, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
});
sleep(1);
}
부하 테스트에서 DB가 타면, 운영에서 안 탄다. 부하 테스트에서 안 타면... 그건 부하가 부족한 거다.
4장. 화재 등급별 대응 — 연기 수준부터 대형 화재까지
Level 1: 연기가 난다 (Slow Query 증가)
증상: 응답 시간이 평소 200ms에서 800ms로 느려졌다. 사용자는 아직 못 느낀다.
대응:
- Slow Query 로그 분석
- EXPLAIN ANALYZE로 실행 계획 확인
- 인덱스 추가 또는 쿼리 리팩토링
- 이건 퇴근 후 집에서 해도 된다
Level 2: 불꽃이 보인다 (커넥션 풀 80% 이상)
증상: 일부 요청이 타임아웃된다. 사용자가 "가끔 느려요"라고 보고한다.
대응:
- 커넥션 풀 설정 점검 (leak 여부)
- 장시간 실행 쿼리 식별 및 최적화
- 읽기 트래픽을 Read Replica로 분산
- 캐시 적중률 확인 및 캐시 대상 확대
- 지금 안 하면 Level 3이 된다
Level 3: 화염이 솟는다 (DB CPU 90%+, 커넥션 고갈)
증상: 대부분의 요청이 실패한다. "서비스 접속이 안 돼요" 문의 폭주.
대응:
- 즉시 문제 쿼리 강제 종료
- 서킷 브레이커 발동
- 비핵심 기능 일시 차단
- 캐시 워밍 (빈 캐시에 데이터 선적재)
- 팀 전원 소집. 퇴근 없다
Level 4: 대형 화재 (DB 다운, 데이터 유실 위험)
증상: DB 프로세스가 죽었다. 연결 자체가 안 된다.
대응:
- DB 재시작 (최후의 수단)
- WAL(Write-Ahead Log) 기반 복구
- 최신 백업에서 Point-in-Time Recovery
- 서비스 점검 모드 전환
- 이건 사고다. 경영진 보고가 필요하다
5장. 불에 강한 아키텍처 — 방화 건물을 짓는 법
CQRS: 읽기와 쓰기를 분리하라
읽기와 쓰기가 같은 DB 인스턴스를 공유하면, 읽기 트래픽 폭주가 쓰기 성능까지 잡아먹는다. 쓰기(Command)와 읽기(Query)를 분리하면 한쪽이 타도 다른 쪽은 살아남는다.
[사용자 요청]
│
├── 쓰기 → Primary DB (PostgreSQL)
│ │
│ └── 변경 이벤트 발행
│ │
└── 읽기 → Read Replica / OpenSearch / Redis
↑
이벤트 기반 동기화
Rate Limiting: 입구에서 통제하라
아무리 튼튼한 건물도 한꺼번에 만 명이 들어오면 무너진다. API Gateway에서 요청 수를 제한하라.
// Token Bucket 기반 Rate Limiter
class RateLimiter {
private tokens: number;
private readonly maxTokens: number;
private readonly refillRate: number; // tokens per second
private lastRefill: number;
constructor(maxTokens: number, refillRate: number) {
this.tokens = maxTokens;
this.maxTokens = maxTokens;
this.refillRate = refillRate;
this.lastRefill = Date.now();
}
tryConsume(): boolean {
this.refill();
if (this.tokens > 0) {
this.tokens--;
return true;
}
return false; // 429 Too Many Requests
}
private refill() {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000;
this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.refillRate);
this.lastRefill = now;
}
}
큐 기반 비동기 처리: 불을 천천히 태워라
모든 요청을 즉시 처리할 필요는 없다. 주문 접수는 즉시 응답하되, 실제 처리는 큐에 넣고 순차적으로 하면 DB 부하를 평탄화할 수 있다.
[대량 주문 요청]
│
├── 즉시 응답: "주문이 접수되었습니다" (202 Accepted)
│
└── 메시지 큐 (Redis Queue / RabbitMQ / Kafka)
│
└── Worker가 순차 처리 → DB 쓰기
(초당 100건으로 제한)
초당 10,000건의 요청이 들어와도, Worker가 초당 100건씩 처리하면 DB는 편안하다. 사용자는 "접수 완료" 응답을 즉시 받으니 체감 성능도 좋다.
Read Replica: 불을 분산시켜라
Primary에서 쓰기만 하고, 읽기는 Replica로 보내라. 트래픽의 80%가 읽기라면, 이것만으로 Primary의 부하가 1/5로 줄어든다.
// 읽기/쓰기 분리 DataSource 설정 (TypeORM)
const dataSource = new DataSource({
type: 'postgres',
replication: {
master: {
host: 'primary-db.internal',
port: 5432,
username: 'app',
password: '***',
database: 'production',
},
slaves: [
{ host: 'replica-1.internal', port: 5432, ... },
{ host: 'replica-2.internal', port: 5432, ... },
],
},
});
6장. 불과 함께 사는 법 — 모니터링은 화재 경보기다
DB가 타는 것을 완전히 막을 수는 없다. 트래픽은 예측 불가능하고, 코드에는 항상 미처 발견하지 못한 구멍이 있다. 중요한 것은 탈 때 빨리 아는 것이다.
최소한의 모니터링 세트
# 이것만은 반드시 알림을 걸어라
alerts:
- name: DB CPU 높음
condition: cpu_usage > 80%
duration: 5분
severity: warning
- name: 커넥션 풀 임계
condition: active_connections > pool_size * 0.8
duration: 2분
severity: critical
- name: Slow Query 급증
condition: slow_query_count > 50/분
duration: 3분
severity: warning
- name: 디스크 사용률
condition: disk_usage > 85%
duration: 10분
severity: critical
- name: 레플리카 지연
condition: replication_lag > 10초
duration: 5분
severity: warning
화재 경보기가 울렸을 때 이미 대형 화재라면, 경보기의 임계값이 너무 높은 것이다. 경보기는 연기 단계에서 울려야 한다. CPU 98%에서 알림이 오면 이미 Level 3이다. 80%에서 울리게 하라.
에필로그: 불은 다시 난다
DB는 반드시 다시 탄다.
규모가 커지면 탄다. 사용자가 늘면 탄다. 블랙프라이데이에 탄다. 마케팅팀이 푸시를 보내면 탄다. 배치 작업 시간을 잘못 잡으면 탄다. 인턴이 WHERE 절 없이 UPDATE를 날리면 탄다.
중요한 건 "불이 안 나게 하겠다"는 환상을 버리는 것이다. 대신 이렇게 생각하라:
- 불이 나기 전에, 작은 불이 날 수 있는 환경을 부하 테스트로 미리 만든다
- 불이 나는 순간에, 30초 안에 어디서 타는지 파악할 수 있는 모니터링이 있다
- 불이 난 직후에, 2분 안에 출혈을 멈추는 매뉴얼이 있다
- 불이 꺼진 뒤에, 같은 원인으로 두 번 타지 않도록 사후 분석을 쓴다
그리고 가장 현실적인 조언 하나. 금요일 오후에 배포하지 마라. 불이 나면 주말이 날아간다.
"DB는 탄다. 문제는 타느냐 안 타느냐가 아니라, 탔을 때 3분 안에 끌 수 있느냐다."
'Platform > Database' 카테고리의 다른 글
| Elasticsearch, 아내의 레시피 검색에서 시작해 세계의 데이터를 색인하다 (0) | 2026.04.07 |
|---|---|
| Redis, 화장실에서 태어나 세상의 속도를 바꾸다 — 등장 배경부터 2026년 현재까지 (0) | 2026.04.07 |
| Statement와 PreparedStatement의 차이점은 무엇인가요? (0) | 2026.03.27 |
| NOT IN 쿼리를 사용할 때 발생할 수 있는 문제와 최적화 방법에 대해 설명해 주세요. (0) | 2026.03.26 |
| NoSQL 데이터베이스의 유형에는 어떤 것들이 있나요? (0) | 2026.03.20 |
