인프라

PostgreSQL 자동 백업 시스템 구축기: 홈서버 DB를 S3로

PSW 2026. 5. 18. 15:49

홈서버에서 운영 중인 PostgreSQL 데이터베이스를 매일 자동으로 AWS S3에 백업하는 시스템을 구축했다. 이 글은 그 과정에서 마주친 문제들과 해결 과정을 정리한 기록이다.


왜 S3에 백업해야 했나

홈서버 환경에서 운영 중인 서비스가 있다. Spring Boot 애플리케이션과 PostgreSQL 데이터베이스가 라즈베리파이 기반 서버에서 돌아가는데, 정전이나 디스크 고장 같은 사고가 발생하면 데이터를 잃을 위험이 있었다.

처음에는 "DB 복제(Replication)를 하면 되지 않나" 싶었다. 그런데 복제와 백업은 보호하는 사고 유형이 완전히 다르다는 걸 알게 됐다.

복제(Replication): 실시간 동기화로 가용성 확보 백업(Backup): 시점별 스냅샷으로 복구 가능성 확보

예를 들어 새벽 3시에 졸린 상태에서 DROP TABLE users 같은 실수를 했다고 가정해보자. 복제본이 있다면 그 명령도 1초 뒤에 복제본에 그대로 전파된다. 둘 다 데이터를 잃는 것이다.

사고 유형 복제로 대응 백업으로 대응

하드웨어 고장 ⚠️
정전 ⚠️
DROP TABLE 실수
애플리케이션 버그
랜섬웨어
데이터 손상

MVP 단계에서는 사용자 거의 없는 상태에서 가용성보다 데이터 보호가 우선이라 판단했고, 백업만 먼저 구축하기로 했다.

3-2-1 원칙

업계 표준 백업 원칙은 3-2-1이다.

  • 데이터 사본 3개 (원본 + 백업 2개)
  • 2가지 다른 매체에 저장
  • 그중 1개는 물리적으로 다른 장소에 보관

홈서버 디스크에만 백업을 두면 디스크 고장 시 백업도 같이 날아간다. 외장하드도 같은 집에 있으면 화재나 도둑 위험에서 자유롭지 않다. AWS S3는 서울 데이터센터에 있고 11 9s(99.999999999%)의 내구성을 제공하므로 "물리적으로 다른 장소"라는 조건을 만족한다.


전체 아키텍처

구축한 백업 파이프라인은 단순하다.

[홈서버 PostgreSQL]
       ↓ (Tailscale)
[lab-main 서버]
       ↓ pg_dump → gzip → SHA256
[AWS S3 (Standard-IA, ap-northeast-2)]
       ↓
[30일 후 라이프사이클로 자동 삭제]

홈서버와 백업을 실행하는 서버가 분리되어 있는 이유는 두 가지다. 첫째, DB 서버에 부담을 덜 주기 위해서고 둘째, 백업 스크립트가 DB와 같은 노드에 있으면 노드 자체가 망가졌을 때 복구 절차도 같이 망가지기 때문이다.

네트워크는 Tailscale을 이용했다. 외부 인터넷에 PostgreSQL 5432 포트를 노출하지 않고도 안전하게 백업 서버에서 접근할 수 있다.


Step 1. AWS 환경 준비

S3 버킷 생성

먼저 백업 전용 버킷을 만들었다. 이미지 저장용 버킷과 분리한 이유는 다음과 같다.

  • 권한 분리: 백업 버킷은 객체 삭제 권한을 일부러 부여하지 않음
  • 라이프사이클 분리: 백업은 30일 후 자동 삭제, 이미지는 영구 보관
  • 비용 추적 분리: 청구서에서 백업 비용만 따로 볼 수 있음
aws s3api create-bucket \
  --bucket exhibition-platform-backups-dev \
  --region ap-northeast-2 \
  --create-bucket-configuration LocationConstraint=ap-northeast-2

여기서 리전 실수가 있었다. 처음 콘솔에서 만들 때 우측 상단 리전 셀렉터가 도쿄(ap-northeast-1)로 되어 있는 것을 못 봐서 버킷이 도쿄에 생성됐다. aws s3api head-bucket 명령으로 확인하다 발견했다.

{
    "BucketArn": "arn:aws:s3:::exhibition-platform-backups-dev",
    "BucketRegion": "ap-northeast-1",
    ...
}

S3 버킷은 한 번 생성되면 리전 변경이 불가능하다. 삭제 후 같은 이름으로 재생성해야 하는데, 이때 "A conflicting conditional operation is currently in progress" 에러를 만났다. S3 내부적으로 글로벌 네임스페이스를 동기화하는 데 시간이 걸려서 발생하는 알려진 현상이다. 1시간쯤 기다린 후 같은 이름으로 서울 리전에 재생성에 성공했다.

이 경험으로 얻은 교훈은 두 가지다.

  1. AWS 콘솔 작업 시 우측 상단 리전을 반드시 먼저 확인할 것
  2. 가능하면 CLI로 --region 옵션을 명시적으로 지정할 것

버킷 보안 설정

생성 직후 세 가지 보안 설정을 적용했다.

# 1. 퍼블릭 액세스 완전 차단
aws s3api put-public-access-block \
  --bucket exhibition-platform-backups-dev \
  --public-access-block-configuration \
    "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"

# 2. 버전 관리 활성화 (실수 덮어쓰기 방지)
aws s3api put-bucket-versioning \
  --bucket exhibition-platform-backups-dev \
  --versioning-configuration Status=Enabled

# 3. 서버사이드 암호화 (AES-256)
aws s3api put-bucket-encryption \
  --bucket exhibition-platform-backups-dev \
  --server-side-encryption-configuration \
    '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'

버전 관리는 특히 중요하다. 같은 키로 새 백업을 올려도 이전 버전이 보관되므로, 실수로 잘못된 백업을 올렸을 때 롤백할 수 있다.

IAM 정책 설계

백업 스크립트가 사용할 IAM 정책은 최소 권한 원칙(Principle of Least Privilege)에 따라 작성했다. 특히 DeleteObject 권한은 의도적으로 제외했다.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "BackupsBucketObjectAccess",
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:GetObject"
      ],
      "Resource": "arn:aws:s3:::exhibition-platform-backups-dev/*"
    },
    {
      "Sid": "BackupsBucketList",
      "Effect": "Allow",
      "Action": [
        "s3:ListBucket",
        "s3:GetBucketLocation"
      ],
      "Resource": "arn:aws:s3:::exhibition-platform-backups-dev"
    }
  ]
}

삭제 권한을 빼는 이유는 두 가지다. 첫째, IAM 자격증명이 노출되어도 공격자가 백업을 지울 수 없다(랜섬웨어 시나리오 방어). 둘째, 오래된 백업 삭제는 사람이 아니라 S3 라이프사이클 정책으로 자동화하는 것이 안전하다.


Step 2. 백업 서버 준비

AWS CLI 설치

Ubuntu 24.04부터 awscli 패키지가 제거되었다.

E: Package 'awscli' has no installation candidate

AWS가 공식적으로 apt 패키지 지원을 중단하고 자체 설치 스크립트로 일원화했기 때문이다. 공식 설치 방법을 따랐다.

sudo apt update
sudo apt install -y unzip curl

curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
sudo ./aws/install

aws --version

이렇게 설치하면 항상 최신 v2가 깔린다. 기존 v1보다 안정적이고 기능도 풍부하니 오히려 잘 됐다고 본다.

PostgreSQL 클라이언트 버전 매칭

pg_dump 명령어가 없어서 설치했다.

sudo apt install -y postgresql-client
pg_dump --version
# pg_dump (PostgreSQL) 16.13

여기서 또 한 가지 함정을 만났다. DB 서버는 PostgreSQL 17을 사용하는데 클라이언트가 16이면 백업이 깨질 수 있다.

pg_dump 버전은 PostgreSQL 서버 버전과 같거나 더 높아야 한다.

Ubuntu 24.04 기본 저장소는 PostgreSQL 16까지만 제공한다. 17을 쓰려면 PostgreSQL 공식 저장소를 추가해야 한다.

sudo install -d /usr/share/postgresql-common/pgdg
sudo curl -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc \
  --fail https://www.postgresql.org/media/keys/ACCC4CF8.asc
sudo sh -c 'echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] \
  https://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo $VERSION_CODENAME)-pgdg main" \
  > /etc/apt/sources.list.d/pgdg.list'

sudo apt update
sudo apt install -y postgresql-client-17

pg_dump --version
# pg_dump (PostgreSQL) 17.10

운영 환경에서는 클라이언트와 서버 버전 호환성을 사전에 반드시 체크하자.


Step 3. PostgreSQL 접속 설정

pg_hba.conf 인증 설정

DB 접속 테스트에서 다음 에러를 만났다.

psql: error: connection to server at "100.111.88.20", port 5432 failed:
FATAL: no pg_hba.conf entry for host "100.109.34.112", user "appuser",
database "appdb", SSL encryption

해석하면 "네트워크는 도달했지만, 너의 IP에서 들어오는 인증을 허용하는 규칙이 pg_hba.conf에 없다"는 뜻이다.

PostgreSQL은 두 단계로 인증한다.

  1. pg_hba.conf 규칙으로 "어느 IP에서 어떤 user/database 접속 허용?" 판정
  2. 1단계 통과 후 비밀번호 검증

DB 서버의 pg_hba.conf 파일 맨 아래에 Tailscale 대역(100.64.0.0/10)을 허용하는 규칙을 추가했다.

# Tailscale 네트워크 백업/관리 서버 접근 허용
host    appdb       appuser     100.64.0.0/10       scram-sha-256

설정 적용은 reload로 충분하다. restart는 DB 다운타임이 발생하므로 필요할 때만 사용한다.

sudo systemctl reload postgresql

여기까지 끝나면 백업 서버에서 DB에 접속할 수 있다.

PGPASSWORD='****' psql -h 100.111.88.20 -p 5432 -U appuser -d appdb -c "SELECT version();"

Tailscale을 활용한 보안 모범 사례

이번에 셋업하면서 한 가지 깨달은 것은 Tailscale의 위력이다. PostgreSQL을 외부 인터넷에 노출하지 않고도 백업 서버에서 안전하게 접근할 수 있다.

권장 설정 패턴은 다음과 같다.

# postgresql.conf — Tailscale 인터페이스만 바인딩
listen_addresses = '100.111.88.20'

# pg_hba.conf — Tailscale + 로컬 LAN만 허용
host    all       all       100.64.0.0/10       scram-sha-256
host    all       all       192.168.1.0/24      scram-sha-256

이렇게 하면 외부 인터넷에서 5432 포트로 접근 자체가 불가능하고, Tailscale 네트워크 내부에서만 접근할 수 있다.


Step 4. 백업 스크립트

이제 본 게임이다. 백업 스크립트는 다음 흐름으로 동작한다.

  1. 환경변수 검증
  2. pg_dump로 SQL 추출 → gzip 압축
  3. SHA256 체크섬 계산
  4. S3 업로드 (Standard-IA 클래스, 메타데이터에 체크섬 포함)
  5. 업로드 후 사이즈 검증
  6. 로컬 7일 이상 된 파일 정리
  7. Discord 알림 (성공/실패 모두)

환경변수 파일

비밀번호 같은 민감 정보는 별도 파일에 분리하고 권한을 잠갔다.

# ~/.backup-env
export PG_HOST="100.111.88.20"
export PG_PORT="5432"
export PG_USER="appuser"
export PG_PASSWORD='****'
export PG_DATABASE="appdb"

export S3_BUCKET="exhibition-platform-backups-dev"
export AWS_REGION="ap-northeast-2"

export DISCORD_WEBHOOK_URL=""
chmod 600 ~/.backup-env

비밀번호에 ! 같은 특수문자가 있으면 작은따옴표로 감싸야 한다. 큰따옴표를 쓰면 셸이 히스토리 확장으로 처리해서 값이 깨진다. 사소해 보이지만 디버깅에 시간 잡아먹는 흔한 함정이다.

백업 스크립트 본체

#!/bin/bash
set -euo pipefail

# 0. 환경변수 검증
REQUIRED_VARS=("PG_HOST" "PG_USER" "PG_PASSWORD" "PG_DATABASE" "S3_BUCKET")
for var in "${REQUIRED_VARS[@]}"; do
    if [ -z "${!var:-}" ]; then
        echo "[ERROR] 필수 환경변수 누락: $var"
        exit 1
    fi
done

# 1. 변수 설정
BACKUP_DIR="/tmp/pg-backups"
LOCAL_RETENTION_DAYS=7

TIMESTAMP=$(date +%Y%m%d-%H%M%S)
DATE_PATH=$(date +%Y/%m/%d)
FILENAME="${PG_DATABASE}-${TIMESTAMP}.sql.gz"
LOCAL_PATH="${BACKUP_DIR}/${FILENAME}"
S3_KEY="postgres/${DATE_PATH}/${FILENAME}"
S3_PATH="s3://${S3_BUCKET}/${S3_KEY}"

mkdir -p "$BACKUP_DIR"
START_TIME=$(date +%s)

# 2. Discord 알림 함수
send_discord() {
    local color=$1
    local title=$2
    local description=$3
    [ -z "${DISCORD_WEBHOOK_URL:-}" ] && return 0

    curl -s -H "Content-Type: application/json" \
        -d "{\"embeds\":[{\"title\":\"${title}\",\"description\":\"${description}\",\"color\":${color}}]}" \
        "$DISCORD_WEBHOOK_URL" > /dev/null 2>&1 || true
}

# 3. 에러 핸들러
on_error() {
    local exit_code=$?
    local line_no=$1
    send_discord 15158332 "❌ DB 백업 실패" \
        "**파일**: \`${FILENAME}\`\n**에러 라인**: ${line_no}"
    exit $exit_code
}
trap 'on_error $LINENO' ERR

# 4. pg_dump 실행
echo "[1/4] pg_dump 실행..."
PGPASSWORD="$PG_PASSWORD" pg_dump \
    --host="$PG_HOST" \
    --port="$PG_PORT" \
    --username="$PG_USER" \
    --dbname="$PG_DATABASE" \
    --format=plain \
    --no-owner \
    --no-acl \
    --verbose 2>/dev/null \
    | gzip -9 > "$LOCAL_PATH"

# 사이즈 검증
FILE_SIZE=$(stat -c%s "$LOCAL_PATH")
if [ "$FILE_SIZE" -lt 100 ]; then
    echo "[ERROR] 백업 파일이 너무 작음 (${FILE_SIZE} bytes)"
    exit 1
fi

# 5. 체크섬
echo "[2/4] SHA256 체크섬 생성..."
CHECKSUM=$(sha256sum "$LOCAL_PATH" | cut -d' ' -f1)

# 6. S3 업로드
echo "[3/4] S3 업로드..."
aws s3 cp "$LOCAL_PATH" "$S3_PATH" \
    --storage-class STANDARD_IA \
    --metadata "db=${PG_DATABASE},sha256=${CHECKSUM}" \
    --no-progress

# 업로드 검증
S3_SIZE=$(aws s3api head-object --bucket "$S3_BUCKET" --key "$S3_KEY" \
    --query 'ContentLength' --output text)
if [ "$S3_SIZE" != "$FILE_SIZE" ]; then
    echo "[ERROR] S3 사이즈 불일치"
    exit 1
fi

# 7. 로컬 정리
echo "[4/4] 로컬 파일 정리..."
find "$BACKUP_DIR" -name "*.sql.gz" -mtime +${LOCAL_RETENTION_DAYS} -delete

# 8. 성공 알림
END_TIME=$(date +%s)
ELAPSED=$((END_TIME - START_TIME))
FILE_SIZE_HUMAN=$(du -h "$LOCAL_PATH" | cut -f1)

echo "✅ 백업 완료 (${ELAPSED}초, ${FILE_SIZE_HUMAN})"
send_discord 3066993 "✅ DB 백업 완료" \
    "**파일**: \`${FILENAME}\`\n**크기**: ${FILE_SIZE_HUMAN}\n**소요시간**: ${ELAPSED}초"

스크립트 설계에서 신경 쓴 점들

set -euo pipefail — 셸 스크립트의 안전 옵션 4종 세트다. 어느 한 명령이라도 실패하면 즉시 중단하고, 미정의 변수 사용도 에러로 잡고, 파이프 중간 실패도 감지한다. 백업 같은 신뢰성 중요한 스크립트에는 필수.

날짜별 폴더 구조 — postgres/2026/05/14/... 형태로 저장한다. S3 콘솔에서 시각적으로 탐색하기 좋고, 라이프사이클 정책도 prefix 기반으로 적용하기 쉽다.

SHA256 메타데이터 — 업로드 시 체크섬을 객체 메타데이터에 같이 박는다. 복구 시 다운로드한 파일의 무결성을 검증할 수 있다.

Standard-IA 스토리지 클래스 — 백업 파일은 자주 다운로드하지 않는다. Standard보다 저장 비용이 약 절반이라 비용 절감 효과가 있다. 다만 최소 보관 기간(30일) 제약이 있으니 라이프사이클을 30일 이상으로 잡아야 한다.

업로드 후 사이즈 검증 — aws s3 cp 자체는 멱등하고 안정적이지만, 네트워크 중단 등으로 부분 업로드가 일어났을 수 있다. head-object로 다시 사이즈를 확인해서 무결성을 보장한다.

에러 트랩 — trap 'on_error $LINENO' ERR로 어느 라인에서 실패했는지 함께 Discord 알림으로 받는다. 실패한 케이스에서도 알림이 와야 다음날 백업 결과를 확인할 수 있다.


Step 5. 첫 백업 실행

이제 진짜 백업을 돌릴 차례다.

source ~/.backup-env
~/scripts/backup-postgres.sh

출력:

═══════════════════════════════════════════════════════
 PostgreSQL 백업 시작
 DB:     appdb @ 100.111.88.20:5432
 Target: s3://exhibition-platform-backups-dev/postgres/2026/05/14/...
═══════════════════════════════════════════════════════
[1/4] pg_dump 실행...
      → 완료 (12K)
[2/4] SHA256 체크섬 생성...
      → a1b2c3d4e5f6789a...
[3/4] S3 업로드...
      → 검증 완료
[4/4] 7일 이전 로컬 파일 정리...
      → 0개 삭제
═══════════════════════════════════════════════════════
 ✅ 백업 완료 (3초, 12K)
═══════════════════════════════════════════════════════

S3에서 확인:

aws s3 ls s3://exhibition-platform-backups-dev/postgres/ --recursive
# 2026-05-14 ...  12345  postgres/2026/05/14/appdb-20260514-203015.sql.gz

테이블이 3개밖에 없고 데이터가 거의 없어서 백업 파일이 12KB로 작다. 운영 단계에서는 더 커질 것이다.


정리하며

이번에 백업 시스템을 셋업하면서 알게 된 것들을 다시 정리해본다.

기술적인 것

  • 백업과 복제는 서로 대체 불가능한 도구다. 보호하는 사고 유형이 다르다.
  • AWS S3 작업 시 리전 실수를 조심하자. 콘솔 우측 상단 또는 CLI --region 옵션을 항상 확인.
  • PostgreSQL 클라이언트는 서버 버전과 같거나 더 높아야 한다.
  • Tailscale을 활용하면 DB 포트를 인터넷에 노출하지 않고도 원격 백업이 가능하다.
  • IAM 정책은 최소 권한 원칙으로 작성하고, 백업 버킷은 의도적으로 삭제 권한을 빼는 것이 안전하다.

프로세스적인 것

  • 백업의 진짜 의미는 "복구 가능성"이다. 셋업하고 끝이 아니라 복구 테스트까지 해야 진짜 백업이다.
  • 에러 알림은 처음부터 같이 설계하는 게 좋다. 1년 뒤 백업이 멈춰있는데 모른 채로 운영하는 게 가장 무서운 시나리오.
  • 자동화 스크립트는 검증 단계가 핵심이다. 사이즈 체크, 체크섬 검증, 업로드 후 head-object 같은 단계들이 신뢰성을 만든다.

다음에는 라이프사이클 정책으로 30일 후 자동 삭제, cron으로 매일 자동 실행, 그리고 가장 중요한 복구 테스트 자동화까지 다뤄볼 예정이다. 백업은 만드는 것보다 잘 복구되는지 확인하는 게 더 중요하다.

백업은 같은 곳에 두면 백업이 아니다. 그리고 복구 안 해본 백업은 그냥 디스크 낭비다.