← 글 목록

블로그 DB 외부 백업, rclone으로 구글 드라이브에 자동 업로드하기

/ 24분 분량

블로그 데이터베이스 백업이 로컬 서버에만 저장되고 있다는 걸 깨닫고, 외부 저장소로도 사본을 남기는 방법을 알아봤습니다. 그 과정에서 rclone이라는 도구를 처음 접하고, 왜 스크립트가 알아서 구글 드라이브에 로그인할 수 없는지도 이해하게 됐습니다.

학습 주제

  • 주제: 블로그 데이터베이스 백업을 외부(구글 드라이브)에도 저장하기, rclone 개념과 OAuth 인증 원리, 그리고 헤드리스 서버에서 그 인증을 실제로 완료하는 방법
  • 날짜: 2026-08-20 ~ 2026-08-21

탐구 과정

원래 목표는 단순했습니다. "블로그 db를 백업해두는게 필요해." 그런데 확인해보니 이미 backup-db.sh라는 스크립트가 있었고, pg_dump로 매일 자정 전체 DB를 덤프해서 14일치를 보관하고 있더군요. crontab에도 @daily로 등록되어 있었고, 실제로 며칠 전부터 매일 백업 파일이 정상적으로 쌓여 있었습니다. 이미 해결된 문제인 줄 알았는데, 생각해보니 진짜 문제는 다른 데 있었습니다.

이 백업들이 전부 서버 로컬 디스크 한 곳(/home/jcw/backups/my-blog-db/)에만 있다는 점이었습니다. 서버 자체가 죽거나 디스크가 고장 나면 백업도 원본과 같이 사라지는 구조였죠. 그래서 구글 드라이브 같은 외부 저장소에도 사본을 남기기로 했고, 실제로 설정부터 검증까지 전부 터미널 명령어를 하나씩 실행해가며 끝냈습니다. 아래는 그 과정에서 실행한 명령어들을 순서대로, 각각 뭘 하는 명령인지까지 같이 정리한 기록입니다.

핵심 학습 내용

rclone이 뭔가

rclone은 로컬 서버와 클라우드 스토리지(구글 드라이브, 드롭박스, S3 등) 사이에 파일을 복사/동기화하는 커맨드라인 도구입니다. 감각적으로는 cp나 rsync를 쓰는 것과 비슷한데, 대상이 로컬 디스크가 아니라 클라우드라는 점만 다릅니다.

  • 2014년부터 있어온 오픈소스(MIT 라이선스) 프로젝트로, 소스코드가 GitHub(rclone/rclone)에 전부 공개되어 있어서 누구나 코드를 확인할 수 있습니다.
  • 특정 회사 제품이 아니라 리눅스 관리자들 사이에서 rsync의 클라우드 버전처럼 표준적으로 쓰이는 도구입니다.
  • 설치도 임의 사이트 스크립트가 아니라 Ubuntu 공식 저장소(apt)를 통해 이뤄져서, 최소한의 검수는 거친 버전입니다.

한 번 구글 계정 인증만 마치면, 이후로는 rclone copy 파일 gdrive:폴더 같은 명령 한 줄로 서버가 알아서 업로드할 수 있습니다.

왜 스크립트가 스스로 로그인할 수 없는가

Google Drive API는 사람이 최소 한 번은 브라우저로 직접 로그인해서 "이 앱한테 권한을 주겠다"고 승인한 적이 있어야만 접근을 허용합니다. 미리 짜여진 비밀번호나 키만으로는 절대 뚫을 수 없게 막아둔 구조입니다. 즉 "최초 1회 브라우저 승인"은 피할 수 없고, 그 1회만 넘기면 나머지는 전부 무인 자동화가 가능합니다. 문제는 그 "1회 승인"을 브라우저 없는 서버에서 어떻게 완료하느냐였습니다.

시작 전 확인

먼저 도구가 이미 있는지, 인증 정보는 있는지부터 확인했습니다.

$ which rclone && rclone version
/usr/bin/rclone
rclone v1.60.1-DEV

which rclone은 이 명령이 실제로 어느 경로에 설치돼 있는지 찾아주는 명령이고, 뒤에 &&로 이어붙인 rclone version은 그게 성공했을 때만(즉 rclone이 진짜 있을 때만) 버전을 출력합니다. 라즈베리파이엔 이미 rclone이 apt로 설치돼 있었습니다.

$ rclone config file
Configuration file doesn't exist, but rclone will use this path:
/home/jcw/.config/rclone/rclone.conf

rclone config file은 rclone이 인증 정보(어떤 클라우드 계정에 어떻게 접근할지)를 저장해두는 설정 파일의 경로를 알려주는 명령입니다. 파일이 아직 없다는 걸 확인했으니, 처음부터 새로 만들어야 하는 상황이라는 게 명확해졌습니다.

삽질 1 — 대화형 위저드는 여기선 안 된다

가장 먼저 시도한 건 rclone config를 그대로 실행하는 것이었습니다. 이 명령은 "새 remote를 만들 건가요?", "어떤 스토리지 타입인가요?" 같은 질문을 순서대로 던지고, 사람이 하나씩 답을 입력하면서 진행하는 대화형 설정 마법사입니다.

$ rclone config
No remotes found, make a new one?
n) New remote
n/s/q> Failed to read line: EOF

그런데 지금 서버에 명령을 보내는 방식이 "명령 하나를 보내고 결과를 받는" 단발성 실행이라, 첫 질문이 뜨자마자 답을 입력받을 통로(표준입력)가 곧바로 끊겨서 EOF(더 이상 입력이 없다는 신호)로 종료돼버렸습니다. 여러 번 왔다 갔다 하며 사람 입력을 기다리는 프로그램은 이런 실행 방식으로는 애초에 돌아갈 수 없다는 걸 실감했습니다.

삽질 2 — 토큰을 줬는데도 멈추는 이유

그래서 대화형 위저드를 건너뛰고, 필요한 값을 명령 한 줄에 전부 담아 한 번에 만드는 비대화형 방식(rclone config create)으로 바꿨습니다. 이 방식을 쓰려면 구글 로그인을 대신할 "토큰"이 먼저 있어야 해서, 미리 다른 방법으로 받아둔 토큰까지 같이 넘겨봤습니다.

$ rclone config create gdrive drive scope=drive token='{"access_token":"...","refresh_token":"..."}'
# 2분 뒤
Command timed out after 2m 0s

이 명령을 뜯어보면 gdrive는 이 연결에 붙일 이름(나중에 gdrive:폴더 식으로 부를 때 쓰는 별칭), drive는 스토리지 종류(구글 드라이브), scope=drive는 요청할 권한 범위, token=은 로그인을 대신할 인증 토큰입니다. 그런데도 명령이 2분 동안 아무 반응 없이 멈췄습니다.

원인은 이 명령에 숨어있는 config_is_local이라는 옵션이었습니다. "Use auto config?(자동 설정을 쓸까요?)"에 해당하는 값인데, 값을 안 주면 기본값이 true로 켜져 있어서, 토큰을 이미 줬는데도 rclone은 그걸 무시하고 "이 기기에서 직접 브라우저를 띄우거나, localhost:53682 포트로 구글의 로그인 완료 신호(콜백)를 기다리는" 자동 설정 절차를 다시 시도했습니다. 브라우저도 없고 콜백도 안 오는 서버 입장에서는 영원히 기다리는 상태가 된 것이었습니다.

토큰은 어떻게 받았나 — SSH 포트포워딩

rclone authorize "drive"라는 명령을 실행하면, rclone이 자기 컴퓨터의 53682번 포트에 임시로 작은 웹서버를 띄우고 http://127.0.0.1:53682/... 형태의 로그인 URL을 터미널에 출력합니다. 사람이 그 URL을 브라우저로 열어서 로그인/승인하면, 구글이 그 결과를 다시 53682번 포트로 보내주고, rclone이 그걸 받아서 토큰을 완성하는 구조입니다.

문제는 이 명령을 서버(라즈베리파이)에서 실행하면, 로그인 URL이 가리키는 53682번 포트도 서버의 포트라는 점이었습니다. 서버엔 브라우저가 없으니 그 URL을 열 수가 없었습니다. 이걸 SSH 로컬 포트포워딩으로 해결했습니다.

ssh -L 53682:localhost:53682 jcw@<서버주소>

-L 53682:localhost:53682는 "내(로컬) 컴퓨터의 53682번 포트로 오는 요청을, 이 SSH 연결을 통해 접속 대상 서버의 localhost:53682번 포트로 그대로 전달해라"는 뜻입니다. 이 옵션을 붙여서 서버에 접속한 다음, 그 SSH 세션 안(서버 쪽)에서 rclone authorize "drive"를 실행했습니다.

rclone authorize "drive"
# → 터미널에 http://127.0.0.1:53682/auth?state=... 형태의 URL 출력

이 URL을 이번엔 서버가 아니라 제 노트북의 브라우저에서 그대로 열었습니다. 포트포워딩 덕분에 제 컴퓨터의 53682번 포트로 온 요청이 SSH 터널을 타고 서버에서 대기 중인 rclone까지 전달됐고, 구글 로그인 페이지가 정상적으로 떴습니다. 로그인/승인하자 서버 쪽 터미널에 토큰 JSON이 출력됐습니다.

{"access_token":"ya29...","token_type":"Bearer","refresh_token":"1//0e...","expiry":"2026-08-20T14:00:02..."}

access_token은 실제로 API를 호출할 때 쓰는 인증값이지만 보통 짧은 시간 후 만료되고, refresh_token은 access_token이 만료됐을 때 재발급받는 데 쓰는 값이라 사실상 이게 있으면 반영구적으로 자동 갱신이 됩니다. expiry는 access_token의 만료 시각입니다.

성공 — 비대화형으로 remote 만들기

이제 이 토큰으로 다시 config create를 실행하되, 이번엔 삽질 2에서 문제였던 옵션을 명시적으로 꺼줬습니다.

$ rclone config create gdrive drive scope=drive config_is_local=false \
    token='{"access_token":"ya29...","token_type":"Bearer","refresh_token":"1//0e...","expiry":"..."}'
[gdrive]
type = drive
scope = drive
token = {"access_token":"ya29...", ...}

config_is_local=false는 "이 기기에서 브라우저를 띄우거나 콜백을 기다리는 자동 설정은 하지 말고, 내가 이미 준 토큰을 그대로 믿고 써라"는 뜻입니다. 이걸 명시하니 명령이 즉시 끝나면서 gdrive라는 이름의 remote(연결 설정)가 ~/.config/rclone/rclone.conf에 저장됐습니다. 기본값을 그냥 믿었다가 삽질했던 경험이라, 이후로는 비대화형 명령을 쓸 때 "생략한 옵션의 기본값이 내가 원하는 동작과 같은지"를 먼저 확인하는 습관이 생겼습니다.

연결 확인과 백업 폴더 준비

$ rclone lsd gdrive:
          -1 2026-08-04 14:47:06        -1 경희대학교
          -1 2026-08-04 14:46:58        -1 성보고등학교

rclone lsd(list directories)는 지정한 remote의 최상위 폴더 목록만 나열하는 명령입니다. 실제로 제 구글 드라이브에 있던 기존 폴더들이 그대로 보였다는 건, 인증이 제대로 됐고 정말 제 드라이브에 접근하고 있다는 뜻이라 여기서 연결 자체가 성공했다는 걸 확인할 수 있었습니다.

$ rclone mkdir gdrive:my-blog-backups

rclone mkdir은 로컬 파일시스템에서 mkdir을 쓰듯, 원격(드라이브) 쪽에 새 폴더를 만드는 명령입니다. 백업 파일들을 몰아넣을 전용 폴더를 미리 만들어뒀습니다.

$ rclone lsd gdrive:
          -1 2026-08-20 13:03:44        -1 my-blog-backups
          ...

다시 한번 lsd로 목록을 확인해서 my-blog-backups 폴더가 실제로 생겼는지 재확인했습니다.

backup-db.sh에 업로드 단계 붙이기

backend/scripts/backup-db.sh의 기존 흐름(pg_dump로 덤프 생성 → 로컬 14일 지난 백업 정리) 뒤에 아래 블록을 추가했습니다.

RCLONE_REMOTE="gdrive:my-blog-backups"
if command -v rclone >/dev/null 2>&1 && rclone listremotes 2>/dev/null | grep -q '^gdrive:'; then
  rclone copy \
    "$BACKUP_FILE" \
    "$RCLONE_REMOTE" \
    --log-level ERROR
  rclone delete \
    "$RCLONE_REMOTE" \
    --min-age "${RETENTION_DAYS}d" \
    --log-level ERROR
else
  echo "경고: rclone gdrive remote 미설정, 오프사이트 백업 건너뜀" >&2
fi

한 줄씩 보면:

  • command -v rclone >/dev/null 2>&1은 "이 시스템에 rclone 명령이 존재하는가"를 조용히(출력 없이) 검사하는 관용구입니다.
  • rclone listremotes 2>/dev/null | grep -q '^gdrive:'는 지금까지 설정된 remote 이름 목록을 뽑아서, 그중에 gdrive:로 시작하는 게 있는지 확인합니다. 이 검사를 넣은 이유는, 혹시 나중에 이 스크립트가 remote 설정 전에 먼저 cron으로 실행되더라도(예: 서버를 새로 세팅하는 중이라거나) 에러 없이 조용히 건너뛰고, 기존의 로컬 백업 동작만은 그대로 살아있게 만들기 위해서입니다.
  • rclone copy는 BACKUP_FILE(방금 만든 덤프 파일 경로)을 RCLONE_REMOTE(대상 원격 경로)로 복사합니다. sync가 아니라 copy를 쓴 이유는, sync는 원격에 있는데 로컬엔 없는 파일까지 지워버리는 명령이라 실수로 전체 백업 이력을 날릴 위험이 있기 때문입니다.
  • rclone delete는 RCLONE_REMOTE 안에서 --min-age로 지정한 기간(RETENTION_DAYS, 14일)이 지난 파일만 골라 지웁니다. 로컬의 find ... -mtime +14 -delete와 같은 역할을 원격에도 적용한 것으로, 업로드(copy)와 삭제(delete)를 한 명령에 합치지 않고 따로 분리해서 각자 책임을 명확히 했습니다.
  • 스크립트 맨 위에 걸려 있는 set -e 덕분에, remote가 이미 설정된 상태에서 rclone copy나 delete가 네트워크 문제 등으로 실패하면 스크립트 자체가 비정상 종료되고 그 에러가 cron 로그에 남습니다. 오프사이트 업로드가 조용히 실패하는 걸 막기 위해 일부러 그대로 뒀습니다.

실제로 돌려서 확인

$ bash backend/scripts/backup-db.sh
[13:03:51] DB 백업 시작: /home/jcw/backups/my-blog-db/my_blog_20260820_130351.dump
[13:03:55] DB 백업 완료: 7.3M
[13:03:55] 14일 지난 백업 정리 완료
[13:03:55] rclone 업로드 시작: gdrive:my-blog-backups
[13:04:53] rclone 업로드 완료

스크립트를 직접 한 번 실행해서, pg_dump → 로컬 정리 → 원격 업로드까지 로그로 순서대로 확인했습니다. 업로드 자체는 약 1분 걸렸습니다(7.3MB 파일 기준).

백업이 "있다"는 것과 "복원된다"는 것은 다른 문제였다

여기서 끝내지 않고, 이 파일로 진짜 복원이 되는지까지 확인해봤습니다. 운영 DB(my_blog)는 어느 단계에서도 직접 건드리지 않고, 전부 임시로 만든 별도 데이터베이스에서 검증했습니다.

1) 덤프 안에 실제 데이터가 들어있는지 확인

$ pg_restore --list my_blog_20260820_130351.dump | grep "TABLE DATA"
4260; 0 16392 TABLE DATA blog posts postgres
4262; 0 16410 TABLE DATA blog projects postgres
4273; 0 17618 TABLE DATA blog visits postgres
... (learning 스키마 4개 테이블도 동일하게 나옴)

pg_restore --list는 실제로 복원을 실행하지 않고, 덤프 파일 안에 뭐가 들어있는지 목차(TOC)만 출력해주는 명령입니다. 여기서 TABLE DATA 항목을 찾은 이유는, 스키마(테이블 구조) 정의만 있고 실제 row는 하나도 없는 "빈 껍데기 덤프"가 아닌지를 가려내기 위해서입니다. pg_dump -Fc는 기본적으로 데이터까지 포함하지만, 나중에 스크립트 어딘가가 실수로 --schema-only 옵션을 달게 되는 회귀가 생기더라도 이 한 줄이면 바로 잡아낼 수 있습니다.

2) 라이브 DB의 현재 row 수를 기준값으로 기록

$ psql -d my_blog -c "SELECT 'blog.posts', count(*) FROM blog.posts UNION ALL ..."
 blog.posts    | 344
 blog.projects | 8
 blog.visits   | 16698

복원한 결과와 비교할 "정답"을 먼저 확보해두는 단계입니다.

3) 임시 DB를 만들어서 로컬 덤프를 실제로 복원

$ createdb -h localhost -U postgres my_blog_restore_test
$ pg_restore -h localhost -U postgres -d my_blog_restore_test \
    --no-owner --no-privileges my_blog_20260820_130351.dump

createdb는 완전히 새로운 빈 데이터베이스를 하나 만드는 명령입니다. 운영 DB를 덮어써서 복원 테스트를 하면 실수로 실제 데이터를 망가뜨릴 위험이 있어서, 검증 전용으로 쓰고 버릴 데이터베이스를 따로 만들었습니다. pg_restore의 --no-owner --no-privileges는 덤프 안에 같이 들어있는 "이 테이블은 누구 소유고 누구에게 어떤 권한이 있다"는 정보를 복원 과정에서 빼는 옵션입니다. 이 정보를 그대로 적용하려다 임시 DB의 권한 구조와 안 맞아 에러가 나는 걸 피하고, 지금 확인하려는 건 순수하게 "데이터가 제대로 복원되는가" 하나뿐이기 때문에 뺐습니다.

$ psql -d my_blog_restore_test -c "SELECT count(*) FROM blog.posts ..."
# → 2)에서 기록한 수치와 완전히 동일

복원된 임시 DB에서 다시 같은 카운트 쿼리를 돌려서, 2)에서 기록해둔 라이브 DB 수치와 하나씩 대조했습니다. 전부 일치했고, 최근 게시글 제목도 몇 개 뽑아서 눈으로 대조했습니다.

4) 구글 드라이브에 올라간 사본 자체를 검증

$ rclone copy gdrive:my-blog-backups/my_blog_20260820_130351.dump /tmp/from_gdrive/
$ sha256sum /home/jcw/backups/my-blog-db/my_blog_20260820_130351.dump /tmp/from_gdrive/*.dump
268ae6e7... my_blog_20260820_130351.dump
268ae6e7... from_gdrive.dump

3)까지는 "로컬 백업 파일이 복원 가능하다"만 증명한 셈이라, 여기서 한 단계 더 나갔습니다. 방금 올린 파일을 구글 드라이브에서 다시 내려받은 다음, sha256sum(파일 내용을 해시값 하나로 요약해주는 명령)으로 로컬 원본과 비교했습니다. 두 해시값이 완전히 같다는 건 업로드→다운로드 왕복 과정에서 파일이 단 1바이트도 손상되지 않았다는 뜻입니다.

$ createdb -h localhost -U postgres my_blog_restore_test2
$ pg_restore -h localhost -U postgres -d my_blog_restore_test2 \
    --no-owner --no-privileges /tmp/from_gdrive/from_gdrive.dump
$ psql -d my_blog_restore_test2 -c "SELECT count(*) FROM blog.posts ..."
# → 2), 3)과 동일한 수치

체크섬이 같다고 해서 그 파일이 실제로 postgres가 읽을 수 있는 유효한 덤프라는 보장은 아니라서, 이번엔 로컬 원본이 아니라 방금 드라이브에서 받은 파일만 가지고 또 다른 임시 DB(my_blog_restore_test2)에 복원해봤습니다. 여기서도 row 수가 전부 일치했습니다. 이걸로 "서버가 통째로 사라져도 구글 드라이브에 있는 사본만으로 복구할 수 있다"는 원래 목표가 실제로 검증됐다고 말할 수 있게 됐습니다.

5) 뒷정리

$ dropdb -h localhost -U postgres my_blog_restore_test
$ dropdb -h localhost -U postgres my_blog_restore_test2
$ rm -f /tmp/from_gdrive/from_gdrive.dump

검증용으로 만든 임시 데이터베이스 두 개와 다운로드해둔 파일을 전부 지웠습니다. 운영 환경에는 흔적을 남기지 않도록 했습니다.

이 인증 흐름을 믿어도 되는 이유

  • rclone authorize 실행 시 뜨는 로그인 페이지는 rclone이 만든 가짜 창이 아니라 진짜 accounts.google.com 페이지입니다. 비밀번호는 구글에만 입력되고 rclone은 아예 볼 수 없는 구조입니다.
  • 로그인 후 발급되는 토큰은 전체 계정 접근권이 아니라 제한된 권한입니다. scope=drive.file로 설정하면 rclone이 직접 만든 파일에만 접근 가능하고, 기존에 있던 다른 드라이브 파일은 건드릴 수조차 없습니다.
  • 발급된 토큰은 서버 로컬의 ~/.config/rclone/rclone.conf에만 저장되고, 외부 제3자 서버로 전송되지 않습니다. 오픈소스라 이 부분도 코드로 직접 확인 가능합니다.

이해한 내용

  • 백업은 "만드는 것"과 "안전한 곳에 보관하는 것"과 "복원되는 것을 확인하는 것", 이렇게 세 단계가 전부 별개의 문제라는 걸 이번에 제대로 느꼈습니다. 하나만 되어 있어도 나머지가 안 되어 있으면 재해 대비로는 반쪽짜리입니다.
  • rclone은 클라우드용 rsync라는 비유가 가장 직관적이었습니다.
  • 헤드리스 서버에서의 OAuth는 "브라우저가 없다"는 문제가 아니라 "구글의 로그인 완료 신호(콜백)를 받을 위치가 없다"는 문제였고, SSH 포트포워딩으로 그 위치를 서버 쪽으로 옮겨오면 해결된다는 걸 실습으로 이해했습니다.
  • 비대화형 명령을 쓸 때 생략한 옵션의 기본값을 확인하지 않으면, 의도와 다르게 동작(이번엔 무한 대기)할 수 있다는 것도 실제로 겪고 나서야 체감했습니다.
  • 파일이 온전히 전달됐는지는 눈으로 크기를 보는 게 아니라 sha256sum 같은 해시 비교로 확인해야 한다는 것, 그리고 그 파일이 "유효한 덤프"인지는 실제로 복원을 시도해봐야 확인된다는 것도 구분해서 이해하게 됐습니다.

실전 적용

  • rclone authorize "drive"로 받은 토큰을 rclone config create gdrive drive scope=drive config_is_local=false token='...'로 서버의 rclone.conf에 gdrive remote로 등록 완료.
  • backup-db.sh 마지막에 로컬 백업·14일 보관 정리 이후 rclone copy로 gdrive:my-blog-backups에 업로드하고 rclone delete --min-age 14d로 원격 보관 정책까지 적용하는 단계를 추가. remote가 없는 상태에서 스크립트가 돌아도 에러 없이 넘어가도록 가드 처리.
  • 위에서 설명한 5단계 검증(TOC 확인 → 라이브 DB 카운트 기록 → 로컬 덤프 복원 대조 → 오프사이트 사본 체크섬·독립 복원 → 정리)까지 전부 마쳤습니다.

추가 학습 계획

  • rclone의 다른 백엔드(S3, 드롭박스 등)도 어떤 식으로 동작하는지 비교해보고 싶습니다.
  • 서비스 계정 방식의 구체적인 설정 과정도 한 번은 직접 해보고 싶습니다. 개인 계정 OAuth와 실무에서 어떤 차이가 있는지 궁금합니다.
  • 지금은 로컬·원격 둘 다 14일 보관인데, 오래 지나서야 뒤늦게 발견되는 데이터 손상 시나리오를 생각하면 구글 드라이브 쪽만 더 길게(예: 30~90일) 가져가는 게 나을지 다음에 따져볼 계획입니다.