맥북 서버 탈출기

[맥북 서버 탈출기 7편] 무료로 만드는 자동 백업: restic + Cloudflare R2, 그리고 복원 테스트

TerryDev 2026. 10. 15. 23:48

이 글 요약

  • 매일 새벽 4시, 두 앱의 DB와 사진을 암호화해서 오라클 밖(Cloudflare R2)에 백업한다. 비용은 0원
  • R2 버킷 만들기 → 버킷 하나에만 권한이 있는 키 발급 → restic 설정 → 백업 스크립트 → systemd 타이머까지 캡처 5장으로 정리
  • 첫 백업은 실패했다. 실패를 잡아낸 건 스크립트에 넣어 둔 검사 한 줄이었다. 마지막으로 실제 복원까지 해 봤다

1편에서 맥북 서버의 가장 큰 문제로 꼽은 게 "백업이 없다"였다. 맥북 SSD가 원본이자 유일한 사본이었다.
서버를 옮겼다고 이 문제가 풀리지는 않는다. 오라클 서버의 디스크도 결국 하나다. 이번 편은 그 하나가 사라져도 되살릴 수 있게 만드는 과정이다.


먼저 정한 것: 백업은 오라클 밖에 둔다

오라클에도 디스크 백업 기능이 있다. 쓰지 않았다. 이유는 하나다.
백업이 원본과 같은 곳에 있으면, 원본이 사라질 때 백업도 같이 사라진다.
2편에서 적었듯 오라클 무료 계정은 갑자기 막혔다는 후기가 종종 있다. 계정이 막히면 서버도, 그 안의 디스크 백업도 같이 못 쓴다. 집에 불이 났을 때 금고가 같은 집 안에 있으면 소용이 없는 것과 같다.
그래서 백업은 다른 회사의 저장소로 보내기로 했다. 고른 곳은 Cloudflare R2다.

오라클 디스크 백업같은 계정 안이라 계정이 막히면 같이 막힌다
맥북으로 내려받기맥북이 꺼져 있으면 안 된다. 맥북에서 벗어나려고 이사했다
AWS S3좋지만 무료 범위가 작고, 꺼낼 때 전송 요금이 붙는다
Cloudflare R210GB까지 무료, 꺼낼 때 전송 요금 없음, 이미 도메인과 터널을 쓰는 계정

내 백업은 지금 약 67MB다. 10GB 무료 범위의 1%도 안 쓴다.


도구: restic

백업 파일을 R2에 그냥 올려도 된다. 대신 restic이라는 백업 도구를 썼다. restic이 대신 해 주는 일이 세 가지 있다.

암호화올리기 전에 내 서버에서 암호화한다R2 쪽에서도, 키가 새어도 비밀번호 없이는 내용을 볼 수 없다
중복 제거(증분)바뀐 부분만 새로 올린다매일 사진 60MB를 통째로 올리지 않는다. 용량도 시간도 아낀다
보관 정책오래된 백업을 규칙대로 지운다매일 쌓기만 하면 언젠가 10GB를 넘는다

R2는 S3와 같은 방식으로 접속할 수 있어서, restic이 S3용으로 지원하는 기능을 그대로 쓸 수 있다.


1단계. R2 버킷 만들기

Cloudflare 대시보드에서 스토리지 및 데이터베이스 → R2 객체 스토리지로 간다.

R2 무료 사용량: 저장소 10GB/월

처음 쓰면 R2 구독 화면이 나온다. 무료 사용량 안에서는 0원이지만 결제 수단 등록이 필요하다. 무료 범위를 넘으면 청구하기 위해서다. 카드 등록과 구독 버튼은 내가 직접 눌렀다. 에이전트는 결제가 걸린 버튼은 누르지 않는다.

구독 직후 몇 분 동안은 R2 주소로 접속하면 연결 오류(TLS handshake 실패)가 날 수 있다. 고장이 아니다. 준비가 끝날 때까지 잠깐 기다리면 된다.

버킷 생성을 누른다. 버킷은 파일을 담는 최상위 폴더다.

버킷 생성: 위치 자동, 저장 공간 클래스 표준

항목 선택 이유
버킷 이름 server-backups 용도가 보이게. 나중에 바꿀 수 없다
위치 자동 Cloudflare가 가까운 곳(아시아 태평양)에 둔다
기본 저장 공간 클래스 표준 Infrequent Access는 저장은 싸지만 꺼낼 때 요금이 붙고, 무료 사용량에서 빠진다

백업은 자주 꺼내지 않으니 Infrequent Access가 맞아 보인다. 하지만 R2의 무료 10GB는 표준 클래스에만 적용된다. 내 용량에서는 표준이 0원, Infrequent Access가 오히려 돈이 든다.
버킷은 기본으로 비공개다. 키 없이는 아무도 읽을 수 없다. 이 설정은 건드리지 않는다.


2단계. 이 버킷에만 쓸 수 있는 키 만들기

서버가 R2에 접속할 키가 필요하다. R2 화면의 API 토큰 관리 → 계정 API 토큰 생성으로 간다.

권한: 객체 읽기 및 쓰기

권한은 네 가지 중에서 고른다.

관리자 읽기 및 쓰기버킷 생성·삭제, 설정 변경까지 전부
관리자 읽기 전용모든 버킷 목록과 설정 읽기
객체 읽기 및 쓰기지정한 버킷 안의 파일 읽기, 쓰기, 목록
객체 읽기 전용지정한 버킷 안의 파일 읽기, 목록

객체 읽기 및 쓰기를 고른다. 백업에는 파일을 쓰고, 복원에는 읽고, 오래된 백업 정리에는 지워야 하니 이것으로 충분하다. 관리자 권한은 필요 없다.

버킷 지정: 특정 버킷에만 적용

아래로 내려서 버킷 지정을 특정 버킷에만 적용 → server-backups로 바꾼다. 기본값은 "이 계정의 모든 버킷"이다.
왜 좁히나: 서버가 해킹당하면 이 키도 같이 털린다고 봐야 한다. 키가 모든 버킷에 권한이 있으면 다른 데이터까지 위험해진다. 이 버킷 하나로 묶어 두면 피해도 이 버킷 하나로 끝난다. 열쇠는 문 하나만 열게 깎는다.

토큰 생성 완료: 권한과 버킷 요약

생성하면 액세스 키 ID와 비밀 액세스 키가 나온다. 이 화면을 떠나면 다시 볼 수 없다. 바로 서버 설정 파일에 넣는다. 화면에 함께 나오는 S3 접속 주소(https://<계정ID>.r2.cloudflarestorage.com)도 같이 적어 둔다.


3단계. 서버에 restic 설치와 설정

sudo apt-get install -y restic
restic version   # restic 0.16.4 ... linux/arm64

설정은 파일 하나에 모은다. 비밀이 들어 있으니 root만 읽게 한다.

# /etc/backup/restic.env   (권한 600)
RESTIC_REPOSITORY=s3:https://<계정ID>.r2.cloudflarestorage.com/server-backups/restic
RESTIC_PASSWORD=<긴 무작위 문자열>
AWS_ACCESS_KEY_ID=<R2 액세스 키 ID>
AWS_SECRET_ACCESS_KEY=<R2 비밀 액세스 키>
AWS_DEFAULT_REGION=auto
항목 뜻
RESTIC_REPOSITORY 백업을 저장할 곳. s3: + R2 주소 + 버킷 + 그 안의 폴더
RESTIC_PASSWORD 백업을 암호화하는 비밀번호. openssl rand -base64 32 같은 걸로 만든다
AWS_... R2 키. R2가 S3 방식이라 이름도 AWS 형식을 쓴다
AWS_DEFAULT_REGION=auto R2는 리전 대신 auto를 쓴다

그리고 저장소를 처음 한 번 만든다.

sudo bash -c 'set -a; . /etc/backup/restic.env; restic init'

이때 restic이 경고를 출력한다. 비밀번호를 잃으면 저장소의 데이터는 "irrecoverably lost", 되찾을 수 없게 사라진다는 내용이다. 과장이 아니다. 암호화가 제대로 돼 있다는 건, 나도 비밀번호 없이는 못 연다는 뜻이다.

비밀번호는 서버 밖에도 보관한다

이 비밀번호는 서버의 설정 파일에만 있다. 그런데 백업이 필요한 순간은 서버가 사라진 순간이다. 서버와 함께 비밀번호도 사라지면 백업은 열 수 없는 암호문 덩어리가 된다.
그래서 저장소 주소, 비밀번호, R2 키를 함께 비밀번호 관리자(나는 아이폰의 암호 앱)에 저장했다. 금고 열쇠를 금고 안에 넣어 두면 안 된다.


4단계. 백업 스크립트

#!/bin/bash
# /usr/local/bin/server-backup
set -euo pipefail
set -a; . /etc/backup/restic.env; set +a

DBS="powerup today"                 # 백업할 DB
PATHS="/var/lib/lovetoday/uploads"  # 백업할 사진 폴더
CONTAINER=powerup-mysql

STAGE=/var/tmp/server-backup        # 덤프를 잠깐 둘 곳 (경로 고정)
rm -rf "$STAGE"; mkdir -m 700 "$STAGE"; trap 'rm -rf "$STAGE"' EXIT

for db in $DBS; do
  docker exec "$CONTAINER" sh -c \
    'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysqldump -uroot --single-transaction --routines --triggers --no-tablespaces "$1"' \
    sh "$db" | gzip > "$STAGE/$db.sql.gz"
  # 덤프가 끝까지 쓰였는지 확인
  zcat "$STAGE/$db.sql.gz" | tail -n 1 | grep -q "Dump completed" \
    || { echo "dump of $db incomplete" >&2; exit 1; }
done

restic backup --host oracle-backend --tag daily "$STAGE" $PATHS
restic forget --host oracle-backend --group-by host,tags \
  --keep-daily 7 --keep-weekly 4 --keep-monthly 12 --prune
restic check --read-data-subset=2%
echo "backup ok $(date '+%F %T')"

줄마다 이유가 있다.

부분 하는 일 이유
set -euo pipefail 중간에 하나라도 실패하면 즉시 멈춘다 덤프가 실패했는데 빈 파일을 백업하고 "성공"이라고 하면 안 된다
mkdir -m 700, trap ... EXIT 덤프 폴더는 root만 보게 만들고, 끝나면 무조건 지운다 DB 덤프는 회원 정보 덩어리다. 디스크에 남기지 않는다
MYSQL_PWD=... 비밀번호를 환경 변수로 넘긴다 -p비밀번호로 넘기면 프로세스 목록에 비밀번호가 보인다
"Dump completed" 확인 덤프 마지막 줄을 검사한다 mysqldump는 끝까지 성공하면 마지막에 이 줄을 쓴다. 없으면 중간에 끊긴 덤프다
restic backup 덤프와 사진 폴더를 올린다 바뀐 부분만 올라간다
restic forget ... --prune 일별 7개, 주별 4개, 월별 12개만 남기고 정리 최근 일주일은 매일, 한 달은 매주, 1년은 매달 되돌릴 수 있다
restic check --read-data-subset=2% 저장된 데이터의 2%를 무작위로 실제로 읽어 본다 매일 조금씩 다른 부분을 읽으니, 손상이 생겨도 언젠가 걸린다

덤프 폴더 경로를 고정한 이유

처음에는 덤프 폴더를 mktemp로 매번 새 이름으로 만들 생각이었다. 그런데 restic은 백업한 경로가 같아야 같은 계열의 백업으로 묶는다. 경로가 매번 다르면 매일의 백업이 서로 남남이 되고, "일별 7개만 남기기" 같은 보관 정책이 제대로 적용되지 않는다. 그래서 /var/tmp/server-backup으로 고정했다.


5단계. 매일 새벽 4시에 자동 실행 (systemd 타이머)

리눅스에서 정해진 시간에 작업을 돌리는 방법은 cron과 systemd 타이머가 있다. 앱을 systemd로 돌리고 있으니 백업도 systemd로 맞췄다. 로그도 같은 곳(journalctl)에서 볼 수 있다.

# /etc/systemd/system/server-backup.service
[Unit]
Description=Daily DB + uploads backup to Cloudflare R2 (restic)
After=docker.service network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/server-backup
Nice=10
# /etc/systemd/system/server-backup.timer
[Unit]
Description=Run server-backup daily at 04:00 KST

[Timer]
OnCalendar=*-*-* 04:00:00
RandomizedDelaySec=10min
Persistent=true

[Install]
WantedBy=timers.target
항목 뜻
Type=oneshot 한 번 실행하고 끝나는 작업이다
Nice=10 CPU 우선순위를 낮춘다. 백업 중에도 앱 응답이 느려지지 않게
OnCalendar=*-*-* 04:00:00 매일 새벽 4시. 사용자가 가장 적은 시간
RandomizedDelaySec=10min 0~10분 사이 아무 때나 시작한다. 정각에 몰리는 걸 피한다
Persistent=true 4시에 서버가 꺼져 있었으면 켜지자마자 놓친 백업을 실행한다
sudo systemctl daemon-reload
sudo systemctl enable --now server-backup.timer
systemctl list-timers server-backup   # 다음 실행 시각 확인

첫 백업은 실패했다

타이머를 켜고 바로 한 번 돌려 봤다.

server-backup: dump of powerup incomplete
server-backup.service: Failed with result 'exit-code'.

덤프가 끝까지 쓰이지 않았다는 메시지다. 4단계에서 넣어 둔 바로 그 검사가 걸렸다.

원인은 스크립트를 서버에 쓰는 방법이었다. 맥에서 SSH 명령 한 줄로 서버에 파일 내용을 흘려보냈는데, 그 과정에서 스크립트 안의 $1, $MYSQL_ROOT_PASSWORD 같은 변수가 서버가 아니라 맥에서 먼저 풀려 버렸다. 서버에 도착한 스크립트에는 DB 이름 자리에 맥 셸의 경로가, 비밀번호 자리에 빈칸이 들어가 있었다. mysqldump는 당연히 실패했다.

스크립트를 맥에서 파일로 저장한 뒤 scp로 복사하는 방식으로 바꿨다. 다시 돌리니 성공했다.

Added to the repository: 8.720 MiB (8.720 MiB stored)
snapshot b3698257 saved
no errors were found
backup ok 2026-10-15 01:26:06

검사 줄이 없었다면 어땠을까. mysqldump가 실패해도 gzip은 빈 압축 파일을 만들고, restic은 그걸 성실하게 암호화해서 R2에 올리고, 스크립트는 "backup ok"를 찍었을 것이다. 매일 새벽 4시에 빈 파일을 안전하게 보관하는 시스템이 완성될 뻔했다.


마지막 단계: 복원해 보기

백업이 R2에 올라갔다. 아직 끝이 아니다. 복원해 보기 전까지는 백업이 아니라 희망이다.
최신 백업을 내려받아 임시 DB에 복원하고, 운영 DB와 숫자를 비교했다. 운영 DB는 건드리지 않는다.

# 1. 최신 백업을 임시 폴더에 내려받기
sudo bash -c 'set -a; . /etc/backup/restic.env; restic restore latest --target /tmp/rt'

# 2. 임시 DB(restore_test)에 복원
zcat /tmp/rt/var/tmp/server-backup/powerup.sql.gz \
  | sudo docker exec -i powerup-mysql sh -c 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" restore_test'

# 3. 운영 DB와 행 개수 비교 후 임시 DB 삭제

결과는 이랬다.

회원 수55
운동 세트 수9,7519,751

6편의 교훈대로 "일치"라는 단어 대신 숫자를 같이 봤다. 0과 0이 아니라 9,751과 9,751이다.
이 과정은 서버 운영 문서에도 적어 뒀다. 실제로 복원해야 하는 날은 대개 정신이 없는 날이다. 그날 처음 복원 명령어를 찾아보는 일은 없어야 한다.


지금 상태

러브투데이를 옮긴 뒤에는 백업 대상에 today DB와 사진 폴더를 추가했다. 스크립트의 DBS와 PATHS 두 줄만 바꾸면 된다.

실행매일 04:00~04:10
대상DB 2개 + 사진 폴더 (사진 360장)
저장 용량약 67MB (무료 10GB의 1% 미만)
걸리는 시간10초 안팎
보관최근 7일 + 4주 + 12개월
비용0원

혹시 몰라 R2에도 1달러 예산 알림을 걸어 뒀다. 비용 이야기는 8편에서 모아서 한다.


에이전트와 한 부분

백업 설계부터 스크립트, 타이머, 복원 테스트까지 Claude Code가 만들고 돌렸다.

  • R2 구독 버튼은 내가 눌렀다. 결제가 걸린 버튼이라 에이전트가 누르지 않고 나한테 넘겼다. 무료 플랜이어도 카드가 걸리면 사람 몫이다.
  • "Dump completed" 검사는 에이전트가 처음부터 넣었다. 그리고 그 검사에 처음 걸린 것도 에이전트가 만든 스크립트였다. 스스로 만든 함정에 스스로 걸리고, 스스로 만든 안전장치 덕분에 바로 알았다. 공정한 거래다.
  • 복원 테스트는 시키지 않았는데 에이전트가 했다. 나는 백업이 올라갔다는 말에 이미 만족하고 있었다.
  • 비밀번호 보관을 여러 번 강조했다. "서버가 통째로 사라지면 이 비밀번호 없이는 백업을 열 수 없다"고 했다. 나는 그 말을 듣고 나서야 암호 앱에 저장했다. 저장하는 방법도 물어봤다.

오늘의 교훈 복원해 보지 않은 백업은 백업이 아니다. 매일 새벽 정성껏 보관되는 빈 파일일 수도 있다.

다음 편은 시리즈의 마지막이다. 배포를 명령어 한 줄로 만들고(실패하면 알아서 되돌린다), 코드를 올릴 때마다 테스트가 도는 CI를 붙이고, 이 모든 게 정말 0원인지 영수증을 확인한다.