| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- 오블완
- 엘라스틱서치
- JavaScript
- 스프링 핵심원리
- 티스토리챌린지
- 김영한
- 카카오
- 이펙티브 자바
- 예제로 배우는 스프링 입문
- 클린아키텍처
- 스프링
- 스프링부트
- ElasticSearch
- Spring
- 데이터베이스
- 알고리즘정렬
- springboot
- Effective Java
- 자바스크립트
- Effective Java 3
- k8s
- 알고리즘
- kubernetes
- Sort
- 스프링핵심원리
- Kotlin
- 자바
- 이펙티브자바
- effectivejava
- java
- Today
- Total
Kim-Baek 개발자 이야기
DB 백업 및 복구 전략 - 데이터 손실 제로를 향한 여정 본문
실수로 테이블을 날렸다
금요일 오후 4시, 배포를 준비하고 있었다.
-- 개발 DB에서 테스트 데이터 삭제하려고
DELETE FROM test_reservations WHERE created_at < '2024-01-01';
실행 버튼을 눌렀다. 1초 후, 끔찍한 메시지가 보였다.
Query OK, 450,234 rows affected
45만 건? 테스트 데이터는 100건인데...
급하게 확인해보니 운영 DB에 접속되어 있었다. 실수로 운영 DB의 예약 데이터를 삭제한 것이다.
SELECT COUNT(*) FROM reservations;
-- 50,000 (원래 500,000)
45만 건의 예약이 사라졌다.
심장이 멎는 것 같았다. 하지만 다행히 백업과 바이너리 로그가 있었다.
# 1. 새벽 2시 백업 복원
mysql < backup_2024-01-10_02-00.sql
# 2. 바이너리 로그로 백업 이후 트랜잭션 재생
mysqlbinlog --start-datetime="2024-01-10 02:00:00" \
--stop-datetime="2024-01-10 16:00:00" \
mysql-bin.000042 | mysql
# 3. 잘못된 DELETE만 제외하고 재생
30분 후, 모든 데이터가 복구됐다. 단 한 건의 예약도 손실되지 않았다.
이 경험으로 백업이 얼마나 중요한지, 어떻게 설계해야 하는지 뼈저리게 배웠다.
백업의 종류
1. 풀 백업 (Full Backup)
전체 데이터베이스 백업:
# mysqldump
mysqldump -u root -p --all-databases > full_backup.sql
# 복원
mysql -u root -p < full_backup.sql
특징:
- 장점: 복원 간단
- 단점: 시간 오래 걸림, 용량 큼
- 주기: 매일 또는 매주
2. 증분 백업 (Incremental Backup)
마지막 백업 이후 변경분만:
# 바이너리 로그가 증분 백업 역할
mysqlbinlog mysql-bin.000042 > incremental.sql
특징:
- 장점: 빠름, 용량 작음
- 단점: 복원 복잡 (풀백업 + 모든 증분)
- 주기: 매시간 또는 실시간
3. 차등 백업 (Differential Backup)
첫 풀백업 이후 모든 변경분:
일요일: 풀백업
월요일: 일요일 이후 변경 (차등)
화요일: 일요일 이후 변경 (차등)
수요일: 일요일 이후 변경 (차등)
복원: 풀백업 + 마지막 차등백업
Court Alarm 백업 전략
전체 구조
1. 매일 새벽 2시: 풀백업 (mysqldump)
└ S3 업로드, 7일 보관
2. 실시간: 바이너리 로그
└ 5분마다 S3 업로드, 30일 보관
3. 매주 일요일: 스냅샷 (AWS RDS)
└ 4주 보관
복구 가능 시점: 5분 이내
목표 복구 시간: 30분 이내
1. 풀백업 자동화
@Scheduled(cron = "0 0 2 * * *") // 매일 새벽 2시
fun fullBackup() {
val timestamp = LocalDateTime.now()
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm"))
val filename = "backup_$timestamp.sql"
try {
// mysqldump 실행
val command = """
mysqldump -h ${dbHost} -u ${dbUser} -p${dbPassword}
--single-transaction
--quick
--lock-tables=false
--routines
--triggers
court_alarm > /tmp/$filename
""".trimIndent()
val process = Runtime.getRuntime().exec(command)
process.waitFor()
if (process.exitValue() != 0) {
throw RuntimeException("Backup failed")
}
// gzip 압축
Runtime.getRuntime().exec("gzip /tmp/$filename").waitFor()
// S3 업로드
s3Client.putObject(
"backups",
filename + ".gz",
File("/tmp/$filename.gz")
)
// 로컬 파일 삭제
File("/tmp/$filename.gz").delete()
logger.info("백업 완료: $filename")
// 7일 이전 백업 삭제
deleteOldBackups(7)
} catch (e: Exception) {
logger.error("백업 실패", e)
slackNotifier.send("⚠️ DB 백업 실패: ${e.message}")
throw e
}
}
옵션 설명:
- --single-transaction: InnoDB 일관성 보장
- --quick: 메모리 절약
- --lock-tables=false: 테이블 락 안 걸음
- --routines: 저장 프로시저 포함
- --triggers: 트리거 포함
2. 바이너리 로그 백업
@Scheduled(fixedRate = 300000) // 5분마다
fun backupBinaryLog() {
try {
// 현재 바이너리 로그 파일 확인
val result = jdbcTemplate.queryForMap("SHOW MASTER STATUS")
val currentLog = result["File"] as String
// 이전 로그 파일들 백업
val logFiles = File("/var/log/mysql")
.listFiles { file ->
file.name.startsWith("mysql-bin.") &&
file.name < currentLog
}
logFiles?.forEach { logFile ->
// S3에 이미 있는지 확인
val s3Key = "binlogs/${logFile.name}"
if (!s3Client.doesObjectExist("backups", s3Key)) {
// 압축 후 업로드
val gzFile = File("${logFile.path}.gz")
Runtime.getRuntime()
.exec("gzip -c ${logFile.path} > ${gzFile.path}")
.waitFor()
s3Client.putObject("backups", s3Key + ".gz", gzFile)
gzFile.delete()
logger.info("바이너리 로그 백업: ${logFile.name}")
}
}
// 30일 이전 로그 삭제
deleteOldBinlogs(30)
} catch (e: Exception) {
logger.error("바이너리 로그 백업 실패", e)
}
}
3. 스냅샷 (AWS RDS)
@Scheduled(cron = "0 0 3 * * SUN") // 매주 일요일 새벽 3시
fun createSnapshot() {
val timestamp = LocalDate.now()
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
val snapshotId = "court-alarm-$timestamp"
try {
rdsClient.createDBSnapshot(
CreateDBSnapshotRequest()
.withDBInstanceIdentifier("court-alarm-prod")
.withDBSnapshotIdentifier(snapshotId)
)
logger.info("스냅샷 생성: $snapshotId")
// 4주 이전 스냅샷 삭제
deleteOldSnapshots(28)
} catch (e: Exception) {
logger.error("스냅샷 생성 실패", e)
throw e
}
}
복구 시나리오
시나리오 1: 실수로 데이터 삭제 (5분 전)
# 1. 현재 바이너리 로그 위치 확인
mysql> SHOW MASTER STATUS;
# File: mysql-bin.000042, Position: 156789
# 2. 잘못된 쿼리 위치 찾기
mysqlbinlog mysql-bin.000042 | grep "DELETE FROM reservations"
# at 156234 (잘못된 DELETE)
# 3. 잘못된 쿼리 직전까지만 재생
mysqlbinlog --stop-position=156234 mysql-bin.000042 | mysql
# 4. 잘못된 쿼리 이후 재생
mysqlbinlog --start-position=156789 mysql-bin.000042 | mysql
복구 시간: 5분
시나리오 2: 테이블 손상 (오늘 오전)
# 1. 새벽 백업 복원
mysql < backup_2024-01-10_02-00.sql
# 2. 백업 이후 바이너리 로그 재생
mysqlbinlog --start-datetime="2024-01-10 02:00:00" \
--stop-datetime="2024-01-10 10:30:00" \
mysql-bin.000042 | mysql
복구 시간: 20분
시나리오 3: 서버 전체 장애 (어제)
# 1. 어제 스냅샷에서 새 RDS 생성
aws rds restore-db-instance-from-db-snapshot \
--db-instance-identifier court-alarm-restore \
--db-snapshot-identifier court-alarm-2024-01-09
# 2. 스냅샷 이후 바이너리 로그 재생
# (S3에서 다운로드)
aws s3 sync s3://backups/binlogs/ ./binlogs/
# 3. 모든 로그 재생
for log in binlogs/mysql-bin.*; do
mysqlbinlog $log | mysql -h court-alarm-restore
done
복구 시간: 2시간
Point-in-Time Recovery (PITR)
개념
특정 시점으로 정확히 복구:
01:00 02:00 03:00 04:00 05:00 06:00
|------|------|------|------|------|
새벽 2시 풀백업
오전 5시 30분 데이터 손상
→ 오전 5시 29분 50초로 복구
구현
# 정확한 시점으로 복구
mysqlbinlog --start-datetime="2024-01-10 02:00:00" \
--stop-datetime="2024-01-10 05:29:50" \
mysql-bin.* | mysql
주의: 바이너리 로그 필수
MySQL 설정
# my.cnf
[mysqld]
server-id = 1
log-bin = /var/log/mysql/mysql-bin
binlog-format = ROW
expire_logs_days = 30
max_binlog_size = 100M
binlog-format 옵션:
- ROW: 변경된 행 저장 (권장, PITR 정확)
- STATEMENT: SQL 문장 저장
- MIXED: 혼합
백업 모니터링
백업 성공 여부 확인
@Service
class BackupMonitor(
private val s3Client: AmazonS3,
private val slackNotifier: SlackNotifier
) {
@Scheduled(cron = "0 30 3 * * *") // 새벽 3시 30분
fun checkBackup() {
val today = LocalDate.now()
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
val expectedFile = "backup_${today}_02-00.sql.gz"
// S3에 파일 있는지 확인
if (!s3Client.doesObjectExist("backups", expectedFile)) {
slackNotifier.send("❌ 오늘 백업 실패: $expectedFile 없음")
return
}
// 파일 크기 확인 (최소 100MB)
val metadata = s3Client.getObjectMetadata("backups", expectedFile)
val sizeMB = metadata.contentLength / 1024 / 1024
if (sizeMB < 100) {
slackNotifier.send("⚠️ 백업 파일 크기 이상: ${sizeMB}MB")
return
}
logger.info("✅ 백업 정상: $expectedFile (${sizeMB}MB)")
}
@Scheduled(cron = "0 0 9 * * MON") // 매주 월요일 오전 9시
fun weeklyReport() {
val report = """
📊 주간 백업 리포트
풀백업: 7개 (${getTotalBackupSize()}GB)
바이너리 로그: 최신
스냅샷: 4개
복구 테스트: 마지막 ${getLastRecoveryTest()}
""".trimIndent()
slackNotifier.send(report)
}
}
복구 테스트
@Scheduled(cron = "0 0 4 1 * *") // 매월 1일 새벽 4시
fun recoveryTest() {
try {
logger.info("복구 테스트 시작")
// 1. 테스트 DB 생성
jdbcTemplate.execute("CREATE DATABASE IF NOT EXISTS test_recovery")
// 2. 최신 백업 복원
val latestBackup = getLatestBackup()
val command = "gunzip -c $latestBackup | mysql test_recovery"
Runtime.getRuntime().exec(command).waitFor()
// 3. 데이터 검증
val count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM test_recovery.reservations",
Long::class.java
)
logger.info("복원된 레코드: $count")
if (count < 100000) {
throw Exception("복원 데이터 부족: $count")
}
// 4. 테스트 DB 삭제
jdbcTemplate.execute("DROP DATABASE test_recovery")
slackNotifier.send("✅ 복구 테스트 성공: ${count}건 복원")
} catch (e: Exception) {
logger.error("복구 테스트 실패", e)
slackNotifier.send("❌ 복구 테스트 실패: ${e.message}")
}
}
백업 전략 Best Practices
3-2-1 규칙
3: 백업 3개 유지
2: 서로 다른 매체 2개 (디스크, S3)
1: 오프사이트 1개 (다른 리전)
Court Alarm 적용:
백업 3개:
- 로컬 디스크 (최근 1일)
- S3 (7일)
- S3 Glacier (30일)
매체 2개:
- SSD (로컬)
- S3 (클라우드)
오프사이트:
- S3 us-west (다른 리전)
RTO / RPO 목표
RTO (Recovery Time Objective):
- 복구 목표 시간
- Court Alarm: 30분
RPO (Recovery Point Objective):
- 복구 목표 시점 (데이터 손실 허용 범위)
- Court Alarm: 5분
시간 →
장애 복구 완료
↓ ↓
──────●──────────●───────
↑←── RTO ──→
←RPO→ (이 사이 데이터 손실 가능)
암호화
# 백업 암호화
mysqldump court_alarm | \
openssl enc -aes-256-cbc -salt -out backup.sql.enc
# 복호화
openssl enc -d -aes-256-cbc -in backup.sql.enc | mysql
면접에서 이렇게 답하자
1단계: 백업 종류 (20초)
"백업은 풀백업, 증분백업, 차등백업이 있습니다. 풀백업은 전체를, 증분은 마지막 백업 이후 변경분을, 차등은 첫 풀백업 이후 모든 변경분을 저장합니다."
2단계: 전략 설명 (40초)
"Court Alarm은 매일 새벽 2시 풀백업을 mysqldump로 수행하고 S3에 업로드합니다. 바이너리 로그는 5분마다 백업해 PITR을 지원하며, 매주 일요일에는 RDS 스냅샷을 생성합니다. RPO 5분, RTO 30분을 목표로 합니다."
3단계: 실무 경험 (1분 30초)
"실수로 운영 DB의 예약 45만 건을 삭제한 적이 있습니다. 새벽 2시 풀백업을 복원하고, 바이너리 로그로 백업 이후부터 삭제 직전까지 트랜잭션을 재생했습니다. 잘못된 DELETE만 제외하고 그 이후 트랜잭션도 재생해서 30분 만에 모든 데이터를 복구했습니다."
"매월 복구 테스트를 자동화해서 백업의 유효성을 검증합니다. 최신 백업을 테스트 DB에 복원하고 레코드 수를 확인하며, 실패 시 Slack 알림을 보냅니다."
핵심 팁
구체적인 수치:
- ❌ "백업했어요"
- ✅ "RPO 5분, RTO 30분, 45만 건 복구 성공"
복구 절차:
- 풀백업 복원
- 바이너리 로그 재생
- 검증
정리
백업은 재앙을 막는 최후의 보루다.
백업 종류:
- 풀백업: 매일
- 증분백업: 실시간 (바이너리 로그)
- 스냅샷: 매주
핵심 전략:
- 3-2-1 규칙
- RPO/RTO 목표
- PITR 지원
- 복구 테스트
Court Alarm 전략:
풀백업: 매일 새벽 2시 (S3, 7일)
바이너리 로그: 5분마다 (S3, 30일)
스냅샷: 매주 일요일 (4주)
복구 목표: RPO 5분, RTO 30분
체크리스트:
- ✅ 자동화된 백업
- ✅ 오프사이트 백업
- ✅ 정기 복구 테스트
- ✅ 모니터링 및 알림
- ✅ 문서화된 복구 절차
'컴퓨터 공학 > DB' 카테고리의 다른 글
| DB 트랜잭션 격리 수준 - 동시성과 일관성의 트레이드오프 (0) | 2026.01.10 |
|---|---|
| NoSQL 특징 - 언제, 왜 사용하는가 (0) | 2026.01.09 |
| 데이터베이스 - RDBMS 특징 (1) | 2026.01.09 |
| MongoDB에서 Time Series 기능을 사용하지 않고 일반 컬렉션을 활용하는 것과의 차이점 (0) | 2025.01.21 |
| MongoDB Time Series Collection (몽고DB 시계열 컬렉션)란? (1) | 2025.01.21 |
